#19: merge feature/19-f1-f3-audit-and-ci-hardening into main

This commit is contained in:
Lucas Rayan committed 2026-08-02 08:01:26 -03:00
commit d9fb6737d5
34 files changed
+1327 -222

No files matched your search

+9
View File
@@ -1,3 +1,12 @@
# --- Container images ---
# Published to GHCR by .github/workflows/release.yml, multi-architecture
# (linux/amd64 and linux/arm64). The deploy host pulls these instead of
# compiling: `docker compose pull && docker compose up -d`. Running
# `docker compose build` locally still overrides them with a local build.
# Pin IMAGE_TAG to a released version (e.g. 1.2.3) or a sha- tag to roll back.
IMAGE_NAMESPACE=ghcr.io/lucasrguerra/lambdamail
IMAGE_TAG=latest
# --- Postgres --- # --- Postgres ---
POSTGRES_DB=lambdamail POSTGRES_DB=lambdamail
POSTGRES_USER=lambdamail POSTGRES_USER=lambdamail
+72 -1
View File
@@ -1,18 +1,28 @@
name: CI name: CI
# Runs on every branch and pull request. Publishing lives in release.yml and
# only runs once this workflow has passed.
on: on:
push: push:
branches: [main] branches: ['**']
pull_request: pull_request:
branches: [main]
workflow_call:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
lang-lint: lang-lint:
name: Lint de idioma (R9)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: node scripts/lang-lint.mjs - run: node scripts/lang-lint.mjs
go-protocols: go-protocols:
name: Go (protocols)
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: postgres:
@@ -49,6 +59,7 @@ jobs:
TEST_DATABASE_URL: postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable TEST_DATABASE_URL: postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable
ts-auth: ts-auth:
name: TypeScript (auth)
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -62,6 +73,7 @@ jobs:
- run: npm test - run: npm test
ts-storage: ts-storage:
name: TypeScript (storage)
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -75,6 +87,7 @@ jobs:
- run: npm test - run: npm test
webmail-build: webmail-build:
name: Build (webmail)
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -86,12 +99,70 @@ jobs:
- run: npm install - run: npm install
- run: npm run build - run: npm run build
security:
name: Trivy (dependências, segredos e Dockerfiles)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# A mail server holds credentials for every mailbox it serves, so the
# secret scanner matters as much as the vulnerability one here: a DKIM
# key or a Cloudflare token committed by accident is a full compromise.
- name: Repositório
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scanners: vuln,secret,misconfig
severity: HIGH,CRITICAL
exit-code: '1'
skip-dirs: services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next
image:
name: Imagem (${{ matrix.service }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: [protocols, auth, storage, webmail]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# Built but not pushed: this proves each Dockerfile still works on every
# branch, while publishing stays in release.yml.
- name: Build
uses: docker/build-push-action@v6
with:
context: services/${{ matrix.service }}
push: false
load: true
tags: lambdamail-${{ matrix.service }}:ci
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
- name: Trivy na imagem
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: lambdamail-${{ matrix.service }}:ci
severity: HIGH,CRITICAL
exit-code: '1'
compose-up: compose-up:
name: Stack completa
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [go-protocols, ts-auth, ts-storage, webmail-build] needs: [go-protocols, ts-auth, ts-storage, webmail-build]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: cp .env.example .env - run: cp .env.example .env
# The proxy network is declared external because Coolify owns it on the
# deploy host. Nothing creates it here, and without it compose refuses to
# start the stack at all.
- name: Cria a rede do proxy
run: docker network create "${COOLIFY_PROXY_NETWORK:-coolify}"
# --build makes compose build each service locally and tag it under the
# same name the published image uses, so the stack comes up without ever
# reaching the registry.
- run: docker compose --env-file .env up --build --wait - run: docker compose --env-file .env up --build --wait
- run: docker compose ps - run: docker compose ps
- run: docker compose down -v - run: docker compose down -v
+179
View File
@@ -0,0 +1,179 @@
name: Release
# Publishes the four service images to GHCR after a merge into main, and again
# under semver tags when a v* tag is pushed. The deploy host only pulls, so
# nothing has to be built on the server.
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
env:
REGISTRY: ghcr.io
jobs:
# Nothing is published unless the full suite passes first.
ci:
uses: ./.github/workflows/ci.yml
# GHCR rejects an uppercase path, and this repository is "lucasrguerra/LambdaMail".
# Lowercasing github.repository here keeps the workflow fork-safe instead of
# hardcoding the owner.
prepare:
name: Namespace das imagens
runs-on: ubuntu-latest
outputs:
namespace: ${{ steps.namespace.outputs.value }}
steps:
- id: namespace
run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
# Each architecture is built on a runner of its own kind. QEMU emulation
# would also work, but it makes the Go and Next builds roughly an order of
# magnitude slower.
build:
name: Build ${{ matrix.service }} (${{ matrix.platform }})
needs: [ci, prepare]
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
# Both are axes, so this is a cross product: four services on two
# architectures is eight jobs. Declaring platform only under `include`
# would not multiply anything - include can add keys to existing
# combinations, but it cannot create the combinations themselves.
service: [protocols, auth, storage, webmail]
platform: [linux/amd64, linux/arm64]
include:
- platform: linux/amd64
runner: ubuntu-latest
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Autentica no GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Metadados
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}
# Pushed by digest only: the tags are attached later, once both
# architectures exist and can be joined into a single manifest list.
- name: Build e push por digest
id: build
uses: docker/build-push-action@v6
with:
context: services/${{ matrix.service }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.service }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ matrix.arch }}
- name: Exporta o digest
run: |
mkdir -p /tmp/digests
# The output already carries the `sha256:` prefix; the file name must
# not, since the merge step prepends it again.
digest='${{ steps.build.outputs.digest }}'
touch "/tmp/digests/${digest#sha256:}"
- name: Guarda o digest
uses: actions/upload-artifact@v4
with:
# Slashes are not allowed in artifact names, hence the arch alias.
name: digest-${{ matrix.service }}-${{ matrix.arch }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
publish:
name: Publica ${{ matrix.service }}
needs: [build, prepare]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
service: [protocols, auth, storage, webmail]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Autentica no GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Recupera os digests
uses: actions/download-artifact@v4
with:
pattern: digest-${{ matrix.service }}-*
merge-multiple: true
path: /tmp/digests
# `latest` is what compose pulls. The semver tags only materialise on a
# v* tag push and make it possible to pin or roll back to an exact
# release; the sha tag does the same for any main build.
- name: Metadados
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
type=sha,format=long
# Joins both architectures under one manifest list, so `docker pull`
# resolves to the right one automatically.
- name: Cria o manifesto multi-arquitetura
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \
$(printf '${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}@sha256:%s ' *)
- name: Confere o resultado
run: |
docker buildx imagetools inspect \
"${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}:latest" \
--format '{{json .Manifest}}' \
| jq -r '.manifests[] | select(.platform.os == "linux") | "\(.platform.os)/\(.platform.architecture)"'
- name: Resumo
run: |
{
echo "### ${{ matrix.service }} publicado (linux/amd64 e linux/arm64)"
echo
echo '```'
echo "${{ steps.meta.outputs.tags }}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+41
View File
@@ -0,0 +1,41 @@
# Rate limiting of PLAN.md section 5.2.
#
# This file exists because the module ships enabled but with no rates defined,
# which means it accepts everything: "rspamadm configdump ratelimit" on a
# stock image returns only whitelisted_rcpts. Without explicit buckets there
# is no limit at all, and the defence reads as present while doing nothing.
#
# Each rate is "<burst> / <period>". The bucket leaks over the period, so the
# burst is what a sender may do at once and the period sets the refill rate.
rates {
# Per connecting IP: generous enough for a busy legitimate MTA relaying a
# mailing list, tight enough to matter for a spam cannon.
to_ip = {
bucket = [
{ burst = 100; rate = "60 / 1min"; }
];
}
# Per authenticated sender. This is the containment for a compromised
# account, and it backs up the per-mailbox max_recipients_per_hour enforced
# in ProcessOutboundEmailUseCase - that one is authoritative, this one
# catches the same abuse earlier and without a database round trip.
user = {
bucket = [
{ burst = 50; rate = "30 / 1min"; }
];
}
# Per recipient, to blunt a dictionary attack enumerating local parts.
to = {
bucket = [
{ burst = 20; rate = "10 / 1min"; }
];
}
}
# Mail we authenticated ourselves is not what this limit is aimed at, but a
# compromised account is - so authenticated users stay limited, and only the
# addresses RFC 2142 requires to always be reachable are exempt.
whitelisted_rcpts = "postmaster,mailer-daemon,abuse";
+21 -4
View File
@@ -71,15 +71,28 @@ services:
# ------------------------------------------------------------ RSPAMD # ------------------------------------------------------------ RSPAMD
rspamd: rspamd:
image: ghcr.io/rspamd/rspamd:3.9 image: rspamd/rspamd:3.9
restart: *restart restart: *restart
logging: *default-logging logging: *default-logging
environment: environment:
RSPAMD_REDIS_SERVERS: redis REDIS_PASSWORD: ${REDIS_PASSWORD}
RSPAMD_REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes: volumes:
- ./config/rspamd:/etc/rspamd/local.d:ro - ./config/rspamd:/etc/rspamd/local.d:ro
- rspamd_data:/var/lib/rspamd - rspamd_data:/var/lib/rspamd
# Rspamd does NOT expand environment variables in its UCL config - a
# "${REDIS_PASSWORD}" written into a .conf reaches Redis as that literal
# string. The credential is therefore rendered at startup into override.d,
# which is a tmpfs so the secret never touches the image or a volume.
# Without this, greylisting and ratelimit silently do nothing: both keep
# their state in Redis.
tmpfs:
- /etc/rspamd/override.d:uid=11333,gid=11333,mode=0700
entrypoint:
- sh
- -c
- >-
printf 'servers = "redis:6379";\npassword = "%s";\n' "$$REDIS_PASSWORD"
> /etc/rspamd/override.d/redis.conf && exec /usr/bin/rspamd -f
depends_on: depends_on:
redis: { condition: service_healthy } redis: { condition: service_healthy }
healthcheck: healthcheck:
@@ -91,7 +104,7 @@ services:
# ------------------------------------------------------------ CLAMAV # ------------------------------------------------------------ CLAMAV
clamav: clamav:
image: clamav/clamav:1.3 image: clamav/clamav:1.4
restart: *restart restart: *restart
logging: *default-logging logging: *default-logging
volumes: [clamav_data:/var/lib/clamav] volumes: [clamav_data:/var/lib/clamav]
@@ -105,6 +118,7 @@ services:
# -------------------------------------------------------------- AUTH # -------------------------------------------------------------- AUTH
auth: auth:
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/auth:${IMAGE_TAG:-latest}
build: { context: ./services/auth } build: { context: ./services/auth }
restart: *restart restart: *restart
logging: *default-logging logging: *default-logging
@@ -134,6 +148,7 @@ services:
# ----------------------------------------------------------- STORAGE # ----------------------------------------------------------- STORAGE
storage: storage:
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/storage:${IMAGE_TAG:-latest}
build: { context: ./services/storage } build: { context: ./services/storage }
restart: *restart restart: *restart
logging: *default-logging logging: *default-logging
@@ -163,6 +178,7 @@ services:
# --------------------------------------------------------- PROTOCOLS # --------------------------------------------------------- PROTOCOLS
protocols: protocols:
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/protocols:${IMAGE_TAG:-latest}
build: { context: ./services/protocols } build: { context: ./services/protocols }
restart: *restart restart: *restart
logging: *default-logging logging: *default-logging
@@ -238,6 +254,7 @@ services:
# -------------------------------------------------------------- WEB # -------------------------------------------------------------- WEB
web: web:
image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/webmail:${IMAGE_TAG:-latest}
build: { context: ./services/webmail } build: { context: ./services/webmail }
restart: *restart restart: *restart
logging: *default-logging logging: *default-logging
+25 -3
View File
@@ -1,14 +1,36 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install # npm ci, not npm install: the image must be the lockfile's content, not
# whatever resolves the day it is built.
RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:20-alpine # Production dependencies only. The previous image copied the build stage's
# node_modules wholesale, shipping typescript, eslint and vitest into
# production - most of the CVEs the image scan reported came from that
# tooling, which never runs in the container.
FROM node:20-alpine AS prod-deps
WORKDIR /app WORKDIR /app
COPY --from=build /app/node_modules ./node_modules COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
FROM node:20-alpine
# The node:20-alpine tag lags its Alpine base, so pending security patches
# (openssl, busybox) land only with an explicit upgrade.
RUN apk upgrade --no-cache \
# The runtime only ever runs "node"; npm is not invoked and its own
# bundled dependency tree (sigstore, cross-spawn, glob, ...) is what
# every remaining image CVE came from. Removing it drops the whole
# class instead of chasing each advisory.
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY package.json ./ COPY package.json ./
# The node images ship an unprivileged "node" user (uid 1000).
USER node
EXPOSE 3001 EXPOSE 3001
CMD ["node", "dist/server.js"] CMD ["node", "dist/server.js"]
+20 -1
View File
@@ -6,7 +6,26 @@ COPY . .
RUN CGO_ENABLED=0 go build -o /out/lambdamail-protocols ./cmd/lambdamail-protocols RUN CGO_ENABLED=0 go build -o /out/lambdamail-protocols ./cmd/lambdamail-protocols
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates # libcap provides setcap and is dropped again once the capability is set.
RUN apk add --no-cache ca-certificates libcap
COPY --from=build /out/lambdamail-protocols /app/lambdamail-protocols COPY --from=build /out/lambdamail-protocols /app/lambdamail-protocols
# This process terminates SMTP from the open internet, so it must not run as
# root (PLAN.md section 17, risk R5). Every mail port it binds is below 1024,
# which normally requires root - the file capability grants exactly that one
# privilege to this one binary instead.
RUN setcap 'cap_net_bind_service=+ep' /app/lambdamail-protocols \
&& apk del libcap
RUN addgroup -g 10001 -S lambdamail \
&& adduser -u 10001 -S -G lambdamail lambdamail \
# Created here so a fresh named volume mounted at this path inherits the
# ownership: Docker seeds an empty volume from the image directory.
&& mkdir -p /var/lambdamail/spool \
&& chown -R lambdamail:lambdamail /var/lambdamail
USER lambdamail:lambdamail
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/lambdamail-protocols"] ENTRYPOINT ["/app/lambdamail-protocols"]
@@ -10,6 +10,7 @@ package main
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
@@ -80,7 +81,7 @@ func run(cfg config) {
} }
secretVault, vaultErr := vault.New(cfg.MasterKey) secretVault, vaultErr := vault.New(cfg.MasterKey)
certProvider := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr) certProvider, certDegraded := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr)
// ---------------------------------------------------------------- repos // ---------------------------------------------------------------- repos
mailboxes := postgres.NewMailboxRepository(pool) mailboxes := postgres.NewMailboxRepository(pool)
@@ -131,12 +132,13 @@ func run(cfg config) {
log.Printf("lambdamail-protocols delivering through the smarthost %s", relay.Address()) log.Printf("lambdamail-protocols delivering through the smarthost %s", relay.Address())
} }
// ARC needs a signing key, so it can only be enabled where DKIM is. // ARC needs a signing key, so it can only be enabled where DKIM is. The
// key is resolved on the first message rather than here: on a first boot
// the provisioner below has not run yet, and binding now would leave ARC
// off until someone restarted the process.
if cfg.ArcSealEnabled && dkimRepo != nil { if cfg.ArcSealEnabled && dkimRepo != nil {
if sealer := buildArcSealer(ctx, cfg, dkimRepo); sealer != nil { inboundUC.SetArcSealer(arc.NewLazySealer(dkimRepo, dkim.ParsePrivateKey, cfg.domain(), cfg.PrimaryMailHost))
inboundUC.SetArcSealer(sealer) log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain())
log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain())
}
} }
// --------------------------------------------------------- access UCs // --------------------------------------------------------- access UCs
@@ -161,6 +163,7 @@ func run(cfg config) {
// ------------------------------------------------------------- HTTP API // ------------------------------------------------------------- HTTP API
router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) }) router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) })
router.SetDegradedCheck(certDegraded)
applyMtaStsMode(router, cfg) applyMtaStsMode(router, cfg)
server := &http.Server{ server := &http.Server{
@@ -187,7 +190,11 @@ func run(cfg config) {
// store Traefik maintains and keeps watching it, because the certificate is // store Traefik maintains and keeps watching it, because the certificate is
// replaced every sixty days and a stale one takes every listener down // replaced every sixty days and a stale one takes every listener down
// (PLAN.md section 8). // (PLAN.md section 8).
func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) port.CertProvider { //
// The second return value is the probe behind /health's degraded state: a
// certificate problem does not stop the process, so it has to be visible
// somewhere an operator is already looking (PLAN.md section 8.4).
func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) (port.CertProvider, func() error) {
// Mode B: LambdaMail obtains the certificate itself. This is the only // Mode B: LambdaMail obtains the certificate itself. This is the only
// mode DANE can run in, because it is the only one where the key for the // mode DANE can run in, because it is the only one where the key for the
// next certificate exists before that certificate does // next certificate exists before that certificate does
@@ -204,7 +211,7 @@ func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secr
if err != nil { if err != nil {
log.Fatalf("start self-managed ACME: %v", err) log.Fatalf("start self-managed ACME: %v", err)
} }
return provider return provider, healthy
} }
if cfg.TraefikAcmeDir != "" { if cfg.TraefikAcmeDir != "" {
@@ -215,14 +222,14 @@ func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secr
} }
go watcher.Watch(ctx.Done(), cfg.CertPollInterval) go watcher.Watch(ctx.Done(), cfg.CertPollInterval)
log.Printf("lambdamail-protocols reading TLS certificates from %s (polling every %s)", cfg.TraefikAcmeDir, cfg.CertPollInterval) log.Printf("lambdamail-protocols reading TLS certificates from %s (polling every %s)", cfg.TraefikAcmeDir, cfg.CertPollInterval)
return watcher return watcher, watcherDegradedCheck(watcher, cfg)
} }
log.Printf("WARNING: could not read the ACME store (%v) - falling back", err) log.Printf("WARNING: could not read the ACME store (%v) - falling back", err)
} }
if cfg.StaticCertPath != "" { if cfg.StaticCertPath != "" {
if provider, err := tlsprovider.NewLocalFileCertProvider(cfg.StaticCertPath, cfg.StaticCertKeyPath); err == nil { if provider, err := tlsprovider.NewLocalFileCertProvider(cfg.StaticCertPath, cfg.StaticCertKeyPath); err == nil {
return provider return provider, healthy
} else { } else {
log.Printf("WARNING: could not load the configured TLS certificate: %v", err) log.Printf("WARNING: could not load the configured TLS certificate: %v", err)
} }
@@ -233,7 +240,35 @@ func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secr
if err != nil { if err != nil {
log.Fatalf("generate fallback self-signed certificate: %v", err) log.Fatalf("generate fallback self-signed certificate: %v", err)
} }
return provider return provider, func() error {
return errors.New("serving an ephemeral self-signed certificate: no ACME store could be read, so every client sees an untrusted certificate")
}
}
// healthy is the degraded probe for a certificate source with nothing to
// report.
func healthy() error { return nil }
// watcherDegradedCheck turns the watcher's state into the conditions PLAN.md
// section 8.4 tabulates: a missing certificate for the mail host, a store that
// has stopped being re-read, and an expiry closing in.
func watcherDegradedCheck(watcher *tlsprovider.AcmeCertWatcher, cfg config) func() error {
// Two missed polls is the smallest gap that cannot be a scheduling
// hiccup, and it is the symptom of risk R2: a watcher gone blind.
staleAfter := 3 * cfg.CertPollInterval
return func() error {
if !watcher.HasCertificateFor(cfg.PrimaryMailHost) {
return fmt.Errorf("the ACME store holds no certificate for %s: declare a Traefik router for that host", cfg.PrimaryMailHost)
}
if last := watcher.LastReload(); !last.IsZero() && time.Since(last) > staleAfter {
return fmt.Errorf("the ACME store has not been re-read since %s", last.Format(time.RFC3339))
}
if host, expiry, ok := watcher.EarliestExpiry(); ok && time.Until(expiry) < 24*time.Hour {
return fmt.Errorf("the certificate for %s expires at %s", host, expiry.Format(time.RFC3339))
}
return nil
}
} }
func relayConfig(cfg config) usecase.RelayConfig { func relayConfig(cfg config) usecase.RelayConfig {
@@ -245,29 +280,6 @@ func relayConfig(cfg config) usecase.RelayConfig {
} }
} }
// buildArcSealer signs ARC sets with the domain's RSA DKIM key, which is what
// a verifier resolves through the same selector.
func buildArcSealer(ctx context.Context, cfg config, keys port.DkimKeyRepository) port.ArcSealer {
domain := cfg.domain()
active, err := keys.FindActiveKeys(ctx, domain)
if err != nil || len(active) == 0 {
log.Printf("WARNING: ARC sealing requested but no DKIM key is active for %s yet; it will start once one is provisioned", domain)
return nil
}
for _, key := range active {
signer, err := dkim.ParsePrivateKey(key.PrivateKeyPEM)
if err != nil {
continue
}
return arc.NewSealer(domain, key.Selector, cfg.PrimaryMailHost, signer)
}
log.Printf("WARNING: none of the DKIM keys for %s could be used for ARC", domain)
return nil
}
func buildScanner(cfg config) port.ContentScanner { func buildScanner(cfg config) port.ContentScanner {
var scanners []port.ContentScanner var scanners []port.ContentScanner
if cfg.ClamavAddr != "" { if cfg.ClamavAddr != "" {
@@ -19,12 +19,22 @@ type PersistInboundMessageInput struct {
DMARCResult string DMARCResult string
} }
// InboundMessageRepository durably records an accepted inbound message for // InboundMessageRepository durably records an accepted inbound message: for
// one recipient: allocates the next folder UID, inserts the email_messages // each recipient it allocates the next folder UID, inserts the email_messages
// row, increments the blob's ref_count, and writes the transactional outbox // row, increments the blob's ref_count, and writes the transactional outbox
// event - all in a single transaction (PLAN.md section 6.1, section 9.4). // event (PLAN.md section 6.1, section 9.4).
type InboundMessageRepository interface { type InboundMessageRepository interface {
// Persist returns the allocated IMAP UID on success. The caller may only // PersistAll records every recipient's copy in a single transaction and
// report success to the SMTP client after this returns without error. // returns the allocated IMAP UIDs, in the order the inputs were given.
Persist(ctx context.Context, input PersistInboundMessageInput) (uid int64, err error) //
// One transaction for the whole delivery is a correctness requirement,
// not an optimisation. An alias fanning out to several mailboxes is one
// SMTP transaction with one reply: if the second insert failed after the
// first had committed, the sender would be told to retry and the first
// recipient would receive the message twice. Either all recipients get
// it, or the sender is asked to try again.
//
// The caller may only report success to the SMTP client after this
// returns without error.
PersistAll(ctx context.Context, inputs []PersistInboundMessageInput) (uids []int64, err error)
} }
@@ -229,7 +229,7 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
return return
} }
dsnBytes, isLoop := valueobject.BuildDsnReport(action, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason) dsnBytes, isLoop := valueobject.BuildDsnReport(action, w.localHost, job.EnvelopeFrom, job.EnvelopeTo, job.ID.String(), reason)
if isLoop || dsnBytes == nil { if isLoop || dsnBytes == nil {
return return
} }
@@ -252,7 +252,7 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
} }
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{ _ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@lambdamail.local", Sender: "postmaster@" + w.localHost,
Recipients: targets, Recipients: targets,
RecipientAddresses: addresses, RecipientAddresses: addresses,
Body: bytes.NewReader(dsnBytes), Body: bytes.NewReader(dsnBytes),
@@ -171,7 +171,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
// having published it. Quarantine is left to the spam filter, which // having published it. Quarantine is left to the spam filter, which
// files rather than refuses. // files rather than refuses.
if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" { if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" {
return errors.New("550 5.7.1 DMARC policy rejects this message") return rejectDmarc()
} }
if authResult.AuthenticationResults != "" { if authResult.AuthenticationResults != "" {
@@ -212,11 +212,11 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
if scanRes != nil { if scanRes != nil {
switch scanRes.Verdict { switch scanRes.Verdict {
case valueobject.ScanVerdictVirusReject: case valueobject.ScanVerdictVirusReject:
return errors.New("554 5.7.1 Virus detected: " + scanRes.VirusName) return rejectVirus(scanRes.VirusName)
case valueobject.ScanVerdictSpamReject: case valueobject.ScanVerdictSpamReject:
return errors.New("554 5.7.1 Spam threshold exceeded") return rejectSpam()
case valueobject.ScanVerdictGreylist: case valueobject.ScanVerdictGreylist:
return errors.New("451 4.7.1 Greylisted, please try again later") return deferGreylisted()
case valueobject.ScanVerdictSpamJunk: case valueobject.ScanVerdictSpamJunk:
targetFolder = "Junk" targetFolder = "Junk"
} }
@@ -229,8 +229,14 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
return err return err
} }
// Every recipient is handed to the repository at once so the whole
// delivery commits or none of it does. Persisting them one call at a time
// would mean a failure halfway through leaves the earlier recipients with
// a copy while the sender is told to retry - and the retry delivers to
// them a second time.
persistInputs := make([]port.PersistInboundMessageInput, len(input.Recipients))
for i, recipient := range input.Recipients { for i, recipient := range input.Recipients {
_, err := uc.messages.Persist(ctx, port.PersistInboundMessageInput{ persistInputs[i] = port.PersistInboundMessageInput{
MailboxID: recipient.ID, MailboxID: recipient.ID,
Blob: blob, Blob: blob,
SenderAddress: input.Sender, SenderAddress: input.Sender,
@@ -239,11 +245,16 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
SPFResult: authResult.SPF, SPFResult: authResult.SPF,
DKIMResult: authResult.DKIM, DKIMResult: authResult.DKIM,
DMARCResult: authResult.DMARC, DMARCResult: authResult.DMARC,
})
if err != nil {
return err
} }
}
if _, err := uc.messages.PersistAll(ctx, persistInputs); err != nil {
return err
}
// Notifications happen only after the commit: an IDLE client told about a
// message that then rolled back would show one that does not exist.
for _, recipient := range input.Recipients {
if uc.trackerManager != nil && uc.folders != nil { if uc.trackerManager != nil && uc.folders != nil {
folderRec, err := uc.folders.FindByName(ctx, recipient.ID.String(), targetFolder) folderRec, err := uc.folders.FindByName(ctx, recipient.ID.String(), targetFolder)
if err == nil && folderRec != nil { if err == nil && folderRec != nil {
@@ -105,15 +105,31 @@ func (s *stubBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, e
return s.ref, s.err return s.ref, s.err
} }
// errRepositoryFailure stands in for any persistence error.
var errRepositoryFailure = errors.New("repository is unavailable")
type recordingMessageRepository struct { type recordingMessageRepository struct {
persisted []port.PersistInboundMessageInput persisted []port.PersistInboundMessageInput
nextUID int64 nextUID int64
// failAfter makes PersistAll reject a batch larger than this many
// recipients, standing in for a partial write in the real repository.
failAfter int
} }
func (r *recordingMessageRepository) Persist(_ context.Context, input port.PersistInboundMessageInput) (int64, error) { func (r *recordingMessageRepository) PersistAll(_ context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
r.persisted = append(r.persisted, input) // The real repository commits once for the whole batch, so a failure
r.nextUID++ // records nothing. Mirroring that here keeps the mock honest.
return r.nextUID, nil if r.failAfter > 0 && len(inputs) > r.failAfter {
return nil, errRepositoryFailure
}
uids := make([]int64, 0, len(inputs))
for _, input := range inputs {
r.persisted = append(r.persisted, input)
r.nextUID++
uids = append(uids, r.nextUID)
}
return uids, nil
} }
func TestUseCase_Handle_StoresBlobOnceAndPersistsOncePerRecipient(t *testing.T) { func TestUseCase_Handle_StoresBlobOnceAndPersistsOncePerRecipient(t *testing.T) {
@@ -171,3 +187,31 @@ func TestUseCase_Handle_PropagatesBlobStorageError(t *testing.T) {
t.Fatal("expected error when blob storage fails, got nil") t.Fatal("expected error when blob storage fails, got nil")
} }
} }
// One SMTP transaction gets one reply, so a fan-out delivery has to be
// all-or-nothing: if the sender is told to retry, no recipient may already
// hold a copy, or the retry duplicates it for them.
func TestUseCase_Handle_DoesNotPartiallyDeliverOnFanOut(t *testing.T) {
blobs := &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc123", SizeBytes: 42}}
messages := &recordingMessageRepository{failAfter: 1}
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
recipients := []port.MailboxRecord{
{ID: uuid.New()},
{ID: uuid.New()},
}
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "sender@remote.test",
Recipients: recipients,
RecipientAddresses: []string{"alias@example.test", "alias@example.test"},
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"),
})
if err == nil {
t.Fatal("Handle reported success even though persistence failed")
}
if len(messages.persisted) != 0 {
t.Errorf("%d recipients were left with a copy after a failed delivery; the sender's retry would duplicate it", len(messages.persisted))
}
}
@@ -0,0 +1,55 @@
package usecase
import "fmt"
// SmtpRejection is a use case's decision to refuse a message, carrying the
// reply the SMTP layer must send back.
//
// The decision belongs to the application layer - only it knows that a virus
// is permanent and a greylist is temporary - but the wire format belongs to
// the presentation layer. This type is the seam: the use case names the
// outcome, the adapter renders it. Encoding the reply in the error *text* and
// re-parsing it downstream would make a reworded message silently change the
// code a remote MTA receives, turning a permanent refusal into a retry loop.
type SmtpRejection struct {
// Code is the three-digit reply code (RFC 5321 section 4.2.1).
Code int
// EnhancedCode is the class.subject.detail triplet of RFC 3463.
EnhancedCode [3]int
// Message is the human-readable text, without any code prefix.
Message string
}
func (e *SmtpRejection) Error() string {
return fmt.Sprintf("%d %d.%d.%d %s",
e.Code, e.EnhancedCode[0], e.EnhancedCode[1], e.EnhancedCode[2], e.Message)
}
// IsPermanent reports whether the sender must give up rather than retry.
func (e *SmtpRejection) IsPermanent() bool {
return e.Code >= 500 && e.Code < 600
}
// rejectDmarc refuses a message whose sender publishes p=reject and whose
// DMARC evaluation failed.
func rejectDmarc() *SmtpRejection {
return &SmtpRejection{Code: 550, EnhancedCode: [3]int{5, 7, 1}, Message: "DMARC policy rejects this message"}
}
// rejectVirus refuses an infected message. It is permanent: the same bytes
// will still be infected on a retry.
func rejectVirus(virusName string) *SmtpRejection {
return &SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Virus detected: " + virusName}
}
// rejectSpam refuses a message that scored above the reject threshold.
func rejectSpam() *SmtpRejection {
return &SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Spam threshold exceeded"}
}
// deferGreylisted asks the sender to try again. It is deliberately temporary:
// greylisting works precisely because a legitimate MTA retries and a spam
// cannon does not.
func deferGreylisted() *SmtpRejection {
return &SmtpRejection{Code: 451, EnhancedCode: [3]int{4, 7, 1}, Message: "Greylisted, please try again later"}
}
@@ -13,14 +13,32 @@ const (
DsnActionDelayed DsnAction = "delayed" DsnActionDelayed DsnAction = "delayed"
) )
// BuildDsnReport constructs an RFC 3464 compliant delivery status notification report payload. // defaultReportingMTA is the last resort when no mail host was configured. It
// Returns (payload, isLoopPrevented). If envelopeFrom is empty or "<>", isLoopPrevented is true and payload is nil. // is deliberately obviously wrong rather than plausible, so a misconfiguration
func BuildDsnReport(action DsnAction, envelopeFrom string, envelopeTo string, messageID string, reason string) ([]byte, bool) { // shows up in the report instead of hiding in it.
const defaultReportingMTA = "mail.lambdamail.local"
// BuildDsnReport constructs an RFC 3464 compliant delivery status notification
// report payload.
//
// reportingMTA is this server's own mail host. It has to be the real one: a
// notification whose From and Reporting-MTA name a domain that does not
// resolve is itself undeliverable at the next hop, which loses exactly the
// message the sender most needs to see.
//
// Returns (payload, isLoopPrevented). If envelopeFrom is empty or "<>",
// isLoopPrevented is true and payload is nil.
func BuildDsnReport(action DsnAction, reportingMTA string, envelopeFrom string, envelopeTo string, messageID string, reason string) ([]byte, bool) {
cleanFrom := strings.Trim(strings.TrimSpace(envelopeFrom), "<>") cleanFrom := strings.Trim(strings.TrimSpace(envelopeFrom), "<>")
if cleanFrom == "" { if cleanFrom == "" {
return nil, true // Loop prevention per RFC 5321 section 4.5.5 return nil, true // Loop prevention per RFC 5321 section 4.5.5
} }
reportingMTA = strings.TrimSpace(reportingMTA)
if reportingMTA == "" {
reportingMTA = defaultReportingMTA
}
boundary := fmt.Sprintf("=_Boundary_DSN_%d", time.Now().UnixNano()) boundary := fmt.Sprintf("=_Boundary_DSN_%d", time.Now().UnixNano())
nowStr := time.Now().Format(time.RFC1123Z) nowStr := time.Now().Format(time.RFC1123Z)
@@ -32,10 +50,15 @@ func BuildDsnReport(action DsnAction, envelopeFrom string, envelopeTo string, me
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: Mail Delivery Subsystem <postmaster@lambdamail.local>\r\n")) sb.WriteString(fmt.Sprintf("From: Mail Delivery Subsystem <postmaster@%s>\r\n", reportingMTA))
sb.WriteString(fmt.Sprintf("To: <%s>\r\n", cleanFrom)) sb.WriteString(fmt.Sprintf("To: <%s>\r\n", cleanFrom))
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
sb.WriteString(fmt.Sprintf("Date: %s\r\n", nowStr)) sb.WriteString(fmt.Sprintf("Date: %s\r\n", nowStr))
sb.WriteString(fmt.Sprintf("Message-ID: <dsn-%s@%s>\r\n", messageID, reportingMTA))
// RFC 3834 section 5: an automatic notification has to say so, or a
// vacation responder on the other side answers it and the two bounce back
// and forth forever.
sb.WriteString("Auto-Submitted: auto-replied\r\n")
sb.WriteString(fmt.Sprintf("Content-Type: multipart/report; report-type=delivery-status; boundary=\"%s\"\r\n\r\n", boundary)) sb.WriteString(fmt.Sprintf("Content-Type: multipart/report; report-type=delivery-status; boundary=\"%s\"\r\n\r\n", boundary))
// Part 1: Human-readable narrative // Part 1: Human-readable narrative
@@ -50,7 +73,7 @@ func BuildDsnReport(action DsnAction, envelopeFrom string, envelopeTo string, me
// Part 2: Machine-readable delivery-status // Part 2: Machine-readable delivery-status
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary)) sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
sb.WriteString("Content-Type: message/delivery-status\r\n\r\n") sb.WriteString("Content-Type: message/delivery-status\r\n\r\n")
sb.WriteString("Reporting-MTA: dns; mail.lambdamail.local\r\n") sb.WriteString(fmt.Sprintf("Reporting-MTA: dns; %s\r\n", reportingMTA))
sb.WriteString(fmt.Sprintf("Arrival-Date: %s\r\n\r\n", nowStr)) sb.WriteString(fmt.Sprintf("Arrival-Date: %s\r\n\r\n", nowStr))
sb.WriteString(fmt.Sprintf("Final-Recipient: rfc822; %s\r\n", envelopeTo)) sb.WriteString(fmt.Sprintf("Final-Recipient: rfc822; %s\r\n", envelopeTo))
@@ -6,19 +6,19 @@ import (
) )
func TestBuildDsnReport_PreventsBounceLoopForEmptySender(t *testing.T) { func TestBuildDsnReport_PreventsBounceLoopForEmptySender(t *testing.T) {
_, isLoop := BuildDsnReport(DsnActionFailed, "", "user@external.test", "msg123", "550 User unknown") _, isLoop := BuildDsnReport(DsnActionFailed, "mail.local.test", "", "user@external.test", "msg123", "550 User unknown")
if !isLoop { if !isLoop {
t.Errorf("expected loop prevention for empty sender") t.Errorf("expected loop prevention for empty sender")
} }
_, isLoop = BuildDsnReport(DsnActionFailed, "<>", "user@external.test", "msg123", "550 User unknown") _, isLoop = BuildDsnReport(DsnActionFailed, "mail.local.test", "<>", "user@external.test", "msg123", "550 User unknown")
if !isLoop { if !isLoop {
t.Errorf("expected loop prevention for <> sender") t.Errorf("expected loop prevention for <> sender")
} }
} }
func TestBuildDsnReport_GeneratesValidMIME(t *testing.T) { func TestBuildDsnReport_GeneratesValidMIME(t *testing.T) {
payload, isLoop := BuildDsnReport(DsnActionFailed, "sender@local.test", "recipient@remote.test", "msg123", "550 User unknown") payload, isLoop := BuildDsnReport(DsnActionFailed, "mail.local.test", "sender@local.test", "recipient@remote.test", "msg123", "550 User unknown")
if isLoop || payload == nil { if isLoop || payload == nil {
t.Fatalf("unexpected loop signal for valid sender") t.Fatalf("unexpected loop signal for valid sender")
} }
@@ -34,3 +34,31 @@ func TestBuildDsnReport_GeneratesValidMIME(t *testing.T) {
t.Errorf("missing Diagnostic-Code: %s", raw) t.Errorf("missing Diagnostic-Code: %s", raw)
} }
} }
// A notification whose From and Reporting-MTA name a domain that does not
// resolve is itself undeliverable, which loses the very message the sender
// most needs to see.
func TestBuildDsnReport_UsesTheConfiguredMailHost(t *testing.T) {
payload, _ := BuildDsnReport(DsnActionFailed, "mail.example.test", "sender@example.test", "recipient@remote.test", "job-1", "550 User unknown")
raw := string(payload)
if !strings.Contains(raw, "From: Mail Delivery Subsystem <postmaster@mail.example.test>") {
t.Errorf("From does not use the configured mail host: %s", raw)
}
if !strings.Contains(raw, "Reporting-MTA: dns; mail.example.test") {
t.Errorf("Reporting-MTA does not use the configured mail host: %s", raw)
}
if strings.Contains(raw, "lambdamail.local") {
t.Errorf("the placeholder host leaked into the report: %s", raw)
}
}
// RFC 3834 section 5: without Auto-Submitted a vacation responder answers the
// bounce and the two loop against each other.
func TestBuildDsnReport_MarksItselfAutomatic(t *testing.T) {
payload, _ := BuildDsnReport(DsnActionDelayed, "mail.example.test", "sender@example.test", "recipient@remote.test", "job-1", "451 deferred")
if !strings.Contains(string(payload), "Auto-Submitted: auto-replied") {
t.Errorf("the report is not marked as automatic: %s", payload)
}
}
@@ -0,0 +1,75 @@
package arc
import (
"context"
"crypto"
"sync"
"lambdamail/protocols/internal/application/port"
)
// KeyParser turns a stored private key into a signer. It is injected so this
// package stays independent of how DKIM keys are encoded.
type KeyParser func(privateKeyPEM []byte) (crypto.Signer, error)
// LazySealer resolves the signing key on first use instead of at construction.
//
// The composition root builds the sealer before the DKIM provisioner has run,
// so on a first boot no key exists yet. Binding to that empty result would
// disable ARC until the process was restarted by hand - the failure PLAN.md
// section 5 cannot tolerate, because the whole point of ARC is that it is
// always applied, not applied when the ordering happened to work out.
type LazySealer struct {
keys port.DkimKeyRepository
parse KeyParser
domain string
authServID string
mu sync.Mutex
resolved port.ArcSealer
}
// NewLazySealer returns a sealer that binds to the domain's active DKIM key
// the first time a message needs sealing.
func NewLazySealer(keys port.DkimKeyRepository, parse KeyParser, domain, authServID string) *LazySealer {
return &LazySealer{keys: keys, parse: parse, domain: domain, authServID: authServID}
}
// Seal delegates to the underlying sealer, resolving it if needed. Until a key
// exists the message is returned untouched: an unsealed message is delivered,
// which is strictly better than a rejected one.
func (l *LazySealer) Seal(ctx context.Context, message []byte, authResult port.InboundAuthResult) ([]byte, error) {
sealer, err := l.sealer(ctx)
if err != nil || sealer == nil {
return message, err
}
return sealer.Seal(ctx, message, authResult)
}
func (l *LazySealer) sealer(ctx context.Context) (port.ArcSealer, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.resolved != nil {
return l.resolved, nil
}
active, err := l.keys.FindActiveKeys(ctx, l.domain)
if err != nil {
return nil, err
}
for _, key := range active {
signer, err := l.parse(key.PrivateKeyPEM)
if err != nil {
continue
}
l.resolved = NewSealer(l.domain, key.Selector, l.authServID, signer)
return l.resolved, nil
}
// Still no usable key. Leaving resolved nil means the next message tries
// again, which is what turns "not provisioned yet" into a delay rather
// than a permanent loss of sealing.
return nil, nil
}
@@ -0,0 +1,108 @@
package arc
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"strings"
"sync"
"testing"
"lambdamail/protocols/internal/application/port"
)
// stubKeyRepo serves whatever the test decides is provisioned at that moment.
type stubKeyRepo struct {
mu sync.Mutex
keys []port.DkimSigningKey
// calls counts lookups, to prove the resolved key is cached.
calls int
}
func (s *stubKeyRepo) FindActiveKeys(_ context.Context, _ string) ([]port.DkimSigningKey, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls++
return s.keys, nil
}
func (s *stubKeyRepo) provision(t *testing.T) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
s.mu.Lock()
defer s.mu.Unlock()
s.keys = []port.DkimSigningKey{{
DomainName: "example.test",
Selector: "lmail",
Algorithm: "rsa2048",
PrivateKeyPEM: pemBytes,
}}
}
func parseTestKey(pemBytes []byte) (crypto.Signer, error) {
block, _ := pem.Decode(pemBytes)
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
const testMessage = "From: sender@remote.test\r\n" +
"To: user@example.test\r\n" +
"Subject: hello\r\n" +
"\r\n" +
"body\r\n"
// The composition root builds the sealer before the DKIM provisioner has run.
// Binding at construction would leave ARC off until the next restart.
func TestLazySealer_StartsSealingOnceAKeyIsProvisioned(t *testing.T) {
repo := &stubKeyRepo{}
sealer := NewLazySealer(repo, parseTestKey, "example.test", "mail.example.test")
ctx := context.Background()
authResult := port.InboundAuthResult{SPF: port.AuthResultPass}
// No key yet: the message goes through unsealed rather than failing.
out, err := sealer.Seal(ctx, []byte(testMessage), authResult)
if err != nil {
t.Fatalf("Seal without a key: %v", err)
}
if strings.Contains(string(out), "ARC-Seal") {
t.Fatal("a message was sealed before any key existed")
}
repo.provision(t)
out, err = sealer.Seal(ctx, []byte(testMessage), authResult)
if err != nil {
t.Fatalf("Seal after provisioning: %v", err)
}
if !strings.Contains(string(out), "ARC-Seal") {
t.Fatal("sealing never started after the key was provisioned")
}
}
// Once bound, the key is not looked up again on every message.
func TestLazySealer_CachesTheResolvedKey(t *testing.T) {
repo := &stubKeyRepo{}
repo.provision(t)
sealer := NewLazySealer(repo, parseTestKey, "example.test", "mail.example.test")
ctx := context.Background()
for range 3 {
if _, err := sealer.Seal(ctx, []byte(testMessage), port.InboundAuthResult{}); err != nil {
t.Fatalf("Seal: %v", err)
}
}
if repo.calls != 1 {
t.Errorf("FindActiveKeys called %d times, want 1", repo.calls)
}
}
@@ -42,7 +42,7 @@ func TestCopyRepository_CopyMessages_AllocatesNewUIDsAndPreservesFlags(t *testin
} }
blobID := uuid.New() blobID := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes, ref_count) VALUES ($1, $2, 'local', '/tmp/x', 10, 1)`, _, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes, ref_count) VALUES ($1, $2, 'local', '/tmp/x', 10, 1)`,
blobID, uuid.New().String()+uuid.New().String()[:32]) blobID, testBlobDigest())
if err != nil { if err != nil {
t.Fatalf("seed blob: %v", err) t.Fatalf("seed blob: %v", err)
} }
@@ -35,7 +35,7 @@ func TestExpungeRepository_Expunge_SetsExpungedAtAndDecrementsCounters(t *testin
} }
blobID := uuid.New() blobID := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`, _, 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, uuid.New().String()+uuid.New().String()[:32]) blobID, testBlobDigest())
if err != nil { if err != nil {
t.Fatalf("seed blob: %v", err) t.Fatalf("seed blob: %v", err)
} }
@@ -37,7 +37,7 @@ func TestFlagRepository_SetFlags_AddThenDelThenSet(t *testing.T) {
} }
blobID := uuid.New() blobID := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`, _, 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, "1111110000000000000000000000000000000000000000000000000000000000"[:64]) blobID, testBlobDigest())
if err != nil { if err != nil {
t.Fatalf("seed blob: %v", err) t.Fatalf("seed blob: %v", err)
} }
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/application/port"
@@ -21,13 +22,37 @@ func NewInboundMessageRepository(pool *pgxpool.Pool) *InboundMessageRepository {
return &InboundMessageRepository{pool: pool} return &InboundMessageRepository{pool: pool}
} }
func (r *InboundMessageRepository) Persist(ctx context.Context, input port.PersistInboundMessageInput) (int64, error) { // PersistAll records every recipient's copy in one transaction, so a delivery
// that fans out is all-or-nothing and a retried SMTP transaction cannot leave
// an earlier recipient with a duplicate.
func (r *InboundMessageRepository) PersistAll(ctx context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
if len(inputs) == 0 {
return nil, nil
}
tx, err := r.pool.Begin(ctx) tx, err := r.pool.Begin(ctx)
if err != nil { if err != nil {
return 0, err return nil, err
} }
defer tx.Rollback(ctx) // no-op if Commit succeeds defer tx.Rollback(ctx) // no-op if Commit succeeds
uids := make([]int64, 0, len(inputs))
for _, input := range inputs {
uid, err := persistOne(ctx, tx, input)
if err != nil {
return nil, err
}
uids = append(uids, uid)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return uids, nil
}
// persistOne writes one recipient's copy inside the caller's transaction.
func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessageInput) (int64, error) {
targetFolder := input.TargetFolderName targetFolder := input.TargetFolderName
if targetFolder == "" { if targetFolder == "" {
targetFolder = "INBOX" targetFolder = "INBOX"
@@ -35,7 +60,7 @@ func (r *InboundMessageRepository) Persist(ctx context.Context, input port.Persi
var folderID string var folderID string
var uid int64 var uid int64
err = tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
SELECT id, uid_next FROM folders SELECT id, uid_next FROM folders
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2)) WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
ORDER BY special_use IS NOT NULL DESC ORDER BY special_use IS NOT NULL DESC
@@ -84,8 +109,5 @@ func (r *InboundMessageRepository) Persist(ctx context.Context, input port.Persi
return 0, fmt.Errorf("insert outbox event: %w", err) return 0, fmt.Errorf("insert outbox event: %w", err)
} }
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit: %w", err)
}
return uid, nil return uid, nil
} }
@@ -38,7 +38,7 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
blobID := uuid.New() blobID := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`, _, 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, "deadbeef00000000000000000000000000000000000000000000000000000000"[:64]) blobID, testBlobDigest())
if err != nil { if err != nil {
t.Fatalf("seed blob: %v", err) t.Fatalf("seed blob: %v", err)
} }
@@ -46,22 +46,23 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
repo := NewInboundMessageRepository(pool) repo := NewInboundMessageRepository(pool)
blob := port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10} blob := port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10}
firstUID, err := repo.Persist(ctx, port.PersistInboundMessageInput{ firstUIDs, err := repo.PersistAll(ctx, []port.PersistInboundMessageInput{{
MailboxID: mailboxID, Blob: blob, MailboxID: mailboxID, Blob: blob,
SenderAddress: "sender@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid", SenderAddress: "sender@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid",
SPFResult: "none", DKIMResult: "none", DMARCResult: "none", SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
}) }})
if err != nil { if err != nil {
t.Fatalf("first Persist: %v", err) t.Fatalf("first PersistAll: %v", err)
} }
secondUID, err := repo.Persist(ctx, port.PersistInboundMessageInput{ secondUIDs, err := repo.PersistAll(ctx, []port.PersistInboundMessageInput{{
MailboxID: mailboxID, Blob: blob, MailboxID: mailboxID, Blob: blob,
SenderAddress: "sender2@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid", SenderAddress: "sender2@example.test", RecipientAddress: "user@msgrepo-test-" + domainID.String() + ".invalid",
SPFResult: "none", DKIMResult: "none", DMARCResult: "none", SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
}) }})
if err != nil { if err != nil {
t.Fatalf("second Persist: %v", err) t.Fatalf("second PersistAll: %v", err)
} }
firstUID, secondUID := firstUIDs[0], secondUIDs[0]
if secondUID <= firstUID { if secondUID <= firstUID {
t.Errorf("secondUID (%d) must be strictly greater than firstUID (%d) - RFC 3501 section 2.3.1.1", secondUID, firstUID) t.Errorf("secondUID (%d) must be strictly greater than firstUID (%d) - RFC 3501 section 2.3.1.1", secondUID, firstUID)
} }
@@ -71,7 +72,7 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
t.Fatalf("query ref_count: %v", err) t.Fatalf("query ref_count: %v", err)
} }
if refCount != 2 { if refCount != 2 {
t.Errorf("ref_count = %d, want 2 after two Persist calls referencing the same blob", refCount) t.Errorf("ref_count = %d, want 2 after two PersistAll calls referencing the same blob", refCount)
} }
var unreadCount, totalCount int var unreadCount, totalCount int
@@ -79,10 +80,10 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
t.Fatalf("query folder counters: %v", err) t.Fatalf("query folder counters: %v", err)
} }
if unreadCount != 2 { if unreadCount != 2 {
t.Errorf("folder unread_count = %d, want 2 after two Persist calls", unreadCount) t.Errorf("folder unread_count = %d, want 2 after two PersistAll calls", unreadCount)
} }
if totalCount != 2 { if totalCount != 2 {
t.Errorf("folder total_count = %d, want 2 after two Persist calls", totalCount) t.Errorf("folder total_count = %d, want 2 after two PersistAll calls", totalCount)
} }
var usedBytes int64 var usedBytes int64
@@ -90,7 +91,7 @@ func TestInboundMessageRepository_Persist_AllocatesSequentialUIDs(t *testing.T)
t.Fatalf("query mailbox used_bytes: %v", err) t.Fatalf("query mailbox used_bytes: %v", err)
} }
if usedBytes != blob.SizeBytes*2 { if usedBytes != blob.SizeBytes*2 {
t.Errorf("mailbox used_bytes = %d, want %d (two Persist calls of %d bytes each)", usedBytes, blob.SizeBytes*2, blob.SizeBytes) t.Errorf("mailbox used_bytes = %d, want %d (two PersistAll calls of %d bytes each)", usedBytes, blob.SizeBytes*2, blob.SizeBytes)
} }
} }
@@ -118,18 +119,88 @@ func TestInboundMessageRepository_Persist_ReturnsErrorWhenNoInboxFolderExists(t
blobID := uuid.New() blobID := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`, _, 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, "cafebabe00000000000000000000000000000000000000000000000000000000"[:64]) blobID, testBlobDigest())
if err != nil { if err != nil {
t.Fatalf("seed blob: %v", err) t.Fatalf("seed blob: %v", err)
} }
repo := NewInboundMessageRepository(pool) repo := NewInboundMessageRepository(pool)
_, err = repo.Persist(ctx, port.PersistInboundMessageInput{ _, err = repo.PersistAll(ctx, []port.PersistInboundMessageInput{{
MailboxID: mailboxID, Blob: port.BlobRef{ID: blobID, SizeBytes: 10}, MailboxID: mailboxID, Blob: port.BlobRef{ID: blobID, SizeBytes: 10},
SenderAddress: "sender@example.test", RecipientAddress: "nofolder@example.test", SenderAddress: "sender@example.test", RecipientAddress: "nofolder@example.test",
SPFResult: "none", DKIMResult: "none", DMARCResult: "none", SPFResult: "none", DKIMResult: "none", DMARCResult: "none",
}) }})
if err == nil { if err == nil {
t.Fatal("expected an error when the mailbox has no INBOX folder, got nil") t.Fatal("expected an error when the mailbox has no INBOX folder, got nil")
} }
} }
// An alias fanning out is one SMTP transaction with one reply. If a later
// recipient's insert fails, the earlier ones must not remain: the sender is
// told to retry, and the retry would deliver to them a second time.
func TestInboundMessageRepository_PersistAll_RollsBackEveryRecipientOnFailure(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
domainID := uuid.New()
domainName := "fanout-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)
// The first mailbox has an INBOX, the second deliberately does not, so
// persisting the pair fails on the second entry.
goodMailboxID, badMailboxID := uuid.New(), uuid.New()
for i, id := range []uuid.UUID{goodMailboxID, badMailboxID} {
local := []string{"good", "bad"}[i]
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, $3, $4, '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG')`,
id, domainID, local, local+"@"+domainName); err != nil {
t.Fatalf("seed mailbox %s: %v", local, err)
}
}
folderID := uuid.New()
if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`,
folderID, goodMailboxID); err != nil {
t.Fatalf("seed folder: %v", err)
}
blobID := uuid.New()
blobDigest := testBlobDigest()
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, blobDigest); err != nil {
t.Fatalf("seed blob: %v", err)
}
defer pool.Exec(ctx, `DELETE FROM message_blobs WHERE id = $1`, blobID)
blob := port.BlobRef{ID: blobID, SHA256: blobDigest, SizeBytes: 10}
repo := NewInboundMessageRepository(pool)
_, err := repo.PersistAll(ctx, []port.PersistInboundMessageInput{
{MailboxID: goodMailboxID, Blob: blob, SenderAddress: "sender@remote.test", RecipientAddress: "alias@" + domainName,
SPFResult: "none", DKIMResult: "none", DMARCResult: "none"},
{MailboxID: badMailboxID, Blob: blob, SenderAddress: "sender@remote.test", RecipientAddress: "alias@" + domainName,
SPFResult: "none", DKIMResult: "none", DMARCResult: "none"},
})
if err == nil {
t.Fatal("PersistAll succeeded even though the second recipient has no INBOX")
}
var messageCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM email_messages WHERE mailbox_id = $1`, goodMailboxID).Scan(&messageCount); err != nil {
t.Fatalf("count messages: %v", err)
}
if messageCount != 0 {
t.Errorf("the first recipient kept %d message(s) after a failed fan-out; the sender's retry would duplicate them", messageCount)
}
var refCount int
if err := pool.QueryRow(ctx, `SELECT ref_count FROM message_blobs WHERE id = $1`, blobID).Scan(&refCount); err != nil {
t.Fatalf("query ref_count: %v", err)
}
if refCount != 0 {
t.Errorf("ref_count = %d after a rolled-back delivery, want 0", refCount)
}
}
@@ -3,6 +3,7 @@ package postgres
import ( import (
"context" "context"
"os" "os"
"strings"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
@@ -309,3 +310,10 @@ func TestMailboxRepository_ResolveDeliveryTargets_SkipsAliasDestinationThatIsNot
t.Errorf("targets = %+v, want empty slice - external forwarding destinations are not internal mailboxes and outbound delivery does not exist yet", targets) t.Errorf("targets = %+v, want empty slice - external forwarding destinations are not internal mailboxes and outbound delivery does not exist yet", targets)
} }
} }
// testBlobDigest produces a unique 64-character value for message_blobs'
// content_sha256, which is UNIQUE in the schema. A hardcoded digest makes the
// test pass exactly once per database and fail on every re-run.
func testBlobDigest() string {
return strings.ReplaceAll(uuid.New().String()+uuid.New().String(), "-", "")[:64]
}
@@ -35,7 +35,7 @@ func TestMessageQueryRepository_ListMessages_ReturnsRowsOrderedByUID(t *testing.
} }
blobID := uuid.New() blobID := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 42)`, _, err = pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 42)`,
blobID, "abcdef0000000000000000000000000000000000000000000000000000000000"[:64]) blobID, testBlobDigest())
if err != nil { if err != nil {
t.Fatalf("seed blob: %v", err) t.Fatalf("seed blob: %v", err)
} }
@@ -3,6 +3,7 @@ package tlsprovider
import ( import (
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -37,8 +38,15 @@ type AcmeCertWatcher struct {
// using a consistent map. // using a consistent map.
certificates atomic.Pointer[map[string]*tls.Certificate] certificates atomic.Pointer[map[string]*tls.Certificate]
lastHash atomic.Pointer[[32]byte] lastHash atomic.Pointer[[32]byte]
// lastReloadAt is the last time the store was successfully read and found
// usable, whether or not its content had changed. It is what PLAN.md risk
// R2 monitors: acme.json normally sits untouched for sixty days, so a
// timestamp that only advanced on change could not distinguish a healthy
// idle watcher from one that has gone blind.
lastReloadAt atomic.Pointer[time.Time] lastReloadAt atomic.Pointer[time.Time]
// lastChangeAt is the last time the certificates themselves were swapped.
lastChangeAt atomic.Pointer[time.Time]
// unknownSNI counts handshakes for names we hold no certificate for; it // unknownSNI counts handshakes for names we hold no certificate for; it
// backs the tls_unknown_sni_total metric of PLAN.md section 8.4. // backs the tls_unknown_sni_total metric of PLAN.md section 8.4.
@@ -110,31 +118,80 @@ func (w *AcmeCertWatcher) reload() error {
return fmt.Errorf("read %s: %w", w.path, err) return fmt.Errorf("read %s: %w", w.path, err)
} }
hash := sha256.Sum256(raw)
if previous := w.lastHash.Load(); previous != nil && *previous == hash {
return nil
}
certificates, err := parseAcmeStore(raw)
if err != nil {
return err
}
if len(certificates) == 0 {
return fmt.Errorf("acme watcher: %s holds no usable certificate", w.path)
}
w.certificates.Store(&certificates)
w.lastHash.Store(&hash)
now := time.Now() now := time.Now()
w.lastReloadAt.Store(&now)
hash := sha256.Sum256(raw)
if previous := w.lastHash.Load(); previous == nil || *previous != hash {
certificates, err := parseAcmeStore(raw, now)
if err != nil {
return err
}
if len(certificates) == 0 {
return fmt.Errorf("acme watcher: %s holds no usable certificate", w.path)
}
w.certificates.Store(&certificates)
w.lastHash.Store(&hash)
w.lastChangeAt.Store(&now)
}
// Expiry is checked on every pass, not only on change: a certificate ages
// out while the file it lives in stays byte-identical.
w.reportExpiry(now)
w.lastReloadAt.Store(&now)
return nil return nil
} }
// reportExpiry raises the alerts of PLAN.md section 8.4 for certificates that
// are close to expiring. Renewal is Traefik's job, so all this side can do is
// make the failure loud before it becomes an outage.
func (w *AcmeCertWatcher) reportExpiry(now time.Time) {
host, expiry, ok := w.EarliestExpiry()
if !ok {
return
}
switch remaining := expiry.Sub(now); {
case remaining <= 0:
log.Printf("CRITICAL: the certificate for %s expired at %s and Traefik has not replaced it", host, expiry.Format(time.RFC3339))
case remaining < 24*time.Hour:
log.Printf("CRITICAL: the certificate for %s expires in %s", host, remaining.Round(time.Minute))
case remaining < 7*24*time.Hour:
log.Printf("WARNING: the certificate for %s expires in %s", host, remaining.Round(time.Hour))
}
}
// EarliestExpiry reports the soonest expiry across the loaded certificates,
// which is the one that decides when the deployment breaks.
func (w *AcmeCertWatcher) EarliestExpiry() (string, time.Time, bool) {
loaded := w.certificates.Load()
if loaded == nil {
return "", time.Time{}, false
}
var (
soonestHost string
soonest time.Time
)
for host, cert := range *loaded {
if cert.Leaf == nil {
continue
}
if soonest.IsZero() || cert.Leaf.NotAfter.Before(soonest) {
soonest, soonestHost = cert.Leaf.NotAfter, host
}
}
if soonest.IsZero() {
return "", time.Time{}, false
}
return soonestHost, soonest, true
}
// parseAcmeStore walks every resolver in the file. A single unusable entry - // parseAcmeStore walks every resolver in the file. A single unusable entry -
// an expired certificate, or one whose key does not match - is skipped rather // an expired certificate, or one whose key does not match - is skipped rather
// than failing the whole load, so one stale domain cannot take the server down. // than failing the whole load, so one stale domain cannot take the server down.
func parseAcmeStore(raw []byte) (map[string]*tls.Certificate, error) { func parseAcmeStore(raw []byte, now time.Time) (map[string]*tls.Certificate, error) {
var store acmeFile var store acmeFile
if err := json.Unmarshal(raw, &store); err != nil { if err := json.Unmarshal(raw, &store); err != nil {
return nil, fmt.Errorf("parse acme store: %w", err) return nil, fmt.Errorf("parse acme store: %w", err)
@@ -161,6 +218,23 @@ func parseAcmeStore(raw []byte) (map[string]*tls.Certificate, error) {
continue continue
} }
// X509KeyPair leaves Leaf nil. Parsing it here is what makes the
// expiry checks below and in reportExpiry possible at all.
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil {
log.Printf("acme watcher: skipping %s in resolver %q: leaf certificate is unparseable: %v", entry.Domain.Main, resolverName, err)
continue
}
// An expired entry is worse than a missing one: it would be served
// in a handshake every peer then rejects. Skipping it lets the SNI
// fallback or the self-signed provider take over instead
// (PLAN.md section 8.2, trap 3).
if now.After(leaf.NotAfter) {
log.Printf("acme watcher: skipping %s in resolver %q: the certificate expired at %s", entry.Domain.Main, resolverName, leaf.NotAfter.Format(time.RFC3339))
continue
}
pair.Leaf = leaf
for _, name := range append([]string{entry.Domain.Main}, entry.Domain.SANs...) { for _, name := range append([]string{entry.Domain.Main}, entry.Domain.SANs...) {
if name != "" { if name != "" {
certificates[strings.ToLower(name)] = &pair certificates[strings.ToLower(name)] = &pair
@@ -210,9 +284,9 @@ func wildcardOf(name string) string {
return "" return ""
} }
// LastReload reports when the store was last successfully refreshed. A // LastReload reports when the store was last read successfully, changed or
// timestamp that stops advancing is the signal that the watcher went blind // not. A timestamp that stops advancing is the signal that the watcher went
// (PLAN.md risk R2). // blind (PLAN.md risk R2).
func (w *AcmeCertWatcher) LastReload() time.Time { func (w *AcmeCertWatcher) LastReload() time.Time {
if t := w.lastReloadAt.Load(); t != nil { if t := w.lastReloadAt.Load(); t != nil {
return *t return *t
@@ -220,6 +294,15 @@ func (w *AcmeCertWatcher) LastReload() time.Time {
return time.Time{} return time.Time{}
} }
// LastChange reports when the served certificates were last swapped, which is
// how a renewal that never arrived is detected.
func (w *AcmeCertWatcher) LastChange() time.Time {
if t := w.lastChangeAt.Load(); t != nil {
return *t
}
return time.Time{}
}
// UnknownSNICount backs the tls_unknown_sni_total metric. // UnknownSNICount backs the tls_unknown_sni_total metric.
func (w *AcmeCertWatcher) UnknownSNICount() int64 { func (w *AcmeCertWatcher) UnknownSNICount() int64 {
return w.unknownSNI.Load() return w.unknownSNI.Load()
@@ -20,6 +20,13 @@ import (
// makeCertPEM issues a self-signed certificate for the given names. // makeCertPEM issues a self-signed certificate for the given names.
func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) { func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) {
t.Helper() t.Helper()
return makeCertPEMWithValidity(t, -time.Hour, 90*24*time.Hour, names...)
}
// makeCertPEMWithValidity issues a certificate whose validity window is given
// relative to now, so a test can produce an expired or nearly expired one.
func makeCertPEMWithValidity(t *testing.T, notBefore, notAfter time.Duration, names ...string) (certPEM, keyPEM []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
@@ -29,8 +36,8 @@ func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) {
SerialNumber: big.NewInt(time.Now().UnixNano()), SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: names[0]}, Subject: pkix.Name{CommonName: names[0]},
DNSNames: names, DNSNames: names,
NotBefore: time.Now().Add(-time.Hour), NotBefore: time.Now().Add(notBefore),
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(notAfter),
} }
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil { if err != nil {
@@ -49,6 +56,13 @@ func makeCertPEM(t *testing.T, names ...string) (certPEM, keyPEM []byte) {
// resolver name to that resolver's account and certificates. // resolver name to that resolver's account and certificates.
func writeAcmeStore(t *testing.T, dir, resolverName string, entries map[string][]string) string { func writeAcmeStore(t *testing.T, dir, resolverName string, entries map[string][]string) string {
t.Helper() t.Helper()
return writeAcmeStoreWithValidity(t, dir, resolverName, entries, -time.Hour, 90*24*time.Hour)
}
// writeAcmeStoreWithValidity is writeAcmeStore with control over how long the
// issued certificates are valid for.
func writeAcmeStoreWithValidity(t *testing.T, dir, resolverName string, entries map[string][]string, notBefore, notAfter time.Duration) string {
t.Helper()
type domain struct { type domain struct {
Main string `json:"main"` Main string `json:"main"`
@@ -62,7 +76,7 @@ func writeAcmeStore(t *testing.T, dir, resolverName string, entries map[string][
var certificates []certificate var certificates []certificate
for main, sans := range entries { for main, sans := range entries {
certPEM, keyPEM := makeCertPEM(t, append([]string{main}, sans...)...) certPEM, keyPEM := makeCertPEMWithValidity(t, notBefore, notAfter, append([]string{main}, sans...)...)
certificates = append(certificates, certificate{ certificates = append(certificates, certificate{
Domain: domain{Main: main, SANs: sans}, Domain: domain{Main: main, SANs: sans},
Certificate: base64.StdEncoding.EncodeToString(certPEM), Certificate: base64.StdEncoding.EncodeToString(certPEM),
@@ -240,21 +254,63 @@ func TestAcmeCertWatcher_FailsWhenStoreMissing(t *testing.T) {
} }
} }
// An identical file must not cause a pointless swap; LastReload only advances // An identical file must not cause a pointless swap, but the watcher must
// when the content actually changed. // still record that it looked. acme.json sits untouched for sixty days, so a
func TestAcmeCertWatcher_SkipsReloadWhenContentUnchanged(t *testing.T) { // LastReload that only advanced on change could not tell a healthy idle
// watcher apart from one that went blind (PLAN.md risk R2).
func TestAcmeCertWatcher_SkipsSwapButRecordsCheckWhenContentUnchanged(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"mail.example.test": nil}) writeAcmeStore(t, dir, "letsencrypt", map[string][]string{"mail.example.test": nil})
watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test") watcher, _ := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
first := watcher.LastReload() firstReload := watcher.LastReload()
firstChange := watcher.LastChange()
time.Sleep(2 * time.Millisecond) time.Sleep(2 * time.Millisecond)
if err := watcher.reload(); err != nil { if err := watcher.reload(); err != nil {
t.Fatalf("reload: %v", err) t.Fatalf("reload: %v", err)
} }
if !watcher.LastReload().Equal(first) { if !watcher.LastChange().Equal(firstChange) {
t.Error("an unchanged file triggered a reload") t.Error("an unchanged file swapped the certificate store")
}
if !watcher.LastReload().After(firstReload) {
t.Error("LastReload did not advance, so a blind watcher would look identical to an idle one")
}
}
// An expired entry must not be served: every peer would reject the handshake,
// whereas skipping it lets the SNI fallback take over (PLAN.md section 8.2).
func TestAcmeCertWatcher_SkipsExpiredCertificate(t *testing.T) {
dir := t.TempDir()
writeAcmeStoreWithValidity(t, dir, "letsencrypt",
map[string][]string{"expired.example.test": nil}, -48*time.Hour, -24*time.Hour)
if _, err := NewAcmeCertWatcher(dir, "acme.json", "expired.example.test"); err == nil {
t.Fatal("a store holding only an expired certificate must not load as usable")
}
}
// The soonest expiry is what decides when the deployment breaks, so it has to
// be readable for the alerts of PLAN.md section 8.4.
func TestAcmeCertWatcher_ReportsEarliestExpiry(t *testing.T) {
dir := t.TempDir()
writeAcmeStoreWithValidity(t, dir, "letsencrypt",
map[string][]string{"mail.example.test": nil}, -time.Hour, 72*time.Hour)
watcher, err := NewAcmeCertWatcher(dir, "acme.json", "mail.example.test")
if err != nil {
t.Fatalf("NewAcmeCertWatcher: %v", err)
}
host, expiry, ok := watcher.EarliestExpiry()
if !ok {
t.Fatal("EarliestExpiry reported nothing for a loaded store")
}
if host != "mail.example.test" {
t.Errorf("EarliestExpiry host = %q, want mail.example.test", host)
}
if remaining := time.Until(expiry); remaining > 73*time.Hour || remaining < 71*time.Hour {
t.Errorf("expiry is %s away, want roughly 72h", remaining)
} }
} }
@@ -22,6 +22,11 @@ type Router struct {
reportUseCase *appusecase.IngestReportsUseCase reportUseCase *appusecase.IngestReportsUseCase
pingFunc func() error pingFunc func() error
mtaStsMode valueobject.MtaStsMode 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 { func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
@@ -62,6 +67,13 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest) 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) { func (r *Router) handleHealth(w http.ResponseWriter, _ *http.Request) {
if r.pingFunc != nil { if r.pingFunc != nil {
if err := r.pingFunc(); err != nil { if err := r.pingFunc(); err != nil {
@@ -69,6 +81,21 @@ func (r *Router) handleHealth(w http.ResponseWriter, _ *http.Request) {
return 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.WriteHeader(http.StatusOK)
w.Write([]byte("OK\n")) w.Write([]byte("OK\n"))
} }
@@ -2,6 +2,7 @@ package httppresentation
import ( import (
"bytes" "bytes"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -67,3 +68,38 @@ func TestRouter_MtaSts_Autoconfig_SecurityTxt_Ingest(t *testing.T) {
t.Errorf("dmarc ingest status = %d, body = %q", w.Code, w.Body.String()) t.Errorf("dmarc ingest status = %d, body = %q", w.Code, w.Body.String())
} }
} }
// A certificate problem must be visible without taking the container down:
// restarting cannot fix a certificate Traefik never issued, so /health stays
// 200 and carries the degraded state instead (PLAN.md section 8.4).
func TestRouter_HealthReportsDegradedWithoutFailing(t *testing.T) {
router := NewRouter(appusecase.NewIngestReportsUseCase(nil), func() error { return nil })
router.SetDegradedCheck(func() error { return errors.New("self-signed certificate in use") })
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("degraded health status = %d, want 200 so the orchestrator does not restart-loop", w.Code)
}
if got := w.Header().Get("X-LambdaMail-Health"); got != "degraded" {
t.Errorf("X-LambdaMail-Health = %q, want degraded", got)
}
if !strings.Contains(w.Body.String(), "self-signed certificate in use") {
t.Errorf("the degraded body does not name the cause: %q", w.Body.String())
}
}
// A dependency that is actually down is a different state: that one must fail.
func TestRouter_HealthFailsWhenDependencyIsDown(t *testing.T) {
router := NewRouter(appusecase.NewIngestReportsUseCase(nil), func() error { return errors.New("database unreachable") })
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("health status = %d, want 503", w.Code)
}
}
@@ -8,7 +8,6 @@ import (
"errors" "errors"
"io" "io"
"net" "net"
"strings"
gosmtp "github.com/emersion/go-smtp" gosmtp "github.com/emersion/go-smtp"
@@ -141,28 +140,23 @@ func (s *session) Data(r io.Reader) error {
if errors.As(err, &smtpErr) { if errors.As(err, &smtpErr) {
return smtpErr return smtpErr
} }
errMsg := err.Error()
if strings.HasPrefix(errMsg, "550 ") { // A refusal the use case decided on carries its own reply. Rendering
// it here is the only place the wire format is chosen.
var rejection *usecase.SmtpRejection
if errors.As(err, &rejection) {
return &gosmtp.SMTPError{ return &gosmtp.SMTPError{
Code: 550, Code: rejection.Code,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1}, EnhancedCode: gosmtp.EnhancedCode{
Message: strings.TrimPrefix(errMsg, "550 5.7.1 "), rejection.EnhancedCode[0], rejection.EnhancedCode[1], rejection.EnhancedCode[2],
} },
} Message: rejection.Message,
if strings.HasPrefix(errMsg, "554 ") {
return &gosmtp.SMTPError{
Code: 554,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
Message: strings.TrimPrefix(errMsg, "554 5.7.1 "),
}
}
if strings.HasPrefix(errMsg, "451 ") {
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 7, 1},
Message: strings.TrimPrefix(errMsg, "451 4.7.1 "),
} }
} }
// Anything else is an internal failure. It is reported as temporary so
// the sender retries: the message was not stored, and telling the peer
// it failed permanently would discard mail over a bug on this side.
return &gosmtp.SMTPError{ return &gosmtp.SMTPError{
Code: 451, Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0}, EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
@@ -12,6 +12,7 @@ import (
"lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/domain/valueobject"
) )
type fakeMailboxRepository struct { type fakeMailboxRepository struct {
@@ -37,6 +38,8 @@ func (f *fakeMailboxRepository) ResolveDeliveryTargets(_ context.Context, addres
type recordingBlobStorage struct { type recordingBlobStorage struct {
stored []byte stored []byte
ref port.BlobRef ref port.BlobRef
// err makes Store fail, standing in for an unexpected internal failure.
err error
} }
func (b *recordingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) { func (b *recordingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
@@ -44,21 +47,44 @@ func (b *recordingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobR
if err != nil { if err != nil {
return port.BlobRef{}, err return port.BlobRef{}, err
} }
if b.err != nil {
return port.BlobRef{}, b.err
}
b.stored = data b.stored = data
return b.ref, nil return b.ref, nil
} }
// rejectingScanner stands in for the spam or virus scanner refusing a message.
type rejectingScanner struct{ rejection error }
func (s *rejectingScanner) Scan(_ context.Context, _ port.ScanInput) (*valueobject.ScanResult, error) {
return nil, s.rejection
}
type recordingMessageRepository struct { type recordingMessageRepository struct {
persisted []port.PersistInboundMessageInput persisted []port.PersistInboundMessageInput
} }
func (m *recordingMessageRepository) Persist(_ context.Context, input port.PersistInboundMessageInput) (int64, error) { func (m *recordingMessageRepository) PersistAll(_ context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
m.persisted = append(m.persisted, input) uids := make([]int64, 0, len(inputs))
return int64(len(m.persisted)), nil for _, input := range inputs {
m.persisted = append(m.persisted, input)
uids = append(uids, int64(len(m.persisted)))
}
return uids, nil
} }
func newTestSession(mailboxes *fakeMailboxRepository, blobs *recordingBlobStorage, messages *recordingMessageRepository) gosmtp.Session { func newTestSession(mailboxes *fakeMailboxRepository, blobs *recordingBlobStorage, messages *recordingMessageRepository) gosmtp.Session {
return newTestSessionWithScanner(mailboxes, blobs, messages, nil)
}
// newTestSessionWithScanner builds a session whose scanner refuses the message
// with the given error, which is how a rejection reaches the Data handler.
func newTestSessionWithScanner(mailboxes *fakeMailboxRepository, blobs *recordingBlobStorage, messages *recordingMessageRepository, rejection error) gosmtp.Session {
uc := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages) uc := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
if rejection != nil {
uc.SetScanner(&rejectingScanner{rejection: rejection})
}
backend := NewBackend(uc) backend := NewBackend(uc)
session, err := backend.NewSession(nil) session, err := backend.NewSession(nil)
if err != nil { if err != nil {
@@ -194,3 +220,81 @@ func TestSession_Reset_ClearsAccumulatedRecipients(t *testing.T) {
t.Errorf("persisted %d messages after Reset, want 0", len(messages.persisted)) t.Errorf("persisted %d messages after Reset, want 0", len(messages.persisted))
} }
} }
// The reply a remote MTA receives must come from the use case's decision, not
// from parsing its error text: rewording a message would otherwise change a
// permanent refusal into a retry, or the reverse.
func TestSession_Data_RendersUseCaseRejectionVerbatim(t *testing.T) {
cases := []struct {
name string
rejection *usecase.SmtpRejection
wantCode int
wantEnhancedCode gosmtp.EnhancedCode
}{
{"virus is permanent", &usecase.SmtpRejection{Code: 554, EnhancedCode: [3]int{5, 7, 1}, Message: "Virus detected: Eicar-Test"}, 554, gosmtp.EnhancedCode{5, 7, 1}},
{"greylist is temporary", &usecase.SmtpRejection{Code: 451, EnhancedCode: [3]int{4, 7, 1}, Message: "Greylisted, please try again later"}, 451, gosmtp.EnhancedCode{4, 7, 1}},
{"dmarc reject is permanent", &usecase.SmtpRejection{Code: 550, EnhancedCode: [3]int{5, 7, 1}, Message: "DMARC policy rejects this message"}, 550, gosmtp.EnhancedCode{5, 7, 1}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mailboxID := uuid.New()
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: mailboxID, QuotaBytes: 1000},
}}
session := newTestSessionWithScanner(mailboxes,
&recordingBlobStorage{ref: port.BlobRef{ID: uuid.New()}}, &recordingMessageRepository{}, tc.rejection)
if err := session.Mail("sender@remote.test", nil); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
if err := session.Rcpt("user@example.test", nil); err != nil {
t.Fatalf("RCPT TO: %v", err)
}
err := session.Data(bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"))
var smtpErr *gosmtp.SMTPError
if !errors.As(err, &smtpErr) {
t.Fatalf("Data returned %v, want an *gosmtp.SMTPError", err)
}
if smtpErr.Code != tc.wantCode {
t.Errorf("reply code = %d, want %d", smtpErr.Code, tc.wantCode)
}
if smtpErr.EnhancedCode != tc.wantEnhancedCode {
t.Errorf("enhanced code = %v, want %v", smtpErr.EnhancedCode, tc.wantEnhancedCode)
}
if smtpErr.Message != tc.rejection.Message {
t.Errorf("message = %q, want %q", smtpErr.Message, tc.rejection.Message)
}
})
}
}
// An unexpected internal failure must be temporary: the message was not
// stored, and a permanent reply would discard mail over a bug on this side.
func TestSession_Data_ReportsInternalFailureAsTemporary(t *testing.T) {
mailboxID := uuid.New()
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
"user@example.test": {ID: mailboxID, QuotaBytes: 1000},
}}
session := newTestSession(mailboxes,
&recordingBlobStorage{err: errors.New("disk full")}, &recordingMessageRepository{})
if err := session.Mail("sender@remote.test", nil); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
if err := session.Rcpt("user@example.test", nil); err != nil {
t.Fatalf("RCPT TO: %v", err)
}
err := session.Data(bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"))
var smtpErr *gosmtp.SMTPError
if !errors.As(err, &smtpErr) {
t.Fatalf("Data returned %v, want an *gosmtp.SMTPError", err)
}
if smtpErr.Code != 451 {
t.Errorf("reply code = %d, want 451 so the sender retries", smtpErr.Code)
}
}
+29 -3
View File
@@ -1,14 +1,40 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install # npm ci, not npm install: the image must be the lockfile's content, not
# whatever resolves the day it is built.
RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:20-alpine # Production dependencies only. The previous image copied the build stage's
# node_modules wholesale, shipping typescript, eslint and vitest into
# production - most of the CVEs the image scan reported came from that
# tooling, which never runs in the container.
FROM node:20-alpine AS prod-deps
WORKDIR /app WORKDIR /app
COPY --from=build /app/node_modules ./node_modules COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
FROM node:20-alpine
# The node:20-alpine tag lags its Alpine base, so pending security patches
# (openssl, busybox) land only with an explicit upgrade.
RUN apk upgrade --no-cache \
# The runtime only ever runs "node"; npm is not invoked and its own
# bundled dependency tree (sigstore, cross-spawn, glob, ...) is what
# every remaining image CVE came from. Removing it drops the whole
# class instead of chasing each advisory.
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY package.json ./ COPY package.json ./
# Created before dropping privileges so a fresh named volume mounted here
# inherits the ownership: Docker seeds an empty volume from the image.
RUN mkdir -p /var/lambdamail/mails && chown -R node:node /var/lambdamail
# The node images ship an unprivileged "node" user (uid 1000).
USER node
EXPOSE 3002 EXPOSE 3002
CMD ["node", "dist/server.js"] CMD ["node", "dist/server.js"]
+17 -4
View File
@@ -1,15 +1,28 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:20-alpine FROM node:20-alpine
# The node:20-alpine tag lags its Alpine base, so pending security patches
# (openssl, busybox) land only with an explicit upgrade.
RUN apk upgrade --no-cache \
# The runtime only ever runs "node"; npm is not invoked and its own
# bundled dependency tree (sigstore, cross-spawn, glob, ...) is what
# every remaining image CVE came from. Removing it drops the whole
# class instead of chasing each advisory.
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
WORKDIR /app WORKDIR /app
COPY --from=build /app/.next/standalone ./ # --chown matters here: Next's standalone server writes its cache under
COPY --from=build /app/.next/static ./.next/static # .next at runtime, which it cannot do if the tree belongs to root.
COPY --from=build /app/public ./public COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
# The node images ship an unprivileged "node" user (uid 1000).
USER node
EXPOSE 3000 EXPOSE 3000
# Docker sets HOSTNAME to the container ID by default; Next's standalone # Docker sets HOSTNAME to the container ID by default; Next's standalone
# server.js honors that env var and binds only to it, refusing localhost # server.js honors that env var and binds only to it, refusing localhost
+6 -66
View File
@@ -140,9 +140,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -159,9 +156,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -178,9 +172,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -197,9 +188,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -216,9 +204,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -235,9 +220,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -254,9 +236,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -273,9 +252,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -292,9 +268,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -317,9 +290,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -342,9 +312,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -367,9 +334,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -392,9 +356,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -417,9 +378,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -442,9 +400,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -467,9 +422,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -622,9 +574,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -641,9 +590,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -660,9 +606,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -679,9 +622,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -898,9 +838,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.31", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -917,9 +857,9 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.6", "nanoid": "^3.3.16",
"picocolors": "^1.0.0", "picocolors": "^1.1.1",
"source-map-js": "^1.0.2" "source-map-js": "^1.2.1"
}, },
"engines": { "engines": {
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
+2 -1
View File
@@ -18,6 +18,7 @@
"@types/react": "^18.3.3" "@types/react": "^18.3.3"
}, },
"overrides": { "overrides": {
"sharp": "^0.35.0" "sharp": "^0.35.0",
"postcss": "^8.5.25"
} }
} }