From 2b369ab3734257f3f7b27e6c61ada0fc696b485f Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 23 Aug 2026 15:52:27 -0300 Subject: [PATCH] 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 --- .../lambdamail-protocols/dns_reconciler.go | 92 +++++++++++ .../cmd/lambdamail-protocols/main.go | 52 +++++-- .../postgres/domain_repository.go | 42 +++++ .../presentation/http/admin_dns_api.go | 92 +++++++++++ .../http/admin_dns_reconcile_test.go | 146 ++++++++++++++++++ .../internal/presentation/http/router.go | 1 + .../presentation/http/websession_test.go | 8 +- services/webmail/messages/en.json | 5 +- services/webmail/messages/es.json | 5 +- services/webmail/messages/pt-BR.json | 5 +- .../src/app/(admin)/admin/domains/page.tsx | 66 +++++++- 11 files changed, 497 insertions(+), 17 deletions(-) create mode 100644 services/protocols/cmd/lambdamail-protocols/dns_reconciler.go create mode 100644 services/protocols/internal/infrastructure/postgres/domain_repository.go create mode 100644 services/protocols/internal/presentation/http/admin_dns_reconcile_test.go diff --git a/services/protocols/cmd/lambdamail-protocols/dns_reconciler.go b/services/protocols/cmd/lambdamail-protocols/dns_reconciler.go new file mode 100644 index 0000000..3faed39 --- /dev/null +++ b/services/protocols/cmd/lambdamail-protocols/dns_reconciler.go @@ -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 +} diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index f6c3e3b..f7e367d 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -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() } } } diff --git a/services/protocols/internal/infrastructure/postgres/domain_repository.go b/services/protocols/internal/infrastructure/postgres/domain_repository.go new file mode 100644 index 0000000..b19e4f8 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/domain_repository.go @@ -0,0 +1,42 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DomainRepository lists the domains this server is responsible for. +type DomainRepository struct { + pool *pgxpool.Pool +} + +func NewDomainRepository(pool *pgxpool.Pool) *DomainRepository { + return &DomainRepository{pool: pool} +} + +// ActiveDomainNames returns every active domain. +// +// The DNS reconciler used to run against MAIL_DOMAIN alone, so a domain added +// through the console was never reconciled: its records simply never appeared, +// and the only visible symptom was the console reporting them missing forever. +func (r *DomainRepository) ActiveDomainNames(ctx context.Context) ([]string, error) { + rows, err := r.pool.Query(ctx, ` + SELECT name FROM domains WHERE is_active ORDER BY name + `) + if err != nil { + return nil, fmt.Errorf("list active domains: %w", err) + } + defer rows.Close() + + out := []string{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} diff --git a/services/protocols/internal/presentation/http/admin_dns_api.go b/services/protocols/internal/presentation/http/admin_dns_api.go index f1e2d9f..059843f 100644 --- a/services/protocols/internal/presentation/http/admin_dns_api.go +++ b/services/protocols/internal/presentation/http/admin_dns_api.go @@ -2,6 +2,7 @@ package httppresentation import ( "context" + "log" "net/http" "strings" @@ -22,10 +23,79 @@ type DnsSpecSource interface { ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) } +// ReconcileResult is what one reconciliation did. +// +// Conflicts are reported rather than resolved: a record of the right type and +// name already holding somebody else's value is not this server's to +// overwrite - it may be the zone's mail, or a service nobody remembered. +type ReconcileResult struct { + Domain string `json:"domain"` + Created int `json:"created"` + Updated int `json:"updated"` + Unchanged int `json:"unchanged"` + Conflicts []string `json:"conflicts"` + Errors []string `json:"errors"` +} + +// DomainReconciler publishes a domain's expected records through the DNS +// provider, creating what is missing and correcting what disagrees. +// +// This is the half the console never had. Verification could say a record was +// missing; nothing exposed the ability to then create it, even though the +// service holding the provider token could do exactly that. +type DomainReconciler interface { + ReconcileDomain(ctx context.Context, domain string) (ReconcileResult, error) +} + type adminDnsAPI struct { spec DnsSpecSource verifier DnsRecordVerifier sessions *WebSessionVerifier + // reconciler is optional: without it the route reports that the feature + // is not configured rather than answering as though it had run. + reconciler DomainReconciler +} + +// handleReconcile publishes the records a domain is missing. +// +// The console's reconcile button used to reach an endpoint that wrote an audit +// row and answered "REQUESTED" - it never touched DNS, and the operator was +// left with a list of missing records and no way to act on it. +func (a *adminDnsAPI) handleReconcile(w http.ResponseWriter, r *http.Request) { + token := bearerOrCookie(r, "lm_admin_session") + if token == "" { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required") + return + } + if _, err := a.sessions.RequireSurface(token, "admin"); err != nil { + // Creating DNS records is an administrative action; a webmail session + // carries the wrong audience and stops here. + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required") + return + } + + domain := strings.TrimSpace(r.URL.Query().Get("domain")) + if domain == "" { + writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required") + return + } + if a.reconciler == nil { + writeError(w, http.StatusServiceUnavailable, "RECONCILER_UNAVAILABLE", + "DNS reconciliation needs a provider token to be configured") + return + } + + result, err := a.reconciler.ReconcileDomain(r.Context(), domain) + if err != nil { + // Logged rather than echoed: a provider error can name the account or + // the zone, and that is not the browser's business. + log.Printf("dns: could not reconcile %s: %v", domain, err) + writeError(w, http.StatusBadGateway, "RECONCILE_FAILED", + "The DNS provider refused the request. Check the API token and the zone.") + return + } + + writeJSON(w, http.StatusOK, result) } // handleVerify checks every expected record against public resolvers and @@ -116,6 +186,28 @@ func (r *Router) SetAdminDnsAPI(spec DnsSpecSource, verifier DnsRecordVerifier, r.dns = &adminDnsAPI{spec: spec, verifier: verifier, sessions: NewWebSessionVerifier(sessionSecret)} } +// handleAdminDnsReconcile publishes the records a domain is missing. +func (r *Router) handleAdminDnsReconcile(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required") + return + } + if r.dns == nil { + writeError(w, http.StatusServiceUnavailable, "DNS_API_DISABLED", + "DNS reconciliation needs JWT_SECRET and a configured domain") + return + } + r.dns.handleReconcile(w, req) +} + +// SetDnsReconciler enables the reconcile route to actually publish records. +// Without it the route reports that reconciliation is not configured. +func (r *Router) SetDnsReconciler(reconciler DomainReconciler) { + if r.dns != nil { + r.dns.reconciler = reconciler + } +} + func (r *Router) handleAdminDnsVerify(w http.ResponseWriter, req *http.Request) { if r.dns == nil { writeError(w, http.StatusServiceUnavailable, "DNS_API_DISABLED", diff --git a/services/protocols/internal/presentation/http/admin_dns_reconcile_test.go b/services/protocols/internal/presentation/http/admin_dns_reconcile_test.go new file mode 100644 index 0000000..84c3cdc --- /dev/null +++ b/services/protocols/internal/presentation/http/admin_dns_reconcile_test.go @@ -0,0 +1,146 @@ +package httppresentation + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Making the reconcile button do what its name says. +// +// It answered {"status":"REQUESTED","message":"DNS records are not verified by +// this endpoint yet"} - it wrote an audit row and nothing else. The service +// that can actually create a record, with the provider token in hand, exposed +// no route at all, so the console could report a record missing and had no way +// to fix it. + +type stubReconciler struct { + called []string + created int + err error +} + +func (s *stubReconciler) ReconcileDomain(_ context.Context, domain string) (ReconcileResult, error) { + s.called = append(s.called, domain) + if s.err != nil { + return ReconcileResult{}, s.err + } + return ReconcileResult{Created: s.created, Domain: domain}, nil +} + +func newReconcileAPI(t *testing.T, r *stubReconciler) *adminDnsAPI { + t.Helper() + return &adminDnsAPI{sessions: testVerifier(interopSecret), reconciler: r} +} + +func adminRequest(target string) *http.Request { + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader("{}")) + req.Header.Set("Authorization", "Bearer "+adminSurfaceToken2()) + return req +} + +func TestReconcileActuallyReconcilesTheDomain(t *testing.T) { + stub := &stubReconciler{created: 11} + rec := httptest.NewRecorder() + newReconcileAPI(t, stub).handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile?domain=example.test")) + + if rec.Code != http.StatusOK { + t.Fatalf("answered %d: %s", rec.Code, rec.Body) + } + if len(stub.called) != 1 || stub.called[0] != "example.test" { + t.Errorf("reconciled %v, want [example.test]", stub.called) + } + + var body map[string]any + _ = json.NewDecoder(rec.Body).Decode(&body) + // It reports what it did. The old endpoint reported a status it had not + // established, which is how the console came to claim records were + // verified when nothing had looked at them. + if body["created"] != float64(11) { + t.Errorf("reported %v created", body["created"]) + } +} + +func TestReconcileRefusesWithoutAnAdminSession(t *testing.T) { + stub := &stubReconciler{} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/dns/reconcile?domain=example.test", nil) + newReconcileAPI(t, stub).handleReconcile(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("answered %d without a session", rec.Code) + } + if len(stub.called) != 0 { + t.Error("it wrote to DNS without an authenticated caller") + } +} + +// A user session must not reach it: creating DNS records is an admin action. +func TestReconcileRefusesAUserSession(t *testing.T) { + stub := &stubReconciler{} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/dns/reconcile?domain=example.test", nil) + req.Header.Set("Authorization", "Bearer "+interopSession) + newReconcileAPI(t, stub).handleReconcile(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("a webmail session reached the reconciler: %d", rec.Code) + } + if len(stub.called) != 0 { + t.Error("a webmail session wrote to DNS") + } +} + +func TestReconcileRequiresADomain(t *testing.T) { + stub := &stubReconciler{} + rec := httptest.NewRecorder() + newReconcileAPI(t, stub).handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile")) + + if rec.Code != http.StatusBadRequest { + t.Errorf("answered %d with no domain", rec.Code) + } + if len(stub.called) != 0 { + t.Error("it reconciled something with no domain named") + } +} + +// A provider that refuses has to be reported as a failure, not swallowed into +// a cheerful answer the way the old endpoint did. +func TestReconcileReportsAProviderFailure(t *testing.T) { + stub := &stubReconciler{err: errors.New("cloudflare refused the token")} + rec := httptest.NewRecorder() + newReconcileAPI(t, stub).handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile?domain=example.test")) + + if rec.Code < 500 { + t.Errorf("a provider failure answered %d, want a 5xx", rec.Code) + } + // The provider's own words must not travel to the browser: they can carry + // account identifiers. + if strings.Contains(rec.Body.String(), "cloudflare refused the token") { + t.Errorf("the provider error leaked: %s", rec.Body) + } +} + +// Without the reconciler wired, the route says so rather than pretending. +func TestReconcileSaysWhenItIsNotConfigured(t *testing.T) { + rec := httptest.NewRecorder() + api := &adminDnsAPI{sessions: testVerifier(interopSecret)} + api.handleReconcile(rec, adminRequest("/api/v1/admin/dns/reconcile?domain=example.test")) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("answered %d with no reconciler configured", rec.Code) + } +} + +func adminSurfaceToken2() string { + return mintSessionRaw(WebSession{ + Subject: "mb-1", Email: "admin@example.test", Role: "SUPER_ADMIN", + Surface: "admin", Audience: "lambdamail:admin", + Purpose: "session", MfaSatisfied: true, + IssuedAt: interopIssuedAt, ExpiresAt: interopIssuedAt + 3600, + }) +} diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index 4ca457f..153e1d6 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -81,6 +81,7 @@ func (r *Router) registerRoutes() { r.mux.HandleFunc("/api/v1/events", r.handleEvents) r.mux.HandleFunc("/api/v1/admin/tls", r.handleAdminTls) r.mux.HandleFunc("/api/v1/admin/dns/verify", r.handleAdminDnsVerify) + r.mux.HandleFunc("/api/v1/admin/dns/reconcile", r.handleAdminDnsReconcile) r.mux.HandleFunc("/api/v1/admin/dkim/keys", r.handleAdminDkimKeys) r.mux.HandleFunc("/api/v1/admin/dkim/rotate", r.handleAdminDkimRotate) r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders) diff --git a/services/protocols/internal/presentation/http/websession_test.go b/services/protocols/internal/presentation/http/websession_test.go index eed9db1..914114a 100644 --- a/services/protocols/internal/presentation/http/websession_test.go +++ b/services/protocols/internal/presentation/http/websession_test.go @@ -104,11 +104,17 @@ func TestWebSessionVerifier_RejectsExpiredToken(t *testing.T) { // variations, and produces them through the same algorithm. func mintSession(t *testing.T, claims WebSession) string { t.Helper() + return mintSessionRaw(claims) +} + +// mintSessionRaw is the same without a *testing.T, for helpers that build a +// token outside a test body. +func mintSessionRaw(claims WebSession) string { header := base64.RawURLEncoding.EncodeToString( []byte(`{"alg":"HS256","typ":"JWT"}`)) body, err := json.Marshal(claims) if err != nil { - t.Fatalf("marshal claims: %v", err) + panic(err) } payload := base64.RawURLEncoding.EncodeToString(body) diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 99f1c2d..62e7527 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -91,7 +91,10 @@ "unlockAccount": "Unlock", "verifyIntro": "Resolves every expected record against public resolvers and reports each one.", "watcherHealthy": "Certificate watcher", - "openAdminShort": "Admin" + "openAdminShort": "Admin", + "reconcileSummary": "{created} created, {updated} corrected, {unchanged} already right.", + "reconcileConflicts": "Left untouched — another value is already published:", + "reconcileErrors": "The provider refused these:" }, "auth": { "adminLoginTitle": "Admin console", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index fc3c623..46035f4 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -91,7 +91,10 @@ "unlockAccount": "Desbloquear", "verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.", "watcherHealthy": "Monitor de certificado", - "openAdminShort": "Admin" + "openAdminShort": "Admin", + "reconcileSummary": "{created} creados, {updated} corregidos, {unchanged} ya correctos.", + "reconcileConflicts": "Sin tocar — ya hay otro valor publicado:", + "reconcileErrors": "El proveedor rechazó estos:" }, "auth": { "adminLoginTitle": "Consola de administración", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 9462c19..c164705 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -91,7 +91,10 @@ "unlockAccount": "Desbloquear", "verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.", "watcherHealthy": "Monitor de certificado", - "openAdminShort": "Admin" + "openAdminShort": "Admin", + "reconcileSummary": "{created} criados, {updated} corrigidos, {unchanged} já corretos.", + "reconcileConflicts": "Não alterados — já existe outro valor publicado:", + "reconcileErrors": "O provedor recusou estes:" }, "auth": { "adminLoginTitle": "Console de administração", diff --git a/services/webmail/src/app/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx index d3d6002..016224c 100644 --- a/services/webmail/src/app/(admin)/admin/domains/page.tsx +++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx @@ -32,6 +32,15 @@ interface DnsRecordCheck { proxied: boolean; } +interface ReconcileResult { + domain: string; + created: number; + updated: number; + unchanged: number; + conflicts?: string[]; + errors?: string[]; +} + interface DnsVerification { domain: string; status: string; @@ -70,6 +79,9 @@ export default function AdminDomainsPage() { const [reconciling, setReconciling] = useState(false); const [verification, setVerification] = useState(null); + // What the last reconciliation actually did, so the panel can say it rather + // than leaving the operator to infer it from the verification that follows. + const [reconcileResult, setReconcileResult] = useState(null); const [onboardDomain, setOnboardDomain] = useState(""); const [onboarding, setOnboarding] = useState(false); @@ -112,13 +124,28 @@ export default function AdminDomainsPage() { setNotice(null); setError(null); setVerification(null); + setReconcileResult(null); try { + // Publish first, then look. The button used to verify and then call an + // endpoint that only wrote an audit row, so it reported what was missing + // and never created any of it - which is what made the panel feel like + // it did nothing. + const applied = await fetch( + `/api/v1/admin/dns/reconcile?domain=${encodeURIComponent(selected.name)}`, + { method: "POST" }, + ); + const result = await applied.json().catch(() => ({})); + if (!applied.ok) throw new Error(result.message); + setReconcileResult(result); + + // Then verify, so what is shown is the state after publishing rather + // than the state that prompted it. const res = await fetch(`/api/v1/admin/dns/verify?domain=${encodeURIComponent(selected.name)}`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.message); setVerification(data); - // Reconciliation still runs, so the stored status and the audit trail - // stay in step with what was just observed. + + // The audit trail still records who asked for it. await fetch("/api/v1/admin/domains/reconcile", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -295,6 +322,41 @@ export default function AdminDomainsPage() { + {/* What the reconciliation did. Without this the panel showed only + the verification that followed, so an operator could not tell + whether anything had been published or the records had simply + been there all along. */} + {reconcileResult && ( +
+ + {t("admin.reconcileSummary", { + created: reconcileResult.created, + updated: reconcileResult.updated, + unchanged: reconcileResult.unchanged, + })} + + {(reconcileResult.conflicts ?? []).length > 0 && ( +
+ {/* Left untouched on purpose: a record of the right type + and name already holding another value may be something + the zone still needs. */} + {t("admin.reconcileConflicts")} + {(reconcileResult.conflicts ?? []).map((c) => ( + {c} + ))} +
+ )} + {(reconcileResult.errors ?? []).length > 0 && ( +
+ {t("admin.reconcileErrors")} + {(reconcileResult.errors ?? []).map((e) => ( + {e} + ))} +
+ )} +
+ )} + {verification && ( <>