nullgate 1.2.1__tar.gz → 1.2.2__tar.gz

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.
Files changed (30) hide show
  1. {nullgate-1.2.1/src/nullgate.egg-info → nullgate-1.2.2}/PKG-INFO +1 -1
  2. nullgate-1.2.2/src/nullgate/__init__.py +1 -0
  3. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/gateway.py +60 -4
  4. {nullgate-1.2.1 → nullgate-1.2.2/src/nullgate.egg-info}/PKG-INFO +1 -1
  5. {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_gateway.py +38 -0
  6. nullgate-1.2.1/src/nullgate/__init__.py +0 -1
  7. {nullgate-1.2.1 → nullgate-1.2.2}/LICENSE +0 -0
  8. {nullgate-1.2.1 → nullgate-1.2.2}/README.md +0 -0
  9. {nullgate-1.2.1 → nullgate-1.2.2}/pyproject.toml +0 -0
  10. {nullgate-1.2.1 → nullgate-1.2.2}/setup.cfg +0 -0
  11. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/account.py +0 -0
  12. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/bridge.py +0 -0
  13. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/client_config.py +0 -0
  14. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/commands.py +0 -0
  15. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/ingress.py +0 -0
  16. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/runtime.py +0 -0
  17. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/session.py +0 -0
  18. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/transports.py +0 -0
  19. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/wsroute.py +0 -0
  20. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/SOURCES.txt +0 -0
  21. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/dependency_links.txt +0 -0
  22. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/entry_points.txt +0 -0
  23. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/requires.txt +0 -0
  24. {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/top_level.txt +0 -0
  25. {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_account.py +0 -0
  26. {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_bridge.py +0 -0
  27. {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_commands.py +0 -0
  28. {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_ingress.py +0 -0
  29. {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_install.py +0 -0
  30. {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_wsroute.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nullgate
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Disposable SSH gateway into confined directory workspaces
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -0,0 +1 @@
1
+ __version__ = "1.2.2"
@@ -11,9 +11,11 @@ import asyncio
11
11
  import contextlib
12
12
  import fcntl
13
13
  import hmac
14
+ import json
14
15
  import os
15
16
  import pty
16
17
  import pwd
18
+ import secrets
17
19
  import signal
18
20
  import stat
19
21
  import struct
@@ -21,6 +23,8 @@ import subprocess
21
23
  import sys
22
24
  import tempfile
23
25
  import termios
26
+ import time
27
+ import traceback
24
28
  from dataclasses import dataclass
25
29
  from pathlib import Path
26
30
 
@@ -278,9 +282,14 @@ def available_locales() -> dict[str, str]:
278
282
  """Map normalized locale names to their canonical form from ``locale -a``."""
279
283
  try:
280
284
  completed = subprocess.run(
281
- ["locale", "-a"], capture_output=True, text=True, timeout=5
285
+ ["locale", "-a"],
286
+ capture_output=True,
287
+ text=True,
288
+ encoding="utf-8",
289
+ errors="replace",
290
+ timeout=5,
282
291
  )
283
- except (OSError, subprocess.SubprocessError):
292
+ except (OSError, subprocess.SubprocessError, UnicodeError):
284
293
  return {}
285
294
  result: dict[str, str] = {}
286
295
  for line in completed.stdout.splitlines():
@@ -296,7 +305,7 @@ def sanitize_locale_environment(environment: dict[str, str]) -> None:
296
305
  Container images often bake in LANG/LC_* values (e.g. en_US.UTF-8) without
297
306
  generating the matching locale, so every child shell emits "setlocale:
298
307
  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.
308
+ as a last resort whenever the requested one is missing.
300
309
  """
301
310
  requested = {
302
311
  var: environment[var] for var in _LOCALE_ENV_VARS if environment.get(var)
@@ -590,6 +599,53 @@ async def handle_process(process: asyncssh.SSHServerProcess, config: Config) ->
590
599
  process.exit(returncode if returncode >= 0 else 128 - returncode)
591
600
 
592
601
 
602
+ async def handle_process_logged(
603
+ process: asyncssh.SSHServerProcess, config: Config
604
+ ) -> None:
605
+ """Record session lifecycle and make process-factory failures visible."""
606
+ session_id = "ngp-" + secrets.token_hex(4)
607
+ started = time.monotonic()
608
+ mode = "shell" if not process.command else "exec"
609
+
610
+ def log(event: str, **fields: object) -> None:
611
+ print(
612
+ json.dumps(
613
+ {
614
+ "event": event,
615
+ "session_id": session_id,
616
+ "mode": mode,
617
+ "pty": process.term_type is not None,
618
+ **fields,
619
+ },
620
+ sort_keys=True,
621
+ ),
622
+ file=sys.stderr,
623
+ flush=True,
624
+ )
625
+
626
+ log("session.start")
627
+ try:
628
+ await handle_process(process, config)
629
+ except Exception as error:
630
+ log(
631
+ "session.error",
632
+ duration_ms=round((time.monotonic() - started) * 1000),
633
+ error=type(error).__name__,
634
+ message=str(error),
635
+ )
636
+ traceback.print_exc(file=sys.stderr)
637
+ with contextlib.suppress(ConnectionError, BrokenPipeError):
638
+ process.stderr.write(b"nullgate: session failed; see gateway log\n")
639
+ await process.stderr.drain()
640
+ with contextlib.suppress(Exception):
641
+ process.exit(255)
642
+ return
643
+ log(
644
+ "session.ok",
645
+ duration_ms=round((time.monotonic() - started) * 1000),
646
+ )
647
+
648
+
593
649
  class HostSFTPServer(asyncssh.SFTPServer):
594
650
  """SFTP mapping that mirrors shell visibility for Nullgate workspaces.
595
651
 
@@ -732,7 +788,7 @@ async def create_acceptor(config: Config) -> asyncssh.SSHAcceptor:
732
788
  config.host,
733
789
  config.port,
734
790
  server_host_keys=[str(config.host_key)],
735
- process_factory=lambda process: handle_process(process, config),
791
+ process_factory=lambda process: handle_process_logged(process, config),
736
792
  sftp_factory=sftp_factory,
737
793
  allow_scp=True,
738
794
  encoding=None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nullgate
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Disposable SSH gateway into confined directory workspaces
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import contextlib
3
+ import io
3
4
  import os
4
5
  import shutil
5
6
  import tempfile
@@ -16,11 +17,13 @@ from nullgate.gateway import (
16
17
  SERVER_KEEPALIVE_COUNT_MAX,
17
18
  SERVER_KEEPALIVE_INTERVAL,
18
19
  TokenSSHServer,
20
+ available_locales,
19
21
  build_child_argv,
20
22
  build_child_environment,
21
23
  build_config,
22
24
  create_acceptor,
23
25
  ensure_host_key,
26
+ handle_process_logged,
24
27
  parse_args,
25
28
  sanitize_locale_environment,
26
29
  )
@@ -218,6 +221,29 @@ class NullgateSSHTests(unittest.IsolatedAsyncioTestCase):
218
221
  acceptor.close()
219
222
  await acceptor.wait_closed()
220
223
 
224
+ async def test_process_factory_errors_are_logged_and_reported(self):
225
+ process = mock.Mock(command=b"printf ok", term_type=None)
226
+ process.stderr.drain = mock.AsyncMock()
227
+ log = io.StringIO()
228
+ with (
229
+ mock.patch(
230
+ "nullgate.gateway.handle_process", side_effect=OSError("spawn denied")
231
+ ),
232
+ mock.patch("sys.stderr", log),
233
+ ):
234
+ await handle_process_logged(process, self.config)
235
+
236
+ output = log.getvalue()
237
+ self.assertIn('"event": "session.start"', output)
238
+ self.assertIn('"event": "session.error"', output)
239
+ self.assertIn('"error": "OSError"', output)
240
+ self.assertIn("spawn denied", output)
241
+ process.stderr.write.assert_called_once_with(
242
+ b"nullgate: session failed; see gateway log\n"
243
+ )
244
+ process.stderr.drain.assert_awaited_once()
245
+ process.exit.assert_called_once_with(255)
246
+
221
247
  async def test_tcp_forwarding_respects_flag(self):
222
248
  async def echo_handler(reader, writer):
223
249
  try:
@@ -721,6 +747,18 @@ class LocaleSanitizerTests(unittest.TestCase):
721
747
  sanitize_locale_environment(environment)
722
748
  return environment
723
749
 
750
+ def test_locale_listing_tolerates_invalid_utf8(self):
751
+ invalid_output = b"C\nC.utf8\nbroken-\xe5\n"
752
+
753
+ def fake_run(*args, encoding=None, errors=None, **kwargs):
754
+ return mock.Mock(stdout=invalid_output.decode(encoding, errors))
755
+
756
+ with mock.patch("nullgate.gateway.subprocess.run", side_effect=fake_run):
757
+ locales = available_locales()
758
+
759
+ self.assertEqual(locales["c"], "C")
760
+ self.assertEqual(locales["c.utf8"], "C.utf8")
761
+
724
762
  def test_missing_locale_falls_back_to_available_utf8(self):
725
763
  environment = {"LANG": "en_US.UTF-8", "LC_ALL": "en_US.UTF-8", "PATH": "/bin"}
726
764
  self.sanitize(environment, self.UTF8_HOST)
@@ -1 +0,0 @@
1
- __version__ = "1.2.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes