WIP
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
99
peer/hub_poller_test.go
Normal file
99
peer/hub_poller_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
112
peer/init.go
112
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
|
||||
}
|
||||
|
||||
18
peer/network_state.go
Normal file
18
peer/network_state.go
Normal 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)
|
||||
}
|
||||
54
peer/network_state_test.go
Normal file
54
peer/network_state_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
12
peer/new.go
12
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user