From b3a4af15ca3dbf0508d5e88a8e74bcc45c528bf8 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Sun, 21 Jun 2026 16:14:07 +0200 Subject: [PATCH] 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 --- .gitignore | 5 + .vscode/extensions.json | 7 ++ .vscode/launch.json | 55 +++++++++ .vscode/settings.json | 21 ++++ .vscode/tasks.json | 46 ++++++++ README.md | 129 +++++++++++++++++++++ cmd/daemon/main.go | 74 ++++++++++++ cmd/relay/main.go | 177 +++++++++++++++++++++++++++++ go.mod | 10 ++ go.sum | 6 + internal/crypto/crypto.go | 232 ++++++++++++++++++++++++++++++++++++++ internal/ipc/ipc.go | 167 +++++++++++++++++++++++++++ internal/mesh/mesh.go | 144 +++++++++++++++++++++++ internal/mesh/peer.go | 194 +++++++++++++++++++++++++++++++ internal/nat/nat.go | 87 ++++++++++++++ internal/proto/proto.go | 212 ++++++++++++++++++++++++++++++++++ 16 files changed, 1566 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 README.md create mode 100644 cmd/daemon/main.go create mode 100644 cmd/relay/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/crypto/crypto.go create mode 100644 internal/ipc/ipc.go create mode 100644 internal/mesh/mesh.go create mode 100644 internal/mesh/peer.go create mode 100644 internal/nat/nat.go create mode 100644 internal/proto/proto.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..750bd08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/bin/ +*.exe +*.identity.json +/tmp/ +.env diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..60687b8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "golang.go", + "usernamehw.errorlens", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4b452c2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,55 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "daemon (peer A)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/daemon", + "args": [ + "-alias", "peer-a", + "-data-dir", "/tmp/waste-a", + "-peer-port", "17338", + "-ipc-port", "17337" + ], + "env": { "WASTE_LOG": "debug" } + }, + { + "name": "daemon (peer B)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/daemon", + "args": [ + "-alias", "peer-b", + "-data-dir", "/tmp/waste-b", + "-peer-port", "17340", + "-ipc-port", "17341" + ], + "env": { "WASTE_LOG": "debug" } + }, + { + "name": "relay (local)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/relay", + "args": ["-bind", "127.0.0.1:17339"] + }, + { + "name": "daemon (with relay)", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/daemon", + "args": [ + "-alias", "peer-a", + "-data-dir", "/tmp/waste-a", + "-peer-port", "17338", + "-ipc-port", "17337", + "-relay", "127.0.0.1:17339" + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8c4fc37 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "go.useLanguageServer": true, + "go.lintTool": "golangci-lint", + "go.lintOnSave": "package", + "go.formatTool": "goimports", + "go.formatOnSave": true, + "editor.formatOnSave": true, + "[go]": { + "editor.defaultFormatter": "golang.go", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "go.testFlags": ["-v", "-race"], + "files.watcherExclude": { + "**/bin/**": true + }, + "search.exclude": { + "**/bin": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f185c62 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,46 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build all", + "type": "shell", + "command": "go build ./...", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": ["$go"] + }, + { + "label": "go mod tidy", + "type": "shell", + "command": "go mod tidy", + "problemMatcher": [] + }, + { + "label": "test all", + "type": "shell", + "command": "go test -race ./...", + "group": "test", + "problemMatcher": ["$go"] + }, + { + "label": "run relay (local)", + "type": "shell", + "command": "go run ./cmd/relay -bind 127.0.0.1:17339", + "presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" }, + "problemMatcher": [] + }, + { + "label": "run daemon (peer A)", + "type": "shell", + "command": "go run ./cmd/daemon -alias peer-a -data-dir /tmp/waste-a -peer-port 17338 -ipc-port 17337", + "presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" }, + "problemMatcher": [] + }, + { + "label": "run daemon (peer B)", + "type": "shell", + "command": "go run ./cmd/daemon -alias peer-b -data-dir /tmp/waste-b -peer-port 17340 -ipc-port 17341", + "presentation": { "reveal": "always", "panel": "dedicated", "group": "runtime" }, + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8209b85 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# waste-go + +A modern reimagining of [WASTE](https://en.wikipedia.org/wiki/WASTE) — decentralized, +friend-to-friend encrypted mesh networking with chat and file sharing. Written in Go. + +## Project layout + +``` +waste-go/ +├── cmd/ +│ ├── daemon/ The peer process — run one on each friend's machine +│ └── relay/ Bootstrap/relay server — run this on your Hetzner VPS +└── internal/ + ├── proto/ All wire types (shared by daemon and relay) + ├── crypto/ Ed25519 identity, X25519 ECDH, ChaCha20-Poly1305 + ├── mesh/ Connected peer state + per-connection handler + ├── ipc/ Local JSON API (UI talks to daemon here, port 17337) + └── nat/ Relay client (hole-punching lives here later) +``` + +## Prerequisites + +- Go 1.22+ → https://go.dev/dl/ +- VS Code with the Go extension (`golang.go`) + +On first open VS Code will prompt you to install `gopls`, `dlv`, and `goimports` — accept all of them. + +## Getting started + +```bash +# Fetch dependencies +go mod tidy + +# Build everything (confirms it compiles) +go build ./... + +# Terminal 1 — relay (optional for LAN testing, required across internet) +go run ./cmd/relay -bind 127.0.0.1:17339 + +# Terminal 2 — peer A +go run ./cmd/daemon -alias alice -data-dir /tmp/waste-alice -peer-port 17338 -ipc-port 17337 + +# Terminal 3 — peer B +go run ./cmd/daemon -alias bob -data-dir /tmp/waste-bob -peer-port 17340 -ipc-port 17341 +``` + +Then connect B → A and send a message (netcat works fine as a quick test): + +```bash +# Tell peer B to connect to peer A +echo '{"type":"connect","addr":"127.0.0.1:17338"}' | nc 127.0.0.1 17341 + +# In another terminal — subscribe to peer A's events, then send a message from B +nc 127.0.0.1 17337 & +echo '{"type":"send_message","room":"general","body":"hello from bob"}' | nc 127.0.0.1 17341 +``` + +**On Windows** — use PowerShell's built-in TCP client instead of `nc`: + +```powershell +# Open a connection to peer B's IPC port and keep it open +$c = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17341) +$w = [System.IO.StreamWriter]::new($c.GetStream()); $w.AutoFlush = $true + +# Tell peer B to connect to peer A +$w.WriteLine('{"type":"connect","addr":"127.0.0.1:17338"}') + +# Send a chat message from B +$w.WriteLine('{"type":"send_message","room":"general","body":"hello from bob"}') + +# In a separate terminal — subscribe to peer A's events +$r = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 17337) +$reader = [System.IO.StreamReader]::new($r.GetStream()) +while ($true) { $reader.ReadLine() } +``` + +Keep `$c` / `$w` in scope for the session; closing them disconnects the peer. + +## Deploying the relay on your Hetzner VPS + +```bash +GOOS=linux GOARCH=amd64 go build -o bin/waste-relay ./cmd/relay +scp bin/waste-relay user@your-vps:~/ + +# On the VPS +./waste-relay -bind 0.0.0.0:17339 +``` + +Then start daemons with `-relay your-vps-ip:17339` and they'll register and +be able to find each other across NAT. + +## IPC protocol (plain JSON over TCP) + +Everything is newline-delimited JSON. You can test with `nc 127.0.0.1 17337`. + +**Commands you send:** +```jsonc +{"type":"connect","addr":"1.2.3.4:17338"} +{"type":"send_message","room":"general","body":"hi"} +{"type":"get_state"} +``` + +**Events the daemon pushes:** +```jsonc +{"type":"state_snapshot","local_peer":{...},"connected_peers":[...]} +{"type":"peer_connected","peer":{...}} +{"type":"message_received","message":{"from":"...","body":"hi","room":"general"}} +{"type":"peer_disconnected","peer_id":"..."} +``` + +## Crypto choices + +| Purpose | Algorithm | Notes | +|---|---|---| +| Identity | Ed25519 | Fast, small keys, standard | +| Key exchange | X25519 ECDH | Per-session ephemeral keys | +| Symmetric | ChaCha20-Poly1305 | No AES-NI needed, authenticated | +| Hashing | SHA-256 | File integrity | + +Replaces WASTE's original Blowfish/PCBC (broken cipher mode) + RSA. + +## Roadmap + +- [ ] UDP hole-punching (STUN) in `internal/nat` +- [ ] Invite file format (`.waste-invite`) — share a keypair + address hint +- [ ] Peer gossip → auto-connect to friends-of-friends +- [ ] File transfer +- [ ] Message persistence (SQLite via `modernc.org/sqlite`) +- [ ] Tauri or simple web UI consuming the IPC port diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go new file mode 100644 index 0000000..271c6e8 --- /dev/null +++ b/cmd/daemon/main.go @@ -0,0 +1,74 @@ +// waste-daemon: the local peer process. +// Run one of these on each friend's machine. +package main + +import ( + "flag" + "fmt" + "log" + "net" + "os" + + "github.com/waste-go/internal/crypto" + "github.com/waste-go/internal/ipc" + "github.com/waste-go/internal/mesh" + "github.com/waste-go/internal/nat" +) + +func main() { + dataDir := flag.String("data-dir", "~/.waste", "path to identity/config directory") + alias := flag.String("alias", "anon", "display name shown to peers (advisory only)") + peerPort := flag.Int("peer-port", 17338, "port to accept incoming peer connections") + ipcPort := flag.Int("ipc-port", 17337, "port for local IPC (UI connects here)") + relay := flag.String("relay", "", "optional relay server address (host:port)") + flag.Parse() + + // Load or generate identity keypair + id, err := crypto.LoadOrCreate(expandHome(*dataDir), *alias) + if err != nil { + log.Fatalf("identity: %v", err) + } + log.Printf("daemon: local peer id: %s alias: %s", id.PeerID().Short(), id.Alias) + + // Shared mesh state + m := mesh.New(id) + + // Peer listener (inbound connections from other nodes) + go func() { + addr := fmt.Sprintf("0.0.0.0:%d", *peerPort) + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Fatalf("peer listener: %v", err) + } + log.Printf("daemon: listening for peers on %s", addr) + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("peer accept error: %v", err) + continue + } + go mesh.HandleConn(conn, m, false) + } + }() + + // NAT traversal / relay client + go func() { + if err := nat.Run(m, *relay); err != nil { + log.Printf("nat: %v", err) + } + }() + + // IPC server — blocks until error + if err := ipc.Run(m, *ipcPort); err != nil { + log.Fatalf("ipc: %v", err) + } +} + +func expandHome(path string) string { + if len(path) >= 2 && path[:2] == "~/" { + if home, err := os.UserHomeDir(); err == nil { + return home + path[1:] + } + } + return path +} diff --git a/cmd/relay/main.go b/cmd/relay/main.go new file mode 100644 index 0000000..472fbe5 --- /dev/null +++ b/cmd/relay/main.go @@ -0,0 +1,177 @@ +// waste-relay: bootstrap and relay server. +// Deploy this on your Hetzner VPS. Peers register here and can ask it +// to forward encrypted envelopes to other peers. +// The relay never sees plaintext — it only shuffles opaque blobs. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "log" + "net" + "sync" + "time" + + "github.com/waste-go/internal/proto" +) + +func main() { + bind := flag.String("bind", "0.0.0.0:17339", "address to listen on") + flag.Parse() + + ln, err := net.Listen("tcp", *bind) + if err != nil { + log.Fatalf("relay: listen on %s: %v", *bind, err) + } + log.Printf("relay: listening on %s", *bind) + + r := newRegistry() + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("relay: accept error: %v", err) + continue + } + go r.handleClient(conn) + } +} + +// ── Registry ────────────────────────────────────────────────────────────────── + +type registeredPeer struct { + gossip proto.GossipEntry + send chan proto.RelayMessage // write to this to forward a message +} + +type registry struct { + mu sync.RWMutex + peers map[proto.PeerID]*registeredPeer +} + +func newRegistry() *registry { + return ®istry{peers: make(map[proto.PeerID]*registeredPeer)} +} + +func (r *registry) register(id proto.PeerID, entry proto.GossipEntry, send chan proto.RelayMessage) { + r.mu.Lock() + r.peers[id] = ®isteredPeer{gossip: entry, send: send} + r.mu.Unlock() + log.Printf("relay: registered %s (%s)", id.Short(), entry.Peer.Alias) +} + +func (r *registry) unregister(id proto.PeerID) { + r.mu.Lock() + delete(r.peers, id) + r.mu.Unlock() + log.Printf("relay: unregistered %s", id.Short()) +} + +func (r *registry) listPeers() []proto.GossipEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]proto.GossipEntry, 0, len(r.peers)) + for _, p := range r.peers { + out = append(out, p.gossip) + } + return out +} + +func (r *registry) forward(to proto.PeerID, msg proto.RelayMessage) bool { + r.mu.RLock() + p, ok := r.peers[to] + r.mu.RUnlock() + if !ok { + return false + } + select { + case p.send <- msg: + return true + default: + return false + } +} + +// ── Client handler ──────────────────────────────────────────────────────────── + +func (r *registry) handleClient(conn net.Conn) { + defer conn.Close() + addr := conn.RemoteAddr().String() + + sendCh := make(chan proto.RelayMessage, 64) + var myID proto.PeerID + + // Writer goroutine + go func() { + enc := json.NewEncoder(conn) + for msg := range sendCh { + if err := enc.Encode(msg); err != nil { + return + } + } + }() + + send := func(msg proto.RelayMessage) { + select { + case sendCh <- msg: + default: + } + } + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + var msg proto.RelayMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + log.Printf("relay: bad message from %s: %v", addr, err) + continue + } + + switch msg.Type { + + case proto.RelayRegister: + if msg.Peer == nil { + send(proto.RelayMessage{Type: proto.RelayError, Message: "register: peer required"}) + continue + } + // TODO: verify msg.Signature against msg.Peer.PublicKey + myID = msg.Peer.ID + entry := proto.GossipEntry{ + Peer: *msg.Peer, + AddrHint: addr, + LastSeen: time.Now(), + } + r.register(myID, entry, sendCh) + + case proto.RelayListPeers: + send(proto.RelayMessage{ + Type: proto.RelayPeerList, + Peers: r.listPeers(), + }) + + case proto.RelayForward: + if msg.To == nil || msg.Envelope == nil { + send(proto.RelayMessage{Type: proto.RelayError, Message: "forward: to and envelope required"}) + continue + } + fwd := proto.RelayMessage{ + Type: proto.RelayForwarded, + From: &myID, + Envelope: msg.Envelope, + } + if !r.forward(*msg.To, fwd) { + send(proto.RelayMessage{ + Type: proto.RelayError, + Message: "peer not connected to relay", + }) + } + + default: + log.Printf("relay: unknown message type %q from %s", msg.Type, addr) + } + } + + close(sendCh) + if myID != "" { + r.unregister(myID) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c7ab81e --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/waste-go + +go 1.22 + +require ( + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.24.0 +) + +require golang.org/x/sys v0.21.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2706ac3 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..f06eb74 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,232 @@ +// Package crypto handles all cryptographic operations for waste-go. +// +// Identity: Ed25519 keypair — who you are, persistent across restarts +// Session: X25519 ECDH — per-connection shared secret +// Symmetric: ChaCha20-Poly1305 — encrypts every message after the handshake +package crypto + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" + + "github.com/waste-go/internal/proto" +) + +// b64 is the base64url encoder we use everywhere (no padding, URL-safe). +var b64 = base64.RawURLEncoding + +// ── Identity ────────────────────────────────────────────────────────────────── + +// Identity holds our Ed25519 keypair and alias. +// Load it once at startup with LoadOrCreate; then pass it around. +type Identity struct { + privateKey ed25519.PrivateKey + PublicKey ed25519.PublicKey + Alias string +} + +// identityFile is what we store on disk. +type identityFile struct { + PrivateKeyB64 string `json:"private_key"` + Alias string `json:"alias"` +} + +// LoadOrCreate loads the identity from dataDir/identity.json, +// or generates a fresh keypair if none exists yet. +func LoadOrCreate(dataDir, alias string) (*Identity, error) { + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("creating data dir: %w", err) + } + + path := filepath.Join(dataDir, "identity.json") + + data, err := os.ReadFile(path) + if err == nil { + // File exists — load it + var f identityFile + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parsing identity file: %w", err) + } + privBytes, err := b64.DecodeString(f.PrivateKeyB64) + if err != nil { + return nil, fmt.Errorf("decoding private key: %w", err) + } + priv := ed25519.PrivateKey(privBytes) + return &Identity{ + privateKey: priv, + PublicKey: priv.Public().(ed25519.PublicKey), + Alias: f.Alias, + }, nil + } + + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("reading identity file: %w", err) + } + + // Generate new keypair + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generating keypair: %w", err) + } + + f := identityFile{ + PrivateKeyB64: b64.EncodeToString(priv), + Alias: alias, + } + raw, _ := json.MarshalIndent(f, "", " ") + if err := os.WriteFile(path, raw, 0600); err != nil { + return nil, fmt.Errorf("saving identity: %w", err) + } + + fmt.Printf("Generated new identity, saved to %s\n", path) + return &Identity{privateKey: priv, PublicKey: pub, Alias: alias}, nil +} + +// PeerID returns the base64url encoding of the public key. +// This is the peer's stable identifier. +func (id *Identity) PeerID() proto.PeerID { + return proto.PeerID(b64.EncodeToString(id.PublicKey)) +} + +// PeerInfo builds the PeerInfo struct for the handshake. +func (id *Identity) PeerInfo() proto.PeerInfo { + return proto.PeerInfo{ + ID: id.PeerID(), + Alias: id.Alias, + PublicKey: b64.EncodeToString(id.PublicKey), + CreatedAt: time.Now(), + } +} + +// Sign signs data with our Ed25519 private key. Returns base64url signature. +func (id *Identity) Sign(data []byte) string { + sig := ed25519.Sign(id.privateKey, data) + return b64.EncodeToString(sig) +} + +// Verify checks an Ed25519 signature against a base64url public key. +func Verify(publicKeyB64 string, data []byte, sigB64 string) error { + pubBytes, err := b64.DecodeString(publicKeyB64) + if err != nil { + return fmt.Errorf("decoding public key: %w", err) + } + sigBytes, err := b64.DecodeString(sigB64) + if err != nil { + return fmt.Errorf("decoding signature: %w", err) + } + if !ed25519.Verify(ed25519.PublicKey(pubBytes), data, sigBytes) { + return errors.New("signature verification failed") + } + return nil +} + +// ── X25519 ECDH ─────────────────────────────────────────────────────────────── + +// EphemeralKey is an X25519 keypair used for a single session. +type EphemeralKey struct { + private [32]byte + public [32]byte +} + +// GenerateEphemeral creates a fresh X25519 keypair. +func GenerateEphemeral() (*EphemeralKey, error) { + ek := &EphemeralKey{} + if _, err := rand.Read(ek.private[:]); err != nil { + return nil, fmt.Errorf("generating ephemeral key: %w", err) + } + // Clamp per RFC 7748 + ek.private[0] &= 248 + ek.private[31] &= 127 + ek.private[31] |= 64 + + pub, err := curve25519.X25519(ek.private[:], curve25519.Basepoint) + if err != nil { + return nil, fmt.Errorf("computing public key: %w", err) + } + copy(ek.public[:], pub) + return ek, nil +} + +// PublicKeyB64 returns the base64url public key to send in the handshake. +func (ek *EphemeralKey) PublicKeyB64() string { + return b64.EncodeToString(ek.public[:]) +} + +// SharedSecret performs ECDH with the other party's public key. +// Returns a 32-byte shared secret suitable for use as an AEAD key. +func (ek *EphemeralKey) SharedSecret(theirPublicB64 string) ([32]byte, error) { + var result [32]byte + theirBytes, err := b64.DecodeString(theirPublicB64) + if err != nil { + return result, fmt.Errorf("decoding their public key: %w", err) + } + shared, err := curve25519.X25519(ek.private[:], theirBytes) + if err != nil { + return result, fmt.Errorf("ECDH: %w", err) + } + // Hash the raw shared secret (simple KDF — use HKDF in production) + result = sha256.Sum256(shared) + return result, nil +} + +// ── ChaCha20-Poly1305 AEAD ──────────────────────────────────────────────────── + +// Session holds the symmetric key for an established peer session. +type Session struct { + key [32]byte +} + +// NewSession wraps a 32-byte shared secret as a usable session. +func NewSession(key [32]byte) *Session { + return &Session{key: key} +} + +// Encrypt encrypts plaintext. Returns (nonce_b64, ciphertext_b64). +func (s *Session) Encrypt(plaintext []byte) (string, string, error) { + aead, err := chacha20poly1305.New(s.key[:]) + if err != nil { + return "", "", fmt.Errorf("creating AEAD: %w", err) + } + + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", "", fmt.Errorf("generating nonce: %w", err) + } + + ciphertext := aead.Seal(nil, nonce, plaintext, nil) + return b64.EncodeToString(nonce), b64.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a nonce+ciphertext pair. Returns plaintext. +func (s *Session) Decrypt(nonceB64, ciphertextB64 string) ([]byte, error) { + aead, err := chacha20poly1305.New(s.key[:]) + if err != nil { + return nil, fmt.Errorf("creating AEAD: %w", err) + } + + nonce, err := b64.DecodeString(nonceB64) + if err != nil { + return nil, fmt.Errorf("decoding nonce: %w", err) + } + ciphertext, err := b64.DecodeString(ciphertextB64) + if err != nil { + return nil, fmt.Errorf("decoding ciphertext: %w", err) + } + + plaintext, err := aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, errors.New("decryption failed — bad key or tampered message") + } + return plaintext, nil +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go new file mode 100644 index 0000000..7d060e8 --- /dev/null +++ b/internal/ipc/ipc.go @@ -0,0 +1,167 @@ +// Package ipc implements the local IPC server. +// The UI (or any local tool) connects to 127.0.0.1:17337 and speaks +// newline-delimited JSON: send IpcMessage commands, receive IpcMessage events. +package ipc + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net" + "time" + + "github.com/google/uuid" + "github.com/waste-go/internal/mesh" + "github.com/waste-go/internal/proto" +) + +// Run starts the IPC listener. Blocks until the listener fails. +func Run(m *mesh.Mesh, port int) error { + addr := fmt.Sprintf("127.0.0.1:%d", port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("ipc listen on %s: %w", addr, err) + } + log.Printf("ipc: listening on %s", addr) + + for { + conn, err := ln.Accept() + if err != nil { + return fmt.Errorf("ipc accept: %w", err) + } + log.Printf("ipc: UI client connected from %s", conn.RemoteAddr()) + go handleClient(conn, m) + } +} + +func handleClient(conn net.Conn, m *mesh.Mesh) { + defer conn.Close() + + // Subscribe to mesh events before anything else so we miss nothing. + events := m.Subscribe() + defer m.Unsubscribe(events) + + // Channel to serialize writes from two goroutines (event pusher + reply sender). + writeCh := make(chan []byte, 128) + + // Writer goroutine — single goroutine owns the connection write side. + go func() { + w := bufio.NewWriter(conn) + for line := range writeCh { + line = append(line, '\n') + if _, err := w.Write(line); err != nil { + return + } + w.Flush() + } + }() + + // Event pusher goroutine — forwards mesh events to the UI. + go func() { + for evt := range events { + line, err := json.Marshal(evt) + if err != nil { + continue + } + select { + case writeCh <- line: + default: + } + } + }() + + // Send an initial state snapshot so the UI has something to render. + send(writeCh, proto.IpcMessage{ + Type: proto.EvtStateSnapshot, + LocalPeer: ptr(m.Identity.PeerInfo()), + ConnectedPeers: m.ConnectedPeers(), + Rooms: []string{"general"}, + }) + + // Command reader loop. + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + var cmd proto.IpcMessage + if err := json.Unmarshal(scanner.Bytes(), &cmd); err != nil { + log.Printf("ipc: bad command: %v", err) + continue + } + handleCommand(cmd, m, writeCh) + } + + close(writeCh) + log.Printf("ipc: UI client disconnected") +} + +func handleCommand(cmd proto.IpcMessage, m *mesh.Mesh, writeCh chan<- []byte) { + switch cmd.Type { + + case proto.CmdSendMessage: + msg := &proto.ChatMessage{ + ID: uuid.NewString(), + From: m.Identity.PeerID(), + To: cmd.To, + Room: cmd.Room, + Body: cmd.Body, + SentAt: time.Now(), + } + payload, err := json.Marshal(proto.PeerMessage{ + Type: proto.MsgChat, + Chat: msg, + }) + if err != nil { + return + } + m.Broadcast(payload) + // Echo locally so the sender sees their own message. + m.Emit(proto.IpcMessage{Type: proto.EvtMessageReceived, Message: msg}) + + case proto.CmdConnect: + if cmd.Addr == "" { + send(writeCh, errMsg("connect: addr is required")) + return + } + go func() { + log.Printf("ipc: connecting to peer at %s", cmd.Addr) + conn, err := net.DialTimeout("tcp", cmd.Addr, 10*time.Second) + if err != nil { + log.Printf("ipc: dial %s failed: %v", cmd.Addr, err) + m.Emit(errMsg(fmt.Sprintf("connect to %s failed: %v", cmd.Addr, err))) + return + } + mesh.HandleConn(conn, m, true) + }() + + case proto.CmdGetState: + send(writeCh, proto.IpcMessage{ + Type: proto.EvtStateSnapshot, + LocalPeer: ptr(m.Identity.PeerInfo()), + ConnectedPeers: m.ConnectedPeers(), + Rooms: []string{"general"}, + }) + + case proto.CmdSendFile: + send(writeCh, errMsg("file transfer not yet implemented")) + + default: + send(writeCh, errMsg(fmt.Sprintf("unknown command: %s", cmd.Type))) + } +} + +func send(ch chan<- []byte, msg proto.IpcMessage) { + line, err := json.Marshal(msg) + if err != nil { + return + } + select { + case ch <- line: + default: + } +} + +func errMsg(s string) proto.IpcMessage { + return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s} +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go new file mode 100644 index 0000000..77e8682 --- /dev/null +++ b/internal/mesh/mesh.go @@ -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: + } + } +} diff --git a/internal/mesh/peer.go b/internal/mesh/peer.go new file mode 100644 index 0000000..0f170ce --- /dev/null +++ b/internal/mesh/peer.go @@ -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) + } +} diff --git a/internal/nat/nat.go b/internal/nat/nat.go new file mode 100644 index 0000000..4312a24 --- /dev/null +++ b/internal/nat/nat.go @@ -0,0 +1,87 @@ +// Package nat handles relay registration and future hole-punching. +package nat + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net" + "time" + + "github.com/waste-go/internal/mesh" + "github.com/waste-go/internal/proto" +) + +// Run connects to the relay server (if configured) and keeps the connection alive. +// Pass an empty relayAddr to skip relay entirely (LAN-only mode). +func Run(m *mesh.Mesh, relayAddr string) error { + if relayAddr == "" { + log.Println("nat: no relay configured, running in LAN-only mode") + select {} // block forever — task stays alive + } + + for { + log.Printf("nat: connecting to relay at %s", relayAddr) + if err := connectRelay(relayAddr, m); err != nil { + log.Printf("nat: relay error: %v", err) + } + log.Printf("nat: reconnecting to relay in 10s...") + time.Sleep(10 * time.Second) + } +} + +func connectRelay(addr string, m *mesh.Mesh) error { + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("dial relay: %w", err) + } + defer conn.Close() + + id := m.Identity + enc := json.NewEncoder(conn) + dec := json.NewDecoder(bufio.NewReader(conn)) + + // Register with the relay + reg := proto.RelayMessage{ + Type: proto.RelayRegister, + Peer: ptr(id.PeerInfo()), + Signature: id.Sign([]byte(id.PeerID())), + } + if err := enc.Encode(reg); err != nil { + return fmt.Errorf("sending register: %w", err) + } + log.Printf("nat: registered with relay as %s", id.PeerID().Short()) + + // Ask for the current peer list (bootstrap) + if err := enc.Encode(proto.RelayMessage{Type: proto.RelayListPeers}); err != nil { + return fmt.Errorf("sending list_peers: %w", err) + } + + // Read relay messages + for { + var msg proto.RelayMessage + if err := dec.Decode(&msg); err != nil { + return fmt.Errorf("reading relay message: %w", err) + } + + switch msg.Type { + case proto.RelayPeerList: + log.Printf("nat: relay gave us %d peer hints", len(msg.Peers)) + // TODO: attempt direct connections to each peer hint + + case proto.RelayForwarded: + from := "unknown" + if msg.From != nil { + from = msg.From.Short() + } + log.Printf("nat: relayed envelope from %s — direct connection not yet implemented", from) + // TODO: decrypt and process as PeerMessage + + case proto.RelayError: + log.Printf("nat: relay error: %s", msg.Message) + } + } +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/proto/proto.go b/internal/proto/proto.go new file mode 100644 index 0000000..c7e37c5 --- /dev/null +++ b/internal/proto/proto.go @@ -0,0 +1,212 @@ +// Package proto defines all wire types shared between the daemon and relay. +// Everything on the wire is newline-delimited JSON. +// Binary data (keys, signatures, ciphertext) is base64url encoded. +package proto + +import "time" + +// ── Identity ────────────────────────────────────────────────────────────────── + +// PeerID is a peer's stable identity: their Ed25519 public key, base64url encoded. +// This IS the peer — display names are advisory only and unauthenticated. +type PeerID string + +// Short returns the first 8 characters, useful for display. +func (p PeerID) Short() string { + if len(p) < 8 { + return string(p) + } + return string(p)[:8] +} + +// PeerInfo is a peer's self-description. Included in the Hello handshake. +type PeerInfo struct { + ID PeerID `json:"id"` + Alias string `json:"alias"` // advisory, not authenticated + PublicKey string `json:"public_key"` // Ed25519 pubkey, base64url + CreatedAt time.Time `json:"created_at"` +} + +// ── Handshake ───────────────────────────────────────────────────────────────── + +// Hello is the first message sent on a new TCP connection. +type Hello struct { + Version int `json:"version"` + Peer PeerInfo `json:"peer"` + EphemeralKey string `json:"ephemeral_key"` // X25519 pubkey, base64url + Signature string `json:"signature"` // Ed25519 sig over ephemeral_key +} + +// HelloAck is the response to Hello, completing the handshake. +type HelloAck struct { + Peer PeerInfo `json:"peer"` + EphemeralKey string `json:"ephemeral_key"` + Signature string `json:"signature"` + RelayCapable bool `json:"relay_capable"` +} + +// ── Encrypted envelope ──────────────────────────────────────────────────────── + +// Envelope wraps all post-handshake messages. +// The payload is ChaCha20-Poly1305 encrypted. +type Envelope struct { + Nonce string `json:"nonce"` // 12 bytes, base64url + Payload string `json:"payload"` // ciphertext, base64url +} + +// ── Peer-to-peer message types ──────────────────────────────────────────────── + +// MsgType identifies the kind of peer message inside an Envelope. +type MsgType string + +const ( + MsgChat MsgType = "chat" + MsgPeerGossip MsgType = "peer_gossip" + MsgFileOffer MsgType = "file_offer" + MsgFileResp MsgType = "file_response" + MsgFileChunk MsgType = "file_chunk" + MsgPing MsgType = "ping" + MsgPong MsgType = "pong" +) + +// PeerMessage is the top-level container decoded from inside an Envelope. +type PeerMessage struct { + Type MsgType `json:"type"` + + // Only one of these will be set, depending on Type. + Chat *ChatMessage `json:"chat,omitempty"` + Gossip *PeerGossip `json:"gossip,omitempty"` + FileOffer *FileOffer `json:"file_offer,omitempty"` + FileResp *FileResponse `json:"file_response,omitempty"` + FileChunk *FileChunk `json:"file_chunk,omitempty"` + Seq *uint64 `json:"seq,omitempty"` // for ping/pong +} + +// ChatMessage is a message to a room or a DM. +type ChatMessage struct { + ID string `json:"id"` + From PeerID `json:"from"` + To *PeerID `json:"to,omitempty"` // nil = broadcast to room + Room string `json:"room"` + Body string `json:"body"` + SentAt time.Time `json:"sent_at"` +} + +// PeerGossip shares known peer addresses. +type PeerGossip struct { + Peers []GossipEntry `json:"peers"` +} + +// GossipEntry is one peer hint shared via gossip. +type GossipEntry struct { + Peer PeerInfo `json:"peer"` + AddrHint string `json:"addr_hint"` // IP:port, may be behind NAT + LastSeen time.Time `json:"last_seen"` +} + +// FileOffer initiates a file transfer. +type FileOffer struct { + TransferID string `json:"transfer_id"` + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` +} + +// FileResponse accepts or declines a FileOffer. +type FileResponse struct { + TransferID string `json:"transfer_id"` + Accepted bool `json:"accepted"` +} + +// FileChunk is one piece of a file transfer. +type FileChunk struct { + TransferID string `json:"transfer_id"` + Seq uint32 `json:"seq"` + Data string `json:"data"` // base64url + IsLast bool `json:"is_last"` +} + +// ── Relay protocol ──────────────────────────────────────────────────────────── + +// RelayMsgType identifies relay wire messages. +type RelayMsgType string + +const ( + RelayRegister RelayMsgType = "register" + RelayForward RelayMsgType = "forward" + RelayListPeers RelayMsgType = "list_peers" + RelayForwarded RelayMsgType = "forwarded" + RelayPeerList RelayMsgType = "peer_list" + RelayError RelayMsgType = "error" +) + +// RelayMessage is used for both client→relay and relay→client. +type RelayMessage struct { + Type RelayMsgType `json:"type"` + + // register + Peer *PeerInfo `json:"peer,omitempty"` + Signature string `json:"signature,omitempty"` + + // forward / forwarded + To *PeerID `json:"to,omitempty"` + From *PeerID `json:"from,omitempty"` + Envelope *Envelope `json:"envelope,omitempty"` + + // peer_list + Peers []GossipEntry `json:"peers,omitempty"` + + // error + Message string `json:"message,omitempty"` +} + +// ── IPC protocol (daemon ↔ local UI) ───────────────────────────────────────── + +// IpcMsgType identifies IPC messages. +type IpcMsgType string + +const ( + // Commands (UI → daemon) + CmdSendMessage IpcMsgType = "send_message" + CmdConnect IpcMsgType = "connect" + CmdGetState IpcMsgType = "get_state" + CmdSendFile IpcMsgType = "send_file" + + // Events (daemon → UI) + EvtMessageReceived IpcMsgType = "message_received" + EvtPeerConnected IpcMsgType = "peer_connected" + EvtPeerDisconnected IpcMsgType = "peer_disconnected" + EvtIncomingFile IpcMsgType = "incoming_file" + EvtFileProgress IpcMsgType = "file_progress" + EvtStateSnapshot IpcMsgType = "state_snapshot" + EvtError IpcMsgType = "error" +) + +// IpcMessage covers both commands and events. +type IpcMessage struct { + Type IpcMsgType `json:"type"` + + // send_message + Room string `json:"room,omitempty"` + To *PeerID `json:"to,omitempty"` + Body string `json:"body,omitempty"` + + // connect + Addr string `json:"addr,omitempty"` + + // send_file + Path string `json:"path,omitempty"` + + // events + Peer *PeerInfo `json:"peer,omitempty"` + PeerID *PeerID `json:"peer_id,omitempty"` + Message *ChatMessage `json:"message,omitempty"` + Offer *FileOffer `json:"offer,omitempty"` + TransferID string `json:"transfer_id,omitempty"` + BytesReceived int64 `json:"bytes_received,omitempty"` + TotalBytes int64 `json:"total_bytes,omitempty"` + LocalPeer *PeerInfo `json:"local_peer,omitempty"` + ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"` + Rooms []string `json:"rooms,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +}