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 // ------------------------------------------------------- background jobs
go runDeliveryWorker(ctx, outboundWorker) go runDeliveryWorker(ctx, outboundWorker)
if cfg.CloudflareToken != "" { if cfg.CloudflareToken != "" {
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo) go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo, postgres.NewDomainRepository(pool))
} else { } else {
log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled") 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) router.SetMailAPI(webmailUC, cfg.JwtSecret)
// Folders the mailbox owner keeps for themselves, which the filing rules // Folders the mailbox owner keeps for themselves, which the filing rules
// can then name as a destination. // 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( router.SetFolderAdmin(usecase.NewManageFoldersUseCase(
postgres.NewWebmailRepository(pool), postgres.NewWebmailRepository(pool))) postgres.NewWebmailRepository(pool), postgres.NewWebmailRepository(pool)))
if dkimRepo != nil { 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 // runDnsReconciler provisions the DKIM keys and reconciles the zone, then
// repeats on the interval of PLAN.md section 7.5 to detect drift. // 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 := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
syncUC.SetVerifier(netdns.NewPublicVerifier()) syncUC.SetVerifier(netdns.NewPublicVerifier())
@@ -555,9 +563,8 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
provisioner = usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey) provisioner = usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey)
} }
reconcile := func() { // reconcileOne publishes the expected records for one domain.
domain := cfg.domain() reconcileOne := func(domain string) (*usecase.SyncDnsRecordsOutput, error) {
input := usecase.SyncDnsRecordsInput{ input := usecase.SyncDnsRecordsInput{
DomainName: domain, DomainName: domain,
MailHost: cfg.PrimaryMailHost, MailHost: cfg.PrimaryMailHost,
@@ -573,8 +580,7 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
if provisioner != nil { if provisioner != nil {
keys, err := provisioner.Execute(ctx, domain) keys, err := provisioner.Execute(ctx, domain)
if err != nil { if err != nil {
log.Printf("DNS reconcile: could not provision DKIM keys for %s: %v", domain, err) return nil, fmt.Errorf("provision DKIM keys for %s: %w", domain, err)
return
} }
input.RsaDkimPubKey = keys.RsaPublicKey input.RsaDkimPubKey = keys.RsaPublicKey
input.EdDkimPubKey = keys.Ed25519PublicKey input.EdDkimPubKey = keys.Ed25519PublicKey
@@ -585,8 +591,7 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
out, err := syncUC.Execute(ctx, input) out, err := syncUC.Execute(ctx, input)
if err != nil { if err != nil {
log.Printf("DNS reconcile for %s failed: %v", domain, err) return nil, err
return
} }
log.Printf("DNS reconcile for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)", 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 { for _, e := range out.Errors {
log.Printf("DNS reconcile error: %s", e) 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) ticker := time.NewTicker(cfg.DnsSyncInterval)
defer ticker.Stop() defer ticker.Stop()
@@ -614,7 +644,7 @@ func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAlia
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
reconcile() reconcileAll()
} }
} }
} }
@@ -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()
}
@@ -2,6 +2,7 @@ package httppresentation
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"strings" "strings"
@@ -22,10 +23,79 @@ type DnsSpecSource interface {
ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) 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 { type adminDnsAPI struct {
spec DnsSpecSource spec DnsSpecSource
verifier DnsRecordVerifier verifier DnsRecordVerifier
sessions *WebSessionVerifier 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 // 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)} 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) { func (r *Router) handleAdminDnsVerify(w http.ResponseWriter, req *http.Request) {
if r.dns == nil { if r.dns == nil {
writeError(w, http.StatusServiceUnavailable, "DNS_API_DISABLED", writeError(w, http.StatusServiceUnavailable, "DNS_API_DISABLED",
@@ -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,
})
}
@@ -81,6 +81,7 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/api/v1/events", r.handleEvents) r.mux.HandleFunc("/api/v1/events", r.handleEvents)
r.mux.HandleFunc("/api/v1/admin/tls", r.handleAdminTls) 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/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/keys", r.handleAdminDkimKeys)
r.mux.HandleFunc("/api/v1/admin/dkim/rotate", r.handleAdminDkimRotate) r.mux.HandleFunc("/api/v1/admin/dkim/rotate", r.handleAdminDkimRotate)
r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders) r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders)
@@ -104,11 +104,17 @@ func TestWebSessionVerifier_RejectsExpiredToken(t *testing.T) {
// variations, and produces them through the same algorithm. // variations, and produces them through the same algorithm.
func mintSession(t *testing.T, claims WebSession) string { func mintSession(t *testing.T, claims WebSession) string {
t.Helper() 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( header := base64.RawURLEncoding.EncodeToString(
[]byte(`{"alg":"HS256","typ":"JWT"}`)) []byte(`{"alg":"HS256","typ":"JWT"}`))
body, err := json.Marshal(claims) body, err := json.Marshal(claims)
if err != nil { if err != nil {
t.Fatalf("marshal claims: %v", err) panic(err)
} }
payload := base64.RawURLEncoding.EncodeToString(body) payload := base64.RawURLEncoding.EncodeToString(body)
+4 -1
View File
@@ -91,7 +91,10 @@
"unlockAccount": "Unlock", "unlockAccount": "Unlock",
"verifyIntro": "Resolves every expected record against public resolvers and reports each one.", "verifyIntro": "Resolves every expected record against public resolvers and reports each one.",
"watcherHealthy": "Certificate watcher", "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": { "auth": {
"adminLoginTitle": "Admin console", "adminLoginTitle": "Admin console",
+4 -1
View File
@@ -91,7 +91,10 @@
"unlockAccount": "Desbloquear", "unlockAccount": "Desbloquear",
"verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.", "verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.",
"watcherHealthy": "Monitor de certificado", "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": { "auth": {
"adminLoginTitle": "Consola de administración", "adminLoginTitle": "Consola de administración",
+4 -1
View File
@@ -91,7 +91,10 @@
"unlockAccount": "Desbloquear", "unlockAccount": "Desbloquear",
"verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.", "verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.",
"watcherHealthy": "Monitor de certificado", "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": { "auth": {
"adminLoginTitle": "Console de administração", "adminLoginTitle": "Console de administração",
@@ -32,6 +32,15 @@ interface DnsRecordCheck {
proxied: boolean; proxied: boolean;
} }
interface ReconcileResult {
domain: string;
created: number;
updated: number;
unchanged: number;
conflicts?: string[];
errors?: string[];
}
interface DnsVerification { interface DnsVerification {
domain: string; domain: string;
status: string; status: string;
@@ -70,6 +79,9 @@ export default function AdminDomainsPage() {
const [reconciling, setReconciling] = useState(false); const [reconciling, setReconciling] = useState(false);
const [verification, setVerification] = useState<DnsVerification | null>(null); const [verification, setVerification] = useState<DnsVerification | null>(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<ReconcileResult | null>(null);
const [onboardDomain, setOnboardDomain] = useState(""); const [onboardDomain, setOnboardDomain] = useState("");
const [onboarding, setOnboarding] = useState(false); const [onboarding, setOnboarding] = useState(false);
@@ -112,13 +124,28 @@ export default function AdminDomainsPage() {
setNotice(null); setNotice(null);
setError(null); setError(null);
setVerification(null); setVerification(null);
setReconcileResult(null);
try { 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 res = await fetch(`/api/v1/admin/dns/verify?domain=${encodeURIComponent(selected.name)}`);
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message); if (!res.ok) throw new Error(data.message);
setVerification(data); 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", { await fetch("/api/v1/admin/domains/reconcile", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -295,6 +322,41 @@ export default function AdminDomainsPage() {
</Button> </Button>
</div> </div>
{/* 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 && (
<div className="flex flex-col gap-2 rounded-xl bg-dark-card p-3.5 shadow-edge">
<span className="text-[13px] text-slate-200">
{t("admin.reconcileSummary", {
created: reconcileResult.created,
updated: reconcileResult.updated,
unchanged: reconcileResult.unchanged,
})}
</span>
{(reconcileResult.conflicts ?? []).length > 0 && (
<div className="flex flex-col gap-1">
{/* Left untouched on purpose: a record of the right type
and name already holding another value may be something
the zone still needs. */}
<span className="text-[12px] text-amber-300">{t("admin.reconcileConflicts")}</span>
{(reconcileResult.conflicts ?? []).map((c) => (
<span key={c} className="font-mono text-[11.5px] text-slate-400">{c}</span>
))}
</div>
)}
{(reconcileResult.errors ?? []).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[12px] text-rose-300">{t("admin.reconcileErrors")}</span>
{(reconcileResult.errors ?? []).map((e) => (
<span key={e} className="font-mono text-[11.5px] text-slate-400">{e}</span>
))}
</div>
)}
</div>
)}
{verification && ( {verification && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">