This commit is contained in:
jdl
2026-06-07 09:33:21 +02:00
parent 199f774ed3
commit cad200b9cc
16 changed files with 692 additions and 63 deletions

View File

@@ -68,7 +68,7 @@ type App struct {
selfV6 netip.AddrPort selfV6 netip.AddrPort
// Monotonically increasing ID for outbound pings (client role only) // Monotonically increasing ID for outbound pings (client role only)
nextPingID int64 nextPingID int64 // TODO: Remove
// Event channels fed by background goroutines // Event channels fed by background goroutines
hubAddCh <-chan HubPeer hubAddCh <-chan HubPeer
@@ -104,7 +104,6 @@ func (a *App) Run() error {
} }
} }
func (a *App) onShutdown() error { func (a *App) onShutdown() error {
return wginterface.Delete(a.dev.Name()) return wginterface.Delete(a.dev.Name())
} }

View File

@@ -3,10 +3,28 @@ package peer
import ( import (
"net/netip" "net/netip"
"testing" "testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
) )
// addRelayPeer adds a public relay peer and marks it Up so it satisfies
// CanRelay. It does not set a.relay — callers do that explicitly.
func addRelayPeer(t *testing.T, a *App, vpnIP string, ep netip.AddrPort) *Peer {
t.Helper()
key := mustKey(t)
a.onAddPeer(HubPeer{
PubKey: key,
VPNIP: netip.MustParseAddr(vpnIP),
IsPublic: true,
IsRelay: true,
EndpointV4: ep,
})
p := a.peersByKey[key]
p.wgPeer.LastHandshakeTime = time.Now()
return p
}
// newTestApp returns a minimal App wired to a fakeWGDevice. // newTestApp returns a minimal App wired to a fakeWGDevice.
// vpnIP is the local VPN address (e.g. "10.0.0.1"). // vpnIP is the local VPN address (e.g. "10.0.0.1").
// isPublic / isRelay describe the local node's role. // isPublic / isRelay describe the local node's role.

76
peer/control/ping.go Normal file
View File

@@ -0,0 +1,76 @@
// Package control implements the VPN-internal peer control protocol.
// Peers exchange Ping packets over UDP on the VPN control port to maintain
// liveness and discover external endpoints for direct connection attempts.
package control
import (
"encoding/binary"
"fmt"
"net/netip"
)
const (
version = 1
Size = 59 // 1 version + 8 ID + 8 PingTS + 6 SrcV4 + 18 SrcV6 + 18 Dst
)
// Ping is the single control packet type exchanged between VPN peers.
//
// In each peer pair, the peer with the lower VPN IP is the client: it sets ID
// and PingTS and sends pings on a timer. The server echoes ID and PingTS back
// in its response, allowing the client to compute RTT = now - PingTS.
//
// Both client and server populate SrcV4, SrcV6, and Dst on every packet so
// endpoint information flows in both directions.
//
// Dst is the recipient's external endpoint as observed by the sender from the
// WireGuard handshake source. Zero if the sender has not observed a handshake
// from the recipient.
type Ping struct {
ID int64 // Client ping ID.
PingTS int64 // Client ping send time in nanoseconds.
SrcV4 netip.AddrPort // Sender's discovered IPv4 address and port.
SrcV6 netip.AddrPort // Sender's discovered IPv6 address and port.
Dst netip.AddrPort
}
// Marshal encodes p into a fixed-size 59-byte array.
func (p Ping) Marshal() [Size]byte {
var buf [Size]byte
buf[0] = version
binary.BigEndian.PutUint64(buf[1:9], uint64(p.ID))
binary.BigEndian.PutUint64(buf[9:17], uint64(p.PingTS))
if p.SrcV4.IsValid() {
a4 := p.SrcV4.Addr().As4()
copy(buf[17:21], a4[:])
binary.BigEndian.PutUint16(buf[21:23], p.SrcV4.Port())
}
a16 := p.SrcV6.Addr().As16()
copy(buf[23:39], a16[:])
binary.BigEndian.PutUint16(buf[39:41], p.SrcV6.Port())
a16 = p.Dst.Addr().As16()
copy(buf[41:57], a16[:])
binary.BigEndian.PutUint16(buf[57:59], p.Dst.Port())
return buf
}
// Unmarshal decodes a Ping from a fixed-size 59-byte array.
func Unmarshal(buf [Size]byte) (Ping, error) {
if buf[0] != version {
return Ping{}, fmt.Errorf("unknown ping version %d", buf[0])
}
p := Ping{
ID: int64(binary.BigEndian.Uint64(buf[1:9])),
PingTS: int64(binary.BigEndian.Uint64(buf[9:17])),
}
if addr := netip.AddrFrom4([4]byte(buf[17:21])); !addr.IsUnspecified() {
p.SrcV4 = netip.AddrPortFrom(addr, binary.BigEndian.Uint16(buf[21:23]))
}
if addr := netip.AddrFrom16([16]byte(buf[23:39])); !addr.IsUnspecified() {
p.SrcV6 = netip.AddrPortFrom(addr, binary.BigEndian.Uint16(buf[39:41]))
}
if addr := netip.AddrFrom16([16]byte(buf[41:57])).Unmap(); !addr.IsUnspecified() {
p.Dst = netip.AddrPortFrom(addr, binary.BigEndian.Uint16(buf[57:59]))
}
return p, nil
}

