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 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())