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