mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
fix(proxy): name the reason when the Control D forwarder answers 502
This commit is contained in:
@@ -462,11 +462,18 @@ it next needs them.
|
|||||||
|
|
||||||
### Troubleshooting Control D
|
### 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 |
|
| 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. |
|
| `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. |
|
| `502 Bad Gateway (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 (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. |
|
| 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. |
|
| `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. |
|
| `Could not read Control D's locations`. | unshackle could not get the location list. It tries again at the next Control D query. |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import http.server
|
import http.server
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import socketserver
|
import socketserver
|
||||||
@@ -13,6 +14,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import pytest
|
import pytest
|
||||||
@@ -30,6 +32,7 @@ from unshackle.core.proxies.controld import (
|
|||||||
parse_a_records,
|
parse_a_records,
|
||||||
)
|
)
|
||||||
from unshackle.core.proxies.resolve import REGION, resolve_proxy
|
from unshackle.core.proxies.resolve import REGION, resolve_proxy
|
||||||
|
from unshackle.core.utilities import get_free_port
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
@@ -74,9 +77,12 @@ def origin(body: bytes) -> http.server.HTTPServer:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def forwarder() -> Iterator[LocalProxy]:
|
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 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"))
|
|
||||||
|
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()
|
threading.Thread(target=proxy.serve_forever, daemon=True).start()
|
||||||
yield proxy
|
yield proxy
|
||||||
proxy.shutdown()
|
proxy.shutdown()
|
||||||
@@ -134,8 +140,8 @@ def test_connect_tunnels_bytes_both_ways(forwarder: LocalProxy) -> None:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("target", "status"),
|
("target", "status"),
|
||||||
[
|
[
|
||||||
("1.2.3.4:443", b"502"), # an IP literal would bypass Control D
|
("1.2.3.4:443", b"502 Bad Gateway (an IP address skips Control D's DNS"),
|
||||||
("[::1]:443", b"502"),
|
("[::1]:443", b"502 Bad Gateway (an IP address skips Control D's DNS"),
|
||||||
("a.test:x", b"400"),
|
("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)
|
assert client.recv(128).startswith(b"HTTP/1.1 " + status)
|
||||||
|
|
||||||
|
|
||||||
def test_an_unreachable_host_answers_502() -> None:
|
def failing_resolver(error: Exception) -> Resolver:
|
||||||
def refuse(host: str) -> str:
|
resolver = Resolver("https://dns.controld.com/SECRETID")
|
||||||
raise ConnectionError("no A record")
|
resolver.session = SimpleNamespace(get=Mock(side_effect=error))
|
||||||
|
return resolver
|
||||||
|
|
||||||
proxy = LocalProxy(SimpleNamespace(resolve=refuse))
|
|
||||||
threading.Thread(target=proxy.serve_forever, daemon=True).start()
|
@pytest.mark.parametrize(
|
||||||
try:
|
("error", "reason"),
|
||||||
response = requests.get("http://unresolvable.test/", proxies={"http": proxy.uri}, timeout=10)
|
[
|
||||||
assert response.status_code == 502
|
(
|
||||||
finally:
|
requests.ConnectionError("HTTPSConnectionPool(host='dns.controld.com'): /SECRETID"),
|
||||||
proxy.shutdown()
|
"Control D DNS could not be reached (ConnectionError)",
|
||||||
proxy.server_close()
|
),
|
||||||
|
(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:
|
def stub_session(response: bytes, seen: list) -> object:
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class Resolver:
|
|||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
def resolve(self, host: str) -> str:
|
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()
|
host = host.lower()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
cached = self.cache.get(host)
|
cached = self.cache.get(host)
|
||||||
@@ -109,13 +110,20 @@ class Resolver:
|
|||||||
return cached[0]
|
return cached[0]
|
||||||
|
|
||||||
query = base64.urlsafe_b64encode(build_query(host)).rstrip(b"=").decode()
|
query = base64.urlsafe_b64encode(build_query(host)).rstrip(b"=").decode()
|
||||||
response = self.session.get(
|
try:
|
||||||
self.url,
|
response = self.session.get(
|
||||||
params={"dns": query},
|
self.url,
|
||||||
headers={"accept": "application/dns-message"},
|
params={"dns": query},
|
||||||
timeout=DOH_TIMEOUT,
|
headers={"accept": "application/dns-message"},
|
||||||
)
|
timeout=DOH_TIMEOUT,
|
||||||
response.raise_for_status()
|
)
|
||||||
|
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)
|
records = parse_a_records(response.content)
|
||||||
if not records:
|
if not records:
|
||||||
@@ -158,6 +166,17 @@ class Handler(socketserver.StreamRequestHandler):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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:
|
def forward_response_head(self, upstream: socket.socket) -> None:
|
||||||
"""Send the response head on with `Connection: close`, so the client never reuses this connection."""
|
"""Send the response head on with `Connection: close`, so the client never reuses this connection."""
|
||||||
data = b""
|
data = b""
|
||||||
@@ -212,16 +231,16 @@ class Handler(socketserver.StreamRequestHandler):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# an IP literal bypasses Control D's DNS and would leak this machine's own IP
|
return self.refuse(host, port, "an IP address skips Control D's DNS and would expose this machine's IP")
|
||||||
log.debug(f"Refused {host}:{port}, an address that Control D cannot redirect")
|
|
||||||
return self.reply(b"502 Bad Gateway")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ip = self.server.resolver.resolve(host)
|
ip = self.server.resolver.resolve(host)
|
||||||
upstream = socket.create_connection((ip, port), timeout=HEAD_TIMEOUT)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug(f"Could not reach {host}:{port}: {e}")
|
return self.refuse(host, port, str(e) or type(e).__name__)
|
||||||
return self.reply(b"502 Bad Gateway")
|
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)
|
self.connection.settimeout(IDLE_TIMEOUT)
|
||||||
upstream.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`."""
|
"""An HTTP forward proxy on loopback that resolves every host through `resolver`."""
|
||||||
super().__init__(("127.0.0.1", 0), Handler)
|
super().__init__(("127.0.0.1", 0), Handler)
|
||||||
self.resolver = resolver
|
self.resolver = resolver
|
||||||
|
self.refused: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uri(self) -> str:
|
def uri(self) -> str:
|
||||||
@@ -460,6 +480,7 @@ class ControlD(Proxy):
|
|||||||
if server:
|
if server:
|
||||||
with server.resolver.lock:
|
with server.resolver.lock:
|
||||||
server.resolver.cache.clear()
|
server.resolver.cache.clear()
|
||||||
|
server.refused.clear()
|
||||||
REGIONS.pop(profile, None)
|
REGIONS.pop(profile, None)
|
||||||
REGIONS[profile] = query
|
REGIONS[profile] = query
|
||||||
LOCATIONS[profile] = location
|
LOCATIONS[profile] = location
|
||||||
|
|||||||
Reference in New Issue
Block a user