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 */
25//namespace Adoy\FastCGI;
26class Adoy_FastCGI_TimedOutException extends Exception {}
27class Adoy_FastCGI_ForbiddenException extends Exception {}
28/**
29 * Handles communication with a FastCGI application
30 *
31 * @author      Pierrick Charron <pierrick@adoy.net>
32 * @version     1.0
33 */
34class Adoy_FastCGI_Client
35{
36    const VERSION_1            = 1;
37    const BEGIN_REQUEST        = 1;
38    const ABORT_REQUEST        = 2;
39    const END_REQUEST          = 3;
40    const PARAMS               = 4;
41    const STDIN                = 5;
42    const STDOUT               = 6;
43    const STDERR               = 7;
44    const DATA                 = 8;
45    const GET_VALUES           = 9;
46    const GET_VALUES_RESULT    = 10;
47    const UNKNOWN_TYPE         = 11;
48    const MAXTYPE              = self::UNKNOWN_TYPE;
49    const RESPONDER            = 1;
50    const AUTHORIZER           = 2;
51    const FILTER               = 3;
52    const REQUEST_COMPLETE     = 0;
53    const CANT_MPX_CONN        = 1;
54    const OVERLOADED           = 2;
55    const UNKNOWN_ROLE         = 3;
56    const MAX_CONNS            = 'MAX_CONNS';
57    const MAX_REQS             = 'MAX_REQS';
58    const MPXS_CONNS           = 'MPXS_CONNS';
59    const HEADER_LEN           = 8;
60    const REQ_STATE_WRITTEN    = 1;
61    const REQ_STATE_OK         = 2;
62    const REQ_STATE_ERR        = 3;
63    const REQ_STATE_TIMED_OUT  = 4;
64    /**
65     * Socket
66     * @var Resource
67     */
68    private $_sock = null;
69    /**
70     * Host
71     * @var String
72     */
73    private $_host = null;
74    /**
75     * Port
76     * @var Integer
77     */
78    private $_port = null;
79    /**
80     * Keep Alive
81     * @var Boolean
82     */
83    private $_keepAlive = false;
84    /**
85     * Outstanding request statuses keyed by request id
86     *
87     * Each request is an array with following form:
88     *
89     *  array(
90     *    'state' => REQ_STATE_*
91     *    'response' => null | string
92     *  )
93     *
94     * @var array
95     */
96    private $_requests = array();
97    /**
98     * Use persistent sockets to connect to backend
99     * @var Boolean
100     */
101    private $_persistentSocket = false;
102    /**
103     * Connect timeout in milliseconds
104     * @var Integer
105     */
106    private $_connectTimeout = 5000;
107    /**
108     * Read/Write timeout in milliseconds
109     * @var Integer
110     */
111    private $_readWriteTimeout = 5000;
112    /**
113     * Constructor
114     *
115     * @param String $host Host of the FastCGI application
116     * @param Integer $port Port of the FastCGI application
117     */
118    public function __construct($host, $port)
119    {
120        $this->_host = $host;
121        $this->_port = $port;
122    }
123    /**
124     * Define whether or not the FastCGI application should keep the connection
125     * alive at the end of a request
126     *
127     * @param Boolean $b true if the connection should stay alive, false otherwise
128     */
129    public function setKeepAlive($b)
130    {
131        $this->_keepAlive = (boolean)$b;
132        if (!$this->_keepAlive && $this->_sock) {
133            fclose($this->_sock);
134        }
135    }
136    /**
137     * Get the keep alive status
138     *
139     * @return Boolean true if the connection should stay alive, false otherwise
140     */
141    public function getKeepAlive()
142    {
143        return $this->_keepAlive;
144    }
145    /**
146     * Define whether or not PHP should attempt to re-use sockets opened by previous
147     * request for efficiency
148     *
149     * @param Boolean $b true if persistent socket should be used, false otherwise
150     */
151    public function setPersistentSocket($b)
152    {
153        $was_persistent = ($this->_sock && $this->_persistentSocket);
154        $this->_persistentSocket = (boolean)$b;
155        if (!$this->_persistentSocket && $was_persistent) {
156            fclose($this->_sock);
157        }
158    }
159    /**
160     * Get the pesistent socket status
161     *
162     * @return Boolean true if the socket should be persistent, false otherwise
163     */
164    public function getPersistentSocket()
165    {
166        return $this->_persistentSocket;
167    }
168    /**
169     * Set the connect timeout
170     *
171     * @param Integer  number of milliseconds before connect will timeout
172     */
173    public function setConnectTimeout($timeoutMs)
174    {
175        $this->_connectTimeout = $timeoutMs;
176    }
177    /**
178     * Get the connect timeout
179     *
180     * @return Integer  number of milliseconds before connect will timeout
181     */
182    public function getConnectTimeout()
183    {
184        return $this->_connectTimeout;
185    }
186    /**
187     * Set the read/write timeout
188     *
189     * @param Integer  number of milliseconds before read or write call will timeout
190     */
191    public function setReadWriteTimeout($timeoutMs)
192    {
193        $this->_readWriteTimeout = $timeoutMs;
194        $this->set_ms_timeout($this->_readWriteTimeout);
195    }
196    /**
197     * Get the read timeout
198     *
199     * @return Integer  number of milliseconds before read will timeout
200     */
201    public function getReadWriteTimeout()
202    {
203        return $this->_readWriteTimeout;
204    }
205    /**
206     * Helper to avoid duplicating milliseconds to secs/usecs in a few places
207     *
208     * @param Integer millisecond timeout
209     * @return Boolean
210     */
211    private function set_ms_timeout($timeoutMs) {
212        if (!$this->_sock) {
213            return false;
214        }
215        return stream_set_timeout($this->_sock, floor($timeoutMs / 1000), ($timeoutMs % 1000) * 1000);
216    }
217    /**
218     * Create a connection to the FastCGI application
219     */
220    private function connect()
221    {
222        if (!$this->_sock) {
223            if ($this->_persistentSocket) {
224                $this->_sock = pfsockopen($this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout/1000);
225            } else {
226                $this->_sock = fsockopen($this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout/1000);
227            }
228            if (!$this->_sock) {
229                throw new Exception('Unable to connect to FastCGI application: ' . $errstr);
230            }
231            if (!$this->set_ms_timeout($this->_readWriteTimeout)) {
232                throw new Exception('Unable to set timeout on socket');
233            }
234        }
235    }
236    /**
237     * Build a FastCGI packet
238     *
239     * @param Integer $type Type of the packet
240     * @param String $content Content of the packet
241     * @param Integer $requestId RequestId
242     */
243    private function buildPacket($type, $content, $requestId = 1)
244    {
245        $clen = strlen($content);
246        return chr(self::VERSION_1)         /* version */
247            . chr($type)                    /* type */
248            . chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
249            . chr($requestId & 0xFF)        /* requestIdB0 */
250            . chr(($clen >> 8 ) & 0xFF)     /* contentLengthB1 */
251            . chr($clen & 0xFF)             /* contentLengthB0 */
252            . chr(0)                        /* paddingLength */
253            . chr(0)                        /* reserved */
254            . $content;                     /* content */
255    }
256    /**
257     * Build an FastCGI Name value pair
258     *
259     * @param String $name Name
260     * @param String $value Value
261     * @return String FastCGI Name value pair
262     */
263    private function buildNvpair($name, $value)
264    {
265        $nlen = strlen($name);
266        $vlen = strlen($value);
267        if ($nlen < 128) {
268            /* nameLengthB0 */
269            $nvpair = chr($nlen);
270        } else {
271            /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
272            $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
273        }
274        if ($vlen < 128) {
275            /* valueLengthB0 */
276            $nvpair .= chr($vlen);
277        } else {
278            /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
279            $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
280        }
281        /* nameData & valueData */
282        return $nvpair . $name . $value;
283    }
284    /**
285     * Read a set of FastCGI Name value pairs
286     *
287     * @param String $data Data containing the set of FastCGI NVPair
288     * @return array of NVPair
289     */
290    private function readNvpair($data, $length = null)
291    {
292        $array = array();
293        if ($length === null) {
294            $length = strlen($data);
295        }
296        $p = 0;
297        while ($p != $length) {
298            $nlen = ord($data[$p++]);
299            if ($nlen >= 128) {
300                $nlen = ($nlen & 0x7F << 24);
301                $nlen |= (ord($data[$p++]) << 16);
302                $nlen |= (ord($data[$p++]) << 8);
303                $nlen |= (ord($data[$p++]));
304            }
305            $vlen = ord($data[$p++]);
306            if ($vlen >= 128) {
307                $vlen = ($nlen & 0x7F << 24);
308                $vlen |= (ord($data[$p++]) << 16);
309                $vlen |= (ord($data[$p++]) << 8);
310                $vlen |= (ord($data[$p++]));
311            }
312            $array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
313            $p += ($nlen + $vlen);
314        }
315        return $array;
316    }
317    /**
318     * Decode a FastCGI Packet
319     *
320     * @param String $data String containing all the packet
321     * @return array
322     */
323    private function decodePacketHeader($data)
324    {
325        $ret = array();
326        $ret['version']       = ord($data[0]);
327        $ret['type']          = ord($data[1]);
328        $ret['requestId']     = (ord($data[2]) << 8) + ord($data[3]);
329        $ret['contentLength'] = (ord($data[4]) << 8) + ord($data[5]);
330        $ret['paddingLength'] = ord($data[6]);
331        $ret['reserved']      = ord($data[7]);
332        return $ret;
333    }
334    /**
335     * Read a FastCGI Packet
336     *
337     * @return array
338     */
339    private function readPacket()
340    {
341        if ($packet = fread($this->_sock, self::HEADER_LEN)) {
342            $resp = $this->decodePacketHeader($packet);
343            $resp['content'] = '';
344            if ($resp['contentLength']) {
345                $len  = $resp['contentLength'];
346                while ($len && $buf=fread($this->_sock, $len)) {
347                    $len -= strlen($buf);
348                    $resp['content'] .= $buf;
349                }
350            }
351            if ($resp['paddingLength']) {
352                $buf = fread($this->_sock, $resp['paddingLength']);
353            }
354            return $resp;
355        } else {
356            return false;
357        }
358    }
359    /**
360     * Get Informations on the FastCGI application
361     *
362     * @param array $requestedInfo information to retrieve
363     * @return array
364     */
365    public function getValues(array $requestedInfo)
366    {
367        $this->connect();
368        $request = '';
369        foreach ($requestedInfo as $info) {
370            $request .= $this->buildNvpair($info, '');
371        }
372        fwrite($this->_sock, $this->buildPacket(self::GET_VALUES, $request, 0));
373        $resp = $this->readPacket();
374        if ($resp['type'] == self::GET_VALUES_RESULT) {
375            return $this->readNvpair($resp['content'], $resp['length']);
376        } else {
377            throw new Exception('Unexpected response type, expecting GET_VALUES_RESULT');
378        }
379    }
380    /**
381     * Execute a request to the FastCGI application
382     *
383     * @param array $params Array of parameters
384     * @param String $stdin Content
385     * @return String
386     */
387    public function request(array $params, $stdin)
388    {
389        $id = $this->async_request($params, $stdin);
390        return $this->wait_for_response($id);
391    }
392    /**
393     * Execute a request to the FastCGI application asyncronously
394     *
395     * This sends request to application and returns the assigned ID for that request.
396     *
397     * You should keep this id for later use with wait_for_response(). Ids are chosen randomly
398     * rather than seqentially to guard against false-positives when using persistent sockets.
399     * In that case it is possible that a delayed response to a request made by a previous script
400     * invocation comes back on this socket and is mistaken for response to request made with same ID
401     * during this request.
402     *
403     * @param array $params Array of parameters
404     * @param String $stdin Content
405     * @return Integer
406     */
407    public function async_request(array $params, $stdin)
408    {
409        $this->connect();
410        // Pick random number between 1 and max 16 bit unsigned int 65535
411        $id = mt_rand(1, (1 << 16) - 1);
412        // Using persistent sockets implies you want them keept alive by server!
413        $keepAlive = intval($this->_keepAlive || $this->_persistentSocket);
414        $request = $this->buildPacket(self::BEGIN_REQUEST
415                                     ,chr(0) . chr(self::RESPONDER) . chr($keepAlive) . str_repeat(chr(0), 5)
416                                     ,$id
417                                     );
418        $paramsRequest = '';
419        foreach ($params as $key => $value) {
420            $paramsRequest .= $this->buildNvpair($key, $value, $id);
421        }
422        if ($paramsRequest) {
423            $request .= $this->buildPacket(self::PARAMS, $paramsRequest, $id);
424        }
425        $request .= $this->buildPacket(self::PARAMS, '', $id);
426        if ($stdin) {
427            $request .= $this->buildPacket(self::STDIN, $stdin, $id);
428        }
429        $request .= $this->buildPacket(self::STDIN, '', $id);
430        if (fwrite($this->_sock, $request) === false || fflush($this->_sock) === false) {
431            $info = stream_get_meta_data($this->_sock);
432            if ($info['timed_out']) {
433                throw new Adoy_FastCGI_TimedOutException('Write timed out');
434            }
435            // Broken pipe, tear down so future requests might succeed
436            fclose($this->_sock);
437            throw new Exception('Failed to write request to socket');
438        }
439        $this->_requests[$id] = array(
440            'state' => self::REQ_STATE_WRITTEN,
441            'response' => null
442        );
443        return $id;
444    }
445    /**
446     * Blocking call that waits for response to specific request
447     *
448     * @param Integer $requestId
449     * @param Integer $timeoutMs [optional] the number of milliseconds to wait. Defaults to the ReadWriteTimeout value set.
450     * @return string  response body
451     */
452    public function wait_for_response($requestId, $timeoutMs = 0) {
453        if (!isset($this->_requests[$requestId])) {
454            throw new Exception('Invalid request id given');
455        }
456        // If we already read the response during an earlier call for different id, just return it
457        if ($this->_requests[$requestId]['state'] == self::REQ_STATE_OK
458            || $this->_requests[$requestId]['state'] == self::REQ_STATE_ERR
459            ) {
460            return $this->_requests[$requestId]['response'];
461        }
462        if ($timeoutMs > 0) {
463            // Reset timeout on socket for now
464            $this->set_ms_timeout($timeoutMs);
465        } else {
466            $timeoutMs = $this->_readWriteTimeout;
467        }
468        // Need to manually check since we might do several reads none of which timeout themselves
469        // but still not get the response requested
470        $startTime = microtime(true);
471        do {
472            $resp = $this->readPacket();
473            if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) {
474                if ($resp['type'] == self::STDERR) {
475                    $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_ERR;
476                }
477                $this->_requests[$resp['requestId']]['response'] .= $resp['content'];
478            }
479            if ($resp['type'] == self::END_REQUEST) {
480                $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_OK;
481                if ($resp['requestId'] == $requestId) {
482                    break;
483                }
484            }
485            if (microtime(true) - $startTime >= ($timeoutMs * 1000)) {
486                // Reset
487                $this->set_ms_timeout($this->_readWriteTimeout);
488                throw new Exception('Timed out');
489            }
490        } while ($resp);
491        if (!is_array($resp)) {
492            $info = stream_get_meta_data($this->_sock);
493            // We must reset timeout but it must be AFTER we get info
494            $this->set_ms_timeout($this->_readWriteTimeout);
495            if ($info['timed_out']) {
496                throw new Adoy_FastCGI_TimedOutException('Read timed out');
497            }
498            if ($info['unread_bytes'] == 0
499                    && $info['blocked']
500                    && $info['eof']) {
501                throw new Adoy_FastCGI_ForbiddenException('Not in white list. Check listen.allowed_clients.');
502            }
503            throw new Exception('Read failed');
504        }
505        // Reset timeout
506        $this->set_ms_timeout($this->_readWriteTimeout);
507        switch (ord($resp['content'][4])) {
508            case self::CANT_MPX_CONN:
509                throw new Exception('This app can\'t multiplex [CANT_MPX_CONN]');
510                break;
511            case self::OVERLOADED:
512                throw new Exception('New request rejected; too busy [OVERLOADED]');
513                break;
514            case self::UNKNOWN_ROLE:
515                throw new Exception('Role value not known [UNKNOWN_ROLE]');
516                break;
517            case self::REQUEST_COMPLETE:
518                return $this->_requests[$requestId]['response'];
519        }
520    }
521}
522