From fb4b37a0b91708e78a8434e551d4851d74b775e5 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 18 Oct 2026 10:46:14 -0400 Subject: [PATCH 1/7] Move the tunnel worker's start-and-wait into WorkerProcess Verbatim move of PhpseclibTunnelDriver's child-process launch, READY handshake, PHP binary lookup and autoloader path, so the TLS relay can start the same way. PhpseclibTunnel and its driver's behaviour are unchanged. --- src/Tunnels/Drivers/PhpseclibTunnelDriver.php | 73 ++--------- src/Tunnels/Support/WorkerProcess.php | 121 ++++++++++++++++++ 2 files changed, 129 insertions(+), 65 deletions(-) create mode 100644 src/Tunnels/Support/WorkerProcess.php diff --git a/src/Tunnels/Drivers/PhpseclibTunnelDriver.php b/src/Tunnels/Drivers/PhpseclibTunnelDriver.php index 4ca6776..12d444d 100644 --- a/src/Tunnels/Drivers/PhpseclibTunnelDriver.php +++ b/src/Tunnels/Drivers/PhpseclibTunnelDriver.php @@ -4,18 +4,14 @@ namespace Simtabi\Laranail\DBConsole\Tunnels\Drivers; -use ReflectionClass; +use RuntimeException; use phpseclib4\Net\SSH2; -use Composer\Autoload\ClassLoader; -use Symfony\Component\Process\Process; -use Symfony\Component\Process\InputStream; -use Simtabi\Laranail\DBConsole\Tunnels\Endpoint; use Simtabi\Laranail\DBConsole\Tunnels\TunnelSpec; -use Symfony\Component\Process\PhpExecutableFinder; use Simtabi\Laranail\DBConsole\Tunnels\SshSettings; use Simtabi\Laranail\DBConsole\Enums\TunnelDriverType; use Simtabi\Laranail\DBConsole\Exceptions\TunnelFailed; use Simtabi\Laranail\DBConsole\Tunnels\Contracts\Tunnel; +use Simtabi\Laranail\DBConsole\Tunnels\Support\WorkerProcess; use Simtabi\Laranail\DBConsole\Tunnels\Contracts\TunnelDriver; /** @@ -45,7 +41,7 @@ public function unavailableReason(SshSettings $ssh): ?string return 'phpseclib is not installed: composer require phpseclib/phpseclib:^4.0.1'; } - return $this->php() === null ? 'no PHP CLI binary was found to run the tunnel process' : null; + return WorkerProcess::php() === null ? 'no PHP CLI binary was found to run the tunnel process' : null; } public function open(TunnelSpec $spec): Tunnel @@ -55,35 +51,15 @@ public function open(TunnelSpec $spec): Tunnel throw TunnelFailed::forServer($spec->server, $reason); } - $input = new InputStream; - $process = new Process([(string) $this->php(), self::script(), $this->autoloader()]); - $process->setInput($input); - $process->setTimeout(null); - $process->start(); - - $input->write(json_encode($this->settings($spec), JSON_THROW_ON_ERROR) . "\n"); - - $local = $this->awaitReady($process, $spec); - if (! $local instanceof Endpoint) { - $input->close(); - $process->stop(1); + try { + $worker = WorkerProcess::start(self::script(), $this->settings($spec), $spec->ssh->connectTimeout + 10, 'phpseclib tunnel'); + } catch (RuntimeException $e) { $spec->directory->remove(); - $stderr = trim((string) preg_replace('/\s+/', ' ', $process->getErrorOutput())); - - throw TunnelFailed::forServer( - $spec->server, - $stderr !== '' ? $stderr : "the phpseclib tunnel process exited with status {$process->getExitCode()}", - ); + throw TunnelFailed::forServer($spec->server, $e->getMessage(), $e); } - return new PhpseclibTunnel($process, $input, $local, $spec->directory); - } - - private function autoloader(): string - { - // vendor/composer/ClassLoader.php -> vendor/autoload.php - return dirname((string) new ReflectionClass(ClassLoader::class)->getFileName(), 2) . '/autoload.php'; + return new PhpseclibTunnel($worker->process, $worker->input, $worker->local, $spec->directory); } /** @@ -105,37 +81,4 @@ private function settings(TunnelSpec $spec): array 'keepalive' => $spec->ssh->keepAlive, ]; } - - private function awaitReady(Process $process, TunnelSpec $spec): ?Endpoint - { - $deadline = microtime(true) + $spec->ssh->connectTimeout + 10; - - while (microtime(true) < $deadline) { - $output = $process->getOutput(); - - if (preg_match('/^READY tcp (\S+) (\d+)$/m', $output, $match) === 1) { - return Endpoint::tcp($match[1], (int) $match[2]); - } - - if (preg_match('/^READY unix (\S+)$/m', $output, $match) === 1) { - return Endpoint::socket($match[1]); - } - - if (! $process->isRunning()) { - return null; - } - - usleep(50_000); - } - - return null; - } - - private function php(): ?string - { - // Under php-fpm, PHP_BINARY is the fpm binary; the finder looks past it. - $php = new PhpExecutableFinder()->find(false); - - return is_string($php) && $php !== '' ? $php : null; - } } diff --git a/src/Tunnels/Support/WorkerProcess.php b/src/Tunnels/Support/WorkerProcess.php new file mode 100644 index 0000000..cb4be7b --- /dev/null +++ b/src/Tunnels/Support/WorkerProcess.php @@ -0,0 +1,121 @@ + ` or `READY unix ` once it + * listens; and it exits when its stdin closes, so it never outlives the + * process that started it. + */ +final readonly class WorkerProcess +{ + private function __construct( + public Process $process, + public InputStream $input, + public Endpoint $local, + ) {} + + /** + * Start the script and wait until it listens. + * + * @param array $settings + * + * @throws RuntimeException naming why the worker did not come up + */ + public static function start(string $script, array $settings, int $readyWithinSeconds, string $label): self + { + $php = self::php() ?? throw new RuntimeException('no PHP CLI binary was found to run the ' . $label . ' process'); + + $input = new InputStream; + $process = new Process([$php, $script, self::autoloader()]); + $process->setInput($input); + $process->setTimeout(null); + $process->start(); + + $input->write(json_encode($settings, JSON_THROW_ON_ERROR) . "\n"); + + $local = self::awaitReady($process, $readyWithinSeconds); + if (! $local instanceof Endpoint) { + $input->close(); + $process->stop(1); + + $stderr = trim((string) preg_replace('/\s+/', ' ', $process->getErrorOutput())); + + throw new RuntimeException( + $stderr !== '' ? $stderr : "the {$label} process exited with status {$process->getExitCode()}", + ); + } + + return new self($process, $input, $local); + } + + public static function php(): ?string + { + // Under php-fpm, PHP_BINARY is the fpm binary; the finder looks past it. + $php = new PhpExecutableFinder()->find(false); + + return is_string($php) && $php !== '' ? $php : null; + } + + public function isRunning(): bool + { + return $this->process->isRunning(); + } + + /** + * Closing stdin is how a worker is told to stop; stop() is only for one + * that does not. + */ + public function stop(): void + { + $this->input->close(); + $this->process->stop(3); + } + + private static function awaitReady(Process $process, int $seconds): ?Endpoint + { + $deadline = microtime(true) + $seconds; + + while (microtime(true) < $deadline) { + $output = $process->getOutput(); + + if (preg_match('/^READY tcp (\S+) (\d+)$/m', $output, $match) === 1) { + return Endpoint::tcp($match[1], (int) $match[2]); + } + + if (preg_match('/^READY unix (\S+)$/m', $output, $match) === 1) { + return Endpoint::socket($match[1]); + } + + if (! $process->isRunning()) { + return null; + } + + usleep(50_000); + } + + return null; + } + + private static function autoloader(): string + { + // vendor/composer/ClassLoader.php -> vendor/autoload.php + return dirname((string) new ReflectionClass(ClassLoader::class)->getFileName(), 2) . '/autoload.php'; + } +} From c2e11637b3b87a9e1278be959d559365aba8e5b2 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 18 Oct 2026 10:46:14 -0400 Subject: [PATCH 2/7] Add the MySQL handshake edits a verifying TLS relay needs Packet framing and parsing across split reads, clearing CLIENT_SSL in the greeting, the SSLRequest built from the client's response, the response renumbered after it, the end of the connection phase, and an ERR packet the client reports. Pure functions, tested without a network. --- src/Tunnels/Relay/MysqlHandshake.php | 143 ++++++++++++++++++ src/Tunnels/Relay/MysqlPacket.php | 44 ++++++ src/Tunnels/Relay/MysqlPacketReader.php | 43 ++++++ .../Unit/Tunnels/Relay/MysqlHandshakeTest.php | 91 +++++++++++ 4 files changed, 321 insertions(+) create mode 100644 src/Tunnels/Relay/MysqlHandshake.php create mode 100644 src/Tunnels/Relay/MysqlPacket.php create mode 100644 src/Tunnels/Relay/MysqlPacketReader.php create mode 100644 tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php diff --git a/src/Tunnels/Relay/MysqlHandshake.php b/src/Tunnels/Relay/MysqlHandshake.php new file mode 100644 index 0000000..926c903 --- /dev/null +++ b/src/Tunnels/Relay/MysqlHandshake.php @@ -0,0 +1,143 @@ +payload, $offset); + $upper = strlen($greeting->payload) >= $offset + 7 ? self::uint16($greeting->payload, $offset + 5) : 0; + + return $lower | ($upper << 16); + } + + /** + * The greeting with CLIENT_SSL cleared, so the real client never asks the + * relay for TLS. The relay does TLS to the server itself. + */ + public static function withoutSsl(MysqlPacket $greeting): MysqlPacket + { + $offset = self::lowerCapabilityOffset($greeting); + $lower = self::uint16($greeting->payload, $offset) & ~self::CLIENT_SSL; + + $payload = $greeting->payload; + $payload[$offset] = chr($lower & 0xFF); + $payload[$offset + 1] = chr(($lower >> 8) & 0xFF); + + return new MysqlPacket($greeting->sequence, $payload); + } + + /** + * The SSLRequest the relay sends the server in the client's place: the + * client's capabilities with CLIENT_SSL, its max packet size and charset, + * and 23 bytes of filler. It takes the response's own sequence number. + */ + public static function sslRequest(MysqlPacket $response): MysqlPacket + { + $capabilities = self::clientCapabilities($response); + + return new MysqlPacket( + $response->sequence, + pack('V', $capabilities | self::CLIENT_SSL) . substr($response->payload, 4, 5) . str_repeat("\0", 23), + ); + } + + /** + * The client's handshake response as the server expects it after the + * SSLRequest: CLIENT_SSL set, and one sequence number later. + */ + public static function afterSslRequest(MysqlPacket $response): MysqlPacket + { + $capabilities = self::clientCapabilities($response); + + return new MysqlPacket( + $response->sequence + 1, + pack('V', $capabilities | self::CLIENT_SSL) . substr($response->payload, 4), + ); + } + + /** + * OK or ERR closes the connection phase. More auth data (0x01) or an auth + * switch (0xFE) means the exchange continues. + */ + public static function endsConnectionPhase(MysqlPacket $serverPacket): bool + { + return in_array($serverPacket->first(), [0x00, 0xFF], true); + } + + /** + * An ERR packet the client reports as the reason its connection failed. + */ + public static function error(int $sequence, string $message): MysqlPacket + { + return new MysqlPacket($sequence, "\xff" . pack('v', self::SSL_CONNECTION_ERROR) . '#08S01' . $message); + } + + private static function clientCapabilities(MysqlPacket $response): int + { + if (strlen($response->payload) < 32) { + throw new UnexpectedValueException('the client handshake response is too short'); + } + + $capabilities = self::uint16($response->payload, 0) | (self::uint16($response->payload, 2) << 16); + if (($capabilities & self::CLIENT_PROTOCOL_41) === 0) { + throw new UnexpectedValueException('the client does not speak MySQL protocol 4.1, which TLS needs'); + } + + return $capabilities; + } + + private static function lowerCapabilityOffset(MysqlPacket $greeting): int + { + $payload = $greeting->payload; + $end = strpos($payload, "\0", 1); + + if ($greeting->first() !== 10 || $end === false || strlen($payload) < $end + 1 + 4 + 8 + 1 + 2) { + throw new UnexpectedValueException('not a MySQL protocol 10 server greeting'); + } + + // Version string and its NUL, connection id, auth data part 1, filler. + return $end + 1 + 4 + 8 + 1; + } + + private static function uint16(string $bytes, int $offset): int + { + return ord($bytes[$offset]) | (ord($bytes[$offset + 1]) << 8); + } +} diff --git a/src/Tunnels/Relay/MysqlPacket.php b/src/Tunnels/Relay/MysqlPacket.php new file mode 100644 index 0000000..6522e72 --- /dev/null +++ b/src/Tunnels/Relay/MysqlPacket.php @@ -0,0 +1,44 @@ + self::MAX_PAYLOAD) { + throw new InvalidArgumentException('a MySQL packet payload is at most 16 MiB - 1'); + } + } + + public function encode(): string + { + $length = strlen($this->payload); + + return chr($length & 0xFF) . chr(($length >> 8) & 0xFF) . chr($length >> 16) + . chr($this->sequence & 0xFF) . $this->payload; + } + + public function withSequence(int $sequence): self + { + return new self($sequence & 0xFF, $this->payload); + } + + public function first(): int + { + return $this->payload === '' ? -1 : ord($this->payload[0]); + } +} diff --git a/src/Tunnels/Relay/MysqlPacketReader.php b/src/Tunnels/Relay/MysqlPacketReader.php new file mode 100644 index 0000000..953dfaf --- /dev/null +++ b/src/Tunnels/Relay/MysqlPacketReader.php @@ -0,0 +1,43 @@ +buffer .= $bytes; + } + + public function next(): ?MysqlPacket + { + if (strlen($this->buffer) < 4) { + return null; + } + + $length = ord($this->buffer[0]) | (ord($this->buffer[1]) << 8) | (ord($this->buffer[2]) << 16); + if (strlen($this->buffer) < 4 + $length) { + return null; + } + + $packet = new MysqlPacket(ord($this->buffer[3]), substr($this->buffer, 4, $length)); + $this->buffer = (string) substr($this->buffer, 4 + $length); + + return $packet; + } + + /** + * Bytes read past the last whole packet. + */ + public function pending(): string + { + return $this->buffer; + } +} diff --git a/tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php b/tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php new file mode 100644 index 0000000..781911e --- /dev/null +++ b/tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php @@ -0,0 +1,91 @@ +> 16) + . "\x15" . str_repeat("\0", 10) . str_repeat('b', 12) . "\0" . "caching_sha2_password\0"); +} + +function handshakeResponse(int $capabilities): MysqlPacket +{ + return new MysqlPacket(1, pack('V', $capabilities) . pack('V', 16_777_216) . "\xff" . str_repeat("\0", 23) + . "root\0" . "\x20" . str_repeat('s', 32) . "db_console_test\0" . "caching_sha2_password\0"); +} + +it('frames and parses packets, across reads split anywhere', function (): void { + $wire = new MysqlPacket(3, 'hello')->encode() . new MysqlPacket(4, str_repeat('x', 300))->encode(); + $reader = new MysqlPacketReader; + + $packets = []; + foreach (str_split($wire, 7) as $chunk) { + $reader->feed($chunk); + while (($packet = $reader->next()) !== null) { + $packets[] = $packet; + } + } + + expect($wire)->toStartWith("\x05\x00\x00\x03hello") + ->and($packets)->toHaveCount(2) + ->and($packets[0]->sequence)->toBe(3) + ->and($packets[1]->payload)->toBe(str_repeat('x', 300)) + ->and($reader->pending())->toBe(''); +}); + +it('hides TLS from the client, which then talks plaintext to the relay', function (): void { + $caps = MysqlHandshake::CLIENT_PROTOCOL_41 | MysqlHandshake::CLIENT_SSL | 0x0008_0000; + $plain = MysqlHandshake::withoutSsl(greeting($caps)); + + expect(MysqlHandshake::serverSupportsSsl(greeting($caps)))->toBeTrue() + ->and(MysqlHandshake::serverSupportsSsl($plain))->toBeFalse() + ->and(MysqlHandshake::capabilities($plain))->toBe($caps & ~MysqlHandshake::CLIENT_SSL) + ->and(strlen($plain->payload))->toBe(strlen(greeting($caps)->payload)); +}); + +it('builds the SSLRequest from the client response, then renumbers the response after it', function (): void { + $caps = MysqlHandshake::CLIENT_PROTOCOL_41 | 0x0008_0000; + $response = handshakeResponse($caps); + + $request = MysqlHandshake::sslRequest($response); + $forwarded = MysqlHandshake::afterSslRequest($response); + + expect($request->sequence)->toBe(1) + ->and(strlen($request->payload))->toBe(32) + ->and(unpack('V', $request->payload)[1])->toBe($caps | MysqlHandshake::CLIENT_SSL) + ->and(substr($request->payload, 4, 5))->toBe(substr($response->payload, 4, 5)) + ->and($forwarded->sequence)->toBe(2) + ->and(unpack('V', $forwarded->payload)[1])->toBe($caps | MysqlHandshake::CLIENT_SSL) + ->and(substr($forwarded->payload, 4))->toBe(substr($response->payload, 4)); +}); + +it('refuses a client response without the 4.1 protocol', function (): void { + expect(fn (): MysqlPacket => MysqlHandshake::sslRequest(handshakeResponse(0))) + ->toThrow(UnexpectedValueException::class, 'protocol 4.1'); +}); + +it('ends the connection phase on OK or ERR, not on an auth switch or more data', function (string $first, bool $ends): void { + expect(MysqlHandshake::endsConnectionPhase(new MysqlPacket(2, $first . 'rest')))->toBe($ends); +})->with([ + 'OK' => ["\x00", true], + 'ERR' => ["\xff", true], + 'auth more data' => ["\x01", false], + 'auth switch' => ["\xfe", false], +]); + +it('writes an ERR packet the client reports as its own error', function (): void { + $packet = MysqlHandshake::error(2, 'TLS to db.example.com failed: certificate did not match'); + + expect($packet->sequence)->toBe(2) + ->and($packet->payload)->toStartWith("\xff" . pack('v', 2026) . '#08S01') + ->and($packet->payload)->toEndWith('certificate did not match'); +}); From 1dcc3d28d5d1823c89efe4ff548db7bde6ebf21e Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 18 Oct 2026 11:02:51 -0400 Subject: [PATCH 3/7] Add a MySQL TLS relay that verifies the server's real name pdo_mysql checks a certificate against the host it dials, and mysqlnd has no peer-name option, so through a tunnel it can only compare 127.0.0.1. The relay, a PHP worker process, takes the client's place in MySQL's in-protocol TLS upgrade: it hides TLS from the client's greeting, sends the SSLRequest itself, starts TLS with peer_name set to the real host and the configured CA (and client certificate), renumbers the connection-phase packets across the extra SSLRequest, then passes bytes through. A failed verification reaches the client as a MySQL ERR naming why. End to end over Unix sockets against a fake TLS MySQL server, in every pest job that can listen: a login with a full-auth round trip, a command, and a certificate for another name refused. --- resources/bin/mysql-tls-relay.php | 27 ++ src/Tunnels/Relay/MysqlPacketReader.php | 2 +- src/Tunnels/Relay/MysqlTlsRelay.php | 437 ++++++++++++++++++ tests/Feature/Tunnels/MysqlTlsRelayTest.php | 124 +++++ tests/Fixtures/fake-mysql-tls-server.php | 100 ++++ .../Unit/Tunnels/Relay/MysqlHandshakeTest.php | 2 +- 6 files changed, 690 insertions(+), 2 deletions(-) create mode 100644 resources/bin/mysql-tls-relay.php create mode 100644 src/Tunnels/Relay/MysqlTlsRelay.php create mode 100644 tests/Feature/Tunnels/MysqlTlsRelayTest.php create mode 100644 tests/Fixtures/fake-mysql-tls-server.php diff --git a/resources/bin/mysql-tls-relay.php b/resources/bin/mysql-tls-relay.php new file mode 100644 index 0000000..497e2b1 --- /dev/null +++ b/resources/bin/mysql-tls-relay.php @@ -0,0 +1,27 @@ +`; settings arrive on stdin, + * and the process exits when its stdin closes. + */ + +use Simtabi\Laranail\DBConsole\Tunnels\Relay\MysqlTlsRelay; + +if (PHP_SAPI !== 'cli' || ! isset($argv[1]) || ! is_file($argv[1])) { + fwrite(STDERR, "usage: php mysql-tls-relay.php \n"); + exit(2); +} + +require $argv[1]; + +$line = fgets(STDIN); +$settings = is_string($line) ? json_decode($line, true) : null; +if (! is_array($settings)) { + fwrite(STDERR, "expected the relay settings as one JSON line on stdin\n"); + exit(2); +} + +exit(new MysqlTlsRelay($settings, STDIN, STDOUT)->run()); diff --git a/src/Tunnels/Relay/MysqlPacketReader.php b/src/Tunnels/Relay/MysqlPacketReader.php index 953dfaf..1bef6e1 100644 --- a/src/Tunnels/Relay/MysqlPacketReader.php +++ b/src/Tunnels/Relay/MysqlPacketReader.php @@ -28,7 +28,7 @@ public function next(): ?MysqlPacket } $packet = new MysqlPacket(ord($this->buffer[3]), substr($this->buffer, 4, $length)); - $this->buffer = (string) substr($this->buffer, 4 + $length); + $this->buffer = substr($this->buffer, 4 + $length); return $packet; } diff --git a/src/Tunnels/Relay/MysqlTlsRelay.php b/src/Tunnels/Relay/MysqlTlsRelay.php new file mode 100644 index 0000000..3b50ee3 --- /dev/null +++ b/src/Tunnels/Relay/MysqlTlsRelay.php @@ -0,0 +1,437 @@ + client and server, by client id */ + private array $pairs = []; + + /** + * @param array{ + * listen: string, + * upstream: array{socket: string}|array{host: string, port: int}, + * peer_name: string, + * tls: array{cafile?: ?string, local_cert?: ?string, local_pk?: ?string}, + * timeout: int + * } $settings + * @param resource $stdin + * @param resource $stdout + */ + public function __construct(private readonly array $settings, private $stdin, private $stdout) {} + + public function run(): int + { + $socket = $this->settings['listen']; + + $umask = umask(0o177); + try { + $listener = @stream_socket_server("unix://{$socket}", $errno, $error); + } finally { + umask($umask); + } + + if ($listener === false) { + fwrite(STDERR, "could not listen on {$socket}: {$error}\n"); + + return 5; + } + + chmod($socket, 0o600); + fwrite($this->stdout, "READY unix {$socket}\n"); + fflush($this->stdout); + + try { + $this->loop($listener); + } finally { + fclose($listener); + @unlink($socket); + } + + return 0; + } + + /** + * @param resource $listener + */ + private function loop($listener): void + { + while (true) { + $read = [$listener, $this->stdin]; + foreach ($this->pairs as [$client, $server]) { + $read[] = $client; + $read[] = $server; + } + + $write = $except = null; + if (@stream_select($read, $write, $except, 1) === false) { + continue; + } + + foreach ($read as $stream) { + if ($stream === $this->stdin) { + if (fgets($this->stdin) === false || feof($this->stdin)) { + $this->closeAll(); + + return; + } + } elseif ($stream === $listener) { + $this->accept($listener); + } else { + $this->pump($stream); + } + } + } + } + + /** + * @param resource $listener + */ + private function accept($listener): void + { + $client = @stream_socket_accept($listener, 0); + if ($client === false) { + return; + } + + try { + $server = $this->upgrade($client); + } catch (Throwable $e) { + fwrite(STDERR, "relay refused a connection: {$e->getMessage()}\n"); + @fclose($client); + + return; + } + + stream_set_blocking($client, false); + stream_set_blocking($server, false); + $this->pairs[(int) $client] = [$client, $server]; + } + + /** + * The connection phase, done synchronously: greeting, TLS to the server + * with verification, the client's response, then authentication, with + * each packet renumbered across the extra SSLRequest. Returns the server + * stream once the server has sent OK. + * + * @param resource $client + * + * @return resource + */ + private function upgrade($client) + { + $timeout = max(1, $this->settings['timeout']); + stream_set_timeout($client, $timeout); + + $server = $this->connectUpstream($timeout); + $fromServer = new MysqlPacketReader; + $fromClient = new MysqlPacketReader; + + $greeting = $this->readPacket($server, $fromServer); + if (! MysqlHandshake::serverSupportsSsl($greeting)) { + $this->refuse($client, 0, "TLS to {$this->settings['peer_name']} is required, and the server does not offer it"); + + throw new RuntimeException('the server does not offer TLS'); + } + + $this->send($client, MysqlHandshake::withoutSsl($greeting)); + $response = $this->readPacket($client, $fromClient); + + $this->send($server, MysqlHandshake::sslRequest($response)); + + $failure = $this->startTls($server); + if ($failure !== null) { + $this->refuse($client, $response->sequence + 1, "TLS to {$this->settings['peer_name']} failed: {$failure}"); + @fclose($server); + + throw new RuntimeException($failure); + } + + $this->send($server, MysqlHandshake::afterSslRequest($response)); + + // Authentication: either side may speak next (fast auth success is + // followed by OK with nothing from the client), so wait on both. + stream_set_blocking($client, false); + stream_set_blocking($server, false); + + while (true) { + if (($packet = $fromServer->next()) instanceof MysqlPacket) { + $this->send($client, $packet->withSequence($packet->sequence - 1)); + + if (! MysqlHandshake::endsConnectionPhase($packet)) { + continue; + } + + if ($packet->first() === 0xFF) { + @fclose($server); + + throw new RuntimeException('the server refused the login'); + } + + // Commands number from 0 on both sides, so from here bytes pass + // as they are. Anything already read past OK goes on with them. + $this->writeAll($server, $fromClient->pending()); + $this->writeAll($client, $fromServer->pending()); + + return $server; + } + + if (($packet = $fromClient->next()) instanceof MysqlPacket) { + $this->send($server, $packet->withSequence($packet->sequence + 1)); + + continue; + } + + $this->waitForEither($client, $server, $fromClient, $fromServer, $timeout); + } + } + + /** + * Wait until either side has bytes, and read everything it has. A TLS + * stream is drained to empty, since select() cannot see what the TLS + * layer has already decrypted. + * + * @param resource $client + * @param resource $server + */ + private function waitForEither($client, $server, MysqlPacketReader $fromClient, MysqlPacketReader $fromServer, int $timeout): void + { + $read = [$client, $server]; + $write = $except = null; + + if (@stream_select($read, $write, $except, $timeout) < 1) { + throw new RuntimeException('authentication timed out'); + } + + foreach ($read as $stream) { + $bytes = $this->drain($stream); + if ($bytes === null) { + throw new RuntimeException('a side closed during authentication'); + } + + ($stream === $server ? $fromServer : $fromClient)->feed($bytes); + } + } + + /** + * Everything readable on a non-blocking stream now, or null at its end. + * + * @param resource $stream + */ + private function drain($stream): ?string + { + $bytes = ''; + + while (true) { + $chunk = @fread($stream, 65_536); + + if ($chunk === false) { + return $bytes === '' ? null : $bytes; + } + + if ($chunk === '') { + return $bytes === '' && feof($stream) ? null : $bytes; + } + + $bytes .= $chunk; + } + } + + /** + * @return resource + */ + private function connectUpstream(int $timeout) + { + $upstream = $this->settings['upstream']; + $address = isset($upstream['socket']) + ? "unix://{$upstream['socket']}" + : "tcp://{$upstream['host']}:{$upstream['port']}"; + + $server = @stream_socket_client($address, $errno, $error, $timeout); + if ($server === false) { + throw new RuntimeException("could not reach the tunnel at {$address}: {$error}"); + } + + stream_set_timeout($server, $timeout); + + return $server; + } + + /** + * Start TLS on the server stream, verifying the certificate against the + * database's real name. Null on success, otherwise why it failed. + * + * @param resource $server + */ + private function startTls($server): ?string + { + $tls = $this->settings['tls']; + $options = array_filter([ + 'peer_name' => $this->settings['peer_name'], + 'verify_peer' => true, + 'verify_peer_name' => true, + 'allow_self_signed' => false, + 'SNI_enabled' => true, + 'cafile' => $tls['cafile'] ?? null, + 'local_cert' => $tls['local_cert'] ?? null, + 'local_pk' => $tls['local_pk'] ?? null, + ], static fn (mixed $value): bool => $value !== null); + + foreach ($options as $key => $value) { + stream_context_set_option($server, 'ssl', $key, $value); + } + + $error = null; + set_error_handler(static function (int $level, string $message) use (&$error): bool { + $error = (string) preg_replace('/^stream_socket_enable_crypto\(\): /', '', $message); + + return true; + }); + + try { + stream_set_blocking($server, true); + $ok = stream_socket_enable_crypto( + $server, + true, + STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + ); + } finally { + restore_error_handler(); + } + + if ($ok === true) { + return null; + } + + return $error ?? 'the TLS handshake failed'; + } + + /** + * @param resource $stream + */ + private function readPacket($stream, MysqlPacketReader $reader): MysqlPacket + { + while (! ($packet = $reader->next()) instanceof MysqlPacket) { + $bytes = fread($stream, 65_536); + if ($bytes === false || $bytes === '') { + throw new UnexpectedValueException( + stream_get_meta_data($stream)['timed_out'] ? 'timed out during the handshake' : 'closed during the handshake', + ); + } + + $reader->feed($bytes); + } + + return $packet; + } + + /** + * @param resource $client + */ + private function refuse($client, int $sequence, string $message): void + { + @fwrite($client, MysqlHandshake::error($sequence, $message)->encode()); + } + + /** + * @param resource $stream + */ + private function send($stream, MysqlPacket $packet): void + { + $this->writeAll($stream, $packet->encode()); + } + + /** + * Copy what is waiting on one side of a pair to the other, draining the + * TLS layer's buffer too: select() cannot see bytes it has decrypted. + * + * @param resource $from + */ + private function pump($from): void + { + foreach ($this->pairs as $id => [$client, $server]) { + if ($from !== $client && $from !== $server) { + continue; + } + + $to = $from === $client ? $server : $client; + $bytes = $this->drain($from); + + if ($bytes === null) { + $this->drop($id); + + return; + } + + try { + $this->writeAll($to, $bytes); + } catch (RuntimeException) { + $this->drop($id); + } + + return; + } + } + + /** + * @param resource $stream + */ + private function writeAll($stream, string $bytes): void + { + while ($bytes !== '') { + $written = @fwrite($stream, $bytes); + + if ($written === false) { + throw new RuntimeException('write failed'); + } + + if ($written === 0) { + $read = $except = null; + $write = [$stream]; + if (@stream_select($read, $write, $except, 5) < 1) { + throw new RuntimeException('write timed out'); + } + + continue; + } + + $bytes = substr($bytes, $written); + } + } + + private function drop(int $id): void + { + [$client, $server] = $this->pairs[$id]; + @fclose($client); + @fclose($server); + unset($this->pairs[$id]); + } + + private function closeAll(): void + { + foreach (array_keys($this->pairs) as $id) { + $this->drop($id); + } + } +} diff --git a/tests/Feature/Tunnels/MysqlTlsRelayTest.php b/tests/Feature/Tunnels/MysqlTlsRelayTest.php new file mode 100644 index 0000000..3012ded --- /dev/null +++ b/tests/Feature/Tunnels/MysqlTlsRelayTest.php @@ -0,0 +1,124 @@ +dir = sys_get_temp_dir() . '/dbcr-' . bin2hex(random_bytes(3)); + mkdir($this->dir, 0o700); + + $probe = @stream_socket_server("unix://{$this->dir}/probe.sock", $errno, $error); + if ($probe === false || new Process(['openssl', 'version'])->run() !== 0) { + getenv('CI') !== false + ? $this->fail('the relay test needs local listening and openssl in CI') + : $this->markTestSkipped('needs local listening and the openssl CLI'); + } + fclose($probe); + @unlink("{$this->dir}/probe.sock"); + + $run = function (array $command): void { + $process = new Process($command, $this->dir); + $process->mustRun(); + }; + file_put_contents("{$this->dir}/ext", "subjectAltName=DNS:mytls\nbasicConstraints=CA:FALSE\n"); + $run(['openssl', 'req', '-x509', '-nodes', '-newkey', 'rsa:2048', '-days', '1', '-subj', '/CN=relay test CA', '-keyout', 'ca.key', '-out', 'ca.crt']); + $run(['openssl', 'req', '-nodes', '-newkey', 'rsa:2048', '-subj', '/CN=mytls', '-keyout', 'server.key', '-out', 'server.csr']); + $run(['openssl', 'x509', '-req', '-in', 'server.csr', '-CA', 'ca.crt', '-CAkey', 'ca.key', '-CAcreateserial', '-days', '1', '-out', 'server.crt', '-extfile', 'ext']); + + $this->server = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/Fixtures/fake-mysql-tls-server.php', "{$this->dir}/db.sock", "{$this->dir}/server.crt", "{$this->dir}/server.key"]); + $this->server->start(); + $this->server->waitUntil(fn (string $type, string $output): bool => str_contains($output, 'READY')); +}); + +afterEach(function (): void { + ($this->relay ?? null)?->stop(); + ($this->server ?? null)?->stop(1); + foreach (glob("{$this->dir}/*") ?: [] as $file) { + @unlink($file); + } + @rmdir($this->dir); +}); + +function startRelay(object $test, string $peerName): WorkerProcess +{ + return $test->relay = WorkerProcess::start(dirname(__DIR__, 3) . '/resources/bin/mysql-tls-relay.php', [ + 'listen' => "{$test->dir}/relay.sock", + 'upstream' => ['socket' => "{$test->dir}/db.sock"], + 'peer_name' => $peerName, + 'tls' => ['cafile' => "{$test->dir}/ca.crt"], + 'timeout' => 5, + ], 10, 'MySQL TLS relay'); +} + +/** + * @param resource $stream + */ +function readMysqlPacket($stream, MysqlPacketReader $reader): MysqlPacket +{ + while (! ($packet = $reader->next()) instanceof MysqlPacket) { + $bytes = fread($stream, 8192); + if ($bytes === false || $bytes === '') { + throw new RuntimeException('the relay closed the connection'); + } + $reader->feed($bytes); + } + + return $packet; +} + +it('verifies the server, hides TLS from the client, and renumbers the login', function (): void { + $relay = startRelay($this, 'mytls'); + $client = stream_socket_client("unix://{$relay->local->socket}", $errno, $error, 5); + stream_set_timeout($client, 5); + $reader = new MysqlPacketReader; + + $greeting = readMysqlPacket($client, $reader); + expect($greeting->sequence)->toBe(0) + ->and(MysqlHandshake::serverSupportsSsl($greeting))->toBeFalse(); + + // A plaintext login, as a client that saw no TLS on offer sends it. + fwrite($client, new MysqlPacket(1, pack('V', 0x0200 | 0x0008_0000) . pack('V', 1 << 24) . "\xff" . str_repeat("\0", 23) . "root\0\0")->encode()); + + $more = readMysqlPacket($client, $reader); + expect($more->sequence)->toBe(2)->and($more->payload)->toBe("\x01\x04"); + + fwrite($client, new MysqlPacket(3, "secret\0")->encode()); + expect(readMysqlPacket($client, $reader)->sequence)->toBe(4); + + // Commands are not renumbered. + fwrite($client, new MysqlPacket(0, "\x03SELECT 1")->encode()); + $reply = readMysqlPacket($client, $reader); + + expect($reply->sequence)->toBe(1) + ->and($reply->payload)->toEndWith("echo:SELECT 1:secret\0"); +}); + +it('refuses a server whose certificate does not name the host, and tells the client why', function (): void { + $relay = startRelay($this, 'mytls-alias'); + $client = stream_socket_client("unix://{$relay->local->socket}", $errno, $error, 5); + stream_set_timeout($client, 5); + $reader = new MysqlPacketReader; + + readMysqlPacket($client, $reader); + fwrite($client, new MysqlPacket(1, pack('V', 0x0200) . pack('V', 1 << 24) . "\xff" . str_repeat("\0", 23) . "root\0\0")->encode()); + + $error = readMysqlPacket($client, $reader); + + expect($error->first())->toBe(0xFF) + ->and($error->sequence)->toBe(2) + ->and($error->payload)->toContain('TLS to mytls-alias failed') + ->and($error->payload)->toContain('did not match'); +}); diff --git a/tests/Fixtures/fake-mysql-tls-server.php b/tests/Fixtures/fake-mysql-tls-server.php new file mode 100644 index 0000000..f55f787 --- /dev/null +++ b/tests/Fixtures/fake-mysql-tls-server.php @@ -0,0 +1,100 @@ + + */ + +[, $socket, $cert, $key] = $argv; + +function frame(int $seq, string $payload): string +{ + $n = strlen($payload); + + return chr($n & 0xFF) . chr(($n >> 8) & 0xFF) . chr($n >> 16) . chr($seq) . $payload; +} + +/** @return array{0: int, 1: string} */ +function packet($stream): array +{ + $header = ''; + while (strlen($header) < 4) { + $chunk = fread($stream, 4 - strlen($header)); + if ($chunk === false || $chunk === '') { + exit(3); + } + $header .= $chunk; + } + $length = ord($header[0]) | (ord($header[1]) << 8) | (ord($header[2]) << 16); + $payload = ''; + while (strlen($payload) < $length) { + $chunk = fread($stream, $length - strlen($payload)); + if ($chunk === false || $chunk === '') { + exit(3); + } + $payload .= $chunk; + } + + return [ord($header[3]), $payload]; +} + +function expectSequence(int $got, int $want, string $what): void +{ + if ($got !== $want) { + fwrite(STDERR, "{$what}: sequence {$got}, expected {$want}\n"); + exit(4); + } +} + +@unlink($socket); +$server = stream_socket_server("unix://{$socket}", $errno, $error, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, + stream_context_create(['ssl' => ['local_cert' => $cert, 'local_pk' => $key]])); +if ($server === false) { + fwrite(STDERR, "listen: {$error}\n"); + exit(2); +} +fwrite(STDOUT, "READY\n"); + +while ($conn = @stream_socket_accept($server, 30)) { + // Protocol 41 | SSL | plugin auth, in the lower and upper halves. + $caps = 0x0200 | 0x0800 | 0x8000 | 0x0008_0000; + fwrite($conn, frame(0, "\x0a" . "8.4.0-fake\0" . pack('V', 1) . str_repeat('a', 8) . "\0" . pack('v', $caps & 0xFFFF) + . "\xff" . pack('v', 2) . pack('v', $caps >> 16) . "\x15" . str_repeat("\0", 10) . str_repeat('b', 12) . "\0" . "caching_sha2_password\0")); + + [$seq, $request] = packet($conn); + expectSequence($seq, 1, 'SSLRequest'); + if (strlen($request) !== 32 || ((unpack('V', $request)[1] & 0x0800) === 0)) { + fwrite(STDERR, "not an SSLRequest\n"); + exit(4); + } + + stream_set_blocking($conn, true); + if (! @stream_socket_enable_crypto($conn, true, STREAM_CRYPTO_METHOD_TLSv1_2_SERVER | STREAM_CRYPTO_METHOD_TLSv1_3_SERVER)) { + // The relay refused the certificate; it never gets a login. + fclose($conn); + + continue; + } + + [$seq, $response] = packet($conn); + expectSequence($seq, 2, 'handshake response'); + + fwrite($conn, frame(3, "\x01\x04")); // perform full authentication + [$seq, $password] = packet($conn); + expectSequence($seq, 4, 'password'); + + fwrite($conn, frame(5, "\x00\x00\x00\x02\x00\x00\x00")); // OK + + [$seq, $query] = packet($conn); + expectSequence($seq, 0, 'COM_QUERY'); + fwrite($conn, frame(1, "\x00\x00\x00\x02\x00\x00\x00" . 'echo:' . substr($query, 1) . ':' . $password)); + + fclose($conn); +} diff --git a/tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php b/tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php index 781911e..edb3fe0 100644 --- a/tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php +++ b/tests/Unit/Tunnels/Relay/MysqlHandshakeTest.php @@ -30,7 +30,7 @@ function handshakeResponse(int $capabilities): MysqlPacket $packets = []; foreach (str_split($wire, 7) as $chunk) { $reader->feed($chunk); - while (($packet = $reader->next()) !== null) { + while (($packet = $reader->next()) instanceof MysqlPacket) { $packets[] = $packet; } } From 591cbbee9c6264c1533a728725344743bb42676b Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 18 Oct 2026 11:02:51 -0400 Subject: [PATCH 4/7] Put the TLS relay in front of tunnels to verified MySQL servers When a MySQL-family server requires verified TLS and is reached across the network from the SSH host, TunnelManager starts the relay on a socket in the tunnel's private directory, and ServerRegistry points the admin connection at it with the client's own TLS settings removed (the PDO attributes and the keys db-tools reads). Postgres keeps hostaddr; a socket or loopback target, unverified TLS, and TLS off need no relay. A socket local end is now accepted for a relayed server, since the relay does TLS itself; it stays refused where the client would start TLS on it. The launcher is a contract so tests open relayed tunnels without a process; a relay that cannot start closes its tunnel and fails it. --- src/Providers/DBConsoleServiceProvider.php | 3 + src/Servers/ServerRegistry.php | 14 +++- src/Tunnels/Contracts/TerminatesTls.php | 18 +++++ src/Tunnels/Contracts/TlsRelayLauncher.php | 28 +++++++ src/Tunnels/Relay/RelayedTunnel.php | 56 ++++++++++++++ src/Tunnels/Relay/VerifyingRelay.php | 71 ++++++++++++++++++ src/Tunnels/TunnelManager.php | 16 +++- src/Tunnels/TunnelledConnection.php | 28 +++++++ tests/Fixtures/FakeTlsRelayLauncher.php | 51 +++++++++++++ .../Unit/Servers/ServerRegistryTunnelTest.php | 73 ++++++++++++++++++- 10 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 src/Tunnels/Contracts/TerminatesTls.php create mode 100644 src/Tunnels/Contracts/TlsRelayLauncher.php create mode 100644 src/Tunnels/Relay/RelayedTunnel.php create mode 100644 src/Tunnels/Relay/VerifyingRelay.php create mode 100644 tests/Fixtures/FakeTlsRelayLauncher.php diff --git a/src/Providers/DBConsoleServiceProvider.php b/src/Providers/DBConsoleServiceProvider.php index 6334488..961aacc 100644 --- a/src/Providers/DBConsoleServiceProvider.php +++ b/src/Providers/DBConsoleServiceProvider.php @@ -58,6 +58,7 @@ use Simtabi\Laranail\DBConsole\Listeners\SendNotifications; use Simtabi\Laranail\DBConsole\Services\ProvisioningWizard; use Simtabi\Laranail\Package\Tools\Commands\InstallCommand; +use Simtabi\Laranail\DBConsole\Tunnels\Relay\VerifyingRelay; use Simtabi\Laranail\DBConsole\Authorization\DBConsolePolicy; use Simtabi\Laranail\DBConsole\Encryption\AtRestStatusReader; use Simtabi\Laranail\DBConsole\Secrets\Contracts\SecretStore; @@ -70,6 +71,7 @@ use Simtabi\Laranail\DBConsole\Services\Catalog\DbConsoleCatalog; use Simtabi\Laranail\DBConsole\Enums\RbacDriver as RbacDriverEnum; use Simtabi\Laranail\DBConsole\Secrets\Stores\DatabaseSecretStore; +use Simtabi\Laranail\DBConsole\Tunnels\Contracts\TlsRelayLauncher; use Simtabi\Laranail\Package\Tools\Providers\PackageServiceProvider; use Simtabi\Laranail\Package\Tools\Support\Definitions\AboutSectionDefinition; use Simtabi\Laranail\Package\Tools\Support\Definitions\InstallCommandDefinition; @@ -365,6 +367,7 @@ private function registerServices(): void $this->app->singleton(ServerRegistry::class); // One tunnel per server per process; closed when the app terminates. $this->app->singleton(TunnelManager::class); + $this->app->bindIf(TlsRelayLauncher::class, VerifyingRelay::class); $this->app->singleton(DBConsoleLogger::class, fn ($app): DBConsoleLogger => new DBConsoleLogger( $app->make(LoggerInterface::class), diff --git a/src/Servers/ServerRegistry.php b/src/Servers/ServerRegistry.php index 59ca8f8..dfe153e 100644 --- a/src/Servers/ServerRegistry.php +++ b/src/Servers/ServerRegistry.php @@ -22,8 +22,10 @@ use Simtabi\Laranail\DBConsole\Exceptions\UnknownServer; use Simtabi\Laranail\DBConsole\Tunnels\TunnelledConnection; use Simtabi\Laranail\DBConsole\Exceptions\ServerUnreachable; +use Simtabi\Laranail\DBConsole\Tunnels\Relay\VerifyingRelay; use Simtabi\Laranail\DBConsole\Exceptions\ServerMisconfigured; use Simtabi\Laranail\DBConsole\Encryption\TlsConnectionOptions; +use Simtabi\Laranail\DBConsole\Tunnels\Contracts\TerminatesTls; /** * The multi-server entry point (section 5). Resolves a server name to an @@ -358,7 +360,11 @@ private function applyTunnel(ServerDefinition $definition, SshSettings $ssh): vo $remote = $this->tunnelTarget($definition->name) ?? throw new LogicException('unreachable: the server has an ssh block'); - if ($ssh->local === LocalEnd::Socket && $definition->tlsEnabled() && ! $remote->isLocalToItsHost()) { + // The relay does TLS itself, over whatever the tunnel's local end is, + // so only a client that would start TLS on a socket is refused. + $relayed = VerifyingRelay::appliesTo($definition, $remote, $driver); + + if ($ssh->local === LocalEnd::Socket && $definition->tlsEnabled() && ! $relayed && ! $remote->isLocalToItsHost()) { throw ServerMisconfigured::named( $definition->name, 'a Unix socket cannot carry TLS, so ssh.local=socket would leave the hop from the SSH host to ' @@ -370,6 +376,12 @@ private function applyTunnel(ServerDefinition $definition, SshSettings $ssh): vo try { $rewritten = TunnelledConnection::rewrite($config, $tunnel->local(), $remote); + + // TLS is the relay's, verified against the real name; the client + // talks plaintext to it over the tunnel's private socket. + if ($tunnel instanceof TerminatesTls) { + $rewritten = TunnelledConnection::withoutClientTls($rewritten); + } } catch (InvalidArgumentException $e) { $this->tunnels->close($definition->name); diff --git a/src/Tunnels/Contracts/TerminatesTls.php b/src/Tunnels/Contracts/TerminatesTls.php new file mode 100644 index 0000000..a16cdcc --- /dev/null +++ b/src/Tunnels/Contracts/TerminatesTls.php @@ -0,0 +1,18 @@ +close(); + } + + public function local(): Endpoint + { + return $this->relay->local; + } + + public function isAlive(): bool + { + return ! $this->closed && $this->relay->isRunning() && $this->tunnel->isAlive(); + } + + public function close(): void + { + if ($this->closed) { + return; + } + + $this->closed = true; + $this->relay->stop(); + $this->tunnel->close(); + } + + public function verifiedName(): string + { + return $this->verifiedName; + } +} diff --git a/src/Tunnels/Relay/VerifyingRelay.php b/src/Tunnels/Relay/VerifyingRelay.php new file mode 100644 index 0000000..e51cabb --- /dev/null +++ b/src/Tunnels/Relay/VerifyingRelay.php @@ -0,0 +1,71 @@ +tlsEnabled() + && $definition->tls['verify'] + && ! $remote->isLocalToItsHost(); + } + + public static function script(): string + { + return dirname(__DIR__, 3) . '/resources/bin/mysql-tls-relay.php'; + } + + /** + * Put the relay in front of an open tunnel, on a socket in the tunnel's + * private directory, with the tunnel's local end as its upstream. + * + * @throws RuntimeException why the relay did not start + */ + public function launch( + ServerDefinition $definition, + Tunnel $tunnel, + Endpoint $remote, + PrivateDirectory $directory, + int $timeout, + ): RelayedTunnel { + $socket = $directory->file('mysql-tls.sock'); + $upstream = $tunnel->local(); + + $worker = WorkerProcess::start(self::script(), [ + 'listen' => $socket, + 'upstream' => $upstream->isSocket() + ? ['socket' => $upstream->socket] + : ['host' => $upstream->host, 'port' => $upstream->port], + 'peer_name' => (string) $remote->host, + 'tls' => [ + 'cafile' => $definition->tls['ca'], + 'local_cert' => $definition->tls['cert'], + 'local_pk' => $definition->tls['key'], + ], + 'timeout' => $timeout, + ], $timeout + 5, 'MySQL TLS relay'); + + return new RelayedTunnel($tunnel, $worker, (string) $remote->host); + } +} diff --git a/src/Tunnels/TunnelManager.php b/src/Tunnels/TunnelManager.php index 66d94bf..472db5e 100644 --- a/src/Tunnels/TunnelManager.php +++ b/src/Tunnels/TunnelManager.php @@ -12,7 +12,9 @@ use Simtabi\Laranail\DBConsole\Exceptions\TunnelFailed; use Simtabi\Laranail\DBConsole\Servers\ServerDefinition; use Simtabi\Laranail\DBConsole\Tunnels\Contracts\Tunnel; +use Simtabi\Laranail\DBConsole\Tunnels\Relay\VerifyingRelay; use Simtabi\Laranail\DBConsole\Tunnels\Contracts\TunnelDriver; +use Simtabi\Laranail\DBConsole\Tunnels\Contracts\TlsRelayLauncher; use Simtabi\Laranail\DBConsole\Tunnels\Drivers\OpenSshTunnelDriver; use Simtabi\Laranail\DBConsole\Tunnels\Drivers\PhpseclibTunnelDriver; @@ -50,7 +52,19 @@ public function open(ServerDefinition $definition, Endpoint $remote, ?string $en $this->registerShutdown(); - return $this->tunnels[$definition->name] = $this->driver($ssh->driver)->open($spec); + $tunnel = $this->driver($ssh->driver)->open($spec); + + if (VerifyingRelay::appliesTo($definition, $remote, $engineDriver)) { + try { + $tunnel = $this->container->make(TlsRelayLauncher::class)->launch($definition, $tunnel, $remote, $spec->directory, $ssh->connectTimeout); + } catch (RuntimeException $e) { + $tunnel->close(); + + throw TunnelFailed::forServer($definition->name, "the MySQL TLS relay did not start: {$e->getMessage()}", $e); + } + } + + return $this->tunnels[$definition->name] = $tunnel; } public function isAlive(string $server): bool diff --git a/src/Tunnels/TunnelledConnection.php b/src/Tunnels/TunnelledConnection.php index 09cf8df..2ffd3f8 100644 --- a/src/Tunnels/TunnelledConnection.php +++ b/src/Tunnels/TunnelledConnection.php @@ -4,6 +4,7 @@ namespace Simtabi\Laranail\DBConsole\Tunnels; +use Pdo\Mysql; use InvalidArgumentException; use Illuminate\Database\ConfigurationUrlParser; @@ -78,6 +79,33 @@ public static function rewrite(array $config, Endpoint $local, Endpoint $remote) }; } + /** + * A MySQL connection with its own TLS settings removed, for a client that + * talks to the TLS relay: TLS is the relay's, and a client asking the + * relay for it would be refused. Both the PDO attributes and the keys + * db-tools reads for mysqldump go. + * + * @param array $config + * + * @return array + */ + public static function withoutClientTls(array $config): array + { + foreach (['ssl-ca', 'ssl_ca', 'ssl-capath', 'ssl_capath', 'ssl-cert', 'ssl_cert', 'ssl-key', 'ssl_key', + 'ssl-cipher', 'ssl_cipher', 'ssl-mode', 'ssl_mode'] as $key) { + unset($config[$key]); + } + + if (is_array($config['options'] ?? null) && extension_loaded('pdo_mysql')) { + foreach ([Mysql::ATTR_SSL_CA, Mysql::ATTR_SSL_CAPATH, Mysql::ATTR_SSL_CERT, Mysql::ATTR_SSL_KEY, + Mysql::ATTR_SSL_CIPHER, Mysql::ATTR_SSL_VERIFY_SERVER_CERT] as $attribute) { + unset($config['options'][$attribute]); + } + } + + return $config; + } + /** * Whether a connection reaches its server over a Unix socket: MySQL's * `unix_socket`, or a Postgres host that is a directory path. diff --git a/tests/Fixtures/FakeTlsRelayLauncher.php b/tests/Fixtures/FakeTlsRelayLauncher.php new file mode 100644 index 0000000..aa8132f --- /dev/null +++ b/tests/Fixtures/FakeTlsRelayLauncher.php @@ -0,0 +1,51 @@ + */ + public array $launched = []; + + public function launch(ServerDefinition $definition, Tunnel $tunnel, Endpoint $remote, PrivateDirectory $directory, int $timeout): Tunnel&TerminatesTls + { + $this->launched[] = ['server' => $definition->name, 'upstream' => $tunnel->local()->describe(), 'name' => (string) $remote->host]; + + return new readonly class($tunnel, Endpoint::socket($directory->file('mysql-tls.sock')), (string) $remote->host) implements TerminatesTls, Tunnel { + public function __construct(private Tunnel $tunnel, private Endpoint $local, private string $name) {} + + public function local(): Endpoint + { + return $this->local; + } + + public function isAlive(): bool + { + return $this->tunnel->isAlive(); + } + + public function close(): void + { + $this->tunnel->close(); + } + + public function verifiedName(): string + { + return $this->name; + } + }; + } +} diff --git a/tests/Unit/Servers/ServerRegistryTunnelTest.php b/tests/Unit/Servers/ServerRegistryTunnelTest.php index 91da9a4..e8abc0d 100644 --- a/tests/Unit/Servers/ServerRegistryTunnelTest.php +++ b/tests/Unit/Servers/ServerRegistryTunnelTest.php @@ -3,14 +3,21 @@ declare(strict_types=1); use Simtabi\Laranail\DBConsole\Secrets\Secret; +use Simtabi\Laranail\DBConsole\Tunnels\Endpoint; use Simtabi\Laranail\DBConsole\Secrets\SecretVault; use Simtabi\Laranail\DBConsole\Encryption\TlsChecker; use Simtabi\Laranail\DBConsole\Tunnels\TunnelManager; use Simtabi\Laranail\DBConsole\Servers\ServerRegistry; use Simtabi\Laranail\DBConsole\Exceptions\TunnelFailed; +use Simtabi\Laranail\DBConsole\Servers\ServerDefinition; +use Simtabi\Laranail\DBConsole\Tunnels\Contracts\Tunnel; +use Simtabi\Laranail\DBConsole\Tunnels\PrivateDirectory; use Simtabi\Laranail\DBConsole\Tests\Fixtures\TunnelKeys; use Simtabi\Laranail\DBConsole\Exceptions\ServerMisconfigured; use Simtabi\Laranail\DBConsole\Tests\Fixtures\FakeTunnelDriver; +use Simtabi\Laranail\DBConsole\Tunnels\Contracts\TerminatesTls; +use Simtabi\Laranail\DBConsole\Tunnels\Contracts\TlsRelayLauncher; +use Simtabi\Laranail\DBConsole\Tests\Fixtures\FakeTlsRelayLauncher; use Simtabi\Laranail\DBConsole\Tunnels\Drivers\OpenSshTunnelDriver; /** @@ -38,6 +45,9 @@ function tunnelledServer(array $ssh = [], array $tls = ['enabled' => false], arr beforeEach(function (): void { $this->driver = new FakeTunnelDriver; app()->instance(OpenSshTunnelDriver::class, $this->driver); + + $this->relays = new FakeTlsRelayLauncher; + app()->instance(TlsRelayLauncher::class, $this->relays); }); it('opens the tunnel on first use and points the admin connection at it', function (): void { @@ -162,13 +172,18 @@ public function driver(): string // The guard: TLS cannot cross a Unix socket, so this would quietly send // the SSH-host-to-database leg in plaintext while config says TLS. - it('refuses a socket local end that would drop required TLS on the onward hop', function (): void { - tunnelledServer(['local' => 'socket'], ['enabled' => true, 'verify' => true]); + it('refuses a socket local end that would drop required TLS on the onward hop', function (array $tls, array $connection): void { + tunnelledServer(['local' => 'socket'], $tls, $connection); expect(fn () => app(ServerRegistry::class)->resolve('behind-bastion')) ->toThrow(ServerMisconfigured::class, 'a Unix socket cannot carry TLS') ->and($this->driver->opened)->toBe([]); - }); + })->with([ + // Postgres verifies through hostaddr, which needs a TCP local end. + 'postgres, verified' => [['enabled' => true, 'verify' => true], ['driver' => 'pgsql', 'port' => 5432]], + // Unverified MySQL TLS is the client's own, and it cannot run on a socket. + 'mysql, not verified' => [['enabled' => true, 'verify' => false], []], + ]); it('allows a socket local end when the onward hop stays on the SSH host', function (): void { tunnelledServer(['local' => 'socket', 'remote_socket' => '/var/run/mysqld/mysqld.sock'], ['enabled' => true]); @@ -269,3 +284,55 @@ public function driver(): string expect(app(ServerRegistry::class)->databaseHost('split'))->toBe('db-a.ondigitalocean.com'); }); + +describe('the MySQL TLS relay', function (): void { + // pdo_mysql verifies against the host it dials, 127.0.0.1 through a + // tunnel. The relay verifies the real name and the client talks to it. + it('puts the relay in front of a tunnel to a verified MySQL', function (): void { + tunnelledServer(tls: ['enabled' => true, 'verify' => true, 'ca' => '/certs/ca.pem']); + + $connection = app(ServerRegistry::class)->connection('behind-bastion')->underlying(); + + expect($this->relays->launched)->toBe([['server' => 'behind-bastion', 'upstream' => '127.0.0.1:40000', 'name' => 'db.internal']]) + ->and($connection->getConfig('unix_socket'))->toBe($this->driver->opened[0]->directory->path . '/mysql-tls.sock') + ->and((array) $connection->getConfig('options'))->toBe([]); + }); + + it('relays a socket local end too, since the relay does TLS itself', function (): void { + tunnelledServer(['local' => 'socket'], ['enabled' => true, 'verify' => true]); + + app(ServerRegistry::class)->resolve('behind-bastion'); + + expect($this->relays->launched)->toHaveCount(1) + ->and($this->relays->launched[0]['upstream'])->toEndWith('/db.sock'); + }); + + it('leaves TLS to the client where it can verify, or where nothing needs verifying', function (array $ssh, array $tls, array $connection): void { + tunnelledServer($ssh, $tls, $connection); + + app(ServerRegistry::class)->resolve('behind-bastion'); + + expect($this->relays->launched)->toBe([]); + })->with([ + 'mysql, unverified TLS' => [[], ['enabled' => true, 'verify' => false], []], + 'mysql, TLS off' => [[], ['enabled' => false], []], + 'mysql to a remote socket' => [['remote_socket' => '/var/run/mysqld/mysqld.sock'], ['enabled' => true, 'verify' => true], []], + 'mysql to the SSH host' => [['remote_host' => '127.0.0.1'], ['enabled' => true, 'verify' => true], []], + 'postgres, verified' => [[], ['enabled' => true, 'verify' => true], ['driver' => 'pgsql', 'port' => 5432]], + ]); + + it('closes the tunnel when its relay cannot start', function (): void { + app()->instance(TlsRelayLauncher::class, new class implements TlsRelayLauncher + { + public function launch(ServerDefinition $definition, Tunnel $tunnel, Endpoint $remote, PrivateDirectory $directory, int $timeout): Tunnel&TerminatesTls + { + throw new RuntimeException('no PHP CLI binary was found'); + } + }); + tunnelledServer(tls: ['enabled' => true, 'verify' => true]); + + expect(fn () => app(ServerRegistry::class)->resolve('behind-bastion')) + ->toThrow(TunnelFailed::class, 'the MySQL TLS relay did not start: no PHP CLI binary was found') + ->and($this->driver->tunnels[0]->closed)->toBeTrue(); + }); +}); From 762558b0ddd32410c7c43894c691b3cf4d9d92de Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 18 Oct 2026 11:02:52 -0400 Subject: [PATCH 5/7] Prove the MySQL relay live against a server that refuses plaintext mytls, a MySQL 8.4 with require_secure_transport and a certificate for its own name from the job's CA, also answering to mytls-alias. Through both drivers: the session is TLS and required_ok, logins take caching_sha2's full and fast paths across the relay, a second session shares it, the alias is refused with the reason, and mysqldump backs up through it. --- .github/workflows/tests.yml | 32 ++++++++++++ tests/Feature/Tunnels/LiveSshTunnelTest.php | 57 +++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c818c7..a5eed84 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -425,6 +425,13 @@ jobs: openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 2 -out server.crt \ -extfile <(printf 'subjectAltName=DNS:pgtls\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\n') openssl x509 -in server.crt -noout -ext subjectAltName + # The same CA signs mytls, a MySQL whose certificate names mytls only. + mkdir -p "$RUNNER_TEMP/mytls" + openssl req -nodes -newkey rsa:2048 -subj "/CN=mytls" -keyout "$RUNNER_TEMP/mytls/server.key" -out "$RUNNER_TEMP/mytls/server.csr" + openssl x509 -req -in "$RUNNER_TEMP/mytls/server.csr" -CA ca.crt -CAkey ca.key -CAcreateserial -days 2 \ + -out "$RUNNER_TEMP/mytls/server.crt" \ + -extfile <(printf 'subjectAltName=DNS:mytls\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\n') + cp ca.crt "$RUNNER_TEMP/mytls/ca.crt" - name: Start PostgreSQL with TLS on the service network run: | docker run -d --name pgtls \ @@ -444,6 +451,29 @@ jobs: sleep 1 done docker exec pgtls pg_isready -h 127.0.0.1 -U db_console + # pdo_mysql verifies a certificate against the host it dials, which a + # tunnel makes 127.0.0.1; the TLS relay verifies the real name instead. + # This MySQL refuses plaintext outright (require_secure_transport), and + # also answers to mytls-alias, a name its certificate does not carry. + - name: Start MySQL with TLS on the service network + run: | + docker run -d --name mytls \ + --network "${{ job.services.postgres.network }}" \ + --network-alias mytls --network-alias mytls-alias \ + -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=db_console_test \ + -v "$RUNNER_TEMP/mytls:/certs:ro" \ + --entrypoint bash mysql:8.4 -euc ' + install -d -o mysql -g mysql -m 700 /etc/mytls + install -o mysql -g mysql -m 600 /certs/server.crt /certs/server.key /certs/ca.crt /etc/mytls/ + exec docker-entrypoint.sh mysqld --require-secure-transport=ON \ + --ssl-ca=/etc/mytls/ca.crt --ssl-cert=/etc/mytls/server.crt --ssl-key=/etc/mytls/server.key + ' + # TCP only answers once the entrypoint's socket-only init server is gone. + for attempt in $(seq 1 90); do + if docker exec mytls mysqladmin ping -h 127.0.0.1 -uroot -psecret --silent >/dev/null 2>&1; then break; fi + sleep 1 + done + docker exec mytls mysqladmin ping -h 127.0.0.1 -uroot -psecret - name: Install the PostgreSQL 18 client tools for pg_dump run: | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y >/dev/null @@ -457,6 +487,7 @@ jobs: DB_CONSOLE_LIVE_MYSQL_CA: ${{ runner.temp }}/mysql-ca.pem DB_CONSOLE_LIVE_PGTLS_CA: ${{ runner.temp }}/pgtls/ca.crt DB_CONSOLE_LIVE_PG_BIN: /usr/lib/postgresql/18/bin + DB_CONSOLE_LIVE_MYTLS_CA: ${{ runner.temp }}/mytls/ca.crt # Bounded so a hung tunnel fails the step (and the logs below still # run) instead of the job being cancelled at its own timeout. Events # stream as they happen, so a hang names the test it happened in; @@ -472,3 +503,4 @@ jobs: ps -eo pid,etime,args | grep -E 'ssh|php|pg_dump|psql' | grep -v grep || true docker logs sshd docker logs pgtls + docker logs mytls diff --git a/tests/Feature/Tunnels/LiveSshTunnelTest.php b/tests/Feature/Tunnels/LiveSshTunnelTest.php index c458a7d..ab2e4c6 100644 --- a/tests/Feature/Tunnels/LiveSshTunnelTest.php +++ b/tests/Feature/Tunnels/LiveSshTunnelTest.php @@ -299,3 +299,60 @@ function liveVerifiedTunnel(string $driver, string $host, array $ssh = []): stri expect($slowest)->toBeLessThan(1.0, sprintf('session A waited %.2fs after the server closed another session', $slowest)); })->with('drivers'); + +/* + * `mytls` is a MySQL 8.4 that refuses plaintext and has a certificate for the + * name `mytls` only, from the same CA; it also answers to `mytls-alias`. + * pdo_mysql would check the certificate against 127.0.0.1, so the TLS relay + * does the TLS step and checks the real name. + */ + +function liveVerifiedMysqlTunnel(string $driver, string $host): string +{ + $ca = (string) getenv('DB_CONSOLE_LIVE_MYTLS_CA'); + expect(is_file($ca))->toBeTrue("DB_CONSOLE_LIVE_MYTLS_CA must name the CA that signed mytls's certificate, got '{$ca}'"); + + return liveTunnel('mysql', $driver, [], ['enabled' => true, 'verify' => true, 'ca' => $ca], $host); +} + +it('verifies a MySQL certificate against the real name through the relay', function (string $driver): void { + $server = liveVerifiedMysqlTunnel($driver, 'mytls'); + $connection = app(ServerRegistry::class)->connection($server)->underlying(); + + // The first login on a fresh server is caching_sha2's full exchange; the + // second is its fast path. Both cross the relay's renumbering. + expect(liveScalar($server, 'SELECT DATABASE()'))->toBe('db_console_test') + ->and($connection->getConfig('unix_socket'))->toEndWith('/mysql-tls.sock') + ->and(app(TlsChecker::class)->status($server))->toBe(TlsStatus::RequiredOk) + ->and((string) ((array) $connection->selectOne("SHOW SESSION STATUS LIKE 'Ssl_version'"))['Value'])->toStartWith('TLS'); + + $second = app('db')->connectUsing("{$server}_second", $connection->getConfig(), true); + expect($second->selectOne('SELECT 41 + 1 AS answer')->answer)->toBe(42); + app('db')->purge("{$server}_second"); +})->with('drivers'); + +it('refuses a MySQL certificate that does not name the host, and says so', function (string $driver): void { + $server = liveVerifiedMysqlTunnel($driver, 'mytls-alias'); + + try { + app(ServerRegistry::class)->ensureReachable($server); + $this->fail('a certificate for mytls was accepted for mytls-alias'); + } catch (ServerUnreachable $e) { + expect((string) $e->getPrevious()?->getMessage()) + ->toContain('TLS to mytls-alias failed') + ->toContain('did not match'); + } +})->with('drivers'); + +it('backs up MySQL through the relay', function (string $driver): void { + app()->register(DbToolsServiceProvider::class); + config()->set('filesystems.disks.local', ['driver' => 'local', 'root' => sys_get_temp_dir() . "/db-console-live-mytls-{$driver}"]); + + $server = liveVerifiedMysqlTunnel($driver, 'mytls'); + liveScalar($server, 'CREATE TABLE IF NOT EXISTS relayed_backup_marker (id int primary key)'); + + $path = app(DbToolsBackupService::class)->snapshot($server, new DbName('db_console_test')); + + expect($path)->toBeString() + ->and(Storage::disk('local')->get((string) $path))->toContain('relayed_backup_marker'); +})->with('drivers'); From 1eac7531be2378c9348d52d84f5a91b7546f2c25 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 18 Oct 2026 11:02:52 -0400 Subject: [PATCH 6/7] Document the MySQL TLS relay and why SQL Server cannot have one --- CHANGELOG.md | 11 +++++++++++ UPGRADING.md | 18 ++++++++++++++++++ docs/tools/ssh-tunnels.md | 7 +++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a58eaa..00a6ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **MySQL and MariaDB verify the server's real name through an SSH tunnel.** pdo_mysql checks a + certificate against the host it dials, which through a tunnel is 127.0.0.1, so `tls.verify=true` + failed for every real certificate. A TLS relay (`Tunnels\Relay\MysqlTlsRelay`, a PHP process + started with the tunnel) now takes the client's place in MySQL's in-protocol TLS upgrade, + verifies the certificate against the database's name and `tls.ca`, and passes everything else + through; PDO talks to it in plaintext over a `0600` socket in the tunnel's `0700` directory. A + certificate that does not match fails the login with a MySQL error naming why. Proven live in the + `live-ssh` job against a MySQL that refuses plaintext, through both drivers. +- `Tunnels\Contracts\TlsRelayLauncher` (bound to `Tunnels\Relay\VerifyingRelay`) and + `Tunnels\Contracts\TerminatesTls`, and `Tunnels\Support\WorkerProcess`, the child-process launch + the phpseclib driver and the relay share. - **A failed wizard run takes back the adoptions it made.** When the provisioning wizard adopts an account or database and a later step fails, the rollback now stops recording that resource as managed (`AdoptionUndone`, a Security-category notification and webhook event). It still never diff --git a/UPGRADING.md b/UPGRADING.md index 12c4778..8b06502 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,23 @@ # Upgrading +## Unreleased + +### MySQL verifies the real name through an SSH tunnel + +A MySQL or MariaDB server reached through an SSH tunnel with `tls.verify=true` used to fail: pdo_mysql +checked the certificate against 127.0.0.1. It now connects through a TLS relay that verifies the +certificate against the server's real name, so the 0.1.1 advice below to set `tls.verify=false` for +such a server no longer applies. Set it back to `true`. + +Two things change for such a server: + +- **A PHP CLI binary is needed on the host**, to run the relay. It is the same requirement the + phpseclib driver has. `doctor` reports the tunnel as failed if the relay cannot start. +- **`ssh.local=socket` is accepted with TLS required**, since the relay does the TLS itself. + +SQL Server through a tunnel still cannot verify the real name, because pdo_sqlsrv offers no way to. +Keep `tls.verify=false` for it. + ## 0.1.3 ### Alerts keep their category's channels diff --git a/docs/tools/ssh-tunnels.md b/docs/tools/ssh-tunnels.md index f3aba39..e748828 100644 --- a/docs/tools/ssh-tunnels.md +++ b/docs/tools/ssh-tunnels.md @@ -81,8 +81,11 @@ With `local => 'socket'` the tunnel listens on a `0600` socket inside a `0700` d The `tls` block still applies, and through a TCP forward the session is TLS from PHP to the database. Two limits come from the clients, not from DBConsole: -- **Hostname verification.** The client dials 127.0.0.1. PostgreSQL keeps verifying the real name: with `sslmode=verify-full` the connection's host becomes `db.example.com;hostaddr=127.0.0.1`, which tells libpq to dial the forward and check the certificate against the name. pdo_mysql and SQL Server have no equivalent, so with `verify` on they check the certificate against 127.0.0.1 and fail unless it names that address. Through a tunnel to MySQL, either set `tls.verify=false` (the session is still encrypted, and the SSH host key is pinned) or forward to `remote_socket`. -- **A socket carries no TLS.** Neither libpq nor mysqlnd negotiates TLS over a Unix socket. `local => 'socket'` with `tls.enabled` and a target across the network is refused, because the hop from the SSH host to the database would be plaintext while config says TLS. A target that is a socket, or the loopback, on the SSH host never leaves that host, and `doctor` counts it as local. +- **Hostname verification.** The client dials 127.0.0.1, and a certificate checked against that name fails. DBConsole keeps the check on the real name for both engines that allow it: + - **PostgreSQL**: with `sslmode=verify-full` the connection's host becomes `db.example.com;hostaddr=127.0.0.1`, which tells libpq to dial the forward and check the certificate against the name. + - **MySQL and MariaDB**: pdo_mysql has no such option, so with `verify` on a **TLS relay** sits in front of the tunnel. It is a small PHP process that takes the client's place in MySQL's in-protocol TLS upgrade, verifies the certificate against the database's real name and `tls.ca` (and presents `tls.cert`/`tls.key` for mutual TLS), and passes everything else through. PDO talks to it in plaintext over a `0600` socket in the tunnel's `0700` directory. Everything past the relay is TLS, and `doctor` measures the session as encrypted. A certificate that does not match fails the login with a MySQL error naming why, for example `TLS to db.example.com failed: … did not match`. The relay needs a PHP CLI binary, as the phpseclib driver does. + - **SQL Server** cannot do it. pdo_sqlsrv has no `HostNameInCertificate` option, and its TLS handshake runs inside TDS packets, which no relay could take over. With `verify` on, a tunnel to SQL Server checks the certificate against 127.0.0.1. Set `tls.verify=false` for that server (the session is still encrypted, and the SSH host key is pinned). +- **A socket carries no TLS.** Neither libpq nor mysqlnd negotiates TLS over a Unix socket. `local => 'socket'` with `tls.enabled` and a target across the network is refused, unless the TLS relay does the TLS (MySQL with `verify` on), because the hop from the SSH host to the database would be plaintext while config says TLS. A target that is a socket, or the loopback, on the SSH host never leaves that host, and `doctor` counts it as local. ## Doctor From fe05104d586353b4bed44fdc53475646e00cdd63 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Sun, 18 Oct 2026 11:29:07 -0400 Subject: [PATCH 7/7] Run the relay's upstream over TCP, and type the packet length for PHP 8.5 PHP starts TLS only on tcp:// streams; on a unix:// one stream_socket_enable_crypto() fails with "This stream does not support SSL/crypto", which is how the end-to-end test failed in CI. The relay now reaches the tunnel over its loopback port only, the fake server listens on TCP, and a relayed server with ssh.local=socket is refused with that reason instead of failing at its first login. The client still talks to the relay over a private socket. On PHP 8.5 chr() takes int<0, 255>, which phpstan cannot prove for the packet length's top byte; the 3-byte length is now taken from pack('V'). --- UPGRADING.md | 4 +++- docs/tools/ssh-tunnels.md | 4 ++-- src/Servers/ServerRegistry.php | 12 ++++++++---- src/Tunnels/Relay/MysqlPacket.php | 6 ++---- src/Tunnels/Relay/MysqlTlsRelay.php | 8 ++++---- src/Tunnels/Relay/VerifyingRelay.php | 13 +++++++++---- tests/Feature/Tunnels/MysqlTlsRelayTest.php | 13 +++++++------ tests/Fixtures/fake-mysql-tls-server.php | 13 ++++++++----- tests/Unit/Servers/ServerRegistryTunnelTest.php | 12 +++++++----- 9 files changed, 50 insertions(+), 35 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 8b06502..489f1de 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -13,7 +13,9 @@ Two things change for such a server: - **A PHP CLI binary is needed on the host**, to run the relay. It is the same requirement the phpseclib driver has. `doctor` reports the tunnel as failed if the relay cannot start. -- **`ssh.local=socket` is accepted with TLS required**, since the relay does the TLS itself. +- **`ssh.local` must be `port`.** The relay reaches the tunnel over TCP, because PHP cannot start + TLS on a Unix socket, and `ssh.local=socket` is refused with that reason. The client still + talks to the relay over a private `0600` socket. SQL Server through a tunnel still cannot verify the real name, because pdo_sqlsrv offers no way to. Keep `tls.verify=false` for it. diff --git a/docs/tools/ssh-tunnels.md b/docs/tools/ssh-tunnels.md index e748828..b2316a2 100644 --- a/docs/tools/ssh-tunnels.md +++ b/docs/tools/ssh-tunnels.md @@ -83,9 +83,9 @@ The `tls` block still applies, and through a TCP forward the session is TLS from - **Hostname verification.** The client dials 127.0.0.1, and a certificate checked against that name fails. DBConsole keeps the check on the real name for both engines that allow it: - **PostgreSQL**: with `sslmode=verify-full` the connection's host becomes `db.example.com;hostaddr=127.0.0.1`, which tells libpq to dial the forward and check the certificate against the name. - - **MySQL and MariaDB**: pdo_mysql has no such option, so with `verify` on a **TLS relay** sits in front of the tunnel. It is a small PHP process that takes the client's place in MySQL's in-protocol TLS upgrade, verifies the certificate against the database's real name and `tls.ca` (and presents `tls.cert`/`tls.key` for mutual TLS), and passes everything else through. PDO talks to it in plaintext over a `0600` socket in the tunnel's `0700` directory. Everything past the relay is TLS, and `doctor` measures the session as encrypted. A certificate that does not match fails the login with a MySQL error naming why, for example `TLS to db.example.com failed: … did not match`. The relay needs a PHP CLI binary, as the phpseclib driver does. + - **MySQL and MariaDB**: pdo_mysql has no such option, so with `verify` on a **TLS relay** sits in front of the tunnel. It is a small PHP process that takes the client's place in MySQL's in-protocol TLS upgrade, verifies the certificate against the database's real name and `tls.ca` (and presents `tls.cert`/`tls.key` for mutual TLS), and passes everything else through. PDO talks to it in plaintext over a `0600` socket in the tunnel's `0700` directory. Everything past the relay is TLS, and `doctor` measures the session as encrypted. A certificate that does not match fails the login with a MySQL error naming why, for example `TLS to db.example.com failed: … did not match`. The relay needs a PHP CLI binary, as the phpseclib driver does, and `ssh.local=port`: it reaches the tunnel over TCP, since PHP cannot start TLS on a Unix socket. - **SQL Server** cannot do it. pdo_sqlsrv has no `HostNameInCertificate` option, and its TLS handshake runs inside TDS packets, which no relay could take over. With `verify` on, a tunnel to SQL Server checks the certificate against 127.0.0.1. Set `tls.verify=false` for that server (the session is still encrypted, and the SSH host key is pinned). -- **A socket carries no TLS.** Neither libpq nor mysqlnd negotiates TLS over a Unix socket. `local => 'socket'` with `tls.enabled` and a target across the network is refused, unless the TLS relay does the TLS (MySQL with `verify` on), because the hop from the SSH host to the database would be plaintext while config says TLS. A target that is a socket, or the loopback, on the SSH host never leaves that host, and `doctor` counts it as local. +- **A socket carries no TLS.** Neither libpq nor mysqlnd negotiates TLS over a Unix socket. `local => 'socket'` with `tls.enabled` and a target across the network is refused (for MySQL with `verify` on, because the relay can only start TLS over TCP), because the hop from the SSH host to the database would be plaintext while config says TLS. A target that is a socket, or the loopback, on the SSH host never leaves that host, and `doctor` counts it as local. ## Doctor diff --git a/src/Servers/ServerRegistry.php b/src/Servers/ServerRegistry.php index dfe153e..56c9015 100644 --- a/src/Servers/ServerRegistry.php +++ b/src/Servers/ServerRegistry.php @@ -360,11 +360,15 @@ private function applyTunnel(ServerDefinition $definition, SshSettings $ssh): vo $remote = $this->tunnelTarget($definition->name) ?? throw new LogicException('unreachable: the server has an ssh block'); - // The relay does TLS itself, over whatever the tunnel's local end is, - // so only a client that would start TLS on a socket is refused. - $relayed = VerifyingRelay::appliesTo($definition, $remote, $driver); + if ($ssh->local === LocalEnd::Socket && VerifyingRelay::appliesTo($definition, $remote, $driver)) { + throw ServerMisconfigured::named( + $definition->name, + 'verified MySQL TLS through a tunnel runs through the TLS relay, which reaches the tunnel over TCP ' + . '(PHP cannot start TLS on a Unix socket); use ssh.local=port. The client still talks to the relay over a private socket', + ); + } - if ($ssh->local === LocalEnd::Socket && $definition->tlsEnabled() && ! $relayed && ! $remote->isLocalToItsHost()) { + if ($ssh->local === LocalEnd::Socket && $definition->tlsEnabled() && ! $remote->isLocalToItsHost()) { throw ServerMisconfigured::named( $definition->name, 'a Unix socket cannot carry TLS, so ssh.local=socket would leave the hop from the SSH host to ' diff --git a/src/Tunnels/Relay/MysqlPacket.php b/src/Tunnels/Relay/MysqlPacket.php index 6522e72..37ea146 100644 --- a/src/Tunnels/Relay/MysqlPacket.php +++ b/src/Tunnels/Relay/MysqlPacket.php @@ -26,10 +26,8 @@ public function __construct( public function encode(): string { - $length = strlen($this->payload); - - return chr($length & 0xFF) . chr(($length >> 8) & 0xFF) . chr($length >> 16) - . chr($this->sequence & 0xFF) . $this->payload; + // The length is 3 bytes little-endian: the low 3 of a 32-bit one. + return substr(pack('V', strlen($this->payload)), 0, 3) . pack('C', $this->sequence & 0xFF) . $this->payload; } public function withSequence(int $sequence): self diff --git a/src/Tunnels/Relay/MysqlTlsRelay.php b/src/Tunnels/Relay/MysqlTlsRelay.php index 3b50ee3..058d51c 100644 --- a/src/Tunnels/Relay/MysqlTlsRelay.php +++ b/src/Tunnels/Relay/MysqlTlsRelay.php @@ -31,7 +31,7 @@ final class MysqlTlsRelay /** * @param array{ * listen: string, - * upstream: array{socket: string}|array{host: string, port: int}, + * upstream: array{host: string, port: int}, * peer_name: string, * tls: array{cafile?: ?string, local_cert?: ?string, local_pk?: ?string}, * timeout: int @@ -263,10 +263,10 @@ private function drain($stream): ?string */ private function connectUpstream(int $timeout) { + // TCP only: PHP can start TLS on a tcp:// stream, and a unix:// + // stream "does not support SSL/crypto". $upstream = $this->settings['upstream']; - $address = isset($upstream['socket']) - ? "unix://{$upstream['socket']}" - : "tcp://{$upstream['host']}:{$upstream['port']}"; + $address = "tcp://{$upstream['host']}:{$upstream['port']}"; $server = @stream_socket_client($address, $errno, $error, $timeout); if ($server === false) { diff --git a/src/Tunnels/Relay/VerifyingRelay.php b/src/Tunnels/Relay/VerifyingRelay.php index e51cabb..0d28c00 100644 --- a/src/Tunnels/Relay/VerifyingRelay.php +++ b/src/Tunnels/Relay/VerifyingRelay.php @@ -52,11 +52,16 @@ public function launch( $socket = $directory->file('mysql-tls.sock'); $upstream = $tunnel->local(); + // PHP cannot start TLS on a Unix socket stream, so the relay reaches + // the tunnel over its loopback port. ServerRegistry refuses the + // combination before it gets here; this is the backstop. + if ($upstream->isSocket()) { + throw new RuntimeException('the relay needs the tunnel on a loopback port (ssh.local=port): PHP cannot start TLS on a Unix socket'); + } + $worker = WorkerProcess::start(self::script(), [ - 'listen' => $socket, - 'upstream' => $upstream->isSocket() - ? ['socket' => $upstream->socket] - : ['host' => $upstream->host, 'port' => $upstream->port], + 'listen' => $socket, + 'upstream' => ['host' => $upstream->host, 'port' => $upstream->port], 'peer_name' => (string) $remote->host, 'tls' => [ 'cafile' => $definition->tls['ca'], diff --git a/tests/Feature/Tunnels/MysqlTlsRelayTest.php b/tests/Feature/Tunnels/MysqlTlsRelayTest.php index 3012ded..3561e20 100644 --- a/tests/Feature/Tunnels/MysqlTlsRelayTest.php +++ b/tests/Feature/Tunnels/MysqlTlsRelayTest.php @@ -9,10 +9,10 @@ use Simtabi\Laranail\DBConsole\Tunnels\Relay\MysqlPacketReader; /* - * The relay end to end, over Unix sockets, against a fake TLS MySQL server + * The relay end to end against a fake TLS MySQL server on a loopback port * (tests/Fixtures/fake-mysql-tls-server.php) with a certificate for `mytls` - * from a CA made here. A raw client speaks plaintext to the relay, as PDO - * would. Needs local listening and the openssl CLI; outside CI a sandbox + * from a CA made here. A raw client speaks plaintext to the relay over its + * Unix socket, as PDO would. Needs local listening and the openssl CLI; outside CI a sandbox * without either skips, and in CI it must run. */ @@ -38,9 +38,10 @@ $run(['openssl', 'req', '-nodes', '-newkey', 'rsa:2048', '-subj', '/CN=mytls', '-keyout', 'server.key', '-out', 'server.csr']); $run(['openssl', 'x509', '-req', '-in', 'server.csr', '-CA', 'ca.crt', '-CAkey', 'ca.key', '-CAcreateserial', '-days', '1', '-out', 'server.crt', '-extfile', 'ext']); - $this->server = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/Fixtures/fake-mysql-tls-server.php', "{$this->dir}/db.sock", "{$this->dir}/server.crt", "{$this->dir}/server.key"]); + $this->server = new Process([PHP_BINARY, dirname(__DIR__, 2) . '/Fixtures/fake-mysql-tls-server.php', "{$this->dir}/server.crt", "{$this->dir}/server.key"]); $this->server->start(); - $this->server->waitUntil(fn (string $type, string $output): bool => str_contains($output, 'READY')); + $this->server->waitUntil(fn (string $type, string $output): bool => preg_match('/READY \d+/', $output) === 1); + $this->serverPort = (int) preg_replace('/\D/', '', $this->server->getOutput()); }); afterEach(function (): void { @@ -56,7 +57,7 @@ function startRelay(object $test, string $peerName): WorkerProcess { return $test->relay = WorkerProcess::start(dirname(__DIR__, 3) . '/resources/bin/mysql-tls-relay.php', [ 'listen' => "{$test->dir}/relay.sock", - 'upstream' => ['socket' => "{$test->dir}/db.sock"], + 'upstream' => ['host' => '127.0.0.1', 'port' => $test->serverPort], 'peer_name' => $peerName, 'tls' => ['cafile' => "{$test->dir}/ca.crt"], 'timeout' => 5, diff --git a/tests/Fixtures/fake-mysql-tls-server.php b/tests/Fixtures/fake-mysql-tls-server.php index f55f787..f014b87 100644 --- a/tests/Fixtures/fake-mysql-tls-server.php +++ b/tests/Fixtures/fake-mysql-tls-server.php @@ -9,10 +9,13 @@ * answers OK, then answers one COM_QUERY with an OK whose info string echoes * the query. Every packet's sequence number is checked against the protocol. * - * Usage: php fake-mysql-tls-server.php + * It listens on a loopback port, because PHP starts TLS only on tcp:// + * streams, and prints `READY `. + * + * Usage: php fake-mysql-tls-server.php */ -[, $socket, $cert, $key] = $argv; +[, $cert, $key] = $argv; function frame(int $seq, string $payload): string { @@ -53,14 +56,14 @@ function expectSequence(int $got, int $want, string $what): void } } -@unlink($socket); -$server = stream_socket_server("unix://{$socket}", $errno, $error, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, +$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $error, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, stream_context_create(['ssl' => ['local_cert' => $cert, 'local_pk' => $key]])); if ($server === false) { fwrite(STDERR, "listen: {$error}\n"); exit(2); } -fwrite(STDOUT, "READY\n"); +$name = (string) stream_socket_get_name($server, false); +fwrite(STDOUT, 'READY ' . substr($name, strrpos($name, ':') + 1) . "\n"); while ($conn = @stream_socket_accept($server, 30)) { // Protocol 41 | SSL | plugin auth, in the lower and upper halves. diff --git a/tests/Unit/Servers/ServerRegistryTunnelTest.php b/tests/Unit/Servers/ServerRegistryTunnelTest.php index e8abc0d..e6b7717 100644 --- a/tests/Unit/Servers/ServerRegistryTunnelTest.php +++ b/tests/Unit/Servers/ServerRegistryTunnelTest.php @@ -298,13 +298,15 @@ public function driver(): string ->and((array) $connection->getConfig('options'))->toBe([]); }); - it('relays a socket local end too, since the relay does TLS itself', function (): void { + // PHP starts TLS only on tcp:// streams, so the relay cannot reach a + // tunnel whose local end is a Unix socket. + it('refuses a socket local end for a relayed server, and says why', function (): void { tunnelledServer(['local' => 'socket'], ['enabled' => true, 'verify' => true]); - app(ServerRegistry::class)->resolve('behind-bastion'); - - expect($this->relays->launched)->toHaveCount(1) - ->and($this->relays->launched[0]['upstream'])->toEndWith('/db.sock'); + expect(fn () => app(ServerRegistry::class)->resolve('behind-bastion')) + ->toThrow(ServerMisconfigured::class, 'PHP cannot start TLS on a Unix socket') + ->and($this->driver->opened)->toBe([]) + ->and($this->relays->launched)->toBe([]); }); it('leaves TLS to the client where it can verify, or where nothing needs verifying', function (array $ssh, array $tls, array $connection): void {