Initial commit: waste-go skeleton
Ed25519/X25519/ChaCha20-Poly1305 crypto, peer handshake, mesh state, IPC server, relay server, and NAT stub. Builds clean on Go 1.22+. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
144
internal/mesh/mesh.go
Normal file
144
internal/mesh/mesh.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Package mesh manages the set of live peer connections and broadcasts events.
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// PeerConn is a live connection to one peer.
|
||||
type PeerConn struct {
|
||||
Info proto.PeerInfo
|
||||
// Send a line of JSON to this peer (pre-encrypted by the sender goroutine).
|
||||
Send chan<- []byte
|
||||
}
|
||||
|
||||
// Mesh is the shared state of the local node.
|
||||
// All methods are safe to call from multiple goroutines.
|
||||
type Mesh struct {
|
||||
Identity *crypto.Identity
|
||||
|
||||
mu sync.RWMutex
|
||||
peers map[proto.PeerID]*PeerConn
|
||||
|
||||
// subscribers receive a copy of every event (fan-out to IPC clients)
|
||||
subMu sync.Mutex
|
||||
subs []chan proto.IpcMessage
|
||||
}
|
||||
|
||||
// New creates an empty mesh with the given identity.
|
||||
func New(id *crypto.Identity) *Mesh {
|
||||
return &Mesh{
|
||||
Identity: id,
|
||||
peers: make(map[proto.PeerID]*PeerConn),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Peer management ───────────────────────────────────────────────────────────
|
||||
|
||||
// AddPeer registers a connected peer and notifies subscribers.
|
||||
func (m *Mesh) AddPeer(conn *PeerConn) {
|
||||
m.mu.Lock()
|
||||
m.peers[conn.Info.ID] = conn
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtPeerConnected,
|
||||
Peer: &conn.Info,
|
||||
})
|
||||
}
|
||||
|
||||
// RemovePeer unregisters a peer and notifies subscribers.
|
||||
func (m *Mesh) RemovePeer(id proto.PeerID) {
|
||||
m.mu.Lock()
|
||||
delete(m.peers, id)
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emit(proto.IpcMessage{
|
||||
Type: proto.EvtPeerDisconnected,
|
||||
PeerID: &id,
|
||||
})
|
||||
}
|
||||
|
||||
// ConnectedPeers returns a snapshot of current peer infos.
|
||||
func (m *Mesh) ConnectedPeers() []proto.PeerInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]proto.PeerInfo, 0, len(m.peers))
|
||||
for _, c := range m.peers {
|
||||
out = append(out, c.Info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SendTo delivers a raw JSON payload to a specific peer.
|
||||
// Returns false if the peer isn't connected.
|
||||
func (m *Mesh) SendTo(id proto.PeerID, payload []byte) bool {
|
||||
m.mu.RLock()
|
||||
conn, ok := m.peers[id]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case conn.Send <- payload:
|
||||
return true
|
||||
default:
|
||||
return false // channel full — peer is slow
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast delivers a raw JSON payload to every connected peer.
|
||||
func (m *Mesh) Broadcast(payload []byte) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, conn := range m.peers {
|
||||
select {
|
||||
case conn.Send <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event fan-out ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Subscribe returns a channel that receives every IPC event.
|
||||
// The caller must drain it; a full channel is silently dropped.
|
||||
func (m *Mesh) Subscribe() <-chan proto.IpcMessage {
|
||||
ch := make(chan proto.IpcMessage, 64)
|
||||
m.subMu.Lock()
|
||||
m.subs = append(m.subs, ch)
|
||||
m.subMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes and closes a subscription channel.
|
||||
func (m *Mesh) Unsubscribe(ch <-chan proto.IpcMessage) {
|
||||
m.subMu.Lock()
|
||||
defer m.subMu.Unlock()
|
||||
for i, s := range m.subs {
|
||||
if s == ch {
|
||||
m.subs = append(m.subs[:i], m.subs[i+1:]...)
|
||||
close(s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit sends an event to all IPC subscribers (exported for ipc/nat packages).
|
||||
func (m *Mesh) Emit(msg proto.IpcMessage) {
|
||||
m.emit(msg)
|
||||
}
|
||||
|
||||
func (m *Mesh) emit(msg proto.IpcMessage) {
|
||||
m.subMu.Lock()
|
||||
defer m.subMu.Unlock()
|
||||
for _, ch := range m.subs {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
194
internal/mesh/peer.go
Normal file
194
internal/mesh/peer.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Package mesh/peer handles individual peer TCP connections.
|
||||
package mesh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/waste-go/internal/crypto"
|
||||
"github.com/waste-go/internal/proto"
|
||||
)
|
||||
|
||||
// HandleConn runs the full lifecycle of one peer connection:
|
||||
//
|
||||
// 1. Handshake (Hello / HelloAck)
|
||||
// 2. ECDH → session key
|
||||
// 3. Register in mesh
|
||||
// 4. Concurrent read + write loops
|
||||
// 5. Unregister on disconnect
|
||||
//
|
||||
// Call this in a goroutine for both inbound and outbound connections.
|
||||
func HandleConn(conn net.Conn, m *Mesh, weInitiated bool) {
|
||||
defer conn.Close()
|
||||
addr := conn.RemoteAddr().String()
|
||||
log.Printf("peer: connected to %s (we initiated: %v)", addr, weInitiated)
|
||||
|
||||
session, peerInfo, err := handshake(conn, m.Identity, weInitiated)
|
||||
if err != nil {
|
||||
log.Printf("peer: handshake with %s failed: %v", addr, err)
|
||||
return
|
||||
}
|
||||
log.Printf("peer: handshake complete with %s (%s)", peerInfo.Alias, peerInfo.ID.Short())
|
||||
|
||||
// Channel for outbound messages (IPC handler writes here)
|
||||
sendCh := make(chan []byte, 64)
|
||||
peerConn := &PeerConn{Info: *peerInfo, Send: sendCh}
|
||||
m.AddPeer(peerConn)
|
||||
defer m.RemovePeer(peerInfo.ID)
|
||||
|
||||
// Outbound writer goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
w := bufio.NewWriter(conn)
|
||||
for payload := range sendCh {
|
||||
nonce, ct, err := session.Encrypt(payload)
|
||||
if err != nil {
|
||||
log.Printf("peer: encrypt error: %v", err)
|
||||
continue
|
||||
}
|
||||
env := proto.Envelope{Nonce: nonce, Payload: ct}
|
||||
line, _ := json.Marshal(env)
|
||||
line = append(line, '\n')
|
||||
if _, err := w.Write(line); err != nil {
|
||||
return
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
// Inbound reader loop (this goroutine)
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
var env proto.Envelope
|
||||
if err := json.Unmarshal(scanner.Bytes(), &env); err != nil {
|
||||
log.Printf("peer: bad envelope from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
plaintext, err := session.Decrypt(env.Nonce, env.Payload)
|
||||
if err != nil {
|
||||
log.Printf("peer: decrypt error from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
var msg proto.PeerMessage
|
||||
if err := json.Unmarshal(plaintext, &msg); err != nil {
|
||||
log.Printf("peer: bad peer message from %s: %v", addr, err)
|
||||
continue
|
||||
}
|
||||
handleMessage(msg, peerInfo, m)
|
||||
}
|
||||
|
||||
close(sendCh)
|
||||
<-done
|
||||
log.Printf("peer: disconnected from %s", addr)
|
||||
}
|
||||
|
||||
// handshake performs the Ed25519-authenticated X25519 key exchange.
|
||||
// Returns the symmetric session and the remote peer's info.
|
||||
func handshake(conn net.Conn, id *crypto.Identity, weInitiated bool) (*crypto.Session, *proto.PeerInfo, error) {
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
defer conn.SetDeadline(time.Time{})
|
||||
|
||||
ek, err := crypto.GenerateEphemeral()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ourHello := proto.Hello{
|
||||
Version: 1,
|
||||
Peer: id.PeerInfo(),
|
||||
EphemeralKey: ek.PublicKeyB64(),
|
||||
Signature: id.Sign([]byte(ek.PublicKeyB64())),
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(conn)
|
||||
dec := json.NewDecoder(conn)
|
||||
|
||||
if weInitiated {
|
||||
// We go first
|
||||
if err := enc.Encode(ourHello); err != nil {
|
||||
return nil, nil, fmt.Errorf("sending hello: %w", err)
|
||||
}
|
||||
var ack proto.HelloAck
|
||||
if err := dec.Decode(&ack); err != nil {
|
||||
return nil, nil, fmt.Errorf("reading hello ack: %w", err)
|
||||
}
|
||||
if err := crypto.Verify(ack.Peer.PublicKey, []byte(ack.EphemeralKey), ack.Signature); err != nil {
|
||||
return nil, nil, fmt.Errorf("bad ack signature: %w", err)
|
||||
}
|
||||
secret, err := ek.SharedSecret(ack.EphemeralKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return crypto.NewSession(secret), &ack.Peer, nil
|
||||
}
|
||||
|
||||
// They go first — read their Hello, then send our Ack
|
||||
var theirHello proto.Hello
|
||||
if err := dec.Decode(&theirHello); err != nil {
|
||||
return nil, nil, fmt.Errorf("reading hello: %w", err)
|
||||
}
|
||||
if err := crypto.Verify(theirHello.Peer.PublicKey, []byte(theirHello.EphemeralKey), theirHello.Signature); err != nil {
|
||||
return nil, nil, fmt.Errorf("bad hello signature: %w", err)
|
||||
}
|
||||
|
||||
ack := proto.HelloAck{
|
||||
Peer: id.PeerInfo(),
|
||||
EphemeralKey: ek.PublicKeyB64(),
|
||||
Signature: id.Sign([]byte(ek.PublicKeyB64())),
|
||||
}
|
||||
if err := enc.Encode(ack); err != nil {
|
||||
return nil, nil, fmt.Errorf("sending ack: %w", err)
|
||||
}
|
||||
|
||||
secret, err := ek.SharedSecret(theirHello.EphemeralKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return crypto.NewSession(secret), &theirHello.Peer, nil
|
||||
}
|
||||
|
||||
// handleMessage dispatches an incoming decrypted peer message.
|
||||
func handleMessage(msg proto.PeerMessage, from *proto.PeerInfo, m *Mesh) {
|
||||
switch msg.Type {
|
||||
case proto.MsgChat:
|
||||
if msg.Chat == nil {
|
||||
return
|
||||
}
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtMessageReceived,
|
||||
Message: msg.Chat,
|
||||
})
|
||||
|
||||
case proto.MsgPeerGossip:
|
||||
if msg.Gossip == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("mesh: gossip from %s: %d peer hints", from.Alias, len(msg.Gossip.Peers))
|
||||
// TODO: attempt connections to new peers
|
||||
|
||||
case proto.MsgPing:
|
||||
log.Printf("mesh: ping from %s", from.Alias)
|
||||
// TODO: send pong back through the send channel
|
||||
|
||||
case proto.MsgPong:
|
||||
log.Printf("mesh: pong from %s", from.Alias)
|
||||
|
||||
case proto.MsgFileOffer:
|
||||
if msg.FileOffer == nil {
|
||||
return
|
||||
}
|
||||
m.Emit(proto.IpcMessage{
|
||||
Type: proto.EvtIncomingFile,
|
||||
PeerID: &from.ID,
|
||||
Offer: msg.FileOffer,
|
||||
})
|
||||
|
||||
default:
|
||||
log.Printf("mesh: unknown message type %q from %s", msg.Type, from.Alias)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user