Skip to main content
RunBook Academy

← All break/fix scenarios in Secrets, PKI & Certificates

intermediatepki-trust-store~40 min

curl trusts the internal CA and the application does not

Reported symptoms

  • At 08:12 UTC the billing service on web-01 begins failing every outbound call to app.lab.example with a TLS trust failure, eight minutes after a certificate migration that every host reported as successful
  • The identical request from the identical host with curl returns 200 and the expected body, which sends the channel looking for an egress proxy change that never happened
  • The Go metrics exporter on the same host keeps scraping app.lab.example every fifteen seconds without one error, so the network path is demonstrably intact
  • The Python reconciliation job fails at 08:15 and the Node notification worker fails at 08:31, each reporting the failure in different words, so the channel opens three incidents instead of one
  • The nginx access log on app.lab.example records no completed requests from the three failing services during the outage, while the Go exporter appears in it on schedule
  • update-ca-certificates exited zero on all forty hosts and the anchor file is present under the trusted directory on every one of them, so the rollout dashboard is entirely green
  • Restarting the billing service changes nothing, and rolling the application back to the previous image changes nothing either, which rules out the deployment that went out that morning

Evidence

  • · curl against the service on web-01 returns the page body lab ok, so the leaf, the chain and the operating system trust store are all correct for an OpenSSL client on that host
  • · openssl verify with the root supplied as the CAfile and the issuing CA supplied as untrusted returns app.crt: OK, which rules out a missing intermediate and a malformed chain
  • · openssl s_client against port 443 reports Protocol: TLSv1.3 and Verify return code: 0 (ok), so the server is presenting a complete chain that validates against the internal root
  • · A deliberate control run of curl inside a container known to lack the anchor returns SSL certificate problem: unable to get local issuer certificate, which is what a genuinely missing operating system anchor looks like and is not what any of the three services reported
  • · keytool listing the JRE truststore used by the billing service returns no entry for the internal root, and the package whose hook synchronises the operating system store into that truststore is not installed on either host
  • · The Python job imports requests, and certifi reports a bundle path inside the job virtualenv rather than the operating system bundle under /etc/ssl/certs
  • · The systemd environment for the Node worker contains no NODE_EXTRA_CA_CERTS setting and the unit is not started with the option that makes Node read the OpenSSL store
  • · The Go exporter on the same host, which reads the operating system store directly through the platform verifier, has logged no TLS error at any point in the incident
  • · The anchor file under the trusted directory has the same SHA-256 fingerprint on both hosts as the root the CA team published in the change record, so distribution itself is not in question
Diagnosis and resolutionclick to reveal

Root cause

Two defects combined, and only the second one turned a design characteristic into an outage. The first is that there is no such thing as the trust store on a Linux host. There are several, they are updated by different mechanisms, and a certificate authority installed into one is invisible to the others. OpenSSL consumers, which is curl, nginx, the shell and anything linked against libssl, read the operating system bundle that update-ca-certificates maintains. A JVM reads its own cacerts keystore, which on this fleet is never touched because the package supplying the synchronisation hook was trimmed out of the base image. A Python process using requests reads the bundle that certifi ships inside the virtualenv, which is a fixed copy of a public root list and has no relationship to the host at all. Node reads a root store compiled into the binary and consults nothing on disk unless it is told to. Go on Linux reads the operating system store, which is why the exporter never noticed anything. The migration plan treated all of these as one thing. The second defect is the one that made this an outage rather than a caught mistake. The acceptance test for every host was a curl request, and curl shares its trust store with the anchor the playbook had just installed. That test could only ever confirm the single consumer class that was never at risk, and it reported success on forty hosts while three runtimes on those same hosts had no path to the new root.

Remediation

