Files
LambdaMail/services/protocols/internal/presentation/http/router.go
T

195 lines
6.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
}
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)
}
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
}
}
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
}