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.
Metadata
Release files for tailnet-guard 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tailnet_guard-0.6.0.tar.gz | 24.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tailnet_guard-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 42.2 kB
Release files / tailnet_guard-0.6.0.tar.gz
| Download URL | tailnet_guard-0.6.0.tar.gz |
|---|---|
| Size | 24.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
37c89f83cefe45f17bb4d8014cb038553b4590c02f8029bc1b9da1b9d89a2f2d
|
|
BLAKE2b-256 checksum How to use checksums |
4ca87eaaef7f06f4ce696540ee73aa2aaebd3132d1871c336540adf26d8dfb72
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 28, 2026.
Transparency logRelease files / tailnet_guard-0.6.0-py3-none-any.whl
| Download URL | tailnet_guard-0.6.0-py3-none-any.whl |
|---|---|
| Size | 18.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
96c475aecd980f367efbd1ad97193decf8669e3ae7c6d674319791aae6e45057
|
|
BLAKE2b-256 checksum How to use checksums |
27e71caa727c33ce645e4cbb448fece72a24577c9eeef081b0a6ec516b68e26e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 28, 2026.
Transparency log