mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
feat(proxies): add Control D proxy provider
Runs a loopback forwarder per region-named profile (unshackle-<region>), so --proxy and --proxy-download can exit in different countries Thanks to Orni_ for the original code and the test account. Co-authored-by: AntCardFr <24798811+AntCardFR@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"""Unit tests for the Control D proxy provider: the DoH answer parser, the loopback forwarder
|
||||
that turns a resolver into something Python-Requests can use as a proxy, the pool of
|
||||
region-named profiles, and the resolver form a client sends to a remote server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import http.server
|
||||
import re
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Iterator
|
||||
|
||||
import click
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from unshackle.core.proxies import controld
|
||||
from unshackle.core.proxies.controld import (
|
||||
MIN_TTL,
|
||||
ControlD,
|
||||
IPv4Adapter,
|
||||
LocalProxy,
|
||||
Resolver,
|
||||
build_query,
|
||||
local_proxy,
|
||||
parse_a_records,
|
||||
)
|
||||
from unshackle.core.proxies.resolve import REGION, resolve_proxy
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def dns_response(ip: bytes, ttl: int = 300) -> bytes:
|
||||
"""A one-question, one-answer A-record response whose answer name is a compression pointer."""
|
||||
header = b"\x00\x00\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00"
|
||||
question = b"\x01a\x04test\x00" + b"\x00\x01\x00\x01"
|
||||
answer = b"\xc0\x0c" + b"\x00\x01\x00\x01" + ttl.to_bytes(4, "big") + b"\x00\x04" + ip
|
||||
return header + question + answer
|
||||
|
||||
|
||||
def test_query_is_a_well_formed_a_record_question() -> None:
|
||||
query = build_query("a.test.")
|
||||
assert query[4:6] == b"\x00\x01"
|
||||
assert query[12:] == b"\x01a\x04test\x00\x00\x01\x00\x01"
|
||||
|
||||
|
||||
def test_parses_the_answer_past_a_compression_pointer() -> None:
|
||||
assert parse_a_records(dns_response(bytes([1, 2, 3, 4]))) == [("1.2.3.4", 300)]
|
||||
assert parse_a_records(b"") == []
|
||||
|
||||
|
||||
def origin(body: bytes) -> http.server.HTTPServer:
|
||||
"""A local HTTP origin that answers every GET with `body`, and keeps connections alive."""
|
||||
|
||||
class Origin(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), Origin)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forwarder() -> Iterator[LocalProxy]:
|
||||
"""A forwarder whose resolver sends every hostname to 127.0.0.1, as Control D sends it to its proxy."""
|
||||
proxy = LocalProxy(SimpleNamespace(resolve=lambda host: "127.0.0.1"))
|
||||
threading.Thread(target=proxy.serve_forever, daemon=True).start()
|
||||
yield proxy
|
||||
proxy.shutdown()
|
||||
proxy.server_close()
|
||||
|
||||
|
||||
def test_forwards_a_request_to_the_resolved_host(forwarder: LocalProxy) -> None:
|
||||
pong = origin(b"pong")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"http://unresolvable.test:{pong.server_address[1]}/", proxies={"http": forwarder.uri}, timeout=10
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"pong"
|
||||
finally:
|
||||
pong.shutdown()
|
||||
|
||||
|
||||
def test_a_reused_connection_never_carries_a_request_to_the_wrong_host(forwarder: LocalProxy) -> None:
|
||||
"""A client pools one proxy connection for every plain-HTTP host, and the relay is bound to the first."""
|
||||
first, second = origin(b"A"), origin(b"B")
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
session.proxies = {"http": forwarder.uri}
|
||||
assert session.get(f"http://a.test:{first.server_address[1]}/", timeout=10).content == b"A"
|
||||
assert session.get(f"http://b.test:{second.server_address[1]}/", timeout=10).content == b"B"
|
||||
finally:
|
||||
first.shutdown()
|
||||
second.shutdown()
|
||||
|
||||
|
||||
def test_connect_tunnels_bytes_both_ways(forwarder: LocalProxy) -> None:
|
||||
"""Every real download is HTTPS, so it all goes through CONNECT rather than absolute-form."""
|
||||
echo = socketserver.TCPServer(
|
||||
("127.0.0.1", 0),
|
||||
type(
|
||||
"Echo",
|
||||
(socketserver.BaseRequestHandler,),
|
||||
{"handle": lambda self: self.request.sendall(self.request.recv(64)[::-1])},
|
||||
),
|
||||
)
|
||||
threading.Thread(target=echo.serve_forever, daemon=True).start()
|
||||
|
||||
try:
|
||||
with socket.create_connection(forwarder.server_address[:2], timeout=10) as client:
|
||||
client.sendall(f"CONNECT unresolvable.test:{echo.server_address[1]} HTTP/1.1\r\n\r\n".encode())
|
||||
assert b"200 Connection established" in client.recv(128)
|
||||
client.sendall(b"stressed")
|
||||
assert client.recv(64) == b"desserts"
|
||||
finally:
|
||||
echo.shutdown()
|
||||
echo.server_close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "status"),
|
||||
[
|
||||
("1.2.3.4:443", b"502"), # an IP literal would bypass Control D
|
||||
("[::1]:443", b"502"),
|
||||
("a.test:x", b"400"),
|
||||
],
|
||||
)
|
||||
def test_connect_refuses_what_it_cannot_redirect(forwarder: LocalProxy, target: str, status: bytes) -> None:
|
||||
with socket.create_connection(forwarder.server_address[:2], timeout=10) as client:
|
||||
client.sendall(f"CONNECT {target} HTTP/1.1\r\n\r\n".encode())
|
||||
assert client.recv(128).startswith(b"HTTP/1.1 " + status)
|
||||
|
||||
|
||||
def test_an_unreachable_host_answers_502() -> None:
|
||||
def refuse(host: str) -> str:
|
||||
raise ConnectionError("no A record")
|
||||
|
||||
proxy = LocalProxy(SimpleNamespace(resolve=refuse))
|
||||
threading.Thread(target=proxy.serve_forever, daemon=True).start()
|
||||
try:
|
||||
response = requests.get("http://unresolvable.test/", proxies={"http": proxy.uri}, timeout=10)
|
||||
assert response.status_code == 502
|
||||
finally:
|
||||
proxy.shutdown()
|
||||
proxy.server_close()
|
||||
|
||||
|
||||
def stub_session(response: bytes, seen: list) -> object:
|
||||
class Session:
|
||||
@staticmethod
|
||||
def get(url: str, params: dict, headers: dict, timeout: int) -> object:
|
||||
seen.append((url, params, headers))
|
||||
|
||||
class Answer:
|
||||
content = response
|
||||
|
||||
@staticmethod
|
||||
def raise_for_status() -> None:
|
||||
pass
|
||||
|
||||
return Answer()
|
||||
|
||||
return Session()
|
||||
|
||||
|
||||
def test_resolve_asks_control_d_in_wireformat_and_caches_it() -> None:
|
||||
seen: list = []
|
||||
resolver = Resolver("https://dns.controld.com/efgh456")
|
||||
resolver.session = stub_session(dns_response(bytes([9, 8, 7, 6]), ttl=1), seen)
|
||||
|
||||
assert resolver.resolve("a.test") == "9.8.7.6"
|
||||
url, params, headers = seen[0]
|
||||
assert url == "https://dns.controld.com/efgh456"
|
||||
assert headers == {"accept": "application/dns-message"}
|
||||
assert params["dns"] == base64.urlsafe_b64encode(build_query("a.test")).rstrip(b"=").decode()
|
||||
assert "=" not in params["dns"] # RFC 8484 wants the padding stripped
|
||||
|
||||
assert resolver.resolve("a.test") == "9.8.7.6"
|
||||
assert len(seen) == 1
|
||||
assert resolver.cache["a.test"][1] > time.monotonic() + MIN_TTL - 1 # a 1s TTL is floored
|
||||
|
||||
|
||||
def test_resolve_raises_when_control_d_has_no_address() -> None:
|
||||
resolver = Resolver("https://dns.controld.com/efgh456")
|
||||
resolver.session = stub_session(b"\x00\x00\x81\x83" + b"\x00\x00" * 4, [])
|
||||
|
||||
with pytest.raises(ConnectionError):
|
||||
resolver.resolve("nowhere.test")
|
||||
|
||||
|
||||
def test_resolve_asks_over_ipv4_and_ignores_environment_proxies() -> None:
|
||||
"""Control D answers a redirected name only over IPv4, and authorises the address that asked."""
|
||||
resolver = Resolver("https://dns.controld.com/efgh456")
|
||||
assert resolver.session.trust_env is False
|
||||
assert isinstance(resolver.session.get_adapter("https://dns.controld.com/"), IPv4Adapter)
|
||||
|
||||
|
||||
CATALOGUE = [
|
||||
{"PK": "YUL", "country": "CA", "city": "Montreal"},
|
||||
{"PK": "YYZ", "country": "CA", "city": "Toronto"},
|
||||
{"PK": "RES_YYZ", "country": "CA", "city": "Res Toronto", "hidden": True},
|
||||
{"PK": "JFK", "country": "US", "city": "New York"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def catalogue(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Serve Control D's location list without going near the network."""
|
||||
response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"body": {"proxies": CATALOGUE}})
|
||||
monkeypatch.setattr("unshackle.core.proxies.controld.requests.get", lambda *a, **kw: response)
|
||||
|
||||
|
||||
class Account:
|
||||
"""A fake Control D account API: holds endpoints, records every call, and answers them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.devices: list[dict] = []
|
||||
self.calls: list[tuple[str, str, dict]] = []
|
||||
self.fail_device = False
|
||||
|
||||
def device(self, name: str, profile: str) -> None:
|
||||
self.devices.append(
|
||||
{"PK": f"d-{name}", "name": name, "profile": {"PK": profile}, "resolvers": {"uid": f"r{profile}"}}
|
||||
)
|
||||
|
||||
def request(self, method: str, url: str, data: dict, headers: dict, timeout: int) -> SimpleNamespace:
|
||||
assert headers == {"authorization": "Bearer secret"}
|
||||
path = url.removeprefix("https://api.controld.com")
|
||||
self.calls.append((method, path, data))
|
||||
body: dict = {}
|
||||
if (method, path) == ("GET", "/devices"):
|
||||
body = {"devices": [dict(x) for x in self.devices]}
|
||||
elif (method, path) == ("POST", "/profiles"):
|
||||
body = {"profiles": [{"PK": f"p{data['name'].replace('-', '')}"}]}
|
||||
elif (method, path) == ("POST", "/devices"):
|
||||
if self.fail_device:
|
||||
raise requests.HTTPError("endpoint limit")
|
||||
self.device(data["name"], data["profile_id"])
|
||||
body = self.devices[-1]
|
||||
elif method == "PUT" and path.startswith("/devices/"):
|
||||
next(x for x in self.devices if x["PK"] == path.split("/")[2])["name"] = data["name"]
|
||||
return SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"body": body})
|
||||
|
||||
def moves(self) -> list[tuple[str, str]]:
|
||||
"""The (profile, location) of every default-rule change, in order."""
|
||||
return [(path.split("/")[2], data["via"]) for method, path, data in self.calls if path.endswith("/default")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account(catalogue: None, monkeypatch: pytest.MonkeyPatch) -> Iterator[Account]:
|
||||
"""A fake account, on a fresh process state whose forwarders are shut down when the test ends."""
|
||||
made = Account()
|
||||
monkeypatch.setattr("unshackle.core.proxies.controld.requests.request", made.request)
|
||||
controld.FORWARDERS.clear()
|
||||
controld.REGIONS.clear()
|
||||
yield made
|
||||
for server in controld.FORWARDERS.values():
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
controld.FORWARDERS.clear()
|
||||
controld.REGIONS.clear()
|
||||
|
||||
|
||||
def test_locations_come_from_control_d(account: Account) -> None:
|
||||
provider = ControlD(token="secret")
|
||||
assert provider.location("ca") in ("YUL", "YYZ") # never the hidden residential one
|
||||
assert provider.location("res_yyz") == "RES_YYZ" # unless asked for by name
|
||||
assert provider.location("jp") is None
|
||||
assert repr(provider) == "2 Countries (4 Servers)"
|
||||
|
||||
|
||||
def test_a_query_steers_the_profile_default_rule(account: Account) -> None:
|
||||
provider = ControlD(token="secret", profile="abcd123", resolver="efgh456", max_profiles=1)
|
||||
|
||||
assert provider.get_proxy("jp") is None
|
||||
assert not account.moves()
|
||||
|
||||
uri = provider.get_proxy("jfk")
|
||||
assert uri and uri.startswith("http://127.0.0.1:")
|
||||
assert ("PUT", "/profiles/abcd123/default", {"do": "3", "via": "JFK", "status": "1"}) in account.calls
|
||||
|
||||
assert provider.get_proxy("JFK") == uri
|
||||
assert len(account.moves()) == 1
|
||||
|
||||
# one profile only: a second country moves it and must clear its cache
|
||||
cache = controld.FORWARDERS["https://dns.controld.com/efgh456"].resolver.cache
|
||||
cache["cached.test"] = ("1.2.3.4", float("inf"))
|
||||
assert provider.get_proxy("yul") == uri
|
||||
assert account.moves()[-1] == ("abcd123", "YUL")
|
||||
assert cache == {}
|
||||
|
||||
|
||||
def test_each_region_gets_its_own_profile_and_forwarder(account: Account) -> None:
|
||||
"""--proxy and --proxy-download in two countries must not move each other."""
|
||||
provider = ControlD(
|
||||
token="secret", profile="one", resolver="r1", profiles=[{"profile": "two", "resolver": "r2"}], max_profiles=2
|
||||
)
|
||||
us = provider.get_proxy("jfk")
|
||||
ca = provider.get_proxy("yul")
|
||||
assert us != ca
|
||||
assert account.moves() == [("one", "JFK"), ("two", "YUL")]
|
||||
|
||||
# out of profiles: the oldest claim moves
|
||||
assert provider.get_proxy("yyz") == us
|
||||
assert account.moves()[-1] == ("one", "YYZ")
|
||||
assert provider.get_proxy("yul") == ca
|
||||
assert len(account.moves()) == 3
|
||||
|
||||
|
||||
def test_a_region_uses_the_endpoint_named_after_it_or_creates_one(account: Account) -> None:
|
||||
account.device("unshackle-us", "kept")
|
||||
account.device("Phone", "theirs")
|
||||
account.device("unshackle-gb", "theirs") # shares the user's own profile, so never touched
|
||||
provider = ControlD(token="secret")
|
||||
|
||||
us = provider.get_proxy("us")
|
||||
ca = provider.get_proxy("ca")
|
||||
assert us != ca
|
||||
assert ("POST", "/profiles", {"name": "unshackle-ca"}) in account.calls
|
||||
assert [x[0] for x in account.moves()] == ["kept", "punshackleca"]
|
||||
assert "theirs" not in provider.resolvers
|
||||
|
||||
# the REST API makes a provider per request; it must find the endpoint by name
|
||||
assert ControlD(token="secret").get_proxy("ca") == ca
|
||||
assert len(account.moves()) == 2
|
||||
|
||||
|
||||
def test_a_moved_profile_is_renamed_to_its_new_region(account: Account) -> None:
|
||||
account.device("unshackle-us", "kept")
|
||||
provider = ControlD(token="secret", max_profiles=1)
|
||||
|
||||
provider.get_proxy("us")
|
||||
provider.get_proxy("ca")
|
||||
assert ("PUT", "/profiles/kept", {"name": "unshackle-ca"}) in account.calls
|
||||
assert account.devices[0]["name"] == "unshackle-ca"
|
||||
assert account.moves()[-1][0] == "kept"
|
||||
|
||||
|
||||
def test_a_failed_endpoint_leaves_no_profile_behind(account: Account) -> None:
|
||||
account.fail_device = True
|
||||
with pytest.raises(requests.HTTPError):
|
||||
ControlD(token="secret").get_proxy("us")
|
||||
assert ("DELETE", "/profiles/punshackleus", None) in [(m, p, d or None) for m, p, d in account.calls]
|
||||
|
||||
|
||||
def test_a_bad_config_is_refused(catalogue: None) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ControlD(token="secret", profile="one")
|
||||
with pytest.raises(ValueError):
|
||||
ControlD(token="secret", max_profiles=0)
|
||||
with pytest.raises(ValueError):
|
||||
ControlD(token="secret", profile="one", resolver="bad id?")
|
||||
|
||||
|
||||
def test_a_server_runs_a_forwarder_for_a_resolver_a_client_sends(account: Account) -> None:
|
||||
provider = ControlD(token="secret", profile="abcd123", resolver="efgh456")
|
||||
sent = provider.get_remote_proxy("jfk")
|
||||
assert sent == "controld://efgh456@dns.controld.com"
|
||||
|
||||
uri = resolve_proxy(sent, [])
|
||||
assert uri and uri.startswith("http://127.0.0.1:")
|
||||
assert local_proxy(sent) == uri # one forwarder per resolver
|
||||
with pytest.raises(ValueError):
|
||||
local_proxy("controld://evil.example/x@dns.controld.com")
|
||||
|
||||
|
||||
def test_remote_sends_the_resolver_and_a_bare_region_skips_control_d(
|
||||
account: Account, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from unshackle.core import remote_service
|
||||
|
||||
provider = ControlD(token="secret", profile="abcd123", resolver="efgh456")
|
||||
monkeypatch.setattr("unshackle.core.proxies.resolve.initialize_proxy_providers", lambda: [provider])
|
||||
|
||||
assert remote_service.resolve_remote_proxy_arg("controld:us") == "controld://efgh456@dns.controld.com"
|
||||
with pytest.raises(click.ClickException):
|
||||
remote_service.resolve_remote_proxy_arg("us") # a loopback forwarder is useless to a server
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["ca", "us:seattle", "us-ny", "uk1", "yul", "jfk", "res_yyz", "nordvpn:ca"])
|
||||
def test_a_region_query_matches(query: str) -> None:
|
||||
assert re.fullmatch(rf"(?:[a-z]+:)?{REGION}", query, re.IGNORECASE)
|
||||
assert re.fullmatch(rf"(?:[a-z]+:){{0,2}}{REGION}", f"gluetun:{query}", re.IGNORECASE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uri", ["nas:3128", "localhost:8080", "proxy.example.com:8080", "1.2.3.4:8080", "us:1080"])
|
||||
def test_a_host_and_port_is_never_a_region_query(uri: str) -> None:
|
||||
assert not re.fullmatch(rf"(?:[a-z]+:){{0,2}}{REGION}", uri, re.IGNORECASE)
|
||||
Reference in New Issue
Block a user