Every fix here is a case where the code and the plan disagreed, and the code was wrong. Certificates (section 8.2, 8.4). The parser documented that it skipped expired entries but never checked, so an expired certificate would be served until every handshake failed. LastReload() only advanced when acme.json changed - and it does not change for sixty days - which made the timestamp risk R2 monitors indistinguishable from a watcher gone blind. Expiry is now checked on every pass, LastReload records the check and LastChange the swap, and the <7d/<24h alerts of section 8.4 exist. ARC (section 5). buildArcSealer resolved the DKIM key at startup, before runDnsReconciler had provisioned one. On a first boot ARC was disabled until someone restarted the process, which the log even admitted while offering no way for it to start. A LazySealer binds on first use instead. Health (section 8.4). There was no degraded state at all. /health now reports one - still 200, because restarting cannot fix a certificate Traefik never issued. Bounces. DSNs were sent From postmaster@lambdamail.local, a domain that does not resolve, so the notification was itself undeliverable. They now carry the configured mail host, a Message-ID, and Auto-Submitted so a vacation responder cannot loop against them. Fan-out atomicity (section 6.1). Persist ran one transaction per recipient, so a failure halfway through an alias fan-out left earlier recipients with a copy while the sender was told to retry - and the retry delivered to them twice. PersistAll commits the whole delivery or none of it. Verified against Postgres by reverting the repository to the old behaviour and confirming the new test fails on it. SMTP replies. The use case encoded replies in error text ("550 5.7.1 ...") which the adapter re-parsed by prefix, so rewording a message could turn a permanent refusal into a retry. SmtpRejection carries the decision and the adapter renders it. Repository tests. These only run with TEST_DATABASE_URL, which CI does set - so the job was red: copy and expunge seeded a 68-character digest into a CHAR(64) column, and three others used fixed digests that collide with the UNIQUE index on any re-run. A shared testBlobDigest() helper fixes both; the suite now passes twice in a row against one database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
7.5 KiB
Go
222 lines
7.5 KiB
Go
package httppresentation
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
|
"lambdamail/protocols/internal/domain/valueobject"
|
|
)
|
|
|
|
// mtaStsMaxAge is the policy lifetime advertised to senders. RFC 8461
|
|
// recommends a value in the order of weeks so that an attacker cannot strip
|
|
// the policy by making the endpoint unreachable.
|
|
const mtaStsMaxAge = 604800 // 7 days
|
|
|
|
type Router struct {
|
|
mux *http.ServeMux
|
|
reportUseCase *appusecase.IngestReportsUseCase
|
|
pingFunc func() error
|
|
mtaStsMode valueobject.MtaStsMode
|
|
// 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
|
|
// (PLAN.md section 8.4).
|
|
degradedFunc func() error
|
|
}
|
|
|
|
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
|
|
r := &Router{
|
|
mux: http.NewServeMux(),
|
|
reportUseCase: reportUseCase,
|
|
pingFunc: pingFunc,
|
|
// PLAN.md section 5 ramps MTA-STS from testing to enforce. Starting at
|
|
// enforce means any TLS misconfiguration silently blackholes inbound
|
|
// mail from every sender that honours the policy, with no way to tell
|
|
// from this side - so the safe mode is the default and the operator
|
|
// promotes it once TLS-RPT reports come back clean.
|
|
mtaStsMode: valueobject.MtaStsModeTesting,
|
|
}
|
|
r.registerRoutes()
|
|
return r
|
|
}
|
|
|
|
// SetMtaStsMode selects the mode advertised in the served policy. Callers
|
|
// should promote to enforce only after TLS-RPT confirms senders can negotiate
|
|
// TLS against the published MX.
|
|
func (r *Router) SetMtaStsMode(mode valueobject.MtaStsMode) {
|
|
r.mtaStsMode = mode
|
|
}
|
|
|
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
r.mux.ServeHTTP(w, req)
|
|
}
|
|
|
|
func (r *Router) registerRoutes() {
|
|
r.mux.HandleFunc("/health", r.handleHealth)
|
|
r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts)
|
|
r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt)
|
|
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
|
|
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
|
|
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)
|
|
r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
|
|
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
|
|
}
|
|
|
|
// SetDegradedCheck registers the probe behind the "degraded" health state.
|
|
// Returning an error means the process is serving but something an operator
|
|
// must fix is wrong.
|
|
func (r *Router) SetDegradedCheck(check func() error) {
|
|
r.degradedFunc = check
|
|
}
|
|
|
|
func (r *Router) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
if r.pingFunc != nil {
|
|
if err := r.pingFunc(); err != nil {
|
|
http.Error(w, fmt.Sprintf("unhealthy: %v", err), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Degraded still answers 200: the container is alive and must not be
|
|
// restarted in a loop over a certificate problem a restart cannot fix.
|
|
// The distinction is carried in the body and the header so monitoring can
|
|
// alert on it (PLAN.md section 8.4).
|
|
if r.degradedFunc != nil {
|
|
if err := r.degradedFunc(); err != nil {
|
|
w.Header().Set("X-LambdaMail-Health", "degraded")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, "DEGRADED: %v\n", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("X-LambdaMail-Health", "ok")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK\n"))
|
|
}
|
|
|
|
func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
|
|
host := extractHost(req.Host)
|
|
domainName := strings.TrimPrefix(host, "mta-sts.")
|
|
|
|
policy := valueobject.NewMtaStsPolicy(r.mtaStsMode, []string{fmt.Sprintf("mail.%s", domainName)}, mtaStsMaxAge)
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
// Kept in step with max_age so an HTTP cache cannot outlive the policy it
|
|
// is caching.
|
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", mtaStsMaxAge))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(policy.Format()))
|
|
}
|
|
|
|
func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
|
|
// The contact must be a real mailbox on the mail domain, not on whichever
|
|
// service hostname the request happened to arrive at.
|
|
domainName := baseDomain(extractHost(req.Host))
|
|
expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339)
|
|
|
|
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", domainName, expires)
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(content))
|
|
}
|
|
|
|
func (r *Router) handleThunderbirdAutoconfig(w http.ResponseWriter, req *http.Request) {
|
|
host := extractHost(req.Host)
|
|
domainName := strings.TrimPrefix(host, "autoconfig.")
|
|
|
|
xml := valueobject.BuildThunderbirdAutoconfigXML(domainName, fmt.Sprintf("mail.%s", domainName))
|
|
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(xml))
|
|
}
|
|
|
|
func (r *Router) handleOutlookAutodiscover(w http.ResponseWriter, req *http.Request) {
|
|
host := extractHost(req.Host)
|
|
domainName := strings.TrimPrefix(host, "autodiscover.")
|
|
|
|
xml := valueobject.BuildOutlookAutodiscoverXML(domainName, fmt.Sprintf("mail.%s", domainName), fmt.Sprintf("user@%s", domainName))
|
|
|
|
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(xml))
|
|
}
|
|
|
|
func (r *Router) handleDmarcIngest(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
http.Error(w, "Read body failed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if r.reportUseCase == nil {
|
|
http.Error(w, "Ingestion unconfigured", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := r.reportUseCase.IngestDmarc(req.Context(), body); err != nil {
|
|
http.Error(w, fmt.Sprintf("Parse error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
http.Error(w, "Read body failed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if r.reportUseCase == nil {
|
|
http.Error(w, "Ingestion unconfigured", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := r.reportUseCase.IngestTlsRpt(req.Context(), body); err != nil {
|
|
http.Error(w, fmt.Sprintf("Parse error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
// extractHost strips the optional port from a Host header, handling the
|
|
// bracketed IPv6 literal form as well.
|
|
func extractHost(hostHeader string) string {
|
|
if hostHeader == "" {
|
|
return "example.test"
|
|
}
|
|
if host, _, err := net.SplitHostPort(hostHeader); err == nil && host != "" {
|
|
return host
|
|
}
|
|
return strings.Trim(hostHeader, "[]")
|
|
}
|
|
|
|
// serviceHostPrefixes are the per-service names published under a mail domain
|
|
// (PLAN.md section 7.3); stripping one yields the mail domain itself.
|
|
var serviceHostPrefixes = []string{"mta-sts.", "autoconfig.", "autodiscover.", "mail."}
|
|
|
|
func baseDomain(host string) string {
|
|
for _, prefix := range serviceHostPrefixes {
|
|
if strings.HasPrefix(host, prefix) {
|
|
return strings.TrimPrefix(host, prefix)
|
|
}
|
|
}
|
|
return host
|
|
}
|