/opt/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: [REDACTED] // MVP: token =[REDACTED] pubkey; replace with real token
}
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 /=[REDACTED]