Establish first that the certificate itself is sound, because the remedy for a distribution problem and the remedy for a chain defect have nothing in common. openssl verify against the root, with the issuing CA supplied as an untrusted intermediate, returning OK settles that in one command. Then enumerate the affected runtimes before changing any of them, so that the fix is applied once rather than three times under pressure. Do not reach for the switches that make the error stop. Setting NODE_TLS_REJECT_UNAUTHORIZED to zero, passing verify=False to requests, or installing a trust manager that accepts everything does not teach the runtime about your certificate authority. It removes the check that a certificate is trusted at all, so the service will then accept any certificate any party on the path presents. That converts a trust rollout into three services with no authentication of the server they talk to, and it is particularly hard to find later because nothing fails. Fix each runtime with the mechanism that runtime actually has. Import the root into a truststore file the JVM reads, and prefer an organisation truststore referenced explicitly over editing the bundled cacerts, because a JRE upgrade replaces the bundled one. Point requests at the operating system bundle with REQUESTS_CA_BUNDLE, or pass an explicit verify path. Give the Node unit NODE_EXTRA_CA_CERTS naming the anchor file. Every one of these is read once at process start, so each unit has to be restarted before it means anything. Restart the least critical service first and confirm the pattern before touching the rest.

Verification

Prove the fix from the far end of the connection rather than from the file you just wrote. Listing the alias in a truststore, or printing an environment variable, only confirms that the change landed on disk; it says nothing about whether a handshake now completes. The independent channel here is the server. On app.lab.example the nginx access log must show completed requests from each of the three services, with the status codes the application expects, at a rate consistent with their normal schedules. A connection that fails trust verification never reaches the access log at all, so its reappearance is the proof. Then confirm from each client that the handshake succeeds under the service account and the service environment, not under your own shell, because a root shell frequently has a different environment and a different working directory. Run the runtime one liner as the unit user with the unit environment loaded. Confirm the process picked the change up by comparing the unit main start timestamp against the time you edited the configuration. Finally run the same check on one host that was not part of the incident, to establish whether the gap is fleet wide and simply has not surfaced yet on the quieter services.

Prevention

Write the runtime inventory down and treat it as part of the trust anchor, not as a footnote. For every service that terminates or initiates TLS, record which trust store it reads and which mechanism updates it, and require that record before a service is allowed into production. A trust anchor rollout then has a checklist rather than an assumption. Change the acceptance test so that it exercises every runtime class rather than the most convenient one. A one line client in each language, run on every host after the anchor is installed and again nightly, catches this in minutes. The playbook must fail the host when any probe fails, rather than reporting success on the strength of curl alone. Prefer explicit truststore paths over editing runtime defaults, because defaults get replaced. A JRE upgrade overwrites the bundled cacerts, a rebuilt virtualenv reinstalls certifi, and a base image refresh restores the compiled in list. Point each runtime at a file your configuration management owns. Monitor the anchor itself. Alert if the fingerprint on any host differs from the published root, and alert on the internal root expiry with a warning at 90 days and a page at 30, because a root transition is a fleet wide change that needs a quarter, not an afternoon. Add a probe that fails when any service completes a handshake without verification enabled, so that the disabled check a colleague added at three in the morning surfaces the same day.

Reported symptoms

Forty application hosts, one internal two-tier certificate authority, and a migration that had been rehearsed twice. The service app.lab.example was moving off a public certificate onto one issued by the RunBook Lab Server Issuing CA, which chains to the RunBook Lab Root CA. The playbook copied the root into the trusted directory on every host, ran update-ca-certificates, then proved the result with a curl request. Forty hosts, forty green ticks.

At 08:12 UTC the billing service on web-01 stopped being able to reach the thing it had been reaching all year:

  • Every outbound call from billing to app.lab.example fails during the TLS handshake. The service is a JVM process and the exception it raises names a certification path builder failure: it cannot construct a path from the certificate it was shown to any anchor it holds.
  • An engineer runs curl against the same URL on the same host, as the same user, and gets a 200 and the expected body. The channel spends the next twenty minutes on egress proxies.
  • The Go metrics exporter on web-01 scrapes the same endpoint every fifteen seconds and has not logged a single error.
  • At 08:15 the Python reconciliation job fails, reporting a certificate verify failure. At 08:31 the Node notification worker fails on web-02, reporting that it could not verify the first certificate. Three services, three wordings, three incidents in the tracker.
  • Restarting billing changes nothing. Rolling the deployment back to yesterday’s image changes nothing either, which quietly removes the only hypothesis anybody actually liked.

