nullgate 1.2.0__tar.gz → 1.2.2__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.0/src/nullgate.egg-info → nullgate-1.2.2}/PKG-INFO +39 -7
- {nullgate-1.2.0 → nullgate-1.2.2}/README.md +38 -6
- nullgate-1.2.2/src/nullgate/__init__.py +1 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/commands.py +41 -12
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/gateway.py +60 -4
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/session.py +11 -2
- {nullgate-1.2.0 → nullgate-1.2.2/src/nullgate.egg-info}/PKG-INFO +39 -7
- {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_commands.py +49 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_gateway.py +38 -0
- nullgate-1.2.0/src/nullgate/__init__.py +0 -1
- {nullgate-1.2.0 → nullgate-1.2.2}/LICENSE +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/pyproject.toml +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/setup.cfg +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/account.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/bridge.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/client_config.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/ingress.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/runtime.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/transports.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/wsroute.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/SOURCES.txt +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/dependency_links.txt +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/entry_points.txt +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/requires.txt +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/top_level.txt +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_account.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_bridge.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_ingress.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_install.py +0 -0
- {nullgate-1.2.0 → nullgate-1.2.2}/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.2
|
|
4
4
|
Summary: Disposable SSH gateway into confined directory workspaces
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -14,6 +14,32 @@ Dynamic: license-file
|
|
|
14
14
|
|
|
15
15
|
Disposable SSH gateway into confined directory workspaces for remote containers, ephemeral developer boxes, CI nodes, and cloud instances.
|
|
16
16
|
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
Run Nullgate directly from PyPI without installing it first:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Share the current directory over the default Upterm provider
|
|
23
|
+
uvx nullgate start
|
|
24
|
+
|
|
25
|
+
# Share a specific workspace
|
|
26
|
+
uvx nullgate start /workspace
|
|
27
|
+
|
|
28
|
+
# Compact form: workspace, SSH port, srv.us slot, provider
|
|
29
|
+
uvx nullgate start /workspace 8022 1 -t srvus
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then check the endpoint and print the SSH connection setup:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uvx nullgate status
|
|
36
|
+
uvx nullgate connect
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Nullgate uses authorized SSH keys by default. Upterm needs a key in
|
|
40
|
+
`~/.ssh/authorized_keys`; use `--public-access` only for an intentionally public
|
|
41
|
+
temporary endpoint.
|
|
42
|
+
|
|
17
43
|
## Features
|
|
18
44
|
|
|
19
45
|
- **Ingress Transports**: Publish endpoints via Upterm WebSocket relays (default), srv.us reverse tunnels, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
|
|
@@ -55,6 +81,9 @@ nullgate start
|
|
|
55
81
|
# Custom path, port, and slot
|
|
56
82
|
nullgate start /workspace --ssh-port 4822 --tunnel-slot 1
|
|
57
83
|
|
|
84
|
+
# The same values in compact positional form
|
|
85
|
+
nullgate start /workspace 4822 1
|
|
86
|
+
|
|
58
87
|
# Select specific transport provider
|
|
59
88
|
nullgate start . --provider upterm
|
|
60
89
|
nullgate start . --provider srvus
|
|
@@ -63,9 +92,11 @@ nullgate start . --provider cloudflared --public-hostname workspace.example.com
|
|
|
63
92
|
```
|
|
64
93
|
|
|
65
94
|
Options:
|
|
66
|
-
- `--
|
|
67
|
-
- `--
|
|
68
|
-
- `--
|
|
95
|
+
- `-p`, `--ssh-port`: Local SSH server port.
|
|
96
|
+
- `-s`, `--tunnel-slot`: srv.us tunnel slot.
|
|
97
|
+
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `upterm`.
|
|
98
|
+
- `-e`, `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
|
|
99
|
+
- `-H`, `--public-hostname`: Public hostname used with `cloudflared`.
|
|
69
100
|
- `--cloudflare-token`: Cloudflare tunnel credential, or set `NULLGATE_CLOUDFLARED_TOKEN`.
|
|
70
101
|
- `--public-access`: Allow connections without credentials. This is unsafe on public endpoints.
|
|
71
102
|
- `--no-public-access`: Require authentication.
|
|
@@ -126,9 +157,10 @@ nullgate upgrade
|
|
|
126
157
|
nullgate upgrade --version 1.0.1
|
|
127
158
|
```
|
|
128
159
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
160
|
+
Short command aliases are `up`, `down`, `ps`, `ssh`, `log`, and `reload`.
|
|
161
|
+
The earlier names `open`, `shut`, `inspect`, `enter`, `trace`, and `cycle`
|
|
162
|
+
remain available as compatibility aliases. Their earlier option names also
|
|
163
|
+
remain accepted, but new scripts should use the names documented above.
|
|
132
164
|
|
|
133
165
|
## Authentication and Security Boundaries
|
|
134
166
|
|
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Disposable SSH gateway into confined directory workspaces for remote containers, ephemeral developer boxes, CI nodes, and cloud instances.
|
|
4
4
|
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
Run Nullgate directly from PyPI without installing it first:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Share the current directory over the default Upterm provider
|
|
11
|
+
uvx nullgate start
|
|
12
|
+
|
|
13
|
+
# Share a specific workspace
|
|
14
|
+
uvx nullgate start /workspace
|
|
15
|
+
|
|
16
|
+
# Compact form: workspace, SSH port, srv.us slot, provider
|
|
17
|
+
uvx nullgate start /workspace 8022 1 -t srvus
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then check the endpoint and print the SSH connection setup:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uvx nullgate status
|
|
24
|
+
uvx nullgate connect
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Nullgate uses authorized SSH keys by default. Upterm needs a key in
|
|
28
|
+
`~/.ssh/authorized_keys`; use `--public-access` only for an intentionally public
|
|
29
|
+
temporary endpoint.
|
|
30
|
+
|
|
5
31
|
## Features
|
|
6
32
|
|
|
7
33
|
- **Ingress Transports**: Publish endpoints via Upterm WebSocket relays (default), srv.us reverse tunnels, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
|
|
@@ -43,6 +69,9 @@ nullgate start
|
|
|
43
69
|
# Custom path, port, and slot
|
|
44
70
|
nullgate start /workspace --ssh-port 4822 --tunnel-slot 1
|
|
45
71
|
|
|
72
|
+
# The same values in compact positional form
|
|
73
|
+
nullgate start /workspace 4822 1
|
|
74
|
+
|
|
46
75
|
# Select specific transport provider
|
|
47
76
|
nullgate start . --provider upterm
|
|
48
77
|
nullgate start . --provider srvus
|
|
@@ -51,9 +80,11 @@ nullgate start . --provider cloudflared --public-hostname workspace.example.com
|
|
|
51
80
|
```
|
|
52
81
|
|
|
53
82
|
Options:
|
|
54
|
-
- `--
|
|
55
|
-
- `--
|
|
56
|
-
- `--
|
|
83
|
+
- `-p`, `--ssh-port`: Local SSH server port.
|
|
84
|
+
- `-s`, `--tunnel-slot`: srv.us tunnel slot.
|
|
85
|
+
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `upterm`.
|
|
86
|
+
- `-e`, `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
|
|
87
|
+
- `-H`, `--public-hostname`: Public hostname used with `cloudflared`.
|
|
57
88
|
- `--cloudflare-token`: Cloudflare tunnel credential, or set `NULLGATE_CLOUDFLARED_TOKEN`.
|
|
58
89
|
- `--public-access`: Allow connections without credentials. This is unsafe on public endpoints.
|
|
59
90
|
- `--no-public-access`: Require authentication.
|
|
@@ -114,9 +145,10 @@ nullgate upgrade
|
|
|
114
145
|
nullgate upgrade --version 1.0.1
|
|
115
146
|
```
|
|
116
147
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
148
|
+
Short command aliases are `up`, `down`, `ps`, `ssh`, `log`, and `reload`.
|
|
149
|
+
The earlier names `open`, `shut`, `inspect`, `enter`, `trace`, and `cycle`
|
|
150
|
+
remain available as compatibility aliases. Their earlier option names also
|
|
151
|
+
remain accepted, but new scripts should use the names documented above.
|
|
120
152
|
|
|
121
153
|
## Authentication and Security Boundaries
|
|
122
154
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.2"
|
|
@@ -10,11 +10,17 @@ from nullgate import session, transports
|
|
|
10
10
|
|
|
11
11
|
SUBCOMMANDS = (
|
|
12
12
|
"start",
|
|
13
|
+
"up",
|
|
13
14
|
"stop",
|
|
15
|
+
"down",
|
|
14
16
|
"status",
|
|
17
|
+
"ps",
|
|
15
18
|
"connect",
|
|
19
|
+
"ssh",
|
|
16
20
|
"logs",
|
|
21
|
+
"log",
|
|
17
22
|
"restart",
|
|
23
|
+
"reload",
|
|
18
24
|
"upgrade",
|
|
19
25
|
"open",
|
|
20
26
|
"shut",
|
|
@@ -40,9 +46,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
40
46
|
|
|
41
47
|
start_parser = sub.add_parser(
|
|
42
48
|
"start",
|
|
43
|
-
aliases=["open"],
|
|
49
|
+
aliases=["up", "open"],
|
|
44
50
|
description="Start SSH access to a workspace.",
|
|
45
|
-
help="Start SSH access to a workspace (
|
|
51
|
+
help="Start SSH access to a workspace (aliases: up, open).",
|
|
46
52
|
)
|
|
47
53
|
start_parser.add_argument(
|
|
48
54
|
"workspace",
|
|
@@ -50,6 +56,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
50
56
|
help="Directory to make available (default: current directory).",
|
|
51
57
|
)
|
|
52
58
|
start_parser.add_argument(
|
|
59
|
+
"port_arg",
|
|
60
|
+
nargs="?",
|
|
61
|
+
type=int,
|
|
62
|
+
help="Local SSH server port (shorthand positional).",
|
|
63
|
+
)
|
|
64
|
+
start_parser.add_argument(
|
|
65
|
+
"slot_arg",
|
|
66
|
+
nargs="?",
|
|
67
|
+
type=int,
|
|
68
|
+
help="srv.us tunnel slot (shorthand positional).",
|
|
69
|
+
)
|
|
70
|
+
start_parser.add_argument(
|
|
71
|
+
"-p",
|
|
53
72
|
"--ssh-port",
|
|
54
73
|
dest="port",
|
|
55
74
|
type=int,
|
|
@@ -58,6 +77,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
58
77
|
)
|
|
59
78
|
start_parser.add_argument("--port", dest="port", type=int, help=argparse.SUPPRESS)
|
|
60
79
|
start_parser.add_argument(
|
|
80
|
+
"-s",
|
|
61
81
|
"--tunnel-slot",
|
|
62
82
|
dest="slot",
|
|
63
83
|
type=int,
|
|
@@ -66,6 +86,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
66
86
|
)
|
|
67
87
|
start_parser.add_argument("--slot", dest="slot", type=int, help=argparse.SUPPRESS)
|
|
68
88
|
start_parser.add_argument(
|
|
89
|
+
"-t",
|
|
69
90
|
"--provider",
|
|
70
91
|
dest="transport",
|
|
71
92
|
choices=transports.VALID_TRANSPORTS,
|
|
@@ -121,12 +142,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
121
142
|
help=argparse.SUPPRESS,
|
|
122
143
|
)
|
|
123
144
|
start_parser.add_argument(
|
|
145
|
+
"-e",
|
|
124
146
|
"--relay-url",
|
|
125
147
|
dest="endpoint",
|
|
126
148
|
help="WebSocket relay URL (for upterm or cloudflare providers).",
|
|
127
149
|
)
|
|
128
150
|
start_parser.add_argument("--endpoint", dest="endpoint", help=argparse.SUPPRESS)
|
|
129
151
|
start_parser.add_argument(
|
|
152
|
+
"-H",
|
|
130
153
|
"--public-hostname",
|
|
131
154
|
dest="hostname",
|
|
132
155
|
help="Public hostname (for the cloudflared provider).",
|
|
@@ -141,30 +164,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
141
164
|
|
|
142
165
|
sub.add_parser(
|
|
143
166
|
"stop",
|
|
144
|
-
aliases=["shut"],
|
|
167
|
+
aliases=["down", "shut"],
|
|
145
168
|
description="Stop SSH access and its connection provider.",
|
|
146
|
-
help="Stop SSH access (
|
|
169
|
+
help="Stop SSH access (aliases: down, shut).",
|
|
147
170
|
)
|
|
148
171
|
|
|
149
172
|
sub.add_parser(
|
|
150
173
|
"status",
|
|
151
|
-
aliases=["inspect"],
|
|
174
|
+
aliases=["ps", "inspect"],
|
|
152
175
|
description="Show service health, connection details, and security settings.",
|
|
153
|
-
help="Show current status (
|
|
176
|
+
help="Show current status (aliases: ps, inspect).",
|
|
154
177
|
)
|
|
155
178
|
|
|
156
179
|
sub.add_parser(
|
|
157
180
|
"connect",
|
|
158
|
-
aliases=["enter"],
|
|
181
|
+
aliases=["ssh", "enter"],
|
|
159
182
|
description="Show how to connect with SSH.",
|
|
160
|
-
help="Show SSH connection instructions (
|
|
183
|
+
help="Show SSH connection instructions (aliases: ssh, enter).",
|
|
161
184
|
)
|
|
162
185
|
|
|
163
186
|
logs_parser = sub.add_parser(
|
|
164
187
|
"logs",
|
|
165
|
-
aliases=["trace"],
|
|
188
|
+
aliases=["log", "trace"],
|
|
166
189
|
description="View or follow service logs.",
|
|
167
|
-
help="View or follow service logs (
|
|
190
|
+
help="View or follow service logs (aliases: log, trace).",
|
|
168
191
|
)
|
|
169
192
|
logs_parser.add_argument(
|
|
170
193
|
"target",
|
|
@@ -183,9 +206,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
183
206
|
|
|
184
207
|
sub.add_parser(
|
|
185
208
|
"restart",
|
|
186
|
-
aliases=["cycle"],
|
|
209
|
+
aliases=["reload", "cycle"],
|
|
187
210
|
description="Restart SSH access using the saved settings.",
|
|
188
|
-
help="Restart using saved settings (
|
|
211
|
+
help="Restart using saved settings (aliases: reload, cycle).",
|
|
189
212
|
)
|
|
190
213
|
|
|
191
214
|
upgrade_parser = sub.add_parser(
|
|
@@ -219,16 +242,22 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
219
242
|
|
|
220
243
|
handlers = {
|
|
221
244
|
"start": session.cmd_open,
|
|
245
|
+
"up": session.cmd_open,
|
|
222
246
|
"open": session.cmd_open,
|
|
223
247
|
"stop": session.cmd_shut,
|
|
248
|
+
"down": session.cmd_shut,
|
|
224
249
|
"shut": session.cmd_shut,
|
|
225
250
|
"status": session.cmd_inspect,
|
|
251
|
+
"ps": session.cmd_inspect,
|
|
226
252
|
"inspect": session.cmd_inspect,
|
|
227
253
|
"connect": session.cmd_enter,
|
|
254
|
+
"ssh": session.cmd_enter,
|
|
228
255
|
"enter": session.cmd_enter,
|
|
229
256
|
"logs": session.cmd_trace,
|
|
257
|
+
"log": session.cmd_trace,
|
|
230
258
|
"trace": session.cmd_trace,
|
|
231
259
|
"restart": session.cmd_cycle,
|
|
260
|
+
"reload": session.cmd_cycle,
|
|
232
261
|
"cycle": session.cmd_cycle,
|
|
233
262
|
"upgrade": session.cmd_upgrade,
|
|
234
263
|
"proxy": transports.cmd_proxy,
|
|
@@ -11,9 +11,11 @@ import asyncio
|
|
|
11
11
|
import contextlib
|
|
12
12
|
import fcntl
|
|
13
13
|
import hmac
|
|
14
|
+
import json
|
|
14
15
|
import os
|
|
15
16
|
import pty
|
|
16
17
|
import pwd
|
|
18
|
+
import secrets
|
|
17
19
|
import signal
|
|
18
20
|
import stat
|
|
19
21
|
import struct
|
|
@@ -21,6 +23,8 @@ import subprocess
|
|
|
21
23
|
import sys
|
|
22
24
|
import tempfile
|
|
23
25
|
import termios
|
|
26
|
+
import time
|
|
27
|
+
import traceback
|
|
24
28
|
from dataclasses import dataclass
|
|
25
29
|
from pathlib import Path
|
|
26
30
|
|
|
@@ -278,9 +282,14 @@ def available_locales() -> dict[str, str]:
|
|
|
278
282
|
"""Map normalized locale names to their canonical form from ``locale -a``."""
|
|
279
283
|
try:
|
|
280
284
|
completed = subprocess.run(
|
|
281
|
-
["locale", "-a"],
|
|
285
|
+
["locale", "-a"],
|
|
286
|
+
capture_output=True,
|
|
287
|
+
text=True,
|
|
288
|
+
encoding="utf-8",
|
|
289
|
+
errors="replace",
|
|
290
|
+
timeout=5,
|
|
282
291
|
)
|
|
283
|
-
except (OSError, subprocess.SubprocessError):
|
|
292
|
+
except (OSError, subprocess.SubprocessError, UnicodeError):
|
|
284
293
|
return {}
|
|
285
294
|
result: dict[str, str] = {}
|
|
286
295
|
for line in completed.stdout.splitlines():
|
|
@@ -296,7 +305,7 @@ def sanitize_locale_environment(environment: dict[str, str]) -> None:
|
|
|
296
305
|
Container images often bake in LANG/LC_* values (e.g. en_US.UTF-8) without
|
|
297
306
|
generating the matching locale, so every child shell emits "setlocale:
|
|
298
307
|
cannot change locale" warnings. Fall back to an available UTF-8 locale, or C
|
|
299
|
-
as a last resort
|
|
308
|
+
as a last resort whenever the requested one is missing.
|
|
300
309
|
"""
|
|
301
310
|
requested = {
|
|
302
311
|
var: environment[var] for var in _LOCALE_ENV_VARS if environment.get(var)
|
|
@@ -590,6 +599,53 @@ async def handle_process(process: asyncssh.SSHServerProcess, config: Config) ->
|
|
|
590
599
|
process.exit(returncode if returncode >= 0 else 128 - returncode)
|
|
591
600
|
|
|
592
601
|
|
|
602
|
+
async def handle_process_logged(
|
|
603
|
+
process: asyncssh.SSHServerProcess, config: Config
|
|
604
|
+
) -> None:
|
|
605
|
+
"""Record session lifecycle and make process-factory failures visible."""
|
|
606
|
+
session_id = "ngp-" + secrets.token_hex(4)
|
|
607
|
+
started = time.monotonic()
|
|
608
|
+
mode = "shell" if not process.command else "exec"
|
|
609
|
+
|
|
610
|
+
def log(event: str, **fields: object) -> None:
|
|
611
|
+
print(
|
|
612
|
+
json.dumps(
|
|
613
|
+
{
|
|
614
|
+
"event": event,
|
|
615
|
+
"session_id": session_id,
|
|
616
|
+
"mode": mode,
|
|
617
|
+
"pty": process.term_type is not None,
|
|
618
|
+
**fields,
|
|
619
|
+
},
|
|
620
|
+
sort_keys=True,
|
|
621
|
+
),
|
|
622
|
+
file=sys.stderr,
|
|
623
|
+
flush=True,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
log("session.start")
|
|
627
|
+
try:
|
|
628
|
+
await handle_process(process, config)
|
|
629
|
+
except Exception as error:
|
|
630
|
+
log(
|
|
631
|
+
"session.error",
|
|
632
|
+
duration_ms=round((time.monotonic() - started) * 1000),
|
|
633
|
+
error=type(error).__name__,
|
|
634
|
+
message=str(error),
|
|
635
|
+
)
|
|
636
|
+
traceback.print_exc(file=sys.stderr)
|
|
637
|
+
with contextlib.suppress(ConnectionError, BrokenPipeError):
|
|
638
|
+
process.stderr.write(b"nullgate: session failed; see gateway log\n")
|
|
639
|
+
await process.stderr.drain()
|
|
640
|
+
with contextlib.suppress(Exception):
|
|
641
|
+
process.exit(255)
|
|
642
|
+
return
|
|
643
|
+
log(
|
|
644
|
+
"session.ok",
|
|
645
|
+
duration_ms=round((time.monotonic() - started) * 1000),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
593
649
|
class HostSFTPServer(asyncssh.SFTPServer):
|
|
594
650
|
"""SFTP mapping that mirrors shell visibility for Nullgate workspaces.
|
|
595
651
|
|
|
@@ -732,7 +788,7 @@ async def create_acceptor(config: Config) -> asyncssh.SSHAcceptor:
|
|
|
732
788
|
config.host,
|
|
733
789
|
config.port,
|
|
734
790
|
server_host_keys=[str(config.host_key)],
|
|
735
|
-
process_factory=lambda process:
|
|
791
|
+
process_factory=lambda process: handle_process_logged(process, config),
|
|
736
792
|
sftp_factory=sftp_factory,
|
|
737
793
|
allow_scp=True,
|
|
738
794
|
encoding=None,
|
|
@@ -41,8 +41,17 @@ def _resolve_launch_settings(
|
|
|
41
41
|
) -> tuple[Path, int, int]:
|
|
42
42
|
saved = saved or {}
|
|
43
43
|
root_arg = getattr(args, "workspace", None) or saved.get("root") or "."
|
|
44
|
-
port_arg = getattr(args, "port", None)
|
|
45
|
-
|
|
44
|
+
port_arg = getattr(args, "port", None)
|
|
45
|
+
if port_arg is None:
|
|
46
|
+
port_arg = getattr(args, "port_arg", None)
|
|
47
|
+
if port_arg is None:
|
|
48
|
+
port_arg = saved.get("port") or DEFAULT_PORT
|
|
49
|
+
|
|
50
|
+
slot_arg = getattr(args, "slot", None)
|
|
51
|
+
if slot_arg is None:
|
|
52
|
+
slot_arg = getattr(args, "slot_arg", None)
|
|
53
|
+
if slot_arg is None:
|
|
54
|
+
slot_arg = saved.get("slot") or DEFAULT_SLOT
|
|
46
55
|
|
|
47
56
|
port_text = str(port_arg)
|
|
48
57
|
if not port_text.isdigit() or not 1 <= int(port_text) <= 65535:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nullgate
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Summary: Disposable SSH gateway into confined directory workspaces
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -14,6 +14,32 @@ Dynamic: license-file
|
|
|
14
14
|
|
|
15
15
|
Disposable SSH gateway into confined directory workspaces for remote containers, ephemeral developer boxes, CI nodes, and cloud instances.
|
|
16
16
|
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
Run Nullgate directly from PyPI without installing it first:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Share the current directory over the default Upterm provider
|
|
23
|
+
uvx nullgate start
|
|
24
|
+
|
|
25
|
+
# Share a specific workspace
|
|
26
|
+
uvx nullgate start /workspace
|
|
27
|
+
|
|
28
|
+
# Compact form: workspace, SSH port, srv.us slot, provider
|
|
29
|
+
uvx nullgate start /workspace 8022 1 -t srvus
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then check the endpoint and print the SSH connection setup:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uvx nullgate status
|
|
36
|
+
uvx nullgate connect
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Nullgate uses authorized SSH keys by default. Upterm needs a key in
|
|
40
|
+
`~/.ssh/authorized_keys`; use `--public-access` only for an intentionally public
|
|
41
|
+
temporary endpoint.
|
|
42
|
+
|
|
17
43
|
## Features
|
|
18
44
|
|
|
19
45
|
- **Ingress Transports**: Publish endpoints via Upterm WebSocket relays (default), srv.us reverse tunnels, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
|
|
@@ -55,6 +81,9 @@ nullgate start
|
|
|
55
81
|
# Custom path, port, and slot
|
|
56
82
|
nullgate start /workspace --ssh-port 4822 --tunnel-slot 1
|
|
57
83
|
|
|
84
|
+
# The same values in compact positional form
|
|
85
|
+
nullgate start /workspace 4822 1
|
|
86
|
+
|
|
58
87
|
# Select specific transport provider
|
|
59
88
|
nullgate start . --provider upterm
|
|
60
89
|
nullgate start . --provider srvus
|
|
@@ -63,9 +92,11 @@ nullgate start . --provider cloudflared --public-hostname workspace.example.com
|
|
|
63
92
|
```
|
|
64
93
|
|
|
65
94
|
Options:
|
|
66
|
-
- `--
|
|
67
|
-
- `--
|
|
68
|
-
- `--
|
|
95
|
+
- `-p`, `--ssh-port`: Local SSH server port.
|
|
96
|
+
- `-s`, `--tunnel-slot`: srv.us tunnel slot.
|
|
97
|
+
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `upterm`.
|
|
98
|
+
- `-e`, `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
|
|
99
|
+
- `-H`, `--public-hostname`: Public hostname used with `cloudflared`.
|
|
69
100
|
- `--cloudflare-token`: Cloudflare tunnel credential, or set `NULLGATE_CLOUDFLARED_TOKEN`.
|
|
70
101
|
- `--public-access`: Allow connections without credentials. This is unsafe on public endpoints.
|
|
71
102
|
- `--no-public-access`: Require authentication.
|
|
@@ -126,9 +157,10 @@ nullgate upgrade
|
|
|
126
157
|
nullgate upgrade --version 1.0.1
|
|
127
158
|
```
|
|
128
159
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
160
|
+
Short command aliases are `up`, `down`, `ps`, `ssh`, `log`, and `reload`.
|
|
161
|
+
The earlier names `open`, `shut`, `inspect`, `enter`, `trace`, and `cycle`
|
|
162
|
+
remain available as compatibility aliases. Their earlier option names also
|
|
163
|
+
remain accepted, but new scripts should use the names documented above.
|
|
132
164
|
|
|
133
165
|
## Authentication and Security Boundaries
|
|
134
166
|
|
|
@@ -126,6 +126,8 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
126
126
|
self.assertEqual(session.DEFAULT_PORT, 4822)
|
|
127
127
|
self.assertEqual(transports.DEFAULT_TRANSPORT, "upterm")
|
|
128
128
|
self.assertIsNone(defaults.workspace)
|
|
129
|
+
self.assertIsNone(defaults.port_arg)
|
|
130
|
+
self.assertIsNone(defaults.slot_arg)
|
|
129
131
|
self.assertIsNone(defaults.port)
|
|
130
132
|
self.assertIsNone(defaults.slot)
|
|
131
133
|
self.assertIsNone(defaults.accept)
|
|
@@ -158,6 +160,31 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
158
160
|
self.assertEqual(clearer.transport, "upterm")
|
|
159
161
|
self.assertEqual(clearer.endpoint, "wss://relay.example")
|
|
160
162
|
|
|
163
|
+
shorthand = parser.parse_args(
|
|
164
|
+
["up", "/workspace", "2222", "2", "-t", "srvus"]
|
|
165
|
+
)
|
|
166
|
+
self.assertEqual(shorthand.command, "up")
|
|
167
|
+
self.assertEqual(shorthand.workspace, "/workspace")
|
|
168
|
+
self.assertEqual(shorthand.port_arg, 2222)
|
|
169
|
+
self.assertEqual(shorthand.slot_arg, 2)
|
|
170
|
+
self.assertEqual(shorthand.transport, "srvus")
|
|
171
|
+
|
|
172
|
+
short_options = parser.parse_args(
|
|
173
|
+
[
|
|
174
|
+
"start",
|
|
175
|
+
"-p", "2223",
|
|
176
|
+
"-s", "3",
|
|
177
|
+
"-t", "cloudflare",
|
|
178
|
+
"-e", "wss://relay.example",
|
|
179
|
+
"-H", "workspace.example.com",
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
self.assertEqual(short_options.port, 2223)
|
|
183
|
+
self.assertEqual(short_options.slot, 3)
|
|
184
|
+
self.assertEqual(short_options.transport, "cloudflare")
|
|
185
|
+
self.assertEqual(short_options.endpoint, "wss://relay.example")
|
|
186
|
+
self.assertEqual(short_options.hostname, "workspace.example.com")
|
|
187
|
+
|
|
161
188
|
legacy = parser.parse_args(
|
|
162
189
|
["open", "--no-accept", "--no-allow-tcp-forwarding", "--confine-sftp"]
|
|
163
190
|
)
|
|
@@ -165,6 +192,28 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
165
192
|
self.assertFalse(legacy.allow_tcp_forwarding)
|
|
166
193
|
self.assertTrue(legacy.confine_sftp)
|
|
167
194
|
|
|
195
|
+
for alias, expected in (
|
|
196
|
+
("down", "down"),
|
|
197
|
+
("ps", "ps"),
|
|
198
|
+
("ssh", "ssh"),
|
|
199
|
+
("log", "log"),
|
|
200
|
+
("reload", "reload"),
|
|
201
|
+
):
|
|
202
|
+
self.assertEqual(parser.parse_args([alias]).command, expected)
|
|
203
|
+
|
|
204
|
+
def test_positional_start_values_and_options_resolve(self):
|
|
205
|
+
parser = commands.build_parser()
|
|
206
|
+
positional = parser.parse_args(["start", ".", "2222", "3"])
|
|
207
|
+
root, port, slot = session._resolve_launch_settings(positional)
|
|
208
|
+
self.assertEqual(root, Path.cwd())
|
|
209
|
+
self.assertEqual((port, slot), (2222, 3))
|
|
210
|
+
|
|
211
|
+
overridden = parser.parse_args(
|
|
212
|
+
["start", ".", "2222", "3", "-p", "4444", "-s", "5"]
|
|
213
|
+
)
|
|
214
|
+
_, port, slot = session._resolve_launch_settings(overridden)
|
|
215
|
+
self.assertEqual((port, slot), (4444, 5))
|
|
216
|
+
|
|
168
217
|
upgrade = parser.parse_args(["upgrade"])
|
|
169
218
|
self.assertIsNone(upgrade.version)
|
|
170
219
|
pinned = parser.parse_args(["upgrade", "--version", "1.2.3"])
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import contextlib
|
|
3
|
+
import io
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import tempfile
|
|
@@ -16,11 +17,13 @@ from nullgate.gateway import (
|
|
|
16
17
|
SERVER_KEEPALIVE_COUNT_MAX,
|
|
17
18
|
SERVER_KEEPALIVE_INTERVAL,
|
|
18
19
|
TokenSSHServer,
|
|
20
|
+
available_locales,
|
|
19
21
|
build_child_argv,
|
|
20
22
|
build_child_environment,
|
|
21
23
|
build_config,
|
|
22
24
|
create_acceptor,
|
|
23
25
|
ensure_host_key,
|
|
26
|
+
handle_process_logged,
|
|
24
27
|
parse_args,
|
|
25
28
|
sanitize_locale_environment,
|
|
26
29
|
)
|
|
@@ -218,6 +221,29 @@ class NullgateSSHTests(unittest.IsolatedAsyncioTestCase):
|
|
|
218
221
|
acceptor.close()
|
|
219
222
|
await acceptor.wait_closed()
|
|
220
223
|
|
|
224
|
+
async def test_process_factory_errors_are_logged_and_reported(self):
|
|
225
|
+
process = mock.Mock(command=b"printf ok", term_type=None)
|
|
226
|
+
process.stderr.drain = mock.AsyncMock()
|
|
227
|
+
log = io.StringIO()
|
|
228
|
+
with (
|
|
229
|
+
mock.patch(
|
|
230
|
+
"nullgate.gateway.handle_process", side_effect=OSError("spawn denied")
|
|
231
|
+
),
|
|
232
|
+
mock.patch("sys.stderr", log),
|
|
233
|
+
):
|
|
234
|
+
await handle_process_logged(process, self.config)
|
|
235
|
+
|
|
236
|
+
output = log.getvalue()
|
|
237
|
+
self.assertIn('"event": "session.start"', output)
|
|
238
|
+
self.assertIn('"event": "session.error"', output)
|
|
239
|
+
self.assertIn('"error": "OSError"', output)
|
|
240
|
+
self.assertIn("spawn denied", output)
|
|
241
|
+
process.stderr.write.assert_called_once_with(
|
|
242
|
+
b"nullgate: session failed; see gateway log\n"
|
|
243
|
+
)
|
|
244
|
+
process.stderr.drain.assert_awaited_once()
|
|
245
|
+
process.exit.assert_called_once_with(255)
|
|
246
|
+
|
|
221
247
|
async def test_tcp_forwarding_respects_flag(self):
|
|
222
248
|
async def echo_handler(reader, writer):
|
|
223
249
|
try:
|
|
@@ -721,6 +747,18 @@ class LocaleSanitizerTests(unittest.TestCase):
|
|
|
721
747
|
sanitize_locale_environment(environment)
|
|
722
748
|
return environment
|
|
723
749
|
|
|
750
|
+
def test_locale_listing_tolerates_invalid_utf8(self):
|
|
751
|
+
invalid_output = b"C\nC.utf8\nbroken-\xe5\n"
|
|
752
|
+
|
|
753
|
+
def fake_run(*args, encoding=None, errors=None, **kwargs):
|
|
754
|
+
return mock.Mock(stdout=invalid_output.decode(encoding, errors))
|
|
755
|
+
|
|
756
|
+
with mock.patch("nullgate.gateway.subprocess.run", side_effect=fake_run):
|
|
757
|
+
locales = available_locales()
|
|
758
|
+
|
|
759
|
+
self.assertEqual(locales["c"], "C")
|
|
760
|
+
self.assertEqual(locales["c.utf8"], "C.utf8")
|
|
761
|
+
|
|
724
762
|
def test_missing_locale_falls_back_to_available_utf8(self):
|
|
725
763
|
environment = {"LANG": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8", "PATH": "/bin"}
|
|
726
764
|
self.sanitize(environment, self.UTF8_HOST)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.2.0"
|
|
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
|