SSL_ERROR_RX_RECORD_TOO_LONG: HTTPS on a plaintext port
Firefox reports SSL_ERROR_RX_RECORD_TOO_LONG, curl reports (35) TLS connect error: error:0A00010B:SSL routines::wrong version number, and the certificate is not the problem. The TLS client is talking to a listener that is not speaking TLS: a plaintext HTTP port, a reverse proxy pointed at the wrong upstream scheme, a Docker port mapping aimed one port too far, a Cloudflare zone set to Flexible. The handshake dies on the first five bytes the server sends back, so every error message names the record layer instead of the setting you actually got wrong.
This is worth a full write-up because the three symptoms — browser string, curl string, openssl string — look unrelated, and because the reverse direction (a plaintext request hitting a TLS port) produces a completely different set of strings on the server side. I rebuilt all of it on a lab host so nothing below is quoted from memory: Debian 13 on Linux 6.17 (aarch64), OpenSSL 3.5.6, curl 8.14.1, Python 3.13.5, Node v26.5.1. Every block of output is pasted from a run you can repeat with the commands in this article.
TL;DR
- A TLS record begins with a 5-byte header: 1 byte content type, 2 bytes version, 2 bytes length. The client parses that header before anything else in the protocol, including certificates.
- A plaintext HTTP reply starts with the bytes
H T T P=0x48 0x54 0x54 0x50. As a record header that is an invalid content type and an invalid version, which is exactly whatwrong version numbermeans. - Measured, same mistake, four clients:
- curl →
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number - openssl →
error:0A00010B:SSL routines:tls_validate_record_header:wrong version number:../ssl/record/methods/tlsany_meth.c:77 - Node undici →
TypeError: fetch failedwith causeERR_SSL_WRONG_VERSION_NUMBER - Firefox →
SSL_ERROR_RX_RECORD_TOO_LONG(NSS code-12263, the name it gives this class of framing failure)
- curl →
- The oversized-length variant — a record header declaring 65535 bytes when the cap is 18432 — is rejected with
0A000092 ... data length too longfollowed by0A000139 ... record layer failure. That is the branch NSS namesSSL_ERROR_RX_RECORD_TOO_LONG. - Reverse direction, plaintext request into a TLS port: Python raises
ssl.SSLError: [SSL: HTTP_REQUEST] http request, Node reportscode=ERR_SSL_HTTP_REQUESTwith OpenSSL’stls_validate_record_header:http request, and the client seescurl: (52) Empty reply from serveror(56) Recv failure: Connection reset by peer. - Triage in one command:
openssl s_client -connect host:port -brief </dev/null.Protocol version: TLSv1.3means the port speaks TLS;wrong version numbermeans it does not. The script in this article classifies a whole list of targets and exits non-zero when one is closed. - The fix is never a certificate: it is the scheme in
proxy_pass, the--http=versus--https=flag intailscale serve, the container port in-p 443:8443, or the zone’s SSL/TLS encryption mode.
The five bytes that decide whether a port is TLS
TLS is a record protocol. Before a ClientHello is answered, before a certificate is sent, before any alert can be raised, the client reads 5 bytes and decides whether what follows can be a TLS record at all. The layout is fixed:
| Offset | Size | Field | Valid values |
|---|---|---|---|
| 0 | 1 byte | Content type | 0x14 change_cipher_spec, 0x15 alert, 0x16 handshake, 0x17 application_data |
| 1–2 | 2 bytes | Legacy record version | 0x0301–0x0304 (TLS 1.0–1.3) |
| 3–4 | 2 bytes | Length | ≤ 0x4000 (16384) plaintext, ≤ 18432 with expansion allowed |
An HTTP status line fails the first two fields at once. H is 0x48, which is not a valid content type; TT is 0x5454, which is not a valid version. OpenSSL reports the pair as a single framing error, and it does so at the record layer, before it has any reason to look at certificates. That ordering is the whole reason this failure is so often misdiagnosed as a trust problem.
The same logic runs in the other direction. A plaintext GET / HTTP/1.1 starts with 0x47 0x45 0x54 0x20: type 0x47, version 0x4520. A TLS server rejects it instantly, and several stacks recognise the specific shape of the mistake — that is where [SSL: HTTP_REQUEST] comes from.
Reproduce it in ninety seconds, no config files
You do not need nginx or a proxy to see the client-side failure. A plaintext HTTP server on a high port is enough, and Python ships one. Start it in one terminal:
$ python3 -m http.server 19460 --bind 127.0.0.1
Serving HTTP on 127.0.0.1 port 19460 (http://127.0.0.1:19460/) ...
Then ask it for HTTPS from another terminal. This is the single most common way to meet the error in a self-hosted stack: something in the middle is speaking https:// to a listener that only ever learned http://.
$ curl -sS -o /dev/null -w 'http_code=%{http_code}\n' https://127.0.0.1:19460/
http_code=000
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number
$ echo "exit=$?"
exit=35
The plaintext origin has the other half of the story, and it is worth reading its log before you touch a certificate store. What arrives is a ClientHello; what the server answers is a 400 about garbage, because it tried to parse a binary record as an HTTP request line:
$ # from the http.server terminal:
127.0.0.1 - - [23/Sep/2026 23:19:22] code 400, message Bad request syntax ('\x16\x03\x01\x06')
\x16\x03\x01\x06 is the first four bytes of a TLS 1.0+ handshake record: content type 0x16, version 0x0301, length 0x0600 and up. The server is telling you, in its own vocabulary, that your client sent TLS. If you see this line on a port you believed was HTTPS, stop debugging the certificate.
For the server side of the same mistake I used two TLS origins, because their error strings are not identical and both are useful. The Python one makes the failure explicit in ssl.py; the Node one surfaces the OpenSSL code that a reverse proxy would log.
#!/usr/bin/env python3
"""TLS origin on an explicit accept loop: a failed handshake is logged and the
listener survives. Self-terminating (LAB_SECONDS, default 25) so it is safe to
put in a script or a test harness.
Usage: python3 tls.py <port> <cert.pem> <key.pem>
"""
import os
import socket
import ssl
import sys
import time
PORT = int(sys.argv[1])
CERT, KEY = sys.argv[2], sys.argv[3]
LIFETIME = float(os.environ.get("LAB_SECONDS", "25"))
BODY = b'{"origin":"tls","tls":true}\n'
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", PORT))
srv.listen(16)
srv.settimeout(1.0)
print("tls origin on 127.0.0.1:%d for %.0fs" % (PORT, LIFETIME), flush=True)
def serve(conn, addr):
"""One connection: handshake, answer one request, log every failure mode."""
try:
tls = ctx.wrap_socket(conn, server_side=True)
except ssl.SSLError as exc:
# this is where a plaintext HTTP request lands
print("HANDSHAKE-FAILURE from %s:%d -> %r" % (addr[0], addr[1], exc), file=sys.stderr, flush=True)
conn.close()
return
try:
if not tls.recv(65536): # client hung up right after the handshake
return
tls.sendall(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
b"Content-Length: " + str(len(BODY)).encode() + b"\r\nConnection: close\r\n\r\n" + BODY
)
except (OSError, ssl.SSLError) as exc:
print("post-handshake error from %s:%d -> %r" % (addr[0], addr[1], exc), file=sys.stderr, flush=True)
finally:
try:
tls.close()
except OSError:
pass
deadline = time.monotonic() + LIFETIME
while time.monotonic() < deadline:
try:
sock, peer = srv.accept()
except socket.timeout:
continue
try:
serve(sock, peer)
except Exception as exc: # a bad client must never take the listener down
print("connection handler crashed: %r" % (exc,), file=sys.stderr, flush=True)
sock.close()
print("tls origin on 127.0.0.1:%d stopped" % PORT, flush=True)
The except ssl.SSLError branch around wrap_socket() is the part that matters. A TLS server has no logging convention for a client that speaks HTTP, so if you do not catch the handshake failure the traceback is all you get — and if you let it escape the accept loop, one bad client takes the whole listener down. I learned that the hard way while building this lab: my first version printed the failure and then died on the next request, which made the following four probes look like unrelated network errors.
The reverse direction: a plaintext request into a TLS port
Now invert it. Same certificate, same port, but the client uses http:// against a listener that expects a ClientHello. Generate a throwaway certificate and start the TLS origin from the script above:
$ openssl req -x509 -newkey rsa:2048 -nodes -keyout lab-key.pem -out lab-cert.pem \
-days 2 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
$ LAB_SECONDS=25 python3 tls.py 19461 lab-cert.pem lab-key.pem
tls origin on 127.0.0.1:19461 for 25s
Hit it with a plaintext client and watch both ends disagree about what just happened:
$ curl -sS -o /dev/null -w 'http_code=%{http_code}\n' http://127.0.0.1:19461/
http_code=000
curl: (56) Recv failure: Connection reset by peer
$ # tls.py stderr:
HANDSHAKE-FAILURE from 127.0.0.1:39562 -> SSLError(1, '[SSL: HTTP_REQUEST] http request (_ssl.c:1029)')
[SSL: HTTP_REQUEST] is OpenSSL recognising an HTTP request line where a record header should be. Python surfaces it as an SSLError from wrap_socket(); Node surfaces the same detection as ERR_SSL_HTTP_REQUEST and hands it to both the clientError and tlsClientError handlers. That second detail is a real trap: if your server only listens for clientError, an https port that gets plain traffic logs nothing at all.
// TLS origin (node:https) that logs tlsClientError / clientError, so a plaintext
// HTTP request arriving on the TLS port shows its real error code.
// Self-terminating, so it is safe inside a script.
// Usage: node node-tls.mjs <port> <cert.pem> <key.pem>
import fs from "node:fs";
import https from "node:https";
const port = Number(process.argv[2] ?? 19462);
const [cert, key] = process.argv.slice(3);
const lifetimeMs = Number(process.env.LAB_MS ?? 25000);
const server = https.createServer(
{ cert: fs.readFileSync(cert), key: fs.readFileSync(key) },
(req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end('{"origin":"tls","tls":true}\n');
},
);
server.on("tlsClientError", (err, socket) => {
console.log(`tlsClientError code=${err.code} message=${err.message}`);
});
server.on("clientError", (err, socket) => {
console.log(`clientError code=${err.code} message=${err.message}`);
if (err.code === "ECONNRESET" || !socket.writable) {
socket.destroy();
return;
}
socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
});
server.listen(port, "127.0.0.1", () => {
console.log(`tls origin on 127.0.0.1:${port} for ${lifetimeMs}ms`);
});
setTimeout(() => server.close(() => process.exit(0)), lifetimeMs);
Run it and repeat the plaintext probe against port 19462:
$ curl -sS -o /dev/null -w 'http_code=%{http_code}\n' http://127.0.0.1:19462/
http_code=000
curl: (52) Empty reply from server
$ # node-tls.mjs stdout:
clientError code=ERR_SSL_HTTP_REQUEST message=602A19A6C6FE0000:error:0A00009C:SSL routines:tls_validate_record_header:http request:../deps/openssl/openssl/ssl/record/methods/tlsany_meth.c:68:
tlsClientError code=ERR_SSL_HTTP_REQUEST message=602A19A6C6FE0000:error:0A00009C:SSL routines:tls_validate_record_header:http request:../deps/openssl/openssl/ssl/record/methods/tlsany_meth.c:68:
OpenSSL’s 0A00009C ... tls_validate_record_header:http request is the mirror image of 0A00010B ... wrong version number from the first test: same validation function, opposite direction. A reverse proxy that terminates TLS in front of a plaintext app tends to log the first string when misconfigured in one direction and the second when misconfigured in the other, which is why searching for one of them often returns the fix for the other.
Where SSL_ERROR_RX_RECORD_TOO_LONG actually comes from
The browser string is not a different bug — it is the same record-header parse, one branch further along. NSS keeps a dedicated code for a record whose declared length is impossible, next to the codes for malformed handshake messages, and Firefox reports it by name. From the NSS source, lib/ssl/sslerr.h:
SSL_ERROR_RX_RECORD_TOO_LONG = (SSL_ERROR_BASE + 25),
SSL_ERROR_TX_RECORD_TOO_LONG = (SSL_ERROR_BASE + 26),
With SSL_ERROR_BASE defined as (-0x3000), that is error code -12263 — useful if you are staring at a numeric NSS code in a log or in a crash report and need to know which side of the handshake broke. To see the same condition from the other side, this responder sends one TLS-looking header whose length field is 0xffff (65535 bytes, far above the cap) and then stalls:
#!/usr/bin/env python3
"""Record-header responder: sends one TLS-looking 5-byte header whose declared
length (0xffff = 65535) exceeds the 2^14+2048 record cap, then stalls. This is
the byte pattern behind NSS error SSL_ERROR_RX_RECORD_TOO_LONG.
Self-terminating (LAB_SECONDS, default 25).
Usage: python3 hdr-too-long.py <port> [hex]
"""
import os
import socket
import sys
import time
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 19463
HEADER = bytes.fromhex(sys.argv[2]) if len(sys.argv) > 2 else b"\x17\x03\x03\xff\xff"
LIFETIME = float(os.environ.get("LAB_SECONDS", "25"))
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", PORT))
srv.listen(8)
srv.settimeout(1.0)
print("record-header responder on 127.0.0.1:%d header=%s for %.0fs"
% (PORT, HEADER.hex(" "), LIFETIME), flush=True)
deadline = time.monotonic() + LIFETIME
while time.monotonic() < deadline:
try:
conn, addr = srv.accept()
except socket.timeout:
continue
conn.sendall(HEADER) # 17 03 03 ff ff: app-data record, 65535 bytes long
time.sleep(1)
conn.close()
print("record-header responder on 127.0.0.1:%d stopped" % PORT, flush=True)
Point openssl s_client at it and you get a two-line rejection that maps cleanly onto the browser string:
$ LAB_SECONDS=25 python3 hdr-too-long.py 19463 &
record-header responder on 127.0.0.1:19463 header=17 03 03 ff ff for 25s
$ timeout 15 openssl s_client -connect 127.0.0.1:19463 -brief < /dev/null
Connecting to 127.0.0.1
20A07A9F92E90000:error:0A000092:SSL routines:tls_validate_record_header:data length too long:../ssl/record/methods/tlsany_meth.c:121:
20A07A9F92E90000:error:0A000139:SSL routines::record layer failure:../ssl/record/rec_layer_s3.c:698:
Two things are worth keeping from that output. First, data length too long is the same validation that produces wrong version number — one function, two rejection reasons — so a browser that reports SSL_ERROR_RX_RECORD_TOO_LONG and a CLI that reports wrong version number can be looking at the identical misconfiguration. Second, the errors come in pairs: the first line names the field, the second names the layer. When you search for a fix, search the first line; when you read a log aggregator that only keeps the last line, you will get record layer failure and no clue at all.
A one-command triage that beats reading the config
The config lies. A Docker -p flag, a proxy upstream, an ingress annotation and a Tailscale serve rule can all describe a scheme that the process on the other end does not implement. Send a real ClientHello instead and look at what comes back:
$ openssl s_client -connect 127.0.0.1:19461 -brief < /dev/null 2>&1 | head -n 7
Connecting to 127.0.0.1
Can't use SSL_get_servername
depth=0 CN=localhost
verify error:num=18:self-signed certificate
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Protocol version: TLSv1.3 is the answer you want: that port speaks TLS, and any remaining problem is trust or hostname, not framing. The same command against the plaintext port answers with a single line and no version at all:
$ openssl s_client -connect 127.0.0.1:19460 -brief < /dev/null
Connecting to 127.0.0.1
20F0425E04FA0000:error:0A00010B:SSL routines:tls_validate_record_header:wrong version number:../ssl/record/methods/tlsany_meth.c:77:
$ echo "exit=$?"
exit=1
Two targets is fine by hand. For a list — every candidate port of a service you are rebuilding — wrap the same idea in a script that reports a verdict per target and still exits non-zero when something is unreachable, so it composes with CI:
#!/usr/bin/env python3
"""Classify a host:port as TLS, plaintext-but-reachable, or closed.
This is the 5-second triage behind curl's "wrong version number" and the
browser's SSL_ERROR_RX_RECORD_TOO_LONG: it sends a real ClientHello and reads
what comes back, so it reports what the listener *speaks* rather than what its
config claims.
Usage: tlsport.py host:port [host:port ...]
Exit: 0 = every target classified, 1 = at least one closed/filtered,
2 = usage error.
"""
import socket
import ssl
import sys
TIMEOUT = 4.0
# OpenSSL wording that means "these bytes are not a TLS record header".
FRAMING_MARKERS = (
"WRONG_VERSION_NUMBER",
"HTTP_REQUEST",
"RECORD_LAYER_FAILURE",
"UNEXPECTED_MESSAGE",
)
def probe(host, port):
"""Return (verdict, detail) for one target."""
try:
with socket.create_connection((host, port), timeout=TIMEOUT) as raw:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # classify framing, not trust
try:
with ctx.wrap_socket(raw, server_hostname=host) as tls:
return "tls", "%s / %s" % (tls.version(), tls.cipher()[0])
except ssl.SSLError as exc:
message = str(exc)
for marker in FRAMING_MARKERS:
if marker in message:
return "plaintext", "not TLS (%s)" % marker
return "tls-refused", message
except ConnectionRefusedError:
return "closed", "ECONNREFUSED"
except socket.timeout:
return "filtered", "no bytes in %.0fs" % TIMEOUT
except OSError as exc:
return "error", "%s: %s" % (type(exc).__name__, exc)
def parse(target):
"""'host:port' -> (host, port); None when the target is malformed."""
host, _, port = target.rpartition(":")
if not host or not port.isdigit() or not 0 < int(port) < 65536:
return None
return host, int(port)
def main(argv):
targets = [parse(a) for a in argv[1:]]
if not argv[1:] or any(t is None for t in targets):
sys.stderr.write(__doc__)
return 2
failed = 0
width = max(len(a) for a in argv[1:])
for label, parsed in zip(argv[1:], targets):
verdict, detail = probe(*parsed)
if verdict in ("closed", "filtered", "error"):
failed = 1
print("%-*s %-12s %s" % (width, label, verdict, detail))
return failed
if __name__ == "__main__":
sys.exit(main(sys.argv))
Run against the lab and one real host, it separates all four states in one shot. The last two lines are the interesting ones: the real HTTPS host is identified as TLS with no trust decision involved, and a port with no listener is reported without a timeout penalty on the others.
$ python3 tlsport.py 127.0.0.1:19460 127.0.0.1:19461 127.0.0.1:19463 www.c2cz.com:443 127.0.0.1:19450
127.0.0.1:19460 plaintext not TLS (WRONG_VERSION_NUMBER)
127.0.0.1:19461 tls TLSv1.3 / TLS_AES_256_GCM_SHA384
127.0.0.1:19463 plaintext not TLS (RECORD_LAYER_FAILURE)
www.c2cz.com:443 tls TLSv1.3 / TLS_AES_256_GCM_SHA384
127.0.0.1:19450 closed ECONNREFUSED
$ echo "exit=$?"
exit=1
A deliberate design note, because it matters in production: the script disables certificate verification (verify_mode = ssl.CERT_NONE). That is not laziness — it is the point. You are asking what protocol the far end speaks, and a self-signed certificate or an expired one must not mask that answer. Trust decisions belong in a second step, with a CA file, after you know you are talking to TLS at all.
Fixes, by the layer that is actually wrong
Every variant of this error comes down to a scheme mismatch that nobody wrote down in one place. Go looking for the layer that connects two protocols and check the direction of the handoff.
| Symptom source | What is really happening | Fix |
|---|---|---|
| Reverse proxy in front of the app | The upstream is TLS but the proxy dials it as http, or the opposite | Make the upstream scheme match the listener: proxy_pass https://app:8443; needs proxy_ssl_verify on; and a CA file, while http://app:3000 is only correct for a plaintext listener |
docker run -p 443:8443 | Host 443 maps to a container port that serves plaintext | Check the container’s own listen directive, then probe both candidates with tlsport.py host:443 host:8443 |
| A public hostname published over a container port | Ingress terminates TLS and forwards plain to a container that also terminates TLS | Terminate in exactly one place: either the ingress does TLS and the app serves http://0.0.0.0:3000, or the ingress passes through and the app terminates |
curl https://host against a service on port 80 | The port is plain HTTP, and the client assumed otherwise | Change the URL, or move the listener to a TLS port — a scheme in front of the right port is not a substitute for the right port |
| A tailnet hostname that fails in the browser | The serve rule and the target disagree about TLS | Use one direction consistently: tailscale serve --https=443 3000 lets the daemon terminate TLS, and for a locally self-signed backend the documented target form is https+insecure://localhost:3000 |
| A domain behind Cloudflare | Encryption mode terminates one hop as plain and the origin expects TLS | Set the zone to Full (strict) so both hops are TLS, rather than letting Flexible send plaintext to an HTTPS-only origin |
The Tailscale case deserves a sentence of explanation because both of its flags are correct in isolation. --https= and --http= describe the listener the daemon creates on your node, not the protocol between the daemon and your app; the target URL decides that second hop. The current documentation lists https+insecure:// as an explicit target scheme precisely so that a self-signed local service can be reached without inventing a trust chain, and --https=443 provisions a certificate for the node name automatically. Mixing the two is the usual way to produce a framing error inside a tailnet, where no firewall is involved and the symptom therefore looks inexplicable.
Cloudflare’s documentation is equally direct about the shape of the problem: the zone’s SSL/TLS encryption mode controls two connections, one between the visitor and Cloudflare and one between Cloudflare and the origin, and Full or Full (strict) is the recommended pairing because it keeps both hops encrypted. The failure mode of Flexible is not subtle once you have the triage above: the edge speaks TLS to the visitor and plain HTTP to your origin, so a probe of the origin's TLS port from anywhere else in your stack can disagree with what the edge is doing.
One more class of cause is not a proxy at all: a second process on the port you expected. On this lab host, a leftover server from an earlier run held the port, and the new listener printed Address already in use while the probes happily measured the impostor’s behaviour. If your strings look impossible for the config you just wrote, confirm which process owns the port before you change anything — openssl s_client will happily prove that a foreign service is not TLS.
Why this is almost never a certificate problem
The reason this error wastes afternoons is that it arrives through the same channels as certificate errors and looks like a security decision rather than a parsing one. The three layers produce distinct, checkable strings, and it is worth knowing which one you are holding.
| Layer | Strings you will see | What it means |
|---|---|---|
| Framing (this article) | wrong version number, data length too long, record layer failure, http request | The bytes are not a TLS record. The certificate was never examined. |
| Trust | curl: (60) SSL certificate problem: self-signed certificate, DEPTH_ZERO_SELF_SIGNED_CERT, certificate has expired, unable to get local issuer certificate | TLS completed and the record layer worked; the peer identity could not be verified. |
| Policy on the client identity | tlsv13 alert certificate required, sslv3 alert handshake failure (alert 40) | The server demanded a client certificate that the client did not present, or the two sides could not agree on parameters. |
The trust layer has its own write-ups on this site, and they are the right place to land if your strings are in the middle row: self-signed certificate in certificate chain covers the curl 60 family and the local-CA fixes, while tlsv13 alert certificate required covers the mutual-TLS branch. If instead your error names the record layer, no amount of certificate work will change the outcome, because OpenSSL has not reached the certificate yet.
A twelve-line client probe makes the contrast reproducible. It prints the whole failure chain rather than just the top-level message, which is what lets you tell a framing failure from a trust failure without guessing:
// Node/undici client probe: prints the fetch() failure chain verbatim.
// Usage: node netcheck.mjs <port> [scheme]
const port = Number(process.argv[2] ?? 19460);
const scheme = process.argv[3] ?? "https";
try {
const res = await fetch(`${scheme}://127.0.0.1:${port}/`);
console.log(`status=${res.status} body=${(await res.text()).trim()}`);
} catch (err) {
console.log(`name=${err.name} message=${err.message}`);
if (err.cause) {
console.log(
`cause.name=${err.cause.name} cause.code=${err.cause.code} cause.message=${err.cause.message}`,
);
}
}
Measured confirmation, for the same lab ports: a real TLS handshake against the self-signed origin produces the trust-layer error, and the same origin answers correctly once trust is taken out of the equation for the test.
$ curl -sS -o /dev/null -w 'http_code=%{http_code}\n' https://127.0.0.1:19462/
http_code=000
curl: (60) SSL certificate problem: self-signed certificate
More details here: https://curl.se/docs/sslcerts.html
$ echo "exit=$?"
exit=60
$ NODE_TLS_REJECT_UNAUTHORIZED=0 node netcheck.mjs 19461 https
status=200 body={"origin":"tls","tls":true}
Two different outcomes, two different layers, one certificate: framing failures are about the shape of the bytes, trust failures are about the identity behind them. If a quick probe shows the port speaks TLS and the certificate is merely untrusted, you are in the second row of the table and the fix is a trust store or a CA, not a proxy config.
Checklist before the next redeploy
- Probe the port, do not read the config:
openssl s_client -connect host:port -brief </dev/null, and confirm aProtocol version:line appears. - Probe every candidate port in the mapping (
443,8443,8080, the container’s own listen port) rather than the one you assume. - Read the plaintext server’s log: a line like
code 400, message Bad request syntax ('\x16\x03\x01')is proof that a ClientHello arrived where HTTP was expected. - On the TLS side, catch the handshake error explicitly and log it; a listener that dies with a traceback on one bad client will hide the cause of the next four failures.
- Check that only one hop terminates TLS. Two terminators in a row — ingress plus app — is the most common self-inflicted version.
- Verify the direction of every scheme you wrote down:
proxy_pass,--http=/--https=, Cloudflare encryption mode, and the target URL of a tunnel. - Confirm who owns the port before changing anything, especially on a host where a previous lab or container may still be running.
- Keep the triage scripted and exit non-zero on a closed port, so a broken scheme is caught in CI instead of by a reader of your status page.
None of this requires a certificate authority, a DNS change, or a browser restart. The client is misreading the first five bytes it receives, and the fastest way to see that is to send the ClientHello yourself and read the answer.