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..8939dcaa 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#L14)
- [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..fbead304 100644
--- a/examples/tcp/basic-http-server.php
+++ b/examples/tcp/basic-http-server.php
@@ -5,46 +5,87 @@
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.
+ *
+ * Generate a self-signed certificate using the following command:
+ *
+ * $ cd examples/tcp/fixtures
+ * $ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout privatekey.pem -out certificate.pem -config openssl.cnf
+ */
+$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/certificate.pem',
+ key_file: __DIR__ . '/fixtures/privatekey.pem',
+ ))
+ )
+);
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();
+while (true) {
+ try {
+ $connection = $server->nextConnection();
- $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();
-});
+ Async\Scheduler::defer(static fn() => handle($connection));
+ } catch (TCP\TLS\Exception\NegotiationException $e) {
+ IO\write_error_line('[SRV]: error "%s" at %s:%d"', $e->getMessage(), $e->getFile(), $e->getLine());
+
+ continue;
+ } catch (Network\Exception\AlreadyStoppedException $e) {
+ break;
+ }
+}
IO\write_error_line('');
IO\write_error_line('Goodbye 👋');
+
+function handle(Network\SocketInterface $connection): void
+{
+ try {
+ $peer = $connection->getPeerAddress();
+
+ IO\write_error_line('[SRV]: received a connection from peer "%s".', $peer);
+
+ 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());
+ }
+ } finally {
+ $connection->close();
+ }
+}
diff --git a/examples/tcp/fixtures/certificate.pem b/examples/tcp/fixtures/certificate.pem
new file mode 100644
index 00000000..cfef326b
--- /dev/null
+++ b/examples/tcp/fixtures/certificate.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIIDyjCCArKgAwIBAgIUKZH353aWdxUxmEabhyie1GbT1wQwDQYJKoZIhvcNAQEL
+BQAwbzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1TYW4gRnJh
+bmNpc2NvMRIwEAYDVQQKDAlNeUNvbXBhbnkxEzARBgNVBAsMCk15RGl2aXNpb24x
+EjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNDA1MjkxMjIzMjlaFw0yNTA1MjkxMjIz
+MjlaMG8xCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNU2FuIEZy
+YW5jaXNjbzESMBAGA1UECgwJTXlDb21wYW55MRMwEQYDVQQLDApNeURpdmlzaW9u
+MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
+AoIBAQCmBzlJGgJNiKvVSQskqh30o8LOApZcsCZf1NlijQ4XggtunMgtV4663EWw
+nOXewWD3Hssi/ZAaK4Az+0GQ3iMSGJAGBtOzAPCr0ZIUN42bx6NMx+iqQ37XkLw8
+A9uPXK7hwM9EG6uWMVtw5OR7TugFGtyFmTLdxU3uaxtkmRi76haTyBOFpJs4xyFj
+7WinGlCJ0EjKibW12xcCYWbRoObeJIBvviJizfve0dK+lVsP4sYP7gd3xwHq4xUO
+x2lWeUFmAPL9+jDNfrwd985OAAkWO71q8MySvAVPaGGiu6gN5ReyzNAUxaYErJfd
+BYQEVW25q/E6ez0r6lGSmLwbGqc3AgMBAAGjXjBcMDsGA1UdEQQ0MDKCCWxvY2Fs
+aG9zdIINKi5leGFtcGxlLmNvbYcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATAdBgNV
+HQ4EFgQUV7Hcc0Urg/HSV0jwHA8A1r/+yCUwDQYJKoZIhvcNAQELBQADggEBAFVf
+ExPw0NL1vLJq1KeXM7dxazd9Ge0K+8OXELbgtPJfxotHLTUGx25uaxc5lKa3v2Aa
+Du1iQMExPQCBWoO2pb9OrfePbagyzbkM2yVR/NNI9cXlk6BMMhluMF5onKkTApH+
+QzqaU/VWyBCOgLsuzM1kwXpsJyTJ+pZgXVmwuFMefVsdcMT3Gz6Fnmn04aslOU62
+Kgnkfx4rDPH4kqC1Zj4RwenJ03gCC9o2jaV8cZsMmu4tC3/hXHdWCJFV4DOCJ6w0
+TFMP83vYgSQoLIZIPo9ka5yELaRSSE27LPEckdVYk4q+X/O2/SSZPgFFFcJ5qXqb
+Y9KqGkvmSp//SvEDyV0=
+-----END CERTIFICATE-----
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/fixtures/privatekey.pem b/examples/tcp/fixtures/privatekey.pem
new file mode 100644
index 00000000..028a2f34
--- /dev/null
+++ b/examples/tcp/fixtures/privatekey.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmBzlJGgJNiKvV
+SQskqh30o8LOApZcsCZf1NlijQ4XggtunMgtV4663EWwnOXewWD3Hssi/ZAaK4Az
++0GQ3iMSGJAGBtOzAPCr0ZIUN42bx6NMx+iqQ37XkLw8A9uPXK7hwM9EG6uWMVtw
+5OR7TugFGtyFmTLdxU3uaxtkmRi76haTyBOFpJs4xyFj7WinGlCJ0EjKibW12xcC
+YWbRoObeJIBvviJizfve0dK+lVsP4sYP7gd3xwHq4xUOx2lWeUFmAPL9+jDNfrwd
+985OAAkWO71q8MySvAVPaGGiu6gN5ReyzNAUxaYErJfdBYQEVW25q/E6ez0r6lGS
+mLwbGqc3AgMBAAECggEADhWLywM9UcV3yjKVkukpfGDN/Drk9Xzt7HA6dq0/lkfu
+X1ZGdu44Cer4sHhG2cQuzRfcJJ489LNe/0nfsIHfmL/jq9c1azh3siOnkDZ8OUxQ
+sok82AC8yF2bUj4DiKBUp4r7KixsvGN4fdW0+i7h6Njz/xNVaNG9gC2u17RTEFGy
+tCMegse4XYTKxziOpGjhxwuIEOlY5sX3TdqTiiiJCz+PkD9WT2fPqZH9+K9msE1v
+ETlCP6IKmt39TrZp6HCMmfDTvcX8tNUdNll/7b2NEAfSH4FJcNRVUVc/Xjw7SXeg
+0e51qoYTRMqaO8Q1xDFtk9881LiWVoSPb6rTe17rBQKBgQDhy14xsmzfKeyTYcjF
+ucs1NHh1xJBUNyQGE2aYq58Pg9CeuSnv9N+WWZdR2xNpQ5wq1F6ZS96B6FU1DT+/
+A2YreGUJoLQan26oI+r3TEOASHZcliqV9Qn9R0vA5GRLixG0LIcv3s6askIqHFLY
+3BX2goGIlvCGJiWpvd2MPvWsPQKBgQC8PRUl9Onob8ar8ELlVCeRCJ0wgIKQew95
+MWKrfLATLaJJMLlU3JPLnN3gnOZhJ2u5/ClArofzbofhfkMMnoZoxInCs/hn4EwT
+W1aeLGCQX/Qb89B2HCDz2t4KPRraq+diEj4vR9H8VvUTdcIvXQ4i9UfM9G1+xVpJ
+1oPUytXUgwKBgGKgBArNFsT7ePx/T8Ud/GbG/n7iVvCSDUgiHUQ+YoHSX8OUuX64
+hRkVFQWKHZZzE7mZfaCUBSLVKrK7kMaMY4pFUky8Ry8ByMHkvnM6epmEDT8v0HYj
+zDM3ex1MJYrhud/rOzlrpu7nQgNGz+EtcOJ16sKQu4q9CuJzrlvd/E05AoGAYeIl
+gCJWC78sATajoprbJEjlbFY3DqhfSHcMxv3ElYRyUjra9Kzq0cNVgTo1dinIk+Lz
+FKZtHYHJeNFuTj6UyCADPtLVBjcVeC9T4FZVNF4hEvP636AK5qNWON7DexhO7qlr
+2qwvHledgywF+Rkbg8QmPQaRdY1sQN8imGGNRb8CgYBoc1MSutl/BFppVs8aNAki
+s+P7O0jifYvceqc3NOfQ36WLMS+7tzC5rI0KhVG7MEnkyi4hbtbSzPu74rTINsQc
+UmmG14Or7Od/BYnLYgVUFK1LA6Xub37PbNqHPLaQM+41oH8n5139L50Hd6xKZ+7n
+RonLBPsLONMcmb4U1ST9oQ==
+-----END PRIVATE KEY-----
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..ac50fdd2 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 $this->wrap($this->nextConnectionImpl());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function incoming(): Generator
+ {
+ /** @psalm-suppress InvalidIterator */
+ foreach ($this->incomingImpl() as $stream) {
+ yield null => $this->wrap($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 */
@@ -155,4 +179,19 @@ public function getStream(): mixed
/** @var resource */
return $this->impl;
}
+
+ /**
+ * Wraps the stream into a socket object.
+ *
+ * @param resource $stream The stream to wrap.
+ *
+ * @throws Network\Exception\AlreadyStoppedException
+ * @throws Network\Exception\RuntimeException
+ *
+ * @return Socket The wrapped socket.
+ */
+ protected function wrap(mixed $stream): Socket
+ {
+ return new Socket($stream);
+ }
}
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/Server.php b/src/Psl/TCP/Server.php
index cc110c39..e543c363 100644
--- a/src/Psl/TCP/Server.php
+++ b/src/Psl/TCP/Server.php
@@ -5,9 +5,12 @@
namespace Psl\TCP;
use Psl;
+use Psl\IO;
use Psl\Network;
use Psl\OS;
+use function stream_context_get_options;
+
final class Server extends Network\Internal\AbstractStreamServer
{
/**
@@ -18,11 +21,8 @@ final class Server extends Network\Internal\AbstractStreamServer
*
* @throws Psl\Network\Exception\RuntimeException In case failed to listen to on given address.
*/
- public static function create(
- string $host,
- int $port = 0,
- ?ServerOptions $options = null,
- ): self {
+ public static function create(string $host, int $port = 0, ?ServerOptions $options = null): self
+ {
$server_options = $options ?? ServerOptions::create();
$socket_options = $server_options->socketOptions;
$socket_context = [
@@ -35,8 +35,37 @@ 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
+ );
+ }
+
+ $socket = Network\Internal\server_listen("tcp://$host:$port", $socket_context);
+
+ return new static($socket, $server_options->idleConnections);
+ }
+
+ /**
+ * @inheritDoc
+ *
+ * @throws Psl\IO\Exception\RuntimeException
+ */
+ protected function wrap(mixed $stream): Network\Internal\Socket
+ {
+ $context = stream_context_get_options($stream);
+ /** @var array|null $ssl_context */
+ $ssl_context = $context['ssl'] ?? null;
+ if (null !== $ssl_context) {
+ try {
+ TLS\Internal\establish_tls_connection($stream, $ssl_context);
+ } catch (Psl\TCP\TLS\Exception\NegotiationException $e) {
+ IO\Internal\close_resource($stream);
+
+ throw $e;
+ }
+ }
- return new self($socket, $server_options->idleConnections);
+ return new Network\Internal\Socket($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, $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)
);