Skip to main content
RunBook Academy

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

advancedtls-config~45 min

The client that refused to speak, and the client that was never asked

Reported symptoms

  • At 02:04 UTC the nightly settlement import from a partner operated appliance fails to establish a connection to app.lab.example, for the first time in three years of unbroken runs
  • Every browser, every internal service and every synthetic probe reaches the same endpoint in the same minute without difficulty, so the service is reported healthy for the entire incident
  • The partner can say only that the connection was closed by the remote end and cannot state which TLS version their appliance offered
  • An engineer reproduces the failure at 10:40 by asking the local OpenSSL client for TLS 1.1, gets an immediate error, and the channel records this as confirmation that the server rejects TLS 1.1
  • The nginx error log on app.lab.example holds one handshake failure per partner attempt at 02:00 and nothing whatsoever at 10:40, which nobody reads until much later
  • A rollback of the hardening change is drafted at 11:15 to restore TLS 1.1, before anyone has established which version the appliance actually offers
  • The only change in the preceding week was a protocol floor raise applied two days earlier as part of a scheduled hardening programme, and it was applied to eleven other services with no complaints

Evidence

  • · openssl s_client asked for TLS 1.1 fails with error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available, an error naming a local OpenSSL routine rather than an alert received from a peer
  • · The same command without a version flag, run seconds later against the same address, reports Protocol: TLSv1.3 and Verify return code: 0 (ok), so connectivity, certificate and chain are all sound
  • · The same command pinned to TLS 1.2 completes a handshake, which places the server protocol floor at or below TLS 1.2 without saying where exactly
  • · The nginx error log records a handshake failure from the partner source address at each 02:00 attempt and records nothing at 10:40, so the workstation reproduction never reached the server at all
  • · A packet capture taken on the workstation during the failing TLS 1.1 attempt shows the TCP connection open and close with no TLS record sent, confirming that no ClientHello was ever constructed
  • · nginx -T on the serving node shows ssl_protocols listing TLSv1.2 and TLSv1.3 only, so the running configuration matches the change that was intended
  • · The system OpenSSL configuration under the directory reported by openssl version -d sets a minimum protocol above TLS 1.1 in its system default section, which is why the client would not construct the request
  • · A capture of the partner ClientHello during the following night shows a maximum supported version of TLS 1.0, so restoring TLS 1.1 would not have restored the integration
  • · The access log format in use carries no TLS fields at all, so there is no record of what any client negotiated at any point before the change
Diagnosis and resolutionclick to reveal

Root cause

Two defects, and the second one is the reason this took nine hours rather than one. The first is an ordinary change management failure. The protocol floor on app.lab.example was raised from TLS 1.0 to TLS 1.2 without an inventory of what the clients of that service actually negotiate. The data needed to build that inventory was available at zero cost, because the web server can record the negotiated protocol and cipher for every request, and the access log format in use recorded neither. The change was therefore approved on the assumption that everything modern reaches this endpoint, which was true of eleven other services and was not true of a partner operated appliance that tops out at TLS 1.0. The second defect is a diagnostic one and it is the part worth keeping. The reproduction that the channel accepted as proof of a server policy was a refusal generated entirely inside the engineer workstation. A current OpenSSL build will not construct a TLS 1.1 ClientHello when its own configuration sets a higher minimum protocol, so the command failed before a single TLS byte reached the network. The error names a local routine, no packet capture shows a ClientHello, and the server logged nothing at that time. Because that result was read as a statement about the server, the team spent the morning re-examining a configuration that was correct, and drafted a rollback restoring a protocol version the partner appliance had never used.

Remediation

