nullgate 1.1.0__py3-none-any.whl
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/__init__.py +1 -0
- nullgate/account.py +23 -0
- nullgate/bridge.py +162 -0
- nullgate/client_config.py +116 -0
- nullgate/commands.py +172 -0
- nullgate/gateway.py +897 -0
- nullgate/ingress.py +206 -0
- nullgate/runtime.py +236 -0
- nullgate/session.py +509 -0
- nullgate/transports.py +406 -0
- nullgate/wsroute.py +483 -0
- nullgate-1.1.0.dist-info/METADATA +10 -0
- nullgate-1.1.0.dist-info/RECORD +17 -0
- nullgate-1.1.0.dist-info/WHEEL +5 -0
- nullgate-1.1.0.dist-info/entry_points.txt +2 -0
- nullgate-1.1.0.dist-info/licenses/LICENSE +21 -0
- nullgate-1.1.0.dist-info/top_level.txt +1 -0
nullgate/wsroute.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Upterm WSS ingress for the Nullgate upterm transport.
|
|
3
|
+
|
|
4
|
+
Nullgate connects outbound to an Upterm relay over WebSocket, registers a
|
|
5
|
+
session via Upterm's SSH protocol, and reverse-forwards incoming connections back
|
|
6
|
+
to the local AsyncSSH server.
|
|
7
|
+
|
|
8
|
+
The client connects through Nullgate's ``upterm-proxy`` subcommand, which bridges
|
|
9
|
+
stdin/stdout to the Upterm relay over WebSocket.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import base64
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import signal
|
|
22
|
+
import socket
|
|
23
|
+
import struct
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
27
|
+
|
|
28
|
+
import asyncssh
|
|
29
|
+
import websockets
|
|
30
|
+
|
|
31
|
+
from nullgate.account import current_username, ensure_username_environment
|
|
32
|
+
from nullgate.ingress import ensure_key
|
|
33
|
+
|
|
34
|
+
DEFAULT_ENDPOINT = "wss://uptermd.upterm.dev"
|
|
35
|
+
CHUNK = 65536
|
|
36
|
+
WS_OPTIONS = dict(
|
|
37
|
+
max_size=None,
|
|
38
|
+
ping_interval=20,
|
|
39
|
+
ping_timeout=20,
|
|
40
|
+
close_timeout=5,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Uptermd routes WebSocket connections by these exact HTTP header values. They
|
|
44
|
+
# are full SSH identification strings, unlike AsyncSSH's ``client_version``
|
|
45
|
+
# option below, which expects the value without the ``SSH-2.0-`` prefix.
|
|
46
|
+
CLIENT_CLIENT_VERSION = "SSH-2.0-upterm-client-client"
|
|
47
|
+
HOST_CLIENT_VERSION = "SSH-2.0-upterm-host-client"
|
|
48
|
+
SESSION_REQUEST = b"upterm-create-session@upterm.dev"
|
|
49
|
+
MSG_REQUEST_SUCCESS = 81
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def log(message: str) -> None:
|
|
53
|
+
sys.stderr.write("nullgate-wsroute: " + message + "\n")
|
|
54
|
+
sys.stderr.flush()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _encode_varint(value: int) -> bytes:
|
|
58
|
+
parts = bytearray()
|
|
59
|
+
while True:
|
|
60
|
+
towrite = value & 0x7F
|
|
61
|
+
value >>= 7
|
|
62
|
+
if value:
|
|
63
|
+
parts.append(towrite | 0x80)
|
|
64
|
+
else:
|
|
65
|
+
parts.append(towrite)
|
|
66
|
+
break
|
|
67
|
+
return bytes(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _decode_varint(data: bytes, offset: int = 0) -> tuple[int, int]:
|
|
71
|
+
value = 0
|
|
72
|
+
shift = 0
|
|
73
|
+
while offset < len(data):
|
|
74
|
+
byte = data[offset]
|
|
75
|
+
offset += 1
|
|
76
|
+
value |= (byte & 0x7F) << shift
|
|
77
|
+
if not (byte & 0x80):
|
|
78
|
+
return value, offset
|
|
79
|
+
shift += 7
|
|
80
|
+
raise ValueError("Truncated protobuf varint")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _encode_bytes_field(field_number: int, data: bytes) -> bytes:
|
|
84
|
+
tag = _encode_varint((field_number << 3) | 2)
|
|
85
|
+
return tag + _encode_varint(len(data)) + data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _encode_string_field(field_number: int, text: str) -> bytes:
|
|
89
|
+
return _encode_bytes_field(field_number, text.encode())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def encode_create_session(
|
|
93
|
+
status: str,
|
|
94
|
+
host_keys: list[bytes],
|
|
95
|
+
authorized_keys: list[bytes],
|
|
96
|
+
) -> bytes:
|
|
97
|
+
"""Encode an Upterm CreateSessionRequest protobuf message."""
|
|
98
|
+
chunks: list[bytes] = []
|
|
99
|
+
if status:
|
|
100
|
+
chunks.append(_encode_string_field(1, status))
|
|
101
|
+
for host_key in host_keys:
|
|
102
|
+
chunks.append(_encode_bytes_field(2, host_key))
|
|
103
|
+
for authorized_key in authorized_keys:
|
|
104
|
+
chunks.append(_encode_bytes_field(3, authorized_key))
|
|
105
|
+
return b"".join(chunks)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def decode_create_session_response(data: bytes) -> dict[str, str]:
|
|
109
|
+
"""Decode an Upterm CreateSessionResponse protobuf message."""
|
|
110
|
+
offset = 0
|
|
111
|
+
session_id = ""
|
|
112
|
+
node_addr = ""
|
|
113
|
+
user = ""
|
|
114
|
+
while offset < len(data):
|
|
115
|
+
tag, offset = _decode_varint(data, offset)
|
|
116
|
+
field_number = tag >> 3
|
|
117
|
+
wire_type = tag & 0x07
|
|
118
|
+
if wire_type == 0:
|
|
119
|
+
_, offset = _decode_varint(data, offset)
|
|
120
|
+
elif wire_type == 2:
|
|
121
|
+
length, offset = _decode_varint(data, offset)
|
|
122
|
+
if offset + length > len(data):
|
|
123
|
+
raise ValueError("Truncated protobuf length-delimited field")
|
|
124
|
+
field_data = data[offset : offset + length]
|
|
125
|
+
offset += length
|
|
126
|
+
if field_number == 1:
|
|
127
|
+
session_id = field_data.decode(errors="replace")
|
|
128
|
+
elif field_number == 2:
|
|
129
|
+
node_addr = field_data.decode(errors="replace")
|
|
130
|
+
elif field_number == 3:
|
|
131
|
+
user = field_data.decode(errors="replace")
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"Unsupported protobuf wire type: {wire_type}")
|
|
134
|
+
return {"session_id": session_id, "node_addr": node_addr, "ssh_user": user}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _clean_websocket_url(url: str) -> tuple[str, str, str]:
|
|
138
|
+
parsed = urlsplit(url)
|
|
139
|
+
if parsed.scheme not in ("ws", "wss") or not parsed.hostname:
|
|
140
|
+
raise ValueError(f"Invalid Upterm WebSocket URL: {url}")
|
|
141
|
+
username = parsed.username or ""
|
|
142
|
+
password = parsed.password or ""
|
|
143
|
+
host = parsed.hostname
|
|
144
|
+
if parsed.port:
|
|
145
|
+
host += f":{parsed.port}"
|
|
146
|
+
clean = urlunsplit((parsed.scheme, host, parsed.path or "/", parsed.query, ""))
|
|
147
|
+
return clean, username, password
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def websocket_headers(username: str, password: str, *, client: bool) -> dict[str, str]:
|
|
151
|
+
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
152
|
+
return {
|
|
153
|
+
"Authorization": f"Basic {credentials}",
|
|
154
|
+
"Upterm-Client-Version": (
|
|
155
|
+
CLIENT_CLIENT_VERSION if client else HOST_CLIENT_VERSION
|
|
156
|
+
),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def client_proxy_url(endpoint: str, session_id: str, node_addr: str) -> str:
|
|
161
|
+
clean, _, _ = _clean_websocket_url(endpoint)
|
|
162
|
+
parsed = urlsplit(clean)
|
|
163
|
+
encoded_node = base64.urlsafe_b64encode(node_addr.encode()).decode()
|
|
164
|
+
userinfo = f"{quote(session_id, safe='')}:{quote(encoded_node, safe='')}@"
|
|
165
|
+
return urlunsplit(
|
|
166
|
+
(parsed.scheme, userinfo + parsed.netloc, parsed.path, parsed.query, "")
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def endpoint_host(endpoint: str) -> str:
|
|
171
|
+
clean, _, _ = _clean_websocket_url(endpoint)
|
|
172
|
+
return urlsplit(clean).hostname or "uptermd.upterm.dev"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def session_proxy_url(session: dict[str, str]) -> str:
|
|
176
|
+
return client_proxy_url(
|
|
177
|
+
session["endpoint"], session["session_id"], session["node_addr"]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def ssh_proxy_url(session: dict[str, str]) -> str:
|
|
182
|
+
"""Escape an Upterm URL for OpenSSH ProxyCommand percent expansion."""
|
|
183
|
+
return session_proxy_url(session).replace("%", "%%")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def read_session(path: Path) -> dict[str, str] | None:
|
|
187
|
+
try:
|
|
188
|
+
value = json.loads(path.read_text())
|
|
189
|
+
except (OSError, ValueError):
|
|
190
|
+
return None
|
|
191
|
+
required = ("session_id", "node_addr", "ssh_user", "endpoint")
|
|
192
|
+
if not all(isinstance(value.get(key), str) and value[key] for key in required):
|
|
193
|
+
return None
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _write_json_atomic(path: Path, value: dict[str, str]) -> None:
|
|
198
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
199
|
+
temporary.write_text(json.dumps(value, separators=(",", ":")) + "\n")
|
|
200
|
+
temporary.chmod(0o600)
|
|
201
|
+
os.replace(temporary, path)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _write_bytes_atomic(path: Path, value: bytes) -> None:
|
|
205
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
206
|
+
temporary.write_bytes(value)
|
|
207
|
+
temporary.chmod(0o600)
|
|
208
|
+
os.replace(temporary, path)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _authorized_public_keys(path: Path) -> list[bytes]:
|
|
212
|
+
if not path.is_file():
|
|
213
|
+
return []
|
|
214
|
+
keys: list[bytes] = []
|
|
215
|
+
for raw_line in path.read_text(errors="replace").splitlines():
|
|
216
|
+
try:
|
|
217
|
+
fields = shlex.split(raw_line, comments=True)
|
|
218
|
+
except ValueError:
|
|
219
|
+
continue
|
|
220
|
+
for index in range(max(0, len(fields) - 1)):
|
|
221
|
+
try:
|
|
222
|
+
key = asyncssh.import_public_key(
|
|
223
|
+
f"{fields[index]} {fields[index + 1]}"
|
|
224
|
+
)
|
|
225
|
+
except (asyncssh.KeyImportError, ValueError):
|
|
226
|
+
continue
|
|
227
|
+
keys.append(key.export_public_key())
|
|
228
|
+
break
|
|
229
|
+
return keys
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class WebSocketSocketBridge:
|
|
233
|
+
"""Expose a WebSocket byte stream as one side of a socketpair."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, websocket, ssh_socket: socket.socket, pump_socket: socket.socket):
|
|
236
|
+
self.websocket = websocket
|
|
237
|
+
self.ssh_socket = ssh_socket
|
|
238
|
+
self.pump_socket = pump_socket
|
|
239
|
+
self.tasks: list[asyncio.Task] = []
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
async def open(cls, url: str, headers: dict[str, str]) -> "WebSocketSocketBridge":
|
|
243
|
+
websocket = await websockets.connect(
|
|
244
|
+
url, additional_headers=headers, **WS_OPTIONS
|
|
245
|
+
)
|
|
246
|
+
ssh_socket, pump_socket = socket.socketpair()
|
|
247
|
+
ssh_socket.setblocking(False)
|
|
248
|
+
pump_socket.setblocking(False)
|
|
249
|
+
bridge = cls(websocket, ssh_socket, pump_socket)
|
|
250
|
+
bridge.tasks = [
|
|
251
|
+
asyncio.create_task(bridge._socket_to_websocket()),
|
|
252
|
+
asyncio.create_task(bridge._websocket_to_socket()),
|
|
253
|
+
]
|
|
254
|
+
return bridge
|
|
255
|
+
|
|
256
|
+
async def _socket_to_websocket(self) -> None:
|
|
257
|
+
loop = asyncio.get_running_loop()
|
|
258
|
+
try:
|
|
259
|
+
while data := await loop.sock_recv(self.pump_socket, CHUNK):
|
|
260
|
+
await self.websocket.send(data)
|
|
261
|
+
except (OSError, websockets.ConnectionClosed):
|
|
262
|
+
pass
|
|
263
|
+
finally:
|
|
264
|
+
with contextlib.suppress(Exception):
|
|
265
|
+
await self.websocket.close()
|
|
266
|
+
|
|
267
|
+
async def _websocket_to_socket(self) -> None:
|
|
268
|
+
loop = asyncio.get_running_loop()
|
|
269
|
+
try:
|
|
270
|
+
async for message in self.websocket:
|
|
271
|
+
if isinstance(message, str):
|
|
272
|
+
message = message.encode()
|
|
273
|
+
await loop.sock_sendall(self.pump_socket, message)
|
|
274
|
+
except (OSError, websockets.ConnectionClosed):
|
|
275
|
+
pass
|
|
276
|
+
finally:
|
|
277
|
+
with contextlib.suppress(OSError):
|
|
278
|
+
self.pump_socket.shutdown(socket.SHUT_RDWR)
|
|
279
|
+
|
|
280
|
+
async def close(self) -> None:
|
|
281
|
+
with contextlib.suppress(Exception):
|
|
282
|
+
await self.websocket.close()
|
|
283
|
+
for sock in (self.ssh_socket, self.pump_socket):
|
|
284
|
+
with contextlib.suppress(OSError):
|
|
285
|
+
sock.close()
|
|
286
|
+
for task in self.tasks:
|
|
287
|
+
task.cancel()
|
|
288
|
+
for task in self.tasks:
|
|
289
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
290
|
+
await task
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def request_session(connection, payload: bytes) -> dict[str, str]:
|
|
294
|
+
method = getattr(connection, "_make_global_request", None)
|
|
295
|
+
if method is None:
|
|
296
|
+
raise RuntimeError("AsyncSSH no longer supports Upterm global requests")
|
|
297
|
+
packet_type, packet = await method(SESSION_REQUEST, payload)
|
|
298
|
+
if packet_type != MSG_REQUEST_SUCCESS:
|
|
299
|
+
raise RuntimeError("Upterm rejected the session request")
|
|
300
|
+
return decode_create_session_response(packet.get_remaining_payload())
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def run_origin_once(args: argparse.Namespace) -> bool:
|
|
304
|
+
endpoint, _, _ = _clean_websocket_url(args.endpoint)
|
|
305
|
+
ensure_username_environment(args.username)
|
|
306
|
+
ensure_key(args.key)
|
|
307
|
+
control_key = asyncssh.read_private_key(str(args.key))
|
|
308
|
+
host_key = asyncssh.read_private_key(str(args.host_key))
|
|
309
|
+
client_keys = [] if args.accept else _authorized_public_keys(args.authorized_keys)
|
|
310
|
+
if not args.accept and not client_keys:
|
|
311
|
+
raise ValueError("Upterm requires an authorized key unless --accept is set")
|
|
312
|
+
|
|
313
|
+
bridge = await WebSocketSocketBridge.open(
|
|
314
|
+
endpoint, websocket_headers(args.username, "", client=False)
|
|
315
|
+
)
|
|
316
|
+
connection = None
|
|
317
|
+
listener = None
|
|
318
|
+
try:
|
|
319
|
+
connection = await asyncssh.connect(
|
|
320
|
+
endpoint_host(endpoint),
|
|
321
|
+
sock=bridge.ssh_socket,
|
|
322
|
+
username=args.username,
|
|
323
|
+
client_keys=[control_key],
|
|
324
|
+
known_hosts=None,
|
|
325
|
+
client_version="upterm-host-client",
|
|
326
|
+
keepalive_interval=10,
|
|
327
|
+
keepalive_count_max=3,
|
|
328
|
+
login_timeout=20,
|
|
329
|
+
)
|
|
330
|
+
relay_key = connection.get_server_host_key()
|
|
331
|
+
if relay_key is None:
|
|
332
|
+
raise RuntimeError("Upterm relay did not present an SSH host key")
|
|
333
|
+
_write_bytes_atomic(args.ca_keys, relay_key.export_public_key())
|
|
334
|
+
|
|
335
|
+
payload = encode_create_session(
|
|
336
|
+
args.username,
|
|
337
|
+
[host_key.export_public_key()],
|
|
338
|
+
client_keys,
|
|
339
|
+
)
|
|
340
|
+
session = await request_session(connection, payload)
|
|
341
|
+
listener = await connection.forward_remote_path_to_port(
|
|
342
|
+
session["session_id"], args.local_host, args.local_port
|
|
343
|
+
)
|
|
344
|
+
published = {**session, "endpoint": endpoint}
|
|
345
|
+
_write_json_atomic(args.session_file, published)
|
|
346
|
+
proxy = session_proxy_url(published)
|
|
347
|
+
log(f"session ready: ssh {session['ssh_user']}@{endpoint_host(endpoint)}")
|
|
348
|
+
log(f"client proxy: nullgate upterm-proxy {proxy}")
|
|
349
|
+
await connection.wait_closed()
|
|
350
|
+
return True
|
|
351
|
+
finally:
|
|
352
|
+
args.session_file.unlink(missing_ok=True)
|
|
353
|
+
if listener is not None:
|
|
354
|
+
listener.close()
|
|
355
|
+
await listener.wait_closed()
|
|
356
|
+
if connection is not None:
|
|
357
|
+
connection.close()
|
|
358
|
+
await connection.wait_closed()
|
|
359
|
+
await bridge.close()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def run_origin(args: argparse.Namespace) -> None:
|
|
363
|
+
stopped = asyncio.Event()
|
|
364
|
+
loop = asyncio.get_running_loop()
|
|
365
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
366
|
+
with contextlib.suppress(NotImplementedError):
|
|
367
|
+
loop.add_signal_handler(signum, stopped.set)
|
|
368
|
+
delay = 1
|
|
369
|
+
args.session_file.unlink(missing_ok=True)
|
|
370
|
+
while not stopped.is_set():
|
|
371
|
+
origin = asyncio.create_task(run_origin_once(args))
|
|
372
|
+
stopping = asyncio.create_task(stopped.wait())
|
|
373
|
+
try:
|
|
374
|
+
done, _ = await asyncio.wait(
|
|
375
|
+
(origin, stopping), return_when=asyncio.FIRST_COMPLETED
|
|
376
|
+
)
|
|
377
|
+
if stopping in done:
|
|
378
|
+
origin.cancel()
|
|
379
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
380
|
+
await origin
|
|
381
|
+
break
|
|
382
|
+
stopping.cancel()
|
|
383
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
384
|
+
await stopping
|
|
385
|
+
if not await origin:
|
|
386
|
+
raise RuntimeError("Upterm connection ended before publishing a session")
|
|
387
|
+
delay = 1
|
|
388
|
+
except asyncio.CancelledError:
|
|
389
|
+
origin.cancel()
|
|
390
|
+
stopping.cancel()
|
|
391
|
+
raise
|
|
392
|
+
except Exception as error: # noqa: BLE001 - tunnel supervisor
|
|
393
|
+
stopping.cancel()
|
|
394
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
395
|
+
await stopping
|
|
396
|
+
log(f"connection ended: {error!r}; retrying in {delay}s")
|
|
397
|
+
try:
|
|
398
|
+
await asyncio.wait_for(stopped.wait(), timeout=delay)
|
|
399
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
400
|
+
pass
|
|
401
|
+
delay = min(delay * 2, 30)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
async def run_client(url: str) -> None:
|
|
405
|
+
endpoint, session_id, encoded_node = _clean_websocket_url(url)
|
|
406
|
+
if not session_id:
|
|
407
|
+
raise ValueError("Upterm proxy URL is missing its session ID")
|
|
408
|
+
async with websockets.connect(
|
|
409
|
+
endpoint,
|
|
410
|
+
additional_headers=websocket_headers(session_id, encoded_node, client=True),
|
|
411
|
+
**WS_OPTIONS,
|
|
412
|
+
) as websocket:
|
|
413
|
+
loop = asyncio.get_running_loop()
|
|
414
|
+
|
|
415
|
+
async def stdin_to_websocket() -> None:
|
|
416
|
+
try:
|
|
417
|
+
while data := await loop.run_in_executor(None, os.read, 0, CHUNK):
|
|
418
|
+
await websocket.send(data)
|
|
419
|
+
except websockets.ConnectionClosed:
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
async def websocket_to_stdout() -> None:
|
|
423
|
+
try:
|
|
424
|
+
async for message in websocket:
|
|
425
|
+
if isinstance(message, str):
|
|
426
|
+
message = message.encode()
|
|
427
|
+
view = memoryview(message)
|
|
428
|
+
while view:
|
|
429
|
+
view = view[os.write(1, view) :]
|
|
430
|
+
except (websockets.ConnectionClosed, BrokenPipeError):
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
tasks = [
|
|
434
|
+
asyncio.create_task(stdin_to_websocket()),
|
|
435
|
+
asyncio.create_task(websocket_to_stdout()),
|
|
436
|
+
]
|
|
437
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
438
|
+
for task in pending:
|
|
439
|
+
task.cancel()
|
|
440
|
+
for task in pending:
|
|
441
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
442
|
+
await task
|
|
443
|
+
for task in done:
|
|
444
|
+
with contextlib.suppress(websockets.ConnectionClosed, BrokenPipeError):
|
|
445
|
+
task.result()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
449
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
450
|
+
sub = parser.add_subparsers(dest="mode", required=True)
|
|
451
|
+
origin = sub.add_parser("origin")
|
|
452
|
+
origin.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
|
|
453
|
+
origin.add_argument("--local-host", default="127.0.0.1")
|
|
454
|
+
origin.add_argument("--local-port", type=int, required=True)
|
|
455
|
+
origin.add_argument("--username", default=current_username())
|
|
456
|
+
origin.add_argument("--key", type=Path, required=True)
|
|
457
|
+
origin.add_argument("--host-key", type=Path, required=True)
|
|
458
|
+
origin.add_argument("--authorized-keys", type=Path, required=True)
|
|
459
|
+
origin.add_argument("--ca-keys", type=Path, required=True)
|
|
460
|
+
origin.add_argument("--session-file", type=Path, required=True)
|
|
461
|
+
origin.add_argument("--accept", action="store_true")
|
|
462
|
+
client = sub.add_parser("client")
|
|
463
|
+
client.add_argument("url")
|
|
464
|
+
return parser
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def main(argv: list[str] | None = None) -> int:
|
|
468
|
+
args = build_parser().parse_args(argv)
|
|
469
|
+
try:
|
|
470
|
+
if args.mode == "origin":
|
|
471
|
+
asyncio.run(run_origin(args))
|
|
472
|
+
else:
|
|
473
|
+
asyncio.run(run_client(args.url))
|
|
474
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
475
|
+
return 130
|
|
476
|
+
except (OSError, ValueError, RuntimeError, asyncssh.Error) as error:
|
|
477
|
+
print(f"nullgate-wsroute: {error}", file=sys.stderr)
|
|
478
|
+
return 1
|
|
479
|
+
return 0
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
if __name__ == "__main__":
|
|
483
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nullgate
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Temporary SSH, SFTP, and SCP access to ephemeral workspaces
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: asyncssh==2.24.0
|
|
8
|
+
Requires-Dist: cryptography>=39
|
|
9
|
+
Requires-Dist: websockets
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
nullgate/__init__.py,sha256=LGVQyDsWifdACo7qztwb8RWWHds1E7uQ-ZqD8SAjyw4,22
|
|
2
|
+
nullgate/account.py,sha256=TaUkEfUhtoZebDlmUMSYSy_j8Lwd70QmvdTgM-GHV5g,713
|
|
3
|
+
nullgate/bridge.py,sha256=wOsyZQAMVpGF69x0YqTeindlt7aVdjgkNG6vRo_504A,5161
|
|
4
|
+
nullgate/client_config.py,sha256=BHLn3E2f7j8eJINPH6MYxlTv36dgY1Qkzh0tTwNDZkQ,3705
|
|
5
|
+
nullgate/commands.py,sha256=vhkKXINvVLR9_9APljPgfkSe49FWKGzIYqEJoiUS8yQ,4846
|
|
6
|
+
nullgate/gateway.py,sha256=O_c2yuWxtcn6KpQ5SxZbtSRMhGfvgA-QnkpF-jgPnIM,32410
|
|
7
|
+
nullgate/ingress.py,sha256=pMcVcAZ-LyhH5U0iMx6wurexigMGWCxKcNQAHINAlvo,7040
|
|
8
|
+
nullgate/runtime.py,sha256=xV9fDUSIoSY51bqXM-gxIcdoqBv_PJY6SDQu4qXXtQ4,6959
|
|
9
|
+
nullgate/session.py,sha256=YV2xXwWAVy-al6OddG-wmOrVEVTkV6iEh5vtLM_EgMU,16873
|
|
10
|
+
nullgate/transports.py,sha256=lyDQ9G0HMrXwMRM2NjLFNqWa1IG5IhzX_eghC0f7w5k,12711
|
|
11
|
+
nullgate/wsroute.py,sha256=rWAVePLSBH1LLNgzuIk7KM4euO06S9T2v3WcsKhI15c,17351
|
|
12
|
+
nullgate-1.1.0.dist-info/licenses/LICENSE,sha256=XTtNEUzWx0pTKaR1SeI8HmOd6REwQNBqk4GutP5vpYo,1061
|
|
13
|
+
nullgate-1.1.0.dist-info/METADATA,sha256=Hc33AibtGpaqaw3pFWxnTPBvGO83siITnn4IcByKR8M,279
|
|
14
|
+
nullgate-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
nullgate-1.1.0.dist-info/entry_points.txt,sha256=ievLSlQlI80j6--FPOmePbO1aCPPNIObGiMW8S9X1gQ,52
|
|
16
|
+
nullgate-1.1.0.dist-info/top_level.txt,sha256=3Q1qRFLv7W5b4CFGRbkBHqrUx6Dos2iiiTksU9BcEWI,9
|
|
17
|
+
nullgate-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lars
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nullgate
|