PortTester::__construct()   A
last analyzed

Complexity

Conditions 4
Paths 6

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 9
c 1
b 0
f 0
dl 0
loc 13
rs 9.9666
cc 4
nc 6
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace PjbServer\Tools\Network;
6
7
class PortTester
8
{
9
    const BACKEND_STREAM_SOCKET = 'stream_socket';
10
    const BACKEND_SOCKET_CREATE = 'socket_create';
11
    const BACKEND_PFSOCKOPEN = 'pfsockopen';
12
    const BACKEND_CURL = 'curl';
13
14
    const PROTOCOL_TCP = 'tcp';
15
    const PROTOCOL_UDP = 'udp';
16
    const PROTOCOL_HTTP = 'http';
17
    const PROTOCOL_HTTPS = 'https';
18
19
    /**
20
     * @var string[]
21
     */
22
    protected $supportedBackends = [self::BACKEND_CURL, self::BACKEND_PFSOCKOPEN, self::BACKEND_SOCKET_CREATE, self::BACKEND_STREAM_SOCKET];
23
24
    /**
25
     * @var string[]
26
     */
27
    protected $supportedProtocols = [self::PROTOCOL_HTTP, self::PROTOCOL_HTTPS, self::PROTOCOL_TCP, self::PROTOCOL_UDP];
28
29
    /**
30
     * @var array<string, mixed>
31
     */
32
    protected $defaults = [
33
        'backend' => null,
34
        'timeout' => 1,
35
        'close_timeout_ms' => null
36
    ];
37
38
    /**
39
     * @var array
40
     */
41
    protected $options;
42
43
    /**
44
     * Constructor.
45
     *
46
     * <code>
47
     * $options = [
48
     *      'backend' => PortTester::BACKEND_STREAM_SOCKET,
49
     *      // connection timeout in seconds
50
     *      'timeout' => 1,
51
     *      // timeout to wait for connection to be closed
52
     *      // properly in milliseconds or null to disable
53
     *      // Use when TIME_WAIT is too long
54
     *      'close_timeout_ms => 300
55
     * ];
56
     * $portTester = new PortTester($options);
57
     * </code>
58
     *
59
     * @throws \InvalidArgumentException
60
     *
61
     * @param array<string, mixed> $options
62
     */
63
    public function __construct(array $options = [])
64
    {
65
        $this->options = array_merge($this->defaults, $options);
66
        if ($this->options['backend'] === null) {
67
            if ($this->isCurlAvailable()) {
68
                $this->options['backend'] = self::BACKEND_CURL;
69
            } else {
70
                $this->options['backend'] = self::BACKEND_STREAM_SOCKET;
71
            }
72
        }
73
        $this->options['backend'] = strtolower($this->options['backend']);
74
        if (!in_array($this->options['backend'], $this->supportedBackends, true)) {
75
            throw new \InvalidArgumentException("Unsupported backend '" . $this->options['backend'] . "'");
76
        }
77
    }
78
79
    /**
80
     * Check if TCP port is available for binding.
81
     *
82
     * @throws \InvalidArgumentException
83
     * @throws \RuntimeException
84
     *
85
     * @param string   $host
86
     * @param int      $port
87
     * @param string   $protocol
88
     * @param int|null $timeout
89
     *
90
     * @return bool
91
     */
92
    public function isAvailable(string $host, int $port, string $protocol = 'http', ?int $timeout = null): bool
93
    {
94
        if (!in_array($protocol, $this->supportedProtocols, true)) {
95
            throw new \InvalidArgumentException("Unsupported protocol '$protocol'");
96
        }
97
98
        if ($timeout === null) {
99
            $timeout = $this->options['timeout'];
100
        }
101
102
        $available = false;
103
        $sock = null;
104
105
        $backend = $this->options['backend'];
106
        switch ($backend) {
107
            case self::BACKEND_PFSOCKOPEN:
108
                $sock = @pfsockopen("$protocol://$host", $port, $errno, $errstr, $timeout);
109
                if ($sock === false) {
110
                    $available = true;
111
                } else {
112
                    fclose($sock);
113
                }
114
                break;
115
116
            case self::BACKEND_SOCKET_CREATE:
117
                $timeout = 0;
118
                $protocolMap = ['tcp' => SOL_TCP, 'udp' => SOL_UDP];
119
                if (!array_key_exists($protocol, $protocolMap)) {
120
                    throw new \RuntimeException("Backedn socket_create does not support protocol $protocol");
121
                }
122
                $proto = $protocolMap[$protocol];
123
124
                $sock = socket_create(AF_INET, SOCK_STREAM, $proto);
125
                socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $timeout, 'usec' => 0]);
126
                if (!@socket_connect($sock, $host, $port)) {
127
                    $available = true;
128
                } else {
129
                    socket_close($sock);
130
                }
131
                break;
132
133
            case self::BACKEND_STREAM_SOCKET:
134
                $flags = STREAM_CLIENT_CONNECT; //  & ~STREAM_CLIENT_PERSISTENT
135
                $sock = @stream_socket_client("$protocol://$host:$port", $errno, $errstr, $timeout, $flags);
136
                if (!$sock) {
0 ignored issues
show
introduced by
$sock is of type false|resource, thus it always evaluated to false.
Loading history...
137
                    $available = true;
138
                } else {
139
                    if (!stream_socket_shutdown($sock, STREAM_SHUT_RDWR)) {
140
                        throw new \RuntimeException('Cannot properly close socket stream.');
141
                    }
142
                    fclose($sock);
143
                }
144
                break;
145
146
            case 'curl':
147
                if (!$this->isCurlAvailable()) {
148
                    throw new \RuntimeException('Curl not available');
149
                }
150
                $curl_options = [
151
                    CURLOPT_URL => "http://$host:$port",
152
                    CURLOPT_TIMEOUT => $timeout,
153
                    CURLOPT_RETURNTRANSFER => true,
154
                    CURLOPT_FAILONERROR => true,
155
                    CURLOPT_PORT => $port,
156
                ];
157
158
                $curl_handle = curl_init();
159
                curl_setopt_array($curl_handle, $curl_options);
160
                curl_exec($curl_handle);
161
                $errno = curl_errno($curl_handle);
162
                if ($errno != 0) {
163
                    $available = true;
164
                }
165
                curl_close($curl_handle);
166
                break;
167
            default:
168
                throw new \InvalidArgumentException("Unsupported backend: '$backend'.");
169
        }
170
        unset($sock);
171
        if ($this->options['close_timeout_ms'] > 0) {
172
            usleep($this->options['close_timeout_ms'] * 1000);
173
        }
174
175
        return $available;
176
    }
177
178
    protected function isCurlAvailable(): bool
179
    {
180
        return function_exists('curl_version');
181
    }
182
}
183