nullgate 1.2.5__tar.gz → 1.2.7__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 (32) hide show
  1. {nullgate-1.2.5/src/nullgate.egg-info → nullgate-1.2.7}/PKG-INFO +6 -3
  2. {nullgate-1.2.5 → nullgate-1.2.7}/README.md +5 -2
  3. nullgate-1.2.7/src/nullgate/__init__.py +1 -0
  4. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/commands.py +11 -0
  5. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/ingress.py +87 -32
  6. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/session.py +395 -28
  7. nullgate-1.2.7/src/nullgate/supervisor.py +70 -0
  8. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/transports.py +54 -0
  9. {nullgate-1.2.5 → nullgate-1.2.7/src/nullgate.egg-info}/PKG-INFO +6 -3
  10. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/SOURCES.txt +1 -0
  11. {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_commands.py +617 -5
  12. nullgate-1.2.7/tests/test_ingress.py +279 -0
  13. nullgate-1.2.5/src/nullgate/__init__.py +0 -1
  14. nullgate-1.2.5/tests/test_ingress.py +0 -119
  15. {nullgate-1.2.5 → nullgate-1.2.7}/LICENSE +0 -0
  16. {nullgate-1.2.5 → nullgate-1.2.7}/pyproject.toml +0 -0
  17. {nullgate-1.2.5 → nullgate-1.2.7}/setup.cfg +0 -0
  18. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/account.py +0 -0
  19. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/bridge.py +0 -0
  20. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/client_config.py +0 -0
  21. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/gateway.py +0 -0
  22. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/runtime.py +0 -0
  23. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/wsroute.py +0 -0
  24. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/dependency_links.txt +0 -0
  25. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/entry_points.txt +0 -0
  26. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/requires.txt +0 -0
  27. {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/top_level.txt +0 -0
  28. {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_account.py +0 -0
  29. {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_bridge.py +0 -0
  30. {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_gateway.py +0 -0
  31. {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_install.py +0 -0
  32. {nullgate-1.2.5 → nullgate-1.2.7}/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.7
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.7"
@@ -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
  )
@@ -2,9 +2,11 @@
2
2
  """srv.us reverse tunnel for the Nullgate srvus transport.
3
3
 
4
4
  Holds one AsyncSSH connection to srv.us with a remote port forward pointing back
5
- at the local SSH server. AsyncSSH is already required by the server, so speaking
6
- SSH in-process here removes the OpenSSH client and ssh-keygen from the host's
7
- requirements; the tunnel then works on stripped images that ship neither.
5
+ at the local SSH server, reconnecting with backoff whenever the server drops it
6
+ (srv.us documents wrapping the tunnel in a reconnect loop). AsyncSSH is already
7
+ required by the server, so speaking SSH in-process here removes the OpenSSH
8
+ client and ssh-keygen from the host's requirements; the tunnel then works on
9
+ stripped images that ship neither.
8
10
 
9
11
  Launch with asyncssh available, e.g.:
10
12
  python -m nullgate.ingress --local-port 4822 --slot 1
@@ -30,6 +32,9 @@ from nullgate.account import current_username, ensure_username_environment
30
32
 
31
33
  IDENTITY_SEED_ENV = "NULLGATE_IDENTITY_SEED"
32
34
 
35
+ RECONNECT_MIN = 1.0
36
+ RECONNECT_MAX = 30.0
37
+
33
38
 
34
39
  class TunnelClient(asyncssh.SSHClient):
35
40
  """Surface srv.us protocol messages in the tunnel log.
@@ -96,37 +101,19 @@ async def pump(reader: asyncssh.SSHReader, writer) -> None:
96
101
  writer.flush()
97
102
 
98
103
 
99
- async def run(
100
- host: str,
101
- port: int,
104
+ async def serve_connection(
105
+ connection: asyncssh.SSHClientConnection,
102
106
  local_host: str,
103
107
  local_port: int,
104
108
  slot: int,
105
- key: Path,
106
- username: str,
107
- ) -> int:
108
- ensure_username_environment(username)
109
- ensure_key(key)
110
-
111
- stopped = asyncio.Event()
112
- loop = asyncio.get_running_loop()
113
- for signum in (signal.SIGINT, signal.SIGTERM):
114
- with contextlib.suppress(NotImplementedError):
115
- loop.add_signal_handler(signum, stopped.set)
116
-
117
- # known_hosts=None matches the connection commands this project prints: the
118
- # ingress is disposable and only carries bytes, so it is not authenticated.
119
- connection = await asyncssh.connect(
120
- host,
121
- port=port,
122
- username=username,
123
- client_keys=[str(key)],
124
- known_hosts=None,
125
- client_factory=TunnelClient,
126
- keepalive_interval=30,
127
- keepalive_count_max=3,
128
- )
109
+ stopped: asyncio.Event,
110
+ ) -> bool:
111
+ """Hold one tunnel connection until it closes or a stop is requested.
129
112
 
113
+ Opens the session channel and the remote port forward, then waits. Returns
114
+ True when ``stopped`` was set (clean shutdown), False when the server
115
+ dropped the connection and the caller should reconnect.
116
+ """
130
117
  process = None
131
118
  listener = None
132
119
  tasks: list[asyncio.Task] = []
@@ -156,9 +143,9 @@ async def run(
156
143
  tasks.extend((closed, requested))
157
144
  await asyncio.wait((closed, requested), return_when=asyncio.FIRST_COMPLETED)
158
145
  if requested.done():
159
- return 0
146
+ return True
160
147
  print("nullgate-ingress: connection closed by the server", flush=True)
161
- return 1
148
+ return False
162
149
  finally:
163
150
  if listener is not None:
164
151
  listener.close()
@@ -172,6 +159,74 @@ async def run(
172
159
  await asyncio.gather(*tasks, return_exceptions=True)
173
160
 
174
161
 
162
+ async def backoff(stopped: asyncio.Event, delay: float) -> None:
163
+ """Wait out a reconnect delay, returning early on a requested stop."""
164
+ with contextlib.suppress(asyncio.TimeoutError):
165
+ await asyncio.wait_for(stopped.wait(), timeout=delay)
166
+
167
+
168
+ async def run(
169
+ host: str,
170
+ port: int,
171
+ local_host: str,
172
+ local_port: int,
173
+ slot: int,
174
+ key: Path,
175
+ username: str,
176
+ ) -> int:
177
+ ensure_username_environment(username)
178
+ ensure_key(key)
179
+
180
+ stopped = asyncio.Event()
181
+ loop = asyncio.get_running_loop()
182
+ for signum in (signal.SIGINT, signal.SIGTERM):
183
+ with contextlib.suppress(NotImplementedError):
184
+ loop.add_signal_handler(signum, stopped.set)
185
+
186
+ # Retry forever: srv.us documents wrapping the tunnel in a reconnect loop,
187
+ # and a disposable gateway must survive transient server-side flaps.
188
+ delay = RECONNECT_MIN
189
+ attempt = 0
190
+ while not stopped.is_set():
191
+ try:
192
+ # known_hosts=None matches the connection commands this project
193
+ # prints: the ingress is disposable and only carries bytes, so it
194
+ # is not authenticated.
195
+ connection = await asyncssh.connect(
196
+ host,
197
+ port=port,
198
+ username=username,
199
+ client_keys=[str(key)],
200
+ known_hosts=None,
201
+ client_factory=TunnelClient,
202
+ keepalive_interval=30,
203
+ keepalive_count_max=3,
204
+ )
205
+ if attempt:
206
+ print("nullgate-ingress: reconnected", flush=True)
207
+ attempt = 0
208
+ delay = RECONNECT_MIN
209
+ clean = await serve_connection(
210
+ connection, local_host, local_port, slot, stopped
211
+ )
212
+ except (OSError, asyncssh.Error) as error:
213
+ print(f"nullgate-ingress: {error}", flush=True)
214
+ clean = False
215
+ if clean:
216
+ return 0
217
+ attempt += 1
218
+ if stopped.is_set():
219
+ break
220
+ delay = min(delay, RECONNECT_MAX)
221
+ print(
222
+ f"nullgate-ingress: reconnecting in {delay:g}s (attempt {attempt})",
223
+ flush=True,
224
+ )
225
+ await backoff(stopped, delay)
226
+ delay *= 2
227
+ return 0
228
+
229
+
175
230
  def main(argv: list[str] | None = None) -> int:
176
231
  parser = argparse.ArgumentParser(description=__doc__)
177
232
  parser.add_argument("--host", default="srv.us", help="tunnel host")