nullgate 1.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
nullgate/account.py ADDED
@@ -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
nullgate/bridge.py ADDED
@@ -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)
nullgate/commands.py ADDED
@@ -0,0 +1,172 @@
1
+ """Command-line parser and entry point dispatch for Nullgate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Sequence
8
+
9
+ from nullgate import session, transports
10
+
11
+ SUBCOMMANDS = ("open", "shut", "inspect", "enter", "trace", "cycle", "upgrade")
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="nullgate",
17
+ description="Disposable SSH gateway into confined directory workspaces.",
18
+ )
19
+ parser.add_argument(
20
+ "-V",
21
+ "--version",
22
+ action="version",
23
+ version=f"nullgate {session._version()}",
24
+ )
25
+ sub = parser.add_subparsers(dest="command", required=True, metavar="command")
26
+
27
+ open_parser = sub.add_parser(
28
+ "open",
29
+ description="Launch gateway daemon and ingress transport.",
30
+ )
31
+ open_parser.add_argument(
32
+ "workspace",
33
+ nargs="?",
34
+ help="Directory exposed by the gateway (default: current directory).",
35
+ )
36
+ open_parser.add_argument(
37
+ "--port",
38
+ type=int,
39
+ default=None,
40
+ help=f"Local SSH listen port (default: {session.DEFAULT_PORT}).",
41
+ )
42
+ open_parser.add_argument(
43
+ "--slot",
44
+ type=int,
45
+ default=None,
46
+ help=f"Ingress slot number (default: {session.DEFAULT_SLOT}).",
47
+ )
48
+ open_parser.add_argument(
49
+ "--transport",
50
+ choices=transports.VALID_TRANSPORTS,
51
+ default=None,
52
+ help=f"Ingress transport provider (default {transports.DEFAULT_TRANSPORT}).",
53
+ )
54
+ open_parser.add_argument(
55
+ "--accept",
56
+ action=argparse.BooleanOptionalAction,
57
+ default=None,
58
+ help="Permit any client without credentials; --no-accept restores authentication.",
59
+ )
60
+ open_parser.add_argument(
61
+ "--allow-tcp-forwarding",
62
+ action=argparse.BooleanOptionalAction,
63
+ default=None,
64
+ help="Allow SSH port forwarding; use --no-allow-tcp-forwarding to disable it.",
65
+ )
66
+ open_parser.add_argument(
67
+ "--confine-sftp",
68
+ action=argparse.BooleanOptionalAction,
69
+ default=None,
70
+ help="Confine SFTP and SCP to the workspace; --no-confine-sftp restores host visibility.",
71
+ )
72
+ open_parser.add_argument(
73
+ "--endpoint",
74
+ help="WebSocket ingress endpoint URL (for upterm or cloudflare transports).",
75
+ )
76
+ open_parser.add_argument(
77
+ "--hostname",
78
+ help="Public hostname (for cloudflared transport).",
79
+ )
80
+ open_parser.add_argument(
81
+ "--token",
82
+ help="Cloudflare Argo tunnel token (for cloudflared transport).",
83
+ )
84
+
85
+ sub.add_parser(
86
+ "shut",
87
+ description="Halt running gateway and transport services.",
88
+ )
89
+
90
+ sub.add_parser(
91
+ "inspect",
92
+ description="Display service health, access URLs, and authentication status.",
93
+ )
94
+
95
+ sub.add_parser(
96
+ "enter",
97
+ description="Show SSH connection strings and client setup commands.",
98
+ )
99
+
100
+ trace_parser = sub.add_parser(
101
+ "trace",
102
+ description="View or stream service operational logs.",
103
+ )
104
+ trace_parser.add_argument(
105
+ "target",
106
+ nargs="?",
107
+ default="all",
108
+ choices=["all", "gateway", "transport"],
109
+ help="Target log stream to inspect (default all).",
110
+ )
111
+ trace_parser.add_argument(
112
+ "-f",
113
+ "--follow",
114
+ dest="f",
115
+ action="store_true",
116
+ help="Follow log stream output continuously.",
117
+ )
118
+
119
+ sub.add_parser(
120
+ "cycle",
121
+ description="Restart services using active or saved configuration.",
122
+ )
123
+
124
+ upgrade_parser = sub.add_parser(
125
+ "upgrade",
126
+ description="Fetch and install latest Nullgate release.",
127
+ )
128
+ upgrade_parser.add_argument(
129
+ "--version",
130
+ help="Install a specific published version instead of the latest stable release.",
131
+ )
132
+
133
+ proxy_parser = sub.add_parser(
134
+ "proxy",
135
+ description=argparse.SUPPRESS,
136
+ )
137
+ proxy_parser.add_argument("url")
138
+
139
+ upterm_proxy = sub.add_parser(
140
+ "upterm-proxy",
141
+ description=argparse.SUPPRESS,
142
+ )
143
+ upterm_proxy.add_argument("url")
144
+
145
+ return parser
146
+
147
+
148
+ def main(argv: Sequence[str] | None = None) -> int:
149
+ parser = build_parser()
150
+ args = parser.parse_args(argv)
151
+
152
+ handlers = {
153
+ "open": session.cmd_open,
154
+ "shut": session.cmd_shut,
155
+ "inspect": session.cmd_inspect,
156
+ "enter": session.cmd_enter,
157
+ "trace": session.cmd_trace,
158
+ "cycle": session.cmd_cycle,
159
+ "upgrade": session.cmd_upgrade,
160
+ "proxy": transports.cmd_proxy,
161
+ "upterm-proxy": transports.cmd_upterm_proxy,
162
+ }
163
+
164
+ handler = handlers.get(args.command)
165
+ if handler is None:
166
+ parser.print_help(sys.stderr)
167
+ return 2
168
+ return handler(args)
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())