SecureSocket::setOptions()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 5
ccs 0
cts 5
cp 0
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 1
crap 2
1
<?php
2
namespace Ekho\Logstash\Lumberjack;
3
4
/**
5
 * Class SecureSocket
6
 * @package Ekho\Logstash\Lumberjack
7
 */
8
class SecureSocket implements SocketInterface
9
{
10
    const MAX32BIT = 4294967295;
11
    const MAX16BIT = 65535;
12
13
    const CONNECTION_TIMEOUT = 3;
14
    const SOCKET_TIMEOUT = 3;
15
16
    private static $acceptableOptions = array(
17
        'autoconnect',
18
        'socket_timeout',
19
        'connection_timeout',
20
        'persistent',
21
        'ssl_allow_self_signed',
22
        'ssl_cafile',
23
        'ssl_peer_name',
24
        'ssl_disable_compression',
25
        'ssl_tls_only',
26
        'ssl_verify_peer_name',
27
    );
28
29
    private $options = array(
30
        'autoconnect'             => true,
31
        'socket_timeout'          => self::SOCKET_TIMEOUT,
32
        'connection_timeout'      => self::CONNECTION_TIMEOUT,
33
        'ssl_allow_self_signed'   => false,
34
        'ssl_disable_compression' => true,
35
        'ssl_tls_only'            => false,
36
        'persistent'              => false,
37
    );
38
39
    /** @var resource */
40
    private $socket;
41
42
    /** @var string */
43
    private $uri;
44
45
    /**
46
     * @param string $host
47
     * @param int $port
48
     * @param array $options
49
     * @throws InvalidArgumentException
50
     */
51
    public function __construct($host, $port, array $options = array())
52
    {
53
        if (!is_string($host)) {
54
            throw new InvalidArgumentException("Parameter 'host' should be a string");
55
        }
56
57
        if (!is_numeric($port) || $port < 1 || $port > self::MAX16BIT) {
58
            throw new InvalidArgumentException("Parameter 'port' should be between 1 and ".self::MAX16BIT);
59
        }
60
61
        $this->uri = "ssl://{$host}:{$port}";
62
63
        $this->mergeOptions($options);
64
        $this->validateOptions();
65
66
        if ($this->getOption('autoconnect', false)) {
67
            $this->connect();
68
        }
69
    }
70
71
    public function __destruct()
72
    {
73
        if ($this->isConnected() && !$this->getOption('persistent', false)) {
74
            $this->disconnect();
75
        }
76
    }
77
78
    /**
79
     * @param int $code
80
     * @param string $message
81
     * @param string $file
82
     * @param int $line
83
     * @throws \ErrorException
84
     */
85
    public function convertErrorToException($code, $message, $file, $line)
86
    {
87
        throw new \ErrorException($message, $code, 1, $file, $line);
88
    }
89
90
    /**
91
     * set options
92
     *
93
     * @param array $options
94
     * @throws \InvalidArgumentException
95
     */
96
    public function setOptions(array $options)
97
    {
98
        $this->options = array();
99
        $this->mergeOptions($options);
100
    }
101
102
    /**
103
     * @return bool
104
     */
105
    public function isConnected()
106
    {
107
        return is_resource($this->socket);
108
    }
109
110
    /**
111
     * @throws Exception
112
     */
113
    public function connect()
114
    {
115
        if ($this->isConnected()) {
116
            throw new Exception(sprintf("Already connected to '%s'", $this->uri));
117
        }
118
119
        $connectOptions = \STREAM_CLIENT_CONNECT;
120
        if ($this->getOption('persistent', false)) {
121
            $connectOptions |= \STREAM_CLIENT_PERSISTENT;
122
        }
123
124
        $contextOptions = array(
125
            'ssl' => array(
126
                'allow_self_signed'   => $this->getOption('ssl_allow_self_signed', false),
127
                'verify_peer'         => $this->getOption('ssl_verify_peer', true),
128
                'verify_peer_name'    => $this->getOption('ssl_verify_peer_name', true),
129
                'cafile'              => $this->getOption('ssl_cafile'),
130
                'peer_name'           => $this->getOption('ssl_peer_name'),
131
                'ciphers'             => $this->getOption('ssl_tls_only') ? 'HIGH:!SSLv2:!SSLv3' : 'DEFAULT',
132
                'disable_compression' => true,
133
            )
134
        );
135
136
        set_error_handler(array($this, 'convertErrorToException'));
137
138
        try {
139
            $socket = stream_socket_client(
140
                $this->uri,
141
                $errorCode,
142
                $errorMessage,
143
                $this->getOption('connection_timeout', self::CONNECTION_TIMEOUT),
144
                $connectOptions,
145
                stream_context_create($contextOptions)
146
            );
147
148
            if (!$socket) {
149
                if (!$errorMessage) {
150
                    $errorMessage = error_get_last();
151
                    $errorMessage = $errorMessage['message'];
152
                }
153
154
                throw new Exception(
155
                    sprintf("Can not connect to '%s': %s", $this->uri, $errorMessage)
156
                );
157
            }
158
159
            // set read / write timeout.
160
            stream_set_timeout($socket, $this->getOption('socket_timeout', self::SOCKET_TIMEOUT));
161
162
            $this->socket = $socket;
163
164
            restore_error_handler();
165
        } catch (\ErrorException $ex) {
166
            restore_error_handler();
167
168
            throw new Exception(
169
                sprintf("Can not connect to '%s': %s", $this->uri, $ex->getMessage()),
170
                0,
171
                $ex
172
            );
173
        }
174
    }
175
176
    /**
177
     * @throws Exception
178
     */
179
    public function disconnect()
180
    {
181
        if ($this->isConnected()) {
182
            set_error_handler(array($this, 'convertErrorToException'));
183
184
            try {
185
                fclose($this->socket);
186
                restore_error_handler();
187
            } catch (\ErrorException $ex) {
188
                restore_error_handler();
189
190
                throw new Exception(
191
                    sprintf("Can not disconnect from '%s': %s", $this->uri, $ex->getMessage()),
192
                    0,
193
                    $ex
194
                );
195
            }
196
        }
197
    }
198
199
    /**
200
     * recreate a connection.
201
     *
202
     * @throws Exception
203
     */
204
    public function reconnect()
205
    {
206
        $this->disconnect();
207
        $this->connect();
208
    }
209
210
    /**
211
     * @param int $length
212
     * @return string
213
     * @throws Exception
214
     */
215
    public function read($length)
216
    {
217
        if (!$this->isConnected()) {
218
            throw new Exception(sprintf("Does not connected to '%s'", $this->uri));
219
        }
220
221
        set_error_handler(array($this, 'convertErrorToException'));
222
223
        try {
224
            $data = fread($this->socket, $length);
225
            restore_error_handler();
226
            return $data;
227
        } catch (\ErrorException $ex) {
228
            restore_error_handler();
229
230
            throw new Exception(
231
                sprintf("Can not read from socket '%s': %s", $this->uri, $ex->getMessage()),
232
                0,
233
                $ex
234
            );
235
        }
236
    }
237
238
    /**
239
     * @param mixed $buffer
240
     * @return int
241
     * @throws Exception
242
     */
243
    public function write($buffer)
244
    {
245
        if (!$this->isConnected()) {
246
            throw new Exception(sprintf("Does not connected to '%s'", $this->uri));
247
        }
248
249
        set_error_handler(array($this, 'convertErrorToException'));
250
251
        try {
252
            $result = fwrite($this->socket, $buffer);
253
            restore_error_handler();
254
255
            if ($result === false) {
256
                // could not write messages to the socket.
257
                // e.g) Resource temporarily unavailable
258
                throw new Exception(
259
                    sprintf("Can not write to socket '%s': %s", $this->uri, 'unknown error')
260
                );
261
            } elseif ($result === '') {
262
                // sometimes fwrite returns null string.
263
                // probably connection aborted.
264
                throw new Exception(
265
                    sprintf("Can not write to socket '%s': %s", $this->uri, 'Connection aborted')
266
                );
267
            } elseif ($result === 0) {
268
                $meta = stream_get_meta_data($this->socket);
269
                if ($meta["timed_out"] === true) {
270
                    // todo: #3 reconnect & retry
271
                    throw new Exception(
272
                        sprintf("Can not write to socket '%s': %s", $this->uri, 'Connection timed out')
273
                    );
274
                } elseif ($meta["eof"] === true) {
275
                    throw new Exception(
276
                        sprintf("Can not write to socket '%s': %s", $this->uri, 'Connection aborted')
277
                    );
278
                } else {
279
                    throw new Exception(
280
                        sprintf("Can not write to socket '%s': %s",
281
                            $this->uri,
282
                            'unexpected flow detected. this is a bug. please report this: '.json_encode($meta)
283
                        )
284
                    );
285
                }
286
            }
287
288
            return $result;
289
        } catch (\ErrorException $ex) {
290
            restore_error_handler();
291
292
            throw new Exception(
293
                sprintf("Can not write to socket '%s': %s", $this->uri, $ex->getMessage()),
294
                0,
295
                $ex
296
            );
297
        }
298
    }
299
300
    /**
301
     * merge options
302
     *
303
     * @param array $options
304
     * @throws InvalidArgumentException
305
     */
306
    private function mergeOptions(array $options)
307
    {
308
        foreach ($options as $key => $value) {
309
            if (!in_array($key, self::$acceptableOptions)) {
310
                throw new InvalidArgumentException("Option '{$key}' does not supported");
311
            }
312
            $this->options[$key] = $value;
313
        }
314
    }
315
316
    /**
317
     * @throws InvalidArgumentException
318
     */
319
    private function validateOptions()
320
    {
321
        if (!isset($this->options['ssl_cafile'])) {
322
            throw new InvalidArgumentException("Option 'ssl_cafile' required");
323
        }
324
325
        if (!file_exists($this->options['ssl_cafile'])
326
            || !is_file($this->options['ssl_cafile'])
327
            || !is_readable($this->options['ssl_cafile'])
328
        ) {
329
            throw new InvalidArgumentException(
330
                "Option 'ssl_cafile' contains invalid path '{$this->options['ssl_cafile']}'"
331
            );
332
        }
333
334
        $certInfo = openssl_x509_parse(file_get_contents($this->options['ssl_cafile']));
335
        if (!is_array($certInfo)) {
336
            throw new InvalidArgumentException(
337
                "Option 'ssl_cafile' contains path '{$this->options['ssl_cafile']}' to invalid certificate"
338
            );
339
        }
340
341
        if (!isset($this->options['ssl_peer_name'])) {
342
            $this->options['ssl_peer_name'] = $certInfo['subject']['CN'];
343
        }
344
    }
345
346
    /**
347
     * get specified option's value
348
     *
349
     * @param string $key
350
     * @param mixed $default
351
     * @return mixed
352
     */
353
    private function getOption($key, $default = null)
354
    {
355
        return array_key_exists($key, $this->options)
356
            ? $this->options[$key]
357
            : $default;
358
    }
359
}
360