From 2eb8a0c225a697d7e9c8731930391fbc759b5176 Mon Sep 17 00:00:00 2001 From: Wayne Hayes Date: Wed, 3 Jun 2026 22:25:38 -0400 Subject: [PATCH] Scaffold tailwart: Stalwart mailbox as a Tailscale sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over-engineered play stack for infinidim.net — Stalwart wired into the shared Postgres + Redis + Garage S3 over the tailnet, with no WAN presence. Public mail ports are fronted by a separate caddy-l4 layer-4 proxy (caddy/) that can run on any tailnet host tagged tag:reverse-proxy — decoupled from the mailbox. - docker-compose.yml: ts-stalwart sidecar + stalwart, backends via MagicDNS - config/config.toml: PG (data/fts) + Redis (lookup) + S3 (blob) strawman - caddy/: xcaddy build with caddy-l4, JSON layer-4 mail proxy, own compose - acl-snippet.hujson: tag:stalwart owner + backend/edge grants - .env.example + gitignored .env (pulled from shared infra) Co-Authored-By: Claude Opus 4.8 --- .env.example | 79 ++++++++++++++++++++++++++++ .gitignore | 11 ++++ CLAUDE.md | 94 ++++++++++++++++++++++++++++++++++ README.md | 50 ++++++++++++++++++ acl-snippet.hujson | 23 +++++++++ caddy/Dockerfile | 15 ++++++ caddy/README.md | 49 ++++++++++++++++++ caddy/caddy.json | 52 +++++++++++++++++++ caddy/docker-compose.yml | 26 ++++++++++ config/config.toml | 108 +++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 72 ++++++++++++++++++++++++++ 11 files changed, 579 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 acl-snippet.hujson create mode 100644 caddy/Dockerfile create mode 100644 caddy/README.md create mode 100644 caddy/caddy.json create mode 100644 caddy/docker-compose.yml create mode 100644 config/config.toml create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..26a1b01 --- /dev/null +++ b/.env.example @@ -0,0 +1,79 @@ +# ============================================================================ +# tailwart — operator configuration (Tailscale × Stalwart) +# ============================================================================ +# Copy to .env and fill in. The mailbox stack (docker-compose.yml) and the +# edge proxy (caddy/docker-compose.yml) both read their values from here. +# +# Design: Stalwart runs as a Tailscale sidecar with NO WAN presence. A +# layer-4 (TCP) Caddy proxy on a public-IP host pipes the raw mail ports to it +# over the tailnet. The proxy can live on a DIFFERENT machine than the mailbox +# — that's the whole point. See CLAUDE.md. +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Tailscale — same OAuth client as the rest of the tailnet +# ---------------------------------------------------------------------------- +# Sidecar advertises tag:stalwart. Assign that tag to the OAuth client on both +# the Devices/Core and Keys/AuthKeys scopes, or node creation 403s at boot. +TS_OAUTH_CLIENT_SECRET= +TS_TAILNET=tailfe8c.ts.net + +# MagicDNS hostname for the Stalwart sidecar → reachable at +# ${STALWART_MAGIC_NAME}.${TS_TAILNET} over the tailnet. +STALWART_MAGIC_NAME=stalwart + +# ---------------------------------------------------------------------------- +# Mail identity +# ---------------------------------------------------------------------------- +# The domain you send/receive for, and the public MX/hostname clients connect +# to (its A record points at the EDGE PROXY's public IP, not the mailbox). +STALWART_DOMAIN=infinidim.net +STALWART_HOSTNAME=mail.infinidim.net + +# Initial admin password for the Stalwart web console (first boot only). +# Generate: openssl rand -base64 24 +STALWART_FALLBACK_ADMIN_SECRET= + +# ---------------------------------------------------------------------------- +# Over-engineering, part 1/3 — Postgres (shared instance on the tailnet) +# ---------------------------------------------------------------------------- +# Stalwart's metadata + full-text store. Create the role/db once (see README). +DB_MAGIC_NAME=the-record-prod +STALWART_DB_NAME=stalwart +STALWART_DB_USER=stalwart +# Generate: openssl rand -base64 24 (avoid / and + if you ever inline it in a URL) +STALWART_DB_PASSWORD= + +# ---------------------------------------------------------------------------- +# Over-engineering, part 2/3 — Redis (shared instance on the tailnet) +# ---------------------------------------------------------------------------- +# Stalwart's in-memory store: distributed rate limits, caches, token revocation. +REDIS_MAGIC_NAME=slo-time-prod +# Use a dedicated logical DB index so we don't collide with the fediverse apps. +STALWART_REDIS_DB=3 + +# ---------------------------------------------------------------------------- +# Over-engineering, part 3/3 — Garage S3 (shared instance on the tailnet) +# ---------------------------------------------------------------------------- +# Stalwart's blob store: message bodies, attachments, sieve scripts. +# Reuses the shared Garage access key; needs its own bucket (see README). +GARAGE_MAGIC_NAME=garage +GARAGE_REGION=garage +GARAGE_ACCESS_KEY_ID= +GARAGE_SECRET_ACCESS_KEY= +STALWART_S3_BUCKET=stalwart-mail + +# ---------------------------------------------------------------------------- +# Outbound delivery +# ---------------------------------------------------------------------------- +# Most VPS providers block outbound :25. If yours does, relay through a +# smarthost (host:port). Leave blank to attempt direct MX delivery. +STALWART_SMARTHOST= + +# ---------------------------------------------------------------------------- +# TLS — Stalwart self-manages certs via ACME DNS-01 (works behind the L4 proxy) +# ---------------------------------------------------------------------------- +# DNS provider + token for the DNS-01 challenge. Leave blank to instead mount +# a certbot-issued cert (see config/config.toml [certificate]). +STALWART_ACME_PROVIDER= +STALWART_ACME_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06e6538 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Operator secrets — never commit. Copy .env.example -> .env and fill in. +.env +caddy/.env + +# Local-only overrides +*.local.toml +*.local.json + +# Built Caddy binary (rebuild from caddy/Dockerfile instead of committing 50MB) +caddy/caddy +caddy/*.bin diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..feeb39e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md — tailwart + +Guidance for Claude Code in this repo. Read before editing. + +## What this is + +A play deployment of **Stalwart** (all-in-one mail/JMAP/IMAP/SMTP server) wired, +gratuitously, into **three** shared backends — Postgres, Redis, and Garage S3 — +to see how far the federatedSocial Tailscale-sidecar pattern stretches past the +fediverse apps. Target domain: `infinidim.net` (may become real later). + +It is **self-contained and outside** `/opt/federatedSocial` on purpose: that's +an upstream clone that `git pull` overwrites. tailwart owns its own `.env`, +compose, config, ACL snippet, and Caddy build, and only *reads from the tailnet* +(shared infra over MagicDNS) at runtime. + +## Architecture — two ends of one wire + +``` + public IP host (tag:reverse-proxy) tailnet-only mailbox + ┌───────────────────────────┐ ┌────────────────────────┐ + │ caddy/ (caddy-l4) │ tailnet │ ts-stalwart sidecar │ + │ :25 :465 :587 :143 :993 ──┼───WireGuard───▶│ stalwart (no WAN, no │ + │ PROXY protocol v2 │ │ host ports) │ + └───────────────────────────┘ └───────────┬────────────┘ + L7 JMAP vhost on the main Caddy │ + mail.infinidim.net → :8080 ┌───────┴───────┐ + ▼ ▼ ▼ + Postgres Redis Garage S3 + (the-record)(slo-time)(garage) +``` + +- **Mailbox** (`docker-compose.yml`): Stalwart in a Tailscale sidecar via + `network_mode: service:ts-stalwart`. Binds nothing on the host. All mail + ports listen on the tailnet only. +- **Edge** (`caddy/`): a layer-4 TCP proxy. Pure pass-through; Stalwart owns + TLS. **Can run on a different machine** than the mailbox — the key idea. +- **Backends**: data+fts → Postgres, blob → Garage S3, lookup/in-memory → + Redis. One stalwart role/db, one Garage bucket, one Redis logical DB. + +## The `.env` contract + +`.env` (gitignored) is the whole operator surface; `.env.example` is the +template. Both compose files read it. Secrets reach Stalwart as env vars and are +referenced from `config/config.toml` via `%{env:NAME}%` so the toml stays +commit-safe. Never hardcode a value that belongs in `.env` — except the two +spots a static file forces it: `caddy/caddy.json` dial targets and any +MagicDNS host in the toml. + +## Sidecar boilerplate + +Identical to federatedSocial's (TS_ACCEPT_DNS true, kernel networking, 127.0.0.1 +healthcheck, ephemeral OAuth auth). Don't drift it. Tag: `tag:stalwart`. + +## Prerequisites (shared tailnet infra — already running for the fediverse) + +1. Postgres role + db: `stalwart` / `STALWART_DB_NAME`. Create via the + federatedSocial `bootstrap.sh` flow or a one-off `CREATE ROLE … LOGIN; CREATE + DATABASE … OWNER …`. +2. Garage bucket `stalwart-mail` + grant the shared access key access to it. +3. Redis: nothing to create — just use a dedicated logical DB index + (`STALWART_REDIS_DB`) so we don't collide with the apps. +4. Admin console: assign `tag:stalwart` to the OAuth client (Devices/Core + + Keys/AuthKeys) and add `acl-snippet.hujson` to the policy. + +## Pitfalls (some learned the hard way next door) + +- **Mail edge is layer 4, not layer 7.** Don't try to give the L4 ports a + normal Caddy vhost. SNI/Host routing doesn't apply to `:25`. +- **PROXY protocol or your mail reputation dies.** Without it Stalwart sees the + proxy's tailnet IP as every client → SPF/DNSBL/greylisting break. Both ends + must agree (caddy.json `proxy_protocol: v2` ↔ config `[server.proxy] + trusted-networks`). +- **Stalwart config drifts between versions and migrates into the admin store + after first boot.** `config/config.toml` is a strawman — verify keys against + the pinned image tag before trusting them. Pin the tag once it works. +- **`POSTGRES_PASSWORD`/role passwords only apply on an empty volume.** If a + password "doesn't work," the stored credential drifted — `ALTER USER`, don't + re-init. And never test a password over `127.0.0.1` against these Postgres + containers: pg_hba `trust`s loopback and accepts ANY password. Test over the + tailnet (scram) or you'll fool yourself. +- **Outbound :25 is usually blocked on VPS.** Set `STALWART_SMARTHOST`. +- **Mail forces WAN ports.** `:25` must be world-reachable for inbound + federation — this is the one place the tailnet-only model can't hold. Keep + submission/IMAP tailnet-only if you want a tighter surface. + +## What not to do + +- Don't put files in `/opt/federatedSocial`. Read its `.env` if you must; never + write there. +- Don't add `ports:` to the Stalwart container — the edge proxy is the only + public surface, and it lives in `caddy/`. +- Don't commit `.env` or a built Caddy binary (see `.gitignore`). +- Don't break the sidecar netns boundary with bridge networks or host ports. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1da7709 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# tailwart + +> Tailscale × Stalwart. A mailbox with no WAN presence, fronted by a layer-4 +> proxy that can live on another machine entirely. + +A deliberately over-engineered playground: [Stalwart](https://github.com/stalwartlabs/stalwart) +mail server wired into **Postgres + Redis + Garage S3** at once, deployed as a +Tailscale sidecar, with a separate `caddy-l4` edge that pipes the raw mail ports +over the tailnet. For `infinidim.net`. + +See [CLAUDE.md](./CLAUDE.md) for the architecture and the gotchas. + +## Layout + +``` +tailwart/ +├── docker-compose.yml # the mailbox: ts-stalwart sidecar + stalwart +├── config/config.toml # Stalwart config — PG + Redis + S3 wiring (strawman) +├── caddy/ # the edge: custom Caddy (caddy-l4) layer-4 mail proxy +│ ├── Dockerfile # xcaddy build with caddy-l4 + ratelimit +│ ├── caddy.json # :25/465/587/143/993 → stalwart over the tailnet +│ ├── docker-compose.yml # deploy on any public-IP, tailnet, tag:reverse-proxy host +│ └── README.md +├── acl-snippet.hujson # tag:stalwart owner + grants to merge into your policy +├── .env.example # operator surface — copy to .env +└── .gitignore +``` + +## Quickstart + +```bash +cp .env.example .env && $EDITOR .env # fill secrets (see CLAUDE.md prereqs) + +# 1. create the stalwart role/db in shared Postgres + the Garage bucket +# (one-off; see CLAUDE.md "Prerequisites") +# 2. admin console: assign tag:stalwart to the OAuth client + paste acl-snippet +# 3. bring up the mailbox (tailnet-only) +docker compose up -d +# 4. bring up the edge (binds public mail ports; can be a different host) +cd caddy && docker compose up -d --build +``` + +Then point `infinidim.net`'s MX at the edge host, add SPF/DKIM/DMARC, and finish +configuration in Stalwart's web admin (`mail.infinidim.net`). + +## Status + +Scaffold / strawman. The Stalwart `config.toml` keys need verifying against a +pinned image version before first real boot — treat it as a starting shape, not +a turnkey config. diff --git a/acl-snippet.hujson b/acl-snippet.hujson new file mode 100644 index 0000000..31ad71f --- /dev/null +++ b/acl-snippet.hujson @@ -0,0 +1,23 @@ +// tailwart — merge into your live Tailscale policy (admin console). +// Snippet, not a full policy. Kept here so an upstream pull of any other repo +// can't clobber it. + +// 1) tagOwners — add (self-ownership required for auth-key node creation): +// "tag:stalwart": ["autogroup:admin", "tag:stalwart"], + +// 2) grants — Stalwart reaches the three shared backends: +{ "src": ["tag:stalwart"], "dst": ["tag:db-postgres"], "ip": ["tcp:5432"] }, +{ "src": ["tag:stalwart"], "dst": ["tag:db-redis"], "ip": ["tcp:6379"] }, +{ "src": ["tag:stalwart"], "dst": ["tag:garage"], "ip": ["tcp:3900"] }, + +// 3) grants — the edge proxy (tag:reverse-proxy) reaches the mailbox ports. +// 8080 is the JMAP/admin HTTP tier (fronted by the main L7 Caddy). +{ + "src": ["tag:reverse-proxy"], + "dst": ["tag:stalwart"], + "ip": ["tcp:25", "tcp:465", "tcp:587", "tcp:143", "tcp:993", "tcp:8080"], +}, + +// 4) admin console (not this file): assign tag:stalwart to the same OAuth +// client federatedSocial uses, on the Devices/Core + Keys/AuthKeys scopes. +// Missing → 403 "calling actor does not have enough permissions" at boot. diff --git a/caddy/Dockerfile b/caddy/Dockerfile new file mode 100644 index 0000000..2dcf44c --- /dev/null +++ b/caddy/Dockerfile @@ -0,0 +1,15 @@ +# Custom Caddy with the layer-4 (TCP/UDP) app so it can proxy raw mail ports, +# not just HTTP. caddy-ratelimit is included to match the house build on `box`. +# +# docker build -t tailwart-caddy ./caddy +# +# Pinned to 2.11 to match box's Caddy. Bump deliberately. +FROM caddy:2.11-builder AS build +RUN xcaddy build \ + --with github.com/mholt/caddy-l4 \ + --with github.com/mholt/caddy-ratelimit + +FROM caddy:2.11 +COPY --from=build /usr/bin/caddy /usr/bin/caddy +# Proof the L4 module is in the binary (fails the build if not): +RUN caddy list-modules | grep -q 'layer4' || (echo 'caddy-l4 missing!' && exit 1) diff --git a/caddy/README.md b/caddy/README.md new file mode 100644 index 0000000..02dc7a6 --- /dev/null +++ b/caddy/README.md @@ -0,0 +1,49 @@ +# tailwart edge — layer-4 mail proxy + +A custom Caddy (with the `caddy-l4` app) that pipes the public mail ports to the +Stalwart sidecar over the tailnet. Pure TCP pass-through with PROXY protocol — +Stalwart still terminates all the TLS. **Runs anywhere** with a public IP that's +on the tailnet and tagged `tag:reverse-proxy`; doesn't need to share a host with +the mailbox. + +## Why layer 4 and not a normal Caddy vhost + +Web apps reverse-proxy at layer 7 (route by Host/SNI, Caddy terminates TLS). +Mail can't: port 25 has no SNI (STARTTLS comes after connect), and you want one +global `:25` listener, not per-domain routing. So the edge is a dumb L4 pipe and +Stalwart owns the TLS. The novelty you spotted: this is the same `stream`-style +proxying nginx/Caddy can do for *any* TCP — it just usually isn't used for it. + +## Build & run + +```bash +docker compose up -d --build # builds Dockerfile (xcaddy + caddy-l4), runs it +caddy list-modules | grep layer4 # (inside the image) proof the module loaded +``` + +The build fails loudly if `caddy-l4` isn't in the resulting binary. + +## Edit the upstream + +`caddy.json` dials `stalwart.tail7b1641.ts.net:`. If your +`STALWART_MAGIC_NAME` / `TS_TAILNET` differ, update the five `dial` lines. (JSON +can't read `.env`; this is the one spot the MagicDNS name is hardcoded — same +trade-off as pgAdmin's `servers.json`.) + +## The HTTP side (JMAP / autoconfig / admin) is separate + +That part *is* ordinary layer 7. Don't put it here if this box already runs the +main Caddy on :443 — you'll collide. Instead add a vhost to the existing Caddy: + +```caddyfile +mail.infinidim.net { + reverse_proxy stalwart.tail7b1641.ts.net:8080 +} +``` + +## Prerequisites on the host running this + +- Joined to the tailnet, tagged `tag:reverse-proxy` (so the ACL lets it reach + `tag:stalwart`). +- Public firewall opens for whichever mail ports you expose (`25` minimum). +- Nothing else bound to those ports. diff --git a/caddy/caddy.json b/caddy/caddy.json new file mode 100644 index 0000000..dec141b --- /dev/null +++ b/caddy/caddy.json @@ -0,0 +1,52 @@ +{ + "//": "Layer-4 mail edge for tailwart. Pipes raw TCP mail ports to the", + "//2": "Stalwart sidecar over the tailnet, preserving client IP via PROXY", + "//3": "protocol v2. One server per port (upstream port differs per listener).", + "//4": "Stalwart terminates TLS itself — this is pure pass-through.", + "apps": { + "layer4": { + "servers": { + "smtp": { + "listen": [":25"], + "routes": [{ "handle": [{ + "handler": "proxy", + "proxy_protocol": "v2", + "upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:25"] }] + }]}] + }, + "submissions": { + "listen": [":465"], + "routes": [{ "handle": [{ + "handler": "proxy", + "proxy_protocol": "v2", + "upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:465"] }] + }]}] + }, + "submission": { + "listen": [":587"], + "routes": [{ "handle": [{ + "handler": "proxy", + "proxy_protocol": "v2", + "upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:587"] }] + }]}] + }, + "imap": { + "listen": [":143"], + "routes": [{ "handle": [{ + "handler": "proxy", + "proxy_protocol": "v2", + "upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:143"] }] + }]}] + }, + "imaptls": { + "listen": [":993"], + "routes": [{ "handle": [{ + "handler": "proxy", + "proxy_protocol": "v2", + "upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:993"] }] + }]}] + } + } + } + } +} diff --git a/caddy/docker-compose.yml b/caddy/docker-compose.yml new file mode 100644 index 0000000..ff6fe0e --- /dev/null +++ b/caddy/docker-compose.yml @@ -0,0 +1,26 @@ +# tailwart edge — the layer-4 mail proxy. Deploy this on ANY host with a public +# IP that is joined to the tailnet and tagged tag:reverse-proxy. It does not +# have to be the same machine as the Stalwart mailbox — that's the fun part. +# +# docker compose up -d --build +# +# network_mode: host is deliberate: the proxy needs to bind the public mail +# ports AND resolve/reach ${STALWART_MAGIC_NAME}.${TS_TAILNET} via the host's +# tailscaled (MagicDNS). The mailbox container, by contrast, binds nothing on +# the host — opposite ends of the same wire. + +name: tailwart-edge + +services: + caddy-l4: + build: . + image: tailwart-caddy:latest + network_mode: host + command: ["caddy", "run", "--config", "/etc/caddy/caddy.json"] + volumes: + - ./caddy.json:/etc/caddy/caddy.json:ro + - caddy-data:/data + restart: unless-stopped + +volumes: + caddy-data: diff --git a/config/config.toml b/config/config.toml new file mode 100644 index 0000000..da09f7a --- /dev/null +++ b/config/config.toml @@ -0,0 +1,108 @@ +# ============================================================================ +# Stalwart config — the over-engineered wiring: Postgres + Redis + Garage S3. +# ============================================================================ +# THIS IS A STRAWMAN. Stalwart's config schema shifts between releases and a +# lot of runtime config moves into the admin store after first boot. Treat +# every key here as "verify against the version you pinned" — the same pitfall +# the federatedSocial repo learned the hard way. Pin the image tag once it works. +# +# Secrets come in via %{env:NAME}% so this file stays commit-safe. +# ============================================================================ + +[server] +hostname = "%{env:STALWART_HOSTNAME}%" + +# ---------------------------------------------------------------------------- +# Listeners — all bind inside the sidecar netns (tailnet only). The public +# edge is caddy/ (layer 4). PROXY protocol carries the real client IP across +# the proxy hop, which mail reputation (SPF/DNSBL/greylisting) depends on. +# ---------------------------------------------------------------------------- +[server.listener.smtp] +bind = ["[::]:25"] +protocol = "smtp" + +[server.listener.submission] +bind = ["[::]:587"] +protocol = "smtp" + +[server.listener.submissions] +bind = ["[::]:465"] +protocol = "smtp" +tls.implicit = true + +[server.listener.imap] +bind = ["[::]:143"] +protocol = "imap" + +[server.listener.imaptls] +bind = ["[::]:993"] +protocol = "imap" +tls.implicit = true + +[server.listener.http] +# JMAP, autoconfig/autodiscover, and the web admin. Fronted by the existing +# layer-7 Caddy vhost (mail.infinidim.net), not the L4 proxy. +bind = ["[::]:8080"] +protocol = "http" + +# Trust the PROXY-protocol header only from the tailnet hop (CGNAT range). +[server.proxy] +trusted-networks = ["100.64.0.0/10"] + +# ---------------------------------------------------------------------------- +# Stores — three backends because we can. +# ---------------------------------------------------------------------------- +[store.pg] +type = "postgresql" +host = "%{env:DB_HOST}%" +port = 5432 +database = "%{env:STALWART_DB_NAME}%" +user = "%{env:STALWART_DB_USER}%" +password = "%{env:STALWART_DB_PASSWORD}%" + +[store.redis] +type = "redis" +urls = ["%{env:REDIS_URL}%"] + +[store.s3] +type = "s3" +endpoint = "%{env:S3_ENDPOINT}%" +region = "%{env:S3_REGION}%" +bucket = "%{env:S3_BUCKET}%" +access-key = "%{env:S3_ACCESS_KEY}%" +secret-key = "%{env:S3_SECRET_KEY}%" + +# Which store backs which concern. +[storage] +data = "pg" # accounts, mailboxes, metadata, indexes +fts = "pg" # full-text search +blob = "s3" # message bodies + attachments → Garage +lookup = "redis" # rate limits, caches, token revocation (distributed) +directory = "internal" + +[directory.internal] +type = "internal" +store = "pg" + +# ---------------------------------------------------------------------------- +# Outbound — relay via smarthost when the VPS blocks direct :25. +# ---------------------------------------------------------------------------- +[queue.outbound] +# If STALWART_SMARTHOST is set, route everything through it; else direct MX. +next-hop = "%{env:STALWART_SMARTHOST}%" + +# ---------------------------------------------------------------------------- +# TLS — Stalwart terminates mail TLS itself (the L4 proxy is pass-through), +# so it needs the cert. DNS-01 works fine behind the proxy (no inbound needed). +# Swap to a mounted certbot cert by pointing [certificate] at files instead. +# ---------------------------------------------------------------------------- +[acme.le] +directory = "https://acme-v02.api.letsencrypt.org/directory" +challenge = "dns-01" +provider = "%{env:STALWART_ACME_PROVIDER}%" +secret = "%{env:STALWART_ACME_TOKEN}%" +domains = ["%{env:STALWART_HOSTNAME}%"] + +[authentication.fallback-admin] +user = "admin" +secret = "%{env:STALWART_FALLBACK_ADMIN_SECRET}%" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..22d896e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,72 @@ +# tailwart — Stalwart mailbox as a Tailscale sidecar (NO WAN presence). +# +# The container shares ts-stalwart's network namespace, so its only interfaces +# are lo and tailscale0. All mail ports listen on the tailnet only; the public +# edge is the separate caddy/ layer-4 proxy, which can run on another host. +# +# Prereq: the shared tailnet infra (Postgres the-record-prod, Redis +# slo-time-prod, Garage) must be up, and the stalwart role/db/bucket created +# (see README). Bring up: docker compose up -d + +name: tailwart + +services: + + ts-stalwart: + image: tailscale/tailscale:latest + hostname: ${STALWART_MAGIC_NAME} + environment: + TS_AUTHKEY: ${TS_OAUTH_CLIENT_SECRET}?ephemeral=true + TS_EXTRA_ARGS: --advertise-tags=tag:stalwart + TS_HOSTNAME: ${STALWART_MAGIC_NAME} + TS_ACCEPT_DNS: "true" + TS_AUTH_ONCE: "true" + TS_USERSPACE: "false" + TS_ENABLE_HEALTH_CHECK: "true" + TS_LOCAL_ADDR_PORT: "127.0.0.1:9002" + dns: [1.1.1.1, 1.0.0.1] + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + - NET_RAW + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9002/healthz"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + restart: unless-stopped + + stalwart: + image: stalwartlabs/mail-server:latest + network_mode: "service:ts-stalwart" + environment: + # Consumed by config/config.toml via its %{env:NAME}% macros. Keeping + # secrets in env (not the mounted toml) means the toml is commit-safe. + STALWART_DB_NAME: ${STALWART_DB_NAME} + STALWART_DB_USER: ${STALWART_DB_USER} + STALWART_DB_PASSWORD: ${STALWART_DB_PASSWORD} + DB_HOST: ${DB_MAGIC_NAME}.${TS_TAILNET} + REDIS_URL: redis://${REDIS_MAGIC_NAME}.${TS_TAILNET}:6379/${STALWART_REDIS_DB} + S3_ENDPOINT: http://${GARAGE_MAGIC_NAME}.${TS_TAILNET}:3900 + S3_REGION: ${GARAGE_REGION} + S3_BUCKET: ${STALWART_S3_BUCKET} + S3_ACCESS_KEY: ${GARAGE_ACCESS_KEY_ID} + S3_SECRET_KEY: ${GARAGE_SECRET_ACCESS_KEY} + STALWART_HOSTNAME: ${STALWART_HOSTNAME} + STALWART_DOMAIN: ${STALWART_DOMAIN} + STALWART_SMARTHOST: ${STALWART_SMARTHOST} + STALWART_FALLBACK_ADMIN_SECRET: ${STALWART_FALLBACK_ADMIN_SECRET} + volumes: + - ./config/config.toml:/opt/stalwart-mail/etc/config.toml:ro + # Local working dir only (logs, ACME cache, queue spool). The bulk data + # lives in Postgres + Garage, not here — but Stalwart still wants a home. + - stalwart-data:/opt/stalwart-mail + depends_on: + ts-stalwart: + condition: service_healthy + restart: unless-stopped + +volumes: + stalwart-data: