A Port Check Has Three Answers, Not Two — 'Open', 'Closed', and 'I Don't Know'
Checking whether a port is open looks like the most binary thing in monitoring. You open a TCP connection; either it succeeds or it doesn't. Open or closed. Two states.
That model is exactly why so many port monitors page you for outages that never happened. The connection not succeeding is not one fact — it is at least four, and only one of them means "the service is down." A port check that folds all of them into closed is not a monitor; it is a random number generator that occasionally wakes you up.
The honest answer set has three members, and the third one is the whole game.
The Three States
Open a raw TCP socket to host:port and race it against a timeout. Here is what the outcomes actually mean:
open— the TCP handshake completed. Something is listening and accepting connections. This one is real.closed— the connection was actively refused or reset. The host is reachable and it said "nothing is listening on that port."ECONNREFUSED/ECONNRESET. This is a real, deterministic negative — it is the thing you alert on.unknown— everything else. A connect timeout. A DNS resolution failure. Your probe getting rate-limited or firewalled. A transient edge error. None of these tell you the port's state. They tell you that you could not find out.
The distinction between closed and unknown is the same distinction as "the server returned 404" versus "the request timed out." A refusal is information about the port. A timeout is information about your probe. A timeout is not a closed port — it is the absence of an answer, and a monitor that records it as closed is asserting something it does not know.
Why does this matter so much? Because the failure modes are asymmetric. A missed real outage is bad. But a fabricated one — a 3am page because a single probe timed out on a port that was fine the whole time — is worse in a specific, corrosive way: it trains you to ignore the monitor. After the third false page you add a mute rule, and the mute rule is still there the night the port is genuinely down. Alert fatigue is not caused by too many alerts; it is caused by alerts you have learned not to trust, and nothing erodes trust faster than a "down" that was never down.
So the rule for a port checker worth running is: only ever say closed when the host actively refused the connection. Everything else is unknown, and unknown does not page anyone. A firewall that DROPs instead of REJECTs (silent — you get a timeout, not a refusal), a slow SYN-ACK, a resolver blip: all unknown. The customer paying for a port monitor is paying for the difference between "your SMTP relay stopped listening" and "my probe had a bad five seconds," and that difference lives entirely in the third state.
An unknown With No Reason Is Undebuggable
There is a subtler trap waiting one level down. Say you get the three states right. You still have to record why you landed on unknown, because "unknown" without a cause is a dead end for whoever is on call.
We learned this the unfun way. For a stretch, every reachable host we port-monitored came back unknown — correctly, as it turned out — but with no recorded reason. From the outside it was indistinguishable from the monitor being broken. Was it a timeout? A resolver failure? A guard rejecting the probe? Nobody could tell, because the row just said unknown and stopped.
The fix is boring and mandatory: every outcome carries a stable machine reason — connection_refused, timeout, resolver_error, unresolved, ssrf_private_ip, connect_error — persisted alongside the status. An unknown that says timeout is a shrug you can act on ("the port is slow or black-holed"). An unknown that says nothing is a shrug you can only stare at. If you build one of these, make the reason a required field, not an afterthought.
Your Port Monitor Is One DNS Rebind Away From Scanning Your Own Network
Here is the part that turns a reliability feature into a security liability if you are not careful.
A port monitor takes a hostname and a port from a user and opens a socket to it. Read that sentence again as an attacker would: it opens a socket to an address the user controls. If the user points it at 169.254.169.254 (the cloud metadata endpoint), 127.0.0.1, or an 10.x/192.168.x address, your monitor becomes an internal port scanner — a classic Server-Side Request Forgery (SSRF) primitive. "Is port 6379 open on 10.0.0.5?" is a question your infrastructure should never answer on a stranger's behalf.
The obvious defense is to resolve the hostname and reject any private, loopback, link-local, or reserved IP before connecting. Necessary — but on its own, not sufficient, because of a timing hole. You resolve evil.example.com, get a public IP, decide it's safe... and then you call connect(hostname), which resolves the name a second time. An attacker who controls the authoritative DNS can hand your safety check a public IP and hand the socket layer 127.0.0.1 a millisecond later. This is DNS rebinding — a time-of-check-to-time-of-use (TOCTOU) bug — and it defeats a naive "resolve then connect by name" guard completely.
The fix is to connect to the vetted IP, not the name. Resolve once, run every returned address through the private-IP check, and then hand the socket the literal IP you already validated — never the hostname. There is no second resolution to poison. (For a raw TCP port probe this is free; you are not doing TLS, so there is no SNI or certificate-hostname reason to keep the name around.) If you take one thing from this section: a port monitor that passes a user-supplied hostname straight to connect() has an SSRF hole even if it "checks for private IPs first," because it checks a different resolution than the one it dials.
The Cloudflare Workers Gotcha: 13,489 Checks, Zero Answers
We run our probes from Cloudflare Workers, using the connect() socket API. If you do the same, there is a specific way this quietly produces a monitor that measures nothing.
The Workers runtime refuses to open a connect() socket to ports 80 and 443. Its own docs say it plainly: "If you need to connect to addresses on port 80 or 443 to make HTTP requests, use fetch." Try it anyway and you get an error containing "it looks like you might be trying to connect to a HTTP-based service — consider using fetch instead."
Now watch what a well-meaning-but-naive checker does with that. The connection didn't succeed, so it's... not open. The error text doesn't contain "refused," so a careful checker won't call it closed either — good, it lands on unknown. Which is technically correct and completely useless, because the two most common ports anyone wants to monitor are exactly the two the runtime will never let you probe this way. We measured it: 13,489 out of 13,489 port checks over a five-week window came back unknown, every one of them a :443 host the runtime refused. A monitor that returns "I don't know" 100% of the time is honest and worthless in the same breath.
Two things fix it, and they are different problems:
- Make the refusal legible. That error is not a transient blip — it is a permanent property of "this runtime, this port." Give it its own reason code and back the probe off, instead of filing it under the catch-all
connect_errorwhose docstring literally says "transient, retry." Otherwise you retry an impossible operation forever. (Our 13,489 were all mis-filed as retryable. They retried forever.) - Actually answer the question. The runtime told you how to reach
:80/:443— over HTTP. So on those ports, ask over HTTP first: issue one request with redirects disabled and the body never read. Any response — 200, 401, 503, doesn't matter — is proof that a TCP connection to that port was established and something is serving on it, which is exactly what a port monitor is asked to determine.redirect: 'manual'matters here: a 301 fromhttp://host:80/tohttps://host/is an answer from port 80; following it would resolve against a different port and turn a true statement about:80into a false one.
The general lesson outlives the specific runtime: when your probe infrastructure refuses an operation, read why it refused. The refusal is often the answer to the question you were asking, in a form you didn't expect.
Merlonix's port monitor (SMTP 25/465/587, DNS 53, and any custom TCP service the HTTP-layer uptime check doesn't cover) is built on exactly these three rules. It returns open, closed, or unknown, and it only pages on a deterministic closed — a genuine connection refusal — so a timeout or a resolver blip never fires a false "your port is down." Every unknown carries a machine reason so it is debuggable instead of mysterious. The SSRF guard resolves the hostname, rejects any private or reserved IP, and connects to the vetted IP rather than re-resolving the name, closing the DNS-rebinding hole. And on :80/:443 it takes Cloudflare's own advice and confirms reachability over HTTP, so the two ports everyone actually wants watched return a real answer instead of a shrug.
It sits on the same surface as the rest of what an attacker or a researcher probes first — your TLS posture and certificate expiry, your security.txt and HTTP headers, and your external reachability from outside your own cloud provider. You can check a domain's live TLS, DNS, and header posture right now without signing up, and the free tools hub has the rest. Whatever you use to watch your ports, hold it to the three-state bar: if it can only say open or closed, it is guessing on every timeout — and it will spend that guess waking you up.