Establish which end refused before changing anything on the server, because the two possibilities have opposite fixes and one of them is not on your estate at all. Two cheap observations settle it. Read the server error log at the exact timestamp of each failure, including your own reproduction: an attempt that the server never saw did not reach it. Then look at the wording of the client error, because an error naming a local TLS setup routine is a refusal to offer, while an alert received from the peer is a rejection of what was offered. Confirm the running configuration rather than the file that was edited, on every node behind the load balancer and not only the one you have a shell on. Then find out what the partner actually offers by capturing their ClientHello during the next attempt. Guessing here is what produced the rollback proposal. Add the negotiated protocol and cipher to the access log format and reload, so that the following day produces the data the change should have been approved against. This is a reload rather than a restart and costs nothing. The real remediation is on the client side. TLS 1.0 and TLS 1.1 are deprecated by RFC 8996 and the appliance must be upgraded; give the partner a date and hold it. If an exception is genuinely unavoidable in the interim, do not lower the floor for the service. Terminate the legacy client on a separate listener with its own address, hostname and certificate, restricted by source address to the partner ranges and carrying a documented removal date. Do not enable every protocol globally, do not lower the server security level, and do not disable certificate verification anywhere; the last of those is not even related to this fault.

Verification

The proof belongs to the server and to the business process, not to the partner assurance that they have upgraded. When the next scheduled run happens, the request must appear in the access log with the negotiated protocol field populated at TLS 1.2 or above, with the expected status code, and the settlement records must land in the database. Three independent observations of one event, none of them a re-reading of the configuration that was changed. Prove that the main listener still enforces the floor, and prove it with a client capable of offering something lower. A current OpenSSL refusing to offer TLS 1.1 demonstrates nothing about the server, which is the whole lesson of this incident. Use a scanner that constructs its own hellos, or a pinned container image with an older library, and confirm that the server answers with a protocol version alert rather than a connection. If an exception listener was created, confirm from a host outside the permitted ranges that it does not answer, and confirm from inside them that it does. Check the error log is silent during the partner window, and run the configuration dump on every serving node rather than one, because a node missed during the change is the thing that fails next month with no obvious cause.

Prevention

Record the negotiated protocol and cipher in the access log on every TLS service, and keep 90 days of it. It costs one directive and it is the only evidence that can retire a protocol version safely. Gate protocol floor changes on that evidence. Require 14 consecutive days showing zero requests below the proposed floor before the change is approved, and alert immediately if any request below the floor appears afterwards, because that is either a client you did not know about or a listener that was missed. Announce deprecations against a calendar rather than a maintenance window. Ninety days of notice with a named contact for every integration, a reminder at 30 days and a page at 7 days before any temporary exception listener expires, turns a partner upgrade into a project rather than an outage. Keep a reproduction environment that can still offer old protocols, as a pinned container image referenced in the runbook. The question can the server negotiate this version is then answerable in one command by anybody, instead of producing a misleading local error on whichever workstation is nearest. Write the discriminator into the TLS troubleshooting runbook in plain words: an error naming a local TLS routine is never evidence about a remote policy, and the first check in every handshake incident is whether the server saw the attempt at all.

Reported symptoms

A hardening programme had been running for a quarter. Twelve externally reachable services were having their TLS protocol floor raised from 1.0 to 1.2, one service a week, each one a two line change and a reload. Eleven had gone through without a single question. app.lab.example was the twelfth, and it was applied on a Monday afternoon.

At 02:04 UTC on the Wednesday the nightly settlement import from a partner operated appliance failed for the first time in three years.

  • The appliance reports only that the remote end closed the connection. The partner cannot say which TLS version it offered, and the vendor support contract expired in 2024.
  • Everything else is fine. Browsers, internal services and the synthetic probes all reach the endpoint, and the service dashboard is green for the whole incident.
  • At 10:40 an engineer reproduces it. openssl s_client -tls1_1 against the endpoint fails instantly. The channel treats this as the answer: the server has stopped accepting TLS 1.1, the change did it, roll it back.
  • A rollback restoring TLS 1.1 is drafted at 11:15 and queued for the afternoon change window.
  • Nobody has yet opened the nginx error log, which contains exactly one handshake failure per partner attempt at 02:00 and absolutely nothing at 10:40.

