nullgate 1.2.5__tar.gz → 1.2.6__tar.gz
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-1.2.5/src/nullgate.egg-info → nullgate-1.2.6}/PKG-INFO +6 -3
- {nullgate-1.2.5 → nullgate-1.2.6}/README.md +5 -2
- nullgate-1.2.6/src/nullgate/__init__.py +1 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/commands.py +11 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/session.py +85 -10
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/transports.py +27 -0
- {nullgate-1.2.5 → nullgate-1.2.6/src/nullgate.egg-info}/PKG-INFO +6 -3
- {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_commands.py +178 -0
- nullgate-1.2.5/src/nullgate/__init__.py +0 -1
- {nullgate-1.2.5 → nullgate-1.2.6}/LICENSE +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/pyproject.toml +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/setup.cfg +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/account.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/bridge.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/client_config.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/gateway.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/ingress.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/runtime.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/wsroute.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/SOURCES.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/dependency_links.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/entry_points.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/requires.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/top_level.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_account.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_bridge.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_gateway.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_ingress.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_install.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_wsroute.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nullgate
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.6
|
|
4
4
|
Summary: Disposable SSH gateway into confined directory workspaces
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -110,6 +110,8 @@ Options:
|
|
|
110
110
|
- `--no-restrict-file-transfer`: Allow host-visible file transfers.
|
|
111
111
|
- `--foreground`: Stay in the foreground and supervise gateway and transport instead of exiting after readiness. The parent handles SIGINT and SIGTERM and stops both children, exiting nonzero with recent log tails if either child dies.
|
|
112
112
|
- `--json`: Emit machine-readable JSON on stdout (the only stdout in JSON mode). `start --foreground --json` prints one readiness object, flushes it, then supervises.
|
|
113
|
+
- `--replace-stale`: Replace tracked gateway and transport processes when they are not healthy. Healthy srv.us sessions are reused.
|
|
114
|
+
- `--rendezvous-url URL`: After srv.us passes its public SSH-over-TLS check, POST a minimal readiness document. Defaults to `NULLGATE_RENDEZVOUS_URL` and is saved for `restart`; the URL is never printed.
|
|
113
115
|
|
|
114
116
|
A fresh `start` uses documented defaults and the current directory when no
|
|
115
117
|
workspace is given; only `restart` reuses saved workspace, provider, port,
|
|
@@ -210,6 +212,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
210
212
|
| `NULLGATE_TUNNEL_PORT` | Port for srv.us ingress (default: `22`) |
|
|
211
213
|
| `NULLGATE_CLOUDFLARED_TOKEN` | Credential token for `cloudflared` tunnels |
|
|
212
214
|
| `NULLGATE_CONFINE_SFTP` | Set to `1` to confine SFTP and SCP transfers to the workspace root |
|
|
215
|
+
| `NULLGATE_RENDEZVOUS_URL` | HTTP endpoint for publishing successful srv.us readiness; may contain a secret token and is never printed |
|
|
213
216
|
| `NULLGATE_VERSION` | Install a specific published version instead of the stable release; accepts `1.0.1` or `v1.0.1` |
|
|
214
217
|
| `NULLGATE_DIST_URL` | Override the public release channel used by `install.sh` |
|
|
215
218
|
| `NULLGATE_INSTALL_URL` | Override the installer URL used by `nullgate upgrade` |
|
|
@@ -222,7 +225,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
222
225
|
secure mode without authorized keys fails immediately instead of hanging.
|
|
223
226
|
- Readiness is decisive: `start` returns success only after the gateway
|
|
224
227
|
listens, the transport stays alive, and (for srv.us) a hostname is
|
|
225
|
-
announced. On timeout or failure both processes are stopped, recent
|
|
228
|
+
announced and serves an SSH banner over certificate-validated TLS. On timeout or failure both processes are stopped, recent
|
|
226
229
|
transport logs go to stderr, and the exit code is nonzero.
|
|
227
230
|
- `start --foreground` keeps the parent alive supervising both children,
|
|
228
231
|
handles SIGINT/SIGTERM by stopping them, and exits nonzero with recent log tails if
|
|
@@ -230,7 +233,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
230
233
|
- `start`, `status`, and `connect` accept `--json` for machine-readable
|
|
231
234
|
output. JSON is the only stdout in JSON mode and carries the operational
|
|
232
235
|
fields agents need: version, provider, running state and PIDs, port,
|
|
233
|
-
workspace, URL, host, user, proxy command, auth mode, TCP forwarding, and
|
|
236
|
+
workspace, URL, host, user, proxy command, public health, auth mode, TCP forwarding, and
|
|
234
237
|
file-transfer mode. `--json` is the stable agent contract; human output may
|
|
235
238
|
change. `start --foreground --json` prints one readiness object,
|
|
236
239
|
flushes it, then supervises.
|
|
@@ -98,6 +98,8 @@ Options:
|
|
|
98
98
|
- `--no-restrict-file-transfer`: Allow host-visible file transfers.
|
|
99
99
|
- `--foreground`: Stay in the foreground and supervise gateway and transport instead of exiting after readiness. The parent handles SIGINT and SIGTERM and stops both children, exiting nonzero with recent log tails if either child dies.
|
|
100
100
|
- `--json`: Emit machine-readable JSON on stdout (the only stdout in JSON mode). `start --foreground --json` prints one readiness object, flushes it, then supervises.
|
|
101
|
+
- `--replace-stale`: Replace tracked gateway and transport processes when they are not healthy. Healthy srv.us sessions are reused.
|
|
102
|
+
- `--rendezvous-url URL`: After srv.us passes its public SSH-over-TLS check, POST a minimal readiness document. Defaults to `NULLGATE_RENDEZVOUS_URL` and is saved for `restart`; the URL is never printed.
|
|
101
103
|
|
|
102
104
|
A fresh `start` uses documented defaults and the current directory when no
|
|
103
105
|
workspace is given; only `restart` reuses saved workspace, provider, port,
|
|
@@ -198,6 +200,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
198
200
|
| `NULLGATE_TUNNEL_PORT` | Port for srv.us ingress (default: `22`) |
|
|
199
201
|
| `NULLGATE_CLOUDFLARED_TOKEN` | Credential token for `cloudflared` tunnels |
|
|
200
202
|
| `NULLGATE_CONFINE_SFTP` | Set to `1` to confine SFTP and SCP transfers to the workspace root |
|
|
203
|
+
| `NULLGATE_RENDEZVOUS_URL` | HTTP endpoint for publishing successful srv.us readiness; may contain a secret token and is never printed |
|
|
201
204
|
| `NULLGATE_VERSION` | Install a specific published version instead of the stable release; accepts `1.0.1` or `v1.0.1` |
|
|
202
205
|
| `NULLGATE_DIST_URL` | Override the public release channel used by `install.sh` |
|
|
203
206
|
| `NULLGATE_INSTALL_URL` | Override the installer URL used by `nullgate upgrade` |
|
|
@@ -210,7 +213,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
210
213
|
secure mode without authorized keys fails immediately instead of hanging.
|
|
211
214
|
- Readiness is decisive: `start` returns success only after the gateway
|
|
212
215
|
listens, the transport stays alive, and (for srv.us) a hostname is
|
|
213
|
-
announced. On timeout or failure both processes are stopped, recent
|
|
216
|
+
announced and serves an SSH banner over certificate-validated TLS. On timeout or failure both processes are stopped, recent
|
|
214
217
|
transport logs go to stderr, and the exit code is nonzero.
|
|
215
218
|
- `start --foreground` keeps the parent alive supervising both children,
|
|
216
219
|
handles SIGINT/SIGTERM by stopping them, and exits nonzero with recent log tails if
|
|
@@ -218,7 +221,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
218
221
|
- `start`, `status`, and `connect` accept `--json` for machine-readable
|
|
219
222
|
output. JSON is the only stdout in JSON mode and carries the operational
|
|
220
223
|
fields agents need: version, provider, running state and PIDs, port,
|
|
221
|
-
workspace, URL, host, user, proxy command, auth mode, TCP forwarding, and
|
|
224
|
+
workspace, URL, host, user, proxy command, public health, auth mode, TCP forwarding, and
|
|
222
225
|
file-transfer mode. `--json` is the stable agent contract; human output may
|
|
223
226
|
change. `start --foreground --json` prints one readiness object,
|
|
224
227
|
flushes it, then supervises.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.6"
|
|
@@ -188,6 +188,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
188
188
|
action="store_true",
|
|
189
189
|
help="Emit machine-readable JSON on stdout.",
|
|
190
190
|
)
|
|
191
|
+
start_parser.add_argument(
|
|
192
|
+
"--replace-stale",
|
|
193
|
+
action="store_true",
|
|
194
|
+
help="Replace tracked processes when they are not publicly healthy.",
|
|
195
|
+
)
|
|
196
|
+
start_parser.add_argument(
|
|
197
|
+
"--rendezvous-url",
|
|
198
|
+
metavar="URL",
|
|
199
|
+
default=None,
|
|
200
|
+
help="Publish srv.us readiness to an HTTP endpoint.",
|
|
201
|
+
)
|
|
191
202
|
start_parser.add_argument(
|
|
192
203
|
"--notte-api", dest="notte_api", action="store_true", help=argparse.SUPPRESS
|
|
193
204
|
)
|
|
@@ -14,6 +14,8 @@ import signal
|
|
|
14
14
|
import subprocess
|
|
15
15
|
import sys
|
|
16
16
|
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
from datetime import datetime, timezone
|
|
17
19
|
from pathlib import Path
|
|
18
20
|
from typing import Any
|
|
19
21
|
|
|
@@ -265,6 +267,9 @@ def _status_payload() -> dict[str, Any]:
|
|
|
265
267
|
transport_running = st.is_running(transport_pid_file)
|
|
266
268
|
provider = st.manifest_get("transport") or transports.DEFAULT_TRANSPORT
|
|
267
269
|
target = transports.resolve_target(provider, state)
|
|
270
|
+
public_healthy = None
|
|
271
|
+
if provider == "srvus" and target is not None:
|
|
272
|
+
public_healthy = transports.probe_ssh_tls(target.host)
|
|
268
273
|
port_text = st.manifest_get("port")
|
|
269
274
|
try:
|
|
270
275
|
port = int(port_text) if port_text else None
|
|
@@ -276,6 +281,7 @@ def _status_payload() -> dict[str, Any]:
|
|
|
276
281
|
"version": _version(),
|
|
277
282
|
"provider": provider,
|
|
278
283
|
"running": bool(gateway_running and transport_running),
|
|
284
|
+
"public_healthy": public_healthy,
|
|
279
285
|
"gateway": {"running": gateway_running, "pid": st.read_pid(gateway_pid_file)},
|
|
280
286
|
"transport": {
|
|
281
287
|
"running": transport_running,
|
|
@@ -297,7 +303,7 @@ def _show_status(args: Any = None) -> int:
|
|
|
297
303
|
if getattr(args, "json", False):
|
|
298
304
|
payload = _status_payload()
|
|
299
305
|
print(json.dumps(payload, indent=2))
|
|
300
|
-
return 0 if payload["running"] else 1
|
|
306
|
+
return 0 if payload["running"] and payload["public_healthy"] is not False else 1
|
|
301
307
|
state = st.state_dir()
|
|
302
308
|
gateway_pid_file = state / "gateway.pid"
|
|
303
309
|
transport_pid_file = state / "transport.pid"
|
|
@@ -306,14 +312,18 @@ def _show_status(args: Any = None) -> int:
|
|
|
306
312
|
transport = st.manifest_get("transport") or transports.DEFAULT_TRANSPORT
|
|
307
313
|
target = transports.resolve_target(transport, state)
|
|
308
314
|
live = bool(gateway_running and transport_running)
|
|
315
|
+
public_healthy = None
|
|
316
|
+
if transport == "srvus" and target is not None:
|
|
317
|
+
public_healthy = transports.probe_ssh_tls(target.host)
|
|
318
|
+
usable = live and public_healthy is not False
|
|
309
319
|
if not sys.stdout.isatty():
|
|
310
|
-
if target is not None and
|
|
320
|
+
if target is not None and usable:
|
|
311
321
|
_print_compact_connection(state, target)
|
|
312
|
-
elif live:
|
|
322
|
+
elif live and public_healthy is not False:
|
|
313
323
|
print("pending")
|
|
314
324
|
else:
|
|
315
325
|
print("stopped")
|
|
316
|
-
return 0 if
|
|
326
|
+
return 0 if usable else 1
|
|
317
327
|
color = _colorizer()
|
|
318
328
|
|
|
319
329
|
print(f"nullgate {_version()} \u00b7 {transport}")
|
|
@@ -321,11 +331,31 @@ def _show_status(args: Any = None) -> int:
|
|
|
321
331
|
print(f" gateway {_proc_row(color, gateway_running, st.read_pid(gateway_pid_file))}")
|
|
322
332
|
print(f" transport {_proc_row(color, transport_running, st.read_pid(transport_pid_file))}")
|
|
323
333
|
_print_auth_rows(color, state)
|
|
324
|
-
if target is not None:
|
|
334
|
+
if target is not None and public_healthy is not False:
|
|
325
335
|
print()
|
|
326
336
|
print(" connect " + color.bold(f"ssh {target.user or current_username()}@{target.host}"))
|
|
327
337
|
print(" " + color.dim("first time here: nullgate connect"))
|
|
328
|
-
return 0 if
|
|
338
|
+
return 0 if usable else 1
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _publish_rendezvous(rendezvous_url: str, target: Any) -> None:
|
|
342
|
+
payload = {
|
|
343
|
+
"provider": "srvus",
|
|
344
|
+
"host": target.host,
|
|
345
|
+
"url": target.url,
|
|
346
|
+
"ssh_command": _fallback_ssh_command(target),
|
|
347
|
+
"healthy": True,
|
|
348
|
+
"updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
349
|
+
}
|
|
350
|
+
request = urllib.request.Request(
|
|
351
|
+
rendezvous_url,
|
|
352
|
+
data=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
|
|
353
|
+
headers={"Content-Type": "application/json"},
|
|
354
|
+
method="POST",
|
|
355
|
+
)
|
|
356
|
+
with urllib.request.urlopen(request, timeout=3.0) as response:
|
|
357
|
+
if not 200 <= response.status < 300:
|
|
358
|
+
raise OSError(f"HTTP {response.status}")
|
|
329
359
|
|
|
330
360
|
|
|
331
361
|
def _supervise_foreground(
|
|
@@ -394,6 +424,12 @@ def _launch(args: Any, saved: dict[str, Any]) -> int:
|
|
|
394
424
|
or saved.get("token")
|
|
395
425
|
or ""
|
|
396
426
|
)
|
|
427
|
+
rendezvous_url = (
|
|
428
|
+
getattr(args, "rendezvous_url", None)
|
|
429
|
+
or os.environ.get("NULLGATE_RENDEZVOUS_URL")
|
|
430
|
+
or saved.get("rendezvous_url")
|
|
431
|
+
or ""
|
|
432
|
+
)
|
|
397
433
|
|
|
398
434
|
root, port, slot = _resolve_launch_settings(args, saved)
|
|
399
435
|
seed_file, identity_seed = _resolve_identity_seed(args, saved, transport)
|
|
@@ -403,10 +439,29 @@ def _launch(args: Any, saved: dict[str, Any]) -> int:
|
|
|
403
439
|
gateway_log = state / "gateway.log"
|
|
404
440
|
transport_log = state / "transport.log"
|
|
405
441
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if
|
|
409
|
-
|
|
442
|
+
gateway_running = st.is_running(gateway_pid_file)
|
|
443
|
+
transport_running = st.is_running(transport_pid_file)
|
|
444
|
+
if gateway_running or transport_running:
|
|
445
|
+
current_provider = st.manifest_get("transport") or transports.DEFAULT_TRANSPORT
|
|
446
|
+
current_target = transports.resolve_target(current_provider, state)
|
|
447
|
+
publicly_healthy = True
|
|
448
|
+
if current_provider == "srvus":
|
|
449
|
+
publicly_healthy = bool(
|
|
450
|
+
current_target and transports.probe_ssh_tls(current_target.host)
|
|
451
|
+
)
|
|
452
|
+
if gateway_running and transport_running and publicly_healthy:
|
|
453
|
+
if getattr(args, "json", False):
|
|
454
|
+
print(json.dumps(_status_payload(), indent=2))
|
|
455
|
+
else:
|
|
456
|
+
_show_status()
|
|
457
|
+
return 0
|
|
458
|
+
if getattr(args, "replace_stale", False):
|
|
459
|
+
st.stop_one(transport_pid_file)
|
|
460
|
+
st.stop_one(gateway_pid_file)
|
|
461
|
+
elif gateway_running:
|
|
462
|
+
die("Gateway service is already running")
|
|
463
|
+
else:
|
|
464
|
+
die("Transport service is already running")
|
|
410
465
|
for pid_file in (gateway_pid_file, transport_pid_file):
|
|
411
466
|
if not pid_file.is_dir():
|
|
412
467
|
pid_file.unlink(missing_ok=True)
|
|
@@ -523,6 +578,11 @@ def _launch(args: Any, saved: dict[str, Any]) -> int:
|
|
|
523
578
|
)
|
|
524
579
|
_tail_stderr(transport_log, 100)
|
|
525
580
|
return 1
|
|
581
|
+
if not transports.probe_ssh_tls(host):
|
|
582
|
+
st.stop_one(transport_pid_file)
|
|
583
|
+
st.stop_one(gateway_pid_file)
|
|
584
|
+
print("srv.us public endpoint failed its SSH-over-TLS probe.", file=sys.stderr)
|
|
585
|
+
return 1
|
|
526
586
|
elif transport == "upterm":
|
|
527
587
|
if not transports.wait_for_upterm_session(transport_pid_file, state / "upterm.json"):
|
|
528
588
|
st.stop_one(transport_pid_file)
|
|
@@ -544,6 +604,18 @@ def _launch(args: Any, saved: dict[str, Any]) -> int:
|
|
|
544
604
|
_tail_stderr(gateway_log, 100)
|
|
545
605
|
return 1
|
|
546
606
|
|
|
607
|
+
if transport == "srvus" and rendezvous_url:
|
|
608
|
+
target = transports.resolve_target(transport, state)
|
|
609
|
+
try:
|
|
610
|
+
if target is None:
|
|
611
|
+
raise OSError("missing srv.us target")
|
|
612
|
+
_publish_rendezvous(rendezvous_url, target)
|
|
613
|
+
except Exception:
|
|
614
|
+
st.stop_one(transport_pid_file)
|
|
615
|
+
st.stop_one(gateway_pid_file)
|
|
616
|
+
print("Rendezvous readiness publication failed.", file=sys.stderr)
|
|
617
|
+
return 1
|
|
618
|
+
|
|
547
619
|
manifest: dict[str, Any] = {
|
|
548
620
|
"transport": transport,
|
|
549
621
|
"port": str(port),
|
|
@@ -572,6 +644,7 @@ def _launch(args: Any, saved: dict[str, Any]) -> int:
|
|
|
572
644
|
"endpoint": endpoint,
|
|
573
645
|
"token": token,
|
|
574
646
|
"identity_seed_file": seed_file,
|
|
647
|
+
"rendezvous_url": rendezvous_url,
|
|
575
648
|
"accept": "1" if accept else "0",
|
|
576
649
|
"allow_tcp_forwarding": "1" if allow_tcp_forwarding else "0",
|
|
577
650
|
"confine_sftp": "1" if confine_sftp else "0",
|
|
@@ -723,6 +796,8 @@ def cmd_cycle(args: Any = None) -> int:
|
|
|
723
796
|
token=None,
|
|
724
797
|
identity_seed=None,
|
|
725
798
|
identity_seed_file=None,
|
|
799
|
+
rendezvous_url=None,
|
|
800
|
+
replace_stale=False,
|
|
726
801
|
accept=None,
|
|
727
802
|
allow_tcp_forwarding=None,
|
|
728
803
|
confine_sftp=None,
|
|
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|
|
9
9
|
import base64
|
|
10
10
|
import os
|
|
11
11
|
import shutil
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
12
14
|
import struct
|
|
13
15
|
import subprocess
|
|
14
16
|
import sys
|
|
@@ -30,6 +32,31 @@ _PKG_SRC = str(Path(__file__).resolve().parent.parent)
|
|
|
30
32
|
_SEED_ENV = ingress.IDENTITY_SEED_ENV
|
|
31
33
|
|
|
32
34
|
|
|
35
|
+
def probe_ssh_tls(
|
|
36
|
+
host: str,
|
|
37
|
+
port: int = 443,
|
|
38
|
+
timeout: float = 3.0,
|
|
39
|
+
*,
|
|
40
|
+
create_connection: Any = socket.create_connection,
|
|
41
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
42
|
+
) -> bool:
|
|
43
|
+
"""Verify a certificate-valid TLS endpoint which serves an SSH banner."""
|
|
44
|
+
context = ssl_context or ssl.create_default_context()
|
|
45
|
+
try:
|
|
46
|
+
with create_connection((host, port), timeout=timeout) as raw:
|
|
47
|
+
raw.settimeout(timeout)
|
|
48
|
+
with context.wrap_socket(raw, server_hostname=host) as secured:
|
|
49
|
+
banner = b""
|
|
50
|
+
while len(banner) < 255 and b"\n" not in banner:
|
|
51
|
+
chunk = secured.recv(255 - len(banner))
|
|
52
|
+
if not chunk:
|
|
53
|
+
break
|
|
54
|
+
banner += chunk
|
|
55
|
+
return banner.startswith(b"SSH-")
|
|
56
|
+
except (OSError, ssl.SSLError, TimeoutError):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
33
60
|
def _scrubbed_env() -> dict[str, str]:
|
|
34
61
|
"""Copy the parent environment without the identity seed.
|
|
35
62
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nullgate
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.6
|
|
4
4
|
Summary: Disposable SSH gateway into confined directory workspaces
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -110,6 +110,8 @@ Options:
|
|
|
110
110
|
- `--no-restrict-file-transfer`: Allow host-visible file transfers.
|
|
111
111
|
- `--foreground`: Stay in the foreground and supervise gateway and transport instead of exiting after readiness. The parent handles SIGINT and SIGTERM and stops both children, exiting nonzero with recent log tails if either child dies.
|
|
112
112
|
- `--json`: Emit machine-readable JSON on stdout (the only stdout in JSON mode). `start --foreground --json` prints one readiness object, flushes it, then supervises.
|
|
113
|
+
- `--replace-stale`: Replace tracked gateway and transport processes when they are not healthy. Healthy srv.us sessions are reused.
|
|
114
|
+
- `--rendezvous-url URL`: After srv.us passes its public SSH-over-TLS check, POST a minimal readiness document. Defaults to `NULLGATE_RENDEZVOUS_URL` and is saved for `restart`; the URL is never printed.
|
|
113
115
|
|
|
114
116
|
A fresh `start` uses documented defaults and the current directory when no
|
|
115
117
|
workspace is given; only `restart` reuses saved workspace, provider, port,
|
|
@@ -210,6 +212,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
210
212
|
| `NULLGATE_TUNNEL_PORT` | Port for srv.us ingress (default: `22`) |
|
|
211
213
|
| `NULLGATE_CLOUDFLARED_TOKEN` | Credential token for `cloudflared` tunnels |
|
|
212
214
|
| `NULLGATE_CONFINE_SFTP` | Set to `1` to confine SFTP and SCP transfers to the workspace root |
|
|
215
|
+
| `NULLGATE_RENDEZVOUS_URL` | HTTP endpoint for publishing successful srv.us readiness; may contain a secret token and is never printed |
|
|
213
216
|
| `NULLGATE_VERSION` | Install a specific published version instead of the stable release; accepts `1.0.1` or `v1.0.1` |
|
|
214
217
|
| `NULLGATE_DIST_URL` | Override the public release channel used by `install.sh` |
|
|
215
218
|
| `NULLGATE_INSTALL_URL` | Override the installer URL used by `nullgate upgrade` |
|
|
@@ -222,7 +225,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
222
225
|
secure mode without authorized keys fails immediately instead of hanging.
|
|
223
226
|
- Readiness is decisive: `start` returns success only after the gateway
|
|
224
227
|
listens, the transport stays alive, and (for srv.us) a hostname is
|
|
225
|
-
announced. On timeout or failure both processes are stopped, recent
|
|
228
|
+
announced and serves an SSH banner over certificate-validated TLS. On timeout or failure both processes are stopped, recent
|
|
226
229
|
transport logs go to stderr, and the exit code is nonzero.
|
|
227
230
|
- `start --foreground` keeps the parent alive supervising both children,
|
|
228
231
|
handles SIGINT/SIGTERM by stopping them, and exits nonzero with recent log tails if
|
|
@@ -230,7 +233,7 @@ Unknown flags fail normally; only the hidden external-launcher flags
|
|
|
230
233
|
- `start`, `status`, and `connect` accept `--json` for machine-readable
|
|
231
234
|
output. JSON is the only stdout in JSON mode and carries the operational
|
|
232
235
|
fields agents need: version, provider, running state and PIDs, port,
|
|
233
|
-
workspace, URL, host, user, proxy command, auth mode, TCP forwarding, and
|
|
236
|
+
workspace, URL, host, user, proxy command, public health, auth mode, TCP forwarding, and
|
|
234
237
|
file-transfer mode. `--json` is the stable agent contract; human output may
|
|
235
238
|
change. `start --foreground --json` prints one readiness object,
|
|
236
239
|
flushes it, then supervises.
|
|
@@ -156,6 +156,11 @@ def state_dir(state_base):
|
|
|
156
156
|
|
|
157
157
|
|
|
158
158
|
class CLILifecycleTests(unittest.TestCase):
|
|
159
|
+
def setUp(self):
|
|
160
|
+
probe = mock.patch.object(session.transports, "probe_ssh_tls", return_value=True)
|
|
161
|
+
probe.start()
|
|
162
|
+
self.addCleanup(probe.stop)
|
|
163
|
+
|
|
159
164
|
def test_redesigned_defaults_and_boolean_overrides(self):
|
|
160
165
|
parser = commands.build_parser()
|
|
161
166
|
defaults = parser.parse_args(["start"])
|
|
@@ -171,6 +176,14 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
171
176
|
self.assertIsNone(defaults.confine_sftp)
|
|
172
177
|
self.assertIsNone(defaults.identity_seed_file)
|
|
173
178
|
self.assertIsNone(defaults.identity_seed)
|
|
179
|
+
self.assertFalse(defaults.replace_stale)
|
|
180
|
+
self.assertIsNone(defaults.rendezvous_url)
|
|
181
|
+
|
|
182
|
+
recovery = parser.parse_args(
|
|
183
|
+
["start", "--replace-stale", "--rendezvous-url", "https://hook.example/token"]
|
|
184
|
+
)
|
|
185
|
+
self.assertTrue(recovery.replace_stale)
|
|
186
|
+
self.assertEqual(recovery.rendezvous_url, "https://hook.example/token")
|
|
174
187
|
|
|
175
188
|
seeded = parser.parse_args(["start", "--identity-seed-file", "/run/seed.txt"])
|
|
176
189
|
self.assertEqual(seeded.identity_seed_file, "/run/seed.txt")
|
|
@@ -2089,6 +2102,171 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
2089
2102
|
code, out, _ = run_cli_tty(["inspect"], env=env)
|
|
2090
2103
|
self.assertNotIn(" url ", out)
|
|
2091
2104
|
|
|
2105
|
+
def test_replace_stale_reuses_healthy_srvus_session(self):
|
|
2106
|
+
args = commands.build_parser().parse_args(["start", "--replace-stale"])
|
|
2107
|
+
target = transports.Target(
|
|
2108
|
+
"healthy.srv.us", "proxy", "*.srv.us", "https://healthy.srv.us/"
|
|
2109
|
+
)
|
|
2110
|
+
with mock.patch.object(session.st, "setup_state", return_value=Path("/state")), mock.patch.object(
|
|
2111
|
+
session.st, "is_running", return_value=True
|
|
2112
|
+
), mock.patch.object(session.st, "manifest_get", return_value="srvus"), mock.patch.object(
|
|
2113
|
+
session.transports, "resolve_target", return_value=target
|
|
2114
|
+
), mock.patch.object(session, "_show_status", return_value=0) as show, mock.patch.object(
|
|
2115
|
+
session.transports, "launch_gateway"
|
|
2116
|
+
) as launch:
|
|
2117
|
+
self.assertEqual(session._launch(args, {}), 0)
|
|
2118
|
+
show.assert_called_once_with()
|
|
2119
|
+
launch.assert_not_called()
|
|
2120
|
+
|
|
2121
|
+
def test_replace_stale_stops_unhealthy_session_and_relaunches(self):
|
|
2122
|
+
args = commands.build_parser().parse_args(
|
|
2123
|
+
[
|
|
2124
|
+
"start",
|
|
2125
|
+
".",
|
|
2126
|
+
"--replace-stale",
|
|
2127
|
+
"--public-access",
|
|
2128
|
+
"--rendezvous-url",
|
|
2129
|
+
"https://hook.example/token",
|
|
2130
|
+
]
|
|
2131
|
+
)
|
|
2132
|
+
target = transports.Target(
|
|
2133
|
+
"dead.srv.us", "proxy", "*.srv.us", "https://dead.srv.us/"
|
|
2134
|
+
)
|
|
2135
|
+
stopped = []
|
|
2136
|
+
stored = {}
|
|
2137
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
2138
|
+
state = Path(temp)
|
|
2139
|
+
for name in ("gateway.pid", "transport.pid"):
|
|
2140
|
+
(state / name).write_text("")
|
|
2141
|
+
with mock.patch.object(session.st, "setup_state", return_value=state), mock.patch.object(
|
|
2142
|
+
session.st, "is_running", side_effect=[True, True, True, True, True, True]
|
|
2143
|
+
), mock.patch.object(session.st, "manifest_get", return_value="srvus"), mock.patch.object(
|
|
2144
|
+
session.transports, "resolve_target", return_value=target
|
|
2145
|
+
), mock.patch.object(
|
|
2146
|
+
session.transports, "probe_ssh_tls", side_effect=[False, True]
|
|
2147
|
+
), mock.patch.object(
|
|
2148
|
+
session.st, "stop_one", side_effect=lambda path: stopped.append(Path(path).name)
|
|
2149
|
+
), mock.patch.object(
|
|
2150
|
+
session.st, "port_open", side_effect=[False, True]
|
|
2151
|
+
), mock.patch.object(session.st, "write_pid", return_value=True), mock.patch.object(
|
|
2152
|
+
session.st, "get_or_create_session", return_value="session"
|
|
2153
|
+
), mock.patch.object(
|
|
2154
|
+
session.transports, "launch_gateway", return_value=mock.Mock(pid=11)
|
|
2155
|
+
) as gateway, mock.patch.object(
|
|
2156
|
+
session.transports, "launch_transport", return_value=mock.Mock(pid=22)
|
|
2157
|
+
), mock.patch.object(
|
|
2158
|
+
session.transports, "wait_for_srvus_host", return_value="new.srv.us"
|
|
2159
|
+
), mock.patch.object(session.st, "write_manifest"), mock.patch.object(
|
|
2160
|
+
session.st, "write_settings", side_effect=lambda settings: stored.update(settings)
|
|
2161
|
+
), mock.patch.object(
|
|
2162
|
+
session, "_publish_rendezvous"
|
|
2163
|
+
), mock.patch.object(session, "_show_status", return_value=0), mock.patch.object(
|
|
2164
|
+
session.time, "sleep"
|
|
2165
|
+
):
|
|
2166
|
+
self.assertEqual(session._launch(args, {}), 0)
|
|
2167
|
+
self.assertEqual(stopped[:2], ["transport.pid", "gateway.pid"])
|
|
2168
|
+
gateway.assert_called_once()
|
|
2169
|
+
self.assertEqual(stored["rendezvous_url"], "https://hook.example/token")
|
|
2170
|
+
|
|
2171
|
+
def test_status_marks_live_srvus_processes_publicly_dead(self):
|
|
2172
|
+
target = transports.Target(
|
|
2173
|
+
"dead.srv.us", "proxy", "*.srv.us", "https://dead.srv.us/"
|
|
2174
|
+
)
|
|
2175
|
+
with mock.patch.object(session.st, "setup_state"), mock.patch.object(
|
|
2176
|
+
session.st, "state_dir", return_value=Path("/state")
|
|
2177
|
+
), mock.patch.object(session.st, "is_running", return_value=True), mock.patch.object(
|
|
2178
|
+
session.st, "manifest_get", side_effect=lambda key: "srvus" if key == "transport" else ""
|
|
2179
|
+
), mock.patch.object(session.st, "read_settings", return_value={}), mock.patch.object(
|
|
2180
|
+
session.transports, "resolve_target", return_value=target
|
|
2181
|
+
), mock.patch.object(session.transports, "probe_ssh_tls", return_value=False):
|
|
2182
|
+
code, out, err = run_cli(["status"])
|
|
2183
|
+
self.assertEqual(code, 1)
|
|
2184
|
+
self.assertEqual(out.strip(), "stopped")
|
|
2185
|
+
code, out, err = run_cli(["status", "--json"])
|
|
2186
|
+
self.assertEqual(code, 1)
|
|
2187
|
+
self.assertFalse(json.loads(out)["public_healthy"])
|
|
2188
|
+
|
|
2189
|
+
def test_rendezvous_payload_is_minimal_and_contains_no_secrets(self):
|
|
2190
|
+
target = transports.Target(
|
|
2191
|
+
"ready.srv.us", "openssl proxy", "*.srv.us", "https://ready.srv.us/"
|
|
2192
|
+
)
|
|
2193
|
+
response = mock.MagicMock()
|
|
2194
|
+
response.__enter__.return_value.status = 204
|
|
2195
|
+
with mock.patch.object(session.urllib.request, "urlopen", return_value=response) as open_url:
|
|
2196
|
+
session._publish_rendezvous("https://hook.example/private-token", target)
|
|
2197
|
+
request = open_url.call_args.args[0]
|
|
2198
|
+
payload = json.loads(request.data)
|
|
2199
|
+
self.assertEqual(
|
|
2200
|
+
set(payload),
|
|
2201
|
+
{"provider", "host", "url", "ssh_command", "healthy", "updated_at"},
|
|
2202
|
+
)
|
|
2203
|
+
self.assertEqual(payload["host"], "ready.srv.us")
|
|
2204
|
+
self.assertTrue(payload["healthy"])
|
|
2205
|
+
serialized = json.dumps(payload)
|
|
2206
|
+
self.assertNotIn("private-token", serialized)
|
|
2207
|
+
self.assertNotIn("password", serialized.lower())
|
|
2208
|
+
|
|
2209
|
+
def test_rendezvous_failure_stops_new_children(self):
|
|
2210
|
+
args = commands.build_parser().parse_args(
|
|
2211
|
+
["start", ".", "--public-access", "--rendezvous-url", "https://hook.example/token"]
|
|
2212
|
+
)
|
|
2213
|
+
target = transports.Target(
|
|
2214
|
+
"ready.srv.us", "proxy", "*.srv.us", "https://ready.srv.us/"
|
|
2215
|
+
)
|
|
2216
|
+
stopped = []
|
|
2217
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
2218
|
+
state = Path(temp)
|
|
2219
|
+
with mock.patch.object(session.st, "setup_state", return_value=state), mock.patch.object(
|
|
2220
|
+
session.st, "is_running", side_effect=[False, False, True, True, True, True]
|
|
2221
|
+
), mock.patch.object(session.st, "port_open", side_effect=[False, True]), mock.patch.object(
|
|
2222
|
+
session.st, "write_pid", return_value=True
|
|
2223
|
+
), mock.patch.object(session.st, "get_or_create_session", return_value="session"), mock.patch.object(
|
|
2224
|
+
session.transports, "launch_gateway", return_value=mock.Mock(pid=11)
|
|
2225
|
+
), mock.patch.object(
|
|
2226
|
+
session.transports, "launch_transport", return_value=mock.Mock(pid=22)
|
|
2227
|
+
), mock.patch.object(
|
|
2228
|
+
session.transports, "wait_for_srvus_host", return_value="ready.srv.us"
|
|
2229
|
+
), mock.patch.object(
|
|
2230
|
+
session.transports, "resolve_target", return_value=target
|
|
2231
|
+
), mock.patch.object(
|
|
2232
|
+
session, "_publish_rendezvous", side_effect=OSError("delivery failed")
|
|
2233
|
+
), mock.patch.object(
|
|
2234
|
+
session.st, "stop_one", side_effect=lambda path: stopped.append(Path(path).name)
|
|
2235
|
+
), mock.patch.object(session.time, "sleep"):
|
|
2236
|
+
code = session._launch(args, {})
|
|
2237
|
+
self.assertEqual(code, 1)
|
|
2238
|
+
self.assertIn("transport.pid", stopped)
|
|
2239
|
+
self.assertIn("gateway.pid", stopped)
|
|
2240
|
+
|
|
2241
|
+
|
|
2242
|
+
class TransportProbeTests(unittest.TestCase):
|
|
2243
|
+
def test_tls_probe_uses_sni_verification_context_and_requires_ssh_banner(self):
|
|
2244
|
+
raw = mock.MagicMock()
|
|
2245
|
+
raw.__enter__.return_value = raw
|
|
2246
|
+
secured = mock.MagicMock()
|
|
2247
|
+
secured.__enter__.return_value = secured
|
|
2248
|
+
secured.recv.side_effect = [b"SSH-2.0-nullgate\r\n"]
|
|
2249
|
+
context = mock.MagicMock(spec=__import__("ssl").SSLContext)
|
|
2250
|
+
context.wrap_socket.return_value = secured
|
|
2251
|
+
connect = mock.MagicMock(return_value=raw)
|
|
2252
|
+
self.assertTrue(
|
|
2253
|
+
transports.probe_ssh_tls(
|
|
2254
|
+
"public.srv.us",
|
|
2255
|
+
port=8443,
|
|
2256
|
+
timeout=1.25,
|
|
2257
|
+
create_connection=connect,
|
|
2258
|
+
ssl_context=context,
|
|
2259
|
+
)
|
|
2260
|
+
)
|
|
2261
|
+
connect.assert_called_once_with(("public.srv.us", 8443), timeout=1.25)
|
|
2262
|
+
context.wrap_socket.assert_called_once_with(raw, server_hostname="public.srv.us")
|
|
2263
|
+
secured.recv.side_effect = [b"HTTP/1.1 200 OK\r\n"]
|
|
2264
|
+
self.assertFalse(
|
|
2265
|
+
transports.probe_ssh_tls(
|
|
2266
|
+
"public.srv.us", create_connection=connect, ssl_context=context
|
|
2267
|
+
)
|
|
2268
|
+
)
|
|
2269
|
+
|
|
2092
2270
|
|
|
2093
2271
|
class ClientConfigTests(unittest.TestCase):
|
|
2094
2272
|
def test_client_config_print_and_idempotent_write(self):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.2.5"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|