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/__init__.py +1 -0
- nullgate/account.py +23 -0
- nullgate/bridge.py +162 -0
- nullgate/client_config.py +116 -0
- nullgate/commands.py +172 -0
- nullgate/gateway.py +897 -0
- nullgate/ingress.py +206 -0
- nullgate/runtime.py +236 -0
- nullgate/session.py +509 -0
- nullgate/transports.py +406 -0
- nullgate/wsroute.py +483 -0
- nullgate-1.1.0.dist-info/METADATA +10 -0
- nullgate-1.1.0.dist-info/RECORD +17 -0
- nullgate-1.1.0.dist-info/WHEEL +5 -0
- nullgate-1.1.0.dist-info/entry_points.txt +2 -0
- nullgate-1.1.0.dist-info/licenses/LICENSE +21 -0
- nullgate-1.1.0.dist-info/top_level.txt +1 -0
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])
|