carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 3497c63c65fe34523acfe852853f8aaec32ba347 parent 1035e87
10 files changed, +310 −34
  • deploy.go +6 −2
  • deploy_test.go +40 −0
  • domains_cli_test.go +63 −0
  • internal/console/apidomains.go +12 −6
  • internal/console/apidomains_test.go +50 −0
  • internal/console/server_test.go +7 −1
  • internal/console/view.go +4 −1
  • internal/domains/reconcile.go +45 −14
  • internal/domains/reconcile_internal_test.go +49 −0
  • internal/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)
}
}