multicast cleanup

This commit is contained in:
jdl
2026-06-12 14:08:41 +02:00
parent 991fcffa68
commit 6856727985
5 changed files with 27 additions and 176 deletions

View File

@@ -11,6 +11,7 @@ import (
"vppn/m" "vppn/m"
"vppn/peer/control" "vppn/peer/control"
"vppn/peer/multicast"
"vppn/peer/wginterface" "vppn/peer/wginterface"
) )
@@ -27,12 +28,6 @@ type PingEvent struct {
ping control.Ping ping control.Ping
} }
// MulticastEvent carries a raw signed beacon for verification in the event loop.
type MulticastEvent struct {
signed []byte // nacl/sign signed beacon (64-byte sig || 35-byte payload)
src netip.Addr // physical LAN source address
}
// App is the peer application. All mutable state lives here and is // App is the peer application. All mutable state lives here and is
// accessed only from the Run goroutine. // accessed only from the Run goroutine.
type App struct { type App struct {
@@ -58,11 +53,14 @@ type App struct {
selfV4 netip.AddrPort selfV4 netip.AddrPort
selfV6 netip.AddrPort selfV6 netip.AddrPort
// Reusable scratch for multicast signature verification (event loop only).
mcVerifyBuf []byte
// Event channels fed by background goroutines // Event channels fed by background goroutines
hubAddCh <-chan m.Peer hubAddCh <-chan m.Peer
hubRemoveCh <-chan wgtypes.Key hubRemoveCh <-chan wgtypes.Key
pingCh <-chan PingEvent pingCh <-chan PingEvent
multicastCh <-chan MulticastEvent multicastCh <-chan multicast.Packet
} }
// Run is the main event loop. It runs until SIGTERM/SIGINT. // Run is the main event loop. It runs until SIGTERM/SIGINT.

View File

@@ -8,6 +8,7 @@ import (
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m" "vppn/m"
"vppn/peer/multicast"
) )
// addRelayPeer adds a public relay peer and marks it Up so it satisfies // addRelayPeer adds a public relay peer and marks it Up so it satisfies
@@ -54,7 +55,7 @@ func newTestApp(t *testing.T, vpnIP string, isPublic, isRelay bool) (*App, *fake
hubAddCh: make(chan m.Peer), hubAddCh: make(chan m.Peer),
hubRemoveCh: make(chan wgtypes.Key), hubRemoveCh: make(chan wgtypes.Key),
pingCh: make(chan PingEvent), pingCh: make(chan PingEvent),
multicastCh: make(chan MulticastEvent), multicastCh: make(chan multicast.Packet),
} }
return a, dev, cc return a, dev, cc
} }

View File

