Skip to content

Repository files navigation

Tailkitty

PyPI Python CI

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, files, UDP, 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.2.0-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.27.1, 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. This default pipe mode accepts one finite stream and exits. Use serve, forward, or a Python Client connection for long-lived bidirectional traffic.

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 --client and 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 forward 'tc...' 18080:8080
tailkitty serve --files=/srv/share:rw files
tailkitty cp ./report.pdf 'tc...':
tailkitty serve --ssh-authorized-keys=alice@github ssh
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:

  1. Executable named by TAILKITTY_BACKEND; an invalid path is an error.
  2. Platform-compatible bundled executable with a valid integrity manifest.
  3. Development executable at .tools/bin/tailcat (then the legacy tailcat-go filename).
  4. A tailcat-go executable on PATH.

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 --allow allows every client that can reach the server.
  • A connection token contains routing information and a server public key, not the server's private key. Current addresses also contain a separate discovery public key and a WireGuard pre-shared key. Treat the complete address as sensitive capability material and avoid publishing it.
  • Use tailkitty genkey --client, then pass its public key through --allow or allow=[...] for restricted access. Passing an empty Python list denies every client.
  • TAILKITTY_BACKEND is 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. The bundled data plane is Tailcat v0.6.0.

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:

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.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages