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.
- {nullgate-1.2.5/src/nullgate.egg-info → nullgate-1.2.7}/PKG-INFO +6 -3
- {nullgate-1.2.5 → nullgate-1.2.7}/README.md +5 -2
- nullgate-1.2.7/src/nullgate/__init__.py +1 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/commands.py +11 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/ingress.py +87 -32
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/session.py +395 -28
- nullgate-1.2.7/src/nullgate/supervisor.py +70 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/transports.py +54 -0
- {nullgate-1.2.5 → nullgate-1.2.7/src/nullgate.egg-info}/PKG-INFO +6 -3
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/SOURCES.txt +1 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_commands.py +617 -5
- nullgate-1.2.7/tests/test_ingress.py +279 -0
- nullgate-1.2.5/src/nullgate/__init__.py +0 -1
- nullgate-1.2.5/tests/test_ingress.py +0 -119
- {nullgate-1.2.5 → nullgate-1.2.7}/LICENSE +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/pyproject.toml +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/setup.cfg +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/account.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/bridge.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/client_config.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/gateway.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/runtime.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate/wsroute.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/dependency_links.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/entry_points.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/requires.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/src/nullgate.egg-info/top_level.txt +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_account.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_bridge.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_gateway.py +0 -0
- {nullgate-1.2.5 → nullgate-1.2.7}/tests/test_install.py +0 -0
- {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.
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
146
|
+
return True
|
|
160
147
|
print("nullgate-ingress: connection closed by the server", flush=True)
|
|
161
|
-
return
|
|
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")
|