diff --git a/peer/app.go b/peer/app.go index 78a0219..1fe68da 100644 --- a/peer/app.go +++ b/peer/app.go @@ -11,6 +11,7 @@ import ( "vppn/m" "vppn/peer/control" + "vppn/peer/multicast" "vppn/peer/wginterface" ) @@ -27,12 +28,6 @@ type PingEvent struct { 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 // accessed only from the Run goroutine. type App struct { @@ -58,11 +53,14 @@ type App struct { selfV4 netip.AddrPort selfV6 netip.AddrPort + // Reusable scratch for multicast signature verification (event loop only). + mcVerifyBuf []byte + // Event channels fed by background goroutines hubAddCh <-chan m.Peer hubRemoveCh <-chan wgtypes.Key pingCh <-chan PingEvent - multicastCh <-chan MulticastEvent + multicastCh <-chan multicast.Packet } // Run is the main event loop. It runs until SIGTERM/SIGINT. diff --git a/peer/app_test.go b/peer/app_test.go index 569b2f1..9d08718 100644 --- a/peer/app_test.go +++ b/peer/app_test.go @@ -8,6 +8,7 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "vppn/m" + "vppn/peer/multicast" ) // 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), hubRemoveCh: make(chan wgtypes.Key), pingCh: make(chan PingEvent), - multicastCh: make(chan MulticastEvent), + multicastCh: make(chan multicast.Packet), } return a, dev, cc } diff --git a/peer/multicast.go b/peer/multicast.go deleted file mode 100644 index f2e1b8e..0000000 --- a/peer/multicast.go +++ /dev/null @@ -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(), - } - } -} diff --git a/peer/new.go b/peer/new.go index 10bbd55..18b2ec7 100644 --- a/peer/new.go +++ b/peer/new.go @@ -7,6 +7,7 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "vppn/m" + "vppn/peer/multicast" "vppn/peer/wginterface" ) @@ -60,7 +61,7 @@ func New( pingCh := make(chan PingEvent) hubAddCh := make(chan m.Peer) hubRemoveCh := make(chan wgtypes.Key) - multicastCh := make(chan MulticastEvent) + multicastCh := make(chan multicast.Packet) poller, err := NewHubPoller( state.VPNIP, @@ -79,8 +80,8 @@ func New( go poller.Run() if !state.IsPublic { - go RunMCWriter(state.VPNIP, state.PrivKey.PublicKey(), state.WGPort, &state.SignKey) - go RunMCReader(state.VPNNet, state.VPNIP, multicastCh) + go multicast.Broadcast(state.VPNIP, state.PrivKey.PublicKey(), state.WGPort, &state.SignKey) + go multicast.Receiver(state.VPNNet, state.VPNIP, multicastCh) } return &App{ @@ -98,6 +99,8 @@ func New( peersByKey: make(map[wgtypes.Key]*Peer), peersByIP: make(map[netip.Addr]*Peer), + mcVerifyBuf: make([]byte, 0, multicast.SignedPacketSize), + hubAddCh: hubAddCh, hubRemoveCh: hubRemoveCh, pingCh: pingCh, diff --git a/peer/on_multicast.go b/peer/on_multicast.go index 9db716b..a0ef21e 100644 --- a/peer/on_multicast.go +++ b/peer/on_multicast.go @@ -1,57 +1,46 @@ package peer import ( - "encoding/binary" "net/netip" - "golang.org/x/crypto/nacl/sign" "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 { return } - // Peek at the VPN IP byte to find the sender peer before verifying. - // nacl/sign prepends a 64-byte signature, so payload starts at offset sign.Overhead. - if len(e.signed) != mcSignedBeaconLen { - return - } - netAddr := a.vpnNet.Addr().As4() - octets := netAddr - octets[3] = e.signed[sign.Overhead] + // Locate the sender peer by its VPN IP (final octet carried in the beacon). + octets := a.vpnNet.Addr().As4() + octets[3] = pkt.PeerIP vpnIP := netip.AddrFrom4(octets) peer, ok := a.peersByIP[vpnIP] - if !ok { + if !ok || peer.IsPublic || peer.State == StateDirect { 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 } - payload, ok := sign.Open(nil, e.signed, &peer.SignPubKey) - if !ok { + // The beacon is authentic but must also advertise the WG key the hub gave + // us for this peer; otherwise it's inconsistent — drop it. + if wgtypes.Key(pkt.WGPubKey) != peer.PubKey() { return } - // payload: [1 VPN IP byte][32 WG pubkey][2 WG port][8 timestamp] - // (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) + endpoint := netip.AddrPortFrom(pkt.Src, pkt.WGPort) if !endpoint.IsValid() { return } var v4, v6 netip.AddrPort - if e.src.Is4() { + if pkt.Src.Is4() { v4 = endpoint } else { v6 = endpoint