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 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-23 11:40:54 -03:00
1 parent 64f1a2e145
commit 8227fe2b97
4 files changed
+217 -14

No files matched your search

@@ -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 "<name>\t<ttl>\t<class>\t<type>\t<rdata>"; 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
}