Blog

Port 443 belongs to the server, not the domain

When one domain on a shared host loses its certificate, the obvious move is to close HTTPS for it. A firewall cannot do that. It can only close HTTPS for every domain on the server. Why iptables never sees a hostname, and the guard that decides when the port may close.

On a fleet of shared hosting servers, certificates and the firewall meet at one port. When a server’s first certificate is issued, port 443 opens on it. When a certificate goes away — revoked, deactivated, expired, deleted — the port may close.

May. That word is the whole of this post.

Picture a server hosting a dozen customer domains. One of them has its certificate revoked. The event arrives at the firewall as “certificate removed on this server”, and the natural response is to close 443. The result: HTTPS breaks for the eleven domains that did nothing wrong.

Where the hostname actually lives

iptables matches on what is in the IP and TCP headers: source and destination addresses, protocol, ports, connection state. A rule can say “accept TCP to port 443”. It cannot say “accept TCP to port 443 for shop.example”, because no packet the firewall decides on carries that name.

Follow a connection from the start:

  1. The client sends a SYN to port 443. It has no payload. The firewall accepts it or rejects it — here, with nothing to go on but an address and a port.
  2. The TCP handshake completes.
  3. The client sends a TLS ClientHello, and inside it, in the Server Name Indication extension, the hostname it wants.
  4. The web server reads the SNI and picks the matching server block and certificate.
  5. After the handshake, the HTTP Host header names the site again.

The decision the firewall makes happens at step 1. The hostname does not exist until step 3. Port 443 is open or closed per server — never per domain.

client SYN :443 iptables sees: address · port · state decides here — no hostname yet handshake TLS ClientHello SNI: b.example ← first sight of the name nginx on :443 — one port, many sites a.example cert A b.example selected by SNI c.example cert C Disable one site's HTTPS here — move its SSL server block aside. close 443 because b.example lost its cert a.example HTTPS down b.example HTTPS down (intended) c.example HTTPS down one domain's event, every domain's outage The firewall answers "may anyone reach HTTPS on this server?" "Should this site serve HTTPS?" is a question for the layer that can see the site.
The firewall's decision is made on a packet with no hostname in it. Per-domain control has to live where the name is visible — in the web server.

Could you match the SNI in the firewall anyway? iptables can match strings inside packets, but it would be fragile — the handshake has to be allowed before the ClientHello even arrives, and Encrypted Client Hello is designed to hide exactly that field from anything on the path. So per-domain HTTPS control belongs in nginx, which chooses a server block by SNI. That is how disabling one domain’s HTTPS works on this fleet: its SSL config moves out of the directory nginx reads, and the other sites are untouched.

The guard: count before you close

The requirement came from my team lead: closing 443 must not break the sites that still have certificates. The design I built puts the guard on the action, not the reason. Revocation, deactivation, expiry and deletion all arrive as “remove”, and all of them get the same check:

-- how many certificates are still active on this server?
SELECT COUNT(*) FROM certificates
WHERE server_id = %s AND status = 'active';

If the answer is above zero, the script declines, and says so: “3 certificate(s) still active on this server — not closing 443.” The rule is only marked inactive when the last certificate is gone.

Which field you count matters

The records also carried an expiry date, and it would have been natural to count certificates that had not expired yet. That date was not read from the certificate — it was written as a fixed ninety days when the record was created, and commented as a placeholder. So a record could be active with an expiry in the past.

Counting by that date would have closed a live server’s port because a placeholder had passed: exactly the outage the guard exists to prevent. The status field was the authoritative one — written back by the server itself when issuance completed — so that is the only one the guard reads.

When you cannot count, do not close

The query can fail. The database can be unreachable. The count’s configuration can be missing. In every one of those cases, the script refuses to close the port and exits with a distinct code, 3: could not prove the last certificate is gone — 443 left open.

That asymmetry is deliberate, and it comes from comparing the two ways of being wrong:

If the script guesses wrong and…Cost
leaves 443 open with no certificate behind ita port that serves nothing useful, until the next reconciliation
closes 443 under live certificatesan HTTPS outage for every site on the server

When the costs are that lopsided, “unknown” must resolve to the cheap failure. Refuse rather than guess — and make the refusal visible, so it gets looked at instead of retried until it happens to pass.

Opening: check state, don’t start a timer

The other direction has its own trap. Provisioning a new server issues one certificate per domain, and each one calls the script. A server with ten domains produces ten “open 443” calls in quick succession — and each firewall deploy takes the fleet’s deployment lock.

The first suggestion was to debounce with a three-minute timer. I proposed a state check instead: before doing anything, ask whether an active 443 rule for this server already exists. If it does, there is nothing to write and nothing to deploy, and the orchestrator is skipped entirely. Only the first domain on a server pays for a deploy.

The difference is what each one can know. A timer knows when you last deployed. It cannot tell a redundant call from a retry of one that failed — so it can skip a deploy that was genuinely needed. A state check knows whether the port is open, which is the question that matters. If an earlier deploy failed while the rule was written, the hourly sweep — which redeploys what the database says, but never decides anything itself — is the backstop.

Validate the identifier, not just the action

During live testing, the server-ID check caught a real typo: an ID with an extra digit. Without it, the script would have written a firewall rule scoped to a server that does not exist. That rule would have looked correct in the table. The generator would have matched it against no host. And 443 would have silently never opened on the server that needed it.

So every call is checked against the list of real servers before anything is written. A rule that matches nothing is worse than an error, because it looks applied.

What I took from it

  • Know which layer your control lives at. A control can only act on what its layer can see. The firewall sees servers; the web server sees sites.
  • Put guards on the action, not the reason. New reasons for “remove” will appear. They should all hit the same check without anyone remembering to add it.
  • Count the authoritative field. A plausible-looking column that nobody keeps accurate is worse than no column.
  • Unknown resolves to the cheap failure. Work out which mistake is an outage, and make uncertainty land on the other one.
  • State beats timers. Ask whether the thing is already true, not whether you did something recently.

This bridge connects the SSL certificate automation to the firewall platform, which compiles and deploys the rule it writes.

References

← All posts