Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/Server/TCP/Swoole.php
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,10 @@ public function onReceive(Server $server, int $fd, string $data, int $port): voi
$this->connections[$fd] = $connection;
}

// Handle PostgreSQL STARTTLS: SSLRequest comes before the real startup message.
if ($this->tlsContext !== null && $port === 5432 && TLS::isPostgreSQLSSLRequest($data)) {
$server->send($fd, TLS::PG_SSL_RESPONSE_OK);
$connection->pendingTls = true;
$sslResponse = $this->postgreSQLSSLResponse($data, $port);
if ($sslResponse !== null) {
$server->send($fd, $sslResponse);
$connection->pendingTls = $sslResponse === TLS::PG_SSL_RESPONSE_OK;

return;
}
Expand Down Expand Up @@ -393,6 +393,19 @@ protected function forward(Server $server, int $clientFd, Client $backend): void
});
}

private function postgreSQLSSLResponse(string $data, int $port): ?string
{
if (!\in_array($port, [5432, 6432], true) || !TLS::isPostgreSQLSSLRequest($data)) {
return null;
}

if ($this->tlsContext !== null) {
return TLS::PG_SSL_RESPONSE_OK;
}

return TLS::PG_SSL_RESPONSE_REJECT;
}

public function onClose(Server $server, int $fd, int $reactorId): void
{
if ($this->config->logConnections) {
Expand Down
62 changes: 47 additions & 15 deletions src/Server/TCP/Swoole/Coroutine.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@
* on most workloads — this one is kept for users who need coroutine-level
* control over each connection (e.g. custom protocol state machines).
*
* Supports optional TLS termination:
* - PostgreSQL: STARTTLS via SSLRequest/SSLResponse handshake
* - MySQL: SSL capability flag in server greeting
* Supports optional PostgreSQL TLS termination via the wire protocol
* SSLRequest/SSLResponse handshake.
*
* Example:
* ```php
Expand All @@ -50,6 +49,8 @@ class Coroutine

protected ?Resolver $resolver;

protected ?int $gcTimer = null;

public function __construct(
?Resolver $resolver = null,
?Config $config = null,
Expand Down Expand Up @@ -106,10 +107,11 @@ protected function configureServers(): void
'log_level' => $this->config->logLevel,
]);

$ssl = $this->tlsContext !== null;

foreach ($this->config->ports as $port) {
$server = new CoroutineServer($this->config->host, $port, $ssl, $this->config->enableReusePort);
// Database wire TLS is negotiated after plaintext protocol bytes.
// A TLS listener would expect ClientHello first and close
// PostgreSQL SSLRequest before PHP can respond with 'S'.
$server = new CoroutineServer($this->config->host, $port, false, $this->config->enableReusePort);

$settings = [
'open_tcp_nodelay' => true,
Expand All @@ -122,10 +124,6 @@ protected function configureServers(): void
'buffer_output_size' => $this->config->bufferOutputSize,
];

if ($this->tlsContext !== null) {
$settings = \array_merge($settings, $this->tlsContext->toSwooleConfig());
}

$server->set($settings);

$server->handle(function (Connection $connection) use ($port): void {
Expand Down Expand Up @@ -159,7 +157,7 @@ public function onStart(): void
public function onWorkerStart(int $workerId = 0): void
{
\gc_disable();
Timer::tick($this->config->gcIntervalMs, static function (): void {
$this->gcTimer = Timer::tick($this->config->gcIntervalMs, static function (): void {
\gc_collect_cycles();
});

Expand Down Expand Up @@ -188,10 +186,19 @@ protected function handleConnection(Connection $connection, int $port): void
return;
}

// PostgreSQL STARTTLS: clients send an SSLRequest before the startup
// message. Respond with 'S' and read the real startup packet.
if ($this->tlsContext !== null && $port === 5432 && TLS::isPostgreSQLSSLRequest($data)) {
$clientSocket->sendAll(TLS::PG_SSL_RESPONSE_OK);
if (\in_array($port, [5432, 6432], true) && TLS::isPostgreSQLSSLRequest($data)) {
if ($this->tlsContext === null) {
$clientSocket->sendAll(TLS::PG_SSL_RESPONSE_REJECT);
$clientSocket->close();

return;
}
Comment thread
abnegate marked this conversation as resolved.
Outdated

if ($clientSocket->sendAll(TLS::PG_SSL_RESPONSE_OK) === false || !$this->startTLS($clientSocket)) {
$clientSocket->close();

return;
}

/** @var string|false $data */
$data = $clientSocket->recv($bufferSize);
Expand Down Expand Up @@ -261,6 +268,19 @@ protected function handleConnection(Connection $connection, int $port): void
}
}

protected function startTLS(Socket $socket): bool
{
if ($this->tlsContext === null) {
return false;
}

if (!$socket->setProtocol($this->tlsContext->toSwooleProtocolConfig())) {
return false;
}

return $socket->sslHandshake();
}

public function start(): void
{
$runner = function (): void {
Expand All @@ -283,6 +303,18 @@ public function start(): void
SwooleCoroutine\run($runner);
}

public function shutdown(): void
{
if ($this->gcTimer !== null) {
Timer::clear($this->gcTimer);
$this->gcTimer = null;
}

foreach ($this->servers as $server) {
$server->shutdown();
}
}

/**
* Report whether the JIT is actually enabled inside this Swoole worker.
*
Expand Down
18 changes: 18 additions & 0 deletions src/Server/TCP/TLSContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ public function toSwooleConfig(): array
return $config;
}

/**
* Build protocol settings for upgrading an accepted coroutine socket.
*
* Swoole server SSL sockets expect TLS immediately on accept. Database
* protocols such as PostgreSQL negotiate TLS after an initial plaintext
* packet, so those sockets need a plaintext listener followed by
* Socket::setProtocol() and Socket::sslHandshake().
*
* @return array<string, mixed>
*/
public function toSwooleProtocolConfig(): array
{
$config = $this->toSwooleConfig();
$config['open_ssl'] = true;

return $config;
}

/**
* Build a PHP stream context resource for SSL connections
*
Expand Down
12 changes: 12 additions & 0 deletions tests/TLSContextTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ public function testToSwooleConfigWithCustomCiphers(): void
$this->assertSame($customCiphers, $config['ssl_ciphers']);
}

public function testToSwooleProtocolConfigEnablesSocketUpgrade(): void
{
$tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key');
$ctx = new TLSContext($tls);

$config = $ctx->toSwooleProtocolConfig();

$this->assertTrue($config['open_ssl']);
$this->assertSame('/certs/server.crt', $config['ssl_cert_file']);
$this->assertSame('/certs/server.key', $config['ssl_key_file']);
}

public function testToStreamContextReturnsResource(): void
{
$tls = new TLS(certificate: '/certs/server.crt', key: '/certs/server.key');
Expand Down
54 changes: 54 additions & 0 deletions tests/TLSTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace Utopia\Tests;

use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Utopia\Proxy\Server\TCP\Swoole as TCPServer;
use Utopia\Proxy\Server\TCP\TLS;

class TLSTest extends TestCase
Expand Down Expand Up @@ -270,6 +272,24 @@ public function testIsPostgreSQLSSLRequestWithRegularStartupMessage(): void
$this->assertFalse(TLS::isPostgreSQLSSLRequest($startup));
}

public function testPostgreSQLSSLRequestIsRejectedWhenTlsIsDisabled(): void
{
$server = $this->tcpServerWithoutConstructor();

$this->assertSame(TLS::PG_SSL_RESPONSE_REJECT, $this->postgreSQLSSLResponse($server, TLS::PG_SSL_REQUEST, 5432));
$this->assertSame(TLS::PG_SSL_RESPONSE_REJECT, $this->postgreSQLSSLResponse($server, TLS::PG_SSL_REQUEST, 6432));
}

public function testPostgreSQLSSLResponseIgnoresNonSslPackets(): void
{
$server = $this->tcpServerWithoutConstructor();

$startup = "\x00\x00\x00\x08\x00\x03\x00\x00";

$this->assertNull($this->postgreSQLSSLResponse($server, $startup, 5432));
$this->assertNull($this->postgreSQLSSLResponse($server, TLS::PG_SSL_REQUEST, 3306));
}

public function testIsMySQLSSLRequestWithValidData(): void
{
// Build a valid MySQL SSL request: 36+ bytes, sequence ID 1, SSL flag set
Expand Down Expand Up @@ -346,4 +366,38 @@ public function testIsMySQLSSLRequestWithLargerPacket(): void
$data[5] = "\x08";
$this->assertTrue(TLS::isMySQLSSLRequest($data));
}

public function testCoroutineServerUsesPlainListenerAndSocketUpgradeForPostgreSQLStartTls(): void
{
$source = \file_get_contents(__DIR__ . '/../src/Server/TCP/Swoole/Coroutine.php');

$this->assertIsString($source);
$this->assertStringContainsString('new CoroutineServer($this->config->host, $port, false, $this->config->enableReusePort)', $source);
$this->assertStringContainsString('$clientSocket->sendAll(TLS::PG_SSL_RESPONSE_OK)', $source);
$this->assertStringContainsString('protected function startTLS(Socket $socket): bool', $source);
$this->assertStringContainsString('$socket->setProtocol($this->tlsContext->toSwooleProtocolConfig())', $source);
$this->assertStringContainsString('$socket->sslHandshake()', $source);
}

private function tcpServerWithoutConstructor(): TCPServer
{
$reflection = new ReflectionClass(TCPServer::class);
$server = $reflection->newInstanceWithoutConstructor();
$property = $reflection->getProperty('tlsContext');
$property->setValue($server, null);

return $server;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

private function postgreSQLSSLResponse(TCPServer $server, string $data, int $port): ?string
{
$reflection = new ReflectionClass(TCPServer::class);
$method = $reflection->getMethod('postgreSQLSSLResponse');
$response = $method->invoke($server, $data, $port);

$this->assertTrue(\is_string($response) || $response === null);

return $response;
}

}
Loading