I Gave It a Real Name and It Stopped Answering
Safari puts a grey “Not Secure” next to a page that nobody but me will ever open. I set out to make the label go away. It broke this site’s own dev server twice, and it ended with three unrelated services no longer listening to the network at all.
The cheapest possible reason to change anything
The browser tries HTTPS before it tries HTTP. A service reached at a bare numeric address on a private network cannot present a certificate that matches, because no certificate authority will issue one for that. So the probe fails, and the warning appears, and there is no setting anywhere that fixes it while the thing is addressed by number.
The way out is a name. Put a TLS proxy in front of each service, get a certificate for a real hostname, open it by that name instead of by its address.
That is the entire motivation. A grey label on a page with one reader.
The dev server refused the front
The first thing to break was the dev server that builds this site. Behind the new proxy, every request arrived carrying a hostname, and the server answered:
Blocked request. This host (…) is not allowed.
That is Vite’s protection against DNS rebinding, and I read the function it lives in rather than guessing at it. isHostAllowedWithoutCache takes the host off the request and decides. An IPv4 literal is allowed. A bracketed IPv6 literal is allowed. The loopback hostname is allowed, and so is any subdomain of it. Everything else has to appear in a list you write yourself.
So it waves through anything addressed by number and interrogates anything addressed by name.
That is coherent, and it is the right way round. Rebinding is an attack on names: someone controls a name, repoints it at your machine, and your browser makes the request on their behalf. A literal address cannot be repointed at anything. Names are exactly the class worth checking.
The shape of it is still strange. The least identifiable way to reach a service is the one that needs no permission, and the moment I gave the service a real name and a certificate proving it, I had to grant that name access to the thing it was already reaching.
The repair is four lines: read the extra hostnames from an environment variable, default to an empty list. The name never appears in the file, which is the only reason that file is safe to publish.
Two listeners, one address
The second break was in a different layer, and I did not connect the two for a while.
The daemon that terminates TLS for the name has to hold the port it serves on. The services behind it were binding that same port too, on that same private address, because that is what they had always done. Two processes, one socket.
They raced. Not once: on every restart, forever, with the winner decided by startup timing. The loser is either a process crash-looping against a port it cannot have, or a proxy standing in front of nothing.
It tried another one
That race is where the dev server broke the second time, and it is why any of this got looked at closely.
When a port is taken, Vite does not stop. It writes one line, at info level:
Port … is in use, trying another one...
and starts on the next port up. The proxy in front kept forwarding to the port it had been given. Two processes, both healthy, both reporting success, and the pair between them serving nothing. The only trace was a sentence that does not read like a problem, because from inside that process it was not one.
The flag that turns that line into a fatal error is strictPort. I have already written the piece about making a default fail loudly rather than usefully, so I am not going to write it again. Setting it was bookkeeping.
The line was right when I wrote it
Here is the part I keep going back to.
Three weeks ago, after a pass that took every service off the local network, I wrote down a rule for bind addresses. Bind loopback when the only thing that talks to a service lives on the same machine. Bind the private network address when the phone opens it directly.
Every one of those services was correct under that rule. The phone did open them directly. They bound the private address. That was the rule, applied, and the answer it produced was right.
Putting a proxy in front of them changed one of the rule’s inputs. The phone no longer opens them directly. The proxy does, from the same machine, which is precisely loopback’s condition.
The rule did not change. Its answer did.
Nothing recomputed it, because a config file stores a conclusion and never the question behind it. There is no field for why this address, and nothing re-reads the reasoning when something new is installed next door. A line that has not needed editing in weeks looks identical to a line that is still correct.
A wrong value announces itself by failing. A stale answer to a rule that is still right keeps working, because a competent person reasoned it into place, and it stays invisible until a neighbour changes and the two collide. Here that collision arrived as an intermittent port race decided by startup order, which is about the least legible symptom it could have picked.
What the label bought
The three bind fixes are one line each. An address, replaced by loopback. One of the three is still waiting on a restart I have to do myself. Every commit message says some version of the same sentence: narrowed, never widened. What none of them records is that the line being narrowed had been the right line, chosen deliberately, three weeks earlier.
I found none of it by auditing anything. The audit had already run, and its output was the rule those services were correctly obeying. I found it because a browser put a grey label on a page that nobody but me will ever load.