relay: wrapped relay socket with counting, buckets and the EIP port gate (spec §3)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+190
−0
internal/relay/conn.go+97 −0internal/relay/conn_test.go+93 −0
diff --git a/internal/relay/conn.go b/internal/relay/conn.go| new file mode 100644 |
| index 000000000..a857c6789 |
| --- /dev/null |
| +++ b/internal/relay/conn.go |
| @@ -0,0 +1,97 @@ |
| +package relay |
| + |
| +import ( |
| + "net" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// Binding is the identity an allocation was created under. Src is the |
| +// typed source key (typedSrc) pion will present on later requests. |
| +type Binding struct { |
| + ID Identity |
| + Expiry time.Time |
| + Kid string |
| + Incarnation string |
| + Src string |
| +} |
| + |
| +// typedSrc keys the owner binding on transport AND address, because pion |
| +// tags every allocation's tuple as UDP even for TCP clients and a UDP and a |
| +// TCP client can share an address string. |
| +func typedSrc(a net.Addr) string { |
| + switch a.(type) { |
| + case *net.TCPAddr: |
| + return "tcp:" + a.String() |
| + default: |
| + return "udp:" + a.String() |
| + } |
| +} |
| + |
| +type portSet struct { |
| + mu sync.Mutex |
| + m map[int]bool |
| +} |
| + |
| +func (p *portSet) add(port int) { p.mu.Lock(); p.m[port] = true; p.mu.Unlock() } |
| +func (p *portSet) remove(port int) { p.mu.Lock(); delete(p.m, port); p.mu.Unlock() } |
| +func (p *portSet) has(port int) bool { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return p.m[port] |
| +} |
| + |
| +// relayConn is the PacketConn the relay hands pion for one allocation. |
| +// Closing it is how the relay ends the allocation: pion's read loop deletes |
| +// the allocation when ReadFrom errors (allocation.go:301 in pion/turn |
| +// v4.1.4). WriteTo is the client→peer leg and is where forwarded bytes are |
| +// counted, the per-allocation and box buckets applied, and the EIP port |
| +// gate enforced. ReadFrom is not overridden: peer→relay traffic is counted |
| +// by the server only after pion forwards it (see Task 6), so this wrapper |
| +// counts nothing on read. |
| +type relayConn struct { |
| + net.PacketConn |
| + b *Binding |
| + eip net.IP |
| + ports *portSet |
| + appBucket *Bucket |
| + boxBucket *Bucket |
| + onIn func(Identity, int) |
| + onRejected func(Identity, int) |
| + onClose func(*relayConn) |
| + once sync.Once |
| +} |
| + |
| +func newRelayConn(inner net.PacketConn, b *Binding, eip net.IP, ports *portSet, appBucket, boxBucket *Bucket, |
| + onIn func(Identity, int), onRejected func(Identity, int), onClose func(*relayConn)) *relayConn { |
| + return &relayConn{PacketConn: inner, b: b, eip: eip, ports: ports, appBucket: appBucket, boxBucket: boxBucket, |
| + onIn: onIn, onRejected: onRejected, onClose: onClose} |
| +} |
| + |
| +func (c *relayConn) WriteTo(p []byte, addr net.Addr) (int, error) { |
| + if ua, ok := addr.(*net.UDPAddr); ok && c.eip != nil && ua.IP.Equal(c.eip) && !c.ports.has(ua.Port) { |
| + if c.onRejected != nil { |
| + c.onRejected(c.b.ID, len(p)) |
| + } |
| + return len(p), nil // dropped: only live relay ports are reachable on our own EIP |
| + } |
| + if (c.appBucket != nil && !c.appBucket.Allow(len(p))) || (c.boxBucket != nil && !c.boxBucket.Allow(len(p))) { |
| + return len(p), nil // dropped by a token bucket |
| + } |
| + n, err := c.PacketConn.WriteTo(p, addr) |
| + if n > 0 && c.onIn != nil { |
| + c.onIn(c.b.ID, n) |
| + } |
| + return n, err |
| +} |
| + |
| +func (c *relayConn) Close() error { |
| + var err error |
| + c.once.Do(func() { |
| + err = c.PacketConn.Close() |
| + if c.onClose != nil { |
| + c.onClose(c) |
| + } |
| + }) |
| + return err |
| +} |
diff --git a/internal/relay/conn_test.go b/internal/relay/conn_test.go| new file mode 100644 |
| index 000000000..080f31730 |
| --- /dev/null |
| +++ b/internal/relay/conn_test.go |
| @@ -0,0 +1,93 @@ |
| +package relay |
| + |
| +import ( |
| + "net" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestRelayConnGatesEIPPorts(t *testing.T) { |
| + inner := &fakePacketConn{} |
| + ports := &portSet{m: map[int]bool{50000: true}} |
| + eip := net.ParseIP("203.0.113.5") |
| + now := time.Unix(1_800_000_000, 0) |
| + clock := func() time.Time { return now } |
| + var in int |
| + c := newRelayConn(inner, &Binding{ID: Identity{"abc", "meet"}}, eip, ports, NewBucket(1<<40, clock), NewBucket(1<<40, clock), |
| + func(_ Identity, n int) { in += n }, nil, func(*relayConn) {}) |
| + ok := &net.UDPAddr{IP: eip, Port: 50000} |
| + bad := &net.UDPAddr{IP: eip, Port: 3478} |
| + other := &net.UDPAddr{IP: net.ParseIP("198.51.100.9"), Port: 4444} |
| + c.WriteTo([]byte("abc"), ok) |
| + c.WriteTo([]byte("abc"), bad) |
| + c.WriteTo([]byte("abcd"), other) |
| + if len(inner.written) != 2 || in != 7 { |
| + t.Fatalf("written=%d in=%d, want 2 packets and 7 bytes", len(inner.written), in) |
| + } |
| +} |
| + |
| +func TestRelayConnBucketDrops(t *testing.T) { |
| + inner := &fakePacketConn{} |
| + now := time.Unix(1_800_000_000, 0) |
| + clock := func() time.Time { return now } |
| + c := newRelayConn(inner, &Binding{}, nil, &portSet{m: map[int]bool{}}, NewBucket(80, clock), NewBucket(1<<40, clock), func(Identity, int) {}, nil, func(*relayConn) {}) |
| + dst := &net.UDPAddr{IP: net.ParseIP("198.51.100.9"), Port: 4444} |
| + c.WriteTo(make([]byte, 10), dst) // 10 bytes = the whole second |
| + c.WriteTo(make([]byte, 1), dst) |
| + if len(inner.written) != 1 { |
| + t.Fatalf("written = %d, want 1 (second dropped by bucket)", len(inner.written)) |
| + } |
| +} |
| + |
| +func TestRelayConnCloseOnce(t *testing.T) { |
| + n := 0 |
| + c := newRelayConn(&fakePacketConn{}, &Binding{}, nil, &portSet{m: map[int]bool{}}, nil, nil, nil, nil, func(*relayConn) { n++ }) |
| + c.Close() |
| + c.Close() |
| + if n != 1 { |
| + t.Fatalf("onClose ran %d times", n) |
| + } |
| +} |
| + |
| +func TestTypedSrc(t *testing.T) { |
| + u := &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 5000} |
| + tc := &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 5000} |
| + if typedSrc(u) == typedSrc(tc) { |
| + t.Fatal("udp and tcp with the same address must not collide") |
| + } |
| +} |
| + |
| +// fakePacketConn records writes; ReadFrom blocks until Close. |
| +type fakePacketConn struct { |
| + mu sync.Mutex |
| + written [][]byte |
| + closed chan struct{} |
| + once sync.Once |
| +} |
| + |
| +func (f *fakePacketConn) init() { f.once.Do(func() { f.closed = make(chan struct{}) }) } |
| +func (f *fakePacketConn) ReadFrom(b []byte) (int, net.Addr, error) { |
| + f.init() |
| + <-f.closed |
| + return 0, nil, net.ErrClosed |
| +} |
| +func (f *fakePacketConn) WriteTo(b []byte, a net.Addr) (int, error) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.written = append(f.written, append([]byte(nil), b...)) |
| + return len(b), nil |
| +} |
| +func (f *fakePacketConn) Close() error { |
| + f.init() |
| + select { |
| + case <-f.closed: |
| + default: |
| + close(f.closed) |
| + } |
| + return nil |
| +} |
| +func (f *fakePacketConn) LocalAddr() net.Addr { return &net.UDPAddr{IP: net.IPv4zero, Port: 50000} } |
| +func (f *fakePacketConn) SetDeadline(time.Time) error { return nil } |
| +func (f *fakePacketConn) SetReadDeadline(time.Time) error { return nil } |
| +func (f *fakePacketConn) SetWriteDeadline(time.Time) error { return nil } |