fix(health): don't error when the advertised HTTPS URL is unreachable from the service

The `service_cert_chain` check ("HTTPS endpoint TLS configuration") dials
the service's own configured HTTPS URL. When that dial fails before any
certificate is presented (connection refused, timeout, handshake reset),
it reported a hard red error.

But from inside the service we can't distinguish "the endpoint is down"
from "the advertised HTTPS URL simply isn't reachable from here" — and
the latter is a normal, healthy deployment: TLS terminated by a reverse
proxy in front of AfterTouch, or a Docker-published port / LAN-only
hostname that the container itself can't dial. In those setups the red
error is a false alarm (issue #355: reporter runs HTTP 8080 / HTTPS 8443
and noted "in my configuration that is expected").

Downgrade that specific case (no cert presented) to a warning, reword it
to name the expected reverse-proxy / unreachable-advertised-URL case, and
add an `openssl s_client` command to verify the endpoint from a client
that actually reaches the advertised URL. Cert-classification outcomes
(own-CA info, foreign-chain warning) are unchanged.

Reproduced locally on a clean data dir before/after: custom ports and
localhost/127.0.0.1 already returned INFO; only the unreachable-URL case
produced the error, which now returns a warning.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-07-04 17:45:41 +02:00
co-authored by Claude Opus 4.8
parent c3e3391db6
commit 9d69b61779
2 changed files with 44 additions and 7 deletions
+24 -3
View File
@@ -29,6 +29,10 @@ const CheckIDCertChain = "service_cert_chain"
// something else → warning with an `openssl s_client`
// investigation prompt (foreign chain / reverse proxy /
// ingress cert).
// - endpoint not reachable from inside the service (no cert
// presented) → warning, not error: the advertised HTTPS URL
// is often intentionally unreachable from the service itself
// (reverse proxy, Docker-published port, LAN-only hostname).
// - HTTPS URL not configured → skip silently.
//
// caCertFn returns AfterTouch's own CA leaf certificate (nil if
@@ -82,10 +86,27 @@ func runCertChainCheck(httpsURL string, caCertFn func() *x509.Certificate) []Fin
// reachability problem.
leaf := leafFromVerifyError(err)
if leaf == nil {
// The dial failed before any certificate was presented
// (connection refused, timeout, handshake reset). From
// inside the service we can't tell "the endpoint is down"
// apart from "the advertised HTTPS URL simply isn't
// reachable from here" — the latter is a normal, healthy
// setup (TLS terminated by a reverse proxy, or a
// Docker-published port / external hostname that only
// resolves on the LAN). Reporting a hard error there is a
// false alarm (issue #355), so this is a warning with the
// context to tell the two apart.
return []Finding{{
Severity: SeverityError,
Message: fmt.Sprintf("Could not connect to %s: %v", addr, err),
Details: "AfterTouch's HTTPS endpoint isn't reachable from inside the service, or the peer dropped the handshake before presenting a certificate. Check that the listener is bound and the URL host:port resolves correctly.",
Severity: SeverityWarning,
Message: fmt.Sprintf("Configured HTTPS endpoint %s isn't reachable from inside the service.", addr),
Details: fmt.Sprintf("AfterTouch dialed its own configured HTTPS URL (%s) and the connection failed before any certificate was presented: %v. "+
"This is expected — and not a problem — if TLS is terminated by a reverse proxy in front of AfterTouch, or the advertised HTTPS URL isn't reachable from inside the container (for example a Docker-published port, or a hostname that only resolves elsewhere on your network). In that case, verify the endpoint from a client instead (see below). "+
"If AfterTouch is meant to serve HTTPS directly, check that the HTTPS listener is bound and that the URL host:port is correct.", httpsURL, err),
ManualCommands: []ManualCommand{{
Label: "Verify the endpoint from a machine on your network:",
Command: fmt.Sprintf("openssl s_client -connect %s -servername %s </dev/null", addr, host),
Hint: "Run from a client that reaches the advertised URL (not necessarily the service host). A successful handshake there means the endpoint is fine and this warning is expected for your setup.",
}},
}}
}
+20 -4
View File
@@ -29,11 +29,27 @@ func TestCertChain_UnparseableURLWarns(t *testing.T) {
}
}
func TestCertChain_UnreachableEndpoint(t *testing.T) {
// 127.0.0.1:1 refuses; using https:// to force TLS path.
func TestCertChain_UnreachableEndpoint_IsWarningNotError(t *testing.T) {
// Regression for issue #355: the service dialing its own
// configured HTTPS URL and finding it unreachable is NOT a hard
// error. The advertised URL is frequently unreachable from
// inside the service on purpose (TLS terminated by a reverse
// proxy, a Docker-published port, or a LAN-only hostname), so a
// red error there is a false alarm. It must be a warning that
// explains the expected case and offers a client-side check.
//
// 127.0.0.1:1 refuses; using https:// to force the TLS path.
got := runCertChainCheck("https://127.0.0.1:1/", nil)
if len(got) != 1 || got[0].Severity != SeverityError {
t.Fatalf("expected one error for unreachable endpoint, got %+v", got)
if len(got) != 1 || got[0].Severity != SeverityWarning {
t.Fatalf("expected one warning for unreachable endpoint, got %+v", got)
}
if !strings.Contains(got[0].Details, "reverse proxy") {
t.Errorf("expected details to name the reverse-proxy / unreachable-advertised-URL case, got %q", got[0].Details)
}
if len(got[0].ManualCommands) == 0 || !strings.Contains(got[0].ManualCommands[0].Command, "openssl s_client") {
t.Errorf("expected an openssl s_client client-side verification command, got %+v", got[0].ManualCommands)
}
}