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.
- {nullgate-1.2.1/src/nullgate.egg-info → nullgate-1.2.2}/PKG-INFO +1 -1
- nullgate-1.2.2/src/nullgate/__init__.py +1 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/gateway.py +60 -4
- {nullgate-1.2.1 → nullgate-1.2.2/src/nullgate.egg-info}/PKG-INFO +1 -1
- {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_gateway.py +38 -0
- nullgate-1.2.1/src/nullgate/__init__.py +0 -1
- {nullgate-1.2.1 → nullgate-1.2.2}/LICENSE +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/README.md +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/pyproject.toml +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/setup.cfg +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/account.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/bridge.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/client_config.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/commands.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/ingress.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/runtime.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/session.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/transports.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate/wsroute.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/SOURCES.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/dependency_links.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/entry_points.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/requires.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/src/nullgate.egg-info/top_level.txt +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_account.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_bridge.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_commands.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_ingress.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_install.py +0 -0
- {nullgate-1.2.1 → nullgate-1.2.2}/tests/test_wsroute.py +0 -0
|
@@ -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"],
|
|
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
|
|
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:
|
|
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,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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|