Skip to content

Commit 20332f9

Browse files
committed
Added option to add multiple upstream servers and tests, v1.4.0
1 parent 3dd4acd commit 20332f9

5 files changed

Lines changed: 294 additions & 79 deletions

File tree

config/phantomd.conf

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,35 @@ trust_anchors_file =
4242
# pinned_certs = dns.example.com=ab12...cd34, *.example.net=ffeedd...
4343
pinned_certs =
4444

45+
# ------------------------------------------------------------------
46+
# Multi-upstream with automatic failover (optional)
47+
# If the [upstreams] section is present and 'servers' is non-empty,
48+
# it replaces the single dns_server/dns_protocol above. Each entry
49+
# is tried in order until one succeeds. This provides automatic
50+
# failover if your primary DNS server goes down.
51+
#
52+
# To enable, uncomment the lines below and adjust as needed.
53+
# ------------------------------------------------------------------
54+
# [upstreams]
55+
# servers = cloudflare, google, quad9
56+
#
57+
# [upstreams.cloudflare]
58+
# address = 1.1.1.1
59+
# protocol = https
60+
# port = 443
61+
# hostname = cloudflare-dns.com
62+
#
63+
# [upstreams.google]
64+
# address = 8.8.8.8
65+
# protocol = udp
66+
# port = 53
67+
#
68+
# [upstreams.quad9]
69+
# address = 9.9.9.9
70+
# protocol = tls
71+
# port = 853
72+
# hostname = dns.quad9.net
73+
4574
[interface]
4675
# Address the DNS server listens on. Use 0.0.0.0 to listen on all IPv4 interfaces.
4776
listen_ip = 0.0.0.0

core/dserver.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
import logging
33
import os
4-
from typing import Dict, Set, Tuple, Optional, Any
4+
from typing import Dict, Set, Tuple, Optional, Any, List
55

66
from core.resolver import DNSResolver, RateLimiter
77
from utils.ListUpdater import fetch_blocklists, periodic_fetch
@@ -216,6 +216,7 @@ async def reload_resolver(holder: ResolverHolder,
216216
metrics_port=config.get("metrics_port", 8000),
217217
rate_limit_rps=config.get("rate_limit_rps"),
218218
rate_limit_burst=config.get("rate_limit_burst"),
219+
upstreams=config.get("upstreams"), # NEW: multi-upstream list
219220
)
220221

221222
if blocklists:
@@ -265,7 +266,8 @@ async def run_server(listen_ip: str, listen_port: int, upstream_dns: str, protoc
265266
upstream_tcp_timeout: float = 5.0,
266267
upstream_doh_timeout: float = 5.0,
267268
rate_limit_rps: float = 0.0,
268-
rate_limit_burst: float = 0.0) -> None:
269+
rate_limit_burst: float = 0.0,
270+
upstreams: Optional[List[Dict[str, Any]]] = None) -> None:
269271
"""Start the DNS server (UDP + TCP) and blocklist background tasks."""
270272
logging.getLogger().setLevel(logging.DEBUG if verbose else logging.INFO)
271273

@@ -291,6 +293,7 @@ async def run_server(listen_ip: str, listen_port: int, upstream_dns: str, protoc
291293
uvloop_enable=uvloop_enable,
292294
rate_limit_rps=rate_limit_rps,
293295
rate_limit_burst=rate_limit_burst,
296+
upstreams=upstreams, # NEW: multi-upstream list
294297
)
295298

296299
# Mutable holder for atomic resolver swaps

core/resolver.py

