fix(proxy): name the reason when the Control D forwarder answers 502

This commit is contained in:
imSp4rky
2026-10-21 18:35:26 -06:00
parent a7e68b7d20
commit 0eaefcaa2b
3 changed files with 110 additions and 31 deletions
+9 -2
View File
@@ -462,11 +462,18 @@ it next needs them.
### Troubleshooting Control D
When the forwarder cannot connect to a host, it answers `502 Bad Gateway` and gives the reason in brackets.
Python-Requests shows it as `Tunnel connection failed: 502 Bad Gateway (<reason>)`. The forwarder also logs
each reason once as a warning, `Could not reach <host>:<port>: <reason>`, because some HTTP clients do not
show the reason.
| Symptom | Cause and fix |
|---|---|
| `Proxy check failed`, because the exit IP is your own IP. | The profile does not redirect. Make sure that the plan includes traffic redirection (*Full Control*, not *Some Control*). After a region change, wait for the TTL of the previous answer to end. |
| A `502 Bad Gateway` for one host. With `-d`, the log shows `Control D returned no A record for …`. | Control D gave no IPv4 address for that host. |
| A `502 Bad Gateway` for one host only. With `-d`, the log shows `Refused …, an address that Control D cannot redirect`. | The request names the host by IP address. The forwarder refuses it. See the note on IPv4 above. |
| `502 Bad Gateway (Control D returned no A record for …)` | Control D gave no IPv4 address for that host. |
| `502 Bad Gateway (an IP address skips Control D's DNS …)` | The request names the host by IP address. The forwarder refuses it. See the note on IPv4 above. |
| `502 Bad Gateway (Control D sent … to …, which did not answer: …)` | Control D gave an address for the host, but the connection to that address failed. This is usually a temporary fault on Control D's network. Try again, or use `--no-proxy-download` to download without the proxy. |
| `502 Bad Gateway (Control D DNS timed out)`, `(Control D DNS could not be reached …)` or `(Control D DNS answered HTTP …)` | unshackle got no answer from Control D's DNS at `dns.controld.com`. Check the network connection of this machine. |
| An HTTP `401` or `403` from `api.controld.com`. | The token is a *Read* token, or its *Allowed IPs* do not include this machine. Use a *Write* token. |
| `Control D needs both a profile and a resolver` or `Control D needs max_profiles of 1 or more` at startup. | The `controld` config is not valid. Give `profile` and `resolver` together, and a `max_profiles` of 1 or more. Every run fails until you correct it, also a run that does not use Control D. |
| `Could not read Control D's locations`. | unshackle could not get the location list. It tries again at the next Control D query. |
+67 -16
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import base64
import http.server
import logging
import re
import socket
import socketserver
@@ -13,6 +14,7 @@ import threading
import time
from types import SimpleNamespace
from typing import Iterator
from unittest.mock import Mock
import click
import pytest
@@ -30,6 +32,7 @@ from unshackle.core.proxies.controld import (
parse_a_records,
)
from unshackle.core.proxies.resolve import REGION, resolve_proxy
from unshackle.core.utilities import get_free_port
pytestmark = pytest.mark.unit
@@ -74,9 +77,12 @@ def origin(body: bytes) -> http.server.HTTPServer:
@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"))
def forwarder(request: pytest.FixtureRequest) -> Iterator[LocalProxy]:
"""A forwarder whose resolver sends every hostname to 127.0.0.1, as Control D sends it to its proxy.
A test can pass its own resolver as the indirect parameter.
"""
proxy = LocalProxy(getattr(request, "param", None) or SimpleNamespace(resolve=lambda host: "127.0.0.1"))
threading.Thread(target=proxy.serve_forever, daemon=True).start()
yield proxy
proxy.shutdown()
@@ -134,8 +140,8 @@ def test_connect_tunnels_bytes_both_ways(forwarder: LocalProxy) -> None:
@pytest.mark.parametrize(
("target", "status"),
[
("1.2.3.4:443", b"502"), # an IP literal would bypass Control D
("[::1]:443", b"502"),
("1.2.3.4:443", b"502 Bad Gateway (an IP address skips Control D's DNS"),
("[::1]:443", b"502 Bad Gateway (an IP address skips Control D's DNS"),
("a.test:x", b"400"),
],
)
@@ -145,18 +151,63 @@ def test_connect_refuses_what_it_cannot_redirect(forwarder: LocalProxy, target:
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")
def failing_resolver(error: Exception) -> Resolver:
resolver = Resolver("https://dns.controld.com/SECRETID")
resolver.session = SimpleNamespace(get=Mock(side_effect=error))
return resolver
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()
@pytest.mark.parametrize(
("error", "reason"),
[
(
requests.ConnectionError("HTTPSConnectionPool(host='dns.controld.com'): /SECRETID"),
"Control D DNS could not be reached (ConnectionError)",
),
(requests.Timeout("read timed out: dns.controld.com/SECRETID"), "Control D DNS timed out"),
(
requests.HTTPError("503 for url: /SECRETID", response=SimpleNamespace(status_code=503)),
"Control D DNS answered HTTP 503",
),
],
)
def test_a_failed_lookup_hides_the_resolver(error: Exception, reason: str) -> None:
with pytest.raises(ConnectionError) as e:
failing_resolver(error).resolve("a.test")
assert str(e.value) == reason and e.value.__suppress_context__ # a traceback must not print the URL
@pytest.mark.parametrize(
("forwarder", "reason"),
[
(
SimpleNamespace(resolve=Mock(side_effect=ConnectionError("Control D returned no A record for a.test"))),
"no A record for a.test",
),
(
failing_resolver(requests.ConnectionError("dns.controld.com: /SECRETID")),
"Control D DNS could not be reached",
),
# a CR/LF left in the reason would end the status line early and cut the reason off
(
SimpleNamespace(resolve=Mock(side_effect=ConnectionError("no A record\r\nX-Injected: 1"))),
"no A record X-Injected: 1",
),
(None, "Control D sent a.test to 127.0.0.1, which did not answer"),
],
indirect=["forwarder"],
)
def test_a_failed_tunnel_names_the_reason(forwarder: LocalProxy, reason: str, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.WARNING, logger="ControlD")
with pytest.raises(requests.exceptions.ProxyError) as e:
requests.get(f"https://a.test:{get_free_port()}/", proxies={"https": forwarder.uri}, timeout=10)
assert "502 Bad Gateway (" in str(e.value) and reason in str(e.value)
assert "SECRETID" not in str(e.value)
response = requests.get(f"http://a.test:{get_free_port()}/", proxies={"http": forwarder.uri}, timeout=10)
assert response.status_code == 502
# clients that drop the reason phrase still get it once in the log
warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1 and reason in warnings[0]
def stub_session(response: bytes, seen: list) -> object:
+34 -13
View File
@@ -102,6 +102,7 @@ class Resolver:
self.lock = threading.Lock()
def resolve(self, host: str) -> str:
"""Get the IPv4 address Control D gives `host`. A failure raises `ConnectionError` without the resolver ID."""
host = host.lower()
with self.lock:
cached = self.cache.get(host)
@@ -109,13 +110,20 @@ class Resolver:
return cached[0]
query = base64.urlsafe_b64encode(build_query(host)).rstrip(b"=").decode()
response = self.session.get(
self.url,
params={"dns": query},
headers={"accept": "application/dns-message"},
timeout=DOH_TIMEOUT,
)
response.raise_for_status()
try:
response = self.session.get(
self.url,
params={"dns": query},
headers={"accept": "application/dns-message"},
timeout=DOH_TIMEOUT,
)
response.raise_for_status()
except requests.HTTPError as e:
raise ConnectionError(f"Control D DNS answered HTTP {e.response.status_code}") from None
except requests.Timeout:
raise ConnectionError("Control D DNS timed out") from None
except requests.RequestException as e:
raise ConnectionError(f"Control D DNS could not be reached ({type(e).__name__})") from None
records = parse_a_records(response.content)
if not records:
@@ -158,6 +166,17 @@ class Handler(socketserver.StreamRequestHandler):
except OSError:
pass
def refuse(self, host: str, port: int, reason: str) -> None:
"""Answer 502 with the reason in the status line, which a client shows as `Tunnel connection failed: ...`.
Some HTTP clients drop the reason phrase, so this also logs each new reason once as a warning.
"""
reason = " ".join(reason.split())
if (host, reason) not in self.server.refused:
self.server.refused.add((host, reason))
log.warning(f"Could not reach {host}:{port}: {reason}")
self.reply(f"502 Bad Gateway ({reason})".encode("latin-1", "replace"))
def forward_response_head(self, upstream: socket.socket) -> None:
"""Send the response head on with `Connection: close`, so the client never reuses this connection."""
data = b""
@@ -212,16 +231,16 @@ class Handler(socketserver.StreamRequestHandler):
except ValueError:
pass
else:
# an IP literal bypasses Control D's DNS and would leak this machine's own IP
log.debug(f"Refused {host}:{port}, an address that Control D cannot redirect")
return self.reply(b"502 Bad Gateway")
return self.refuse(host, port, "an IP address skips Control D's DNS and would expose this machine's IP")
try:
ip = self.server.resolver.resolve(host)
upstream = socket.create_connection((ip, port), timeout=HEAD_TIMEOUT)
except Exception as e:
log.debug(f"Could not reach {host}:{port}: {e}")
return self.reply(b"502 Bad Gateway")
return self.refuse(host, port, str(e) or type(e).__name__)
try:
upstream = socket.create_connection((ip, port), timeout=HEAD_TIMEOUT)
except OSError as e:
return self.refuse(host, port, f"Control D sent {host} to {ip}, which did not answer: {e.strerror or e}")
self.connection.settimeout(IDLE_TIMEOUT)
upstream.settimeout(IDLE_TIMEOUT)
@@ -253,6 +272,7 @@ class LocalProxy(socketserver.ThreadingTCPServer):
"""An HTTP forward proxy on loopback that resolves every host through `resolver`."""
super().__init__(("127.0.0.1", 0), Handler)
self.resolver = resolver
self.refused: set[tuple[str, str]] = set()
@property
def uri(self) -> str:
@@ -460,6 +480,7 @@ class ControlD(Proxy):
if server:
with server.resolver.lock:
server.resolver.cache.clear()
server.refused.clear()
REGIONS.pop(profile, None)
REGIONS[profile] = query
LOCATIONS[profile] = location