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.
@@ -0,0 +1,863 @@
1
+ """Spawn, supervise, and refcount per-profile userspace tailscaled instances.
2
+
3
+ This is the heart of the userspace-per-identity model — the replacement for
4
+ ``coordinator.py``. There is no global switching and no FIFO queue: each profile
5
+ that a session needs gets its OWN headless ``tailscaled`` (userspace networking,
6
+ own socket/statedir, own SOCKS5 + HTTP proxy ports), and any number run at once.
7
+
8
+ ``up(profile)`` ensure a live instance exists; reuse + refcount++ if so, else
9
+ spawn the daemon, drive ``tailscale up`` (surfacing the auth
10
+ URL on first login), start the configured port-forwards, and
11
+ register it.
12
+ ``down(profile)`` refcount--; at zero, stop forwards, log out, kill the daemon,
13
+ and drop the registry row + runtime dir.
14
+ ``reap()`` drop registry rows whose daemon pid is dead (lazy cleanup).
15
+ ``list()`` snapshot of running instances for ``tailctl ps``.
16
+
17
+ All OS interaction goes through injectable seams (``spawn_daemon``,
18
+ ``spawn_forwarder``, ``make_client``, ``process_table``, ``sleep``/``clock``) so
19
+ the orchestration is unit-testable without a real tailscaled.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import fcntl
26
+ import json
27
+ import os
28
+ import re
29
+ import shutil
30
+ import signal
31
+ import socket as _socket
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from collections.abc import Callable
36
+ from dataclasses import dataclass
37
+ from datetime import UTC, datetime
38
+
39
+ from tailctl import paths
40
+ from tailctl.config import Config
41
+ from tailctl.liveness import ProcessTable, PsutilProcessTable
42
+ from tailctl.profiles import Profile
43
+ from tailctl.registry import Forward, Holder, Instance, RegistryStore
44
+ from tailctl.tailscale import TailscaleClient, TailscaleError
45
+
46
+ # Type of a daemon-spawn seam: (argv, log_path) -> pid
47
+ SpawnDaemon = Callable[[list[str], str], int]
48
+ # Type of a forwarder-spawn seam: (local_port, socks_port, host, port, log_path) -> pid
49
+ SpawnForwarder = Callable[[int, int, str, int, str], int]
50
+ # Type of a client factory bound to a socket.
51
+ MakeClient = Callable[[str], TailscaleClient]
52
+
53
+
54
+ @dataclass
55
+ class UpResult:
56
+ """Outcome of ``InstanceManager.up``."""
57
+
58
+ profile: str
59
+ ready: bool
60
+ socks_port: int | None = None
61
+ http_port: int | None = None
62
+ forwards: dict[str, int] | None = None # service -> local_port
63
+ needs_auth: bool = False
64
+ auth_url: str | None = None
65
+
66
+
67
+ class InstanceError(RuntimeError):
68
+ """Instance lifecycle failure surfaced to the CLI."""
69
+
70
+
71
+ def _now_iso() -> str:
72
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
73
+
74
+
75
+ def _free_port() -> int:
76
+ """Ask the OS for an unused localhost TCP port."""
77
+ with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
78
+ s.bind(("127.0.0.1", 0))
79
+ return s.getsockname()[1]
80
+
81
+
82
+ def _scrub_key(text: str) -> str:
83
+ """Redact any Tailscale auth key (tskey-...) before surfacing CLI output."""
84
+ cleaned = re.sub(r"tskey-[A-Za-z0-9-]+", "tskey-***", text or "")
85
+ # Keep it to the last non-empty line — tailscale prints the actionable
86
+ # error there; earlier lines are usually banner/noise.
87
+ lines = [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
88
+ return lines[-1] if lines else ""
89
+
90
+
91
+ def _spawn_detached(argv: list[str], log_path: str) -> int:
92
+ """Spawn a detached, log-redirected child and return its pid.
93
+
94
+ The parent's copy of the log fd is closed immediately after Popen — the
95
+ child inherits its own dup, so keeping the parent fd open just leaks a
96
+ descriptor per spawn (→ EMFILE in a long-lived agent). One shared helper so
97
+ the detach discipline (new session, DEVNULL stdin, unbuffered append log)
98
+ lives in exactly one place.
99
+ """
100
+ log = open(log_path, "ab", buffering=0)
101
+ try:
102
+ proc = subprocess.Popen(
103
+ argv,
104
+ stdout=log,
105
+ stderr=log,
106
+ stdin=subprocess.DEVNULL,
107
+ start_new_session=True, # detach from tailctl's process group
108
+ )
109
+ finally:
110
+ log.close()
111
+ return proc.pid
112
+
113
+
114
+ def _default_spawn_daemon(argv: list[str], log_path: str) -> int:
115
+ return _spawn_detached(argv, log_path)
116
+
117
+
118
+ def _default_spawn_forwarder(
119
+ local_port: int, socks_port: int, remote_host: str, remote_port: int, log_path: str
120
+ ) -> int:
121
+ return _spawn_detached(
122
+ [
123
+ sys.executable,
124
+ "-m",
125
+ "tailctl.forwarder",
126
+ str(local_port),
127
+ str(socks_port),
128
+ remote_host,
129
+ str(remote_port),
130
+ ],
131
+ log_path,
132
+ )
133
+
134
+
135
+ def _default_spawn_login(argv: list[str], log_path: str) -> None:
136
+ _spawn_detached(argv, log_path)
137
+
138
+
139
+ def _default_schedule_reap(linger_seconds: float) -> None:
140
+ """Detached self-reaper: after the linger window, run `tailctl reap` once so
141
+ a lingering daemon is bounded even if no other tailctl command ever runs."""
142
+ delay = int(linger_seconds) + 5
143
+ with contextlib.suppress(OSError, subprocess.SubprocessError):
144
+ subprocess.Popen(
145
+ ["sh", "-c", f"sleep {delay}; exec tailctl reap >/dev/null 2>&1"],
146
+ stdin=subprocess.DEVNULL,
147
+ stdout=subprocess.DEVNULL,
148
+ stderr=subprocess.DEVNULL,
149
+ start_new_session=True,
150
+ )
151
+
152
+
153
+ def _default_bws_resolve(secret_key: str) -> str | None:
154
+ """Fetch a secret value from Bitwarden Secrets Manager by its key name.
155
+
156
+ Used as the fallback when the auth-key env var isn't already injected into
157
+ tailctl's environment — lets a bare ``tailctl up`` self-resolve the key via
158
+ the ambient ``BWS_ACCESS_TOKEN`` (or bws config), instead of requiring a
159
+ ``bws run --`` wrapper. Returns None if bws is absent, unauthenticated, or
160
+ the key isn't found. The secret value is never logged.
161
+ """
162
+ if not shutil.which("bws"):
163
+ return None
164
+ try:
165
+ proc = subprocess.run(
166
+ ["bws", "secret", "list", "-o", "json"],
167
+ capture_output=True,
168
+ text=True,
169
+ timeout=20,
170
+ check=False,
171
+ )
172
+ except (OSError, subprocess.SubprocessError):
173
+ return None
174
+ if proc.returncode != 0:
175
+ return None
176
+ try:
177
+ for secret in json.loads(proc.stdout):
178
+ if isinstance(secret, dict) and secret.get("key") == secret_key:
179
+ val = secret.get("value")
180
+ return val if isinstance(val, str) and val else None
181
+ except (json.JSONDecodeError, TypeError):
182
+ return None
183
+ return None
184
+
185
+
186
+ class InstanceManager:
187
+ def __init__(
188
+ self,
189
+ config: Config,
190
+ *,
191
+ store: RegistryStore | None = None,
192
+ process_table: ProcessTable | None = None,
193
+ spawn_daemon: SpawnDaemon = _default_spawn_daemon,
194
+ spawn_forwarder: SpawnForwarder = _default_spawn_forwarder,
195
+ spawn_login: Callable[[list[str], str], None] = _default_spawn_login,
196
+ make_client: MakeClient | None = None,
197
+ bws_resolve: Callable[[str], str | None] = _default_bws_resolve,
198
+ schedule_reap: Callable[[float], None] = _default_schedule_reap,
199
+ sleep: Callable[[float], None] = time.sleep,
200
+ clock: Callable[[], float] = time.monotonic,
201
+ now_epoch: Callable[[], float] = time.time,
202
+ ready_timeout_s: float = 15.0,
203
+ auth_url_timeout_s: float = 10.0,
204
+ linger_seconds: float = 30.0,
205
+ ) -> None:
206
+ self._config = config
207
+ self._store = store or RegistryStore()
208
+ self._procs = process_table or PsutilProcessTable()
209
+ self._spawn_daemon = spawn_daemon
210
+ self._spawn_forwarder = spawn_forwarder
211
+ self._spawn_login = spawn_login
212
+ # Default client factory binds the configured control CLI to the
213
+ # instance's socket (no hardcoded path; non-brew installs configurable).
214
+ self._make_client = make_client or (
215
+ lambda sock: TailscaleClient(
216
+ binary=config.tailscale_cli_binary, socket=sock
217
+ )
218
+ )
219
+ self._bws_resolve = bws_resolve
220
+ self._schedule_reap = schedule_reap
221
+ self._sleep = sleep
222
+ self._clock = clock
223
+ self._now_epoch = now_epoch
224
+ self._ready_timeout = ready_timeout_s
225
+ self._auth_url_timeout = auth_url_timeout_s
226
+ self._linger = linger_seconds
227
+
228
+ # === public API ========================================================
229
+
230
+ def up(
231
+ self, profile_name: str, *, owner_pid: int, owner_create_time: float
232
+ ) -> UpResult:
233
+ profile = self._config.profiles.get(profile_name)
234
+
235
+ # Fast path: reuse a live instance if one already exists.
236
+ with self._store.transaction() as reg:
237
+ self._reap_inplace(reg)
238
+ res = self._try_reuse(reg, profile, owner_pid, owner_create_time)
239
+ if res is not None:
240
+ return res
241
+
242
+ # Slow path. Serialize per-profile so two concurrent `up`s don't both
243
+ # spawn a daemon (the registry lock is NOT held across the slow spawn,
244
+ # and the registry is keyed by a single profile name — without this the
245
+ # loser's daemon becomes an untracked orphan). Re-check under the spawn
246
+ # lock: another `up` may have registered the instance while we waited.
247
+ spawn_fd = self._acquire_spawn_lock(profile_name)
248
+ try:
249
+ with self._store.transaction() as reg:
250
+ self._reap_inplace(reg)
251
+ res = self._try_reuse(reg, profile, owner_pid, owner_create_time)
252
+ if res is not None:
253
+ return res
254
+ return self._spawn_and_register(profile, owner_pid, owner_create_time)
255
+ finally:
256
+ self._release_spawn_lock(spawn_fd)
257
+
258
+ def _try_reuse(
259
+ self, reg, profile: Profile, owner_pid: int, owner_create_time: float
260
+ ) -> UpResult | None:
261
+ """If a live instance for the profile exists, claim+return it; else None.
262
+ Adds the caller as a holder in BOTH the running and pending cases so a
263
+ reused instance is never reaped out from under a new claimant."""
264
+ inst = reg.instances.get(profile.name)
265
+ if inst is None or not self._alive(inst.pid, inst.create_time):
266
+ return None
267
+ client = self._make_client(inst.socket)
268
+ if self._is_running(client):
269
+ # Finalize a previously-pending (just-authed) instance exactly once.
270
+ if not inst.finalized:
271
+ if profile.exit_node:
272
+ self._set_exit_node_with_retry(client, profile) # raises on hard fail
273
+ inst.forwards = self._start_forwards(
274
+ profile, paths.instance_dir(profile.name), inst.socks_port
275
+ )
276
+ inst.finalized = True
277
+ self._add_holder(inst, owner_pid, owner_create_time)
278
+ return self._ready_result(profile.name, inst)
279
+ # Exists but not Running yet (auth pending) — hold it so reap won't kill
280
+ # it while this caller is mid-authentication, then surface the auth URL.
281
+ self._add_holder(inst, owner_pid, owner_create_time)
282
+ return self._auth_result(profile.name, client)
283
+
284
+ def _acquire_spawn_lock(self, profile_name: str) -> int:
285
+ lock_path = paths.instance_dir(profile_name) / "spawn.lock"
286
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
287
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
288
+ fcntl.flock(fd, fcntl.LOCK_EX)
289
+ return fd
290
+
291
+ def _try_acquire_spawn_lock(self, profile_name: str) -> int | None:
292
+ """Non-blocking variant: return the held fd, or None if a spawn for this
293
+ profile is in progress (lock contended)."""
294
+ lock_path = paths.instance_dir(profile_name) / "spawn.lock"
295
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
296
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
297
+ try:
298
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
299
+ except BlockingIOError:
300
+ os.close(fd)
301
+ return None
302
+ return fd
303
+
304
+ def _release_spawn_lock(self, fd: int) -> None:
305
+ try:
306
+ fcntl.flock(fd, fcntl.LOCK_UN)
307
+ finally:
308
+ os.close(fd)
309
+
310
+ def down(self, profile_name: str, *, owner_pid: int) -> None:
311
+ with self._store.transaction() as reg:
312
+ inst = reg.instances.get(profile_name)
313
+ if inst is None:
314
+ return # idempotent
315
+ inst.holders = [h for h in inst.holders if h.owner_pid != owner_pid]
316
+ if inst.holders:
317
+ return # other live claims remain
318
+ if self._linger > 0:
319
+ # Keep the warm daemon for a linger window so a quick follow-up
320
+ # `up`/`run` reuses it instead of paying the cold respawn.
321
+ inst.released_at = self._now_epoch()
322
+ # Bound the linger: schedule a detached `tailctl reap` so the
323
+ # daemon is stopped even if no other tailctl command ever runs
324
+ # (otherwise reap, which only fires on command entry, might
325
+ # never run and the daemon would leak indefinitely).
326
+ self._schedule_reap(self._linger)
327
+ return
328
+ self._teardown_instance(inst)
329
+ del reg.instances[profile_name]
330
+
331
+ def reap(self) -> list[str]:
332
+ with self._store.transaction() as reg:
333
+ reaped = self._reap_inplace(reg)
334
+ # Untracked-orphan sweep runs after the registry transaction so it can
335
+ # take per-profile spawn locks without inverting the lock order.
336
+ reaped.extend(self._sweep_untracked_orphans())
337
+ return reaped
338
+
339
+ def teardown_all(self) -> int:
340
+ """Force-stop every instance regardless of holders (for `reset`)."""
341
+ with self._store.transaction() as reg:
342
+ names = list(reg.instances)
343
+ for name in names:
344
+ self._teardown_instance(reg.instances[name])
345
+ del reg.instances[name]
346
+ return len(names)
347
+
348
+ def list_instances(self) -> list[Instance]:
349
+ reg = self._store.read()
350
+ return list(reg.instances.values())
351
+
352
+ # === internals =========================================================
353
+
354
+ def _spawn_and_register(
355
+ self, profile: Profile, owner_pid: int, owner_create_time: float
356
+ ) -> UpResult:
357
+ rundir = paths.instance_dir(profile.name)
358
+ (rundir / "state").mkdir(parents=True, exist_ok=True)
359
+ sock = str(rundir / "tailscaled.sock")
360
+ statedir = str(rundir / "state")
361
+ # A prior daemon may have leaked while still bound to this socket (spawn
362
+ # race, a row dropped without killing the pid, create_time drift). It
363
+ # would fail our fresh `--socket` bind with "address already in use", so
364
+ # clear it before spawning.
365
+ self._kill_socket_orphans(sock)
366
+ socks_port = _free_port()
367
+ http_port = _free_port()
368
+ log_path = str(rundir / "tailscaled.log")
369
+
370
+ argv = [
371
+ self._config.tailscaled_binary,
372
+ "--tun=userspace-networking",
373
+ f"--socket={sock}",
374
+ f"--statedir={statedir}",
375
+ f"--socks5-server=127.0.0.1:{socks_port}",
376
+ f"--outbound-http-proxy-listen=127.0.0.1:{http_port}",
377
+ "--port=0",
378
+ ]
379
+ pid = self._spawn_daemon(argv, log_path)
380
+ try:
381
+ create_time = self._procs.create_time(pid)
382
+ except Exception as exc: # noqa: BLE001
383
+ raise InstanceError(f"daemon for {profile.name!r} died immediately: {exc}") from exc
384
+
385
+ client = self._make_client(sock)
386
+ try:
387
+ self._wait_socket_reachable(client, profile.name)
388
+ except InstanceError:
389
+ self._kill(pid) # don't leak the just-spawned daemon
390
+ self._cleanup_socket(sock)
391
+ raise
392
+
393
+ # Drive `up` (idempotent): an already-authed statedir reconnects and
394
+ # converges to Running; an unauthed one prints an auth URL. We can't
395
+ # tell which yet, so kick it then wait for convergence — a reconnecting
396
+ # authed daemon passes through NeedsLogin/Starting before Running, so a
397
+ # bare is-Running check here would wrongly conclude "needs auth".
398
+ if not self._is_running(client):
399
+ auth_key = self._resolve_auth_key(profile)
400
+ if auth_key:
401
+ # Non-interactive first login — no browser URL. Synchronous:
402
+ # `up --auth-key` returns once the node is registered. Do NOT
403
+ # set the exit node here: a brand-new node hasn't synced its
404
+ # netmap yet, so `up --exit-node=<peer>` can fail with "no such
405
+ # node" and abort the whole registration. The exit node is set
406
+ # after the daemon reaches Running (finalize block below).
407
+ rc, out, err = 1, "", ""
408
+ try:
409
+ rc, out, err = client.up(
410
+ accept_routes=profile.accept_routes,
411
+ accept_dns=False,
412
+ hostname="tailctl-" + profile.name,
413
+ auth_key=auth_key,
414
+ )
415
+ except TailscaleError as exc:
416
+ err = str(exc)
417
+ if self._wait_running_or_auth(client) != "running":
418
+ # A key was supplied but login didn't reach Running — this
419
+ # is a real auth failure (expired/wrong-tailnet/single-use/
420
+ # untagged), NOT "needs interactive login". Tear down the
421
+ # orphaned daemon and surface why.
422
+ self._kill(pid)
423
+ self._cleanup_socket(sock)
424
+ raise InstanceError(
425
+ f"auth-key login for {profile.name!r} failed: "
426
+ f"{_scrub_key(err or out) or 'tailscale up did not reach Running'}. "
427
+ f"Verify ${profile.auth_key_env} is a valid, reusable key for "
428
+ f"{profile.tailnet!r}. Daemon log: {log_path}"
429
+ )
430
+ else:
431
+ self._kick_login(profile, client, log_path)
432
+ if self._wait_running_or_auth(client) != "running":
433
+ # Register a pending instance held by the caller so a re-run
434
+ # reuses it, and reap cleans it up if the caller dies before
435
+ # authenticating.
436
+ self._register_pending(
437
+ profile, pid, create_time, sock, statedir, socks_port,
438
+ http_port, owner_pid, owner_create_time,
439
+ )
440
+ return self._auth_result(profile.name, client)
441
+
442
+ # Running: set exit node (if any), start forwards, register. The node
443
+ # is Running but its netmap may still be syncing the exit-node peer, so
444
+ # retry until the hostname resolves. If it ultimately can't be applied,
445
+ # _set_exit_node_with_retry RAISES — we must NOT register a ready
446
+ # instance that routes through the wrong/no exit node, so tear the
447
+ # just-spawned daemon down and propagate.
448
+ if profile.exit_node:
449
+ try:
450
+ self._set_exit_node_with_retry(client, profile)
451
+ except InstanceError:
452
+ self._kill(pid)
453
+ self._cleanup_socket(sock)
454
+ raise
455
+ forwards = self._start_forwards(profile, rundir, socks_port)
456
+ inst = Instance(
457
+ profile=profile.name,
458
+ pid=pid,
459
+ create_time=create_time,
460
+ socket=sock,
461
+ statedir=statedir,
462
+ socks_port=socks_port,
463
+ http_port=http_port,
464
+ created_at=_now_iso(),
465
+ forwards=forwards,
466
+ holders=[Holder(owner_pid, owner_create_time, _now_iso())],
467
+ )
468
+ with self._store.transaction() as reg:
469
+ reg.instances[profile.name] = inst
470
+ return self._ready_result(profile.name, inst)
471
+
472
+ def _kick_login(self, profile: Profile, client: TailscaleClient, log_path: str) -> None:
473
+ """Run `tailscale up` detached so it can block on interactive auth while
474
+ we poll for the AuthURL.
475
+
476
+ No ``--exit-node`` here: a freshly-spawned node hasn't synced its netmap,
477
+ so Tailscale can't resolve the exit-node hostname and rejects the whole
478
+ ``up`` ("invalid value ... must be IP or hostname"). The exit node is set
479
+ from the finalize block once the node is Running and peers are visible.
480
+ """
481
+ argv = [
482
+ self._config.tailscale_cli_binary,
483
+ f"--socket={client._socket}", # noqa: SLF001 (same package seam)
484
+ "up",
485
+ f"--accept-routes={'true' if profile.accept_routes else 'false'}",
486
+ "--accept-dns=false",
487
+ "--hostname=tailctl-" + profile.name,
488
+ ]
489
+ self._spawn_login(argv, log_path)
490
+
491
+ def _start_forwards(
492
+ self, profile: Profile, rundir, socks_port: int
493
+ ) -> list[Forward]:
494
+ forwards: list[Forward] = []
495
+ for pf in profile.port_forwards:
496
+ lp = pf.local_port or _free_port()
497
+ log_path = str(rundir / f"forward-{pf.service}.log")
498
+ fpid = self._spawn_forwarder(lp, socks_port, pf.remote_host, pf.remote_port, log_path)
499
+ fct: float | None
500
+ try:
501
+ fct = self._procs.create_time(fpid)
502
+ except Exception: # noqa: BLE001
503
+ fct = None
504
+ forwards.append(
505
+ Forward(
506
+ service=pf.service,
507
+ local_port=lp,
508
+ remote_host=pf.remote_host,
509
+ remote_port=pf.remote_port,
510
+ pid=fpid,
511
+ create_time=fct,
512
+ )
513
+ )
514
+ # Best-effort readiness check: a forwarder whose local port can't bind
515
+ # exits almost immediately, so a short pause + liveness probe catches the
516
+ # common failure (port already in use) rather than recording a dead
517
+ # forward as live. Not a guarantee the listener is accepting yet.
518
+ if forwards:
519
+ self._sleep(0.3)
520
+ for f in forwards:
521
+ if f.pid is not None and f.create_time is not None and not self._procs.is_alive(
522
+ f.pid, f.create_time
523
+ ):
524
+ print(
525
+ f"warning: {profile.name}: forward {f.service!r} "
526
+ f"(127.0.0.1:{f.local_port}) failed to start — check "
527
+ f"{rundir}/forward-{f.service}.log (port in use?).",
528
+ file=sys.stderr,
529
+ )
530
+ return forwards
531
+
532
+ def _teardown_instance(self, inst: Instance) -> None:
533
+ # Stop the daemon + forwarders but DO NOT log out: the statedir keeps
534
+ # the node's login so a later `up` reconnects without re-authenticating.
535
+ # (Deregistering the node is a separate, explicit action.) The socket
536
+ # file IS removed — a stale UDS would otherwise collide with the next
537
+ # spawn (bind failure or a false "reachable" against the dead socket).
538
+ for fwd in inst.forwards:
539
+ if fwd.pid:
540
+ self._kill(fwd.pid)
541
+ self._kill(inst.pid)
542
+ self._cleanup_socket(inst.socket)
543
+
544
+ def _cleanup_socket(self, socket_path: str) -> None:
545
+ with contextlib.suppress(OSError):
546
+ os.unlink(socket_path)
547
+
548
+ def _kill_socket_orphans(
549
+ self, socket_path: str, *, keep_pid: int | None = None
550
+ ) -> list[int]:
551
+ """Kill any tailscaled bound to ``socket_path`` except ``keep_pid``.
552
+
553
+ A leaked daemon keeps its UDS bound, so the next ``--socket`` spawn
554
+ fails with ``address already in use`` (the symptom that wedges ``up``).
555
+ Leaks arise from a spawn race, a registry row dropped without killing
556
+ the pid, or create_time drift hiding the daemon from the two-factor
557
+ liveness check. Returns the pids killed; cleans the stale socket if any
558
+ were."""
559
+ killed: list[int] = []
560
+ for pid in self._procs.pids_for_socket(socket_path):
561
+ if keep_pid is not None and pid == keep_pid:
562
+ continue
563
+ self._kill(pid)
564
+ killed.append(pid)
565
+ if killed:
566
+ self._cleanup_socket(socket_path)
567
+ return killed
568
+
569
+ def _register_pending(
570
+ self, profile, pid, create_time, sock, statedir, socks_port, http_port,
571
+ owner_pid, owner_create_time,
572
+ ) -> None:
573
+ with self._store.transaction() as reg:
574
+ reg.instances[profile.name] = Instance(
575
+ profile=profile.name,
576
+ pid=pid,
577
+ create_time=create_time,
578
+ socket=sock,
579
+ statedir=statedir,
580
+ socks_port=socks_port,
581
+ http_port=http_port,
582
+ created_at=_now_iso(),
583
+ forwards=[],
584
+ finalized=False, # awaiting first login; finalize on re-up
585
+ # Held by the caller so it survives until they re-up (after auth)
586
+ # — and is reaped if the caller dies first.
587
+ holders=[Holder(owner_pid, owner_create_time, _now_iso())],
588
+ )
589
+
590
+ def _add_holder(self, inst: Instance, owner_pid: int, owner_create_time: float) -> None:
591
+ """Add a claim for an owner, de-duplicating by owner_pid alone.
592
+
593
+ One owning process == one holder; dedup on pid (not pid+create_time)
594
+ avoids appending a duplicate when a re-read create_time differs by a
595
+ float ULP, which `down` (filters by pid) would otherwise fail to fully
596
+ release."""
597
+ inst.released_at = None # reclaimed → cancel any linger countdown
598
+ for h in inst.holders:
599
+ if h.owner_pid == owner_pid:
600
+ h.owner_create_time = owner_create_time # refresh
601
+ return
602
+ inst.holders.append(Holder(owner_pid, owner_create_time, _now_iso()))
603
+
604
+ def _reap_inplace(self, reg) -> list[str]:
605
+ """Drop dead-owner holders; stop+drop any instance whose daemon died,
606
+ whose owner crashed, or whose graceful linger window has expired.
607
+
608
+ - daemon dead → drop (clean up forwarders).
609
+ - no live holders + no ``released_at`` → owner crashed without `down`:
610
+ stop immediately (crash-safety; no grace).
611
+ - no live holders + ``released_at`` within linger → keep (warm reuse).
612
+ - no live holders + linger expired → stop.
613
+ """
614
+ reaped: list[str] = []
615
+ for name in list(reg.instances):
616
+ inst = reg.instances[name]
617
+ inst.holders = [
618
+ h
619
+ for h in inst.holders
620
+ if self._procs.is_alive(h.owner_pid, h.owner_create_time)
621
+ ]
622
+ # Dead daemon → always drop (clean up forwarders), regardless of holders.
623
+ if not self._alive(inst.pid, inst.create_time):
624
+ for fwd in inst.forwards:
625
+ if fwd.pid:
626
+ self._kill(fwd.pid)
627
+ # "Dead" by the two-factor check can still mean a process holds
628
+ # the socket (create_time drift, or PID reuse by an unrelated
629
+ # daemon on the same path). Kill whatever's bound so the row's
630
+ # socket is free for the next spawn.
631
+ self._kill_socket_orphans(inst.socket)
632
+ del reg.instances[name]
633
+ reaped.append(name)
634
+ continue
635
+ if inst.holders:
636
+ continue # actively held
637
+ lingering = (
638
+ inst.released_at is not None
639
+ and (self._now_epoch() - inst.released_at) < self._linger
640
+ )
641
+ if lingering:
642
+ continue # warm, within the linger window
643
+ # No live holders, daemon alive: crashed owner (no released_at) or
644
+ # expired linger → stop.
645
+ self._teardown_instance(inst)
646
+ del reg.instances[name]
647
+ reaped.append(name)
648
+ return reaped
649
+
650
+ def _sweep_untracked_orphans(self) -> list[str]:
651
+ """Kill leaked daemons that hold a per-profile socket with no registry
652
+ row (forked-but-died-before-register, or a row dropped without killing
653
+ the pid). ``_reap_inplace`` can't see these — it only iterates rows.
654
+
655
+ Runs OUTSIDE the registry transaction and skips any profile whose spawn
656
+ lock is held: a daemon mid-spawn holds its socket but isn't registered
657
+ yet, and must not be mistaken for an orphan and killed. Acquiring the
658
+ spawn lock (not the registry lock) here also preserves the
659
+ spawn-lock-then-registry-lock ordering used by ``up``."""
660
+ swept: list[str] = []
661
+ tracked = {inst.socket for inst in self._store.read().instances.values()}
662
+ for pname in self._config.profiles.names():
663
+ sock = str(paths.instance_dir(pname) / "tailscaled.sock")
664
+ if sock in tracked:
665
+ continue
666
+ fd = self._try_acquire_spawn_lock(pname)
667
+ if fd is None:
668
+ continue # spawn in progress — not an orphan
669
+ try:
670
+ if self._kill_socket_orphans(sock):
671
+ swept.append(pname)
672
+ finally:
673
+ self._release_spawn_lock(fd)
674
+ return swept
675
+
676
+ # --- small helpers ---
677
+
678
+ def _resolve_auth_key(self, profile: Profile) -> str | None:
679
+ """Return the Tailscale auth key for a profile, or None for interactive
680
+ login.
681
+
682
+ Resolution order for ``profile.auth_key_env``:
683
+ 1. the environment (if something injected it, e.g. ``bws run --``), then
684
+ 2. Bitwarden Secrets Manager by that same key name (ambient token), so a
685
+ bare ``tailctl up`` self-resolves without a wrapper.
686
+ Unset everywhere → None (interactive fallback)."""
687
+ if not profile.auth_key_env:
688
+ return None
689
+ return (
690
+ os.environ.get(profile.auth_key_env)
691
+ or self._bws_resolve(profile.auth_key_env)
692
+ or None
693
+ )
694
+
695
+ def _alive(self, pid: int, create_time: float) -> bool:
696
+ return self._procs.is_alive(pid, create_time)
697
+
698
+ def _is_running(self, client: TailscaleClient) -> bool:
699
+ try:
700
+ return client.status().backend_state == "Running"
701
+ except TailscaleError:
702
+ return False
703
+
704
+ def _wait_running_or_auth(self, client: TailscaleClient) -> str:
705
+ """Poll until the daemon reaches Running (authed statedir reconnected)
706
+ or surfaces an AuthURL (needs login). Returns "running" or "auth".
707
+
708
+ Distinguishes a genuinely-unauthed daemon from one that merely hasn't
709
+ finished reconnecting yet — without this, a saved login would look like
710
+ it needs re-auth on every `up`.
711
+ """
712
+ deadline = self._clock() + self._ready_timeout
713
+ while True:
714
+ try:
715
+ st = client.status()
716
+ if st.backend_state == "Running":
717
+ return "running"
718
+ if (st.raw.get("AuthURL") or "").strip():
719
+ return "auth"
720
+ except TailscaleError:
721
+ pass
722
+ if self._clock() >= deadline:
723
+ return "auth"
724
+ self._sleep(0.25)
725
+
726
+ def _discover_exit_node(self, client: TailscaleClient, configured: str) -> str | None:
727
+ """Find the advertising exit-node peer matching the configured name,
728
+ tolerating a changed numeric suffix (e.g. configured ``…-production`` vs
729
+ live ``…-production-2``). Returns the live HostName, or None."""
730
+ try:
731
+ raw = client.status().raw
732
+ except TailscaleError:
733
+ return None
734
+ base = re.sub(r"-\d+$", "", configured) # strip a trailing -N suffix
735
+ # Match the exact name, or the SAME base with a (different) numeric
736
+ # suffix — NOT an arbitrary prefix. `startswith(base)` would wrongly
737
+ # match e.g. `acme-development-prod` for base `acme-dev` and route
738
+ # through the wrong exit node. Anchored regex: base, or base + "-<N>".
739
+ variant = re.compile(re.escape(base) + r"(-\d+)?$")
740
+ exact: str | None = None
741
+ variants: list[str] = []
742
+ for peer in (raw.get("Peer") or {}).values():
743
+ if not isinstance(peer, dict):
744
+ continue
745
+ host = peer.get("HostName") or ""
746
+ if not (peer.get("ExitNodeOption") and peer.get("Online")):
747
+ continue
748
+ if host == configured:
749
+ exact = host
750
+ elif variant.fullmatch(host):
751
+ variants.append(host)
752
+ # Prefer the exact configured name; else the lexicographically-first
753
+ # advertising variant (deterministic, not dict-iteration order).
754
+ return exact or (sorted(variants)[0] if variants else None)
755
+
756
+ def _set_exit_node_with_retry(self, client: TailscaleClient, profile: Profile) -> None:
757
+ """Set the exit node, retrying while the netmap syncs and auto-discovering
758
+ the advertising node by suffix-variant (heals the -2/no-suffix drift).
759
+
760
+ RAISES InstanceError if it can't be applied within the window. A profile
761
+ that declares an exit_node but whose traffic would NOT egress through it
762
+ is the cardinal failure this tool exists to prevent — far worse to return
763
+ a 'ready' instance that silently routes through the wrong identity than
764
+ to fail loudly. The caller tears the daemon down on this error."""
765
+ target = profile.exit_node
766
+ deadline = self._clock() + self._ready_timeout
767
+ last_err: TailscaleError | None = None
768
+ discovered = False
769
+ while True:
770
+ try:
771
+ client.set_exit_node(target)
772
+ if target != profile.exit_node:
773
+ print(
774
+ f"note: {profile.name}: exit node {profile.exit_node!r} "
775
+ f"resolved to the advertising node {target!r}.",
776
+ file=sys.stderr,
777
+ )
778
+ return
779
+ except TailscaleError as exc:
780
+ last_err = exc
781
+ if not discovered:
782
+ discovered = True
783
+ found = self._discover_exit_node(client, profile.exit_node)
784
+ if found and found != target:
785
+ target = found
786
+ continue # retry immediately with the discovered name
787
+ if self._clock() >= deadline:
788
+ raise InstanceError(
789
+ f"{profile.name}: could not apply exit node "
790
+ f"{profile.exit_node!r} ({last_err}). Refusing to proceed — "
791
+ f"traffic would egress through the wrong identity. Check the "
792
+ f"name against `tailscale status` (advertising exit nodes)."
793
+ )
794
+ self._sleep(0.5)
795
+
796
+ def _wait_socket_reachable(self, client: TailscaleClient, profile_name: str) -> None:
797
+ deadline = self._clock() + self._ready_timeout
798
+ while True:
799
+ try:
800
+ client.status()
801
+ return
802
+ except TailscaleError:
803
+ pass
804
+ if self._clock() >= deadline:
805
+ raise InstanceError(
806
+ f"tailscaled for {profile_name!r} did not become reachable "
807
+ f"within {self._ready_timeout}s"
808
+ )
809
+ self._sleep(0.25)
810
+
811
+ def _auth_result(self, profile_name: str, client: TailscaleClient) -> UpResult:
812
+ url = self._poll_auth_url(client)
813
+ return UpResult(profile=profile_name, ready=False, needs_auth=True, auth_url=url)
814
+
815
+ def _poll_auth_url(self, client: TailscaleClient) -> str | None:
816
+ deadline = self._clock() + self._auth_url_timeout
817
+ while True:
818
+ try:
819
+ raw = client.status().raw
820
+ url = (raw.get("AuthURL") or "").strip()
821
+ if url:
822
+ return url
823
+ except TailscaleError:
824
+ pass
825
+ if self._clock() >= deadline:
826
+ return None
827
+ self._sleep(0.25)
828
+
829
+ def _ready_result(self, profile_name: str, inst: Instance) -> UpResult:
830
+ return UpResult(
831
+ profile=profile_name,
832
+ ready=True,
833
+ socks_port=inst.socks_port,
834
+ http_port=inst.http_port,
835
+ forwards={f.service: f.local_port for f in inst.forwards},
836
+ )
837
+
838
+ @staticmethod
839
+ def _pid_alive(pid: int) -> bool:
840
+ try:
841
+ os.kill(pid, 0)
842
+ return True
843
+ except ProcessLookupError:
844
+ return False
845
+ except PermissionError:
846
+ return True
847
+
848
+ def _kill(self, pid: int) -> None:
849
+ """SIGTERM, wait briefly for graceful exit, then SIGKILL. Daemons run in
850
+ their own session (not our children), so we can't waitpid — poll instead.
851
+ Without escalation an ignored/hung SIGTERM would leave an orphan while
852
+ its registry row is already deleted."""
853
+ try:
854
+ os.kill(pid, signal.SIGTERM)
855
+ except (ProcessLookupError, PermissionError):
856
+ return
857
+ deadline = self._clock() + 3.0
858
+ while self._clock() < deadline:
859
+ if not self._pid_alive(pid):
860
+ return
861
+ self._sleep(0.1)
862
+ with contextlib.suppress(ProcessLookupError, PermissionError):
863
+ os.kill(pid, signal.SIGKILL)