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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ *.egg-info/
5
+ dist/
6
+ /config.yaml
7
+ .env
8
+ .DS_Store
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial standalone SSH tunnel rotation server, copied from the existing MIT-licensed project.
6
+ - SOCKS5 tunnels, HTTP forwarding, HTTPS CONNECT, bearer-authenticated REST API and CLI.
@@ -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