Cleanup - WIP

This commit is contained in:
jdl
2026-06-11 18:11:21 +02:00
parent ab2837817d
commit 0902341322
12 changed files with 223 additions and 160 deletions

View File

@@ -10,6 +10,7 @@ import (
"git.crumpington.com/lib/go/webutil"
"golang.org/x/crypto/bcrypt"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func (a *App) _root(s *api.Session, w http.ResponseWriter, r *http.Request) error {
@@ -366,19 +367,26 @@ func (a *App) peersList(networkID int64) (peers []m.Peer, err error) {
peers = make([]m.Peer, 0, len(l))
for _, p := range l {
if len(p.WGPubKey) != 0 {
peers = append(peers, m.Peer{
PeerIP: p.PeerIP,
Version: p.Version,
Name: p.Name,
Addr4: p.Addr4,
Addr6: p.Addr6,
Port: p.Port,
Relay: p.Relay,
WGPubKey: p.WGPubKey,
SignPubKey: p.SignPubKey,
})
if len(p.WGPubKey) == 0 {
continue
}
wgKey, err := wgtypes.NewKey(p.WGPubKey)
if err != nil {
continue // malformed key; skip rather than serve garbage
}
var signKey [32]byte
copy(signKey[:], p.SignPubKey)
peers = append(peers, m.Peer{
PeerIP: p.PeerIP,
Version: p.Version,
Name: p.Name,
Addr4: addrFromBytes(p.Addr4),
Addr6: addrFromBytes(p.Addr6),
Port: p.Port,
Relay: p.Relay,
WGPubKey: wgKey,
SignPubKey: signKey,
})
}
return peers, nil

View File

@@ -38,6 +38,19 @@ func (app *App) sendJSON(w http.ResponseWriter, data any) error {
return nil
}
// addrFromBytes parses raw IP bytes (4 or 16) into a netip.Addr, unmapping
// IPv4-in-IPv6, returning the zero Addr for empty/invalid input.
func addrFromBytes(b []byte) netip.Addr {
if len(b) == 0 {
return netip.Addr{}
}
addr, ok := netip.AddrFromSlice(b)
if !ok {
return netip.Addr{}
}
return addr.Unmap()
}
func stringToIP(in string) ([]byte, error) {
in = strings.TrimSpace(in)
if len(in) == 0 {

View File

@@ -1,6 +1,15 @@
// The package `m` contains models shared between the hub and peer programs.
package m
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/netip"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
type PeerInitArgs struct {
WGPubKey []byte
SignPubKey []byte
@@ -13,16 +22,114 @@ type PeerInitResp struct {
NetworkState NetworkState
}
// Peer is the network membership record for a single peer, exchanged between
// the hub and peers. Addr4/Addr6 are the peer's public endpoint addresses (zero
// if it has none); Port is its WireGuard listen port, meaningful even for a
// non-public peer (it is the peer's own bind/beacon port).
type Peer struct {
PeerIP byte
Version int64
Name string
Addr4 []byte
Addr6 []byte
Addr4 netip.Addr // zero if none
Addr6 netip.Addr // zero if none
Port uint16
Relay bool
WGPubKey []byte
SignPubKey []byte
WGPubKey wgtypes.Key
SignPubKey [32]byte
}
// IsPublic reports whether the peer advertises at least one reachable endpoint.
func (p Peer) IsPublic() bool {
return p.Addr4.IsValid() || p.Addr6.IsValid()
}
// Endpoint4 returns the IPv4 endpoint (addr+port), or the zero AddrPort if the
// peer has no IPv4 address.
func (p Peer) Endpoint4() netip.AddrPort {
if !p.Addr4.IsValid() {
return netip.AddrPort{}
}
return netip.AddrPortFrom(p.Addr4, p.Port)
}
// Endpoint6 returns the IPv6 endpoint (addr+port), or the zero AddrPort if the
// peer has no IPv6 address.
func (p Peer) Endpoint6() netip.AddrPort {
if !p.Addr6.IsValid() {
return netip.AddrPort{}
}
return netip.AddrPortFrom(p.Addr6, p.Port)
}
// PreferredEndpoint returns the IPv4 endpoint if present, else IPv6.
func (p Peer) PreferredEndpoint() netip.AddrPort {
if ep := p.Endpoint4(); ep.IsValid() {
return ep
}
return p.Endpoint6()
}
// peerJSON is the wire representation. netip.Addr fields round-trip as text
// strings automatically; only the fixed-size key arrays need base64 (otherwise
// encoding/json would emit them as arrays of numbers).
type peerJSON struct {
PeerIP byte
Version int64
Name string
Addr4 netip.Addr
Addr6 netip.Addr
Port uint16
Relay bool
WGPubKey string
SignPubKey string
}
func (p Peer) MarshalJSON() ([]byte, error) {
return json.Marshal(peerJSON{
PeerIP: p.PeerIP,
Version: p.Version,
Name: p.Name,
Addr4: p.Addr4,
Addr6: p.Addr6,
Port: p.Port,
Relay: p.Relay,
WGPubKey: base64.StdEncoding.EncodeToString(p.WGPubKey[:]),
SignPubKey: base64.StdEncoding.EncodeToString(p.SignPubKey[:]),
})
}
func (p *Peer) UnmarshalJSON(data []byte) error {
var j peerJSON
if err := json.Unmarshal(data, &j); err != nil {
return err
}
wg, err := base64.StdEncoding.DecodeString(j.WGPubKey)
if err != nil {
return fmt.Errorf("decode WGPubKey: %w", err)
}
key, err := wgtypes.NewKey(wg)
if err != nil {
return fmt.Errorf("invalid WGPubKey: %w", err)
}
sign, err := base64.StdEncoding.DecodeString(j.SignPubKey)
if err != nil {
return fmt.Errorf("decode SignPubKey: %w", err)
}
if len(sign) != 32 {
return fmt.Errorf("invalid SignPubKey length: %d", len(sign))
}
*p = Peer{
PeerIP: j.PeerIP,
Version: j.Version,
Name: j.Name,
Addr4: j.Addr4,
Addr6: j.Addr6,
Port: j.Port,
Relay: j.Relay,
WGPubKey: key,
SignPubKey: [32]byte(sign),
}
return nil
}
type NetworkState struct {

View File

@@ -9,6 +9,7 @@ import (
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
"vppn/peer/control"
"vppn/peer/wginterface"
)
@@ -21,18 +22,6 @@ const (
TimeoutInterval = 30 * time.Second
)
// HubPeer is a peer entry as reported by the hub poller.
type HubPeer struct {
PubKey wgtypes.Key
VPNIP netip.Addr
Name string
IsRelay bool
IsPublic bool
EndpointV4 netip.AddrPort // zero if none
EndpointV6 netip.AddrPort // zero if none
SignPubKey [32]byte
}
type PingEvent struct {
srcVPNIP netip.Addr
ping control.Ping
@@ -70,7 +59,7 @@ type App struct {
selfV6 netip.AddrPort
// Event channels fed by background goroutines
hubAddCh <-chan HubPeer
hubAddCh <-chan m.Peer
hubRemoveCh <-chan wgtypes.Key
pingCh <-chan PingEvent
multicastCh <-chan MulticastEvent

View File

@@ -6,6 +6,8 @@ import (
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
)
// addRelayPeer adds a public relay peer and marks it Up so it satisfies
@@ -13,12 +15,13 @@ import (
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,
ip := netip.MustParseAddr(vpnIP)
a.onAddPeer(m.Peer{
WGPubKey: key,
PeerIP: ip.As4()[3],
Addr4: ep.Addr(),
Port: ep.Port(),
Relay: true,
})
p := a.peersByKey[key]
p.wgPeer.LastHandshakeTime = time.Now()
@@ -48,7 +51,7 @@ func newTestApp(t *testing.T, vpnIP string, isPublic, isRelay bool) (*App, *fake
controlConn: cc,
peersByKey: make(map[wgtypes.Key]*Peer),
peersByIP: make(map[netip.Addr]*Peer),
hubAddCh: make(chan HubPeer),
hubAddCh: make(chan m.Peer),
hubRemoveCh: make(chan wgtypes.Key),
pingCh: make(chan PingEvent),
multicastCh: make(chan MulticastEvent),

View File

@@ -22,7 +22,7 @@ type HubPoller struct {
hubURL string
apiKey string
statePath string // where the network state cache is persisted
addCh chan<- HubPeer
addCh chan<- m.Peer
removeCh chan<- wgtypes.Key
known map[wgtypes.Key]int64 // pubKey → last seen version
}
@@ -32,7 +32,7 @@ func NewHubPoller(
vpnNet netip.Prefix,
hubURL, apiKey string,
statePath string,
addCh chan<- HubPeer,
addCh chan<- m.Peer,
removeCh chan<- wgtypes.Key,
) (*HubPoller, error) {
u, err := url.Parse(hubURL)
@@ -119,12 +119,7 @@ func (hp *HubPoller) apply(state m.NetworkState) (changed bool) {
netAddr := hp.vpnNet.Addr().As4()
for _, p := range state.Peers {
if len(p.WGPubKey) != wgtypes.KeyLen || len(p.SignPubKey) != 32 {
continue
}
pubKey, err := wgtypes.NewKey(p.WGPubKey)
if err != nil {
if p.WGPubKey == (wgtypes.Key{}) {
continue
}
@@ -135,13 +130,13 @@ func (hp *HubPoller) apply(state m.NetworkState) (changed bool) {
continue
}
seen[pubKey] = struct{}{}
seen[p.WGPubKey] = struct{}{}
if v, ok := hp.known[pubKey]; ok && v == p.Version {
if v, ok := hp.known[p.WGPubKey]; ok && v == p.Version {
continue
}
hp.known[pubKey] = p.Version
hp.addCh <- hubPeerFrom(pubKey, vpnIP, p)
hp.known[p.WGPubKey] = p.Version
hp.addCh <- p
changed = true
}
@@ -155,30 +150,3 @@ func (hp *HubPoller) apply(state m.NetworkState) (changed bool) {
return changed
}
func hubPeerFrom(pubKey wgtypes.Key, vpnIP netip.Addr, p m.Peer) HubPeer {
var ep4, ep6 netip.AddrPort
if len(p.Addr4) > 0 {
if addr, ok := netip.AddrFromSlice(p.Addr4); ok {
ep4 = netip.AddrPortFrom(addr.Unmap(), p.Port)
}
}
if len(p.Addr6) > 0 {
if addr, ok := netip.AddrFromSlice(p.Addr6); ok {
ep6 = netip.AddrPortFrom(addr.Unmap(), p.Port)
}
}
var signPubKey [32]byte
copy(signPubKey[:], p.SignPubKey)
return HubPeer{
PubKey: pubKey,
VPNIP: vpnIP,
Name: p.Name,
IsRelay: p.Relay,
IsPublic: ep4.IsValid() || ep6.IsValid(),
EndpointV4: ep4,
EndpointV6: ep6,
SignPubKey: signPubKey,
}
}

View File

@@ -9,9 +9,9 @@ import (
"vppn/m"
)
func testPoller(t *testing.T) (*HubPoller, chan HubPeer, chan wgtypes.Key) {
func testPoller(t *testing.T) (*HubPoller, chan m.Peer, chan wgtypes.Key) {
t.Helper()
addCh := make(chan HubPeer, 8)
addCh := make(chan m.Peer, 8)
removeCh := make(chan wgtypes.Key, 8)
hp := &HubPoller{
selfVPNIP: netip.MustParseAddr("10.0.0.1"),
@@ -25,10 +25,9 @@ func testPoller(t *testing.T) (*HubPoller, chan HubPeer, chan wgtypes.Key) {
func stateWith(key wgtypes.Key, peerIP byte, version int64) m.NetworkState {
return m.NetworkState{Peers: []m.Peer{{
PeerIP: peerIP,
Version: version,
WGPubKey: key[:],
SignPubKey: make([]byte, 32),
PeerIP: peerIP,
Version: version,
WGPubKey: key,
}}}
}
@@ -42,7 +41,7 @@ func TestApply_EmitsAddsAndReportsChange(t *testing.T) {
if len(addCh) != 1 {
t.Fatalf("expected 1 add, got %d", len(addCh))
}
if got := <-addCh; got.PubKey != key {
if got := <-addCh; got.WGPubKey != key {
t.Errorf("add pubkey mismatch")
}
}

View File

@@ -78,10 +78,13 @@ func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error)
return LocalState{}, fmt.Errorf("generate sign key: %w", err)
}
body, _ := json.Marshal(m.PeerInitArgs{
body, err := json.Marshal(m.PeerInitArgs{
WGPubKey: wgPubKey[:],
SignPubKey: signPubKey[:],
})
if err != nil {
return LocalState{}, fmt.Errorf("json error: %w", err)
}
req, err := http.NewRequest(http.MethodPost, hubURL+"/peer/init/", bytes.NewReader(body))
if err != nil {
@@ -126,31 +129,15 @@ func initFromHub(hubURL, apiKey string, privKey wgtypes.Key) (LocalState, error)
return LocalState{}, fmt.Errorf("hub init: no peer for own IP: %d", r.PeerIP)
}
var isRelay, public bool
var wgPort uint16
var ep4, ep6 netip.AddrPort
if len(self.Addr4) > 0 {
if addr, ok := netip.AddrFromSlice(self.Addr4); ok {
ep4 = netip.AddrPortFrom(addr.Unmap(), self.Port)
}
}
if len(self.Addr6) > 0 {
if addr, ok := netip.AddrFromSlice(self.Addr6); ok {
ep6 = netip.AddrPortFrom(addr.Unmap(), self.Port)
}
}
public = ep4.IsValid() || ep6.IsValid()
isRelay = self.Relay && public
wgPort = self.Port
public := self.IsPublic()
return LocalState{
PrivKey: privKey,
SignKey: *signPrivKey,
VPNIP: vpnIP,
VPNNet: vpnNet,
WGPort: wgPort,
IsRelay: isRelay,
WGPort: self.Port,
IsRelay: self.Relay && public,
IsPublic: public,
LocalDomain: r.LocalDomain,
}, nil

View File

@@ -1,6 +1,7 @@
package peer
import (
"net/netip"
"path/filepath"
"reflect"
"testing"
@@ -11,25 +12,27 @@ import (
func TestNetworkState_RoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "network.json")
var sign1 [32]byte
copy(sign1[:], []byte("0123456789abcdef0123456789abcdef"))
state := m.NetworkState{Peers: []m.Peer{
{
PeerIP: 1,
Version: 7,
Name: "hub",
Addr4: []byte{10, 11, 12, 1},
Addr4: netip.MustParseAddr("10.11.12.1"),
Port: 51820,
Relay: true,
WGPubKey: make([]byte, 32),
SignPubKey: make([]byte, 32),
WGPubKey: mustKey(t),
SignPubKey: sign1,
},
{
PeerIP: 10,
Version: 3,
Name: "laptop",
Addr4: []byte{10, 11, 12, 10},
Port: 51820,
WGPubKey: []byte("0123456789abcdef0123456789abcdef"),
SignPubKey: []byte("fedcba9876543210fedcba9876543210"),
PeerIP: 10,
Version: 3,
Name: "laptop",
Addr4: netip.MustParseAddr("10.11.12.10"),
Port: 51820,
WGPubKey: mustKey(t),
},
}}

View File

@@ -6,6 +6,7 @@ import (
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
"vppn/peer/wginterface"
)
@@ -57,7 +58,7 @@ func New(
}
pingCh := make(chan PingEvent)
hubAddCh := make(chan HubPeer)
hubAddCh := make(chan m.Peer)
hubRemoveCh := make(chan wgtypes.Key)
multicastCh := make(chan MulticastEvent)

View File

@@ -8,33 +8,31 @@ import (
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
"vppn/peer/control"
)
func (a *App) onAddPeer(p HubPeer) {
a.onRemovePeer(p.PubKey)
func (a *App) onAddPeer(p m.Peer) {
a.onRemovePeer(p.WGPubKey)
octets := a.vpnNet.Addr().As4()
octets[3] = p.PeerIP
vpnIP := netip.AddrFrom4(octets)
peer := &Peer{
wgPeer: wgtypes.Peer{PublicKey: p.PubKey},
VPNIP: p.VPNIP,
wgPeer: wgtypes.Peer{PublicKey: p.WGPubKey},
VPNIP: vpnIP,
Name: p.Name,
IsRelay: p.IsRelay,
IsPublic: p.IsPublic,
Endpoint4: p.EndpointV4,
Endpoint6: p.EndpointV6,
IsRelay: p.Relay,
IsPublic: p.IsPublic(),
Endpoint4: p.Endpoint4(),
Endpoint6: p.Endpoint6(),
RTT: time.Duration(math.MaxInt64) * time.Nanosecond,
Role: roleFor(a.isPublic, a.vpnIP, p),
Role: roleFor(a.isPublic, a.vpnIP, p.IsPublic(), vpnIP),
SignPubKey: p.SignPubKey,
}
endpoint := peer.PreferredEndpoint()
if peer.IsPublic && !endpoint.IsValid() {
// The peer is misconfigured.
// TODO: Log here.
return
}
a.peersByKey[p.PubKey] = peer
a.peersByKey[p.WGPubKey] = peer
a.peersByIP[peer.VPNIP] = peer
defer a.updateHosts()
@@ -50,7 +48,7 @@ func (a *App) onAddPeer(p HubPeer) {
return
}
a.devAddDirect(peer, endpoint)
a.devAddDirect(peer, peer.PreferredEndpoint())
}
func (a *App) onRemovePeer(key wgtypes.Key) {
@@ -104,12 +102,12 @@ func preferredEndpoint(v4, v6 netip.AddrPort) netip.AddrPort {
return v6
}
func roleFor(selfIsPublic bool, selfIP netip.Addr, p HubPeer) control.Role {
if !selfIsPublic && p.IsPublic {
func roleFor(selfIsPublic bool, selfIP netip.Addr, peerIsPublic bool, peerVPNIP netip.Addr) control.Role {
if !selfIsPublic && peerIsPublic {
return control.Client
}
if selfIsPublic && !p.IsPublic {
if selfIsPublic && !peerIsPublic {
return control.Server
}
return control.RoleFor(selfIP, p.VPNIP)
return control.RoleFor(selfIP, peerVPNIP)
}

View File

@@ -6,6 +6,8 @@ import (
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m"
)
func mustKey(t *testing.T) wgtypes.Key {
@@ -25,13 +27,13 @@ func TestOnAddPeer(t *testing.T) {
testCases := []struct {
name string
setup func(a *App, key wgtypes.Key)
peer func(key wgtypes.Key) HubPeer
peer func(key wgtypes.Key) m.Peer
check func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key)
}{
{
name: "non-public peer registered in WG via AddPeer",
peer: func(k wgtypes.Key) HubPeer {
return HubPeer{PubKey: k, VPNIP: peerVPNIP}
peer: func(k wgtypes.Key) m.Peer {
return m.Peer{WGPubKey: k, PeerIP: 2}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
p := a.peersByKey[key]
@@ -49,8 +51,8 @@ func TestOnAddPeer(t *testing.T) {
},
{
name: "public peer with endpoint registered via AddDirect",
peer: func(k wgtypes.Key) HubPeer {
return HubPeer{PubKey: k, VPNIP: peerVPNIP, IsPublic: true, EndpointV4: ep1}
peer: func(k wgtypes.Key) m.Peer {
return m.Peer{WGPubKey: k, PeerIP: 2, Addr4: ep1.Addr(), Port: ep1.Port()}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
p := a.peersByKey[key]
@@ -60,28 +62,13 @@ func TestOnAddPeer(t *testing.T) {
dev.AssertAddDirect(t, 0, p.PubKey(), ep1, 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})
a.onAddPeer(m.Peer{WGPubKey: key, PeerIP: 2, Addr4: ep1.Addr(), Port: ep1.Port()})
},
peer: func(k wgtypes.Key) HubPeer {
return HubPeer{PubKey: k, VPNIP: peerVPNIP, IsPublic: true, EndpointV4: ep2}
peer: func(k wgtypes.Key) m.Peer {
return m.Peer{WGPubKey: k, PeerIP: 2, Addr4: ep2.Addr(), Port: ep2.Port()}
},
check: func(t *testing.T, a *App, dev *fakeWGDevice, key wgtypes.Key) {
if len(dev.Calls) != 2 {
@@ -135,7 +122,7 @@ func TestOnRemovePeer(t *testing.T) {
name: "StateRelayed 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")})
a.onAddPeer(m.Peer{WGPubKey: key, PeerIP: 2})
return key
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {
@@ -152,7 +139,7 @@ func TestOnRemovePeer(t *testing.T) {
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})
a.onAddPeer(m.Peer{WGPubKey: key, PeerIP: 2, Addr4: ep1.Addr(), Port: ep1.Port()})
return key
},
check: func(t *testing.T, a *App, dev *fakeWGDevice) {