By 08:50 the team has a working curl, a passing rollout, a healthy network and three services that cannot speak to a host they can demonstrably reach. Somebody proposes turning verification off in all three, just to get the batch through.

Evidence provided

Read-only / Safeweb-01 at 08:47, as the same user the billing unit runs under
$ curl -sS https://app.lab.example/
lab ok

Illustrative output

Read-only / Safeweb-01, a deliberate control in an image known to lack the anchor
$ docker run --rm alpine:3 sh -c 'apk add --no-cache curl >/dev/null && curl -sS https://app.lab.example/'
curl: (60) SSL certificate problem: unable to get local issuer certificate

Illustrative output

Read-only / Safethe CA host - the certificate and chain themselves are sound
$ openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crt
app.crt: OK

Illustrative output

Read-only / Safeweb-01 - what the server actually presents, verified against the OS store
$ openssl s_client -connect app.lab.example:443 -servername app.lab.example < /dev/null
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
Verify return code: 0 (ok)

Illustrative output

Read-only / Safeweb-01 - the store the billing JVM actually reads
$ sudo keytool -list -cacerts -storepass changeit | grep -i runbook
Read-only / Safeweb-01 - where the Python job looks for anchors
$ sudo -u reconcile /opt/reconcile/venv/bin/python -c 'import certifi; print(certifi.where())'
Read-only / Safeweb-02 - what the Node worker was told about trust, which is nothing
$ systemctl show notify.service -p Environment

Work the evidence before reading on

The interesting thing is not that three services failed. It is that one host produced a success and a failure for the same URL in the same minute, and both results are correct.

  1. curl succeeds and the JVM fails, on one host, as one user, against one endpoint. What is different between those two processes, given that the network, the certificate and the chain are all shared?
  2. The control run in the Alpine container failed with a different wording from anything the three services reported. What does that comparison rule out?
  3. The Go exporter never failed. Work out what Go does on Linux that the JVM, Python and Node all do differently, and you have the shape of the whole incident.
  4. Forty hosts reported success. What exactly did that acceptance test measure, and what is the largest claim it could honestly support?

Before continuing: name the one property shared by every component that kept working, and say why the rollout was incapable of detecting the components that did not.

Root cause

There is no single trust store on a Linux host

update-ca-certificates maintains the bundle under /etc/ssl/certs that OpenSSL consumers read. That is curl, nginx, the shell, and anything linked against libssl. It is a large and important set of programs, and it is not everything.

A JVM reads its own keystore, cacerts, in the Java installation. On Debian family systems a hook can synchronise the operating system store into it, but that hook belongs to a package, and this fleet trimmed that package out of the base image to save space. Nothing else was ever going to update it.

A Python process using requests reads the bundle that certifi installs inside the virtualenv. It is a copy of a public root list frozen at the moment the package was built. It has no connection to the host and does not change when the host does.

Node reads a root store compiled into the binary. It consults nothing on disk unless NODE_EXTRA_CA_CERTS names a file, or the process is started with the option that switches it to the OpenSSL store.

Go on Linux reads the operating system store. That is the entire reason the exporter carried on working, and it is the single property shared by everything that stayed up.

The acceptance test could only pass

curl shares its trust store with the anchor the playbook had just installed. Testing the rollout with curl asks whether the file was written and the bundle rebuilt, which is worth knowing and is not what anybody thought they were measuring.

The largest honest claim that test supports is that OpenSSL clients on this host now trust the internal root. It cannot speak for the JVM, for Python, or for Node, because it does not touch anything those runtimes read. Forty green ticks were forty correct answers to a question nobody had asked.

Three wordings, one fault

The three services report differently because each runtime wrote its own message for the same condition: a leaf certificate arrived, and the runtime could not build a path from it to an anchor it holds. That is why the incident arrived as three tickets. Reading the three messages side by side, and noticing they all describe path building rather than expiry, hostname or protocol, collapses them into one.

