From c515659499644e3413a575aba0058373039b6b26 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Sat, 26 Sep 2026 16:06:52 +0000 Subject: [PATCH] tailcat: replace AllowedClients with an AllowClient hook, add KeySet and DisconnectClient Server.AllowedClients and Server.AddAllowedClient are replaced by a single Server.AllowClient func(key.NodePublic) bool, asked about each client as it connects. A nil hook allows all clients, which is the one fail-open rule. The old slice's "empty means open" semantics forced the CLI's --allow=none to insert a zero key as a sentinel; an empty KeySet now simply allows nobody. The hook runs with the backend mutex released, so unlike the hook proposed in #120 it may block on an external lookup and may call other Server methods. onMeow already runs in its own goroutine per meow ping, so a slow answer delays only that client. A pendingAllow map keeps a client's once-a-second meow retries from starting a second call for the same key while one is in flight. The new KeySet type is a mutex-guarded set of node keys whose Contains method is the ready-made hook for a fixed or hand-maintained list, so the runtime add that AddAllowedClient offered is still available, and removal comes with it. The new Server.DisconnectClient drops a connected client from the network map so its traffic is blackholed in both directions; it does not reset its connections. Revoking a client is then two orthogonal steps the caller composes: make the hook reject the key, then disconnect it. Client IDs now come from a counter that is never reused, since after a removal len(clients)+2 could collide with a live peer. Supersedes #120 and #125, which each added a second admission form alongside the slice. Fixes #119 Fixes #124 Co-authored-by: Jormen Janssen Co-authored-by: Yann --- CHANGELOG.md | 18 +++ cmd/tailcat/tailcat.go | 7 +- keyset.go | 53 +++++++++ tailcat.go | 152 +++++++++++++++++-------- tailcat_test.go | 204 +++++++++++++++++++++++++++++++++- tool/directtest/directtest.go | 10 +- 6 files changed, 386 insertions(+), 58 deletions(-) create mode 100644 keyset.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2345df7d0..b87d718e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## Unreleased +- **Breaking Go API change:** `Server.AllowedClients` and + `Server.AddAllowedClient` are replaced by the `Server.AllowClient` + hook, a `func(key.NodePublic) bool` asked about each client as it + connects, so a program embedding tailcat can decide at runtime, for + example from a database, instead of listing every key up front. A + nil hook allows all clients. The hook runs without any server lock + held, so it may block and may call other `Server` methods. For a + list of keys, the new `KeySet` type's `Contains` method is a + ready-made hook: `s.AllowClient = allow.Contains`. The new + `Server.DisconnectClient` drops a connected client; it does not + reset the client's connections, which stall instead. The `--allow` + flag is unchanged. + ([#119](https://github.com/tailscale/tailcat/issues/119) and + [#120](https://github.com/tailscale/tailcat/pull/120) by + [@jormenjanssen](https://github.com/jormenjanssen); + [#124](https://github.com/tailscale/tailcat/issues/124) and + [#125](https://github.com/tailscale/tailcat/pull/125) by + [@ybaelli](https://github.com/ybaelli)) - New `tailcat perf` command and `perf` service run an iperf-like throughput and latency test between a client and a server, over TCP or UDP, in either or both directions, with one or more parallel diff --git a/cmd/tailcat/tailcat.go b/cmd/tailcat/tailcat.go index 06e2eb1d6..3f7495b32 100644 --- a/cmd/tailcat/tailcat.go +++ b/cmd/tailcat/tailcat.go @@ -1419,17 +1419,18 @@ func server(logf logger.Logf, serveSpec string, execArgs []string) { s.ServedUDPPorts = []filter.PortRange{{First: perf.Port, Last: perf.Port}} } if *flagAllow != "" { + var allow tailcat.KeySet for _, ks := range strings.Split(*flagAllow, ",") { if ks == "none" { - s.AddAllowedClient(key.NodePublic{}) - continue + continue // an empty set allows no clients } var k key.NodePublic if err := k.UnmarshalText([]byte(ks)); err != nil { log.Fatalf("invalid key %q in --allow: %v", ks, err) } - s.AddAllowedClient(k) + allow.Add(k) } + s.AllowClient = allow.Contains } // localDialer dials the local services that incoming connections diff --git a/keyset.go b/keyset.go new file mode 100644 index 000000000..56553d7ae --- /dev/null +++ b/keyset.go @@ -0,0 +1,53 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package tailcat + +import ( + "sync" + + "tailscale.com/types/key" + "tailscale.com/util/set" +) + +// KeySet is a set of node public keys that is safe for concurrent +// use. Its zero value is an empty set. +// +// Its Contains method is the usual value for [Server.AllowClient] +// when the allowed clients are a known list rather than a decision +// made per client: +// +// var allow tailcat.KeySet +// allow.Add(k) +// s.AllowClient = allow.Contains +// +// An empty set allows no clients, unlike a nil AllowClient, which +// allows all. Because AllowClient is only consulted when a client +// connects, removing a connected client's key from the set does not +// disconnect it; call [Server.DisconnectClient] as well. +type KeySet struct { + mu sync.Mutex + s set.Set[key.NodePublic] +} + +// Add adds k to the set. +func (s *KeySet) Add(k key.NodePublic) { + s.mu.Lock() + defer s.mu.Unlock() + s.s.Make() + s.s.Add(k) +} + +// Remove removes k from the set, if present. +func (s *KeySet) Remove(k key.NodePublic) { + s.mu.Lock() + defer s.mu.Unlock() + s.s.Delete(k) +} + +// Contains reports whether k is in the set. +func (s *KeySet) Contains(k key.NodePublic) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.s.Contains(k) +} diff --git a/tailcat.go b/tailcat.go index 1e79c01b1..d110ffcf1 100644 --- a/tailcat.go +++ b/tailcat.go @@ -353,12 +353,17 @@ type locoBackend struct { // peer map lookup. Set before createEngine. onDERPRecv func(regionID tailcfg.DERPRegionID, src key.NodePublic, pkt []byte) bool - mu sync.Mutex - clients map[key.NodePublic]*tailcfg.Node // for the server - nm *netmap.NetworkMap - allowedClients map[key.NodePublic]bool // or nil map for all - eps []netip.AddrPort // our current local UDP endpoints, sorted - closeOnce sync.Once + // allowClient is the server's [Server.AllowClient] hook, or nil + // to allow all clients. Set before Start. + allowClient func(key.NodePublic) bool + + mu sync.Mutex + clients map[key.NodePublic]*tailcfg.Node // for the server + nextClientID tailcfg.NodeID // for the server; never reused after a removal + pendingAllow map[key.NodePublic]bool // client keys with an allowClient call in flight + nm *netmap.NetworkMap + eps []netip.AddrPort // our current local UDP endpoints, sorted + closeOnce sync.Once } func (b *locoBackend) derpRegionID() tailcfg.DERPRegionID { @@ -443,11 +448,30 @@ type Server struct { // process-wide in-memory cache is used. DERPMapCache DERPMapCache - // AllowedClients, if non-empty, restricts which client node keys - // may connect; all others are silently ignored. If empty, all - // clients are allowed. See [Server.AddAllowedClient] to add more - // at runtime. - AllowedClients []key.NodePublic + // AllowClient, if non-nil, reports whether the client with node + // key k may connect. It is consulted when a client that is not + // already connected announces itself. A client it rejects is + // silently ignored and is asked about again if it retries, which + // clients do about once a second while trying to connect. If nil, + // all clients are allowed. + // + // It is called without any server lock held and may block, for + // example on a lookup in another service, and may call other + // Server methods. A slow answer delays only that client, and at + // most one call per key is in flight at a time. Because a rejected + // client keeps retrying, an expensive hook should remember its + // negative answers itself. + // + // AllowClient decides admission only; it is not asked again about + // a connected client. Use [Server.DisconnectClient] to drop one. + // For a fixed or hand-maintained list of keys, use a [KeySet]: + // + // var allow tailcat.KeySet + // allow.Add(k) + // s.AllowClient = allow.Contains + // + // It must be set before calling Start. + AllowClient func(k key.NodePublic) bool lb *locoBackend // non-nil once Start has been called @@ -617,9 +641,7 @@ func (s *Server) startLocked(ctx context.Context) error { lb.logf = logf lb.dm = &tailcfg.DERPMap{} mak.Set(&lb.dm.Regions, reg.RegionID, reg) - for _, k := range s.AllowedClients { - mak.Set(&lb.allowedClients, k, true) - } + lb.allowClient = s.AllowClient sys := &lb.sys bus := eventbus.New() @@ -969,19 +991,22 @@ func tcpipStackOf(ns *netstack.Impl) *stack.Stack { return reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem().Interface().(*stack.Stack) } -// AddAllowedClient adds k as an allowed client. +// DisconnectClient drops the connected client with node key k, if +// any, and reports whether it was connected. The server forgets the +// client and stops routing its traffic in either direction. It does +// not reset the client's connections: they stall until they time +// out, and the client's packets are dropped. // -// Until a key is allowed (here or via [Server.AllowedClients]), all -// clients are allowed. -func (s *Server) AddAllowedClient(k key.NodePublic) { +// Nothing stops k from connecting again, so a caller revoking a +// client should first make [Server.AllowClient] reject it (report +// false for the key), before disconnecting the client. +func (s *Server) DisconnectClient(k key.NodePublic) bool { if s.lb == nil { - // Not yet started; applied at Start. - s.AllowedClients = append(s.AllowedClients, k) - return + return false // nothing is connected before Start } s.lb.mu.Lock() defer s.lb.mu.Unlock() - mak.Set(&s.lb.allowedClients, k, true) + return s.lb.removeClientLocked(k) } // TailcatAddr returns the tailcat address that clients use to connect to this @@ -1452,9 +1477,9 @@ func (b *locoBackend) peerConfig(k key.NodePublic) (_ wgcfg.PeerConfig, ok bool) // or a connection from a [Server.Listen] listener. // // The tunnel has already authenticated the peer by this key, so a -// caller can tell which peer it is serving, and can match it against -// [Server.AllowedClients]. It reports ok=false if remote is not a -// known peer's address. +// caller can tell which peer it is serving: it is the key that +// [Server.AllowClient] admitted. It reports ok=false if remote is +// not a known peer's address. // // [Server.PeerEnv] reports the same key to served subprocesses as // TAILCAT_PEER_KEY. @@ -1694,21 +1719,42 @@ func (lb *locoBackend) Start() error { // whether the client is allowed and configured, meaning a "meowed" // acknowledgment may be sent. func (b *locoBackend) onMeow(src key.NodePublic, discoPub key.DiscoPublic) bool { + b.logf("got meow from %v", src.String()) b.mu.Lock() defer b.mu.Unlock() - b.logf("got meow from %v", src.String()) - if b.allowedClients != nil && !b.allowedClients[src] { - b.logf("ignoring meow from %v: not in allowedClients", src.String()) - return false - } - if _, ok := b.clients[src]; ok { return true } - id := len(b.clients) + 2 // server is ID 1, clients are IDs 2, 3, ... - derpRegion := b.derpRegionID() + if b.allowClient != nil { + if b.pendingAllow[src] { + // An earlier meow from src is still waiting on the + // hook. Drop this one; src retries once a second. + return false + } + // Ask the hook with b.mu released: it may block, and it may + // call Server methods that take b.mu. pendingAllow keeps a + // second meow from src from racing us to add the client. + mak.Set(&b.pendingAllow, src, true) + b.mu.Unlock() + allowed := b.allowClient(src) + b.mu.Lock() + delete(b.pendingAllow, src) + if !allowed { + b.logf("ignoring meow from %v: rejected by AllowClient", src.String()) + return false + } + } + + // The server is ID 1 and clients are IDs 2, 3, and so on. IDs + // are never reused: after a removal, len(b.clients)+2 could + // collide with a live peer. + if b.nextClientID < 2 { + b.nextClientID = 2 + } + id := b.nextClientID + b.nextClientID++ mak.Set(&b.clients, src, &tailcfg.Node{ - ID: tailcfg.NodeID(id), + ID: id, StableID: tailcfg.StableNodeID(fmt.Sprint(id)), Name: fmt.Sprintf("client%d.tailcat.", id), User: 100, @@ -1716,9 +1762,25 @@ func (b *locoBackend) onMeow(src key.NodePublic, discoPub key.DiscoPublic) bool DiscoKey: discoPub, Addresses: []netip.Prefix{pfxOf(tcAddrForKey(src))}, AllowedIPs: []netip.Prefix{pfxOf(tcAddrForKey(src))}, - HomeDERP: derpRegion, + HomeDERP: b.derpRegionID(), }) + b.setNetworkMapLocked() + + // No engine reconfig needed: the WireGuard device learns about the + // new peer lazily via the config source installed with + // SetPeerConfigFunc when the client's handshake arrives. + // Tell the new client our UDP endpoints so both sides can attempt + // a direct path. Async because advertiseEndpoints takes b.mu. + go b.advertiseEndpoints() + return true +} + +// setNetworkMapLocked rebuilds the server's network map from +// b.clients and pushes it to magicsock and netstack. b.mu must be +// held. +func (b *locoBackend) setNetworkMapLocked() { + derpRegion := b.derpRegionID() nm := &netmap.NetworkMap{ NodeKey: b.pub, SelfNode: (&tailcfg.Node{ @@ -1741,17 +1803,19 @@ func (b *locoBackend) onMeow(src key.NodePublic, discoPub key.DiscoPublic) bool }) b.nm = nm - mc := b.sys.MagicSock.Get() - mc.SetNetworkMap(nm.SelfNode, nm.Peers) + b.sys.MagicSock.Get().SetNetworkMap(nm.SelfNode, nm.Peers) b.sys.Netstack.Get().UpdateNetstackIPs(nm) +} - // No engine reconfig needed: the WireGuard device learns about the - // new peer lazily via the config source installed with - // SetPeerConfigFunc when the client's handshake arrives. - - // Tell the new client our UDP endpoints so both sides can attempt - // a direct path. Async because advertiseEndpoints takes b.mu. - go b.advertiseEndpoints() +// removeClientLocked forgets the connected client k, if any, dropping +// it from the network map so magicsock and netstack no longer know +// it. It reports whether k was connected. b.mu must be held. +func (b *locoBackend) removeClientLocked(k key.NodePublic) bool { + if _, ok := b.clients[k]; !ok { + return false + } + delete(b.clients, k) + b.setNetworkMapLocked() return true } diff --git a/tailcat_test.go b/tailcat_test.go index 82520157f..6b7e7b285 100644 --- a/tailcat_test.go +++ b/tailcat_test.go @@ -17,6 +17,7 @@ import ( "net/netip" "os" "strings" + "sync" "sync/atomic" "testing" "time" @@ -290,7 +291,9 @@ func TestTailcat(t *testing.T) { } // Start with a non-matching allowlist entry so the first ping can verify // that disallowed clients get no acknowledgement. - s.AddAllowedClient(key.NewNode().Public()) + var allow KeySet + allow.Add(key.NewNode().Public()) + s.AllowClient = allow.Contains if err := s.Start(); err != nil { t.Fatalf("server Start: %v", err) @@ -307,7 +310,7 @@ func TestTailcat(t *testing.T) { badInfo.PresharedKey = NewPresharedKey() } bad := &Client{Server: badInfo.Addr(), Logf: mkLogger(t, "wrong-psk-client")} - s.AddAllowedClient(bad.PublicKey()) + allow.Add(bad.PublicKey()) PingForTest(t, s, bad) // the pre-WireGuard discovery handshake still works ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) if conn, err := bad.DialTCPPort(ctx, 80); err == nil { @@ -328,7 +331,7 @@ func TestTailcat(t *testing.T) { t.Fatalf("Ping from disallowed client = %v; want context deadline exceeded", err) } cancel() - s.AddAllowedClient(c.PublicKey()) + allow.Add(c.PublicKey()) pi := PingForTest(t, s, c) t.Logf("got ping: %+v", pi) @@ -363,7 +366,8 @@ func TestStatusReportsPeers(t *testing.T) { t.Fatal("no region 1 in derpmap") } - s := &Server{Key: key.NewNode(), Logf: mkLogger(t, "server"), Region: reg} + var allow KeySet + s := &Server{Key: key.NewNode(), Logf: mkLogger(t, "server"), Region: reg, AllowClient: allow.Contains} t.Cleanup(func() { s.Close() }) if err := s.Start(); err != nil { t.Fatalf("server Start: %v", err) @@ -371,7 +375,7 @@ func TestStatusReportsPeers(t *testing.T) { c := &Client{Server: s.TailcatAddr(), Logf: mkLogger(t, "client")} t.Cleanup(func() { c.Close() }) - s.AddAllowedClient(c.PublicKey()) + allow.Add(c.PublicKey()) // A successful ping means the server has fully added us as a peer. PingForTest(t, s, c) @@ -395,6 +399,194 @@ func TestStatusReportsPeers(t *testing.T) { t.Logf("peer %v: CurAddr=%q Relay=%q", c.PublicKey(), ps.CurAddr, ps.Relay) } +// pingRejectedForTest checks that the server never acknowledges c's +// meow: a disallowed client gets no reply, so Ping must run out its +// context deadline rather than fail fast. +func pingRejectedForTest(t testing.TB, s *Server, c *Client) { + t.Helper() + WaitForDERPForTest(t, s, c) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + if _, err := c.Ping(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Ping from disallowed client = %v; want context deadline exceeded", err) + } +} + +// TestAllowClient checks that Server.AllowClient admits and rejects +// clients, is asked once per admission rather than on every meow, +// and may block and call back into the Server without deadlocking. +func TestAllowClient(t *testing.T) { + t.Parallel() + + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + reg := dm.Regions[1] + if reg == nil { + t.Fatal("no region 1 in derpmap") + } + + approved := key.NewNode() + rejected := key.NewNode() + slow := key.NewNode() + + var ( + mu sync.Mutex + calls = map[key.NodePublic]int{} + inFlight = map[key.NodePublic]bool{} + ) + var s *Server + s = &Server{ + Logf: mkLogger(t, "server"), + Region: reg, + AllowClient: func(k key.NodePublic) bool { + mu.Lock() + calls[k]++ + if inFlight[k] { + t.Errorf("concurrent AllowClient calls for %v", k) + } + inFlight[k] = true + mu.Unlock() + defer func() { + mu.Lock() + defer mu.Unlock() + inFlight[k] = false + }() + if k == slow.Public() { + // Outlast a meow retry, so a second meow arrives + // while this one is pending, and take the backend + // lock via Status to prove the hook runs without it. + time.Sleep(1500 * time.Millisecond) + s.Status() + } + return k != rejected.Public() + }, + } + if err := s.Start(); err != nil { + t.Fatalf("server Start: %v", err) + } + t.Cleanup(func() { s.Close() }) + + newClient := func(name string, k key.NodePrivate) *Client { + c := &Client{Server: s.TailcatAddr(), Key: k, Logf: mkLogger(t, name)} + t.Cleanup(func() { c.Close() }) + return c + } + callsFor := func(k key.NodePublic) int { + mu.Lock() + defer mu.Unlock() + return calls[k] + } + + // An approved key is admitted, and once connected the client's + // later pings are acknowledged without asking again. + approvedClient := newClient("approved", approved) + PingForTest(t, s, approvedClient) + PingForTest(t, s, approvedClient) + if n := callsFor(approved.Public()); n != 1 { + t.Errorf("AllowClient called %d times for the approved key; want 1", n) + } + + // A rejected key gets no reply, but the hook was consulted. + pingRejectedForTest(t, s, newClient("rejected", rejected)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for callsFor(rejected.Public()) < 1 { + select { + case <-ctx.Done(): + t.Fatal("AllowClient never called for the rejected key") + case <-time.After(time.Millisecond): + } + } + + // A slow hook delays only its own client, which is still admitted. + // Meows arriving while the hook is pending are dropped rather than + // starting a second call. + PingForTest(t, s, newClient("slow", slow)) + if n := callsFor(slow.Public()); n != 1 { + t.Errorf("AllowClient called %d times for the slow key; want 1", n) + } +} + +// TestDisconnectClient checks that dropping a connected client +// removes it from the server and stops its traffic, while leaving +// other clients untouched. +func TestDisconnectClient(t *testing.T) { + t.Parallel() + + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + reg := dm.Regions[1] + if reg == nil { + t.Fatal("no region 1 in derpmap") + } + + var allow KeySet + s := &Server{Key: key.NewNode(), Logf: mkLogger(t, "server"), Region: reg, AllowClient: allow.Contains} + t.Cleanup(func() { s.Close() }) + s.OnTCP = func(port uint16) func(net.Conn) { + if port != 80 { + return nil + } + return func(c net.Conn) { + io.WriteString(c, "hello\n") + c.Close() + } + } + if s.DisconnectClient(key.NewNode().Public()) { + t.Error("DisconnectClient before Start reported a connected client") + } + if err := s.Start(); err != nil { + t.Fatalf("server Start: %v", err) + } + + revokedKey := key.NewNode() + revoked := &Client{Server: s.TailcatAddr(), Key: revokedKey, Logf: mkLogger(t, "revoked")} + t.Cleanup(func() { revoked.Close() }) + kept := &Client{Server: s.TailcatAddr(), Logf: mkLogger(t, "kept")} + t.Cleanup(func() { kept.Close() }) + allow.Add(revoked.PublicKey()) + allow.Add(kept.PublicKey()) + + PingForTest(t, s, revoked) + PingForTest(t, s, kept) + if _, ok := s.Status().Peer[revoked.PublicKey()]; !ok { + t.Fatal("revoked client not a peer before removal") + } + + // Revoke: stop admitting the key, then drop the live client. + allow.Remove(revoked.PublicKey()) + if !s.DisconnectClient(revoked.PublicKey()) { + t.Fatal("DisconnectClient reported the revoked client as not connected") + } + if s.DisconnectClient(revoked.PublicKey()) { + t.Fatal("second DisconnectClient reported the revoked client as still connected") + } + + if _, ok := s.Status().Peer[revoked.PublicKey()]; ok { + t.Fatal("revoked client still reported as a peer") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + if conn, err := revoked.DialTCPPort(ctx, 80); err == nil { + conn.Close() + t.Fatal("revoked client dialed the server after removal") + } + cancel() + + // A fresh client presenting the revoked key is ignored at the meow. + again := &Client{Server: s.TailcatAddr(), Key: revokedKey, Logf: mkLogger(t, "again")} + t.Cleanup(func() { again.Close() }) + pingRejectedForTest(t, s, again) + + // The other client is unaffected. + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := kept.DialTCPPort(ctx, 80) + if err != nil { + t.Fatalf("kept client DialTCPPort = %v", err) + } + if got, _ := io.ReadAll(conn); string(got) != "hello\n" { + t.Fatalf("kept client read %q; want %q", got, "hello\n") + } +} + func TestUDP(t *testing.T) { dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") reg := dm.Regions[1] @@ -941,7 +1133,7 @@ func TestServerCloseClosesActiveConnections(t *testing.T) { s := &Server{ Logf: mkLogger(t, "server"), Region: reg, - AllowedClients: []key.NodePublic{clientKey.Public()}, + AllowClient: func(k key.NodePublic) bool { return k == clientKey.Public() }, ServedTCPPorts: []filter.PortRange{{First: 80, Last: 80}}, OnTCP: func(port uint16) func(net.Conn) { if port != 80 { diff --git a/tool/directtest/directtest.go b/tool/directtest/directtest.go index e5ea22a57..153aa4657 100644 --- a/tool/directtest/directtest.go +++ b/tool/directtest/directtest.go @@ -112,11 +112,11 @@ func runServer(args []string) error { return fmt.Errorf("resolving DERP region: %w", err) } s := &tailcat.Server{ - Key: conf.Private, - PresharedKey: conf.Public.PresharedKey, - Region: ci.Region[0], - AllowedClients: []key.NodePublic{clientKey}, - Logf: logger.WithPrefix(log.Printf, "[tailcat] "), + Key: conf.Private, + PresharedKey: conf.Public.PresharedKey, + Region: ci.Region[0], + AllowClient: func(k key.NodePublic) bool { return k == clientKey }, + Logf: logger.WithPrefix(log.Printf, "[tailcat] "), } defer s.Close() ln, err := s.Listen(ctx, "tcp", fmt.Sprintf(":%d", echoPort))