diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 5aa01ec..04a3dda 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -207,11 +207,6 @@ Being explicit so nothing is assumed: - Master key rotation is manual. - Log retention is whatever Docker's json-file driver is configured for (10 MB × 3 per service, in `docker-compose.yaml`). -- **Per-record DNS verification is not implemented.** The admin console's - reconcile button records the request and re-reads the stored status; it does - not query a resolver and compare the thirteen expected records. The console - used to draw a table of green ticks for records nobody had checked. Verify by - hand, or with `make preflight`, until this is real. - **Attachments are not uploaded.** The compose screen lists the files you pick and sends their names only; there is no upload endpoint behind the picker yet. The screen says so when you attach something, rather than letting the diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index 9596a5f..747ad8f 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -27,6 +27,7 @@ import ( "lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/domain/entity" "lambdamail/protocols/internal/domain/valueobject" "lambdamail/protocols/internal/infrastructure/arc" "lambdamail/protocols/internal/infrastructure/clamav" @@ -180,6 +181,16 @@ func run(cfg config) { if dkimRepo != nil { router.SetAdminDkimAPI(dkimRepo, generateDkimKey, cfg.JwtSecret) } + // Per-record DNS verification. The console's reconcile button had nothing + // like this: the auth service answered it by re-reading the stored status, + // because the resolver and the record spec both live here. + if cfg.CloudflareToken != "" || cfg.PublicIPv4 != "" { + router.SetAdminDnsAPI( + &dnsSpecSource{cfg: cfg, dkim: dkimRepo}, + netdns.NewPublicVerifier(), + cfg.JwtSecret, + ) + } // The TLS panel reads the live watcher instead of the constants the admin // service used to return, which reported a healthy certificate whatever // the process actually held. @@ -600,3 +611,35 @@ func runHealthcheck() { os.Exit(1) } } + +// dnsSpecSource assembles the records a domain is expected to publish, using +// the same inputs the background reconciler uses so the console and the sync +// cannot disagree about what "expected" means. +type dnsSpecSource struct { + cfg config + dkim *postgres.DkimRepository +} + +func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) { + spec := entity.DnsRecordSpec{ + DomainName: domain, + MailHost: s.cfg.PrimaryMailHost, + ServerIPv4: s.cfg.PublicIPv4, + ServerIPv6: s.cfg.PublicIPv6, + DaneEnabled: s.cfg.OutboundDane, + RelaySpfInclude: relayConfig(s.cfg).SpfInclude(), + } + + // Read rather than provisioned: verifying must not create keys as a side + // effect of somebody opening a screen. + if s.dkim != nil { + if rsa, err := s.dkim.FindPublicKey(ctx, domain, "rsa2048"); err == nil { + spec.RsaDkimPubKey = rsa + } + if ed, err := s.dkim.FindPublicKey(ctx, domain, "ed25519"); err == nil { + spec.EdDkimPubKey = ed + } + } + + return entity.BuildDnsRecordSpecs(spec), nil +} diff --git a/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go b/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go index 83c839a..e3f31fd 100644 --- a/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go +++ b/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go @@ -120,7 +120,16 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes, nullIfEmpty(input.Subject), nullIfEmpty(input.Snippet), nullIfEmpty(input.FromDisplayName), nullIfEmpty(input.MessageIDHeader), input.HasAttachments, - input.SPFResult, input.DKIMResult, input.DMARCResult) + // These three columns are constrained to the RFC verdict vocabulary + // ('pass', 'fail', 'none', ...) and are nullable, so "not evaluated" + // has to be NULL. Passing the Go zero value straight through sent an + // empty string, which satisfies neither the CHECK nor the meaning. + // + // It only ever surfaced on messages this server composed itself - the + // Sent copy and drafts have no authentication results by definition - + // and it failed the whole insert. Sent stayed empty because that write + // is deliberately non-fatal, and saving a draft reported an error. + nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult)) if err != nil { return 0, fmt.Errorf("insert email_messages: %w", err) } diff --git a/services/protocols/internal/infrastructure/postgres/inbound_message_repository_test.go b/services/protocols/internal/infrastructure/postgres/inbound_message_repository_test.go index d1b40e6..0fe1ae4 100644 --- a/services/protocols/internal/infrastructure/postgres/inbound_message_repository_test.go +++ b/services/protocols/internal/infrastructure/postgres/inbound_message_repository_test.go @@ -263,3 +263,75 @@ func TestInboundMessageRepository_FallsBackToInboxWhenFolderMissing(t *testing.T t.Errorf("message landed in folder %s, want the INBOX %s", folderID, inboxID) } } + +// A message this server composed - the Sent copy, or a draft - has no +// authentication results, and the columns holding them are constrained to the +// RFC verdict vocabulary. Passing Go's zero value sent an empty string, which +// satisfies neither the CHECK nor the meaning, and the whole insert failed. +// +// The existing test above always supplied "none", so it never touched this. +// The visible effect was a permanently empty Sent folder - that write is +// non-fatal by design - and "could not save the draft" in the composer. +func TestInboundMessageRepository_PersistsWithoutAuthenticationResults(t *testing.T) { + pool := testPool(t) + defer pool.Close() + ctx := context.Background() + + domainID := uuid.New() + domainName := "noauth-test-" + domainID.String() + ".invalid" + if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, + domainID, domainName, domainName); err != nil { + t.Fatalf("seed domain: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) + + address := "user@" + domainName + mailboxID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`, + mailboxID, domainID, address); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + for _, f := range []struct{ name, use string }{{"INBOX", "inbox"}, {"Sent", "sent"}, {"Drafts", "drafts"}} { + if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, $3, $4)`, + uuid.New(), mailboxID, f.name, f.use); err != nil { + t.Fatalf("seed folder %s: %v", f.name, err) + } + } + + blobID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`, + blobID, testBlobDigest()); err != nil { + t.Fatalf("seed blob: %v", err) + } + + repo := NewInboundMessageRepository(pool) + blob := port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10} + + for _, folder := range []string{"Sent", "Drafts"} { + uids, err := repo.PersistAll(ctx, []port.PersistInboundMessageInput{{ + MailboxID: mailboxID, Blob: blob, + SenderAddress: address, RecipientAddress: address, + TargetFolderName: folder, Subject: "composed here", + // Left empty exactly as the compose path leaves them. + }}) + if err != nil { + t.Fatalf("PersistAll into %s with no authentication results: %v", folder, err) + } + if len(uids) != 1 { + t.Fatalf("want one uid for %s, got %v", folder, uids) + } + } + + // Stored as NULL, not as an empty string: "not evaluated" and "evaluated + // to nothing" are different answers. + var nulls int + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM email_messages + WHERE mailbox_id = $1 AND spf_result IS NULL AND dkim_result IS NULL AND dmarc_result IS NULL + `, mailboxID).Scan(&nulls); err != nil { + t.Fatalf("query: %v", err) + } + if nulls != 2 { + t.Errorf("want 2 rows with NULL authentication results, got %d", nulls) + } +} diff --git a/services/protocols/internal/presentation/http/admin_dns_api.go b/services/protocols/internal/presentation/http/admin_dns_api.go new file mode 100644 index 0000000..f1e2d9f --- /dev/null +++ b/services/protocols/internal/presentation/http/admin_dns_api.go @@ -0,0 +1,126 @@ +package httppresentation + +import ( + "context" + "net/http" + "strings" + + "lambdamail/protocols/internal/domain/entity" +) + +// DnsRecordVerifier answers whether one expected record is actually published. +// Implemented by the same public-resolver verifier the reconciler uses, so the +// console and the background sync agree on what "verified" means. +type DnsRecordVerifier interface { + VerifyRecord(ctx context.Context, record entity.DnsRecord) (bool, string) +} + +// DnsSpecSource builds the records a domain is expected to publish. It lives +// behind an interface because assembling the spec needs the DKIM public keys, +// which only the service holding the vault can read. +type DnsSpecSource interface { + ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) +} + +type adminDnsAPI struct { + spec DnsSpecSource + verifier DnsRecordVerifier + sessions *WebSessionVerifier +} + +// handleVerify checks every expected record against public resolvers and +// reports each one individually. +// +// This is the endpoint the admin console's reconcile button had no equivalent +// of. The auth service answered it by writing an audit row and re-reading the +// stored status, because it has no resolver - so the console could only ever +// repeat what the database already believed. Verification belongs here, with +// the resolver and the record spec. +func (a *adminDnsAPI) handleVerify(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 { + 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 + } + + expected, err := a.spec.ExpectedRecords(r.Context(), domain) + if err != nil { + writeError(w, http.StatusInternalServerError, "SPEC_FAILED", "Could not assemble the expected records") + return + } + + type result struct { + Type string `json:"type"` + Name string `json:"name"` + Expected string `json:"expected"` + Verified bool `json:"verified"` + Detail string `json:"detail"` + Proxied bool `json:"proxied"` + } + + results := make([]result, 0, len(expected)) + verified := 0 + for _, record := range expected { + ok, detail := a.verifier.VerifyRecord(r.Context(), record) + if ok { + verified++ + } + results = append(results, result{ + Type: record.Type, + Name: record.Name, + Expected: record.Value, + Verified: ok, + Detail: detail, + // Reported so the console can explain why a proxied record is + // checked for presence only: behind a proxy the published answer + // is the provider's address, never the value we asked for. + Proxied: record.Proxied, + }) + } + + status := "VERIFIED" + switch { + case len(results) == 0: + status = "UNKNOWN" + case verified == 0: + status = "MISSING" + case verified < len(results): + status = "PARTIAL" + } + + writeJSON(w, http.StatusOK, map[string]any{ + "domain": domain, + "status": status, + "verified": verified, + "total": len(results), + "records": results, + }) +} + +// SetAdminDnsAPI wires per-record DNS verification. Without a session secret +// the route answers 503 rather than opening unauthenticated. +func (r *Router) SetAdminDnsAPI(spec DnsSpecSource, verifier DnsRecordVerifier, sessionSecret string) { + if spec == nil || verifier == nil || sessionSecret == "" { + return + } + r.dns = &adminDnsAPI{spec: spec, verifier: verifier, sessions: NewWebSessionVerifier(sessionSecret)} +} + +func (r *Router) handleAdminDnsVerify(w http.ResponseWriter, req *http.Request) { + if r.dns == nil { + writeError(w, http.StatusServiceUnavailable, "DNS_API_DISABLED", + "DNS verification needs JWT_SECRET and a configured domain") + return + } + r.dns.handleVerify(w, req) +} diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index 083c380..929e030 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -34,6 +34,9 @@ type Router struct { dkim *adminDkimAPI // tls reports what the certificate watcher sees, rather than a constant. tls *adminTlsAPI + // dns verifies the published records against public resolvers. It lives + // here because this is the process with the resolver and the record spec. + dns *adminDnsAPI // degradedFunc reports a condition that leaves the service running but // not fit for production, the clearest case being a self-signed // certificate standing in for one Traefik never issued @@ -77,6 +80,7 @@ func (r *Router) registerRoutes() { r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover) 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/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/webmail/messages/en.json b/services/webmail/messages/en.json index 69109ea..7062c81 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -46,10 +46,12 @@ "noCertificate": "No certificate is loaded for this host.", "noChecks": "No checks reported.", "noDomains": "No domains have been onboarded yet.", + "notPublished": "Not published", "onboardIntro": "Creates the domain and its standard RFC 2142 aliases, pointed at your address.", "onboardTitle": "Add a domain", "openAdmin": "Admin console", "preflightTitle": "Readiness checks", + "presentProxied": "Present (proxied)", "queueEmpty": "The outbound queue is empty.", "queueSubtitle": "Inspect the outbound queue, retry a job, or cancel one.", "queueTitle": "Outbound queue", @@ -73,6 +75,7 @@ "tracePlaceholder": "Queue ID", "traceTitle": "Trace a message", "unknownSni": "Unknown SNI requests", + "verifyIntro": "Resolves every expected record against public resolvers and reports each one.", "watcherHealthy": "Certificate watcher" }, "auth": { diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index fc039bc..fe00849 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -46,10 +46,12 @@ "noCertificate": "No hay certificado cargado para este host.", "noChecks": "No se informaron verificaciones.", "noDomains": "Aún no se ha dado de alta ningún dominio.", + "notPublished": "No publicado", "onboardIntro": "Crea el dominio y sus alias estándar RFC 2142, apuntados a su dirección.", "onboardTitle": "Añadir un dominio", "openAdmin": "Consola de administración", "preflightTitle": "Verificaciones de preparación", + "presentProxied": "Presente (con proxy)", "queueEmpty": "La cola de salida está vacía.", "queueSubtitle": "Inspeccione la cola de salida, reintente un trabajo o cancele uno.", "queueTitle": "Cola de salida", @@ -73,6 +75,7 @@ "tracePlaceholder": "ID de la cola", "traceTitle": "Rastrear un mensaje", "unknownSni": "Solicitudes SNI desconocidas", + "verifyIntro": "Resuelve cada registro esperado en resolutores públicos e informa uno por uno.", "watcherHealthy": "Monitor de certificado" }, "auth": { diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 1ae1eab..90bd292 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -46,10 +46,12 @@ "noCertificate": "Nenhum certificado carregado para este host.", "noChecks": "Nenhuma verificação relatada.", "noDomains": "Nenhum domínio foi cadastrado ainda.", + "notPublished": "Não publicado", "onboardIntro": "Cria o domínio e os apelidos padrão da RFC 2142, apontados para o seu endereço.", "onboardTitle": "Adicionar um domínio", "openAdmin": "Console de administração", "preflightTitle": "Verificações de prontidão", + "presentProxied": "Presente (com proxy)", "queueEmpty": "A fila de saída está vazia.", "queueSubtitle": "Inspecione a fila de saída, tente reenviar um trabalho ou cancele um.", "queueTitle": "Fila de saída", @@ -73,6 +75,7 @@ "tracePlaceholder": "ID da fila", "traceTitle": "Rastrear uma mensagem", "unknownSni": "Requisições SNI desconhecidas", + "verifyIntro": "Resolve cada registro esperado em servidores DNS públicos e relata um a um.", "watcherHealthy": "Monitor de certificado" }, "auth": { diff --git a/services/webmail/src/app/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx index 7e47d21..919d616 100644 --- a/services/webmail/src/app/(admin)/admin/domains/page.tsx +++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx @@ -24,6 +24,23 @@ import { Button } from "../../../../components/ui/Button"; * instead of drawing a table of green ticks. */ +interface DnsRecordCheck { + type: string; + name: string; + expected: string; + verified: boolean; + detail: string; + proxied: boolean; +} + +interface DnsVerification { + domain: string; + status: string; + verified: number; + total: number; + records: DnsRecordCheck[]; +} + interface Domain { id: string; name: string; @@ -53,6 +70,7 @@ export default function AdminDomainsPage() { const [error, setError] = useState(null); const [reconciling, setReconciling] = useState(false); + const [verification, setVerification] = useState(null); const [onboardDomain, setOnboardDomain] = useState(""); const [onboarding, setOnboarding] = useState(false); @@ -86,22 +104,27 @@ export default function AdminDomainsPage() { const selected = domains.find((d) => d.id === selectedId) ?? null; + // Asks the protocols service to resolve every expected record and report + // each one. It used to post to the auth service, which has no resolver and + // could only re-read the status already in the database. const handleReconcile = async () => { if (!selected) return; setReconciling(true); setNotice(null); setError(null); + setVerification(null); try { - // The real domain id, not the literal string "default-domain" that used - // to be sent here for every domain on the server. - const res = await fetch("/api/v1/admin/domains/reconcile", { + 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. + await fetch("/api/v1/admin/domains/reconcile", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain_id: selected.id }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.message); - setNotice(data.message ?? t("settings.settingsSaved")); + }).catch(() => undefined); await load(); } catch (err) { setError(err instanceof Error && err.message ? err.message : t("errors.serverError")); @@ -262,19 +285,67 @@ export default function AdminDomainsPage() { )) )} - {/* Says what reconciliation currently does. The old copy announced - "Cloudflare sync finished: all 13 DNS records verified" on every - press - including when the request failed - while the endpoint - behind it only writes an audit entry. */} - -
- -

{t("admin.reconcileNotImplemented")}

+ +
+

{t("admin.verifyIntro")}

+
- + + {verification && ( + <> +
+ + {verification.verified} / {verification.total} + + {verification.status} +
+ +
+ + + + + + + + + + {verification.records.map((rec, i) => ( + + + + + + ))} + +
{t("ui.recordName")}{t("ui.expectedValue")}{t("common.status")}
+ {rec.type} {rec.name} + {rec.expected} + {rec.verified ? ( + + + {rec.proxied ? t("admin.presentProxied") : t("admin.dnsStatusVerified")} + + ) : ( + + + {t("admin.notPublished")} + + )} +
+
+ + )}
)}