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/liveness.py ADDED
@@ -0,0 +1,137 @@
1
+ """Two-factor process liveness for tailctl holders and waiters.
2
+
3
+ A holder is alive iff its PID is still running AND that process's create-time
4
+ matches the value recorded when the holder was registered. The create-time
5
+ check is what defeats PID reuse: macOS recycles PIDs aggressively, so a bare
6
+ ``os.kill(pid, 0)`` can wrongly report a dead session as alive.
7
+
8
+ The module is structured around a ``ProcessTable`` protocol so the production
9
+ code uses ``psutil`` and tests use ``FakeProcessTable`` — simulating PID reuse
10
+ in real OS processes is unreliable on macOS.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Protocol
17
+
18
+ import psutil
19
+
20
+
21
+ class ProcessTable(Protocol):
22
+ """Minimal interface needed to capture and verify a process identity.
23
+
24
+ ``create_time`` is float seconds since epoch (locale-independent,
25
+ sub-second resolution). ``is_alive`` does the (pid, create_time) two-factor
26
+ check that PID reuse cannot fool.
27
+ """
28
+
29
+ def create_time(self, pid: int) -> float: ...
30
+
31
+ def is_alive(self, pid: int, expected_create_time: float) -> bool: ...
32
+
33
+ def pids_for_socket(self, socket_path: str) -> list[int]: ...
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ProcessIdentity:
38
+ """A captured (pid, create_time) pair.
39
+
40
+ Pass the resulting identity into ``ProcessTable.is_alive`` to test
41
+ whether the same process is still running.
42
+ """
43
+
44
+ pid: int
45
+ create_time: float
46
+
47
+ @classmethod
48
+ def capture(cls, table: ProcessTable, pid: int) -> ProcessIdentity:
49
+ return cls(pid=pid, create_time=table.create_time(pid))
50
+
51
+
52
+ class PsutilProcessTable:
53
+ """Production implementation of ``ProcessTable`` backed by psutil."""
54
+
55
+ def create_time(self, pid: int) -> float:
56
+ return psutil.Process(pid).create_time()
57
+
58
+ def is_alive(self, pid: int, expected_create_time: float) -> bool:
59
+ try:
60
+ proc = psutil.Process(pid)
61
+ except psutil.NoSuchProcess:
62
+ return False
63
+ try:
64
+ actual = proc.create_time()
65
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
66
+ return False
67
+ # Allow a small tolerance since create_time is float, but PID reuse on
68
+ # macOS happens on second-ish boundaries — a tight epsilon is enough.
69
+ return abs(actual - expected_create_time) < 0.001
70
+
71
+ def pids_for_socket(self, socket_path: str) -> list[int]:
72
+ """Pids of running tailscaled daemons bound to ``socket_path``.
73
+
74
+ Matched by an exact ``--socket=<path>`` argv token. Used to find a
75
+ leaked daemon that still holds an instance's UDS but has no live
76
+ registry row (spawn race, a row dropped without killing the pid, or
77
+ create_time drift hiding it from the two-factor check) — it would
78
+ otherwise wedge the next spawn with ``address already in use``.
79
+ """
80
+ needle = f"--socket={socket_path}"
81
+ found: list[int] = []
82
+ for proc in psutil.process_iter(["pid", "cmdline"]):
83
+ try:
84
+ cmdline = proc.info.get("cmdline") or []
85
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
86
+ continue
87
+ if needle in cmdline:
88
+ found.append(proc.info["pid"])
89
+ return found
90
+
91
+
92
+ class FakeProcessTable:
93
+ """Test double for ``ProcessTable``.
94
+
95
+ Construct with a dict mapping pid → create_time. Use ``kill(pid)`` to
96
+ remove a process, and ``reuse(pid, new_create_time)`` to simulate a new
97
+ process being assigned the same PID — the key case real OS APIs make
98
+ hard to test deterministically.
99
+ """
100
+
101
+ def __init__(self, processes: dict[int, float] | None = None) -> None:
102
+ self._procs: dict[int, float] = dict(processes or {})
103
+ self._sockets: dict[int, str] = {}
104
+
105
+ def create_time(self, pid: int) -> float:
106
+ if pid not in self._procs:
107
+ raise LookupError(f"pid {pid} not in fake process table")
108
+ return self._procs[pid]
109
+
110
+ def is_alive(self, pid: int, expected_create_time: float) -> bool:
111
+ if pid not in self._procs:
112
+ return False
113
+ return abs(self._procs[pid] - expected_create_time) < 0.001
114
+
115
+ def pids_for_socket(self, socket_path: str) -> list[int]:
116
+ return [
117
+ pid
118
+ for pid, sock in self._sockets.items()
119
+ if sock == socket_path and pid in self._procs
120
+ ]
121
+
122
+ # --- test helpers ---
123
+
124
+ def kill(self, pid: int) -> None:
125
+ self._procs.pop(pid, None)
126
+ self._sockets.pop(pid, None)
127
+
128
+ def attach_socket(self, pid: int, socket_path: str) -> None:
129
+ """Record that ``pid`` is bound to ``socket_path`` (for orphan tests)."""
130
+ self._sockets[pid] = socket_path
131
+
132
+ def reuse(self, pid: int, new_create_time: float) -> None:
133
+ """Simulate PID reuse: same pid, different create_time."""
134
+ self._procs[pid] = new_create_time
135
+
136
+ def spawn(self, pid: int, create_time: float) -> None:
137
+ self._procs[pid] = create_time
tailctl/log.py ADDED
@@ -0,0 +1,43 @@
1
+ """Append-only JSON log with a single-generation 10 MB rotation.
2
+
3
+ Every coordinator action (acquire, release, switch, reconciliation, reap,
4
+ rollback escalation) writes one JSON object per line. Format is intentionally
5
+ simple: a single object, no nesting beyond the immediate keys.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from tailctl import paths
16
+
17
+ ROTATE_AT_BYTES = 10 * 1024 * 1024 # 10 MB
18
+
19
+
20
+ def append(record: dict[str, Any], *, log_path: Path | None = None) -> None:
21
+ """Append one record. Rotates if the file would exceed ``ROTATE_AT_BYTES``."""
22
+ target = log_path or paths.log_jsonl()
23
+ target.parent.mkdir(parents=True, exist_ok=True)
24
+ _rotate_if_needed(target)
25
+ line = json.dumps(record, separators=(",", ":"), sort_keys=True) + "\n"
26
+ with open(target, "a", encoding="utf-8") as fh:
27
+ fh.write(line)
28
+
29
+
30
+ def _rotate_if_needed(target: Path) -> None:
31
+ try:
32
+ size = target.stat().st_size
33
+ except FileNotFoundError:
34
+ return
35
+ if size < ROTATE_AT_BYTES:
36
+ return
37
+ rotated = paths.log_jsonl_rotated() if target == paths.log_jsonl() else target.with_suffix(
38
+ target.suffix + ".1"
39
+ )
40
+ # Overwrite any prior rotation generation; we keep exactly one.
41
+ if rotated.exists():
42
+ rotated.unlink()
43
+ os.replace(target, rotated)
tailctl/paths.py ADDED
@@ -0,0 +1,79 @@
1
+ """Filesystem path resolution for tailctl runtime state.
2
+
3
+ All on-disk state lives under a single root directory, defaulting to
4
+ ``~/.tailctl`` and overridable via the ``TAILCTL_HOME`` environment variable.
5
+ The override is what makes tests hermetic: a test sets ``TAILCTL_HOME`` to a
6
+ tmp dir and every path-returning function in this module follows it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ _ENV_VAR = "TAILCTL_HOME"
15
+ _DEFAULT_HOME = "~/.tailctl"
16
+
17
+
18
+ def home() -> Path:
19
+ """Return the tailctl state root directory.
20
+
21
+ Honors ``$TAILCTL_HOME`` if set, falling back to ``~/.tailctl``.
22
+ """
23
+ return Path(os.environ.get(_ENV_VAR, _DEFAULT_HOME)).expanduser()
24
+
25
+
26
+ def profiles_yaml() -> Path:
27
+ return home() / "profiles.yaml"
28
+
29
+
30
+ def state_json() -> Path:
31
+ return home() / "state.json"
32
+
33
+
34
+ def state_lock() -> Path:
35
+ """Separate lock file; never atomic-renamed.
36
+
37
+ The flock is held on this file's inode so that an atomic rename of
38
+ ``state.json`` itself does not orphan the lock.
39
+ """
40
+ return home() / "state.json.lock"
41
+
42
+
43
+ def instances_json() -> Path:
44
+ """Registry of running per-profile userspace tailscaled instances."""
45
+ return home() / "instances.json"
46
+
47
+
48
+ def instances_lock() -> Path:
49
+ """Separate lock file for the instances registry (never atomic-renamed)."""
50
+ return home() / "instances.json.lock"
51
+
52
+
53
+ def instances_dir() -> Path:
54
+ """Root for per-instance runtime dirs (socket + statedir + logs)."""
55
+ return home() / "instances"
56
+
57
+
58
+ def instance_dir(profile: str) -> Path:
59
+ """Per-profile runtime dir holding its tailscaled socket, statedir, logs."""
60
+ return instances_dir() / profile
61
+
62
+
63
+ def log_jsonl() -> Path:
64
+ return home() / "log.jsonl"
65
+
66
+
67
+ def log_jsonl_rotated() -> Path:
68
+ return home() / "log.1.jsonl"
69
+
70
+
71
+ def fixtures_dir() -> Path:
72
+ return home() / "fixtures"
73
+
74
+
75
+ def ensure_home() -> Path:
76
+ """Create the state root if it doesn't exist; return its path."""
77
+ h = home()
78
+ h.mkdir(parents=True, exist_ok=True)
79
+ return h
tailctl/profiles.py ADDED
@@ -0,0 +1,71 @@
1
+ """Profile data type and lookup table.
2
+
3
+ Profiles are the user's logical name → ``(account_id, tailnet, exit_node)``
4
+ mapping. ``config.py`` (next PR up) constructs ``Profiles`` from
5
+ ``profiles.yaml``; the coordinator only depends on this lightweight type.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class PortForward:
15
+ """A native-TCP forward for a profile.
16
+
17
+ Under the userspace-per-identity model, a profile's tailnet may host
18
+ services (ClickHouse, Postgres, …) that native clients reach by connecting
19
+ to a stable ``127.0.0.1:<local_port>``. The forwarder relays that local
20
+ port through the profile's userspace tailscaled to ``remote_host:remote_port``.
21
+ ``local_port`` is optional; when 0/None the instance allocates a free port.
22
+ The resolved port is exported to ``tailctl run`` subprocesses as
23
+ ``<SERVICE>_ADDR=127.0.0.1:<local_port>`` (service upper-cased).
24
+ """
25
+
26
+ service: str
27
+ remote_host: str
28
+ remote_port: int
29
+ local_port: int = 0
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Profile:
34
+ name: str
35
+ account_id: str
36
+ tailnet: str | None = None
37
+ exit_node: str | None = None
38
+ expected_egress_ip: str | None = None
39
+ # Userspace-model fields:
40
+ accept_routes: bool = True
41
+ port_forwards: tuple[PortForward, ...] = field(default_factory=tuple)
42
+ # Name of an env var holding a Tailscale auth key for this profile's
43
+ # tailnet (sourced from BWS). When set and present in the environment,
44
+ # first login is non-interactive (no browser URL). Absent → fall back to
45
+ # the interactive one-time login.
46
+ auth_key_env: str | None = None
47
+
48
+
49
+ class UnknownProfileError(KeyError):
50
+ """Profile name does not exist in profiles.yaml."""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Profiles:
55
+ """Read-only profile table with a configured default."""
56
+
57
+ default_name: str
58
+ by_name: dict[str, Profile]
59
+
60
+ def get(self, name: str) -> Profile:
61
+ try:
62
+ return self.by_name[name]
63
+ except KeyError as exc:
64
+ raise UnknownProfileError(name) from exc
65
+
66
+ @property
67
+ def default(self) -> Profile:
68
+ return self.get(self.default_name)
69
+
70
+ def names(self) -> list[str]:
71
+ return sorted(self.by_name.keys())
tailctl/registry.py ADDED
@@ -0,0 +1,180 @@
1
+ """On-disk registry of running userspace tailscaled instances.
2
+
3
+ Replaces the global ``state.json`` coordination model. There is no shared
4
+ network to coordinate anymore: each profile that a session needs gets its own
5
+ userspace ``tailscaled`` (own socket, statedir, SOCKS/HTTP proxy ports), and
6
+ several run simultaneously. This registry is a lightweight table — NOT a state
7
+ machine — recording which profiles have a live instance, the ports they expose,
8
+ their port-forwards, and a refcount so multiple sessions can share one instance.
9
+
10
+ The locking discipline mirrors ``state.py``: an exclusive ``fcntl.flock`` on a
11
+ SEPARATE ``instances.json.lock`` file, with the registry itself read/mutated/
12
+ written atomically via ``tmp + os.replace``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fcntl
18
+ import json
19
+ import os
20
+ from collections.abc import Iterator
21
+ from contextlib import contextmanager
22
+ from dataclasses import asdict, dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from tailctl import paths
27
+
28
+ REGISTRY_SCHEMA_VERSION = 1
29
+
30
+
31
+ @dataclass
32
+ class Forward:
33
+ """One active port-forward for an instance."""
34
+
35
+ service: str
36
+ local_port: int
37
+ remote_host: str
38
+ remote_port: int
39
+ pid: int | None = None # relay subprocess pid (None until started)
40
+ create_time: float | None = None
41
+
42
+
43
+ @dataclass
44
+ class Holder:
45
+ """A live claim on an instance by some owning process.
46
+
47
+ The refcount is ``len(holders)``; each holder is identified by a two-factor
48
+ ``(owner_pid, owner_create_time)`` so a crashed owner's claim can be reaped
49
+ (and PID reuse can't resurrect it). ``up`` adds a holder; ``down`` removes
50
+ the caller's; ``reap`` drops dead-owner holders and stops orphaned daemons.
51
+ """
52
+
53
+ owner_pid: int
54
+ owner_create_time: float
55
+ since: str = ""
56
+
57
+
58
+ @dataclass
59
+ class Instance:
60
+ """A running per-profile userspace tailscaled, plus its forwards/holders."""
61
+
62
+ profile: str
63
+ pid: int
64
+ create_time: float
65
+ socket: str
66
+ statedir: str
67
+ socks_port: int
68
+ http_port: int
69
+ created_at: str = ""
70
+ forwards: list[Forward] = field(default_factory=list)
71
+ holders: list[Holder] = field(default_factory=list)
72
+ # False while the daemon is registered but awaiting first login; flips True
73
+ # once exit node + forwards are applied. Distinguishes a pending instance
74
+ # (which already has a holder) from a finalized one.
75
+ finalized: bool = True
76
+ # Epoch seconds when the last holder released gracefully — starts the linger
77
+ # window during which the warm daemon is kept for fast reuse. None while held
78
+ # (or when the owner crashed: then reap stops it immediately, no grace).
79
+ released_at: float | None = None
80
+
81
+ @property
82
+ def refcount(self) -> int:
83
+ return len(self.holders)
84
+
85
+
86
+ @dataclass
87
+ class Registry:
88
+ schema_version: int = REGISTRY_SCHEMA_VERSION
89
+ generation: int = 0
90
+ instances: dict[str, Instance] = field(default_factory=dict)
91
+
92
+
93
+ class RegistryCorruptError(RuntimeError):
94
+ """instances.json could not be parsed."""
95
+
96
+
97
+ def _registry_from_dict(data: dict[str, Any]) -> Registry:
98
+ if data.get("schema_version") not in (None, REGISTRY_SCHEMA_VERSION):
99
+ raise ValueError(
100
+ f"unrecognized registry schema_version: {data.get('schema_version')!r}; "
101
+ f"expected {REGISTRY_SCHEMA_VERSION}"
102
+ )
103
+ instances: dict[str, Instance] = {}
104
+ _fields = (
105
+ "profile", "pid", "create_time", "socket", "statedir",
106
+ "socks_port", "http_port", "created_at", "finalized", "released_at",
107
+ )
108
+ for name, body in (data.get("instances") or {}).items():
109
+ forwards = [Forward(**f) for f in body.get("forwards", [])]
110
+ holders = [Holder(**h) for h in body.get("holders", [])]
111
+ # Only pass known scalar fields; drop anything else (e.g. a legacy
112
+ # ``refcount`` key, now a derived property).
113
+ known = {k: body[k] for k in _fields if k in body}
114
+ instances[name] = Instance(forwards=forwards, holders=holders, **known)
115
+ return Registry(
116
+ schema_version=REGISTRY_SCHEMA_VERSION,
117
+ generation=int(data.get("generation", 0)),
118
+ instances=instances,
119
+ )
120
+
121
+
122
+ class RegistryStore:
123
+ """Atomic, flock-coordinated access to instances.json.
124
+
125
+ Same contract as ``state.StateStore``: ``transaction()`` yields a mutable
126
+ ``Registry`` written on clean exit; ``read()`` is a lock-free snapshot.
127
+ """
128
+
129
+ def __init__(
130
+ self, *, registry_path: Path | None = None, lock_path: Path | None = None
131
+ ) -> None:
132
+ self._path = registry_path or paths.instances_json()
133
+ self._lock_path = lock_path or paths.instances_lock()
134
+
135
+ @contextmanager
136
+ def transaction(self) -> Iterator[Registry]:
137
+ self._lock_path.parent.mkdir(parents=True, exist_ok=True)
138
+ fd = os.open(self._lock_path, os.O_RDWR | os.O_CREAT, 0o600)
139
+ try:
140
+ fcntl.flock(fd, fcntl.LOCK_EX)
141
+ reg = self._read_unlocked()
142
+ yield reg
143
+ reg.generation += 1
144
+ self._write_unlocked(reg)
145
+ finally:
146
+ try:
147
+ fcntl.flock(fd, fcntl.LOCK_UN)
148
+ finally:
149
+ os.close(fd)
150
+
151
+ def read(self) -> Registry:
152
+ return self._read_unlocked()
153
+
154
+ def _read_unlocked(self) -> Registry:
155
+ if not self._path.exists():
156
+ return Registry()
157
+ try:
158
+ data = json.loads(self._path.read_text())
159
+ except json.JSONDecodeError as exc:
160
+ raise RegistryCorruptError(
161
+ f"registry at {self._path} is not valid JSON: {exc}"
162
+ ) from exc
163
+ if not isinstance(data, dict):
164
+ raise RegistryCorruptError(f"registry at {self._path} is not a JSON object")
165
+ return _registry_from_dict(data)
166
+
167
+ def _write_unlocked(self, reg: Registry) -> None:
168
+ self._path.parent.mkdir(parents=True, exist_ok=True)
169
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
170
+ payload = json.dumps(
171
+ {
172
+ "schema_version": reg.schema_version,
173
+ "generation": reg.generation,
174
+ "instances": {n: asdict(i) for n, i in reg.instances.items()},
175
+ },
176
+ indent=2,
177
+ sort_keys=True,
178
+ )
179
+ tmp.write_text(payload)
180
+ os.replace(tmp, self._path)
tailctl/signals.py ADDED
@@ -0,0 +1,52 @@
1
+ """Signal handling for the wait loop.
2
+
3
+ When a session sitting in ``coord.acquire`` receives SIGINT or SIGTERM (e.g.
4
+ the user hits Ctrl-C, or the parent shell kills the tailctl process), we
5
+ remove the session from the waiters list under the lock and re-elect the
6
+ named handoff if we were the chosen successor. Then we re-raise the default
7
+ disposition so the process actually exits.
8
+
9
+ This module installs handlers on import-time only when ``install_handlers`` is
10
+ called — leaving import side-effect free for tests that don't need them.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import signal
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ # Callbacks registered by Coordinator.install_signal_handlers (one per wait).
20
+ _cleanups: list[Callable[[], None]] = []
21
+ _previous_handlers: dict[int, Any] = {}
22
+
23
+
24
+ def push_cleanup(fn: Callable[[], None]) -> None:
25
+ """Register a cleanup to run on SIGINT/SIGTERM. Pops back automatically
26
+ when ``pop_cleanup`` is called."""
27
+ _cleanups.append(fn)
28
+
29
+
30
+ def pop_cleanup() -> None:
31
+ if _cleanups:
32
+ _cleanups.pop()
33
+
34
+
35
+ def _handler(signum: int, frame: Any) -> None:
36
+ while _cleanups:
37
+ try:
38
+ _cleanups.pop()()
39
+ except Exception:
40
+ # Best-effort: keep popping even on individual cleanup failure.
41
+ continue
42
+ # Re-raise default disposition by re-installing the previous handler
43
+ # and re-sending the signal.
44
+ prev = _previous_handlers.get(signum, signal.SIG_DFL)
45
+ signal.signal(signum, prev)
46
+ signal.raise_signal(signum)
47
+
48
+
49
+ def install_handlers() -> None:
50
+ """Install SIGINT + SIGTERM handlers. Safe to call repeatedly."""
51
+ for sig in (signal.SIGINT, signal.SIGTERM):
52
+ _previous_handlers[sig] = signal.signal(sig, _handler)