feat(tunnel): route --local-proxy through a typed --proxy

The client dials the server's targets through the proxy (http/https CONNECT or SOCKS5, with login), with the same target checks and no direct fallback. tunnel-test takes --proxy too.
This commit is contained in:
imSp4rky
2026-11-09 19:38:01 -07:00
parent b10fd2d3a4
commit 6f9ab94536
10 changed files with 329 additions and 54 deletions
+4 -3
View File
@@ -207,7 +207,7 @@ order the service used, then renumbers the episodes into the order you asked for
| Flag | Description |
|---|---|
| `--proxy` | Proxy URI, a country or location code resolved from configured providers, or `provider:region` (e.g. `nordvpn:ca`, `gluetun:us`, `protonvpn:de:berlin`, `controld:yul`). |
| `--local-proxy` | With `--remote`, send the server's service requests through your machine's own connection. Works with no other proxy option. Check it first with `unshackle tunnel-test`. See [Proxies & VPN](proxies-and-vpn.md#send-the-servers-requests-through-your-machine). |
| `--local-proxy` | With `--remote`, send the server's service requests through your machine's own connection, or through a `--proxy` that your machine connects to. Check it first with `unshackle tunnel-test`. See [Proxies & VPN](proxies-and-vpn.md#send-the-servers-requests-through-your-machine). |
| `--no-proxy` | Force-disable all proxy use. |
| `--no-proxy-download` | Bypass the proxy for **all downloads** (manifest, licence, and auth stay proxied). |
| `--proxy-download` | Use a different proxy for **all downloads**, in the same forms as `--proxy` (manifest, licence, and auth stay on `--proxy`). |
@@ -251,7 +251,7 @@ unshackle logs in to the service before the search. A service that declares a se
|---|---|
| `-p`, `--profile` | Profile for credentials and cookies. |
| `--proxy` | Proxy URI, country or location code, or `provider:region` (e.g. `controld:ca`). |
| `--local-proxy` | With `--remote`, send the server's search requests through your machine's own connection. Works with no other proxy option. |
| `--local-proxy` | With `--remote`, send the server's search requests through your machine's own connection, or through a `--proxy` that your machine connects to. |
| `--no-proxy` | Force-disable all proxy use. |
| `--remote` | Search on a remote unshackle server. The server logs in to the service. unshackle sends it your credentials, cookies and cache for the profile, and saves the cache that the server sends back. A service that searches with no login gets none of them. |
| `--local` | Search with the local service code, also when the config sets `remote: true` for the service. |
@@ -294,12 +294,13 @@ machine exits from, opens a tunnel to the server, and makes the server look up i
through the tunnel. It stops at the first step that fails.
```
unshackle tunnel-test [--server NAME]
unshackle tunnel-test [--server NAME] [--proxy VALUE]
```
| Flag | Description |
|---|---|
| `--server` | Name of the remote server from `remote_services` config, if you configure more than one. |
| `--proxy` | Test the route through this proxy, as `--local-proxy --proxy` uses it. Takes any `--proxy` value. |
See [Proxies & VPN](proxies-and-vpn.md#send-the-servers-requests-through-your-machine).
+22 -9
View File
@@ -27,7 +27,7 @@ Five options on the `dl` command control proxying at download time:
| Option | Effect |
|---|---|
| `--proxy` | The proxy to use. Either an explicit URI, or a query that unshackle resolves against your configured providers. |
| `--local-proxy` | With `--remote`, send the server's service requests through your machine's own connection. Works with no other proxy option. See [Send the server's requests through your machine](#send-the-servers-requests-through-your-machine). |
| `--local-proxy` | With `--remote`, send the server's service requests through your machine's own connection, or through a `--proxy` that your machine connects to. See [Send the server's requests through your machine](#send-the-servers-requests-through-your-machine). |
| `--no-proxy` | Force **all** proxy use off. No providers are initialised and no proxy query is resolved. |
| `--no-proxy-download` | Use the proxy for the manifest, licence, and authentication, but bypass it for the **downloads** themselves. |
| `--proxy-download` | Use `--proxy` for the manifest, licence, and authentication, and a different proxy for the **downloads**. Takes the same forms as `--proxy`. |
@@ -273,18 +273,31 @@ unshackle dl --remote --local-proxy EXAMPLE 81234567
A VPN app that covers your whole machine then also covers the server's requests, so the
service sees your VPN's IP address. `--local-proxy` needs `--remote` (or a service with
`remote: true`), and it works with no other proxy option: unshackle stops if you also give
`--proxy`, `--no-proxy`, `--proxy-download` or `--no-proxy-download`. A proxy option from the `dl`
config defaults does not count: `--local-proxy` turns it off for the run.
`remote: true`). unshackle stops if you also give `--no-proxy`, `--proxy-download` or
`--no-proxy-download`. A proxy option from the `dl` config defaults does not count:
`--local-proxy` turns it off for the run.
A `--proxy` on `localhost` or a loopback address, such as the local port of a VPN app, stops a
`--remote` run with `That proxy is on your machine, so --remote cannot use it`. Connect the VPN
app for the whole machine, then use `--local-proxy` without `--proxy`.
Add `--proxy` to send the server's requests through a proxy that your machine connects to. The
proxy can be on your machine, such as the local port of a VPN app or `hola-proxy`, or any value
that `--proxy` takes, such as `ca` or `nordvpn:us`:
```shell
unshackle dl --remote --local-proxy --proxy http://127.0.0.1:8080 EXAMPLE 81234567
```
The proxy must be `http://`, `https://`, `socks5://` or `socks5h://`, with a username and
password in the URI if it needs them. If the proxy fails, the request fails: unshackle never
sends it from your own connection instead. Your machine still refuses a target on your own
machine or network, and a host name that your machine resolves to a local address only.
Without `--local-proxy`, a `--proxy` on `localhost` or a loopback address stops a `--remote` run
with `--remote cannot use a proxy on this machine without --local-proxy`, because the server
cannot reach it.
#### Test it first
`unshackle tunnel-test` checks the route before a real run. Add `--server <name>` to test a
server other than the default one:
server other than the default one, and `--proxy <value>` to test the route through a proxy:
```shell
unshackle tunnel-test
@@ -292,7 +305,7 @@ unshackle tunnel-test
It does three steps, and it stops at the first one that fails:
1. It looks up the IP address that your machine exits from.
1. It looks up the IP address that your machine exits from, through `--proxy` if you give one.
2. It opens a tunnel to the server.
3. It makes the server look up its exit IP address through the tunnel. That address must be
the same as in step 1, or in the same country when one lookup used IPv4 and the other IPv6.
+7 -4
View File
@@ -73,10 +73,12 @@ def test_search_sends_no_login(server_accounts: Any, anonymous: bool) -> None:
assert client.sent == {"endpoint": "/api/search", "service": "TEST", "query": "query"}
def test_local_proxy_sends_the_server_a_tunnel(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.mark.parametrize("proxy", [None, "http://127.0.0.1:8080"])
def test_local_proxy_sends_the_server_a_tunnel(monkeypatch: pytest.MonkeyPatch, proxy: str | None) -> None:
class FakeTunnel:
def __init__(self, server_url: str, headers: dict) -> None:
def __init__(self, server_url: str, headers: dict, exit_proxy: str | None = None) -> None:
self.id = "abc"
self.exit_proxy = exit_proxy
def start(self) -> str:
return self.id
@@ -85,13 +87,14 @@ def test_local_proxy_sends_the_server_a_tunnel(monkeypatch: pytest.MonkeyPatch)
self.stopped = True
monkeypatch.setattr(remote_service, "TunnelClient", FakeTunnel)
svc, client, _ = make_service({"local_proxy": True})
svc, client, _ = make_service({"local_proxy": True, "proxy": proxy})
svc.log = logging.getLogger("test")
client.session = SimpleNamespace(headers={"X-Secret-Key": "k"}) # type: ignore[attr-defined]
list(svc.search())
assert client.sent["proxy"] == "tunnel:abc"
assert client.tunnel.exit_proxy == proxy
assert client.tunnel.stopped
@@ -101,7 +104,7 @@ def test_a_loopback_proxy_points_to_local_proxy(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(remote_service, "resolve_remote_proxy_arg", lambda arg, providers=None: arg)
svc, _, _ = make_service({"proxy": "http://127.0.0.1:8888"}, {"global": True})
with pytest.raises(click.ClickException, match="--local-proxy without --proxy"):
with pytest.raises(click.ClickException, match="without --local-proxy"):
list(svc.search())
+124 -2
View File
@@ -21,12 +21,14 @@ from multidict import CIMultiDict
from unshackle.core import tunnel
from unshackle.core.api.routes import tunnel as tunnel_route
from unshackle.core.api.routes import tunnel_check
from unshackle.core.proxies.controld import relay
from unshackle.core.tunnel import (
CHECK_ROUTE,
OPEN,
ROUTE,
TUNNELS,
TunnelClient,
connect_proxied,
connect_public,
frame,
is_ip_literal,
@@ -258,13 +260,16 @@ def local_proxy_ctx(params: dict, typed: set) -> click.Context:
return ctx
def test_local_proxy_works_with_no_other_proxy_option() -> None:
def test_local_proxy_works_with_a_typed_proxy_and_no_other_proxy_option() -> None:
from unshackle.core.proxies.resolve import require_alone_local_proxy
with pytest.raises(click.UsageError, match="--proxy, --proxy-download"):
with pytest.raises(click.UsageError, match="with --proxy-download\\.$"):
require_alone_local_proxy(
local_proxy_ctx({"remote": True, "proxy": "us", "proxy_download": "us"}, {"proxy", "proxy_download"})
)
ctx = local_proxy_ctx({"remote": True, "proxy": "http://127.0.0.1:8080"}, {"proxy"})
require_alone_local_proxy(ctx)
assert ctx.params["proxy"] == "http://127.0.0.1:8080"
with pytest.raises(click.UsageError, match="needs --remote"):
require_alone_local_proxy(local_proxy_ctx({"remote": False}, set()))
@@ -612,3 +617,120 @@ def test_a_failed_start_leaves_no_thread_behind() -> None:
assert not client.thread.is_alive()
finally:
silent.close()
class ExitProxy(socketserver.BaseRequestHandler):
"""An exit proxy on this machine: takes one HTTP CONNECT or SOCKS5 CONNECT and pipes it to the test target."""
server: ExitServer
def handle(self) -> None:
sock, seen = self.request, self.server.seen
stream = sock.makefile("rb")
if stream.peek(1)[:1] == b"\x05":
stream.read(stream.read(2)[1])
sock.sendall(b"\x05\x02")
stream.read(1)
user = stream.read(stream.read(1)[0])
seen.append(user + b":" + stream.read(stream.read(1)[0]))
sock.sendall(b"\x01\x00")
address_type = stream.read(4)[3]
seen.append(stream.read(stream.read(1)[0] if address_type == 3 else 4 if address_type == 1 else 16))
stream.read(2)
sock.sendall(b"\x05\x00\x00\x01" + bytes(6))
else:
head = b""
while not head.endswith(b"\r\n\r\n"):
head += stream.readline()
seen.append(head)
sock.sendall(b"HTTP/1.1 " + self.server.status + b"\r\n\r\n")
if not self.server.status.startswith(b"200"):
return
upstream = socket.create_connection(("127.0.0.1", self.server.target))
threading.Thread(target=relay, args=(upstream, sock), daemon=True).start()
relay(sock, upstream)
class ExitServer(socketserver.ThreadingTCPServer):
daemon_threads = True
seen: list[bytes]
status: bytes
target: int
@pytest.fixture
def exit_proxy(target: int) -> Iterator[ExitServer]:
proxy = ExitServer(("127.0.0.1", 0), ExitProxy)
proxy.seen, proxy.status, proxy.target = [], b"200 Connection established", target
threading.Thread(target=proxy.serve_forever, daemon=True).start()
yield proxy
proxy.shutdown()
@pytest.mark.parametrize("scheme", ["http", "socks5h"])
def test_the_tunnel_dials_through_an_exit_proxy_with_its_login(
server: str, exit_proxy: ExitServer, scheme: str
) -> None:
port = exit_proxy.server_address[1]
client = TunnelClient(server, {"X-Secret-Key": "alice"}, f"{scheme}://us%40er:p%3Ass@127.0.0.1:{port}")
proxy = tunnel_proxy(client.start(), "alice")
resp = requests.get("http://hello.example/", proxies={"all": proxy}, timeout=10)
client.stop()
assert resp.text == "hello through the tunnel"
if scheme == "http":
assert exit_proxy.seen[0].startswith(b"CONNECT hello.example:80 HTTP/1.1\r\n")
assert b"Proxy-Authorization: Basic dXNAZXI6cDpzcw==\r\n" in exit_proxy.seen[0]
else:
assert exit_proxy.seen == [b"us@er:p:ss", b"hello.example"]
@pytest.mark.parametrize(
("host", "address"),
[
("127.0.0.1", None),
("::1", None),
("10.1", None),
("localhost", None),
("printer.localhost.", None),
("bad.example\r\nX-Injected: 1", None),
("bad example", None),
("rebind.example", "192.168.1.1"),
("metadata.example", "169.254.169.254"),
],
)
def test_the_exit_proxy_never_carries_a_local_or_malformed_target(
monkeypatch: pytest.MonkeyPatch, exit_proxy: ExitServer, host: str, address: str | None
) -> None:
async def resolve(self: object, host: str, port: int, **_: object) -> list:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (address, port))]
if address:
monkeypatch.setattr(asyncio.BaseEventLoop, "getaddrinfo", resolve)
with pytest.raises(PermissionError):
asyncio.run(connect_proxied(f"http://127.0.0.1:{exit_proxy.server_address[1]}", host, 80))
assert exit_proxy.seen == []
def test_a_refused_connect_fails_and_never_dials_direct(
server: str, target: int, exit_proxy: ExitServer, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True) # so a direct dial to the target would work
monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False)
exit_proxy.status = b"407 Proxy Authentication Required"
hits = Hello.hits
client = TunnelClient(server, {"X-Secret-Key": "alice"}, f"http://127.0.0.1:{exit_proxy.server_address[1]}")
proxy = tunnel_proxy(client.start(), "alice")
with pytest.raises(requests.exceptions.ConnectionError):
requests.get(f"http://127.0.0.1:{target}/", proxies={"all": proxy}, timeout=10)
client.stop()
assert len(exit_proxy.seen) == 1 and Hello.hits == hits
with pytest.raises(OSError, match="407"):
asyncio.run(connect_proxied(f"http://127.0.0.1:{exit_proxy.server_address[1]}", "hello.example", 80))
def test_a_proxy_the_tunnel_cannot_dial_through_is_refused_at_start() -> None:
from unshackle.core.remote_service import start_tunnel
with pytest.raises(click.ClickException, match="cannot use a socks4 proxy"):
start_tunnel(None, "socks4://127.0.0.1:1080") # type: ignore[arg-type]
+7 -3
View File
@@ -936,7 +936,7 @@ class dl:
"--local-proxy",
is_flag=True,
default=False,
help="With --remote, the server sends its service requests out through this machine's own connection (and any VPN on it). Works with no other proxy option.",
help="With --remote, the server sends its service requests out through this machine's own connection (and any VPN on it), or through a --proxy dialled from this machine.",
)
@click.option(
"--tag",
@@ -1570,14 +1570,18 @@ class dl:
proxy, no_proxy = ctx.params.get("proxy"), bool(ctx.params.get("no_proxy"))
# A server on this machine can reach a local proxy, so only a server elsewhere is guarded
self.remote_needs_public_proxy = self.is_remote and not is_loopback(server_url(ctx.params.get("server")))
self.remote_needs_public_proxy = (
self.is_remote
and not ctx.params.get("local_proxy")
and not is_loopback(server_url(ctx.params.get("server")))
)
self.proxy_providers = []
if no_proxy:
ctx.params["proxy"] = None
else:
if self.remote_needs_public_proxy and proxy and proxy.lower().startswith("gluetun:"):
raise click.UsageError("Gluetun runs on your machine, so --remote cannot use it.")
raise click.UsageError("Gluetun runs on your machine, so --remote needs --local-proxy to use it.")
if proxy_providers is not None:
self.proxy_providers = list(proxy_providers)
else:
+1 -1
View File
@@ -48,7 +48,7 @@ from unshackle.core.utils.collections import ci_get, merge_dict
"--local-proxy",
is_flag=True,
default=False,
help="With --remote, the server sends its service requests out through this machine's own connection (and any VPN on it). Works with no other proxy option.",
help="With --remote, the server sends its service requests out through this machine's own connection (and any VPN on it), or through a --proxy dialled from this machine.",
)
@click.option("--no-proxy", is_flag=True, default=False, help="Force disable all proxy use.")
@click.option(
+18 -6
View File
@@ -2,9 +2,12 @@ import logging
from typing import Optional
import click
import requests
from unshackle.core.remote_service import remote_client, start_tunnel
from unshackle.core.proxies.resolve import load_proxy_providers
from unshackle.core.remote_service import remote_client, resolve_proxy_arg, start_tunnel
from unshackle.core.tunnel import CHECK_ROUTE
from unshackle.core.utils import run_proxy
from unshackle.core.utils.ip_info import get_ip_info
from unshackle.core.utils.redact import safe_display_url
@@ -13,21 +16,30 @@ from unshackle.core.utils.redact import safe_display_url
@click.option(
"--server", type=str, default=None, help="Name of the remote server from remote_services config (if multiple)."
)
def tunnel_test(server: Optional[str]) -> None:
@click.option(
"--proxy", type=str, default=None, help="Check the tunnel through this proxy, as --local-proxy --proxy does."
)
def tunnel_test(server: Optional[str], proxy: Optional[str]) -> None:
"""Check --local-proxy before a real --remote run.
The command finds the exit IP of this machine, opens a tunnel to the server, and makes the server
look up its exit IP through the tunnel. The two must match.
The command finds the exit IP of this machine (through --proxy when given), opens a tunnel to the server,
and makes the server look up its exit IP through the tunnel. The two must match.
"""
log = logging.getLogger("tunnel-test")
local = get_ip_info()
proxy = resolve_proxy_arg(proxy, load_proxy_providers()) if proxy else None
session = None
if proxy:
session = requests.Session()
session.proxies["all"] = proxy
run_proxy.pin(session)
local = get_ip_info(session)
if not local:
raise click.ClickException("Step 1 failed: an IP lookup from this machine did not work.")
log.info(f"Step 1: this machine exits at {local['ip']} ({str(local.get('country', '?')).upper()})")
client = remote_client(server)
tunnel = start_tunnel(client)
tunnel = start_tunnel(client, proxy)
log.info(f"Step 2: opened a tunnel to {safe_display_url(client.server_url)}")
try:
remote = client.get(f"{CHECK_ROUTE}?id={tunnel.id}")
+4 -3
View File
@@ -207,9 +207,10 @@ def describe_proxy(provider: Any, uri: str, query: str, allow_debug: bool = True
def require_alone_local_proxy(ctx: click.Context) -> None:
"""Check ``--local-proxy``: it needs a remote run and no other proxy option.
"""Check ``--local-proxy``: it needs a remote run, and takes no proxy option but a typed ``--proxy``.
A proxy option that comes from the ``dl`` config defaults, not the command line, is cleared instead.
A typed ``--proxy`` becomes the tunnel's exit on this machine. A proxy option that comes from the ``dl``
config defaults, not the command line, is cleared instead.
"""
if not ctx.params.get("remote"):
raise click.UsageError(
@@ -221,7 +222,7 @@ def require_alone_local_proxy(ctx: click.Context) -> None:
continue
if ctx.get_parameter_source(key) in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP):
ctx.params[key] = False if key.startswith("no_") else None
else:
elif key != "proxy":
given.append("--" + key.replace("_", "-"))
if given:
raise click.UsageError(f"--local-proxy does not work with {', '.join(given)}.")
+17 -12
View File
@@ -26,7 +26,7 @@ from http.cookiejar import CookieJar
from pathlib import Path
from threading import Event, Lock, Thread, local
from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Protocol, TypeVar, Union
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
from uuid import UUID
import click
@@ -54,7 +54,7 @@ from unshackle.core.titles.music import SONG_FIELDS, Album, Song
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.track import Track
from unshackle.core.tunnel import TunnelClient, parse_retry_after
from unshackle.core.tunnel import PROXY_PORTS, TunnelClient, parse_retry_after
from unshackle.core.utils import run_proxy
from unshackle.core.utils.collections import ci_get
from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url
@@ -819,15 +819,14 @@ def load_cookies_for_transport(service_tag: str, profile: Optional[str]) -> Opti
return None
def resolve_proxy_arg(proxy_arg: Optional[str]) -> Optional[str]:
def resolve_proxy_arg(proxy_arg: Optional[str], providers: Optional[list] = None) -> Optional[str]:
if not proxy_arg:
return None
from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy
try:
providers = initialize_proxy_providers()
return resolve_proxy(proxy_arg, providers)
return resolve_proxy(proxy_arg, initialize_proxy_providers() if providers is None else providers)
except ValueError as e:
raise click.ClickException(str(e))
@@ -860,13 +859,16 @@ def resolve_remote_proxy_arg(proxy_arg: str, providers: Optional[list] = None) -
raise click.ClickException(str(e))
def start_tunnel(client: RemoteClient) -> TunnelClient:
"""Open a tunnel that sends the server's requests out through this machine's own connection."""
def start_tunnel(client: RemoteClient, proxy: Optional[str] = None) -> TunnelClient:
"""Open a tunnel that sends the server's requests out through this machine's own connection, or through
``proxy`` from this machine."""
if proxy and (scheme := urlsplit(proxy).scheme.lower()) not in PROXY_PORTS:
raise click.ClickException(f"--local-proxy cannot use a {scheme} proxy")
headers = {k: str(v) for k, v in client.session.headers.items() if k.lower() != "connection"}
slot_deadline = time.monotonic() + RETRY_AFTER_MAX
rate_waits = 0
while True:
tunnel = TunnelClient(client.server_url, headers)
tunnel = TunnelClient(client.server_url, headers, proxy)
try:
tunnel.start()
break
@@ -1190,7 +1192,7 @@ class RemoteService:
def require_reachable_proxy(self, proxy: Optional[str]) -> None:
"""Refuse a proxy on this machine when the server is elsewhere: the server cannot reach it."""
if proxy and is_loopback(proxy) and not is_loopback(self.client.server_url):
raise click.ClickException("--remote cannot use a proxy on this machine; use --local-proxy without --proxy")
raise click.ClickException("--remote cannot use a proxy on this machine without --local-proxy")
def tunnel(self) -> str:
"""The ``tunnel:<id>`` proxy to send the server for ``--local-proxy``, opening the tunnel the first time."""
@@ -1200,8 +1202,11 @@ class RemoteService:
)
tunnel = self.client.tunnel
if tunnel is None:
tunnel = start_tunnel(self.client)
self.log.info("The server sends its requests through your connection on this machine")
proxy = self.ctx.parent.params.get("proxy") if self.ctx.parent else None
tunnel = start_tunnel(self.client, proxy)
self.log.info(
f"The server sends its requests through your {'proxy' if proxy else 'connection'} on this machine"
)
return f"tunnel:{tunnel.id}"
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
@@ -1256,7 +1261,7 @@ class RemoteService:
if country:
create_data["proxy_region"] = country.group(1).lower()
self.require_reachable_proxy(create_data.get("proxy"))
local_proxy = None if no_proxy or local else (proxy or create_data.get("proxy"))
local_proxy = None if no_proxy else proxy if local else proxy or create_data.get("proxy")
if local_proxy:
self._session.proxies["all"] = local_proxy
run_proxy.pin(self._session)
+125 -11
View File
@@ -9,22 +9,32 @@ the server and the service, so the client sees only ciphertext.
from __future__ import annotations
import asyncio
import base64
import functools
import ipaddress
import itertools
import json
import logging
import re
import secrets
import socket
import ssl
import struct
import threading
from typing import Any, Optional
from urllib.parse import unquote, urlsplit
import aiohttp
import certifi
from aiohttp import WSCloseCode, WSMsgType, web
from multidict import CIMultiDict
log = logging.getLogger("tunnel")
PROXY_PORTS = {"http": 80, "https": 443, "socks5": 1080, "socks5h": 1080}
PROXY_TIMEOUT = 10
HOSTNAME = re.compile(r"[A-Za-z0-9_-]{1,63}(\.[A-Za-z0-9_-]{1,63})*\.?")
OPEN, OK, DATA, CLOSE = range(4)
HEADER = struct.Struct("!IB") # stream id, frame kind
HEARTBEAT = 20
@@ -358,15 +368,7 @@ async def connect_public(host: str, port: int) -> tuple[asyncio.StreamReader, as
synthetic one (such as fake-IP DNS in a TUN) and map it back to the real host, which the client cannot
check. An IP address from the server must be public."""
infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM)
resolved = [str(info[4][0]) for info in infos]
if is_ip_literal(host):
addresses = [a for a in resolved if is_public_ip(a)]
if not addresses:
raise PermissionError(f"{host} is not a public address")
else:
addresses = [a for a in resolved if not is_local_ip(a)]
if not addresses:
raise PermissionError(f"{host} resolves only to local addresses ({', '.join(resolved)})")
addresses = allowed_addresses(host, [str(info[4][0]) for info in infos])
error: OSError = OSError(f"could not connect to {host}")
for address in addresses:
try:
@@ -381,6 +383,111 @@ async def connect_public(host: str, port: int) -> tuple[asyncio.StreamReader, as
raise error
def allowed_addresses(host: str, resolved: list[str]) -> list[str]:
"""The addresses of ``host`` the client may dial for the server: public ones for an IP address, non-local
ones for a name."""
if is_ip_literal(host):
if addresses := [a for a in resolved if is_public_ip(a)]:
return addresses
raise PermissionError(f"{host} is not a public address")
if addresses := [a for a in resolved if not is_local_ip(a)]:
return addresses
raise PermissionError(f"{host} resolves only to local addresses ({', '.join(resolved)})")
async def check_proxied_target(host: str, port: int) -> None:
"""Refuse a target the exit proxy must not reach for the server: a malformed name, a non-public IP address,
or a name that resolves here only to local addresses. The proxy resolves the name itself, so a name this
machine cannot resolve passes."""
if not (HOSTNAME.fullmatch(host) or parse_ip(host)):
raise PermissionError("a malformed host name")
name = host.lower().rstrip(".")
if name == "localhost" or name.endswith(".localhost"):
raise PermissionError(f"{host} is this machine")
try:
infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM)
except (OSError, UnicodeError):
if is_ip_literal(host):
raise PermissionError(f"{host} is not a public address")
return
allowed_addresses(host, [str(info[4][0]) for info in infos])
async def connect_proxied(proxy: str, host: str, port: int) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
"""Connect to ``host`` through an http, https or socks5 proxy on the client's side. A failure raises, and
never falls back to a direct connection."""
await check_proxied_target(host, port)
uri = urlsplit(proxy)
scheme = uri.scheme.lower()
user, password = unquote(uri.username or ""), unquote(uri.password or "")
tls = tls_context() if scheme == "https" else None
reader, writer = await asyncio.wait_for(
asyncio.open_connection(
uri.hostname, uri.port or PROXY_PORTS[scheme], ssl=tls, server_hostname=uri.hostname if tls else None
),
PROXY_TIMEOUT,
)
handshake = socks5_handshake if scheme.startswith("socks5") else http_connect
try:
await asyncio.wait_for(handshake(reader, writer, host, port, user, password), PROXY_TIMEOUT)
except BaseException as e:
writer.close()
if isinstance(e, asyncio.IncompleteReadError):
raise OSError("the proxy closed the connection") from e
raise
return reader, writer
@functools.cache
def tls_context() -> ssl.SSLContext:
return ssl.create_default_context(cafile=certifi.where())
async def http_connect(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter, host: str, port: int, user: str, password: str
) -> None:
target = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
auth = f"Proxy-Authorization: Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}\r\n" if user else ""
writer.write(f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n{auth}\r\n".encode())
status = await reader.readline()
parts = status.split(maxsplit=2)
if len(parts) < 2 or not parts[0].startswith(b"HTTP/") or not (parts[1].isdigit() and parts[1][:1] == b"2"):
raise OSError(f"the proxy answered {status[:100].decode('latin-1').strip() or 'nothing'}")
for _ in range(100):
line = await reader.readline()
if not line:
raise OSError("the proxy closed the connection")
if line in (b"\r\n", b"\n"):
return
raise OSError("the proxy sent too many headers")
async def socks5_handshake(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter, host: str, port: int, user: str, password: str
) -> None:
writer.write(b"\x05\x02\x00\x02" if user else b"\x05\x01\x00")
version, method = await reader.readexactly(2)
if version != 5:
raise OSError("the proxy is not a SOCKS5 proxy")
if method == 2 and user:
name, secret = user.encode(), password.encode()
writer.write(b"\x01" + bytes([len(name)]) + name + bytes([len(secret)]) + secret)
if (await reader.readexactly(2))[1] != 0:
raise OSError("the SOCKS5 proxy refused the login")
elif method != 0:
raise OSError("the SOCKS5 proxy accepts no login method this client has")
ip = parse_ip(host)
address = (
(b"\x01" if ip.version == 4 else b"\x04") + ip.packed if ip else b"\x03" + bytes([len(host)]) + host.encode()
)
writer.write(b"\x05\x01\x00" + address + port.to_bytes(2, "big"))
_, code, _, address_type = await reader.readexactly(4)
if code != 0:
raise OSError(f"the SOCKS5 proxy refused the connection (code {code})")
length = {1: 4, 4: 16}.get(address_type) or (await reader.readexactly(1))[0]
await reader.readexactly(length + 2)
def parse_retry_after(value: Optional[str]) -> Optional[int]:
"""Whole seconds, at least 1, from a delay-seconds Retry-After; None for an absent, HTTP-date or malformed one."""
if not value or len(value) > 9 or not (value.isascii() and value.isdigit()):
@@ -405,9 +512,11 @@ def refusal(e: aiohttp.WSServerHandshakeError) -> str:
class TunnelClient:
"""The client side: a background thread that keeps the tunnel's WebSocket open and dials for the server."""
def __init__(self, server_url: str, headers: dict[str, str]) -> None:
def __init__(self, server_url: str, headers: dict[str, str], proxy: Optional[str] = None) -> None:
self.url = server_url.rstrip("/") + ROUTE
self.headers = headers
self.proxy = proxy
self.proxy_failed = False
self.id: Optional[str] = None
self.error: Optional[BaseException] = None
self.retry_after: Optional[int] = None
@@ -502,7 +611,9 @@ class TunnelClient:
async def open(self, link: Link, sid: int, host: str, port: int) -> None:
try:
reader, writer = await connect_public(host, port)
reader, writer = await (
connect_proxied(self.proxy, host, port) if self.proxy else connect_public(host, port)
)
except PermissionError as e:
if host not in self.refused: # the server retries, so warn once per host
self.refused.add(host)
@@ -510,6 +621,9 @@ class TunnelClient:
await link.close_stream(sid)
return
except (OSError, ValueError, asyncio.TimeoutError) as e: # the idna codec raises ValueError on a bad hostname
if self.proxy and not self.proxy_failed:
self.proxy_failed = True
log.warning(f"Tunnel dial through the proxy failed: {str(e) or 'timed out'}")
log.debug(f"Tunnel dial to {host}:{port} failed: {e}")
await link.close_stream(sid)
return