tailctl 0.1.0__py3-none-any.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.
- tailctl/__init__.py +1 -0
- tailctl/cli.py +674 -0
- tailctl/config.py +275 -0
- tailctl/forwarder.py +123 -0
- tailctl/instance_manager.py +863 -0
- tailctl/liveness.py +137 -0
- tailctl/log.py +43 -0
- tailctl/paths.py +79 -0
- tailctl/profiles.py +71 -0
- tailctl/registry.py +180 -0
- tailctl/signals.py +52 -0
- tailctl/tailscale.py +365 -0
- tailctl-0.1.0.dist-info/METADATA +143 -0
- tailctl-0.1.0.dist-info/RECORD +18 -0
- tailctl-0.1.0.dist-info/WHEEL +5 -0
- tailctl-0.1.0.dist-info/entry_points.txt +2 -0
- tailctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- tailctl-0.1.0.dist-info/top_level.txt +1 -0
tailctl/tailscale.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Thin, side-effect-free wrapper around the Tailscale CLI.
|
|
2
|
+
|
|
3
|
+
All subprocess invocations use ``shell=False`` with an argv list so config
|
|
4
|
+
values flow through ``execve`` arguments, never shell strings. Profile names,
|
|
5
|
+
account IDs, and exit-node values are validated upstream (config-load time)
|
|
6
|
+
against ``^[A-Za-z0-9._-]+$``, so they can pass through without quoting.
|
|
7
|
+
|
|
8
|
+
This module knows nothing about the registry, holders, or instance lifecycle.
|
|
9
|
+
It just runs commands (optionally against a per-instance ``--socket``) and parses
|
|
10
|
+
JSON. Higher-level orchestration lives in ``instance_manager.py``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
|
|
22
|
+
DEFAULT_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
|
23
|
+
|
|
24
|
+
# Convergence + verification timeouts (seconds). Tuned conservative — the
|
|
25
|
+
# Round-2 review flagged 5s as unrealistic for account swaps that re-handshake
|
|
26
|
+
# WireGuard and fetch a fresh netmap.
|
|
27
|
+
DEFAULT_CONVERGENCE_TIMEOUT_S = 20.0
|
|
28
|
+
DEFAULT_CONVERGENCE_POLL_INTERVAL_S = 0.5
|
|
29
|
+
DEFAULT_SUBPROCESS_TIMEOUT_S = 30.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- exceptions ---
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TailscaleError(RuntimeError):
|
|
36
|
+
"""Generic Tailscale CLI failure (non-zero exit, parse error, timeout)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TailscaleConvergenceTimeout(TailscaleError):
|
|
40
|
+
"""Tailscale daemon did not converge to the expected state in time."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- types ---
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SwitchAccount:
|
|
48
|
+
"""One row from ``tailscale switch --list --json``."""
|
|
49
|
+
|
|
50
|
+
id: str
|
|
51
|
+
nickname: str
|
|
52
|
+
tailnet: str
|
|
53
|
+
account: str
|
|
54
|
+
selected: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ExitNodeStatus:
|
|
59
|
+
"""Minimal projection of ``ExitNodeStatus`` in status --json.
|
|
60
|
+
|
|
61
|
+
``id`` is Tailscale's opaque short ID (e.g. ``nKKJQbqKax11CNTRL``).
|
|
62
|
+
``hostname`` is the human name from the matching ``Peer`` entry — what
|
|
63
|
+
users put in ``profiles.yaml`` under ``exit_node:``. It's resolved by
|
|
64
|
+
``parse_status`` joining ``ExitNodeStatus.ID`` against ``Peer[*].ID``;
|
|
65
|
+
``None`` when the peer entry isn't present (e.g. during reconnect).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
id: str | None
|
|
69
|
+
online: bool
|
|
70
|
+
hostname: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class TailscaleStatus:
|
|
75
|
+
"""Minimal projection of ``tailscale status --json`` for our needs."""
|
|
76
|
+
|
|
77
|
+
backend_state: str
|
|
78
|
+
current_tailnet_name: str | None
|
|
79
|
+
exit_node: ExitNodeStatus | None
|
|
80
|
+
# The raw dict is preserved so the caller can pull arbitrary fields
|
|
81
|
+
# (e.g. for forensic logging) without re-parsing.
|
|
82
|
+
raw: dict[str, Any]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- subprocess seam ---
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CommandRunner(Protocol):
|
|
89
|
+
"""Seam for unit tests: replace subprocess with a fake.
|
|
90
|
+
|
|
91
|
+
Returns ``(returncode, stdout, stderr)``. Implementations MUST NOT raise
|
|
92
|
+
on non-zero exit — the wrapper decides what's an error.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __call__(
|
|
96
|
+
self, argv: list[str], *, timeout: float | None = None
|
|
97
|
+
) -> tuple[int, str, str]: ...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def real_runner(argv: list[str], *, timeout: float | None = None) -> tuple[int, str, str]:
|
|
101
|
+
"""Production runner: ``subprocess.run`` with shell=False, check=False."""
|
|
102
|
+
proc = subprocess.run(
|
|
103
|
+
argv,
|
|
104
|
+
shell=False,
|
|
105
|
+
check=False,
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
)
|
|
110
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- client ---
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TailscaleClient:
|
|
117
|
+
"""High-level interface to the Tailscale CLI."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
binary: str = DEFAULT_BINARY,
|
|
123
|
+
runner: CommandRunner | None = None,
|
|
124
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
125
|
+
clock: Callable[[], float] = time.monotonic,
|
|
126
|
+
socket: str | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._binary = binary
|
|
129
|
+
self._runner = runner or real_runner
|
|
130
|
+
self._sleep = sleep
|
|
131
|
+
self._clock = clock
|
|
132
|
+
# When set, every CLI invocation targets a specific (userspace)
|
|
133
|
+
# tailscaled via its UDS, instead of the machine-global daemon. On
|
|
134
|
+
# macOS the CLI otherwise defaults to [::1]:64511, so this MUST be
|
|
135
|
+
# passed explicitly to drive a per-identity instance.
|
|
136
|
+
self._socket = socket
|
|
137
|
+
|
|
138
|
+
def _argv(self, *rest: str) -> list[str]:
|
|
139
|
+
"""Build a CLI argv, prepending --socket when this client is bound to
|
|
140
|
+
a specific daemon socket."""
|
|
141
|
+
head = [self._binary]
|
|
142
|
+
if self._socket:
|
|
143
|
+
head.append(f"--socket={self._socket}")
|
|
144
|
+
return [*head, *rest]
|
|
145
|
+
|
|
146
|
+
# --- raw probes ---
|
|
147
|
+
|
|
148
|
+
def switch_list(self) -> list[SwitchAccount]:
|
|
149
|
+
"""Return all signed-in accounts. Equivalent of ``switch --list --json``."""
|
|
150
|
+
rc, stdout, stderr = self._runner(
|
|
151
|
+
self._argv("switch", "--list", "--json"),
|
|
152
|
+
timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
|
|
153
|
+
)
|
|
154
|
+
if rc != 0:
|
|
155
|
+
raise TailscaleError(f"switch --list --json failed (rc={rc}): {stderr.strip()}")
|
|
156
|
+
return parse_switch_list(stdout)
|
|
157
|
+
|
|
158
|
+
def status(self) -> TailscaleStatus:
|
|
159
|
+
"""Return ``tailscale status --json`` parsed into a minimal projection."""
|
|
160
|
+
rc, stdout, stderr = self._runner(
|
|
161
|
+
self._argv("status", "--json"),
|
|
162
|
+
timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
|
|
163
|
+
)
|
|
164
|
+
if rc != 0:
|
|
165
|
+
raise TailscaleError(f"status --json failed (rc={rc}): {stderr.strip()}")
|
|
166
|
+
return parse_status(stdout)
|
|
167
|
+
|
|
168
|
+
def active_account(self) -> SwitchAccount | None:
|
|
169
|
+
for acct in self.switch_list():
|
|
170
|
+
if acct.selected:
|
|
171
|
+
return acct
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# --- mutators ---
|
|
175
|
+
|
|
176
|
+
def switch_account(self, account_id: str) -> None:
|
|
177
|
+
"""Issue ``tailscale switch <id>``. Does NOT wait for convergence."""
|
|
178
|
+
rc, _stdout, stderr = self._runner(
|
|
179
|
+
self._argv("switch", account_id),
|
|
180
|
+
timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
|
|
181
|
+
)
|
|
182
|
+
if rc != 0:
|
|
183
|
+
raise TailscaleError(
|
|
184
|
+
f"switch {account_id} failed (rc={rc}): {stderr.strip()}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def set_exit_node(self, exit_node: str | None) -> None:
|
|
188
|
+
"""Issue ``tailscale set --exit-node=<name>`` (empty = clear)."""
|
|
189
|
+
value = exit_node or ""
|
|
190
|
+
rc, _stdout, stderr = self._runner(
|
|
191
|
+
self._argv("set", f"--exit-node={value}"),
|
|
192
|
+
timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
|
|
193
|
+
)
|
|
194
|
+
if rc != 0:
|
|
195
|
+
raise TailscaleError(
|
|
196
|
+
f"set --exit-node={value!r} failed (rc={rc}): {stderr.strip()}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def up(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
accept_routes: bool = True,
|
|
203
|
+
accept_dns: bool = False,
|
|
204
|
+
exit_node: str | None = None,
|
|
205
|
+
hostname: str | None = None,
|
|
206
|
+
auth_key: str | None = None,
|
|
207
|
+
timeout_s: float = DEFAULT_SUBPROCESS_TIMEOUT_S,
|
|
208
|
+
) -> tuple[int, str, str]:
|
|
209
|
+
"""Issue ``tailscale up`` against this client's (userspace) daemon.
|
|
210
|
+
|
|
211
|
+
With ``auth_key`` the login is non-interactive and returns once the
|
|
212
|
+
node is registered. Without it, a first-time login blocks/prints an
|
|
213
|
+
auth URL on stdout for the caller to surface; an already-authed
|
|
214
|
+
statedir returns rc=0 immediately. Subprocess timeout still raises.
|
|
215
|
+
|
|
216
|
+
The auth key is passed via argv to the tailscale CLI; it is never
|
|
217
|
+
included in this wrapper's logs or error messages.
|
|
218
|
+
"""
|
|
219
|
+
argv = self._argv("up")
|
|
220
|
+
argv.append(f"--accept-routes={'true' if accept_routes else 'false'}")
|
|
221
|
+
argv.append(f"--accept-dns={'true' if accept_dns else 'false'}")
|
|
222
|
+
if exit_node:
|
|
223
|
+
argv.append(f"--exit-node={exit_node}")
|
|
224
|
+
if hostname:
|
|
225
|
+
argv.append(f"--hostname={hostname}")
|
|
226
|
+
if auth_key:
|
|
227
|
+
argv.append(f"--auth-key={auth_key}")
|
|
228
|
+
return self._runner(argv, timeout=timeout_s)
|
|
229
|
+
|
|
230
|
+
def logout(self) -> None:
|
|
231
|
+
"""Deregister this daemon's node (clears the statedir's login)."""
|
|
232
|
+
self._runner(self._argv("logout"), timeout=DEFAULT_SUBPROCESS_TIMEOUT_S)
|
|
233
|
+
|
|
234
|
+
# --- convergence ---
|
|
235
|
+
|
|
236
|
+
def wait_for_backend_running_and_tailnet(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
expected_tailnet: str | None,
|
|
240
|
+
timeout_s: float = DEFAULT_CONVERGENCE_TIMEOUT_S,
|
|
241
|
+
poll_interval_s: float = DEFAULT_CONVERGENCE_POLL_INTERVAL_S,
|
|
242
|
+
) -> TailscaleStatus:
|
|
243
|
+
"""Poll status until ``BackendState == Running`` and (optionally) the
|
|
244
|
+
active tailnet matches ``expected_tailnet``.
|
|
245
|
+
|
|
246
|
+
``expected_tailnet`` may be ``None`` or empty to skip the tailnet
|
|
247
|
+
check (used when the active account has no tailnet name, e.g. a
|
|
248
|
+
personal account).
|
|
249
|
+
"""
|
|
250
|
+
deadline = self._clock() + timeout_s
|
|
251
|
+
last: TailscaleStatus | None = None
|
|
252
|
+
while True:
|
|
253
|
+
try:
|
|
254
|
+
last = self.status()
|
|
255
|
+
except TailscaleError:
|
|
256
|
+
last = None
|
|
257
|
+
if last is not None and last.backend_state == "Running":
|
|
258
|
+
if not expected_tailnet or last.current_tailnet_name == expected_tailnet:
|
|
259
|
+
return last
|
|
260
|
+
if self._clock() >= deadline:
|
|
261
|
+
raise TailscaleConvergenceTimeout(
|
|
262
|
+
f"backend did not reach Running/{expected_tailnet!r} within "
|
|
263
|
+
f"{timeout_s}s (last={last!r})"
|
|
264
|
+
)
|
|
265
|
+
self._sleep(poll_interval_s)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --- parsers ---
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_switch_list(stdout: str) -> list[SwitchAccount]:
|
|
272
|
+
"""Parse ``switch --list --json`` output into typed records."""
|
|
273
|
+
try:
|
|
274
|
+
data = json.loads(stdout)
|
|
275
|
+
except json.JSONDecodeError as exc:
|
|
276
|
+
raise TailscaleError(f"switch --list --json: invalid JSON: {exc}") from exc
|
|
277
|
+
if not isinstance(data, list):
|
|
278
|
+
raise TailscaleError("switch --list --json: expected a JSON array")
|
|
279
|
+
out: list[SwitchAccount] = []
|
|
280
|
+
for i, entry in enumerate(data):
|
|
281
|
+
if not isinstance(entry, dict):
|
|
282
|
+
raise TailscaleError(f"switch --list --json: entry {i} is not an object")
|
|
283
|
+
try:
|
|
284
|
+
out.append(
|
|
285
|
+
SwitchAccount(
|
|
286
|
+
id=str(entry["id"]),
|
|
287
|
+
nickname=str(entry.get("nickname", "")),
|
|
288
|
+
tailnet=str(entry.get("tailnet", "")),
|
|
289
|
+
account=str(entry.get("account", "")),
|
|
290
|
+
selected=bool(entry["selected"]),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
except KeyError as exc:
|
|
294
|
+
raise TailscaleError(
|
|
295
|
+
f"switch --list --json: entry {i} missing key {exc.args[0]!r}"
|
|
296
|
+
) from exc
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_status(stdout: str) -> TailscaleStatus:
|
|
301
|
+
"""Parse ``status --json`` into the minimal projection used internally."""
|
|
302
|
+
try:
|
|
303
|
+
data = json.loads(stdout)
|
|
304
|
+
except json.JSONDecodeError as exc:
|
|
305
|
+
raise TailscaleError(f"status --json: invalid JSON: {exc}") from exc
|
|
306
|
+
if not isinstance(data, dict):
|
|
307
|
+
raise TailscaleError("status --json: expected a JSON object")
|
|
308
|
+
backend_state = data.get("BackendState")
|
|
309
|
+
if not isinstance(backend_state, str):
|
|
310
|
+
raise TailscaleError("status --json: missing or non-string BackendState")
|
|
311
|
+
tailnet_name: str | None = None
|
|
312
|
+
current_tailnet = data.get("CurrentTailnet")
|
|
313
|
+
if isinstance(current_tailnet, dict):
|
|
314
|
+
name = current_tailnet.get("Name")
|
|
315
|
+
if isinstance(name, str) and name:
|
|
316
|
+
tailnet_name = name
|
|
317
|
+
exit_node: ExitNodeStatus | None = None
|
|
318
|
+
ens = data.get("ExitNodeStatus")
|
|
319
|
+
if isinstance(ens, dict):
|
|
320
|
+
en_id = str(ens["ID"]) if isinstance(ens.get("ID"), str) else None
|
|
321
|
+
exit_node = ExitNodeStatus(
|
|
322
|
+
id=en_id,
|
|
323
|
+
online=bool(ens.get("Online", False)),
|
|
324
|
+
hostname=_resolve_exit_node_hostname(data, en_id),
|
|
325
|
+
)
|
|
326
|
+
return TailscaleStatus(
|
|
327
|
+
backend_state=backend_state,
|
|
328
|
+
current_tailnet_name=tailnet_name,
|
|
329
|
+
exit_node=exit_node,
|
|
330
|
+
raw=data,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _resolve_exit_node_hostname(
|
|
335
|
+
status_data: dict[str, Any], exit_node_id: str | None
|
|
336
|
+
) -> str | None:
|
|
337
|
+
"""Join ExitNodeStatus.ID against Peer[*].ID (and Self) to find the
|
|
338
|
+
human hostname.
|
|
339
|
+
|
|
340
|
+
Tailscale reports the active exit node by opaque internal ID; users
|
|
341
|
+
write profiles.yaml using the peer's HostName. Without this join, a
|
|
342
|
+
correctly-configured exit node looks like drift.
|
|
343
|
+
|
|
344
|
+
Self is checked alongside Peer because a node advertising itself as an
|
|
345
|
+
exit node appears in Self, not Peer — uncommon but legal.
|
|
346
|
+
|
|
347
|
+
Returns None when the ID is absent or no node matches (treat as
|
|
348
|
+
unverified — callers should not flag drift in that case).
|
|
349
|
+
"""
|
|
350
|
+
if not exit_node_id:
|
|
351
|
+
return None
|
|
352
|
+
candidates: list[dict[str, Any]] = []
|
|
353
|
+
self_node = status_data.get("Self")
|
|
354
|
+
if isinstance(self_node, dict):
|
|
355
|
+
candidates.append(self_node)
|
|
356
|
+
peers = status_data.get("Peer")
|
|
357
|
+
if isinstance(peers, dict):
|
|
358
|
+
candidates.extend(p for p in peers.values() if isinstance(p, dict))
|
|
359
|
+
for node in candidates:
|
|
360
|
+
if node.get("ID") == exit_node_id:
|
|
361
|
+
host = node.get("HostName")
|
|
362
|
+
if isinstance(host, str) and host:
|
|
363
|
+
return host
|
|
364
|
+
return None
|
|
365
|
+
return None
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tailctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Per-identity Tailscale networking for parallel sessions on a single Mac
|
|
5
|
+
Author-email: DRYCodeWorks <dan@drycodeworks.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://drycodeworks.com
|
|
8
|
+
Project-URL: Repository, https://github.com/DRYCodeWorks/tailctl
|
|
9
|
+
Project-URL: Issues, https://github.com/DRYCodeWorks/tailctl/issues
|
|
10
|
+
Keywords: tailscale,vpn,wireguard,networking,socks5,proxy,cli,macos,multi-account,ai-agents,parallel-sessions
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Networking
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: PyYAML>=6.0
|
|
27
|
+
Requires-Dist: psutil>=5.9
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-timeout>=2.2; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="https://raw.githubusercontent.com/DRYCodeWorks/tailctl/main/docs/tailctl-banner.jpg" alt="tailctl — per-identity Tailscale networking for parallel sessions on a single Mac" width="100%">
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
# tailctl
|
|
39
|
+
|
|
40
|
+
Per-identity Tailscale networking for parallel sessions on a single Mac.
|
|
41
|
+
|
|
42
|
+
`tailctl` gives each process (e.g. a Claude Code session in its own Ghostty window)
|
|
43
|
+
its **own** Tailscale identity — a private, headless userspace `tailscaled` with a
|
|
44
|
+
per-identity SOCKS5/HTTP proxy and localhost port-forwards — instead of switching
|
|
45
|
+
the machine-global Tailscale account. Many identities run at once, and the GUI
|
|
46
|
+
Tailscale app (the browser's default tailnet) is never touched. No FIFO queue, no
|
|
47
|
+
shared network, no flapping.
|
|
48
|
+
|
|
49
|
+
Agent traffic is opted in per command via proxy env vars (`tailctl run`) or stable
|
|
50
|
+
localhost forwards for native-TCP clients (ClickHouse, Postgres, …).
|
|
51
|
+
|
|
52
|
+
No always-on daemon beyond the per-identity tailscaled instances. Single-laptop scope.
|
|
53
|
+
|
|
54
|
+
## Status
|
|
55
|
+
|
|
56
|
+
Working prototype — macOS-only, single-laptop scope.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
Requires macOS, Python 3.11+, and the Homebrew Tailscale daemon (see
|
|
61
|
+
[Requirements](#requirements)).
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
brew install tailscale
|
|
65
|
+
pipx install tailctl # or: pip install tailctl
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or from source:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
brew install tailscale
|
|
72
|
+
git clone https://github.com/DRYCodeWorks/tailctl && cd tailctl
|
|
73
|
+
python3 -m venv .venv && .venv/bin/pip install -e .
|
|
74
|
+
ln -sf "$PWD/.venv/bin/tailctl" ~/.local/bin/tailctl # put it on PATH
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quick view
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
tailctl init # scaffold ~/.tailctl/profiles.yaml
|
|
81
|
+
tailctl doctor # validate config + probe daemon + verify accounts
|
|
82
|
+
tailctl bootstrap <name> --tailnet <t> [--exit-node <e>] [--port-forward svc=host:port]
|
|
83
|
+
# onboard a NEW tailnet: discovers account_id,
|
|
84
|
+
# appends a profile (preserving comments), prints
|
|
85
|
+
# the key steps (mint in console -> store in BWS)
|
|
86
|
+
tailctl up acme-dev # spawn a userspace tailscaled for the profile
|
|
87
|
+
# (first time: prints a one-time auth URL)
|
|
88
|
+
tailctl run acme-dev -- curl http://service.internal # route a command via that identity
|
|
89
|
+
tailctl run acme-dev -- clickhouse-client --host 127.0.0.1 --port $CLICKHOUSE_ADDR
|
|
90
|
+
tailctl ps # list running instances + their proxy ports/forwards
|
|
91
|
+
tailctl down acme-dev # release (refcount--); stops the daemon at zero
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`run` sets `ALL_PROXY`/`HTTPS_PROXY`/`HTTP_PROXY` (+ `NO_PROXY`) so proxy-aware
|
|
95
|
+
clients route through the identity, and exports `<SERVICE>_ADDR=127.0.0.1:<port>`
|
|
96
|
+
for each configured `port_forward` so native-TCP clients connect to localhost.
|
|
97
|
+
|
|
98
|
+
## profiles.yaml
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
default: acme-dev
|
|
102
|
+
tailscaled_binary: /opt/homebrew/opt/tailscale/bin/tailscaled
|
|
103
|
+
profiles:
|
|
104
|
+
acme-dev:
|
|
105
|
+
account_id: a2c4
|
|
106
|
+
tailnet: acme.example.ts.net
|
|
107
|
+
exit_node: tailscale-subnet-router-development
|
|
108
|
+
accept_routes: true
|
|
109
|
+
auth_key_env: ACME_TS_AUTHKEY # optional: env var holding a Tailscale
|
|
110
|
+
# auth key (from BWS) for non-interactive
|
|
111
|
+
# first login — no browser URL
|
|
112
|
+
port_forwards:
|
|
113
|
+
- service: clickhouse # exported as CLICKHOUSE_ADDR
|
|
114
|
+
remote_host: 100.64.0.10
|
|
115
|
+
remote_port: 9000
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Authentication
|
|
119
|
+
|
|
120
|
+
Each profile is a distinct device on its tailnet, authenticated **once** — the
|
|
121
|
+
login persists in the profile's statedir and is reused on every later `up`/`run`
|
|
122
|
+
(teardown never logs out). Two ways to do that one-time login:
|
|
123
|
+
|
|
124
|
+
- **Auth key (recommended):** set `auth_key_env` to an env var holding a
|
|
125
|
+
Tailscale auth key (minted in the tailnet's admin console, stored in BWS).
|
|
126
|
+
First `up` logs in non-interactively — no browser.
|
|
127
|
+
- **Interactive:** with no auth key, the first `up` prints a login URL to open
|
|
128
|
+
once.
|
|
129
|
+
|
|
130
|
+
Profiles that share an account but differ by exit node (e.g. Acme
|
|
131
|
+
dev/staging/prod) are separate profiles → separate nodes → one auth each.
|
|
132
|
+
|
|
133
|
+
## Requirements
|
|
134
|
+
|
|
135
|
+
- macOS
|
|
136
|
+
- Homebrew Tailscale for the userspace daemon: `brew install tailscale`
|
|
137
|
+
(coexists with the GUI Tailscale.app, which keeps the browser's default tailnet)
|
|
138
|
+
- Python 3.11+
|
|
139
|
+
- Each profile's Tailscale account authenticated once per profile on first `up`
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
tailctl/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
tailctl/cli.py,sha256=WCJEWvINo9bn6T_Je6PhIYzD7LuaA5ZWrOS22WgVeiQ,24055
|
|
3
|
+
tailctl/config.py,sha256=mdLf3f-FljubE2n31RL-LrzMgIhnpoOWsCzszHcYmwk,11233
|
|
4
|
+
tailctl/forwarder.py,sha256=puLpCMWRID8qUB1P3eLl94PQjuwcow8DWd3rU3IzEDI,4606
|
|
5
|
+
tailctl/instance_manager.py,sha256=qCpIc_Uc0LSl27WYiKbl_tZzwp3aP8IVidG2EsT90-w,37379
|
|
6
|
+
tailctl/liveness.py,sha256=-qeFpMlkL0a-nKoB7O22YVDjA-F5REAKZ8qnWUC65FU,4967
|
|
7
|
+
tailctl/log.py,sha256=6H0rRPqe5iMjoD9gBhMD1JgtSrmrnWW9TcITpErJPh4,1368
|
|
8
|
+
tailctl/paths.py,sha256=S0_XwxphmiRf6gWqLDTikqi59u7_UwakQt9y7wajn08,2026
|
|
9
|
+
tailctl/profiles.py,sha256=TSTwr-GSRpkZrnuG_-DHN6f-FRqixUixopiVvxBupOY,2270
|
|
10
|
+
tailctl/registry.py,sha256=380McJU1EAfVIyWukCPv2DfbHTY-mEFFvhI5wLe5N-Y,6300
|
|
11
|
+
tailctl/signals.py,sha256=ZvLbmPY6dMAzHko8GPS3kFRYeSh66WjspDB1wuu1hfM,1753
|
|
12
|
+
tailctl/tailscale.py,sha256=hCrojMxhRrmiyoJpVmATfLFZQt3kq4vnswP3LjpxvKw,13005
|
|
13
|
+
tailctl-0.1.0.dist-info/licenses/LICENSE,sha256=tCJ1mJyU60MYqdPDvF-E5lFzcloGJkHszr4qRp-MC8A,1069
|
|
14
|
+
tailctl-0.1.0.dist-info/METADATA,sha256=oTbMFjAlxHqbXNciWQ5K8X1JfFTyp2UuNbiFdSgwWzg,5736
|
|
15
|
+
tailctl-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
16
|
+
tailctl-0.1.0.dist-info/entry_points.txt,sha256=ZCJHEcit4mUTBAJ1kqQL8O3hfmO6olHo8rTBtt-e0ss,45
|
|
17
|
+
tailctl-0.1.0.dist-info/top_level.txt,sha256=_9CWOOPeY9wB1f_ch9A7jdpHpAEzQM_OiCEBpmztjzk,8
|
|
18
|
+
tailctl-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DRYCodeWorks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tailctl
|