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/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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nullgate = nullgate.commands:main
@@ -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