tailkitty 0.1.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tailkitty/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Tailkitty Python tooling for Tailcat."""
2
+
3
+ from .constants import TAILKITTY_VERSION
4
+
5
+ __version__ = TAILKITTY_VERSION
6
+
7
+ from .backend import BackendInfo, inspect_backend, run
8
+ from .bundle import BundleError, BundleManifest
9
+ from .client import AsyncClient, Client
10
+ from .derp import DerpMapCache, DerpMapError
11
+ from .destination import DestinationError, resolve_destination, resolve_destination_async
12
+ from .diagnostics import diagnostics
13
+ from .process import AsyncServerProcess, ServerProcess, run_async, send
14
+ from .token import ConnInfo, DerpNode, DerpRegion, TokenError, parse_token, resolve_token
15
+
16
+ __all__ = [
17
+ "AsyncClient",
18
+ "AsyncServerProcess",
19
+ "BackendInfo",
20
+ "BundleError",
21
+ "BundleManifest",
22
+ "Client",
23
+ "ConnInfo",
24
+ "DerpMapCache",
25
+ "DerpMapError",
26
+ "DerpNode",
27
+ "DerpRegion",
28
+ "DestinationError",
29
+ "ServerProcess",
30
+ "TokenError",
31
+ "diagnostics",
32
+ "inspect_backend",
33
+ "parse_token",
34
+ "resolve_destination",
35
+ "resolve_destination_async",
36
+ "resolve_token",
37
+ "run",
38
+ "run_async",
39
+ "send",
40
+ ]
tailkitty/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Support ``python -m tailkitty``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
tailkitty/backend.py ADDED
@@ -0,0 +1,107 @@
1
+ """Discovery and execution of Tailcat's Go data-plane helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, NoReturn
12
+
13
+ from .bundle import BundleError, BundleManifest, verify_bundle
14
+
15
+
16
+ class BackendNotFound(RuntimeError):
17
+ """No usable Tailcat data-plane executable was found."""
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class BackendInfo:
22
+ path: Path
23
+ source: str
24
+ manifest: BundleManifest | None = None
25
+
26
+
27
+ def project_backend() -> Path:
28
+ return Path(__file__).resolve().parents[2] / ".tools" / "bin" / "tailcat"
29
+
30
+
31
+ def inspect_backend() -> BackendInfo:
32
+ configured = os.environ.get("TAILKITTY_BACKEND")
33
+ if configured:
34
+ path = Path(configured).expanduser().resolve()
35
+ if _is_executable(path):
36
+ return BackendInfo(path, "environment")
37
+ raise BackendNotFound(f"TAILKITTY_BACKEND is not executable: {path}")
38
+ bundle = verify_bundle()
39
+ if bundle is not None:
40
+ return BackendInfo(bundle.executable, "bundle", bundle.manifest)
41
+ development = project_backend()
42
+ if _is_executable(development):
43
+ return BackendInfo(development, "development")
44
+ legacy_development = development.with_name("tailcat-go")
45
+ if _is_executable(legacy_development):
46
+ return BackendInfo(legacy_development, "development")
47
+ if candidate := shutil.which("tailcat-go"):
48
+ return BackendInfo(Path(candidate).resolve(), "path")
49
+ raise BackendNotFound(
50
+ "Tailcat data-plane backend is not installed. Run `mise run backend`, "
51
+ "or set TAILKITTY_BACKEND to an upstream tailcat executable."
52
+ )
53
+
54
+
55
+ def _is_executable(path: Path) -> bool:
56
+ return path.is_file() and (os.name == "nt" or os.access(path, os.X_OK))
57
+
58
+
59
+ def find_backend() -> str:
60
+ try:
61
+ return str(inspect_backend().path)
62
+ except BundleError as exc:
63
+ raise BackendNotFound(f"bundled Tailcat executable failed integrity checks: {exc}") from exc
64
+
65
+
66
+ def exec_backend(arguments: Sequence[str]) -> NoReturn:
67
+ backend = find_backend()
68
+ if os.name == "posix":
69
+ os.execv(backend, [backend, *arguments])
70
+ completed = subprocess.run([backend, *arguments], check=False)
71
+ raise SystemExit(completed.returncode)
72
+
73
+
74
+ def backend_version() -> str:
75
+ info = inspect_backend()
76
+ if info.manifest is not None:
77
+ return info.manifest.tailcat_version
78
+ return "unknown (external executable)"
79
+
80
+
81
+ def run(
82
+ arguments: Sequence[str],
83
+ *,
84
+ input: str | bytes | None = None,
85
+ capture_output: bool = False,
86
+ text: bool | None = None,
87
+ check: bool = False,
88
+ timeout: float | None = None,
89
+ **kwargs: Any,
90
+ ) -> subprocess.CompletedProcess[Any]:
91
+ """Run an upstream-compatible Tailcat command.
92
+
93
+ This deliberately follows :func:`subprocess.run` conventions. ``text`` is
94
+ inferred from the input type when omitted, avoiding the common bytes/text
95
+ mismatch in thin process wrappers.
96
+ """
97
+ if text is None:
98
+ text = not isinstance(input, bytes)
99
+ return subprocess.run(
100
+ [find_backend(), *arguments],
101
+ input=input,
102
+ capture_output=capture_output,
103
+ text=text,
104
+ check=check,
105
+ timeout=timeout,
106
+ **kwargs,
107
+ )
@@ -0,0 +1,17 @@
1
+ {
2
+ "cgo_enabled": false,
3
+ "filename": "tailcat.exe",
4
+ "go_version": "go version go1.26.5 darwin/arm64",
5
+ "reproducible_flags": [
6
+ "-trimpath",
7
+ "-buildvcs=false",
8
+ "-ldflags=-s -w -buildid="
9
+ ],
10
+ "schema": 1,
11
+ "sha256": "17d06a87f24dc9f579c2beff0c6cdf923c70699714f30ceffcf3463894f64dab",
12
+ "size": 20491264,
13
+ "tailcat_module": "github.com/tailscale/tailcat",
14
+ "tailcat_version": "v0.0.0-20260828194103-53845983d15e",
15
+ "target": "windows-x86_64",
16
+ "wheel_platform": "win_amd64"
17
+ }
Binary file
tailkitty/bundle.py ADDED
@@ -0,0 +1,139 @@
1
+ """Integrity validation for an executable embedded in a Tailkitty platform wheel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import platform
9
+ import stat
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .constants import TAILCAT_MODULE, TAILCAT_VERSION
17
+
18
+
19
+ class BundleError(RuntimeError):
20
+ """A bundled executable or its manifest is missing or invalid."""
21
+
22
+
23
+ MAX_BUNDLE_SIZE = 100 * 1024 * 1024
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class BundleManifest:
28
+ schema: int
29
+ target: str
30
+ wheel_platform: str
31
+ filename: str
32
+ sha256: str
33
+ size: int
34
+ tailcat_module: str
35
+ tailcat_version: str
36
+ go_version: str
37
+
38
+ @classmethod
39
+ def from_dict(cls, value: dict[str, Any]) -> BundleManifest:
40
+ try:
41
+ manifest = cls(
42
+ schema=int(value["schema"]),
43
+ target=str(value["target"]),
44
+ wheel_platform=str(value["wheel_platform"]),
45
+ filename=str(value["filename"]),
46
+ sha256=str(value["sha256"]),
47
+ size=int(value["size"]),
48
+ tailcat_module=str(value["tailcat_module"]),
49
+ tailcat_version=str(value["tailcat_version"]),
50
+ go_version=str(value["go_version"]),
51
+ )
52
+ except (KeyError, TypeError, ValueError) as exc:
53
+ raise BundleError(f"invalid bundle manifest: {exc}") from exc
54
+ if manifest.schema != 1:
55
+ raise BundleError(f"unsupported bundle manifest schema {manifest.schema}")
56
+ if manifest.tailcat_version != TAILCAT_VERSION:
57
+ raise BundleError(
58
+ f"bundle contains Tailcat {manifest.tailcat_version}, expected {TAILCAT_VERSION}"
59
+ )
60
+ if manifest.tailcat_module != TAILCAT_MODULE:
61
+ raise BundleError(f"bundle contains unexpected module {manifest.tailcat_module!r}")
62
+ if Path(manifest.filename).name != manifest.filename:
63
+ raise BundleError("bundle manifest filename must not contain a path")
64
+ if len(manifest.sha256) != 64 or any(
65
+ character not in "0123456789abcdef" for character in manifest.sha256
66
+ ):
67
+ raise BundleError("bundle manifest contains an invalid SHA-256 digest")
68
+ if manifest.size <= 0 or manifest.size > MAX_BUNDLE_SIZE:
69
+ raise BundleError("bundle manifest contains an invalid executable size")
70
+ return manifest
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class VerifiedBundle:
75
+ executable: Path
76
+ manifest: BundleManifest
77
+
78
+
79
+ def runtime_target() -> str:
80
+ """Return the bundle target name compatible with this interpreter."""
81
+ system = {"darwin": "macos", "linux": "linux", "win32": "windows"}.get(sys.platform)
82
+ machine = platform.machine().lower()
83
+ architecture = {
84
+ "amd64": "x86_64",
85
+ "x86_64": "x86_64",
86
+ "arm64": "arm64",
87
+ "aarch64": "aarch64" if system == "linux" else "arm64",
88
+ }.get(machine)
89
+ if system is None or architecture is None:
90
+ raise BundleError(f"unsupported runtime platform: {sys.platform}/{platform.machine()}")
91
+ return f"{system}-{architecture}"
92
+
93
+
94
+ def bundle_directory() -> Path:
95
+ return Path(__file__).resolve().parent / "bin"
96
+
97
+
98
+ def _sha256(path: Path) -> str:
99
+ digest = hashlib.sha256()
100
+ with path.open("rb") as stream:
101
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
102
+ digest.update(chunk)
103
+ return digest.hexdigest()
104
+
105
+
106
+ @lru_cache(maxsize=4)
107
+ def verify_bundle(directory: Path | None = None) -> VerifiedBundle | None:
108
+ root = directory or bundle_directory()
109
+ manifest_path = root / "manifest.json"
110
+ if not manifest_path.exists():
111
+ return None
112
+ try:
113
+ raw = json.loads(manifest_path.read_text(encoding="utf-8"))
114
+ except (OSError, json.JSONDecodeError) as exc:
115
+ raise BundleError(f"cannot read bundle manifest: {exc}") from exc
116
+ if not isinstance(raw, dict):
117
+ raise BundleError("bundle manifest must contain a JSON object")
118
+ manifest = BundleManifest.from_dict(raw)
119
+ expected_target = runtime_target()
120
+ if manifest.target != expected_target:
121
+ raise BundleError(
122
+ f"bundle target {manifest.target!r} is incompatible with runtime {expected_target!r}"
123
+ )
124
+ executable = root / manifest.filename
125
+ if executable.is_symlink():
126
+ raise BundleError("bundled executable must not be a symbolic link")
127
+ if not executable.is_file():
128
+ raise BundleError(f"bundled executable is missing: {executable}")
129
+ if executable.stat().st_size != manifest.size:
130
+ raise BundleError("bundled executable size does not match its manifest")
131
+ if _sha256(executable) != manifest.sha256:
132
+ raise BundleError("bundled executable checksum does not match its manifest")
133
+ if os.name != "nt" and not os.access(executable, os.X_OK):
134
+ executable.chmod(executable.stat().st_mode | stat.S_IXUSR)
135
+ return VerifiedBundle(executable, manifest)
136
+
137
+
138
+ def clear_bundle_cache() -> None:
139
+ verify_bundle.cache_clear()
tailkitty/cli.py ADDED
@@ -0,0 +1,82 @@
1
+ """Tailkitty command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .backend import BackendNotFound, exec_backend
11
+ from .bundle import BundleError
12
+ from .derp import DEFAULT_DERP_MAP_URL
13
+ from .destination import DestinationError, resolve_destination
14
+ from .diagnostics import diagnostics
15
+ from .token import TokenError, parse_token, resolve_token
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="tailkitty",
21
+ description="Tailkitty: Python tooling with an upstream-compatible Tailcat data plane",
22
+ epilog=(
23
+ "Streaming, port serving, ping, SOCKS, SSH, and key commands are passed "
24
+ "unchanged to the bundled data-plane helper."
25
+ ),
26
+ )
27
+ parser.add_argument("--version", action="version", version=f"tailkitty {__version__}")
28
+ commands = parser.add_subparsers(dest="command")
29
+ parse = commands.add_parser("parse", help="decode a Tailcat connection token in Python")
30
+ parse.add_argument("token")
31
+ resolve = commands.add_parser("resolve", help="embed DERP relay details in a token")
32
+ resolve.add_argument("token")
33
+ resolve.add_argument("--derpmap-url", default=DEFAULT_DERP_MAP_URL)
34
+ doctor = commands.add_parser("doctor", help="show environment and backend diagnostics")
35
+ doctor.add_argument("--json", action="store_true", dest="as_json")
36
+ return parser
37
+
38
+
39
+ def main(arguments: list[str] | None = None) -> int:
40
+ args = list(sys.argv[1:] if arguments is None else arguments)
41
+ # Keep the upstream CLI surface intact. Only Python-native commands are
42
+ # parsed here; everything else (including no arguments) is passed through.
43
+ if not args or args[0] not in {"parse", "resolve", "doctor", "--version", "-h", "--help"}:
44
+ try:
45
+ exec_backend(args)
46
+ except BackendNotFound as exc:
47
+ print(f"tailkitty: {exc}", file=sys.stderr)
48
+ return 127
49
+
50
+ parser = _parser()
51
+ namespace = parser.parse_args(args)
52
+ try:
53
+ if namespace.command == "parse":
54
+ info = parse_token(resolve_destination(namespace.token))
55
+ print(json.dumps(info.to_display_dict(raw=True), indent=4))
56
+ return 0
57
+ if namespace.command == "resolve":
58
+ token = resolve_destination(namespace.token)
59
+ print(resolve_token(token, derp_map_url=namespace.derpmap_url))
60
+ return 0
61
+ if namespace.command == "doctor":
62
+ report = diagnostics()
63
+ if namespace.as_json:
64
+ print(json.dumps(report, indent=2, sort_keys=True))
65
+ else:
66
+ print(f"tailkitty {report['tailkitty_version']}")
67
+ print(f"Python {report['python_version']} ({report['machine']})")
68
+ print(
69
+ f"data-plane backend: {report['backend']['path']} "
70
+ f"[{report['backend']['source']}]"
71
+ )
72
+ if bundle := report["backend"].get("bundle"):
73
+ print(f"Tailcat {bundle['tailcat_version']} ({bundle['target']}, verified)")
74
+ return 0
75
+ except (TokenError, DestinationError, BackendNotFound, BundleError) as exc:
76
+ parser.error(str(exc))
77
+ parser.print_help()
78
+ return 0
79
+
80
+
81
+ if __name__ == "__main__":
82
+ raise SystemExit(main())
tailkitty/client.py ADDED
@@ -0,0 +1,150 @@
1
+ """High-level synchronous and asynchronous Tailcat clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import subprocess
7
+ from typing import Self
8
+
9
+ from .backend import find_backend
10
+ from .destination import resolve_destination, resolve_destination_async
11
+
12
+
13
+ def _port_argument(port: int) -> list[str]:
14
+ if not 0 <= port <= 65535:
15
+ raise ValueError(f"port must be between 0 and 65535, got {port}")
16
+ return [] if port == 0 else [str(port)]
17
+
18
+
19
+ class Client:
20
+ """A reusable Tailcat destination with process and request helpers."""
21
+
22
+ def __init__(self, destination: str, *, dns_timeout: float = 5.0) -> None:
23
+ self.destination = destination
24
+ self.dns_timeout = dns_timeout
25
+ self._token: str | None = None
26
+
27
+ @property
28
+ def token(self) -> str:
29
+ if self._token is None:
30
+ self._token = resolve_destination(self.destination, timeout=self.dns_timeout)
31
+ return self._token
32
+
33
+ def connect(
34
+ self,
35
+ port: int = 0,
36
+ *,
37
+ stdin: int = subprocess.PIPE,
38
+ stdout: int = subprocess.PIPE,
39
+ stderr: int = subprocess.PIPE,
40
+ ) -> subprocess.Popen[bytes]:
41
+ """Start a streaming connection and return its process handles."""
42
+ return subprocess.Popen(
43
+ [find_backend(), self.token, *_port_argument(port)],
44
+ stdin=stdin,
45
+ stdout=stdout,
46
+ stderr=stderr,
47
+ )
48
+
49
+ def request(
50
+ self,
51
+ data: str | bytes = b"",
52
+ *,
53
+ port: int = 0,
54
+ timeout: float | None = None,
55
+ ) -> bytes:
56
+ """Send a finite payload, close stdin, and return response bytes."""
57
+ return self.run(data, port=port, timeout=timeout, check=True).stdout
58
+
59
+ def run(
60
+ self,
61
+ data: str | bytes = b"",
62
+ *,
63
+ port: int = 0,
64
+ timeout: float | None = None,
65
+ check: bool = False,
66
+ ) -> subprocess.CompletedProcess[bytes]:
67
+ """Run a finite exchange and retain status, stdout, and stderr."""
68
+ payload = data.encode() if isinstance(data, str) else data
69
+ return subprocess.run(
70
+ [find_backend(), self.token, *_port_argument(port)],
71
+ input=payload,
72
+ capture_output=True,
73
+ check=check,
74
+ timeout=timeout,
75
+ )
76
+
77
+ def refresh(self) -> Self:
78
+ """Discard a cached DNS result so the next operation resolves it again."""
79
+ self._token = None
80
+ return self
81
+
82
+
83
+ class AsyncClient:
84
+ """Asyncio Tailcat client."""
85
+
86
+ def __init__(self, destination: str, *, dns_timeout: float = 5.0) -> None:
87
+ self.destination = destination
88
+ self.dns_timeout = dns_timeout
89
+ self._token: str | None = None
90
+
91
+ async def resolve(self) -> str:
92
+ if self._token is None:
93
+ self._token = await resolve_destination_async(
94
+ self.destination, timeout=self.dns_timeout
95
+ )
96
+ return self._token
97
+
98
+ async def connect(self, port: int = 0) -> asyncio.subprocess.Process:
99
+ token = await self.resolve()
100
+ return await asyncio.create_subprocess_exec(
101
+ find_backend(),
102
+ token,
103
+ *_port_argument(port),
104
+ stdin=asyncio.subprocess.PIPE,
105
+ stdout=asyncio.subprocess.PIPE,
106
+ stderr=asyncio.subprocess.PIPE,
107
+ )
108
+
109
+ async def request(
110
+ self,
111
+ data: str | bytes = b"",
112
+ *,
113
+ port: int = 0,
114
+ timeout: float | None = None,
115
+ ) -> bytes:
116
+ """Send a finite payload and return response bytes."""
117
+ return (await self.run(data, port=port, timeout=timeout, check=True)).stdout
118
+
119
+ async def run(
120
+ self,
121
+ data: str | bytes = b"",
122
+ *,
123
+ port: int = 0,
124
+ timeout: float | None = None,
125
+ check: bool = False,
126
+ ) -> subprocess.CompletedProcess[bytes]:
127
+ """Async finite exchange retaining status, stdout, and stderr."""
128
+ payload = data.encode() if isinstance(data, str) else data
129
+ backend = find_backend()
130
+ token = await self.resolve()
131
+ command = [backend, token, *_port_argument(port)]
132
+ process = await asyncio.create_subprocess_exec(
133
+ *command,
134
+ stdin=asyncio.subprocess.PIPE,
135
+ stdout=asyncio.subprocess.PIPE,
136
+ stderr=asyncio.subprocess.PIPE,
137
+ )
138
+ try:
139
+ stdout, stderr = await asyncio.wait_for(process.communicate(payload), timeout=timeout)
140
+ except (TimeoutError, asyncio.CancelledError):
141
+ process.kill()
142
+ await process.wait()
143
+ raise
144
+ if check and process.returncode:
145
+ raise subprocess.CalledProcessError(process.returncode, command, stdout, stderr)
146
+ return subprocess.CompletedProcess(command, process.returncode or 0, stdout, stderr)
147
+
148
+ def refresh(self) -> Self:
149
+ self._token = None
150
+ return self
tailkitty/constants.py ADDED
@@ -0,0 +1,7 @@
1
+ """Version pins shared by Tailkitty runtime diagnostics and build tooling."""
2
+
3
+ TAILKITTY_VERSION = "0.1.0"
4
+ GO_VERSION = "1.26.5"
5
+ TAILCAT_MODULE = "github.com/tailscale/tailcat"
6
+ TAILCAT_VERSION = "v0.0.0-20260828194103-53845983d15e"
7
+ TAILCAT_COMMAND = f"{TAILCAT_MODULE}/cmd/tailcat"