Completed
Push — master ( ebf796...01f05e )
by Никита
44:34
created

Server::get_default_settings()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 1

Importance

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