Pure, dependency-free trust barrier for Tailscale-fronted services: tailnet/loopback membership, constant-time token auth, and bind-safety.
Project description
tailnet-guard
A tiny, pure, dependency-free trust barrier for services fronted by a Tailscale tailnet. It answers one question — may I serve this peer? — from facts you supply (the peer's real socket address, never a forwarded header, plus the capability token it presented), and it makes "listen on a public interface" hard to do by accident.
Extracted from a security-reviewed pattern shared by two projects (easy-podcast +
antibody-analysis) so the posture lives in one place to audit and fix. Stdlib only
(ipaddress, hmac); every check is fail-closed.
Install
pip install tailnet-guard # once published
pip install git+https://github.com/falahat/tailnet-guard.git # from source
Use
from tailnet_guard import PeerRequest, GuardOutcome, evaluate_peer, resolve_bind_host
# 1. Refuse to bind anything but a tailnet/loopback interface.
host = resolve_bind_host(args.bind) # raises BindError otherwise
# allow_any=True permits 0.0.0.0 ONLY behind an outer reachability constraint
# (e.g. a container whose ports are published on a tailnet address).
# 2. Gate each request on the REAL socket address + the capability token.
outcome = evaluate_peer(
PeerRequest(remote_addr=request.client.host, token=presented_token),
expected_token=worker_secret,
)
if outcome is not GuardOutcome.ALLOW:
raise Forbidden() # flat 403 — don't leak which check failed
Two network policies
The membership predicate is a parameter, so each caller keeps its own policy:
is_tailnet_or_loopback(default) — strict: only the Tailscale CGNAT range (100.64.0.0/10) or loopback. For a server that should never trust the physical LAN.is_local— broad: any non-public address (RFC1918 private, link-local, loopback, tailnet). For a host that also trusts LAN peers.
evaluate_peer(req, secret, membership=is_local) # admit LAN peers too
resolve_bind_host("192.168.1.10", membership=is_local) # allow a LAN bind
What's in the box
| Symbol | Purpose |
|---|---|
evaluate_peer / PeerRequest / GuardOutcome |
the core guard decision (address → membership → pinned identity → token) |
resolve_bind_host / BindError |
refuse unsafe bind hosts; allow_any for constrained wildcards |
tokens_match |
constant-time token compare; empty never matches |
normalize_ip, is_loopback, is_tailnet, is_tailnet_or_loopback, is_local |
membership predicates (IPv4-mapped-IPv6 aware) |
whois / PeerIdentity |
resolve a peer's real tailnet identity (node/user/tags) via tailscaled — the strong check behind the coarse range gate (the one module that does I/O) |
mtls.server_context / mtls.client_context / mtls.peer_identity |
build mutually-authenticating TLS contexts (certs from e.g. step-ca) for service↔service hops; recover the verified peer's identity from its cert |
biscuit.mint / biscuit.attenuate / biscuit.authorize |
Biscuit capability tokens — issue a least-privilege grant, narrow it, and authorize it offline with the issuer's public key (the biscuit extra) |
signing.sign / signing.verify |
RFC 9421 HTTP message signing — bind a request (method/URI/body digest) to an Ed25519 key so it can't be replayed or tampered (the signing extra) |
host_ok |
Host-header anti-DNS-rebinding check |
parse_allowlist / peer_allowed |
optional IP/CIDR allowlist that only ever narrows |
Security notes
- Pass the real socket peer, e.g.
request.client.host(ASGI) orself.client_address[0](http.server) — neverX-Forwarded-For/Host, which a client controls. - The guard is stateless — it does not provide replay protection. If you need that, add a nonce/freshness layer at your message envelope (Tailscale's WireGuard already authenticates the transport).
- Turn any non-
ALLOWoutcome into a flat403without echoing the reason.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tailnet_guard-0.6.0.tar.gz.
File metadata
- Download URL: tailnet_guard-0.6.0.tar.gz
- Upload date:
- Size: 24.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
37c89f83cefe45f17bb4d8014cb038553b4590c02f8029bc1b9da1b9d89a2f2d
|
|
| MD5 |
289c08b961fa24da488bc9c21d8d2e19
|
|
| BLAKE2b-256 |
4ca87eaaef7f06f4ce696540ee73aa2aaebd3132d1871c336540adf26d8dfb72
|
Provenance
The following attestation bundles were made for tailnet_guard-0.6.0.tar.gz:
Publisher:
release.yml on falahat/tailnet-guard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tailnet_guard-0.6.0.tar.gz -
Subject digest:
37c89f83cefe45f17bb4d8014cb038553b4590c02f8029bc1b9da1b9d89a2f2d - Sigstore transparency entry: 1987638749
- Sigstore integration time:
-
Permalink:
falahat/tailnet-guard@9d7b8bffb52f85f804bfbac0fc58dc388eb71c1a -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/falahat
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@9d7b8bffb52f85f804bfbac0fc58dc388eb71c1a -
Trigger Event:
push
-
Statement type:
File details
Details for the file tailnet_guard-0.6.0-py3-none-any.whl.
File metadata
- Download URL: tailnet_guard-0.6.0-py3-none-any.whl
- Upload date:
- Size: 18.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
96c475aecd980f367efbd1ad97193decf8669e3ae7c6d674319791aae6e45057
|
|
| MD5 |
796285792766ebf3198292b22b8004dd
|
|
| BLAKE2b-256 |
27e71caa727c33ce645e4cbb448fece72a24577c9eeef081b0a6ec516b68e26e
|
Provenance
The following attestation bundles were made for tailnet_guard-0.6.0-py3-none-any.whl:
Publisher:
release.yml on falahat/tailnet-guard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
tailnet_guard-0.6.0-py3-none-any.whl -
Subject digest:
96c475aecd980f367efbd1ad97193decf8669e3ae7c6d674319791aae6e45057 - Sigstore transparency entry: 1987638890
- Sigstore integration time:
-
Permalink:
falahat/tailnet-guard@9d7b8bffb52f85f804bfbac0fc58dc388eb71c1a -
Branch / Tag:
refs/tags/v0.6.0 - Owner: https://github.com/falahat
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@9d7b8bffb52f85f804bfbac0fc58dc388eb71c1a -
Trigger Event:
push
-
Statement type: