Scaffold tailwart: Stalwart mailbox as a Tailscale sidecar
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 <noreply@anthropic.com>
This commit is contained in:
commit
2eb8a0c225
79
.env.example
Normal file
79
.env.example
Normal file
@ -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=
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -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
|
||||||
94
CLAUDE.md
Normal file
94
CLAUDE.md
Normal file
@ -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.
|
||||||
50
README.md
Normal file
50
README.md
Normal file
@ -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.
|
||||||
23
acl-snippet.hujson
Normal file
23
acl-snippet.hujson
Normal file
@ -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.
|
||||||
15
caddy/Dockerfile
Normal file
15
caddy/Dockerfile
Normal file
@ -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)
|
||||||
49
caddy/README.md
Normal file
49
caddy/README.md
Normal file
@ -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:<port>`. 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.
|
||||||
52
caddy/caddy.json
Normal file
52
caddy/caddy.json
Normal file
@ -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"] }]
|
||||||
|
}]}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
caddy/docker-compose.yml
Normal file
26
caddy/docker-compose.yml
Normal file
@ -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:
|
||||||
108
config/config.toml
Normal file
108
config/config.toml
Normal file
@ -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}%"
|
||||||
72
docker-compose.yml
Normal file
72
docker-compose.yml
Normal file
@ -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:
|
||||||
Loading…
Reference in New Issue
Block a user