A small proxy that lets modern MySQL clients read a legacy server whose account
still uses pre-4.1 (mysql_old_password) authentication.
client ──mysql_native_password──▶ proxy ──pre-4.1 auth──▶ legacy MySQL
(TCP 3306) (TCP 3306)
Standard library only, no dependencies. The published image is a single static
binary on scratch.
Modern drivers have dropped pre-4.1 authentication — MariaDB Connector/J in
3.x, MySQL Connector/J in 8.0, the mysql CLI from 5.7 — so a client meeting a
server that still holds a 16-byte password hash fails during plugin
negotiation:
Client does not support authentication protocol requested by server.
plugin type was = ''
ERROR 2059 (HY000): Authentication plugin 'mysql_old_password' cannot be loaded
The proper fix is one statement on the legacy server, storing a
mysql_native_password hash for a second account:
SET SESSION old_passwords = 0;
CREATE USER 'app'@'%' IDENTIFIED BY '...';
GRANT SELECT ON yourdb.* TO 'app'@'%';Do that if you can, and delete this project. This proxy is for when you cannot: no DBA access, a vendor-owned appliance, a server nobody is allowed to restart.
Authentication is terminated separately on each side. The proxy speaks
mysql_native_password to its clients and pre-4.1 authentication to the legacy
server, then relays the post-authentication packet stream. It never parses SQL,
with two narrow and optional exceptions (below).
After authentication, MySQL's wire framing depends on the capabilities each side
negotiated. If a client negotiates CLIENT_DEPRECATE_EOF and the legacy server
does not, result sets are framed differently on the two halves and a byte copy
corrupts them silently.
So the proxy learns what the legacy server actually supports and offers clients
only the intersection with a set that excludes every framing-changing flag.
Never offered: CLIENT_DEPRECATE_EOF, CLIENT_SESSION_TRACK,
CLIENT_QUERY_ATTRIBUTES, CLIENT_OPTIONAL_RESULTSET_METADATA (framing),
CLIENT_COMPRESS, CLIENT_ZSTD_COMPRESSION_ALGORITHM (would need re-framing),
CLIENT_SSL (see Security), CLIENT_LOCAL_FILES (turns the server
into a file-read request against the client), and CLIENT_NO_SCHEMA — which
changes no framing at all, but makes the server reject database.table
qualifiers, so carrying it breaks every schema-qualified query. Framing is not
the only way a capability can be unsafe to relay.
What the relay negotiates with the legacy server is what the client
negotiated, not the whole set: a capability enabled on one half only would make
statements mean different things on the two sides. CLIENT_FOUND_ROWS is the
plain example — an UPDATE would report matched rows to one side and changed
rows to the other.
Clients routinely claim flags the server never advertised — the mysql 8.0
client sends CLIENT_DEPRECATE_EOF, CLIENT_SESSION_TRACK and
CLIENT_QUERY_ATTRIBUTES to everything — and a real server ignores the surplus.
The proxy does the same, so what is in force is the intersection, which is safe
by construction. The invariant is then re-checked against the live backend
handshake before a byte is relayed; a backend restarted onto a different version
produces a clear error instead of corrupt results.
client handshake → client auth → backend dial + auth → OK to client → relay
Authenticating the client first means a Kubernetes probe, a port scan, or any connection that never completes a handshake costs the legacy server nothing — worth having when that server has a host cache that blocks an IP after enough failed handshakes. The client's OK packet is withheld until the backend is up, so a backend failure still surfaces as a clean error at connect time rather than a hang. Backend capabilities come from a probe made at startup, which also validates the credentials in your Secret at boot instead of on the first query.
All are optional and all are off the SQL parser path — they are substring and
regexp checks on COM_QUERY payloads.
-
-rewrite-utf8mb4(default on) rewritesutf8mb4→utf8. MySQL 5.0 predatesutf8mb4(added in 5.5) and drivers issueSET NAMES utf8mb4. It is a blunt replacement: a query carrying that literal in data is rewritten too. The same mapping is applied to the character set in the handshake.Collations are handled rather than renamed.
utf8mb4gained collations the oldutf8never had, so a plain substitution invents names no server knows —utf8mb4_0900_ai_ci, MySQL 8.0's default, would becomeutf8_0900_ai_ciand be refused. Suffixes that the old character set really has are carried across (utf8mb4_unicode_ci→utf8_unicode_ci); anything else becomesutf8_general_ci, which changes sort order for the session but leaves a connection that opens. -
-rewrite-datetime-precision(defaultauto) replaces theDATETIME_PRECISIONcolumn with the literal0ininformation_schemaqueries, but only when the backend predates the column. That column arrived in MySQL 5.6 and in MariaDB 5.3, so without this, reading column metadata from a 5.0 or 5.1 server fails outright:Unknown column 'DATETIME_PRECISION' in 'field list'The gate matters in both directions. Against a server that has the column, substituting 0 would report every fractional-second column as having none: a
DATETIME(3)would come back 19 wide instead of 23. Soautoreads the version from the backend's handshake and only rewrites where it must — MySQL 5.5 is rewritten, MySQL 5.6 is not, and MariaDB is recognised behind the5.5.5-compatibility prefix it puts in front of its real version. A version string it cannot parse is left alone, so the failure is a loudUnknown columnrather than quietly wrong metadata;alwaysforces it.MariaDB Connector/J 3.x uses it in
DatabaseMetaData.getColumns()to size temporal columns —IF(DATETIME_PRECISION = 0, 19, CAST(20 + DATETIME_PRECISION as signed integer))— so the substitution must be0, notNULL:NULLwould poison the comparison and the arithmetic, and every temporal column would come back with aNULLsize. Zero is also the truthful answer, since a server without fractional seconds has precision 0. Unlike theutf8mb4rewrite this one is narrow: it only fires on statements mentioninginformation_schema, and it respects identifier boundaries, soMY_DATETIME_PRECISION_Xand a backtick-quoted`DATETIME_PRECISION`are left alone.Without it the proxy authenticates but no JDBC client can read a schema — which is most of the point.
-
-fake-ok-regex(default off) answers matching statements with an OK packet without forwarding them. This is the escape hatch for driver session setup a legacy server rejects outright —SET session_track_schema=1and friends. Find them with-log-queries.
Everything else is forwarded verbatim with its original sequence id, including statements larger than 16 MB, which the protocol splits across packets.
| Flag | Default | Meaning |
|---|---|---|
-listen |
:3306 |
address to listen on for clients |
-backend |
required | legacy MySQL server host:port |
-backend-user |
required | username on the legacy server |
-frontend-user |
-backend-user |
username clients must present |
-server-version |
5.5.62-auth-relay |
version string advertised to clients |
-rewrite-utf8mb4 |
true |
rewrite utf8mb4 to utf8 |
-rewrite-datetime-precision |
auto |
substitute DATETIME_PRECISION in information_schema queries: auto, always or never |
-fake-ok-regex |
empty | statements answered OK without reaching the backend |
-log-queries |
false |
log every COM_QUERY (verbose; may expose data) |
-max-connections |
0 |
cap on concurrent sessions, each holding one backend connection (0 = unlimited) |
-frontend-password-from-backend |
false |
let clients authenticate with the legacy password instead of a separate one |
-health-addr |
:8081 |
HTTP /healthz and /readyz; empty disables |
-dial-timeout |
10s |
connecting to the backend |
-auth-timeout |
30s |
completing authentication on either side |
-shutdown-timeout |
30s |
waiting for in-flight connections on SIGTERM |
-version |
print the version and exit |
Passwords come from the environment, never flags — flags are visible in the
process list and in kubectl describe pod:
| Variable | Meaning |
|---|---|
MYSQL_RELAY_BACKEND_PASSWORD |
the legacy server's password |
MYSQL_RELAY_FRONTEND_PASSWORD |
what clients must present to the proxy |
The two credentials are independent. Rotating the client-facing one does not require touching the legacy server.
The client's password cannot be forwarded to the legacy server, because the
proxy never receives it. MySQL authentication is challenge–response: a client
sends SHA1(pw) XOR SHA1(scramble ‖ SHA1(SHA1(pw))) against a random scramble,
which is one-way and salted per connection. The legacy side then needs a
different algorithm over the raw password bytes, against its own scramble. So
the proxy has to hold the legacy password, and has to run a complete, separate
authentication on each side. There is no pass-through arrangement.
What it can do is use the same password for both, so there is only one credential to deploy:
-frontend-password-from-backend # and leave MYSQL_RELAY_FRONTEND_PASSWORD unset
Consider what that costs before using it. The legacy password then lives in
every client's configuration, and a password on a server nobody can run
ALTER USER against is usually one nobody can rotate either — so a leak from a
client config becomes a problem you cannot fix. A separate frontend password
keeps the legacy one in exactly one place, and lets you rotate the
client-facing credential freely. Without the flag, a missing frontend password
is a startup error rather than a silent fallback.
-server-version is worth tuning. Claim too new and a driver assumes features
the legacy server lacks; too old and some drivers refuse to connect at all.
docker run -d --name mysql-old-password-proxy \
-p 127.0.0.1:3306:3306 -p 127.0.0.1:8081:8081 \
-e MYSQL_RELAY_BACKEND_PASSWORD='...' \
-e MYSQL_RELAY_FRONTEND_PASSWORD='...' \
ralforion/mysql-old-password-proxy:latest \
-backend legacy-mysql.internal:3306 \
-backend-user legacyaccount \
-frontend-user app
mysql -h 127.0.0.1 -P 3306 --protocol TCP -u app -pdeploy/compose.yaml is the same thing as Compose.
deploy/proxy.yaml has a Deployment, Service, PodDisruptionBudget and a Secret
template. Replace NAMESPACE, IMAGE_REF, LEGACY_HOST:PORT and
LEGACY_USER, and create the Secret out of band rather than committing it:
kubectl -n mynamespace create secret generic mysql-old-password-proxy \
--from-literal=backend-password='...' \
--from-literal=frontend-password='...'
sed -e 's/NAMESPACE/mynamespace/g' \
-e 's|IMAGE_REF|ralforion/mysql-old-password-proxy:v0.1.0|' \
-e 's/LEGACY_HOST:PORT/legacy-mysql.internal:3306/' \
-e 's/LEGACY_USER/legacyaccount/' \
deploy/proxy.yaml | kubectl apply -f -Clients then connect to
mysql-old-password-proxy.<namespace>.svc.cluster.local:3306.
Two things in that manifest are deliberate. Probes hit the HTTP port, not 3306, so Kubernetes never opens a client connection — and so the pod stays ready when the legacy server is down, because a clear MySQL error beats a connection refused from an empty Service. And there is no CPU limit: throttling something on the query path shows up as latency in every query behind it.
Note that MySQL authorises per user@host, and pods usually SNAT to their node
IP. With the proxy in place only the proxy's nodes need a grant on the legacy
server — which shrinks the grant surface rather than widening it.
Connections are handled fully in parallel: every client connection gets its own goroutine and its own connection to the legacy server, and nothing is shared between them. Queries on different connections never wait on each other.
There is no connection pooling, and that is deliberate rather than missing. A MySQL session is stateful — session variables, the default schema, temporary tables, an open transaction, prepared statements — so multiplexing several client sessions onto one backend connection would leak that state between them. One client session is one backend session.
The consequence is that N clients means N connections on the legacy server, and
an old server may have a small max_connections and other users besides you.
-max-connections bounds it: clients over the limit are refused with the same
"Too many connections" error MySQL itself sends, before the legacy server is
contacted at all. /healthz reports the live count.
The image is one static binary on scratch: no shell, no libc, no package
manager, nothing to pivot into and nothing to patch on a CVE treadmill. It runs
as uid 65532 with a read-only root filesystem and all capabilities dropped.
There are no third-party Go modules either, so the dependency surface is the
standard library.
A relayed connection costs on the order of tens of kilobytes — two goroutines,
two 4 KB read buffers, and a 32 KB copy buffer taken from a pool. Packets are
read into a per-session buffer that is reused, so a steady stream of queries
allocates nothing per packet. The manifest requests 10m CPU and 32Mi, with
GOMEMLIMIT set just under a 128Mi limit so the garbage collector sizes the
heap to the container rather than to the node.
The image holds exactly one file — the binary — as docker export will show;
everything else in a running container is mounted by the runtime.
| Measured | |
|---|---|
| binary | 5.7 MB |
| image | 8.2 MB |
make build # binary into bin/ (git-ignored; deploy/ is manifests only)
make test # unit tests, with the race detector
make test-integration # against real MySQL servers in Docker (slow)
make vet fmt cover
make help # everything elsetest/unit needs neither network nor Docker. It pins the two password
algorithms against values measured on a real server (OLD_PASSWORD() prints
exactly the pre-4.1 hash), parses a captured 5.6 handshake, and runs the real
proxy in-process against a fake MySQL 5.0-shaped backend that greets with the
short handshake and asks for the old password with a bare 0xFE. That fake
exists because the servers that need this proxy are usually 4.1–5.1, which
publish no official image.
test/integration runs the real binary against two MySQL server images, each
seeded with data and a genuine pre-4.1 account:
- mysql:5.5 and mysql:5.6 — the last servers that implement
mysql_old_password; 5.7 removed it. They ask for it differently (5.6 needsCLIENT_PLUGIN_AUTH, and answers "Bad handshake" without it), which is exactly the kind of thing only a real server catches.
Against each, it runs stock mysql 5.6, mysql 8.0 (which defaults to
caching_sha2_password, exercising the auth switch) and mariadb 10.11
clients in containers, plus a raw protocol client for what no CLI exposes:
prepared statements over the binary protocol, statements larger than 16 MB,
COM_QUIT, and result-set framing checked packet by packet. It also asserts the
premise — that a modern client cannot reach the account directly — and covers
a backend outage and recovery.
go test -tags integration -v -timeout 40m ./test/integration/...Images are published to Docker Hub by GitHub Actions. What changed in each one is in CHANGELOG.md, which the release workflow also uses as the GitHub release notes.
| Trigger | Tags |
|---|---|
push to main |
:edge, :main-<sha> |
tag v1.2.3 |
:1.2.3, :1.2, :1, :latest |
make release TAG=v0.1.0Nothing is published until the unit and integration suites pass in CI. Builds
are multi-arch (linux/amd64, linux/arm64) with provenance and an SBOM, and
the published image is smoke-tested by digest before the run is green.
Two repository secrets are required: DOCKERHUB_USERNAME, and DOCKERHUB_TOKEN
— an access token from Docker Hub → Account Settings → Personal access tokens
with Read & Write scope, never the account password. Override the image name
with a DOCKERHUB_IMAGE repository variable. To push to an internal mirror
instead:
make docker-push REGISTRY=registry.internal.example.comVersion comes from git describe, is stamped into the binary, and is reported
by -version and on /healthz. A plain go build with no stamp falls back to
the VCS revision the Go toolchain embeds.
- Both hops are plaintext. Pre-4.1 authentication is weak by construction
and this changes nothing about that. Credentials and data cross the network in
the clear, so run this on an internal network only,
ClusterIPonly, and record it as an accepted risk.CLIENT_SSLis never offered; a client requesting TLS is refused with a clear error rather than being silently downgraded. - The proxy holds the legacy password in plaintext in memory. It has to: computing the pre-4.1 response requires the password itself, not a hash.
- Client passwords are compared in constant time.
LOAD DATA LOCAL INFILEis not available through the proxy — the capability is never negotiated with the backend, so the legacy server cannot ask a client for a local file.-log-querieslogs statements, which may contain data. It is for debugging a driver's session setup, not for leaving on.
- No TLS, on either side.
- No connection pooling: one client connection is one backend connection (see Concurrency and connections).
- No compression (
CLIENT_COMPRESSis deliberately never negotiated). - One backend per process. Two legacy servers means two deployments.
-rewrite-utf8mb4also rewrites the literalutf8mb4inside query data.- The
information_schemashim coversDATETIME_PRECISIONonly. It is the sole column MariaDB Connector/J 3.x'sgetColumns()needs that MySQL 5.0 lacks, but another driver, or another metadata call, may reach for something else —GENERATION_EXPRESSION(5.7) andSRS_ID(8.0) are the likely candidates. Find them with-log-queries. - Statements over 16 MB are relayed but not inspected past their first chunk, so no rewrite applies to them.
MIT. See LICENSE.
The pre-4.1 scramble follows the algorithm as implemented in
go-sql-driver/mysql (MPL-2.0). It is
reimplemented rather than imported because that package's scrambleOldPassword,
pwHash and myRnd are unexported — it exports a database/sql driver, not
protocol helpers — and because a database/sql connection is the wrong shape
here anyway: the proxy has to keep the socket after authentication in order to
relay it. Keeping it in-tree is also what makes "standard library only" true.