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

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

View File

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

View File

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