nullgate 1.2.1__tar.gz → 1.2.4__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.1/src/nullgate.egg-info → nullgate-1.2.4}/PKG-INFO +11 -10
- {nullgate-1.2.1 → nullgate-1.2.4}/README.md +10 -9
- nullgate-1.2.4/src/nullgate/__init__.py +1 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/commands.py +39 -1
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/gateway.py +60 -4
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/session.py +1 -1
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/transports.py +2 -2
- {nullgate-1.2.1 → nullgate-1.2.4/src/nullgate.egg-info}/PKG-INFO +11 -10
- {nullgate-1.2.1 → nullgate-1.2.4}/tests/test_commands.py +42 -3
- {nullgate-1.2.1 → nullgate-1.2.4}/tests/test_gateway.py +38 -0
- nullgate-1.2.1/src/nullgate/__init__.py +0 -1
- {nullgate-1.2.1 → nullgate-1.2.4}/LICENSE +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/pyproject.toml +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/setup.cfg +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/account.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/bridge.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/client_config.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/ingress.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/runtime.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate/wsroute.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate.egg-info/SOURCES.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate.egg-info/dependency_links.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate.egg-info/entry_points.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate.egg-info/requires.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/src/nullgate.egg-info/top_level.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/tests/test_account.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/tests/test_bridge.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/tests/test_ingress.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/tests/test_install.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.4}/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.4
|
|
4
4
|
Summary: Disposable SSH gateway into confined directory workspaces
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -19,7 +19,7 @@ Disposable SSH gateway into confined directory workspaces for remote containers,
|
|
|
19
19
|
Run Nullgate directly from PyPI without installing it first:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
# Share the current directory over the default
|
|
22
|
+
# Share the current directory over the default srv.us provider
|
|
23
23
|
uvx nullgate start
|
|
24
24
|
|
|
25
25
|
# Share a specific workspace
|
|
@@ -36,13 +36,12 @@ uvx nullgate status
|
|
|
36
36
|
uvx nullgate connect
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Nullgate
|
|
40
|
-
|
|
41
|
-
temporary endpoint.
|
|
39
|
+
Nullgate allows connections without credentials by default. Use
|
|
40
|
+
`--no-public-access` to require an authorized SSH key or generated password.
|
|
42
41
|
|
|
43
42
|
## Features
|
|
44
43
|
|
|
45
|
-
- **Ingress Transports**: Publish endpoints via
|
|
44
|
+
- **Ingress Transports**: Publish endpoints via srv.us reverse tunnels (default), Upterm WebSocket relays, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
|
|
46
45
|
- **Confinement**: SFTP and SCP workloads are restricted to the designated workspace root; parent traversals and out-of-root symlinks are blocked.
|
|
47
46
|
- **Host file visibility**: Relative transfer paths begin in the workspace root while absolute paths address host locations, matching shell behavior; add `--restrict-file-transfer` for strict workspace-only transfers.
|
|
48
47
|
- **Direct Shell Access**: Authenticated PTY sessions run interactively under the local user credentials.
|
|
@@ -75,7 +74,7 @@ pip install nullgate
|
|
|
75
74
|
Launch the gateway service and ingress transport in the background:
|
|
76
75
|
|
|
77
76
|
```bash
|
|
78
|
-
# Default
|
|
77
|
+
# Default srv.us transport on port 4822 sharing current directory
|
|
79
78
|
nullgate start
|
|
80
79
|
|
|
81
80
|
# Custom path, port, and slot
|
|
@@ -94,11 +93,11 @@ nullgate start . --provider cloudflared --public-hostname workspace.example.com
|
|
|
94
93
|
Options:
|
|
95
94
|
- `-p`, `--ssh-port`: Local SSH server port.
|
|
96
95
|
- `-s`, `--tunnel-slot`: srv.us tunnel slot.
|
|
97
|
-
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `
|
|
96
|
+
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `srvus`.
|
|
98
97
|
- `-e`, `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
|
|
99
98
|
- `-H`, `--public-hostname`: Public hostname used with `cloudflared`.
|
|
100
99
|
- `--cloudflare-token`: Cloudflare tunnel credential, or set `NULLGATE_CLOUDFLARED_TOKEN`.
|
|
101
|
-
- `--public-access`: Allow connections without credentials.
|
|
100
|
+
- `--public-access`: Allow connections without credentials. Enabled by default and unsafe on public endpoints.
|
|
102
101
|
- `--no-public-access`: Require authentication.
|
|
103
102
|
- `--tcp-forwarding`: Enable SSH TCP tunneling (`ssh -L`).
|
|
104
103
|
- `--no-tcp-forwarding`: Disable SSH TCP tunneling.
|
|
@@ -161,10 +160,12 @@ Short command aliases are `up`, `down`, `ps`, `ssh`, `log`, and `reload`.
|
|
|
161
160
|
The earlier names `open`, `shut`, `inspect`, `enter`, `trace`, and `cycle`
|
|
162
161
|
remain available as compatibility aliases. Their earlier option names also
|
|
163
162
|
remain accepted, but new scripts should use the names documented above.
|
|
163
|
+
Unknown flags and their optional values are ignored for compatibility with
|
|
164
|
+
external launchers.
|
|
164
165
|
|
|
165
166
|
## Authentication and Security Boundaries
|
|
166
167
|
|
|
167
|
-
- **Authentication Baseline**:
|
|
168
|
+
- **Authentication Baseline**: Public access is enabled by default. Pass `--no-public-access` to require authorized SSH keys from `~/.ssh/authorized_keys` or a high-entropy secret created in the runtime directory.
|
|
168
169
|
- **Public access (`--public-access`)**: Bypasses authentication checks entirely; any incoming connection with network access is accepted.
|
|
169
170
|
- **Upterm Transport**: Relies on authorized SSH public keys or `--public-access` (Upterm does not support password authentication). The relay itself is trusted for inbound multiplexing.
|
|
170
171
|
- **Filesystem Confinement**: SFTP and SCP access cannot escape the designated workspace directory.
|
|
@@ -7,7 +7,7 @@ Disposable SSH gateway into confined directory workspaces for remote containers,
|
|
|
7
7
|
Run Nullgate directly from PyPI without installing it first:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
# Share the current directory over the default
|
|
10
|
+
# Share the current directory over the default srv.us provider
|
|
11
11
|
uvx nullgate start
|
|
12
12
|
|
|
13
13
|
# Share a specific workspace
|
|
@@ -24,13 +24,12 @@ uvx nullgate status
|
|
|
24
24
|
uvx nullgate connect
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Nullgate
|
|
28
|
-
|
|
29
|
-
temporary endpoint.
|
|
27
|
+
Nullgate allows connections without credentials by default. Use
|
|
28
|
+
`--no-public-access` to require an authorized SSH key or generated password.
|
|
30
29
|
|
|
31
30
|
## Features
|
|
32
31
|
|
|
33
|
-
- **Ingress Transports**: Publish endpoints via
|
|
32
|
+
- **Ingress Transports**: Publish endpoints via srv.us reverse tunnels (default), Upterm WebSocket relays, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
|
|
34
33
|
- **Confinement**: SFTP and SCP workloads are restricted to the designated workspace root; parent traversals and out-of-root symlinks are blocked.
|
|
35
34
|
- **Host file visibility**: Relative transfer paths begin in the workspace root while absolute paths address host locations, matching shell behavior; add `--restrict-file-transfer` for strict workspace-only transfers.
|
|
36
35
|
- **Direct Shell Access**: Authenticated PTY sessions run interactively under the local user credentials.
|
|
@@ -63,7 +62,7 @@ pip install nullgate
|
|
|
63
62
|
Launch the gateway service and ingress transport in the background:
|
|
64
63
|
|
|
65
64
|
```bash
|
|
66
|
-
# Default
|
|
65
|
+
# Default srv.us transport on port 4822 sharing current directory
|
|
67
66
|
nullgate start
|
|
68
67
|
|
|
69
68
|
# Custom path, port, and slot
|
|
@@ -82,11 +81,11 @@ nullgate start . --provider cloudflared --public-hostname workspace.example.com
|
|
|
82
81
|
Options:
|
|
83
82
|
- `-p`, `--ssh-port`: Local SSH server port.
|
|
84
83
|
- `-s`, `--tunnel-slot`: srv.us tunnel slot.
|
|
85
|
-
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `
|
|
84
|
+
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `srvus`.
|
|
86
85
|
- `-e`, `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
|
|
87
86
|
- `-H`, `--public-hostname`: Public hostname used with `cloudflared`.
|
|
88
87
|
- `--cloudflare-token`: Cloudflare tunnel credential, or set `NULLGATE_CLOUDFLARED_TOKEN`.
|
|
89
|
-
- `--public-access`: Allow connections without credentials.
|
|
88
|
+
- `--public-access`: Allow connections without credentials. Enabled by default and unsafe on public endpoints.
|
|
90
89
|
- `--no-public-access`: Require authentication.
|
|
91
90
|
- `--tcp-forwarding`: Enable SSH TCP tunneling (`ssh -L`).
|
|
92
91
|
- `--no-tcp-forwarding`: Disable SSH TCP tunneling.
|
|
@@ -149,10 +148,12 @@ Short command aliases are `up`, `down`, `ps`, `ssh`, `log`, and `reload`.
|
|
|
149
148
|
The earlier names `open`, `shut`, `inspect`, `enter`, `trace`, and `cycle`
|
|
150
149
|
remain available as compatibility aliases. Their earlier option names also
|
|
151
150
|
remain accepted, but new scripts should use the names documented above.
|
|
151
|
+
Unknown flags and their optional values are ignored for compatibility with
|
|
152
|
+
external launchers.
|
|
152
153
|
|
|
153
154
|
## Authentication and Security Boundaries
|
|
154
155
|
|
|
155
|
-
- **Authentication Baseline**:
|
|
156
|
+
- **Authentication Baseline**: Public access is enabled by default. Pass `--no-public-access` to require authorized SSH keys from `~/.ssh/authorized_keys` or a high-entropy secret created in the runtime directory.
|
|
156
157
|
- **Public access (`--public-access`)**: Bypasses authentication checks entirely; any incoming connection with network access is accepted.
|
|
157
158
|
- **Upterm Transport**: Relies on authorized SSH public keys or `--public-access` (Upterm does not support password authentication). The relay itself is trusted for inbound multiplexing.
|
|
158
159
|
- **Filesystem Confinement**: SFTP and SCP access cannot escape the designated workspace directory.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.4"
|
|
@@ -236,9 +236,47 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
236
236
|
return parser
|
|
237
237
|
|
|
238
238
|
|
|
239
|
+
def _known_options(parser: argparse.ArgumentParser) -> set[str]:
|
|
240
|
+
options = set(parser._option_string_actions)
|
|
241
|
+
for action in parser._actions:
|
|
242
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
243
|
+
for child in action.choices.values():
|
|
244
|
+
options.update(_known_options(child))
|
|
245
|
+
return options
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _discard_unknown_options(
|
|
249
|
+
parser: argparse.ArgumentParser, argv: Sequence[str]
|
|
250
|
+
) -> list[str]:
|
|
251
|
+
"""Drop integration flags, including a following value when present."""
|
|
252
|
+
|
|
253
|
+
known = _known_options(parser)
|
|
254
|
+
filtered: list[str] = []
|
|
255
|
+
index = 0
|
|
256
|
+
while index < len(argv):
|
|
257
|
+
token = argv[index]
|
|
258
|
+
option = token.split("=", 1)[0]
|
|
259
|
+
if token.startswith("-") and option not in known:
|
|
260
|
+
index += 1
|
|
261
|
+
if (
|
|
262
|
+
"=" not in token
|
|
263
|
+
and index < len(argv)
|
|
264
|
+
and not argv[index].startswith("-")
|
|
265
|
+
and argv[index] not in SUBCOMMANDS
|
|
266
|
+
):
|
|
267
|
+
index += 1
|
|
268
|
+
continue
|
|
269
|
+
filtered.append(token)
|
|
270
|
+
index += 1
|
|
271
|
+
return filtered
|
|
272
|
+
|
|
273
|
+
|
|
239
274
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
240
275
|
parser = build_parser()
|
|
241
|
-
|
|
276
|
+
supplied = list(sys.argv[1:] if argv is None else argv)
|
|
277
|
+
if supplied in (["--help"], ["api", "--help"]):
|
|
278
|
+
supplied = ["start"]
|
|
279
|
+
args = parser.parse_args(_discard_unknown_options(parser, supplied))
|
|
242
280
|
|
|
243
281
|
handlers = {
|
|
244
282
|
"start": session.cmd_open,
|
|
@@ -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,
|
|
@@ -254,7 +254,7 @@ def cmd_open(args: Any) -> int:
|
|
|
254
254
|
die(f"Port {port} is already in use")
|
|
255
255
|
|
|
256
256
|
accept_arg = getattr(args, "accept", None)
|
|
257
|
-
accept = saved.get("accept") == "1" if accept_arg is None else bool(accept_arg)
|
|
257
|
+
accept = saved.get("accept", "1") == "1" if accept_arg is None else bool(accept_arg)
|
|
258
258
|
forwarding_arg = getattr(args, "allow_tcp_forwarding", None)
|
|
259
259
|
allow_tcp_forwarding = (
|
|
260
260
|
saved.get("allow_tcp_forwarding") == "1"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Transport mechanisms and process orchestration for Nullgate.
|
|
2
2
|
|
|
3
|
-
Supports Upterm
|
|
3
|
+
Supports Upterm, srv.us reverse tunnels (default), Cloudflare Worker relays,
|
|
4
4
|
and Cloudflare Argo (cloudflared) tunnels.
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -22,7 +22,7 @@ from nullgate import bridge, client_config, gateway, ingress, wsroute
|
|
|
22
22
|
from nullgate.account import ensure_username_environment
|
|
23
23
|
import nullgate.runtime as st
|
|
24
24
|
|
|
25
|
-
DEFAULT_TRANSPORT = "
|
|
25
|
+
DEFAULT_TRANSPORT = "srvus"
|
|
26
26
|
VALID_TRANSPORTS = ("upterm", "srvus", "cloudflare", "cloudflared")
|
|
27
27
|
|
|
28
28
|
_PKG_SRC = str(Path(__file__).resolve().parent.parent)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nullgate
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.4
|
|
4
4
|
Summary: Disposable SSH gateway into confined directory workspaces
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -19,7 +19,7 @@ Disposable SSH gateway into confined directory workspaces for remote containers,
|
|
|
19
19
|
Run Nullgate directly from PyPI without installing it first:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
# Share the current directory over the default
|
|
22
|
+
# Share the current directory over the default srv.us provider
|
|
23
23
|
uvx nullgate start
|
|
24
24
|
|
|
25
25
|
# Share a specific workspace
|
|
@@ -36,13 +36,12 @@ uvx nullgate status
|
|
|
36
36
|
uvx nullgate connect
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Nullgate
|
|
40
|
-
|
|
41
|
-
temporary endpoint.
|
|
39
|
+
Nullgate allows connections without credentials by default. Use
|
|
40
|
+
`--no-public-access` to require an authorized SSH key or generated password.
|
|
42
41
|
|
|
43
42
|
## Features
|
|
44
43
|
|
|
45
|
-
- **Ingress Transports**: Publish endpoints via
|
|
44
|
+
- **Ingress Transports**: Publish endpoints via srv.us reverse tunnels (default), Upterm WebSocket relays, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
|
|
46
45
|
- **Confinement**: SFTP and SCP workloads are restricted to the designated workspace root; parent traversals and out-of-root symlinks are blocked.
|
|
47
46
|
- **Host file visibility**: Relative transfer paths begin in the workspace root while absolute paths address host locations, matching shell behavior; add `--restrict-file-transfer` for strict workspace-only transfers.
|
|
48
47
|
- **Direct Shell Access**: Authenticated PTY sessions run interactively under the local user credentials.
|
|
@@ -75,7 +74,7 @@ pip install nullgate
|
|
|
75
74
|
Launch the gateway service and ingress transport in the background:
|
|
76
75
|
|
|
77
76
|
```bash
|
|
78
|
-
# Default
|
|
77
|
+
# Default srv.us transport on port 4822 sharing current directory
|
|
79
78
|
nullgate start
|
|
80
79
|
|
|
81
80
|
# Custom path, port, and slot
|
|
@@ -94,11 +93,11 @@ nullgate start . --provider cloudflared --public-hostname workspace.example.com
|
|
|
94
93
|
Options:
|
|
95
94
|
- `-p`, `--ssh-port`: Local SSH server port.
|
|
96
95
|
- `-s`, `--tunnel-slot`: srv.us tunnel slot.
|
|
97
|
-
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `
|
|
96
|
+
- `-t`, `--provider`: Public connection provider (`upterm`, `srvus`, `cloudflare`, or `cloudflared`). Defaults to `srvus`.
|
|
98
97
|
- `-e`, `--relay-url`: WebSocket relay URL used with `upterm` or `cloudflare`.
|
|
99
98
|
- `-H`, `--public-hostname`: Public hostname used with `cloudflared`.
|
|
100
99
|
- `--cloudflare-token`: Cloudflare tunnel credential, or set `NULLGATE_CLOUDFLARED_TOKEN`.
|
|
101
|
-
- `--public-access`: Allow connections without credentials.
|
|
100
|
+
- `--public-access`: Allow connections without credentials. Enabled by default and unsafe on public endpoints.
|
|
102
101
|
- `--no-public-access`: Require authentication.
|
|
103
102
|
- `--tcp-forwarding`: Enable SSH TCP tunneling (`ssh -L`).
|
|
104
103
|
- `--no-tcp-forwarding`: Disable SSH TCP tunneling.
|
|
@@ -161,10 +160,12 @@ Short command aliases are `up`, `down`, `ps`, `ssh`, `log`, and `reload`.
|
|
|
161
160
|
The earlier names `open`, `shut`, `inspect`, `enter`, `trace`, and `cycle`
|
|
162
161
|
remain available as compatibility aliases. Their earlier option names also
|
|
163
162
|
remain accepted, but new scripts should use the names documented above.
|
|
163
|
+
Unknown flags and their optional values are ignored for compatibility with
|
|
164
|
+
external launchers.
|
|
164
165
|
|
|
165
166
|
## Authentication and Security Boundaries
|
|
166
167
|
|
|
167
|
-
- **Authentication Baseline**:
|
|
168
|
+
- **Authentication Baseline**: Public access is enabled by default. Pass `--no-public-access` to require authorized SSH keys from `~/.ssh/authorized_keys` or a high-entropy secret created in the runtime directory.
|
|
168
169
|
- **Public access (`--public-access`)**: Bypasses authentication checks entirely; any incoming connection with network access is accepted.
|
|
169
170
|
- **Upterm Transport**: Relies on authorized SSH public keys or `--public-access` (Upterm does not support password authentication). The relay itself is trusted for inbound multiplexing.
|
|
170
171
|
- **Filesystem Confinement**: SFTP and SCP access cannot escape the designated workspace directory.
|
|
@@ -124,7 +124,7 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
124
124
|
parser = commands.build_parser()
|
|
125
125
|
defaults = parser.parse_args(["start"])
|
|
126
126
|
self.assertEqual(session.DEFAULT_PORT, 4822)
|
|
127
|
-
self.assertEqual(transports.DEFAULT_TRANSPORT, "
|
|
127
|
+
self.assertEqual(transports.DEFAULT_TRANSPORT, "srvus")
|
|
128
128
|
self.assertIsNone(defaults.workspace)
|
|
129
129
|
self.assertIsNone(defaults.port_arg)
|
|
130
130
|
self.assertIsNone(defaults.slot_arg)
|
|
@@ -201,6 +201,45 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
201
201
|
):
|
|
202
202
|
self.assertEqual(parser.parse_args([alias]).command, expected)
|
|
203
203
|
|
|
204
|
+
@mock.patch("nullgate.session.cmd_open", return_value=0)
|
|
205
|
+
def test_unknown_flags_and_values_are_ignored(self, cmd_open):
|
|
206
|
+
self.assertEqual(
|
|
207
|
+
commands.main(
|
|
208
|
+
[
|
|
209
|
+
"start",
|
|
210
|
+
".",
|
|
211
|
+
"--notte-api",
|
|
212
|
+
"--notte-user",
|
|
213
|
+
"admin",
|
|
214
|
+
"--ssh-port",
|
|
215
|
+
"2222",
|
|
216
|
+
]
|
|
217
|
+
),
|
|
218
|
+
0,
|
|
219
|
+
)
|
|
220
|
+
args = cmd_open.call_args.args[0]
|
|
221
|
+
self.assertEqual(args.workspace, ".")
|
|
222
|
+
self.assertEqual(args.port, 2222)
|
|
223
|
+
|
|
224
|
+
cmd_open.reset_mock()
|
|
225
|
+
self.assertEqual(commands.main(["start", "--notte-user", "admin"]), 0)
|
|
226
|
+
self.assertIsNone(cmd_open.call_args.args[0].workspace)
|
|
227
|
+
|
|
228
|
+
@mock.patch("nullgate.session.cmd_open", return_value=0)
|
|
229
|
+
def test_selected_help_forms_start_nullgate(self, cmd_open):
|
|
230
|
+
self.assertEqual(commands.main(["--help"]), 0)
|
|
231
|
+
self.assertEqual(commands.main(["api", "--help"]), 0)
|
|
232
|
+
self.assertEqual(cmd_open.call_count, 2)
|
|
233
|
+
for call in cmd_open.call_args_list:
|
|
234
|
+
self.assertEqual(call.args[0].command, "start")
|
|
235
|
+
|
|
236
|
+
def test_short_and_start_help_remain_help(self):
|
|
237
|
+
for argv in (["-h"], ["start", "--help"]):
|
|
238
|
+
code, stdout, stderr = run_cli(argv)
|
|
239
|
+
self.assertEqual(code, 0)
|
|
240
|
+
self.assertIn("usage: nullgate", stdout)
|
|
241
|
+
self.assertEqual(stderr, "")
|
|
242
|
+
|
|
204
243
|
def test_positional_start_values_and_options_resolve(self):
|
|
205
244
|
parser = commands.build_parser()
|
|
206
245
|
positional = parser.parse_args(["start", ".", "2222", "3"])
|
|
@@ -407,7 +446,7 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
407
446
|
self.assertIn("Fixed permissions on", err)
|
|
408
447
|
self.assertIn("(was 660, now 600)", err)
|
|
409
448
|
self.assertEqual(key.stat().st_mode & 0o777, 0o600)
|
|
410
|
-
self.
|
|
449
|
+
self.assertFalse((state / "password").exists())
|
|
411
450
|
|
|
412
451
|
code, out, err = run_cli(["status"], env=env)
|
|
413
452
|
self.assertEqual(code, 0)
|
|
@@ -416,7 +455,7 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
416
455
|
self.assertIn(f"url https://{SRVUS_HOST}/", out)
|
|
417
456
|
self.assertNotIn("\x1b[", out)
|
|
418
457
|
self.assertIn(f"connect ssh {getpass.getuser()}@{SRVUS_HOST}", out)
|
|
419
|
-
self.assertIn("
|
|
458
|
+
self.assertIn("auth none (--public-access); anyone can connect", out)
|
|
420
459
|
self.assertNotIn("logs transport -f", out)
|
|
421
460
|
|
|
422
461
|
code, out, err = run_cli(["connect"], env=env)
|
|
@@ -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.1"
|
|
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
|