carlos / platform Public

relay: pion server with our own address generator, owner binding, termination and peer policy (spec §3, §4)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6add08378bba3b48f13d2096437c51d286d89679 parent 8a02616
4 files changed, +1381 −2
  • go.mod +8 −0
  • go.sum +18 −2
  • internal/relay/server.go +862 −0
  • internal/relay/server_test.go +493 −0
diff --git a/go.mod b/go.mod
index 33b1435e2..0d683e268 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,8 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
github.com/aws/smithy-go v1.28.1
github.com/benbjohnson/litestream v0.5.14
+ github.com/pion/logging v0.2.4
+ github.com/pion/turn/v4 v4.1.4
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
modernc.org/sqlite v1.55.0
@@ -53,6 +55,11 @@ require (
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
+ github.com/pion/dtls/v3 v3.0.7 // indirect
+ github.com/pion/randutil v0.1.0 // indirect
+ github.com/pion/stun/v3 v3.0.1 // indirect
+ github.com/pion/transport/v3 v3.0.8 // indirect
+ github.com/pion/transport/v4 v4.0.1 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
@@ -62,6 +69,7 @@ require (
github.com/superfly/ltx v0.5.1 // indirect
github.com/tetratelabs/wazero v1.2.1 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
+ github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.37.0 // indirect
diff --git a/go.sum b/go.sum
index 9f53be021..2e64a4dc5 100644
--- a/go.sum
+++ b/go.sum
@@ -146,6 +146,20 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
+github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
+github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
+github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
+github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
+github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
+github.com/pion/stun/v3 v3.0.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA=
+github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw=
+github.com/pion/transport/v3 v3.0.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc=
+github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
+github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
+github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
+github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
+github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
@@ -176,6 +190,8 @@ github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -205,8 +221,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
-golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
-golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
+golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/internal/relay/server.go b/internal/relay/server.go
new file mode 100644
index 000000000..403d01380
--- /dev/null
+++ b/internal/relay/server.go
@@ -0,0 +1,862 @@
+package relay
+
+import (
+ "errors"
+ "fmt"
+ "math/rand/v2"
+ "net"
+ "sort"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/pion/logging"
+ "github.com/pion/turn/v4"
+)
+
+// Realm is the TURN realm every credential is minted and verified under.
+const Realm = "carlos"
+
+// pendingLife is how long a socket or an admission may sit unclaimed
+// before tick reclaims it. Both are consumed within one request in the
+// normal path; this only catches an allocation that failed after pion had
+// already asked us for a socket.
+const pendingLife = 30 * time.Second
+
+// authMemory is how long the last parsed username for a typed source is
+// kept once that source has no allocation, so a client that authenticates
+// and never allocates cannot grow the map without bound.
+const authMemory = 10 * time.Minute
+
+var errTCPAllocUnsupported = errors.New("relay: TCP relay allocations are not supported")
+
+// ServerConfig is everything the relay needs from its process: where to
+// listen, what to advertise, and the four live views (masters, authority,
+// lease, limits) it reads on every request.
+type ServerConfig struct {
+ Region string
+ RelayIP net.IP // the EIP advertised to clients
+ UDP, TCP string // listen addresses, e.g. ":3478"; empty disables
+ MinPort, MaxPort uint16 // 49152, 65535
+ DenyCIDRs []*net.IPNet // from CARLOS_RELAY_DENY_CIDRS, on top of the built-in private ranges
+ Masters func() *Masters
+ Authority func() *Authority
+ Lease func(now time.Time) bool
+ Limits Limits
+ Now func() time.Time
+ Log func(string, ...any)
+
+ // Meter is the byte meter (Task 7); nil disables metering. It is an
+ // interface so this file does not depend on the meter's shape.
+ Meter interface {
+ Add(id Identity, in, out, rejected int64)
+ }
+}
+
+// Server is the relay: a pion TURN server whose every socket we own, so
+// that an allocation can be ended, attributed and bounded from outside
+// pion, which exposes no way to do any of it.
+type Server struct {
+ cfg ServerConfig
+ now func() time.Time
+ log func(string, ...any)
+
+ adm *Admission
+ box *Bucket
+ ports *portSet
+ deny []*net.IPNet
+ local []net.IP // our listeners' own addresses, when not wildcard
+
+ turn *turn.Server
+ udp net.PacketConn
+ tcp net.Listener
+ listeners []string
+
+ mu sync.Mutex
+ seq int64
+ pending map[int]pendingConn // relay port -> socket awaiting OnAllocationCreated
+ lastAuth map[string]authRec // typed source -> the username it last authenticated with
+ admits map[string]admitRec // typed source -> an admission not yet owned by an allocation
+ bound map[string]*Binding // typed source -> the identity it is bound to
+ allocs map[string]*allocEntry // typed source -> its live allocation
+ owners map[*relayConn]*allocEntry // socket -> what closing it must undo
+ refusals map[Identity]map[string]int
+ shedding map[Identity]bool
+}
+
+type allocEntry struct {
+ conn *relayConn
+ id Identity
+ key string // the typed source this allocation was created for
+ seq int64
+ timer *time.Timer
+}
+
+type pendingConn struct {
+ conn *relayConn
+ at time.Time
+}
+
+type authRec struct {
+ u Username
+ at time.Time
+}
+
+type admitRec struct {
+ id Identity
+ at time.Time
+}
+
+// builtinDeny is the peer policy every relay carries whatever the operator
+// configures: loopback, link-local (the IMDS address included), RFC 1918,
+// unique-local and carrier-grade NAT space.
+var builtinDeny = mustCIDRs(
+ "127.0.0.0/8", "::1/128",
+ "169.254.0.0/16", "fe80::/10",
+ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
+ "fc00::/7", "100.64.0.0/10",
+)
+
+func mustCIDRs(ss ...string) []*net.IPNet {
+ out := make([]*net.IPNet, 0, len(ss))
+ for _, s := range ss {
+ _, n, err := net.ParseCIDR(s)
+ if err != nil {
+ panic("relay: bad built-in CIDR " + s + ": " + err.Error())
+ }
+ out = append(out, n)
+ }
+ return out
+}
+
+func NewServer(cfg ServerConfig) (*Server, error) {
+ if cfg.Now == nil {
+ cfg.Now = time.Now
+ }
+ if cfg.Log == nil {
+ cfg.Log = func(string, ...any) {}
+ }
+ if cfg.MinPort == 0 {
+ cfg.MinPort = 49152
+ }
+ if cfg.MaxPort == 0 {
+ cfg.MaxPort = 65535
+ }
+ switch {
+ case cfg.RelayIP == nil:
+ return nil, errors.New("relay: RelayIP is required")
+ case cfg.MinPort > cfg.MaxPort:
+ return nil, fmt.Errorf("relay: port range %d-%d is empty", cfg.MinPort, cfg.MaxPort)
+ case cfg.Masters == nil || cfg.Authority == nil || cfg.Lease == nil:
+ return nil, errors.New("relay: Masters, Authority and Lease are required")
+ case cfg.UDP == "" && cfg.TCP == "":
+ return nil, errors.New("relay: no listener configured")
+ }
+
+ s := &Server{
+ cfg: cfg,
+ now: cfg.Now,
+ log: cfg.Log,
+ adm: NewAdmission(cfg.Limits, cfg.Now),
+ box: NewBucket(cfg.Limits.BoxBitsPerSec, cfg.Now),
+ ports: &portSet{m: map[int]bool{}},
+ deny: append(append([]*net.IPNet{}, builtinDeny...), cfg.DenyCIDRs...),
+ pending: map[int]pendingConn{},
+ lastAuth: map[string]authRec{},
+ admits: map[string]admitRec{},
+ bound: map[string]*Binding{},
+ allocs: map[string]*allocEntry{},
+ owners: map[*relayConn]*allocEntry{},
+ refusals: map[Identity]map[string]int{},
+ shedding: map[Identity]bool{},
+ }
+
+ var packets []turn.PacketConnConfig
+ var listeners []turn.ListenerConfig
+ if cfg.UDP != "" {
+ pc, err := net.ListenPacket("udp", cfg.UDP)
+ if err != nil {
+ return nil, fmt.Errorf("relay: listen udp %s: %w", cfg.UDP, err)
+ }
+ s.udp = pc
+ s.noteLocal(pc.LocalAddr())
+ s.listeners = append(s.listeners, "udp:"+portOf(pc.LocalAddr()))
+ packets = append(packets, turn.PacketConnConfig{
+ PacketConn: &listenerConn{PacketConn: pc, s: s},
+ RelayAddressGenerator: s,
+ PermissionHandler: s.permit,
+ })
+ }
+ if cfg.TCP != "" {
+ l, err := net.Listen("tcp", cfg.TCP)
+ if err != nil {
+ if s.udp != nil {
+ s.udp.Close()
+ }
+ return nil, fmt.Errorf("relay: listen tcp %s: %w", cfg.TCP, err)
+ }
+ s.tcp = l
+ s.noteLocal(l.Addr())
+ s.listeners = append(s.listeners, "tcp:"+portOf(l.Addr()))
+ listeners = append(listeners, turn.ListenerConfig{
+ Listener: &countingListener{Listener: l, s: s},
+ RelayAddressGenerator: s,
+ PermissionHandler: s.permit,
+ })
+ }
+
+ ts, err := turn.NewServer(turn.ServerConfig{
+ Realm: Realm,
+ AuthHandler: s.auth,
+ QuotaHandler: s.quota,
+ EventHandler: turn.EventHandler{OnAllocationCreated: s.created},
+ PacketConnConfigs: packets,
+ ListenerConfigs: listeners,
+ LoggerFactory: logging.NewDefaultLoggerFactory(),
+ })
+ if err != nil {
+ if s.udp != nil {
+ s.udp.Close()
+ }
+ if s.tcp != nil {
+ s.tcp.Close()
+ }
+ return nil, fmt.Errorf("relay: pion server: %w", err)
+ }
+ s.turn = ts
+ return s, nil
+}
+
+func (s *Server) noteLocal(a net.Addr) {
+ var ip net.IP
+ switch v := a.(type) {
+ case *net.UDPAddr:
+ ip = v.IP
+ case *net.TCPAddr:
+ ip = v.IP
+ }
+ if ip != nil && !ip.IsUnspecified() {
+ s.local = append(s.local, ip)
+ }
+}
+
+func portOf(a net.Addr) string {
+ switch v := a.(type) {
+ case *net.UDPAddr:
+ return strconv.Itoa(v.Port)
+ case *net.TCPAddr:
+ return strconv.Itoa(v.Port)
+ }
+ _, p, err := net.SplitHostPort(a.String())
+ if err != nil {
+ return a.String()
+ }
+ return p
+}
+
+// Listeners names the transports that are up, e.g. ["udp:3478","tcp:3478"],
+// for the heartbeat.
+func (s *Server) Listeners() []string { return append([]string{}, s.listeners...) }
+
+// UDPAddr is the UDP listener's bound address, or nil if UDP is disabled.
+func (s *Server) UDPAddr() net.Addr {
+ if s.udp == nil {
+ return nil
+ }
+ return s.udp.LocalAddr()
+}
+
+// Live is the number of allocations the relay is carrying.
+func (s *Server) Live() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return len(s.allocs)
+}
+
+// Seed replays metered bytes into the rolling-hour windows, so a restart
+// resumes its budgets from the metrics objects it wrote.
+func (s *Server) Seed(id Identity, at time.Time, n int64) { s.adm.Seed(id, at, n) }
+
+// Close ends every allocation and stops listening.
+func (s *Server) Close() error {
+ s.EndAll("server closing")
+ s.mu.Lock()
+ pending := make([]*relayConn, 0, len(s.pending))
+ for _, p := range s.pending {
+ pending = append(pending, p.conn)
+ }
+ s.mu.Unlock()
+ for _, c := range pending {
+ c.Close()
+ }
+ if s.turn == nil {
+ return nil
+ }
+ return s.turn.Close()
+}
+
+// EndAll ends every allocation: a lease lapse, or shutdown.
+func (s *Server) EndAll(reason string) {
+ s.EndWhere(func(*Binding) bool { return true }, reason)
+}
+
+// EndWhere ends every allocation whose binding matches: a disable, an
+// incarnation change, this region leaving an app's list, a byte budget, a
+// retired master. The predicate is called on a copy of the binding, off
+// the server's lock.
+func (s *Server) EndWhere(match func(*Binding) bool, reason string) {
+ type candidate struct {
+ conn *relayConn
+ b Binding
+ }
+ s.mu.Lock()
+ cands := make([]candidate, 0, len(s.allocs))
+ for _, e := range s.allocs {
+ cands = append(cands, candidate{conn: e.conn, b: *e.conn.b})
+ }
+ s.mu.Unlock()
+
+ n := 0
+ for _, c := range cands {
+ b := c.b
+ if match(&b) {
+ c.conn.Close()
+ n++
+ }
+ }
+ if n > 0 {
+ s.log("relay: ended %d allocation(s): %s", n, reason)
+ }
+}
+
+// ShedOldest ends the n oldest allocations, for memory pressure.
+func (s *Server) ShedOldest(n int) {
+ if n <= 0 {
+ return
+ }
+ s.mu.Lock()
+ entries := make([]*allocEntry, 0, len(s.allocs))
+ for _, e := range s.allocs {
+ entries = append(entries, e)
+ }
+ s.mu.Unlock()
+ sort.Slice(entries, func(i, j int) bool { return entries[i].seq < entries[j].seq })
+ if n > len(entries) {
+ n = len(entries)
+ }
+ for _, e := range entries[:n] {
+ e.conn.Close()
+ }
+ if n > 0 {
+ s.log("relay: shed %d oldest allocation(s)", n)
+ }
+}
+
+// tick is the sweep: it ends allocations whose credential has expired
+// (belt for the per-allocation timers), whose master has retired, whose app
+// or account is over budget, and every allocation at all if the serving
+// lease has lapsed. It also reclaims sockets and admissions that a failed
+// allocation left behind.
+func (s *Server) tick() {
+ now := s.now()
+ masters := s.cfg.Masters()
+ leaseDead := !s.cfg.Lease(now)
+
+ type victim struct {
+ conn *relayConn
+ reason string
+ }
+ var victims []victim
+ var stale []*relayConn
+
+ // A masters file that failed to reload leaves masters nil; that is a
+ // reason to keep serving on the keys we have, not to end anything.
+ retired := func(kid string) bool {
+ if masters == nil || kid == "" {
+ return false
+ }
+ _, ok := masters.Lookup(kid, now)
+ return !ok
+ }
+
+ s.mu.Lock()
+ for _, e := range s.allocs {
+ b := e.conn.b
+ switch {
+ case leaseDead:
+ victims = append(victims, victim{e.conn, "serving lease lapsed"})
+ case !b.Expiry.IsZero() && !b.Expiry.After(now):
+ victims = append(victims, victim{e.conn, "credential expired"})
+ case retired(b.Kid):
+ victims = append(victims, victim{e.conn, "master " + b.Kid + " retired"})
+ case s.adm.OverBudget(e.id, now):
+ victims = append(victims, victim{e.conn, "byte budget"})
+ }
+ }
+ for port, p := range s.pending {
+ if now.Sub(p.at) > pendingLife {
+ delete(s.pending, port)
+ stale = append(stale, p.conn)
+ }
+ }
+ var lapsed []Identity
+ for key, a := range s.admits {
+ if now.Sub(a.at) > pendingLife {
+ delete(s.admits, key)
+ lapsed = append(lapsed, a.id)
+ }
+ }
+ for key, a := range s.lastAuth {
+ if _, live := s.allocs[key]; !live && now.Sub(a.at) > authMemory {
+ delete(s.lastAuth, key)
+ }
+ }
+ s.mu.Unlock()
+
+ for _, id := range lapsed {
+ s.adm.Release(id)
+ }
+ for _, c := range stale {
+ c.Close()
+ }
+ for _, v := range victims {
+ s.log("relay: ending allocation for %s/%s: %s", v.conn.b.ID.Account, v.conn.b.ID.App, v.reason)
+ v.conn.Close()
+ }
+}
+
+// --- turn.RelayAddressGenerator ------------------------------------------
+
+func (s *Server) Validate() error {
+ if s.cfg.RelayIP == nil {
+ return errors.New("relay: RelayIP is required")
+ }
+ if s.cfg.MinPort > s.cfg.MaxPort {
+ return fmt.Errorf("relay: port range %d-%d is empty", s.cfg.MinPort, s.cfg.MaxPort)
+ }
+ return nil
+}
+
+// AllocatePacketConn binds one allocation's relay socket in the configured
+// port range and hands pion our wrapper for it: the wrapper is how the
+// allocation is ended, metered and bounded. The advertised address is the
+// EIP, which is not an address on the box, so the socket itself binds the
+// wildcard.
+func (s *Server) AllocatePacketConn(network string, requestedPort int) (net.PacketConn, net.Addr, error) {
+ if network == "" {
+ network = "udp4"
+ }
+ lo, hi := int(s.cfg.MinPort), int(s.cfg.MaxPort)
+ span := hi - lo + 1
+ tries := span
+ if tries > 64 {
+ tries = 64
+ }
+ ports := make([]int, 0, tries+1)
+ if requestedPort >= lo && requestedPort <= hi {
+ ports = append(ports, requestedPort)
+ }
+ start := rand.IntN(span)
+ for i := 0; i < tries; i++ {
+ ports = append(ports, lo+(start+i)%span)
+ }
+
+ for _, port := range ports {
+ if s.ports.has(port) {
+ continue
+ }
+ pc, err := net.ListenPacket(network, net.JoinHostPort("", strconv.Itoa(port)))
+ if err != nil {
+ continue
+ }
+ bucket := NewBucket(s.cfg.Limits.AllocBitsPerSec, s.now)
+ c := newRelayConn(pc, &Binding{}, s.cfg.RelayIP, s.ports, bucket, s.box,
+ s.countIn, s.countRejected, func(rc *relayConn) { s.release(port, rc) })
+ s.ports.add(port)
+ s.mu.Lock()
+ s.pending[port] = pendingConn{conn: c, at: s.now()}
+ s.mu.Unlock()
+ return c, &net.UDPAddr{IP: s.cfg.RelayIP, Port: port}, nil
+ }
+ return nil, nil, fmt.Errorf("relay: no free port in %d-%d", lo, hi)
+}
+
+// AllocateConn would be a TCP relay allocation (RFC 6062), which we do not
+// offer: a TURN client may reach us over TCP, but every relayed leg is UDP.
+func (s *Server) AllocateConn(string, int) (net.Conn, net.Addr, error) {
+ return nil, nil, errTCPAllocUnsupported
+}
+
+// --- pion handlers -------------------------------------------------------
+
+// auth is pion's AuthHandler: called on every authenticated request, and
+// the whole of spec §4's verification. It returns the key pion checks
+// MESSAGE-INTEGRITY against, so a wrong credential fails inside pion and
+// never reaches here.
+func (s *Server) auth(username, realm string, src net.Addr) ([]byte, bool) {
+ now := s.now()
+ u, err := ParseUsername(username)
+ if err != nil {
+ return s.refused(Identity{}, "bad shape", "relay: auth refused from %s: %v", src, err)
+ }
+ id := Identity{Account: u.Account, App: u.App}
+
+ if !u.Expiry.After(now) || u.Expiry.After(now.Add(MaxCredentialLife)) {
+ return s.refused(id, "expired", "relay: auth refused for %s/%s: expiry %s outside (now, now+%s]",
+ id.Account, id.App, u.Expiry.UTC().Format(time.RFC3339), MaxCredentialLife)
+ }
+
+ s.mu.Lock()
+ bound, isBound := s.bound[typedSrc(src)]
+ s.mu.Unlock()
+ if isBound && (bound.ID != id || bound.Incarnation != u.Incarnation) {
+ return s.refused(id, "bound elsewhere", "relay: auth refused for %s/%s: source %s is bound to %s/%s",
+ id.Account, id.App, typedSrc(src), bound.ID.Account, bound.ID.App)
+ }
+
+ app, ok := s.cfg.Authority().Lookup(u.Account, u.App)
+ switch {
+ case !ok:
+ return s.refused(id, "unknown app", "relay: auth refused for %s/%s: not in the authority record", id.Account, id.App)
+ case app.Incarnation != u.Incarnation:
+ return s.refused(id, "wrong incarnation", "relay: auth refused for %s/%s: incarnation %s, record has %s",
+ id.Account, id.App, u.Incarnation, app.Incarnation)
+ case !contains(app.Regions, s.cfg.Region):
+ return s.refused(id, "wrong region", "relay: auth refused for %s/%s: region %s not in %v",
+ id.Account, id.App, s.cfg.Region, app.Regions)
+ }
+
+ masters := s.cfg.Masters()
+ if masters == nil {
+ return s.refused(id, "unknown kid", "relay: auth refused for %s/%s: no masters loaded", id.Account, id.App)
+ }
+ master, ok := masters.Lookup(u.Kid, now)
+ if !ok {
+ return s.refused(id, "unknown kid", "relay: auth refused for %s/%s: kid %s is unknown or retired",
+ id.Account, id.App, u.Kid)
+ }
+
+ key := DeriveKey(master.Key, u.Account, u.App, u.Incarnation)
+ s.mu.Lock()
+ s.lastAuth[typedSrc(src)] = authRec{u: u, at: now}
+ s.mu.Unlock()
+ return turn.GenerateAuthKey(username, realm, Credential(key, username)), true
+}
+
+// quota is pion's QuotaHandler, called on Allocate only: a refusal answers
+// 486. The lease comes first, because a relay whose authority read is
+// stale must take nothing new on.
+func (s *Server) quota(username, _ string, src net.Addr) bool {
+ now := s.now()
+ if !s.cfg.Lease(now) {
+ s.log("relay: allocation refused: serving lease has lapsed")
+ return false
+ }
+ u, err := ParseUsername(username)
+ if err != nil {
+ return false // auth would already have refused this
+ }
+ id := Identity{Account: u.Account, App: u.App}
+ ok, reason := s.adm.Admit(id)
+ if !ok {
+ s.countRefusal(id, reason)
+ s.log("relay: allocation refused for %s/%s: %s", id.Account, id.App, reason)
+ return false
+ }
+ key := typedSrc(src)
+ s.mu.Lock()
+ old, had := s.admits[key]
+ s.admits[key] = admitRec{id: id, at: now}
+ s.mu.Unlock()
+ if had {
+ // A previous attempt from this source was admitted and never
+ // became an allocation; it owns no socket, so release it here.
+ s.adm.Release(old.id)
+ }
+ return true
+}
+
+// created is pion's OnAllocationCreated, called with the relay address
+// before pion starts reading from the socket. It is where the wrapper we
+// handed out earlier learns whose allocation it is.
+func (s *Server) created(src, _ net.Addr, _, _, _ string, relayAddr net.Addr, _ int) {
+ ua, ok := relayAddr.(*net.UDPAddr)
+ if !ok {
+ s.log("relay: allocation created with a non-UDP relay address %v", relayAddr)
+ return
+ }
+ key := typedSrc(src)
+ now := s.now()
+
+ s.mu.Lock()
+ p, havePending := s.pending[ua.Port]
+ if !havePending {
+ s.mu.Unlock()
+ s.log("relay: allocation created on port %d with no socket of ours", ua.Port)
+ return
+ }
+ delete(s.pending, ua.Port)
+ rec, haveAuth := s.lastAuth[key]
+ if !haveAuth {
+ s.mu.Unlock()
+ s.log("relay: allocation created for %s with no authenticated username", key)
+ p.conn.Close()
+ return
+ }
+ c := p.conn
+ c.b.ID = Identity{Account: rec.u.Account, App: rec.u.App}
+ c.b.Expiry = rec.u.Expiry
+ c.b.Kid = rec.u.Kid
+ c.b.Incarnation = rec.u.Incarnation
+ c.b.Src = key
+ s.seq++
+ e := &allocEntry{conn: c, id: c.b.ID, key: key, seq: s.seq}
+ s.allocs[key] = e
+ s.owners[c] = e
+ s.bound[key] = c.b
+ delete(s.admits, key)
+ expiry := rec.u.Expiry
+ s.mu.Unlock()
+
+ // The credential's own deadline. Set outside the lock: a timer whose
+ // deadline has already passed fires on the spot, and closing takes the
+ // lock we would still be holding.
+ t := time.AfterFunc(expiry.Sub(now), func() { c.Close() })
+ s.mu.Lock()
+ if cur, ok := s.allocs[key]; ok && cur == e {
+ e.timer = t
+ } else {
+ t.Stop()
+ }
+ s.mu.Unlock()
+ s.log("relay: allocation for %s/%s on %s from %s until %s",
+ c.b.ID.Account, c.b.ID.App, relayAddr, key, expiry.UTC().Format(time.RFC3339))
+}
+
+// release is the wrapped socket's onClose, whoever closed it: pion on a
+// read error, a timer, tick, EndWhere. Its state is keyed on this socket,
+// not on the typed source, so a replacement allocation bound for the same
+// source while this one was closing (pion removes an allocation from its
+// manager before closing it) keeps its binding and this one still gives
+// its admission back.
+func (s *Server) release(port int, c *relayConn) {
+ s.ports.remove(port)
+ var id Identity
+ var release bool
+ s.mu.Lock()
+ if p, ok := s.pending[port]; ok && p.conn == c {
+ delete(s.pending, port)
+ }
+ if e, ok := s.owners[c]; ok {
+ delete(s.owners, c)
+ if e.timer != nil {
+ e.timer.Stop()
+ }
+ if cur, ok := s.allocs[e.key]; ok && cur == e {
+ delete(s.allocs, e.key)
+ }
+ if b, ok := s.bound[e.key]; ok && b == c.b {
+ delete(s.bound, e.key)
+ }
+ id, release = e.id, true
+ }
+ s.mu.Unlock()
+ if release {
+ s.adm.Release(id)
+ }
+}
+
+// permit is pion's PermissionHandler: it sees only a peer IP, so the port
+// half of the EIP rule lives in relayConn.WriteTo.
+func (s *Server) permit(client net.Addr, peer net.IP) bool {
+ deny := func(why string) bool {
+ s.mu.Lock()
+ b, ok := s.bound[typedSrc(client)]
+ var id Identity
+ if ok {
+ id = b.ID
+ }
+ s.mu.Unlock()
+ s.countRefusal(id, "peer denied")
+ s.log("relay: permission refused for %s to %s: %s", client, peer, why)
+ return false
+ }
+ switch {
+ case peer == nil || peer.IsUnspecified():
+ return deny("unspecified address")
+ case peer.IsMulticast():
+ return deny("multicast address")
+ }
+ // Our own EIP is allowed: two clients that both relay here hold relay
+ // candidates on it and must reach each other.
+ if s.cfg.RelayIP != nil && peer.Equal(s.cfg.RelayIP) {
+ return true
+ }
+ for _, n := range s.deny {
+ if n.Contains(peer) {
+ return deny("denied range " + n.String())
+ }
+ }
+ for _, ip := range s.local {
+ if peer.Equal(ip) {
+ return deny("the relay's own address")
+ }
+ }
+ return true
+}
+
+// --- metering ------------------------------------------------------------
+
+// countIn is the client-to-peer leg, counted at the relay socket after
+// pion forwarded it.
+func (s *Server) countIn(id Identity, n int) {
+ if s.cfg.Meter != nil {
+ s.cfg.Meter.Add(id, int64(n), 0, 0)
+ }
+ if s.adm.AddBytes(id, int64(n)) {
+ s.overBudget(id)
+ }
+}
+
+// countOut is the peer-to-client leg, counted at the listener socket and
+// attributed through the owner binding.
+func (s *Server) countOut(id Identity, n int) {
+ if s.cfg.Meter != nil {
+ s.cfg.Meter.Add(id, 0, int64(n), 0)
+ }
+ if s.adm.AddBytes(id, int64(n)) {
+ s.overBudget(id)
+ }
+}
+
+// countRejected is what we dropped rather than forwarded: a packet aimed at
+// our own EIP on a port that is not a live allocation. Never a budget.
+func (s *Server) countRejected(id Identity, n int) {
+ if s.cfg.Meter != nil {
+ s.cfg.Meter.Add(id, 0, 0, int64(n))
+ }
+ s.countRefusal(id, "eip port")
+}
+
+// overBudget ends an app's allocations once its rolling-hour budget is
+// crossed, because a budget that only stops new allocations bounds nothing.
+// It runs off the forwarding path, once per identity at a time.
+func (s *Server) overBudget(id Identity) {
+ s.mu.Lock()
+ if s.shedding[id] {
+ s.mu.Unlock()
+ return
+ }
+ s.shedding[id] = true
+ s.mu.Unlock()
+ go func() {
+ s.EndWhere(func(b *Binding) bool { return b.ID == id }, "byte budget")
+ s.mu.Lock()
+ delete(s.shedding, id)
+ s.mu.Unlock()
+ }()
+}
+
+func (s *Server) countRefusal(id Identity, reason string) {
+ s.mu.Lock()
+ m := s.refusals[id]
+ if m == nil {
+ m = map[string]int{}
+ s.refusals[id] = m
+ }
+ m[reason]++
+ s.mu.Unlock()
+}
+
+// refused counts a refusal against the app and logs why. Its return values
+// are the AuthHandler's, so a refusal is one line at the call site.
+func (s *Server) refused(id Identity, reason, format string, args ...any) ([]byte, bool) {
+ s.countRefusal(id, reason)
+ s.log(format, args...)
+ return nil, false
+}
+
+func (s *Server) refusalCount(id Identity, reason string) int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.refusals[id][reason]
+}
+
+// entryFor is the owner binding lookup the out leg is attributed through.
+func (s *Server) entryFor(key string) *allocEntry {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.allocs[key]
+}
+
+// --- listener wrappers ---------------------------------------------------
+
+// listenerConn wraps the UDP listener pion answers clients on. It mirrors
+// relayConn without the EIP gate: everything written here goes to a client
+// that asked us for it, so the only questions are whose bytes they are and
+// whether the buckets still have room.
+type listenerConn struct {
+ net.PacketConn
+ s *Server
+}
+
+func (c *listenerConn) WriteTo(p []byte, addr net.Addr) (int, error) {
+ e := c.s.entryFor(typedSrc(addr))
+ if e != nil {
+ // Peek both, then spend both: a box refusal must not leave the
+ // app's bucket debited (see relayConn.WriteTo).
+ if !e.conn.appBucket.Peek(len(p)) || !c.s.box.Peek(len(p)) {
+ return len(p), nil
+ }
+ e.conn.appBucket.Spend(len(p))
+ c.s.box.Spend(len(p))
+ }
+ n, err := c.PacketConn.WriteTo(p, addr)
+ if n > 0 && e != nil {
+ c.s.countOut(e.id, n)
+ }
+ return n, err
+}
+
+// countingListener wraps the TCP listener so the out leg is metered for
+// TCP clients too. A TCP stream cannot have bytes dropped out of it the
+// way a datagram can, so the buckets are spent but never enforced here.
+type countingListener struct {
+ net.Listener
+ s *Server
+}
+
+func (l *countingListener) Accept() (net.Conn, error) {
+ c, err := l.Listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+ return &countingConn{Conn: c, s: l.s, key: typedSrc(c.RemoteAddr())}, nil
+}
+
+type countingConn struct {
+ net.Conn
+ s *Server
+ key string
+}
+
+func (c *countingConn) Write(p []byte) (int, error) {
+ n, err := c.Conn.Write(p)
+ if n > 0 {
+ if e := c.s.entryFor(c.key); e != nil {
+ e.conn.appBucket.Spend(n)
+ c.s.box.Spend(n)
+ c.s.countOut(e.id, n)
+ }
+ }
+ return n, err
+}
+
+func contains(ss []string, s string) bool {
+ for _, x := range ss {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/relay/server_test.go b/internal/relay/server_test.go
new file mode 100644
index 000000000..375245c4e
--- /dev/null
+++ b/internal/relay/server_test.go
@@ -0,0 +1,493 @@
+package relay
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/pion/logging"
+ "github.com/pion/turn/v4"
+)
+
+// testMaster is the fixture region master every server test derives from.
+func testMaster() []byte {
+ m := sha256.Sum256([]byte("carlos-relay-test-master"))
+ return m[:]
+}
+
+// newTestServer starts a relay on loopback with one enabled app,
+// abc/meet, incarnation 0123456789abcdef, in region "test". The returned
+// setNow moves the server's clock; the returned Masters and Authority are
+// the live ones, so a test can rewrite them in place.
+// relayIP is the address the fixture server advertises. Loopback is what
+// the brief's tests use; a test that carries real traffic between two
+// loopback sockets must advertise something else, because the EIP port
+// gate (spec §3) drops anything aimed at the advertised address on a port
+// that is not a live allocation — and on loopback every peer is on it.
+func newTestServer(t *testing.T, relayIP string, meter *fakeMeter) (s *Server, setNow func(time.Time), now func() time.Time, masters *Masters, auth *Authority) {
+ t.Helper()
+ master := testMaster()
+ masters, err := ParseMasters(strings.NewReader("k1 " + b64(master) + " -\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ auth = &Authority{
+ Apps: []AuthorityApp{{Account: "abc", App: "meet", Incarnation: "0123456789abcdef", Regions: []string{"test"}}},
+ WrittenAt: time.Now(),
+ }
+ var mu sync.Mutex
+ at := time.Now()
+ clock := func() time.Time { mu.Lock(); defer mu.Unlock(); return at }
+ setNow = func(t time.Time) { mu.Lock(); at = t; mu.Unlock() }
+
+ // A client's retransmit can reach the server's read loop after the test
+ // body returns, and t.Logf from a finished test is a data race, so the
+ // log goes quiet once the server is shut down.
+ var logMu sync.Mutex
+ quiet := false
+ logf := func(format string, args ...any) {
+ logMu.Lock()
+ defer logMu.Unlock()
+ if !quiet {
+ t.Logf(format, args...)
+ }
+ }
+
+ cfg := ServerConfig{
+ Region: "test", RelayIP: net.ParseIP(relayIP), UDP: "127.0.0.1:0",
+ MinPort: 40000, MaxPort: 40100,
+ Masters: func() *Masters { return masters }, Authority: func() *Authority { return auth },
+ Lease: func(time.Time) bool { return true }, Limits: DefaultLimits(), Now: clock, Log: logf,
+ }
+ if meter != nil {
+ cfg.Meter = meter
+ }
+ s, err = NewServer(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ s.Close()
+ logMu.Lock()
+ quiet = true
+ logMu.Unlock()
+ })
+ return s, setNow, clock, masters, auth
+}
+
+// creds mints a username and credential for account/app/incarnation under
+// kid k1, expiring an hour after now.
+func creds(now time.Time, account, app, incarnation string) (string, string) {
+ username := fmt.Sprintf("%d:%s", now.Add(time.Hour).Unix(), Suffix(account, app, incarnation, "k1"))
+ key := DeriveKey(testMaster(), account, app, incarnation)
+ return username, Credential(key, username)
+}
+
+func turnClient(t *testing.T, addr net.Addr, username, password string) *turn.Client {
+ t.Helper()
+ conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ c, err := turn.NewClient(&turn.ClientConfig{
+ STUNServerAddr: addr.String(),
+ TURNServerAddr: addr.String(),
+ Conn: conn,
+ Username: username,
+ Password: password,
+ Realm: "carlos",
+ LoggerFactory: logging.NewDefaultLoggerFactory(),
+ })
+ if err != nil {
+ conn.Close()
+ t.Fatal(err)
+ }
+ if err := c.Listen(); err != nil {
+ c.Close()
+ conn.Close()
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { c.Close(); conn.Close() })
+ return c
+}
+
+func waitFor(t *testing.T, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatal("condition not met within two seconds")
+}
+
+func TestServerEndToEnd(t *testing.T) {
+ s, setNow, now, _, _ := newTestServer(t, "127.0.0.1", nil)
+
+ username, password := creds(now(), "abc", "meet", "0123456789abcdef")
+ client := turnClient(t, s.UDPAddr(), username, password)
+ relayConn, err := client.Allocate()
+ if err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ defer relayConn.Close()
+ if s.Live() != 1 {
+ t.Fatalf("live = %d", s.Live())
+ }
+ if got := s.Listeners(); len(got) != 1 || !strings.HasPrefix(got[0], "udp:") {
+ t.Fatalf("listeners = %v", got)
+ }
+
+ // A relay candidate on the advertised address, in our port range.
+ ra, ok := relayConn.LocalAddr().(*net.UDPAddr)
+ if !ok || !ra.IP.Equal(net.ParseIP("127.0.0.1")) || ra.Port < 40000 || ra.Port > 40100 {
+ t.Fatalf("relay address = %v, want 127.0.0.1 in 40000-40100", relayConn.LocalAddr())
+ }
+
+ // Wrong app's credential is refused: abc/docs is not in the record.
+ otherUser, otherPass := creds(now(), "abc", "docs", "0123456789abcdef")
+ if _, err := turnClient(t, s.UDPAddr(), otherUser, otherPass).Allocate(); err == nil {
+ t.Fatal("unknown app allocated")
+ }
+ if s.refusalCount(Identity{"abc", "docs"}, "unknown app") != 1 {
+ t.Fatalf("refusals = %v", s.refusals)
+ }
+
+ // Expiry ends the allocation with no client action.
+ setNow(now().Add(2 * time.Hour))
+ s.tick()
+ waitFor(t, func() bool { return s.Live() == 0 })
+}
+
+// TestServerRelaysBothLegs carries real traffic client to peer and back,
+// and checks both meter legs. It advertises an EIP that is not the peers'
+// address, as a real box does.
+func TestServerRelaysBothLegs(t *testing.T) {
+ allowLoopbackPeers(t)
+ meter := &fakeMeter{}
+ s, _, now, _, _ := newTestServer(t, "203.0.113.5", meter)
+ // The fixture listens on loopback, so its own listener address is the
+ // peers' address too; a real box's listener is not on the peer's net.
+ s.local = nil
+ username, password := creds(now(), "abc", "meet", "0123456789abcdef")
+ client := turnClient(t, s.UDPAddr(), username, password)
+ relayConn, err := client.Allocate()
+ if err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ defer relayConn.Close()
+
+ peer, err := net.ListenPacket("udp4", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peer.Close()
+ if err := client.CreatePermission(peer.LocalAddr()); err != nil {
+ t.Fatalf("create permission: %v", err)
+ }
+ if _, err := relayConn.WriteTo([]byte("ping"), peer.LocalAddr()); err != nil {
+ t.Fatalf("write to peer: %v", err)
+ }
+ buf := make([]byte, 64)
+ peer.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, from, err := peer.ReadFrom(buf)
+ if err != nil {
+ t.Fatalf("peer read: %v", err)
+ }
+ if string(buf[:n]) != "ping" {
+ t.Fatalf("peer got %q", buf[:n])
+ }
+ if _, err := peer.WriteTo([]byte("pong"), from); err != nil {
+ t.Fatalf("peer write: %v", err)
+ }
+ relayConn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, _, err = relayConn.ReadFrom(buf)
+ if err != nil {
+ t.Fatalf("client read: %v", err)
+ }
+ if string(buf[:n]) != "pong" {
+ t.Fatalf("client got %q", buf[:n])
+ }
+
+ id := Identity{"abc", "meet"}
+ in, out, _ := meter.totals(id)
+ if in < 4 {
+ t.Fatalf("in = %d, want at least the 4 bytes forwarded to the peer", in)
+ }
+ if out < 4 {
+ t.Fatalf("out = %d, want at least the 4 bytes forwarded to the client", out)
+ }
+}
+
+// allowLoopbackPeers drops loopback from the built-in deny list for one
+// test. A relay on a real box never permits a loopback peer; a test that
+// carries traffic between two sockets on this machine has nowhere else to
+// put them, which is why builtinDeny is a var.
+func allowLoopbackPeers(t *testing.T) {
+ t.Helper()
+ saved := builtinDeny
+ builtinDeny = mustCIDRs(
+ "169.254.0.0/16", "fe80::/10",
+ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
+ "fc00::/7", "100.64.0.0/10",
+ )
+ t.Cleanup(func() { builtinDeny = saved })
+}
+
+// fakeMeter is the Task 7 meter's shape, which ServerConfig.Meter names as
+// an interface so this file does not wait on it.
+type fakeMeter struct {
+ mu sync.Mutex
+ rows map[Identity][3]int64
+}
+
+func (m *fakeMeter) Add(id Identity, in, out, rejected int64) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.rows == nil {
+ m.rows = map[Identity][3]int64{}
+ }
+ r := m.rows[id]
+ m.rows[id] = [3]int64{r[0] + in, r[1] + out, r[2] + rejected}
+}
+
+func (m *fakeMeter) totals(id Identity) (in, out, rejected int64) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ r := m.rows[id]
+ return r[0], r[1], r[2]
+}
+
+func TestServerRefusesPrivatePeer(t *testing.T) {
+ s, _, now, _, _ := newTestServer(t, "127.0.0.1", nil)
+ username, password := creds(now(), "abc", "meet", "0123456789abcdef")
+ client := turnClient(t, s.UDPAddr(), username, password)
+ if _, err := client.Allocate(); err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ if err := client.CreatePermission(&net.UDPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1}); err == nil {
+ t.Fatal("permission to a private peer was granted")
+ }
+}
+
+func TestServerEndWhere(t *testing.T) {
+ s, _, now, _, _ := newTestServer(t, "127.0.0.1", nil)
+ username, password := creds(now(), "abc", "meet", "0123456789abcdef")
+ client := turnClient(t, s.UDPAddr(), username, password)
+ if _, err := client.Allocate(); err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ if s.Live() != 1 {
+ t.Fatalf("live = %d", s.Live())
+ }
+ s.EndWhere(func(b *Binding) bool { return b.ID.App == "meet" }, "disable")
+ waitFor(t, func() bool { return s.Live() == 0 })
+}
+
+func TestServerShedOldest(t *testing.T) {
+ s, _, now, _, _ := newTestServer(t, "127.0.0.1", nil)
+ username, password := creds(now(), "abc", "meet", "0123456789abcdef")
+ first := turnClient(t, s.UDPAddr(), username, password)
+ if _, err := first.Allocate(); err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ second := turnClient(t, s.UDPAddr(), username, password)
+ if _, err := second.Allocate(); err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ if s.Live() != 2 {
+ t.Fatalf("live = %d", s.Live())
+ }
+ s.ShedOldest(1)
+ waitFor(t, func() bool { return s.Live() == 1 })
+}
+
+// TestServerBudgetEndsAllocations is the spec's rule that a budget which
+// only refuses new allocations bounds nothing: seeded past its hourly
+// budget, the app's live allocation goes too.
+func TestServerBudgetEndsAllocations(t *testing.T) {
+ s, _, now, _, _ := newTestServer(t, "127.0.0.1", nil)
+ username, password := creds(now(), "abc", "meet", "0123456789abcdef")
+ client := turnClient(t, s.UDPAddr(), username, password)
+ if _, err := client.Allocate(); err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ s.Seed(Identity{"abc", "meet"}, now(), DefaultLimits().AppBytesPerHour+1)
+ s.tick()
+ waitFor(t, func() bool { return s.Live() == 0 })
+}
+
+func TestServerAuthRefusals(t *testing.T) {
+ s, _, now, masters, auth := newTestServer(t, "127.0.0.1", nil)
+ src := &net.UDPAddr{IP: net.ParseIP("192.0.2.7"), Port: 5000}
+
+ sign := func(username, account, app, incarnation string) string {
+ return Credential(DeriveKey(testMaster(), account, app, incarnation), username)
+ }
+ ok := func(username string) bool {
+ key, ok := s.auth(username, "carlos", src)
+ return ok && len(key) > 0
+ }
+
+ good, _ := creds(now(), "abc", "meet", "0123456789abcdef")
+ if !ok(good) {
+ t.Fatal("a good username was refused")
+ }
+
+ cases := []struct{ name, username, reason string }{
+ {"bad shape", "not-a-username", "bad shape"},
+ {"expired", fmt.Sprintf("%d:%s", now().Add(-time.Minute).Unix(), Suffix("abc", "meet", "0123456789abcdef", "k1")), "expired"},
+ {"too far ahead", fmt.Sprintf("%d:%s", now().Add(5*time.Hour).Unix(), Suffix("abc", "meet", "0123456789abcdef", "k1")), "expired"},
+ {"unknown app", fmt.Sprintf("%d:%s", now().Add(time.Hour).Unix(), Suffix("abc", "docs", "0123456789abcdef", "k1")), "unknown app"},
+ {"wrong incarnation", fmt.Sprintf("%d:%s", now().Add(time.Hour).Unix(), Suffix("abc", "meet", "fedcba9876543210", "k1")), "wrong incarnation"},
+ {"unknown kid", fmt.Sprintf("%d:%s", now().Add(time.Hour).Unix(), Suffix("abc", "meet", "0123456789abcdef", "k9")), "unknown kid"},
+ }
+ for _, c := range cases {
+ if ok(c.username) {
+ t.Fatalf("%s: accepted", c.name)
+ }
+ if s.refusalCount(refusalIdentity(c.username), c.reason) == 0 {
+ t.Fatalf("%s: reason %q not counted (%v)", c.name, c.reason, s.refusals)
+ }
+ }
+
+ // Wrong region.
+ auth.Apps[0].Regions = []string{"elsewhere"}
+ if ok(good) {
+ t.Fatal("wrong region accepted")
+ }
+ if s.refusalCount(Identity{"abc", "meet"}, "wrong region") == 0 {
+ t.Fatalf("wrong region not counted (%v)", s.refusals)
+ }
+ auth.Apps[0].Regions = []string{"test"}
+
+ // A retired master ends the app's use of that kid.
+ masters.byKid["k1"] = Master{Kid: "k1", Key: testMaster(), RetireAt: now().Add(-time.Second)}
+ if ok(good) {
+ t.Fatal("retired kid accepted")
+ }
+ masters.byKid["k1"] = Master{Kid: "k1", Key: testMaster()}
+
+ // Owner binding: a live allocation's typed source refuses another app's
+ // valid credential.
+ s.mu.Lock()
+ s.bound[typedSrc(src)] = &Binding{ID: Identity{"abc", "meet"}, Incarnation: "0123456789abcdef"}
+ s.mu.Unlock()
+ auth.Apps = append(auth.Apps, AuthorityApp{Account: "abc", App: "docs", Incarnation: "0123456789abcdef", Regions: []string{"test"}})
+ docs := fmt.Sprintf("%d:%s", now().Add(time.Hour).Unix(), Suffix("abc", "docs", "0123456789abcdef", "k1"))
+ _ = sign(docs, "abc", "docs", "0123456789abcdef")
+ if ok(docs) {
+ t.Fatal("a second app allocated from a bound source")
+ }
+ if s.refusalCount(Identity{"abc", "docs"}, "bound elsewhere") == 0 {
+ t.Fatalf("bound elsewhere not counted (%v)", s.refusals)
+ }
+ // The bound app itself is still served, and a TCP source with the same
+ // address string is a separate binding.
+ if !ok(good) {
+ t.Fatal("the bound app was refused on its own source")
+ }
+ tcpSrc := &net.TCPAddr{IP: src.IP, Port: src.Port}
+ if key, okAuth := s.auth(docs, "carlos", tcpSrc); !okAuth || len(key) == 0 {
+ t.Fatal("a tcp source shares the udp source's binding")
+ }
+}
+
+// refusalIdentity is the identity a refusal is attributed to: the parsed
+// username's, or the zero identity when the username would not parse.
+func refusalIdentity(username string) Identity {
+ u, err := ParseUsername(username)
+ if err != nil {
+ return Identity{}
+ }
+ return Identity{u.Account, u.App}
+}
+
+func TestServerPermit(t *testing.T) {
+ _, deny, err := net.ParseCIDR("172.31.0.0/16")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s, err := NewServer(ServerConfig{
+ Region: "test", RelayIP: net.ParseIP("203.0.113.5"), UDP: "127.0.0.1:0",
+ MinPort: 40200, MaxPort: 40300, DenyCIDRs: []*net.IPNet{deny},
+ Masters: func() *Masters { return nil },
+ Authority: func() *Authority { return nil },
+ Lease: func(time.Time) bool { return true },
+ Limits: DefaultLimits(), Log: t.Logf,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+
+ client := &net.UDPAddr{IP: net.ParseIP("198.51.100.1"), Port: 1}
+ refused := []string{
+ "127.0.0.1", "::1", "169.254.169.254", "fe80::1", "10.0.0.1",
+ "172.16.5.5", "192.168.1.1", "fd00::1", "100.64.0.1", "172.31.9.9",
+ "0.0.0.0", "224.0.0.1",
+ }
+ for _, ip := range refused {
+ if s.permit(client, net.ParseIP(ip)) {
+ t.Fatalf("peer %s was permitted", ip)
+ }
+ }
+ for _, ip := range []string{"203.0.113.5", "198.51.100.9", "2001:db8::1"} {
+ if !s.permit(client, net.ParseIP(ip)) {
+ t.Fatalf("peer %s was refused", ip)
+ }
+ }
+}
+
+// TestServerCloseKeepsReplacementBinding is the race the spec calls out:
+// pion removes an allocation from its manager before closing its socket, so
+// a replacement allocation on the same typed source can exist while the old
+// wrapper is still closing. The old wrapper's close must not erase it.
+func TestServerCloseKeepsReplacementBinding(t *testing.T) {
+ s, _, now, _, _ := newTestServer(t, "127.0.0.1", nil)
+ username, password := creds(now(), "abc", "meet", "0123456789abcdef")
+ client := turnClient(t, s.UDPAddr(), username, password)
+ if _, err := client.Allocate(); err != nil {
+ t.Fatalf("allocate: %v", err)
+ }
+ key := ""
+ var old *relayConn
+ s.mu.Lock()
+ for k, e := range s.allocs {
+ key, old = k, e.conn
+ }
+ s.mu.Unlock()
+
+ // A replacement allocation lands for the same typed source while the
+ // old socket is still closing.
+ replacement := &Binding{ID: Identity{"abc", "meet"}, Src: key}
+ replConn := newRelayConn(&fakePacketConn{}, replacement, nil, s.ports, nil, nil, nil, nil, func(*relayConn) {})
+ replEntry := &allocEntry{conn: replConn, id: replacement.ID, key: key, seq: 99}
+ s.mu.Lock()
+ s.bound[key] = replacement
+ s.allocs[key] = replEntry
+ s.owners[replConn] = replEntry
+ s.mu.Unlock()
+
+ old.Close()
+
+ s.mu.Lock()
+ gotBound, gotAlloc := s.bound[key], s.allocs[key]
+ _, oldOwned := s.owners[old]
+ s.mu.Unlock()
+ if gotBound != replacement {
+ t.Fatalf("close erased the replacement binding: %v", gotBound)
+ }
+ if gotAlloc != replEntry {
+ t.Fatalf("close erased the replacement allocation: %v", gotAlloc)
+ }
+ if oldOwned {
+ t.Fatal("the closed socket is still owned, so its admission never came back")
+ }
+}