ssh-tunnel-switch 0.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.
- ssh_tunnel_switch-0.1.0/.gitignore +8 -0
- ssh_tunnel_switch-0.1.0/CHANGELOG.md +6 -0
- ssh_tunnel_switch-0.1.0/CONTRIBUTING.md +22 -0
- ssh_tunnel_switch-0.1.0/LICENSE +21 -0
- ssh_tunnel_switch-0.1.0/PKG-INFO +122 -0
- ssh_tunnel_switch-0.1.0/README.md +99 -0
- ssh_tunnel_switch-0.1.0/SECURITY.md +30 -0
- ssh_tunnel_switch-0.1.0/deploy/systemd/tunnel-switch.service +33 -0
- ssh_tunnel_switch-0.1.0/docs/deployment.md +125 -0
- ssh_tunnel_switch-0.1.0/examples/config.yaml +29 -0
- ssh_tunnel_switch-0.1.0/pyproject.toml +34 -0
- ssh_tunnel_switch-0.1.0/src/tunnel_switch/__init__.py +1 -0
- ssh_tunnel_switch-0.1.0/src/tunnel_switch/api.py +51 -0
- ssh_tunnel_switch-0.1.0/src/tunnel_switch/cli.py +82 -0
- ssh_tunnel_switch-0.1.0/src/tunnel_switch/config.py +118 -0
- ssh_tunnel_switch-0.1.0/src/tunnel_switch/http_proxy.py +238 -0
- ssh_tunnel_switch-0.1.0/src/tunnel_switch/tunnel.py +153 -0
- ssh_tunnel_switch-0.1.0/tests/test_http_proxy.py +263 -0
- ssh_tunnel_switch-0.1.0/tests/test_service.py +312 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Use a feature branch and a pull request. Include validation results and keep credentials out of code, logs, and issues.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
python3 -m venv .venv
|
|
7
|
+
.venv/bin/python -m pip install -e '.[test]' build twine
|
|
8
|
+
.venv/bin/python -m pytest -q
|
|
9
|
+
.venv/bin/python -m build
|
|
10
|
+
.venv/bin/python -m twine check dist/*
|
|
11
|
+
.venv/bin/tunnel-switch validate --config examples/config.yaml
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Tests use local sockets and subprocess fixtures, without remote SSH accounts or JavaScript dependencies. Python 3.12+ and OpenSSH are required to run the server.
|
|
15
|
+
|
|
16
|
+
## Releases
|
|
17
|
+
|
|
18
|
+
Update the version in `pyproject.toml` and the changelog. Publish a GitHub release tagged `service-v<version>` from an approved commit. CI validates the package before the publish job uploads the exact artifacts to PyPI. Versions cannot be overwritten.
|
|
19
|
+
|
|
20
|
+
Before the first release, create a pending Trusted Publisher in your PyPI account with project name `ssh-tunnel-switch`, owner `timlaing`, repository `ssh-tunnel-switch`, workflow `publish-pypi.yml`, and environment `pypi`. Configure the GitHub `pypi` environment and any desired reviewer protection. No PyPI API token is needed. See https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/ . A pending publisher does not guarantee name availability; PyPI confirms acceptance on publication.
|
|
21
|
+
|
|
22
|
+
After publishing, verify the registry version and install `ssh-tunnel-switch==<version>` in a clean environment. Check `tunnel-switch --help` and example configuration validation before announcing the release.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Laing
|
|
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,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ssh-tunnel-switch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SSH SOCKS5 tunnel rotation service with an HTTP proxy and REST API
|
|
5
|
+
Project-URL: Homepage, https://github.com/timlaing/ssh-tunnel-switch
|
|
6
|
+
Project-URL: Repository, https://github.com/timlaing/ssh-tunnel-switch
|
|
7
|
+
Project-URL: Issues, https://github.com/timlaing/ssh-tunnel-switch/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
12
|
+
Requires-Dist: h11<1,>=0.16
|
|
13
|
+
Requires-Dist: httpx<1,>=0.28
|
|
14
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
15
|
+
Requires-Dist: pyyaml<7,>=6
|
|
16
|
+
Requires-Dist: ruamel-yaml<0.20,>=0.18
|
|
17
|
+
Requires-Dist: typer<1,>=0.15
|
|
18
|
+
Requires-Dist: uvicorn<1,>=0.34
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest-asyncio<2,>=0.25; extra == 'test'
|
|
21
|
+
Requires-Dist: pytest<10,>=8; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# SSH Tunnel Switch
|
|
25
|
+
|
|
26
|
+
Rotate between SSH SOCKS5 tunnels using a REST API or CLI. A built-in HTTP proxy forwards HTTP requests and HTTPS CONNECT traffic through the active tunnel, with remote DNS resolution through SOCKS5.
|
|
27
|
+
|
|
28
|
+
The server runs independently. It requires Python 3.12+ and OpenSSH; no OpenCode, Node.js, Bun, or plugin is needed.
|
|
29
|
+
|
|
30
|
+
## Install and run
|
|
31
|
+
|
|
32
|
+
Install from a checkout until the first PyPI release is available:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
python3 -m venv .venv
|
|
36
|
+
.venv/bin/python -m pip install .
|
|
37
|
+
cp examples/config.yaml config.yaml
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
After publication, install with `python -m pip install ssh-tunnel-switch`. The executable remains `tunnel-switch`, and Python imports use `tunnel_switch`.
|
|
41
|
+
|
|
42
|
+
Edit `config.yaml` with your SSH host aliases and the SOCKS, HTTP proxy and API bind IP/port. Only `host` is required for each SSH server. `name` defaults to `host`; `user`, `port` and `identity_file` are optional overrides. When omitted (or null), the SSH command leaves them unset, so the system SSH client inherits settings from `~/.ssh/config` for the account running the service. Without configured SSH values, OpenSSH uses its own defaults. Identity paths relative to the YAML file resolve from its directory; `~` expands. YAML is mandatory for every CLI command. Unknown fields, invalid IPs/ports, duplicate server names and missing identity files are rejected.
|
|
43
|
+
|
|
44
|
+
A host-only server list can use existing SSH config aliases:
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
servers:
|
|
48
|
+
- host: ssh-one
|
|
49
|
+
- host: ssh-two
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Normal `HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump` and other SSH config settings apply. An explicit YAML `user`, `port` or `identity_file` is passed as `-l`, `-p` or `-i`. Existing configurations with named servers and explicit values still work. Effective server names must remain unique, including names derived from host aliases.
|
|
53
|
+
|
|
54
|
+
Authenticate to each SSH server once using your normal SSH client and verify its host key. The service requires key/agent authentication (`BatchMode=yes`) and known hosts (`StrictHostKeyChecking=yes`). It does not prompt for passwords or accept unknown host keys. The SSH accounts must permit TCP forwarding.
|
|
55
|
+
|
|
56
|
+
The API token lives in `api.token` in the YAML file. On startup, `serve` generates and saves a random token if the field is missing, null or blank. Existing tokens are reused. In both cases it prints `API token: ...` to the console before starting the API. Generated-token writes are atomic, preserve YAML comments, and save the file with mode 0600. If saving fails, startup fails rather than using an unsaved token.
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
.venv/bin/tunnel-switch validate --config config.yaml
|
|
60
|
+
.venv/bin/tunnel-switch serve --config config.yaml
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
No token environment variable is used. `status` and `rotate` read the saved YAML token; they do not generate one. `serve` starts the first tunnel unless `start_on_launch: false`. Ctrl-C cleans up its SSH process. Each command supports `--help`; command/configuration/network failures print an error and CLI help with a nonzero exit code.
|
|
64
|
+
|
|
65
|
+
Run exactly one service process for a given SOCKS socket. The supplied CLI uses one Uvicorn worker, without reload. It manages only SSH processes it spawned. An existing process occupying the SOCKS address is rejected rather than terminated. The cooldown also applies after startup and failed rotations, so an immediate rotation within the default 30 seconds returns 429.
|
|
66
|
+
|
|
67
|
+
## Use the proxy with any client
|
|
68
|
+
|
|
69
|
+
Start the service, then configure an HTTP proxy-aware client:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
curl --proxy http://127.0.0.1:8118 https://example.com/
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Alternatively, set `HTTP_PROXY` and `HTTPS_PROXY` to `http://127.0.0.1:8118` for clients that support those variables. Keep local control requests direct using `NO_PROXY=localhost,127.0.0.1,::1` or `curl --noproxy '*'`. SOCKS5-aware clients can use `socks5h://127.0.0.1:1080` directly.
|
|
76
|
+
|
|
77
|
+
The HTTP proxy defaults to enabled on loopback port 8118. It streams HTTP/1.1 requests and supports HTTPS CONNECT without decrypting TLS. Forwarding fails when SOCKS is unavailable; it never falls back to a direct destination connection. Rotation closes existing tunneled connections. The API remains responsive during rotation. There is no automatic SSH restart loop.
|
|
78
|
+
|
|
79
|
+
The HTTP and SOCKS listeners are unauthenticated. Keep them on loopback or restrict access with a firewall. The bearer token protects only the REST API. Use a TLS reverse proxy for remote API access. See [security](SECURITY.md).
|
|
80
|
+
|
|
81
|
+
## REST API and CLI
|
|
82
|
+
|
|
83
|
+
All endpoints require `Authorization: Bearer <token>`. FastAPI interactive API docs are available at `/docs`.
|
|
84
|
+
|
|
85
|
+
| Endpoint | Result |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `GET /status` | State, connection ID, start time, server, SOCKS and HTTP proxy addresses, and last error |
|
|
88
|
+
| `POST /rotate` | Stops current tunnel, starts next server, returns status after readiness |
|
|
89
|
+
|
|
90
|
+
Overlapping rotations and cooldown violations return **429**, with `Retry-After`; callers must trigger again later. They are not queued. Startup/SSH failures return **503** and leave the service disconnected; another trigger after the cooldown advances to the next server. A single-server list restarts the same server. Status remains responsive during rotation and returns `state: "rotating"`; connection ID/start time are null until the new tunnel is ready. After SSH exits, status reports disconnected and clears active connection fields. There is no automatic restart loop.
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
.venv/bin/tunnel-switch status --config config.yaml
|
|
94
|
+
.venv/bin/tunnel-switch rotate --config config.yaml
|
|
95
|
+
curl --noproxy '*' -H "Authorization: Bearer <token-from-YAML>" http://127.0.0.1:8765/status
|
|
96
|
+
curl --noproxy '*' -X POST -H "Authorization: Bearer <token-from-YAML>" http://127.0.0.1:8765/rotate
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Example connected status:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"state": "connected",
|
|
104
|
+
"connection_id": "9d7e9746-c4e2-4a43-8da5-674ab452cb91",
|
|
105
|
+
"started_at": "2026-10-14T12:00:00+00:00",
|
|
106
|
+
"server": "first",
|
|
107
|
+
"proxy": {"ip": "127.0.0.1", "port": 1080},
|
|
108
|
+
"http_proxy": {"enabled": true, "ip": "127.0.0.1", "port": 8118},
|
|
109
|
+
"last_error": null
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Development and deployment
|
|
114
|
+
|
|
115
|
+
- [Contributing and releases](CONTRIBUTING.md)
|
|
116
|
+
- [Linux systemd deployment](docs/deployment.md)
|
|
117
|
+
- [Security policy](SECURITY.md)
|
|
118
|
+
- [Changelog](CHANGELOG.md)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SSH Tunnel Switch
|
|
2
|
+
|
|
3
|
+
Rotate between SSH SOCKS5 tunnels using a REST API or CLI. A built-in HTTP proxy forwards HTTP requests and HTTPS CONNECT traffic through the active tunnel, with remote DNS resolution through SOCKS5.
|
|
4
|
+
|
|
5
|
+
The server runs independently. It requires Python 3.12+ and OpenSSH; no OpenCode, Node.js, Bun, or plugin is needed.
|
|
6
|
+
|
|
7
|
+
## Install and run
|
|
8
|
+
|
|
9
|
+
Install from a checkout until the first PyPI release is available:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
python3 -m venv .venv
|
|
13
|
+
.venv/bin/python -m pip install .
|
|
14
|
+
cp examples/config.yaml config.yaml
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
After publication, install with `python -m pip install ssh-tunnel-switch`. The executable remains `tunnel-switch`, and Python imports use `tunnel_switch`.
|
|
18
|
+
|
|
19
|
+
Edit `config.yaml` with your SSH host aliases and the SOCKS, HTTP proxy and API bind IP/port. Only `host` is required for each SSH server. `name` defaults to `host`; `user`, `port` and `identity_file` are optional overrides. When omitted (or null), the SSH command leaves them unset, so the system SSH client inherits settings from `~/.ssh/config` for the account running the service. Without configured SSH values, OpenSSH uses its own defaults. Identity paths relative to the YAML file resolve from its directory; `~` expands. YAML is mandatory for every CLI command. Unknown fields, invalid IPs/ports, duplicate server names and missing identity files are rejected.
|
|
20
|
+
|
|
21
|
+
A host-only server list can use existing SSH config aliases:
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
servers:
|
|
25
|
+
- host: ssh-one
|
|
26
|
+
- host: ssh-two
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Normal `HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump` and other SSH config settings apply. An explicit YAML `user`, `port` or `identity_file` is passed as `-l`, `-p` or `-i`. Existing configurations with named servers and explicit values still work. Effective server names must remain unique, including names derived from host aliases.
|
|
30
|
+
|
|
31
|
+
Authenticate to each SSH server once using your normal SSH client and verify its host key. The service requires key/agent authentication (`BatchMode=yes`) and known hosts (`StrictHostKeyChecking=yes`). It does not prompt for passwords or accept unknown host keys. The SSH accounts must permit TCP forwarding.
|
|
32
|
+
|
|
33
|
+
The API token lives in `api.token` in the YAML file. On startup, `serve` generates and saves a random token if the field is missing, null or blank. Existing tokens are reused. In both cases it prints `API token: ...` to the console before starting the API. Generated-token writes are atomic, preserve YAML comments, and save the file with mode 0600. If saving fails, startup fails rather than using an unsaved token.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
.venv/bin/tunnel-switch validate --config config.yaml
|
|
37
|
+
.venv/bin/tunnel-switch serve --config config.yaml
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
No token environment variable is used. `status` and `rotate` read the saved YAML token; they do not generate one. `serve` starts the first tunnel unless `start_on_launch: false`. Ctrl-C cleans up its SSH process. Each command supports `--help`; command/configuration/network failures print an error and CLI help with a nonzero exit code.
|
|
41
|
+
|
|
42
|
+
Run exactly one service process for a given SOCKS socket. The supplied CLI uses one Uvicorn worker, without reload. It manages only SSH processes it spawned. An existing process occupying the SOCKS address is rejected rather than terminated. The cooldown also applies after startup and failed rotations, so an immediate rotation within the default 30 seconds returns 429.
|
|
43
|
+
|
|
44
|
+
## Use the proxy with any client
|
|
45
|
+
|
|
46
|
+
Start the service, then configure an HTTP proxy-aware client:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
curl --proxy http://127.0.0.1:8118 https://example.com/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Alternatively, set `HTTP_PROXY` and `HTTPS_PROXY` to `http://127.0.0.1:8118` for clients that support those variables. Keep local control requests direct using `NO_PROXY=localhost,127.0.0.1,::1` or `curl --noproxy '*'`. SOCKS5-aware clients can use `socks5h://127.0.0.1:1080` directly.
|
|
53
|
+
|
|
54
|
+
The HTTP proxy defaults to enabled on loopback port 8118. It streams HTTP/1.1 requests and supports HTTPS CONNECT without decrypting TLS. Forwarding fails when SOCKS is unavailable; it never falls back to a direct destination connection. Rotation closes existing tunneled connections. The API remains responsive during rotation. There is no automatic SSH restart loop.
|
|
55
|
+
|
|
56
|
+
The HTTP and SOCKS listeners are unauthenticated. Keep them on loopback or restrict access with a firewall. The bearer token protects only the REST API. Use a TLS reverse proxy for remote API access. See [security](SECURITY.md).
|
|
57
|
+
|
|
58
|
+
## REST API and CLI
|
|
59
|
+
|
|
60
|
+
All endpoints require `Authorization: Bearer <token>`. FastAPI interactive API docs are available at `/docs`.
|
|
61
|
+
|
|
62
|
+
| Endpoint | Result |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `GET /status` | State, connection ID, start time, server, SOCKS and HTTP proxy addresses, and last error |
|
|
65
|
+
| `POST /rotate` | Stops current tunnel, starts next server, returns status after readiness |
|
|
66
|
+
|
|
67
|
+
Overlapping rotations and cooldown violations return **429**, with `Retry-After`; callers must trigger again later. They are not queued. Startup/SSH failures return **503** and leave the service disconnected; another trigger after the cooldown advances to the next server. A single-server list restarts the same server. Status remains responsive during rotation and returns `state: "rotating"`; connection ID/start time are null until the new tunnel is ready. After SSH exits, status reports disconnected and clears active connection fields. There is no automatic restart loop.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
.venv/bin/tunnel-switch status --config config.yaml
|
|
71
|
+
.venv/bin/tunnel-switch rotate --config config.yaml
|
|
72
|
+
curl --noproxy '*' -H "Authorization: Bearer <token-from-YAML>" http://127.0.0.1:8765/status
|
|
73
|
+
curl --noproxy '*' -X POST -H "Authorization: Bearer <token-from-YAML>" http://127.0.0.1:8765/rotate
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Example connected status:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"state": "connected",
|
|
81
|
+
"connection_id": "9d7e9746-c4e2-4a43-8da5-674ab452cb91",
|
|
82
|
+
"started_at": "2026-10-14T12:00:00+00:00",
|
|
83
|
+
"server": "first",
|
|
84
|
+
"proxy": {"ip": "127.0.0.1", "port": 1080},
|
|
85
|
+
"http_proxy": {"enabled": true, "ip": "127.0.0.1", "port": 8118},
|
|
86
|
+
"last_error": null
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Development and deployment
|
|
91
|
+
|
|
92
|
+
- [Contributing and releases](CONTRIBUTING.md)
|
|
93
|
+
- [Linux systemd deployment](docs/deployment.md)
|
|
94
|
+
- [Security policy](SECURITY.md)
|
|
95
|
+
- [Changelog](CHANGELOG.md)
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
Report vulnerabilities privately using [GitHub private vulnerability reporting](https://github.com/timlaing/ssh-tunnel-switch/security/advisories/new). Do not post sensitive details in public issues. If reporting is unavailable, request a private reporting channel without disclosing the vulnerability. No response-time guarantee is offered. Fixes target the latest release and current development branch.
|
|
4
|
+
|
|
5
|
+
## Deployment boundaries
|
|
6
|
+
|
|
7
|
+
- The bearer token protects REST status and rotation endpoints. HTTP and SOCKS
|
|
8
|
+
proxy listeners have no authentication. Use loopback or firewall access limited
|
|
9
|
+
to intended clients; do not expose an unrestricted proxy to the Internet.
|
|
10
|
+
- Use TLS in front of a remotely accessed REST API. HTTPS CONNECT transports
|
|
11
|
+
destination TLS unchanged; the service does not decrypt it.
|
|
12
|
+
- The YAML and client configuration contain the REST token. Keep both private.
|
|
13
|
+
The CLI deliberately prints the token at every startup, including to systemd
|
|
14
|
+
journal logs. Restrict log access and redact diagnostics before sharing.
|
|
15
|
+
- Protect the dedicated SSH key and verify host keys. The service uses batch
|
|
16
|
+
authentication and strict host-key checking. It runs as one account and reads
|
|
17
|
+
that account's SSH configuration. Configuration-write access can change hosts
|
|
18
|
+
and exposed listeners, so grant it only to trusted administrators.
|
|
19
|
+
- Token rotation: stop the service, replace `api.token` with a fresh secret (or
|
|
20
|
+
set it to null to generate one on the next startup), start the service, update
|
|
21
|
+
every client using that token. Existing tokens are loaded
|
|
22
|
+
at startup; editing the file alone does not revoke the running token.
|
|
23
|
+
- Run one API worker and one service instance per SOCKS socket. Coordination is
|
|
24
|
+
within one process, not across multiple instances.
|
|
25
|
+
- Forwarding fails when SOCKS is unavailable. Tunnel rotation does not establish
|
|
26
|
+
that a provider quota was reset and does not replace provider account controls.
|
|
27
|
+
|
|
28
|
+
Dependency audits and updates complement review; passing audits are not a claim
|
|
29
|
+
that the code has no vulnerabilities. See the [deployment guide](docs/deployment.md)
|
|
30
|
+
for service-account and filesystem settings.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=SSH tunnel rotation API and HTTP proxy
|
|
3
|
+
Wants=network-online.target
|
|
4
|
+
After=network-online.target
|
|
5
|
+
StartLimitIntervalSec=60
|
|
6
|
+
StartLimitBurst=5
|
|
7
|
+
|
|
8
|
+
[Service]
|
|
9
|
+
Type=simple
|
|
10
|
+
User=tunnel-switch
|
|
11
|
+
Group=tunnel-switch
|
|
12
|
+
WorkingDirectory=/var/lib/tunnel-switch
|
|
13
|
+
ExecStart=/opt/tunnel-switch/.venv/bin/tunnel-switch serve --config /etc/tunnel-switch/config.yaml
|
|
14
|
+
Restart=on-failure
|
|
15
|
+
RestartSec=5
|
|
16
|
+
TimeoutStopSec=30
|
|
17
|
+
KillMode=mixed
|
|
18
|
+
UMask=0077
|
|
19
|
+
Environment=PYTHONUNBUFFERED=1
|
|
20
|
+
Environment=PYTHONDONTWRITEBYTECODE=1
|
|
21
|
+
NoNewPrivileges=true
|
|
22
|
+
PrivateTmp=true
|
|
23
|
+
ProtectSystem=strict
|
|
24
|
+
ProtectHome=true
|
|
25
|
+
ReadWritePaths=/etc/tunnel-switch /var/lib/tunnel-switch
|
|
26
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
27
|
+
CapabilityBoundingSet=
|
|
28
|
+
ProtectKernelTunables=true
|
|
29
|
+
ProtectKernelModules=true
|
|
30
|
+
ProtectControlGroups=true
|
|
31
|
+
|
|
32
|
+
[Install]
|
|
33
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Deploying the Python service
|
|
2
|
+
|
|
3
|
+
The supplied [systemd unit](../deploy/systemd/tunnel-switch.service) targets
|
|
4
|
+
Linux with systemd, Python 3.12 or newer, and OpenSSH. macOS users can run the
|
|
5
|
+
foreground CLI described in the README; no launchd service is supplied.
|
|
6
|
+
These instructions install files on a deployment host, not on your workstation.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Check out an approved repository revision at `/opt/tunnel-switch`. Code and its virtual environment should be root-owned and
|
|
11
|
+
not writable by the service account. On Debian/Ubuntu, install `openssh-client`
|
|
12
|
+
and the `python3-venv` package matching a supported Python version first.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
sudo useradd --system --user-group --create-home \
|
|
16
|
+
--home-dir /var/lib/tunnel-switch --shell /usr/sbin/nologin tunnel-switch
|
|
17
|
+
sudo install -d -m 0700 -o tunnel-switch -g tunnel-switch /etc/tunnel-switch
|
|
18
|
+
sudo install -d -m 0700 -o tunnel-switch -g tunnel-switch /var/lib/tunnel-switch/.ssh
|
|
19
|
+
sudo python3 -m venv /opt/tunnel-switch/.venv
|
|
20
|
+
sudo /opt/tunnel-switch/.venv/bin/python -m pip install /opt/tunnel-switch
|
|
21
|
+
sudo install -m 0600 -o tunnel-switch -g tunnel-switch \
|
|
22
|
+
/opt/tunnel-switch/examples/config.yaml /etc/tunnel-switch/config.yaml
|
|
23
|
+
sudoedit /etc/tunnel-switch/config.yaml
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run `useradd` only when the account does not already exist. The configuration
|
|
27
|
+
directory is writable by the service because saving a newly generated token
|
|
28
|
+
uses an atomic file replacement. The unit permits writes only to that directory,
|
|
29
|
+
the service's home, and its private temporary directory. Root owns the executable
|
|
30
|
+
and unit. Configuration values can select SSH hosts, identities and bind sockets,
|
|
31
|
+
so write access to the YAML is operational control of the service.
|
|
32
|
+
|
|
33
|
+
## Configure SSH under the service account
|
|
34
|
+
|
|
35
|
+
Host-only YAML entries use `/var/lib/tunnel-switch/.ssh/config`, not your own
|
|
36
|
+
account's SSH configuration. Provision the necessary SSH config, known-host
|
|
37
|
+
entries and a dedicated authentication key for this account. Set `.ssh` to mode
|
|
38
|
+
0700 and config/private-key files to 0600 with owner `tunnel-switch`.
|
|
39
|
+
|
|
40
|
+
Example SSH config, with deployment-specific values substituted:
|
|
41
|
+
|
|
42
|
+
```sshconfig
|
|
43
|
+
Host ssh-one
|
|
44
|
+
HostName ssh-one.example.org
|
|
45
|
+
User tunnel
|
|
46
|
+
IdentityFile /var/lib/tunnel-switch/.ssh/id_ed25519
|
|
47
|
+
IdentitiesOnly yes
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Verify each server's host-key fingerprint through a trusted channel before
|
|
51
|
+
adding it to this account's `known_hosts`. Test the effective configuration and
|
|
52
|
+
key authentication as the actual account:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
sudo -u tunnel-switch ssh -G ssh-one
|
|
56
|
+
sudo -u tunnel-switch ssh -o BatchMode=yes -o StrictHostKeyChecking=yes ssh-one true
|
|
57
|
+
sudo -u tunnel-switch /opt/tunnel-switch/.venv/bin/tunnel-switch \
|
|
58
|
+
validate --config /etc/tunnel-switch/config.yaml
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Repeat for every host. A restricted SSH account may reject the `true` remote
|
|
62
|
+
command even when authentication works; use its administrator-approved check
|
|
63
|
+
instead. SSH diagnostic output can contain private hostnames and paths.
|
|
64
|
+
|
|
65
|
+
The unit does not inherit your desktop SSH agent. Encrypted keys require a
|
|
66
|
+
separately managed agent reachable by this service; its socket must also be
|
|
67
|
+
accessible inside the unit sandbox. A dedicated noninteractive key should have
|
|
68
|
+
narrow server-side permissions and carefully restricted local access. The SSH
|
|
69
|
+
server must allow TCP forwarding. `ProxyJump` works when its authentication
|
|
70
|
+
requirements are also satisfied under this account.
|
|
71
|
+
|
|
72
|
+
## Start and verify
|
|
73
|
+
|
|
74
|
+
Keep the default loopback listeners for a local deployment. Install the unit:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
sudo install -m 0644 /opt/tunnel-switch/deploy/systemd/tunnel-switch.service \
|
|
78
|
+
/etc/systemd/system/tunnel-switch.service
|
|
79
|
+
sudo systemd-analyze verify /etc/systemd/system/tunnel-switch.service
|
|
80
|
+
sudo systemctl daemon-reload
|
|
81
|
+
sudo systemctl enable --now tunnel-switch.service
|
|
82
|
+
sudo systemctl status tunnel-switch.service
|
|
83
|
+
sudo -u tunnel-switch /opt/tunnel-switch/.venv/bin/tunnel-switch \
|
|
84
|
+
status --config /etc/tunnel-switch/config.yaml
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The status command should report `connected`, a connection ID and a start time.
|
|
88
|
+
`systemctl` reporting `active` only confirms the Python process is running; it
|
|
89
|
+
does not confirm SSH connectivity. Configure clients after checking `/status`.
|
|
90
|
+
|
|
91
|
+
Startup prints the API token as required by the CLI, so under systemd it appears
|
|
92
|
+
in the journal. Read it only through a trusted local administrator session:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
sudo journalctl -u tunnel-switch.service -n 50 --no-pager
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Do not paste this output into issues or share it unredacted. See [SECURITY.md](../SECURITY.md).
|
|
99
|
+
|
|
100
|
+
## Remote clients and updates
|
|
101
|
+
|
|
102
|
+
For remote clients, bind the HTTP proxy to a reachable interface and
|
|
103
|
+
restrict both HTTP and SOCKS access using the host firewall. Keep SOCKS on
|
|
104
|
+
loopback when only the built-in bridge needs it. Protect a remotely accessed
|
|
105
|
+
REST API with TLS at a reverse proxy and configure clients to use HTTPS.
|
|
106
|
+
The built-in API serves HTTP; it does not terminate TLS. The CLI accesses the
|
|
107
|
+
configured local HTTP socket directly. Wildcard API bindings are mapped to
|
|
108
|
+
loopback by CLI status/rotate commands.
|
|
109
|
+
|
|
110
|
+
The unit hides `/home`, `/root` and `/run/user`; place SSH files in its own home
|
|
111
|
+
and adjust any `Include`, `IdentityFile` or `ProxyJump` dependencies accordingly.
|
|
112
|
+
Its filesystem restrictions follow [systemd's execution settings](https://github.com/systemd/systemd/blob/main/man/systemd.exec.xml).
|
|
113
|
+
|
|
114
|
+
Before upgrading, preserve a protected copy of the configuration and current
|
|
115
|
+
revision outside the checkout. Stop the service, install the approved new
|
|
116
|
+
revision into the existing virtual environment, validate YAML as the service
|
|
117
|
+
account, then start it and verify status. Restore the previous revision and
|
|
118
|
+
configuration if validation fails. Changing YAML requires a service restart;
|
|
119
|
+
changing HTTP proxy settings also requires updating client configuration.
|
|
120
|
+
|
|
121
|
+
The unit restarts a failed Python process. An SSH subprocess exiting while the
|
|
122
|
+
API stays alive does not trigger a systemd restart; use `/status`, diagnose SSH,
|
|
123
|
+
and request rotation after the cooldown. `KillMode=mixed` lets the Python process
|
|
124
|
+
clean up its tunnel before systemd forcibly stops remaining child processes at
|
|
125
|
+
the shutdown deadline. Keep `TimeoutStopSec` longer than configured stop timeouts.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
servers:
|
|
2
|
+
# Host aliases from ~/.ssh/config; User, Port and IdentityFile are inherited.
|
|
3
|
+
- host: ssh-one
|
|
4
|
+
- host: ssh-two
|
|
5
|
+
# Optional per-server overrides:
|
|
6
|
+
# - host: ssh-three
|
|
7
|
+
# name: third
|
|
8
|
+
# user: tunnel
|
|
9
|
+
# port: 2222
|
|
10
|
+
# identity_file: ~/.ssh/id_ed25519
|
|
11
|
+
proxy:
|
|
12
|
+
ip: 127.0.0.1
|
|
13
|
+
port: 1080
|
|
14
|
+
http_proxy:
|
|
15
|
+
enabled: true
|
|
16
|
+
ip: 127.0.0.1
|
|
17
|
+
port: 8118
|
|
18
|
+
connect_timeout_seconds: 10
|
|
19
|
+
idle_timeout_seconds: 120
|
|
20
|
+
max_connections: 128
|
|
21
|
+
api:
|
|
22
|
+
ip: 127.0.0.1
|
|
23
|
+
port: 8765
|
|
24
|
+
# Leave unset to generate and save a token on service startup.
|
|
25
|
+
token: null
|
|
26
|
+
cooldown_seconds: 30
|
|
27
|
+
startup_timeout_seconds: 15
|
|
28
|
+
stop_timeout_seconds: 5
|
|
29
|
+
start_on_launch: true
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ssh-tunnel-switch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SSH SOCKS5 tunnel rotation service with an HTTP proxy and REST API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
dependencies = ["fastapi>=0.115,<1", "uvicorn>=0.34,<1", "typer>=0.15,<1", "pydantic>=2.10,<3", "PyYAML>=6,<7", "ruamel.yaml>=0.18,<0.20", "httpx>=0.28,<1", "h11>=0.16,<1"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/timlaing/ssh-tunnel-switch"
|
|
17
|
+
Repository = "https://github.com/timlaing/ssh-tunnel-switch"
|
|
18
|
+
Issues = "https://github.com/timlaing/ssh-tunnel-switch/issues"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest>=8,<10", "pytest-asyncio>=0.25,<2"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
tunnel-switch = "tunnel_switch.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
asyncio_mode = "auto"
|
|
28
|
+
testpaths = ["tests"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/tunnel_switch"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
include = ["/src", "/tests", "/examples", "/deploy", "/docs", "/README.md", "/LICENSE", "/SECURITY.md", "/CONTRIBUTING.md", "/CHANGELOG.md", "/pyproject.toml"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SSH SOCKS tunnel rotation service."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
import secrets
|
|
3
|
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
|
4
|
+
from .config import Config
|
|
5
|
+
from .http_proxy import HTTPProxyServer
|
|
6
|
+
from .tunnel import RotationRejected, TunnelFailure, TunnelManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_app(config: Config, token: str, manager: TunnelManager | None = None) -> FastAPI:
|
|
10
|
+
if not token:
|
|
11
|
+
raise ValueError("API token must not be empty")
|
|
12
|
+
manager = manager or TunnelManager(config)
|
|
13
|
+
proxy = HTTPProxyServer(config)
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def lifespan(app):
|
|
17
|
+
try:
|
|
18
|
+
if config.start_on_launch:
|
|
19
|
+
await manager.rotate()
|
|
20
|
+
await proxy.start()
|
|
21
|
+
yield
|
|
22
|
+
finally:
|
|
23
|
+
try:
|
|
24
|
+
await proxy.close()
|
|
25
|
+
finally:
|
|
26
|
+
await manager.close()
|
|
27
|
+
|
|
28
|
+
app = FastAPI(title="Tunnel Switch", lifespan=lifespan)
|
|
29
|
+
app.state.manager = manager
|
|
30
|
+
app.state.http_proxy = proxy
|
|
31
|
+
|
|
32
|
+
async def authorize(authorization: str | None = Header(default=None)):
|
|
33
|
+
if authorization is None or not secrets.compare_digest(authorization.encode(), f"Bearer {token}".encode()):
|
|
34
|
+
raise HTTPException(status_code=401, detail="Invalid bearer token")
|
|
35
|
+
|
|
36
|
+
@app.get("/status", dependencies=[Depends(authorize)])
|
|
37
|
+
async def status():
|
|
38
|
+
return {**manager.status(), "http_proxy": {
|
|
39
|
+
"enabled": config.http_proxy.enabled, "ip": str(config.http_proxy.ip),
|
|
40
|
+
"port": config.http_proxy.port}}
|
|
41
|
+
|
|
42
|
+
@app.post("/rotate", dependencies=[Depends(authorize)])
|
|
43
|
+
async def rotate():
|
|
44
|
+
try:
|
|
45
|
+
return await manager.rotate()
|
|
46
|
+
except RotationRejected as exc:
|
|
47
|
+
raise HTTPException(status_code=429, detail=str(exc), headers={"Retry-After": str(exc.retry_after)}) from exc
|
|
48
|
+
except TunnelFailure as exc:
|
|
49
|
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
50
|
+
|
|
51
|
+
return app
|