Files
netbird/sharedsock/sock_linux.go
Riccardo Manfrin 1e5b0a5c89 [client] Make Test_ConnectPeers deterministic under Docker/eBPF kernel / Darwin CI (#6884)
## Describe your changes

Make `Test_ConnectPeers` deterministic. Two issues, both surfaced once
the
privileged suite moved into a `--privileged` Docker container (#6425):

1. The peers used `getLocalIP()` as their WireGuard endpoint, i.e. the
host's
routable NIC IP (the docker bridge IP `172.17.0.2` in CI). That address
might
not hairpin reliably inside the container, so the handshake
intermittently
timed out (flaky). Use loopback (`127.1.0.x`) instead — always
self-reachable.
2. On a Linux runner with the WG kernel module the iface uses the eBPF
proxy
factory. Its manager is a singleton with one shared XDP program +
settings
map, so bringing up the two ifaces makes the second factory overwrite
the
first's `wg_port`/`proxy_port` and the handshake is dropped. The test is
   incompatible with the eBPF factory, so disable it via
`NB_DISABLE_EBPF_WG_PROXY` (peers then handshake directly over
loopback).
Running the suite across all three modes (eBPF / UDP proxy / ICE bind)
would
   need a larger refactor.

Also fixes a typo in the `ErrSharedSockStopped` message (`socked` →
`socket`).

## Issue ticket number and link

No public issue — CI flakiness follow-up to #6871 on `Test_ConnectPeers`

(https://github.com/netbirdio/netbird/blob/main/client/iface/iface_test.go).

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

Test-only change (plus a log-string typo). No public API, CLI, config,
or
behavior change.

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

N/A

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6884"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787485967&installation_model_id=427504&pr_number=6884&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6884&signature=09fb996715d039bd02775a140e8cc03deca5cb42540790b82a744527264a30ad"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Corrected the “shared socket stopped” error message for clearer
output.
* **Tests**
* Improved peer connection test reliability in privileged CI by
disabling the eBPF WireGuard proxy and using fixed loopback UDP
endpoints for deterministic setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-24 15:23:22 +02:00

348 lines
9.2 KiB
Go

//go:build linux && !android
// Inspired by
// Jason Donenfeld (https://git.zx2c4.com/wireguard-tools/tree/contrib/nat-hole-punching/nat-punch-client.c#n96)
// and @stv0g in https://github.com/stv0g/cunicu/tree/ebpf-poc/ebpf_poc
package sharedsock
import (
"context"
"fmt"
"net"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/mdlayher/socket"
log "github.com/sirupsen/logrus"
"github.com/vishvananda/netlink"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/unix"
nbnet "github.com/netbirdio/netbird/client/net"
)
// ErrSharedSockStopped indicates that shared socket has been stopped
var ErrSharedSockStopped = fmt.Errorf("shared socket stopped")
// SharedSocket is a net.PacketConn that initiates two raw sockets (ipv4 and ipv6) and listens to UDP packets filtered
// by BPF instructions (e.g., IncomingSTUNFilter that checks and sends only STUN packets to the listeners (ReadFrom)).
// It is meant to be used when sharing a port with some other process.
type SharedSocket struct {
ctx context.Context
conn4 *socket.Conn
conn6 *socket.Conn
port int
mtu uint16
packetDemux chan rcvdPacket
cancel context.CancelFunc
}
type rcvdPacket struct {
n int
addr unix.Sockaddr
buf []byte
err error
}
type receiver func(ctx context.Context, p []byte, flags int) (int, unix.Sockaddr, error)
var writeSerializerOptions = gopacket.SerializeOptions{
ComputeChecksums: true,
FixLengths: true,
}
// Maximum overhead for IP + UDP headers on raw socket
// IPv4: max 60 bytes (20 base + 40 options) + UDP 8 bytes = 68 bytes
// IPv6: 40 bytes + UDP 8 bytes = 48 bytes
// We use the maximum (68) for both IPv4 and IPv6
const maxIPUDPOverhead = 68
// Listen creates an IPv4 and IPv6 raw sockets, starts a reader and routing table routines
func Listen(port int, filter BPFFilter, mtu uint16) (_ net.PacketConn, err error) {
ctx, cancel := context.WithCancel(context.Background())
rawSock := &SharedSocket{
ctx: ctx,
cancel: cancel,
mtu: mtu,
port: port,
packetDemux: make(chan rcvdPacket),
}
defer func() {
if err != nil {
if closeErr := rawSock.Close(); closeErr != nil {
log.Errorf("Failed to close raw socket: %v", closeErr)
}
}
}()
rawSock.conn4, err = socket.Socket(unix.AF_INET, unix.SOCK_RAW, unix.IPPROTO_UDP, "raw_udp4", nil)
if err != nil {
return nil, fmt.Errorf("failed to create ipv4 raw socket: %w", err)
}
if err = nbnet.SetSocketMark(rawSock.conn4); err != nil {
return nil, fmt.Errorf("set SO_MARK on ipv4 socket: %w", err)
}
var sockErr error
rawSock.conn6, sockErr = socket.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_UDP, "raw_udp6", nil)
if sockErr != nil {
log.Errorf("Failed to create ipv6 raw socket: %v", err)
} else {
if err = nbnet.SetSocketMark(rawSock.conn6); err != nil {
return nil, fmt.Errorf("set SO_MARK on ipv6 socket: %w", err)
}
}
ipv4Instructions, ipv6Instructions, err := filter.GetInstructions(uint32(rawSock.port))
if err != nil {
return nil, fmt.Errorf("getBPFInstructions failed with: %w", err)
}
err = rawSock.conn4.SetBPF(ipv4Instructions)
if err != nil {
return nil, fmt.Errorf("socket4.SetBPF failed with: %w", err)
}
if rawSock.conn6 != nil {
err = rawSock.conn6.SetBPF(ipv6Instructions)
if err != nil {
return nil, fmt.Errorf("socket6.SetBPF failed with: %w", err)
}
}
go rawSock.read(rawSock.conn4.Recvfrom)
if rawSock.conn6 != nil {
go rawSock.read(rawSock.conn6.Recvfrom)
}
return rawSock, nil
}
// resolveSrc returns the source IP the kernel will pick for a packet sent to
// dst by these raw sockets, mirroring the fwmark the kernel will see on send.
func (s *SharedSocket) resolveSrc(dst net.IP) (net.IP, error) {
opts := &netlink.RouteGetOptions{}
if nbnet.AdvancedRouting() {
opts.Mark = nbnet.ControlPlaneMark
}
routes, err := netlink.RouteGetWithOptions(dst, opts)
if err != nil {
return nil, fmt.Errorf("route get %s: %w", dst, err)
}
for _, r := range routes {
if r.Src != nil {
return r.Src, nil
}
}
return nil, fmt.Errorf("no source IP for %s", dst)
}
// LocalAddr returns the local address, preferring IPv4 for backward compatibility.
func (s *SharedSocket) LocalAddr() net.Addr {
if s.conn4 != nil {
return &net.UDPAddr{
IP: net.IPv4zero,
Port: s.port,
}
}
if s.conn6 != nil {
return &net.UDPAddr{
IP: net.IPv6zero,
Port: s.port,
}
}
return &net.UDPAddr{
IP: net.IPv4zero,
Port: s.port,
}
}
// SetDeadline sets both the read and write deadlines associated with the ipv4 and ipv6 Conn sockets
func (s *SharedSocket) SetDeadline(t time.Time) error {
err := s.conn4.SetDeadline(t)
if err != nil {
return fmt.Errorf("s.conn4.SetDeadline error: %w", err)
}
if s.conn6 == nil {
return nil
}
err = s.conn6.SetDeadline(t)
if err != nil {
return fmt.Errorf("s.conn6.SetDeadline error: %w", err)
}
return nil
}
// SetReadDeadline sets the read deadline associated with the ipv4 and ipv6 Conn sockets
func (s *SharedSocket) SetReadDeadline(t time.Time) error {
err := s.conn4.SetReadDeadline(t)
if err != nil {
return fmt.Errorf("s.conn4.SetReadDeadline error: %w", err)
}
if s.conn6 == nil {
return nil
}
err = s.conn6.SetReadDeadline(t)
if err != nil {
return fmt.Errorf("s.conn6.SetReadDeadline error: %w", err)
}
return nil
}
// SetWriteDeadline sets the write deadline associated with the ipv4 and ipv6 Conn sockets
func (s *SharedSocket) SetWriteDeadline(t time.Time) error {
err := s.conn4.SetWriteDeadline(t)
if err != nil {
return fmt.Errorf("s.conn4.SetWriteDeadline error: %w", err)
}
if s.conn6 == nil {
return nil
}
err = s.conn6.SetWriteDeadline(t)
if err != nil {
return fmt.Errorf("s.conn6.SetWriteDeadline error: %w", err)
}
return nil
}
// Close closes the underlying ipv4 and ipv6 conn sockets
func (s *SharedSocket) Close() error {
s.cancel()
errGrp := errgroup.Group{}
if s.conn4 != nil {
errGrp.Go(s.conn4.Close)
}
if s.conn6 != nil {
errGrp.Go(s.conn6.Close)
}
return errGrp.Wait()
}
// read start a read loop for a specific receiver and sends the packet to the packetDemux channel
func (s *SharedSocket) read(receiver receiver) {
for {
buf := make([]byte, s.mtu+maxIPUDPOverhead)
n, addr, err := receiver(s.ctx, buf, 0)
select {
case <-s.ctx.Done():
return
case s.packetDemux <- rcvdPacket{n, addr, buf[:n], err}:
}
}
}
// ReadFrom reads packets received in the packetDemux channel
func (s *SharedSocket) ReadFrom(b []byte) (int, net.Addr, error) {
var pkt rcvdPacket
select {
case <-s.ctx.Done():
return -1, nil, ErrSharedSockStopped
case pkt = <-s.packetDemux:
}
if pkt.err != nil {
return -1, nil, pkt.err
}
var ip4layer layers.IPv4
var udp layers.UDP
var payload gopacket.Payload
var parser *gopacket.DecodingLayerParser
var ip net.IP
if sa, isIPv4 := pkt.addr.(*unix.SockaddrInet4); isIPv4 {
ip = sa.Addr[:]
parser = gopacket.NewDecodingLayerParser(layers.LayerTypeIPv4, &ip4layer, &udp, &payload)
} else if sa, isIPv6 := pkt.addr.(*unix.SockaddrInet6); isIPv6 {
ip = sa.Addr[:]
parser = gopacket.NewDecodingLayerParser(layers.LayerTypeUDP, &udp, &payload)
} else {
return -1, nil, fmt.Errorf("received invalid address family")
}
decodedLayers := make([]gopacket.LayerType, 0, 3)
if err := parser.DecodeLayers(pkt.buf, &decodedLayers); err != nil {
return 0, nil, err
}
remoteAddr := &net.UDPAddr{
IP: ip,
Port: int(udp.SrcPort),
}
n := copy(b, payload)
return n, remoteAddr, nil
}
// WriteTo builds a UDP packet and writes it using the specific IP version writer
func (s *SharedSocket) WriteTo(buf []byte, rAddr net.Addr) (n int, err error) {
rUDPAddr, ok := rAddr.(*net.UDPAddr)
if !ok {
return -1, fmt.Errorf("invalid address type")
}
buffer := gopacket.NewSerializeBuffer()
payload := gopacket.Payload(buf)
udp := &layers.UDP{
SrcPort: layers.UDPPort(s.port),
DstPort: layers.UDPPort(rUDPAddr.Port),
}
src, err := s.resolveSrc(rUDPAddr.IP)
if err != nil {
return 0, fmt.Errorf("resolve source for %s: %w", rUDPAddr.IP, err)
}
rSockAddr, conn, nwLayer := s.getWriterObjects(src, rUDPAddr.IP)
if conn == nil {
return 0, fmt.Errorf("no raw socket for %s", rUDPAddr.IP)
}
if err := udp.SetNetworkLayerForChecksum(nwLayer); err != nil {
return -1, fmt.Errorf("failed to set network layer for checksum: %w", err)
}
if err := gopacket.SerializeLayers(buffer, writeSerializerOptions, udp, payload); err != nil {
return -1, fmt.Errorf("failed serialize rcvdPacket: %w", err)
}
bufser := buffer.Bytes()
return 0, conn.Sendto(context.TODO(), bufser, 0, rSockAddr)
}
// getWriterObjects returns the specific IP version objects that are used to build a packet and send it using the raw socket
func (s *SharedSocket) getWriterObjects(src, dest net.IP) (sa unix.Sockaddr, conn *socket.Conn, layer gopacket.NetworkLayer) {
if dest.To4() == nil {
sa = &unix.SockaddrInet6{}
copy(sa.(*unix.SockaddrInet6).Addr[:], dest.To16())
conn = s.conn6
layer = &layers.IPv6{
SrcIP: src,
DstIP: dest,
}
} else {
sa = &unix.SockaddrInet4{}
copy(sa.(*unix.SockaddrInet4).Addr[:], dest.To4())
conn = s.conn4
layer = &layers.IPv4{
Version: 4,
TTL: 64,
Protocol: layers.IPProtocolUDP,
SrcIP: src,
DstIP: dest,
}
}
return sa, conn, layer
}