The twist arrives at 19:30, when somebody finally captures the partner ClientHello on the server and reads the version field. The appliance offers TLS 1.0. The rollback that had been queued all afternoon would have restored TLS 1.1 and the integration would have stayed down, at which point the team would have had a reverted hardening change, an unexplained outage, and no remaining hypotheses.

Evidence provided

Read-only / Safethe workstation at 10:40 - read the error text before reading the intent
$ openssl s_client -tls1_1 -connect app.lab.example:443 -servername app.lab.example < /dev/null
error:0A0000BF:SSL routines:tls_setup_handshake:no protocols available

Illustrative output

Read-only / Safethe same workstation, seconds later, same address - nothing is wrong with the path
$ 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 / Safeapp.lab.example - the log that settles which end refused
$ sudo grep -F 'SSL_do_handshake' /var/log/nginx/error.log | tail -20
Read-only / Safethe workstation - proof that nothing left the host
$ sudo tcpdump -ni any -c 20 'tcp port 443 and host 198.51.100.20'
Read-only / Safethe workstation - why the client would not even ask
$ openssl version -d
Read-only / Safeapp.lab.example - the configuration in force, not the file that was edited
$ sudo nginx -T | grep -n ssl_protocols
Read-only / Safeapp.lab.example - what the partner actually offers
$ sudo tcpdump -ni any -s0 -w /tmp/partner.pcap 'tcp port 443 and host 203.0.113.10'

Work the evidence before reading on

Two commands failed in this incident and they failed in completely different places. Separating them is the whole exercise.

  1. One error names an OpenSSL routine. Where does an error named after a local function come from, and what would an error caused by a remote peer look like instead?
  2. The server error log has entries at 02:00 and none at 10:40, and both events were described in the channel as the same failure. Which of the two did the server actually experience?
  3. The packet capture on the workstation shows no TLS record at all. Given that, what is the maximum claim the 10:40 reproduction can support?
  4. The rollback was going to restore TLS 1.1. Before reading on, work out what would have to be true about the appliance for that to have helped, and whether anything in the evidence establishes it.

Before continuing: state the single observation that distinguishes a client that would not offer a version from a server that would not accept it, and say where that observation is recorded.

Root cause

A protocol floor was raised without knowing what used it

The change itself was correct and overdue. TLS 1.0 and TLS 1.1 are deprecated, the eleven earlier services took it without incident, and the twelfth was scheduled the same way.

What was missing was the evidence. A web server can record the negotiated protocol and cipher for every request it serves, at the cost of two fields in a log format. This estate recorded neither, so there was no way to answer the only question that mattered before approval: has anything below TLS 1.2 connected to this service in the last month. The change was approved on the reasonable belief that everything modern reaches this endpoint. One thing that reaches it is not modern.

The reproduction was a local refusal

This is the part that cost the day.

A current OpenSSL build will not construct a TLS 1.1 ClientHello when its own configuration forbids it. The version flag asks for a protocol; the local policy decides whether that protocol may be offered at all; and when it may not, the library gives up during handshake setup, before any socket work that matters. The error text says so: it names tls_setup_handshake, a function inside the local library, and it mentions no peer.

Three independent observations confirm it. The packet capture on the workstation shows the TCP connection opening and closing with no TLS record. The server error log has no entry at that timestamp. And the unflagged command against the same address in the same minute completed a TLS 1.3 handshake perfectly, so nothing about the path or the certificate was in question.

The team read a client side refusal as a server side policy, and every conclusion downstream of that reading was wrong.

The proposed fix addressed a version nobody used

Because the reproduction appeared to prove that TLS 1.1 was rejected, the rollback restored TLS 1.1. The partner ClientHello, captured the following night, offers a maximum of TLS 1.0.

That is what makes an unverified diagnosis expensive rather than merely slow. The rollback would have reverted a correct security change, left the integration down, and removed the most recent change from the list of suspects. The next investigation would have started from a worse position than this one did.

