domains: an unreachable resolver is not a missing record
Three distinct unknowns, not one:
- "" the resolver answered: no such record. The customer
has not created it yet, so guidance is right.
- "unresolved" the lookup FAILED (SERVFAIL, timeout, no resolver).
That is OUR problem, and telling the customer their
record is missing blames them for our outage.
- pointing / elsewhere — a real answer, judged against the edge set.
Section 4's non-overwrite guard moves onto "unresolved", which is the
case it was always meant to catch: our failure must not flip a working
domain, because #287's publisher gates on "pointing" and the flip
would unpublish every alias fleet-wide for a cadence. An authoritative
NXDOMAIN is the opposite — the resolver answered, the record is gone,
and a customer who deleted it must stop reading "pointing".
Also: hostCertExpiry now checks <host>+rsa alongside <host> and the
wildcard PEM. autocert writes the RSA bundle under that suffix, so a
host served only by an RSA cert read as "no certificate" — an
expiry of 0 on a host that had a perfectly good cert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 files changed,
+310
−34
deploy.go+6 −2deploy_test.go+40 −0domains_cli_test.go+63 −0internal/console/apidomains.go+12 −6internal/console/apidomains_test.go+50 −0internal/console/server_test.go+7 −1internal/console/view.go+4 −1internal/domains/reconcile.go+45 −14internal/domains/reconcile_internal_test.go+49 −0internal/domains/reconcile_test.go+34 −10
diff --git a/deploy.go b/deploy.go| index 8c221ca54..4e52ce1eb 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -957,8 +957,10 @@ func isConnectionClassFailure(err error) bool { |
| // - DNSStatus != "pointing" (elsewhere, or never checked) WITH guidance: |
| // the DNS-not-pointing report, the console's own Create: text verbatim. |
| // |
| -// A host absent from domains, or present with DNSStatus != "pointing" and |
| -// no guidance yet (checked, but the console has nothing to say), is |
| +// A host absent from domains, present with DNSStatus != "pointing" and no |
| +// guidance yet (checked, but the console has nothing to say), or stamped |
| +// "unresolved" (OUR lookup failed — not an answer about the member's |
| +// record, so "Create: ..." would blame them for our outage), is |
| // deliberately ok=false — a guessed-at guidance line this call cannot back |
| // up would be worse than the generic timeout report already in hand. |
| func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcct) (msg string, ok bool) { |
| @@ -971,6 +973,8 @@ func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcc |
| return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+ |
| "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.", |
| host, app, acct.Seg), true |
| + case d.DNSStatus == "unresolved": |
| + return "", false |
| case d.DNSStatus != "pointing" && d.Guidance != "": |
| return fmt.Sprintf("DNS for %s is not pointing at this deployment yet. Create: %s. "+ |
| "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy any time — the shipped release is already promoted.", |
diff --git a/deploy_test.go b/deploy_test.go| index 2c4a15e78..7d7975a71 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -1117,6 +1117,46 @@ func TestDeployDNSGuidanceMessageBothArms(t *testing.T) { |
| } |
| } |
| +// TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure |
| +// pins the third status: "unresolved" means the DNS LOOKUP failed (our |
| +// resolver, a timeout, a SERVFAIL) — not that the member's record is |
| +// missing. Printing "DNS ... is not pointing ... Create: ..." here would |
| +// tell them to fix something that was never wrong; the honest move is the |
| +// same fall-through as "never checked", so the caller reports the plain |
| +// watchVersion timeout instead of a guess. |
| +func TestDeployDNSGuidanceMessageRefusesToBlameTheMemberForOurLookupFailure(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{ |
| + {"host": "x.test", "dns_status": "unresolved"}, |
| + }}) |
| + })) |
| + defer ts.Close() |
| + domains, err := newTestSession(ts.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if msg, ok := dnsGuidanceMessage(domains, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { |
| + t.Fatalf("unresolved must fall through, not print a message: ok=%v msg=%q", ok, msg) |
| + } |
| + |
| + // Defensive: even if some future server bug stamped guidance alongside |
| + // "unresolved", this function must still refuse it rather than trust an |
| + // upstream promise it cannot verify. |
| + ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"domains": []map[string]string{ |
| + {"host": "x.test", "dns_status": "unresolved", "guidance": "create a CNAME"}, |
| + }}) |
| + })) |
| + defer ts2.Close() |
| + domains2, err := newTestSession(ts2.URL).domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if msg, ok := dnsGuidanceMessage(domains2, "x.test", "sitely", apiAcct{Seg: "ops"}); ok { |
| + t.Fatalf("unresolved with stray guidance must still fall through: ok=%v msg=%q", ok, msg) |
| + } |
| +} |
| + |
| // TestDeployAutoAttachStandsDownWhenOldConsoleHasNoDomainsDoor pins review |
| // fix #2: silence is only right when nothing needed attaching anyway. A |
| // console that predates the /domains door 404s the list; when the target |
diff --git a/domains_cli_test.go b/domains_cli_test.go| index 5f8aa3fb7..8f87ff6dc 100644 |
| --- a/domains_cli_test.go |
| +++ b/domains_cli_test.go |
| @@ -316,6 +316,13 @@ func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole { |
| {"host": "guide.example.com", "route": "default.go.tito.io", "status": "pending", |
| "dns_status": "elsewhere", |
| "guidance": "Point an A record for guide.example.com at 203.0.113.5. Checks run every few minutes."}, |
| + // flaky.example.com's lookup FAILED on the box (our resolver, |
| + // not the member's record) — the door sends "unresolved" with |
| + // no guidance, and TestRunDomainsListRendersUnresolvedHonestly |
| + // pins that the CLI prints it as such, never as "pending" (no |
| + // answer yet) and never with a "Create:" instruction. |
| + {"host": "flaky.example.com", "route": "default.go.tito.io", "status": "live", |
| + "dns_status": "unresolved"}, |
| }, |
| }) |
| default: |
| @@ -384,6 +391,26 @@ func TestRunDomainsAttachPrintsDNSGuidanceWhenNotPointing(t *testing.T) { |
| } |
| } |
| +// TestRunDomainsAttachPrintsUnresolvedHonestly: an attach onto a host whose |
| +// DNS lookup FAILED (our resolver — flaky.example.com's fixed "unresolved" |
| +// row, no guidance) must complete with that literal status, never with a |
| +// "Create:" instruction telling the member to fix a record we never even |
| +// managed to check. |
| +func TestRunDomainsAttachPrintsUnresolvedHonestly(t *testing.T) { |
| + domainsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"attach", "-app", "tito", "-route", "default.go.tito.io", "flaky.example.com"}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "DNS: unresolved") { |
| + t.Fatalf("stdout = %q, want a plain \"DNS: unresolved\" line", out) |
| + } |
| + if strings.Contains(out, "Create:") { |
| + t.Fatalf("stdout = %q, must not print guidance for a lookup WE failed", out) |
| + } |
| +} |
| + |
| func TestRunDomainsAttachPrintsAlreadyAttachedOnNoOp(t *testing.T) { |
| fc := domainsCLIFixture(t) |
| fc.attachCreated = false |
| @@ -482,6 +509,42 @@ func TestRunDomainsListPrintsTable(t *testing.T) { |
| } |
| } |
| +// TestRunDomainsListRendersUnresolvedHonestly pins the CLI's half of the |
| +// consumer sweep: "unresolved" means the LOOKUP failed on the box (our |
| +// resolver), not that the member's record is missing — the DNS column must |
| +// print the literal status, never fall back to "pending" (which means "no |
| +// answer yet", a different and better state), and the GUIDANCE column must |
| +// stay "-" since the door sends none for it. |
| +func TestRunDomainsListRendersUnresolvedHonestly(t *testing.T) { |
| + domainsCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runDomains([]string{"list", "-app", "tito"}) |
| + |
| + var flakyLine string |
| + for _, l := range strings.Split(buf.String(), "\n") { |
| + if strings.Contains(l, "flaky.example.com") { |
| + flakyLine = l |
| + } |
| + } |
| + if flakyLine == "" { |
| + t.Fatal("no row for flaky.example.com") |
| + } |
| + fields := strings.Fields(flakyLine) |
| + if len(fields) < 4 { |
| + t.Fatalf("flaky row = %q, too few columns", flakyLine) |
| + } |
| + if dns := fields[3]; dns != "unresolved" { |
| + t.Fatalf("flaky row DNS column = %q, want the literal \"unresolved\" status (not \"pending\")", dns) |
| + } |
| + if guidance := fields[len(fields)-1]; guidance != "-" { |
| + t.Fatalf("flaky row GUIDANCE column = %q, want \"-\" — our lookup failure is not the member's problem to fix", guidance) |
| + } |
| + if strings.Contains(flakyLine, "Create:") || strings.Contains(flakyLine, "Point an A record") { |
| + t.Fatalf("flaky row = %q, must not tell the member to (re)point DNS we failed to check", flakyLine) |
| + } |
| +} |
| + |
| // TestRunDomainsAttachWildcardFlagSentOnlyWhenNamed: -wildcard/-catchall are |
| // tri-state on the wire, the way -target already is on `apps place` — |
| // fs.Visit is what makes "not named" and "named false" distinguishable, and |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 8da9ed21e..baa72c915 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -555,11 +555,13 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) { |
| // "live" or "pending" and never absent; the two reading columns omit when |
| // unstamped, the same unknown-stays-absent convention status.AliasReading |
| // itself uses. Guidance is the point-your-DNS-here instruction, present |
| -// exactly when DNSStatus is anything but "pointing" — the same words and |
| -// the same condition as the console Settings page (domainGuidance, view.go), |
| -// so `carlos deploy`'s DNS-guidance exit and the page a member opens next |
| -// can never tell them two different stories. Additive: a client that |
| -// predates it simply never decodes it. |
| +// exactly when DNSStatus is "" (no answer yet) or "elsewhere" (a real, |
| +// wrong answer) — never "pointing" (nothing to fix) and never "unresolved" |
| +// (our lookup failed; telling the member to create a record would blame |
| +// them for our outage). Same condition as the console Settings page |
| +// (domainGuidance, view.go), so `carlos deploy`'s DNS-guidance exit and the |
| +// page a member opens next can never tell them two different stories. |
| +// Additive: a client that predates it simply never decodes it. |
| type domainListItem struct { |
| Host string `json:"host"` |
| Route string `json:"route"` |
| @@ -626,7 +628,11 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| item.DNSStatus = reading.DNSStatus |
| item.CertExpiresAt = reading.CertExpiresAt |
| } |
| - if item.DNSStatus != "pointing" { |
| + // "unresolved" means the lookup FAILED (our resolver, not the |
| + // customer's record) — pointing them at "create this record" |
| + // guidance would blame them for our outage. Only "" (no answer |
| + // yet) and "elsewhere" (a real, wrong answer) earn guidance. |
| + if item.DNSStatus != "pointing" && item.DNSStatus != "unresolved" { |
| item.Guidance = domainGuidance(c.Host, s.dnsTargets()) |
| } |
| // Each stamp is a live, sequential, uncancellable-per-call DNS round |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 195f27b05..aa6771e0c 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -416,6 +416,56 @@ func TestAPIDomainList(t *testing.T) { |
| } |
| } |
| +// TestAPIDomainListSuppressesGuidanceForUnresolvedDNS pins the honesty rule |
| +// this task adds: "unresolved" means the DNS LOOKUP FAILED (our resolver), |
| +// not that the member's record is missing or wrong. The door must not print |
| +// "point your DNS here" guidance for it — that would blame the member for |
| +// our outage. "elsewhere" (a real, wrong answer) still earns guidance, so |
| +// the suppression is specific to "unresolved" and not a blanket regression. |
| +func TestAPIDomainListSuppressesGuidanceForUnresolvedDNS(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-unresolved") |
| + ctx := context.Background() |
| + now := time.Now() |
| + attachDomain(t, fx, member, "broken.example.com", "go.tito.io") |
| + attachDomain(t, fx, member, "wrong.example.com", "go.tito.io") |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| + GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "broken.example.com": {Route: "go.tito.io", DNSStatus: "unresolved"}, |
| + "wrong.example.com": {Route: "go.tito.io", DNSStatus: "elsewhere"}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| + if code != http.StatusOK { |
| + t.Fatalf("list = %d %s, want 200", code, out) |
| + } |
| + var got struct { |
| + Domains []struct { |
| + Host string `json:"host"` |
| + DNSStatus string `json:"dns_status"` |
| + Guidance string `json:"guidance"` |
| + } `json:"domains"` |
| + } |
| + if err := json.Unmarshal([]byte(out), &got); err != nil { |
| + t.Fatalf("list body %s: %v", out, err) |
| + } |
| + byHost := map[string]string{} |
| + for _, d := range got.Domains { |
| + if d.DNSStatus != map[string]string{"broken.example.com": "unresolved", "wrong.example.com": "elsewhere"}[d.Host] { |
| + t.Fatalf("%s dns_status = %q, unexpected", d.Host, d.DNSStatus) |
| + } |
| + byHost[d.Host] = d.Guidance |
| + } |
| + if g := byHost["broken.example.com"]; g != "" { |
| + t.Fatalf("unresolved host got guidance %q, want none — our lookup failure is not the member's problem to fix", g) |
| + } |
| + if g := byHost["wrong.example.com"]; g == "" { |
| + t.Fatal("a real wrong-answer (elsewhere) host must still get point-your-DNS guidance") |
| + } |
| +} |
| + |
| // domainEvents counts this app's audit rows whose action starts "domain-". |
| func domainEvents(t *testing.T, fx *consoleFixture) int { |
| t.Helper() |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 746eac862..49a968992 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1509,7 +1509,9 @@ func TestAppPageBackfillsAccountManifest(t *testing.T) { |
| // TestDomainViewStates is the pure Domains-row builder over route fixtures: |
| // type from the platform flag, unknown DNS as "" (rendered em-dash), |
| -// guidance ONLY for non-pointing custom domains, canary labeling preserved. |
| +// guidance ONLY for non-pointing custom domains — except "unresolved" (our |
| +// lookup failed; guidance would blame the member for our outage) — canary |
| +// labeling preserved. |
| func TestDomainViewStates(t *testing.T) { |
| edge := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}} |
| renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix() |
| @@ -1539,6 +1541,10 @@ func TestDomainViewStates(t *testing.T) { |
| registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "pointing"}, |
| edge, |
| DomainView{Domain: "kass.training", Type: "custom", DNS: "pointing", TLS: "ready"}}, |
| + {"custom unresolved: our lookup failed, no guidance — not the member's problem", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "unresolved"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "unresolved", TLS: "ready"}}, |
| {"platform elsewhere: never guided (we made this domain; guidance is for THEIR dns)", |
| registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "elsewhere"}, |
| edge, |
diff --git a/internal/console/view.go b/internal/console/view.go| index c4a5f1215..0d8a116c3 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -225,7 +225,10 @@ func domainView(rt registry.Route, dns DNSTargets) DomainView { |
| dv := DomainView{Domain: rt.Host, Type: typ, DNS: rt.DNSStatus, TLS: tls} |
| // Guidance only for custom + non-pointing: platform domains are ours to |
| // fix, and a pointing custom domain needs no pointing instructions. |
| - if !rt.Platform && rt.DNSStatus != "pointing" { |
| + // "unresolved" is excluded too — that means OUR lookup failed, not that |
| + // the member's record is missing or wrong, so telling them to (re)point |
| + // it would blame them for our outage. |
| + if !rt.Platform && rt.DNSStatus != "pointing" && rt.DNSStatus != "unresolved" { |
| dv.Guidance = domainGuidance(rt.Host, dns) |
| } |
| return dv |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index a812f016b..418ecb905 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -6,8 +6,10 @@ import ( |
| "crypto/x509" |
| "encoding/json" |
| "encoding/pem" |
| + "errors" |
| "fmt" |
| "log" |
| + "net" |
| "os" |
| "path/filepath" |
| "slices" |
| @@ -436,16 +438,23 @@ func StampHealth(ctx context.Context, reg *registry.Registry, cfg Config, routes |
| log.Printf("domains: no edge addresses known this pass — DNS stamping stands down rather than stamping every host elsewhere") |
| } else { |
| now := cfg.Now().Unix() |
| - // A resolver failure is UNKNOWN (""), and unknown never overwrites a |
| - // verdict: one bad DNS tick must not flip a working domain to |
| - // "never checked" — the edges-file publisher gates on "pointing" |
| - // (#287), so that flip would unpublish every alias fleet-wide for a |
| - // cadence. The stamp time is left alone too, so staleness stays |
| - // visible (dns_checked_at stops advancing) rather than being |
| - // laundered into a fresh unknown. |
| + // OUR failure never overwrites a verdict. "unresolved" means the |
| + // lookup itself failed (SERVFAIL, timeout, no resolver) — one bad |
| + // DNS tick must not flip a working domain, because the edges-file |
| + // publisher gates on "pointing" (#287), so that flip would |
| + // unpublish every alias fleet-wide for a cadence. The stamp time |
| + // is left alone too, so staleness stays visible (dns_checked_at |
| + // stops advancing) rather than being laundered into a fresh |
| + // unknown. |
| + // |
| + // An authoritative NXDOMAIN ("") is NOT that case and DOES stamp: |
| + // the resolver answered, and the answer is that the record is |
| + // gone. A customer who deletes their record must stop reading |
| + // "pointing" — refusing that news would be the same lie in the |
| + // opposite direction. |
| for _, rt := range routes { |
| status := dnsStatus(cfg.Resolve, rt.Host, edge) |
| - if status == "" && rt.DNSStatus != "" { |
| + if status == unresolvedDNS && rt.DNSStatus != "" { |
| continue |
| } |
| if err := reg.SetDNSStatus(rt.Host, status, now); err != nil { |
| @@ -454,7 +463,7 @@ func StampHealth(ctx context.Context, reg *registry.Registry, cfg Config, routes |
| } |
| for _, a := range aliases { |
| status := dnsStatus(cfg.Resolve, a.Alias, edge) |
| - if status == "" && a.DNSStatus != "" { |
| + if status == unresolvedDNS && a.DNSStatus != "" { |
| continue |
| } |
| if err := reg.SetAliasDNSStatus(a.Alias, status, now); err != nil { |
| @@ -482,14 +491,32 @@ func StampHealth(ctx context.Context, reg *registry.Registry, cfg Config, routes |
| return nil |
| } |
| -// dnsStatus resolves host through resolve and classifies the answer against |
| -// edge: a resolver failure is UNKNOWN ("") — never a guess — an edge-IP match |
| -// is "pointing", anything else is "elsewhere". Shared by the route and alias |
| -// loops in section 4 so the two verdicts can never drift apart. |
| +// unresolvedDNS is the stamp for "we could not look this up" as distinct |
| +// from "the resolver says there is no such record". The distinction is the |
| +// difference between blaming the customer and owning the outage. |
| +const unresolvedDNS = "unresolved" |
| + |
| +// dnsStatus resolves host and classifies the answer against edge. Three |
| +// distinct unknowns, deliberately: |
| +// |
| +// - "" — the resolver answered authoritatively that the name does not |
| +// exist. The customer has not created the record yet; guidance is right. |
| +// - unresolvedDNS — the lookup FAILED (SERVFAIL, timeout, no resolver). |
| +// That is OUR problem, and telling the customer their record is missing |
| +// would blame them for our outage. Section 4 never lets it overwrite an |
| +// existing verdict. |
| +// - "elsewhere"/"pointing" — a real answer, judged against the set. |
| +// |
| +// Shared by the route and alias loops in section 4 so the two verdicts can |
| +// never drift apart. |
| func dnsStatus(resolve func(host string) ([]string, error), host string, edge map[string]bool) string { |
| ips, err := resolve(host) |
| if err != nil { |
| - return "" |
| + var dnsErr *net.DNSError |
| + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { |
| + return "" |
| + } |
| + return unresolvedDNS |
| } |
| for _, ip := range ips { |
| if edge[ip] { |
| @@ -843,6 +870,10 @@ func mintedRoute(host, app, account string, src registry.Route) registry.Route { |
| // covering wildcard file, so the apex never matches its own wildcard. |
| func hostCertExpiry(acmeDir, host, appsDomain string) int64 { |
| perHost := certExpiry(filepath.Join(acmeDir, host)) |
| + rsa := certExpiry(filepath.Join(acmeDir, host+"+rsa")) |
| + if rsa > perHost { |
| + perHost = rsa |
| + } |
| wildcard := int64(0) |
| if i := strings.IndexByte(host, '.'); i > 0 { |
| parent := strings.ToLower(host[i+1:]) |
diff --git a/internal/domains/reconcile_internal_test.go b/internal/domains/reconcile_internal_test.go| index b44c18ea6..a95f3d968 100644 |
| --- a/internal/domains/reconcile_internal_test.go |
| +++ b/internal/domains/reconcile_internal_test.go |
| @@ -15,9 +15,11 @@ import ( |
| "crypto/x509" |
| "crypto/x509/pkix" |
| "encoding/pem" |
| + "errors" |
| "io" |
| "log" |
| "math/big" |
| + "net" |
| "os" |
| "path/filepath" |
| "testing" |
| @@ -115,6 +117,53 @@ func TestCertExpiryWildcardFallback(t *testing.T) { |
| } |
| } |
| +// A host whose only cert is the RSA variant (autocert writes <host>+rsa |
| +// alongside <host> when a client can't do ECDSA) has a cert. Reading it as |
| +// certless would hold it at "issuing cert" forever. |
| +func TestHostCertExpiryFindsTheRSAVariant(t *testing.T) { |
| + dir := t.TempDir() |
| + notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second) |
| + writeWildcardPEM(t, filepath.Join(dir, "shop.customer.com+rsa"), "shop.customer.com", notAfter) |
| + if got := hostCertExpiry(dir, "shop.customer.com", "oncarlos.com"); got != notAfter.Unix() { |
| + t.Fatalf("hostCertExpiry = %d, want %d (rsa variant)", got, notAfter.Unix()) |
| + } |
| + |
| + // The ECDSA per-host file, when it also exists and is LATER, still wins. |
| + later := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second) |
| + writeWildcardPEM(t, filepath.Join(dir, "shop.customer.com"), "shop.customer.com", later) |
| + if got := hostCertExpiry(dir, "shop.customer.com", "oncarlos.com"); got != later.Unix() { |
| + t.Fatalf("hostCertExpiry with later ecdsa file = %d, want %d", got, later.Unix()) |
| + } |
| +} |
| + |
| +// An authoritative "no such name" is the customer's record not existing yet. |
| +// A resolver FAILURE is ours. Collapsing both to "" makes the status machine |
| +// blame the customer for our outage. |
| +func TestDNSStatusSeparatesNoRecordFromLookupFailure(t *testing.T) { |
| + edge := map[string]bool{"1.1.1.1": true} |
| + |
| + notFound := func(string) ([]string, error) { |
| + return nil, &net.DNSError{Err: "no such host", IsNotFound: true} |
| + } |
| + if got := dnsStatus(notFound, "absent.customer.com", edge); got != "" { |
| + t.Fatalf("NXDOMAIN = %q, want \"\" (no record yet)", got) |
| + } |
| + |
| + servfail := func(string) ([]string, error) { |
| + return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true} |
| + } |
| + if got := dnsStatus(servfail, "unknown.customer.com", edge); got != "unresolved" { |
| + t.Fatalf("resolver failure = %q, want \"unresolved\" — our fault, not the customer's", got) |
| + } |
| + |
| + plain := func(string) ([]string, error) { |
| + return nil, errors.New("boom") |
| + } |
| + if got := dnsStatus(plain, "other.customer.com", edge); got != "unresolved" { |
| + t.Fatalf("non-DNSError failure = %q, want \"unresolved\"", got) |
| + } |
| +} |
| + |
| // TestMintedRouteCarriesRegionAndDropsSocketWhenFleetPlaced: a minted |
| // platform apex must carry its source's Region so the edge dispatches it |
| // through the same fleet channel — but when that Region is a fleet |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index b6f2afa34..8e94b7167 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "fmt" |
| "log" |
| "math/big" |
| + "net" |
| "os" |
| "path/filepath" |
| "reflect" |
| @@ -540,7 +541,7 @@ func TestReconcileStampsDNS(t *testing.T) { |
| case "elsewhere.test": |
| return []string{"198.51.100.9"}, nil |
| default: |
| - return nil, errors.New("NXDOMAIN or worse") |
| + return nil, errors.New("server misbehaving") // not a *net.DNSError: our lookup failed, not an authoritative answer |
| } |
| } |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| @@ -553,7 +554,9 @@ func TestReconcileStampsDNS(t *testing.T) { |
| for host, want := range map[string]string{ |
| "points.test": "pointing", |
| "elsewhere.test": "elsewhere", |
| - "broken.test": "", // resolver failure is UNKNOWN, never a guess |
| + // A non-DNSError resolver failure is OURS, not an authoritative |
| + // "no such name" — it must not read as "the customer has no record". |
| + "broken.test": "unresolved", |
| } { |
| got := mustGet(t, reg, host) |
| if got.DNSStatus != want { |
| @@ -734,7 +737,7 @@ func TestReconcileStampsAliasDNS(t *testing.T) { |
| case "elsewhere-alias.test": |
| return []string{"198.51.100.9"}, nil |
| default: |
| - return nil, errors.New("NXDOMAIN or worse") |
| + return nil, errors.New("server misbehaving") // not a *net.DNSError: our lookup failed, not an authoritative answer |
| } |
| } |
| if err := domains.Reconcile(ctx, s, reg, c); err != nil { |
| @@ -747,7 +750,9 @@ func TestReconcileStampsAliasDNS(t *testing.T) { |
| for alias, want := range map[string]string{ |
| "points-alias.test": "pointing", |
| "elsewhere-alias.test": "elsewhere", |
| - "broken-alias.test": "", // resolver failure is UNKNOWN, never a guess |
| + // A non-DNSError resolver failure is OURS, not an authoritative |
| + // "no such name" — it must not read as "the customer has no record". |
| + "broken-alias.test": "unresolved", |
| } { |
| got := aliasByName(t, reg, alias) |
| if got.DNSStatus != want { |
| @@ -1304,9 +1309,12 @@ func TestStampHealthStandsAlone(t *testing.T) { |
| } |
| } |
| -// A resolver failure never overwrites a verdict: the last known verdict |
| -// and its stamp time stay, so a bad DNS tick cannot unpublish a working |
| -// custom domain (#287's gate) or launder staleness into "never checked". |
| +// OUR failure never overwrites a verdict: the last known verdict and its |
| +// stamp time stay, so a bad DNS tick cannot unpublish a working custom |
| +// domain (#287's gate) or launder staleness into "never checked". |
| +// |
| +// An authoritative NXDOMAIN is the opposite case and DOES stamp — the |
| +// resolver answered, and the answer is that the record is gone. |
| func TestStampHealthHoldsLastVerdictOnResolverFailure(t *testing.T) { |
| reg := regWith(t, registry.Route{Host: "app.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.customer.test", Host: "app.acct.oncarlos.test", Account: "acct", App: "app"}); err != nil { |
| @@ -1331,13 +1339,29 @@ func TestStampHealthHoldsLastVerdictOnResolverFailure(t *testing.T) { |
| if len(aliases) != 1 || aliases[0].DNSStatus != "pointing" || aliases[0].DNSCheckedAt != 1000 { |
| t.Errorf("alias after resolver failure = %+v; want pointing @1000", aliases) |
| } |
| - // A never-checked row still records unknown, so "unknown" stays honest. |
| + // A never-checked row records that we could not check — "unresolved", |
| + // not "" — so the operator can tell our outage from a missing record. |
| fresh := regWith(t, registry.Route{Host: "new.acct.oncarlos.test", Kind: "service", Addr: "10.0.0.1:1", Status: "ready"}) |
| if err := domains.StampHealth(context.Background(), fresh, broken, nil); err != nil { |
| t.Fatal(err) |
| } |
| - if got := mustGet(t, fresh, "new.acct.oncarlos.test"); got.DNSStatus != "" || got.DNSCheckedAt != 2000 { |
| - t.Errorf("fresh row = %q @%d; want \"\" @2000", got.DNSStatus, got.DNSCheckedAt) |
| + if got := mustGet(t, fresh, "new.acct.oncarlos.test"); got.DNSStatus != "unresolved" || got.DNSCheckedAt != 2000 { |
| + t.Errorf("fresh row = %q @%d; want unresolved @2000", got.DNSStatus, got.DNSCheckedAt) |
| + } |
| + |
| + // And the case the guard must NOT swallow: the customer deleted the |
| + // record. NXDOMAIN is an answer, so "pointing" gives way to it. Without |
| + // this, a domain that stopped resolving would read "pointing" forever. |
| + gone := good |
| + gone.Resolve = func(string) ([]string, error) { |
| + return nil, &net.DNSError{Err: "no such host", IsNotFound: true} |
| + } |
| + gone.Now = func() time.Time { return time.Unix(3000, 0) } |
| + if err := domains.StampHealth(context.Background(), reg, gone, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "app.acct.oncarlos.test"); got.DNSStatus != "" || got.DNSCheckedAt != 3000 { |
| + t.Errorf("route after NXDOMAIN = %q @%d; want \"\" @3000 — an authoritative answer is news", got.DNSStatus, got.DNSCheckedAt) |
| } |
| } |