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 +40 -0
- tailkitty/__main__.py +5 -0
- tailkitty/backend.py +107 -0
- tailkitty/bin/manifest.json +17 -0
- tailkitty/bin/tailcat.exe +0 -0
- tailkitty/bundle.py +139 -0
- tailkitty/cli.py +82 -0
- tailkitty/client.py +150 -0
- tailkitty/constants.py +7 -0
- tailkitty/derp.py +140 -0
- tailkitty/destination.py +66 -0
- tailkitty/diagnostics.py +38 -0
- tailkitty/process.py +322 -0
- tailkitty/py.typed +1 -0
- tailkitty/token.py +259 -0
- tailkitty-0.1.0.dist-info/METADATA +391 -0
- tailkitty-0.1.0.dist-info/RECORD +21 -0
- tailkitty-0.1.0.dist-info/WHEEL +4 -0
- tailkitty-0.1.0.dist-info/entry_points.txt +3 -0
- tailkitty-0.1.0.dist-info/licenses/LICENSE +28 -0
- tailkitty-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +13 -0
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
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"
|