109
peer/control/ping_test.go Normal file
View File

@@ -0,0 +1,109 @@
package control_test
import (
"net/netip"
"testing"
"vppn/peer/control"
)
func TestRoundTrip(t *testing.T) {
cases := []struct {
name string
ping control.Ping
}{
{
name: "zero",
ping: control.Ping{},
},
{
name: "client ping",
ping: control.Ping{
ID: 42,
PingTS: 1234567890,
SrcV4: netip.MustParseAddrPort("1.2.3.4:51820"),
Dst: netip.MustParseAddrPort("5.6.7.8:51820"),
},
},
{
name: "server response",
ping: control.Ping{
ID: 42,
PingTS: 1234567890,
SrcV4: netip.MustParseAddrPort("5.6.7.8:51820"),
Dst: netip.MustParseAddrPort("1.2.3.4:9999"),
},
},
{
name: "IPv6 only",
ping: control.Ping{
ID: 1,
PingTS: 999,
SrcV6: netip.MustParseAddrPort("[2001:db8::1]:51820"),
Dst: netip.MustParseAddrPort("[2001:db8::2]:51820"),
},
},
{
name: "dual stack",
ping: control.Ping{
ID: 7,
PingTS: 555,
SrcV4: netip.MustParseAddrPort("1.2.3.4:51820"),
SrcV6: netip.MustParseAddrPort("[2001:db8::1]:51820"),
Dst: netip.MustParseAddrPort("5.6.7.8:9999"),
},
},
{
name: "no src known",
ping: control.Ping{
ID: 3,
Dst: netip.MustParseAddrPort("5.6.7.8:51820"),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
buf := tc.ping.Marshal()
got, err := control.Unmarshal(buf)
if err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got != tc.ping {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", got, tc.ping)
}
})
}
}
func TestUnmarshalBadVersion(t *testing.T) {
var buf [control.Size]byte
buf[0] = 99
if _, err := control.Unmarshal(buf); err == nil {
t.Fatal("expected error for unknown version, got nil")
}
}
func TestZeroEncoding(t *testing.T) {
buf := (control.Ping{}).Marshal()
for i, b := range buf {
if i == 0 {
continue // version byte
}
if b != 0 {
t.Fatalf("expected zero encoding at byte %d, got %d", i, b)
}
}
}
func TestRoleFor(t *testing.T) {
lo := netip.MustParseAddr("10.0.0.1")
hi := netip.MustParseAddr("10.0.0.2")
if control.RoleFor(lo, hi) != control.Client {
t.Error("lower IP should be client")
}
if control.RoleFor(hi, lo) != control.Server {
t.Error("higher IP should be server")
}
}

22
peer/control/role.go Normal file
View File

@@ -0,0 +1,22 @@
package control
import "net/netip"
// Role identifies a peer's role in a ping exchange with a specific remote peer.
type Role string
const (
// Client initiates pings and measures RTT.
Client Role = "CLIENT"
// Server responds to pings.
Server Role = "SERVER"
)
// RoleFor returns the Role of local relative to remote.
// The peer with the lower VPN IP is the client.
func RoleFor(local, remote netip.Addr) Role {
if local.Compare(remote) < 0 {
return Client
}
return Server
}

View File

