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/ingress.py ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """srv.us reverse tunnel for the Nullgate srvus transport.
3
+
4
+ Holds one AsyncSSH connection to srv.us with a remote port forward pointing back
5
+ at the local SSH server. AsyncSSH is already required by the server, so speaking
6
+ SSH in-process here removes the OpenSSH client and ssh-keygen from the host's
7
+ requirements; the tunnel then works on stripped images that ship neither.
8
+
9
+ Launch with asyncssh available, e.g.:
10
+ python -m nullgate.ingress --local-port 4822 --slot 1
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import contextlib
18
+ import hashlib
19
+ import os
20
+ import signal
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ import asyncssh
25
+ from cryptography.hazmat.primitives import serialization
26
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
27
+
28
+ from nullgate.account import current_username, ensure_username_environment
29
+
30
+
31
+ IDENTITY_SEED_ENV = "NULLGATE_IDENTITY_SEED"
32
+
33
+
34
+ class TunnelClient(asyncssh.SSHClient):
35
+ """Surface srv.us protocol messages in the tunnel log.
36
+
37
+ srv.us reports the assigned https URL over the session channel, and reports
38
+ errors as SSH debug messages or an auth banner. The launcher scrapes the log
39
+ for that URL, so all three have to land on stdout.
40
+ """
41
+
42
+ def debug_msg_received(self, msg: str, lang: str, always_display: bool) -> None:
43
+ del lang, always_display
44
+ print(msg, flush=True)
45
+
46
+ def auth_banner_received(self, msg: str, lang: str) -> None:
47
+ del lang
48
+ print(msg, flush=True)
49
+
50
+
51
+ def ensure_key(path: Path) -> None:
52
+ """Create the tunnel key when missing; reuse it for a stable hostname.
53
+
54
+ srv.us derives the hostname from this key, so it is written once and kept.
55
+ """
56
+ if path.exists():
57
+ if path.is_symlink() or not path.is_file():
58
+ raise ValueError(f"Tunnel key must be a regular file: {path}")
59
+ return
60
+
61
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
62
+ seed = os.getenv(IDENTITY_SEED_ENV, "")
63
+ if seed:
64
+ private = Ed25519PrivateKey.from_private_bytes(
65
+ hashlib.sha256(b"nullgate/srv.us/v1\0" + seed.encode()).digest()
66
+ )
67
+ encoded = private.private_bytes(
68
+ serialization.Encoding.PEM,
69
+ serialization.PrivateFormat.PKCS8,
70
+ serialization.NoEncryption(),
71
+ )
72
+ key = asyncssh.import_private_key(encoded)
73
+ else:
74
+ key = asyncssh.generate_private_key("ssh-ed25519")
75
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
76
+ try:
77
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
78
+ with os.fdopen(fd, "wb") as output:
79
+ output.write(key.export_private_key())
80
+ output.flush()
81
+ os.fsync(output.fileno())
82
+ os.replace(temporary, path)
83
+ finally:
84
+ with contextlib.suppress(FileNotFoundError):
85
+ temporary.unlink()
86
+ source = IDENTITY_SEED_ENV if seed else "random identity"
87
+ print(f"nullgate-ingress: created {path} from {source}", flush=True)
88
+
89
+
90
+ async def pump(reader: asyncssh.SSHReader, writer) -> None:
91
+ while True:
92
+ data = await reader.read(4096)
93
+ if not data:
94
+ return
95
+ writer.write(data)
96
+ writer.flush()
97
+
98
+
99
+ async def run(
100
+ host: str,
101
+ port: int,
102
+ local_host: str,
103
+ local_port: int,
104
+ slot: int,
105
+ key: Path,
106
+ username: str,
107
+ ) -> int:
108
+ ensure_username_environment(username)
109
+ ensure_key(key)
110
+
111
+ stopped = asyncio.Event()
112
+ loop = asyncio.get_running_loop()
113
+ for signum in (signal.SIGINT, signal.SIGTERM):
114
+ with contextlib.suppress(NotImplementedError):
115
+ loop.add_signal_handler(signum, stopped.set)
116
+
117
+ # known_hosts=None matches the connection commands this project prints: the
118
+ # ingress is disposable and only carries bytes, so it is not authenticated.
119
+ connection = await asyncssh.connect(
120
+ host,
121
+ port=port,
122
+ username=username,
123
+ client_keys=[str(key)],
124
+ known_hosts=None,
125
+ client_factory=TunnelClient,
126
+ keepalive_interval=30,
127
+ keepalive_count_max=3,
128
+ )
129
+
130
+ process = None
131
+ listener = None
132
+ tasks: list[asyncio.Task] = []
133
+ try:
134
+ # srv.us withholds its tcpip-forward reply until a session channel
135
+ # exists, so open the shell first and keep reading it.
136
+ process = await connection.create_process(request_pty=False)
137
+ tasks = [
138
+ asyncio.create_task(pump(process.stdout, sys.stdout)),
139
+ asyncio.create_task(pump(process.stderr, sys.stderr)),
140
+ ]
141
+ with contextlib.suppress(ConnectionError, BrokenPipeError, asyncssh.Error):
142
+ process.stdin.write_eof()
143
+ await process.stdin.drain()
144
+
145
+ listener = await connection.forward_remote_port(
146
+ "", slot, local_host, local_port
147
+ )
148
+ print(
149
+ f"remote forward ready on slot {listener.get_port()} "
150
+ f"-> {local_host}:{local_port}",
151
+ flush=True,
152
+ )
153
+
154
+ closed = asyncio.create_task(connection.wait_closed())
155
+ requested = asyncio.create_task(stopped.wait())
156
+ tasks.extend((closed, requested))
157
+ await asyncio.wait((closed, requested), return_when=asyncio.FIRST_COMPLETED)
158
+ if requested.done():
159
+ return 0
160
+ print("nullgate-ingress: connection closed by the server", flush=True)
161
+ return 1
162
+ finally:
163
+ if listener is not None:
164
+ listener.close()
165
+ if process is not None:
166
+ process.stdin.close()
167
+ connection.close()
168
+ await connection.wait_closed()
169
+ for task in tasks:
170
+ task.cancel()
171
+ if tasks:
172
+ await asyncio.gather(*tasks, return_exceptions=True)
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ parser = argparse.ArgumentParser(description=__doc__)
177
+ parser.add_argument("--host", default="srv.us", help="tunnel host")
178
+ parser.add_argument("--port", type=int, default=22, help="tunnel host port")
179
+ parser.add_argument("--local-host", default="127.0.0.1", help="forward destination")
180
+ parser.add_argument("--local-port", type=int, required=True, help="local SSH port")
181
+ parser.add_argument("--slot", type=int, required=True, help="srv.us slot")
182
+ parser.add_argument("--key", required=True, help="tunnel private key path")
183
+ parser.add_argument("--username", default=None, help="tunnel username")
184
+ args = parser.parse_args(argv)
185
+
186
+ try:
187
+ return asyncio.run(
188
+ run(
189
+ args.host,
190
+ args.port,
191
+ args.local_host,
192
+ args.local_port,
193
+ args.slot,
194
+ Path(args.key).expanduser(),
195
+ args.username or current_username(),
196
+ )
197
+ )
198
+ except KeyboardInterrupt: # pragma: no cover
199
+ return 130
200
+ except (OSError, ValueError, asyncssh.Error) as error:
201
+ print(f"nullgate-ingress: {error}", file=sys.stderr, flush=True)
202
+ return 1
203
+
204
+
205
+ if __name__ == "__main__":
206
+ raise SystemExit(main())
nullgate/runtime.py ADDED
@@ -0,0 +1,236 @@
1
+ """Runtime state for the Nullgate CLI.
2
+
3
+ Owns the state directory, PID files (with process-identity protection against
4
+ recycled PIDs), credentials, manifest, session IDs, and key permissions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import secrets
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ DEFAULT_PORT = 4822
23
+ SRVUS_HOST_RE = re.compile(r"https://([A-Za-z0-9.-]+\.srv\.us)/?")
24
+
25
+
26
+ def state_dir() -> Path:
27
+ default = os.environ.get(
28
+ "XDG_STATE_HOME",
29
+ os.path.join(os.path.expanduser("~"), ".local", "state"),
30
+ )
31
+ return Path(os.environ.get("NULLGATE_STATE_DIR", default)) / "nullgate"
32
+
33
+
34
+ def setup_state() -> Path:
35
+ """Create the state directory with owner-only access."""
36
+ directory = state_dir()
37
+ os.umask(0o077)
38
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
39
+ directory.chmod(0o700)
40
+ return directory
41
+
42
+
43
+ def process_identity(pid: int) -> str | None:
44
+ """Hash an origin for ``pid`` so a recycled pid is never mistaken for ours.
45
+
46
+ Uses /proc/PID/stat field 20 (starttime) when available, else the ps start
47
+ time. Returns None when the pid cannot be inspected.
48
+ """
49
+ proc_stat = Path("/proc") / str(pid) / "stat"
50
+ try:
51
+ if proc_stat.is_file():
52
+ data = proc_stat.read_text().rsplit(")", 1)[1].split()[19]
53
+ else:
54
+ data = subprocess.check_output(
55
+ ["ps", "-p", str(pid), "-o", "lstart="], text=True
56
+ ).strip()
57
+ except (OSError, IndexError, subprocess.SubprocessError):
58
+ return None
59
+ if not data:
60
+ return None
61
+ return hashlib.sha256(data.encode()).hexdigest()
62
+
63
+
64
+ def read_pid(pid_file: Path) -> int | None:
65
+ """Return the pid recorded in ``pid_file``, or None if malformed/absent."""
66
+ with contextlib.suppress(OSError):
67
+ fields = pid_file.read_text().split()
68
+ if len(fields) == 2 and fields[0].isdigit():
69
+ return int(fields[0])
70
+ return None
71
+
72
+
73
+ def _identity_of(pid_file: Path) -> str | None:
74
+ with contextlib.suppress(OSError):
75
+ fields = pid_file.read_text().split()
76
+ if len(fields) == 2:
77
+ return fields[1]
78
+ return None
79
+
80
+
81
+ def is_running(pid_file: Path) -> bool:
82
+ """True when the recorded pid is alive and still matches its identity."""
83
+ pid = read_pid(pid_file)
84
+ if pid is None:
85
+ return False
86
+ try:
87
+ os.kill(pid, 0)
88
+ except OSError:
89
+ return False
90
+ identity = process_identity(pid)
91
+ return identity is not None and identity == _identity_of(pid_file)
92
+
93
+
94
+ def write_pid(pid_file: Path, pid: int) -> bool:
95
+ """Record ``pid IDENTITY``; fails rather than track an unidentifiable pid."""
96
+ identity = process_identity(pid)
97
+ if identity is None:
98
+ return False
99
+ try:
100
+ pid_file.write_text(f"{pid} {identity}\n")
101
+ except OSError:
102
+ return False
103
+ return True
104
+
105
+
106
+ def kill_untracked(pid: int) -> None:
107
+ """Stop a background process we cannot track via a pid file."""
108
+ with contextlib.suppress(OSError):
109
+ os.kill(pid, 15)
110
+ for _ in range(50):
111
+ try:
112
+ os.kill(pid, 0)
113
+ except OSError:
114
+ break
115
+ time.sleep(0.1)
116
+ else:
117
+ with contextlib.suppress(OSError):
118
+ os.kill(pid, 9)
119
+
120
+
121
+ def stop_one(pid_file: Path) -> None:
122
+ """Stop the process recorded in ``pid_file``, guarding against recycled pids."""
123
+ pid = read_pid(pid_file)
124
+ if pid is None:
125
+ pid_file.unlink(missing_ok=True)
126
+ return
127
+ if is_running(pid_file):
128
+ with contextlib.suppress(OSError):
129
+ os.kill(pid, 15)
130
+ for _ in range(50):
131
+ if not is_running(pid_file):
132
+ break
133
+ time.sleep(0.1)
134
+ else:
135
+ with contextlib.suppress(OSError):
136
+ os.kill(pid, 9)
137
+ pid_file.unlink(missing_ok=True)
138
+
139
+
140
+ def port_open(port: int) -> bool:
141
+ with contextlib.suppress(OSError):
142
+ connection = socket.create_connection(("127.0.0.1", port), 0.2)
143
+ connection.close()
144
+ return True
145
+ return False
146
+
147
+
148
+ def extract_host(transport_log: Path) -> str:
149
+ """Return the last srv.us https host the transport log announced, if any."""
150
+ try:
151
+ text = transport_log.read_text(errors="replace")
152
+ except OSError:
153
+ return ""
154
+ hosts = SRVUS_HOST_RE.findall(text)
155
+ return hosts[-1] if hosts else ""
156
+
157
+
158
+ def generate_password() -> str:
159
+ return secrets.token_urlsafe(32)
160
+
161
+
162
+ def get_or_create_session() -> str:
163
+ """Reuse the persisted session id so the tunnel URL is stable across restarts."""
164
+ session_file = state_dir() / "session"
165
+ try:
166
+ persisted = session_file.read_text().strip()
167
+ if persisted:
168
+ return persisted
169
+ except OSError:
170
+ pass
171
+ session = secrets.token_urlsafe(24)
172
+ session_file.write_text(session + "\n")
173
+ return session
174
+
175
+
176
+ def manifest_get(key: str) -> str:
177
+ """Read a field from manifest.json; return '' when missing."""
178
+ manifest_file = state_dir() / "manifest.json"
179
+ try:
180
+ data = json.loads(manifest_file.read_text())
181
+ if isinstance(data, dict):
182
+ val = data.get(key, "")
183
+ return str(val) if val is not None else ""
184
+ except (OSError, json.JSONDecodeError):
185
+ pass
186
+ return ""
187
+
188
+
189
+ def write_manifest(entries: dict[str, Any]) -> None:
190
+ """Record active session state in manifest.json."""
191
+ directory = setup_state()
192
+ manifest_file = directory / "manifest.json"
193
+ manifest_file.write_text(json.dumps(entries, indent=2) + "\n")
194
+
195
+
196
+ def read_settings() -> dict[str, Any]:
197
+ """Return parameters persisted in settings.json by the previous open command."""
198
+ settings_file = state_dir() / "settings.json"
199
+ try:
200
+ data = json.loads(settings_file.read_text())
201
+ if isinstance(data, dict):
202
+ return data
203
+ except (OSError, json.JSONDecodeError):
204
+ pass
205
+ return {}
206
+
207
+
208
+ def write_settings(entries: dict[str, Any]) -> None:
209
+ """Persist parameters to settings.json with owner-only access permissions."""
210
+ directory = setup_state()
211
+ settings_file = directory / "settings.json"
212
+ cleaned = {k: v for k, v in entries.items() if v is not None and v != ""}
213
+ settings_file.write_text(json.dumps(cleaned, indent=2) + "\n")
214
+ with contextlib.suppress(OSError):
215
+ settings_file.chmod(0o600)
216
+
217
+
218
+ def repair_key_permissions(key: Path) -> None:
219
+ """chmod an srv.us tunnel key to 0600, mirroring OpenSSH's restriction."""
220
+ if not key.is_file():
221
+ return
222
+ mode = key.stat().st_mode & 0o777
223
+ if mode == 0o600:
224
+ return
225
+ try:
226
+ key.chmod(0o600)
227
+ except OSError:
228
+ print(
229
+ f"Warning: could not fix permissions on {key} (mode {mode:03o})",
230
+ file=sys.stderr,
231
+ )
232
+ return
233
+ print(
234
+ f"Fixed permissions on {key} (was {mode:03o}, now 600)",
235
+ file=sys.stderr,
236
+ )