/opt/splicevpn/agent/main.go
// Command splice-agent runs on each endpoint. It generates a WireGuard identity,
// registers with the coordinator, then polls for the full mesh and (re)applies a
// WireGuard config that peers this node with every other node plus the relay hub.
//
// MVP: brings the interface up via the system `wg`/`wg-quick` tools.
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"time"
"splicevpn/proto"
)
type config struct {
Coordinator string
JoinKey string
Name string
Routes []string
Profile string
Iface string
ListenPort int
PrivKey string
PubKey string
}
func main() {
coord := flag.String("coordinator", "", "coordinator base URL, e.g. http://1.2.3.4:7777")
name := flag.String("name", "", "this endpoint's name")
subnet := flag.String("advertise", "", "subnet this endpoint routes for the mesh (blank = host only)")
profile := flag.String("profile", "", "speed | resilience | both")
iface := flag.String("iface", "splice0", "WireGuard interface name")
port := flag.Int("port", 51820, "WireGuard listen port")
flag.Parse()
cfg := config{
Coordinator: *coord,
JoinKey: os.Getenv("SPLICE_JOIN_KEY"),
Name: *name,
Profile: *profile,
Iface: *iface,
ListenPort: *port,
}
if *subnet != "" {
cfg.Routes = []string{*subnet}
}
wizard(&cfg) // fill anything still missing, interactively
priv, pub, err := loadOrCreateKeys(cfg.Iface)
if err != nil {
log.Fatalf("keygen: %v", err)
}
cfg.PrivKey, cfg.PubKey = priv, pub
log.Printf("identity: %s pubkey=%s", cfg.Name, cfg.PubKey)
reg, err := register(cfg)
if err != nil {
log.Fatalf("register: %v", err)
}
log.Printf("joined mesh as %s (overlay %s); relay hub=%s", cfg.Name, reg.OverlayIP, reg.HubEndpoint)
// Poll + reconcile loop. Re-applies WireGuard only when the mesh epoch changes.
var lastEpoch int64 = -1
for {
snap, err := poll(cfg)
if err != nil {
log.Printf("poll: %v (retrying)", err)
time.Sleep(5 * time.Second)
continue
}
if snap.Epoch != lastEpoch {
conf := renderWireGuard(cfg, reg, snap)
if err := applyWireGuard(cfg.Iface, conf); err != nil {
log.Printf("apply: %v", err)
} else {
lastEpoch = snap.Epoch
log.Printf("mesh updated: %d peer(s) connected (epoch %d)", len(snap.Peers), snap.Epoch)
}
}
time.Sleep(15 * time.Second)
}
}
// wizard fills missing fields interactively — the "answer a few questions" flow.
func wizard(cfg *config) {
in := bufio.NewReader(os.Stdin)
ask := func(prompt, cur string) string {
if cur != "" {
return cur
}
fmt.Print(prompt)
s, _ := in.ReadString('\n')
return strings.TrimSpace(s)
}
cfg.Coordinator = ask("Coordinator URL (e.g. http://1.2.3.4:7777): ", cfg.Coordinator)
cfg.JoinKey = ask("Join key: ", cfg.JoinKey)
host, _ := os.Hostname()
cfg.Name = ask(fmt.Sprintf("Name [%s]: ", host), cfg.Name)
if cfg.Name == "" {
cfg.Name = host
}
if len(cfg.Routes) == 0 {
ans := ask("Advertise a subnet for this site? (blank = just this host): ", "")
if ans != "" {
cfg.Routes = []string{ans}
}
}
cfg.Profile = ask("Optimise for [speed/resilience/both]: ", cfg.Profile)
if cfg.Profile == "" {
cfg.Profile = "both"
}
}
// loadOrCreateKeys returns a stable WireGuard keypair, generated via the `wg` tools
// on first run and persisted so the node keeps its identity (and overlay IP) across
// restarts. Uses `wg` rather than a crypto library because the agent already requires
// the WireGuard tools, and this is the canonical key format.
func loadOrCreateKeys(iface string) (priv, pub string, err error) {
keyPath := fmt.Sprintf("/etc/wireguard/%s.key", iface)
if b, e := os.ReadFile(keyPath); e == nil {
priv = strings.TrimSpace(string(b))
} else {
out, e := exec.Command("wg", "genkey").Output()
if e != nil {
return "", "", fmt.Errorf("wg genkey: %w", e)
}
priv = strings.TrimSpace(string(out))
if e := os.WriteFile(keyPath, []byte(priv+"\n"), 0600); e != nil {
return "", "", e
}
}
cmd := exec.Command("wg", "pubkey")
cmd.Stdin = strings.NewReader(priv + "\n")
out, e := cmd.Output()
if e != nil {
return "", "", fmt.Errorf("wg pubkey: %w", e)
}
return priv, strings.TrimSpace(string(out)), nil
}
func localEndpoints(port int) []string {
var out []string
addrs, _ := net.InterfaceAddrs()
for _, a := range addrs {
ipn, ok := a.(*net.IPNet)
if !ok || ipn.IP.IsLoopback() || ipn.IP.To4() == nil {
continue
}
out = append(out, net.JoinHostPort(ipn.IP.String(), fmt.Sprint(port)))
}
return out
}
func register(cfg config) (proto.RegisterResponse, error) {
req := proto.RegisterRequest{
JoinKey: cfg.JoinKey,
Name: cfg.Name,
PublicKey: cfg.PubKey,
Endpoints: localEndpoints(cfg.ListenPort),
Routes: cfg.Routes,
ListenPort: cfg.ListenPort,
Profile: cfg.Profile,
}
var resp proto.RegisterResponse
body, _ := json.Marshal(req)
r, err := http.Post(cfg.Coordinator+"/register", "application/json", bytes.NewReader(body))
if err != nil {
return resp, err
}
defer r.Body.Close()
if r.StatusCode != 200 {
return resp, fmt.Errorf("coordinator returned %s", r.Status)
}
return resp, json.NewDecoder(r.Body).Decode(&resp)
}
func poll(cfg config) (proto.MeshSnapshot, error) {
var snap proto.MeshSnapshot
r, err := http.Get(cfg.Coordinator + "/poll?pubkey=" + url.QueryEscape(cfg.PubKey))
if err != nil {
return snap, err
}
defer r.Body.Close()
if r.StatusCode != 200 {
return snap, fmt.Errorf("poll returned %s", r.Status)
}
return snap, json.NewDecoder(r.Body).Decode(&snap)
}
// applyWireGuard writes the config and (re)brings the interface up via wg-quick.
// MVP uses a down/up cycle on change for simplicity; production should `wg syncconf`
// to avoid the momentary blip.
func applyWireGuard(iface, conf string) error {
path := fmt.Sprintf("/etc/wireguard/%s.conf", iface)
if err := os.WriteFile(path, []byte(conf), 0600); err != nil {
return err
}
_ = exec.Command("wg-quick", "down", iface).Run() // ignore: may not be up yet
out, err := exec.Command("wg-quick", "up", iface).CombinedOutput()
if err != nil {
return fmt.Errorf("wg-quick up: %v: %s", err, out)
}
return nil
}