This commit is contained in:
jdl
2026-06-10 19:17:55 +02:00
parent db78964033
commit f89f5d5083
7 changed files with 260 additions and 65 deletions

View File

@@ -45,7 +45,7 @@ func main() {
} }
ifaceName := strings.TrimSuffix(state.LocalDomain, ".local") 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 { if err != nil {
log.Fatalf("start: %v", err) log.Fatalf("start: %v", err)
} }

View File

@@ -21,6 +21,7 @@ type HubPoller struct {
vpnNet netip.Prefix vpnNet netip.Prefix
hubURL string hubURL string
apiKey string apiKey string
statePath string // where the network state cache is persisted
addCh chan<- HubPeer addCh chan<- HubPeer
removeCh chan<- wgtypes.Key removeCh chan<- wgtypes.Key
known map[wgtypes.Key]int64 // pubKey → last seen version known map[wgtypes.Key]int64 // pubKey → last seen version
@@ -30,6 +31,7 @@ func NewHubPoller(
selfVPNIP netip.Addr, selfVPNIP netip.Addr,
vpnNet netip.Prefix, vpnNet netip.Prefix,
hubURL, apiKey string, hubURL, apiKey string,
statePath string,
addCh chan<- HubPeer, addCh chan<- HubPeer,
removeCh chan<- wgtypes.Key, removeCh chan<- wgtypes.Key,
) (*HubPoller, error) { ) (*HubPoller, error) {
@@ -44,6 +46,7 @@ func NewHubPoller(
vpnNet: vpnNet, vpnNet: vpnNet,
hubURL: u.String(), hubURL: u.String(),
apiKey: apiKey, apiKey: apiKey,
statePath: statePath,
addCh: addCh, addCh: addCh,
removeCh: removeCh, removeCh: removeCh,
known: make(map[wgtypes.Key]int64), known: make(map[wgtypes.Key]int64),
@@ -51,6 +54,14 @@ func NewHubPoller(
} }
func (hp *HubPoller) Run() { 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() hp.poll()
for range time.Tick(hubPollInterval) { for range time.Tick(hubPollInterval) {
hp.poll() hp.poll()
@@ -90,10 +101,19 @@ func (hp *HubPoller) poll() {
return 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)) seen := make(map[wgtypes.Key]struct{}, len(hp.known))
netAddr := hp.vpnNet.Addr().As4() netAddr := hp.vpnNet.Addr().As4()
@@ -122,14 +142,18 @@ func (hp *HubPoller) apply(state m.NetworkState) {
} }
hp.known[pubKey] = p.Version hp.known[pubKey] = p.Version
hp.addCh <- hubPeerFrom(pubKey, vpnIP, p) hp.addCh <- hubPeerFrom(pubKey, vpnIP, p)
changed = true
} }
for key := range hp.known { for key := range hp.known {
if _, ok := seen[key]; !ok { if _, ok := seen[key]; !ok {
delete(hp.known, key) delete(hp.known, key)
hp.removeCh <- key hp.removeCh <- key
changed = true
} }
} }
return changed
} }
func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p *m.Peer) HubPeer { func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p *m.Peer) HubPeer {

99
peer/hub_poller_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -9,7 +9,6 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
"path/filepath"
"golang.org/x/crypto/nacl/sign" "golang.org/x/crypto/nacl/sign"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -32,21 +31,27 @@ type LocalState struct {
// localStateJSON is the on-disk representation. // localStateJSON is the on-disk representation.
type localStateJSON struct { type localStateJSON struct {
PrivKey string `json:"priv_key"` // standard base64 PrivKey string
SignKey string `json:"sign_key"` // standard base64 SignKey string
VPNIP netip.Addr `json:"vpn_ip"` VPNIP netip.Addr
VPNNet netip.Prefix `json:"vpn_net"` VPNNet netip.Prefix
WGPort uint16 `json:"wg_port"` WGPort uint16
IsRelay bool `json:"is_relay"` IsRelay bool
IsPublic bool `json:"is_public"` IsPublic bool
LocalDomain string `json:"local_domain"` LocalDomain string
} }
// LoadOrInit loads LocalState from path, or registers with the hub and creates // LoadOrInit loads LocalState from path, or registers with the hub and creates
// the file if it doesn't exist. // the file if it doesn't exist.
func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) { func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) {
if data, err := os.ReadFile(statePath); err == nil { var state LocalState
return parseLocalState(data) 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() privKey, err := wgtypes.GeneratePrivateKey()
@@ -54,12 +59,12 @@ func LoadOrInit(statePath, hubURL, apiKey string) (LocalState, error) {
return LocalState{}, fmt.Errorf("generate key: %w", err) return LocalState{}, fmt.Errorf("generate key: %w", err)
} }
state, err := initFromHub(hubURL, apiKey, privKey) state, err = initFromHub(hubURL, apiKey, privKey)
if err != nil { if err != nil {
return LocalState{}, err 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 LocalState{}, fmt.Errorf("save state: %w", err)
} }
return state, nil return state, nil
@@ -130,42 +135,8 @@ func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error)
}, nil }, nil
} }
func parseLocalState(data []byte) (LocalState, error) { func (s LocalState) MarshalJSON() ([]byte, error) {
var j localStateJSON return json.Marshal(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{
PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]), PrivKey: base64.StdEncoding.EncodeToString(s.PrivKey[:]),
SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]), SignKey: base64.StdEncoding.EncodeToString(s.SignKey[:]),
VPNIP: s.VPNIP, VPNIP: s.VPNIP,
@@ -174,13 +145,38 @@ func saveLocalState(path string, s LocalState) error {
IsRelay: s.IsRelay, IsRelay: s.IsRelay,
IsPublic: s.IsPublic, IsPublic: s.IsPublic,
LocalDomain: s.LocalDomain, LocalDomain: s.LocalDomain,
} })
data, err := json.MarshalIndent(j, "", " ") }
if err != nil {
return err func (s *LocalState) UnmarshalJSON(data []byte) error {
} var j localStateJSON
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { if err := json.Unmarshal(data, &j); err != nil {
return err return err
} }
return os.WriteFile(path, data, 0600) 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
} }

18
peer/network_state.go Normal file
View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -17,6 +17,7 @@ func New(
hubURL, apiKey string, hubURL, apiKey string,
ifaceName string, ifaceName string,
localDomain string, localDomain string,
networkStatePath string,
) (*App, error) { ) (*App, error) {
a4 := state.VPNIP.As4() a4 := state.VPNIP.As4()
@@ -50,10 +51,13 @@ func New(
multicastCh := make(chan MulticastEvent) multicastCh := make(chan MulticastEvent)
poller, err := NewHubPoller( poller, err := NewHubPoller(
state.VPNIP, state.VPNNet, state.VPNIP,
hubURL, apiKey, state.VPNNet,
hubAddCh, hubRemoveCh, hubURL,
) apiKey,
networkStatePath,
hubAddCh,
hubRemoveCh)
if err != nil { if err != nil {
return nil, fmt.Errorf("hub poller: %w", err) return nil, fmt.Errorf("hub poller: %w", err)
} }