@@ -15,34 +15,38 @@ func (a *App) devPeers() []wgtypes.Peer {
return peers return peers
} }
func (a *App) devAddPeer(p *Peer) {
if err := a.dev.AddPeer(p.PubKey()); err != nil {
log.Fatalf("Failed to add peer %v: %v", p.VPNIP, err)
}
}
func (a *App) devAddDirect(p *Peer, endpoint netip.AddrPort) { func (a *App) devAddDirect(p *Peer, endpoint netip.AddrPort) {
if err := a.dev.AddDirect(p.PubKey, endpoint, p.VPNIP); err != nil { if err := a.dev.AddDirect(p.PubKey(), endpoint, p.VPNIP); err != nil {
log.Fatalf("Failed to add peer %v: %v", p.VPNIP, err) log.Fatalf("Failed to add peer %v: %v", p.VPNIP, err)
} }
} }
func (a *App) devSetRelay(p *Peer, endpoint netip.AddrPort) { func (a *App) devSetRelay(p *Peer, endpoint netip.AddrPort) {
if err := a.dev.SetRelay(p.PubKey, endpoint, a.vpnNet); err != nil { if err := a.dev.SetRelay(p.PubKey(), endpoint, a.vpnNet); err != nil {
log.Fatalf("Failed to add relay %v: %v", p.VPNIP, err) log.Fatalf("Failed to add relay %v: %v", p.VPNIP, err)
} }
} }
func (a *App) devPromote(p *Peer) { func (a *App) devPromote(p *Peer) {
if err := a.dev.Promote(p.PubKey, p.VPNIP); err != nil { if err := a.dev.Promote(p.PubKey(), p.VPNIP); err != nil {
log.Fatalf("Failed to promot peer %v: %v", p.VPNIP, err) log.Fatalf("Failed to promote peer %v: %v", p.VPNIP, err)
} }
} }
func (a *App) devAddProbe(p *Peer, endpoint netip.AddrPort) { func (a *App) devAddProbe(p *Peer, endpoint netip.AddrPort) {
if err := a.dev.AddProbe(p.PubKey, endpoint); err != nil { if err := a.dev.AddProbe(p.PubKey(), endpoint); err != nil {
log.Fatalf("Failed to add probe %v: %v", p.VPNIP, err) log.Fatalf("Failed to add probe %v: %v", p.VPNIP, err)
} }
} }
func (a *App) devRemove(p *Peer) { func (a *App) devRemove(p *Peer) {
if p.State != StateRelayed { if err := a.dev.RemovePeer(p.PubKey()); err != nil {
if err := a.dev.RemovePeer(p.PubKey); err != nil { log.Fatalf("Failed to remove peer %v: %v", p.VPNIP, err)
log.Fatalf("Failed to remove peer %v: %v", p.VPNIP, err)
}
} }
} }

View File

@@ -3,6 +3,7 @@ package peer
import ( import (
"net/netip" "net/netip"
"sync" "sync"
"testing"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
) )
@@ -41,6 +42,11 @@ func (f *fakeWGDevice) Peers() ([]wgtypes.Peer, error) {
return out, nil return out, nil
} }
func (f *fakeWGDevice) AddPeer(pubKey wgtypes.Key) error {
f.record(fakeCall{Method: "AddPeer", PubKey: pubKey})
return nil
}
func (f *fakeWGDevice) AddDirect(pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) error { func (f *fakeWGDevice) AddDirect(pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) error {
f.record(fakeCall{Method: "AddDirect", PubKey: pubKey, Endpoint: endpoint, VPNiP: vpnIP}) f.record(fakeCall{Method: "AddDirect", PubKey: pubKey, Endpoint: endpoint, VPNiP: vpnIP})
return nil return nil
@@ -65,3 +71,53 @@ func (f *fakeWGDevice) RemovePeer(pubKey wgtypes.Key) error {
f.record(fakeCall{Method: "RemovePeer", PubKey: pubKey}) f.record(fakeCall{Method: "RemovePeer", PubKey: pubKey})
return nil return nil
} }
// AssertNoCalls fails the test if any dev calls were recorded.
func (f *fakeWGDevice) AssertNoCalls(t *testing.T) {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if len(f.Calls) != 0 {
t.Fatalf("unexpected dev calls: %v", f.Calls)
}
}
func (f *fakeWGDevice) AssertAddPeer(t *testing.T, i int, pubKey wgtypes.Key) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "AddPeer", PubKey: pubKey})
}
func (f *fakeWGDevice) AssertAddDirect(t *testing.T, i int, pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "AddDirect", PubKey: pubKey, Endpoint: endpoint, VPNiP: vpnIP})
}
func (f *fakeWGDevice) AssertSetRelay(t *testing.T, i int, pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "SetRelay", PubKey: pubKey, Endpoint: endpoint, Network: network})
}
func (f *fakeWGDevice) AssertAddProbe(t *testing.T, i int, pubKey wgtypes.Key, endpoint netip.AddrPort) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "AddProbe", PubKey: pubKey, Endpoint: endpoint})
}
func (f *fakeWGDevice) AssertPromote(t *testing.T, i int, pubKey wgtypes.Key, vpnIP netip.Addr) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "Promote", PubKey: pubKey, VPNiP: vpnIP})
}
func (f *fakeWGDevice) AssertRemovePeer(t *testing.T, i int, pubKey wgtypes.Key) {
t.Helper()
f.assertCall(t, i, fakeCall{Method: "RemovePeer", PubKey: pubKey})
}
func (f *fakeWGDevice) assertCall(t *testing.T, i int, c fakeCall) {
t.Helper()
if len(f.Calls) <= i {
t.Fatalf("no call at index %d: %v", i, c)
}
if c != f.Calls[i] {
t.Fatalf("call[%d]: got %v, want %v", i, f.Calls[i], c)
}
}

