xref: /PHP-7.4/sapi/fpm/tests/fcgi.inc (revision d574df63)
1<?php
2/*
3 * This file is part of PHP-FastCGI-Client.
4 *
5 * (c) Pierrick Charron <pierrick@adoy.net>
6 *
7 * Permission is hereby granted, free of charge, to any person obtaining a copy of
8 * this software and associated documentation files (the "Software"), to deal in
9 * the Software without restriction, including without limitation the rights to
10 * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
11 * of the Software, and to permit persons to whom the Software is furnished to do
12 * so, subject to the following conditions:
13 *
14 * The above copyright notice and this permission notice shall be included in all
15 * copies or substantial portions of the Software.
16 *
17 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 * SOFTWARE.
24 */
25namespace Adoy\FastCGI;
26
27class TimedOutException extends \Exception {}
28class ForbiddenException extends \Exception {}
29
30/**
31 * Handles communication with a FastCGI application
32 *
33 * @author      Pierrick Charron <pierrick@adoy.net>
34 * @version     1.0
35 */
36class Client
37{
38    const VERSION_1            = 1;
39
40    const BEGIN_REQUEST        = 1;
41    const ABORT_REQUEST        = 2;
42    const END_REQUEST          = 3;
43    const PARAMS               = 4;
44    const STDIN                = 5;
45    const STDOUT               = 6;
46    const STDERR               = 7;
47    const DATA                 = 8;
48    const GET_VALUES           = 9;
49    const GET_VALUES_RESULT    = 10;
50    const UNKNOWN_TYPE         = 11;
51    const MAXTYPE              = self::UNKNOWN_TYPE;
52
53    const RESPONDER            = 1;
54    const AUTHORIZER           = 2;
55    const FILTER               = 3;
56
57    const REQUEST_COMPLETE     = 0;
58    const CANT_MPX_CONN        = 1;
59    const OVERLOADED           = 2;
60    const UNKNOWN_ROLE         = 3;
61
62    const MAX_CONNS            = 'MAX_CONNS';
63    const MAX_REQS             = 'MAX_REQS';
64    const MPXS_CONNS           = 'MPXS_CONNS';
65
66    const HEADER_LEN           = 8;
67
68    const REQ_STATE_WRITTEN    = 1;
69    const REQ_STATE_OK         = 2;
70    const REQ_STATE_ERR        = 3;
71    const REQ_STATE_TIMED_OUT  = 4;
72
73    /**
74     * Socket
75     * @var resource
76     */
77    private $_sock = null;
78
79    /**
80     * Host
81     * @var string
82     */
83    private $_host = null;
84
85    /**
86     * Port
87     * @var int
88     */
89    private $_port = null;
90
91    /**
92     * Keep Alive
93     * @var bool
94     */
95    private $_keepAlive = false;
96
97    /**
98     * Outstanding request statuses keyed by request id
99     *
100     * Each request is an array with following form:
101     *
102     *  array(
103     *    'state' => REQ_STATE_*
104     *    'response' => null | string
105     *  )
106     *
107     * @var array
108     */
109    private $_requests = array();
110
111    /**
112     * Use persistent sockets to connect to backend
113     * @var bool
114     */
115    private $_persistentSocket = false;
116
117    /**
118     * Connect timeout in milliseconds
119     * @var int
120     */
121    private $_connectTimeout = 5000;
122
123    /**
124     * Read/Write timeout in milliseconds
125     * @var int
126     */
127    private $_readWriteTimeout = 5000;
128
129    /**
130     * Constructor
131     *
132     * @param string $host Host of the FastCGI application
133     * @param int $port Port of the FastCGI application
134     */
135    public function __construct($host, $port)
136    {
137        $this->_host = $host;
138        $this->_port = $port;
139    }
140
141    /**
142     * Get host.
143     *
144     * @return string
145     */
146    public function getHost()
147    {
148        return $this->_host;
149    }
150
151    /**
152     * Define whether or not the FastCGI application should keep the connection
153     * alive at the end of a request
154     *
155     * @param bool $b true if the connection should stay alive, false otherwise
156     */
157    public function setKeepAlive($b)
158    {
159        $this->_keepAlive = (bool)$b;
160        if (!$this->_keepAlive && $this->_sock) {
161            fclose($this->_sock);
162        }
163    }
164
165    /**
166     * Get the keep alive status
167     *
168     * @return bool true if the connection should stay alive, false otherwise
169     */
170    public function getKeepAlive()
171    {
172        return $this->_keepAlive;
173    }
174
175    /**
176     * Define whether or not PHP should attempt to re-use sockets opened by previous
177     * request for efficiency
178     *
179     * @param bool $b true if persistent socket should be used, false otherwise
180     */
181    public function setPersistentSocket($b)
182    {
183        $was_persistent = ($this->_sock && $this->_persistentSocket);
184        $this->_persistentSocket = (bool)$b;
185        if (!$this->_persistentSocket && $was_persistent) {
186            fclose($this->_sock);
187        }
188    }
189
190    /**
191     * Get the pesistent socket status
192     *
193     * @return bool true if the socket should be persistent, false otherwise
194     */
195    public function getPersistentSocket()
196    {
197        return $this->_persistentSocket;
198    }
199
200
201    /**
202     * Set the connect timeout
203     *
204     * @param int  number of milliseconds before connect will timeout
205     */
206    public function setConnectTimeout($timeoutMs)
207    {
208        $this->_connectTimeout = $timeoutMs;
209    }
210
211    /**
212     * Get the connect timeout
213     *
214     * @return int  number of milliseconds before connect will timeout
215     */
216    public function getConnectTimeout()
217    {
218        return $this->_connectTimeout;
219    }
220
221    /**
222     * Set the read/write timeout
223     *
224     * @param int  number of milliseconds before read or write call will timeout
225     */
226    public function setReadWriteTimeout($timeoutMs)
227    {
228        $this->_readWriteTimeout = $timeoutMs;
229        $this->set_ms_timeout($this->_readWriteTimeout);
230    }
231
232    /**
233     * Get the read timeout
234     *
235     * @return int  number of milliseconds before read will timeout
236     */
237    public function getReadWriteTimeout()
238    {
239        return $this->_readWriteTimeout;
240    }
241
242    /**
243     * Helper to avoid duplicating milliseconds to secs/usecs in a few places
244     *
245     * @param int millisecond timeout
246     * @return bool
247     */
248    private function set_ms_timeout($timeoutMs) {
249        if (!$this->_sock) {
250            return false;
251        }
252        return stream_set_timeout(
253            $this->_sock,
254            floor($timeoutMs / 1000),
255            ($timeoutMs % 1000) * 1000
256        );
257    }
258
259
260    /**
261     * Create a connection to the FastCGI application
262     */
263    private function connect()
264    {
265        if (!$this->_sock) {
266            if ($this->_persistentSocket) {
267                $this->_sock = pfsockopen(
268                    $this->_host,
269                    $this->_port,
270                    $errno,
271                    $errstr,
272                    $this->_connectTimeout/1000
273                );
274            } else {
275                $this->_sock = fsockopen(
276                    $this->_host,
277                    $this->_port,
278                    $errno,
279                    $errstr,
280                    $this->_connectTimeout/1000
281                );
282            }
283
284            if (!$this->_sock) {
285                throw new \Exception('Unable to connect to FastCGI application: ' . $errstr);
286            }
287
288            if (!$this->set_ms_timeout($this->_readWriteTimeout)) {
289                throw new \Exception('Unable to set timeout on socket');
290            }
291        }
292    }
293
294    /**
295     * Build a FastCGI packet
296     *
297     * @param int $type Type of the packet
298     * @param string $content Content of the packet
299     * @param int $requestId RequestId
300     * @return string
301     */
302    private function buildPacket($type, $content, $requestId = 1)
303    {
304        $clen = strlen($content);
305        return chr(self::VERSION_1)         /* version */
306            . chr($type)                    /* type */
307            . chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
308            . chr($requestId & 0xFF)        /* requestIdB0 */
309            . chr(($clen >> 8 ) & 0xFF)     /* contentLengthB1 */
310            . chr($clen & 0xFF)             /* contentLengthB0 */
311            . chr(0)                        /* paddingLength */
312            . chr(0)                        /* reserved */
313            . $content;                     /* content */
314    }
315
316    /**
317     * Build an FastCGI Name value pair
318     *
319     * @param string $name Name
320     * @param string $value Value
321     * @return string FastCGI Name value pair
322     */
323    private function buildNvpair($name, $value)
324    {
325        $nlen = strlen($name);
326        $vlen = strlen($value);
327        if ($nlen < 128) {
328            /* nameLengthB0 */
329            $nvpair = chr($nlen);
330        } else {
331            /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
332            $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF)
333                . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
334        }
335        if ($vlen < 128) {
336            /* valueLengthB0 */
337            $nvpair .= chr($vlen);
338        } else {
339            /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
340            $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF)
341                . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
342        }
343        /* nameData & valueData */
344        return $nvpair . $name . $value;
345    }
346
347    /**
348     * Read a set of FastCGI Name value pairs
349     *
350     * @param string $data Data containing the set of FastCGI NVPair
351     * @return array of NVPair
352     */
353    private function readNvpair($data, $length = null)
354    {
355        $array = array();
356
357        if ($length === null) {
358            $length = strlen($data);
359        }
360
361        $p = 0;
362
363        while ($p != $length) {
364
365            $nlen = ord($data[$p++]);
366            if ($nlen >= 128) {
367                $nlen = ($nlen & 0x7F << 24);
368                $nlen |= (ord($data[$p++]) << 16);
369                $nlen |= (ord($data[$p++]) << 8);
370                $nlen |= (ord($data[$p++]));
371            }
372            $vlen = ord($data[$p++]);
373            if ($vlen >= 128) {
374                $vlen = ($nlen & 0x7F << 24);
375                $vlen |= (ord($data[$p++]) << 16);
376                $vlen |= (ord($data[$p++]) << 8);
377                $vlen |= (ord($data[$p++]));
378            }
379            $array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
380            $p += ($nlen + $vlen);
381        }
382
383        return $array;
384    }
385
386    /**
387     * Decode a FastCGI Packet
388     *
389     * @param string $data string containing all the packet
390     * @return array
391     */
392    private function decodePacketHeader($data)
393    {
394        $ret = array();
395        $ret['version']       = ord($data[0]);
396        $ret['type']          = ord($data[1]);
397        $ret['requestId']     = (ord($data[2]) << 8) + ord($data[3]);
398        $ret['contentLength'] = (ord($data[4]) << 8) + ord($data[5]);
399        $ret['paddingLength'] = ord($data[6]);
400        $ret['reserved']      = ord($data[7]);
401        return $ret;
402    }
403
404    /**
405     * Read a FastCGI Packet
406     *
407     * @return array
408     */
409    private function readPacket()
410    {
411        if ($packet = fread($this->_sock, self::HEADER_LEN)) {
412            $resp = $this->decodePacketHeader($packet);
413            $resp['content'] = '';
414            if ($resp['contentLength']) {
415                $len  = $resp['contentLength'];
416                while ($len && $buf=fread($this->_sock, $len)) {
417                    $len -= strlen($buf);
418                    $resp['content'] .= $buf;
419                }
420            }
421            if ($resp['paddingLength']) {
422                $buf = fread($this->_sock, $resp['paddingLength']);
423            }
424            return $resp;
425        } else {
426            return false;
427        }
428    }
429
430    /**
431     * Get Information on the FastCGI application
432     *
433     * @param array $requestedInfo information to retrieve
434     * @return array
435     * @throws \Exception
436     */
437    public function getValues(array $requestedInfo)
438    {
439        $this->connect();
440
441        $request = '';
442        foreach ($requestedInfo as $info) {
443            $request .= $this->buildNvpair($info, '');
444        }
445        fwrite($this->_sock, $this->buildPacket(self::GET_VALUES, $request, 0));
446
447        $resp = $this->readPacket();
448        if ($resp['type'] == self::GET_VALUES_RESULT) {
449            return $this->readNvpair($resp['content'], $resp['length']);
450        } else {
451            throw new \Exception('Unexpected response type, expecting GET_VALUES_RESULT');
452        }
453    }
454
455    /**
456     * Execute a request to the FastCGI application and return response body
457     *
458     * @param array $params Array of parameters
459     * @param string $stdin Content
460     * @return string
461     * @throws ForbiddenException
462     * @throws TimedOutException
463     * @throws \Exception
464     */
465    public function request(array $params, $stdin)
466    {
467        $id = $this->async_request($params, $stdin);
468        return $this->wait_for_response($id);
469    }
470
471    /**
472     * Execute a request to the FastCGI application and return request data
473     *
474     * @param array $params Array of parameters
475     * @param string $stdin Content
476     * @return array
477     * @throws ForbiddenException
478     * @throws TimedOutException
479     * @throws \Exception
480     */
481    public function request_data(array $params, $stdin)
482    {
483        $id = $this->async_request($params, $stdin);
484        return $this->wait_for_response_data($id);
485    }
486
487    /**
488     * Execute a request to the FastCGI application asyncronously
489     *
490     * This sends request to application and returns the assigned ID for that request.
491     *
492     * You should keep this id for later use with wait_for_response(). Ids are chosen randomly
493     * rather than sequentially to guard against false-positives when using persistent sockets.
494     * In that case it is possible that a delayed response to a request made by a previous script
495     * invocation comes back on this socket and is mistaken for response to request made with same
496     * ID during this request.
497     *
498     * @param array $params Array of parameters
499     * @param string $stdin Content
500     * @return int
501     * @throws TimedOutException
502     * @throws \Exception
503     */
504    public function async_request(array $params, $stdin)
505    {
506        $this->connect();
507
508        // Pick random number between 1 and max 16 bit unsigned int 65535
509        $id = mt_rand(1, (1 << 16) - 1);
510
511        // Using persistent sockets implies you want them keept alive by server!
512        $keepAlive = intval($this->_keepAlive || $this->_persistentSocket);
513
514        $request = $this->buildPacket(
515            self::BEGIN_REQUEST,
516            chr(0) . chr(self::RESPONDER) . chr($keepAlive)
517            . str_repeat(chr(0), 5),
518            $id
519        );
520
521        $paramsRequest = '';
522        foreach ($params as $key => $value) {
523            $paramsRequest .= $this->buildNvpair($key, $value, $id);
524        }
525        if ($paramsRequest) {
526            $request .= $this->buildPacket(self::PARAMS, $paramsRequest, $id);
527        }
528        $request .= $this->buildPacket(self::PARAMS, '', $id);
529
530        if ($stdin) {
531            $request .= $this->buildPacket(self::STDIN, $stdin, $id);
532        }
533        $request .= $this->buildPacket(self::STDIN, '', $id);
534
535        if (fwrite($this->_sock, $request) === false || fflush($this->_sock) === false) {
536
537            $info = stream_get_meta_data($this->_sock);
538
539            if ($info['timed_out']) {
540                throw new TimedOutException('Write timed out');
541            }
542
543            // Broken pipe, tear down so future requests might succeed
544            fclose($this->_sock);
545            throw new \Exception('Failed to write request to socket');
546        }
547
548        $this->_requests[$id] = array(
549            'state' => self::REQ_STATE_WRITTEN,
550            'response' => null,
551            'err_response' => null,
552            'out_response' => null,
553        );
554
555        return $id;
556    }
557
558    /**
559     * Blocking call that waits for response data of the specific request
560     *
561     * @param int $requestId
562     * @param int $timeoutMs [optional] the number of milliseconds to wait.
563     * @return array response data
564     * @throws ForbiddenException
565     * @throws TimedOutException
566     * @throws \Exception
567     */
568    public function wait_for_response_data($requestId, $timeoutMs = 0)
569    {
570        if (!isset($this->_requests[$requestId])) {
571            throw new \Exception('Invalid request id given');
572        }
573
574        // If we already read the response during an earlier call for different id, just return it
575        if ($this->_requests[$requestId]['state'] == self::REQ_STATE_OK
576            || $this->_requests[$requestId]['state'] == self::REQ_STATE_ERR
577            ) {
578            return $this->_requests[$requestId]['response'];
579        }
580
581        if ($timeoutMs > 0) {
582            // Reset timeout on socket for now
583            $this->set_ms_timeout($timeoutMs);
584        } else {
585            $timeoutMs = $this->_readWriteTimeout;
586        }
587
588        // Need to manually check since we might do several reads none of which timeout themselves
589        // but still not get the response requested
590        $startTime = microtime(true);
591
592        while ($resp = $this->readPacket()) {
593            if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) {
594                if ($resp['type'] == self::STDERR) {
595                    $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_ERR;
596                    $this->_requests[$resp['requestId']]['err_response'] .= $resp['content'];
597                } else {
598                    $this->_requests[$resp['requestId']]['out_response'] .= $resp['content'];
599                }
600                $this->_requests[$resp['requestId']]['response'] .= $resp['content'];
601            }
602            if ($resp['type'] == self::END_REQUEST) {
603                $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_OK;
604                if ($resp['requestId'] == $requestId) {
605                    break;
606                }
607            }
608            if (microtime(true) - $startTime >= ($timeoutMs * 1000)) {
609                // Reset
610                $this->set_ms_timeout($this->_readWriteTimeout);
611                throw new \Exception('Timed out');
612            }
613        }
614
615        if (!is_array($resp)) {
616            $info = stream_get_meta_data($this->_sock);
617
618            // We must reset timeout but it must be AFTER we get info
619            $this->set_ms_timeout($this->_readWriteTimeout);
620
621            if ($info['timed_out']) {
622                throw new TimedOutException('Read timed out');
623            }
624
625            if ($info['unread_bytes'] == 0
626                    && $info['blocked']
627                    && $info['eof']) {
628                throw new ForbiddenException('Not in white list. Check listen.allowed_clients.');
629            }
630
631            throw new \Exception('Read failed');
632        }
633
634        // Reset timeout
635        $this->set_ms_timeout($this->_readWriteTimeout);
636
637        switch (ord($resp['content'][4])) {
638            case self::CANT_MPX_CONN:
639                throw new \Exception('This app can\'t multiplex [CANT_MPX_CONN]');
640                break;
641            case self::OVERLOADED:
642                throw new \Exception('New request rejected; too busy [OVERLOADED]');
643                break;
644            case self::UNKNOWN_ROLE:
645                throw new \Exception('Role value not known [UNKNOWN_ROLE]');
646                break;
647            case self::REQUEST_COMPLETE:
648                return $this->_requests[$requestId];
649        }
650    }
651
652    /**
653     * Blocking call that waits for response to specific request
654     *
655     * @param int $requestId
656     * @param int $timeoutMs [optional] the number of milliseconds to wait.
657     * @return string The response content.
658     * @throws ForbiddenException
659     * @throws TimedOutException
660     * @throws \Exception
661     */
662    public function wait_for_response($requestId, $timeoutMs = 0)
663    {
664        return $this->wait_for_response_data($requestId, $timeoutMs)['response'];
665    }
666}
667