ssh-tunnel-switch 0.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.
- ssh_tunnel_switch-0.1.0.dist-info/METADATA +122 -0
- ssh_tunnel_switch-0.1.0.dist-info/RECORD +11 -0
- ssh_tunnel_switch-0.1.0.dist-info/WHEEL +4 -0
- ssh_tunnel_switch-0.1.0.dist-info/entry_points.txt +2 -0
- ssh_tunnel_switch-0.1.0.dist-info/licenses/LICENSE +21 -0
- tunnel_switch/__init__.py +1 -0
- tunnel_switch/api.py +51 -0
- tunnel_switch/cli.py +82 -0
- tunnel_switch/config.py +118 -0
- tunnel_switch/http_proxy.py +238 -0
- tunnel_switch/tunnel.py +153 -0
|
@@ -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,11 @@
|
|
|
1
|
+
tunnel_switch/__init__.py,sha256=QOPPZ-4STPtsDN8e-ePWDJOm-gVi9harY0GCAVu4gGU,41
|
|
2
|
+
tunnel_switch/api.py,sha256=Mytz-dLVX2wh6zz-NMcOW9coFGNr4_HJPIuRAqgx_lI,1921
|
|
3
|
+
tunnel_switch/cli.py,sha256=wn4EvzQm1L3paiVPWXELHF6w_RVXmYwSByzYHcWpW8g,2731
|
|
4
|
+
tunnel_switch/config.py,sha256=HhdT_gZn_oDOSi6RQlhS7_BiWAcjz4jCXssM_0_d0xM,4536
|
|
5
|
+
tunnel_switch/http_proxy.py,sha256=DmhEUxO81m6h0-uZXO6syPt9hmm1LfVFfSmx5GOSlDg,10900
|
|
6
|
+
tunnel_switch/tunnel.py,sha256=d77bzZwEE1WfAAEqr-8x-Oyi9fVBUPieVi-2nQpMvVY,6162
|
|
7
|
+
ssh_tunnel_switch-0.1.0.dist-info/METADATA,sha256=fHJW6Yg7T6V3vlXFdkMgN6Nyfj-9Rx51wI3J7SdO1I8,7073
|
|
8
|
+
ssh_tunnel_switch-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
ssh_tunnel_switch-0.1.0.dist-info/entry_points.txt,sha256=ZplYS21XnBdQH_8-bp3ZtyeLVylsFKqeDzuwxADqGHA,57
|
|
10
|
+
ssh_tunnel_switch-0.1.0.dist-info/licenses/LICENSE,sha256=1lM9TSlsMJxz8mNexJ3AlTLUbxpKrak2kpPk3ORC0CU,1066
|
|
11
|
+
ssh_tunnel_switch-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
"""SSH SOCKS tunnel rotation service."""
|
tunnel_switch/api.py
ADDED
|
@@ -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
|
tunnel_switch/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
import uvicorn
|
|
6
|
+
from .api import create_app
|
|
7
|
+
from .config import ensure_api_token, load_config
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(no_args_is_help=True, help="Manage the SSH SOCKS tunnel rotation API.")
|
|
10
|
+
ConfigOption = Annotated[Path, typer.Option("--config", "-c", help="Required YAML configuration file.")]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def settings(path: Path):
|
|
14
|
+
config = load_config(path)
|
|
15
|
+
token = config.api.token
|
|
16
|
+
if not token:
|
|
17
|
+
raise ValueError("API token is missing from YAML; start the service with serve to generate and save it")
|
|
18
|
+
return config, token
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def validate(config: ConfigOption):
|
|
23
|
+
"""Validate YAML and SSH identity paths."""
|
|
24
|
+
load_config(config)
|
|
25
|
+
typer.echo("Configuration valid")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def serve(config: ConfigOption):
|
|
30
|
+
"""Start the API and manage one SSH process; Ctrl-C stops the tunnel."""
|
|
31
|
+
cfg = load_config(config)
|
|
32
|
+
token = ensure_api_token(config, cfg)
|
|
33
|
+
typer.echo(f"API token: {token}")
|
|
34
|
+
if cfg.http_proxy.enabled:
|
|
35
|
+
ip = str(cfg.http_proxy.ip)
|
|
36
|
+
host = f"[{ip}]" if ":" in ip else ip
|
|
37
|
+
typer.echo(f"HTTP proxy: http://{host}:{cfg.http_proxy.port}")
|
|
38
|
+
uvicorn.run(create_app(cfg, token), host=str(cfg.api.ip), port=cfg.api.port, workers=1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def request(path: Path, method: str, endpoint: str):
|
|
42
|
+
cfg, token = settings(path)
|
|
43
|
+
ip = str(cfg.api.ip)
|
|
44
|
+
ip = "127.0.0.1" if ip == "0.0.0.0" else "::1" if ip == "::" else ip
|
|
45
|
+
host = f"[{ip}]" if ":" in ip else ip
|
|
46
|
+
response = httpx.request(method, f"http://{host}:{cfg.api.port}/{endpoint}",
|
|
47
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
48
|
+
timeout=cfg.startup_timeout_seconds + cfg.stop_timeout_seconds + 5,
|
|
49
|
+
trust_env=False)
|
|
50
|
+
response.raise_for_status()
|
|
51
|
+
typer.echo(response.text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command()
|
|
55
|
+
def status(config: ConfigOption):
|
|
56
|
+
"""Display connection ID, start time and current tunnel state."""
|
|
57
|
+
request(config, "GET", "status")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def rotate(config: ConfigOption):
|
|
62
|
+
"""Stop the current tunnel and start the next configured SSH server."""
|
|
63
|
+
request(config, "POST", "rotate")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
try:
|
|
68
|
+
app(standalone_mode=False)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
71
|
+
import click
|
|
72
|
+
ctx = click.Context(typer.main.get_command(app))
|
|
73
|
+
from contextlib import redirect_stdout
|
|
74
|
+
import sys
|
|
75
|
+
with redirect_stdout(sys.stderr):
|
|
76
|
+
typer.echo(ctx.get_help(), err=True)
|
|
77
|
+
typer.echo("Use tunnel-switch COMMAND --help for command options.", err=True)
|
|
78
|
+
raise SystemExit(1) from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
tunnel_switch/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from ipaddress import IPv4Address, IPv6Address
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import tempfile
|
|
7
|
+
from ruamel.yaml import YAML
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StrictModel(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Server(StrictModel):
|
|
16
|
+
name: str | None = Field(default=None, min_length=1)
|
|
17
|
+
host: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9.:%_-]*$")
|
|
18
|
+
user: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_][A-Za-z0-9_.-]*$")
|
|
19
|
+
port: int | None = Field(default=None, ge=1, le=65535)
|
|
20
|
+
identity_file: Path | None = None
|
|
21
|
+
|
|
22
|
+
@model_validator(mode="after")
|
|
23
|
+
def default_name(self):
|
|
24
|
+
if self.name is None:
|
|
25
|
+
self.name = self.host
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Proxy(StrictModel):
|
|
30
|
+
ip: IPv4Address | IPv6Address = IPv4Address("127.0.0.1")
|
|
31
|
+
port: int = Field(default=1080, ge=1, le=65535)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HTTPProxy(StrictModel):
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
ip: IPv4Address | IPv6Address = IPv4Address("127.0.0.1")
|
|
37
|
+
port: int = Field(default=8118, ge=1, le=65535)
|
|
38
|
+
connect_timeout_seconds: float = Field(default=10, gt=0, le=120)
|
|
39
|
+
idle_timeout_seconds: float = Field(default=120, gt=0)
|
|
40
|
+
header_limit_bytes: int = Field(default=65536, ge=1024, le=1048576)
|
|
41
|
+
max_connections: int = Field(default=128, ge=1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class API(StrictModel):
|
|
45
|
+
ip: IPv4Address | IPv6Address = IPv4Address("127.0.0.1")
|
|
46
|
+
port: int = Field(default=8765, ge=1, le=65535)
|
|
47
|
+
token: str | None = Field(default=None, repr=False)
|
|
48
|
+
|
|
49
|
+
@field_validator("token", mode="before")
|
|
50
|
+
@classmethod
|
|
51
|
+
def empty_token(cls, value):
|
|
52
|
+
return None if isinstance(value, str) and not value.strip() else value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Config(StrictModel):
|
|
56
|
+
servers: list[Server] = Field(min_length=1)
|
|
57
|
+
proxy: Proxy
|
|
58
|
+
api: API = Field(default_factory=API)
|
|
59
|
+
http_proxy: HTTPProxy = Field(default_factory=HTTPProxy)
|
|
60
|
+
cooldown_seconds: float = Field(default=30, ge=0)
|
|
61
|
+
startup_timeout_seconds: float = Field(default=15, gt=0, le=120)
|
|
62
|
+
stop_timeout_seconds: float = Field(default=5, gt=0, le=60)
|
|
63
|
+
start_on_launch: bool = True
|
|
64
|
+
|
|
65
|
+
@model_validator(mode="after")
|
|
66
|
+
def unique_names(self):
|
|
67
|
+
if len({server.name for server in self.servers}) != len(self.servers):
|
|
68
|
+
raise ValueError("SSH server names must be unique")
|
|
69
|
+
listeners = [self.proxy, self.api]
|
|
70
|
+
if self.http_proxy.enabled:
|
|
71
|
+
listeners.append(self.http_proxy)
|
|
72
|
+
for index, left in enumerate(listeners):
|
|
73
|
+
for right in listeners[index + 1:]:
|
|
74
|
+
if left.port == right.port and (left.ip == right.ip or
|
|
75
|
+
(left.ip.version == right.ip.version and
|
|
76
|
+
(left.ip.is_unspecified or right.ip.is_unspecified))):
|
|
77
|
+
raise ValueError("API, HTTP proxy and SOCKS proxy must use different sockets")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_config(path: Path) -> Config:
|
|
82
|
+
config = Config.model_validate(yaml.safe_load(path.read_text()))
|
|
83
|
+
for server in config.servers:
|
|
84
|
+
if server.identity_file is not None:
|
|
85
|
+
identity = server.identity_file.expanduser()
|
|
86
|
+
server.identity_file = identity if identity.is_absolute() else path.resolve().parent / identity
|
|
87
|
+
if not server.identity_file.is_file():
|
|
88
|
+
raise ValueError(f"Identity file does not exist: {server.identity_file}")
|
|
89
|
+
return config
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ensure_api_token(path: Path, config: Config) -> str:
|
|
93
|
+
"""Persist a generated token atomically, retaining YAML comments and values."""
|
|
94
|
+
if config.api.token:
|
|
95
|
+
return config.api.token
|
|
96
|
+
target = path.resolve()
|
|
97
|
+
yaml_editor = YAML()
|
|
98
|
+
yaml_editor.preserve_quotes = True
|
|
99
|
+
document = yaml_editor.load(target.read_text())
|
|
100
|
+
if document.get("api") is None:
|
|
101
|
+
document["api"] = {}
|
|
102
|
+
token = secrets.token_urlsafe(32)
|
|
103
|
+
document["api"]["token"] = token
|
|
104
|
+
temporary = None
|
|
105
|
+
try:
|
|
106
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent,
|
|
107
|
+
prefix=f".{target.name}.", delete=False) as stream:
|
|
108
|
+
temporary = Path(stream.name)
|
|
109
|
+
yaml_editor.dump(document, stream)
|
|
110
|
+
stream.flush()
|
|
111
|
+
os.fsync(stream.fileno())
|
|
112
|
+
# The temporary file is mode 0600; replacement keeps the saved token private.
|
|
113
|
+
os.replace(temporary, target)
|
|
114
|
+
finally:
|
|
115
|
+
if temporary is not None:
|
|
116
|
+
temporary.unlink(missing_ok=True)
|
|
117
|
+
config.api.token = token
|
|
118
|
+
return token
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""HTTP/1.1 forward proxy and CONNECT bridge over the managed SSH SOCKS5 socket."""
|
|
2
|
+
import asyncio
|
|
3
|
+
from ipaddress import ip_address
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
import h11
|
|
6
|
+
from .config import Config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProxyError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def destination(target: str, connect: bool = False) -> tuple[str, int, str]:
|
|
14
|
+
url = urlsplit(f"//{target}" if connect else target)
|
|
15
|
+
if url.username is not None or url.password is not None or url.fragment:
|
|
16
|
+
raise ValueError("Invalid proxy target")
|
|
17
|
+
if not url.hostname or (not connect and url.scheme != "http"):
|
|
18
|
+
raise ValueError("Use an absolute HTTP URL or CONNECT for HTTPS")
|
|
19
|
+
if connect and (url.path or url.query or url.port is None):
|
|
20
|
+
raise ValueError("CONNECT requires host:port")
|
|
21
|
+
port = url.port if url.port is not None else 80
|
|
22
|
+
if not 1 <= port <= 65535:
|
|
23
|
+
raise ValueError("Invalid destination port")
|
|
24
|
+
path = (url.path or "/") + (f"?{url.query}" if url.query else "")
|
|
25
|
+
return url.hostname, port, path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def headers_for_forwarding(headers, *, host: str | None = None):
|
|
29
|
+
names = {name.lower() for name, _ in headers}
|
|
30
|
+
if {b"content-length", b"transfer-encoding"} <= names:
|
|
31
|
+
raise ValueError("Ambiguous HTTP body framing")
|
|
32
|
+
connection_tokens = {
|
|
33
|
+
item.strip().lower() for name, value in headers if name.lower() == b"connection"
|
|
34
|
+
for item in value.split(b",")
|
|
35
|
+
}
|
|
36
|
+
if connection_tokens & {b"host", b"content-length", b"transfer-encoding"}:
|
|
37
|
+
raise ValueError("Invalid Connection header")
|
|
38
|
+
stripped = connection_tokens | {b"connection", b"proxy-connection", b"proxy-authorization",
|
|
39
|
+
b"proxy-authenticate", b"keep-alive", b"upgrade"}
|
|
40
|
+
if host is not None:
|
|
41
|
+
stripped.add(b"host")
|
|
42
|
+
result = [(name, value) for name, value in headers if name.lower() not in stripped]
|
|
43
|
+
if host is not None:
|
|
44
|
+
result.append((b"host", host.encode("ascii")))
|
|
45
|
+
result.append((b"connection", b"close"))
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HTTPProxyServer:
|
|
50
|
+
def __init__(self, config: Config):
|
|
51
|
+
self.config = config
|
|
52
|
+
self.server = None
|
|
53
|
+
self._tasks = set()
|
|
54
|
+
self._closing = False
|
|
55
|
+
|
|
56
|
+
async def start(self):
|
|
57
|
+
if self.config.http_proxy.enabled:
|
|
58
|
+
self.server = await asyncio.start_server(
|
|
59
|
+
self._accept, str(self.config.http_proxy.ip), self.config.http_proxy.port,
|
|
60
|
+
limit=self.config.http_proxy.header_limit_bytes)
|
|
61
|
+
|
|
62
|
+
async def close(self):
|
|
63
|
+
self._closing = True
|
|
64
|
+
if self.server:
|
|
65
|
+
self.server.close()
|
|
66
|
+
tasks = list(self._tasks)
|
|
67
|
+
for task in tasks:
|
|
68
|
+
task.cancel()
|
|
69
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
70
|
+
if self.server:
|
|
71
|
+
await self.server.wait_closed()
|
|
72
|
+
|
|
73
|
+
async def _socks(self, host: str, port: int):
|
|
74
|
+
proxy_ip = str(self.config.proxy.ip)
|
|
75
|
+
proxy_ip = "127.0.0.1" if proxy_ip == "0.0.0.0" else "::1" if proxy_ip == "::" else proxy_ip
|
|
76
|
+
reader, writer = await asyncio.open_connection(proxy_ip, self.config.proxy.port)
|
|
77
|
+
try:
|
|
78
|
+
writer.write(b"\x05\x01\x00")
|
|
79
|
+
await writer.drain()
|
|
80
|
+
if await reader.readexactly(2) != b"\x05\x00":
|
|
81
|
+
raise ProxyError("SOCKS authentication negotiation failed")
|
|
82
|
+
try:
|
|
83
|
+
address = ip_address(host)
|
|
84
|
+
encoded = (b"\x01" if address.version == 4 else b"\x04") + address.packed
|
|
85
|
+
except ValueError:
|
|
86
|
+
name = host.encode("idna")
|
|
87
|
+
if not 1 <= len(name) <= 255:
|
|
88
|
+
raise ProxyError("Destination hostname too long")
|
|
89
|
+
encoded = b"\x03" + bytes([len(name)]) + name
|
|
90
|
+
writer.write(b"\x05\x01\x00" + encoded + port.to_bytes(2, "big"))
|
|
91
|
+
await writer.drain()
|
|
92
|
+
reply = await reader.readexactly(4)
|
|
93
|
+
if reply[:3] != b"\x05\x00\x00":
|
|
94
|
+
raise ProxyError("SOCKS destination connection failed")
|
|
95
|
+
if reply[3] == 1:
|
|
96
|
+
length = 4
|
|
97
|
+
elif reply[3] == 4:
|
|
98
|
+
length = 16
|
|
99
|
+
elif reply[3] == 3:
|
|
100
|
+
length = (await reader.readexactly(1))[0]
|
|
101
|
+
else:
|
|
102
|
+
raise ProxyError("Invalid SOCKS address type")
|
|
103
|
+
await reader.readexactly(length + 2)
|
|
104
|
+
return reader, writer
|
|
105
|
+
except BaseException:
|
|
106
|
+
writer.close()
|
|
107
|
+
await writer.wait_closed()
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
async def _read_event(self, connection, reader):
|
|
111
|
+
while True:
|
|
112
|
+
event = connection.next_event()
|
|
113
|
+
if event is not h11.NEED_DATA:
|
|
114
|
+
return event
|
|
115
|
+
data = await asyncio.wait_for(reader.read(65536), self.config.http_proxy.idle_timeout_seconds)
|
|
116
|
+
connection.receive_data(data)
|
|
117
|
+
|
|
118
|
+
async def _relay_connect(self, client_reader, client_writer, upstream_reader, upstream_writer):
|
|
119
|
+
async def pump(reader, writer):
|
|
120
|
+
while data := await asyncio.wait_for(reader.read(65536), self.config.http_proxy.idle_timeout_seconds):
|
|
121
|
+
writer.write(data)
|
|
122
|
+
await writer.drain()
|
|
123
|
+
if writer.can_write_eof():
|
|
124
|
+
writer.write_eof()
|
|
125
|
+
await writer.drain()
|
|
126
|
+
tasks = [asyncio.create_task(pump(client_reader, upstream_writer)),
|
|
127
|
+
asyncio.create_task(pump(upstream_reader, client_writer))]
|
|
128
|
+
try:
|
|
129
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
130
|
+
for task in done:
|
|
131
|
+
task.result()
|
|
132
|
+
if tasks[1] not in done:
|
|
133
|
+
await tasks[1]
|
|
134
|
+
finally:
|
|
135
|
+
for task in tasks:
|
|
136
|
+
task.cancel()
|
|
137
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
138
|
+
|
|
139
|
+
async def _forward_http(self, request, inbound, reader, writer, upstream_reader, upstream_writer, path, authority):
|
|
140
|
+
outbound = h11.Connection(h11.CLIENT)
|
|
141
|
+
upstream_writer.write(outbound.send(h11.Request(
|
|
142
|
+
method=request.method, target=path.encode("ascii"),
|
|
143
|
+
headers=headers_for_forwarding(request.headers, host=authority))))
|
|
144
|
+
await upstream_writer.drain()
|
|
145
|
+
|
|
146
|
+
async def send_body():
|
|
147
|
+
while True:
|
|
148
|
+
event = await self._read_event(inbound, reader)
|
|
149
|
+
if not isinstance(event, (h11.Data, h11.EndOfMessage)):
|
|
150
|
+
raise ProxyError("Incomplete HTTP request body")
|
|
151
|
+
upstream_writer.write(outbound.send(event))
|
|
152
|
+
await upstream_writer.drain()
|
|
153
|
+
if isinstance(event, h11.EndOfMessage):
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
async def receive_response():
|
|
157
|
+
while True:
|
|
158
|
+
event = await self._read_event(outbound, upstream_reader)
|
|
159
|
+
if isinstance(event, (h11.Response, h11.InformationalResponse)):
|
|
160
|
+
if event.status_code == 101:
|
|
161
|
+
raise ProxyError("HTTP Upgrade is unsupported; use CONNECT")
|
|
162
|
+
event = type(event)(status_code=event.status_code, reason=event.reason,
|
|
163
|
+
headers=headers_for_forwarding(event.headers))
|
|
164
|
+
elif not isinstance(event, (h11.Data, h11.EndOfMessage)):
|
|
165
|
+
raise ProxyError("Incomplete upstream response")
|
|
166
|
+
writer.write(inbound.send(event))
|
|
167
|
+
await writer.drain()
|
|
168
|
+
if isinstance(event, h11.EndOfMessage):
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
tasks = [asyncio.create_task(send_body()), asyncio.create_task(receive_response())]
|
|
172
|
+
try:
|
|
173
|
+
# The response may finish before the request body (e.g. an upstream rejection).
|
|
174
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
175
|
+
for task in done:
|
|
176
|
+
task.result()
|
|
177
|
+
if tasks[1] not in done:
|
|
178
|
+
await tasks[1]
|
|
179
|
+
finally:
|
|
180
|
+
for task in tasks:
|
|
181
|
+
task.cancel()
|
|
182
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
183
|
+
|
|
184
|
+
async def _accept(self, reader, writer):
|
|
185
|
+
if self._closing or len(self._tasks) >= self.config.http_proxy.max_connections:
|
|
186
|
+
writer.write(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
|
187
|
+
await writer.drain()
|
|
188
|
+
writer.close()
|
|
189
|
+
await writer.wait_closed()
|
|
190
|
+
return
|
|
191
|
+
task = asyncio.current_task()
|
|
192
|
+
self._tasks.add(task)
|
|
193
|
+
upstream_writer = None
|
|
194
|
+
inbound = h11.Connection(h11.SERVER)
|
|
195
|
+
connected = False
|
|
196
|
+
status = 400
|
|
197
|
+
try:
|
|
198
|
+
raw = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), self.config.http_proxy.idle_timeout_seconds)
|
|
199
|
+
inbound.receive_data(raw)
|
|
200
|
+
request = inbound.next_event()
|
|
201
|
+
if not isinstance(request, h11.Request):
|
|
202
|
+
raise ValueError("Invalid request")
|
|
203
|
+
connect = request.method == b"CONNECT"
|
|
204
|
+
host, port, path = destination(request.target.decode("ascii"), connect)
|
|
205
|
+
headers_for_forwarding(request.headers) # Validate before connecting.
|
|
206
|
+
status = 502
|
|
207
|
+
upstream_reader, upstream_writer = await asyncio.wait_for(
|
|
208
|
+
self._socks(host, port), self.config.http_proxy.connect_timeout_seconds)
|
|
209
|
+
if connect:
|
|
210
|
+
writer.write(inbound.send(h11.Response(status_code=200, headers=[], reason=b"Connection Established")))
|
|
211
|
+
await writer.drain()
|
|
212
|
+
connected = True
|
|
213
|
+
await self._relay_connect(reader, writer, upstream_reader, upstream_writer)
|
|
214
|
+
else:
|
|
215
|
+
authority = f"[{host}]" if ":" in host else host.encode("idna").decode("ascii")
|
|
216
|
+
if port != 80:
|
|
217
|
+
authority += f":{port}"
|
|
218
|
+
await self._forward_http(request, inbound, reader, writer, upstream_reader, upstream_writer, path, authority)
|
|
219
|
+
except (OSError, TimeoutError, ValueError, ProxyError, h11.ProtocolError,
|
|
220
|
+
asyncio.IncompleteReadError, asyncio.LimitOverrunError):
|
|
221
|
+
# Never append an HTTP error after a response or tunnel has begun.
|
|
222
|
+
if not connected and inbound.our_state in (h11.IDLE, h11.SEND_RESPONSE):
|
|
223
|
+
reason = b"Bad Request" if status == 400 else b"Bad Gateway"
|
|
224
|
+
writer.write(b"HTTP/1.1 " + str(status).encode() + b" " + reason
|
|
225
|
+
+ b"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
|
226
|
+
try:
|
|
227
|
+
await writer.drain()
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
finally:
|
|
231
|
+
for stream in (upstream_writer, writer):
|
|
232
|
+
if stream:
|
|
233
|
+
stream.close()
|
|
234
|
+
try:
|
|
235
|
+
await stream.wait_closed()
|
|
236
|
+
except OSError:
|
|
237
|
+
pass
|
|
238
|
+
self._tasks.discard(task)
|
tunnel_switch/tunnel.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
from .config import Config, Server
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RotationRejected(Exception):
|
|
10
|
+
def __init__(self, message: str, retry_after: int = 1):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.retry_after = retry_after
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TunnelFailure(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TunnelManager:
|
|
20
|
+
def __init__(self, config: Config):
|
|
21
|
+
self.config = config
|
|
22
|
+
self.process = None
|
|
23
|
+
self.connection_id = None
|
|
24
|
+
self.started_at = None
|
|
25
|
+
self.server_name = None
|
|
26
|
+
self.last_error = None
|
|
27
|
+
self._index = -1
|
|
28
|
+
self._busy = False
|
|
29
|
+
self._last_rotation = None
|
|
30
|
+
|
|
31
|
+
def status(self) -> dict:
|
|
32
|
+
alive = self.process is not None and self.process.returncode is None
|
|
33
|
+
return {
|
|
34
|
+
"state": "rotating" if self._busy else "connected" if alive else "disconnected",
|
|
35
|
+
"connection_id": self.connection_id if alive else None,
|
|
36
|
+
"started_at": self.started_at.isoformat() if alive and self.started_at else None,
|
|
37
|
+
"server": self.server_name if alive else None,
|
|
38
|
+
"proxy": self.config.proxy.model_dump(mode="json"),
|
|
39
|
+
"last_error": self.last_error,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def command(self, server: Server) -> list[str]:
|
|
43
|
+
ip = str(self.config.proxy.ip)
|
|
44
|
+
bind = f"[{ip}]" if ":" in ip else ip
|
|
45
|
+
args = ["ssh", "-N", "-T", "-D", f"{bind}:{self.config.proxy.port}",
|
|
46
|
+
"-o", "BatchMode=yes",
|
|
47
|
+
"-o", "StrictHostKeyChecking=yes", "-o", "ExitOnForwardFailure=yes",
|
|
48
|
+
"-o", "ControlMaster=no", "-o", "ControlPath=none",
|
|
49
|
+
"-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
|
|
50
|
+
"-o", f"ConnectTimeout={int(self.config.startup_timeout_seconds) or 1}"]
|
|
51
|
+
if server.port is not None:
|
|
52
|
+
args.extend(["-p", str(server.port)])
|
|
53
|
+
if server.user is not None:
|
|
54
|
+
args.extend(["-l", server.user])
|
|
55
|
+
if server.identity_file:
|
|
56
|
+
args.extend(["-i", str(server.identity_file)])
|
|
57
|
+
return [*args, "--", server.host]
|
|
58
|
+
|
|
59
|
+
async def _stop(self):
|
|
60
|
+
process = self.process
|
|
61
|
+
if process is None:
|
|
62
|
+
return
|
|
63
|
+
if process.returncode is None:
|
|
64
|
+
try:
|
|
65
|
+
process.terminate()
|
|
66
|
+
except ProcessLookupError:
|
|
67
|
+
pass
|
|
68
|
+
try:
|
|
69
|
+
await asyncio.wait_for(process.wait(), self.config.stop_timeout_seconds)
|
|
70
|
+
except TimeoutError:
|
|
71
|
+
try:
|
|
72
|
+
process.kill()
|
|
73
|
+
except ProcessLookupError:
|
|
74
|
+
pass
|
|
75
|
+
await process.wait()
|
|
76
|
+
self.process = None
|
|
77
|
+
self.connection_id = self.started_at = self.server_name = None
|
|
78
|
+
|
|
79
|
+
async def _ready(self):
|
|
80
|
+
ip = str(self.config.proxy.ip)
|
|
81
|
+
if ip == "0.0.0.0":
|
|
82
|
+
ip = "127.0.0.1"
|
|
83
|
+
elif ip == "::":
|
|
84
|
+
ip = "::1"
|
|
85
|
+
writer = None
|
|
86
|
+
try:
|
|
87
|
+
reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, self.config.proxy.port), 0.5)
|
|
88
|
+
writer.write(b"\x05\x01\x00")
|
|
89
|
+
await writer.drain()
|
|
90
|
+
return await asyncio.wait_for(reader.readexactly(2), 0.5) == b"\x05\x00"
|
|
91
|
+
except (OSError, TimeoutError, asyncio.IncompleteReadError):
|
|
92
|
+
return False
|
|
93
|
+
finally:
|
|
94
|
+
if writer:
|
|
95
|
+
writer.close()
|
|
96
|
+
await writer.wait_closed()
|
|
97
|
+
|
|
98
|
+
def _ensure_socket_free(self):
|
|
99
|
+
family = socket.AF_INET6 if self.config.proxy.ip.version == 6 else socket.AF_INET
|
|
100
|
+
with socket.socket(family) as sock:
|
|
101
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
102
|
+
try:
|
|
103
|
+
sock.bind((str(self.config.proxy.ip), self.config.proxy.port))
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
raise TunnelFailure("SOCKS bind address is already occupied or unavailable") from exc
|
|
106
|
+
|
|
107
|
+
async def rotate(self) -> dict:
|
|
108
|
+
# No await before admission: atomic within the single service event loop.
|
|
109
|
+
if self._busy:
|
|
110
|
+
raise RotationRejected("Tunnel rotation already in progress")
|
|
111
|
+
now = time.monotonic()
|
|
112
|
+
if self._last_rotation is not None:
|
|
113
|
+
remaining = self.config.cooldown_seconds - (now - self._last_rotation)
|
|
114
|
+
if remaining > 0:
|
|
115
|
+
raise RotationRejected("Tunnel rotation cooldown active", int(remaining) + 1)
|
|
116
|
+
self._busy = True
|
|
117
|
+
self.last_error = None
|
|
118
|
+
try:
|
|
119
|
+
await self._stop()
|
|
120
|
+
self._ensure_socket_free()
|
|
121
|
+
self._index = (self._index + 1) % len(self.config.servers)
|
|
122
|
+
server = self.config.servers[self._index]
|
|
123
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
124
|
+
*self.command(server), stdin=asyncio.subprocess.DEVNULL,
|
|
125
|
+
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
|
126
|
+
deadline = time.monotonic() + self.config.startup_timeout_seconds
|
|
127
|
+
while time.monotonic() < deadline:
|
|
128
|
+
if self.process.returncode is not None:
|
|
129
|
+
raise TunnelFailure(f"SSH exited with code {self.process.returncode}; check authentication, known_hosts and forwarding permissions")
|
|
130
|
+
if await self._ready():
|
|
131
|
+
await asyncio.sleep(0.05)
|
|
132
|
+
if self.process.returncode is None:
|
|
133
|
+
self.connection_id = str(uuid4())
|
|
134
|
+
self.started_at = datetime.now(timezone.utc)
|
|
135
|
+
self.server_name = server.name
|
|
136
|
+
break
|
|
137
|
+
await asyncio.sleep(0.1)
|
|
138
|
+
else:
|
|
139
|
+
raise TunnelFailure("SSH SOCKS startup timed out")
|
|
140
|
+
except asyncio.CancelledError:
|
|
141
|
+
await self._stop()
|
|
142
|
+
raise
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
self.last_error = str(exc)
|
|
145
|
+
await self._stop()
|
|
146
|
+
raise TunnelFailure(str(exc)) from exc
|
|
147
|
+
finally:
|
|
148
|
+
self._last_rotation = time.monotonic()
|
|
149
|
+
self._busy = False
|
|
150
|
+
return self.status()
|
|
151
|
+
|
|
152
|
+
async def close(self):
|
|
153
|
+
await self._stop()
|