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/gateway.py
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""Disposable SSH/SFTP server using AsyncSSH.
|
|
2
|
+
|
|
3
|
+
Exports a single local directory over SSH/SFTP/SCP, backed by a persistent host
|
|
4
|
+
key and authenticated by an explicit password or authorized keys.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import fcntl
|
|
13
|
+
import hmac
|
|
14
|
+
import os
|
|
15
|
+
import pty
|
|
16
|
+
import pwd
|
|
17
|
+
import signal
|
|
18
|
+
import stat
|
|
19
|
+
import struct
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import termios
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import asyncssh
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
from nullgate import __version__ as VERSION
|
|
31
|
+
from nullgate.account import current_username, ensure_username_environment
|
|
32
|
+
DEFAULT_PASSWORD_ENV = "NULLGATE_PASSWORD"
|
|
33
|
+
DEFAULT_HOST_KEY = (
|
|
34
|
+
Path(tempfile.gettempdir()) / f"nullgate-{os.geteuid()}" / "host-key"
|
|
35
|
+
)
|
|
36
|
+
SERVER_KEEPALIVE_INTERVAL = 30
|
|
37
|
+
SERVER_KEEPALIVE_COUNT_MAX = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Config:
|
|
42
|
+
root: Path
|
|
43
|
+
host: str
|
|
44
|
+
port: int
|
|
45
|
+
username: str
|
|
46
|
+
password: str
|
|
47
|
+
password_env: str
|
|
48
|
+
authorized_keys: Path
|
|
49
|
+
host_key: Path
|
|
50
|
+
shell: str
|
|
51
|
+
accept: bool = False
|
|
52
|
+
password_auth: bool = True
|
|
53
|
+
allow_tcp_forwarding: bool = False
|
|
54
|
+
upterm_ca_keys: Path | None = None
|
|
55
|
+
confine_sftp: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TokenSSHServer(asyncssh.SSHServer):
|
|
59
|
+
"""Authenticate one disposable user with authorized keys or a password.
|
|
60
|
+
|
|
61
|
+
When ``accept`` is set, no authentication is requested at all and any
|
|
62
|
+
connecting client is admitted, mirroring upterm's wide-open share mode.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, config: Config):
|
|
66
|
+
self.config = config
|
|
67
|
+
self.connection: asyncssh.SSHServerConnection | None = None
|
|
68
|
+
|
|
69
|
+
def connection_made(self, connection: asyncssh.SSHServerConnection) -> None:
|
|
70
|
+
self.connection = connection
|
|
71
|
+
|
|
72
|
+
def begin_auth(self, username: str) -> bool:
|
|
73
|
+
if self.config.accept:
|
|
74
|
+
# Return False to signal that no authentication is required, so
|
|
75
|
+
# the handshake succeeds immediately regardless of credentials.
|
|
76
|
+
return False
|
|
77
|
+
if self.config.upterm_ca_keys is not None:
|
|
78
|
+
if self.connection:
|
|
79
|
+
self.connection.set_authorized_keys(None)
|
|
80
|
+
return True
|
|
81
|
+
valid_user = hmac.compare_digest(
|
|
82
|
+
username.encode("utf-8"), self.config.username.encode("utf-8")
|
|
83
|
+
)
|
|
84
|
+
authorized_keys = self.config.authorized_keys
|
|
85
|
+
if self.connection and valid_user and authorized_keys.is_file():
|
|
86
|
+
self.connection.set_authorized_keys(str(authorized_keys))
|
|
87
|
+
elif self.connection:
|
|
88
|
+
self.connection.set_authorized_keys(None)
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def public_key_auth_supported(self) -> bool:
|
|
92
|
+
return (
|
|
93
|
+
self.config.upterm_ca_keys is not None
|
|
94
|
+
or self.config.authorized_keys.is_file()
|
|
95
|
+
) and not self.config.accept
|
|
96
|
+
|
|
97
|
+
def password_auth_supported(self) -> bool:
|
|
98
|
+
return (
|
|
99
|
+
self.config.password_auth
|
|
100
|
+
and self.config.upterm_ca_keys is None
|
|
101
|
+
and not self.config.accept
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def validate_ca_key(self, username: str, key: asyncssh.SSHKey) -> bool:
|
|
105
|
+
"""Trust only the relay key recorded by the authenticated Upterm origin."""
|
|
106
|
+
del username
|
|
107
|
+
path = self.config.upterm_ca_keys
|
|
108
|
+
if path is None or not path.is_file():
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
expected = asyncssh.read_public_key(str(path)).export_public_key()
|
|
112
|
+
return expected == key.export_public_key()
|
|
113
|
+
except (OSError, ValueError, asyncssh.KeyImportError):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def validate_password(self, username: str, password: str) -> bool:
|
|
117
|
+
return hmac.compare_digest(
|
|
118
|
+
username.encode("utf-8"), self.config.username.encode("utf-8")
|
|
119
|
+
) and hmac.compare_digest(
|
|
120
|
+
password.encode("utf-8"), self.config.password.encode("utf-8")
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def connection_requested(
|
|
124
|
+
self,
|
|
125
|
+
dest_host: str,
|
|
126
|
+
dest_port: int,
|
|
127
|
+
orig_host: str,
|
|
128
|
+
orig_port: int,
|
|
129
|
+
) -> bool:
|
|
130
|
+
"""Allow SSH local TCP forwarding (ssh -L) when enabled.
|
|
131
|
+
|
|
132
|
+
Direct-TCP/IP channel opens reach destinations visible to the host.
|
|
133
|
+
Returning ``True`` makes AsyncSSH connect to ``dest_host:dest_port``
|
|
134
|
+
and splice the bytes, so a client can ``ssh -L`` through the session.
|
|
135
|
+
"""
|
|
136
|
+
del dest_host, dest_port, orig_host, orig_port
|
|
137
|
+
return self.config.allow_tcp_forwarding
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ensure_host_key(path: Path) -> None:
|
|
141
|
+
parent = path.parent
|
|
142
|
+
if parent.exists():
|
|
143
|
+
info = parent.lstat()
|
|
144
|
+
if not stat.S_ISDIR(info.st_mode) or parent.is_symlink():
|
|
145
|
+
raise ValueError(f"Host key parent must be a directory: {parent}")
|
|
146
|
+
if info.st_uid != os.geteuid():
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Host key parent must be owned by uid {os.geteuid()}: {parent}"
|
|
149
|
+
)
|
|
150
|
+
if stat.S_IMODE(info.st_mode) & 0o077:
|
|
151
|
+
# Owner-only access is the security property; tolerate setgid/sticky
|
|
152
|
+
# bits that some filesystems force onto directories (e.g. grpid mounts).
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Host key parent must not be group- or world-accessible: {parent}"
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
parent.mkdir(mode=0o700, parents=True)
|
|
158
|
+
|
|
159
|
+
if path.exists():
|
|
160
|
+
if not path.is_file() or path.is_symlink():
|
|
161
|
+
raise ValueError(f"Host key must be a regular file: {path}")
|
|
162
|
+
info = path.stat()
|
|
163
|
+
if info.st_uid != os.geteuid():
|
|
164
|
+
raise ValueError(f"Host key must be owned by uid {os.geteuid()}: {path}")
|
|
165
|
+
if stat.S_IMODE(info.st_mode) != 0o600:
|
|
166
|
+
raise ValueError(f"Host key must have mode 0600: {path}")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
170
|
+
key = asyncssh.generate_private_key("ssh-ed25519")
|
|
171
|
+
try:
|
|
172
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
173
|
+
with os.fdopen(fd, "wb") as output:
|
|
174
|
+
output.write(key.export_private_key())
|
|
175
|
+
output.flush()
|
|
176
|
+
os.fsync(output.fileno())
|
|
177
|
+
os.replace(temporary, path)
|
|
178
|
+
path.chmod(0o600)
|
|
179
|
+
finally:
|
|
180
|
+
with contextlib.suppress(FileNotFoundError):
|
|
181
|
+
temporary.unlink()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def terminate_process_group(process: asyncio.subprocess.Process) -> None:
|
|
185
|
+
if process.returncode is not None:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
with contextlib.suppress(ProcessLookupError):
|
|
189
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
await asyncio.wait_for(process.wait(), timeout=5)
|
|
193
|
+
except TimeoutError:
|
|
194
|
+
with contextlib.suppress(ProcessLookupError):
|
|
195
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
196
|
+
await process.wait()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def pump_ssh_input(process: asyncssh.SSHServerProcess, child: asyncio.subprocess.Process) -> None:
|
|
200
|
+
assert child.stdin is not None
|
|
201
|
+
try:
|
|
202
|
+
while data := await process.stdin.read(64 * 1024):
|
|
203
|
+
if isinstance(data, str):
|
|
204
|
+
data = data.encode()
|
|
205
|
+
child.stdin.write(data)
|
|
206
|
+
await child.stdin.drain()
|
|
207
|
+
except (asyncssh.BreakReceived, ConnectionError, BrokenPipeError):
|
|
208
|
+
pass
|
|
209
|
+
finally:
|
|
210
|
+
child.stdin.close()
|
|
211
|
+
with contextlib.suppress(BrokenPipeError, ConnectionError):
|
|
212
|
+
await child.stdin.wait_closed()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def pump_child_output(
|
|
216
|
+
source: asyncio.StreamReader, destination: asyncssh.SSHWriter
|
|
217
|
+
) -> None:
|
|
218
|
+
try:
|
|
219
|
+
while data := await source.read(64 * 1024):
|
|
220
|
+
destination.write(data)
|
|
221
|
+
await destination.drain()
|
|
222
|
+
except (ConnectionError, BrokenPipeError):
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_child_argv(command: str, config: Config) -> list[str]:
|
|
227
|
+
try:
|
|
228
|
+
pwd.getpwuid(os.geteuid())
|
|
229
|
+
synthetic_uid = False
|
|
230
|
+
except KeyError:
|
|
231
|
+
synthetic_uid = True
|
|
232
|
+
|
|
233
|
+
shell_name = os.path.basename(config.shell)
|
|
234
|
+
if command:
|
|
235
|
+
if synthetic_uid and shell_name == "bash":
|
|
236
|
+
return [config.shell, "--noprofile", "--norc", "-c", command]
|
|
237
|
+
if synthetic_uid and shell_name == "zsh":
|
|
238
|
+
return [config.shell, "-f", "-c", command]
|
|
239
|
+
return [config.shell, "-lc", command]
|
|
240
|
+
# Interactive session: start a login shell so the usual profile scripts run
|
|
241
|
+
# and construct PS1, PATH, etc., matching a normal OpenSSH login.
|
|
242
|
+
# A synthetic hosting UID has no passwd entry. Its system profiles commonly
|
|
243
|
+
# run `id -un` and construct a prompt through NSS, producing repeated errors
|
|
244
|
+
# and "I have no name!". Preserve the inherited environment and skip those
|
|
245
|
+
# profiles only for this case.
|
|
246
|
+
if synthetic_uid and shell_name == "bash":
|
|
247
|
+
return [config.shell, "--noprofile", "--norc", "-i"]
|
|
248
|
+
if synthetic_uid and shell_name == "zsh":
|
|
249
|
+
return [config.shell, "-f", "-i"]
|
|
250
|
+
if shell_name in ("bash", "zsh"):
|
|
251
|
+
return [config.shell, "-l", "-i"]
|
|
252
|
+
return [config.shell, "-i"]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_LOCALE_ENV_VARS = (
|
|
256
|
+
"LANG",
|
|
257
|
+
"LC_ALL",
|
|
258
|
+
"LC_CTYPE",
|
|
259
|
+
"LC_NUMERIC",
|
|
260
|
+
"LC_TIME",
|
|
261
|
+
"LC_COLLATE",
|
|
262
|
+
"LC_MONETARY",
|
|
263
|
+
"LC_MESSAGES",
|
|
264
|
+
"LC_PAPER",
|
|
265
|
+
"LC_NAME",
|
|
266
|
+
"LC_ADDRESS",
|
|
267
|
+
"LC_TELEPHONE",
|
|
268
|
+
"LC_MEASUREMENT",
|
|
269
|
+
"LC_IDENTIFICATION",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _normalize_locale(name: str) -> str:
|
|
274
|
+
return name.strip().casefold().replace("-", "")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def available_locales() -> dict[str, str]:
|
|
278
|
+
"""Map normalized locale names to their canonical form from ``locale -a``."""
|
|
279
|
+
try:
|
|
280
|
+
completed = subprocess.run(
|
|
281
|
+
["locale", "-a"], capture_output=True, text=True, timeout=5
|
|
282
|
+
)
|
|
283
|
+
except (OSError, subprocess.SubprocessError):
|
|
284
|
+
return {}
|
|
285
|
+
result: dict[str, str] = {}
|
|
286
|
+
for line in completed.stdout.splitlines():
|
|
287
|
+
name = line.strip()
|
|
288
|
+
if name:
|
|
289
|
+
result.setdefault(_normalize_locale(name), name)
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def sanitize_locale_environment(environment: dict[str, str]) -> None:
|
|
294
|
+
"""Replace locale settings the host cannot provide so shells stop warning.
|
|
295
|
+
|
|
296
|
+
Container images often bake in LANG/LC_* values (e.g. en_US.UTF-8) without
|
|
297
|
+
generating the matching locale, so every child shell emits "setlocale:
|
|
298
|
+
cannot change locale" warnings. Fall back to an available UTF-8 locale, or C
|
|
299
|
+
as a last resort, whenever the requested one is missing dustbin.
|
|
300
|
+
"""
|
|
301
|
+
requested = {
|
|
302
|
+
var: environment[var] for var in _LOCALE_ENV_VARS if environment.get(var)
|
|
303
|
+
}
|
|
304
|
+
if not requested:
|
|
305
|
+
return
|
|
306
|
+
available = available_locales()
|
|
307
|
+
if not available:
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
def is_usable(value: str) -> bool:
|
|
311
|
+
normalized = _normalize_locale(value)
|
|
312
|
+
return normalized in ("c", "posix") or normalized in available
|
|
313
|
+
|
|
314
|
+
if all(is_usable(value) for value in requested.values()):
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
replacement = None
|
|
318
|
+
for preferred in ("C.UTF-8", "C.utf8"):
|
|
319
|
+
replacement = available.get(_normalize_locale(preferred))
|
|
320
|
+
if replacement:
|
|
321
|
+
break
|
|
322
|
+
if replacement is None:
|
|
323
|
+
replacement = next(
|
|
324
|
+
(original for key, original in available.items() if key.endswith("utf8")),
|
|
325
|
+
"C",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
for var, value in requested.items():
|
|
329
|
+
if not is_usable(value):
|
|
330
|
+
environment[var] = replacement
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def build_child_environment(
|
|
334
|
+
process: asyncssh.SSHServerProcess, config: Config
|
|
335
|
+
) -> dict[str, str]:
|
|
336
|
+
environment = os.environ.copy()
|
|
337
|
+
environment.pop(config.password_env, None)
|
|
338
|
+
try:
|
|
339
|
+
account = pwd.getpwuid(os.geteuid())
|
|
340
|
+
username = account.pw_name
|
|
341
|
+
home = account.pw_dir
|
|
342
|
+
except KeyError:
|
|
343
|
+
username = current_username(config.username)
|
|
344
|
+
home = environment.get("HOME") or str(config.root)
|
|
345
|
+
shell_name = os.path.basename(config.shell)
|
|
346
|
+
if shell_name == "zsh":
|
|
347
|
+
environment["PS1"] = f"[{username}@%m %1~]%# "
|
|
348
|
+
elif shell_name == "bash":
|
|
349
|
+
environment["PS1"] = f"[{username}@\\h \\W]\\$ "
|
|
350
|
+
else:
|
|
351
|
+
environment["PS1"] = f"[{username}]$ "
|
|
352
|
+
environment["USER"] = username
|
|
353
|
+
environment["LOGNAME"] = username
|
|
354
|
+
environment["SHELL"] = config.shell
|
|
355
|
+
environment.setdefault("HOME", home)
|
|
356
|
+
environment["PWD"] = str(config.root)
|
|
357
|
+
if process.term_type is not None:
|
|
358
|
+
# A pty was requested; the term type may be empty when the client has no
|
|
359
|
+
# TERM set, so fall back to a widely supported value. PS1 and the rest of
|
|
360
|
+
# the prompt are left to the login shell's own startup files.
|
|
361
|
+
environment["TERM"] = process.term_type or "xterm-256color"
|
|
362
|
+
sanitize_locale_environment(environment)
|
|
363
|
+
return environment
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def set_terminal_size(fd: int, term_size: tuple[int, int, int, int]) -> None:
|
|
367
|
+
width, height, pixwidth, pixheight = term_size
|
|
368
|
+
if width and height:
|
|
369
|
+
winsize = struct.pack("HHHH", height, width, pixwidth, pixheight)
|
|
370
|
+
with contextlib.suppress(OSError):
|
|
371
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def acquire_controlling_tty() -> None:
|
|
375
|
+
"""Give the child its own session and controlling terminal for job control."""
|
|
376
|
+
os.setsid()
|
|
377
|
+
with contextlib.suppress(OSError):
|
|
378
|
+
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def forward_signal(child: asyncio.subprocess.Process, name: str) -> None:
|
|
382
|
+
"""Deliver an SSH-requested signal to the child's process group."""
|
|
383
|
+
signum = getattr(signal, f"SIG{name}", None) or getattr(signal, name, None)
|
|
384
|
+
if signum is None or child.returncode is not None:
|
|
385
|
+
return
|
|
386
|
+
with contextlib.suppress(ProcessLookupError, ValueError):
|
|
387
|
+
os.killpg(child.pid, int(signum))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def pump_ssh_to_pty(
|
|
391
|
+
process: asyncssh.SSHServerProcess,
|
|
392
|
+
transport: asyncio.WriteTransport,
|
|
393
|
+
master_fd: int,
|
|
394
|
+
child: asyncio.subprocess.Process,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Forward client input to the pty, applying resize and signal requests."""
|
|
397
|
+
while True:
|
|
398
|
+
try:
|
|
399
|
+
data = await process.stdin.read(64 * 1024)
|
|
400
|
+
except asyncssh.TerminalSizeChanged as change:
|
|
401
|
+
set_terminal_size(
|
|
402
|
+
master_fd,
|
|
403
|
+
(change.width, change.height, change.pixwidth, change.pixheight),
|
|
404
|
+
)
|
|
405
|
+
continue
|
|
406
|
+
except asyncssh.SignalReceived as received:
|
|
407
|
+
forward_signal(child, received.signal)
|
|
408
|
+
continue
|
|
409
|
+
except (asyncssh.BreakReceived, asyncssh.SoftEOFReceived):
|
|
410
|
+
continue
|
|
411
|
+
except (ConnectionError, BrokenPipeError):
|
|
412
|
+
break
|
|
413
|
+
if not data:
|
|
414
|
+
break
|
|
415
|
+
if isinstance(data, str):
|
|
416
|
+
data = data.encode("utf-8", "surrogateescape")
|
|
417
|
+
try:
|
|
418
|
+
transport.write(data)
|
|
419
|
+
except (ConnectionError, BrokenPipeError):
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def pump_pty_to_ssh(
|
|
424
|
+
reader: asyncio.StreamReader, process: asyncssh.SSHServerProcess
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Stream pty output back to the client until the pty reaches EOF."""
|
|
427
|
+
while True:
|
|
428
|
+
try:
|
|
429
|
+
data = await reader.read(64 * 1024)
|
|
430
|
+
except OSError:
|
|
431
|
+
break
|
|
432
|
+
if not data:
|
|
433
|
+
break
|
|
434
|
+
try:
|
|
435
|
+
process.stdout.write(data)
|
|
436
|
+
await process.stdout.drain()
|
|
437
|
+
except (ConnectionError, BrokenPipeError):
|
|
438
|
+
break
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async def handle_pty_process(
|
|
442
|
+
process: asyncssh.SSHServerProcess,
|
|
443
|
+
config: Config,
|
|
444
|
+
argv: list[str],
|
|
445
|
+
environment: dict[str, str],
|
|
446
|
+
) -> None:
|
|
447
|
+
loop = asyncio.get_running_loop()
|
|
448
|
+
master_fd, slave_fd = pty.openpty()
|
|
449
|
+
set_terminal_size(slave_fd, process.term_size)
|
|
450
|
+
read_dup = -1
|
|
451
|
+
slave_open = True
|
|
452
|
+
read_transport: asyncio.ReadTransport | None = None
|
|
453
|
+
write_transport: asyncio.WriteTransport | None = None
|
|
454
|
+
child: asyncio.subprocess.Process | None = None
|
|
455
|
+
try:
|
|
456
|
+
try:
|
|
457
|
+
child = await asyncio.create_subprocess_exec(
|
|
458
|
+
*argv,
|
|
459
|
+
cwd=config.root,
|
|
460
|
+
env=environment,
|
|
461
|
+
stdin=slave_fd,
|
|
462
|
+
stdout=slave_fd,
|
|
463
|
+
stderr=slave_fd,
|
|
464
|
+
preexec_fn=acquire_controlling_tty,
|
|
465
|
+
)
|
|
466
|
+
finally:
|
|
467
|
+
os.close(slave_fd)
|
|
468
|
+
slave_open = False
|
|
469
|
+
|
|
470
|
+
# Output: read the pty master through a dedicated descriptor.
|
|
471
|
+
read_dup = os.dup(master_fd)
|
|
472
|
+
reader = asyncio.StreamReader()
|
|
473
|
+
read_transport, _ = await loop.connect_read_pipe(
|
|
474
|
+
lambda: asyncio.StreamReaderProtocol(reader),
|
|
475
|
+
os.fdopen(read_dup, "rb", 0),
|
|
476
|
+
)
|
|
477
|
+
read_dup = -1 # owned by read_transport now
|
|
478
|
+
# Input: write through a transport so partial writes and EAGAIN on the
|
|
479
|
+
# (now non-blocking) master descriptor are handled by asyncio.
|
|
480
|
+
write_transport, _ = await loop.connect_write_pipe(
|
|
481
|
+
asyncio.Protocol, os.fdopen(master_fd, "wb", 0)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
input_task = asyncio.create_task(
|
|
485
|
+
pump_ssh_to_pty(process, write_transport, master_fd, child)
|
|
486
|
+
)
|
|
487
|
+
output_task = asyncio.create_task(pump_pty_to_ssh(reader, process))
|
|
488
|
+
child_wait = asyncio.create_task(child.wait())
|
|
489
|
+
channel_wait = asyncio.create_task(process.wait_closed())
|
|
490
|
+
disconnected = False
|
|
491
|
+
try:
|
|
492
|
+
done, _ = await asyncio.wait(
|
|
493
|
+
(child_wait, channel_wait), return_when=asyncio.FIRST_COMPLETED
|
|
494
|
+
)
|
|
495
|
+
disconnected = channel_wait in done
|
|
496
|
+
if disconnected and child.returncode is None:
|
|
497
|
+
await terminate_process_group(child)
|
|
498
|
+
returncode = await child_wait
|
|
499
|
+
if not disconnected:
|
|
500
|
+
# Drain buffered pty output before reporting the exit status.
|
|
501
|
+
await output_task
|
|
502
|
+
finally:
|
|
503
|
+
input_task.cancel()
|
|
504
|
+
output_task.cancel()
|
|
505
|
+
channel_wait.cancel()
|
|
506
|
+
if child.returncode is None:
|
|
507
|
+
await terminate_process_group(child)
|
|
508
|
+
await asyncio.gather(
|
|
509
|
+
input_task,
|
|
510
|
+
output_task,
|
|
511
|
+
channel_wait,
|
|
512
|
+
child_wait,
|
|
513
|
+
return_exceptions=True,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
if not disconnected:
|
|
517
|
+
process.exit(returncode if returncode >= 0 else 128 - returncode)
|
|
518
|
+
finally:
|
|
519
|
+
if slave_open:
|
|
520
|
+
with contextlib.suppress(OSError):
|
|
521
|
+
os.close(slave_fd)
|
|
522
|
+
if read_transport is not None:
|
|
523
|
+
read_transport.close()
|
|
524
|
+
elif read_dup >= 0:
|
|
525
|
+
with contextlib.suppress(OSError):
|
|
526
|
+
os.close(read_dup)
|
|
527
|
+
if write_transport is not None:
|
|
528
|
+
write_transport.close()
|
|
529
|
+
else:
|
|
530
|
+
with contextlib.suppress(OSError):
|
|
531
|
+
os.close(master_fd)
|
|
532
|
+
if child is not None and child.returncode is None:
|
|
533
|
+
await terminate_process_group(child)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
async def handle_process(process: asyncssh.SSHServerProcess, config: Config) -> None:
|
|
537
|
+
command = process.command
|
|
538
|
+
if isinstance(command, bytes):
|
|
539
|
+
command = command.decode("utf-8", "surrogateescape")
|
|
540
|
+
|
|
541
|
+
argv = build_child_argv(command, config)
|
|
542
|
+
environment = build_child_environment(process, config)
|
|
543
|
+
|
|
544
|
+
if process.term_type is not None:
|
|
545
|
+
await handle_pty_process(process, config, argv, environment)
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
child = await asyncio.create_subprocess_exec(
|
|
549
|
+
*argv,
|
|
550
|
+
cwd=config.root,
|
|
551
|
+
env=environment,
|
|
552
|
+
stdin=asyncio.subprocess.PIPE,
|
|
553
|
+
stdout=asyncio.subprocess.PIPE,
|
|
554
|
+
stderr=asyncio.subprocess.PIPE,
|
|
555
|
+
start_new_session=True,
|
|
556
|
+
)
|
|
557
|
+
assert child.stdout is not None and child.stderr is not None
|
|
558
|
+
|
|
559
|
+
input_task = asyncio.create_task(pump_ssh_input(process, child))
|
|
560
|
+
output_tasks = [
|
|
561
|
+
asyncio.create_task(pump_child_output(child.stdout, process.stdout)),
|
|
562
|
+
asyncio.create_task(pump_child_output(child.stderr, process.stderr)),
|
|
563
|
+
]
|
|
564
|
+
child_wait = asyncio.create_task(child.wait())
|
|
565
|
+
channel_wait = asyncio.create_task(process.wait_closed())
|
|
566
|
+
disconnected = False
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
done, _ = await asyncio.wait(
|
|
570
|
+
(child_wait, channel_wait), return_when=asyncio.FIRST_COMPLETED
|
|
571
|
+
)
|
|
572
|
+
disconnected = channel_wait in done
|
|
573
|
+
if disconnected and child.returncode is None:
|
|
574
|
+
await terminate_process_group(child)
|
|
575
|
+
returncode = await child_wait
|
|
576
|
+
if not disconnected:
|
|
577
|
+
await asyncio.gather(*output_tasks)
|
|
578
|
+
finally:
|
|
579
|
+
input_task.cancel()
|
|
580
|
+
channel_wait.cancel()
|
|
581
|
+
for task in output_tasks:
|
|
582
|
+
task.cancel()
|
|
583
|
+
if child.returncode is None:
|
|
584
|
+
await terminate_process_group(child)
|
|
585
|
+
await asyncio.gather(
|
|
586
|
+
input_task, child_wait, channel_wait, *output_tasks, return_exceptions=True
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
if not disconnected:
|
|
590
|
+
process.exit(returncode if returncode >= 0 else 128 - returncode)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class HostSFTPServer(asyncssh.SFTPServer):
|
|
594
|
+
"""SFTP mapping that mirrors shell visibility for Nullgate workspaces.
|
|
595
|
+
|
|
596
|
+
Relative client paths start in the configured workspace root, matching
|
|
597
|
+
the gateway command working directory. Absolute client paths address
|
|
598
|
+
host filesystem locations directly, matching exec and shell behavior.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
def __init__(self, channel: asyncssh.SSHServerChannel, root: Path):
|
|
602
|
+
self._workspace_root = os.fsencode(root.resolve())
|
|
603
|
+
self._client_channel = channel
|
|
604
|
+
self._handled_request = False
|
|
605
|
+
super().__init__(channel, chroot=None)
|
|
606
|
+
|
|
607
|
+
def map_path(self, path: bytes) -> bytes:
|
|
608
|
+
self._handled_request = True
|
|
609
|
+
if os.path.isabs(path):
|
|
610
|
+
return os.path.normpath(path)
|
|
611
|
+
return os.path.normpath(os.path.join(self._workspace_root, path))
|
|
612
|
+
|
|
613
|
+
def exit(self) -> None:
|
|
614
|
+
if self._handled_request:
|
|
615
|
+
with contextlib.suppress(Exception):
|
|
616
|
+
self._client_channel.exit(0)
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class ConfinedSFTPServer(asyncssh.SFTPServer):
|
|
621
|
+
"""SFTP server which rejects paths resolving outside its virtual root."""
|
|
622
|
+
|
|
623
|
+
def __init__(self, channel: asyncssh.SSHServerChannel, root: Path):
|
|
624
|
+
# The resolved root is the chroot boundary AsyncSSH enforces (it
|
|
625
|
+
# realpaths the chroot internally). The configured root is kept
|
|
626
|
+
# separately as the prefix used to recognize client-supplied absolute
|
|
627
|
+
# host paths, because clients address the root by its configured,
|
|
628
|
+
# unresolved spelling (e.g. /var/... which resolves to /private/var
|
|
629
|
+
# on macOS). The authoritative escape guard always uses the resolved
|
|
630
|
+
# root.
|
|
631
|
+
self._root_path = os.fsencode(root.resolve())
|
|
632
|
+
self._root_prefix = os.path.normpath(os.fsencode(root))
|
|
633
|
+
self._channel = channel
|
|
634
|
+
self._served = False
|
|
635
|
+
super().__init__(channel, chroot=self._root_path)
|
|
636
|
+
|
|
637
|
+
def _is_confined_by(self, path: bytes, root: bytes) -> bool:
|
|
638
|
+
try:
|
|
639
|
+
return os.path.commonpath((root, path)) == root
|
|
640
|
+
except ValueError:
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
def _is_confined(self, path: bytes) -> bool:
|
|
644
|
+
return self._is_confined_by(path, self._root_path)
|
|
645
|
+
|
|
646
|
+
def _require_confined(self, path: bytes) -> None:
|
|
647
|
+
if not self._is_confined(path):
|
|
648
|
+
raise asyncssh.SFTPPermissionDenied("Path resolves outside the SFTP root")
|
|
649
|
+
|
|
650
|
+
def _map_requested(self, path: bytes) -> bytes:
|
|
651
|
+
"""Map a client path to a host path within the configured root.
|
|
652
|
+
|
|
653
|
+
A path that already names a location under the root (absolute host
|
|
654
|
+
paths passed by the asyncssh API or legacy clients, matched against
|
|
655
|
+
the root as it was configured) is kept as that host path; map_path
|
|
656
|
+
and _map_entry then reject any symlink escape. Every other path is
|
|
657
|
+
interpreted relative to the SFTP virtual root ("/"), which is how
|
|
658
|
+
standard SCP clients address the remote namespace after
|
|
659
|
+
canonicalizing it.
|
|
660
|
+
"""
|
|
661
|
+
if os.path.isabs(path) and self._is_confined_by(
|
|
662
|
+
os.path.normpath(path), self._root_prefix
|
|
663
|
+
):
|
|
664
|
+
return os.path.normpath(path)
|
|
665
|
+
return super().map_path(path)
|
|
666
|
+
|
|
667
|
+
def map_path(self, path: bytes) -> bytes:
|
|
668
|
+
self._served = True
|
|
669
|
+
mapped = self._map_requested(path)
|
|
670
|
+
resolved = os.path.realpath(mapped)
|
|
671
|
+
self._require_confined(resolved)
|
|
672
|
+
return resolved
|
|
673
|
+
|
|
674
|
+
def _map_entry(self, path: bytes) -> bytes:
|
|
675
|
+
"""Map a directory entry without following its final symlink."""
|
|
676
|
+
self._served = True
|
|
677
|
+
mapped = self._map_requested(path)
|
|
678
|
+
self._require_confined(os.path.realpath(os.path.dirname(mapped)))
|
|
679
|
+
return mapped
|
|
680
|
+
|
|
681
|
+
def exit(self) -> None:
|
|
682
|
+
# OpenSSH scp in SFTP mode reads the channel's exit status and treats
|
|
683
|
+
# a missing one as failure, so report success for a session that
|
|
684
|
+
# actually served requests. A session torn down before serving any
|
|
685
|
+
# request (e.g. by a protocol error) must not be reported as a clean
|
|
686
|
+
# success.
|
|
687
|
+
if self._served:
|
|
688
|
+
with contextlib.suppress(Exception):
|
|
689
|
+
self._channel.exit(0)
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
def lstat(self, path: bytes) -> os.stat_result:
|
|
693
|
+
return os.lstat(self._map_entry(path))
|
|
694
|
+
|
|
695
|
+
def remove(self, path: bytes) -> None:
|
|
696
|
+
os.remove(self._map_entry(path))
|
|
697
|
+
|
|
698
|
+
def rmdir(self, path: bytes) -> None:
|
|
699
|
+
os.rmdir(self._map_entry(path))
|
|
700
|
+
|
|
701
|
+
def rename(self, oldpath: bytes, newpath: bytes) -> None:
|
|
702
|
+
old_mapped = self._map_entry(oldpath)
|
|
703
|
+
new_mapped = self._map_entry(newpath)
|
|
704
|
+
if os.path.lexists(new_mapped):
|
|
705
|
+
raise asyncssh.SFTPFileAlreadyExists("File already exists")
|
|
706
|
+
os.rename(old_mapped, new_mapped)
|
|
707
|
+
|
|
708
|
+
def posix_rename(self, oldpath: bytes, newpath: bytes) -> None:
|
|
709
|
+
os.replace(self._map_entry(oldpath), self._map_entry(newpath))
|
|
710
|
+
|
|
711
|
+
def readlink(self, path: bytes) -> bytes:
|
|
712
|
+
mapped = self._map_entry(path)
|
|
713
|
+
resolved = os.path.realpath(mapped)
|
|
714
|
+
self._require_confined(resolved)
|
|
715
|
+
return self.reverse_map_path(resolved)
|
|
716
|
+
|
|
717
|
+
def symlink(self, oldpath: bytes, newpath: bytes) -> None:
|
|
718
|
+
del oldpath, newpath
|
|
719
|
+
raise asyncssh.SFTPPermissionDenied("Symbolic links are disabled")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
async def create_acceptor(config: Config) -> asyncssh.SSHAcceptor:
|
|
723
|
+
ensure_host_key(config.host_key)
|
|
724
|
+
|
|
725
|
+
def sftp_factory(channel: asyncssh.SSHServerChannel) -> asyncssh.SFTPServer:
|
|
726
|
+
if config.confine_sftp:
|
|
727
|
+
return ConfinedSFTPServer(channel, config.root)
|
|
728
|
+
return HostSFTPServer(channel, config.root)
|
|
729
|
+
|
|
730
|
+
return await asyncssh.create_server(
|
|
731
|
+
lambda: TokenSSHServer(config),
|
|
732
|
+
config.host,
|
|
733
|
+
config.port,
|
|
734
|
+
server_host_keys=[str(config.host_key)],
|
|
735
|
+
process_factory=lambda process: handle_process(process, config),
|
|
736
|
+
sftp_factory=sftp_factory,
|
|
737
|
+
allow_scp=True,
|
|
738
|
+
encoding=None,
|
|
739
|
+
line_editor=False,
|
|
740
|
+
keepalive_interval=SERVER_KEEPALIVE_INTERVAL,
|
|
741
|
+
keepalive_count_max=SERVER_KEEPALIVE_COUNT_MAX,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
746
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
747
|
+
parser.add_argument("--root", default="/marimo", help="SFTP root and command working directory")
|
|
748
|
+
parser.add_argument("--host", default="127.0.0.1", help="listen address")
|
|
749
|
+
parser.add_argument("--port", type=int, default=4822, help="listen port")
|
|
750
|
+
parser.add_argument(
|
|
751
|
+
"--username",
|
|
752
|
+
default=current_username(),
|
|
753
|
+
help="accepted SSH username (default: current OS user)",
|
|
754
|
+
)
|
|
755
|
+
parser.add_argument("--password-env", default=DEFAULT_PASSWORD_ENV, help="environment variable containing the password")
|
|
756
|
+
parser.add_argument("--no-password", action="store_true", help="disable password authentication and require an authorized key")
|
|
757
|
+
parser.add_argument("--authorized-keys", default="~/.ssh/authorized_keys", help="OpenSSH authorized_keys file")
|
|
758
|
+
parser.add_argument("--host-key", default=str(DEFAULT_HOST_KEY), help="persistent Ed25519 host key path")
|
|
759
|
+
parser.add_argument("--shell", default=None, help="shell used for commands and interactive sessions (default: $SHELL, then bash, then sh)")
|
|
760
|
+
parser.add_argument(
|
|
761
|
+
"--accept",
|
|
762
|
+
action="store_true",
|
|
763
|
+
help="skip all authentication and admit any connecting client",
|
|
764
|
+
)
|
|
765
|
+
parser.add_argument(
|
|
766
|
+
"--allow-tcp-forwarding",
|
|
767
|
+
action="store_true",
|
|
768
|
+
help="allow clients to use local TCP forwarding (ssh -L) through the session",
|
|
769
|
+
)
|
|
770
|
+
parser.add_argument(
|
|
771
|
+
"--upterm-ca-keys",
|
|
772
|
+
default=None,
|
|
773
|
+
help="accept Upterm user certificates signed by a relay key in this file",
|
|
774
|
+
)
|
|
775
|
+
parser.add_argument(
|
|
776
|
+
"--confine-sftp",
|
|
777
|
+
action=argparse.BooleanOptionalAction,
|
|
778
|
+
default=None,
|
|
779
|
+
help="confine SFTP and SCP paths to the workspace root",
|
|
780
|
+
)
|
|
781
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
|
|
782
|
+
return parser.parse_args(argv)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def resolve_shell(requested: str | None) -> str:
|
|
786
|
+
if requested is not None:
|
|
787
|
+
candidates = [requested]
|
|
788
|
+
else:
|
|
789
|
+
candidates = [os.environ.get("SHELL", ""), "/bin/bash", "/bin/sh"]
|
|
790
|
+
for candidate in candidates:
|
|
791
|
+
if not candidate:
|
|
792
|
+
continue
|
|
793
|
+
try:
|
|
794
|
+
shell = Path(candidate).expanduser().resolve(strict=True)
|
|
795
|
+
except OSError:
|
|
796
|
+
continue
|
|
797
|
+
if shell.is_file() and os.access(shell, os.X_OK):
|
|
798
|
+
return str(shell)
|
|
799
|
+
raise ValueError("No usable shell found; set --shell to an executable")
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def build_config(args: argparse.Namespace) -> Config:
|
|
803
|
+
ensure_username_environment(args.username)
|
|
804
|
+
root = Path(args.root).expanduser().resolve(strict=True)
|
|
805
|
+
if not root.is_dir():
|
|
806
|
+
raise ValueError(f"Root is not a directory: {root}")
|
|
807
|
+
if not 1 <= args.port <= 65535:
|
|
808
|
+
raise ValueError("Port must be from 1 to 65535")
|
|
809
|
+
if not args.username or any(character.isspace() for character in args.username):
|
|
810
|
+
raise ValueError("Username must be non-empty and contain no whitespace")
|
|
811
|
+
|
|
812
|
+
password = os.environ.get(args.password_env, "")
|
|
813
|
+
if not args.accept and not args.no_password and not args.upterm_ca_keys and len(password) < 16:
|
|
814
|
+
raise ValueError(f"{args.password_env} must contain at least 16 characters")
|
|
815
|
+
|
|
816
|
+
shell = resolve_shell(args.shell)
|
|
817
|
+
|
|
818
|
+
confine_sftp = args.confine_sftp
|
|
819
|
+
if confine_sftp is None:
|
|
820
|
+
raw_env = os.environ.get("NULLGATE_CONFINE_SFTP")
|
|
821
|
+
if raw_env is not None:
|
|
822
|
+
confine_sftp = raw_env.strip().lower() in ("1", "true", "yes")
|
|
823
|
+
else:
|
|
824
|
+
confine_sftp = False
|
|
825
|
+
|
|
826
|
+
return Config(
|
|
827
|
+
root=root,
|
|
828
|
+
host=args.host,
|
|
829
|
+
port=args.port,
|
|
830
|
+
username=args.username,
|
|
831
|
+
password=password,
|
|
832
|
+
password_env=args.password_env,
|
|
833
|
+
authorized_keys=Path(args.authorized_keys).expanduser().resolve(),
|
|
834
|
+
host_key=Path(args.host_key).expanduser().resolve(),
|
|
835
|
+
shell=str(shell),
|
|
836
|
+
accept=args.accept,
|
|
837
|
+
password_auth=not args.no_password,
|
|
838
|
+
allow_tcp_forwarding=args.allow_tcp_forwarding,
|
|
839
|
+
upterm_ca_keys=(
|
|
840
|
+
Path(args.upterm_ca_keys).expanduser().resolve()
|
|
841
|
+
if args.upterm_ca_keys
|
|
842
|
+
else None
|
|
843
|
+
),
|
|
844
|
+
confine_sftp=bool(confine_sftp),
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
async def serve(config: Config) -> None:
|
|
849
|
+
acceptor = await create_acceptor(config)
|
|
850
|
+
stop = asyncio.Event()
|
|
851
|
+
loop = asyncio.get_running_loop()
|
|
852
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
853
|
+
with contextlib.suppress(NotImplementedError):
|
|
854
|
+
loop.add_signal_handler(signum, stop.set)
|
|
855
|
+
|
|
856
|
+
print(
|
|
857
|
+
f"Nullgate {VERSION} listening on {config.host}:{config.port} "
|
|
858
|
+
f"as {config.username}; SFTP root and command cwd: {config.root}",
|
|
859
|
+
flush=True,
|
|
860
|
+
)
|
|
861
|
+
if config.accept:
|
|
862
|
+
print(
|
|
863
|
+
"nullgate: --accept set; anyone can connect without "
|
|
864
|
+
"authentication",
|
|
865
|
+
flush=True,
|
|
866
|
+
)
|
|
867
|
+
if config.allow_tcp_forwarding:
|
|
868
|
+
print(
|
|
869
|
+
"nullgate: --allow-tcp-forwarding set; clients may use "
|
|
870
|
+
"ssh -L through this session",
|
|
871
|
+
flush=True,
|
|
872
|
+
)
|
|
873
|
+
if config.confine_sftp:
|
|
874
|
+
print(
|
|
875
|
+
"nullgate: --confine-sftp set; SFTP and SCP stay inside "
|
|
876
|
+
f"{config.root}",
|
|
877
|
+
flush=True,
|
|
878
|
+
)
|
|
879
|
+
try:
|
|
880
|
+
await stop.wait()
|
|
881
|
+
finally:
|
|
882
|
+
acceptor.close()
|
|
883
|
+
await acceptor.wait_closed()
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def main(argv: list[str] | None = None) -> int:
|
|
887
|
+
try:
|
|
888
|
+
config = build_config(parse_args(argv))
|
|
889
|
+
asyncio.run(serve(config))
|
|
890
|
+
except (OSError, ValueError, asyncssh.Error) as error:
|
|
891
|
+
print(f"nullgate: {error}", file=sys.stderr)
|
|
892
|
+
return 1
|
|
893
|
+
return 0
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
if __name__ == "__main__":
|
|
897
|
+
raise SystemExit(main())
|