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.
Files changed (31) hide show
  1. {nullgate-1.2.7/src/nullgate.egg-info → nullgate-1.2.8}/PKG-INFO +1 -1
  2. nullgate-1.2.8/src/nullgate/__init__.py +1 -0
  3. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/runtime.py +21 -0
  4. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/session.py +2 -0
  5. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/transports.py +7 -5
  6. {nullgate-1.2.7 → nullgate-1.2.8/src/nullgate.egg-info}/PKG-INFO +1 -1
  7. {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_commands.py +39 -5
  8. nullgate-1.2.7/src/nullgate/__init__.py +0 -1
  9. {nullgate-1.2.7 → nullgate-1.2.8}/LICENSE +0 -0
  10. {nullgate-1.2.7 → nullgate-1.2.8}/README.md +0 -0
  11. {nullgate-1.2.7 → nullgate-1.2.8}/pyproject.toml +0 -0
  12. {nullgate-1.2.7 → nullgate-1.2.8}/setup.cfg +0 -0
  13. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/account.py +0 -0
  14. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/bridge.py +0 -0
  15. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/client_config.py +0 -0
  16. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/commands.py +0 -0
  17. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/gateway.py +0 -0
  18. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/ingress.py +0 -0
  19. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/supervisor.py +0 -0
  20. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate/wsroute.py +0 -0
  21. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/SOURCES.txt +0 -0
  22. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/dependency_links.txt +0 -0
  23. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/entry_points.txt +0 -0
  24. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/requires.txt +0 -0
  25. {nullgate-1.2.7 → nullgate-1.2.8}/src/nullgate.egg-info/top_level.txt +0 -0
  26. {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_account.py +0 -0
  27. {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_bridge.py +0 -0
  28. {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_gateway.py +0 -0
  29. {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_ingress.py +0 -0
  30. {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_install.py +0 -0
  31. {nullgate-1.2.7 → nullgate-1.2.8}/tests/test_wsroute.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nullgate
3
- Version: 1.2.7
3
+ Version: 1.2.8
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.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)
@@ -534,6 +534,8 @@ def _supervise_tick(
534
534
  pid_file, child = transport_pid_file, "transport"
535
535
  if child is None:
536
536
  return "none"
537
+ old_pid = st.read_pid(pid_file)
538
+ st.reap_child(old_pid)
537
539
  pid_file.unlink(missing_ok=True)
538
540
  recent = [
539
541
  stamp
@@ -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
- ssh_dir = Path(os.path.expanduser("~/.ssh"))
261
- if not prepare_ssh_dir(ssh_dir):
262
- st.stop_one(state / "gateway.pid")
263
- raise SystemExit(f"Failed to initialize SSH directory: {ssh_dir}")
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nullgate
3
- Version: 1.2.7
3
+ Version: 1.2.8
4
4
  Summary: Disposable SSH gateway into confined directory workspaces
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -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.assertIn("Fixed permissions on", err)
544
- self.assertIn("(was 660, now 600)", err)
545
- self.assertEqual(key.stat().st_mode & 0o777, 0o600)
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 = home / ".ssh" / "id_ed25519"
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.assertTrue((home / ".ssh").is_dir())
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