1: <?php
2: /**
3: * This file is part of GameQ.
4: *
5: * GameQ is free software; you can redistribute it and/or modify
6: * it under the terms of the GNU Lesser General Public License as published by
7: * the Free Software Foundation; either version 3 of the License, or
8: * (at your option) any later version.
9: *
10: * GameQ is distributed in the hope that it will be useful,
11: * but WITHOUT ANY WARRANTY; without even the implied warranty of
12: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13: * GNU Lesser General Public License for more details.
14: *
15: * You should have received a copy of the GNU Lesser General Public License
16: * along with this program. If not, see <http://www.gnu.org/licenses/>.
17: */
18:
19: namespace GameQ;
20:
21: use GameQ\Exception\Protocol as ProtocolException;
22: use GameQ\Exception\Query as QueryException;
23:
24: /**
25: * Base GameQ Class
26: *
27: * This class should be the only one that is included when you use GameQ to query
28: * any games servers.
29: *
30: * Requirements: See wiki or README for more information on the requirements
31: * - PHP 5.4.14+
32: * * Bzip2 - http://www.php.net/manual/en/book.bzip2.php
33: *
34: * @author Austin Bischoff <austin@codebeard.com>
35: *
36: * @property bool $debug
37: * @property string $capture_packets_file
38: * @property int $stream_timeout
39: * @property int $timeout
40: * @property int $write_wait
41: */
42: class GameQ
43: {
44: // Constants
45: const PROTOCOLS_DIRECTORY = __DIR__ . '/Protocols';
46:
47: // Static Section
48:
49: /**
50: * Holds the instance of itself
51: *
52: * @var self
53: */
54: protected static $instance = null;
55:
56: /**
57: * Create a new instance of this class
58: *
59: * @return \GameQ\GameQ
60: */
61: public static function factory()
62: {
63: // Create a new instance
64: self::$instance = new self();
65:
66: // Return this new instance
67: return self::$instance;
68: }
69:
70: // Dynamic Section
71:
72: /**
73: * Default options
74: *
75: * @var array
76: */
77: protected $options = [
78: 'debug' => false,
79: 'timeout' => 3, // Seconds
80: 'filters' => [
81: // Default normalize
82: 'normalize_d751713988987e9331980363e24189ce' => [
83: 'filter' => 'normalize',
84: 'options' => [],
85: ],
86: ],
87: // Advanced settings
88: 'stream_timeout' => 200000, // See http://www.php.net/manual/en/function.stream-select.php for more info
89: 'write_wait' => 500,
90: // How long (in micro-seconds) to pause between writing to server sockets, helps cpu usage
91:
92: // Used for generating protocol test data
93: 'capture_packets_file' => null,
94: ];
95:
96: /**
97: * Array of servers being queried
98: *
99: * @var array
100: */
101: protected $servers = [];
102:
103: /**
104: * The query library to use. Default is Native
105: *
106: * @var string
107: */
108: protected $queryLibrary = 'GameQ\\Query\\Native';
109:
110: /**
111: * Holds the instance of the queryLibrary
112: *
113: * @var \GameQ\Query\Core|null
114: */
115: protected $query = null;
116:
117: /**
118: * Get an option's value
119: *
120: * @param mixed $option
121: *
122: * @return mixed|null
123: */
124: public function __get($option)
125: {
126: return isset($this->options[$option]) ? $this->options[$option] : null;
127: }
128:
129: /**
130: * Set an option's value
131: *
132: * @param mixed $option
133: * @param mixed $value
134: *
135: * @return bool
136: */
137: public function __set($option, $value)
138: {
139: $this->options[$option] = $value;
140:
141: return true;
142: }
143:
144: public function getServers()
145: {
146: return $this->servers;
147: }
148:
149: public function getOptions()
150: {
151: return $this->options;
152: }
153:
154: /**
155: * Chainable call to __set, uses set as the actual setter
156: *
157: * @param mixed $var
158: * @param mixed $value
159: *
160: * @return $this
161: */
162: public function setOption($var, $value)
163: {
164: // Use magic
165: $this->{$var} = $value;
166:
167: return $this; // Make chainable
168: }
169:
170: /**
171: * Add a single server
172: *
173: * @param array $server_info
174: *
175: * @return $this
176: */
177: public function addServer(array $server_info = [])
178: {
179: // Add and validate the server
180: $this->servers[uniqid()] = new Server($server_info);
181:
182: return $this; // Make calls chainable
183: }
184:
185: /**
186: * Add multiple servers in a single call
187: *
188: * @param array $servers
189: *
190: * @return $this
191: */
192: public function addServers(array $servers = [])
193: {
194: // Loop through all the servers and add them
195: foreach ($servers as $server_info) {
196: $this->addServer($server_info);
197: }
198:
199: return $this; // Make calls chainable
200: }
201:
202: /**
203: * Add a set of servers from a file or an array of files.
204: * Supported formats:
205: * JSON
206: *
207: * @param array $files
208: *
209: * @return $this
210: * @throws \Exception
211: */
212: public function addServersFromFiles($files = [])
213: {
214: // Since we expect an array let us turn a string (i.e. single file) into an array
215: if (!is_array($files)) {
216: $files = [$files];
217: }
218:
219: // Iterate over the file(s) and add them
220: foreach ($files as $file) {
221: // Check to make sure the file exists and we can read it
222: if (!file_exists($file) || !is_readable($file)) {
223: continue;
224: }
225:
226: // See if this file is JSON
227: if (($servers = json_decode(file_get_contents($file), true)) === null
228: && json_last_error() !== JSON_ERROR_NONE
229: ) {
230: // Type not supported
231: continue;
232: }
233:
234: // Add this list of servers
235: $this->addServers($servers);
236: }
237:
238: return $this;
239: }
240:
241: /**
242: * Clear all of the defined servers
243: *
244: * @return $this
245: */
246: public function clearServers()
247: {
248: // Reset all the servers
249: $this->servers = [];
250:
251: return $this; // Make Chainable
252: }
253:
254: /**
255: * Add a filter to the processing list
256: *
257: * @param string $filterName
258: * @param array $options
259: *
260: * @return $this
261: */
262: public function addFilter($filterName, $options = [])
263: {
264: // Create the filter hash so we can run multiple versions of the same filter
265: $filterHash = sprintf('%s_%s', strtolower($filterName), md5(json_encode($options)));
266:
267: // Add the filter
268: $this->options['filters'][$filterHash] = [
269: 'filter' => strtolower($filterName),
270: 'options' => $options,
271: ];
272:
273: unset($filterHash);
274:
275: return $this;
276: }
277:
278: /**
279: * Remove an added filter
280: *
281: * @param string $filterHash
282: *
283: * @return $this
284: */
285: public function removeFilter($filterHash)
286: {
287: // Make lower case
288: $filterHash = strtolower($filterHash);
289:
290: // Remove this filter if it has been defined
291: if (array_key_exists($filterHash, $this->options['filters'])) {
292: unset($this->options['filters'][$filterHash]);
293: }
294:
295: unset($filterHash);
296:
297: return $this;
298: }
299:
300: /**
301: * Return the list of applied filters
302: *
303: * @return array
304: */
305: public function listFilters()
306: {
307: return $this->options['filters'];
308: }
309:
310: /**
311: * Main method used to actually process all of the added servers and return the information
312: *
313: * @return array
314: * @throws \Exception
315: */
316: public function process()
317: {
318: // Initialize the query library we are using
319: $class = new \ReflectionClass($this->queryLibrary);
320:
321: // Set the query pointer to the new instance of the library
322: $this->query = $class->newInstance();
323:
324: unset($class);
325:
326: // Define the return
327: $results = [];
328:
329: // @todo: Add break up into loop to split large arrays into smaller chunks
330:
331: // Do server challenge(s) first, if any
332: $this->doChallenges();
333:
334: // Do packets for server(s) and get query responses
335: $this->doQueries();
336:
337: // Now we should have some information to process for each server
338: foreach ($this->servers as $server) {
339: // @var $server \GameQ\Server
340:
341: // Parse the responses for this server
342: $result = $this->doParseResponse($server);
343:
344: // Apply the filters
345: $result = array_merge($result, $this->doApplyFilters($result, $server));
346:
347: // Sort the keys so they are alphabetical and nicer to look at
348: ksort($result);
349:
350: // Add the result to the results array
351: $results[$server->id()] = $result;
352: }
353:
354: return $results;
355: }
356:
357: /**
358: * Do server challenges, where required
359: */
360: protected function doChallenges()
361: {
362: // Initialize the sockets for reading
363: $sockets = [];
364:
365: // By default we don't have any challenges to process
366: $server_challenge = false;
367:
368: // Do challenge packets
369: foreach ($this->servers as $server_id => $server) {
370: // @var $server \GameQ\Server
371:
372: // This protocol has a challenge packet that needs to be sent
373: if ($server->protocol()->hasChallenge()) {
374: // We have a challenge, set the flag
375: $server_challenge = true;
376:
377: // Let's make a clone of the query class
378: $socket = clone $this->query;
379:
380: // Set the information for this query socket
381: $socket->set(
382: $server->protocol()->transport(),
383: $server->ip,
384: $server->port_query,
385: $this->timeout
386: );
387:
388: try {
389: // Now write the challenge packet to the socket.
390: $socket->write($server->protocol()->getPacket(Protocol::PACKET_CHALLENGE));
391:
392: // Add the socket information so we can reference it easily
393: $sockets[(int)$socket->get()] = [
394: 'server_id' => $server_id,
395: 'socket' => $socket,
396: ];
397: } catch (QueryException $exception) {
398: // Check to see if we are in debug, if so bubble up the exception
399: if ($this->debug) {
400: throw new \Exception($exception->getMessage(), $exception->getCode(), $exception);
401: }
402: }
403:
404: unset($socket);
405:
406: // Let's sleep shortly so we are not hammering out calls rapid fire style hogging cpu
407: usleep($this->write_wait);
408: }
409: }
410:
411: // We have at least one server with a challenge, we need to listen for responses
412: if ($server_challenge) {
413: // Now we need to listen for and grab challenge response(s)
414: $responses = call_user_func_array(
415: [$this->query, 'getResponses'],
416: [$sockets, $this->timeout, $this->stream_timeout]
417: );
418:
419: // Iterate over the challenge responses
420: foreach ($responses as $socket_id => $response) {
421: // Back out the server_id we need to update the challenge response for
422: $server_id = $sockets[$socket_id]['server_id'];
423:
424: // Make this into a buffer so it is easier to manipulate
425: $challenge = new Buffer(implode('', $response));
426:
427: // Grab the server instance
428: // @var $server \GameQ\Server
429: $server = $this->servers[$server_id];
430:
431: // Apply the challenge
432: $server->protocol()->challengeParseAndApply($challenge);
433:
434: // Add this socket to be reused, has to be reused in GameSpy3 for example
435: $server->socketAdd($sockets[$socket_id]['socket']);
436:
437: // Clear
438: unset($server);
439: }
440: }
441: }
442:
443: /**
444: * Run the actual queries and get the response(s)
445: */
446: protected function doQueries()
447: {
448: // Initialize the array of sockets
449: $sockets = [];
450:
451: // Iterate over the server list
452: foreach ($this->servers as $server_id => $server) {
453: // @var $server \GameQ\Server
454:
455: // Invoke the beforeSend method
456: $server->protocol()->beforeSend($server);
457:
458: // Get all the non-challenge packets we need to send
459: $packets = $server->protocol()->getPacket('!' . Protocol::PACKET_CHALLENGE);
460:
461: if (count($packets) == 0) {
462: // Skip nothing else to do for some reason.
463: continue;
464: }
465:
466: // Try to use an existing socket
467: if (($socket = $server->socketGet()) === null) {
468: // Let's make a clone of the query class
469: $socket = clone $this->query;
470:
471: // Set the information for this query socket
472: $socket->set(
473: $server->protocol()->transport(),
474: $server->ip,
475: $server->port_query,
476: $this->timeout
477: );
478: }
479:
480: try {
481: // Iterate over all the packets we need to send
482: foreach ($packets as $packet_data) {
483: // Now write the packet to the socket.
484: $socket->write($packet_data);
485:
486: // Let's sleep shortly so we are not hammering out calls rapid fire style
487: usleep($this->write_wait);
488: }
489:
490: unset($packets);
491:
492: // Add the socket information so we can reference it easily
493: $sockets[(int)$socket->get()] = [
494: 'server_id' => $server_id,
495: 'socket' => $socket,
496: ];
497: } catch (QueryException $exception) {
498: // Check to see if we are in debug, if so bubble up the exception
499: if ($this->debug) {
500: throw new \Exception($exception->getMessage(), $exception->getCode(), $exception);
501: }
502:
503: continue;
504: }
505:
506: // Clean up the sockets, if any left over
507: $server->socketCleanse();
508: }
509:
510: // Now we need to listen for and grab response(s)
511: $responses = call_user_func_array(
512: [$this->query, 'getResponses'],
513: [$sockets, $this->timeout, $this->stream_timeout]
514: );
515:
516: // Iterate over the responses
517: foreach ($responses as $socket_id => $response) {
518: // Back out the server_id
519: $server_id = $sockets[$socket_id]['server_id'];
520:
521: // Grab the server instance
522: // @var $server \GameQ\Server
523: $server = $this->servers[$server_id];
524:
525: // Save the response from this packet
526: $server->protocol()->packetResponse($response);
527:
528: unset($server);
529: }
530:
531: // Now we need to close all of the sockets
532: foreach ($sockets as $socketInfo) {
533: // @var $socket \GameQ\Query\Core
534: $socket = $socketInfo['socket'];
535:
536: // Close the socket
537: $socket->close();
538:
539: unset($socket);
540: }
541:
542: unset($sockets);
543: }
544:
545: /**
546: * Parse the response for a specific server
547: *
548: * @param \GameQ\Server $server
549: *
550: * @return array
551: * @throws \Exception
552: */
553: protected function doParseResponse(Server $server)
554: {
555: try {
556: // @codeCoverageIgnoreStart
557: // We want to save this server's response to a file (useful for unit testing)
558: if (!is_null($this->capture_packets_file)) {
559: file_put_contents(
560: $this->capture_packets_file,
561: implode(PHP_EOL . '||' . PHP_EOL, $server->protocol()->packetResponse())
562: );
563: }
564: // @codeCoverageIgnoreEnd
565:
566: // Get the server response
567: $results = $server->protocol()->processResponse();
568:
569: // Check for online before we do anything else
570: $results['gq_online'] = (count($results) > 0);
571: } catch (ProtocolException $e) {
572: // Check to see if we are in debug, if so bubble up the exception
573: if ($this->debug) {
574: throw new \Exception($e->getMessage(), $e->getCode(), $e);
575: }
576:
577: // We ignore this server
578: $results = [
579: 'gq_online' => false,
580: ];
581: }
582:
583: // Now add some default stuff
584: $results['gq_address'] = (isset($results['gq_address'])) ? $results['gq_address'] : $server->ip();
585: $results['gq_port_client'] = $server->portClient();
586: $results['gq_port_query'] = (isset($results['gq_port_query'])) ? $results['gq_port_query'] : $server->portQuery();
587: $results['gq_protocol'] = $server->protocol()->getProtocol();
588: $results['gq_type'] = (string)$server->protocol();
589: $results['gq_name'] = $server->protocol()->nameLong();
590: $results['gq_transport'] = $server->protocol()->transport();
591:
592: // Process the join link
593: if (!isset($results['gq_joinlink']) || empty($results['gq_joinlink'])) {
594: $results['gq_joinlink'] = $server->getJoinLink();
595: }
596:
597: return $results;
598: }
599:
600: /**
601: * Apply any filters to the results
602: *
603: * @param array $results
604: * @param \GameQ\Server $server
605: *
606: * @return array
607: */
608: protected function doApplyFilters(array $results, Server $server)
609: {
610: // Loop over the filters
611: foreach ($this->options['filters'] as $filterOptions) {
612: // Try to do this filter
613: try {
614: // Make a new reflection class
615: $class = new \ReflectionClass(sprintf('GameQ\\Filters\\%s', ucfirst($filterOptions['filter'])));
616:
617: // Create a new instance of the filter class specified
618: $filter = $class->newInstanceArgs([$filterOptions['options']]);
619:
620: // Apply the filter to the data
621: $results = $filter->apply($results, $server);
622: } catch (\ReflectionException $exception) {
623: // Invalid, skip it
624: continue;
625: }
626: }
627:
628: return $results;
629: }
630: }
631: