@@ -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