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/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())