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 +1 -0
- nullgate/account.py +23 -0
- nullgate/bridge.py +162 -0
- nullgate/client_config.py +116 -0
- nullgate/commands.py +172 -0
- nullgate/gateway.py +897 -0
- nullgate/ingress.py +206 -0
- nullgate/runtime.py +236 -0
- nullgate/session.py +509 -0
- nullgate/transports.py +406 -0
- nullgate/wsroute.py +483 -0
- nullgate-1.1.0.dist-info/METADATA +10 -0
- nullgate-1.1.0.dist-info/RECORD +17 -0
- nullgate-1.1.0.dist-info/WHEEL +5 -0
- nullgate-1.1.0.dist-info/entry_points.txt +2 -0
- nullgate-1.1.0.dist-info/licenses/LICENSE +21 -0
- nullgate-1.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|