Lines changed: 121 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ class DNSResolver:
122122
- Optional certificate pinning and DNSSEC validation
123123
- Optional Prometheus metrics and uvloop enable
124124
- Integrated rate limiter (per client IP)
125+
- Multi-upstream with automatic failover (configurable upstream list)
125126
126127
Logging:
127128
The resolver emits DEBUG logs for cache hits/misses, request lifecycle,
@@ -149,9 +150,14 @@ def __init__(self,
149150
metrics_port: int = 8000,
150151
uvloop_enable: bool = False,
151152
rate_limit_rps: float = 0.0,
152-
rate_limit_burst: float = 0.0) -> None:
153+
rate_limit_burst: float = 0.0,
154+
upstreams: Optional[List[Dict[str, Any]]] = None) -> None:
155+
# --- single upstream fallback ---
153156
self.upstream_dns: str = upstream_dns
154157
self.protocol: str = protocol.lower()
158+
# --- multi-upstream list (optional) ---
159+
self.upstreams: List[Dict[str, Any]] = upstreams or []
160+
155161
self.dns_resolver_server: Optional[str] = dns_resolver_server
156162
self.disable_ipv6: bool = bool(disable_ipv6)
157163
self.verbose: bool = bool(verbose)
@@ -380,14 +386,7 @@ async def _wire_cache_get_valid(self, key: Tuple[str, int, str]) -> Optional[byt
380386
def _parse_dns_name(packet: bytes, offset: int,
381387
max_depth: int = 20,
382388
_depth: int = 0) -> Tuple[str, int]:
383-
"""Parse a DNS name from wire format. Returns (name, new_offset).
384-
385-
Parameters:
386-
packet: full DNS message bytes
387-
offset: start of the name
388-
max_depth: maximum pointer chain depth (to avoid loops)
389-
_depth: current recursion depth (used internally)
390-
"""
389+
"""Parse a DNS name from wire format. Returns (name, new_offset)."""
391390
labels = []
392391
while True:
393392
if offset >= len(packet):
@@ -733,6 +732,30 @@ def _validate() -> bool:
733732
raise
734733
self.logger.debug("DNSSEC validation passed for %s", qname)
735734

735+
# --- New: try an upstream and return response or raise ---
736+
async def _try_upstream(self, upstream: Dict[str, Any], data: bytes) -> bytes:
737+
"""Forward a query to a single upstream and return the response."""
738+
proto = upstream.get('protocol', 'udp')
739+
if proto == 'udp':
740+
return await self._with_retries(
741+
lambda d: self._forward_udp(d, upstream), data, timeout=self.udp_timeout)
742+
elif proto == 'tcp':
743+
return await self._with_retries(
744+
lambda d: self._forward_tcp(d, upstream), data, timeout=self.tcp_timeout)
745+
elif proto == 'tls':
746+
return await self._with_retries(
747+
lambda d: self._forward_tls(d, upstream), data, timeout=self.tcp_timeout)
748+
elif proto == 'https':
749+
return await self._with_retries(
750+
lambda d: self._forward_https(d, upstream), data, timeout=self.doh_timeout)
751+
elif proto == 'quic':
752+
if not _HAS_AIOQUIC:
753+
raise RuntimeError("aioquic not available for DoQ")
754+
return await self._with_retries(
755+
lambda d: self._forward_quic(d, upstream), data, timeout=self.doh_timeout)
756+
else:
757+
raise ValueError(f"Unsupported upstream protocol: {proto}")
758+
736759
async def forward_dns_query(self, data: bytes) -> bytes:
737760
qname = self._extract_qname_from_wire(data)
738761
qtype = self._extract_qtype_from_wire(data) or 1
@@ -744,7 +767,6 @@ async def forward_dns_query(self, data: bytes) -> bytes:
744767
ip = host_values[0]
745768
try:
746769
if _HAS_DNSPY:
747-
# Ensure absolute name for dnspython
748770
absolute_qname = qname if qname.endswith('.') else f"{qname}."
749771
from dns import message, rdatatype, rdataclass, rrset
750772
resp = dns.message.make_response(dns.message.from_wire(data) if data else None)
@@ -768,45 +790,49 @@ async def forward_dns_query(self, data: bytes) -> bytes:
768790
self.logger.debug("wire-cache hit %s", key)
769791
return cached
770792

771-
proto = self.protocol
772-
if proto == "udp":
773-
resp = await self._with_retries(self._forward_udp, data, timeout=self.udp_timeout)
774-
elif proto == "tcp":
775-
resp = await self._with_retries(self._forward_tcp, data, timeout=self.tcp_timeout)
776-
elif proto == "tls":
777-
resp = await self._with_retries(self._forward_tls, data, timeout=self.tcp_timeout)
778-
elif proto == "https":
779-
resp = await self._with_retries(self._forward_https, data, timeout=self.doh_timeout)
780-
elif proto == "quic":
781-
if not _HAS_AIOQUIC:
782-
raise RuntimeError("aioquic not available for DoQ")
783-
resp = await self._with_retries(self._forward_quic, data, timeout=self.doh_timeout)
784-
else:
785-
raise ValueError(f"Unsupported protocol {proto}")
793+
# --- prepare upstream list ---
794+
upstream_list = self.upstreams if self.upstreams else [
795+
{'address': self.upstream_dns, 'protocol': self.protocol, 'hostname': self.upstream_dns}
796+
]
786797

787-
if self.metrics_enabled and self._metrics:
798+
last_exc = None
799+
for upstream in upstream_list:
788800
try:
789-
self._metrics['requests_total'].labels(proto=proto).inc()
790-
except Exception:
791-
pass
801+
resp = await self._try_upstream(upstream, data)
802+
# metrics (use protocol of the successful upstream)
803+
if self.metrics_enabled and self._metrics:
804+
try:
805+
self._metrics['requests_total'].labels(proto=upstream['protocol']).inc()
806+
except Exception:
807+
pass
808+
# DNSSEC validate if enabled
809+
if self.dnssec_enabled and qname:
810+
try:
811+
await self._dnssec_validate(qname, resp)
812+
except Exception as e:
813+
self.logger.warning("DNSSEC validation failed for %s: %s", qname, e)
814+
raise
815+
ttl = self._extract_min_ttl(resp)
816+
if ttl <= 0:
817+
ttl = 30
818+
await self._wire_cache_set(key, resp, ttl)
819+
return resp
820+
except Exception as e:
821+
last_exc = e
822+
self.logger.debug("upstream %s failed: %s", upstream.get('address'), e)
823+
continue
792824

793-
if self.dnssec_enabled:
794-
if qname:
795-
try:
796-
await self._dnssec_validate(qname, resp)
797-
except Exception as e:
798-
self.logger.warning("DNSSEC validation failed for %s: %s", qname, e)
799-
raise
800-
801-
ttl = self._extract_min_ttl(resp)
802-
if ttl <= 0:
803-
ttl = 30
804-
await self._wire_cache_set(key, resp, ttl)
805-
return resp
806-
807-
# --- forwarding implementations ---
808-
async def _forward_udp(self, data: bytes) -> bytes:
809-
host, port = self._split_hostport(self.upstream_dns, default_port=53)
825+
self.logger.error("all upstreams failed")
826+
raise last_exc or Exception("All upstreams exhausted")
827+
828+
# --- forwarding implementations (now accept optional upstream) ---
829+
830+
async def _forward_udp(self, data: bytes, upstream: Optional[Dict[str, Any]] = None) -> bytes:
831+
if upstream is None:
832+
host, port = self._split_hostport(self.upstream_dns, default_port=53)
833+
else:
834+
host = upstream['address']
835+
port = upstream.get('port', 53)
810836
resolved = await self._resolve_upstream_ip(host)
811837
family = socket.AF_INET6 if self._is_ipv6_address(resolved) else socket.AF_INET
812838
if self.disable_ipv6 and self._is_ipv6_address(resolved):
@@ -845,8 +871,12 @@ def connection_lost(self, exc: Optional[Exception]) -> None:
845871
finally:
846872
transport.close()
847873

848-
async def _forward_tcp(self, data: bytes) -> bytes:
849-
host, port = self._split_hostport(self.upstream_dns, default_port=53)
874+
async def _forward_tcp(self, data: bytes, upstream: Optional[Dict[str, Any]] = None) -> bytes:
875+
if upstream is None:
876+
host, port = self._split_hostport(self.upstream_dns, default_port=53)
877+
else:
878+
host = upstream['address']
879+
port = upstream.get('port', 53)
850880
resolved = await self._resolve_upstream_ip(host)
851881
if self.disable_ipv6 and self._is_ipv6_address(resolved):
852882
raise Exception("IPv6 disabled but resolved to IPv6")
@@ -861,17 +891,23 @@ async def _forward_tcp(self, data: bytes) -> bytes:
861891
writer.close()
862892
await writer.wait_closed()
863893

864-
async def _forward_tls(self, data: bytes) -> bytes:
865-
host, port = self._split_hostport(self.upstream_dns, default_port=853)
894+
async def _forward_tls(self, data: bytes, upstream: Optional[Dict[str, Any]] = None) -> bytes:
895+
if upstream is None:
896+
host, port = self._split_hostport(self.upstream_dns, default_port=853)
897+
hostname = host
898+
else:
899+
host = upstream['address']
900+
port = upstream.get('port', 853)
901+
hostname = upstream.get('hostname', host)
866902
resolved = await self._resolve_upstream_ip(host)
867903
if self.disable_ipv6 and self._is_ipv6_address(resolved):
868904
raise Exception("IPv6 disabled but resolved to IPv6")
869905
ssl_ctx = ssl.create_default_context()
870-
reader, writer = await asyncio.open_connection(resolved, int(port), ssl=ssl_ctx, server_hostname=host)
906+
reader, writer = await asyncio.open_connection(resolved, int(port), ssl=ssl_ctx, server_hostname=hostname)
871907
try:
872908
ssl_obj = writer.get_extra_info('ssl_object')
873909
if ssl_obj is not None and self.pinned_certs:
874-
await self._check_cert_pins(host, ssl_obj)
910+
await self._check_cert_pins(hostname, ssl_obj)
875911
writer.write(len(data).to_bytes(2, "big") + data)
876912
await writer.drain()
877913
length_bytes = await asyncio.wait_for(reader.readexactly(2), timeout=self.tcp_timeout)
@@ -881,26 +917,34 @@ async def _forward_tls(self, data: bytes) -> bytes:
881917
writer.close()
882918
await writer.wait_closed()
883919

884-
async def _forward_https(self, data: bytes) -> bytes:
885-
url = self.upstream_dns if (self.upstream_dns.startswith("http://") or self.upstream_dns.startswith("https://")) \
886-
else (f"https://{self.upstream_dns}" if "/" in self.upstream_dns else f"https://{self.upstream_dns}/dns-query")
887-
parsed = urlparse(url)
888-
host = parsed.hostname or ""
889-
path = parsed.path or "/dns-query"
890-
port = parsed.port or 443
891-
920+
async def _forward_https(self, data: bytes, upstream: Optional[Dict[str, Any]] = None) -> bytes:
921+
if upstream is None:
922+
# fallback: build URL from self.upstream_dns
923+
url = self.upstream_dns if (self.upstream_dns.startswith("http://") or self.upstream_dns.startswith("https://")) \
924+
else (f"https://{self.upstream_dns}" if "/" in self.upstream_dns else f"https://{self.upstream_dns}/dns-query")
925+
parsed = urlparse(url)
926+
host = parsed.hostname or ""
927+
path = parsed.path or "/dns-query"
928+
port = parsed.port or 443
929+
hostname = host
930+
else:
931+
host = upstream['address']
932+
port = upstream.get('port', 443)
933+
hostname = upstream.get('hostname', host)
934+
path = upstream.get('path', '/dns-query')
935+
892936
resolved = await self._resolve_upstream_ip(host)
893937
ssl_ctx = ssl.create_default_context()
894-
reader, writer = await asyncio.open_connection(resolved, port, ssl=ssl_ctx, server_hostname=host)
938+
reader, writer = await asyncio.open_connection(resolved, port, ssl=ssl_ctx, server_hostname=hostname)
895939

896940
try:
897941
ssl_obj = writer.get_extra_info('ssl_object')
898942
if ssl_obj is not None and self.pinned_certs:
899-
await self._check_cert_pins(host, ssl_obj)
943+
await self._check_cert_pins(hostname, ssl_obj)
900944

901945
headers = [
902946
f"POST {path} HTTP/1.1",
903-
f"Host: {host}",
947+
f"Host: {hostname}",
904948
"User-Agent: phantomd/1.0",
905949
"Accept: application/dns-message",
906950
"Content-Type: application/dns-message",
@@ -981,8 +1025,14 @@ async def _forward_https(self, data: bytes) -> bytes:
9811025
writer.close()
9821026
await writer.wait_closed()
9831027

984-
async def _forward_quic(self, data: bytes) -> bytes:
985-
host, port = self._split_hostport(self.upstream_dns, default_port=784)
1028+
async def _forward_quic(self, data: bytes, upstream: Optional[Dict[str, Any]] = None) -> bytes:
1029+
if upstream is None:
1030+
host, port = self._split_hostport(self.upstream_dns, default_port=784)
1031+
hostname = host
1032+
else:
1033+
host = upstream['address']
1034+
port = upstream.get('port', 784)
1035+
hostname = upstream.get('hostname', host)
9861036
resolved = await self._resolve_upstream_ip(host)
9871037
if self.disable_ipv6 and self._is_ipv6_address(resolved):
9881038
raise Exception("IPv6 disabled but resolved to IPv6")
@@ -1045,7 +1095,7 @@ def _get_quic_cert_der(client: Any) -> Optional[bytes]:
10451095
if self.pinned_certs:
10461096
der = _get_quic_cert_der(client)
10471097
if der:
1048-
await self._check_cert_pins(host, self._DERPeerWrapper(der))
1098+
await self._check_cert_pins(hostname, self._DERPeerWrapper(der))
10491099
else:
10501100
self.logger.warning("DoQ: unable to obtain peer certificate for pin-check; proceeding without")
10511101

@@ -1368,7 +1418,8 @@ def update_config(self, *,
13681418
metrics_port: Optional[int] = None,
13691419
uvloop_enable: Optional[bool] = None,
13701420
rate_limit_rps: Optional[float] = None,
1371-
rate_limit_burst: Optional[float] = None) -> None:
1421+
rate_limit_burst: Optional[float] = None,
1422+
upstreams: Optional[List[Dict[str, Any]]] = None) -> None:
13721423
"""Hot‑update resolver settings without recreating the object."""
13731424
if upstream_dns is not None:
13741425
self.upstream_dns = upstream_dns
@@ -1405,7 +1456,6 @@ def update_config(self, *,
14051456
self.metrics_port = int(metrics_port)
14061457
if uvloop_enable is not None:
14071458
pass
1408-
14091459
# Rate limiter updates
14101460
if rate_limit_rps is not None:
14111461
self.rate_limit_rps = rate_limit_rps
@@ -1419,6 +1469,9 @@ def update_config(self, *,
14191469
self.rate_limiter.burst = self.rate_limit_burst
14201470
else:
14211471
self.rate_limiter = None
1472+
# Upstream list update
1473+
if upstreams is not None:
1474+
self.upstreams = upstreams
14221475

14231476
self.logger.info("DNSResolver configuration updated: upstream=%s, protocol=%s, "
14241477
"disable_ipv6=%s, verbose=%s, rate_limit=%s/%s",

0 commit comments

Comments
 (0)