Skip to main content

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_localbroad: 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) or self.client_address[0] (http.server) — never X-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-ALLOW outcome into a flat 403 without echoing the reason.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tailnet_guard-0.6.0.tar.gz (24.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tailnet_guard-0.6.0-py3-none-any.whl (18.2 kB view details)

Uploaded Python 3

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

Hashes for tailnet_guard-0.6.0.tar.gz
Algorithm Hash digest
SHA256 37c89f83cefe45f17bb4d8014cb038553b4590c02f8029bc1b9da1b9d89a2f2d
MD5 289c08b961fa24da488bc9c21d8d2e19
BLAKE2b-256 4ca87eaaef7f06f4ce696540ee73aa2aaebd3132d1871c336540adf26d8dfb72

See more details on using hashes here.

Provenance

The following attestation bundles were made for tailnet_guard-0.6.0.tar.gz:

Publisher: release.yml on falahat/tailnet-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for tailnet_guard-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 96c475aecd980f367efbd1ad97193decf8669e3ae7c6d674319791aae6e45057
MD5 796285792766ebf3198292b22b8004dd
BLAKE2b-256 27e71caa727c33ce645e4cbb448fece72a24577c9eeef081b0a6ec516b68e26e

See more details on using hashes here.

Provenance

The following attestation bundles were made for tailnet_guard-0.6.0-py3-none-any.whl:

Publisher: release.yml on falahat/tailnet-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page