22
33namespace EdgeTelemetrics \React \Dns ;
44
5+ use Exception ;
6+ use InvalidArgumentException ;
57use Psr \Http \Message \ResponseInterface ;
68use React \Dns \Model \Message ;
79use React \Dns \Protocol \BinaryDumper ;
1214use React \EventLoop \LoopInterface ;
1315use React \Http \Browser ;
1416use React \Promise ;
17+ use React \Promise \Deferred ;
18+ use React \Socket \ConnectionInterface ;
1519use React \Socket \Connector ;
1620use RuntimeException ;
21+ use function parse_url ;
22+ use function strlen ;
23+ use function strtolower ;
24+ use function substr_count ;
1725
1826class DohExecutor implements ExecutorInterface {
1927
@@ -24,11 +32,15 @@ class DohExecutor implements ExecutorInterface {
2432
2533 private string $ method ;
2634
27- private Browser $ browser ;
35+ private bool $ ipv6address = false ;
36+
37+ private Promise \PromiseInterface $ browserResolution ;
2838
2939 const METHOD_GET = 'get ' ;
3040 const METHOD_POST = 'post ' ;
3141
42+ const FINGERPRINT_HASH_METHOD = 'sha256 ' ;
43+
3244 /**
3345 * @param string $nameserver
3446 * @param ?LoopInterface $loop
@@ -40,58 +52,67 @@ public function __construct(string $nameserver, LoopInterface $loop = null, stri
4052 throw new RuntimeException ('DNS over HTTPS support requires reactphp/http library ' ); //@codeCoverageIgnore
4153 }
4254
43- if (!str_contains ($ nameserver , '[ ' ) && \ substr_count ($ nameserver , ': ' ) >= 2 && !str_contains ($ nameserver , ':// ' )) {
55+ if (!str_contains ($ nameserver , '[ ' ) && substr_count ($ nameserver , ': ' ) >= 2 && !str_contains ($ nameserver , ':// ' )) {
4456 // several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets
4557 $ nameserver = '[ ' . $ nameserver . '] ' ;
4658 }
4759
48- $ parts = \parse_url ((!str_contains ($ nameserver , ':// ' ) ? 'https:// ' : '' ) . $ nameserver );
49- if (!isset ($ parts ['scheme ' ], $ parts ['host ' ]) || $ parts ['scheme ' ] !== 'https ' || @\inet_pton (\trim ($ parts ['host ' ], '[] ' )) === false ) {
50- throw new \InvalidArgumentException ('Invalid nameserver address given ' );
60+ $ parts = parse_url ((!str_contains ($ nameserver , ':// ' ) ? 'https:// ' : '' ) . $ nameserver );
61+ if (!isset ($ parts ['scheme ' ], $ parts ['host ' ]) || $ parts ['scheme ' ] !== 'https ' ) {
62+ throw new InvalidArgumentException ('Invalid nameserver address given ' );
63+ }
64+
65+ if (filter_var (trim ($ parts ['host ' ], '[] ' ), FILTER_VALIDATE_IP , FILTER_FLAG_IPV6 )) {
66+ $ this ->ipv6address = true ;
5167 }
5268
53- $ method = \ strtolower ($ method );
69+ $ method = strtolower ($ method );
5470 if (!in_array ($ method , [self ::METHOD_GET , self ::METHOD_POST ], true )) {
55- throw new \ InvalidArgumentException ('Invalid HTTP request method given ' );
71+ throw new InvalidArgumentException ('Invalid HTTP request method given ' );
5672 }
5773
5874 $ this ->nameserver = 'https:// ' . $ parts ['host ' ] . ': ' . ($ parts ['port ' ] ?? 443 . '/dns-query ' );
5975 $ this ->loop = $ loop ?: Loop::get ();
6076 $ this ->parser = new Parser ();
6177 $ this ->dumper = new BinaryDumper ();
6278 $ this ->method = $ method ;
63- $ this ->browser = (new Browser (new Connector (['tcp_nodelay ' => true ,]), $ this ->loop ));
6479 }
6580
6681 public function query (Query $ query )
6782 {
68- $ request = Message::createRequestForQuery ($ query );
69-
70- $ queryData = $ this ->dumper ->toBinary ($ request );
71- $ length = \strlen ($ queryData );
72-
73- if ($ length > 0xffff ) {
74- return Promise \reject (new \RuntimeException (
75- 'DNS query for ' . $ query ->describe () . ' failed: Query too large for HTTPS transport '
76- ));
77- }
78-
79- if ($ this ->method === self ::METHOD_GET ) {
80- $ requestUrl = $ this ->nameserver . '? ' . http_build_query (['dns ' => $ this ->urlsafeBase64 ($ queryData )]);
81- $ request = $ this ->browser ->get ($ requestUrl );
82- } else {
83- $ requestUrl = $ this ->nameserver ;
84- $ request = $ this ->browser ->post ($ requestUrl , [
85- 'accept ' => 'application/dns-message ' ,
86- 'content-type ' => 'application/dns-message '
87- ], $ queryData );
88- }
89-
90- return $ request ->then (function (ResponseInterface $ response ) {
91- $ response = $ this ->parser ->parseMessage ((string )$ response ->getBody ());
92- return Promise \resolve ($ response );
93- }, function (\Exception $ e ) use ($ query ) {
94- return Promise \reject (new \RuntimeException (
83+ return $ this ->getBrowser ()->then (function ($ browser ) use ($ query ) {
84+ $ request = Message::createRequestForQuery ($ query );
85+
86+ $ queryData = $ this ->dumper ->toBinary ($ request );
87+ $ length = strlen ($ queryData );
88+
89+ if ($ length > 0xffff ) {
90+ return Promise \reject (new RuntimeException (
91+ 'DNS query for ' . $ query ->describe () . ' failed: Query too large for HTTPS transport '
92+ ));
93+ }
94+
95+ if ($ this ->method === self ::METHOD_GET ) {
96+ $ requestUrl = $ this ->nameserver . '? ' . http_build_query (['dns ' => $ this ->urlsafeBase64 ($ queryData )]);
97+ $ request = $ browser ->get ($ requestUrl );
98+ } else {
99+ $ requestUrl = $ this ->nameserver ;
100+ $ request = $ browser ->post ($ requestUrl , [
101+ 'accept ' => 'application/dns-message ' ,
102+ 'content-type ' => 'application/dns-message '
103+ ], $ queryData );
104+ }
105+
106+ return $ request ->then (function (ResponseInterface $ response ) {
107+ $ response = $ this ->parser ->parseMessage ((string )$ response ->getBody ());
108+ return Promise \resolve ($ response );
109+ }, function (Exception $ e ) use ($ query ) {
110+ return Promise \reject (new RuntimeException (
111+ 'DNS query for ' . $ query ->describe () . ' failed: ' . $ e ->getMessage ()
112+ ));
113+ });
114+ }, function ($ e ) use ($ query ) {
115+ return Promise \reject (new RuntimeException (
95116 'DNS query for ' . $ query ->describe () . ' failed: ' . $ e ->getMessage ()
96117 ));
97118 });
@@ -104,10 +125,82 @@ public function query(Query $query)
104125 private function urlsafeBase64 (string $ data ) : string {
105126 // @codeCoverageIgnoreStart
106127 if (function_exists ('sodium_bin2base64 ' )) {
107- return sodium_bin2base64 ( $ data , SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING );
108- } else {
109- return rtrim ( strtr ( base64_encode ( $ data ), ' +/ ' , ' -_ ' ), ' = ' );
128+ try {
129+ return sodium_bin2base64 ( $ data , SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING );
130+ } catch ( \ SodiumException $ ex ) { /* Allow fallthrough to non sodium method */ }
110131 }
132+ return rtrim ( strtr ( base64_encode ( $ data ), '+/ ' , '-_ ' ), '= ' );
111133 //@codeCoverageIgnoreEnd
112134 }
135+
136+ private function getBrowser () : Promise \PromiseInterface {
137+ if (!isset ($ this ->browserResolution )) {
138+ $ deferred = new Deferred ();
139+ $ this ->browserResolution = $ deferred ->promise ();
140+ if ($ this ->ipv6address ) {
141+ // PHP does not validate IPv6 addresses contained in the SAN fields of a certificate
142+ // To support IPv6 we download the certificate on the first connect and manually verify our nameserver IPv6 IP
143+ // is listed in the SAN fields. We then construct a Browser instance with verify_peer_name set to false but with the peer_fingerprint set to our verified certificate.
144+ // This doesn't always work because the server may use different front end certificates (SIGH!)
145+ $ address = str_replace ('https:// ' , 'tls:// ' , $ this ->nameserver );
146+ $ connector = new Connector ([
147+ 'tcp ' => [
148+ 'tcp_nodelay ' => true ,
149+ ],
150+ 'tls ' => [
151+ 'verify_peer_name ' => false ,
152+ 'capture_peer_cert ' => true
153+ ],
154+ 'dns ' => false ,
155+ ], $ this ->loop );
156+ $ connector ->connect ($ address )->then (function (ConnectionInterface $ connection ) use ($ deferred ) {
157+ $ response = stream_context_get_params ($ connection ->stream ); //Using @internal stream
158+ $ connection ->end ();
159+ $ certificatePem = $ response ['options ' ]['ssl ' ]['peer_certificate ' ];
160+
161+ $ certificateFields = openssl_x509_parse ($ certificatePem );
162+ $ additionalDomains = explode (', ' , $ certificateFields ['extensions ' ]['subjectAltName ' ] ?? '' );
163+
164+ $ ip = inet_pton (trim (parse_url ($ this ->nameserver , PHP_URL_HOST ), '[] ' ));
165+ if ($ ip !== false ) {
166+ foreach ($ additionalDomains as $ subAltName ) {
167+ $ subAltName = trim (strtolower ($ subAltName ));
168+ if (str_starts_with ($ subAltName , 'ip address: ' )) {
169+ $ compare = inet_pton (str_replace ('ip address: ' , '' , $ subAltName ));
170+ if ($ compare === $ ip ) {
171+ $ fingerprint = openssl_x509_fingerprint ($ certificatePem , self ::FINGERPRINT_HASH_METHOD );
172+ $ browser = (new Browser (new Connector ([
173+ 'tcp ' => [
174+ 'tcp_nodelay ' => true ,
175+ ],
176+ 'tls ' => [
177+ 'verify_peer_name ' => false ,
178+ 'peer_fingerprint ' =>[
179+ self ::FINGERPRINT_HASH_METHOD => $ fingerprint ,
180+ ],
181+ ],
182+ ], $ this ->loop ), $ this ->loop ));
183+ $ deferred ->resolve ($ browser );
184+ return ;
185+ }
186+ }
187+ }
188+ }
189+ $ deferred ->reject (new RuntimeException ('IPv6 IP Address Connection Failed. Unable to Validate Peer Certificate ' ));
190+
191+ }, function ($ ex ) use ($ deferred ) {
192+ $ deferred ->reject (new RuntimeException ('IPv6 IP Address Connection Failed. ' . $ ex ->getMessage ()));
193+ });
194+ } else {
195+ $ browser = (new Browser (new Connector ([
196+ 'tcp ' => [
197+ 'tcp_nodelay ' => true ,
198+ ],
199+ ]
200+ ), $ this ->loop ));
201+ $ deferred ->resolve ($ browser );
202+ }
203+ }
204+ return $ this ->browserResolution ;
205+ }
113206}
0 commit comments