@@ -1,140 +0,0 @@
package peer
import (
"encoding/binary"
"fmt"
"log"
"net"
"net/netip"
"time"
"golang.org/x/crypto/nacl/sign"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
const (
// Beacon payload layout (after the 64-byte nacl/sign prefix):
// [0] final octet of the sender's VPN IP
// [1:33] WG public key
// [33:35] WG listen port (big-endian uint16)
// [35:43] send time, Unix seconds (big-endian int64) — freshness/replay gate
mcBeaconLen = 43
mcSignedBeaconLen = sign.Overhead + mcBeaconLen // 64-byte nacl/sign prefix + payload
mcBroadcastInterval = 32 * time.Second
mcErrorRetryInterval = 16 * time.Second
// mcBeaconMaxAge bounds how far a beacon's timestamp may be from now (in
// either direction) before the reader drops it: it tolerates modest clock
// skew between roughly-synchronized (NTP) hosts and caps the replay window.
mcBeaconMaxAge = 60 * time.Second
)
var mcAddr = net.UDPAddrFromAddrPort(netip.AddrPortFrom(
netip.AddrFrom4([4]byte{224, 0, 0, 157}),
4560))
// RunMCWriter broadcasts a signed beacon on the local multicast group every
// mcBroadcastInterval so that LAN peers can discover our WireGuard endpoint.
func RunMCWriter(selfVPNIP netip.Addr, pubKey wgtypes.Key, wgPort uint16, signKey *[64]byte) {
for {
runMCWriterInner(selfVPNIP, pubKey, wgPort, signKey)
time.Sleep(mcErrorRetryInterval)
}
}
func runMCWriterInner(selfVPNIP netip.Addr, pubKey wgtypes.Key, wgPort uint16, signKey *[64]byte) {
conn, err := net.ListenMulticastUDP("udp", nil, mcAddr)
if err != nil {
log.Printf("[MCWriter] bind: %v", err)
return
}
defer conn.Close()
// Re-sign on each send so the timestamp is fresh; a stale timestamp would be
// dropped by receivers' freshness gate.
send := func() error {
payload := buildBeacon(selfVPNIP, pubKey, wgPort, time.Now().Unix())
signed := sign.Sign(nil, payload, signKey)
_, err := conn.WriteToUDP(signed, mcAddr)
return err
}
if err := send(); err != nil {
log.Printf("[MCWriter] write: %v", err)
}
for range time.Tick(mcBroadcastInterval) {
if err := send(); err != nil {
log.Printf("[MCWriter] write: %v", err)
return
}
}
}
func buildBeacon(selfVPNIP netip.Addr, pubKey wgtypes.Key, wgPort uint16, ts int64) []byte {
beacon := make([]byte, mcBeaconLen)
beacon[0] = selfVPNIP.As4()[3]
copy(beacon[1:33], pubKey[:])
binary.BigEndian.PutUint16(beacon[33:35], wgPort)
binary.BigEndian.PutUint64(beacon[35:43], uint64(ts))
return beacon
}
// RunMCReader listens for multicast beacons from LAN peers and feeds
// MulticastEvents to ch.
func RunMCReader(vpnNet netip.Prefix, selfVPNIP netip.Addr, ch chan<- MulticastEvent) {
for {
if err := runMCReaderInner(vpnNet, selfVPNIP, ch); err != nil {
log.Printf("[MCReader] %v", err)
}
time.Sleep(mcErrorRetryInterval)
}
}
func runMCReaderInner(vpnNet netip.Prefix, selfVPNIP netip.Addr, ch chan<- MulticastEvent) error {
conn, err := net.ListenMulticastUDP("udp", nil, mcAddr)
if err != nil {
return fmt.Errorf("bind: %w", err)
}
defer conn.Close()
buf := make([]byte, mcSignedBeaconLen+1) // +1 to detect oversized packets
netAddr := vpnNet.Addr().As4()
for {
conn.SetReadDeadline(time.Now().Add(32 * time.Second))
n, src, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return fmt.Errorf("read: %w", err)
}
if n != mcSignedBeaconLen {
continue
}
// Cheap pre-filters on the unverified payload, before the costly
// signature check in the event loop: skip our own beacon, and drop
// stale ones (replay/freshness gate). The timestamp is authenticated by
// sign.Open later, so a forged-fresh timestamp still fails there — this
// only spares us verifying old replays.
octets := netAddr
octets[3] = buf[sign.Overhead]
vpnIP := netip.AddrFrom4(octets)
if vpnIP == selfVPNIP {
continue
}
if age := beaconAge(buf, time.Now()); age > mcBeaconMaxAge || age < -mcBeaconMaxAge {
continue
}
signed := make([]byte, mcSignedBeaconLen)
copy(signed, buf[:mcSignedBeaconLen])
ch <- MulticastEvent{
signed: signed,
src: src.Addr().Unmap(),
}
}
}

View File

