Files
waste-go/cmd/anchor/main.go
Fredrik Johansson f425e0bb8e
All checks were successful
Build / Build & release (push) Successful in 13m40s
fix: stop leaking TURN secret to browser clients
WASTE_CONFIG.turnSecret was shipped in plaintext config.js and used to
compute coturn HMAC credentials client-side in browser.ts. Anyone reading
the PWA's JS could read the secret and mint unlimited long-lived TURN
credentials, turning the relay into an open proxy.

The anchor now mints short-lived (1h) credentials server-side via a new
GET /turn-credentials endpoint (-turn-secret flag), mirroring what the
daemon already does. The browser fetches credentials instead of holding
the secret. Daemon mode was unaffected (already server-side).

Docs updated to drop turnSecret from config.js examples, document the
new nginx route, and instruct anyone with an old config.js to rotate
the coturn secret since it was previously exposed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 18:48:59 +02:00

275 lines
7.0 KiB
Go

// waste-anchor: WebSocket signaling server for YAW/2.
// Deploy on your Hetzner VPS alongside a STUN server (coturn in STUN-only mode).
// The anchor never reads the content of "box" fields — it only routes sealed blobs.
package main
import (
"context"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"log"
"net/http"
"strconv"
"sync"
"time"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"github.com/waste-go/internal/proto"
)
func main() {
bind := flag.String("bind", "0.0.0.0:17339", "address to listen on")
turnSecret := flag.String("turn-secret", "", "coturn use-auth-secret shared secret; enables GET /turn-credentials")
flag.Parse()
a := newAnchor()
http.HandleFunc("/ws", a.handleWS)
if *turnSecret != "" {
http.HandleFunc("/turn-credentials", turnCredentialsHandler(*turnSecret))
log.Printf("anchor: /turn-credentials enabled")
}
log.Printf("anchor: listening on %s", *bind)
if err := http.ListenAndServe(*bind, nil); err != nil {
log.Fatalf("anchor: %v", err)
}
}
// turnCredentialsHandler mints short-lived coturn use-auth-secret credentials
// server-side, so the shared secret never reaches the browser. Mirrors the
// scheme in internal/netmgr.Manager.turnICEServers (daemon mode).
func turnCredentialsHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(expiry))
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
json.NewEncoder(w).Encode(struct {
Username string `json:"username"`
Credential string `json:"credential"`
TTL int `json:"ttl"`
}{Username: expiry, Credential: credential, TTL: 3600})
}
}
// ── Anchor ────────────────────────────────────────────────────────────────────
type client struct {
id string // hex peer id, set after join
net string // hashed network name, set after join
send chan proto.AnchorMessage
conn *websocket.Conn
}
type anchor struct {
mu sync.RWMutex
clients map[string]*client // keyed by hex peer id
}
func newAnchor() *anchor {
return &anchor{clients: make(map[string]*client)}
}
func (a *anchor) register(c *client) {
a.mu.Lock()
a.clients[c.id] = c
a.mu.Unlock()
}
func (a *anchor) unregister(c *client) {
if c.id == "" {
return
}
a.mu.Lock()
delete(a.clients, c.id)
a.mu.Unlock()
// Notify everyone in the same network.
leave := proto.AnchorMessage{Type: proto.AnchorPeerLeave, ID: c.id}
a.mu.RLock()
for _, peer := range a.clients {
if peer.net == c.net {
select {
case peer.send <- leave:
default:
}
}
}
a.mu.RUnlock()
log.Printf("anchor: peer left: %s", c.id[:min(8, len(c.id))])
}
// networkPeerIDs returns the hex ids of all peers in the same network as netHash.
func (a *anchor) networkPeerIDs(netHash, excludeID string) []string {
a.mu.RLock()
defer a.mu.RUnlock()
var ids []string
for id, c := range a.clients {
if c.net == netHash && id != excludeID {
ids = append(ids, id)
}
}
return ids
}
func (a *anchor) forwardTo(toID string, msg proto.AnchorMessage) bool {
a.mu.RLock()
c, ok := a.clients[toID]
a.mu.RUnlock()
if !ok {
return false
}
select {
case c.send <- msg:
return true
default:
return false
}
}
// ── WebSocket handler ─────────────────────────────────────────────────────────
func (a *anchor) handleWS(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
log.Printf("anchor: ws accept: %v", err)
return
}
defer conn.Close(websocket.StatusNormalClosure, "bye")
c := &client{
send: make(chan proto.AnchorMessage, 64),
conn: conn,
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Send a challenge nonce immediately.
nonce := make([]byte, 16)
rand.Read(nonce)
nonceHex := hex.EncodeToString(nonce)
if err := wsjson.Write(ctx, conn, proto.AnchorMessage{
Type: proto.AnchorChallenge,
Nonce: nonceHex,
}); err != nil {
return
}
// Writer goroutine.
go func() {
for msg := range c.send {
if err := wsjson.Write(ctx, conn, msg); err != nil {
cancel()
return
}
}
}()
// Read loop.
for {
var msg proto.AnchorMessage
if err := wsjson.Read(ctx, conn, &msg); err != nil {
break
}
switch msg.Type {
case proto.AnchorJoin:
if msg.ID == "" || msg.Net == "" || msg.Sig == "" {
log.Printf("anchor: join missing fields from %s", r.RemoteAddr)
continue
}
pubBytes, err := hex.DecodeString(msg.ID)
if err != nil || len(pubBytes) != ed25519.PublicKeySize {
log.Printf("anchor: join: bad peer id from %s", r.RemoteAddr)
continue
}
sigBytes, err := hex.DecodeString(msg.Sig)
if err != nil {
log.Printf("anchor: join: bad sig from %s", r.RemoteAddr)
continue
}
// §5.1: sig covers nonce_raw || net_ascii (net as 64-char hex UTF-8 string)
signed := append(nonce, []byte(msg.Net)...)
if !ed25519.Verify(ed25519.PublicKey(pubBytes), signed, sigBytes) {
log.Printf("anchor: join: sig verification failed for %s", msg.ID[:min(8, len(msg.ID))])
continue
}
c.id = msg.ID
c.net = msg.Net
a.register(c)
peers := a.networkPeerIDs(msg.Net, c.id)
if err := wsjson.Write(ctx, conn, proto.AnchorMessage{
Type: proto.AnchorJoined,
Peers: peers,
}); err != nil {
break
}
// Notify existing peers that someone new joined.
join := proto.AnchorMessage{Type: proto.AnchorPeerJoin, ID: c.id}
for _, pid := range peers {
a.forwardTo(pid, join)
}
log.Printf("anchor: peer joined: %s net=%s peers=%d", c.id[:min(8, len(c.id))], msg.Net[:8], len(peers))
case proto.AnchorTo:
if c.id == "" {
continue // not joined yet
}
if msg.To == "" || msg.Box == "" {
continue
}
fwd := proto.AnchorMessage{
Type: proto.AnchorFrom,
From: c.id,
Box: msg.Box,
}
if !a.forwardTo(msg.To, fwd) {
select {
case c.send <- proto.AnchorMessage{Type: proto.AnchorNoPeer, ID: msg.To}:
default:
}
}
default:
log.Printf("anchor: unknown message type %q from %s", msg.Type, r.RemoteAddr)
}
}
a.unregister(c)
close(c.send)
}
// hashNetName returns the SHA-256 hash of a plaintext network name.
// The anchor stores only hashed names — it never sees the plaintext.
func hashNetName(name string) string {
h := sha256.Sum256([]byte("yaw2-net:" + name))
return hex.EncodeToString(h[:])
}
// Compile-time check: hashNetName is used by daemons, kept here for reference.
var _ = hashNetName
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Ensure wsjson import is used (avoids accidental drop).
var _ = time.Now