From bb5f1bb5c16cb1ea156cf9af8e01d0df6a7519a0 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 2 Jul 2026 13:49:43 -0400 Subject: [PATCH 1/6] feat(tls): support TLS connections (upgrade-after-INFO) Adds client TLS so the driver can connect to TLS-required servers such as Synadia NGS (tls://connect.ngs.global). Previously the client only spoke plaintext (CONNECT always sent tls_required:false) and any TLS-required server reset the connection. Design: - Config: new optional `?ClientTlsContext $tls` (null = current plaintext behavior, fully backward compatible). `fromURI` enables TLS from a tls:// / nats+tls:// / ssl:// scheme (peer name defaults to the host for SNI/verification); `fromArray` accepts a `tls` key. - SocketConnectionFactory: attaches the TLS context to the ConnectContext so the socket can be upgraded (does NOT connect TLS-first). - Framer: when TLS is configured, the single reader fiber reads the plaintext INFO line, runs setupTls(), THEN pushes INFO to the parser. Doing the upgrade inside the sole socket-reader fiber preserves the single-reader invariant (amphp forbids concurrent reads); pushing INFO only after the handshake completes prevents startup() from writing CONNECT mid-handshake. - SocketConnection::startup() is unchanged (no TLS logic). Scope/limitation: implements the standard NATS upgrade-after-INFO flow (server sends plaintext INFO advertising tls_required, client then upgrades). Servers configured with `tls { handshake_first: true }` are not covered. Non-TLS code path is byte-for-byte unchanged; existing tests unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Config.php | 19 ++++++++++ src/Internal/Connection/Framer.php | 38 ++++++++++++++++++- src/Internal/Connection/SocketConnection.php | 11 +++++- .../Connection/SocketConnectionFactory.php | 8 ++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/Config.php b/src/Config.php index fada1f2..7c0eb7a 100644 --- a/src/Config.php +++ b/src/Config.php @@ -54,6 +54,11 @@ public function __construct( public int $maxPings = self::DEFAULT_MAX_PINGS, public ?string $jetStreamDomain = null, public string $clientName = self::DEFAULT_CLIENT_NAME, + // TLS client context. null keeps the existing plaintext behavior. When + // set, the connection upgrades to TLS after the server's plaintext INFO + // (the standard NATS tls_required flow, e.g. Synadia NGS). The upgrade is + // performed by the socket's single reader fiber — see Framer. + public ?\Amp\Socket\ClientTlsContext $tls = null, ) { $this->version = '0.1.x'; } @@ -75,6 +80,17 @@ public static function fromURI(#[\SensitiveParameter] string $uri): self throw new \InvalidArgumentException("The uri '{$uri}' is invalid."); } + // A tls:// (or nats+tls://) scheme turns on TLS — the same convention the + // Go/JS clients use for connect.ngs.global. Default the SNI/peer name to + // the host so certificate verification works against NGS. + $tls = null; + $scheme = strtolower($components['scheme'] ?? ''); + if (($scheme === 'tls' || $scheme === 'nats+tls' || $scheme === 'ssl') && ($components['host'] ?? '') !== '') { + $firstHost = explode(':', explode(',', $components['host'])[0])[0]; + $tls = (new \Amp\Socket\ClientTlsContext($firstHost)); + } + + $query = []; if (isset($components['query']) && $components['query'] !== '') { parse_str($components['query'], $query); @@ -177,6 +193,7 @@ public static function fromURI(#[\SensitiveParameter] string $uri): self maxPings: $maxPings, jetStreamDomain: $jetStreamDomain, clientName: $clientName, + tls: $tls, ); } @@ -196,6 +213,7 @@ public static function fromURI(#[\SensitiveParameter] string $uri): self * max_pings?: positive-int, * jetstream_domain?: non-empty-string, * client_name?: non-empty-string, + * tls?: \Amp\Socket\ClientTlsContext, * } $options */ public static function fromArray(#[\SensitiveParameter] array $options): self @@ -215,6 +233,7 @@ public static function fromArray(#[\SensitiveParameter] array $options): self maxPings: $options['max_pings'] ?? self::DEFAULT_MAX_PINGS, jetStreamDomain: $options['jetstream_domain'] ?? null, clientName: $options['client_name'] ?? self::DEFAULT_CLIENT_NAME, + tls: $options['tls'] ?? null, ); } } diff --git a/src/Internal/Connection/Framer.php b/src/Internal/Connection/Framer.php index 53738f5..ed89bcf 100644 --- a/src/Internal/Connection/Framer.php +++ b/src/Internal/Connection/Framer.php @@ -21,7 +21,7 @@ /** @var ConcurrentIterator */ private ConcurrentIterator $iterator; - public function __construct(Socket $socket) + public function __construct(Socket $socket, bool $upgradeTls = false) { $this->writer = new Writer($socket); @@ -29,10 +29,44 @@ public function __construct(Socket $socket) $queue = new Queue(); $this->iterator = $queue->iterate(); - EventLoop::queue(static function () use ($socket, $queue): void { + EventLoop::queue(static function () use ($socket, $queue, $upgradeTls): void { $parser = new Protocol\Parser($queue->push(...)); try { + // TLS upgrade (NATS `tls_required`, e.g. Synadia NGS). The server + // sends a plaintext INFO first, then expects the client to run the + // TLS handshake before CONNECT. We do BOTH here — in the single + // fiber that owns socket reads — so setupTls() never races another + // read (amphp forbids concurrent reads on a socket). We read up to + // the end of the plaintext INFO line, hand those bytes to the + // parser (so startup() still gets the INFO frame + its nonce), then + // upgrade in place. NGS sends nothing between INFO and the upgrade, + // so no post-INFO plaintext bytes are lost. After this the loop + // below reads the encrypted stream exactly as normal. + if ($upgradeTls) { + $preamble = ''; + while (!str_contains($preamble, "\r\n")) { + $bytes = $socket->read(); + if ($bytes === null) { + $parser->cancel(); + $queue->complete(); + $socket->close(); + return; + } + $preamble .= $bytes; + } + // Upgrade BEFORE handing the INFO frame to the parser/queue. + // Order matters: parser->push() makes the INFO frame available + // to startup() (a DIFFERENT fiber), which then immediately + // writes CONNECT. If we pushed before setupTls() completed, that + // CONNECT would be written mid-TLS-handshake and corrupt the + // stream (server resets). setupTls() is synchronous-until-done + // here, so once it returns the channel is encrypted and it's + // safe to release the INFO frame. + $socket->setupTls(); + $parser->push($preamble); + } + while (($bytes = $socket->read()) !== null) { $parser->push($bytes); } diff --git a/src/Internal/Connection/SocketConnection.php b/src/Internal/Connection/SocketConnection.php index c5173e8..7eaba22 100644 --- a/src/Internal/Connection/SocketConnection.php +++ b/src/Internal/Connection/SocketConnection.php @@ -40,7 +40,10 @@ public function __construct( private readonly Config $config, private readonly Socket $socket, ) { - $this->framer = new Framer($this->socket); + // When a TLS context is configured, the Framer upgrades the socket to + // TLS inside its reader fiber (after the plaintext INFO, before its read + // loop) so the single socket reader also owns the handshake. + $this->framer = new Framer($this->socket, $this->config->tls !== null); $this->hooks = new Hooks\ConcurrentProvider(); $this->pingpongs = new PingPongHandler($this); $this->signer = new Signer(); @@ -55,6 +58,12 @@ public function __construct( */ public function startup(): void { + // The TLS upgrade (when configured) happens inside the Framer's reader + // fiber, BEFORE its read loop — see Framer. That keeps the socket's + // single-reader invariant: reading the plaintext INFO and running the + // TLS handshake are done by the one fiber that owns socket reads, so + // startup() here is unchanged from stock — it just reads the (now + // possibly TLS-delivered) INFO frame and sends CONNECT. $frame = $this->framer->readFrame() ?? throw new ConnectionIsNotAvailable(); if (!$frame instanceof Protocol\ServerInfo) { diff --git a/src/Internal/Connection/SocketConnectionFactory.php b/src/Internal/Connection/SocketConnectionFactory.php index e92c14f..a5b9d18 100644 --- a/src/Internal/Connection/SocketConnectionFactory.php +++ b/src/Internal/Connection/SocketConnectionFactory.php @@ -29,6 +29,14 @@ public static function fromConfig(Config $config): self $context = $context->withTcpNoDelay(); } + // Attach the TLS context so the socket can be upgraded later. We do NOT + // connect over TLS immediately: NATS sends a plaintext INFO first, then + // the client upgrades. The upgrade itself runs in the Framer's reader + // fiber; setting the context here just makes setupTls() available to it. + if ($config->tls !== null) { + $context = $context->withTlsContext($config->tls); + } + return new self($config, $context); } From df124ba10b7895069a347a0ddcbe32ab8a19c8b8 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 2 Jul 2026 13:54:36 -0400 Subject: [PATCH 2/6] test(tls): cover TLS enablement from tls:// scheme + fromArray tls key - fromURI tls://, nats+tls://, ssl:// -> ClientTlsContext with host as peer name - non-tls scheme leaves tls null (backward compat) - fromArray accepts a tls key Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/ConfigTest.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 5ebfe52..06b62d4 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -188,6 +188,32 @@ public function testFromURI(string $uri, Config $config): void self::assertEquals($config, Config::fromURI($uri)); } + /** + * @param non-empty-string $uri + */ + #[TestWith(['tls://connect.ngs.global:4222', 'connect.ngs.global'])] + #[TestWith(['nats+tls://example.com:4222', 'example.com'])] + #[TestWith(['ssl://nats.internal:4222', 'nats.internal'])] + public function testFromURIEnablesTlsFromScheme(string $uri, string $peerName): void + { + $config = Config::fromURI($uri); + + self::assertNotNull($config->tls); + self::assertEquals(new \Amp\Socket\ClientTlsContext($peerName), $config->tls); + } + + public function testFromURIWithoutTlsSchemeLeavesTlsNull(): void + { + self::assertNull(Config::fromURI('tcp://127.0.0.1:4222')->tls); + } + + public function testFromArrayAcceptsTlsContext(): void + { + $tls = new \Amp\Socket\ClientTlsContext('nats.example.com'); + + self::assertSame($tls, Config::fromArray(['tls' => $tls])->tls); + } + public function testFromArrayWithJwtAndNkey(): void { $config = Config::fromArray([ From a24cbe2be9b7dd45f222c9ec5b905d26408c0e6b Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 2 Jul 2026 13:55:06 -0400 Subject: [PATCH 3/6] docs(tls): document TLS connections in README Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 7626169..e518425 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,29 @@ Pure non-blocking (fiber based) strictly typed full-featured PHP driver for NATS composer require thesis/nats ``` +## TLS + +For servers that require TLS (for example [Synadia NGS](https://www.synadia.com/), `tls://connect.ngs.global`), use a `tls://` URI — TLS is enabled automatically with the host as the peer name for certificate verification: + +```php +$nc = new Nats\Client( + Nats\Config::fromURI('tls://connect.ngs.global:4222?jwt=...&nkey=...'), +); +``` + +To customise the TLS parameters (CA file, peer verification, minimum version, …), pass an `Amp\Socket\ClientTlsContext` explicitly: + +```php +use Amp\Socket\ClientTlsContext; + +$nc = new Nats\Client(new Nats\Config( + urls: ['nats.internal:4222'], + tls: (new ClientTlsContext('nats.internal'))->withCaFile('/etc/ssl/ca.pem'), +)); +``` + +The client follows the standard NATS upgrade-after-`INFO` flow: it reads the plaintext `INFO`, then performs the TLS handshake before sending `CONNECT`. Servers configured with `tls { handshake_first: true }` are not currently supported. + ## Nats Core The library implements the full functionality of NATS Core, including pub-sub, queues and request–reply. From 67cf8959149588aad9fed35b59d48a5e6bf6cbb9 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 28 Jul 2026 15:17:05 -0400 Subject: [PATCH 4/6] refactor: address review feedback - import ClientTlsContext instead of using the FQCN inline - drop the explanatory comment in SocketConnection::startup() Co-Authored-By: Claude Opus 5 (1M context) --- src/Config.php | 8 +++++--- src/Internal/Connection/SocketConnection.php | 6 ------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Config.php b/src/Config.php index 7c0eb7a..aba7ac8 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,6 +4,8 @@ namespace Thesis\Nats; +use Amp\Socket\ClientTlsContext; + /** * @api */ @@ -58,7 +60,7 @@ public function __construct( // set, the connection upgrades to TLS after the server's plaintext INFO // (the standard NATS tls_required flow, e.g. Synadia NGS). The upgrade is // performed by the socket's single reader fiber — see Framer. - public ?\Amp\Socket\ClientTlsContext $tls = null, + public ?ClientTlsContext $tls = null, ) { $this->version = '0.1.x'; } @@ -87,7 +89,7 @@ public static function fromURI(#[\SensitiveParameter] string $uri): self $scheme = strtolower($components['scheme'] ?? ''); if (($scheme === 'tls' || $scheme === 'nats+tls' || $scheme === 'ssl') && ($components['host'] ?? '') !== '') { $firstHost = explode(':', explode(',', $components['host'])[0])[0]; - $tls = (new \Amp\Socket\ClientTlsContext($firstHost)); + $tls = new ClientTlsContext($firstHost); } @@ -213,7 +215,7 @@ public static function fromURI(#[\SensitiveParameter] string $uri): self * max_pings?: positive-int, * jetstream_domain?: non-empty-string, * client_name?: non-empty-string, - * tls?: \Amp\Socket\ClientTlsContext, + * tls?: ClientTlsContext, * } $options */ public static function fromArray(#[\SensitiveParameter] array $options): self diff --git a/src/Internal/Connection/SocketConnection.php b/src/Internal/Connection/SocketConnection.php index 7eaba22..e34d69d 100644 --- a/src/Internal/Connection/SocketConnection.php +++ b/src/Internal/Connection/SocketConnection.php @@ -58,12 +58,6 @@ public function __construct( */ public function startup(): void { - // The TLS upgrade (when configured) happens inside the Framer's reader - // fiber, BEFORE its read loop — see Framer. That keeps the socket's - // single-reader invariant: reading the plaintext INFO and running the - // TLS handshake are done by the one fiber that owns socket reads, so - // startup() here is unchanged from stock — it just reads the (now - // possibly TLS-delivered) INFO frame and sends CONNECT. $frame = $this->framer->readFrame() ?? throw new ConnectionIsNotAvailable(); if (!$frame instanceof Protocol\ServerInfo) { From 6963f33016df5087511ef16fddf077d3a6e71c28 Mon Sep 17 00:00:00 2001 From: ChrisFrizza6969 Date: Tue, 28 Jul 2026 15:24:57 -0400 Subject: [PATCH 5/6] Update src/Config.php Co-authored-by: Vadim Zanfir --- src/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.php b/src/Config.php index aba7ac8..1b24d80 100644 --- a/src/Config.php +++ b/src/Config.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Thesis\Nats; - +use Amp\Socket\ClientTlsContext; use Amp\Socket\ClientTlsContext; /** From 62c582ba6d5e324384d6a396c5cf1ae6c44778fe Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 28 Jul 2026 15:26:47 -0400 Subject: [PATCH 6/6] fix: remove duplicated ClientTlsContext import The accepted suggestion was authored against the pre-import file and added a second identical use statement, which is a fatal error. Co-Authored-By: Claude Opus 5 (1M context) --- src/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.php b/src/Config.php index 1b24d80..aba7ac8 100644 --- a/src/Config.php +++ b/src/Config.php @@ -3,7 +3,7 @@ declare(strict_types=1); namespace Thesis\Nats; -use Amp\Socket\ClientTlsContext; + use Amp\Socket\ClientTlsContext; /**