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/config.py ADDED
@@ -0,0 +1,275 @@
1
+ """profiles.yaml loader and validator.
2
+
3
+ The on-disk shape:
4
+
5
+ default: drycode-github
6
+ tailscale_binary: /Applications/Tailscale.app/Contents/MacOS/Tailscale # optional
7
+ profiles:
8
+ drycode-github:
9
+ account_id: a1b2
10
+ tailnet: drycode.github # optional; empty/null skips tailnet verification
11
+ exit_node: null # optional
12
+ expected_egress_ip: null # optional; only checked with --verify-egress
13
+ description: "" # optional, informational only
14
+ ...
15
+
16
+ Every identifier (profile name, account_id, exit_node) must match
17
+ ``^[A-Za-z0-9._-]+$`` so it can flow through subprocess argv without
18
+ quoting concerns. Duplicate account_ids are rejected. ``default`` must
19
+ reference an existing profile name.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import yaml
30
+
31
+ from tailctl import paths
32
+ from tailctl.profiles import PortForward, Profile, Profiles
33
+ from tailctl.tailscale import DEFAULT_BINARY
34
+
35
+ _ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
36
+ # Env-var-name safe: what a shell can actually export/expand. Used for
37
+ # port-forward service names (exported as <SERVICE>_ADDR) and auth_key_env.
38
+ _ENV_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
39
+
40
+ # Userspace-model defaults.
41
+ DEFAULT_TAILSCALED = "/opt/homebrew/opt/tailscale/bin/tailscaled"
42
+ # The tailscale CLI used to control a per-identity daemon over its --socket.
43
+ # Distinct from `tailscale_binary` (the GUI app's CLI, used only for account
44
+ # discovery in init/bootstrap).
45
+ DEFAULT_TAILSCALE_CLI = "/opt/homebrew/bin/tailscale"
46
+
47
+
48
+ class ConfigError(RuntimeError):
49
+ """profiles.yaml is missing, malformed, or fails validation."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Config:
54
+ """Top-level parsed configuration."""
55
+
56
+ profiles: Profiles
57
+ tailscale_binary: str
58
+ # Userspace-model config: the headless daemon binary and the CLI used to
59
+ # drive a per-identity daemon over its --socket.
60
+ tailscaled_binary: str = DEFAULT_TAILSCALED
61
+ tailscale_cli_binary: str = DEFAULT_TAILSCALE_CLI
62
+
63
+
64
+ def load(path: Path | None = None) -> Config:
65
+ """Load + validate profiles.yaml. Returns a Config on success.
66
+
67
+ Raises ``ConfigError`` with a specific message on any problem.
68
+ """
69
+ target = path or paths.profiles_yaml()
70
+ if not target.exists():
71
+ raise ConfigError(
72
+ f"profiles.yaml not found at {target}. "
73
+ f"Run `tailctl init` to scaffold one."
74
+ )
75
+ try:
76
+ raw = yaml.safe_load(target.read_text())
77
+ except yaml.YAMLError as exc:
78
+ raise ConfigError(f"profiles.yaml is not valid YAML: {exc}") from exc
79
+ if not isinstance(raw, dict):
80
+ raise ConfigError("profiles.yaml: top-level must be a mapping")
81
+ return _parse(raw)
82
+
83
+
84
+ def _parse(raw: dict[str, Any]) -> Config:
85
+ default_name = raw.get("default")
86
+ if not isinstance(default_name, str) or not default_name:
87
+ raise ConfigError("profiles.yaml: `default` is required and must be a string")
88
+ if not _ID_RE.match(default_name):
89
+ raise ConfigError(
90
+ f"profiles.yaml: `default` value {default_name!r} contains "
91
+ f"characters outside [A-Za-z0-9._-]"
92
+ )
93
+
94
+ binary = raw.get("tailscale_binary", DEFAULT_BINARY)
95
+ if not isinstance(binary, str) or not binary:
96
+ raise ConfigError("profiles.yaml: `tailscale_binary` must be a non-empty string")
97
+
98
+ tailscaled_binary = raw.get("tailscaled_binary", DEFAULT_TAILSCALED)
99
+ if not isinstance(tailscaled_binary, str) or not tailscaled_binary:
100
+ raise ConfigError("profiles.yaml: `tailscaled_binary` must be a non-empty string")
101
+
102
+ tailscale_cli_binary = raw.get("tailscale_cli_binary", DEFAULT_TAILSCALE_CLI)
103
+ if not isinstance(tailscale_cli_binary, str) or not tailscale_cli_binary:
104
+ raise ConfigError("profiles.yaml: `tailscale_cli_binary` must be a non-empty string")
105
+
106
+ profiles_section = raw.get("profiles")
107
+ if not isinstance(profiles_section, dict) or not profiles_section:
108
+ raise ConfigError("profiles.yaml: `profiles` must be a non-empty mapping")
109
+
110
+ by_name: dict[str, Profile] = {}
111
+ # Uniqueness key is (account_id, exit_node), not account_id alone.
112
+ # Many clients use one tailnet account but distinct exit nodes per
113
+ # environment (dev / staging / prod) — those are legitimate distinct
114
+ # profiles. The narrower check still catches the "I typed the same
115
+ # profile twice" typo.
116
+ seen_account_exit: dict[tuple[str, str | None], str] = {}
117
+ for name, body in profiles_section.items():
118
+ if not isinstance(name, str):
119
+ raise ConfigError(f"profiles.yaml: profile name must be a string, got {name!r}")
120
+ if not _ID_RE.match(name):
121
+ raise ConfigError(
122
+ f"profiles.yaml: profile name {name!r} contains characters "
123
+ f"outside [A-Za-z0-9._-]"
124
+ )
125
+ if not isinstance(body, dict):
126
+ raise ConfigError(
127
+ f"profiles.yaml: profile {name!r} must be a mapping"
128
+ )
129
+ account_id = body.get("account_id")
130
+ if not isinstance(account_id, str) or not account_id:
131
+ raise ConfigError(
132
+ f"profiles.yaml: profile {name!r} missing required `account_id`"
133
+ )
134
+ if not _ID_RE.match(account_id):
135
+ raise ConfigError(
136
+ f"profiles.yaml: profile {name!r} `account_id` {account_id!r} "
137
+ f"contains characters outside [A-Za-z0-9._-]"
138
+ )
139
+ # Defer the uniqueness check until we've parsed exit_node below.
140
+
141
+ tailnet = body.get("tailnet") or None
142
+ if tailnet is not None and not isinstance(tailnet, str):
143
+ raise ConfigError(
144
+ f"profiles.yaml: profile {name!r} `tailnet` must be a string or null"
145
+ )
146
+
147
+ exit_node = body.get("exit_node") or None
148
+ if exit_node is not None:
149
+ if not isinstance(exit_node, str):
150
+ raise ConfigError(
151
+ f"profiles.yaml: profile {name!r} `exit_node` must be a string or null"
152
+ )
153
+ if not _ID_RE.match(exit_node):
154
+ raise ConfigError(
155
+ f"profiles.yaml: profile {name!r} `exit_node` {exit_node!r} "
156
+ f"contains characters outside [A-Za-z0-9._-]"
157
+ )
158
+
159
+ expected_egress_ip = body.get("expected_egress_ip") or None
160
+ if expected_egress_ip is not None and not isinstance(expected_egress_ip, str):
161
+ raise ConfigError(
162
+ f"profiles.yaml: profile {name!r} `expected_egress_ip` "
163
+ f"must be a string or null"
164
+ )
165
+
166
+ uniq_key = (account_id, exit_node)
167
+ if uniq_key in seen_account_exit:
168
+ raise ConfigError(
169
+ f"profiles.yaml: (account_id={account_id!r}, exit_node="
170
+ f"{exit_node!r}) is duplicated by both "
171
+ f"{seen_account_exit[uniq_key]!r} and {name!r}"
172
+ )
173
+ seen_account_exit[uniq_key] = name
174
+
175
+ accept_routes = body.get("accept_routes", True)
176
+ if not isinstance(accept_routes, bool):
177
+ raise ConfigError(
178
+ f"profiles.yaml: profile {name!r} `accept_routes` must be a boolean"
179
+ )
180
+
181
+ auth_key_env = body.get("auth_key_env") or None
182
+ if auth_key_env is not None and (
183
+ not isinstance(auth_key_env, str) or not _ENV_RE.match(auth_key_env)
184
+ ):
185
+ raise ConfigError(
186
+ f"profiles.yaml: profile {name!r} `auth_key_env` must be a valid "
187
+ f"env var name [A-Za-z_][A-Za-z0-9_]* (got {auth_key_env!r}); a "
188
+ f"shell cannot export names containing '.' or '-'."
189
+ )
190
+
191
+ port_forwards = _parse_port_forwards(name, body.get("port_forwards"))
192
+
193
+ by_name[name] = Profile(
194
+ name=name,
195
+ account_id=account_id,
196
+ tailnet=tailnet,
197
+ exit_node=exit_node,
198
+ expected_egress_ip=expected_egress_ip,
199
+ accept_routes=accept_routes,
200
+ port_forwards=port_forwards,
201
+ auth_key_env=auth_key_env,
202
+ )
203
+
204
+ if default_name not in by_name:
205
+ raise ConfigError(
206
+ f"profiles.yaml: `default` value {default_name!r} does not match "
207
+ f"any profile name (have {sorted(by_name.keys())!r})"
208
+ )
209
+
210
+ return Config(
211
+ profiles=Profiles(default_name=default_name, by_name=by_name),
212
+ tailscale_binary=binary,
213
+ tailscaled_binary=tailscaled_binary,
214
+ tailscale_cli_binary=tailscale_cli_binary,
215
+ )
216
+
217
+
218
+ def _parse_port_forwards(profile_name: str, raw: Any) -> tuple[PortForward, ...]:
219
+ """Parse a profile's optional ``port_forwards`` list into PortForward records."""
220
+ if raw is None:
221
+ return ()
222
+ if not isinstance(raw, list):
223
+ raise ConfigError(
224
+ f"profiles.yaml: profile {profile_name!r} `port_forwards` must be a list"
225
+ )
226
+ out: list[PortForward] = []
227
+ seen_services: set[str] = set()
228
+ for i, entry in enumerate(raw):
229
+ if not isinstance(entry, dict):
230
+ raise ConfigError(
231
+ f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
232
+ f"must be a mapping"
233
+ )
234
+ service = entry.get("service")
235
+ # Must be env-var-safe: it's exported to `run` subprocesses as
236
+ # <SERVICE>_ADDR, so '.'/'-' would produce a name no shell can expand.
237
+ if not isinstance(service, str) or not _ENV_RE.match(service):
238
+ raise ConfigError(
239
+ f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
240
+ f"`service` must be a valid env var name [A-Za-z_][A-Za-z0-9_]* "
241
+ f"(exported as <SERVICE>_ADDR); got {service!r}"
242
+ )
243
+ if service in seen_services:
244
+ raise ConfigError(
245
+ f"profiles.yaml: profile {profile_name!r} has duplicate "
246
+ f"port_forward service {service!r}"
247
+ )
248
+ seen_services.add(service)
249
+ remote_host = entry.get("remote_host")
250
+ if not isinstance(remote_host, str) or not remote_host:
251
+ raise ConfigError(
252
+ f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
253
+ f"missing `remote_host`"
254
+ )
255
+ remote_port = entry.get("remote_port")
256
+ if not isinstance(remote_port, int) or not (1 <= remote_port <= 65535):
257
+ raise ConfigError(
258
+ f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
259
+ f"`remote_port` must be an int in [1, 65535]"
260
+ )
261
+ local_port = entry.get("local_port", 0)
262
+ if not isinstance(local_port, int) or not (0 <= local_port <= 65535):
263
+ raise ConfigError(
264
+ f"profiles.yaml: profile {profile_name!r} port_forwards[{i}] "
265
+ f"`local_port` must be an int in [0, 65535] (0 = auto)"
266
+ )
267
+ out.append(
268
+ PortForward(
269
+ service=service,
270
+ remote_host=remote_host,
271
+ remote_port=remote_port,
272
+ local_port=local_port,
273
+ )
274
+ )
275
+ return tuple(out)
tailctl/forwarder.py ADDED
@@ -0,0 +1,123 @@
1
+ """Localhost TCP forward through a userspace tailscaled SOCKS5 proxy.
2
+
3
+ This is the native-DB path: clients that ignore proxy env vars (ClickHouse
4
+ native :9000, Postgres, SQL Server) connect to a stable ``127.0.0.1:<local_port>``
5
+ and the relay tunnels each connection through the profile's userspace tailscaled
6
+ SOCKS5 proxy to ``<remote_host>:<remote_port>`` on the tailnet.
7
+
8
+ Validated end-to-end in the Phase 0 spike (a non-proxy curl reached a tailnet
9
+ Caddy through this exact relay). Run as a detached process per forward:
10
+
11
+ python -m tailctl.forwarder <local_port> <socks_port> <remote_host> <remote_port>
12
+
13
+ The instance manager spawns one per configured port-forward and records its pid
14
+ in the registry so it can be reaped with the instance.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import struct
22
+ import sys
23
+
24
+
25
+ async def socks5_connect(
26
+ proxy_host: str, proxy_port: int, dst_host: str, dst_port: int
27
+ ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
28
+ """Open a SOCKS5 (no-auth) CONNECT tunnel to dst through the proxy."""
29
+ r, w = await asyncio.open_connection(proxy_host, proxy_port)
30
+ w.write(b"\x05\x01\x00") # version 5, 1 method, no-auth
31
+ await w.drain()
32
+ if await r.readexactly(2) != b"\x05\x00":
33
+ raise RuntimeError("SOCKS5 no-auth not accepted")
34
+ host = dst_host.encode()
35
+ # CONNECT, ATYP=domain — let the proxy resolve the name on the tailnet side.
36
+ w.write(b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack("!H", dst_port))
37
+ await w.drain()
38
+ rep = await r.readexactly(4)
39
+ if rep[1] != 0x00:
40
+ raise RuntimeError(f"SOCKS5 connect failed, REP={rep[1]}")
41
+ atyp = rep[3]
42
+ if atyp == 0x01:
43
+ await r.readexactly(4)
44
+ elif atyp == 0x04:
45
+ await r.readexactly(16)
46
+ elif atyp == 0x03:
47
+ ln = (await r.readexactly(1))[0]
48
+ await r.readexactly(ln)
49
+ await r.readexactly(2) # bound port
50
+ return r, w
51
+
52
+
53
+ async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
54
+ """Copy src→dst until src EOF, then HALF-close dst (write_eof) so the other
55
+ direction can still deliver its response. Does NOT fully close dst — that
56
+ would truncate request/response protocols (ClickHouse native, Postgres, TDS)
57
+ where a client finishes sending but still awaits the server's reply."""
58
+ try:
59
+ while data := await src.read(65536):
60
+ dst.write(data)
61
+ await dst.drain()
62
+ except Exception: # noqa: BLE001
63
+ # On a real transport error, fully close so the peer doesn't hang.
64
+ with contextlib.suppress(Exception):
65
+ dst.close()
66
+ return
67
+ # Clean EOF on src → propagate a half-close (FIN) to dst, keep reading the
68
+ # reverse direction.
69
+ try:
70
+ if dst.can_write_eof():
71
+ dst.write_eof()
72
+ except Exception: # noqa: BLE001
73
+ with contextlib.suppress(Exception):
74
+ dst.close()
75
+
76
+
77
+ async def serve(
78
+ local_port: int, socks_port: int, remote_host: str, remote_port: int
79
+ ) -> None:
80
+ async def handle(cr: asyncio.StreamReader, cw: asyncio.StreamWriter) -> None:
81
+ try:
82
+ pr, pw = await socks5_connect("127.0.0.1", socks_port, remote_host, remote_port)
83
+ except Exception as e: # noqa: BLE001
84
+ print(f"forwarder: upstream connect failed: {e}", file=sys.stderr)
85
+ with contextlib.suppress(Exception):
86
+ cw.close()
87
+ return
88
+ # Both directions run to completion (each half-closes its peer on EOF);
89
+ # only once both finish do we fully close both writers.
90
+ await asyncio.gather(_pipe(cr, pw), _pipe(pr, cw))
91
+ for w in (cw, pw):
92
+ with contextlib.suppress(Exception):
93
+ w.close()
94
+
95
+ server = await asyncio.start_server(handle, "127.0.0.1", local_port)
96
+ print(
97
+ f"forwarder: 127.0.0.1:{local_port} -> socks5 127.0.0.1:{socks_port} "
98
+ f"-> {remote_host}:{remote_port}",
99
+ flush=True,
100
+ )
101
+ async with server:
102
+ await server.serve_forever()
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ args = argv if argv is not None else sys.argv[1:]
107
+ if len(args) != 4:
108
+ print(
109
+ "usage: python -m tailctl.forwarder <local_port> <socks_port> "
110
+ "<remote_host> <remote_port>",
111
+ file=sys.stderr,
112
+ )
113
+ return 2
114
+ lp, sp, rh, rp = int(args[0]), int(args[1]), args[2], int(args[3])
115
+ try:
116
+ asyncio.run(serve(lp, sp, rh, rp))
117
+ except KeyboardInterrupt:
118
+ return 0
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ raise SystemExit(main())