From 8227fe2b9721fd355481d9c1edb4997df9e05b4a Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 23 Aug 2026 11:40:54 -0300 Subject: [PATCH] fix(dns): o painel dizia que faltavam registos que estavam publicados Tres defeitos na comparacao, todos encontrados contra a zona real. Cada um fazia a consola declarar em falta um registo que existia e estava a funcionar - o que e pior do que nao dizer nada, porque manda o operador procurar um problema que nao ha. MX. A especificacao guarda a prioridade num campo proprio, portanto o valor desejado e so o host. A comparacao era feita contra "10 mail.x." como texto, e por isso o MX de um dominio que recebe correio todos os dias aparecia como inexistente. TXT longo. Um valor acima de 255 bytes e publicado em varias strings, e o resolver devolve-as entre aspas. Tirar as aspas e juntar tudo com espacos punha um espaco no meio de uma chave DKIM: todos os registos DKIM RSA da zona eram dados como em falta. As strings sao agora unidas ponta a ponta, e os espacos dentro de cada uma sobrevivem. Registos atras do proxy. Um CNAME proxied nao e publicado como CNAME - o proxy responde A. Perguntar pelo CNAME devolvia resposta vazia, e o registo era dado como em falta apesar de estar la a servir trafego. A pergunta passa a ser do tipo que o proxy responde de facto. A comparacao passa a ser por tipo em vez de uma normalizacao unica para tudo, que era a origem comum dos tres. Co-Authored-By: Claude Opus 5 --- .../infrastructure/netdns/public_verifier.go | 106 ++++++++++++++++-- .../netdns/public_verifier_match_test.go | 96 ++++++++++++++++ .../webmail/src/app/(admin)/admin/layout.tsx | 7 +- .../webmail/src/interface-integrity.test.ts | 22 ++++ 4 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 services/protocols/internal/infrastructure/netdns/public_verifier_match_test.go diff --git a/services/protocols/internal/infrastructure/netdns/public_verifier.go b/services/protocols/internal/infrastructure/netdns/public_verifier.go index a40f9cf..c0c7d2d 100644 --- a/services/protocols/internal/infrastructure/netdns/public_verifier.go +++ b/services/protocols/internal/infrastructure/netdns/public_verifier.go @@ -50,6 +50,12 @@ func (v *PublicVerifier) VerifyRecord(ctx context.Context, record entity.DnsReco // disagree. Presence is all that can be checked. checkValue := !record.Proxied + // And it is not published as the type it was created as: a proxied CNAME + // answers A, so asking for the CNAME comes back empty and the record was + // reported as missing while it was there and serving traffic. For those, + // the question has to be the type the proxy actually answers. + qtype = dns.StringToType[queryTypeFor(record.Type, record.Proxied)] + var lastDetail string for _, resolver := range v.resolvers { msg := new(dns.Msg) @@ -67,7 +73,7 @@ func (v *PublicVerifier) VerifyRecord(ctx context.Context, record entity.DnsReco if !checkValue { return true, "" } - if answerMatches(reply.Answer, record.Value) { + if answerMatches(reply.Answer, record.Type, record.Value) { return true, "" } lastDetail = fmt.Sprintf("%s: published value does not match the desired one", resolver) @@ -77,11 +83,7 @@ func (v *PublicVerifier) VerifyRecord(ctx context.Context, record entity.DnsReco } // answerMatches compares the resolver's answer against the desired value. -// Comparison is on the record's own presentation form with the owner name and -// TTL stripped, which is what makes it work uniformly across record types. -func answerMatches(answers []dns.RR, want string) bool { - want = normalizeRdata(want) - +func answerMatches(answers []dns.RR, recordType, want string) bool { for _, rr := range answers { // String() renders "\t\t\t\t"; the // rdata is everything after the fourth tab. @@ -89,18 +91,98 @@ func answerMatches(answers []dns.RR, want string) bool { if len(parts) < 5 { continue } - if normalizeRdata(parts[4]) == want { + if answerMatchesValue(recordType, parts[4], want) { return true } } return false } -// normalizeRdata removes the differences that do not change meaning: quoting -// of TXT strings, trailing dots on names, case, and repeated whitespace. -func normalizeRdata(value string) string { - value = strings.ReplaceAll(value, `"`, "") +// answerMatchesValue decides whether one published rdata means the same thing +// as the value that was asked for. +// +// The comparison has to be per type. A single normalisation for everything is +// what made the console report records as missing while they were published +// and correct: an MX answer carries a priority the desired value does not, and +// a TXT longer than 255 bytes arrives as several quoted strings that belong +// end to end rather than separated. +func answerMatchesValue(recordType, published, want string) bool { + switch strings.ToUpper(recordType) { + case "MX": + // The spec keeps the priority in its own field, so the desired value + // is the host alone. Comparing it against "10 mail.example.test." + // said the MX of a domain receiving mail daily did not exist. + return normalizeName(mxHost(published)) == normalizeName(want) + case "TXT", "SPF": + return joinTxtStrings(published) == joinTxtStrings(want) + case "CNAME", "NS", "PTR": + return normalizeName(published) == normalizeName(want) + default: + return normalizeName(published) == normalizeName(want) + } +} + +// mxHost drops the preference number an MX answer carries. +func mxHost(rdata string) string { + fields := strings.Fields(strings.TrimSpace(rdata)) + if len(fields) >= 2 { + return fields[len(fields)-1] + } + return strings.TrimSpace(rdata) +} + +// normalizeName removes the differences that do not change what a name means. +func normalizeName(value string) string { value = strings.Join(strings.Fields(value), " ") + value = strings.Trim(value, `"`) value = strings.TrimSuffix(value, ".") - return strings.ToLower(value) + return strings.ToLower(strings.TrimSpace(value)) +} + +// joinTxtStrings puts a TXT value back together. +// +// A value over 255 bytes is published as several strings, and a resolver +// renders them as `"first" "second"`. They are one value end to end: joining +// them with a space inserted one into the middle of a DKIM public key, so +// every RSA DKIM record was reported as missing. Spaces inside a single +// string are meaningful and survive. +func joinTxtStrings(value string) string { + var out strings.Builder + inQuote := false + quoted := false + + for i := 0; i < len(value); i++ { + c := value[i] + switch { + case c == '\\' && i+1 < len(value) && inQuote: + i++ + out.WriteByte(value[i]) + case c == '"': + inQuote = !inQuote + quoted = true + case inQuote: + out.WriteByte(c) + case c == ' ' || c == '\t': + // Whitespace between strings is the rendering, not the value. + if !quoted { + out.WriteByte(c) + } + default: + out.WriteByte(c) + } + } + return strings.TrimSpace(out.String()) +} + +// queryTypeFor is the record type to actually ask a resolver about. +// +// A proxied CNAME is answered as A by the provider, so asking for the CNAME +// returns nothing at all - which is how a record that was published and +// serving traffic came back reported as missing. +func queryTypeFor(recordType string, proxied bool) string { + upper := strings.ToUpper(recordType) + if proxied && upper == "CNAME" { + return "A" + } + return upper } diff --git a/services/protocols/internal/infrastructure/netdns/public_verifier_match_test.go b/services/protocols/internal/infrastructure/netdns/public_verifier_match_test.go new file mode 100644 index 0000000..a879465 --- /dev/null +++ b/services/protocols/internal/infrastructure/netdns/public_verifier_match_test.go @@ -0,0 +1,96 @@ +package netdns + +import "testing" + +// Comparing a desired record against what a resolver actually answers. +// +// Every case here comes from a real zone where the console reported a record +// as missing while it was published and correct. Telling an operator that a +// record they can see in their DNS does not exist is worse than saying +// nothing: they go looking for a problem that is not there. + +func TestMxIgnoresThePriorityAndTrailingDot(t *testing.T) { + // The spec carries the host in Value and the priority in its own field, so + // the desired value is the host alone. A resolver answers "10 mail.x." - + // comparing the two as strings said the MX of a domain that receives mail + // every day did not exist. + if !answerMatchesValue("MX", "10 mail.lucasrguerra.dev.br.", "mail.lucasrguerra.dev.br") { + t.Error("a published MX was reported as missing") + } + if !answerMatchesValue("MX", "20 mail.lucasrguerra.dev.br.", "mail.lucasrguerra.dev.br") { + t.Error("the priority should not decide whether the host matches") + } + // A different host is still a real difference. + if answerMatchesValue("MX", "10 outro.example.test.", "mail.lucasrguerra.dev.br") { + t.Error("a different mail host was accepted") + } +} + +// A TXT value longer than 255 bytes is published as several strings, and a +// resolver renders them quoted and separated. Joining them with a space - which +// is what stripping the quotes and collapsing whitespace did - inserted a space +// into the middle of a DKIM public key, so every RSA DKIM record on the zone +// was reported as missing. +func TestLongTxtIsJoinedWithoutASeparator(t *testing.T) { + published := `"v=DKIM1; k=rsa; p=AAAA" "BBBB"` + if !answerMatchesValue("TXT", published, "v=DKIM1; k=rsa; p=AAAABBBB") { + t.Error("a split TXT record was reported as missing") + } +} + +func TestShortTxtStillMatches(t *testing.T) { + if !answerMatchesValue("TXT", `"v=spf1 mx -all"`, "v=spf1 mx -all") { + t.Error("a single-string TXT was reported as missing") + } +} + +// The spaces inside a policy string are meaningful and must survive. +func TestTxtKeepsItsOwnSpaces(t *testing.T) { + if !answerMatchesValue("TXT", `"v=spf1 include:_spf.example.test -all"`, + "v=spf1 include:_spf.example.test -all") { + t.Error("a TXT with real spaces stopped matching") + } + if answerMatchesValue("TXT", `"v=spf1 mx -all"`, "v=spf1 mx ~all") { + t.Error("a genuinely different policy was accepted") + } +} + +func TestCnameIgnoresTheTrailingDot(t *testing.T) { + if !answerMatchesValue("CNAME", "mail.lucasrguerra.dev.br.", "mail.lucasrguerra.dev.br") { + t.Error("a published CNAME was reported as missing") + } +} + +func TestCaseDoesNotDecide(t *testing.T) { + if !answerMatchesValue("CNAME", "MAIL.LUCASRGUERRA.DEV.BR.", "mail.lucasrguerra.dev.br") { + t.Error("a name in another case was reported as missing") + } +} + +func TestAValueIsCompared(t *testing.T) { + if !answerMatchesValue("A", "203.0.113.10", "203.0.113.10") { + t.Error("a matching A record was reported as missing") + } + if answerMatchesValue("A", "203.0.113.99", "203.0.113.10") { + t.Error("a different address was accepted") + } +} + +// A record behind the provider's proxy is not published as the type it was +// created as: a proxied CNAME answers A, carrying the proxy's own addresses. +// Asking for the CNAME therefore comes back empty, and the console reported +// the record as missing while it was there and serving traffic. +func TestProxiedCnameIsQueriedAsTheTypeTheProxyAnswers(t *testing.T) { + proxied := queryTypeFor("CNAME", true) + if proxied != "A" { + t.Errorf("a proxied CNAME is queried as %s; the proxy answers A", proxied) + } + // Without the proxy it is still a CNAME question. + if plain := queryTypeFor("CNAME", false); plain != "CNAME" { + t.Errorf("an ordinary CNAME is queried as %s", plain) + } + // Other types are unaffected: a proxied A is still an A. + if a := queryTypeFor("A", true); a != "A" { + t.Errorf("a proxied A is queried as %s", a) + } +} diff --git a/services/webmail/src/app/(admin)/admin/layout.tsx b/services/webmail/src/app/(admin)/admin/layout.tsx index 3ba0171..aa9817a 100644 --- a/services/webmail/src/app/(admin)/admin/layout.tsx +++ b/services/webmail/src/app/(admin)/admin/layout.tsx @@ -105,7 +105,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {initialsFor(account?.email)} - + {account?.email ?? t("common.loading")} @@ -154,7 +157,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {/* Desktop rail */} -