Metadata-Version: 2.4
Name: tailnet-guard
Version: 0.6.0
Summary: Pure, dependency-free trust barrier for Tailscale-fronted services: tailnet/loopback membership, constant-time token auth, and bind-safety.
Author-email: Aryan Falahatpisheh <aryanfalahat@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/falahat/tailnet-guard
Project-URL: Repository, https://github.com/falahat/tailnet-guard.git
Keywords: tailscale,tailnet,security,zero-trust,wireguard,loopback
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: black>=23.0; extra == "dev"
Requires-Dist: flake8>=6.0; extra == "dev"
Requires-Dist: pylint>=3.0; extra == "dev"
Requires-Dist: ruff>=0.1; extra == "dev"
Requires-Dist: trustme>=1.0; extra == "dev"
Requires-Dist: biscuit-python>=0.4; extra == "dev"
Requires-Dist: http-message-signatures>=0.5; extra == "dev"
Requires-Dist: cryptography>=42; extra == "dev"
Requires-Dist: requests>=2.28; extra == "dev"
Provides-Extra: biscuit
Requires-Dist: biscuit-python>=0.4; extra == "biscuit"
Provides-Extra: signing
Requires-Dist: http-message-signatures>=0.5; extra == "signing"
Requires-Dist: cryptography>=42; extra == "signing"
Requires-Dist: requests>=2.28; extra == "signing"

# tailnet-guard

A tiny, **pure, dependency-free** trust barrier for services fronted by a
[Tailscale](https://tailscale.com) 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

```python
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.

```python
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.
