Files

113 lines
3.4 KiB
Go

package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"time"
"lambdamail/protocols/internal/application/usecase"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
)
// runPreflight executes the environment checks of PLAN.md section 15 and
// exits non-zero when a critical one fails, so the deploy pipeline can gate
// on it.
func runPreflight(cfg config) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
report := usecase.NewPreflightUseCase(buildPreflightDeps(cfg)).Execute(ctx, usecase.PreflightInput{
PrimaryMailHost: cfg.PrimaryMailHost,
MailDomain: cfg.domain(),
PublicIPv4: cfg.PublicIPv4,
PublicIPv6: cfg.PublicIPv6,
DaneEnabled: cfg.OutboundDane,
TLSMode: cfg.TLSMode,
SmarthostSet: cfg.RelayHost != "",
})
fmt.Print(report)
if report.Blocking() {
fmt.Fprintln(os.Stderr, "\npreflight FAILED: at least one critical check did not pass")
os.Exit(1)
}
fmt.Println("\npreflight passed")
}
func buildPreflightDeps(cfg config) usecase.PreflightDeps {
resolver := net.DefaultResolver
deps := usecase.PreflightDeps{
DialPort25: func(ctx context.Context, address string) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
return conn.Close()
},
LookupAddr: resolver.LookupAddr,
LookupHost: resolver.LookupHost,
LookupTXT: resolver.LookupTXT,
FetchMtaSts: func(ctx context.Context, domain string) error {
url := fmt.Sprintf("https://mta-sts.%s/.well-known/mta-sts.txt", domain)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// The certificate is validated on purpose: RFC 8461 section 3.3
// requires the policy to be served under a valid certificate for
// the mta-sts host, and a sender that cannot validate it ignores
// the policy entirely.
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
},
FreeDiskPercent: func() (float64, error) { return freeDiskPercent(cfg.SpoolDir) },
Now: time.Now,
NetworkTime: httpDateTime,
}
// The certificate check only means something when a store is configured;
// otherwise it stays skipped rather than reporting a false pass.
if cfg.TraefikAcmeDir != "" {
if watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost); err == nil {
deps.HasCertificateFor = watcher.HasCertificateFor
}
}
return deps
}
// httpDateTime reads a trusted clock from the Date header of a well-known
// HTTPS endpoint. It is not NTP, but it is accurate to about a second, which
// is enough to catch the drift that breaks DKIM and TOTP.
func httpDateTime(ctx context.Context) (time.Time, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "https://www.cloudflare.com/", nil)
if err != nil {
return time.Time{}, err
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
dateHeader := resp.Header.Get("Date")
if dateHeader == "" {
return time.Time{}, fmt.Errorf("no Date header in the response")
}
return http.ParseTime(dateHeader)
}