nullgate 1.1.0__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.1.0/LICENSE +21 -0
- nullgate-1.1.0/PKG-INFO +10 -0
- nullgate-1.1.0/README.md +166 -0
- nullgate-1.1.0/pyproject.toml +23 -0
- nullgate-1.1.0/setup.cfg +4 -0
- nullgate-1.1.0/src/nullgate/__init__.py +1 -0
- nullgate-1.1.0/src/nullgate/account.py +23 -0
- nullgate-1.1.0/src/nullgate/bridge.py +162 -0
- nullgate-1.1.0/src/nullgate/client_config.py +116 -0
- nullgate-1.1.0/src/nullgate/commands.py +172 -0
- nullgate-1.1.0/src/nullgate/gateway.py +897 -0
- nullgate-1.1.0/src/nullgate/ingress.py +206 -0
- nullgate-1.1.0/src/nullgate/runtime.py +236 -0
- nullgate-1.1.0/src/nullgate/session.py +509 -0
- nullgate-1.1.0/src/nullgate/transports.py +406 -0
- nullgate-1.1.0/src/nullgate/wsroute.py +483 -0
- nullgate-1.1.0/src/nullgate.egg-info/PKG-INFO +10 -0
- nullgate-1.1.0/src/nullgate.egg-info/SOURCES.txt +27 -0
- nullgate-1.1.0/src/nullgate.egg-info/dependency_links.txt +1 -0
- nullgate-1.1.0/src/nullgate.egg-info/entry_points.txt +2 -0
- nullgate-1.1.0/src/nullgate.egg-info/requires.txt +3 -0
- nullgate-1.1.0/src/nullgate.egg-info/top_level.txt +1 -0
- nullgate-1.1.0/tests/test_account.py +25 -0
- nullgate-1.1.0/tests/test_bridge.py +20 -0
- nullgate-1.1.0/tests/test_commands.py +658 -0
- nullgate-1.1.0/tests/test_gateway.py +755 -0
- nullgate-1.1.0/tests/test_ingress.py +119 -0
- nullgate-1.1.0/tests/test_install.py +306 -0
- nullgate-1.1.0/tests/test_wsroute.py +172 -0
nullgate-1.1.0/LICENSE
ADDED
|
@@ -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.
|
nullgate-1.1.0/PKG-INFO
ADDED
|
@@ -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
|
nullgate-1.1.0/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Nullgate
|
|
2
|
+
|
|
3
|
+
Disposable SSH gateway into confined directory workspaces for remote containers, ephemeral developer boxes, CI nodes, and cloud instances.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Ingress Transports**: Publish endpoints via Upterm WebSocket relays (default), srv.us reverse tunnels, Cloudflare Worker relays, or Cloudflare Argo (`cloudflared`) tunnels.
|
|
8
|
+
- **Confinement**: SFTP and SCP workloads are restricted to the designated workspace root; parent traversals and out-of-root symlinks are blocked.
|
|
9
|
+
- **Host file visibility**: Relative transfer paths begin in the workspace root while absolute paths address host locations, matching shell behavior; add `--confine-sftp` for strict workspace-only transfers.
|
|
10
|
+
- **Direct Shell Access**: Authenticated PTY sessions run interactively under the local user credentials.
|
|
11
|
+
- **Synthetic UID Resilience**: Runs seamlessly in container environments missing user records in `/etc/passwd`.
|
|
12
|
+
- **Persistent Key Identities**: Host keys and identity seeds persist across restarts rather than being silently regenerated.
|
|
13
|
+
- **Safe Process Tracking**: Process launch start times are verified alongside PIDs to prevent signaling recycled processes.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Install from the public distribution channel:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
curl -LsSf https://shadowdocks.github.io/nullgate-dist/install.sh | sh
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The installer downloads the published wheel, verifies its SHA-256 checksum,
|
|
24
|
+
and installs it in an isolated `uv` tool environment. The private source
|
|
25
|
+
repository is not required on the target machine.
|
|
26
|
+
|
|
27
|
+
Alternatively, install the published package from PyPI:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install nullgate
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
### Open
|
|
36
|
+
|
|
37
|
+
Launch the gateway service and ingress transport in the background:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Default Upterm transport on port 4822 sharing current directory
|
|
41
|
+
nullgate open
|
|
42
|
+
|
|
43
|
+
# Custom path, port, and slot
|
|
44
|
+
nullgate open /workspace --port 4822 --slot 1
|
|
45
|
+
|
|
46
|
+
# Select specific transport provider
|
|
47
|
+
nullgate open . --transport upterm
|
|
48
|
+
nullgate open . --transport srvus
|
|
49
|
+
nullgate open . --transport cloudflare --endpoint https://relay.example.com
|
|
50
|
+
nullgate open . --transport cloudflared --hostname workspace.example.com --token "$TUNNEL_TOKEN"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
- `--transport`: Ingress provider (`upterm`, `srvus`, `cloudflare`, `cloudflared`). Defaults to `upterm`.
|
|
55
|
+
- `--endpoint`: WebSocket or relay endpoint URL (used with `upterm` or `cloudflare`).
|
|
56
|
+
- `--hostname`: Public ingress hostname for `cloudflared`.
|
|
57
|
+
- `--token`: Argo tunnel authentication credential (or set via `NULLGATE_CLOUDFLARED_TOKEN`).
|
|
58
|
+
- `--accept`: Skip credential checks and admit any client connection.
|
|
59
|
+
- `--no-accept`: Restore authentication when saved settings previously enabled unrestricted access.
|
|
60
|
+
- `--allow-tcp-forwarding`: Enable SSH TCP tunneling (`ssh -L`).
|
|
61
|
+
- `--no-allow-tcp-forwarding`: Disable forwarding when it was enabled in saved settings.
|
|
62
|
+
- `--confine-sftp`: Confine SFTP and SCP transfers to the workspace root.
|
|
63
|
+
- `--no-confine-sftp`: Restore host-visible transfers when confinement was saved.
|
|
64
|
+
|
|
65
|
+
### Inspect
|
|
66
|
+
|
|
67
|
+
Display operational status, running PIDs, connection URLs, and security settings:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
nullgate inspect
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Enter
|
|
74
|
+
|
|
75
|
+
Print client connection strings and OpenSSH client configuration directives:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
nullgate enter
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Trace
|
|
82
|
+
|
|
83
|
+
Stream or print daemon logs:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
nullgate trace
|
|
87
|
+
nullgate trace -f
|
|
88
|
+
nullgate trace gateway
|
|
89
|
+
nullgate trace transport
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Shut
|
|
93
|
+
|
|
94
|
+
Terminate running gateway and transport background processes:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
nullgate shut
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Cycle
|
|
101
|
+
|
|
102
|
+
Restart daemon instances reusing active or saved workspace settings:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
nullgate cycle
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Upgrade
|
|
109
|
+
|
|
110
|
+
Fetch and install the newest release:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
nullgate upgrade
|
|
114
|
+
nullgate upgrade --version 1.0.1
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Authentication and Security Boundaries
|
|
118
|
+
|
|
119
|
+
- **Authentication Baseline**: Access requires authorized SSH keys in `~/.ssh/authorized_keys` or a high-entropy secret created in the runtime directory.
|
|
120
|
+
- **Unrestricted Mode (`--accept`)**: Bypasses authentication checks entirely; any incoming connection with network access is accepted.
|
|
121
|
+
- **Upterm Transport**: Relies on authorized SSH public keys or `--accept` (Upterm does not support password authentication). The relay itself is trusted for inbound multiplexing.
|
|
122
|
+
- **Filesystem Confinement**: SFTP and SCP access cannot escape the designated workspace directory.
|
|
123
|
+
- **Host Transfer Mode (default)**: Relative SFTP and SCP paths begin in the workspace directory, while absolute paths refer to host filesystem locations subject to OS user permissions. Pass `--confine-sftp` to limit transfers to the workspace namespace and reject symlink escapes.
|
|
124
|
+
- **Interactive Shell Privileges**: Command and terminal sessions execute with the permissions of the host account rather than inside an OS chroot.
|
|
125
|
+
- **State Storage**: Daemon PIDs and logs are maintained in `gateway.pid`, `transport.pid`, `gateway.log`, and `transport.log`, with session records kept in `manifest.json` and `settings.json`.
|
|
126
|
+
|
|
127
|
+
## Environment Variables
|
|
128
|
+
|
|
129
|
+
| Variable | Description |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `NULLGATE_PASSWORD` | Gateway-process credential; normally managed internally by `nullgate open` |
|
|
132
|
+
| `NULLGATE_STATE_DIR` | Directory holding runtime state and logs (defaults to `~/.local/state/nullgate`) |
|
|
133
|
+
| `NULLGATE_IDENTITY_SEED` | Seed value used for deterministic Ed25519 key derivation |
|
|
134
|
+
| `NULLGATE_TUNNEL_HOST` | Host address for srv.us ingress (default: `srv.us`) |
|
|
135
|
+
| `NULLGATE_TUNNEL_PORT` | Port for srv.us ingress (default: `22`) |
|
|
136
|
+
| `NULLGATE_CLOUDFLARED_TOKEN` | Credential token for `cloudflared` tunnels |
|
|
137
|
+
| `NULLGATE_CONFINE_SFTP` | Set to `1` to confine SFTP and SCP transfers to the workspace root |
|
|
138
|
+
| `NULLGATE_VERSION` | Install a specific published version instead of the stable release; accepts `1.0.1` or `v1.0.1` |
|
|
139
|
+
| `NULLGATE_DIST_URL` | Override the public release channel used by `install.sh` |
|
|
140
|
+
| `NULLGATE_INSTALL_URL` | Override the installer URL used by `nullgate upgrade` |
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
Run internal modules directly:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
# Gateway daemon
|
|
148
|
+
python -m nullgate.gateway --root . --port 4822 --accept
|
|
149
|
+
|
|
150
|
+
# srv.us transport
|
|
151
|
+
python -m nullgate.ingress --host srv.us --port 22 --local-port 4822 --slot 1 --key ~/.ssh/id_ed25519
|
|
152
|
+
|
|
153
|
+
# Cloudflare WebSocket bridge
|
|
154
|
+
python -m nullgate.bridge origin wss://relay.example.com/relay/SESSION --port 4822
|
|
155
|
+
python -m nullgate.bridge client wss://relay.example.com/relay/SESSION
|
|
156
|
+
|
|
157
|
+
# Upterm WebSocket client route
|
|
158
|
+
python -m nullgate.wsroute client wss://uptermd.upterm.dev/...
|
|
159
|
+
|
|
160
|
+
# Run test suite
|
|
161
|
+
uv run python -m unittest discover -s tests
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=75"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nullgate"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Temporary SSH, SFTP, and SCP access to ephemeral workspaces"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"asyncssh==2.24.0",
|
|
12
|
+
"cryptography>=39",
|
|
13
|
+
"websockets",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
nullgate = "nullgate.commands:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.dynamic]
|
|
23
|
+
version = {attr = "nullgate.__version__"}
|
nullgate-1.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Current-account helpers for hosts without a passwd database entry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def current_username(default: str = "nullgate") -> str:
|
|
10
|
+
"""Return the current username, or a stable fallback for synthetic UIDs."""
|
|
11
|
+
try:
|
|
12
|
+
return getpass.getuser() or default
|
|
13
|
+
except (KeyError, OSError):
|
|
14
|
+
return default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_username_environment(username: str | None = None) -> str:
|
|
18
|
+
"""Populate the account variables required by libraries such as AsyncSSH."""
|
|
19
|
+
resolved = username or current_username()
|
|
20
|
+
for name in ("USER", "LOGNAME"):
|
|
21
|
+
if not os.environ.get(name):
|
|
22
|
+
os.environ[name] = resolved
|
|
23
|
+
return resolved
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stdio/TCP <-> WebSocket bridge for the Nullgate cloudflare transport.
|
|
3
|
+
|
|
4
|
+
Two modes pump a raw byte stream across a WebSocket relay (a Cloudflare Worker):
|
|
5
|
+
|
|
6
|
+
origin Connects out to the relay and, on the first inbound byte, dials the
|
|
7
|
+
local SSH server, splicing the two. Loops to serve many sessions.
|
|
8
|
+
client Connects out to the relay and splices it to stdin/stdout, so it can be
|
|
9
|
+
used as an ssh ProxyCommand.
|
|
10
|
+
|
|
11
|
+
The relay pairs one "origin" and one "client" socket per tunnel id. The role
|
|
12
|
+
is the final path segment of ``/relay/<session>/<role>``.
|
|
13
|
+
|
|
14
|
+
Launched by the CLI as ``python -m nullgate.bridge`` for both the origin
|
|
15
|
+
(remote) and client (ProxyCommand) roles; it needs the websockets package.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
import websockets
|
|
24
|
+
|
|
25
|
+
CHUNK = 65536
|
|
26
|
+
CONNECT_KWARGS = dict(max_size=None, ping_interval=20, ping_timeout=20, close_timeout=5)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log(message):
|
|
30
|
+
sys.stderr.write("nullgate-bridge: " + message + "\n")
|
|
31
|
+
sys.stderr.flush()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def role_url(base, role):
|
|
35
|
+
return base.rstrip("/") + "/" + role
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write_all(fd, data):
|
|
39
|
+
view = memoryview(data)
|
|
40
|
+
while view:
|
|
41
|
+
written = os.write(fd, view)
|
|
42
|
+
view = view[written:]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _await_first_completed(*coros):
|
|
46
|
+
tasks = [asyncio.ensure_future(coro) for coro in coros]
|
|
47
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
48
|
+
for task in pending:
|
|
49
|
+
task.cancel()
|
|
50
|
+
for task in pending:
|
|
51
|
+
try:
|
|
52
|
+
await task
|
|
53
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001 - draining only
|
|
54
|
+
pass
|
|
55
|
+
for task in done:
|
|
56
|
+
exc = task.exception()
|
|
57
|
+
if exc is not None:
|
|
58
|
+
raise exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def run_client(base):
|
|
62
|
+
url = role_url(base, "client")
|
|
63
|
+
async with websockets.connect(url, **CONNECT_KWARGS) as ws:
|
|
64
|
+
loop = asyncio.get_event_loop()
|
|
65
|
+
stdin_fd = sys.stdin.fileno()
|
|
66
|
+
|
|
67
|
+
async def stdin_to_ws():
|
|
68
|
+
while True:
|
|
69
|
+
data = await loop.run_in_executor(None, os.read, stdin_fd, CHUNK)
|
|
70
|
+
if not data:
|
|
71
|
+
break
|
|
72
|
+
await ws.send(data)
|
|
73
|
+
|
|
74
|
+
async def ws_to_stdout():
|
|
75
|
+
async for message in ws:
|
|
76
|
+
if isinstance(message, str):
|
|
77
|
+
message = message.encode()
|
|
78
|
+
_write_all(1, message)
|
|
79
|
+
|
|
80
|
+
await _await_first_completed(stdin_to_ws(), ws_to_stdout())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def run_origin_once(base, host, port):
|
|
84
|
+
url = role_url(base, "origin")
|
|
85
|
+
async with websockets.connect(url, **CONNECT_KWARGS) as ws:
|
|
86
|
+
# Wait for the client's first byte before dialing the local server, so
|
|
87
|
+
# the server's SSH banner is never emitted into a peerless relay.
|
|
88
|
+
try:
|
|
89
|
+
first = await ws.recv()
|
|
90
|
+
except websockets.ConnectionClosed:
|
|
91
|
+
return
|
|
92
|
+
if isinstance(first, str):
|
|
93
|
+
first = first.encode()
|
|
94
|
+
reader, writer = await asyncio.open_connection(host, port)
|
|
95
|
+
writer.write(first)
|
|
96
|
+
await writer.drain()
|
|
97
|
+
|
|
98
|
+
async def tcp_to_ws():
|
|
99
|
+
while True:
|
|
100
|
+
data = await reader.read(CHUNK)
|
|
101
|
+
if not data:
|
|
102
|
+
break
|
|
103
|
+
await ws.send(data)
|
|
104
|
+
|
|
105
|
+
async def ws_to_tcp():
|
|
106
|
+
async for message in ws:
|
|
107
|
+
if isinstance(message, str):
|
|
108
|
+
message = message.encode()
|
|
109
|
+
writer.write(message)
|
|
110
|
+
await writer.drain()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
await _await_first_completed(tcp_to_ws(), ws_to_tcp())
|
|
114
|
+
finally:
|
|
115
|
+
writer.close()
|
|
116
|
+
try:
|
|
117
|
+
await writer.wait_closed()
|
|
118
|
+
except Exception: # noqa: BLE001 - best-effort teardown
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def run_origin(base, host, port):
|
|
123
|
+
delay = 1
|
|
124
|
+
while True:
|
|
125
|
+
try:
|
|
126
|
+
await run_origin_once(base, host, port)
|
|
127
|
+
delay = 1
|
|
128
|
+
except asyncio.CancelledError:
|
|
129
|
+
raise
|
|
130
|
+
except Exception as exc: # noqa: BLE001 - keep the tunnel alive
|
|
131
|
+
log("origin session ended: " + repr(exc))
|
|
132
|
+
await asyncio.sleep(delay)
|
|
133
|
+
delay = min(delay * 2, 30)
|
|
134
|
+
continue
|
|
135
|
+
await asyncio.sleep(0.5)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main(argv=None):
|
|
139
|
+
parser = argparse.ArgumentParser(description="Nullgate WebSocket bridge")
|
|
140
|
+
sub = parser.add_subparsers(dest="mode", required=True)
|
|
141
|
+
|
|
142
|
+
client = sub.add_parser("client", help="stdio <-> relay (ssh ProxyCommand)")
|
|
143
|
+
client.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
|
|
144
|
+
|
|
145
|
+
origin = sub.add_parser("origin", help="local SSH server <-> relay")
|
|
146
|
+
origin.add_argument("url", help="base relay URL, e.g. wss://host/relay/ID")
|
|
147
|
+
origin.add_argument("--host", default="127.0.0.1")
|
|
148
|
+
origin.add_argument("--port", type=int, required=True)
|
|
149
|
+
|
|
150
|
+
args = parser.parse_args(argv)
|
|
151
|
+
try:
|
|
152
|
+
if args.mode == "client":
|
|
153
|
+
asyncio.run(run_client(args.url))
|
|
154
|
+
else:
|
|
155
|
+
asyncio.run(run_origin(args.url, args.host, args.port))
|
|
156
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
157
|
+
return 130
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The ssh-config block for Nullgate.
|
|
2
|
+
|
|
3
|
+
``ssh-config`` prints a ``Host`` block so plain ``ssh USER@HOST`` works on the
|
|
4
|
+
connecting machine. With no HOST it prints the default ``Host *.srv.us`` block
|
|
5
|
+
for the srv.us transport; with a HOST it prints a block for that host using the
|
|
6
|
+
currently configured transport's ProxyCommand. ``--write`` appends it to
|
|
7
|
+
~/.ssh/config (never prepends, which would drag any leading global keywords
|
|
8
|
+
under the new Host block), is idempotent, chmods the file to 0600, and then runs
|
|
9
|
+
``ssh -G`` to confirm the block actually took effect.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
SRVUS_PROXY_COMMAND = (
|
|
21
|
+
"openssl s_client -quiet -no_ign_eof -verify_return_error "
|
|
22
|
+
"-verify_hostname %h -connect %h:443 -servername %h 2>/dev/null"
|
|
23
|
+
)
|
|
24
|
+
DEFAULT_HOST = "*.srv.us"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def block(host: str, proxy_command: str) -> str:
|
|
28
|
+
"""Build the ssh-config block for a host and ProxyCommand."""
|
|
29
|
+
return (
|
|
30
|
+
f"Host {host}\n"
|
|
31
|
+
f" ProxyCommand {proxy_command}\n"
|
|
32
|
+
" StrictHostKeyChecking no\n"
|
|
33
|
+
" UserKnownHostsFile /dev/null\n"
|
|
34
|
+
" LogLevel ERROR"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
return Path(os.path.expanduser("~/.ssh/config"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def print_block(
|
|
43
|
+
host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
|
|
44
|
+
) -> None:
|
|
45
|
+
print(block(host, proxy_command))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def write(
|
|
49
|
+
host: str = DEFAULT_HOST, proxy_command: str = SRVUS_PROXY_COMMAND
|
|
50
|
+
) -> int:
|
|
51
|
+
config = config_path()
|
|
52
|
+
ssh_dir = config.parent
|
|
53
|
+
try:
|
|
54
|
+
ssh_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
ssh_dir.chmod(0o700)
|
|
56
|
+
except OSError:
|
|
57
|
+
_error(f"Unable to prepare {ssh_dir}")
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
present = config.is_file() and any(
|
|
62
|
+
line.strip().startswith("Host " + host)
|
|
63
|
+
for line in config.read_text().splitlines()
|
|
64
|
+
)
|
|
65
|
+
except OSError:
|
|
66
|
+
present = False
|
|
67
|
+
if present:
|
|
68
|
+
print(f"Already present in {config}")
|
|
69
|
+
else:
|
|
70
|
+
# Appending never changes the meaning of existing lines, unlike
|
|
71
|
+
# prepending, which would pull any leading global keywords under this
|
|
72
|
+
# Host block.
|
|
73
|
+
try:
|
|
74
|
+
with open(config, "a") as handle:
|
|
75
|
+
if config.exists() and config.stat().st_size > 0:
|
|
76
|
+
handle.write("\n")
|
|
77
|
+
handle.write(block(host, proxy_command) + "\n")
|
|
78
|
+
config.chmod(0o600)
|
|
79
|
+
except OSError as error:
|
|
80
|
+
_error(f"Unable to write {config}: {error}")
|
|
81
|
+
return 1
|
|
82
|
+
print(f"Added the {host} block to {config}")
|
|
83
|
+
|
|
84
|
+
# ssh takes the first value it finds for each keyword, so an earlier
|
|
85
|
+
# matching block wins. Ask ssh what it will actually do rather than assuming.
|
|
86
|
+
ssh = shutil.which("ssh")
|
|
87
|
+
if ssh:
|
|
88
|
+
probe = host.replace("*", "example")
|
|
89
|
+
try:
|
|
90
|
+
resolved = subprocess.run(
|
|
91
|
+
[ssh, "-G", probe],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=15,
|
|
95
|
+
).stdout
|
|
96
|
+
except (OSError, subprocess.SubprocessError):
|
|
97
|
+
return 0
|
|
98
|
+
proxycommand = next(
|
|
99
|
+
(
|
|
100
|
+
line.split(" ", 1)[1].strip()
|
|
101
|
+
for line in resolved.splitlines()
|
|
102
|
+
if line.lower().startswith("proxycommand ")
|
|
103
|
+
),
|
|
104
|
+
"",
|
|
105
|
+
)
|
|
106
|
+
marker = proxy_command.split()[0]
|
|
107
|
+
if marker not in proxycommand:
|
|
108
|
+
_error(
|
|
109
|
+
f"Warning: an earlier block in {config} overrides it; move the "
|
|
110
|
+
f'{host} block above any "Host *" block.'
|
|
111
|
+
)
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _error(message: str) -> None:
|
|
116
|
+
print(message, file=sys.stderr)
|