Tailkitty
Python tooling for Tailscale Tailcat: encrypted, account-free, netcat-style connections over Tailscale's data plane.
Tailkitty implements connection tokens, DNS destination lookup, and DERP-map resolution in Python. For interoperable network transport, its platform wheels contain a pinned upstream Tailcat executable whose platform, version, size, and SHA-256 digest are checked before execution.
[!WARNING] Both upstream Tailcat and Tailkitty are experimental. Do not depend on stable token, CLI, or API compatibility before a stable release.
Why use it?
- Inspect, validate, and resolve Tailcat tokens without starting a native process.
- Use typed synchronous and asyncio clients instead of assembling subprocess commands.
- Manage server startup, readiness, timeouts, and cleanup safely from Python.
- Keep upstream CLI compatibility for streaming, forwarding, ping, SOCKS, SSH, and key commands.
- Install a self-contained platform wheel with runtime bundle-integrity checks.
- Reproduce releases with pinned Python, Go, uv, Tailcat, and cross-platform build inputs.
Contents
Install
Install a bundled wheel
For the command-line tool:
uv tool install tailkitty
tailkitty doctor
For library use inside a project:
uv add tailkitty
To build and install the host wheel from this checkout instead:
mise install
uv sync --all-groups --locked
mise run wheel
uv tool install --force ./dist/tailkitty-0.1.1-py3-none-<platform>.whl
A bundled wheel does not require Go at runtime. It provides the tailkitty command plus tailcat
as an upstream-compatible alias. See the platform matrix for tags.
Work from this checkout
Install mise, then run:
mise install
mise run setup
uv run tailkitty doctor
This installs Python 3.13.11, Go 1.26.5, and uv 0.12.7; synchronizes the uv environment; and
builds the pinned development backend at .tools/bin/tailcat.
Use only the Python functionality
Token parsing, token resolution, DERP caching, and DNS destination lookup are pure Python. They can
be used from a binary-free source installation. Network commands and Client/ServerProcess
still require either a bundled wheel or an executable selected by TAILKITTY_BACKEND.
Quickstart
The following example creates an ephemeral, one-shot byte stream between two machines. Both need
the tailkitty command from a bundled wheel or configured backend.
On the receiving machine:
tailkitty --key=new < /dev/null
# 🐈 Server listening with new address: tc...
Copy the complete tc... address to the sending machine:
printf 'hello from tailcat\n' | tailkitty --key=new 'tc...'
The message appears on the receiving terminal. The server accepts one connection and exits.
Tailcat is full-duplex; remove < /dev/null if you also want to type a response on the server.
The PowerShell equivalents are:
$null | tailkitty --key=new
'hello from tailcat' | tailkitty --key=new 'tc...'
[!IMPORTANT] A server allows any client by default. For controlled access, generate a client key with
tailkitty genkey --clientand start the server with--allow=<client-public-key>. See Security before exposing a service.
CLI
Three commands are implemented natively in Python:
tailkitty parse 'tc...' # decode and validate a token as JSON
tailkitty resolve 'tc...' # embed the referenced DERP region
tailkitty doctor # show the selected backend and provenance
tailkitty doctor --json # machine-readable diagnostics
Every other argument sequence is passed unchanged to the upstream-compatible data plane:
tailkitty --serve=8080,8443
tailkitty 'tc...' 8080
tailkitty ping 'tc...'
tailkitty ssh 'tc...'
tailkitty socks 'tc...' curl http://server.tailcat:8080/
tailkitty genkey --client
Run tailkitty --help for the Python command summary. Upstream commands retain their own help, for
example tailkitty genkey --help.
Destinations may be literal connection tokens or DNS names with a TXT record of the form:
server.example.com. 300 IN TXT "tailcat=tc..."
Python API
Inspect tokens without the native backend
from tailkitty import parse_token, resolve_token
info = parse_token("tc...")
print(info.server_public.hex())
print(info.region_id)
# Fetch and embed the referenced DERP region. Cached maps are revalidated with ETags.
self_contained_token = resolve_token("tc...")
Malformed tokens raise TokenError. DNS lookup raises DestinationError, and DERP-map failures
raise DerpMapError or are translated to TokenError by resolve_token().
Send a finite request
import subprocess
from tailkitty import Client
client = Client("tc...") # A DNS name with a tailcat= TXT record also works.
# request() checks the exit status and returns stdout bytes.
response = client.request(b"GET / HTTP/1.0\r\n\r\n", port=8080, timeout=30)
# run() preserves status, stdout, and stderr; checking is opt-in.
result = client.run(b"hello", timeout=30, check=False)
if result.returncode:
raise subprocess.CalledProcessError(
result.returncode, result.args, result.stdout, result.stderr
)
Use Client.connect() when a long-lived, full-duplex subprocess.Popen stream is needed. DNS
results are cached on each client; call client.refresh() to resolve the destination again.
Manage a server
from tailkitty import ServerProcess
with ServerProcess(serve=[8080, 8443], key="new", allow=["nodekey:..."]) as server:
print(f"share this address with the allowed client: {server.token}")
print(f"native process id: {server.process.pid}")
# The context remains active while the remote client uses the forwarded ports.
ServerProcess.start() waits up to 20 seconds by default for a validated connection token. The
context manager terminates the server and escalates to a kill if it does not stop within its grace
period. allow=[] means --allow=none; allow=None preserves upstream's allow-all default.
Use asyncio
import asyncio
from tailkitty import AsyncClient, AsyncServerProcess
async def main() -> None:
response = await AsyncClient("tc...").request(b"hello", timeout=30)
print(response)
async with AsyncServerProcess(serve=8080, key="new", allow=[]) as server:
print(server.token) # Starts successfully, but rejects all clients.
asyncio.run(main())
Cancellation and timeout paths kill and reap their native child process before propagating the exception.
API behavior at a glance
| API | Backend needed? | Result |
|---|---|---|
parse_token(token) |
No | Typed ConnInfo |
resolve_token(token) |
No | Self-contained token string |
resolve_destination(name) |
No | Validated token string |
Client.request(data, ...) |
Yes | Response bytes; raises on non-zero exit |
Client.run(data, ...) |
Yes | subprocess.CompletedProcess[bytes] |
Client.connect(...) |
Yes | Streaming subprocess.Popen[bytes] |
ServerProcess(...) |
Yes | Managed synchronous server |
AsyncClient / AsyncServerProcess |
Yes | Asyncio equivalents |
run(arguments, ...) / run_async(arguments, ...) |
Yes | Low-level upstream command execution |
The package is marked with py.typed and is checked with strict mypy.
How it works
Tailcat's connection-token format is CBOR encoded as unpadded base64url after a tc prefix. That
wire format and the lightweight control-plane operations are implemented in Python. The encrypted
transport remains upstream because it depends on Tailscale's Go implementations of magicsock,
userspace WireGuard, DERP routing, and gVisor netstack.
Python caller / CLI
|
+-- token.py -------- CBOR token codec (pure Python)
+-- destination.py -- token or DNS TXT resolution (pure Python)
+-- derp.py --------- DERP-map cache and HTTP revalidation (pure Python)
+-- client.py ------- typed sync/async client facade
+-- process.py ------ managed server and subprocess lifecycle
|
+-- backend.py ------ backend discovery and command execution
|
+-- verified wheel bundle, development build, or explicit executable
Backend discovery is deterministic and fail-closed:
- Executable named by
TAILKITTY_BACKEND; an invalid path is an error. - Platform-compatible bundled executable with a valid integrity manifest.
- Development executable at
.tools/bin/tailcat(then the legacytailcat-gofilename). - A
tailcat-goexecutable onPATH.
If a bundled executable is present but fails validation, Tailkitty reports an integrity error
instead of silently selecting another backend. tailkitty doctor --json shows the selected source,
target, upstream revision, compiler, size, and digest.
DERP maps use a one-hour disk cache by default, ETag revalidation, a 5 MiB response limit, atomic cache writes, and stale-cache fallback when a refresh fails.
Security
- Tailcat traffic uses upstream's encrypted Tailscale data plane, but authorization is a separate
choice: omitting
--allowallows every client that can reach the server. - A connection token contains routing information and a server public key, not the server's private key. Nevertheless, avoid publishing an active unrestricted server address.
- Use
tailkitty genkey --client, then pass its public key through--alloworallow=[...]for restricted access. Passing an empty Python list denies every client. TAILKITTY_BACKENDis an explicit code-execution override. Tailkitty verifies that it is executable but cannot prove the provenance of a user-selected file.- Bundled executables are checked against their manifest for schema, upstream module and revision, runtime platform, filename safety, symlinks, size, and SHA-256 before use.
- Do not include active addresses, saved private keys, or verbose networking logs in public bug reports without reviewing them first.
Read SECURITY.md for the bundle trust model and reporting guidance.
Compatibility and limitations
Supported platform wheels
| Operating system | Architecture | Wheel platform tag |
|---|---|---|
| macOS 12 or newer | arm64 | macosx_12_0_arm64 |
| macOS 12 or newer | x86-64 | macosx_12_0_x86_64 |
| Linux, glibc 2.17 or newer | x86-64 | manylinux_2_17_x86_64 |
| Linux, glibc 2.17 or newer | arm64 | manylinux_2_17_aarch64 |
| Windows | x86-64 | win_amd64 |
Python 3.11 and newer is supported. Wheels use the py3-none-<platform> tag because the Python
modules are not tied to a CPython ABI; the embedded executable is still platform-specific.
Current limitations:
- There is no pure-Python network data plane. A binary-free install supports control-plane APIs only.
- Public DERP relays are rate-limited external infrastructure with no uptime guarantee. A relay timeout does not necessarily indicate a local packaging or token error.
- Wheel smoke tests also run an isolated local DERP/STUN relay and require a real encrypted peer handshake to complete within five seconds, so public-relay availability cannot hide a broken bundled data plane.
- Upstream Tailcat is experimental and its token and CLI interfaces can change.
- Only the five targets above are built. Other platforms may use an explicitly supplied compatible backend, but are not release-tested here.
See COMPARISON.md for a feature-by-feature comparison with the existing PyPI project.
Development
The normal contributor loop is:
mise install
uv sync --all-groups --locked
mise run test
Useful build and verification commands:
mise run backend # development helper in .tools/bin
mise run bundle # host helper in src/tailkitty/bin
mise run bundle-verify # verify the host bundle manifest
mise run wheel # build the host platform wheel
mise run wheels # cross-build and verify all five wheels
# Install and inspect a newly built host wheel in isolation:
uv run python -m scripts.smoke_wheel dist/wheels/<host-wheel>.whl
# Exercise the minimum supported Python version:
uv run --isolated --python 3.11 --all-groups pytest
Generated executables, manifests, wheel files, and source archives are build artifacts; do not edit
them manually. The release pin is defined in src/tailkitty/constants.py, and Go must match the
version in .mise.toml exactly.
Additional documentation:
- BUILDING.md — reproducible binary and wheel pipeline
- SECURITY.md — integrity checks and trust boundaries
- COMPARISON.md — differences from the existing PyPI package
- THIRD_PARTY_NOTICES.md — bundled upstream licensing
- CHANGELOG.md — release history
- ITERATIONS.md — the initial 100-pass implementation audit
Coding agents and automated contributors must also follow AGENTS.md.
License and relationship to Tailscale
This project is licensed under the MIT License. Bundled wheels contain upstream Tailcat and its Go dependencies; see THIRD_PARTY_NOTICES.md.
Tailcat and Tailscale are trademarks of Tailscale Inc. This project is not an official Tailscale product.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
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 tailkitty-0.1.1.tar.gz.
File metadata
- Download URL: tailkitty-0.1.1.tar.gz
- Upload date:
- Size: 91.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
61b29e05ea853932bc0ec609f6077c4cf5fdc0fbc41c22426e1adaa76517dea0
|
|
| MD5 |
b11e1d791f0e574cca6ca06ddd86f106
|
|
| BLAKE2b-256 |
1c6cfe130f58d8b79569c90dd8bbd3446f83c4e4847abdcb4cfe8a2b9eca419f
|
File details
Details for the file tailkitty-0.1.1-py3-none-win_amd64.whl.
File metadata
- Download URL: tailkitty-0.1.1-py3-none-win_amd64.whl
- Upload date:
- Size: 8.0 MB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8ecfbe8f1cc0e2f7a1333d9e3656c6391f0fade405192bb1854e183cf814b492
|
|
| MD5 |
d01c1189d5e5bd5c19541aa08e6224c3
|
|
| BLAKE2b-256 |
b920f63a258878bcd596650658494e11ca16b519364389b632839e951d1adeda
|
File details
Details for the file tailkitty-0.1.1-py3-none-manylinux_2_17_x86_64.whl.
File metadata
- Download URL: tailkitty-0.1.1-py3-none-manylinux_2_17_x86_64.whl
- Upload date:
- Size: 8.3 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ae595c27d3c048bf36596e758a05e56cdaf8a9b13fa5386ad7970a87e75b1e87
|
|
| MD5 |
dc7e8cd8a62879c94b29a98ea835d0e3
|
|
| BLAKE2b-256 |
9ce8fafa2c768835c897f64c7eda53c9ff42f099af91f648dd5ad7bf94920b33
|
File details
Details for the file tailkitty-0.1.1-py3-none-manylinux_2_17_aarch64.whl.
File metadata
- Download URL: tailkitty-0.1.1-py3-none-manylinux_2_17_aarch64.whl
- Upload date:
- Size: 7.5 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
885cd545773cd6ebe2e145ea90a55b452dc40c946d5d3b1679c45ee65e35019e
|
|
| MD5 |
94c564fbfa185de45b0c4558b07c2bdc
|
|
| BLAKE2b-256 |
7458edff2874bda2b5cb53786e94623f50ecc79ea9ec9cbcdedfc95cb6964595
|
File details
Details for the file tailkitty-0.1.1-py3-none-macosx_12_0_x86_64.whl.
File metadata
- Download URL: tailkitty-0.1.1-py3-none-macosx_12_0_x86_64.whl
- Upload date:
- Size: 8.2 MB
- Tags: Python 3, macOS 12.0+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff5e112e7d3c17df16527eb4946a196bfc9d014e2df2c6edef740ec3e5d08df9
|
|
| MD5 |
5eb0b2ea64b43eaa094bfabf65e7c442
|
|
| BLAKE2b-256 |
cbc3444a4a4e0439c48d45678af389f66ab5a3a8c71ed7a053f86f8e29a755c1
|
File details
Details for the file tailkitty-0.1.1-py3-none-macosx_12_0_arm64.whl.
File metadata
- Download URL: tailkitty-0.1.1-py3-none-macosx_12_0_arm64.whl
- Upload date:
- Size: 7.6 MB
- Tags: Python 3, macOS 12.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
81050593145f823ce2a8644fbcab7a1c8ccf178d0c441f4d9204d702053dd0ce
|
|
| MD5 |
23aa632c36e3baf8d179e75de787f0e9
|
|
| BLAKE2b-256 |
3b7803197172179f995fbf9153ed12b4fc64523654566fa70571ce7797d8640e
|