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