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 Informations 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 do { 593 $resp = $this->readPacket(); 594 595 if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) { 596 if ($resp['type'] == self::STDERR) { 597 $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_ERR; 598 $this->_requests[$resp['requestId']]['err_response'] .= $resp['content']; 599 } else { 600 $this->_requests[$resp['requestId']]['out_response'] .= $resp['content']; 601 } 602 $this->_requests[$resp['requestId']]['response'] .= $resp['content']; 603 } 604 if ($resp['type'] == self::END_REQUEST) { 605 $this->_requests[$resp['requestId']]['state'] = self::REQ_STATE_OK; 606 if ($resp['requestId'] == $requestId) { 607 break; 608 } 609 } 610 if (microtime(true) - $startTime >= ($timeoutMs * 1000)) { 611 // Reset 612 $this->set_ms_timeout($this->_readWriteTimeout); 613 throw new \Exception('Timed out'); 614 } 615 } while ($resp); 616 617 if (!is_array($resp)) { 618 $info = stream_get_meta_data($this->_sock); 619 620 // We must reset timeout but it must be AFTER we get info 621 $this->set_ms_timeout($this->_readWriteTimeout); 622 623 if ($info['timed_out']) { 624 throw new TimedOutException('Read timed out'); 625 } 626 627 if ($info['unread_bytes'] == 0 628 && $info['blocked'] 629 && $info['eof']) { 630 throw new ForbiddenException('Not in white list. Check listen.allowed_clients.'); 631 } 632 633 throw new \Exception('Read failed'); 634 } 635 636 // Reset timeout 637 $this->set_ms_timeout($this->_readWriteTimeout); 638 639 switch (ord($resp['content']{4})) { 640 case self::CANT_MPX_CONN: 641 throw new \Exception('This app can\'t multiplex [CANT_MPX_CONN]'); 642 break; 643 case self::OVERLOADED: 644 throw new \Exception('New request rejected; too busy [OVERLOADED]'); 645 break; 646 case self::UNKNOWN_ROLE: 647 throw new \Exception('Role value not known [UNKNOWN_ROLE]'); 648 break; 649 case self::REQUEST_COMPLETE: 650 return $this->_requests[$requestId]; 651 } 652 } 653 654 /** 655 * Blocking call that waits for response to specific request 656 * 657 * @param int $requestId 658 * @param int $timeoutMs [optional] the number of milliseconds to wait. 659 * @return string The response content. 660 * @throws ForbiddenException 661 * @throws TimedOutException 662 * @throws \Exception 663 */ 664 public function wait_for_response($requestId, $timeoutMs = 0) 665 { 666 return $this->wait_for_response_data($requestId, $timeoutMs)['response']; 667 } 668} 669