How to Allow‑list Specific Clients in Tailcat: A Complete Guide
Use the --allow flag at startup or call Server.AddAllowedClient at runtime to restrict Tailcat tunnel connections to specific WireGuard node public keys.
Tailcat enforces client access control at the network layer by validating peer identities against an allow‑list of WireGuard node public keys. This mechanism prevents unauthorized nodes from establishing tunnels, even if they possess valid Tailscale credentials. The implementation resides in the core server logic of the tailscale/tailcat repository, with both CLI and programmatic interfaces available for managing allowed clients.
How Tailcat's Client Allow‑listing Works
At its core, Tailcat stores permitted clients in an internal map keyed by key.NodePublic. The server performs a fast map lookup when handling incoming connections. If no allow‑list is configured, the map remains nil and all clients pass through.
In [tailcat.go](https://github.com/tailscale/tailcat/blob/main/tailcat.go), the critical validation occurs around line 1595:
if b.allowedClients != nil && !b.allowedClients[src] {
return nil, fmt.Errorf("client %v not allowed", src)
}
The allowedClients field lives on the locoBackend struct and is populated through Server.AddAllowedClient or CLI flag parsing. When allowedClients is nil, this check short‑circuits, preserving backward compatibility for open servers.
Method 1: Using the --allow CLI Flag
The simplest way to restrict access is passing public keys via the --allow flag when starting the server. This is implemented in [cmd/tailcat/tailcat.go](https://github.com/tailscale/tailcat/blob/main/cmd/tailcat/tailcat.go).
Flag Syntax and Parsing
The flag accepts a comma‑separated list of base64‑encoded node public keys or the special value none to block all clients:
# Allow specific clients
tailcat serve --allow=nodekey:abc123...,nodekey:def456... 22
# Block all clients (explicit deny)
tailcat serve --allow=none 22
Behind the scenes, cmd/tailcat/tailcat.go parses each entry around line 1405 using key.NewNodePublicFromString, then invokes Server.AddAllowedClient for each valid key.
Generating Client Keys
Use the built‑in key generation command to create client credentials:
# Generate a client key pair
tailcat genkey --client > client1.key
cat client1.key | tailcat pubkey > client1.pub
# Copy the public key (nodekey:...) for the server --allow flag
cat client1.pub
Complete Server Example
#!/bin/bash
# Generate two client keys
CLIENT1=$(tailcat genkey --client | tailcat pubkey | tr -d '\n')
CLIENT2=$(tailcat genkey --client | tailcat pubkey | tr -d '\n')
# Start server allowing only these two clients
tailcat serve \
--allow="${CLIENT1},${CLIENT2}" \
--statedir=/var/lib/tailcat \
0.0.0.0:22
Method 2: Programmatic Allow‑listing with Server.AddAllowedClient
For dynamic access control, import Tailcat as a library and manipulate the allow‑list at runtime. The Server.AddAllowedClient method in [tailcat.go](https://github.com/tailscale/tailcat/blob/main/tailcat.go) (lines 893‑898) provides a safe, concurrency‑aware way to add keys.
Method Signature
func (s *Server) AddAllowedClient(k key.NodePublic)
The method uses mak.Set to handle map initialization safely:
func (s *Server) AddAllowedClient(k key.NodePublic) {
s.lb.mu.Lock()
defer s.lb.mu.Unlock()
mak.Set(&s.lb.allowedClients, k, true)
}
Runtime Allow‑listing Example
package main
import (
"context"
"log"
"os"
"os/signal"
"tailscale.dev/tailcat"
"tailscale.dev/tailcat/key"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
// Initialize server with default configuration
srv := &tailcat.Server{
Port: 22,
}
if err := srv.Start(); err != nil {
log.Fatalf("server start failed: %v", err)
}
// Add permitted clients from environment or external source
allowedKeys := []string{
os.Getenv("ALLOWED_CLIENT_1"),
os.Getenv("ALLOWED_CLIENT_2"),
}
for _, ks := range allowedKeys {
if ks == "" {
continue
}
k, err := key.NewNodePublicFromString(ks)
if err != nil {
log.Printf("invalid key %q: %v", ks, err)
continue
}
srv.AddAllowedClient(k)
log.Printf("added client: %v", k)
}
<-ctx.Done()
srv.Close()
}
Method 3: SSH‑level Authorization (Separate from Tunnel Allow‑list)
Tailcat's ssh subcommand supports an additional layer of access control via --ssh-authorized-keys. This operates at the SSH protocol layer and is distinct from the WireGuard tunnel allow‑list described above.
Key differences:
- Tunnel allow‑list (
--allow): Controls who may establish the encrypted WireGuard tunnel. Denied clients cannot reach the SSH server at all. - SSH authorization: Controls who may authenticate via SSH once connected. A client may pass tunnel allow‑listing but fail SSH key verification.
For defense‑in‑depth, configure both layers as shown in [cmd/tailcat/ssh_authorized_keys.go](https://github.com/tailscale/tailcat/blob/main/cmd/tailcat/ssh_authorized_keys.go):
tailcat serve \
--allow=nodekey:abc123... \
--ssh-authorized-keys=/etc/tailcat/authorized_keys \
22
Testing Allow‑list Behavior
The repository includes comprehensive tests in [tailcat_test.go](https://github.com/tailscale/tailcat/blob/main/tailcat_test.go) (lines 293‑331) demonstrating allow‑list enforcement. These tests verify that:
- Servers with empty allow‑lists accept all clients
- Servers with populated allow‑lists reject unknown keys
- Runtime mutations via
AddAllowedClienttake effect immediately
Excerpt from the test suite:
func TestServerAllowlist(t *testing.T) {
s := newTestServer(t)
// Initially nil allow‑list permits all
if s.lb.allowedClients != nil {
t.Fatal("expected nil allowedClients initially")
}
// Add specific client
allowed := key.NewNode().Public()
s.AddAllowedClient(allowed)
// Verify map populated
s.lb.mu.Lock()
if !s.lb.allowedClients[allowed] {
t.Fatal("expected client to be allowed")
}
s.lb.mu.Unlock()
// Connection from disallowed client should fail
// ... test helper simulates handshake with random key
}
Comparison of Allow‑listing Approaches
| Approach | Use Case | Persistence | Performance Impact |
|---|---|---|---|
--allow flag |
Static, known client sets | Configured at startup; restart required to modify | None; map built once |
AddAllowedClient |
Dynamic, runtime membership changes | In‑memory only; implement persistence externally | Negligible; lock‑contended only on mutation |
none value |
Maintenance mode, emergency lockdown | Immediate effect | N/A (blocks all) |
Security Considerations
- Key rotation: When rotating client keys, add the new key before removing the old to avoid connection interruptions.
- Audit logging: Wrap
AddAllowedClientcalls with logging to maintain an audit trail of runtime permission changes. - Least privilege: Start with
--allow=noneand explicitly add required clients rather than relying on default open behavior.
Summary
- Tailcat's client allow‑list operates on WireGuard node public keys stored in
locoBackend.allowedClients - Use
--allowat startup for static configuration; parse keys withkey.NewNodePublicFromString - Call
Server.AddAllowedClientfor dynamic, runtime access control - The special
nonevalue creates an empty allow‑list, blocking all clients - SSH authorization (
--ssh-authorized-keys) provides a separate, complementary security layer - Test coverage in
tailcat_test.govalidates allow‑list behavior across connection scenarios
Frequently Asked Questions
What happens if I don't specify the --allow flag?
If no --allow flag is provided, allowedClients remains nil and Tailcat permits connections from any authenticated Tailscale node. This default behavior ensures backward compatibility but should be explicitly restricted in production deployments.
Can I remove a client from the allow‑list at runtime?
The current API only supports adding clients via AddAllowedClient. To remove access, restart the server with an updated --allow list or implement a custom wrapper that maintains a separate revocation layer. The underlying map structure supports deletion, but no public method exposes this functionality.
How does Tailcat validate that a presented key is legitimate?
Tailcat relies on the WireGuard handshake and Tailscale control plane to cryptographically verify that a peer possesses the private key corresponding to its claimed public key. The allow‑list check occurs after this verification, ensuring that only cryptographically authenticated identities are evaluated against your access policy.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →