nullgate 1.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.
nullgate/transports.py ADDED
@@ -0,0 +1,406 @@
1
+ """Transport mechanisms and process orchestration for Nullgate.
2
+
3
+ Supports Upterm (default), srv.us reverse tunnels, Cloudflare Worker relays,
4
+ and Cloudflare Argo (cloudflared) tunnels.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+ import shutil
12
+ import struct
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from nullgate import bridge, client_config, gateway, ingress, wsroute
22
+ from nullgate.account import ensure_username_environment
23
+ import nullgate.runtime as st
24
+
25
+ DEFAULT_TRANSPORT = "upterm"
26
+ VALID_TRANSPORTS = ("upterm", "srvus", "cloudflare", "cloudflared")
27
+
28
+ _PKG_SRC = str(Path(__file__).resolve().parent.parent)
29
+
30
+
31
+ @dataclass
32
+ class Target:
33
+ """Remote endpoint and SSH command details for an active transport."""
34
+
35
+ host: str
36
+ proxy_command: str
37
+ setup_host: str
38
+ url: str
39
+ note: str = ""
40
+ user: str = ""
41
+
42
+
43
+ def _spawn(command: list[str], environment: dict[str, str], log: Path) -> subprocess.Popen:
44
+ pythonpath = _PKG_SRC
45
+ if environment.get("PYTHONPATH"):
46
+ pythonpath = f"{_PKG_SRC}{os.pathsep}{environment['PYTHONPATH']}"
47
+ environment = {**environment, "PYTHONPATH": pythonpath}
48
+ with open(log, "ab") as output:
49
+ return subprocess.Popen(
50
+ command,
51
+ env=environment,
52
+ stdout=output,
53
+ stderr=subprocess.STDOUT,
54
+ stdin=subprocess.DEVNULL,
55
+ )
56
+
57
+
58
+ def normalize_endpoint(value: str, scheme: str) -> str | None:
59
+ stripped = value.strip().rstrip("/")
60
+ for prefix in ("https://", "http://", "wss://", "ws://"):
61
+ if stripped.startswith(prefix):
62
+ stripped = stripped[len(prefix) :]
63
+ break
64
+ if not stripped:
65
+ return None
66
+ return f"{scheme}://{stripped}"
67
+
68
+
69
+ def prepare_ssh_dir(ssh_dir: Path | None = None) -> bool:
70
+ target = ssh_dir if ssh_dir is not None else Path(os.path.expanduser("~/.ssh"))
71
+ try:
72
+ target.mkdir(parents=True, exist_ok=True)
73
+ target.chmod(0o700)
74
+ return True
75
+ except OSError:
76
+ return False
77
+
78
+
79
+ def validate_ssh_public_key(path: Path) -> bool:
80
+ ssh_keygen = shutil.which("ssh-keygen")
81
+ if ssh_keygen:
82
+ try:
83
+ return (
84
+ subprocess.run(
85
+ [ssh_keygen, "-l", "-f", str(path)],
86
+ capture_output=True,
87
+ ).returncode
88
+ == 0
89
+ )
90
+ except OSError:
91
+ return False
92
+ try:
93
+ parts = path.read_text().split()
94
+ if len(parts) < 2:
95
+ return False
96
+ blob = base64.b64decode(parts[1], validate=True)
97
+ size = struct.unpack(">I", blob[:4])[0]
98
+ return blob[4 : 4 + size].decode() == parts[0]
99
+ except Exception:
100
+ return False
101
+
102
+
103
+ def prompt_authorized_key() -> None:
104
+ """Prompt user for SSH public key if none exists on standard input / tty."""
105
+ keys = Path(os.path.expanduser("~/.ssh/authorized_keys"))
106
+ if keys.is_file() and keys.stat().st_size > 0:
107
+ return
108
+ source = None
109
+ if not sys.stdin.isatty():
110
+ try:
111
+ source = open("/dev/tty", "r")
112
+ except OSError:
113
+ return
114
+ print("No SSH keys detected in ~/.ssh/authorized_keys.", file=sys.stderr)
115
+ print(
116
+ "Paste an OpenSSH public key for key-based authentication (or Enter to skip): ",
117
+ file=sys.stderr,
118
+ end="",
119
+ flush=True,
120
+ )
121
+ pasted = (source or sys.stdin).readline().strip()
122
+ if source is not None:
123
+ source.close()
124
+ if not pasted:
125
+ print("Key entry skipped; relying on password authentication.", file=sys.stderr)
126
+ return
127
+ ssh_dir = Path(os.path.expanduser("~/.ssh"))
128
+ if not prepare_ssh_dir(ssh_dir):
129
+ print(f"Failed to prepare directory {ssh_dir}; skipped.", file=sys.stderr)
130
+ return
131
+ with tempfile.NamedTemporaryFile(
132
+ prefix="nullgate-key.", dir=str(ssh_dir), mode="w"
133
+ ) as temp:
134
+ temp.write(pasted + "\n")
135
+ temp.flush()
136
+ if validate_ssh_public_key(Path(temp.name)):
137
+ with open(keys, "ab") as output:
138
+ output.write(pasted.encode() + b"\n")
139
+ keys.chmod(0o600)
140
+ print(f"Appended key to {keys}.", file=sys.stderr)
141
+ else:
142
+ print("Invalid SSH public key format; entry skipped.", file=sys.stderr)
143
+
144
+
145
+ def launch_gateway(
146
+ transport: str,
147
+ root: Path,
148
+ port: int,
149
+ password: str,
150
+ accept: bool,
151
+ allow_tcp_forwarding: bool,
152
+ state: Path,
153
+ confine_sftp: bool = False,
154
+ ) -> subprocess.Popen:
155
+ """Spawn the background gateway AsyncSSH daemon."""
156
+ username = ensure_username_environment()
157
+ flags = []
158
+ if accept:
159
+ flags.append("--accept")
160
+ if allow_tcp_forwarding:
161
+ flags.append("--allow-tcp-forwarding")
162
+ flags.append("--confine-sftp" if confine_sftp else "--no-confine-sftp")
163
+ if transport == "upterm":
164
+ flags.extend(
165
+ ["--no-password", "--upterm-ca-keys", str(state / "upterm-ca-keys")]
166
+ )
167
+ command = [
168
+ sys.executable,
169
+ "-m",
170
+ "nullgate.gateway",
171
+ "--root",
172
+ str(root),
173
+ "--host",
174
+ "127.0.0.1",
175
+ "--port",
176
+ str(port),
177
+ "--username",
178
+ username,
179
+ *flags,
180
+ ]
181
+ environment = {
182
+ **os.environ,
183
+ "NULLGATE_PASSWORD": password,
184
+ "USER": username,
185
+ "LOGNAME": username,
186
+ }
187
+ return _spawn(command, environment, state / "gateway.log")
188
+
189
+
190
+ def launch_transport_srvus(port: int, slot: int, state: Path) -> subprocess.Popen:
191
+ ssh_dir = Path(os.path.expanduser("~/.ssh"))
192
+ if not prepare_ssh_dir(ssh_dir):
193
+ st.stop_one(state / "gateway.pid")
194
+ raise SystemExit(f"Failed to initialize SSH directory: {ssh_dir}")
195
+ key = Path(os.path.expanduser("~/.ssh/id_ed25519"))
196
+ st.repair_key_permissions(key)
197
+ host = os.environ.get("NULLGATE_TUNNEL_HOST", "srv.us")
198
+ try:
199
+ sink_port = int(os.environ.get("NULLGATE_TUNNEL_PORT", "22"))
200
+ except ValueError:
201
+ sink_port = 22
202
+ command = [
203
+ sys.executable,
204
+ "-m",
205
+ "nullgate.ingress",
206
+ "--host",
207
+ host,
208
+ "--port",
209
+ str(sink_port),
210
+ "--local-port",
211
+ str(port),
212
+ "--slot",
213
+ str(slot),
214
+ "--key",
215
+ str(key),
216
+ ]
217
+ return _spawn(command, os.environ, state / "transport.log")
218
+
219
+
220
+ def launch_transport_cloudflare(
221
+ endpoint: str, session: str, port: int, state: Path
222
+ ) -> subprocess.Popen:
223
+ if not endpoint:
224
+ st.stop_one(state / "gateway.pid")
225
+ raise SystemExit("The --endpoint option is required for the cloudflare transport")
226
+ wss = normalize_endpoint(endpoint, "wss")
227
+ if wss is None:
228
+ st.stop_one(state / "gateway.pid")
229
+ raise SystemExit(f"Malformed endpoint URL: {endpoint}")
230
+ base_url = f"{wss}/relay/{session}"
231
+ command = [
232
+ sys.executable,
233
+ "-m",
234
+ "nullgate.bridge",
235
+ "origin",
236
+ "--host",
237
+ "127.0.0.1",
238
+ "--port",
239
+ str(port),
240
+ base_url,
241
+ ]
242
+ return _spawn(command, os.environ, state / "transport.log")
243
+
244
+
245
+ def launch_transport_cloudflared(
246
+ hostname: str, token: str, state: Path
247
+ ) -> subprocess.Popen:
248
+ if not shutil.which("cloudflared"):
249
+ st.stop_one(state / "gateway.pid")
250
+ raise SystemExit("cloudflared executable not found in PATH")
251
+ if not hostname:
252
+ st.stop_one(state / "gateway.pid")
253
+ raise SystemExit("--hostname must be specified for cloudflared transport")
254
+ tunnel_token = token or os.environ.get("NULLGATE_CLOUDFLARED_TOKEN", "")
255
+ if not tunnel_token:
256
+ st.stop_one(state / "gateway.pid")
257
+ raise SystemExit(
258
+ "A tunnel token must be supplied via --token or NULLGATE_CLOUDFLARED_TOKEN"
259
+ )
260
+ environment = {**os.environ, "TUNNEL_TOKEN": tunnel_token}
261
+ return _spawn(["cloudflared", "tunnel", "run"], environment, state / "transport.log")
262
+
263
+
264
+ def launch_transport_upterm(
265
+ endpoint: str | None, accept: bool, port: int, state: Path
266
+ ) -> subprocess.Popen:
267
+ ssh_dir = Path(os.path.expanduser("~/.ssh"))
268
+ if not prepare_ssh_dir(ssh_dir):
269
+ st.stop_one(state / "gateway.pid")
270
+ raise SystemExit(f"Failed to initialize SSH directory: {ssh_dir}")
271
+ key = Path(os.path.expanduser("~/.ssh/id_ed25519"))
272
+ st.repair_key_permissions(key)
273
+ wss_endpoint = normalize_endpoint(endpoint or wsroute.DEFAULT_ENDPOINT, "wss")
274
+ if wss_endpoint is None:
275
+ st.stop_one(state / "gateway.pid")
276
+ raise SystemExit(f"Malformed endpoint URL: {endpoint}")
277
+ command = [
278
+ sys.executable,
279
+ "-m",
280
+ "nullgate.wsroute",
281
+ "origin",
282
+ "--endpoint",
283
+ wss_endpoint,
284
+ "--local-port",
285
+ str(port),
286
+ "--username",
287
+ ensure_username_environment(),
288
+ "--key",
289
+ str(key),
290
+ "--host-key",
291
+ str(gateway.DEFAULT_HOST_KEY),
292
+ "--authorized-keys",
293
+ os.path.expanduser("~/.ssh/authorized_keys"),
294
+ "--ca-keys",
295
+ str(state / "upterm-ca-keys"),
296
+ "--session-file",
297
+ str(state / "upterm.json"),
298
+ ]
299
+ if accept:
300
+ command.append("--accept")
301
+ return _spawn(command, os.environ, state / "transport.log")
302
+
303
+
304
+ def launch_transport(
305
+ transport: str,
306
+ port: int,
307
+ slot: int,
308
+ session: str,
309
+ state: Path,
310
+ endpoint: str = "",
311
+ hostname: str = "",
312
+ token: str = "",
313
+ accept: bool = False,
314
+ ) -> subprocess.Popen:
315
+ """Launch the chosen transport daemon."""
316
+ if transport == "srvus":
317
+ return launch_transport_srvus(port, slot, state)
318
+ if transport == "cloudflare":
319
+ return launch_transport_cloudflare(endpoint, session, port, state)
320
+ if transport == "cloudflared":
321
+ return launch_transport_cloudflared(hostname, token, state)
322
+ return launch_transport_upterm(endpoint, accept, port, state)
323
+
324
+
325
+ def wait_for_srvus_host(transport_pid_file: Path, transport_log: Path) -> None:
326
+ for _ in range(40):
327
+ if not st.is_running(transport_pid_file):
328
+ return
329
+ if st.extract_host(transport_log):
330
+ return
331
+ time.sleep(0.5)
332
+
333
+
334
+ def wait_for_upterm_session(
335
+ transport_pid_file: Path, session_file: Path
336
+ ) -> dict[str, Any] | None:
337
+ for _ in range(40):
338
+ if not st.is_running(transport_pid_file):
339
+ return None
340
+ session = wsroute.read_session(session_file)
341
+ if session:
342
+ return session
343
+ time.sleep(0.5)
344
+ return None
345
+
346
+
347
+ def resolve_target(transport: str, state: Path) -> Target | None:
348
+ """Resolve connection target details for the given transport."""
349
+ if transport == "cloudflare":
350
+ endpoint = st.manifest_get("endpoint")
351
+ session = st.manifest_get("session")
352
+ if not (endpoint and session):
353
+ return None
354
+ https = normalize_endpoint(endpoint, "https") or endpoint
355
+ wss = normalize_endpoint(endpoint, "wss") or endpoint
356
+ return Target(
357
+ host="nullgate",
358
+ proxy_command=f"nullgate proxy {wss}/relay/{session}",
359
+ setup_host="nullgate",
360
+ url=f"{https}/relay/{session}",
361
+ note="Nullgate client must be installed on the connecting machine.",
362
+ )
363
+ if transport == "cloudflared":
364
+ hostname = st.manifest_get("hostname")
365
+ if not hostname:
366
+ return None
367
+ return Target(
368
+ host=hostname,
369
+ proxy_command="cloudflared access ssh --hostname %h",
370
+ setup_host=hostname,
371
+ url=f"ssh://{hostname}",
372
+ note="cloudflared must be installed on the connecting machine.",
373
+ )
374
+ if transport == "upterm":
375
+ session = wsroute.read_session(state / "upterm.json")
376
+ if not session:
377
+ return None
378
+ endpoint = session["endpoint"]
379
+ host = wsroute.endpoint_host(endpoint)
380
+ proxy_url = wsroute.ssh_proxy_url(session)
381
+ return Target(
382
+ host=host,
383
+ proxy_command=f"nullgate upterm-proxy {proxy_url}",
384
+ setup_host=host,
385
+ url=f"ssh://{session['ssh_user']}@{host}:443",
386
+ note="Nullgate client must be installed on the connecting machine.",
387
+ user=session["ssh_user"],
388
+ )
389
+ # srvus transport
390
+ host = st.extract_host(state / "transport.log")
391
+ if not host:
392
+ return None
393
+ return Target(
394
+ host=host,
395
+ proxy_command=client_config.SRVUS_PROXY_COMMAND,
396
+ setup_host="*.srv.us",
397
+ url=f"https://{host}/",
398
+ )
399
+
400
+
401
+ def cmd_proxy(args: Any) -> int:
402
+ return bridge.main(["client", args.url])
403
+
404
+
405
+ def cmd_upterm_proxy(args: Any) -> int:
406
+ return wsroute.main(["client", args.url])