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 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-26 22:16:18 +02:00
parent 31e13fd509
commit 1308082c7b
4 changed files with 50 additions and 8 deletions

View File

@@ -22,6 +22,8 @@ func main() {
anchorURL := flag.String("anchor", "", "anchor WebSocket URL, e.g. ws://your-vps:17339/ws") 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") 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") 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") 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") importPassword := flag.String("import-passphrase", "", "passphrase for --import-identity")
flag.Parse() flag.Parse()
@@ -74,6 +76,8 @@ func main() {
StoreDir: dir, StoreDir: dir,
AnchorURL: *anchorURL, AnchorURL: *anchorURL,
ShareDir: expandHome(*shareDir), ShareDir: expandHome(*shareDir),
TurnURL: *turnURL,
TurnSecret: *turnSecret,
}) })
if autoJoinNetwork != "" { if autoJoinNetwork != "" {

View File

@@ -229,7 +229,7 @@ func runOnce(ctx context.Context, anchorURL, netHash string, id *crypto.Identity
mu.Lock() mu.Lock()
if sess == nil { if sess == nil {
// Answerer: we haven't created a session yet, do it now. // Answerer: we haven't created a session yet, do it now.
pc, err := newPC() pc, err := newPC(m.ICEServers)
if err != nil { if err != nil {
mu.Unlock() mu.Unlock()
log.Printf("anchor: new PC for answerer: %v", err) 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 // startOffer creates a session, sends our ekey, waits up to ekeyTimeout for
// the peer's ekey, then sends the offer (ephemeral or static). // 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) { 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 { if err != nil {
return nil, err 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. // 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -618,10 +618,9 @@ func hashNetName(name string) string {
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
func newPC() (*webrtc.PeerConnection, error) { func newPC(extra []webrtc.ICEServer) (*webrtc.PeerConnection, error) {
return webrtc.NewPeerConnection(webrtc.Configuration{ servers := append([]webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, extra...)
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, return webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: servers})
})
} }
func boolPtr(b bool) *bool { return &b } func boolPtr(b bool) *bool { return &b }

View File

@@ -13,6 +13,9 @@ import (
"github.com/waste-go/internal/store" "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. // PeerConn is a live connection to one peer.
type PeerConn struct { type PeerConn struct {
Info proto.PeerInfo 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) 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 // ScanFiles overrides ScanShareDir when set — allows the manager to inject
// multi-share scanning without the mesh needing to know about shares.json. // 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 mu sync.RWMutex
peers map[proto.PeerID]*PeerConn peers map[proto.PeerID]*PeerConn

View File

@@ -6,13 +6,20 @@ package netmgr
import ( import (
"context" "context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"sync" "sync"
"time"
"github.com/pion/webrtc/v3"
"github.com/waste-go/internal/anchor" "github.com/waste-go/internal/anchor"
"github.com/waste-go/internal/crypto" "github.com/waste-go/internal/crypto"
@@ -28,6 +35,8 @@ type Config struct {
StoreDir string // base directory for per-network SQLite files StoreDir string // base directory for per-network SQLite files
AnchorURL string // WebSocket anchor URL used for all networks AnchorURL string // WebSocket anchor URL used for all networks
ShareDir string // default share directory; overridden per network via Join or SetShareDir 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. // 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) m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID_full)
capturedNetID := netID capturedNetID := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID) } 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. // Forward all mesh events to the Manager's fan-out, tagging with network_id.
meshEvents := m.Subscribe() 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) m.DownloadDir = filepath.Join(mgr.cfg.StoreDir, "downloads-"+netID)
capturedNetID2 := netID capturedNetID2 := netID
m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) } m.ScanFiles = func() []proto.FileEntry { return mgr.ScanAllShares(capturedNetID2) }
if ice := mgr.turnICEServers(); ice != nil {
m.ICEServers = ice
}
meshEvents := m.Subscribe() meshEvents := m.Subscribe()
go func() { 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 ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
func hashNetName(name string) string { func hashNetName(name string) string {