From f89f5d508330d500fa87a874600ab14539c03e5e Mon Sep 17 00:00:00 2001 From: jdl Date: Wed, 10 Jun 2026 19:17:55 +0200 Subject: [PATCH] WIP --- cmd/vppn/main.go | 2 +- peer/hub_poller.go | 28 +++++++++- peer/hub_poller_test.go | 99 ++++++++++++++++++++++++++++++++ peer/init.go | 112 ++++++++++++++++++------------------- peer/network_state.go | 18 ++++++ peer/network_state_test.go | 54 ++++++++++++++++++ peer/new.go | 12 ++-- 7 files changed, 260 insertions(+), 65 deletions(-) create mode 100644 peer/hub_poller_test.go create mode 100644 peer/network_state.go create mode 100644 peer/network_state_test.go diff --git a/cmd/vppn/main.go b/cmd/vppn/main.go index 8af56a3..d0e5be0 100644 --- a/cmd/vppn/main.go +++ b/cmd/vppn/main.go @@ -45,7 +45,7 @@ func main() { } ifaceName := strings.TrimSuffix(state.LocalDomain, ".local") - app, err := peer.New(state, *hub, apiKey, ifaceName, state.LocalDomain) + app, err := peer.New(state, *hub, apiKey, ifaceName, state.LocalDomain, vppnPath(*name, "network.json")) if err != nil { log.Fatalf("start: %v", err) } diff --git a/peer/hub_poller.go b/peer/hub_poller.go index e16d902..ab0783b 100644 --- a/peer/hub_poller.go +++ b/peer/hub_poller.go @@ -21,6 +21,7 @@ type HubPoller struct { vpnNet netip.Prefix hubURL string apiKey string + statePath string // where the network state cache is persisted addCh chan<- HubPeer removeCh chan<- wgtypes.Key known map[wgtypes.Key]int64 // pubKey → last seen version @@ -30,6 +31,7 @@ func NewHubPoller( selfVPNIP netip.Addr, vpnNet netip.Prefix, hubURL, apiKey string, + statePath string, addCh chan<- HubPeer, removeCh chan<- wgtypes.Key, ) (*HubPoller, error) { @@ -44,6 +46,7 @@ func NewHubPoller( vpnNet: vpnNet, hubURL: u.String(), apiKey: apiKey, + statePath: statePath, addCh: addCh, removeCh: removeCh, known: make(map[wgtypes.Key]int64), @@ -51,6 +54,14 @@ func NewHubPoller( } func (hp *HubPoller) Run() { + // Prime from the on-disk cache before reaching the hub, so the peer + // configures WireGuard from its last known state even if the hub is down. + // known starts empty, so this emits every cached peer as an add and seeds + // the version map; the first real poll then emits only deltas. + if state, err := loadNetworkState(hp.statePath); err == nil { + hp.apply(state) + } + hp.poll() for range time.Tick(hubPollInterval) { hp.poll() @@ -90,10 +101,19 @@ func (hp *HubPoller) poll() { return } - hp.apply(state) + // Persist only when the state actually changed, to avoid needless writes + // on every poll. + if hp.apply(state) { + if err := saveNetworkState(hp.statePath, state); err != nil { + log.Printf("[HubPoller] save state: %v", err) + } + } } -func (hp *HubPoller) apply(state m.NetworkState) { +// apply diffs state against the known versions, emitting add events for new or +// changed peers and remove events for peers that disappeared. It returns true +// if anything changed. +func (hp *HubPoller) apply(state m.NetworkState) (changed bool) { seen := make(map[wgtypes.Key]struct{}, len(hp.known)) netAddr := hp.vpnNet.Addr().As4() @@ -122,14 +142,18 @@ func (hp *HubPoller) apply(state m.NetworkState) { } hp.known[pubKey] = p.Version hp.addCh <- hubPeerFrom(pubKey, vpnIP, p) + changed = true } for key := range hp.known { if _, ok := seen[key]; !ok { delete(hp.known, key) hp.removeCh <- key + changed = true } } + + return changed } func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p *m.Peer) HubPeer { diff --git a/peer/hub_poller_test.go b/peer/hub_poller_test.go new file mode 100644 index 0000000..b19565f --- /dev/null +++ b/peer/hub_poller_test.go @@ -0,0 +1,99 @@ +package peer + +import ( + "net/netip" + "testing" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "vppn/m" +) + +func testPoller(t *testing.T) (*HubPoller, chan HubPeer, chan wgtypes.Key) { + t.Helper() + addCh := make(chan HubPeer, 8) + removeCh := make(chan wgtypes.Key, 8) + hp := &HubPoller{ + selfVPNIP: netip.MustParseAddr("10.0.0.1"), + vpnNet: netip.MustParsePrefix("10.0.0.0/24"), + addCh: addCh, + removeCh: removeCh, + known: make(map[wgtypes.Key]int64), + } + return hp, addCh, removeCh +} + +func stateWith(key wgtypes.Key, peerIP byte, version int64) m.NetworkState { + var s m.NetworkState + s.Peers[peerIP] = &m.Peer{ + PeerIP: peerIP, + Version: version, + WGPubKey: key[:], + SignPubKey: make([]byte, 32), + } + return s +} + +func TestApply_EmitsAddsAndReportsChange(t *testing.T) { + hp, addCh, _ := testPoller(t) + key := mustKey(t) + + if changed := hp.apply(stateWith(key, 2, 1)); !changed { + t.Fatal("expected changed=true on first apply") + } + if len(addCh) != 1 { + t.Fatalf("expected 1 add, got %d", len(addCh)) + } + if got := <-addCh; got.PubKey != key { + t.Errorf("add pubkey mismatch") + } +} + +func TestApply_NoChangeWhenVersionSame(t *testing.T) { + hp, addCh, _ := testPoller(t) + key := mustKey(t) + + hp.apply(stateWith(key, 2, 1)) + <-addCh // drain initial add + + if changed := hp.apply(stateWith(key, 2, 1)); changed { + t.Fatal("expected changed=false when version unchanged") + } + if len(addCh) != 0 { + t.Fatalf("expected no re-emit, got %d adds", len(addCh)) + } +} + +func TestApply_ReEmitsOnVersionBump(t *testing.T) { + hp, addCh, _ := testPoller(t) + key := mustKey(t) + + hp.apply(stateWith(key, 2, 1)) + <-addCh + + if changed := hp.apply(stateWith(key, 2, 2)); !changed { + t.Fatal("expected changed=true on version bump") + } + if len(addCh) != 1 { + t.Fatalf("expected 1 re-emit, got %d", len(addCh)) + } +} + +func TestApply_RemovesVanishedPeer(t *testing.T) { + hp, addCh, removeCh := testPoller(t) + key := mustKey(t) + + hp.apply(stateWith(key, 2, 1)) + <-addCh + + // Empty state: the peer is gone. + if changed := hp.apply(m.NetworkState{}); !changed { + t.Fatal("expected changed=true when peer vanishes") + } + if len(removeCh) != 1 { + t.Fatalf("expected 1 remove, got %d", len(removeCh)) + } + if got := <-removeCh; got != key { + t.Errorf("remove key mismatch") + } +} diff --git a/peer/init.go b/peer/init.go index c514998..45cc788 100644 --- a/peer/init.go +++ b/peer/init.go @@ -9,7 +9,6 @@ import ( "net/http" "net/netip" "os" - "path/filepath" "golang.org/x/crypto/nacl/sign" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -32,21 +31,27 @@ type LocalState struct { // 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"` - LocalDomain string `json:"local_domain"` + PrivKey string + SignKey string + VPNIP netip.Addr + VPNNet netip.Prefix + WGPort uint16 + IsRelay bool + IsPublic bool + LocalDomain string } // LoadOrInit loads LocalState from path, or registers with the hub and creates // the file if it doesn't exist. func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) { - if data, err := os.ReadFile(statePath); err == nil { - return parseLocalState(data) + var state LocalState + switch err := loadJSON(statePath, &state); { + case err == nil: + return state, nil + case !os.IsNotExist(err): + // File exists but is unreadable/corrupt: surface it rather than + // silently regenerating a new identity and re-registering. + return LocalState{}, fmt.Errorf("load state: %w", err) } privKey, err := wgtypes.GeneratePrivateKey() @@ -54,12 +59,12 @@ func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) { return LocalState{}, fmt.Errorf("generate key: %w", err) } - state, err := initFromHub(hubURL, apiKey, privKey) + state, err = initFromHub(hubURL, apiKey, privKey) if err != nil { return LocalState{}, err } - if err := saveLocalState(statePath, state); err != nil { + if err := storeJSON(statePath, state); err != nil { return LocalState{}, fmt.Errorf("save state: %w", err) } return state, nil @@ -130,42 +135,8 @@ func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error) }, nil } -func parseLocalState(data []byte) (LocalState, error) { - var j localStateJSON - if err := json.Unmarshal(data, &j); err != nil { - return LocalState{}, fmt.Errorf("parse state: %w", err) - } - keyBytes, err := base64.StdEncoding.DecodeString(j.PrivKey) - if err != nil { - return LocalState{}, fmt.Errorf("decode key: %w", err) - } - key, err := wgtypes.NewKey(keyBytes) - if err != nil { - return LocalState{}, fmt.Errorf("invalid key: %w", err) - } - signKeyBytes, err := base64.StdEncoding.DecodeString(j.SignKey) - if err != nil { - return LocalState{}, fmt.Errorf("decode sign key: %w", err) - } - if len(signKeyBytes) != 64 { - return LocalState{}, fmt.Errorf("invalid sign key length: %d", len(signKeyBytes)) - } - 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, - LocalDomain: j.LocalDomain, - }, nil -} - -func saveLocalState(path string, s LocalState) error { - j := localStateJSON{ +func (s LocalState) MarshalJSON() ([]byte, error) { + return json.Marshal(localStateJSON{ PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]), SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]), VPNIP: s.VPNIP, @@ -174,13 +145,38 @@ func saveLocalState(path string, s LocalState) error { IsRelay: s.IsRelay, IsPublic: s.IsPublic, LocalDomain: s.LocalDomain, - } - data, err := json.MarshalIndent(j, "", " ") - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - return os.WriteFile(path, data, 0600) + }) +} + +func (s *LocalState) UnmarshalJSON(data []byte) error { + var j localStateJSON + if err := json.Unmarshal(data, &j); err != nil { + return err + } + keyBytes, err := base64.StdEncoding.DecodeString(j.PrivKey) + if err != nil { + return fmt.Errorf("decode key: %w", err) + } + key, err := wgtypes.NewKey(keyBytes) + if err != nil { + return fmt.Errorf("invalid key: %w", err) + } + signKeyBytes, err := base64.StdEncoding.DecodeString(j.SignKey) + if err != nil { + return fmt.Errorf("decode sign key: %w", err) + } + if len(signKeyBytes) != 64 { + return fmt.Errorf("invalid sign key length: %d", len(signKeyBytes)) + } + *s = LocalState{ + PrivKey: key, + SignKey: [64]byte(signKeyBytes), + VPNIP: j.VPNIP, + VPNNet: j.VPNNet, + WGPort: j.WGPort, + IsRelay: j.IsRelay, + IsPublic: j.IsPublic, + LocalDomain: j.LocalDomain, + } + return nil } diff --git a/peer/network_state.go b/peer/network_state.go new file mode 100644 index 0000000..7f80218 --- /dev/null +++ b/peer/network_state.go @@ -0,0 +1,18 @@ +package peer + +import "vppn/m" + +// loadNetworkState reads a cached network state from disk. Any error (most +// commonly a missing file on first run) is returned to the caller, which +// treats it as "no cache available". +func loadNetworkState(path string) (m.NetworkState, error) { + var state m.NetworkState + err := loadJSON(path, &state) + return state, err +} + +// saveNetworkState writes state to path atomically (see storeJSON), so a crash +// mid-write cannot leave a corrupt cache. +func saveNetworkState(path string, state m.NetworkState) error { + return storeJSON(path, state) +} diff --git a/peer/network_state_test.go b/peer/network_state_test.go new file mode 100644 index 0000000..42e6741 --- /dev/null +++ b/peer/network_state_test.go @@ -0,0 +1,54 @@ +package peer + +import ( + "path/filepath" + "reflect" + "testing" + + "vppn/m" +) + +func TestNetworkState_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "network.json") + + var state m.NetworkState + state.Peers[1] = &m.Peer{ + PeerIP: 1, + Version: 7, + Name: "hub", + Addr4: []byte{10, 11, 12, 1}, + Port: 51820, + Relay: true, + WGPubKey: make([]byte, 32), + SignPubKey: make([]byte, 32), + } + state.Peers[10] = &m.Peer{ + PeerIP: 10, + Version: 3, + Name: "laptop", + Addr4: []byte{10, 11, 12, 10}, + Port: 51820, + WGPubKey: []byte("0123456789abcdef0123456789abcdef"), + SignPubKey: []byte("fedcba9876543210fedcba9876543210"), + } + + if err := saveNetworkState(path, state); err != nil { + t.Fatal(err) + } + + got, err := loadNetworkState(path) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(got, state) { + t.Errorf("round-trip mismatch:\n got: %+v\nwant: %+v", got.Peers[1], state.Peers[1]) + } +} + +func TestNetworkState_LoadMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist.json") + if _, err := loadNetworkState(path); err == nil { + t.Fatal("expected error loading missing cache, got nil") + } +} diff --git a/peer/new.go b/peer/new.go index 4d756b7..e8396ce 100644 --- a/peer/new.go +++ b/peer/new.go @@ -17,6 +17,7 @@ func New( hubURL, apiKey string, ifaceName string, localDomain string, + networkStatePath string, ) (*App, error) { a4 := state.VPNIP.As4() @@ -50,10 +51,13 @@ func New( multicastCh := make(chan MulticastEvent) poller, err := NewHubPoller( - state.VPNIP, state.VPNNet, - hubURL, apiKey, - hubAddCh, hubRemoveCh, - ) + state.VPNIP, + state.VPNNet, + hubURL, + apiKey, + networkStatePath, + hubAddCh, + hubRemoveCh) if err != nil { return nil, fmt.Errorf("hub poller: %w", err) }