fix(dns): o botao de reconciliar nao criava registo nenhum

O painel listava os registos em falta e oferecia um botao para os criar, mas
esse botao chegava a um endpoint que so escrevia uma linha de auditoria e
respondia "REQUESTED". Nunca tocou no DNS. Quem tinha o token da Cloudflare
era o servico de protocolos, e a unica coisa capaz de o usar corria num
temporizador -- para um dominio so, o de MAIL_DOMAIN.

Por isso um dominio adicionado pela consola, como o cienciaembarcada.com.br,
nunca era reconciliado: os registos nao apareciam e o unico sintoma visivel
era a consola dizer que faltavam, para sempre.

- POST /api/v1/admin/dns/reconcile publica os registos de um dominio, agora,
  e devolve o que foi criado, corrigido e o que ficou em conflito
- o temporizador passa a percorrer todos os dominios ativos
- conflitos sao reportados, nao resolvidos: um registo do mesmo tipo e nome
  com outro valor pode ser correio da zona ou um servico de que ninguem se
  lembra, e nao e deste servidor decidir sobrepo-lo
- o erro do fornecedor fica no log, nao na resposta: pode nomear a conta ou
  a zona

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-23 15:52:27 -03:00
1 parent 8227fe2b97
commit 2b369ab373
11 files changed
+497 -17

No files matched your search

