1<?php
2/**
3 * This file is part of the FreeDSx Socket package.
4 *
5 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10
11namespace FreeDSx\Socket;
12
13use FreeDSx\Socket\Exception\ConnectionException;
14
15/**
16 * Represents a generic socket.
17 *
18 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
19 */
20class Socket
21{
22    /**
23     * Supported transport types.
24     */
25    public const TRANSPORTS = [
26        'tcp',
27        'udp',
28        'unix',
29    ];
30
31    /**
32     * @var bool
33     */
34    protected $isEncrypted = false;
35
36    /**
37     * @var resource|null
38     */
39    protected $socket;
40
41    /**
42     * @var resource|null
43     */
44    protected $context;
45
46    /**
47     * @var string
48     */
49    protected $errorMessage;
50
51    /**
52     * @var int
53     */
54    protected $errorNumber;
55
56    /**
57     * @var array
58     */
59    protected $sslOptsMap = [
60        'ssl_allow_self_signed' => 'allow_self_signed',
61        'ssl_ca_cert' => 'cafile',
62        'ssl_crypto_type' => 'crypto_type',
63        'ssl_peer_name' => 'peer_name',
64        'ssl_cert' => 'local_cert',
65        'ssl_cert_key' => 'local_pk',
66        'ssl_cert_passphrase' => 'passphrase',
67    ];
68
69    /**
70     * @var array
71     */
72    protected $sslOpts = [
73        'allow_self_signed' => false,
74        'verify_peer' => true,
75        'verify_peer_name' => true,
76        'capture_peer_cert' => true,
77        'capture_peer_cert_chain' => true,
78    ];
79
80    /**
81     * @var array
82     */
83    protected $options = [
84        'transport' => 'tcp',
85        'port' => 389,
86        'use_ssl' => false,
87        'ssl_crypto_type' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLS_CLIENT,
88        'ssl_validate_cert' => true,
89        'ssl_allow_self_signed' => null,
90        'ssl_ca_cert' => null,
91        'ssl_peer_name' => null,
92        'timeout_connect' => 3,
93        'timeout_read' => 15,
94        'buffer_size' => 8192,
95    ];
96
97    /**
98     * @param resource|null $resource
99     * @param array $options
100     */
101    public function __construct($resource = null, array $options = [])
102    {
103        $this->socket = $resource;
104        $this->options = \array_merge($this->options, $options);
105        if (!\in_array($this->options['transport'], self::TRANSPORTS, true)) {
106            throw new \RuntimeException(sprintf(
107                'The transport "%s" is not valid. It must be one of: %s',
108                $this->options['transport'],
109                implode(',', self::TRANSPORTS)
110            ));
111        }
112        if ($this->socket !== null) {
113            $this->setStreamOpts();
114        }
115    }
116
117    /**
118     * @param bool $block
119     * @return string|false
120     */
121    public function read(bool $block = true)
122    {
123        $data = false;
124
125        \stream_set_blocking($this->socket, $block);
126        while (\strlen((string) ($buffer = \fread($this->socket, $this->options['buffer_size']))) > 0) {
127            $data .= $buffer;
128            if ($block) {
129                $block = false;
130                \stream_set_blocking($this->socket, false);
131            }
132        }
133        \stream_set_blocking($this->socket, true);
134
135        return $data;
136    }
137
138    /**
139     * @param string $data
140     * @return $this
141     */
142    public function write(string $data)
143    {
144        @\fwrite($this->socket, $data);
145
146        return $this;
147    }
148
149    /**
150     * @param bool $block
151     * @return $this
152     */
153    public function block(bool $block)
154    {
155        \stream_set_blocking($this->socket, $block);
156
157        return $this;
158    }
159
160    /**
161     * @return bool
162     */
163    public function isConnected() : bool
164    {
165        return $this->socket !== null && !@\feof($this->socket);
166    }
167
168    /**
169     * @return bool
170     */
171    public function isEncrypted() : bool
172    {
173        return $this->isEncrypted;
174    }
175
176    /**
177     * @return $this
178     */
179    public function close()
180    {
181        if ($this->socket !== null) {
182            \stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR);
183        }
184        $this->socket = null;
185        $this->isEncrypted = false;
186        $this->context = null;
187
188        return $this;
189    }
190
191    /**
192     * Enable/Disable encryption on the TCP connection stream.
193     *
194     * @param bool $encrypt
195     * @return $this
196     * @throws ConnectionException
197     */
198    public function encrypt(bool $encrypt)
199    {
200        \stream_set_blocking($this->socket, true);
201        $result = \stream_socket_enable_crypto($this->socket, $encrypt, $this->options['ssl_crypto_type']);
202        \stream_set_blocking($this->socket, false);
203
204        if ((bool) $result == false) {
205            throw new ConnectionException(sprintf(
206                'Unable to %s encryption on TCP connection. %s',
207                $encrypt ? 'enable' : 'disable',
208                $this->errorMessage
209            ));
210        }
211        $this->isEncrypted = $encrypt;
212
213        return $this;
214    }
215
216    /**
217     * @param string $host
218     * @return $this
219     * @throws ConnectionException
220     */
221    public function connect(string $host)
222    {
223        $transport = $this->options['transport'];
224        if ($transport === 'tcp' && (bool) $this->options['use_ssl'] === true) {
225            $transport = 'ssl';
226        }
227
228        $uri = $transport . '://' . $host;
229
230        if ($transport !== 'unix') {
231            $uri .= ':' . $this->options['port'];
232        }
233
234        $socket = @\stream_socket_client(
235            $uri,
236            $this->errorNumber,
237            $this->errorMessage,
238            $this->options['timeout_connect'],
239            STREAM_CLIENT_CONNECT,
240            $this->createSocketContext()
241        );
242        if ($socket === false) {
243            throw new ConnectionException(sprintf(
244                'Unable to connect to %s: %s',
245                $host,
246                $this->errorMessage
247            ));
248        }
249        $this->socket = $socket;
250        $this->setStreamOpts();
251        $this->isEncrypted = $this->options['use_ssl'];
252
253        return $this;
254    }
255
256    /**
257     * Get the options set for the socket.
258     *
259     * @return array
260     */
261    public function getOptions() : array
262    {
263        return $this->options;
264    }
265
266    /**
267     * Create a socket by connecting to a specific host.
268     *
269     * @param string $host
270     * @param array $options
271     * @return Socket
272     * @throws ConnectionException
273     */
274    public static function create(string $host, array $options = []) : Socket
275    {
276        return (new self(null, $options))->connect($host);
277    }
278
279    /**
280     * Create a UNIX based socket.
281     *
282     * @param string $file The full path to the unix socket.
283     * @param array $options Any additional options.
284     * @return Socket
285     * @throws ConnectionException
286     */
287    public static function unix(
288        string $file,
289        array $options = []
290    ): Socket {
291        return self::create(
292            $file,
293            \array_merge(
294                $options,
295                ['transport' => 'unix']
296            )
297        );
298    }
299
300    /**
301     * Create a TCP based socket.
302     *
303     * @param string $host
304     * @param array $options
305     * @return Socket
306     * @throws ConnectionException
307     */
308    public static function tcp(string $host, array $options = []) : Socket
309    {
310        return self::create($host, \array_merge($options, ['transport' => 'tcp']));
311    }
312
313    /**
314     * Create a UDP based socket.
315     *
316     * @param string $host
317     * @param array $options
318     * @return Socket
319     * @throws ConnectionException
320     */
321    public static function udp(string $host, array $options = []) : Socket
322    {
323        return self::create($host, \array_merge($options, [
324            'transport' => 'udp',
325            'buffer_size' => 65507,
326        ]));
327    }
328
329    /**
330     * @return resource
331     */
332    protected function createSocketContext()
333    {
334        $sslOpts = $this->sslOpts;
335        foreach ($this->sslOptsMap as $optName => $sslOptsName) {
336            if (isset($this->options[$optName])) {
337                $sslOpts[$sslOptsName] = $this->options[$optName];
338            }
339        }
340        if ($this->options['ssl_validate_cert'] === false) {
341            $sslOpts = \array_merge($sslOpts, [
342                'allow_self_signed' => true,
343                'verify_peer' => false,
344                'verify_peer_name' => false,
345            ]);
346        }
347        $this->context = \stream_context_create([
348            'ssl' => $sslOpts,
349        ]);
350
351        return $this->context;
352    }
353
354    /**
355     * Sets options on the stream that must be done after it is a resource.
356     */
357    protected function setStreamOpts() : void
358    {
359        \stream_set_timeout($this->socket, $this->options['timeout_read']);
360    }
361}
362