Preview — /tmp/sv-test/splicevpn/coordinator/main.go

/tmp/sv-test/splicevpn/coordinator/main.go

// Command splice-coordinator is the SpliceVPN control plane and relay hub — the
// "gateway point we supply". Agents register here, are assigned an overlay IP, and
// poll for the full mesh peer list. The coordinator also advertises a WireGuard hub
// that every node peers with as a guaranteed relay fallback.
//
// MVP: in-memory state, shared-join-key auth only. Not production-hardened.
package main

import (
	"encoding/json"
	"flag"
	"log"
	"net"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	"splicevpn/proto"
)

type node struct {
	name       string
	pubKey     string
	overlayIP  net.IP
	endpoints  []string // candidate ip:port (agent-reported + observed public)
	routes     []string
	listenPort int
	profile    string
	lastSeen   time.Time
}

type coordinator struct {
	mu       sync.Mutex
	nodes    map[string]*node // keyed by WireGuard public key
	alloc    *ipAllocator
	epoch    int64
	joinKey  string
	overlay  *net.IPNet
	hubKey   string
	hubEndpt string
}

func main() {
	listen := flag.String("listen", ":7777", "HTTP listen address")
	overlay := flag.String("overlay", "100.96.0.0/16", "overlay address range")
	hubEndpoint := flag.String("hub-endpoint", "", "public ip:port of the WireGuard relay hub")
	hubKey := flag.String("hub-pubkey", "", "WireGuard public key of the relay hub")
	flag.Parse()

	joinKey := os.Getenv("SPLICE_JOIN_KEY")
	if joinKey == "" {
		log.Fatal("SPLICE_JOIN_KEY must be set")
	}
	_, cidr, err := net.ParseCIDR(*overlay)
	if err != nil {
		log.Fatalf("bad overlay cidr: %v", err)
	}

	c := &coordinator{
		nodes:    make(map[string]*node),
		alloc:    newIPAllocator(cidr),
		joinKey:  joinKey,
		overlay:  cidr,
		hubKey:   *hubKey,
		hubEndpt: *hubEndpoint,
	}
	// Reserve .1 for the hub itself.
	c.alloc.reserve(c.alloc.next())

	mux := http.NewServeMux()
	mux.HandleFunc("/register", c.handleRegister)
	mux.HandleFunc("/poll", c.handlePoll)
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })

	go c.reaper()

	log.Printf("splice-coordinator listening on %s, overlay %s, hub %s", *listen, *overlay, *hubEndpoint)
	log.Fatal(http.ListenAndServe(*listen, mux))
}

func (c *coordinator) handleRegister(w http.ResponseWriter, r *http.Request) {
	var req proto.RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if req.JoinKey != c.joinKey {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	if req.PublicKey == "" {
		http.Error(w, "missing public_key", http.StatusBadRequest)
		return
	}

	// STUN-lite: record the public source ip:port we observed this request from,
	// so peers can try reaching this node at its public mapping.
	observed := observedEndpoint(r, req.ListenPort)

	c.mu.Lock()
	defer c.mu.Unlock()

	n, existing := c.nodes[req.PublicKey]
	if !existing {
		ip := c.alloc.next()
		if ip == nil {
			c.mu.Unlock()
			http.Error(w, "overlay exhausted", http.StatusServiceUnavailable)
			return
		}
		n = &node{pubKey: req.PublicKey, overlayIP: ip}
		c.nodes[req.PublicKey] = n
		c.epoch++
		log.Printf("join: %s -> %s", req.Name, ip)
	}
	n.name = req.Name
	n.endpoints = mergeEndpoints(req.Endpoints, observed)
	n.routes = req.Routes
	n.listenPort = req.ListenPort
	n.profile = req.Profile
	n.lastSeen = time.Now()

	resp := proto.RegisterResponse{
		OverlayIP:    n.overlayIP.String() + "/32",
		OverlayCIDR:  c.overlay.String(),
		HubPublicKey: c.hubKey,
		HubEndpoint:  c.hubEndpt,
		Token:        req.PublicKey, // MVP: token =[REDACTED]
	}
	writeJSON(w, resp)
}

func (c *coordinator) handlePoll(w http.ResponseWriter, r *http.Request) {
	self := r.URL.Query().Get("pubkey")

	c.mu.Lock()
	defer c.mu.Unlock()

	me, ok := c.nodes[self]
	if !ok {
		http.Error(w, "unknown node; re-register", http.StatusNotFound)
		return
	}
	me.lastSeen = time.Now()

	snap := proto.MeshSnapshot{
		Self:          c.toPeer(me),
		Epoch:         c.epoch,
		HubAllowedIPs: []string{c.overlay.String()}, // catch-all via the relay
	}
	for k, n := range c.nodes {
		if k == self {
			continue
		}
		snap.Peers = append(snap.Peers, c.toPeer(n)) // every other node => full mesh
	}
	writeJSON(w, snap)
}

func (c *coordinator) toPeer(n *node) proto.Peer {
	allowed := []string{n.overlayIP.String() + "/32"}
	allowed = append(allowed, n.routes...)
	return proto.Peer{
		Name:       n.name,
		PublicKey:  n.pubKey,
		OverlayIP:  n.overlayIP.String() + "/32",
		Endpoints:  n.endpoints,
		AllowedIPs: allowed,
	}
}

// reaper drops nodes that stopped polling, so the mesh self-heals.
func (c *coordinator) reaper() {
	t := time.NewTicker(30 * time.Second)
	for range t.C {
		c.mu.Lock()
		for k, n := range c.nodes {
			if time.Since(n.lastSeen) > 3*time.Minute {
				delete(c.nodes, k)
				c.alloc.release(n.overlayIP)
				c.epoch++
				log.Printf("reap: %s (%s) idle", n.name, n.overlayIP)
			}
		}
		c.mu.Unlock()
	}
}

func observedEndpoint(r *http.Request, port int) string {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil || host == "" || port == 0 {
		return ""
	}
	return net.JoinHostPort(host, itoa(port))
}

func mergeEndpoints(reported []string, observed string) []string {
	seen := map[string]bool{}
	var out []string
	add := func(e string) {
		e = strings.TrimSpace(e)
		if e == "" || seen[e] {
			return
		}
		seen[e] = true
		out = append(out, e)
	}
	add(observed) // public mapping first — most likely reachable across NAT
	for _, e := range reported {
		add(e)
	}
	return out
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(v)
}

func itoa(i int) string {
	if i == 0 {
		return "0"
	}
	var b [20]byte
	pos := len(b)
	for i > 0 {
		pos--
		b[pos] = byte('0' + i%10)
		i /= 10
	}
	return string(b[pos:])
}