77use React \Dns \Protocol \Parser ;
88use React \EventLoop \Loop ;
99use React \EventLoop \LoopInterface ;
10- use React \Promise \ Deferred ;
10+ use React \Promise ;
1111
1212/**
1313 * Send DNS queries over a TCP/IP stream transport.
7474 * organizational reasons to avoid a cyclic dependency between the two
7575 * packages. Higher-level components should take advantage of the Socket
7676 * component instead of reimplementing this socket logic from scratch.
77+ *
78+ * Support for DNS over TLS can be enabled via specifying the nameserver with scheme tls://
79+ * @link https://tools.ietf.org/html/rfc7858
7780 */
7881class TcpTransportExecutor implements ExecutorInterface
7982{
@@ -88,7 +91,7 @@ class TcpTransportExecutor implements ExecutorInterface
8891 private $ socket ;
8992
9093 /**
91- * @var Deferred[]
94+ * @var Promise\ Deferred[]
9295 */
9396 private $ pending = array ();
9497
@@ -97,6 +100,12 @@ class TcpTransportExecutor implements ExecutorInterface
97100 */
98101 private $ names = array ();
99102
103+ /** @var bool */
104+ private $ tls = false ;
105+
106+ /** @var bool */
107+ private $ cryptoEnabled = false ;
108+
100109 /**
101110 * Maximum idle time when socket is current unused (i.e. no pending queries outstanding)
102111 *
@@ -130,6 +139,8 @@ class TcpTransportExecutor implements ExecutorInterface
130139 /** @var string */
131140 private $ readChunk = 0xffff ;
132141
142+ private $ connection_parameters = array ();
143+
133144 /**
134145 * @param string $nameserver
135146 * @param ?LoopInterface $loop
@@ -142,11 +153,17 @@ public function __construct($nameserver, LoopInterface $loop = null)
142153 }
143154
144155 $ parts = \parse_url ((\strpos ($ nameserver , ':// ' ) === false ? 'tcp:// ' : '' ) . $ nameserver );
145- if (!isset ($ parts ['scheme ' ], $ parts ['host ' ]) || $ parts ['scheme ' ] !== 'tcp ' || @\inet_pton (\trim ($ parts ['host ' ], '[] ' )) === false ) {
156+ if (!isset ($ parts ['scheme ' ], $ parts ['host ' ]) || ! in_array ( $ parts ['scheme ' ], array ( 'tcp ' , ' tls ' ), true ) || @\inet_pton (\trim ($ parts ['host ' ], '[] ' )) === false ) {
146157 throw new \InvalidArgumentException ('Invalid nameserver address given ' );
147158 }
148159
149- $ this ->nameserver = 'tcp:// ' . $ parts ['host ' ] . ': ' . (isset ($ parts ['port ' ]) ? $ parts ['port ' ] : 53 );
160+ //Parse any connection parameters to be supplied to stream_context_create()
161+ if (isset ($ parts ['query ' ])) {
162+ parse_str ($ parts ['query ' ], $ this ->connection_parameters );
163+ }
164+
165+ $ this ->tls = $ parts ['scheme ' ] === 'tls ' ;
166+ $ this ->nameserver = 'tcp:// ' . $ parts ['host ' ] . ': ' . (isset ($ parts ['port ' ]) ? $ parts ['port ' ] : ($ this ->tls ? 853 : 53 ));
150167 $ this ->loop = $ loop ?: Loop::get ();
151168 $ this ->parser = new Parser ();
152169 $ this ->dumper = new BinaryDumper ();
@@ -164,18 +181,36 @@ public function query(Query $query)
164181 $ queryData = $ this ->dumper ->toBinary ($ request );
165182 $ length = \strlen ($ queryData );
166183 if ($ length > 0xffff ) {
167- return \ React \ Promise \reject (new \RuntimeException (
184+ return Promise \reject (new \RuntimeException (
168185 'DNS query for ' . $ query ->describe () . ' failed: Query too large for TCP transport '
169186 ));
170187 }
171188
172189 $ queryData = \pack ('n ' , $ length ) . $ queryData ;
173190
174191 if ($ this ->socket === null ) {
192+ //Setup TLS context if requested
193+ $ cOption = array ();
194+ if ($ this ->tls ) {
195+ if (!\function_exists ('stream_socket_enable_crypto ' ) || defined ('HHVM_VERSION ' ) || \PHP_VERSION_ID < 50600 ) {
196+ return Promise \reject (new \BadMethodCallException ('Encryption not supported on your platform (HHVM < 3.8 or PHP < 5.6?) ' )); // @codeCoverageIgnore
197+ }
198+ // Setup sane defaults for SSL to ensure secure connection to the DNS server
199+ $ cOption ['ssl ' ] = array (
200+ 'verify_peer ' => true ,
201+ 'verify_peer_name ' => true ,
202+ 'allow_self_signed ' => false ,
203+ );
204+ }
205+ $ cOption = array_merge ($ cOption , $ this ->connection_parameters );
206+ if (empty ($ cOption )) {
207+ $ cOption = null ;
208+ }
209+ $ context = stream_context_create ($ cOption );
175210 // create async TCP/IP connection (may take a while)
176- $ socket = @\stream_socket_client ($ this ->nameserver , $ errno , $ errstr , 0 , \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT );
211+ $ socket = @\stream_socket_client ($ this ->nameserver , $ errno , $ errstr , 0 , \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT , $ context );
177212 if ($ socket === false ) {
178- return \ React \ Promise \reject (new \RuntimeException (
213+ return Promise \reject (new \RuntimeException (
179214 'DNS query for ' . $ query ->describe () . ' failed: Unable to connect to DNS server ' . $ this ->nameserver . ' ( ' . $ errstr . ') ' ,
180215 $ errno
181216 ));
@@ -203,7 +238,7 @@ public function query(Query $query)
203238
204239 $ names =& $ this ->names ;
205240 $ that = $ this ;
206- $ deferred = new Deferred (function () use ($ that , &$ names , $ request ) {
241+ $ deferred = new Promise \ Deferred (function () use ($ that , &$ names , $ request ) {
207242 // remove from list of pending names, but remember pending query
208243 $ name = $ names [$ request ->id ];
209244 unset($ names [$ request ->id ]);
@@ -223,9 +258,51 @@ public function query(Query $query)
223258 */
224259 public function handleWritable ()
225260 {
261+ if ($ this ->tls && false === $ this ->cryptoEnabled ) {
262+ $ error = null ;
263+ \set_error_handler (function ($ _ , $ errstr ) use (&$ error ) {
264+ $ error = \str_replace (array ("\r" , "\n" ), ' ' , $ errstr );
265+
266+ // remove useless function name from error message
267+ if (($ pos = \strpos ($ error , "): " )) !== false ) {
268+ $ error = \substr ($ error , $ pos + 3 );
269+ }
270+ });
271+
272+ $ method = \STREAM_CRYPTO_METHOD_TLS_CLIENT ;
273+ if (\PHP_VERSION_ID < 70200 && \PHP_VERSION_ID >= 50600 ) {
274+ $ method |= \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; // @codeCoverageIgnore
275+ }
276+
277+ $ result = \stream_socket_enable_crypto ($ this ->socket , true , $ method );
278+
279+ \restore_error_handler ();
280+
281+ if (true === $ result ) {
282+ $ this ->cryptoEnabled = true ;
283+ } elseif (false === $ result ) {
284+ if (\feof ($ this ->socket ) || $ error === null ) {
285+ // EOF or failed without error => connection closed during handshake
286+ $ this ->closeError (
287+ 'Connection lost during TLS handshake (ECONNRESET) ' ,
288+ \defined ('SOCKET_ECONNRESET ' ) ? \SOCKET_ECONNRESET : 104
289+ );
290+ } else {
291+ // handshake failed with error message
292+ $ this ->closeError (
293+ $ error
294+ );
295+ }
296+ return ;
297+ } else {
298+ // need more data, will retry
299+ return ;
300+ }
301+ }
302+
226303 if ($ this ->readPending === false ) {
227304 $ name = @\stream_socket_get_name ($ this ->socket , true );
228- if ($ name === false ) {
305+ if (! is_string ( $ name)) { //PHP: false, HHVM: null on error
229306 // Connection failed? Check socket error if available for underlying errno/errstr.
230307 // @codeCoverageIgnoreStart
231308 if (\function_exists ('socket_import_stream ' )) {
@@ -247,7 +324,7 @@ public function handleWritable()
247324 }
248325
249326 $ errno = 0 ;
250- $ errstr = '' ;
327+ $ errstr = null ;
251328 \set_error_handler (function ($ _ , $ error ) use (&$ errno , &$ errstr ) {
252329 // Match errstr from PHP's warning message.
253330 // fwrite(): Send of 327712 bytes failed with errno=32 Broken pipe
@@ -256,18 +333,42 @@ public function handleWritable()
256333 $ errstr = isset ($ m [2 ]) ? $ m [2 ] : $ error ;
257334 });
258335
259- $ written = \fwrite ($ this ->socket , $ this ->writeBuffer );
260-
261- \restore_error_handler ();
336+ // PHP < 7.1.4 (and PHP < 7.0.18) suffers from a bug when writing big
337+ // chunks of data over TLS streams at once.
338+ // We try to work around this by limiting the write chunk size to 8192
339+ // bytes for older PHP versions only.
340+ // This is only a work-around and has a noticable performance penalty on
341+ // affected versions. Please update your PHP version.
342+ // This applies only to configured TLS connections
343+ // See https://github.com/reactphp/socket/issues/105
344+ if ($ this ->tls && (\PHP_VERSION_ID < 70018 || (\PHP_VERSION_ID >= 70100 && \PHP_VERSION_ID < 70104 ))) {
345+ $ written = \fwrite ($ this ->socket , $ this ->writeBuffer , 8192 ); // @codeCoverageIgnore
346+ } else {
347+ $ written = \fwrite ($ this ->socket , $ this ->writeBuffer );
348+ }
262349
263- if ($ written === false || $ written === 0 ) {
264- $ this ->closeError (
265- 'Unable to send query to DNS server ' . $ this ->nameserver . ' ( ' . $ errstr . ') ' ,
266- $ errno
267- );
268- return ;
350+ // Only report errors if *nothing* could be sent and an error has been raised, or we are unable to retrieve the remote socket name (connection dead) [HHVM].
351+ // Ignore non-fatal warnings if *some* data could be sent.
352+ // Any hard (permanent) error will fail to send any data at all.
353+ // Sending excessive amounts of data will only flush *some* data and then
354+ // report a temporary error (EAGAIN) which we do not raise here in order
355+ // to keep the stream open for further tries to write.
356+ // Should this turn out to be a permanent error later, it will eventually
357+ // send *nothing* and we can detect this.
358+ if (($ written === false || $ written === 0 )) {
359+ $ name = @\stream_socket_get_name ($ this ->socket , true );
360+ if (!is_string ($ name ) || $ errstr !== null ) {
361+ \restore_error_handler ();
362+ $ this ->closeError (
363+ 'Unable to send query to DNS server ' . $ this ->nameserver . ' ( ' . $ errstr . ') ' ,
364+ $ errno
365+ );
366+ return ;
367+ }
269368 }
270369
370+ \restore_error_handler ();
371+
271372 if (isset ($ this ->writeBuffer [$ written ])) {
272373 $ this ->writeBuffer = \substr ($ this ->writeBuffer , $ written );
273374 } else {
@@ -282,9 +383,30 @@ public function handleWritable()
282383 */
283384 public function handleRead ()
284385 {
386+ // @codeCoverageIgnoreStart
387+ if (null === $ this ->socket ) {
388+ $ this ->closeError ('Connection to DNS server ' . $ this ->nameserver . ' lost ' );
389+ return ;
390+ }
391+ // @codeCoverageIgnoreEnd
392+
285393 // read one chunk of data from the DNS server
286394 // any error is fatal, this is a stream of TCP/IP data
287- $ chunk = @\fread ($ this ->socket , $ this ->readChunk );
395+ // PHP < 7.3.3 (and PHP < 7.2.15) suffers from a bug where feof() might
396+ // block with 100% CPU usage on fragmented TLS records.
397+ // We try to work around this by always consuming the complete receive
398+ // buffer at once to avoid stale data in TLS buffers. This is known to
399+ // work around high CPU usage for well-behaving peers, but this may
400+ // cause very large data chunks for high throughput scenarios. The buggy
401+ // behavior can still be triggered due to network I/O buffers or
402+ // malicious peers on affected versions, upgrading is highly recommended.
403+ // @link https://bugs.php.net/bug.php?id=77390
404+ if ($ this ->tls && (\PHP_VERSION_ID < 70215 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70303 ))) {
405+ $ chunk = @\stream_get_contents ($ this ->socket , -1 ); // @codeCoverageIgnore
406+ } else {
407+ $ chunk = @\stream_get_contents ($ this ->socket , $ this ->readChunk );
408+ }
409+
288410 if ($ chunk === false || $ chunk === '' ) {
289411 $ this ->closeError ('Connection to DNS server ' . $ this ->nameserver . ' lost ' );
290412 return ;
@@ -351,8 +473,10 @@ public function closeError($reason, $code = 0)
351473 $ this ->idleTimer = null ;
352474 }
353475
354- @\fclose ($ this ->socket );
355- $ this ->socket = null ;
476+ if (null !== $ this ->socket ) {
477+ @\fclose ($ this ->socket );
478+ $ this ->socket = null ;
479+ }
356480
357481 foreach ($ this ->names as $ id => $ name ) {
358482 $ this ->pending [$ id ]->reject (new \RuntimeException (
0 commit comments