nullgate 1.2.7__tar.gz → 1.2.8__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.7/src/nullgate.egg-info → nullgate-1.2.8}/PKG-INFO +1 -1
- nullgate-1.2.8/src/nullgate/__init__.py +1 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/runtime.py +21 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/session.py +2 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/transports.py +7 -5
- {nullgate-1.2.7 → nullgate-1.2.8/src/nullgate.egg-info}/PKG-INFO +1 -1
- {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_commands.py +39 -5
- nullgate-1.2.7/src/nullgate/__init__.py +0 -1
- {nullgate-1.2.7 → nullgate-1.2.8}/LICENSE +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/README.md +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/pyproject.toml +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/setup.cfg +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/account.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/bridge.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/client_config.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/commands.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/gateway.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/ingress.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/supervisor.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/wsroute.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/SOURCES.txt +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/dependency_links.txt +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/entry_points.txt +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/requires.txt +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/top_level.txt +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_account.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_bridge.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_gateway.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_ingress.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_install.py +0 -0
- {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_wsroute.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.8"
|
|
@@ -78,6 +78,17 @@ def _identity_of(pid_file: Path) -> str | None:
|
|
|
78
78
|
return None
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def _process_state(pid: int) -> str | None:
|
|
82
|
+
"""Return the Linux process state letter, when procfs exposes it."""
|
|
83
|
+
proc_stat = Path("/proc") / str(pid) / "stat"
|
|
84
|
+
try:
|
|
85
|
+
if proc_stat.is_file():
|
|
86
|
+
return proc_stat.read_text().rsplit(")", 1)[1].split()[0]
|
|
87
|
+
except (OSError, IndexError):
|
|
88
|
+
pass
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
81
92
|
def is_running(pid_file: Path) -> bool:
|
|
82
93
|
"""True when the recorded pid is alive and still matches its identity."""
|
|
83
94
|
pid = read_pid(pid_file)
|
|
@@ -87,10 +98,20 @@ def is_running(pid_file: Path) -> bool:
|
|
|
87
98
|
os.kill(pid, 0)
|
|
88
99
|
except OSError:
|
|
89
100
|
return False
|
|
101
|
+
if _process_state(pid) == "Z":
|
|
102
|
+
return False
|
|
90
103
|
identity = process_identity(pid)
|
|
91
104
|
return identity is not None and identity == _identity_of(pid_file)
|
|
92
105
|
|
|
93
106
|
|
|
107
|
+
def reap_child(pid: int | None) -> None:
|
|
108
|
+
"""Reap a dead direct child without blocking; ignore unrelated processes."""
|
|
109
|
+
if pid is None:
|
|
110
|
+
return
|
|
111
|
+
with contextlib.suppress(ChildProcessError, OSError):
|
|
112
|
+
os.waitpid(pid, os.WNOHANG)
|
|
113
|
+
|
|
114
|
+
|
|
94
115
|
def write_pid(pid_file: Path, pid: int) -> bool:
|
|
95
116
|
"""Record ``pid IDENTITY``; fails rather than track an unidentifiable pid."""
|
|
96
117
|
identity = process_identity(pid)
|
|
@@ -120,6 +120,9 @@ def _spawn(command: list[str], environment: dict[str, str], log: Path) -> subpro
|
|
|
120
120
|
stdout=output,
|
|
121
121
|
stderr=subprocess.STDOUT,
|
|
122
122
|
stdin=subprocess.DEVNULL,
|
|
123
|
+
# Background services must outlive the shell or SSH exec channel
|
|
124
|
+
# which invoked `nullgate start`.
|
|
125
|
+
start_new_session=True,
|
|
123
126
|
)
|
|
124
127
|
|
|
125
128
|
|
|
@@ -257,11 +260,10 @@ def launch_gateway(
|
|
|
257
260
|
def launch_transport_srvus(
|
|
258
261
|
port: int, slot: int, state: Path, identity_seed: str = ""
|
|
259
262
|
) -> subprocess.Popen:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
key = Path(os.path.expanduser("~/.ssh/id_ed25519"))
|
|
263
|
+
# The tunnel identity belongs to this Nullgate state, not to the host's
|
|
264
|
+
# SSH configuration. ingress.ensure_key() creates it atomically in pure
|
|
265
|
+
# Python, including deterministic creation when an identity seed is set.
|
|
266
|
+
key = state / "srvus-id_ed25519"
|
|
265
267
|
st.repair_key_permissions(key)
|
|
266
268
|
host = os.environ.get("NULLGATE_TUNNEL_HOST", "srv.us")
|
|
267
269
|
try:
|
|
@@ -540,9 +540,11 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
540
540
|
[line for line in lines if not line.startswith(("ssh ", "password: "))],
|
|
541
541
|
[],
|
|
542
542
|
)
|
|
543
|
-
self.
|
|
544
|
-
self.
|
|
545
|
-
|
|
543
|
+
self.assertNotIn("Fixed permissions on", err)
|
|
544
|
+
self.assertEqual(key.stat().st_mode & 0o777, 0o660)
|
|
545
|
+
tunnel_key = state / "srvus-id_ed25519"
|
|
546
|
+
self.assertTrue(tunnel_key.is_file())
|
|
547
|
+
self.assertEqual(tunnel_key.stat().st_mode & 0o777, 0o600)
|
|
546
548
|
|
|
547
549
|
code, out, err = run_cli(["status"], env=env)
|
|
548
550
|
self.assertEqual(code, 0)
|
|
@@ -1893,7 +1895,7 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
1893
1895
|
home.mkdir()
|
|
1894
1896
|
state = temp / "state"
|
|
1895
1897
|
state.mkdir()
|
|
1896
|
-
key_path =
|
|
1898
|
+
key_path = state / "srvus-id_ed25519"
|
|
1897
1899
|
with mock.patch.dict(os.environ, {"HOME": str(home)}):
|
|
1898
1900
|
with mock.patch.object(transports, "_spawn", side_effect=fake_spawn):
|
|
1899
1901
|
transports.launch_transport_srvus(4822, 1, state, "child-seed")
|
|
@@ -1901,7 +1903,7 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
1901
1903
|
spawned[-1]["environment"].get("NULLGATE_IDENTITY_SEED"), "child-seed"
|
|
1902
1904
|
)
|
|
1903
1905
|
self.assertIn(str(key_path), spawned[-1]["command"])
|
|
1904
|
-
self.
|
|
1906
|
+
self.assertFalse((home / ".ssh").exists())
|
|
1905
1907
|
with mock.patch.dict(
|
|
1906
1908
|
os.environ,
|
|
1907
1909
|
{"HOME": str(home), "NULLGATE_IDENTITY_SEED": "leaked"},
|
|
@@ -2387,6 +2389,22 @@ class CLILifecycleTests(unittest.TestCase):
|
|
|
2387
2389
|
|
|
2388
2390
|
|
|
2389
2391
|
class TransportProbeTests(unittest.TestCase):
|
|
2392
|
+
def test_spawn_detaches_background_service_from_launching_session(self):
|
|
2393
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
2394
|
+
log = Path(temp) / "child.log"
|
|
2395
|
+
process = mock.Mock(pid=1234)
|
|
2396
|
+
with mock.patch.object(
|
|
2397
|
+
transports.subprocess, "Popen", return_value=process
|
|
2398
|
+
) as popen:
|
|
2399
|
+
self.assertIs(
|
|
2400
|
+
transports._spawn(["child"], {"EXAMPLE": "1"}, log),
|
|
2401
|
+
process,
|
|
2402
|
+
)
|
|
2403
|
+
kwargs = popen.call_args.kwargs
|
|
2404
|
+
self.assertTrue(kwargs["start_new_session"])
|
|
2405
|
+
self.assertIs(kwargs["stdin"], subprocess.DEVNULL)
|
|
2406
|
+
self.assertIs(kwargs["stderr"], subprocess.STDOUT)
|
|
2407
|
+
|
|
2390
2408
|
def test_tls_probe_uses_sni_verification_context_and_requires_ssh_banner(self):
|
|
2391
2409
|
raw = mock.MagicMock()
|
|
2392
2410
|
raw.__enter__.return_value = raw
|
|
@@ -2469,6 +2487,22 @@ class ClientConfigTests(unittest.TestCase):
|
|
|
2469
2487
|
|
|
2470
2488
|
|
|
2471
2489
|
class RuntimeUnitTests(unittest.TestCase):
|
|
2490
|
+
def test_is_running_rejects_zombie(self):
|
|
2491
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
2492
|
+
pid_file = Path(temp) / "child.pid"
|
|
2493
|
+
pid_file.write_text("12345 identity\n")
|
|
2494
|
+
with mock.patch.object(runtime_mod.os, "kill"), mock.patch.object(
|
|
2495
|
+
runtime_mod, "_process_state", return_value="Z"
|
|
2496
|
+
), mock.patch.object(
|
|
2497
|
+
runtime_mod, "process_identity", return_value="identity"
|
|
2498
|
+
):
|
|
2499
|
+
self.assertFalse(runtime_mod.is_running(pid_file))
|
|
2500
|
+
|
|
2501
|
+
def test_reap_child_is_nonblocking_and_tolerates_non_child(self):
|
|
2502
|
+
with mock.patch.object(runtime_mod.os, "waitpid", return_value=(0, 0)) as waitpid:
|
|
2503
|
+
runtime_mod.reap_child(12345)
|
|
2504
|
+
waitpid.assert_called_once_with(12345, os.WNOHANG)
|
|
2505
|
+
|
|
2472
2506
|
def test_repair_key_permissions(self):
|
|
2473
2507
|
with tempfile.TemporaryDirectory() as temp:
|
|
2474
2508
|
key = Path(temp) / "id_ed25519"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.2.7"
|
|
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
|