Skip to content

Verify MySQL's real name through SSH tunnels with a TLS relay - #64

Merged
imanimanyara merged 9 commits into
mainfrom
fix/mysql-verified-tls-through-tunnels
Oct 18, 2026
Merged

imanimanyara merged 9 commits into
mainfrom
fix/mysql-verified-tls-through-tunnels

Conversation

@imanimanyara

Copy link
Copy Markdown
Member

Closes the last open limitation of the SSH tunnel work: MySQL and MariaDB can now verify the server's real name through a tunnel.

Why it needed more than a config change

Through a TCP tunnel the client dials 127.0.0.1, and checking a certificate against that name fails. PostgreSQL avoids this with libpq's hostaddr: dial one address, verify another name (#47, proven live in #58). MySQL and SQL Server have no equivalent:

  • mysqlnd builds a fresh TLS context for each connection and never sets peer_name, so PHP checks the certificate against the host string it dialled. No PDO attribute changes that.
  • pdo_sqlsrv has no HostNameInCertificate key (connection options). Its TLS handshake also runs inside TDS PRELOGIN packets, which a relay can't take over with PHP streams.

The fix: a verifying TLS relay for MySQL

MySQL starts TLS inside its own protocol, so the TLS step can be done for the client. Tunnels\Relay\MysqlTlsRelay is a PHP worker process, started like the phpseclib pump, that sits in front of the tunnel:

  1. It forwards the server's greeting with CLIENT_SSL cleared, so PDO never asks the relay for TLS.
  2. It sends the SSLRequest itself, built from the client's handshake response, and starts TLS with peer_name set to the real host, the configured tls.ca, and tls.cert/tls.key for mutual TLS.
  3. It renumbers the connection-phase packets across the extra SSLRequest. The client's packet 1 becomes the server's packet 2, and so on until OK or ERR. Both sides take turns, so caching_sha2's fast and full paths work.
  4. After that it passes bytes through unchanged; commands number from 0 on both sides.

PDO talks to the relay in plaintext over a 0600 socket inside the tunnel's 0700 directory. Everything past the relay is TLS, and doctor measures the session as encrypted and required_ok. A certificate that doesn't match fails the login with a MySQL ERR naming why, e.g. TLS to db.example.com failed: … did not match.

When it applies: a MySQL-family server, tls.enabled and tls.verify on, and a target across the network from the SSH host. PostgreSQL keeps hostaddr. A socket or loopback target, unverified TLS, and TLS off need no relay.

Refactor

Tunnels\Support\WorkerProcess now holds the start-and-wait logic the phpseclib driver had: settings on stdin, a READY line, and exit on stdin EOF. It's a verbatim move, in its own commit. TlsRelayLauncher is a contract, so the registry tests open relayed tunnels without spawning a process.

Tests

  • Unit: packet framing across split reads, the greeting edit, the SSLRequest, the renumbering, the end of the connection phase, the ERR packet. Plus the registry wiring: when the relay applies and when it doesn't, the client's TLS options stripped, a socket local end allowed only when relayed, and the tunnel closed if its relay can't start. Each wiring guard goes red when what it guards is removed.
  • End to end over Unix sockets, in every pest job that can listen (required in CI): a fake TLS MySQL server with a certificate for mytls. One case logs in with a full-auth round trip and runs a command; the other has a certificate for another name refused, with the reason reaching the client.
  • Live, live-ssh job: mytls, a MySQL 8.4 with require_secure_transport=ON and a certificate for its own name, which also answers to mytls-alias. With both drivers: the session is TLS and required_ok, a second session shares the relay, the alias is refused with the reason, and mysqldump backs up through it.

Locally: pest 854 passed (92 live or listening tests skipped), with laranail-pint --test, phpstan and rector clean. CHANGELOG [0.1.4] and older sections are byte-identical to the v0.1.4 tag.

SQL Server stays documented: tls.verify=false through a tunnel, with the reason above.

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.
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.
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.
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.
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.
Copilot AI balanced review requested due to automatic review settings October 18, 2026 15:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… 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').
…s-through-tunnels

# Conflicts:
#	CHANGELOG.md
@imanimanyara
imanimanyara merged commit 53d459e into main Oct 18, 2026
25 checks passed
@imanimanyara
imanimanyara deleted the fix/mysql-verified-tls-through-tunnels branch October 18, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants