Skip to content

tailcat: add Server.AllowClient hook for admitting clients - #120

Closed
jormenjanssen wants to merge 1 commit into
tailscale:mainfrom
riwo:jormen/allow-client-callback
Closed

jormenjanssen wants to merge 1 commit into
tailscale:mainfrom
riwo:jormen/allow-client-callback

Conversation

@jormenjanssen

Copy link
Copy Markdown

Adds Server.AllowClient, a func(key.NodePublic) bool hook in the shape of AllowProxy, for programs embedding tailcat that decide at runtime which clients may connect and can't list every node key in AllowedClients up front.

Semantics: a key in AllowedClients is admitted without consulting the hook, any other key is admitted if the hook approves it, and with neither configured the server stays open to all clients as before. Rejected clients are ignored the same way unlisted ones are, and are asked about again when they retry their meow. onMeow now recognizes an already connected client before the admission check, so a connected client's later pings never reach the hook.

Design choice for review. The hook runs under the backend's mutex, which peerByIP and peerForIP also take, so it is documented as non-blocking: return quickly, no I/O, cache decisions from an external service for the hook to look up. That kept the change small. The alternative is a blocking-capable func(ctx context.Context, k key.NodePublic) bool, which needs the check moved out of the lock, a ctx canceled at Server.Close, and deduplication of the client's once-a-second meow retries while a decision is pending (the client's 10 second ping timeout bounds it either way). Happy to rework to that variant if you'd rather have it; say which and I'll update this PR.

Tests: TestAllowClient covers a listed key admitted without the hook, an approved key admitted with exactly one hook call across two pings, and a rejected key that gets no meowed and does reach the hook. TestAllowClientOnly checks that a hook without any AllowedClients still closes the server to rejected clients. Both run against the local DERP and STUN from tstest/integration like the other server tests. go test ./..., go vet ./... and make tidy are clean on Windows; the CI matrix covers the rest.

No CLI change; --allow is untouched.

Updates #119

Server.AllowedClients only admits a fixed list of node keys, so a
program that decides at runtime which clients may connect, from a
database or a directory service say, has to know every key before
Start or keep the list current itself with AddAllowedClient, and can
never turn a key down once it is on the list.

Add Server.AllowClient, a func(key.NodePublic) bool in the shape of
AllowProxy, consulted when a client that is not already connected and
not in AllowedClients announces itself. A listed key is admitted
without the hook; any other key is admitted if the hook approves it;
with neither configured the server stays open to all clients, as
before. A rejected client is ignored like an unlisted one and asked
about again when it retries its meow.

The hook runs under the backend's mutex, which peerByIP and peerForIP
on the data path also take, so it must return quickly and not do I/O.
That keeps this change small. A blocking-capable variant would need
the check moved out of the lock, a context canceled at Close, and
deduplication of the client's once-a-second meow retries while a
decision is pending; a program gating clients on another service can
instead decide ahead of time and have the hook look the decision up.

onMeow now recognizes an already connected client before the
admission check rather than after it, so a connected client's later
pings never reach the hook. For the list this changes nothing, since
a key can't be removed from it.

Updates tailscale#119

Signed-off-by: Jormen Janssen <j.janssen@riwo.eu>
@jormenjanssen
jormenjanssen force-pushed the jormen/allow-client-callback branch from 45a0a2b to a95bb65 Compare September 16, 2026 12:29
Comment thread tailcat.go
Comment on lines 449 to +470
// 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.
// at runtime, and AllowClient to decide per client instead of
// from a fixed list.
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 and not in AllowedClients announces itself;
// a client it rejects is silently ignored, like an unlisted one,
// and is asked about again if it retries. If AllowClient is nil
// and AllowedClients is empty, all clients are allowed.
//
// It is called with the server's internal lock held, so it must
// return quickly and must not block on I/O: a slow call stalls
// every connected client's traffic. A program gating clients on
// another service should decide ahead of time and have AllowClient
// look the decision up.
//
// It must be set before calling Start.
AllowClient func(k key.NodePublic) bool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to tighten the docs on the precedence here about when we fail open.

Actually, maybe we should just ditch the slice and only use the func? We can provide a tiny little adapter for people migrating from the slice style (probably very few people)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Replied on the issue too)

bradfitz added a commit that referenced this pull request Sep 26, 2026
…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 <j.janssen@riwo.eu>
Co-authored-by: Yann <y.baelli05@gmail.com>
@bradfitz

Copy link
Copy Markdown
Member

Folded into #134

@bradfitz bradfitz closed this Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants