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:
Fredrik Johansson
2026-06-21 16:14:07 +02:00
commit b3a4af15ca
16 changed files with 1566 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/bin/
*.exe
*.identity.json
/tmp/
.env

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"golang.go",
"usernamehw.errorlens",
"streetsidesoftware.code-spell-checker"
]
}

55
.vscode/launch.json vendored Normal file
View File

@@ -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"
]
}
]
}

21
.vscode/settings.json vendored Normal file
View File

@@ -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
}
}

46
.vscode/tasks.json vendored Normal file
View File

@@ -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": []
}
]
}

129
README.md Normal file
View File

@@ -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

74
cmd/daemon/main.go Normal file
View File

@@ -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
}

177
cmd/relay/main.go Normal file
View File

@@ -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 &registry{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] = &registeredPeer{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)
}
}

10
go.mod Normal file
View File

@@ -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

6
go.sum Normal file
View File

@@ -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=

232
internal/crypto/crypto.go Normal file
View File

@@ -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
}

167
internal/ipc/ipc.go Normal file
View File

@@ -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 }

144
internal/mesh/mesh.go Normal file
View 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
View 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)
}
}

87
internal/nat/nat.go Normal file
View File

@@ -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 }

212
internal/proto/proto.go Normal file
View File

@@ -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"`
}