Resolution

  1. Determine which end refused before changing anything, because a client side refusal and a server side rejection have opposite fixes and one of them is not on your estate. Read the server error log at the exact timestamp of every failure including your own reproduction: an attempt with no server side record did not reach the server.
  2. Read the client error text as text. An error naming a local TLS setup routine is a refusal to offer. An error reporting an alert received from the peer is a rejection of what was offered. Only the second one is evidence about the server.
  3. Confirm the running configuration with nginx -T rather than the file that was edited, and do it on every node behind the load balancer. A node missed during a change is a fault that surfaces weeks later with nothing to connect it to.
  4. Capture the partner ClientHello during their next attempt and read the maximum offered version. Until that number exists, every proposed remedy is a guess, and the queued rollback in this incident was a guess that would have failed.
  5. Add the negotiated protocol and cipher to the access log format and reload. It is a reload rather than a restart, it costs nothing, and it produces the evidence the change should have been approved against.
  6. Fix the client, because that is where the defect is. TLS 1.0 and TLS 1.1 are deprecated by RFC 8996 and the appliance needs an upgrade. Give the partner a date, put it in writing, and hold it.
  7. If an exception is genuinely unavoidable while the partner upgrades, do not lower the floor on the service. Terminate the legacy client on a separate listener with its own address, hostname and certificate, restricted by source address to the partner ranges, and carrying a removal date and an alert seven days before it.
  8. Do not enable all protocols globally and do not lower the server security level. Both reach well past protocol versions into signature algorithms and key sizes, and both apply to every client rather than the one that needed help.
  9. Do not reach for curl -k, verify=false or any other suppression of certificate checking. Nothing in this incident involves certificate validation, and adding a disabled check to an unrelated fault is how estates acquire permanent silent weaknesses.

Verification

  1. Watch the next scheduled partner run from the server. The request must appear in the access log with the negotiated protocol field populated at TLS 1.2 or above and with the expected status code. A connection that fails negotiation never reaches the access log, so its presence there is the proof.
  2. Confirm the business outcome as well as the connection. The settlement records must be present in the database for that run, because a completed handshake and a completed import are different claims.
  3. Prove the main listener still enforces its floor using a client that can actually offer something lower: a scanner that constructs its own hellos, or a pinned container image with an older library. A current OpenSSL refusing to offer proves nothing, which is the lesson this incident exists to teach.
  4. That client must receive a protocol version alert from the server, and the server error log must record the corresponding handshake failure. Two records, one at each end, is what a server side rejection looks like.
  5. If an exception listener was created, confirm from a host outside the permitted source ranges that it does not answer at all, and from inside them that it does. An exception that is reachable from everywhere is not an exception.
  6. Run the configuration dump on all four serving nodes and compare, rather than trusting the one node you have a shell on.
  7. Confirm the error log is silent through the partner window on the following two nights, since a single successful run can be a retry that happened to land on a node that had not been changed.

Prevention

  • Log the negotiated protocol and cipher on every TLS service. Two fields in a log format, 90 days of retention, and protocol retirement stops being a judgement call. This estate had eleven successful floor raises and no data from any of them.
  • Gate floor changes on that data. Require 14 consecutive days showing zero requests below the proposed floor before approval, and alert immediately if any request below the floor appears afterwards. Such a request is either a client nobody knew about or a node that was missed.
  • Announce deprecations against a calendar, not a change window. Ninety days of notice with a named contact per integration, a reminder at 30 days, and a page 7 days before any temporary exception listener expires. Partner upgrades take months, and the only variable you control is when you start.
  • Keep a reproduction environment that can still speak old protocols. A pinned container image named in the runbook means anyone can answer what will the server negotiate in one command, rather than producing a misleading local error on whichever workstation is nearest.
  • Write the discriminator into the runbook in plain words. An error naming a local TLS routine is never evidence about a remote policy, and the first question in any handshake incident is whether the far end saw the attempt at all.
  • Borrow the monitoring from the adjacent courses. Observability for Production Sysadmins - Part LXIV (TLSMonitoring) covers probing a TLS endpoint continuously rather than at change time, and Linux for Production Sysadmins - Part LXXI (TLS) covers the system OpenSSL configuration that produced the misleading local error here.