nullgate 1.2.6__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 (32) hide show
  1. {nullgate-1.2.6/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.6 → nullgate-1.2.8}/src/nullgate/ingress.py +87 -32
  4. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/runtime.py +21 -0
  5. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/session.py +326 -32
  6. nullgate-1.2.8/src/nullgate/supervisor.py +70 -0
  7. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/transports.py +34 -5
  8. {nullgate-1.2.6 → nullgate-1.2.8/src/nullgate.egg-info}/PKG-INFO +1 -1
  9. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/SOURCES.txt +1 -0
  10. {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_commands.py +483 -15
  11. nullgate-1.2.8/tests/test_ingress.py +279 -0
  12. nullgate-1.2.6/src/nullgate/__init__.py +0 -1
  13. nullgate-1.2.6/tests/test_ingress.py +0 -119
  14. {nullgate-1.2.6 → nullgate-1.2.8}/LICENSE +0 -0
  15. {nullgate-1.2.6 → nullgate-1.2.8}/README.md +0 -0
  16. {nullgate-1.2.6 → nullgate-1.2.8}/pyproject.toml +0 -0
  17. {nullgate-1.2.6 → nullgate-1.2.8}/setup.cfg +0 -0
  18. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/account.py +0 -0
  19. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/bridge.py +0 -0
  20. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/client_config.py +0 -0
  21. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/commands.py +0 -0
  22. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/gateway.py +0 -0
  23. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/wsroute.py +0 -0
  24. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/dependency_links.txt +0 -0
  25. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/entry_points.txt +0 -0
  26. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/requires.txt +0 -0
  27. {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/top_level.txt +0 -0
  28. {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_account.py +0 -0
  29. {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_bridge.py +0 -0
  30. {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_gateway.py +0 -0
  31. {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_install.py +0 -0
  32. {nullgate-1.2.6 → 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.6
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"
@@ -2,9 +2,11 @@
2
2
  """srv.us reverse tunnel for the Nullgate srvus transport.
3
3
 
4
4
  Holds one AsyncSSH connection to srv.us with a remote port forward pointing back
5
- at the local SSH server. AsyncSSH is already required by the server, so speaking
6
- SSH in-process here removes the OpenSSH client and ssh-keygen from the host's
7
- requirements; the tunnel then works on stripped images that ship neither.
5
+ at the local SSH server, reconnecting with backoff whenever the server drops it
6
+ (srv.us documents wrapping the tunnel in a reconnect loop). AsyncSSH is already
7
+ required by the server, so speaking SSH in-process here removes the OpenSSH
8
+ client and ssh-keygen from the host's requirements; the tunnel then works on
9
+ stripped images that ship neither.
8
10
 
9
11
  Launch with asyncssh available, e.g.:
10
12
  python -m nullgate.ingress --local-port 4822 --slot 1
@@ -30,6 +32,9 @@ from nullgate.account import current_username, ensure_username_environment
30
32
 
31
33
  IDENTITY_SEED_ENV = "NULLGATE_IDENTITY_SEED"
32
34
 
35
+ RECONNECT_MIN = 1.0
36
+ RECONNECT_MAX = 30.0
37
+
33
38
 
34
39
  class TunnelClient(asyncssh.SSHClient):
35
40
  """Surface srv.us protocol messages in the tunnel log.
@@ -96,37 +101,19 @@ async def pump(reader: asyncssh.SSHReader, writer) -> None:
96
101
  writer.flush()
97
102
 
98
103
 
99
- async def run(
100
- host: str,
101
- port: int,
104
+ async def serve_connection(
105
+ connection: asyncssh.SSHClientConnection,
102
106
  local_host: str,
103
107
  local_port: int,
104
108
  slot: int,
105
- key: Path,
106
- username: str,
107
- ) -> int:
108
- ensure_username_environment(username)
109
- ensure_key(key)
110
-
111
- stopped = asyncio.Event()
112
- loop = asyncio.get_running_loop()
113
- for signum in (signal.SIGINT, signal.SIGTERM):
114
- with contextlib.suppress(NotImplementedError):
115
- loop.add_signal_handler(signum, stopped.set)
116
-
117
- # known_hosts=None matches the connection commands this project prints: the
118
- # ingress is disposable and only carries bytes, so it is not authenticated.
119
- connection = await asyncssh.connect(
120
- host,
121
- port=port,
122
- username=username,
123
- client_keys=[str(key)],
124
- known_hosts=None,
125
- client_factory=TunnelClient,
126
- keepalive_interval=30,
127
- keepalive_count_max=3,
128
- )
109
+ stopped: asyncio.Event,
110
+ ) -> bool:
111
+ """Hold one tunnel connection until it closes or a stop is requested.
129
112
 
113
+ Opens the session channel and the remote port forward, then waits. Returns
114
+ True when ``stopped`` was set (clean shutdown), False when the server
115
+ dropped the connection and the caller should reconnect.
116
+ """
130
117
  process = None
131
118
  listener = None
132
119
  tasks: list[asyncio.Task] = []
@@ -156,9 +143,9 @@ async def run(
156
143
  tasks.extend((closed, requested))
157
144
  await asyncio.wait((closed, requested), return_when=asyncio.FIRST_COMPLETED)
158
145
  if requested.done():
159
- return 0
146
+ return True
160
147
  print("nullgate-ingress: connection closed by the server", flush=True)
161
- return 1
148
+ return False
162
149
  finally:
163
150
  if listener is not None:
164
151
  listener.close()
@@ -172,6 +159,74 @@ async def run(
172
159
  await asyncio.gather(*tasks, return_exceptions=True)
173
160
 
174
161
 
162
+ async def backoff(stopped: asyncio.Event, delay: float) -> None:
163
+ """Wait out a reconnect delay, returning early on a requested stop."""
164
+ with contextlib.suppress(asyncio.TimeoutError):
165
+ await asyncio.wait_for(stopped.wait(), timeout=delay)
166
+
167
+
168
+ async def run(
169
+ host: str,
170
+ port: int,
171
+ local_host: str,
172
+ local_port: int,
173
+ slot: int,
174
+ key: Path,
175
+ username: str,
176
+ ) -> int:
177
+ ensure_username_environment(username)
178
+ ensure_key(key)
179
+
180
+ stopped = asyncio.Event()
181
+ loop = asyncio.get_running_loop()
182
+ for signum in (signal.SIGINT, signal.SIGTERM):
183
+ with contextlib.suppress(NotImplementedError):
184
+ loop.add_signal_handler(signum, stopped.set)
185
+
186
+ # Retry forever: srv.us documents wrapping the tunnel in a reconnect loop,
187
+ # and a disposable gateway must survive transient server-side flaps.
188
+ delay = RECONNECT_MIN
189
+ attempt = 0
190
+ while not stopped.is_set():
191
+ try:
192
+ # known_hosts=None matches the connection commands this project
193
+ # prints: the ingress is disposable and only carries bytes, so it
194
+ # is not authenticated.
195
+ connection = await asyncssh.connect(
196
+ host,
197
+ port=port,
198
+ username=username,
199
+ client_keys=[str(key)],
200
+ known_hosts=None,
201
+ client_factory=TunnelClient,
202
+ keepalive_interval=30,
203
+ keepalive_count_max=3,
204
+ )
205
+ if attempt:
206
+ print("nullgate-ingress: reconnected", flush=True)
207
+ attempt = 0
208
+ delay = RECONNECT_MIN
209
+ clean = await serve_connection(
210
+ connection, local_host, local_port, slot, stopped
211
+ )
212
+ except (OSError, asyncssh.Error) as error:
213
+ print(f"nullgate-ingress: {error}", flush=True)
214
+ clean = False
215
+ if clean:
216
+ return 0
217
+ attempt += 1
218
+ if stopped.is_set():
219
+ break
220
+ delay = min(delay, RECONNECT_MAX)
221
+ print(
222
+ f"nullgate-ingress: reconnecting in {delay:g}s (attempt {attempt})",
223
+ flush=True,
224
+ )
225
+ await backoff(stopped, delay)
226
+ delay *= 2
227
+ return 0
228
+
229
+
175
230
  def main(argv: list[str] | None = None) -> int:
176
231
  parser = argparse.ArgumentParser(description=__doc__)
177
232
  parser.add_argument("--host", default="srv.us", help="tunnel host")
@@ -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)