fix(dns): reconciliar reescrevia os mesmos registos a cada passagem

Duas coisas apanhadas ao correr o reconcile contra a zona real depois de a
rota passar a funcionar.

1) Nao era idempotente: 0 criados, 4 corrigidos, 11 iguais -- e outra vez 4
   corrigidos na passagem seguinte, para sempre, com o temporizador a fazer o
   mesmo em ciclo. Os 4 eram os SRV. A Cloudflare devolve o content de um SRV
   SEM a prioridade ("1 993 host" para um registo guardado como "0 1 993
   host") e guarda-a em data. O adaptador so recorria a data quando o content
   vinha vazio, portanto lia sempre um valor diferente do da spec.
   Para SRV e TLSA a data passa a ser a autoridade.

2) A spec de cada dominio incluia o A e o AAAA do mail host. Um segundo
   dominio servido pelo mesmo servidor aponta o MX para um host que nao lhe
   pertence, por isso reconciliar o cienciaembarcada.com.br tentava escrever
   mail.lucasrguerra.dev.br -- zona alheia. A Cloudflare respondia "an
   identical record already exists" e a consola mostrava dois erros para um
   dominio que estava correcto. O mesmo valia para o TLSA, que tambem tem o
   nome do mail host.
   Os registos de endereco passam a ser publicados so pela zona que contem o
   host; o MX e os SRV, que apontam para ele, continuam a ser de cada dominio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-24 08:34:14 -03:00
1 parent 9ce5fcafaa
commit 47c3700380
7 files changed
+153 -20

No files matched your search

@@ -50,16 +50,15 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
mailHost = fmt.Sprintf("mail.%s", domainName)
}
// The address records for the mail host belong to whichever zone actually
// contains that host. A second domain served by the same server points its
// MX at a host it does not own, and publishing an A for it meant trying to
// write into somebody else's zone: the provider answered "an identical
// record already exists" and the console showed errors for a domain that
// was correctly configured.
ownsMailHost := hostInsideDomain(mailHost, domainName)
records := []DnsRecord{
// 1. A record for mail host
{
Type: "A",
Name: mailHost,
Value: serverIPv4,
TTL: 1, // Auto
Proxied: false,
Comment: "LambdaMail Mail Host A Record",
},
// 3. MX record
{
Type: "MX",
@@ -185,8 +184,20 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
})
}
// 1. A record for the mail host, only when this domain contains it.
if ownsMailHost {
records = append(records, DnsRecord{
Type: "A",
Name: mailHost,
Value: serverIPv4,
TTL: 1, // Auto
Proxied: false,
Comment: "LambdaMail Mail Host A Record",
})
}
// 2. Conditional AAAA record if IPv6 is supplied
if serverIPv6 != "" {
if ownsMailHost && serverIPv6 != "" {
records = append(records, DnsRecord{
Type: "AAAA",
Name: mailHost,
@@ -200,7 +211,7 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
// 11. Conditional TLSA records if DANE is enabled. More than one is normal
// during a rollover: the current association and the next one are both
// published so the certificate can change without a gap (RFC 7671 8.1).
if spec.DaneEnabled {
if ownsMailHost && spec.DaneEnabled {
for _, hash := range spec.TlsaHashes {
if hash == "" {
continue
@@ -268,3 +279,16 @@ func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
}
return true
}
// hostInsideDomain reports whether a host name sits inside a domain's zone,
// and so whether this domain is the one that should publish its addresses.
func hostInsideDomain(host, domain string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
domain = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(domain), "."))
if host == "" || domain == "" {
return false
}
// The suffix must fall on a label boundary: "notexample.test" is not
// inside "example.test".
return host == domain || strings.HasSuffix(host, "."+domain)
}
@@ -132,3 +132,66 @@ func TestOnlyTheAvailableDkimKeyIsPublished(t *testing.T) {
}
}
}
// A second domain served by the same server does not own the mail host.
//
// Every domain's spec included an A and an AAAA for the mail host, so
// reconciling cienciaembarcada.com.br tried to create records for
// mail.lucasrguerra.dev.br - a name in somebody else's zone. Cloudflare
// answered "An identical record already exists" and the console showed the
// operator two errors about a domain that was in fact perfectly configured.
//
// The address records belong to whichever zone actually contains the host.
func TestBuildDnsRecordSpecs_SkipsMailHostAddressesOutsideTheDomain(t *testing.T) {
specs := BuildDnsRecordSpecs(DnsRecordSpec{
DomainName: "cienciaembarcada.test",
MailHost: "mail.lucasrguerra.test",
ServerIPv4: "192.0.2.1",
ServerIPv6: "2001:db8::1",
DaneEnabled: true,
TlsaHashes: []string{"hash123"},
})
for _, r := range specs {
if r.Type == "A" || r.Type == "AAAA" || r.Type == "TLSA" {
t.Errorf("spec publishes %s for %q, which is not inside cienciaembarcada.test",
r.Type, r.Name)
}
}
// The records that point AT the mail host are still the domain's own and
// must stay, or the domain stops receiving mail.
var mx bool
for _, r := range specs {
if r.Type == "MX" && r.Value == "mail.lucasrguerra.test" {
mx = true
}
}
if !mx {
t.Error("the domain lost its MX; it would stop receiving mail")
}
}
// The ordinary case must keep them: a domain whose mail host is its own
// subdomain still needs the address records published.
func TestBuildDnsRecordSpecs_KeepsMailHostAddressesInsideTheDomain(t *testing.T) {
specs := BuildDnsRecordSpecs(DnsRecordSpec{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
ServerIPv6: "2001:db8::1",
})
var a, aaaa bool
for _, r := range specs {
if r.Type == "A" && r.Name == "mail.example.test" {
a = true
}
if r.Type == "AAAA" && r.Name == "mail.example.test" {
aaaa = true
}
}
if !a || !aaaa {
t.Errorf("lost the mail host addresses (A=%v AAAA=%v)", a, aaaa)
}
}