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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tailctl/cli.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"""tailctl command-line entry point.
|
|
2
|
+
|
|
3
|
+
Per-identity userspace model: each profile a session needs runs its own
|
|
4
|
+
headless userspace tailscaled (SOCKS5 + HTTP proxy, localhost port-forwards),
|
|
5
|
+
fully isolated from the machine-global Tailscale identity the browser uses.
|
|
6
|
+
|
|
7
|
+
Subcommands:
|
|
8
|
+
init — scaffold ~/.tailctl/profiles.yaml from `tailscale switch --list --json`
|
|
9
|
+
doctor — validate config, probe daemon, verify accounts are signed in
|
|
10
|
+
profiles — list configured profiles
|
|
11
|
+
up — ensure a profile's userspace tailscaled is running (refcount++)
|
|
12
|
+
run — run a command routed through a profile's identity
|
|
13
|
+
down — release a profile (refcount--); stop the daemon at zero
|
|
14
|
+
ps — list running userspace instances
|
|
15
|
+
reset — stop ALL instances and clear the registry (requires --force)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import psutil
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from tailctl import paths
|
|
31
|
+
from tailctl.config import Config, ConfigError
|
|
32
|
+
from tailctl.config import load as load_config
|
|
33
|
+
from tailctl.instance_manager import InstanceError, InstanceManager
|
|
34
|
+
from tailctl.profiles import UnknownProfileError
|
|
35
|
+
from tailctl.registry import RegistryStore
|
|
36
|
+
from tailctl.tailscale import (
|
|
37
|
+
DEFAULT_BINARY,
|
|
38
|
+
TailscaleClient,
|
|
39
|
+
TailscaleError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
EXIT_OK = 0
|
|
43
|
+
EXIT_ERROR = 1
|
|
44
|
+
EXIT_USAGE = 2
|
|
45
|
+
EXIT_TIMEOUT = 124
|
|
46
|
+
EXIT_BROKEN = 3
|
|
47
|
+
EXIT_CORRUPT = 4
|
|
48
|
+
EXIT_DRIFT = 5
|
|
49
|
+
EXIT_AUTH = 7
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# === entry points ========================================================
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
56
|
+
parser = _build_parser()
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
handler = args.func # set by subparser default
|
|
59
|
+
try:
|
|
60
|
+
return handler(args)
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
print("interrupted", file=sys.stderr)
|
|
63
|
+
return EXIT_ERROR
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="tailctl",
|
|
69
|
+
description="Tailnet identity coordinator for parallel sessions on a single Mac.",
|
|
70
|
+
)
|
|
71
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
72
|
+
|
|
73
|
+
p_init = sub.add_parser("init", help="scaffold profiles.yaml")
|
|
74
|
+
p_init.add_argument(
|
|
75
|
+
"--force",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="overwrite an existing profiles.yaml",
|
|
78
|
+
)
|
|
79
|
+
p_init.set_defaults(func=_cmd_init)
|
|
80
|
+
|
|
81
|
+
p_doctor = sub.add_parser("doctor", help="validate config and probe Tailscale daemon")
|
|
82
|
+
p_doctor.set_defaults(func=_cmd_doctor)
|
|
83
|
+
|
|
84
|
+
p_profiles = sub.add_parser("profiles", help="list configured profiles")
|
|
85
|
+
p_profiles.set_defaults(func=_cmd_profiles)
|
|
86
|
+
|
|
87
|
+
p_reset = sub.add_parser("reset", help="stop ALL instances and clear the registry")
|
|
88
|
+
p_reset.add_argument("--force", action="store_true", help="required")
|
|
89
|
+
p_reset.set_defaults(func=_cmd_reset)
|
|
90
|
+
|
|
91
|
+
# --- userspace-per-identity verbs ---
|
|
92
|
+
p_up = sub.add_parser("up", help="ensure a userspace tailscaled is running for a profile")
|
|
93
|
+
p_up.add_argument("profile", help="profile name from profiles.yaml")
|
|
94
|
+
p_up.set_defaults(func=_cmd_up)
|
|
95
|
+
|
|
96
|
+
p_down = sub.add_parser("down", help="release/stop a profile's userspace instance")
|
|
97
|
+
p_down.add_argument("profile", help="profile name from profiles.yaml")
|
|
98
|
+
p_down.set_defaults(func=_cmd_down)
|
|
99
|
+
|
|
100
|
+
p_run = sub.add_parser(
|
|
101
|
+
"run", help="run a command routed through a profile's identity"
|
|
102
|
+
)
|
|
103
|
+
p_run.add_argument("profile", help="profile name from profiles.yaml")
|
|
104
|
+
p_run.add_argument(
|
|
105
|
+
"command",
|
|
106
|
+
nargs=argparse.REMAINDER,
|
|
107
|
+
help="-- <command> [args...] to run with proxy env set",
|
|
108
|
+
)
|
|
109
|
+
p_run.set_defaults(func=_cmd_run)
|
|
110
|
+
|
|
111
|
+
p_ps = sub.add_parser("ps", help="list running userspace instances")
|
|
112
|
+
p_ps.add_argument("--json", action="store_true", help="emit JSON")
|
|
113
|
+
p_ps.set_defaults(func=_cmd_ps)
|
|
114
|
+
|
|
115
|
+
p_reap = sub.add_parser(
|
|
116
|
+
"reap", help="stop instances whose owner died or whose linger expired"
|
|
117
|
+
)
|
|
118
|
+
p_reap.set_defaults(func=_cmd_reap)
|
|
119
|
+
|
|
120
|
+
p_boot = sub.add_parser(
|
|
121
|
+
"bootstrap", help="onboard a new tailnet: add a profile to profiles.yaml"
|
|
122
|
+
)
|
|
123
|
+
p_boot.add_argument("name", help="new profile name")
|
|
124
|
+
p_boot.add_argument("--account-id", help="Tailscale account id (else discover via --tailnet)")
|
|
125
|
+
p_boot.add_argument(
|
|
126
|
+
"--tailnet", help="tailnet name; discovers account_id from signed-in accounts"
|
|
127
|
+
)
|
|
128
|
+
p_boot.add_argument("--exit-node", help="exit-node hostname (optional)")
|
|
129
|
+
p_boot.add_argument(
|
|
130
|
+
"--auth-key-env",
|
|
131
|
+
help="env/BWS secret name holding this tailnet's auth key "
|
|
132
|
+
"(default: <name-with-underscores>_ts_authkey)",
|
|
133
|
+
)
|
|
134
|
+
p_boot.add_argument(
|
|
135
|
+
"--port-forward",
|
|
136
|
+
action="append",
|
|
137
|
+
default=[],
|
|
138
|
+
metavar="SVC=HOST:PORT",
|
|
139
|
+
help="native-TCP forward, repeatable (e.g. clickhouse=100.64.0.10:9000)",
|
|
140
|
+
)
|
|
141
|
+
p_boot.add_argument(
|
|
142
|
+
"--accept-routes",
|
|
143
|
+
action=argparse.BooleanOptionalAction,
|
|
144
|
+
default=True,
|
|
145
|
+
help="accept advertised subnet routes (default: yes)",
|
|
146
|
+
)
|
|
147
|
+
p_boot.add_argument(
|
|
148
|
+
"--register",
|
|
149
|
+
action="store_true",
|
|
150
|
+
help="after writing, run `up` to register the node now (needs the key)",
|
|
151
|
+
)
|
|
152
|
+
p_boot.add_argument("--force", action="store_true", help="overwrite an existing profile")
|
|
153
|
+
p_boot.set_defaults(func=_cmd_bootstrap)
|
|
154
|
+
|
|
155
|
+
return parser
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# === init =================================================================
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
162
|
+
binary = os.environ.get("TAILSCALE_BIN", DEFAULT_BINARY)
|
|
163
|
+
if not Path(binary).exists():
|
|
164
|
+
print(
|
|
165
|
+
f"error: Tailscale binary not found at {binary}\n"
|
|
166
|
+
f"set TAILSCALE_BIN=/path/to/tailscale and retry",
|
|
167
|
+
file=sys.stderr,
|
|
168
|
+
)
|
|
169
|
+
return EXIT_ERROR
|
|
170
|
+
|
|
171
|
+
client = TailscaleClient(binary=binary)
|
|
172
|
+
try:
|
|
173
|
+
accounts = client.switch_list()
|
|
174
|
+
except TailscaleError as exc:
|
|
175
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
176
|
+
return EXIT_ERROR
|
|
177
|
+
if not accounts:
|
|
178
|
+
print(
|
|
179
|
+
"error: no Tailscale accounts signed in. Open Tailscale.app, sign "
|
|
180
|
+
"into the accounts you want, then retry.",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
return EXIT_ERROR
|
|
184
|
+
|
|
185
|
+
config_path = paths.profiles_yaml()
|
|
186
|
+
if config_path.exists() and not args.force:
|
|
187
|
+
print(
|
|
188
|
+
f"error: {config_path} already exists. Pass --force to overwrite.",
|
|
189
|
+
file=sys.stderr,
|
|
190
|
+
)
|
|
191
|
+
return EXIT_ERROR
|
|
192
|
+
|
|
193
|
+
paths.ensure_home()
|
|
194
|
+
# Build a starter profiles map: one entry per signed-in account.
|
|
195
|
+
profiles_map: dict[str, dict[str, object]] = {}
|
|
196
|
+
default_name: str | None = None
|
|
197
|
+
for acct in accounts:
|
|
198
|
+
# Synthesize a profile name. Prefer the tailnet (it's stable and
|
|
199
|
+
# human-readable); fall back to the nickname; finally the id.
|
|
200
|
+
name_seed = acct.tailnet or acct.nickname or f"acct-{acct.id}"
|
|
201
|
+
name = _safe_profile_name(name_seed)
|
|
202
|
+
# Collisions: append the id to disambiguate.
|
|
203
|
+
if name in profiles_map:
|
|
204
|
+
name = f"{name}-{acct.id}"
|
|
205
|
+
profiles_map[name] = {
|
|
206
|
+
"account_id": acct.id,
|
|
207
|
+
"tailnet": acct.tailnet or None,
|
|
208
|
+
"exit_node": None,
|
|
209
|
+
"description": acct.account,
|
|
210
|
+
}
|
|
211
|
+
if acct.selected and default_name is None:
|
|
212
|
+
default_name = name
|
|
213
|
+
if default_name is None:
|
|
214
|
+
# Pick the first profile if none was selected.
|
|
215
|
+
default_name = next(iter(profiles_map))
|
|
216
|
+
|
|
217
|
+
payload = {
|
|
218
|
+
"default": default_name,
|
|
219
|
+
"tailscale_binary": binary,
|
|
220
|
+
"profiles": profiles_map,
|
|
221
|
+
}
|
|
222
|
+
config_path.write_text(yaml.safe_dump(payload, sort_keys=False))
|
|
223
|
+
print(f"wrote {config_path}")
|
|
224
|
+
print(f"default profile: {default_name}")
|
|
225
|
+
print(f" ({len(profiles_map)} profile(s) scaffolded — edit to add exit nodes)")
|
|
226
|
+
return EXIT_OK
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
_PROFILE_NAME_SAFE_RE = __import__("re").compile(r"[^A-Za-z0-9._-]")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _safe_profile_name(seed: str) -> str:
|
|
233
|
+
cleaned = _PROFILE_NAME_SAFE_RE.sub("-", seed).strip("-.")
|
|
234
|
+
return cleaned or "profile"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# === doctor ===============================================================
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _cmd_doctor(args: argparse.Namespace) -> int:
|
|
241
|
+
"""Validate the userspace-per-identity model: config, the tailscaled daemon
|
|
242
|
+
binary, the tailscale CLI, bws (for key self-resolve), and per-profile
|
|
243
|
+
registration state."""
|
|
244
|
+
import shutil
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
config = load_config()
|
|
248
|
+
except ConfigError as exc:
|
|
249
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
250
|
+
return EXIT_ERROR
|
|
251
|
+
print(f"config: ok ({len(config.profiles.by_name)} profile(s))")
|
|
252
|
+
|
|
253
|
+
ok = True
|
|
254
|
+
|
|
255
|
+
# tailscaled (the headless userspace daemon) is required for the model.
|
|
256
|
+
if Path(config.tailscaled_binary).exists():
|
|
257
|
+
print(f"tailscaled: {config.tailscaled_binary}")
|
|
258
|
+
else:
|
|
259
|
+
print(
|
|
260
|
+
f"tailscaled: NOT FOUND at {config.tailscaled_binary} "
|
|
261
|
+
f"(install with `brew install tailscale`)",
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
)
|
|
264
|
+
ok = False
|
|
265
|
+
|
|
266
|
+
# tailscale CLI used to control per-identity daemons over their --socket.
|
|
267
|
+
if Path(config.tailscale_cli_binary).exists():
|
|
268
|
+
print(f"tailscale cli: {config.tailscale_cli_binary}")
|
|
269
|
+
elif shutil.which("tailscale"):
|
|
270
|
+
print(f"tailscale cli: {shutil.which('tailscale')} (PATH; config path missing)")
|
|
271
|
+
else:
|
|
272
|
+
print(
|
|
273
|
+
f"tailscale cli: NOT FOUND at {config.tailscale_cli_binary}",
|
|
274
|
+
file=sys.stderr,
|
|
275
|
+
)
|
|
276
|
+
ok = False
|
|
277
|
+
|
|
278
|
+
# bws — lets first registration self-resolve auth keys without `bws run`.
|
|
279
|
+
if any(p.auth_key_env for p in config.profiles.by_name.values()):
|
|
280
|
+
if shutil.which("bws"):
|
|
281
|
+
print("bws: present (auth keys self-resolve on first `up`)")
|
|
282
|
+
else:
|
|
283
|
+
print(
|
|
284
|
+
"bws: NOT FOUND — first registration will need a `bws run` "
|
|
285
|
+
"wrapper or interactive login",
|
|
286
|
+
file=sys.stderr,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Per-profile summary + whether a node has already been registered (statedir).
|
|
290
|
+
for name in config.profiles.names():
|
|
291
|
+
p = config.profiles.get(name)
|
|
292
|
+
registered = (paths.instance_dir(name) / "state").exists()
|
|
293
|
+
bits = [f"account={p.account_id}"]
|
|
294
|
+
if p.exit_node:
|
|
295
|
+
bits.append(f"exit={p.exit_node}")
|
|
296
|
+
if p.auth_key_env:
|
|
297
|
+
bits.append(f"key=${p.auth_key_env}")
|
|
298
|
+
bits.append("registered" if registered else "not-yet-registered")
|
|
299
|
+
print(f" {name}: {', '.join(bits)}")
|
|
300
|
+
|
|
301
|
+
return EXIT_OK if ok else EXIT_ERROR
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# === profiles / reset =====================================================
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _cmd_profiles(args: argparse.Namespace) -> int:
|
|
308
|
+
try:
|
|
309
|
+
config = load_config()
|
|
310
|
+
except ConfigError as exc:
|
|
311
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
312
|
+
return EXIT_ERROR
|
|
313
|
+
for name in config.profiles.names():
|
|
314
|
+
p = config.profiles.get(name)
|
|
315
|
+
marker = " (default)" if name == config.profiles.default_name else ""
|
|
316
|
+
exit_node = p.exit_node or "<none>"
|
|
317
|
+
print(f" {name}{marker} account={p.account_id} exit_node={exit_node}")
|
|
318
|
+
return EXIT_OK
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# === reset ================================================================
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _cmd_reset(args: argparse.Namespace) -> int:
|
|
325
|
+
"""Stop ALL running userspace instances and clear the registry.
|
|
326
|
+
|
|
327
|
+
Each instance is a private userspace tailscaled, so this only affects
|
|
328
|
+
tailctl-managed daemons — the GUI app and the browser's global identity
|
|
329
|
+
are never touched.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
config = load_config()
|
|
333
|
+
except ConfigError as exc:
|
|
334
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
335
|
+
return EXIT_ERROR
|
|
336
|
+
instances = RegistryStore().read().instances
|
|
337
|
+
if instances:
|
|
338
|
+
print("running instances:", file=sys.stderr)
|
|
339
|
+
for name, inst in instances.items():
|
|
340
|
+
print(f" {name}: pid={inst.pid} refcount={inst.refcount}", file=sys.stderr)
|
|
341
|
+
if not args.force:
|
|
342
|
+
print(
|
|
343
|
+
"error: tailctl reset stops all instances; pass --force to confirm.",
|
|
344
|
+
file=sys.stderr,
|
|
345
|
+
)
|
|
346
|
+
return EXIT_USAGE
|
|
347
|
+
mgr = _build_instance_manager(config)
|
|
348
|
+
stopped = mgr.teardown_all()
|
|
349
|
+
print(f"stopped {stopped} instance(s)")
|
|
350
|
+
return EXIT_OK
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# === userspace-per-identity verbs ========================================
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _build_instance_manager(config: Config) -> InstanceManager:
|
|
357
|
+
return InstanceManager(config)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _resolve_owner(*, for_run: bool) -> tuple[int, float]:
|
|
361
|
+
"""Identify the process whose liveness owns this claim.
|
|
362
|
+
|
|
363
|
+
- ``run`` claims are scoped to the run process itself (``getpid``) — if it
|
|
364
|
+
crashes, reap releases the claim.
|
|
365
|
+
- ``up``/``down`` claims should outlive the CLI invocation, so they bind to
|
|
366
|
+
the parent shell / session (``$TAILCTL_OWNER_PID`` or ``getppid``); reap
|
|
367
|
+
releases them when that session dies.
|
|
368
|
+
"""
|
|
369
|
+
env_pid = os.environ.get("TAILCTL_OWNER_PID")
|
|
370
|
+
if env_pid:
|
|
371
|
+
pid = int(env_pid)
|
|
372
|
+
elif for_run:
|
|
373
|
+
pid = os.getpid()
|
|
374
|
+
else:
|
|
375
|
+
pid = os.getppid()
|
|
376
|
+
try:
|
|
377
|
+
return pid, psutil.Process(pid).create_time()
|
|
378
|
+
except psutil.NoSuchProcess:
|
|
379
|
+
# Fall back to self if the named owner is already gone.
|
|
380
|
+
me = psutil.Process(os.getpid())
|
|
381
|
+
return me.pid, me.create_time()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _print_unconfigured(profile: str, config: Config) -> None:
|
|
385
|
+
"""Agent-facing report: the requested tailnet/profile isn't configured."""
|
|
386
|
+
print(
|
|
387
|
+
f"error: profile {profile!r} is not configured in profiles.yaml.\n"
|
|
388
|
+
f" configured profiles: {', '.join(config.profiles.names()) or '(none)'}\n"
|
|
389
|
+
f" this tailnet is not available until it is bootstrapped — that needs a\n"
|
|
390
|
+
f" human to mint + store an auth key, then: tailctl bootstrap {profile} ...",
|
|
391
|
+
file=sys.stderr,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _print_auth_instructions(result) -> None:
|
|
396
|
+
print(
|
|
397
|
+
f"profile {result.profile!r} needs a one-time login.", file=sys.stderr
|
|
398
|
+
)
|
|
399
|
+
if result.auth_url:
|
|
400
|
+
print(f" authenticate at: {result.auth_url}", file=sys.stderr)
|
|
401
|
+
print(
|
|
402
|
+
" then re-run the same command — the instance reuses the saved login.",
|
|
403
|
+
file=sys.stderr,
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
print(
|
|
407
|
+
" (could not read the auth URL yet; check "
|
|
408
|
+
"`~/.tailctl/instances/<profile>/tailscaled.log`)",
|
|
409
|
+
file=sys.stderr,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _cmd_up(args: argparse.Namespace) -> int:
|
|
414
|
+
try:
|
|
415
|
+
config = load_config()
|
|
416
|
+
except ConfigError as exc:
|
|
417
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
418
|
+
return EXIT_ERROR
|
|
419
|
+
mgr = _build_instance_manager(config)
|
|
420
|
+
owner_pid, owner_ct = _resolve_owner(for_run=False)
|
|
421
|
+
try:
|
|
422
|
+
result = mgr.up(args.profile, owner_pid=owner_pid, owner_create_time=owner_ct)
|
|
423
|
+
except UnknownProfileError:
|
|
424
|
+
_print_unconfigured(args.profile, config)
|
|
425
|
+
return EXIT_USAGE
|
|
426
|
+
except InstanceError as exc:
|
|
427
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
428
|
+
return EXIT_ERROR
|
|
429
|
+
if result.needs_auth:
|
|
430
|
+
_print_auth_instructions(result)
|
|
431
|
+
return EXIT_AUTH
|
|
432
|
+
print(f"{result.profile}: up")
|
|
433
|
+
print(f" socks5: 127.0.0.1:{result.socks_port} http: 127.0.0.1:{result.http_port}")
|
|
434
|
+
for service, lp in (result.forwards or {}).items():
|
|
435
|
+
print(f" forward {service}: 127.0.0.1:{lp}")
|
|
436
|
+
return EXIT_OK
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _cmd_down(args: argparse.Namespace) -> int:
|
|
440
|
+
try:
|
|
441
|
+
config = load_config()
|
|
442
|
+
except ConfigError as exc:
|
|
443
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
444
|
+
return EXIT_ERROR
|
|
445
|
+
mgr = _build_instance_manager(config)
|
|
446
|
+
owner_pid, _ = _resolve_owner(for_run=False)
|
|
447
|
+
mgr.down(args.profile, owner_pid=owner_pid)
|
|
448
|
+
print(f"{args.profile}: down")
|
|
449
|
+
return EXIT_OK
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
453
|
+
command = list(args.command)
|
|
454
|
+
if command and command[0] == "--":
|
|
455
|
+
command = command[1:]
|
|
456
|
+
if not command:
|
|
457
|
+
print("error: no command given. Usage: tailctl run <profile> -- <cmd>", file=sys.stderr)
|
|
458
|
+
return EXIT_USAGE
|
|
459
|
+
try:
|
|
460
|
+
config = load_config()
|
|
461
|
+
except ConfigError as exc:
|
|
462
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
463
|
+
return EXIT_ERROR
|
|
464
|
+
mgr = _build_instance_manager(config)
|
|
465
|
+
owner_pid, owner_ct = _resolve_owner(for_run=True)
|
|
466
|
+
try:
|
|
467
|
+
result = mgr.up(args.profile, owner_pid=owner_pid, owner_create_time=owner_ct)
|
|
468
|
+
except UnknownProfileError:
|
|
469
|
+
_print_unconfigured(args.profile, config)
|
|
470
|
+
return EXIT_USAGE
|
|
471
|
+
except InstanceError as exc:
|
|
472
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
473
|
+
return EXIT_ERROR
|
|
474
|
+
if result.needs_auth:
|
|
475
|
+
_print_auth_instructions(result)
|
|
476
|
+
return EXIT_AUTH
|
|
477
|
+
|
|
478
|
+
env = dict(os.environ)
|
|
479
|
+
env["ALL_PROXY"] = f"socks5h://127.0.0.1:{result.socks_port}"
|
|
480
|
+
env["all_proxy"] = env["ALL_PROXY"]
|
|
481
|
+
env["HTTP_PROXY"] = f"http://127.0.0.1:{result.http_port}"
|
|
482
|
+
env["HTTPS_PROXY"] = env["HTTP_PROXY"]
|
|
483
|
+
env["http_proxy"] = env["HTTP_PROXY"]
|
|
484
|
+
env["https_proxy"] = env["HTTP_PROXY"]
|
|
485
|
+
env["NO_PROXY"] = "localhost,127.0.0.1,::1"
|
|
486
|
+
env["no_proxy"] = env["NO_PROXY"]
|
|
487
|
+
for service, lp in (result.forwards or {}).items():
|
|
488
|
+
env[f"{service.upper()}_ADDR"] = f"127.0.0.1:{lp}"
|
|
489
|
+
try:
|
|
490
|
+
proc = __import__("subprocess").run(command, env=env, check=False)
|
|
491
|
+
rc = proc.returncode
|
|
492
|
+
except FileNotFoundError:
|
|
493
|
+
print(f"error: command not found: {command[0]!r}", file=sys.stderr)
|
|
494
|
+
rc = EXIT_ERROR
|
|
495
|
+
finally:
|
|
496
|
+
mgr.down(args.profile, owner_pid=owner_pid)
|
|
497
|
+
return rc
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _cmd_reap(args: argparse.Namespace) -> int:
|
|
501
|
+
"""Drop dead-owner claims and stop orphaned/expired instances. Safe to run
|
|
502
|
+
from cron/launchd as the janitor for crashed sessions."""
|
|
503
|
+
try:
|
|
504
|
+
config = load_config()
|
|
505
|
+
except ConfigError as exc:
|
|
506
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
507
|
+
return EXIT_ERROR
|
|
508
|
+
reaped = _build_instance_manager(config).reap()
|
|
509
|
+
print(f"reaped {len(reaped)} instance(s)" + (f": {', '.join(reaped)}" if reaped else ""))
|
|
510
|
+
return EXIT_OK
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _cmd_ps(args: argparse.Namespace) -> int:
|
|
514
|
+
try:
|
|
515
|
+
config = load_config()
|
|
516
|
+
except ConfigError as exc:
|
|
517
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
518
|
+
return EXIT_ERROR
|
|
519
|
+
mgr = _build_instance_manager(config)
|
|
520
|
+
mgr.reap()
|
|
521
|
+
instances = mgr.list_instances()
|
|
522
|
+
if args.json:
|
|
523
|
+
from dataclasses import asdict
|
|
524
|
+
|
|
525
|
+
print(json.dumps([asdict(i) for i in instances], indent=2, sort_keys=True))
|
|
526
|
+
return EXIT_OK
|
|
527
|
+
if not instances:
|
|
528
|
+
print("no running instances")
|
|
529
|
+
return EXIT_OK
|
|
530
|
+
for inst in instances:
|
|
531
|
+
print(
|
|
532
|
+
f" {inst.profile} pid={inst.pid} refcount={inst.refcount} "
|
|
533
|
+
f"socks=127.0.0.1:{inst.socks_port} http=127.0.0.1:{inst.http_port}"
|
|
534
|
+
)
|
|
535
|
+
for f in inst.forwards:
|
|
536
|
+
print(
|
|
537
|
+
f" forward {f.service}: 127.0.0.1:{f.local_port} "
|
|
538
|
+
f"-> {f.remote_host}:{f.remote_port}"
|
|
539
|
+
)
|
|
540
|
+
return EXIT_OK
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# === bootstrap ============================================================
|
|
544
|
+
|
|
545
|
+
_BOOT_ID_RE = __import__("re").compile(r"^[A-Za-z0-9._-]+$")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _discover_account_id(config: Config, tailnet: str) -> str | None:
|
|
549
|
+
"""Look up an account_id by tailnet name from the signed-in GUI accounts."""
|
|
550
|
+
try:
|
|
551
|
+
accounts = TailscaleClient(binary=config.tailscale_binary).switch_list()
|
|
552
|
+
except TailscaleError:
|
|
553
|
+
return None
|
|
554
|
+
for acct in accounts:
|
|
555
|
+
if acct.tailnet == tailnet:
|
|
556
|
+
return acct.id
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _render_profile_block(
|
|
561
|
+
name: str,
|
|
562
|
+
account_id: str,
|
|
563
|
+
tailnet: str | None,
|
|
564
|
+
exit_node: str | None,
|
|
565
|
+
accept_routes: bool,
|
|
566
|
+
auth_key_env: str,
|
|
567
|
+
forwards: list[tuple[str, str, int]],
|
|
568
|
+
) -> str:
|
|
569
|
+
"""Render a profiles.yaml profile block as text (so existing comments in the
|
|
570
|
+
file are preserved — a PyYAML round-trip would strip them)."""
|
|
571
|
+
lines = [f" {name}:", f" account_id: {account_id}"]
|
|
572
|
+
if tailnet:
|
|
573
|
+
lines.append(f" tailnet: {tailnet}")
|
|
574
|
+
if exit_node:
|
|
575
|
+
lines.append(f" exit_node: {exit_node}")
|
|
576
|
+
lines.append(f" accept_routes: {'true' if accept_routes else 'false'}")
|
|
577
|
+
lines.append(f" auth_key_env: {auth_key_env}")
|
|
578
|
+
if forwards:
|
|
579
|
+
lines.append(" port_forwards:")
|
|
580
|
+
for svc, host, port in forwards:
|
|
581
|
+
lines.append(f" - service: {svc}")
|
|
582
|
+
lines.append(f" remote_host: {host}")
|
|
583
|
+
lines.append(f" remote_port: {port}")
|
|
584
|
+
return "\n".join(lines) + "\n"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _cmd_bootstrap(args: argparse.Namespace) -> int:
|
|
588
|
+
try:
|
|
589
|
+
config = load_config()
|
|
590
|
+
except ConfigError as exc:
|
|
591
|
+
print(f"config: {exc}", file=sys.stderr)
|
|
592
|
+
return EXIT_ERROR
|
|
593
|
+
|
|
594
|
+
name = args.name
|
|
595
|
+
if not _BOOT_ID_RE.match(name):
|
|
596
|
+
print(f"error: profile name {name!r} must match [A-Za-z0-9._-]", file=sys.stderr)
|
|
597
|
+
return EXIT_USAGE
|
|
598
|
+
if name in config.profiles.by_name and not args.force:
|
|
599
|
+
print(
|
|
600
|
+
f"error: profile {name!r} already exists. Pass --force to overwrite "
|
|
601
|
+
f"(note: overwrite appends a duplicate key — edit profiles.yaml by hand "
|
|
602
|
+
f"to replace).",
|
|
603
|
+
file=sys.stderr,
|
|
604
|
+
)
|
|
605
|
+
return EXIT_USAGE
|
|
606
|
+
|
|
607
|
+
# Resolve account id: explicit, else discover by tailnet.
|
|
608
|
+
account_id = args.account_id
|
|
609
|
+
if not account_id and args.tailnet:
|
|
610
|
+
account_id = _discover_account_id(config, args.tailnet)
|
|
611
|
+
if not account_id:
|
|
612
|
+
print(
|
|
613
|
+
f"error: could not find a signed-in account for tailnet "
|
|
614
|
+
f"{args.tailnet!r}. Sign into it in Tailscale.app, or pass "
|
|
615
|
+
f"--account-id explicitly.",
|
|
616
|
+
file=sys.stderr,
|
|
617
|
+
)
|
|
618
|
+
return EXIT_ERROR
|
|
619
|
+
if not account_id:
|
|
620
|
+
print("error: provide --account-id or --tailnet (to discover it)", file=sys.stderr)
|
|
621
|
+
return EXIT_USAGE
|
|
622
|
+
if not _BOOT_ID_RE.match(account_id):
|
|
623
|
+
print(f"error: account-id {account_id!r} must match [A-Za-z0-9._-]", file=sys.stderr)
|
|
624
|
+
return EXIT_USAGE
|
|
625
|
+
|
|
626
|
+
# Parse port-forwards "svc=host:port".
|
|
627
|
+
forwards: list[tuple[str, str, int]] = []
|
|
628
|
+
for spec in args.port_forward:
|
|
629
|
+
try:
|
|
630
|
+
svc, hostport = spec.split("=", 1)
|
|
631
|
+
host, port_s = hostport.rsplit(":", 1)
|
|
632
|
+
port = int(port_s)
|
|
633
|
+
except ValueError:
|
|
634
|
+
print(f"error: bad --port-forward {spec!r}; expected SVC=HOST:PORT", file=sys.stderr)
|
|
635
|
+
return EXIT_USAGE
|
|
636
|
+
if not _BOOT_ID_RE.match(svc) or not host or not (1 <= port <= 65535):
|
|
637
|
+
print(f"error: bad --port-forward {spec!r}", file=sys.stderr)
|
|
638
|
+
return EXIT_USAGE
|
|
639
|
+
forwards.append((svc, host, port))
|
|
640
|
+
|
|
641
|
+
auth_key_env = args.auth_key_env or f"{name.replace('-', '_')}_ts_authkey"
|
|
642
|
+
|
|
643
|
+
block = _render_profile_block(
|
|
644
|
+
name, account_id, args.tailnet, args.exit_node,
|
|
645
|
+
args.accept_routes, auth_key_env, forwards,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Append under `profiles:` (last top-level key) — text append preserves the
|
|
649
|
+
# file's existing comments. Then reload to validate the result parses.
|
|
650
|
+
path = paths.profiles_yaml()
|
|
651
|
+
original = path.read_text()
|
|
652
|
+
path.write_text(original.rstrip("\n") + "\n\n" + block)
|
|
653
|
+
try:
|
|
654
|
+
load_config()
|
|
655
|
+
except ConfigError as exc:
|
|
656
|
+
path.write_text(original) # roll back the append
|
|
657
|
+
print(f"error: appended profile did not validate ({exc}); reverted.", file=sys.stderr)
|
|
658
|
+
return EXIT_ERROR
|
|
659
|
+
|
|
660
|
+
print(f"bootstrapped profile {name!r} (account={account_id}, tailnet={args.tailnet})")
|
|
661
|
+
print("next steps to finish onboarding this tailnet:")
|
|
662
|
+
print(f" 1. mint a REUSABLE auth key in {args.tailnet or 'the tailnet'}'s admin console")
|
|
663
|
+
print(f" 2. store it in BWS under the key name: {auth_key_env}")
|
|
664
|
+
print(f" 3. register the node: tailctl up {name}")
|
|
665
|
+
|
|
666
|
+
if args.register:
|
|
667
|
+
print("\n--register: attempting first registration now...")
|
|
668
|
+
ns = argparse.Namespace(profile=name)
|
|
669
|
+
return _cmd_up(ns)
|
|
670
|
+
return EXIT_OK
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
if __name__ == "__main__":
|
|
674
|
+
sys.exit(main())
|