NEBULA.CAT // MFD-2600 BARCELONA · ES
BLOG/THE OPENVPN OUTAGE THAT WAS ACTUALLY A PKI ARCHAEOLOGY DIG

// NOTES FROM THE TERMINAL

POST

The OpenVPN Outage That Was Actually a PKI Archaeology Dig

2026-09-20 ~5 min read pkiopenvpntlssecurity

Why are you here? Because the VPN just went down for everyone, and the certificate error in the log makes it very clear this has been a ticking clock for a while, and nobody was watching it. Out of the jokes, this one turned into a small PKI archaeology dig through a server nobody had fully mapped in years.


We run an OpenVPN server — call it vpn-host, public endpoint vpn.example.com — using Easy-RSA 3 for its PKI. It's the trust anchor for a lot of things: the robot fleet, staff, and a handful of external client connections. One day it just stopped accepting anyone.

The Problem

Total outage. Nobody could connect, old client or new. The server log pointed at one line: the server certificate itself had expired.

TLS Error: could not determine whether or not server certificate is expired
Cert expired at 2026-09-03 13:06:39

Renewing an expired cert should be a ten-minute job. It was not, because pulling on that thread unraveled something nobody had documented.

The Investigation

First, a red herring

There were two systemd units that looked relevant: openvpn@server.service, and a separate openvpn-server@server.service template unit that was crash-looping in the journal. Spent longer than I'd like to admit assuming the crash-looping one was the live service and the actual problem. It wasn't — it was pointed at the wrong config path, unrelated to real traffic, and had probably been crash-looping harmlessly for a long time. Stopped and disabled it so it would stop polluting the logs, and moved on to the unit that actually terminated client connections.

Then, the part that actually mattered: the trust bundle is not one CA

The server's client-trust bundle (ca_combined.crt) turned out to be two CAs concatenated together:

  • The current signing CA, valid until 2033 — everything issued recently chains to this one.
  • A legacy CA, valid until 2037, left over from consolidating an older OpenVPN server's clients into this one at some point in the past. Its metadata still had the org field from whatever project it was originally created for — completely unrelated to VPN infrastructure, the kind of naming leftover that makes you go "why does our trust anchor say CN=spaghetti" and then quietly decide not to ask.

So: two independent trust roots, merged into one bundle, serving certs from both eras. Anyone whose .ovpn file predates the migration trusts the legacy CA. Anyone issued more recently trusts the current one. Nobody had written this down anywhere I could find.

Can I just re-sign with the legacy CA and move on?

No. Searched every private key on disk by modulus hash, looking for the legacy CA's key specifically. Not there. Whoever consolidated the old server into this one kept the legacy CA's certificate (so old clients could still validate against it) but never brought over its private key. Which means nothing new can ever be signed by that CA again — it's a dead root that old clients still trust, permanently frozen.

That ruled out the obvious "just reissue from whichever CA is convenient" fix.

The thing that was already sitting on disk, unused

While mapping out the Easy-RSA directory structure, found a file that had clearly been prepared for exactly this problem and then never wired in: an intermediate certificate, subject matching the current CA, CA:TRUE, but issued by the legacy CA. Sitting right next to it, the legacy root itself.

That's a cross-sign. Someone, at some point, had already built the bridge between the two trust roots and just never put it into the server's actual sent chain.

The Fix

Cross-signing means: instead of sending just server cert, send server cert + intermediate. The intermediate is trusted by both camps —

  • Old clients, who only trust the legacy CA, validate the intermediate because the legacy CA issued it, and from there validate the server cert.
  • New clients, who trust the current CA, validate straight through as normal, since the intermediate's subject is the current CA.

One chain, both trust anchors satisfied, zero client .ovpn files touched. That last part matters — Easy-RSA's own bookkeeping (pki/index.txt) only tracks 87 active certs, but the real number of peers on this server is in the hundreds, managed outside that file entirely. Reissuing everyone was never a realistic option; this was.

# what the server actually sends, in order
cat server.crt intermediate.crt > server_combined.crt

Verified the chain manually before touching the live config:

openssl verify -CAfile ca_old.crt -untrusted intermediate.crt server.crt
# server.crt: OK

Renewed the expired server certificate itself first — same key, same CA, valid until 2029 — to actually restore service, then applied the combined chain so both cert generations keep working going forward.

What I Would Tell Past Me

  • A .crt bundle with more than one certificate in it is a fact worth documenting the day you find it, not something to leave for whoever hits the outage next.
  • When a legacy CA's certificate is present but its private key is gone, that CA is permanently read-only. Confirm which state you're in (searchable key vs. cert-only) before planning any fix around it.
  • Check for a cross-signing intermediate before assuming you need to build one. Someone may have already solved this and just never finished wiring it in — which is exactly what happened here.
  • A crash-looping unrelated service in the same log stream will eat your first twenty minutes of triage if you let it. Confirm which unit is actually handling live traffic before debugging either one.
  • Certificate expiry monitoring should have caught this weeks in advance. It didn't, because nobody had it wired up for this specific cert. That's a separate, smaller fix, and it's now on the list.

Applied the fix, service is back, both cert generations validate. The bigger, uncomfortable follow-up — that hundreds of live peers exist outside any inventory Easy-RSA knows about — is its own project.

// daniel.nebula.cat/blog/openvpn-dual-ca-cross-signing
— END OF DISPLAY · MFD-2600 —