Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ 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. The relay reaches
the tunnel over TCP (PHP cannot start TLS on a Unix socket), so it needs `ssh.local=port`.
- `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.
- **`doctor` reports whether each server's backup-before-drop can run: `server:<name>:backup-client`.**
A missing `mysqldump`, or a `pg_dump` older than the server, used to surface only when a drop of a
non-empty database needed the backup, and with `backup.abort_drop_on_failure` on the drop was then
Expand Down
20 changes: 20 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# 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` 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.

## 0.1.3

### Alerts keep their category's channels
Expand Down
7 changes: 5 additions & 2 deletions docs/tools/ssh-tunnels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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 (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

Expand Down
27 changes: 27 additions & 0 deletions resources/bin/mysql-tls-relay.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

/*
* The MySQL TLS relay's child process. Started by WorkerProcess as
* `php mysql-tls-relay.php <vendor/autoload.php>`; 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 <vendor/autoload.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());
3 changes: 3 additions & 0 deletions src/Providers/DBConsoleServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
16 changes: 16 additions & 0 deletions src/Servers/ServerRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -358,6 +360,14 @@ 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 && 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() && ! $remote->isLocalToItsHost()) {
throw ServerMisconfigured::named(
$definition->name,
Expand All @@ -370,6 +380,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);

Expand Down
18 changes: 18 additions & 0 deletions src/Tunnels/Contracts/TerminatesTls.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\DBConsole\Tunnels\Contracts;

/**
* A tunnel whose local end already does TLS to the database, verified
* against the database's real name. The client must not start TLS of its own
* on top: it talks plaintext over the tunnel's private socket.
*/
interface TerminatesTls
{
/**
* The name the server's certificate was verified against.
*/
public function verifiedName(): string;
}
28 changes: 28 additions & 0 deletions src/Tunnels/Contracts/TlsRelayLauncher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\DBConsole\Tunnels\Contracts;

use RuntimeException;
use Simtabi\Laranail\DBConsole\Tunnels\Endpoint;
use Simtabi\Laranail\DBConsole\Servers\ServerDefinition;
use Simtabi\Laranail\DBConsole\Tunnels\PrivateDirectory;

/**
* Puts a TLS relay in front of an open tunnel. A contract so tests can open
* relayed tunnels without spawning a process.
*/
interface TlsRelayLauncher
{
/**
* @throws RuntimeException why the relay did not start
*/
public function launch(
ServerDefinition $definition,
Tunnel $tunnel,
Endpoint $remote,
PrivateDirectory $directory,
int $timeout,
): Tunnel&TerminatesTls;
}
73 changes: 8 additions & 65 deletions src/Tunnels/Drivers/PhpseclibTunnelDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand All @@ -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;
}
}
Loading
Loading