diff --git a/composer.json b/composer.json index 6127ff32..ac23c281 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "ext-mbstring": "*", "ext-sodium": "*", "ext-intl": "*", + "ext-openssl": "*", "revolt/event-loop": "^1.0.6" }, "require-dev": { diff --git a/docs/README.md b/docs/README.md index 9d6523cf..e290db34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,6 +54,7 @@ - [Psl\Str\Byte](./component/str-byte.md) - [Psl\Str\Grapheme](./component/str-grapheme.md) - [Psl\TCP](./component/tcp.md) +- [Psl\TCP\TLS](./component/tcp-tls.md) - [Psl\Trait](./component/trait.md) - [Psl\Unix](./component/unix.md) - [Psl\Vec](./component/vec.md) diff --git a/docs/component/network.md b/docs/component/network.md index 60089787..5efd9f9e 100644 --- a/docs/component/network.md +++ b/docs/component/network.md @@ -19,7 +19,7 @@ #### `Classes` -- [Address](./../../src/Psl/Network/Address.php#L10) +- [Address](./../../src/Psl/Network/Address.php#L12) - [SocketOptions](./../../src/Psl/Network/SocketOptions.php#L14) #### `Enums` diff --git a/docs/component/tcp-tls.md b/docs/component/tcp-tls.md new file mode 100644 index 00000000..e6b8c923 --- /dev/null +++ b/docs/component/tcp-tls.md @@ -0,0 +1,21 @@ + + +[*index](./../README.md) + +--- + +### `Psl\TCP\TLS` Component + +#### `Classes` + +- [Certificate](./../../src/Psl/TCP/TLS/Certificate.php#L16) +- [ClientOptions](./../../src/Psl/TCP/TLS/ClientOptions.php#L34) +- [HashingAlgorithm](./../../src/Psl/TCP/TLS/HashingAlgorithm.php#L15) +- [SecurityLevel](./../../src/Psl/TCP/TLS/SecurityLevel.php#L18) +- [Version](./../../src/Psl/TCP/TLS/Version.php#L10) + + diff --git a/docs/component/tcp.md b/docs/component/tcp.md index 11694b10..233397c8 100644 --- a/docs/component/tcp.md +++ b/docs/component/tcp.md @@ -12,12 +12,12 @@ #### `Functions` -- [connect](./../../src/Psl/TCP/connect.php#L19) +- [connect](./../../src/Psl/TCP/connect.php#L21) #### `Classes` -- [ConnectOptions](./../../src/Psl/TCP/ConnectOptions.php#L14) -- [Server](./../../src/Psl/TCP/Server.php#L11) +- [ClientOptions](./../../src/Psl/TCP/ClientOptions.php#L14) +- [Server](./../../src/Psl/TCP/Server.php#L12) - [ServerOptions](./../../src/Psl/TCP/ServerOptions.php#L15) diff --git a/docs/documenter.php b/docs/documenter.php index 2e06e501..32b8af2c 100644 --- a/docs/documenter.php +++ b/docs/documenter.php @@ -223,6 +223,7 @@ function get_all_components(): array 'Psl\\Str\\Byte', 'Psl\\Str\\Grapheme', 'Psl\\TCP', + 'Psl\\TCP\\TLS', 'Psl\\Trait', 'Psl\\Unix', 'Psl\\Locale', diff --git a/examples/tcp/basic-http-client.php b/examples/tcp/basic-http-client.php index cb877141..c61c09c5 100644 --- a/examples/tcp/basic-http-client.php +++ b/examples/tcp/basic-http-client.php @@ -6,24 +6,43 @@ use Psl\Async; use Psl\IO; +use Psl\Str; use Psl\TCP; require __DIR__ . '/../../vendor/autoload.php'; -function fetch(string $host, string $path): string +Async\main(static function(): void { + [$headers, $content] = fetch('https://php-standard-library.github.io'); + + $output = IO\error_handle() ?? IO\output_handle(); + + $output->writeAll($headers); + $output->writeAll("\n"); + $output->writeAll($content); +}); + +function fetch(string $url): array { - $client = TCP\connect($host, 80); - $client->writeAll("GET {$path} HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"); - $response = $client->readAll(); - $client->close(); + $parsed_url = parse_url($url); + $host = $parsed_url['host']; + $port = $parsed_url['port'] ?? ($parsed_url['scheme'] === 'https' ? 443 : 80); + $path = $parsed_url['path'] ?? '/'; - return $response; -} + $options = TCP\ClientOptions::create(); + if ($parsed_url['scheme'] === 'https') { + $options = $options->withTlsClientOptions( + TCP\TLS\ClientOptions::default()->withPeerName($host), + ); + } -Async\main(static function (): int { - $response = fetch('example.com', '/'); + $client = TCP\connect($host, $port, $options); + $client->writeAll("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"); - IO\write_error_line($response); + $response = $client->readAll(); - return 0; -}); + $position = Str\search($response, "\r\n\r\n"); + $headers = Str\slice($response, 0, $position); + $content = Str\slice($response, $position + 4); + + return [$headers, $content]; +} diff --git a/examples/tcp/basic-http-server.php b/examples/tcp/basic-http-server.php index 95bdeaa1..6a5d6331 100644 --- a/examples/tcp/basic-http-server.php +++ b/examples/tcp/basic-http-server.php @@ -5,46 +5,72 @@ namespace Psl\Example\TCP; use Psl\Async; +use Psl\File; use Psl\Html; use Psl\IO; -use Psl\Iter; use Psl\Network; use Psl\Str; use Psl\TCP; require __DIR__ . '/../../vendor/autoload.php'; -const RESPONSE_FORMAT = << - - - PHP Standard Library - TCP server - - -

Hello, World!

-
%s
- - -HTML; - -$server = TCP\Server::create('localhost', 3030, TCP\ServerOptions::create(idle_connections: 1024)); +/** + * Note: This example is purely for demonstration purposes, and should never be used in a production environment. + */ +$server = TCP\Server::create('localhost', 3030, TCP\ServerOptions::default() + ->withTlsServerOptions( + TCP\TLS\ServerOptions::default() + ->withMinimumVersion(TCP\TLS\Version::Tls12) + ->withAllowSelfSignedCertificates() + ->withPeerVerification(false) + ->withSecurityLevel(TCP\TLS\SecurityLevel::Level2) + ->withDefaultCertificate(TCP\TLS\Certificate::create( + certificate_file: __DIR__ . '/fixtures/localhost.crt', + key_file: __DIR__ . '/fixtures/localhost.key', + )) + ) +); Async\Scheduler::onSignal(SIGINT, $server->close(...)); -IO\write_error_line('Server is listening on http://localhost:3030'); +IO\write_error_line('Server is listening on https://localhost:3030'); IO\write_error_line('Click Ctrl+C to stop the server.'); -Iter\apply($server->incoming(), static function (Network\StreamSocketInterface $connection): void { - Async\run(static function() use($connection): void { - $request = $connection->read(); - - $connection->writeAll("HTTP/1.1 200 OK\nConnection: close\nContent-Type: text/html; charset=utf-8\n\n"); - $connection->writeAll(Str\format(RESPONSE_FORMAT, Html\encode_special_characters($request))); - $connection->close(); - })->catch( - static fn(IO\Exception\ExceptionInterface $e) => IO\write_error_line('Error: %s.', $e->getMessage()) - )->ignore(); -}); +foreach ($server->incoming() as $connection) { + Async\Scheduler::defer( + static fn() => handle($connection) + ); +} IO\write_error_line(''); IO\write_error_line('Goodbye 👋'); + +function handle(Network\SocketInterface $connection): void +{ + $peer = $connection->getPeerAddress(); + + IO\write_error_line('[SRV]: received a connection from peer "%s".', $peer); + + try { + do { + $request = $connection->read(); + + $template = File\read(__DIR__ . '/templates/index.html'); + $content = Str\format($template, Html\encode_special_characters($request)); + $length = Str\Byte\length($content); + + $connection->writeAll("HTTP/1.1 200 OK\nConnection: keep-alive\nContent-Type: text/html; charset=utf-8\nContent-Length: $length\n\n"); + $connection->writeAll($content); + } while(!$connection->reachedEndOfDataSource()); + + IO\write_error_line('[SRV]: connection dropped by peer "%s".', $peer); + } catch (IO\Exception\ExceptionInterface $e) { + if (!$connection->reachedEndOfDataSource()) { + // If we reached end of data source ( EOF ) and gotten an error, that means that connect was most likely dropped + // by peer while we are performing a write operation, ignore it. + // + // otherwise, log the error: + IO\write_error_line('[SRV]: error "%s" at %s:%d"', $e->getMessage(), $e->getFile(), $e->getLine()); + } + } +} diff --git a/examples/tcp/fixtures/localhost.crt b/examples/tcp/fixtures/localhost.crt new file mode 100644 index 00000000..b5c35f2d --- /dev/null +++ b/examples/tcp/fixtures/localhost.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDyTCCArGgAwIBAgITCuhaYvn3KHwsNke3HvooYbAq+TANBgkqhkiG9w0BAQsF +ADBvMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDVNhbiBGcmFu +Y2lzY28xEjAQBgNVBAoMCU15Q29tcGFueTETMBEGA1UECwwKTXlEaXZpc2lvbjES +MBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0MDMzMDA4NTg1M1oXDTI1MDMzMDA4NTg1 +M1owbzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1TYW4gRnJh +bmNpc2NvMRIwEAYDVQQKDAlNeUNvbXBhbnkxEzARBgNVBAsMCk15RGl2aXNpb24x +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKKeShr27nJtWytAfo/6yr4JpJofE24tzKgeisfDS/YUkj/qqSNyLRO7J82c +dKQTcNajblzTv5RCSuM63uWwXvydMWNBe6YNES2m+CldgHOoztZI8K6r4DFoxuAI +8YqqNqexMP+FBvxfOfabqZjwnwnVRobwqW1jUSiYSbkgLhOMVrr+0FE+BXF0Xe/d +eXhy0sswhxZfQhZE09cS+D2N7XiP/iEnh50Ga6mtXpAKFs2OLvQwF1Mg+fGDufrz +TZhb4TrnmAPF4W3bSZqZWz2ERmb6u1Re8oUFGl6OgRkgYSaEaTMA65rLxeJwpebn +ZK4LjxxG9SXmt2PLxvZMbQs16fkCAwEAAaNeMFwwOwYDVR0RBDQwMoIJbG9jYWxo +b3N0gg0qLmV4YW1wbGUuY29thwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMB0GA1Ud +DgQWBBTQ3g3NGILNZckYdeFp/Ek6IGm1iDANBgkqhkiG9w0BAQsFAAOCAQEAkeLK ++pCBiC0P0y4vXbpeQ29JOkDda5jlzkXYgGqxPjIVVHy69zZWQEzmTQa7ItG0RIj+ +/YJkF3eZGgeGs/dLn8oWDiO3WiAGkHFWuGHXihC6a4XH5/dWwCLyZq675HRv0F2J +I/glEq9MGJRvqhLqS8r/8HH03QP87UNMTLVmWuZZ6ugxtQIjqt8v1MzDqB/obpPV +3wkwzzZnqwsxDbr4jLKL/SaPZ9NJyTe5C8gxwPAUawBwmlErem2SVlfjoRSyx/zs +uEqQLW2kpIEEcpMY6g6gR/RJr18IPnBmf1R5DFg/S8/6sbPIRXgcY1AULQ3aiPSd +75hLwcPxLVtyGy6t7A== +-----END CERTIFICATE----- diff --git a/examples/tcp/fixtures/localhost.key b/examples/tcp/fixtures/localhost.key new file mode 100644 index 00000000..91415a0d --- /dev/null +++ b/examples/tcp/fixtures/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCinkoa9u5ybVsr +QH6P+sq+CaSaHxNuLcyoHorHw0v2FJI/6qkjci0TuyfNnHSkE3DWo25c07+UQkrj +Ot7lsF78nTFjQXumDREtpvgpXYBzqM7WSPCuq+AxaMbgCPGKqjansTD/hQb8Xzn2 +m6mY8J8J1UaG8KltY1EomEm5IC4TjFa6/tBRPgVxdF3v3Xl4ctLLMIcWX0IWRNPX +Evg9je14j/4hJ4edBmuprV6QChbNji70MBdTIPnxg7n6802YW+E655gDxeFt20ma +mVs9hEZm+rtUXvKFBRpejoEZIGEmhGkzAOuay8XicKXm52SuC48cRvUl5rdjy8b2 +TG0LNen5AgMBAAECgf9PPlNeUHZhzGhg60zBXLTvZkOP1xTg2/Ce/EMklUau49dg +zjkdzMWql8kNqPAuBEs4TOu60HTLCoLzt/xmcUvYTcGDXKWkhTmZxYOopKeztM8W +HPUsKRVW/nfrNHB/4fJARVhbK7f7w2u7gJ9kp9zYLdXwa9YkOAGUhqFmVQge/b1o +9INIjW1RpcpFaKoFQIpqJzrWYczAXy9adGcHA04Di4WISyyKAib1Z1PxFhhC009J +9JhPdLEKprWJgVTfE/tlMDcOGY4niZYoKgPGCLsqvim6uB03P273lag/1CG+kZC3 +oa3TLiSygCFZbdeG41zxBxvEk8mA39/snc21Nr0CgYEAze/ZBTyj8wM4ZGFXgEvl +jmBAHNFi1LD04/D7TcpDjY8YXiC2ULzw8mEOPVabR/Wov7cBCQNfNo35AI/MH6L8 +ucu6gpI8ZAdZVtBN4T+wVEHdXsdsGLJq2wEgTsg580LYsXO7NN/gnmh0QL5d48cc +jjpOxmP+JIR0h9jbyARl0H0CgYEAyiaUQJZDc6gNlCTx13WSyPMBj71RTtpxagzL +ueC9H7qTxMfgHhSNIRxjB+Pu/VQ2WPG847KqrE7UmTl2dH8MWDn8HrEE5xM1A1I+ +z8tzfMMuIJVmFwGSPuyHFGNl51Oct6xmP0jNdrve/lknH4R5O7ecwu2lxHhnZoST +k25olC0CgYAM4ihtj3GiTl1EymIzAIyH77WTF/Za4AcyC21tXG4FeSJJITrGqktY +noHJjJWCVvgLpmNGMRPP0en2Awj+IbA132z3pjZo+5y3NajpopZhbw1uVIOKt/6/ +XL6srxIRCemMkHTxxd/DiT1cn4w4J8i9jSBIgRDxL+gqZ4K4bK4B8QKBgQCWto6f +XKhraSa+hZDdH2ZRdYN7hB1Dme8mruWQ7rJyHmufMZmxM4dI4V4f+tsqegeO5qP6 +azF+B8PPfR0Im9Q7TvfedgH+ub4zfLUhvUCcCvSwDFKx4lUDntrS44yNHDRiaCFP +G1s8I7OMlDFr+Rtd33X7iqylP1NwBnX0XEOR/QKBgQDF/dMnvPO1sYxCczRNyf+I +HX4hdaxBbOOcuYwaRNBrWtAFf2QD5mbR+rr+s3Maka4EcBwbIdNhc8R4CahgIfRY +j5nvrT03RUA3PqgJ/fLhQ5A55A7Z3byLXvJ4/kN41yNSVgiyDHlRtrIa9+k0AtMi +JGklocjlCYwXv/YH2ltHzA== +-----END PRIVATE KEY----- diff --git a/examples/tcp/fixtures/openssl.cnf b/examples/tcp/fixtures/openssl.cnf new file mode 100644 index 00000000..4d2ec178 --- /dev/null +++ b/examples/tcp/fixtures/openssl.cnf @@ -0,0 +1,26 @@ +[ req ] +default_bits = 2048 +distinguished_name = req_distinguished_name +req_extensions = req_ext +x509_extensions = v3_req +prompt = no + +[ req_distinguished_name ] +C = US +ST = CA +L = San Francisco +O = MyCompany +OU = MyDivision +CN = localhost + +[ req_ext ] +subjectAltName = @alt_names + +[ v3_req ] +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = localhost +DNS.2 = *.example.com +IP.1 = 127.0.0.1 +IP.2 = ::1 diff --git a/examples/tcp/templates/index.html b/examples/tcp/templates/index.html new file mode 100644 index 00000000..4558e50d --- /dev/null +++ b/examples/tcp/templates/index.html @@ -0,0 +1,11 @@ + + + + PHP Standard Library - TCP server + + +

Hello, World!

+
%s
+ + + diff --git a/src/Psl/IO/Internal/ResourceHandle.php b/src/Psl/IO/Internal/ResourceHandle.php index a42789e3..7569326e 100644 --- a/src/Psl/IO/Internal/ResourceHandle.php +++ b/src/Psl/IO/Internal/ResourceHandle.php @@ -14,7 +14,6 @@ use Revolt\EventLoop\Suspension; use function error_get_last; -use function fclose; use function feof; use function fread; use function fseek; @@ -360,13 +359,8 @@ public function close(): void if ($this->close && is_resource($this->stream)) { $stream = $this->stream; $this->stream = null; - $result = @fclose($stream); - if ($result === false) { - /** @var array{message: string} $error */ - $error = error_get_last(); - throw new Exception\RuntimeException($error['message'] ?? 'unknown error.'); - } + namespace\close_resource($stream); } else { // Stream could be set to a non-null closed-resource, // if manually closed using `fclose($handle->getStream)`. diff --git a/src/Psl/IO/Internal/close_resource.php b/src/Psl/IO/Internal/close_resource.php new file mode 100644 index 00000000..1f933045 --- /dev/null +++ b/src/Psl/IO/Internal/close_resource.php @@ -0,0 +1,32 @@ + 'Psl/Filesystem/get_modification_time.php', 'Psl\\Filesystem\\get_inode' => 'Psl/Filesystem/get_inode.php', 'Psl\\IO\\Internal\\open_resource' => 'Psl/IO/Internal/open_resource.php', + 'Psl\\IO\\Internal\\close_resource' => 'Psl/IO/Internal/close_resource.php', 'Psl\\IO\\input_handle' => 'Psl/IO/input_handle.php', 'Psl\\IO\\output_handle' => 'Psl/IO/output_handle.php', 'Psl\\IO\\error_handle' => 'Psl/IO/error_handle.php', @@ -554,6 +555,9 @@ final class Loader 'Psl\\DateTime\\Internal\\create_intl_date_formatter' => 'Psl/DateTime/Internal/create_intl_date_formatter.php', 'Psl\\DateTime\\Internal\\parse' => 'Psl/DateTime/Internal/parse.php', 'Psl\\DateTime\\Internal\\format_rfc3339' => 'Psl/DateTime/Internal/format_rfc3339.php', + 'Psl\\TCP\\TLS\\Internal\\establish_tls_connection' => 'Psl/TCP/TLS/Internal/establish_tls_connection.php', + 'Psl\\TCP\\TLS\\Internal\\server_context' => 'Psl/TCP/TLS/Internal/server_context.php', + 'Psl\\TCP\\TLS\\Internal\\client_context' => 'Psl/TCP/TLS/Internal/client_context.php', ]; public const INTERFACES = [ @@ -649,6 +653,7 @@ final class Loader 'Psl\\DateTime\\Exception\\ExceptionInterface' => 'Psl/DateTime/Exception/ExceptionInterface.php', 'Psl\\DateTime\\TemporalInterface' => 'Psl/DateTime/TemporalInterface.php', 'Psl\\DateTime\\DateTimeInterface' => 'Psl/DateTime/DateTimeInterface.php', + 'Psl\\TCP\\TLS\\Exception\\ExceptionInterface' => 'Psl/TCP/TLS/Exception/ExceptionInterface.php', ]; public const TRAITS = [ @@ -816,9 +821,10 @@ final class Loader 'Psl\\Network\\SocketOptions' => 'Psl/Network/SocketOptions.php', 'Psl\\Network\\Internal\\AbstractStreamServer' => 'Psl/Network/Internal/AbstractStreamServer.php', 'Psl\\Network\\Internal\\Socket' => 'Psl/Network/Internal/Socket.php', - 'Psl\\TCP\\ConnectOptions' => 'Psl/TCP/ConnectOptions.php', + 'Psl\\TCP\\ClientOptions' => 'Psl/TCP/ConnectOptions.php', 'Psl\\TCP\\ServerOptions' => 'Psl/TCP/ServerOptions.php', 'Psl\\TCP\\Server' => 'Psl/TCP/Server.php', + 'Psl\\TCP\\Internal\\TCPSocket' => 'Psl/TCP/Internal/TCPSocket.php', 'Psl\\Unix\\Server' => 'Psl/Unix/Server.php', 'Psl\\Channel\\Internal\\BoundedChannelState' => 'Psl/Channel/Internal/BoundedChannelState.php', 'Psl\\Channel\\Internal\\BoundedSender' => 'Psl/Channel/Internal/BoundedSender.php', @@ -862,6 +868,12 @@ final class Loader 'Psl\\DateTime\\DateTime' => 'Psl/DateTime/DateTime.php', 'Psl\\DateTime\\Duration' => 'Psl/DateTime/Interval.php', 'Psl\\DateTime\\Timestamp' => 'Psl/DateTime/Timestamp.php', + 'Psl\\TCP\\TLS\\Exception\\NegotiationException' => 'Psl/TCP/TLS/Exception/NegotiationException.php', + 'Psl\\TCP\\TLS\\Certificate' => 'Psl/TCP/TLS/Certificate.php', + 'Psl\\TCP\\TLS\\ClientOptions' => 'Psl/TCP/TLS/ConnectOptions.php', + 'Psl\\TCP\\TLS\\HashingAlgorithm' => 'Psl/TCP/TLS/HashingAlgorithm.php', + 'Psl\\TCP\\TLS\\SecurityLevel' => 'Psl/TCP/TLS/SecurityLevel.php', + 'Psl\\TCP\\TLS\\Version' => 'Psl/TCP/TLS/Version.php', ]; public const ENUMS = [ diff --git a/src/Psl/Network/Address.php b/src/Psl/Network/Address.php index 8a2a59f3..ef06ffff 100644 --- a/src/Psl/Network/Address.php +++ b/src/Psl/Network/Address.php @@ -4,10 +4,12 @@ namespace Psl\Network; +use Stringable; + /** * @psalm-immutable */ -final readonly class Address +final readonly class Address implements Stringable { public const DEFAULT_HOST = '127.0.0.1'; public const DEFAULT_PORT = 0; @@ -85,4 +87,12 @@ public function toString(): string return "$address:$this->port"; } + + /** + * @psalm-mutation-free + */ + public function __toString(): string + { + return $this->toString(); + } } diff --git a/src/Psl/Network/Exception/ExceptionInterface.php b/src/Psl/Network/Exception/ExceptionInterface.php index 89df5dce..9456dd44 100644 --- a/src/Psl/Network/Exception/ExceptionInterface.php +++ b/src/Psl/Network/Exception/ExceptionInterface.php @@ -4,8 +4,8 @@ namespace Psl\Network\Exception; -use Psl; +use Psl\IO\Exception; -interface ExceptionInterface extends Psl\Exception\ExceptionInterface +interface ExceptionInterface extends Exception\ExceptionInterface { } diff --git a/src/Psl/Network/Internal/AbstractStreamServer.php b/src/Psl/Network/Internal/AbstractStreamServer.php index a8d4e809..89afaf65 100644 --- a/src/Psl/Network/Internal/AbstractStreamServer.php +++ b/src/Psl/Network/Internal/AbstractStreamServer.php @@ -20,7 +20,7 @@ */ abstract class AbstractStreamServer implements StreamServerInterface { - private const DEFAULT_IDLE_CONNECTIONS = 256; + protected const DEFAULT_IDLE_CONNECTIONS = 256; /** * @var closed-resource|resource|null $impl @@ -33,7 +33,7 @@ abstract class AbstractStreamServer implements StreamServerInterface private string $watcher; /** - * @var Channel\ReceiverInterface + * @var Channel\ReceiverInterface */ private Channel\ReceiverInterface $receiver; @@ -45,14 +45,14 @@ protected function __construct(mixed $impl, int $idleConnections = self::DEFAULT { $this->impl = $impl; /** - * @var Channel\SenderInterface $sender + * @var Channel\SenderInterface $sender */ [$this->receiver, $sender] = Channel\bounded($idleConnections); $this->watcher = EventLoop::onReadable($impl, static function ($watcher, $resource) use ($sender): void { try { - $sock = @stream_socket_accept($resource, timeout: 0.0); - if ($sock !== false) { - $sender->send([true, new Socket($sock)]); + $stream = @stream_socket_accept($resource, timeout: 0.0); + if ($stream !== false) { + $sender->send([true, $stream]); return; } @@ -74,6 +74,28 @@ protected function __construct(mixed $impl, int $idleConnections = self::DEFAULT * {@inheritDoc} */ public function nextConnection(): Network\StreamSocketInterface + { + return new Socket($this->nextConnectionImpl()); + } + + /** + * {@inheritDoc} + */ + public function incoming(): Generator + { + /** @psalm-suppress InvalidIterator */ + foreach ($this->incomingImpl() as $stream) { + yield null => new Socket($stream); + } + } + + /** + * @throws Network\Exception\AlreadyStoppedException + * @throws Network\Exception\RuntimeException + * + * @return resource + */ + protected function nextConnectionImpl(): mixed { try { [$success, $result] = $this->receiver->receive(); @@ -82,7 +104,7 @@ public function nextConnection(): Network\StreamSocketInterface } if ($success) { - /** @var Socket $result */ + /** @var resource $result */ return $result; } @@ -91,15 +113,17 @@ public function nextConnection(): Network\StreamSocketInterface } /** - * {@inheritDoc} + * @throws Network\Exception\RuntimeException + * + * @return Generator */ - public function incoming(): Generator + protected function incomingImpl(): Generator { try { while (true) { [$success, $result] = $this->receiver->receive(); if ($success) { - /** @var Socket $result */ + /** @var resource $result */ yield null => $result; } else { /** @var Network\Exception\RuntimeException $result */ diff --git a/src/Psl/Network/Internal/socket_connect.php b/src/Psl/Network/Internal/socket_connect.php index 088e92bd..a8e02fc1 100644 --- a/src/Psl/Network/Internal/socket_connect.php +++ b/src/Psl/Network/Internal/socket_connect.php @@ -7,6 +7,7 @@ use Psl\DateTime\Duration; use Psl\Internal; use Psl\Network\Exception; +use Psl\Str; use Revolt\EventLoop; use function fclose; @@ -14,6 +15,7 @@ use function max; use function stream_context_create; use function stream_socket_client; +use function stream_socket_get_name; use const STREAM_CLIENT_ASYNC_CONNECT; use const STREAM_CLIENT_CONNECT; @@ -64,8 +66,19 @@ function socket_connect(string $uri, array $context = [], ?Duration $timeout = n }); try { - /** @var resource */ - return $suspension->suspend(); + /** @var resource $socket */ + $socket = $suspension->suspend(); + + if (stream_socket_get_name($socket, true) === false) { + fclose($socket); + + throw new Exception\RuntimeException(Str\format( + 'Connection to %s refused%s', + $uri, + )); + } + + return $socket; } finally { EventLoop::cancel($write_watcher); EventLoop::cancel($timeout_watcher); diff --git a/src/Psl/Network/ServerInterface.php b/src/Psl/Network/ServerInterface.php index 98a30fbd..85f207c3 100644 --- a/src/Psl/Network/ServerInterface.php +++ b/src/Psl/Network/ServerInterface.php @@ -27,7 +27,7 @@ public function nextConnection(): SocketInterface; * * @throws Exception\RuntimeException In case failed to accept incoming connection. * - * @return Generator + * @return Generator */ public function incoming(): Generator; diff --git a/src/Psl/Network/SocketOptions.php b/src/Psl/Network/SocketOptions.php index 1a59a6ca..b1e9dfb5 100644 --- a/src/Psl/Network/SocketOptions.php +++ b/src/Psl/Network/SocketOptions.php @@ -13,24 +13,21 @@ */ final readonly class SocketOptions implements DefaultInterface { - public bool $addressReuse; - public bool $portReuse; - public bool $broadcast; - /** * Initializes a new instance of SocketOptions with the specified settings. * - * @param bool $address_reuse Enables or disables the SO_REUSEADDR socket option. - * @param bool $port_reuse Enables or disables the SO_REUSEPORT socket option. + * @param bool $addressReuse Enables or disables the SO_REUSEADDR socket option. + * @param bool $portReuse Enables or disables the SO_REUSEPORT socket option. * @param bool $broadcast Enables or disables the SO_BROADCAST socket option. * * @psalm-mutation-free */ - public function __construct(bool $address_reuse, bool $port_reuse, bool $broadcast) - { - $this->addressReuse = $address_reuse; - $this->portReuse = $port_reuse; - $this->broadcast = $broadcast; + public function __construct( + public readonly bool $addressReuse, + public readonly bool $portReuse, + public readonly bool $broadcast, + public readonly int $backlog, + ) { } /** @@ -42,12 +39,13 @@ public function __construct(bool $address_reuse, bool $port_reuse, bool $broadca * @param bool $address_reuse Determines the SO_REUSEADDR socket option state. * @param bool $port_reuse Determines the SO_REUSEPORT socket option state. * @param bool $broadcast Determines the SO_BROADCAST socket option state. + * @param positive-int $backlog A maximum of backlog incoming connections will be queued for processing. * * @pure */ - public static function create(bool $address_reuse = false, bool $port_reuse = false, bool $broadcast = false): SocketOptions + public static function create(bool $address_reuse = false, bool $port_reuse = false, bool $broadcast = false, int $backlog = 128): SocketOptions { - return new self($address_reuse, $port_reuse, $broadcast); + return new self($address_reuse, $port_reuse, $broadcast, $backlog); } /** @@ -72,7 +70,7 @@ public static function default(): static */ public function withAddressReuse(bool $enabled = true): SocketOptions { - return new self($enabled, $this->portReuse, $this->broadcast); + return new self($enabled, $this->portReuse, $this->broadcast, $this->backlog); } /** @@ -84,7 +82,7 @@ public function withAddressReuse(bool $enabled = true): SocketOptions */ public function withPortReuse(bool $enabled = true): SocketOptions { - return new self($this->addressReuse, $enabled, $this->broadcast); + return new self($this->addressReuse, $enabled, $this->broadcast, $this->backlog); } /** @@ -96,6 +94,18 @@ public function withPortReuse(bool $enabled = true): SocketOptions */ public function withBroadcast(bool $enabled = true): SocketOptions { - return new self($this->addressReuse, $this->portReuse, $enabled); + return new self($this->addressReuse, $this->portReuse, $enabled, $this->backlog); + } + + /** + * Returns a new instance with the backlog option modified. + * + * @param positive-int $backlog A maximum of backlog incoming connections will be queued for processing. + * + * @mutation-free + */ + public function withBacklog(int $backlog): SocketOptions + { + return new self($this->addressReuse, $this->portReuse, $this->broadcast, $backlog); } } diff --git a/src/Psl/Network/StreamServerInterface.php b/src/Psl/Network/StreamServerInterface.php index d9d73c2d..0125c238 100644 --- a/src/Psl/Network/StreamServerInterface.php +++ b/src/Psl/Network/StreamServerInterface.php @@ -22,7 +22,7 @@ public function nextConnection(): StreamSocketInterface; /** * {@inheritDoc} * - * @return Generator + * @return Generator */ public function incoming(): Generator; } diff --git a/src/Psl/TCP/ClientOptions.php b/src/Psl/TCP/ClientOptions.php new file mode 100644 index 00000000..fb1f27fa --- /dev/null +++ b/src/Psl/TCP/ClientOptions.php @@ -0,0 +1,143 @@ +noDelay = $no_delay; + $this->bindTo = $bind_to; + $this->tlsClientOptions = $tls_client_options; + } + + /** + * Constructs a new ConnectOptions instance with specified noDelay setting. + * + * This static method provides a named constructor pattern, allowing for explicit + * configuration of the options at the time of instantiation. It offers an alternative + * to the default constructor for cases where named constructors improve readability + * and usage clarity. + * + * @param bool $no_delay Specifies whether the TCP_NODELAY option should be enabled. + * + * @pure + */ + public static function create(bool $no_delay = false): ClientOptions + { + return new self($no_delay, null, null); + } + + /** + * Creates and returns a default instance of {@see ClientOptions}. + * + * @pure + */ + public static function default(): static + { + return self::create(); + } + + /** + * Returns a new instance with noDelay enabled. + * + * @return ClientOptions A new instance with noDelay set to true. + * + * @mutation-free + */ + public function withNoDelay(bool $enabled = true): ClientOptions + { + return new self($enabled, $this->bindTo, $this->tlsClientOptions); + } + + /** + * Returns a new instance with the specified IP and optionally port to bind to. + * + * @param non-empty-string $ip The IP address to bind the connection to. + * @param int|null $port The port number to bind the connection to, or null to not specify. + * + * @return ClientOptions A new instance with the updated bindTo option. + * + * @mutation-free + */ + public function withBindTo(string $ip, ?int $port = null): ClientOptions + { + return new self($this->noDelay, [$ip, $port], $this->tlsClientOptions); + } + + /** + * Returns a new instance without any bindTo configuration. + * + * @return ClientOptions A new instance with bindTo set to null. + * + * @mutation-free + */ + public function withoutBindTo(): ClientOptions + { + return new self($this->noDelay, null, $this->tlsClientOptions); + } + + /** + * Returns a new instance with the specified TLS client options. + * + * @param TLS\ClientOptions $tls_connect_options The TLS connect options. + * + * @mutation-free + */ + public function withTlsClientOptions(TLS\ClientOptions $tls_connect_options): ClientOptions + { + return new self($this->noDelay, $this->bindTo, $tls_connect_options); + } + + /** + * Returns a new instance without the Tls client options. + * + * @mutation-free + */ + public function withoutTlsClientOptions(): ClientOptions + { + return new self($this->noDelay, $this->bindTo, null); + } +} diff --git a/src/Psl/TCP/ConnectOptions.php b/src/Psl/TCP/ConnectOptions.php deleted file mode 100644 index 533f595b..00000000 --- a/src/Psl/TCP/ConnectOptions.php +++ /dev/null @@ -1,76 +0,0 @@ -noDelay = $no_delay; - } - - /** - * Constructs a new ConnectOptions instance with specified noDelay setting. - * - * This static method provides a named constructor pattern, allowing for explicit - * configuration of the options at the time of instantiation. It offers an alternative - * to the default constructor for cases where named constructors improve readability - * and usage clarity. - * - * @param bool $no_delay Specifies whether the TCP_NODELAY option should be enabled. - * - * @pure - */ - public static function create(bool $no_delay = false): ConnectOptions - { - return new self($no_delay); - } - - /** - * Creates and returns a default instance of {@see ConnectOptions}. - * - * The default instance has the TCP_NODELAY option disabled, allowing the Nagle algorithm - * to be used. This method is a convenience wrapper around the `create` method, adhering to - * the {@see DefaultInterface} contract. - * - * @return static A default ConnectOptions instance with noDelay set to false. - * - * @pure - */ - public static function default(): static - { - return self::create(); - } - - /** - * Returns a new instance of {@see ConnectOptions} with the noDelay setting modified. - * - * @param bool $enabled Specifies the desired state of the TCP_NODELAY option. - * - * @psalm-mutation-free - */ - public function withNoDelay(bool $enabled = true): ConnectOptions - { - return new self($enabled); - } -} diff --git a/src/Psl/TCP/Internal/TCPSocket.php b/src/Psl/TCP/Internal/TCPSocket.php new file mode 100644 index 00000000..18f6a3e6 --- /dev/null +++ b/src/Psl/TCP/Internal/TCPSocket.php @@ -0,0 +1,209 @@ +awaitable = $deferred->getAwaitable(); + + EventLoop::defer(function () use ($stream, $deferred): void { + $context = stream_context_get_options($stream); + /** @var array|null $ssl_context */ + $ssl_context = $context['ssl'] ?? null; + if (null !== $ssl_context) { + TLS\Internal\establish_tls_connection($stream, $ssl_context); + } + + try { + $this->handle = new Internal\ResourceHandle($stream, read: true, write: true, seek: false, close: true); + $deferred->complete(null); + } catch (Throwable $exception) { + $deferred->error($exception); + } + }); + } + + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->reachedEndOfDataSource(); + } + + /** + * {@inheritDoc} + */ + public function tryRead(?int $max_bytes = null): string + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->tryRead($max_bytes); + } + + /** + * {@inheritDoc} + */ + public function read(?int $max_bytes = null, ?Duration $timeout = null): string + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->read($max_bytes, $timeout); + } + + /** + * {@inheritDoc} + */ + public function readAll(?int $max_bytes = null, ?Duration $timeout = null): string + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->readAll($max_bytes, $timeout); + } + + /** + * {@inheritDoc} + */ + public function readFixedSize(int $size, ?Duration $timeout = null): string + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->readFixedSize($size, $timeout); + } + + /** + * {@inheritDoc} + */ + public function tryWrite(string $bytes): int + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->tryWrite($bytes); + } + + /** + * {@inheritDoc} + */ + public function write(string $bytes, ?Duration $timeout = null): int + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->write($bytes, $timeout); + } + + /** + * {@inheritDoc} + */ + public function writeAll(string $bytes, ?Duration $timeout = null): void + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + $this->handle->writeAll($bytes, $timeout); + } + + /** + * {@inheritDoc} + */ + public function getStream(): mixed + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + return $this->handle->getStream(); + } + + /** + * {@inheritDoc} + */ + public function getLocalAddress(): Address + { + $stream = $this->getStream(); + if (!is_resource($stream)) { + throw new Exception\AlreadyClosedException('Socket handle has already been closed.'); + } + + return Network\Internal\get_sock_name($stream); + } + + /** + * {@inheritDoc} + */ + public function getPeerAddress(): Address + { + $stream = $this->getStream(); + if (!is_resource($stream)) { + throw new Exception\AlreadyClosedException('Socket handle has already been closed.'); + } + + return Network\Internal\get_peer_name($stream); + } + + /** + * {@inheritDoc} + */ + public function close(): void + { + if (!$this->awaitable->isComplete()) { + $this->awaitable->await(); + } + + $this->handle->close(); + } + + public function __destruct() + { + /** @psalm-suppress MissingThrowsDocblock */ + $this->close(); + } +} diff --git a/src/Psl/TCP/Server.php b/src/Psl/TCP/Server.php index cc110c39..b814f275 100644 --- a/src/Psl/TCP/Server.php +++ b/src/Psl/TCP/Server.php @@ -4,6 +4,7 @@ namespace Psl\TCP; +use Generator; use Psl; use Psl\Network; use Psl\OS; @@ -35,8 +36,33 @@ public static function create( ] ]; - $socket = Network\Internal\server_listen("tcp://{$host}:{$port}", $socket_context); + if (null !== $server_options->tlsServerOptions) { + $socket_context['ssl'] = TLS\Internal\server_context( + $server_options->tlsServerOptions + ); + } - return new self($socket, $server_options->idleConnections); + $socket = Network\Internal\server_listen("tcp://$host:$port", $socket_context); + + return new static($socket, $server_options->idleConnections); + } + + /** + * {@inheritDoc} + */ + public function nextConnection(): Network\StreamSocketInterface + { + return new Internal\TCPSocket($this->nextConnectionImpl()); + } + + /** + * {@inheritDoc} + */ + public function incoming(): Generator + { + /** @psalm-suppress InvalidIterator */ + foreach ($this->incomingImpl() as $stream) { + yield null => new Internal\TCPSocket($stream); + } } } diff --git a/src/Psl/TCP/ServerOptions.php b/src/Psl/TCP/ServerOptions.php index ac8cb41b..9fc59420 100644 --- a/src/Psl/TCP/ServerOptions.php +++ b/src/Psl/TCP/ServerOptions.php @@ -19,14 +19,30 @@ */ public const DEFAULT_IDLE_CONNECTIONS = 256; + /** + * Determines whether the TCP_NODELAY option is enabled, controlling + * the use of the Nagle algorithm. When true, TCP_NODELAY is enabled, + * and the Nagle algorithm is disabled. + */ public bool $noDelay; /** + * The maximum number of idle connections the server will keep open. + * * @var int<1, max> */ public int $idleConnections; + + /** + * Socket configuration options. + */ public Network\SocketOptions $socketOptions; + /** + * TLS server options. + */ + public ?TLS\ServerOptions $tlsServerOptions; + /** * Initializes a new instance of ServerOptions with specified settings. * @@ -35,14 +51,16 @@ * and the Nagle algorithm is disabled. * @param int<1, max> $idle_connections The maximum number of idle connections the server will keep open. * @param Network\SocketOptions $socket_options Socket configuration options. + * @param null|TLS\ServerOptions $tls_server_options TLS server options. * * @psalm-mutation-free */ - public function __construct(bool $no_delay, int $idle_connections, Network\SocketOptions $socket_options) + public function __construct(bool $no_delay, int $idle_connections, Network\SocketOptions $socket_options, ?TLS\ServerOptions $tls_server_options) { $this->noDelay = $no_delay; $this->idleConnections = $idle_connections; $this->socketOptions = $socket_options; + $this->tlsServerOptions = $tls_server_options; } /** @@ -61,8 +79,9 @@ public static function create( bool $no_delay = false, int $idle_connections = self::DEFAULT_IDLE_CONNECTIONS, ?Network\SocketOptions $socket_options = null, + ?TLS\ServerOptions $tls_server_options = null, ): ServerOptions { - return new self($no_delay, $idle_connections, $socket_options ?? Network\SocketOptions::default()); + return new self($no_delay, $idle_connections, $socket_options ?? Network\SocketOptions::default(), $tls_server_options); } /** @@ -87,7 +106,7 @@ public static function default(): static */ public function withSocketOptions(Network\SocketOptions $socket_options): ServerOptions { - return new self($this->noDelay, $this->idleConnections, $socket_options); + return new self($this->noDelay, $this->idleConnections, $socket_options, $this->tlsServerOptions); } /** @@ -99,7 +118,7 @@ public function withSocketOptions(Network\SocketOptions $socket_options): Server */ public function withNoDelay(bool $enabled = true): ServerOptions { - return new self($enabled, $this->idleConnections, $this->socketOptions); + return new self($enabled, $this->idleConnections, $this->socketOptions, $this->tlsServerOptions); } /** @@ -111,6 +130,28 @@ public function withNoDelay(bool $enabled = true): ServerOptions */ public function withIdleConnections(int $idleConnections): ServerOptions { - return new self($this->noDelay, $idleConnections, $this->socketOptions); + return new self($this->noDelay, $idleConnections, $this->socketOptions, $this->tlsServerOptions); + } + + /** + * Returns a new instance with the update TLS server options. + * + * @param TLS\ServerOptions $tls_server_options The new TLS server options. + * + * @mutation-free + */ + public function withTlsServerOptions(TLS\ServerOptions $tls_server_options): ServerOptions + { + return new self($this->noDelay, $this->idleConnections, $this->socketOptions, $tls_server_options); + } + + /** + * Returns a new instance without the TLS server options. + * + * @mutation-free + */ + public function withoutTlsServerOptions(): ServerOptions + { + return new self($this->noDelay, $this->idleConnections, $this->socketOptions, null); } } diff --git a/src/Psl/TCP/TLS/Certificate.php b/src/Psl/TCP/TLS/Certificate.php new file mode 100644 index 00000000..bb66f0ff --- /dev/null +++ b/src/Psl/TCP/TLS/Certificate.php @@ -0,0 +1,47 @@ +withMinimumVersion(TCP\TLS\Version::Tls12) + * ->withVerifyPeer(true) + * ->withCertificateAuthorityFile('/path/to/cafile.pem'); + * + * ``` + * + * @immutable + */ +final class ClientOptions implements DefaultInterface +{ + /** + * Constructs a new instance of the TLS connection options with specified settings. + * + * @param Version $minimumVersion Specifies the minimum TLS version that is acceptable for connections. + * @param string $peerName Specifies the expected name of the peer, used in verifying the peer's certificate. + * @param bool $peerVerification Indicates whether the peer's SSL certificate should be verified. + * @param int<0, max> $verificationDepth Specifies the maximum depth for certificate chain verification. + * @param null|non-empty-list $peerFingerprints Optional. Specifies peer fingerprints for certificate verification, allowing + * for additional security checks based on expected certificate fingerprints. + * @param null|non-empty-string $ciphers Specifies the cipher suite(s) to be used for the TLS connection, determining + * the encryption algorithms that will be available during the TLS handshake. + * @param null|non-empty-string $certificateAuthorityFile Optional. Specifies the path to a Certificate Authority (CA) file + * to be used for verifying the peer's certificate. + * @param null|non-empty-string $certificateAuthorityPath Optional. Specifies the path to a directory containing Certificate + * Authority (CA) certificates, which will be used for verifying the peer's certificate. + * @param bool $capturePeerCertificate Indicates whether the peer's certificate should be captured during + * the handshake process. This can be useful for inspection or logging purposes. + * @param bool $SNIEnabled Indicates whether Server Name Indication (SNI) should be used, + * which allows multiple domains to be served over HTTPS from the same IP address. + * @param SecurityLevel $securityLevel Specifies the security level for the TLS connection, influencing + * the choice of cryptographic algorithms. + * @param null|Certificate $certificate Optional. Specifies a client certificate to be used for the TLS connection, + * which may be required by servers expecting client authentication. + * @param list $alpnProtocols Specifies the protocols to be used for Application Layer Protocol Negotiation (ALPN), + * enabling the selection of application-specific protocols within the TLS layer. + * + * @pure + */ + private function __construct( + public readonly Version $minimumVersion, + public readonly string $peerName, + public readonly bool $peerVerification, + public readonly int $verificationDepth, + public readonly ?array $peerFingerprints, + public readonly ?string $ciphers, + public readonly ?string $certificateAuthorityFile, + public readonly ?string $certificateAuthorityPath, + public readonly bool $capturePeerCertificate, + public readonly bool $SNIEnabled, + public readonly SecurityLevel $securityLevel, + public readonly ?Certificate $certificate, + public readonly array $alpnProtocols, + ) { + } + + /** + * Creates a new instance of ConnectOptions with default settings. + * + * @return ClientOptions The new instance with default values. + * + * @pure + */ + public static function create(): self + { + return new self( + minimumVersion: Version::default(), + peerName: '', + peerVerification: true, + verificationDepth: 10, + peerFingerprints: null, + ciphers: null, + certificateAuthorityFile: null, + certificateAuthorityPath: null, + capturePeerCertificate: false, + SNIEnabled: true, + securityLevel: SecurityLevel::default(), + certificate: null, + alpnProtocols: [] + ); + } + + /** + * Creates and returns a default instance of {@see ClientOptions}. + * + * @pure + */ + public static function default(): static + { + return static::create(); + } + + /** + * Specifies the minimum version of the TLS protocol that is acceptable. + * + * @param Version $version The minimum TLS version. + * + * @return ClientOptions A new instance with the specified minimum TLS version. + * + * @mutation-free + */ + public function withMinimumVersion(Version $version): self + { + return new self( + $version, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Sets the expected name of the peer for certificate verification. + * + * @param string $peer_name The expected name of the peer. + * + * @return ClientOptions A new instance with the specified peer name. + * + * @mutation-free + */ + public function withPeerName(string $peer_name): self + { + return new self( + $this->minimumVersion, + $peer_name, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Enables verification of the peer's SSL certificate. + * + * @return ClientOptions A new instance with peer verification enabled. + * + * @mutation-free + */ + public function withPeerVerification(bool $peer_verification = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $peer_verification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Sets the maximum depth for certificate chain verification. + * + * @param int<0, max> $verification_depth The maximum verification depth. + * + * @return ClientOptions A new instance with the specified verification depth. + * + * @mutation-free + */ + public function withVerificationDepth(int $verification_depth): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $verification_depth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + + /** + * Adds a peer fingerprint for certificate verification. + * + * @param HashingAlgorithm $hashing_algorithm The hashing algorithm used for the fingerprint. + * @param string $fingerprint The fingerprint string. + * + * @return ClientOptions A new instance with the added peer fingerprint. + * + * @mutation-free + */ + public function withPeerFingerprint(HashingAlgorithm $hashing_algorithm, string $fingerprint): self + { + return $this->withPeerFingerprints([ + [$hashing_algorithm, $fingerprint], + ]); + } + + /** + * Sets multiple peer fingerprints for certificate verification. + * + * @param null|non-empty-list $peer_fingerprints An array of peer fingerprints. + * + * @return ClientOptions A new instance with the specified peer fingerprints. + * + * @mutation-free + */ + public function withPeerFingerprints(?array $peer_fingerprints): self + { + if (null !== $peer_fingerprints) { + foreach ($peer_fingerprints as [$algorithm, $fingerprint]) { + Psl\invariant( + Byte\length($fingerprint) === $algorithm->getExpectedLength(), + 'Fingerprint length does not match expected length for "%s" algorithm.', + $algorithm->value, + ); + } + } + + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $peer_fingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Removes all peer fingerprints from the certificate verification process. + * + * @return ClientOptions A new instance without any peer fingerprints. + * + * @mutation-free + */ + public function withoutPeerFingerprints(): self + { + return $this->withPeerFingerprints(null); + } + + /** + * Specifies the cipher suite to be used for the TLS connection. + * + * @param non-empty-string $ciphers The cipher suite. + * + * @return ClientOptions A new instance with the specified ciphers. + * + * @mutation-free + */ + public function withCiphers(string $ciphers): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) file for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_file The path to the CA file. + * + * @return ClientOptions A new instance with the specified CA file path. + * + * @mutation-free + */ + public function withCertificateAuthorityFile(?string $certificate_authority_file): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $certificate_authority_file, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) directory for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_path The path to the CA directory. + * + * @return ClientOptions A new instance with the specified CA directory path. + * + * @mutation-free + */ + public function withCertificateAuthorityPath(?string $certificate_authority_path): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $certificate_authority_path, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Enables or disables capturing of the peer's certificate. + * + * @param bool $capture_peer_certificate Whether to capture the peer's certificate. + * + * @return ClientOptions A new instance with the specified peer certificate capturing setting. + * + * @mutation-free + */ + public function withCapturePeerCertificate(bool $capture_peer_certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $capture_peer_certificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Enables or disables Server Name Indication (SNI). + * + * @param bool $sni_enabled Whether SNI is enabled. + * + * @return ClientOptions A new instance with the specified SNI setting. + * + * @mutation-free + */ + public function withSNIEnabled(bool $sni_enabled = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $sni_enabled, + $this->securityLevel, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Sets the security level for the TLS connection. + * + * @param SecurityLevel $security_level The security level. + * + * @return ClientOptions A new instance with the specified security level. + * + * @mutation-free + */ + public function withSecurityLevel(SecurityLevel $security_level): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $security_level, + $this->certificate, + $this->alpnProtocols + ); + } + + /** + * Specifies a Certificate to be used for the TLS connection. + * + * @param null|Certificate $certificate The certificate. + * + * @return ClientOptions A new instance with the specified certificate. + * + * @mutation-free + */ + public function withCertificate(?Certificate $certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $certificate, + $this->alpnProtocols + ); + } + + /** + * Sets the protocols to be used for Application Layer Protocol Negotiation (ALPN). + * + * @param list $alpn_protocols The ALPN protocols. + * + * @return ClientOptions A new instance with the specified ALPN protocols. + * + * @mutation-free + */ + public function withAlpnProtocols(array $alpn_protocols): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $alpn_protocols + ); + } +} diff --git a/src/Psl/TCP/TLS/Exception/ExceptionInterface.php b/src/Psl/TCP/TLS/Exception/ExceptionInterface.php new file mode 100644 index 00000000..65940c60 --- /dev/null +++ b/src/Psl/TCP/TLS/Exception/ExceptionInterface.php @@ -0,0 +1,11 @@ + 40, + self::Sha256 => 64, + }; + } +} diff --git a/src/Psl/TCP/TLS/Internal/client_context.php b/src/Psl/TCP/TLS/Internal/client_context.php new file mode 100644 index 00000000..e9978f7d --- /dev/null +++ b/src/Psl/TCP/TLS/Internal/client_context.php @@ -0,0 +1,67 @@ +peerName === '') { + $options = $options->withPeerName($host); + } + + $context = [ + 'crypto_method' => match ($options->minimumVersion) { + TLS\Version::Tls10 => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls11 => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls12 => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls13 => STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + }, + 'peer_name' => $options->peerName, + 'verify_peer' => $options->peerVerification, + 'verify_peer_name' => $options->peerVerification, + 'verify_depth' => $options->verificationDepth, + 'ciphers' => $options->ciphers ?? OPENSSL_DEFAULT_STREAM_CIPHERS, + 'capture_peer_cert' => $options->capturePeerCertificate, + 'capture_peer_cert_chain' => $options->capturePeerCertificate, + 'SNI_enabled' => $options->SNIEnabled, + 'security_level' => $options->securityLevel->value, + ]; + + if (null !== $options->certificate) { + $context['local_cert'] = $options->certificate->certificateFile; + if ($options->certificate->certificateFile !== $options->certificate->keyFile) { + $context['local_pk'] = $options->certificate->keyFile; + } + + if ($options->certificate->passphrase !== null) { + $context['passphrase'] = $options->certificate->passphrase; + } + } + + if ($options->certificateAuthorityFile !== null) { + $context['cafile'] = $options->certificateAuthorityFile; + } + + if ($options->certificateAuthorityPath !== null) { + $context['capath'] = $options->certificateAuthorityPath; + } + + if ([] !== $options->alpnProtocols) { + $context['alpn_protocols'] = Str\join($options->alpnProtocols, ','); + } + + if ($options->peerFingerprints !== null) { + $peer_fingerprints = []; + foreach ($options->peerFingerprints as $peer_fingerprint) { + $peer_fingerprints[$peer_fingerprint[0]->value] = $peer_fingerprint[1]; + } + + $context['peer_fingerprint'] = $peer_fingerprints; + } + + return $context; +} diff --git a/src/Psl/TCP/TLS/Internal/establish_tls_connection.php b/src/Psl/TCP/TLS/Internal/establish_tls_connection.php new file mode 100644 index 00000000..785ae1e3 --- /dev/null +++ b/src/Psl/TCP/TLS/Internal/establish_tls_connection.php @@ -0,0 +1,123 @@ += 80300) { + /** + * @psalm-suppress UnusedFunctionCall + */ + stream_context_set_options($resource, ['ssl' => $context]); + } else { + stream_context_set_option($resource, ['ssl' => $context]); + } + + $error_handler = static function (int $code, string $message) use ($resource): never { + if (feof($resource)) { + $message = 'Connection reset by peer'; + } + + throw new NegotiationException('TLS negotiation failed: ' . $message); + }; + + try { + set_error_handler($error_handler); + $result = stream_socket_enable_crypto($resource, enable: true); + + if ($result === false) { + throw new NegotiationException('TLS negotiation failed: Unknown error'); + } + } finally { + restore_error_handler(); + } + + if (true !== $result) { + while (true) { + $suspension = EventLoop::getSuspension(); + + $read_watcher = ''; + $timeout_watcher = ''; + $timeout = $optional_timeout->getRemaining(); + if (null !== $timeout) { + $timeout_watcher = EventLoop::delay($timeout->getTotalSeconds(), static function () use ($suspension, &$read_watcher, $resource) { + EventLoop::cancel($read_watcher); + + /** @psalm-suppress RedundantCondition - it can be resource|closed-resource */ + if (is_resource($resource)) { + IO\close_resource($resource); + } + + $suspension->throw(new Exception\TimeoutException('TLS negotiation timed out.')); + }); + } + + $read_watcher = EventLoop::onReadable($resource, static function () use ($suspension, $timeout_watcher) { + EventLoop::cancel($timeout_watcher); + + $suspension->resume(); + }); + + try { + $suspension->suspend(); + } finally { + EventLoop::cancel($read_watcher); + EventLoop::cancel($timeout_watcher); + } + + try { + set_error_handler($error_handler); + $result = stream_socket_enable_crypto($resource, enable: true); + if ($result === false) { + $message = feof($resource) ? 'Connection reset by peer' : 'Unknown error'; + throw new NegotiationException('TLS negotiation failed: ' . $message); + } + } finally { + restore_error_handler(); + } + + if ($result === true) { + break; + } + } + } + }); +} diff --git a/src/Psl/TCP/TLS/Internal/server_context.php b/src/Psl/TCP/TLS/Internal/server_context.php new file mode 100644 index 00000000..1f1cb71a --- /dev/null +++ b/src/Psl/TCP/TLS/Internal/server_context.php @@ -0,0 +1,79 @@ + match ($options->minimumVersion) { + TLS\Version::Tls10 => STREAM_CRYPTO_METHOD_TLSv1_0_SERVER | STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLSv1_2_SERVER | STREAM_CRYPTO_METHOD_TLSv1_3_SERVER, + TLS\Version::Tls11 => STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLSv1_2_SERVER | STREAM_CRYPTO_METHOD_TLSv1_3_SERVER, + TLS\Version::Tls12 => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER | STREAM_CRYPTO_METHOD_TLSv1_3_SERVER, + TLS\Version::Tls13 => STREAM_CRYPTO_METHOD_TLSv1_3_SERVER, + }, + 'peer_name' => $options->peerName, + 'verify_peer' => $options->peerVerification, + 'allow_self_signed' => $options->allowSelfSignedCertificates, + 'verify_peer_name' => $options->peerVerification, + 'verify_depth' => $options->verificationDepth, + 'ciphers' => $options->ciphers ?? OPENSSL_DEFAULT_STREAM_CIPHERS, + 'capture_peer_cert' => $options->capturePeerCertificate, + 'capture_peer_cert_chain' => $options->capturePeerCertificate, + 'security_level' => $options->securityLevel->value, + 'honor_cipher_order' => true, + 'single_dh_use' => true, + 'no_ticket' => true, + ]; + + if ([] !== $options->alpnProtocols) { + $ssl_context['alpn_protocols'] = Str\join($options->alpnProtocols, ','); + } + + if (null !== $options->defaultCertificate) { + $ssl_context['local_cert'] = $options->defaultCertificate->certificateFile; + if ($options->defaultCertificate->certificateFile !== $options->defaultCertificate->keyFile) { + $ssl_context['local_pk'] = $options->defaultCertificate->keyFile; + } + + if (null !== $options->defaultCertificate->passphrase) { + $ssl_context['passphrase'] = $options->defaultCertificate->passphrase; + } + } + + if ([] !== $options->certificates) { + $ssl_context['SNI_server_certs'] = Dict\map( + $options->certificates, + /** + * @returns array{local_cert: non-empty-string, local_pk: non-empty-string, passphrase?: non-empty-string} + */ + static function (TLS\Certificate $certificate): array { + $options = [ + 'local_cert' => $certificate->certificateFile, + 'local_pk' => $certificate->keyFile, + ]; + + if (null !== $certificate->passphrase) { + $options['passphrase'] = $certificate->passphrase; + } + + return $options; + }, + ); + } + + if (null !== $options->certificateAuthorityFile) { + $ssl_context['cafile'] = $options->certificateAuthorityFile; + } + + if (null !== $options->certificateAuthorityPath) { + $ssl_context['capath'] = $options->certificateAuthorityPath; + } + + return $ssl_context; +} diff --git a/src/Psl/TCP/TLS/SecurityLevel.php b/src/Psl/TCP/TLS/SecurityLevel.php new file mode 100644 index 00000000..a04306b5 --- /dev/null +++ b/src/Psl/TCP/TLS/SecurityLevel.php @@ -0,0 +1,78 @@ +withMinimumVersion(TCP\TLS\Version::Tls12) + * ->withVerifyPeer(true) + * ->withCertificateAuthorityFile('/path/to/cafile.pem'); + * ```. + * + * @immutable + */ +final class ServerOptions implements DefaultInterface +{ + /** + * Constructs a new instance of the TLS server options with specified settings. + * + * @param Version $minimumVersion Specifies the minimum TLS version that is acceptable for negotiation. + * @param string $peerName Specifies the expected name of the peer, used in verifying the peer's certificate. + * @param bool $peerVerification Indicates whether the peer's SSL certificate should be verified. + * @param int<0, max> $verificationDepth Specifies the maximum depth for certificate chain verification. + * @param null|non-empty-string $ciphers Specifies the cipher suite(s) to be used for the TLS server, determining + * the encryption algorithms that will be available during the TLS handshake. + * @param null|non-empty-string $certificateAuthorityFile Optional. Specifies the path to a Certificate Authority (CA) file + * to be used for verifying the peer's certificate. + * @param null|non-empty-string $certificateAuthorityPath Optional. Specifies the path to a directory containing Certificate + * Authority (CA) certificates, which will be used for verifying the peer's certificate. + * @param bool $capturePeerCertificate Indicates whether the peer's certificate should be captured during + * the handshake process. This can be useful for inspection or logging purposes. + * @param SecurityLevel $securityLevel Specifies the security level for the TLS server, influencing + * the choice of cryptographic algorithms. + * @param array $certificates + * @param null|Certificate $defaultCertificate Optional. + * @param list $alpnProtocols Specifies the protocols to be used for Application Layer Protocol Negotiation (ALPN), + * enabling the selection of application-specific protocols within the TLS layer. + * + * @pure + */ + private function __construct( + public readonly Version $minimumVersion, + public readonly string $peerName, + public readonly bool $peerVerification, + public readonly bool $allowSelfSignedCertificates, + public readonly int $verificationDepth, + public readonly ?string $ciphers, + public readonly ?string $certificateAuthorityFile, + public readonly ?string $certificateAuthorityPath, + public readonly bool $capturePeerCertificate, + public readonly SecurityLevel $securityLevel, + public readonly array $certificates, + public readonly ?Certificate $defaultCertificate, + public readonly array $alpnProtocols, + ) { + } + + /** + * Creates a new instance of {@see ServerOptions} with default settings. + * + * @return ServerOptions The new instance with default values. + * + * @pure + */ + public static function create(): self + { + return new self( + minimumVersion: Version::default(), + peerName: '', + peerVerification: true, + allowSelfSignedCertificates: true, + verificationDepth: 10, + ciphers: null, + certificateAuthorityFile: null, + certificateAuthorityPath: null, + capturePeerCertificate: false, + securityLevel: SecurityLevel::default(), + certificates: [], + defaultCertificate: null, + alpnProtocols: [] + ); + } + + /** + * Creates and returns a default instance of {@see ClientOptions}. + * + * @pure + */ + public static function default(): static + { + return static::create(); + } + + /** + * Specifies the minimum version of the TLS protocol to negotiate. + * + * @param Version $version The minimum TLS version. + * + * @return ServerOptions A new instance with the specified minimum TLS version. + * + * @mutation-free + */ + public function withMinimumVersion(Version $version): self + { + return new self( + $version, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Sets the expected name of the peer for certificate verification. + * + * @param string $peer_name The expected name of the peer. + * + * @return ServerOptions A new instance with the specified peer name. + * + * @mutation-free + */ + public function withPeerName(string $peer_name): self + { + return new self( + $this->minimumVersion, + $peer_name, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Enables verification of the peer's SSL certificate. + * + * @return ServerOptions A new instance with the peer verification option modified. + * + * @mutation-free + */ + public function withPeerVerification(bool $peer_verification = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $peer_verification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Allow self-signed certificates. + * + * @return ServerOptions A new instance with to allow self-signed certificate option modified. + * + * @mutation-free + */ + public function withAllowSelfSignedCertificates(bool $allow_self_signed_certificates = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $allow_self_signed_certificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Sets the maximum depth for certificate chain verification. + * + * @param int<0, max> $verification_depth The maximum verification depth. + * + * @return ServerOptions A new instance with the specified verification depth. + * + * @mutation-free + */ + public function withVerificationDepth(int $verification_depth): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $verification_depth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Specifies the cipher suite to be used for the TLS server. + * + * @param non-empty-string $ciphers The cipher suite. + * + * @return ServerOptions A new instance with the specified ciphers. + * + * @mutation-free + */ + public function withCiphers(string $ciphers): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) file for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_file The path to the CA file. + * + * @return ServerOptions A new instance with the specified CA file path. + * + * @mutation-free + */ + public function withCertificateAuthorityFile(?string $certificate_authority_file): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $certificate_authority_file, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) directory for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_path The path to the CA directory. + * + * @return ServerOptions A new instance with the specified CA directory path. + * + * @mutation-free + */ + public function withCertificateAuthorityPath(?string $certificate_authority_path): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $certificate_authority_path, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Enables or disables capturing of the peer's certificate. + * + * @param bool $capture_peer_certificate Whether to capture the peer's certificate. + * + * @return ServerOptions A new instance with the specified peer certificate capturing setting. + * + * @mutation-free + */ + public function withCapturePeerCertificate(bool $capture_peer_certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $capture_peer_certificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Sets the security level for the TLS server. + * + * @param SecurityLevel $security_level The security level. + * + * @return ServerOptions A new instance with the specified security level. + * + * @mutation-free + */ + public function withSecurityLevel(SecurityLevel $security_level): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $security_level, + $this->certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * @param array $certificates + * + * @return ServerOptions A new instance with the specified certificates. + * + * @mutation-free + */ + public function withCertificates(array $certificates): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $certificates, + $this->defaultCertificate, + $this->alpnProtocols + ); + } + + /** + * Specifies a Certificate to be used for the TLS server. + * + * @param null|Certificate $default_certificate The certificate. + * + * @return ServerOptions A new instance with the specified certificate. + * + * @mutation-free + */ + public function withDefaultCertificate(?Certificate $default_certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $default_certificate, + $this->alpnProtocols + ); + } + + /** + * Sets the protocols to be used for Application Layer Protocol Negotiation (ALPN). + * + * @param list $alpn_protocols The ALPN protocols. + * + * @return ServerOptions A new instance with the specified ALPN protocols. + * + * @mutation-free + */ + public function withAlpnProtocols(array $alpn_protocols): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->allowSelfSignedCertificates, + $this->verificationDepth, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->securityLevel, + $this->certificates, + $this->defaultCertificate, + $alpn_protocols + ); + } +} diff --git a/src/Psl/TCP/TLS/Version.php b/src/Psl/TCP/TLS/Version.php new file mode 100644 index 00000000..e7e7c790 --- /dev/null +++ b/src/Psl/TCP/TLS/Version.php @@ -0,0 +1,62 @@ + [ 'tcp_nodelay' => $options->noDelay, - ] + ], ]; - $socket = Network\Internal\socket_connect("tcp://$host:$port", $context, $timeout); + if (null !== $options->bindTo) { + $context['socket']['bindto'] = $options->bindTo[0] . ':' . ((string) ($options->bindTo[1] ?? 0)); + } + + $socket = Network\Internal\socket_connect("tcp://$host:$port", $context, $optional_timeout->getRemaining()); + $tls_options = $options->tlsClientOptions; + + if (null !== $tls_options) { + $context = TLS\Internal\client_context($host, $tls_options); + + TLS\Internal\establish_tls_connection($socket, ['ssl' => $context], $optional_timeout->getRemaining()); + } /** @psalm-suppress MissingThrowsDocblock */ return new Network\Internal\Socket($socket); diff --git a/tests/unit/TCP/ConnectOptionsTest.php b/tests/unit/TCP/ConnectOptionsTest.php index 89433864..64dd3c68 100644 --- a/tests/unit/TCP/ConnectOptionsTest.php +++ b/tests/unit/TCP/ConnectOptionsTest.php @@ -5,13 +5,13 @@ namespace Psl\Tests\Unit\TCP; use PHPUnit\Framework\TestCase; -use Psl\TCP\ConnectOptions; +use Psl\TCP\ClientOptions; final class ConnectOptionsTest extends TestCase { public function testOptions(): void { - $options = ConnectOptions::default(); + $options = ClientOptions::default(); static::assertFalse($options->noDelay); diff --git a/tests/unit/TCP/ConnectTest.php b/tests/unit/TCP/ConnectTest.php index 6555b19f..93bc1a95 100644 --- a/tests/unit/TCP/ConnectTest.php +++ b/tests/unit/TCP/ConnectTest.php @@ -28,7 +28,7 @@ public function testConnect(): void $client = TCP\connect( '127.0.0.1', 8089, - TCP\ConnectOptions::create() + TCP\ClientOptions::create() ->withNoDelay(false) );