From a68f2b668fdb6261b18f2681fb06bd7208754083 Mon Sep 17 00:00:00 2001 From: hawkff Date: Sun, 28 Jun 2026 19:58:43 -0400 Subject: [PATCH] feat: apply socket protection to Pion --- go.mod | 4 +- internal/engine/jitsi/jitsi.go | 16 +- internal/protect/pionnet.go | 247 +++++++++++++++++++++++++++++++ internal/protect/pionnet_test.go | 220 +++++++++++++++++++++++++++ 4 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 internal/protect/pionnet.go create mode 100644 internal/protect/pionnet_test.go diff --git a/go.mod b/go.mod index d405868..e62e62d 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/livekit/protocol v1.46.0 github.com/livekit/server-sdk-go/v2 v2.16.4-0.20260522175902-00c9771fae5a github.com/magefile/mage v1.17.2 + github.com/pion/ice/v4 v4.2.5 github.com/pion/interceptor v0.1.45 github.com/pion/logging v0.2.4 github.com/pion/rtp v1.10.2 + github.com/pion/transport/v4 v4.0.1 github.com/pion/webrtc/v4 v4.2.13 github.com/xtaci/kcp-go/v5 v5.6.72 github.com/xtaci/smux v1.5.57 @@ -57,7 +59,6 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/pion/datachannel v1.6.0 // indirect github.com/pion/dtls/v3 v3.1.2 // indirect - github.com/pion/ice/v4 v4.2.5 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.16 // indirect @@ -65,7 +66,6 @@ require ( github.com/pion/sdp/v3 v3.0.18 // indirect github.com/pion/srtp/v3 v3.0.10 // indirect github.com/pion/stun/v3 v3.1.2 // indirect - github.com/pion/transport/v4 v4.0.1 // indirect github.com/pion/turn/v5 v5.0.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect diff --git a/internal/engine/jitsi/jitsi.go b/internal/engine/jitsi/jitsi.go index ca5cd80..636b738 100644 --- a/internal/engine/jitsi/jitsi.go +++ b/internal/engine/jitsi/jitsi.go @@ -32,6 +32,8 @@ import ( "github.com/openlibrecommunity/olcrtc/internal/engine" "github.com/openlibrecommunity/olcrtc/internal/logger" + "github.com/openlibrecommunity/olcrtc/internal/protect" + "github.com/pion/ice/v4" pioninterceptor "github.com/pion/interceptor" "github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4/pkg/media" @@ -68,7 +70,7 @@ const ( // A half-open TCP connection lets Send() succeed while replies never // arrive; waiting for the IQ result detects this and triggers reconnect. xmppKeepaliveTimeout = 15 * time.Second - reconnectJoinTimeout = 30 * time.Second + reconnectJoinTimeout = 30 * time.Second ) // bridgeMagic tags every EndpointMessage produced by this engine. JVB broadcasts @@ -480,6 +482,17 @@ func (s *Session) negotiatePC(ctx context.Context, jSess *j.Session, sctpBridge settings := webrtc.SettingEngine{} settings.LoggerFactory = logger.NewPionLoggerFactory() + // When a socket protector is set, route Pion sockets through ProtectedNet. + // Do not fall back to the default network path if setup fails. + if protect.Protector != nil { + pnet, perr := protect.NewProtectedNet() + if perr != nil { + return fmt.Errorf("protected net: %w", perr) + } + settings.SetNet(pnet) + settings.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled) + } + // pion auto-registers a default interceptor chain (sender reports, // receiver reports, NACK, etc.) when none is supplied. Several of // those probe the DTLS transport on a tick - until DTLS comes up @@ -758,7 +771,6 @@ func randomTrackSuffix() string { return base64.RawURLEncoding.EncodeToString(b[:]) } - // updates its endpoint lastActivity timestamp. Without this, JVB expires the // endpoint after its inactivity timeout (~30-60s) when the ICE/DTLS path is // routed through a TURN relay whose allocation silently dies. diff --git a/internal/protect/pionnet.go b/internal/protect/pionnet.go new file mode 100644 index 0000000..2ede93e --- /dev/null +++ b/internal/protect/pionnet.go @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: WTFPL +// +// ProtectedNet wraps Pion's network adapter. It applies Protector to each +// socket fd and hides tunnel-style interfaces from candidate gathering. Callers +// install it only when Protector is set, so default builds keep Pion's standard +// network stack. +package protect + +import ( + "context" + "fmt" + "net" + "strings" + "syscall" + + "github.com/pion/transport/v4" + "github.com/pion/transport/v4/stdnet" +) + +// tunInterfacePrefixes lists interface name prefixes excluded from candidate +// gathering. Keep pptp explicit; it does not match the ppp prefix. +// +//nolint:gochecknoglobals // fixed lookup table; a slice cannot be const +var tunInterfacePrefixes = []string{"tun", "ppp", "pptp"} + +// ErrUnexpectedConnType is returned when a protected listen/dial yields an +// unexpected concrete type. The caller closes that connection instead of using +// an unprotected fallback. +// +//nolint:gochecknoglobals // sentinel error +var ErrUnexpectedConnType = fmt.Errorf("protect: unexpected connection type") + +// ProtectedNet wraps Pion's standard net. +type ProtectedNet struct { + *stdnet.Net +} + +// NewProtectedNet builds a ProtectedNet over Pion's standard net. +func NewProtectedNet() (*ProtectedNet, error) { + base, err := stdnet.NewNet() + if err != nil { + return nil, fmt.Errorf("stdnet: %w", err) + } + return &ProtectedNet{Net: base}, nil +} + +// Interfaces returns system interfaces after filtering tunnel-style devices. +func (n *ProtectedNet) Interfaces() ([]*transport.Interface, error) { + all, err := n.Net.Interfaces() + if err != nil { + return nil, fmt.Errorf("list interfaces: %w", err) + } + out := make([]*transport.Interface, 0, len(all)) + for _, ifc := range all { + if !isTunInterface(ifc.Name) { + out = append(out, ifc) + } + } + return out, nil +} + +// InterfaceByName applies the same filtering as Interfaces. +func (n *ProtectedNet) InterfaceByName(name string) (*transport.Interface, error) { + if isTunInterface(name) { + return nil, transport.ErrInterfaceNotFound + } + ifc, err := n.Net.InterfaceByName(name) + if err != nil { + return nil, fmt.Errorf("lookup interface %q: %w", name, err) + } + return ifc, nil +} + +func isTunInterface(name string) bool { + for _, p := range tunInterfacePrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +func (n *ProtectedNet) ListenPacket(network, address string) (net.PacketConn, error) { + lc := net.ListenConfig{Control: controlFunc} + conn, err := lc.ListenPacket(context.Background(), network, address) + if err != nil { + return nil, fmt.Errorf("listen packet %s %q: %w", network, address, err) + } + return conn, nil +} + +func (n *ProtectedNet) ListenUDP(network string, locAddr *net.UDPAddr) (transport.UDPConn, error) { + lc := net.ListenConfig{Control: controlFunc} + address := udpAddrString(locAddr) + pc, err := lc.ListenPacket(context.Background(), network, address) + if err != nil { + return nil, fmt.Errorf("listen udp %s %q: %w", network, address, err) + } + uc, ok := pc.(*net.UDPConn) + if !ok { + _ = pc.Close() + return nil, ErrUnexpectedConnType + } + return uc, nil +} + +func (n *ProtectedNet) Dial(network, address string) (net.Conn, error) { + d := net.Dialer{Control: controlFunc} + conn, err := d.Dial(network, address) + if err != nil { + return nil, fmt.Errorf("dial %s %q: %w", network, address, err) + } + return conn, nil +} + +func (n *ProtectedNet) DialUDP(network string, laddr, raddr *net.UDPAddr) (transport.UDPConn, error) { + d := net.Dialer{Control: controlFunc} + if laddr != nil { + d.LocalAddr = laddr + } + address := udpAddrString(raddr) + conn, err := d.Dial(network, address) + if err != nil { + return nil, fmt.Errorf("dial udp %s %q: %w", network, address, err) + } + uc, ok := conn.(*net.UDPConn) + if !ok { + _ = conn.Close() + return nil, ErrUnexpectedConnType + } + return uc, nil +} + +func (n *ProtectedNet) DialTCP(network string, laddr, raddr *net.TCPAddr) (transport.TCPConn, error) { + d := net.Dialer{Control: controlFunc} + if laddr != nil { + d.LocalAddr = laddr + } + address := tcpAddrString(raddr) + conn, err := d.Dial(network, address) + if err != nil { + return nil, fmt.Errorf("dial tcp %s %q: %w", network, address, err) + } + tc, ok := conn.(*net.TCPConn) + if !ok { + _ = conn.Close() + return nil, ErrUnexpectedConnType + } + return tc, nil +} + +func (n *ProtectedNet) ListenTCP(network string, laddr *net.TCPAddr) (transport.TCPListener, error) { + lc := net.ListenConfig{Control: controlFunc} + address := tcpAddrString(laddr) + l, err := lc.Listen(context.Background(), network, address) + if err != nil { + return nil, fmt.Errorf("listen tcp %s %q: %w", network, address, err) + } + tl, ok := l.(*net.TCPListener) + if !ok { + _ = l.Close() + return nil, ErrUnexpectedConnType + } + return protectedTCPListener{tl}, nil +} + +// CreateDialer returns a dialer that protects each fd. It copies d and chains +// any existing Control hook. +func (n *ProtectedNet) CreateDialer(d *net.Dialer) transport.Dialer { + var dialer net.Dialer + if d != nil { + dialer = *d + } + if dialer.ControlContext != nil { + dialer.ControlContext = chainControlContext(dialer.ControlContext) + } else { + dialer.Control = chainControl(dialer.Control) + } + return n.Net.CreateDialer(&dialer) +} + +// CreateListenConfig returns a listen config that protects each fd. It copies +// lc and chains any existing Control hook. +func (n *ProtectedNet) CreateListenConfig(lc *net.ListenConfig) transport.ListenConfig { + var cfg net.ListenConfig + if lc != nil { + cfg = *lc + } + // net.ListenConfig exposes Control only; net.Dialer is the type with ControlContext. + cfg.Control = chainControl(cfg.Control) + return n.Net.CreateListenConfig(&cfg) +} + +// chainControl runs the protector first, then any existing Control hook. +func chainControl( + next func(network, address string, c syscall.RawConn) error, +) func(network, address string, c syscall.RawConn) error { + return func(network, address string, c syscall.RawConn) error { + if err := controlFunc(network, address, c); err != nil { + return err + } + if next != nil { + return next(network, address, c) + } + return nil + } +} + +// chainControlContext runs the protector first, then any existing ControlContext hook. +func chainControlContext( + next func(context.Context, string, string, syscall.RawConn) error, +) func(context.Context, string, string, syscall.RawConn) error { + return func(ctx context.Context, network, address string, c syscall.RawConn) error { + if err := controlFunc(network, address, c); err != nil { + return err + } + if next != nil { + return next(ctx, network, address, c) + } + return nil + } +} + +type protectedTCPListener struct { + *net.TCPListener +} + +func (l protectedTCPListener) AcceptTCP() (transport.TCPConn, error) { + return l.TCPListener.AcceptTCP() +} + +func udpAddrString(a *net.UDPAddr) string { + if a == nil { + return ":0" + } + return a.String() +} + +func tcpAddrString(a *net.TCPAddr) string { + if a == nil { + return ":0" + } + return a.String() +} + +// Compile-time assertion that ProtectedNet satisfies Pion's Net. +var _ transport.Net = (*ProtectedNet)(nil) diff --git a/internal/protect/pionnet_test.go b/internal/protect/pionnet_test.go new file mode 100644 index 0000000..1d7dcb4 --- /dev/null +++ b/internal/protect/pionnet_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: WTFPL + +package protect + +import ( + "context" + "errors" + "net" + "reflect" + "syscall" + "testing" + + "github.com/pion/transport/v4" +) + +func TestIsTunInterface(t *testing.T) { + t.Parallel() + + cases := map[string]bool{ + "tun0": true, + "tun": true, + "ppp0": true, + "pptp0": true, + "wlan0": false, + "eth0": false, + "rmnet0": false, + "lo": false, + } + for name, want := range cases { + if got := isTunInterface(name); got != want { + t.Errorf("isTunInterface(%q) = %v, want %v", name, got, want) + } + } +} + +func TestInterfacesHidesTun(t *testing.T) { + t.Parallel() + + n, err := NewProtectedNet() + if err != nil { + t.Fatalf("NewProtectedNet: %v", err) + } + ifaces, err := n.Interfaces() + if err != nil { + t.Fatalf("Interfaces: %v", err) + } + for _, ifc := range ifaces { + if isTunInterface(ifc.Name) { + t.Errorf("Interfaces returned tun device %q", ifc.Name) + } + } +} + +func TestInterfaceByNameRejectsTun(t *testing.T) { + t.Parallel() + + n, err := NewProtectedNet() + if err != nil { + t.Fatalf("NewProtectedNet: %v", err) + } + if _, err := n.InterfaceByName("tun0"); !errors.Is(err, transport.ErrInterfaceNotFound) { + t.Errorf("InterfaceByName(tun0) error = %v, want %v", err, transport.ErrInterfaceNotFound) + } +} + +// TestControlFuncFailClosed verifies that Protector can reject a socket. +// +//nolint:tparallel // mutates package-level Protector +func TestControlFuncFailClosed(t *testing.T) { + old := Protector + t.Cleanup(func() { Protector = old }) + + Protector = func(int) bool { return false } + lc := net.ListenConfig{Control: controlFunc} + pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") + if err == nil { + _ = pc.Close() + t.Fatal("expected protected ListenPacket to fail when Protector rejects fd") + } +} + +// TestControlFuncProtects verifies that Protector receives a real fd. +// +//nolint:tparallel // mutates package-level Protector +func TestControlFuncProtects(t *testing.T) { + old := Protector + t.Cleanup(func() { Protector = old }) + + var calls int + Protector = func(fd int) bool { + if fd < 0 { + t.Errorf("protector got negative fd %d", fd) + } + calls++ + return true + } + lc := net.ListenConfig{Control: controlFunc} + pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("ListenPacket: %v", err) + } + defer pc.Close() + if calls == 0 { + t.Error("protector was not invoked") + } +} + +// TestCreateDialerProtectsAndChains verifies that CreateDialer copies the +// caller's Dialer and keeps the caller's Control hook. +// +//nolint:tparallel // mutates package-level Protector +func TestCreateDialerProtectsAndChains(t *testing.T) { + old := Protector + t.Cleanup(func() { Protector = old }) + + var protectorRan bool + Protector = func(int) bool { protectorRan = true; return true } + + n, err := NewProtectedNet() + if err != nil { + t.Fatalf("NewProtectedNet: %v", err) + } + + // Dial a local TCP listener. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + go func() { + if c, aerr := ln.Accept(); aerr == nil { + _ = c.Close() + } + }() + + var callerControlRan bool + caller := &net.Dialer{ + Control: func(_, _ string, _ syscall.RawConn) error { + callerControlRan = true + return nil + }, + } + callerControl := caller.Control + + dialer := n.CreateDialer(caller) + + // Keep the caller's Control unchanged. + if reflect.ValueOf(caller.Control).Pointer() != reflect.ValueOf(callerControl).Pointer() { + t.Error("CreateDialer mutated the caller's Dialer.Control") + } + + conn, err := dialer.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial via CreateDialer: %v", err) + } + _ = conn.Close() + + if !protectorRan { + t.Error("protector hook did not run for the CreateDialer dialer") + } + if !callerControlRan { + t.Error("caller's Control hook did not run (chain dropped it)") + } +} + +// TestCreateDialerProtectsAndChainsControlContext verifies that CreateDialer +// keeps the caller's ControlContext hook. +// +//nolint:tparallel // mutates package-level Protector +func TestCreateDialerProtectsAndChainsControlContext(t *testing.T) { + old := Protector + t.Cleanup(func() { Protector = old }) + + var protectorRan bool + Protector = func(int) bool { protectorRan = true; return true } + + n, err := NewProtectedNet() + if err != nil { + t.Fatalf("NewProtectedNet: %v", err) + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + go func() { + if c, aerr := ln.Accept(); aerr == nil { + _ = c.Close() + } + }() + + var callerControlContextRan bool + caller := &net.Dialer{ + ControlContext: func(_ context.Context, _, _ string, _ syscall.RawConn) error { + callerControlContextRan = true + return nil + }, + } + callerControlContext := caller.ControlContext + + dialer := n.CreateDialer(caller) + + if reflect.ValueOf(caller.ControlContext).Pointer() != reflect.ValueOf(callerControlContext).Pointer() { + t.Error("CreateDialer mutated the caller's Dialer.ControlContext") + } + + conn, err := dialer.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("dial via CreateDialer: %v", err) + } + _ = conn.Close() + + if !protectorRan { + t.Error("protector hook did not run for the CreateDialer dialer") + } + if !callerControlContextRan { + t.Error("caller's ControlContext hook did not run (chain dropped it)") + } +}