Preview — /opt/splicevpn/agent/main.go

/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
}