Files
waste-go/cmd/anchor/main.go
Fredrik Johansson 586d39e3b8 Anchor: add EXT-009 scoped presence query
Lets a client ask "is peer X currently online in network Y?" without
joining that network first — a stateless O(1) lookup against the
anchor's existing client registry. Scoped to (net, id) rather than a
global lookup by id alone, so it can't be used as a cross-network
"is this pubkey online anywhere" oracle: the caller must already know
a network the peer belongs to, matching the knowledge already required
to join it and observe presence the slow way via peer-join/peer-leave.

Motivated by flit's known-devices list, where each pairing is its own
isolated anchor network and the app holds no persistent connection
while idle — today the only way to know if a device is reachable is
to attempt a full connect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:06:11 +02:00

325 lines
8.6 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
// EXT-009 presence_query rate limiting. Only ever touched from this
// connection's own read-loop goroutine, so no lock needed.
presenceQueryCount int
presenceWindowStart time.Time
}
// allowPresenceQuery implements a simple per-connection token bucket for
// EXT-009 presence_query: presenceQueryLimit requests per presenceQueryWindow.
func (c *client) allowPresenceQuery() bool {
now := time.Now()
if now.Sub(c.presenceWindowStart) > presenceQueryWindow {
c.presenceWindowStart = now
c.presenceQueryCount = 0
}
c.presenceQueryCount++
return c.presenceQueryCount <= presenceQueryLimit
}
const (
presenceQueryLimit = 20
presenceQueryWindow = 10 * time.Second
)
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))])
}
// isOnline reports whether a client is currently registered with the exact
// (net, id) pair — EXT-009. O(1): reuses the existing global clients map,
// no new state.
func (a *anchor) isOnline(net, id string) bool {
a.mu.RLock()
defer a.mu.RUnlock()
c, ok := a.clients[id]
return ok && c.net == net
}
// 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. §5.1 requires 32 bytes.
nonce := make([]byte, 32)
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.AnchorPresenceQuery:
// EXT-009. Stateless and anchor-local: does not require the
// querying connection to have joined any network. Scoped to
// (net, id) — never a global "is this pubkey online anywhere"
// lookup, to avoid a cross-network presence oracle.
if msg.Net == "" || msg.ID == "" {
continue
}
if !c.allowPresenceQuery() {
continue
}
online := a.isOnline(msg.Net, msg.ID)
resp := proto.AnchorMessage{Type: proto.AnchorPresence, Net: msg.Net, ID: msg.ID, Online: &online}
select {
case c.send <- resp:
default:
}
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