View File

@@ -10,6 +10,7 @@ import (
type WGDevice interface { type WGDevice interface {
Name() string Name() string
Peers() ([]wgtypes.Peer, error) Peers() ([]wgtypes.Peer, error)
AddPeer(pubKey wgtypes.Key) error
AddDirect(pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) error AddDirect(pubKey wgtypes.Key, endpoint netip.AddrPort, vpnIP netip.Addr) error
SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error
AddProbe(pubKey wgtypes.Key, endpoint netip.AddrPort) error AddProbe(pubKey wgtypes.Key, endpoint netip.AddrPort) error

View File

@@ -10,19 +10,16 @@ import (
) )
func (a *App) onAddPeer(p HubPeer) { func (a *App) onAddPeer(p HubPeer) {
if _, exists := a.peersByKey[p.PubKey]; exists { a.onRemovePeer(p.PubKey)
a.onRemovePeer(p.PubKey)
}
peer := &Peer{ peer := &Peer{
PubKey: p.PubKey, wgPeer: wgtypes.Peer{PublicKey: p.PubKey},
VPNIP: p.VPNIP, VPNIP: p.VPNIP,
IsRelay: p.IsRelay, IsRelay: p.IsRelay,
IsPublic: p.IsPublic, IsPublic: p.IsPublic,
Endpoint4: p.EndpointV4, Endpoint4: p.EndpointV4,
Endpoint6: p.EndpointV6, Endpoint6: p.EndpointV6,
Role: roleFor(a.isPublic, a.vpnIP, p), Role: roleFor(a.isPublic, a.vpnIP, p),
State: StateRelayed,
} }
endpoint := peer.PreferredEndpoint() endpoint := peer.PreferredEndpoint()
@@ -36,11 +33,10 @@ func (a *App) onAddPeer(p HubPeer) {
a.peersByIP[peer.VPNIP] = peer a.peersByIP[peer.VPNIP] = peer
if !peer.IsPublic { if !peer.IsPublic {
a.devAddPeer(peer)
return return
} }
peer.WGEndpoint = endpoint
peer.State = StateDirect
a.devAddDirect(peer, endpoint) a.devAddDirect(peer, endpoint)
} }
@@ -62,7 +58,7 @@ func (a *App) onRemovePeer(key wgtypes.Key) {
// switchActiveRelay promotes the lowest-latency relay peer to active. // switchActiveRelay promotes the lowest-latency relay peer to active.
func (a *App) switchActiveRelay() { func (a *App) switchActiveRelay() {
if a.relay != nil { if a.relay != nil {
a.devAddDirect(a.relay, a.relay.WGEndpoint) a.devAddDirect(a.relay, a.relay.WGEndpoint())
a.relay = nil a.relay = nil
} }
@@ -81,10 +77,11 @@ func (a *App) switchActiveRelay() {
return return
} }
a.devSetRelay(best, best.WGEndpoint) a.devSetRelay(best, best.WGEndpoint())
a.relay = best a.relay = best
} }
// TODO: Why not < ??
// betterRelay reports whether a is a better relay candidate than b. // betterRelay reports whether a is a better relay candidate than b.
// Prefers lower RTT; treats zero RTT (no measurement yet) as worst case. // Prefers lower RTT; treats zero RTT (no measurement yet) as worst case.
func betterRelay(a, b *Peer) bool { func betterRelay(a, b *Peer) bool {

315
peer/on_hub_test.go Normal file
View File

@@ -0,0 +1,315 @@
package peer
import (
"net/netip"
"testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func mustKey(t *testing.T) wgtypes.Key {
t.Helper()
k, err := wgtypes.GeneratePrivateKey()
if err != nil {
t.Fatalf("generate key: %v", err)
}
return k.PublicKey()
}
func TestOnAddPeer(t *testing.T) {
ep1 := netip.MustParseAddrPort("1.2.3.4:51820")
ep2 := netip.MustParseAddrPort("5.6.7.8:51820")
peerVPNIP := netip.MustParseAddr("10.0.0.2")
testCases := []struct {
name string
setup func(a *App, key wgtypes.Key)
peer func(key wgtypes.Key) HubPeer
check func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key)
}{
{
name: "non-public peer added in StateRelayed with no dev calls",
peer: func(k wgtypes.Key) HubPeer {
return HubPeer{PubKey: k, VPNIP: peerVPNIP}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
p := a.peersByKey[key]
if p == nil {
t.Fatal("not in peersByKey")
}
if a.peersByIP[peerVPNIP] == nil {
t.Fatal("not in peersByIP")
}
if p.State != StateRelayed {
t.Fatalf("state = %v, want StateRelayed", p.State)
}
dev.AssertNoCalls(t)
},
},
{
name: "public peer with endpoint goes to StateDirect via AddDirect",
peer: func(k wgtypes.Key) HubPeer {
return HubPeer{PubKey: k, VPNIP: peerVPNIP, IsPublic: true, EndpointV4: ep1}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
p := a.peersByKey[key]
if p == nil {
t.Fatal("not in peersByKey")
}
if p.State != StateDirect {
t.Fatalf("state = %v, want StateDirect", p.State)
}
if p.WGEndpoint != ep1 {
t.Fatalf("WGEndpoint = %v, want %v", p.WGEndpoint, ep1)
}
dev.AssertAddDirect(t, 0, p.PubKey, p.WGEndpoint, p.VPNIP)
},
},
{
name: "public peer with no endpoint is dropped",
peer: func(k wgtypes.Key) HubPeer {
return HubPeer{PubKey: k, VPNIP: peerVPNIP, IsPublic: true}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
if a.peersByKey[key] != nil {
t.Fatal("peer should not be in peersByKey")
}
if a.peersByIP[peerVPNIP] != nil {
t.Fatal("peer should not be in peersByIP")
}
dev.AssertNoCalls(t)
},
},
{
name: "re-add removes old WG entry before adding new one",
setup: func(a *App, key wgtypes.Key) {
a.onAddPeer(HubPeer{PubKey: key, VPNIP: peerVPNIP, IsPublic: true, EndpointV4: ep1})
},
peer: func(k wgtypes.Key) HubPeer {
return HubPeer{PubKey: k, VPNIP: peerVPNIP, IsPublic: true, EndpointV4: ep2}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
if len(dev.Calls) != 2 {
t.Fatalf("dev calls = %v, want [RemovePeer, AddDirect]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, key)
dev.AssertAddDirect(t, 1, key, ep2, peerVPNIP)
if len(a.peersByKey) != 1 || len(a.peersByIP) != 1 {
t.Errorf("maps: peersByKey=%d peersByIP=%d, want 1 each", len(a.peersByKey), len(a.peersByIP))
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a, dev := newTestApp(t, "10.0.0.1", false, false)
key := mustKey(t)
if tc.setup != nil {
tc.setup(a, key)
dev.Calls = nil
}
a.onAddPeer(tc.peer(key))
tc.check(t, a, dev, key)
})
}
}
func TestOnRemovePeer(t *testing.T) {
ep1 := netip.MustParseAddrPort("1.2.3.4:51820")
ep2 := netip.MustParseAddrPort("5.6.7.8:51820")
testCases := []struct {
name string
setup func(t *testing.T, a *App) wgtypes.Key // returns the key to remove
check func(t *testing.T, a *App, dev *fakeWGDevice)
}{
{
name: "unknown key is a no-op",
setup: func(t *testing.T, a *App) wgtypes.Key {
return mustKey(t)
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
dev.AssertNoCalls(t)
if len(a.peersByKey) != 0 {
t.Errorf("peersByKey should be empty")
}
},
},
{
name: "StateRelayed peer removed from maps without RemovePeer",
setup: func(t *testing.T, a *App) wgtypes.Key {
key := mustKey(t)
a.onAddPeer(HubPeer{PubKey: key, VPNIP: netip.MustParseAddr("10.0.0.2")})
return key
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
dev.AssertNoCalls(t)
if len(a.peersByKey) != 0 || len(a.peersByIP) != 0 {
t.Errorf("maps should be empty after remove")
}
},
},
{
name: "StateDirect peer removed from maps with RemovePeer",
setup: func(t *testing.T, a *App) wgtypes.Key {
key := mustKey(t)
a.onAddPeer(HubPeer{PubKey: key, VPNIP: netip.MustParseAddr("10.0.0.2"), IsPublic: true, EndpointV4: ep1})
return key
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [RemovePeer]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, dev.Calls[0].PubKey)
if len(a.peersByKey) != 0 || len(a.peersByIP) != 0 {
t.Errorf("maps should be empty after remove")
}
},
},
{
name: "removing active relay with no backup clears relay field",
setup: func(t *testing.T, a *App) wgtypes.Key {
relay := addRelayPeer(t, a, "10.0.0.10", ep1)
a.relay = relay
return relay.PubKey
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [RemovePeer]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, dev.Calls[0].PubKey)
if a.relay != nil {
t.Errorf("relay should be nil after removing only relay")
}
},
},
{
name: "removing active relay elects backup via SetRelay",
setup: func(t *testing.T, a *App) wgtypes.Key {
relay1 := addRelayPeer(t, a, "10.0.0.10", ep1)
addRelayPeer(t, a, "10.0.0.11", ep2)
a.relay = relay1
return relay1.PubKey
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 2 {
t.Fatalf("dev calls = %v, want [RemovePeer, SetRelay]", dev.Calls)
}
dev.AssertRemovePeer(t, 0, dev.Calls[0].PubKey)
dev.AssertSetRelay(t, 1, dev.Calls[1].PubKey, ep2, a.vpnNet)
if a.relay == nil {
t.Errorf("relay should be set to backup after failover")
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a, dev := newTestApp(t, "10.0.0.1", false, false)
key := tc.setup(t, a)
dev.Calls = nil
a.onRemovePeer(key)
tc.check(t, a, dev)
})
}
}
func TestSwitchActiveRelay(t *testing.T) {
ep1 := netip.MustParseAddrPort("1.2.3.4:51820")
ep2 := netip.MustParseAddrPort("5.6.7.8:51820")
testCases := []struct {
name string
setup func(t *testing.T, a *App)
check func(t *testing.T, a *App, dev *fakeWGDevice)
}{
{
name: "no candidates leaves relay nil",
setup: func(t *testing.T, a *App) {},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
dev.AssertNoCalls(t)
if a.relay != nil {
t.Error("relay should be nil")
}
},
},
{
name: "single candidate elected via SetRelay",
setup: func(t *testing.T, a *App) {
addRelayPeer(t, a, "10.0.0.10", ep1)
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [SetRelay]", dev.Calls)
}
dev.AssertSetRelay(t, 0, dev.Calls[0].PubKey, ep1, a.vpnNet)
if a.relay == nil {
t.Error("relay should be set")
}
},
},
{
name: "measured RTT beats zero RTT",
setup: func(t *testing.T, a *App) {
r1 := addRelayPeer(t, a, "10.0.0.10", ep1)
r1.RTT = 10 * time.Millisecond
addRelayPeer(t, a, "10.0.0.11", ep2) // RTT stays 0
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [SetRelay]", dev.Calls)
}
dev.AssertSetRelay(t, 0, dev.Calls[0].PubKey, ep1, a.vpnNet)
},
},
{
name: "lower RTT wins",
setup: func(t *testing.T, a *App) {
r1 := addRelayPeer(t, a, "10.0.0.10", ep1)
r1.RTT = 5 * time.Millisecond
r2 := addRelayPeer(t, a, "10.0.0.11", ep2)
r2.RTT = 20 * time.Millisecond
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 1 {
t.Fatalf("dev calls = %v, want [SetRelay]", dev.Calls)
}
dev.AssertSetRelay(t, 0, dev.Calls[0].PubKey, ep1, a.vpnNet)
},
},
{
name: "stale relay demoted to direct before backup elected",
setup: func(t *testing.T, a *App) {
old := addRelayPeer(t, a, "10.0.0.10", ep1)
old.Up = false // stale — this is what triggers the switch from onTick
a.relay = old
addRelayPeer(t, a, "10.0.0.11", ep2)
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
if len(dev.Calls) != 2 {
t.Fatalf("dev calls = %v, want [AddDirect, SetRelay]", dev.Calls)
}
if dev.Calls[0].Method != "AddDirect" || dev.Calls[0].Endpoint != ep1 {
t.Errorf("call[0]: got %v, want AddDirect with ep1", dev.Calls[0])
}
dev.AssertSetRelay(t, 1, dev.Calls[1].PubKey, ep2, a.vpnNet)
if a.relay == nil || a.relay.WGEndpoint != ep2 {
t.Error("relay should be the backup peer")
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a, dev := newTestApp(t, "10.0.0.1", false, false)
tc.setup(t, a)
dev.Calls = nil
a.switchActiveRelay()
tc.check(t, a, dev)
})
}
}

View File

@@ -12,7 +12,7 @@ func (a *App) onMulticastDiscovery(e MulticastEvent) {
return return
} }
if peer.IsPublic || peer.State == StateDirect { if peer.IsPublic || peer.State() == StateDirect {
return return
} }

View File

@@ -15,10 +15,9 @@ func (a *App) onPing(e PingEvent) {
} }
now := time.Now() now := time.Now()
peer.LastPing = now
peer.Up = true
// If we're the server, respond. // If we're the server, respond - this is always necessary as it's used to
// know if peers are up or down.
if peer.Role == control.Server { if peer.Role == control.Server {
a.sendPing(peer, e.ping.ID, e.ping.PingTS) a.sendPing(peer, e.ping.ID, e.ping.PingTS)
} }
@@ -35,7 +34,7 @@ func (a *App) onPing(e PingEvent) {
// We can only learn our own endpoint from directly-connected peers — Dst // We can only learn our own endpoint from directly-connected peers — Dst
// is the sender's observation of our WG handshake source. // is the sender's observation of our WG handshake source.
if peer.State == StateDirect { if peer.State() == StateDirect {
if dst := e.ping.Dst; dst.IsValid() { if dst := e.ping.Dst; dst.IsValid() {
if dst.Addr().Is4() { if dst.Addr().Is4() {
a.selfV4 = dst a.selfV4 = dst
@@ -51,16 +50,11 @@ func (a *App) onPing(e PingEvent) {
func (a *App) addProbe(peer *Peer, v4, v6 netip.AddrPort) { func (a *App) addProbe(peer *Peer, v4, v6 netip.AddrPort) {
endpoint := preferredEndpoint(v4, v6) endpoint := preferredEndpoint(v4, v6)
if !endpoint.IsValid() || endpoint == peer.WGEndpoint { if !endpoint.IsValid() || endpoint == peer.WGEndpoint() {
return return
} }
peer.Endpoint4 = v4 peer.Endpoint4 = v4
peer.Endpoint6 = v6 peer.Endpoint6 = v6
peer.WGEndpoint = endpoint
if peer.State == StateRelayed {
peer.State = StateProbing
}
a.devAddProbe(peer, endpoint) a.devAddProbe(peer, endpoint)
} }

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"vppn/peer/control" "vppn/peer/control"
"vppn/peer/wginterface"
) )
func (a *App) onTick() { func (a *App) onTick() {
@@ -13,38 +14,36 @@ func (a *App) onTick() {
a.nextPingID++ a.nextPingID++
now := time.Now().UnixNano() now := time.Now().UnixNano()
// Update Up values.
for _, p := range a.peersByKey {
p.Up = p.Alive()
}
for _, wgPeer := range wgPeers { for _, wgPeer := range wgPeers {
p, ok := a.peersByKey[wgPeer.PublicKey] p, ok := a.peersByKey[wgPeer.PublicKey]
if !ok { if !ok {
log.Fatalf("Wireguard peer not in index: %v", wgPeer) log.Fatalf("Wireguard peer not in index: %v", wgPeer)
} }
p.wgPeer = wgPeer
// Send pings to peers where we're the client. // Send pings to peers where we're the client.
if p.Role == control.Client { if p.Role == control.Client {
a.sendPing(p, a.nextPingID, now) a.sendPing(p, a.nextPingID, now)
} }
// Promote probing peers to direct once alive (direct path confirmed switch p.State() {
// working). case StateProbing:
if p.State == StateProbing && time.Since(wgPeer.LastHandshakeTime) < 2*PingInterval { // Promote probing peers to direct once alive (direct path confirmed
p.State = StateDirect // working).
a.devAddDirect(p, p.WGEndpoint) if time.Since(p.LastHandshakeTime()) < wginterface.SessionTimeout {
} a.devAddDirect(p, p.WGEndpoint())
}
// Demote stale non-public direct peers back to probing. case StateDirect:
if p.State == StateDirect && !p.IsPublic && !p.Up { // Demote stale non-public direct peers back to probing.
p.State = StateProbing if !p.IsPublic && !p.Up() {
a.devAddProbe(p, p.WGEndpoint) a.devAddProbe(p, p.WGEndpoint())
}
} }
} }
// Ensure we have a live relay. // Ensure we have a live relay.
if a.relay == nil || !a.relay.Up { if a.relay == nil || !a.relay.Up() {
a.switchActiveRelay() a.switchActiveRelay()
} }
} }

View File

@@ -7,6 +7,7 @@ import (
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/peer/control" "vppn/peer/control"
"vppn/peer/wginterface"
) )
type PeerState string type PeerState string
@@ -18,27 +19,53 @@ const (
) )
type Peer struct { type Peer struct {
PubKey wgtypes.Key // WireGuard public key. wgPeer wgtypes.Peer
VPNIP netip.Addr // VPN IP address. VPNIP netip.Addr // VPN IP address.
IsRelay bool // Peer is a relay. IsRelay bool // Peer is a relay.
IsPublic bool // Peer has a public IP. IsPublic bool // Peer has a public IP.
Endpoint4 netip.AddrPort // Reported IPv4 endpoint. Endpoint4 netip.AddrPort // Reported IPv4 endpoint.
Endpoint6 netip.AddrPort // Reported IPv6 endpoint. Endpoint6 netip.AddrPort // Reported IPv6 endpoint.
ObservedEndpoint netip.AddrPort // If we're public: WG handshake source endpoint. RTT time.Duration // Round-trip time.
LastPing time.Time // Time of last ping received. Role control.Role // Client initiates pings; server responds.
RTT time.Duration // Round-trip time.
Role control.Role // Client initiates pings; server responds.
State PeerState // Current connection state.
Up bool // Is the peer alive.
WGEndpoint netip.AddrPort // Endpoint currently in WG (direct/probe).
} }
func (p *Peer) Alive() bool { // PubKey is the wireguard public key.
return time.Since(p.LastPing) < TimeoutInterval func (p *Peer) PubKey() wgtypes.Key {
return p.wgPeer.PublicKey
}
func (p *Peer) State() PeerState {
if len(p.wgPeer.AllowedIPs) > 0 {
return StateDirect
}
if p.wgPeer.Endpoint == nil {
return StateRelayed
}
return StateProbing
}
func (p *Peer) WGEndpoint() netip.AddrPort {
ep := p.wgPeer.Endpoint
if ep == nil {
return netip.AddrPort{}
}
addr, ok := netip.AddrFromSlice(ep.IP)
if !ok {
return netip.AddrPort{}
}
return netip.AddrPortFrom(addr.Unmap(), uint16(ep.Port))
}
func (p *Peer) LastHandshakeTime() time.Time {
return p.wgPeer.LastHandshakeTime
}
func (p *Peer) Up() bool {
return time.Since(p.wgPeer.LastHandshakeTime) < wginterface.SessionTimeout
} }
func (p *Peer) CanRelay() bool { func (p *Peer) CanRelay() bool {
return p.IsRelay && p.Up && p.WGEndpoint.IsValid() return p.IsRelay && p.Up()
} }
func (p *Peer) PreferredEndpoint() netip.AddrPort { func (p *Peer) PreferredEndpoint() netip.AddrPort {

View File

@@ -21,6 +21,7 @@ import (
// Create creates a WireGuard interface named name, assigns vpnIP/prefixLen to // Create creates a WireGuard interface named name, assigns vpnIP/prefixLen to
// it, and brings it up. // it, and brings it up.
func Create(name string, vpnIP net.IP, prefixLen int) error { func Create(name string, vpnIP net.IP, prefixLen int) error {
_ = Delete(name) // remove any stale interface left by a previous run
if err := nlNewLink(name); err != nil { if err := nlNewLink(name); err != nil {
return fmt.Errorf("failed to create wireguard link: %w", err) return fmt.Errorf("failed to create wireguard link: %w", err)
} }

View File

@@ -84,6 +84,17 @@ func (d *Device) Peer(pubKey wgtypes.Key) (wgtypes.Peer, error) {
return wgtypes.Peer{}, fmt.Errorf("peer %v not found in %q", pubKey, d.name) return wgtypes.Peer{}, fmt.Errorf("peer %v not found in %q", pubKey, d.name)
} }
// AddPeer registers a peer with no AllowedIPs and no endpoint. WireGuard will
// accept handshakes from this peer but route no traffic to it yet.
func (d *Device) AddPeer(pubKey wgtypes.Key) error {
return d.client.ConfigureDevice(d.name, wgtypes.Config{
Peers: []wgtypes.PeerConfig{{
PublicKey: pubKey,
ReplaceAllowedIPs: true,
}},
})
}
// SetRelay configures the relay peer with AllowedIPs covering the entire VPN // SetRelay configures the relay peer with AllowedIPs covering the entire VPN
// network prefix. This is the fallback route for all VPN traffic. // network prefix. This is the fallback route for all VPN traffic.
func (d *Device) SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error { func (d *Device) SetRelay(pubKey wgtypes.Key, endpoint netip.AddrPort, network netip.Prefix) error {