@@ -7,6 +7,7 @@ import (
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/m" "vppn/m"
"vppn/peer/multicast"
"vppn/peer/wginterface" "vppn/peer/wginterface"
) )
@@ -60,7 +61,7 @@ func New(
pingCh := make(chan PingEvent) pingCh := make(chan PingEvent)
hubAddCh := make(chan m.Peer) hubAddCh := make(chan m.Peer)
hubRemoveCh := make(chan wgtypes.Key) hubRemoveCh := make(chan wgtypes.Key)
multicastCh := make(chan MulticastEvent) multicastCh := make(chan multicast.Packet)
poller, err := NewHubPoller( poller, err := NewHubPoller(
state.VPNIP, state.VPNIP,
@@ -79,8 +80,8 @@ func New(
go poller.Run() go poller.Run()
if !state.IsPublic { if !state.IsPublic {
go RunMCWriter(state.VPNIP, state.PrivKey.PublicKey(), state.WGPort, &state.SignKey) go multicast.Broadcast(state.VPNIP, state.PrivKey.PublicKey(), state.WGPort, &state.SignKey)
go RunMCReader(state.VPNNet, state.VPNIP, multicastCh) go multicast.Receiver(state.VPNNet, state.VPNIP, multicastCh)
} }
return &App{ return &App{
@@ -98,6 +99,8 @@ func New(
peersByKey: make(map[wgtypes.Key]*Peer), peersByKey: make(map[wgtypes.Key]*Peer),
peersByIP: make(map[netip.Addr]*Peer), peersByIP: make(map[netip.Addr]*Peer),
mcVerifyBuf: make([]byte, 0, multicast.SignedPacketSize),
hubAddCh: hubAddCh, hubAddCh: hubAddCh,
hubRemoveCh: hubRemoveCh, hubRemoveCh: hubRemoveCh,
pingCh: pingCh, pingCh: pingCh,

View File

@@ -1,57 +1,46 @@
package peer package peer
import ( import (
"encoding/binary"
"net/netip" "net/netip"
"golang.org/x/crypto/nacl/sign"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes" "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"vppn/peer/multicast"
) )
func (a *App) onMulticastDiscovery(e MulticastEvent) { func (a *App) onMulticastDiscovery(pkt multicast.Packet) {
if a.isPublic { if a.isPublic {
return return
} }
// Peek at the VPN IP byte to find the sender peer before verifying. // Locate the sender peer by its VPN IP (final octet carried in the beacon).
// nacl/sign prepends a 64-byte signature, so payload starts at offset sign.Overhead. octets := a.vpnNet.Addr().As4()
if len(e.signed) != mcSignedBeaconLen { octets[3] = pkt.PeerIP
return
}
netAddr := a.vpnNet.Addr().As4()
octets := netAddr
octets[3] = e.signed[sign.Overhead]
vpnIP := netip.AddrFrom4(octets) vpnIP := netip.AddrFrom4(octets)
peer, ok := a.peersByIP[vpnIP] peer, ok := a.peersByIP[vpnIP]
if !ok { if !ok || peer.IsPublic || peer.State == StateDirect {
return return
} }
if peer.IsPublic || peer.State == StateDirect { // Authenticate the beacon against the peer's known sign key.
if !pkt.Verify(a.mcVerifyBuf, &peer.SignPubKey) {
return return
} }
payload, ok := sign.Open(nil, e.signed, &peer.SignPubKey) // The beacon is authentic but must also advertise the WG key the hub gave
if !ok { // us for this peer; otherwise it's inconsistent — drop it.
if wgtypes.Key(pkt.WGPubKey) != peer.PubKey() {
return return
} }
// payload: [1 VPN IP byte][32 WG pubkey][2 WG port][8 timestamp] endpoint := netip.AddrPortFrom(pkt.Src, pkt.WGPort)
// (timestamp freshness is gated in the reader before this point).
wgPubKey, err := wgtypes.NewKey(payload[1:33])
if err != nil || wgPubKey != peer.PubKey() {
return
}
wgPort := binary.BigEndian.Uint16(payload[33:35])
endpoint := netip.AddrPortFrom(e.src, wgPort)
if !endpoint.IsValid() { if !endpoint.IsValid() {
return return
} }
var v4, v6 netip.AddrPort var v4, v6 netip.AddrPort
if e.src.Is4() { if pkt.Src.Is4() {
v4 = endpoint v4 = endpoint
} else { } else {
v6 = endpoint v6 = endpoint