From 1308082c7b3b554979c93f4768e1af8b38cf6d63 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Fri, 26 Jun 2026 22:16:18 +0200 Subject: [PATCH] Add TURN relay support for daemon mode -turn-url and -turn-secret flags on the daemon; credentials generated using coturn use-auth-secret HMAC-SHA1 scheme (same as browser mode). ICEServers field on mesh.Mesh threads extra ICE servers through to every PeerConnection created by the anchor client. Co-Authored-By: Claude Sonnet 4.6 --- cmd/daemon/main.go | 4 ++++ internal/anchor/client.go | 13 ++++++------- internal/mesh/mesh.go | 6 +++++- internal/netmgr/manager.go | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index dedddf5..5c8b09f 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -22,6 +22,8 @@ func main() { anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") shareDir := flag.String("share-dir", "", "directory to share with peers on the network") joinInvite := flag.String("join", "", "waste: invite string — sets anchor URL and auto-joins the network on startup") + turnURL := flag.String("turn-url", "", "TURN server URL, e.g. turn:your-vps:3478") + turnSecret := flag.String("turn-secret", "", "shared secret for coturn use-auth-secret HMAC credential") importBackup := flag.String("import-identity", "", "path to a yaw-key-backup-1 JSON file to import") importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity") flag.Parse() @@ -74,6 +76,8 @@ func main() { StoreDir: dir, AnchorURL: *anchorURL, ShareDir: expandHome(*shareDir), + TurnURL: *turnURL, + TurnSecret: *turnSecret, }) if autoJoinNetwork != "" { diff --git a/internal/anchor/client.go b/internal/anchor/client.go index b073ef7..41cfcc9 100644 --- a/internal/anchor/client.go +++ b/internal/anchor/client.go @@ -229,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity mu.Lock() if sess == nil { // Answerer: we haven't created a session yet, do it now. - pc, err := newPC() + pc, err := newPC(m.ICEServers) if err != nil { mu.Unlock() log.Printf("anchor: new PC for answerer: %v", err) @@ -412,7 +412,7 @@ func dispatchSignaling( // startOffer creates a session, sends our ekey, waits up to ekeyTimeout for // the peer's ekey, then sends the offer (ephemeral or static). func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender) (*peerSession, error) { - pc, err := newPC() + pc, err := newPC(m.ICEServers) if err != nil { return nil, err } @@ -473,7 +473,7 @@ func startOffer(ctx context.Context, peerID proto.PeerID, id *crypto.Identity, m // answerOffer processes an incoming offer and returns the PeerConnection. func answerOffer(ctx context.Context, payload proto.SignalingPayload, fromID proto.PeerID, id *crypto.Identity, m *mesh.Mesh, s *sender, sess *peerSession) (*webrtc.PeerConnection, error) { - pc, err := newPC() + pc, err := newPC(m.ICEServers) if err != nil { return nil, err } @@ -618,10 +618,9 @@ func hashNetName(name string) string { return hex.EncodeToString(h[:]) } -func newPC() (*webrtc.PeerConnection, error) { - return webrtc.NewPeerConnection(webrtc.Configuration{ - ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, - }) +func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) { + servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...) + return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers}) } func boolPtr(b bool) *bool { return &b } diff --git a/internal/mesh/mesh.go b/internal/mesh/mesh.go index 7e17bd0..14b21de 100644 --- a/internal/mesh/mesh.go +++ b/internal/mesh/mesh.go @@ -13,6 +13,9 @@ import ( "github.com/waste-go/internal/store" ) +// ICEServer mirrors webrtc.ICEServer so callers don't import pion directly. +type ICEServer = webrtc.ICEServer + // PeerConn is a live connection to one peer. type PeerConn struct { Info proto.PeerInfo @@ -33,7 +36,8 @@ type Mesh struct { InviteString string // the invite this peer used to join (sent in hello to other peers) // ScanFiles overrides ScanShareDir when set — allows the manager to inject // multi-share scanning without the mesh needing to know about shares.json. - ScanFiles func() []proto.FileEntry + ScanFiles func() []proto.FileEntry + ICEServers []ICEServer // extra ICE servers (e.g. TURN); appended to the default STUN entry mu sync.RWMutex peers map[proto.PeerID]*PeerConn diff --git a/internal/netmgr/manager.go b/internal/netmgr/manager.go index fc83f01..628b737 100644 --- a/internal/netmgr/manager.go +++ b/internal/netmgr/manager.go @@ -6,13 +6,20 @@ package netmgr import ( "context" + "crypto/hmac" + "crypto/sha1" "crypto/sha256" + "encoding/base64" "encoding/hex" "fmt" "log" "os" "path/filepath" + "strconv" "sync" + "time" + + "github.com/pion/webrtc/v3" "github.com/waste-go/internal/anchor" "github.com/waste-go/internal/crypto" @@ -28,6 +35,8 @@ type Config struct { StoreDir string // base directory for per-network SQLite files AnchorURL string // WebSocket anchor URL used for all networks ShareDir string // default share directory; overridden per network via Join or SetShareDir + TurnURL string // optional TURN server URL, e.g. "turn:your-vps:3478" + TurnSecret string // shared secret for coturn use-auth-secret HMAC credential } // Network is a single joined network context. @@ -113,6 +122,9 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) { m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full) capturedNetID := netID m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) } + if ice := mgr.turnICEServers(); ice != nil { + m.ICEServers = ice + } // Forward all mesh events to the Manager's fan-out, tagging with network_id. meshEvents := m.Subscribe() @@ -198,6 +210,9 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) { m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID) capturedNetID2 := netID m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) } + if ice := mgr.turnICEServers(); ice != nil { + m.ICEServers = ice + } meshEvents := m.Subscribe() go func() { @@ -411,6 +426,26 @@ func (mgr *Manager) emit(msg proto.IpcMessage) { } } +// turnICEServers returns TURN ICE servers if TurnURL and TurnSecret are set, +// using coturn's use-auth-secret HMAC-SHA1 time-limited credential scheme. +// Returns nil if TURN is not configured. +func (mgr *Manager) turnICEServers() []webrtc.ICEServer { + if mgr.cfg.TurnURL == "" || mgr.cfg.TurnSecret == "" { + return nil + } + // Username = Unix timestamp 1 hour from now. + expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + mac := hmac.New(sha1.New, []byte(mgr.cfg.TurnSecret)) + mac.Write([]byte(expiry)) + credential := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + return []webrtc.ICEServer{{ + URLs: []string{mgr.cfg.TurnURL}, + Username: expiry, + Credential: credential, + CredentialType: webrtc.ICECredentialTypePassword, + }} +} + // ── helpers ─────────────────────────────────────────────────────────────────── func hashNetName(name string) string {