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.
Files changed (30) hide show
  1. {nullgate-1.2.5/src/nullgate.egg-info → nullgate-1.2.6}/PKG-INFO +6 -3
  2. {nullgate-1.2.5 → nullgate-1.2.6}/README.md +5 -2
  3. nullgate-1.2.6/src/nullgate/__init__.py +1 -0
  4. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/commands.py +11 -0
  5. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/session.py +85 -10
  6. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/transports.py +27 -0
  7. {nullgate-1.2.5 → nullgate-1.2.6/src/nullgate.egg-info}/PKG-INFO +6 -3
  8. {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_commands.py +178 -0
  9. nullgate-1.2.5/src/nullgate/__init__.py +0 -1
  10. {nullgate-1.2.5 → nullgate-1.2.6}/LICENSE +0 -0
  11. {nullgate-1.2.5 → nullgate-1.2.6}/pyproject.toml +0 -0
  12. {nullgate-1.2.5 → nullgate-1.2.6}/setup.cfg +0 -0
  13. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/account.py +0 -0
  14. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/bridge.py +0 -0
  15. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/client_config.py +0 -0
  16. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/gateway.py +0 -0
  17. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/ingress.py +0 -0
  18. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/runtime.py +0 -0
  19. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate/wsroute.py +0 -0
  20. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/SOURCES.txt +0 -0
  21. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/dependency_links.txt +0 -0
  22. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/entry_points.txt +0 -0
  23. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/requires.txt +0 -0
  24. {nullgate-1.2.5 → nullgate-1.2.6}/src/nullgate.egg-info/top_level.txt +0 -0
  25. {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_account.py +0 -0
  26. {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_bridge.py +0 -0
  27. {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_gateway.py +0 -0
  28. {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_ingress.py +0 -0
  29. {nullgate-1.2.5 → nullgate-1.2.6}/tests/test_install.py +0 -0
  30. {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.5
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 live:
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 live else 1
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 live else 1
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
- if st.is_running(gateway_pid_file):
407
- die("Gateway service is already running")
408
- if st.is_running(transport_pid_file):
409
- die("Transport service is already running")
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.5
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