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