@@ -0,0 +1,92 @@
package main
import (
"context"
"fmt"
"log"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/cloudflare"
"lambdamail/protocols/internal/infrastructure/netdns"
"lambdamail/protocols/internal/infrastructure/postgres"
httppresentation "lambdamail/protocols/internal/presentation/http"
)
// domainReconciler publishes one domain's expected records on demand.
//
// The console could tell an operator a record was missing and offer no way to
// create it: the button reached an endpoint that wrote an audit row, and the
// only code able to talk to the DNS provider ran on a timer, for one domain.
type domainReconciler struct {
run func(ctx context.Context, domain string) (*usecase.SyncDnsRecordsOutput, error)
}
func (d domainReconciler) ReconcileDomain(
ctx context.Context, domain string,
) (httppresentation.ReconcileResult, error) {
out, err := d.run(ctx, domain)
if err != nil {
return httppresentation.ReconcileResult{}, err
}
conflicts := make([]string, 0, len(out.Conflicts))
for _, c := range out.Conflicts {
// Named plainly, because the operator has to decide what to do: the
// existing value may be something they still need.
conflicts = append(conflicts, fmt.Sprintf("%s %s already holds %q", c.Type, c.Name, c.Existing))
}
return httppresentation.ReconcileResult{
Domain: domain,
Created: out.CreatedCount,
Updated: out.UpdatedCount,
Unchanged: out.UnchangedCount,
Conflicts: conflicts,
Errors: out.Errors,
}, nil
}
// reconcileDomainOnDemand publishes one domain's records, now.
//
// It builds the same use case the timer uses rather than sharing an instance:
// the console's button and the background sweep must not be able to interleave
// halfway through each other's zone updates.
func reconcileDomainOnDemand(
ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository,
dkimRepo *postgres.DkimRepository, domain string,
) (*usecase.SyncDnsRecordsOutput, error) {
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
syncUC.SetVerifier(netdns.NewPublicVerifier())
input := usecase.SyncDnsRecordsInput{
DomainName: domain,
MailHost: cfg.PrimaryMailHost,
ServerIPv4: cfg.PublicIPv4,
ServerIPv6: cfg.PublicIPv6,
DaneEnabled: cfg.OutboundDane,
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
}
// A domain with no DKIM key of its own would publish a selector pointing
// at nothing, so the keys are provisioned first, exactly as the timer does.
if dkimRepo != nil {
keys, err := usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey).Execute(ctx, domain)
if err != nil {
return nil, fmt.Errorf("provision DKIM keys for %s: %w", domain, err)
}
input.RsaDkimPubKey = keys.RsaPublicKey
input.EdDkimPubKey = keys.Ed25519PublicKey
if len(keys.Created) > 0 {
log.Printf("DNS reconcile (on demand): generated DKIM keys for %s: %v", domain, keys.Created)
}
}
out, err := syncUC.Execute(ctx, input)
if err != nil {
return nil, err
}
log.Printf("DNS reconcile (on demand) for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)",
domain, out.Status, out.CreatedCount, out.UpdatedCount, out.UnchangedCount, out.ConflictCount)
return out, nil
}
@@ -185,7 +185,7 @@ func run(cfg config) {
// ------------------------------------------------------- background jobs
go runDeliveryWorker(ctx, outboundWorker)
if cfg.CloudflareToken != "" {
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo)
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo, postgres.NewDomainRepository(pool))
} else {
log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled")
}
@@ -208,6 +208,11 @@ func run(cfg config) {
router.SetMailAPI(webmailUC, cfg.JwtSecret)
// Folders the mailbox owner keeps for themselves, which the filing rules
// can then name as a destination.
// The console's reconcile button reaches this: the same sync the timer
// runs, for one named domain, on demand.
router.SetDnsReconciler(domainReconciler{run: func(c context.Context, domain string) (*usecase.SyncDnsRecordsOutput, error) {
return reconcileDomainOnDemand(c, cfg, aliasRepo, dkimRepo, domain)
}})
router.SetFolderAdmin(usecase.NewManageFoldersUseCase(
postgres.NewWebmailRepository(pool), postgres.NewWebmailRepository(pool)))
if dkimRepo != nil {
@@ -546,7 +551,10 @@ func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCas
// runDnsReconciler provisions the DKIM keys and reconciles the zone, then
// repeats on the interval of PLAN.md section 7.5 to detect drift.
func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository, dkimRepo *postgres.DkimRepository) {
func runDnsReconciler(
ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository,
dkimRepo *postgres.DkimRepository, domainRepo *postgres.DomainRepository,
) {
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
syncUC.SetVerifier(netdns.NewPublicVerifier())
@@ -555,9 +563,8 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
provisioner = usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey)
}
reconcile := func() {
domain := cfg.domain()
// reconcileOne publishes the expected records for one domain.
reconcileOne := func(domain string) (*usecase.SyncDnsRecordsOutput, error) {
input := usecase.SyncDnsRecordsInput{
DomainName: domain,
MailHost: cfg.PrimaryMailHost,
@@ -573,8 +580,7 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
if provisioner != nil {
keys, err := provisioner.Execute(ctx, domain)
if err != nil {
log.Printf("DNS reconcile: could not provision DKIM keys for %s: %v", domain, err)
return
return nil, fmt.Errorf("provision DKIM keys for %s: %w", domain, err)
}
input.RsaDkimPubKey = keys.RsaPublicKey
input.EdDkimPubKey = keys.Ed25519PublicKey
@@ -585,8 +591,7 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
out, err := syncUC.Execute(ctx, input)
if err != nil {
log.Printf("DNS reconcile for %s failed: %v", domain, err)
return
return nil, err
}
log.Printf("DNS reconcile for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)",
@@ -603,9 +608,34 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
for _, e := range out.Errors {
log.Printf("DNS reconcile error: %s", e)
}
return out, nil
}
reconcile()
// reconcileAll covers every domain this server serves, not just the one in
// MAIL_DOMAIN. A domain added through the console was never reconciled at
// all: its records simply never appeared, and the only visible symptom was
// the console reporting them missing forever.
reconcileAll := func() {
domains := []string{cfg.domain()}
if domainRepo != nil {
if found, err := domainRepo.ActiveDomainNames(ctx); err != nil {
log.Printf("DNS reconcile: could not list the domains, falling back to %s: %v",
cfg.domain(), err)
} else if len(found) > 0 {
domains = found
}
}
for _, domain := range domains {
// One domain failing must not stop the others: a zone the token
// cannot reach is a problem for that domain alone.
if _, err := reconcileOne(domain); err != nil {
log.Printf("DNS reconcile for %s failed: %v", domain, err)
}
}
}
reconcileAll()
ticker := time.NewTicker(cfg.DnsSyncInterval)
defer ticker.Stop()
@@ -614,7 +644,7 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
case <-ctx.Done():
return
case <-ticker.C:
reconcile()
reconcileAll()
}
}
}