nullgate 1.1.0__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.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lars
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: nullgate
3
+ Version: 1.1.0
4
+ Summary: Temporary SSH, SFTP, and SCP access to ephemeral workspaces
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: asyncssh==2.24.0
8
+ Requires-Dist: cryptography>=39
9
+ Requires-Dist: websockets
10
+ Dynamic: license-file
@@ -0,0 +1,166 @@
1
+ # Nullgate
2
+
3
+ Disposable SSH gateway into confined directory workspaces for remote containers, ephemeral developer boxes, CI nodes, and cloud instances.
4
+
5
+ ## Features
6
+
7
+ - **Ingress Transports**: Publish endpoints via Upterm WebSocket relays (default), srv.us reverse tunnels, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
8
+ - **Confinement**: SFTP and SCP workloads are restricted to the designated workspace root; parent traversals and out-of-root symlinks are blocked.
9
+ - **Host file visibility**: Relative transfer paths begin in the workspace root while absolute paths address host locations, matching shell behavior; add `--confine-sftp` for strict workspace-only transfers.
10
+ - **Direct Shell Access**: Authenticated PTY sessions run interactively under the local user credentials.
11
+ - **Synthetic UID Resilience**: Runs seamlessly in container environments missing user records in `/etc/passwd`.
12
+ - **Persistent Key Identities**: Host keys and identity seeds persist across restarts rather than being silently regenerated.
13
+ - **Safe Process Tracking**: Process launch start times are verified alongside PIDs to prevent signaling recycled processes.
14
+
15
+ ## Installation
16
+
17
+ Install from the public distribution channel:
18
+
19
+ ```bash
20
+ curl -LsSf https://shadowdocks.github.io/nullgate-dist/install.sh | sh
21
+ ```
22
+
23
+ The installer downloads the published wheel, verifies its SHA-256 checksum,
24
+ and installs it in an isolated `uv` tool environment. The private source
25
+ repository is not required on the target machine.
26
+
27
+ Alternatively, install the published package from PyPI:
28
+
29
+ ```bash
30
+ pip install nullgate
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Open
36
+
37
+ Launch the gateway service and ingress transport in the background:
38
+
39
+ ```bash
40
+ # Default Upterm transport on port 4822 sharing current directory
41
+ nullgate open
42
+
43
+ # Custom path, port, and slot
44
+ nullgate open /workspace --port 4822 --slot 1
45
+
46
+ # Select specific transport provider
47
+ nullgate open . --transport upterm
48
+ nullgate open . --transport srvus
49
+ nullgate open . --transport cloudflare --endpoint https://relay.example.com
50
+ nullgate open . --transport cloudflared --hostname workspace.example.com --token "$TUNNEL_TOKEN"
51
+ ```
52
+
53
+ Options:
54
+ - `--transport`: Ingress provider (`upterm`, `srvus`, `cloudflare`, `cloudflared`). Defaults to `upterm`.
55
+ - `--endpoint`: WebSocket or relay endpoint URL (used with `upterm` or `cloudflare`).
56
+ - `--hostname`: Public ingress hostname for `cloudflared`.
57
+ - `--token`: Argo tunnel authentication credential (or set via `NULLGATE_CLOUDFLARED_TOKEN`).
58
+ - `--accept`: Skip credential checks and admit any client connection.
59
+ - `--no-accept`: Restore authentication when saved settings previously enabled unrestricted access.
60
+ - `--allow-tcp-forwarding`: Enable SSH TCP tunneling (`ssh -L`).
61
+ - `--no-allow-tcp-forwarding`: Disable forwarding when it was enabled in saved settings.
62
+ - `--confine-sftp`: Confine SFTP and SCP transfers to the workspace root.
63
+ - `--no-confine-sftp`: Restore host-visible transfers when confinement was saved.
64
+
65
+ ### Inspect
66
+
67
+ Display operational status, running PIDs, connection URLs, and security settings:
68
+
69
+ ```bash
70
+ nullgate inspect
71
+ ```
72
+
73
+ ### Enter
74
+
75
+ Print client connection strings and OpenSSH client configuration directives:
76
+
77
+ ```bash
78
+ nullgate enter
79
+ ```
80
+
81
+ ### Trace
82
+
83
+ Stream or print daemon logs:
84
+
85
+ ```bash
86
+ nullgate trace
87
+ nullgate trace -f
88
+ nullgate trace gateway
89
+ nullgate trace transport
90
+ ```
91
+
92
+ ### Shut
93
+
94
+ Terminate running gateway and transport background processes:
95
+
96
+ ```bash
97
+ nullgate shut
98
+ ```
99
+
100
+ ### Cycle
101
+
102
+ Restart daemon instances reusing active or saved workspace settings:
103
+
104
+ ```bash
105
+ nullgate cycle
106
+ ```
107
+
108
+ ### Upgrade
109
+
110
+ Fetch and install the newest release:
111
+
112
+ ```bash
113
+ nullgate upgrade
114
+ nullgate upgrade --version 1.0.1
115
+ ```
116
+
117
+ ## Authentication and Security Boundaries
118
+
119
+ - **Authentication Baseline**: Access requires authorized SSH keys in `~/.ssh/authorized_keys` or a high-entropy secret created in the runtime directory.
120
+ - **Unrestricted Mode (`--accept`)**: Bypasses authentication checks entirely; any incoming connection with network access is accepted.
121
+ - **Upterm Transport**: Relies on authorized SSH public keys or `--accept` (Upterm does not support password authentication). The relay itself is trusted for inbound multiplexing.
122
+ - **Filesystem Confinement**: SFTP and SCP access cannot escape the designated workspace directory.
123
+ - **Host Transfer Mode (default)**: Relative SFTP and SCP paths begin in the workspace directory, while absolute paths refer to host filesystem locations subject to OS user permissions. Pass `--confine-sftp` to limit transfers to the workspace namespace and reject symlink escapes.
124
+ - **Interactive Shell Privileges**: Command and terminal sessions execute with the permissions of the host account rather than inside an OS chroot.
125
+ - **State Storage**: Daemon PIDs and logs are maintained in `gateway.pid`, `transport.pid`, `gateway.log`, and `transport.log`, with session records kept in `manifest.json` and `settings.json`.
126
+
127
+ ## Environment Variables
128
+
129
+ | Variable | Description |
130
+ |---|---|
131
+ | `NULLGATE_PASSWORD` | Gateway-process credential; normally managed internally by `nullgate open` |
132
+ | `NULLGATE_STATE_DIR` | Directory holding runtime state and logs (defaults to `~/.local/state/nullgate`) |
133
+ | `NULLGATE_IDENTITY_SEED` | Seed value used for deterministic Ed25519 key derivation |
134
+ | `NULLGATE_TUNNEL_HOST` | Host address for srv.us ingress (default: `srv.us`) |
135
+ | `NULLGATE_TUNNEL_PORT` | Port for srv.us ingress (default: `22`) |
136
+ | `NULLGATE_CLOUDFLARED_TOKEN` | Credential token for `cloudflared` tunnels |
137
+ | `NULLGATE_CONFINE_SFTP` | Set to `1` to confine SFTP and SCP transfers to the workspace root |
138
+ | `NULLGATE_VERSION` | Install a specific published version instead of the stable release; accepts `1.0.1` or `v1.0.1` |
139
+ | `NULLGATE_DIST_URL` | Override the public release channel used by `install.sh` |
140
+ | `NULLGATE_INSTALL_URL` | Override the installer URL used by `nullgate upgrade` |
141
+
142
+ ## Development
143
+
144
+ Run internal modules directly:
145
+
146
+ ```bash
147
+ # Gateway daemon
148
+ python -m nullgate.gateway --root . --port 4822 --accept
149
+
150
+ # srv.us transport
151
+ python -m nullgate.ingress --host srv.us --port 22 --local-port 4822 --slot 1 --key ~/.ssh/id_ed25519
152
+
153
+ # Cloudflare WebSocket bridge
154
+ python -m nullgate.bridge origin wss://relay.example.com/relay/SESSION --port 4822
155
+ python -m nullgate.bridge client wss://relay.example.com/relay/SESSION
156
+
157
+ # Upterm WebSocket client route
158
+ python -m nullgate.wsroute client wss://uptermd.upterm.dev/...
159
+
160
+ # Run test suite
161
+ uv run python -m unittest discover -s tests
162
+ ```
163
+
164
+ ## License
165
+
166
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nullgate"
7
+ dynamic = ["version"]
8
+ description = "Temporary SSH, SFTP, and SCP access to ephemeral workspaces"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "asyncssh==2.24.0",
12
+ "cryptography>=39",
13
+ "websockets",
14
+ ]
15
+
16
+ [project.scripts]
17
+ nullgate = "nullgate.commands:main"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.setuptools.dynamic]
23
+ version = {attr = "nullgate.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
@@ -0,0 +1,23 @@
1
+ """Current-account helpers for hosts without a passwd database entry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+
8
+
9
+ def current_username(default: str = "nullgate") -> str:
10
+ """Return the current username, or a stable fallback for synthetic UIDs."""
11
+ try:
12
+ return getpass.getuser() or default
13
+ except (KeyError, OSError):
14
+ return default
15
+
16
+
17
+ def ensure_username_environment(username: str | None = None) -> str:
18
+ """Populate the account variables required by libraries such as AsyncSSH."""
19
+ resolved = username or current_username()
20
+ for name in ("USER", "LOGNAME"):
21
+ if not os.environ.get(name):
22
+ os.environ[name] = resolved
23
+ return resolved
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """Stdio/TCP <-> WebSocket bridge for the Nullgate cloudflare transport.
3
+
4
+ Two modes pump a raw byte stream across a WebSocket relay (a Cloudflare Worker):
5
+
6
+ origin Connects out to the relay and, on the first inbound byte, dials the
7
+ local SSH server, splicing the two. Loops to serve many sessions.
8
+ client Connects out to the relay and splices it to stdin/stdout, so it can be
9
+ used as an ssh ProxyCommand.
10
+
11
+ The relay pairs one "origin" and one "client" socket per tunnel id. The role
12
+ is the final path segment of ``/relay/<session>/<role>``.
13
+
14
+ Launched by the CLI as ``python -m nullgate.bridge`` for both the origin
15
+ (remote) and client (ProxyCommand) roles; it needs the websockets package.
16
+ """
17
+
18
+ import argparse
19
+ import asyncio
20
+ import os
21
+ import sys
22
+
23
+ import websockets
24
+
25
+ CHUNK = 65536
26
+ CONNECT_KWARGS = dict(max_size=None, ping_interval=20, ping_timeout=20, close_timeout=5)
27
+
28
+
29
+ def log(message):
30
+ sys.stderr.write("nullgate-bridge: " + message + "\n")
31
+ sys.stderr.flush()
32
+
33
+
34
+ def role_url(base, role):
35
+ return base.rstrip("/") + "/" + role
36
+
37
+
38
+ def _write_all(fd, data):
39
+ view = memoryview(data)
40
+ while view:
41
+ written = os.write(fd, view)
42
+ view = view[written:]
43
+
44
+
45
+ async def _await_first_completed(*coros):
46
+ tasks = [asyncio.ensure_future(coro) for coro in coros]
47
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
48
+ for task in pending:
49
+ task.cancel()
50
+ for task in pending:
51
+ try:
52
+ await task
53
+ except (asyncio.CancelledError, Exception): # noqa: BLE001 - draining only
54
+ pass
55
+ for task in done:
56
+ exc = task.exception()
57
+ if exc is not None:
58
+ raise exc
59
+
60
+
61
+ async def run_client(base):
62
+ url = role_url(base, "client")
63
+ async with websockets.connect(url, **CONNECT_KWARGS) as ws:
64
+ loop = asyncio.get_event_loop()
65
+ stdin_fd = sys.stdin.fileno()
66
+
67
+ async def stdin_to_ws():
68
+ while True:
69
+ data = await loop.run_in_executor(None, os.read, stdin_fd, CHUNK)
70
+ if not data:
71
+ break
72
+ await ws.send(data)
73
+
74
+ async def ws_to_stdout():
75
+ async for message in ws:
76
+ if isinstance(message, str):
77
+ message = message.encode()
78
+ _write_all(1, message)
79
+
80
+ await _await_first_completed(stdin_to_ws(), ws_to_stdout())
81
+
82
+
83
+ async def run_origin_once(base, host, port):
84
+ url = role_url(base, "origin")
85
+ async with websockets.connect(url, **CONNECT_KWARGS) as ws:
86
+ # Wait for the client's first byte before dialing the local server, so
87
+ # the server's SSH banner is never emitted into a peerless relay.
88
+ try:
89
+ first = await ws.recv()
90
+ except websockets.ConnectionClosed:
91
+ return
92
+ if isinstance(first, str):
93
+ first = first.encode()
94
+ reader, writer = await asyncio.open_connection(host, port)
95
+ writer.write(first)
96
+ await writer.drain()
97
+
98
+ async def tcp_to_ws():
99
+ while True:
100
+ data = await reader.read(CHUNK)
101
+ if not data:
102
+ break
103
+ await ws.send(data)
104
+
105
+ async def ws_to_tcp():
106
+ async for message in ws:
107
+ if isinstance(message, str):
108
+ message = message.encode()
109
+ writer.write(message)
110
+ await writer.drain()
111
+
112
+ try:
113
+ await _await_first_completed(tcp_to_ws(), ws_to_tcp())
114
+ finally:
115
+ writer.close()
116
+ try:
117
+ await writer.wait_closed()
118
+ except Exception: # noqa: BLE001 - best-effort teardown
119
+ pass
120
+
121
+
122
+ async def run_origin(base, host, port):
123
+ delay = 1
124
+ while True:
125
+ try:
126
+ await run_origin_once(base, host, port)
127
+ delay = 1
128
+ except asyncio.CancelledError:
129
+ raise
130
+ except Exception as exc: # noqa: BLE001 - keep the tunnel alive
131
+ log("origin session ended: " + repr(exc))
132
+ await asyncio.sleep(delay)
133
+ delay = min(delay * 2, 30)
134
+ continue
135
+ await asyncio.sleep(0.5)
136
+
137
+
138
+ def main(argv=None):
139
+ parser = argparse.ArgumentParser(description="Nullgate WebSocket bridge")
140
+ sub = parser.add_subparsers(dest="mode", required=True)
141
+
142
+ client = sub.add_parser("client", help="stdio <-> relay (ssh ProxyCommand)")
143
+ client.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
144
+
145
+ origin = sub.add_parser("origin", help="local SSH server <-> relay")
146
+ origin.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
147
+ origin.add_argument("--host", default="127.0.0.1")
148
+ origin.add_argument("--port", type=int, required=True)
149
+
150
+ args = parser.parse_args(argv)
151
+ try:
152
+ if args.mode == "client":
153
+ asyncio.run(run_client(args.url))
154
+ else:
155
+ asyncio.run(run_origin(args.url, args.host, args.port))
156
+ except KeyboardInterrupt: # pragma: no cover
157
+ return 130
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """The ssh-config block for Nullgate.
2
+
3
+ ``ssh-config`` prints a ``Host`` block so plain ``ssh USER@HOST`` works on the
4
+ connecting machine. With no HOST it prints the default ``Host *.srv.us`` block
5
+ for the srv.us transport; with a HOST it prints a block for that host using the
6
+ currently configured transport's ProxyCommand. ``--write`` appends it to
7
+ ~/.ssh/config (never prepends, which would drag any leading global keywords
8
+ under the new Host block), is idempotent, chmods the file to 0600, and then runs
9
+ ``ssh -G`` to confirm the block actually took effect.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ SRVUS_PROXY_COMMAND = (
21
+ "openssl s_client -quiet -no_ign_eof -verify_return_error "
22
+ "-verify_hostname %h -connect %h:443 -servername %h 2>/dev/null"
23
+ )
24
+ DEFAULT_HOST = "*.srv.us"
25
+
26
+
27
+ def block(host: str, proxy_command: str) -> str:
28
+ """Build the ssh-config block for a host and ProxyCommand."""
29
+ return (
30
+ f"Host {host}\n"
31
+ f" ProxyCommand {proxy_command}\n"
32
+ " StrictHostKeyChecking no\n"
33
+ " UserKnownHostsFile /dev/null\n"
34
+ " LogLevel ERROR"
35
+ )
36
+
37
+
38
+ def config_path() -> Path:
39
+ return Path(os.path.expanduser("~/.ssh/config"))
40
+
41
+
42
+ def print_block(
43
+ host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
44
+ ) -> None:
45
+ print(block(host, proxy_command))
46
+
47
+
48
+ def write(
49
+ host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
50
+ ) -> int:
51
+ config = config_path()
52
+ ssh_dir = config.parent
53
+ try:
54
+ ssh_dir.mkdir(parents=True, exist_ok=True)
55
+ ssh_dir.chmod(0o700)
56
+ except OSError:
57
+ _error(f"Unable to prepare {ssh_dir}")
58
+ return 1
59
+
60
+ try:
61
+ present = config.is_file() and any(
62
+ line.strip().startswith("Host " + host)
63
+ for line in config.read_text().splitlines()
64
+ )
65
+ except OSError:
66
+ present = False
67
+ if present:
68
+ print(f"Already present in {config}")
69
+ else:
70
+ # Appending never changes the meaning of existing lines, unlike
71
+ # prepending, which would pull any leading global keywords under this
72
+ # Host block.
73
+ try:
74
+ with open(config, "a") as handle:
75
+ if config.exists() and config.stat().st_size > 0:
76
+ handle.write("\n")
77
+ handle.write(block(host, proxy_command) + "\n")
78
+ config.chmod(0o600)
79
+ except OSError as error:
80
+ _error(f"Unable to write {config}: {error}")
81
+ return 1
82
+ print(f"Added the {host} block to {config}")
83
+
84
+ # ssh takes the first value it finds for each keyword, so an earlier
85
+ # matching block wins. Ask ssh what it will actually do rather than assuming.
86
+ ssh = shutil.which("ssh")
87
+ if ssh:
88
+ probe = host.replace("*", "example")
89
+ try:
90
+ resolved = subprocess.run(
91
+ [ssh, "-G", probe],
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=15,
95
+ ).stdout
96
+ except (OSError, subprocess.SubprocessError):
97
+ return 0
98
+ proxycommand = next(
99
+ (
100
+ line.split(" ", 1)[1].strip()
101
+ for line in resolved.splitlines()
102
+ if line.lower().startswith("proxycommand ")
103
+ ),
104
+ "",
105
+ )
106
+ marker = proxy_command.split()[0]
107
+ if marker not in proxycommand:
108
+ _error(
109
+ f"Warning: an earlier block in {config} overrides it; move the "
110
+ f'{host} block above any "Host *" block.'
111
+ )
112
+ return 0
113
+
114
+
115
+ def _error(message: str) -> None:
116
+ print(message, file=sys.stderr)