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:
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user