diff --git a/src/Server/Socket/TlsProxy.php b/src/Server/Socket/TlsProxy.php new file mode 100644 index 0000000..fd8e6e6 --- /dev/null +++ b/src/Server/Socket/TlsProxy.php @@ -0,0 +1,95 @@ + $sslContextOptions + */ + public function __construct( + private readonly SocketProxyInterface $inner, + private readonly string $certFile, + private readonly string $keyFile, + private readonly array $sslContextOptions = [], + ) { + } + + public function createSocket(int $port, ProtocolType $protocol): Socket + { + return $this->inner->createSocket($port, $protocol); + } + + /** + * @throws SocketCreationException + */ + public function accept(Socket $socket): Connection + { + $connection = $this->inner->accept($socket); + + $reflection = new \ReflectionProperty(Connection::class, 'resource'); + /** @var \Socket $socketResource */ + $socketResource = $reflection->getValue($connection); + + $stream = @\socket_export_stream($socketResource); + + if ($stream === false) { + $error = \error_get_last(); + + throw new SocketCreationException( + \is_array($error) ? $error['message'] : 'Failed to export socket to stream', + ); + } + + \stream_set_timeout($stream, 30); + + if (!\stream_context_set_option($stream, 'ssl', 'local_cert', $this->certFile)) { + \fclose($stream); + + throw new SocketCreationException('Failed to set SSL context option: local_cert'); + } + + if (!\stream_context_set_option($stream, 'ssl', 'local_pk', $this->keyFile)) { + \fclose($stream); + + throw new SocketCreationException('Failed to set SSL context option: local_pk'); + } + + foreach ($this->sslContextOptions as $option => $value) { + if (!\stream_context_set_option($stream, 'ssl', $option, $value)) { + \fclose($stream); + + throw new SocketCreationException( + \sprintf('Failed to set SSL context option: %s', $option), + ); + } + } + + $result = @\stream_socket_enable_crypto($stream, true, \STREAM_CRYPTO_METHOD_TLS_SERVER); + + if ($result === false) { + $error = \error_get_last(); + \fclose($stream); + + throw new SocketCreationException( + \sprintf( + 'TLS handshake failed: %s', + \is_array($error) ? $error['message'] : 'unknown error', + ), + ); + } + + return $connection; + } + + public function isSupported(): bool + { + return \function_exists('stream_socket_enable_crypto') + && \function_exists('socket_export_stream'); + } +} diff --git a/tests/Server/Socket/TlsProxyTest.php b/tests/Server/Socket/TlsProxyTest.php new file mode 100644 index 0000000..078fcbe --- /dev/null +++ b/tests/Server/Socket/TlsProxyTest.php @@ -0,0 +1,91 @@ +createMock(SocketProxyInterface::class); + $proxy = new TlsProxy($inner, '/tmp/cert.pem', '/tmp/key.pem'); + + $this->assertInstanceOf(SocketProxyInterface::class, $proxy); + } + + public function testCreateSocketDelegatesToInner(): void + { + $expectedSocket = $this->createMock(Socket::class); + + $inner = $this->createMock(SocketProxyInterface::class); + $inner->expects($this->once()) + ->method('createSocket') + ->with(8080, ProtocolType::TCP) + ->willReturn($expectedSocket); + + $proxy = new TlsProxy($inner, '/tmp/cert.pem', '/tmp/key.pem'); + + $result = $proxy->createSocket(8080, ProtocolType::TCP); + + $this->assertSame($expectedSocket, $result); + } + + public function testAcceptThrowsOnTlsHandshakeFailure(): void + { + $server = @\socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($server); + + \socket_bind($server, '127.0.0.1', 0); + \socket_listen($server); + + \socket_getsockname($server, $address, $port); + + /** @var string $address */ + /** @var int $port */ + $client = @\socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($client); + \socket_connect($client, $address, $port); + + $clientSocket = @\socket_accept($server); + $this->assertNotFalse($clientSocket); + + $connection = new Connection($clientSocket); + + $inner = $this->createMock(SocketProxyInterface::class); + $inner->expects($this->once()) + ->method('accept') + ->willReturn($connection); + + $mockSocket = $this->createMock(Socket::class); + + $proxy = new TlsProxy($inner, '/tmp/nonexistent-cert.pem', '/tmp/nonexistent-key.pem'); + + $this->expectException(SocketCreationException::class); + $this->expectExceptionMessage('TLS handshake failed'); + + $proxy->accept($mockSocket); + + \socket_close($client); + \socket_close($server); + } + + public function testIsSupported(): void + { + $inner = $this->createMock(SocketProxyInterface::class); + $proxy = new TlsProxy($inner, '/tmp/cert.pem', '/tmp/key.pem'); + + $expected = \function_exists('stream_socket_enable_crypto') + && \function_exists('socket_export_stream'); + + $this->assertSame($expected, $proxy->isSupported()); + } +}