Resolution

  1. Confirm the certificate and chain are sound before touching any client. openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crt returning app.crt: OK establishes that this is a trust distribution problem and not a chain defect, and those two have entirely different remedies.
  2. Enumerate the affected runtimes across the whole fleet before fixing any of them, using the runtime inventory if one exists and building it now if it does not. Fixing three services one at a time under pressure guarantees a fourth is discovered next week.
  3. Rule out the switches that disable verification. NODE_TLS_REJECT_UNAUTHORIZED=0, verify=False and an all-trusting Java trust manager each remove peer authentication entirely rather than adding your anchor, and because they never fail again they are effectively permanent once merged.
  4. For the JVM: import the internal root into a truststore file that configuration management owns, and point the unit at it with the javax.net.ssl.trustStore system property. Importing into the JDK bundled cacerts also works and is undone by the next JRE upgrade, so prefer the explicit file.
  5. For Python: set REQUESTS_CA_BUNDLE in the unit environment to the operating system bundle, or pass an explicit verify path at the call site. certifi is a snapshot of a public root list and adding to it inside a virtualenv is undone the next time that virtualenv is rebuilt.
  6. For Node: set NODE_EXTRA_CA_CERTS in the unit environment to the anchor file. Node reads this once during startup, so it must be set in the unit rather than exported in a shell, and the unit must then be restarted.
  7. Restart each unit, starting with the least critical of the three, and confirm the expected result before moving to the next. Every mechanism above is read at process start, so an unrestarted service is an unfixed service no matter how correct its configuration file now looks.
  8. Reload the systemd manager configuration before restarting any unit whose environment you edited, otherwise the unit starts again with the environment it had at 08:00 and the fix appears not to work.
  9. Record the three settings in the base image and in configuration management in the same change, so that the next host built from that image does not arrive with the same gap.

Verification

  1. Read the server, not the client. On app.lab.example the nginx access log must show completed requests from billing, reconcile and notify at their normal cadence. A connection that fails trust verification is torn down before any request is sent, so its appearance in the access log is proof that a full handshake now completes.
  2. Confirm each runtime under the service account and the service environment rather than under your own shell, because a root shell usually has neither. Run the client one liner with sudo -u and the unit environment loaded, and treat a success in your own shell as meaningless.
  3. Check that the processes actually restarted: systemctl show billing.service -p ExecMainStartTimestamp must report a time after the configuration change, not before it.
  4. Prove the anchor is being used rather than verification being skipped. The connection must succeed with verification on and must still fail when pointed at a certificate from an unrelated authority. A client that accepts both is not fixed, it is disabled.
  5. Run the same three checks on one host that was not part of the incident. The gap is fleet wide and has simply not surfaced yet on services with quieter schedules.
  6. Confirm the trust path survives a rebuild by running the checks once more after rebuilding the Python virtualenv and after redeploying the Node worker from a fresh image, since both operations restore the runtime default store.
  7. Close the three tickets as one incident with one cause, and record the wording each runtime used, so that the next engineer recognises three unfamiliar messages as one familiar fault.

Prevention

  • Keep a runtime trust inventory. For every service that makes or terminates a TLS connection, record which store it reads and what updates that store. Without it, a trust anchor rollout is an assumption applied to forty hosts at once.
  • Make the acceptance test cover every runtime class. A one line client in each language, run after the anchor is installed and again nightly, turns this incident into a five minute finding. The playbook must fail the host when any probe fails.
  • Point runtimes at a truststore you own. Defaults get replaced. A JRE upgrade overwrites cacerts, a rebuilt virtualenv restores certifi, and a base image refresh restores the compiled in list. An explicit path under configuration management survives all three.
  • Alert on the anchor, not just the leaf. Compare the anchor fingerprint on every host against the published root and alert on any difference, and alert on the internal root expiry with a warning at 90 days and a page at 30. A root transition needs a quarter of planning, not an afternoon.
  • Detect disabled verification automatically. Grep the estate for NODE_TLS_REJECT_UNAUTHORIZED, verify=False and all-trusting trust managers on every merge, and fail the build. These never announce themselves at runtime, so the only place to catch them is the diff.
  • Cross-reference the platform courses when writing the runbook. Linux for Production Sysadmins - Part LXXI (TLS) covers the operating system trust store and the tooling that maintains it, and Observability for Production Sysadmins - Part LXIII (Synthetic) covers the per runtime probe that turns this into an alert instead of an outage.