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.
Files changed (30) hide show
  1. {nullgate-1.2.0/src/nullgate.egg-info → nullgate-1.2.2}/PKG-INFO +39 -7
  2. {nullgate-1.2.0 → nullgate-1.2.2}/README.md +38 -6
  3. nullgate-1.2.2/src/nullgate/__init__.py +1 -0
  4. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/commands.py +41 -12
  5. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/gateway.py +60 -4
  6. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/session.py +11 -2
  7. {nullgate-1.2.0 → nullgate-1.2.2/src/nullgate.egg-info}/PKG-INFO +39 -7
  8. {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_commands.py +49 -0
  9. {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_gateway.py +38 -0
  10. nullgate-1.2.0/src/nullgate/__init__.py +0 -1
  11. {nullgate-1.2.0 → nullgate-1.2.2}/LICENSE +0 -0
  12. {nullgate-1.2.0 → nullgate-1.2.2}/pyproject.toml +0 -0
  13. {nullgate-1.2.0 → nullgate-1.2.2}/setup.cfg +0 -0
  14. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/account.py +0 -0
  15. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/bridge.py +0 -0
  16. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/client_config.py +0 -0
  17. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/ingress.py +0 -0
  18. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/runtime.py +0 -0
  19. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/transports.py +0 -0
  20. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate/wsroute.py +0 -0
  21. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/SOURCES.txt +0 -0
  22. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/dependency_links.txt +0 -0
  23. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/entry_points.txt +0 -0
  24. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/requires.txt +0 -0
  25. {nullgate-1.2.0 → nullgate-1.2.2}/src/nullgate.egg-info/top_level.txt +0 -0
  26. {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_account.py +0 -0
  27. {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_bridge.py +0 -0
  28. {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_ingress.py +0 -0
  29. {nullgate-1.2.0 → nullgate-1.2.2}/tests/test_install.py +0 -0
  30. {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.0
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
- - `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `upterm`.
67
- - `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
68
- - `--public-hostname`: Public hostname used with `cloudflared`.
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
- The earlier command names `open`, `shut`, `inspect`, `enter`, `trace`, and
130
- `cycle` remain available as compatibility aliases. Their earlier option names
131
- also remain accepted, but new scripts should use the names documented above.
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
- - `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `upterm`.
55
- - `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
56
- - `--public-hostname`: Public hostname used with `cloudflared`.
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
- The earlier command names `open`, `shut`, `inspect`, `enter`, `trace`, and
118
- `cycle` remain available as compatibility aliases. Their earlier option names
119
- also remain accepted, but new scripts should use the names documented above.
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 (alias: open).",
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 (alias: shut).",
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 (alias: inspect).",
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 (alias: enter).",
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 (alias: trace).",
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 (alias: cycle).",
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"], capture_output=True, text=True, timeout=5
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, whenever the requested one is missing dustbin.
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: handle_process(process, config),
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) or saved.get("port") or DEFAULT_PORT
45
- slot_arg = getattr(args, "slot", None) or saved.get("slot") or DEFAULT_SLOT
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.0
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
- - `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `upterm`.
67
- - `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
68
- - `--public-hostname`: Public hostname used with `cloudflared`.
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
- The earlier command names `open`, `shut`, `inspect`, `enter`, `trace`, and
130
- `cycle` remain available as compatibility aliases. Their earlier option names
131
- also remain accepted, but new scripts should use the names documented above.
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