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