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.
- {nullgate-1.2.6/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.6 → nullgate-1.2.8}/src/nullgate/ingress.py +87 -32
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/runtime.py +21 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/session.py +326 -32
- nullgate-1.2.8/src/nullgate/supervisor.py +70 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/transports.py +34 -5
- {nullgate-1.2.6 → nullgate-1.2.8/src/nullgate.egg-info}/PKG-INFO +1 -1
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/SOURCES.txt +1 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_commands.py +483 -15
- nullgate-1.2.8/tests/test_ingress.py +279 -0
- nullgate-1.2.6/src/nullgate/__init__.py +0 -1
- nullgate-1.2.6/tests/test_ingress.py +0 -119
- {nullgate-1.2.6 → nullgate-1.2.8}/LICENSE +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/README.md +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/pyproject.toml +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/setup.cfg +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/account.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/bridge.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/client_config.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/commands.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/gateway.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate/wsroute.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/dependency_links.txt +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/entry_points.txt +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/requires.txt +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/src/nullgate.egg-info/top_level.txt +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_account.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_bridge.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_gateway.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_install.py +0 -0
- {nullgate-1.2.6 → nullgate-1.2.8}/tests/test_wsroute.py +0 -0
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
146
|
+
return True
|
|
160
147
|
print("nullgate-ingress: connection closed by the server", flush=True)
|
|
161
|
-
return
|
|
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)
|