From db78964033bda9b29a2e6e8497f2f12e6670d4eb Mon Sep 17 00:00:00 2001 From: jdl Date: Wed, 10 Jun 2026 18:52:11 +0200 Subject: [PATCH] WIP --- cmd/vppn/main.go | 3 +- hub/api/db/sanitize-validate.go | 5 +- hub/handlers.go | 15 +-- m/models.go | 4 +- peer/app.go | 20 ++-- peer/device.go | 6 ++ peer/hosts.go | 121 +++++++++++++++++++++++ peer/hosts_test.go | 167 ++++++++++++++++++++++++++++++++ peer/hub_poller.go | 1 + peer/init.go | 75 +++++++------- peer/new.go | 14 +-- peer/on_hub.go | 3 + peer/remote.go | 1 + 13 files changed, 373 insertions(+), 62 deletions(-) create mode 100644 peer/hosts.go create mode 100644 peer/hosts_test.go diff --git a/cmd/vppn/main.go b/cmd/vppn/main.go index 8dd105f..8af56a3 100644 --- a/cmd/vppn/main.go +++ b/cmd/vppn/main.go @@ -44,7 +44,8 @@ func main() { log.Fatalf("init: %v", err) } - app, err := peer.New(state, *hub, apiKey, *name) + ifaceName := strings.TrimSuffix(state.LocalDomain, ".local") + app, err := peer.New(state, *hub, apiKey, ifaceName, state.LocalDomain) if err != nil { log.Fatalf("start: %v", err) } diff --git a/hub/api/db/sanitize-validate.go b/hub/api/db/sanitize-validate.go index 0c3826f..2782bf5 100644 --- a/hub/api/db/sanitize-validate.go +++ b/hub/api/db/sanitize-validate.go @@ -95,6 +95,9 @@ func Peer_Validate(p *Peer) error { return ErrInvalidPort } + if len(p.Name) == 0 { + return ErrInvalidPeerName + } for _, c := range p.Name { if c >= 'a' && c <= 'z' { continue @@ -102,7 +105,7 @@ func Peer_Validate(p *Peer) error { if c >= '0' && c <= '9' { continue } - if c == '.' || c == '-' || c == '_' { + if c == '-' { continue } return ErrInvalidPeerName diff --git a/hub/handlers.go b/hub/handlers.go index 4f125d8..ffa4095 100644 --- a/hub/handlers.go +++ b/hub/handlers.go @@ -332,8 +332,9 @@ func (a *App) _peerInit(peer *api.Peer, w http.ResponseWriter, r *http.Request) } resp := m.PeerInitResp{ - PeerIP: peer.PeerIP, - Network: net.Network, + PeerIP: peer.PeerIP, + Network: net.Network, + LocalDomain: net.LocalDomain, } resp.NetworkState.Peers, err = a.peersArray(net.NetworkID) @@ -345,19 +346,11 @@ func (a *App) _peerInit(peer *api.Peer, w http.ResponseWriter, r *http.Request) } func (a *App) _peerFetchState(peer *api.Peer, w http.ResponseWriter, r *http.Request) error { - net, err := a.api.Network_Get(peer.NetworkID) - if err != nil { - return err - } - peers, err := a.peersArray(peer.NetworkID) if err != nil { return err } - return a.sendJSON(w, m.NetworkState{ - LocalDomain: net.LocalDomain, - Peers: peers, - }) + return a.sendJSON(w, m.NetworkState{Peers: peers}) } func (a *App) peersArray(networkID int64) (peers [256]*m.Peer, err error) { diff --git a/m/models.go b/m/models.go index a10ebfe..232c87e 100644 --- a/m/models.go +++ b/m/models.go @@ -9,6 +9,7 @@ type PeerInitArgs struct { type PeerInitResp struct { PeerIP byte Network []byte + LocalDomain string NetworkState NetworkState } @@ -25,6 +26,5 @@ type Peer struct { } type NetworkState struct { - LocalDomain string - Peers [256]*Peer + Peers [256]*Peer } diff --git a/peer/app.go b/peer/app.go index 3c69a14..0042ec1 100644 --- a/peer/app.go +++ b/peer/app.go @@ -25,6 +25,7 @@ const ( type HubPeer struct { PubKey wgtypes.Key VPNIP netip.Addr + Name string IsRelay bool IsPublic bool EndpointV4 netip.AddrPort // zero if none @@ -47,12 +48,13 @@ type MulticastEvent struct { // accessed only from the Run goroutine. type App struct { // Identity - vpnIP netip.Addr - vpnNet netip.Prefix - privKey wgtypes.Key - pubKey wgtypes.Key - isRelay bool - isPublic bool + vpnIP netip.Addr + vpnNet netip.Prefix + privKey wgtypes.Key + pubKey wgtypes.Key + isRelay bool + isPublic bool + localDomain string // Infrastructure dev WGDevice @@ -76,6 +78,11 @@ type App struct { // Run is the main event loop. It runs until SIGTERM/SIGINT. func (a *App) Run() error { + // Establish a clean hosts section before the first poll lands, clearing + // any stale entries left by a prior run (e.g. crash, or peers removed + // while we were down). + a.updateHosts() + ticker := time.NewTicker(PingInterval) defer ticker.Stop() @@ -102,5 +109,6 @@ func (a *App) Run() error { } func (a *App) onShutdown() error { + // TODO: removeHosts() ? return wginterface.Delete(a.dev.Name()) } diff --git a/peer/device.go b/peer/device.go index 6630813..61765c5 100644 --- a/peer/device.go +++ b/peer/device.go @@ -38,30 +38,36 @@ func (a *App) devPeers() []wgtypes.Peer { } func (a *App) devAddPeer(p *Peer) { + log.Printf("RELAYED: %s - %s ", p.Name, p.VPNIP.String()) devRetry(p.VPNIP, "AddPeer", func() error { return a.dev.AddPeer(p.PubKey()) }) p.State = StateRelayed } func (a *App) devAddDirect(p *Peer, endpoint netip.AddrPort) { + log.Printf("DIRECT: %s - %s @ %s", p.Name, p.VPNIP.String(), endpoint.String()) devRetry(p.VPNIP, "AddDirect", func() error { return a.dev.AddDirect(p.PubKey(), endpoint, p.VPNIP) }) p.State = StateDirect } func (a *App) devSetRelay(p *Peer, endpoint netip.AddrPort) { + log.Printf("RELAY: %s - %s @ %s", p.Name, p.VPNIP.String(), endpoint.String()) devRetry(p.VPNIP, "SetRelay", func() error { return a.dev.SetRelay(p.PubKey(), endpoint, a.vpnNet) }) p.State = StateDirect } func (a *App) devPromote(p *Peer) { + log.Printf("PROMOTED: %s - %s @ %s", p.Name, p.VPNIP.String(), p.WGEndpoint().String()) devRetry(p.VPNIP, "Promote", func() error { return a.dev.Promote(p.PubKey(), p.VPNIP) }) p.State = StateDirect } func (a *App) devAddProbe(p *Peer, endpoint netip.AddrPort) { + log.Printf("PROBE: %s - %s @ %s", p.Name, p.VPNIP.String(), endpoint.String()) devRetry(p.VPNIP, "AddProbe", func() error { return a.dev.AddProbe(p.PubKey(), endpoint) }) p.State = StateProbing } func (a *App) devRemove(p *Peer) { + log.Printf("REMOVED: %s - %s", p.Name, p.VPNIP.String()) devRetry(p.VPNIP, "RemovePeer", func() error { return a.dev.RemovePeer(p.PubKey()) }) } diff --git a/peer/hosts.go b/peer/hosts.go new file mode 100644 index 0000000..2423d7f --- /dev/null +++ b/peer/hosts.go @@ -0,0 +1,121 @@ +package peer + +import ( + "fmt" + "log" + "net/netip" + "os" + "sort" + "strings" + "syscall" + + "git.crumpington.com/lib/go/flock" +) + +const ( + hostsFile = "/etc/hosts" + hostsBegin = "# BEGIN vppn " + hostsEnd = "# END vppn " +) + +// updateHosts rewrites the managed vppn section in /etc/hosts using the +// current peersByIP map. Peers without a Name are skipped. +func (a *App) updateHosts() { + if a.localDomain == "" { + return + } + if err := updateHosts(hostsFile, a.localDomain, a.peersByIP); err != nil { + log.Printf("Failed to update hosts file: %v", err) + } +} + +func updateHosts(hostsPath, localDomain string, peers map[netip.Addr]*Peer) error { + lockFile, err := flock.Lock(hostsPath + ".vppn.lock") + if err != nil { + return err + } + defer lockFile.Close() + + begin := hostsBegin + localDomain + end := hostsEnd + localDomain + + info, err := os.Stat(hostsPath) + if err != nil { + return err + } + + raw, err := os.ReadFile(hostsPath) + if err != nil { + return err + } + + data := string(raw) + + before := strings.TrimSpace(data) + after := "" + + if idxBegin := strings.Index(data, begin); idxBegin != -1 { + idxEnd := strings.Index(data, end) + if idxEnd != -1 { + after = strings.TrimSpace(data[idxEnd+len(end):]) + } + before = strings.TrimSpace(data[:idxBegin]) + } + + b := strings.Builder{} + b.WriteString(before) + b.WriteRune('\n') + b.WriteString(after) + b.WriteRune('\n') + b.WriteRune('\n') + + b.WriteString(begin) + b.WriteRune('\n') + + // Collect entries so we can sort by IP for stable output. Pad the IP + // column to the width of the widest possible address ("255.255.255.255") + // for readability. + type entry struct { + ip netip.Addr + host string + } + var entries []entry + for ip, p := range peers { + if p.Name == "" { + continue + } + entries = append(entries, entry{ip: ip, host: p.Name + "." + localDomain}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].ip.Less(entries[j].ip) + }) + + for _, e := range entries { + b.WriteString(fmt.Sprintf("%-15s %s\n", e.ip.String(), e.host)) + } + + b.WriteString(end) + b.WriteRune('\n') + + // Write to a temp file in the same directory, then rename over the + // original so readers never observe a partial file. Preserve the + // original's mode and ownership, since rename replaces the inode. + tmpPath := hostsPath + ".vppn.tmp" + if err := os.WriteFile(tmpPath, []byte(b.String()), info.Mode().Perm()); err != nil { + return err + } + + if st, ok := info.Sys().(*syscall.Stat_t); ok { + if err := os.Chown(tmpPath, int(st.Uid), int(st.Gid)); err != nil { + os.Remove(tmpPath) + return err + } + } + + if err := os.Rename(tmpPath, hostsPath); err != nil { + os.Remove(tmpPath) + return err + } + + return nil +} diff --git a/peer/hosts_test.go b/peer/hosts_test.go new file mode 100644 index 0000000..c7fc4e4 --- /dev/null +++ b/peer/hosts_test.go @@ -0,0 +1,167 @@ +package peer + +import ( + "net/netip" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// writeTempHosts creates a temp hosts file with the given content and returns +// its path. +func writeTempHosts(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + return path +} + +// readManagedSection returns the lines between the begin/end markers for the +// given localDomain, plus everything outside the section ("outside"). +func readManagedSection(t *testing.T, path, localDomain string) (inside, outside []string) { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + begin := hostsBegin + localDomain + end := hostsEnd + localDomain + + inSection := false + for _, line := range strings.Split(string(raw), "\n") { + switch { + case strings.HasPrefix(line, begin): + inSection = true + case strings.HasPrefix(line, end): + inSection = false + case inSection: + if f := strings.Join(strings.Fields(line), " "); f != "" { + inside = append(inside, f) + } + default: + if f := strings.Join(strings.Fields(line), " "); f != "" { + outside = append(outside, f) + } + } + } + return inside, outside +} + +func peer(name string) *Peer { + return &Peer{Name: name} +} + +func TestUpdateHosts_AddsSection(t *testing.T) { + path := writeTempHosts(t, "127.0.0.1 localhost\n") + + peers := map[netip.Addr]*Peer{ + netip.MustParseAddr("10.11.12.1"): peer("hub"), + netip.MustParseAddr("10.11.12.10"): peer("laptop"), + } + + if err := updateHosts(path, "mynet.local", peers); err != nil { + t.Fatal(err) + } + + inside, outside := readManagedSection(t, path, "mynet.local") + + sort.Strings(inside) + want := []string{ + "10.11.12.1 hub.mynet.local", + "10.11.12.10 laptop.mynet.local", + } + if strings.Join(inside, "\n") != strings.Join(want, "\n") { + t.Errorf("managed section = %v, want %v", inside, want) + } + + if !contains(outside, "127.0.0.1 localhost") { + t.Errorf("original content lost; outside = %v", outside) + } +} + +func TestUpdateHosts_ReplacesExistingSection(t *testing.T) { + path := writeTempHosts(t, "127.0.0.1 localhost\n") + + // First write. + first := map[netip.Addr]*Peer{ + netip.MustParseAddr("10.11.12.1"): peer("hub"), + } + if err := updateHosts(path, "mynet.local", first); err != nil { + t.Fatal(err) + } + + // Second write with a different set of peers. + second := map[netip.Addr]*Peer{ + netip.MustParseAddr("10.11.12.20"): peer("phone"), + } + if err := updateHosts(path, "mynet.local", second); err != nil { + t.Fatal(err) + } + + inside, outside := readManagedSection(t, path, "mynet.local") + + if len(inside) != 1 || inside[0] != "10.11.12.20 phone.mynet.local" { + t.Errorf("section not replaced; inside = %v", inside) + } + if contains(inside, "10.11.12.1 hub.mynet.local") { + t.Errorf("stale entry remained; inside = %v", inside) + } + if !contains(outside, "127.0.0.1 localhost") { + t.Errorf("original content lost; outside = %v", outside) + } +} + +func TestUpdateHosts_SkipsEmptyNames(t *testing.T) { + path := writeTempHosts(t, "127.0.0.1 localhost\n") + + peers := map[netip.Addr]*Peer{ + netip.MustParseAddr("10.11.12.1"): peer("hub"), + netip.MustParseAddr("10.11.12.99"): peer(""), // no name + } + if err := updateHosts(path, "mynet.local", peers); err != nil { + t.Fatal(err) + } + + inside, _ := readManagedSection(t, path, "mynet.local") + if len(inside) != 1 || inside[0] != "10.11.12.1 hub.mynet.local" { + t.Errorf("expected only named peer; inside = %v", inside) + } +} + +func TestUpdateHosts_Idempotent(t *testing.T) { + path := writeTempHosts(t, "127.0.0.1 localhost\n") + + peers := map[netip.Addr]*Peer{ + netip.MustParseAddr("10.11.12.1"): peer("hub"), + } + if err := updateHosts(path, "mynet.local", peers); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := updateHosts(path, "mynet.local", peers); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Errorf("repeated update changed file:\nfirst:\n%s\nsecond:\n%s", first, second) + } +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} diff --git a/peer/hub_poller.go b/peer/hub_poller.go index edfc6b9..e16d902 100644 --- a/peer/hub_poller.go +++ b/peer/hub_poller.go @@ -149,6 +149,7 @@ func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p *m.Peer) HubPeer { return HubPeer{ PubKey: pubKey, VPNIP: vpnIP, + Name: p.Name, IsRelay: p.Relay, IsPublic: ep4.IsValid() || ep6.IsValid(), EndpointV4: ep4, diff --git a/peer/init.go b/peer/init.go index 9d5fb9f..c514998 100644 --- a/peer/init.go +++ b/peer/init.go @@ -20,24 +20,26 @@ import ( // LocalState is the persisted identity for this peer, written on first run and // loaded on every subsequent run. type LocalState struct { - PrivKey wgtypes.Key - SignKey [64]byte // nacl/sign Ed25519 private key - VPNIP netip.Addr - VPNNet netip.Prefix - WGPort uint16 - IsRelay bool - IsPublic bool + PrivKey wgtypes.Key + SignKey [64]byte // nacl/sign Ed25519 private key + VPNIP netip.Addr + VPNNet netip.Prefix + WGPort uint16 + IsRelay bool + IsPublic bool + LocalDomain string } // localStateJSON is the on-disk representation. type localStateJSON struct { - PrivKey string `json:"priv_key"` // standard base64 - SignKey string `json:"sign_key"` // standard base64 - VPNIP netip.Addr `json:"vpn_ip"` - VPNNet netip.Prefix `json:"vpn_net"` - WGPort uint16 `json:"wg_port"` - IsRelay bool `json:"is_relay"` - IsPublic bool `json:"is_public"` + PrivKey string `json:"priv_key"` // standard base64 + SignKey string `json:"sign_key"` // standard base64 + VPNIP netip.Addr `json:"vpn_ip"` + VPNNet netip.Prefix `json:"vpn_net"` + WGPort uint16 `json:"wg_port"` + IsRelay bool `json:"is_relay"` + IsPublic bool `json:"is_public"` + LocalDomain string `json:"local_domain"` } // LoadOrInit loads LocalState from path, or registers with the hub and creates @@ -117,13 +119,14 @@ func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error) } return LocalState{ - PrivKey: privKey, - SignKey: *signPrivKey, - VPNIP: vpnIP, - VPNNet: vpnNet, - WGPort: wgPort, - IsRelay: isRelay, - IsPublic: isPublic, + PrivKey: privKey, + SignKey: *signPrivKey, + VPNIP: vpnIP, + VPNNet: vpnNet, + WGPort: wgPort, + IsRelay: isRelay, + IsPublic: isPublic, + LocalDomain: r.LocalDomain, }, nil } @@ -150,25 +153,27 @@ func parseLocalState(data []byte) (LocalState, error) { var signKey [64]byte copy(signKey[:], signKeyBytes) return LocalState{ - PrivKey: key, - SignKey: signKey, - VPNIP: j.VPNIP, - VPNNet: j.VPNNet, - WGPort: j.WGPort, - IsRelay: j.IsRelay, - IsPublic: j.IsPublic, + PrivKey: key, + SignKey: signKey, + VPNIP: j.VPNIP, + VPNNet: j.VPNNet, + WGPort: j.WGPort, + IsRelay: j.IsRelay, + IsPublic: j.IsPublic, + LocalDomain: j.LocalDomain, }, nil } func saveLocalState(path string, s LocalState) error { j := localStateJSON{ - PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]), - SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]), - VPNIP: s.VPNIP, - VPNNet: s.VPNNet, - WGPort: s.WGPort, - IsRelay: s.IsRelay, - IsPublic: s.IsPublic, + PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]), + SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]), + VPNIP: s.VPNIP, + VPNNet: s.VPNNet, + WGPort: s.WGPort, + IsRelay: s.IsRelay, + IsPublic: s.IsPublic, + LocalDomain: s.LocalDomain, } data, err := json.MarshalIndent(j, "", " ") if err != nil { diff --git a/peer/new.go b/peer/new.go index 8795e37..4d756b7 100644 --- a/peer/new.go +++ b/peer/new.go @@ -16,6 +16,7 @@ func New( state LocalState, hubURL, apiKey string, ifaceName string, + localDomain string, ) (*App, error) { a4 := state.VPNIP.As4() @@ -65,12 +66,13 @@ func New( } return &App{ - vpnIP: state.VPNIP, - vpnNet: state.VPNNet, - privKey: state.PrivKey, - pubKey: state.PrivKey.PublicKey(), - isRelay: state.IsRelay, - isPublic: state.IsPublic, + vpnIP: state.VPNIP, + vpnNet: state.VPNNet, + privKey: state.PrivKey, + pubKey: state.PrivKey.PublicKey(), + isRelay: state.IsRelay, + isPublic: state.IsPublic, + localDomain: localDomain, dev: dev, controlConn: cc, diff --git a/peer/on_hub.go b/peer/on_hub.go index 59ba67e..02b11e4 100644 --- a/peer/on_hub.go +++ b/peer/on_hub.go @@ -17,6 +17,7 @@ func (a *App) onAddPeer(p HubPeer) { peer := &Peer{ wgPeer: wgtypes.Peer{PublicKey: p.PubKey}, VPNIP: p.VPNIP, + Name: p.Name, IsRelay: p.IsRelay, IsPublic: p.IsPublic, Endpoint4: p.EndpointV4, @@ -35,6 +36,7 @@ func (a *App) onAddPeer(p HubPeer) { a.peersByKey[p.PubKey] = peer a.peersByIP[peer.VPNIP] = peer + defer a.updateHosts() if !peer.IsPublic { if a.isPublic { @@ -59,6 +61,7 @@ func (a *App) onRemovePeer(key wgtypes.Key) { a.devRemove(peer) delete(a.peersByKey, key) delete(a.peersByIP, peer.VPNIP) + a.updateHosts() if peer == a.relay { a.relay = nil diff --git a/peer/remote.go b/peer/remote.go index 0e8924a..6ef7bbe 100644 --- a/peer/remote.go +++ b/peer/remote.go @@ -21,6 +21,7 @@ const ( type Peer struct { wgPeer wgtypes.Peer VPNIP netip.Addr // VPN IP address. + Name string // Human-readable DNS label. IsRelay bool // Peer is a relay. IsPublic bool // Peer has a public IP. Endpoint4 netip.AddrPort // Reported IPv4 endpoint.