Completed
Push — master ( 9c8eec...2ddd9f )
by Никита
01:46
created

Server::__destruct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 3
c 0
b 0
f 0
ccs 3
cts 3
cp 1
rs 10
cc 1
eloc 2
nc 1
nop 0
crap 1
1
<?php
2
3
    namespace NokitaKaze\TestHTTPServer;
4
5
    /**
6
     * Class Server
7
     * @package NokitaKaze\TestHTTPServer
8
     *
9
     * @doc https://tools.ietf.org/html/rfc7230
10
     * @doc http://httpwg.org/specs/rfc7230.html
11
     */
12
    class Server {
13
        const CHUNK_SIZE = 4096;
14
15
        /**
16
         * @var ServerSettings
17
         */
18
        protected $_settings = null;
19
20
        /**
21
         * @var resource
22
         */
23
        protected $_server_socket = null;
24
25
        /**
26
         * @var resource
27
         */
28
        protected $_stream_context = null;
29
30
        /**
31
         * @var ClientDatum[]
32
         */
33
        protected $_client_connects = [];
34
35
        /**
36
         * Server constructor
37
         *
38
         * @param ServerSettings|object|array $settings
39
         *
40
         * @throws Exception
41
         */
42 456
        function __construct($settings) {
43 456
            $settings = (object) $settings;
44 456
            self::fix_incoming_settings($settings);
45 456
            $this->_settings = $settings;
46 456
            $this->init_ssl_settings();
47 447
        }
48
49 447
        function __destruct() {
50 447
            $this->shutdown();
51 447
        }
52
53 447
        function shutdown() {
54 447
            foreach ($this->_client_connects as &$connect) {
55 198
                $this->close_connection($connect);
56 151
            }
57 447
            $this->_client_connects = [];
58 447
            $this->close_connection_socket($this->_server_socket);
59 447
            $this->_server_socket = null;
60 447
        }
61
62
        /**
63
         * @param resource $connection
64
         */
65 447
        function close_connection_socket($connection) {
66 447
            if (is_resource($connection)) {
67 447
                stream_socket_shutdown($connection, STREAM_SHUT_RDWR);
68 447
                fclose($connection);
69 151
            }
70 447
        }
71
72
        /**
73
         * @param object|ClientDatum $connection
74
         */
75 426
        function close_connection(&$connection) {
76 426
            if (is_null($connection)) {
77 183
                return;
78
            }
79 426
            $this->close_connection_socket($connection->client);
80 426
            $connection->client = null;
81 426
            $connection = null;
82 426
        }
83
84
        /**
85
         * @return array
86
         */
87 456
        static function get_default_settings() {
88
            return [
89 456
                'interface' => '127.0.0.1',
90 456
                'port' => 58080,
91 456
                'server_sleep_if_no_connect' => 1,
92 154
                'is_ssl' => false,
93
                'filterIncomingConnect' => function () { return true; },
94 154
                'server_maximum_chunk' => 300 * 1024,
95
96 456
                'time_wait_until_first_byte' => 60,
97 154
            ];
98
        }
99
100 456
        function init_ssl_settings() {
101 456
            $this->_stream_context = stream_context_create();
102 456
            if (isset($this->_settings->ssl_server_certificate_file)) {
103
                // @hint Здесь специально не задаётся _settings->is_ssl
104 309
                $this->stream_set_ssl_option('local_cert', $this->_settings->ssl_server_certificate_file);
105 309
                $this->stream_set_ssl_option('allow_self_signed', true);
106 309
                $this->stream_set_ssl_option('verify_peer', false);
107 309
                if (isset($this->_settings->ssl_server_key_file)) {
108 309
                    $this->stream_set_ssl_option('local_pk', $this->_settings->ssl_server_key_file);
109 103
                }
110 309
                $this->stream_set_ssl_option('passphrase',
111 309
                    isset($this->_settings->ssl_server_key_password) ? $this->_settings->ssl_server_key_password : '');
112
113 309
                if (isset($this->_settings->ssl_client_certificate_file)) {
114 36
                    $this->stream_set_ssl_option('verify_peer', true);
115 36
                    $this->stream_set_ssl_option('capture_peer_cert', true);
116 36
                    $this->stream_set_ssl_option('capture_peer_cert_chain', true);
117 127
                    $this->stream_set_ssl_option('cafile', $this->_settings->ssl_client_certificate_file);
118 12
                }
119 154
            } elseif (isset($this->_settings->ssl_server_key_file)) {
120 3
                throw new Exception('ssl_server_key_file is set, but ssl_server_certificate_file is missing');
121 50
            } elseif (isset($this->_settings->ssl_server_key_password)) {
122 3
                throw new Exception('ssl_server_key_password is set, but ssl_server_certificate_file is missing');
123 49
            } elseif (isset($this->_settings->ssl_client_certificate_file)) {
124 3
                throw new Exception('ssl_client_certificate_file is set, but ssl_server_certificate_file is missing');
125
            }
126 447
        }
127
128
        /**
129
         * Server constructor.
130
         *
131
         * @param ServerSettings|object $settings
132
         */
133 456
        protected static function fix_incoming_settings($settings) {
134 456
            $default_settings = self::get_default_settings();
135 456
            foreach ($default_settings as $key => $value) {
136 456
                if (!isset($settings->{$key})) {
137 456
                    $settings->{$key} = $value;
138 154
                }
139 154
            }
140 456
            foreach (['ListenStart', 'Connect', 'Request', 'Disconnect', 'ListenStop', 'AnyIncomingData', 'HeadIncomingData',
141 154
                      'BodyIncomingData', 'HeadReceived', 'HeadInvalidReceived'] as $event) {
142 456
                if (!isset($settings->{'on'.$event})) {
143 456
                    $settings->{'on'.$event} = null;
144 154
                }
145 154
            }
146 456
        }
147
148
        /**
149
         * @param callable|double $param
150
         * @param callable|null   $tick
151
         */
152 24
        function listen($param, $tick = null) {
153 24
            $this->init_listening();
154 24
            if (is_callable($param)) {
155 24
                $closure = $param;
156 10
            } else {
157
                $closure = function () use ($param) { return (microtime(true) < $param); };
158
            }
159
            /** @noinspection PhpMethodParametersCountMismatchInspection */
160 24
            while ($closure($this)) {
161 24
                $this->listen_tick();
162 24
                if (!is_null($tick)) {
163
                    $tick($this);
164
                }
165 10
            }
166 24
        }
167
168 447
        function init_listening() {
169 447
            if (!is_null($this->_server_socket)) {
170 24
                return;
171
            }
172 447
            $this->_server_socket = stream_socket_server(
173 447
                sprintf('%s://%s:%d',
174 447
                    $this->_settings->is_ssl ? 'ssl' : 'tcp',
175 447
                    is_null($this->_settings->interface) ? '0.0.0.0' : $this->_settings->interface,
176 447
                    $this->_settings->port
177 151
                ),
178 447
                $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
179 447
                $this->_stream_context);
180 447
            if (!isset($this->_server_socket)) {
181
                // @codeCoverageIgnoreStart
182
                throw new Exception('Can not create socket ['.$errstr.']', 100 + $errno);
183
                // @codeCoverageIgnoreEnd
184
            }
185
186 447
            $this->event_raise('ListenStart');
187 447
        }
188
189 447
        function listen_tick() {
190 447
            $read = [$this->_server_socket];
191 447
            $write = null;
192 447
            $except = null;
193 447
            if (stream_select($read, $write, $except, 0) === false) {
194
                // @codeCoverageIgnoreStart
195
                throw new Exception('Error on stream select');
196
                // @codeCoverageIgnoreEnd
197
            }
198 447
            if (empty($read)) {
199 447
                $ts_before = microtime(true);
200 447
                $this->process_all_connected_clients();
201 447
                $sleep = $this->_settings->server_sleep_if_no_connect - microtime(true) + $ts_before;
202 447
                if ($sleep > 0) {
203 447
                    usleep($sleep * 1000000);
204 151
                }
205
206 447
                return;
207
            }
208
209 438
            $client = @stream_socket_accept($this->_server_socket);
210 438
            if ($client === false) {
211 21
                $this->event_raise('InvalidConnection');
212
213
                // Если я не запущу process_all_connected_clients, то может быть DDoS через неправильные коннекты
214 21
                $this->process_all_connected_clients();
215
216 21
                return;
217
            }
218
219 426
            if (stream_set_blocking($client, 0) === false) {
220
                // @codeCoverageIgnoreStart
221
                throw new Exception('Can not set socket as non blocking');
222
                // @codeCoverageIgnoreEnd
223
            }
224 426
            $connect_time = microtime(true);
225
            /**
226
             * @var ClientDatum $datum
227
             */
228 426
            $datum = new \stdClass();
229 426
            $datum->status = 0;
230 426
            $datum->client = $client;
231 426
            $datum->connection_time = $connect_time;
232 426
            $datum->blob_request = '';
233 426
            $datum->request_head_params = [];
234 426
            $datum->server = $this;
235 426
            $datum->accepted_hosts = null;
236 426
            $this->event_raise('Connect', $datum);
237 426
            $this->_client_connects[] = $datum;
238
239
            //
240 426
            $this->process_all_connected_clients();
241 426
        }
242
243 318
        function stream_set_ssl_option($key, $value) {
244 318
            stream_context_set_option($this->_stream_context, 'ssl', $key, $value);
245 318
        }
246
247 12
        function set_option($key, $value) {
248 12
            $this->_settings->{$key} = $value;
249 12
        }
250
251 3
        function get_option($key) {
252 3
            return $this->_settings->{$key};
253
        }
254
255
        /**
256
         * Первый байт не пришёл
257
         *
258
         * @param object|ClientDatum $connection
259
         *
260
         * @return boolean
261
         */
262 426
        protected function check_timeouts_on_connection_first_byte($connection) {
263 426
            return isset($this->_settings->time_wait_until_first_byte) and
264 426
                   !isset($connection->first_byte_received_time) and
265 426
                   ($connection->connection_time < microtime(true) - $this->_settings->time_wait_until_first_byte);
266
        }
267
268
        /**
269
         * Голова не пришла
270
         *
271
         * @param object|ClientDatum $connection
272
         *
273
         * @return boolean
274
         */
275 426
        protected function check_timeouts_on_connection_head_received($connection) {
276 426
            return isset($this->_settings->time_wait_until_head_received) and
277 287
                   !isset($connection->head_received_time) and
278 426
                   ($connection->connection_time < microtime(true) - $this->_settings->time_wait_until_head_received);
279
        }
280
281
        /**
282
         * Запрос не пришёл
283
         *
284
         * @param object|ClientDatum $connection
285
         *
286
         * @return boolean
287
         */
288 426
        protected function check_timeouts_on_connection_request_received($connection) {
289 426
            return isset($this->_settings->time_wait_until_request_received) and
290 287
                   !isset($connection->full_request_received_time) and
291 426
                   ($connection->connection_time < microtime(true) - $this->_settings->time_wait_until_request_received);
292
        }
293
294
        /**
295
         * Тело не пришло (голова пришла)
296
         *
297
         * @param object|ClientDatum $connection
298
         *
299
         * @return boolean
300
         */
301 426
        protected function check_timeouts_on_connection_body_received_without_head($connection) {
302 426
            return isset($this->_settings->time_between_head_and_body_received, $connection->head_received_time) and
303 287
                   !isset($connection->full_request_received_time) and
304 426
                   ($connection->head_received_time < microtime(true) - $this->_settings->time_between_head_and_body_received);
305
        }
306
307
        /**
308
         * Проверяем слишком старые подключения и убиваем их
309
         *
310
         * @param object|ClientDatum $connection
311
         *
312
         * @return bool
313
         */
314 426
        protected function check_timeouts_on_connection(&$connection) {
315 426
            if ($this->check_timeouts_on_connection_first_byte($connection) or
316 426
                $this->check_timeouts_on_connection_head_received($connection) or
317 426
                $this->check_timeouts_on_connection_request_received($connection) or
318 426
                $this->check_timeouts_on_connection_body_received_without_head($connection)
319 144
            ) {
320 18
                $this->close_connection($connection);
321
322 18
                return false;
323
            }
324
325
            // @hint No Slowloris ( https://en.wikipedia.org/wiki/Slowloris_%28computer_security%29 ) test.
326
            // This is not a real server
327
328 426
            return true;
329
        }
330
331
        /**
332
         * Processing all connected clients
333
         */
334 447
        protected function process_all_connected_clients() {
335 447
            if (empty($this->_client_connects)) {
336 447
                return;
337
            }
338
339
            /**
340
             * @var resource[] $read
341
             * @var resource[] $write
342
             * @var resource[] $except
343
             */
344 426
            $read = [];
345 426
            foreach ($this->_client_connects as $connected) {
346 426
                if (is_null($connected)) {
347 165
                    continue;
348
                }
349 426
                if (is_null($connected->client)) {
350 45
                    $connected = null;
351 45
                    continue;
352
                }
353
354 426
                if (!is_resource($connected->client)) {
355
                    // @codeCoverageIgnoreStart
356
                    throw new Exception(sprintf('Connection became non resource: %s', (string) $connected->client));
357
                    // @codeCoverageIgnoreEnd
358
                }
359
360
                // Проверяем слишком старые подключения и убиваем их
361 426
                if (!$this->check_timeouts_on_connection($connected)) {
362 18
                    continue;
363
                }
364
365 426
                $read[] = $connected->client;
366 144
            }
367 426
            if (empty($read)) {
368 228
                $this->_client_connects = [];
369
370 228
                return;
371
            }
372 426
            $write = null;
373 426
            $except = null;
374 426
            if (stream_select($read, $write, $except, 0) === false) {
375
                // @codeCoverageIgnoreStart
376
                throw new Exception('Error on stream select');
377
                // @codeCoverageIgnoreEnd
378
            }
379 426
            unset($connected);
380
381 426
            foreach ($read as &$socket_resource) {
382 414
                foreach ($this->_client_connects as &$connected) {
383 414
                    if (!is_null($connected) and ($connected->client == $socket_resource)) {
384 414
                        $this->process_connected_client($connected);
385 414
                        break;
386
                    }
387 140
                }
388 144
            }
389 426
        }
390
391
        /**
392
         * @param ClientDatum $connect
393
         * @param double      $time
394
         * @param string      $buf
395
         *
396
         * @return boolean
397
         */
398 414
        protected function receive_data_from_connected_client($connect, $time, &$buf) {
399 414
            $buf = @fread($connect->client, self::CHUNK_SIZE);
400 414
            if (empty($buf)) {
401 96
                $this->close_connection($connect);
402
403 96
                return false;
404
            }
405 333
            $connect->last_byte_received_time = $time;
406 333
            if (!isset($connect->first_byte_received_time)) {
407 333
                $connect->first_byte_received_time = $time;
408 113
            }
409 333
            if (strlen($buf) >= self::CHUNK_SIZE) {
410
                do {
411 24
                    $sub_buf = @fread($connect->client, self::CHUNK_SIZE);
412 24
                    $buf .= $sub_buf;
413 24
                    if (isset($this->_settings->server_maximum_chunk) and
414 24
                        (strlen($buf) >= $this->_settings->server_maximum_chunk)
415 8
                    ) {
416
                        // Слишком много пришло за один раз
417 6
                        break;
418
                    }
419 18
                } while (strlen($sub_buf) >= self::CHUNK_SIZE);
420 8
            }
421
422 333
            return true;
423
        }
424
425
        /**
426
         * Processing all connected clients
427
         *
428
         * @param ClientDatum $connect
429
         */
430 414
        protected function process_connected_client(&$connect) {
431 414
            $time = microtime(true);
432 414
            $connect->context_options = stream_context_get_options($connect->client);
433 414
            $connect->context_params = stream_context_get_params($connect->client);
434 414
            if (!$this->receive_data_from_connected_client($connect, $time, $buf)) {
435 96
                $this->close_connection($connect);
436
437 96
                return;
438
            }
439 333
            $this->event_raise('AnyIncomingData', $connect, $buf);
440 333
            if ($connect->status == 0) {
441 333
                $this->event_raise('HeadIncomingData', $connect, $buf);
442 147
            } elseif ($connect->status == 1) {
443 51
                $this->event_raise('BodyIncomingData', $connect, $buf);
444 17
            }
445 333
            $connect->blob_request .= $buf;
446 333
            if (strpos($connect->blob_request, "\r\n\r\n") === false) {
447
                // Head не дошёл
448 6
                return;
449
            }
450
451
            // Проверяем на голову
452
            // Голова только-только дошла
453 327
            if (($connect->status == 0) and !$this->process_connected_client_head($connect, $time)) {
454 42
                return;
455
            }
456
457
            // Проверяем на body
458 285
            if ($connect->status == 1) {
459 72
                $this->process_connected_client_body($connect, $buf, $time);
460 24
            }
461 285
            if (is_null($connect) or ($connect->status != 2)) {
462 69
                return;
463
            }
464
465
            // Проверяем, что Host в списке обрабатываемых
466 267
            if (!$this->check_requested_host_in_accepted_list($connect)) {
467 54
                return;
468
            }
469
470
            // filterIncomingConnect
471 213
            $closure = $this->_settings->filterIncomingConnect;
472 213
            if (!$closure($this, $connect)) {
473 6
                $this->close_connection($connect);
474
475 6
                return;
476
            }
477 207
            unset($closure, $buf);
478
479
            // Request
480 207
            $this->event_raise('Request', $connect);
481
482 207
            if ($connect->status == 3) {
483 207
                $this->close_connection($connect);
484 71
            }
485 207
        }
486
487 267
        function check_requested_host_in_accepted_list($connect) {
488 267
            if (!isset($this->_settings->accepted_hosts)) {
489 162
                return true;
490
            }
491
492 105
            list($host) = explode(':', $connect->request_head_params['Host']);
493 105
            if (!in_array(strtolower($host), $this->_settings->accepted_hosts)) {
494 54
                if ($this->event_raise('HostNotFound', $connect) === false) {
495 9
                    $this->close_connection($connect);
496
497 9
                    return false;
498
                }
499
500 45
                $this->answer($connect, 404, 'Not Found', 'Host not found');
501 45
                $this->close_connection($connect);
502
503 45
                return false;
504
            }
505
506 51
            return true;
507
        }
508
509
        /**
510
         * If returns "false" connection closed
511
         *
512
         * @param ClientDatum $connect
513
         * @param double      $time
514
         *
515
         * @return boolean
516
         */
517 327
        protected function process_connected_client_head(&$connect, $time) {
518 327
            $connect->head_received_time = $time;
519 327
            list($connect->blob_head) = explode("\r\n\r\n", $connect->blob_request, 2);
520 327
            $a = explode("\r\n", $connect->blob_head, 2);
521 327
            list($first_line, $other_lines) = (count($a) == 2) ? $a : [$a[0], ''];
522 327
            if (!preg_match('_^([A-Z]+)\\s+(.+)\\s+HTTP/([0-9.]+)$_', $first_line, $a)) {
523 12
                if ($this->event_raise('HeadInvalidReceived', $connect, 0) === false) {
524
                    // Подключение сдохло
525 6
                    $this->close_connection($connect);
526
527 6
                    return false;
528
                }
529
530 6
                $this->answer($connect, 400, 'Bad Request', "This is not a HTTP request");
531 6
                $this->close_connection($connect);
532
533 6
                return false;
534
            }
535
536 315
            $connect->request_type = $a[1];
537 315
            $connect->request_url = $a[2];
538 315
            $connect->request_http_version = $a[3];
539 315
            if ($connect->request_type == 'POST') {
540 72
                $connect->status = 1;
541 267
            } elseif ($connect->request_type == 'GET') {
542 231
                $connect->status = 2;
543 231
                $connect->full_request_received_time = $time;
544 79
            } else {
545 12
                if ($this->event_raise('HeadInvalidReceived', $connect, 1) === false) {
546
                    // Подключение сдохло
547 6
                    $this->close_connection($connect);
548
549 6
                    return false;
550
                }
551
552 6
                $this->answer($connect, 400, 'Bad Request', "Can not process this request type");
553 6
                $this->close_connection($connect);
554
555 6
                return false;
556
            }
557
558 303
            $connect->request_head_params = [];
559 303
            foreach (explode("\r\n", $other_lines) as $other_line) {
560 303
                if (empty($other_line) or !preg_match('_^([A-Za-z0-9-]+):\\s*(.*)$_', $other_line, $a)) {
561 12
                    if ($this->event_raise('HeadInvalidReceived', $connect, 2) === false) {
562
                        // Подключение сдохло
563 6
                        $this->close_connection($connect);
564
565 6
                        return false;
566
                    }
567
568 6
                    $this->answer($connect, 400, 'Bad Request', "Malformed head");
569 6
                    $this->close_connection($connect);
570
571 6
                    return false;
572
                }
573
574 303
                $connect->request_head_params[$a[1]] = $a[2];
575 103
            }
576
577 291
            if (!isset($connect->request_head_params['Host'])) {
578 6
                $this->answer($connect, 400, 'Bad Request', "Field 'host' missed");
579 6
                $this->close_connection($connect);
580
581 6
                return false;
582
            }
583
584
            // event
585 285
            $this->event_raise('HeadReceived', $connect);
586
587 285
            return true;
588
        }
589
590
        /**
591
         * If returns "false" connection closed
592
         *
593
         * @param ClientDatum $connect
594
         * @param string      $buf
595
         * @param double      $time
596
         */
597 72
        protected function process_connected_client_body(&$connect, $buf, $time) {
598 72
            $head_end = strpos($connect->blob_request, "\r\n\r\n");
599 72
            if (!isset($connect->request_head_params['Content-Length'])) {
600 12
                if ($this->event_raise('HeadInvalidReceived', $connect, 3) === false) {
601 6
                    $this->close_connection($connect);
602
603 6
                    return;
604
                }
605
606 6
                $this->answer($connect, 400, 'malformed request', 'Malformed request');
607 6
                $this->close_connection($connect);
608
609 6
                return;
610
            }
611 60
            $requested_body_length = (int) $connect->request_head_params['Content-Length'];
612 60
            if (strlen($connect->blob_request) >= $head_end + 4 + $requested_body_length) {
613 54
                $connect->blob_body = substr($connect->blob_request, $head_end + 4, $requested_body_length);
614 54
                $connect->status = 2;
615 54
                $connect->body_received_time = $time;
616 54
                $connect->full_request_received_time = $time;
617
                // @hint Request raised in process_connected_client
618 18
            }
619
620 60
            $this->event_raise('BodyIncomingData', $connect, $buf);
621 60
        }
622
623
        /**
624
         * @param string $event
625
         *
626
         * @return mixed|null
627
         */
628 447
        function event_raise($event) {
629 447
            $method_name = 'on'.$event;
630 447
            if (!isset($this->_settings->{$method_name}) or !is_callable($this->_settings->{$method_name})) {
631 447
                return null;
632
            }
633
634 261
            $args = func_get_args();
635 261
            $args[0] = $this;
636
637 261
            return call_user_func_array($this->_settings->{$method_name}, $args);
638
        }
639
640
        /**
641
         * @param ClientDatum $connect
642
         * @param integer     $code
643
         * @param string      $code_text
644
         * @param string      $body
645
         * @param array       $headers
646
         */
647 321
        function answer($connect, $code, $code_text, $body, array $headers = []) {
648 321
            $buf = sprintf("HTTP/1.0 %d %s\r\n", $code, $code_text);
649 321
            $headers['Content-Length'] = strlen($body);
650 321
            foreach ($headers as $key => &$value) {
651 321
                $buf .= sprintf("%s: %s\r\n", $key, $value);
652 109
            }
653 321
            fwrite($connect->client, "{$buf}\r\n{$body}");
654 321
        }
655
    }
656
657
?>