feat: provision-stalwart.sh — configure Stalwart fully from .env
Stalwart v0.16 keeps all config in Postgres, reachable via the x: JMAP
management objects. This script writes everything the setup wizard would —
stores (Garage S3 + Redis), listeners (with per-listener PROXY trust on the
mail ports), the primary domain (+auto DKIM), admin + relay/catch-all
accounts, TLS/DNS, and optional Authelia SSO — straight into Postgres over
HTTP Basic. Idempotent (query-before-create), so re-runs are safe.
Tiers (the DNS/TLS automation boundary):
* Tier 1 (default, trustless): manual DNS, prints the records to publish.
* Tier 2 (STALWART_DNS_API_KEY set): Stalwart auto-publishes DNS + ACME
DNS-01 via the provider (Spaceship wired).
Authelia SSO is opt-in (STALWART_SSO_ENABLE); admin + relay keep password
auth as break-glass so enabling SSO can never lock you out.
.env.example: documents the tiered DNS + SSO surface (core reuses existing
fields; only tier-2 needs DNS provider keys). README: quickstart step + layout.
Validated: bash -n; all JMAP payloads build valid JSON; read/idempotency
paths against a live instance. NOT yet validated on a fresh boot (fallback
admin -> create -> re-auth) or the OIDC login round-trip — verify on a
throwaway deploy before relying on those paths.
Shaped to drop into federatedSocial bootstrap.sh as cmd_provision_stalwart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1e5fc982eb
commit
238cce506d
32
.env.example
32
.env.example
@ -76,12 +76,34 @@ STALWART_S3_BUCKET=stalwart-mail
|
|||||||
STALWART_SMARTHOST=
|
STALWART_SMARTHOST=
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# TLS — Stalwart self-manages certs via ACME DNS-01 (works behind the L4 proxy)
|
# Provisioning — `./provision-stalwart.sh` configures Stalwart from this .env
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# DNS provider + token for the DNS-01 challenge. Leave blank to instead mount
|
# Run it AFTER `docker compose up -d`. It writes stores, listeners, the primary
|
||||||
# a certbot-issued cert (see config/config.toml [certificate]).
|
# domain (+DKIM), the admin + relay/catch-all accounts, TLS/DNS and (optional)
|
||||||
STALWART_ACME_PROVIDER=
|
# SSO — all via the x: JMAP API. Idempotent; re-run any time.
|
||||||
STALWART_ACME_TOKEN=
|
#
|
||||||
|
# TIER 1 (default, trustless): leave the DNS keys below blank. The domain is
|
||||||
|
# created in MANUAL dns/dkim mode and the script PRINTS the records to publish.
|
||||||
|
# Certs: mount your wildcard, or front :80 at the edge for HTTP-01.
|
||||||
|
#
|
||||||
|
# TIER 2 (auto-DNS): set the DNS provider keys and Stalwart auto-publishes every
|
||||||
|
# record (MX/SPF/DKIM/DMARC/MTA-STS/SRV/CAA/TLS-RPT) and does ACME DNS-01.
|
||||||
|
# Provider tokens are fiddly and provider-specific (HE's API is flaky; Spaceship
|
||||||
|
# needs API access enabled on the key) — so this stays opt-in and user-managed.
|
||||||
|
STALWART_DNS_PROVIDER=spaceship # currently only 'spaceship' is wired
|
||||||
|
STALWART_DNS_API_KEY= # set => tier 2; blank => tier 1
|
||||||
|
STALWART_DNS_API_SECRET=
|
||||||
|
STALWART_DNS_DESC=managed # label for the x:DnsServer entry
|
||||||
|
# ACME contact email (enables Let's Encrypt DNS-01 in tier 2). Blank = skip ACME.
|
||||||
|
STALWART_ACME_CONTACT=
|
||||||
|
|
||||||
|
# --- SSO: let Authelia manage Stalwart login (optional) ---------------------
|
||||||
|
# true => provision-stalwart.sh creates an OIDC directory pointing at
|
||||||
|
# AUTHELIA_PORTAL_URL and prints the Authelia client block to paste. admin and
|
||||||
|
# the relay account KEEP password auth as break-glass, so SSO can't lock you out.
|
||||||
|
# (Login flow is UNVALIDATED on a throwaway here — test before trusting upstream.)
|
||||||
|
STALWART_SSO_ENABLE=false
|
||||||
|
STALWART_OIDC_CLIENT_SECRET= # shared secret for the Stalwart<->Authelia client
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
15
README.md
15
README.md
@ -24,6 +24,7 @@ tailwart/
|
|||||||
│ ├── caddy.json # :25/465/587/143/993 mail + :443 SNI fan-out → stalwart over the tailnet
|
│ ├── caddy.json # :25/465/587/143/993 mail + :443 SNI fan-out → stalwart over the tailnet
|
||||||
│ ├── docker-compose.yml # deploy on any public-IP, tailnet, tag:reverse-proxy host
|
│ ├── docker-compose.yml # deploy on any public-IP, tailnet, tag:reverse-proxy host
|
||||||
│ └── README.md
|
│ └── README.md
|
||||||
|
├── provision-stalwart.sh # one-shot: configure Stalwart entirely from .env (idempotent)
|
||||||
├── acl-snippet.hujson # tag:stalwart owner + grants to merge into your policy
|
├── acl-snippet.hujson # tag:stalwart owner + grants to merge into your policy
|
||||||
├── .env.example # operator surface — copy to .env
|
├── .env.example # operator surface — copy to .env
|
||||||
└── .gitignore
|
└── .gitignore
|
||||||
@ -44,12 +45,20 @@ cp .env.example .env && $EDITOR .env # fill secrets (see CLAUDE.md prereq
|
|||||||
# 2. admin console: assign tag:stalwart to the OAuth client + paste acl-snippet
|
# 2. admin console: assign tag:stalwart to the OAuth client + paste acl-snippet
|
||||||
# 3. bring up the mailbox (tailnet-only)
|
# 3. bring up the mailbox (tailnet-only)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
# 4. bring up the edge (binds public mail ports; can be a different host)
|
# 4. configure Stalwart entirely from .env — stores, listeners, domain (+DKIM),
|
||||||
|
# admin + relay/catch-all accounts, TLS/DNS, optional Authelia SSO. Idempotent.
|
||||||
|
./provision-stalwart.sh # add --print-dns to also dump records to publish
|
||||||
|
# 5. bring up the edge (binds public mail ports; can be a different host)
|
||||||
cd caddy && docker compose up -d --build
|
cd caddy && docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then point `infinidim.net`'s MX at the edge host, add SPF/DKIM/DMARC, and finish
|
`provision-stalwart.sh` replaces the setup wizard: it writes config straight into
|
||||||
configuration in Stalwart's web admin (`mail.infinidim.net`).
|
Postgres via the `x:` JMAP API. **Tier 1** (default) configures everything and
|
||||||
|
*prints* the DNS records for you to publish; **tier 2** (set `STALWART_DNS_API_KEY`)
|
||||||
|
lets Stalwart auto-publish DNS + run ACME DNS-01. See the comments in `.env.example`.
|
||||||
|
|
||||||
|
Then point `infinidim.net`'s MX at the edge host (or let tier-2 publish it) and
|
||||||
|
finish any opinionated bits (spam tuning, retention) in the web admin.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
265
provision-stalwart.sh
Normal file
265
provision-stalwart.sh
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# provision-stalwart.sh — bring a fresh Stalwart up *fully configured from .env*.
|
||||||
|
#
|
||||||
|
# Stalwart v0.16 keeps ALL of its config in Postgres (not files), reachable
|
||||||
|
# through the `x:` JMAP management objects. config/config.json only tells the
|
||||||
|
# image where Postgres lives; this script writes everything else the setup
|
||||||
|
# wizard would — stores, listeners, the primary domain (+DKIM), the admin and
|
||||||
|
# relay/catch-all accounts, TLS/DNS, and (optionally) SSO via Authelia — so the
|
||||||
|
# operator never has to touch the wizard for a working mail server.
|
||||||
|
#
|
||||||
|
# Idempotent: every object is keyed by a stable name/singleton and created only
|
||||||
|
# if missing, so re-running is safe. Run AFTER `docker compose up -d` (the
|
||||||
|
# stalwart sidecar must be reachable on the tailnet).
|
||||||
|
#
|
||||||
|
# ./provision-stalwart.sh # provision from ./.env
|
||||||
|
# ./provision-stalwart.sh --print-dns # also dump the zone records to publish
|
||||||
|
#
|
||||||
|
# Designed to drop into federatedSocial's bootstrap.sh as `cmd_provision_stalwart`.
|
||||||
|
#
|
||||||
|
# TIERS (the DNS/TLS automation boundary):
|
||||||
|
# * Default (trustless): domain in MANUAL dkim/dns mode; certs via the existing
|
||||||
|
# wildcard / HTTP-01; the script PRINTS the exact DNS records to publish.
|
||||||
|
# * Opt-in: if STALWART_DNS_API_KEY is set AND the provider probe succeeds, the
|
||||||
|
# domain flips to AUTOMATIC — Stalwart auto-publishes DNS + does DNS-01.
|
||||||
|
#
|
||||||
|
# NOTE: the Authelia-SSO step (provision_oidc) writes the Stalwart side via API
|
||||||
|
# but only PRINTS the Authelia client block to paste — editing Authelia's
|
||||||
|
# hand-maintained YAML from a script is deliberately avoided. The admin and
|
||||||
|
# relay accounts ALWAYS keep password auth as break-glass, so enabling SSO can
|
||||||
|
# never lock you out of Stalwart. The SSO login flow could not be validated
|
||||||
|
# against a throwaway instance here — verify it on a test deploy before trusting.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Setup: load .env, derive endpoint + admin auth
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ENV_FILE="${REPO_ROOT}/.env"
|
||||||
|
[[ -f "$ENV_FILE" ]] || { echo "Error: ${ENV_FILE} not found (cp .env.example .env)" >&2; exit 1; }
|
||||||
|
set -a; source "$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
PRINT_DNS=0; [[ "${1:-}" == "--print-dns" ]] && PRINT_DNS=1
|
||||||
|
|
||||||
|
for v in STALWART_MAGIC_NAME TS_TAILNET STALWART_DOMAIN STALWART_HOSTNAME \
|
||||||
|
STALWART_FALLBACK_ADMIN_SECRET REDIS_MAGIC_NAME STALWART_REDIS_DB \
|
||||||
|
GARAGE_MAGIC_NAME GARAGE_REGION GARAGE_ACCESS_KEY_ID GARAGE_SECRET_ACCESS_KEY \
|
||||||
|
STALWART_S3_BUCKET SMTP_USER SMTP_PASSWORD; do
|
||||||
|
[[ -n "${!v:-}" ]] || { echo "Error: required \$$v is empty in .env" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
SW_HOST="${STALWART_MAGIC_NAME}.${TS_TAILNET}"
|
||||||
|
SW_BASE="http://${SW_HOST}:8080"
|
||||||
|
SW_JMAP="${SW_BASE}/jmap"
|
||||||
|
NET_TRUST='{"100.64.0.0/10":true,"fd7a:115c:a1e0::/48":true}' # tailnet CGNAT + ULA
|
||||||
|
RELAY_LOCAL="${SMTP_USER%@*}" # zaphod
|
||||||
|
RELAY_DOMAIN="${SMTP_USER#*@}" # infinidim.net
|
||||||
|
ACCT_ID="b" # principals primary account
|
||||||
|
|
||||||
|
log(){ printf ' %s\n' "$*"; }
|
||||||
|
die(){ echo "Error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JMAP helpers (HTTP Basic — no PKCE/Bearer needed for scripting)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SW_AUTH="" # set by sw_auth()
|
||||||
|
|
||||||
|
# sw_call <methodCalls-json-array> -> raw response on stdout
|
||||||
|
sw_call(){
|
||||||
|
curl -s -m 25 -u "$SW_AUTH" -H 'Content-Type: application/json' "$SW_JMAP" -X POST \
|
||||||
|
--data "$(jq -nc --argjson mc "$1" '{using:["urn:ietf:params:jmap:core","urn:stalwart:jmap"],methodCalls:$mc}')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# sw_ok <response> — die if any error/notCreated/notUpdated/notDestroyed present
|
||||||
|
sw_ok(){
|
||||||
|
local r="$1"
|
||||||
|
if echo "$r" | jq -e '.methodResponses[0][0]=="error"' >/dev/null 2>&1; then
|
||||||
|
die "JMAP error: $(echo "$r" | jq -c '.methodResponses[0][1]')"
|
||||||
|
fi
|
||||||
|
if echo "$r" | jq -e '.methodResponses[0][1] | (.notCreated//{}|length>0) or (.notUpdated//{}|length>0) or (.notDestroyed//[]|length>0)' >/dev/null 2>&1; then
|
||||||
|
die "JMAP set rejected: $(echo "$r" | jq -c '.methodResponses[0][1]|{notCreated,notUpdated,notDestroyed}')"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# find id of object of TYPE whose `name` == NAME (empty if none)
|
||||||
|
sw_find_id(){ # $1 type $2 name
|
||||||
|
local r; r=$(sw_call "$(jq -nc --arg t "$1" '[[($t+"/query"),{accountId:"'"$ACCT_ID"'"},"0"],[($t+"/get"),{accountId:"'"$ACCT_ID"'","#ids":{resultOf:"0",name:($t+"/query"),path:"/ids"},properties:["name"]},"1"]]')")
|
||||||
|
echo "$r" | jq -r --arg n "$2" '.methodResponses[1][1].list[]? | select(.name==$n) | .id' | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# authenticate: prefer a real admin account; fall back to the first-boot virtual admin
|
||||||
|
sw_auth(){
|
||||||
|
local secret="$STALWART_FALLBACK_ADMIN_SECRET" u
|
||||||
|
for u in "admin@${STALWART_DOMAIN}" "admin"; do
|
||||||
|
if curl -s -m 8 -u "${u}:${secret}" -o /dev/null -w '%{http_code}' "${SW_JMAP}/session" 2>/dev/null | grep -q 200; then
|
||||||
|
SW_AUTH="${u}:${secret}"; log "authenticated as ${u}"; return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
die "cannot authenticate to ${SW_JMAP} as admin (check STALWART_FALLBACK_ADMIN_SECRET / first-boot state)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Provisioning steps
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
ensure_admin(){ # persistent superuser (survives once config is written; fallback then goes inert)
|
||||||
|
[[ -n "${DOMAIN_ID:-}" ]] || die "ensure_admin needs the domain first"
|
||||||
|
local id; id=$(sw_find_id x:Account admin)
|
||||||
|
if [[ -z "$id" ]]; then
|
||||||
|
log "creating persistent admin account"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg pw "$STALWART_FALLBACK_ADMIN_SECRET" --arg dom "$DOMAIN_ID" \
|
||||||
|
'[["x:Account/set",{accountId:"'"$ACCT_ID"'",create:{a:{"@type":"User",name:"admin",domainId:$dom,description:"System administrator",roles:{"@type":"Admin"},credentials:{"0":{"@type":"Password",secret:$pw}}}}},"0"]]')")"
|
||||||
|
else log "admin account present"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
re_auth(){ # switch to the persistent admin so the rest of the run is immune to the
|
||||||
|
# first-boot fallback going inert the moment a real admin exists.
|
||||||
|
local u="admin@${STALWART_DOMAIN}"
|
||||||
|
if curl -s -m 8 -u "${u}:${STALWART_FALLBACK_ADMIN_SECRET}" -o /dev/null -w '%{http_code}' "${SW_JMAP}/session" 2>/dev/null | grep -q 200; then
|
||||||
|
SW_AUTH="${u}:${STALWART_FALLBACK_ADMIN_SECRET}"; log "re-authenticated as ${u}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_stores(){
|
||||||
|
log "blob store -> Garage S3"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc \
|
||||||
|
--arg ep "http://${GARAGE_MAGIC_NAME}.${TS_TAILNET}:3900" --arg region "$GARAGE_REGION" \
|
||||||
|
--arg bucket "$STALWART_S3_BUCKET" --arg ak "$GARAGE_ACCESS_KEY_ID" --arg sk "$GARAGE_SECRET_ACCESS_KEY" \
|
||||||
|
'[["x:BlobStore/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{"@type":"S3",region:{"@type":"Custom",customEndpoint:$ep,customRegion:$region},bucket:$bucket,accessKey:$ak,secretKey:{"@type":"Value",secret:$sk},verifyAfterWrite:true}}},"0"]]')")"
|
||||||
|
log "in-memory store -> Redis db ${STALWART_REDIS_DB}"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg url "redis://${REDIS_MAGIC_NAME}.${TS_TAILNET}:6379/${STALWART_REDIS_DB}" \
|
||||||
|
'[["x:InMemoryStore/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{"@type":"Redis",url:$url}}},"0"]]')")"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_listeners(){
|
||||||
|
# name port protocol implicitTls proxyTrust(0/1)
|
||||||
|
local rows=(
|
||||||
|
"smtp 25 smtp 0 1" "submission 587 smtp 0 1" "submissions 465 smtp 1 1"
|
||||||
|
"imap 143 imap 0 1" "imaps 993 imap 1 1"
|
||||||
|
"http 8080 http 0 0" "https 443 http 1 0"
|
||||||
|
"sieve 4190 manageSieve 0 0"
|
||||||
|
)
|
||||||
|
local row name port proto impl trust id
|
||||||
|
for row in "${rows[@]}"; do
|
||||||
|
read -r name port proto impl trust <<<"$row"
|
||||||
|
id=$(sw_find_id x:NetworkListener "$name"); [[ -n "$id" ]] && { log "listener ${name} present"; continue; }
|
||||||
|
log "creating listener ${name} (:${port})"
|
||||||
|
local pt='{}'; [[ "$trust" == 1 ]] && pt="$NET_TRUST"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg n "$name" --arg bind "[::]:${port}" --arg p "$proto" \
|
||||||
|
--argjson impl "$([[ $impl == 1 ]] && echo true || echo false)" --argjson pt "$pt" \
|
||||||
|
'[["x:NetworkListener/set",{accountId:"'"$ACCT_ID"'",create:{l:{name:$n,bind:{($bind):true},protocol:$p,useTls:true,tlsImplicit:$impl,overrideProxyTrustedNetworks:$pt}}},"0"]]')")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_domain(){ # primary mail domain; DKIM auto-gen; dns/cert mode set later by tier
|
||||||
|
DOMAIN_ID=$(sw_find_id x:Domain "$STALWART_DOMAIN")
|
||||||
|
if [[ -z "$DOMAIN_ID" ]]; then
|
||||||
|
log "creating domain ${STALWART_DOMAIN} (auto DKIM)"
|
||||||
|
local r; r=$(sw_call "$(jq -nc --arg d "$STALWART_DOMAIN" --arg ca "$SMTP_USER" \
|
||||||
|
'[["x:Domain/set",{accountId:"'"$ACCT_ID"'",create:{d:{name:$d,isEnabled:true,description:"Primary mail domain",catchAllAddress:$ca,subAddressing:{"@type":"Enabled"},dkimManagement:{"@type":"Automatic",algorithms:{Dkim1Ed25519Sha256:true,Dkim1RsaSha256:true},selectorTemplate:"v{version}-{algorithm}-{date-%Y%m%d}",rotateAfter:7776000000,retireAfter:604800000,deleteAfter:2592000000}}}},"0"]]')")
|
||||||
|
sw_ok "$r"; DOMAIN_ID=$(echo "$r" | jq -r '.methodResponses[0][1].created.d.id')
|
||||||
|
else log "domain ${STALWART_DOMAIN} present"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_accounts(){ # relay / catch-all account from SMTP_USER (admin handled separately)
|
||||||
|
local id; id=$(sw_find_id x:Account "$RELAY_LOCAL")
|
||||||
|
if [[ -z "$id" ]]; then
|
||||||
|
log "creating relay account ${SMTP_USER}"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg n "$RELAY_LOCAL" --arg dom "$DOMAIN_ID" --arg pw "$SMTP_PASSWORD" \
|
||||||
|
'[["x:Account/set",{accountId:"'"$ACCT_ID"'",create:{a:{"@type":"User",name:$n,domainId:$dom,description:"Relay / catch-all",credentials:{"0":{"@type":"Password",secret:$pw}}}}},"0"]]')")"
|
||||||
|
else log "relay account ${SMTP_USER} present"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_system(){
|
||||||
|
log "system settings: hostname ${STALWART_HOSTNAME}"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg h "$STALWART_HOSTNAME" --arg d "$DOMAIN_ID" \
|
||||||
|
'[["x:SystemSettings/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{defaultHostname:$h,defaultDomainId:$d}}},"0"]]')")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- TLS / DNS tier -------------------------------------------------------
|
||||||
|
ensure_dns_tier(){
|
||||||
|
if [[ -n "${STALWART_DNS_API_KEY:-}" && "${STALWART_DNS_PROVIDER:-spaceship}" == "spaceship" ]]; then
|
||||||
|
log "DNS provider key present -> tier 2 (automatic publish via Spaceship)"
|
||||||
|
local sid; sid=$(sw_find_id x:DnsServer "${STALWART_DNS_DESC:-managed}")
|
||||||
|
if [[ -z "$sid" ]]; then
|
||||||
|
local r; r=$(sw_call "$(jq -nc --arg k "$STALWART_DNS_API_KEY" --arg s "${STALWART_DNS_API_SECRET:-}" --arg n "${STALWART_DNS_DESC:-managed}" \
|
||||||
|
'[["x:DnsServer/set",{accountId:"'"$ACCT_ID"'",create:{s:{"@type":"Spaceship",description:$n,apiKey:$k,secret:$s,ttl:300000,pollingInterval:15000,propagationTimeout:60000}}},"0"]]')")
|
||||||
|
sw_ok "$r"; sid=$(echo "$r" | jq -r '.methodResponses[0][1].created.s.id')
|
||||||
|
fi
|
||||||
|
log "domain -> Automatic DNS (origin ${STALWART_DOMAIN}) + ACME DNS-01"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg sid "$sid" --arg origin "$STALWART_DOMAIN" \
|
||||||
|
'[["x:Domain/set",{accountId:"'"$ACCT_ID"'",update:{"'"$DOMAIN_ID"'":{dnsManagement:{"@type":"Automatic",dnsServerId:$sid,origin:$origin,publishRecords:{autoConfig:true,autoConfigLegacy:true,autoDiscover:true,caa:true,dkim:true,dmarc:true,mtaSts:true,mx:true,spf:true,srv:true,tlsRpt:true}}}}},"0"]]')")"
|
||||||
|
ensure_acme_dns01
|
||||||
|
PRINT_DNS=0 # records publish themselves
|
||||||
|
else
|
||||||
|
log "no DNS API key -> tier 1 (trustless): manual DNS, records printed below"
|
||||||
|
PRINT_DNS=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_acme_dns01(){
|
||||||
|
[[ -z "${STALWART_ACME_CONTACT:-}" ]] && { log " (set STALWART_ACME_CONTACT to enable DNS-01 ACME)"; return; }
|
||||||
|
local id; id=$(sw_find_id x:AcmeProvider letsencrypt)
|
||||||
|
[[ -n "$id" ]] && { log " ACME provider present"; return; }
|
||||||
|
log " creating ACME provider (Let's Encrypt, DNS-01)"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg c "$STALWART_ACME_CONTACT" \
|
||||||
|
'[["x:AcmeProvider/set",{accountId:"'"$ACCT_ID"'",create:{p:{name:"letsencrypt",directory:"https://acme-v02.api.letsencrypt.org/directory",challengeType:"Dns01",contact:$c,renewBefore:"R23",maxRetries:10}}},"0"]]')")"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_dns_records(){
|
||||||
|
[[ "$PRINT_DNS" == 1 ]] || return 0
|
||||||
|
echo; echo "=== Publish these DNS records for ${STALWART_DOMAIN} (tier 1 / manual) ==="
|
||||||
|
sw_call "$(jq -nc --arg id "$DOMAIN_ID" '[["x:Domain/get",{accountId:"'"$ACCT_ID"'",ids:[$id],properties:["dnsZoneFile"]},"0"]]')" \
|
||||||
|
| jq -r '.methodResponses[0][1].list[0].dnsZoneFile // "(zone file unavailable)"'
|
||||||
|
echo "=== then re-run with STALWART_DNS_API_KEY set for automatic publishing ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Authelia SSO (opt-in; admin/relay keep password auth as break-glass) --
|
||||||
|
provision_oidc(){
|
||||||
|
[[ "${STALWART_SSO_ENABLE:-false}" == "true" ]] || { log "SSO disabled (set STALWART_SSO_ENABLE=true to wire Authelia)"; return; }
|
||||||
|
[[ -n "${AUTHELIA_PORTAL_URL:-}" ]] || die "STALWART_SSO_ENABLE=true but AUTHELIA_PORTAL_URL is empty"
|
||||||
|
local issuer="$AUTHELIA_PORTAL_URL" id
|
||||||
|
id=$(sw_find_id x:Directory "authelia")
|
||||||
|
if [[ -z "$id" ]]; then
|
||||||
|
log "creating Stalwart OIDC directory -> ${issuer}"
|
||||||
|
sw_ok "$(sw_call "$(jq -nc --arg iss "$issuer" --arg ud "$STALWART_DOMAIN" \
|
||||||
|
'[["x:Directory/set",{accountId:"'"$ACCT_ID"'",create:{d:{"@type":"Oidc",description:"authelia",issuerUrl:$iss,claimUsername:"preferred_username",claimName:"name",claimGroups:"groups",usernameDomain:$ud}}},"0"]]')")"
|
||||||
|
else log "OIDC directory present"; fi
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
>>> Add this client to authelia/configuration.yml under
|
||||||
|
>>> identity_providers.oidc.clients (hash the secret with:
|
||||||
|
>>> docker run --rm authelia/authelia authelia crypto hash generate pbkdf2 --password '<secret>')
|
||||||
|
- client_id: stalwart
|
||||||
|
client_name: Stalwart Mail
|
||||||
|
client_secret: '<PBKDF2-HASH-OF-${STALWART_OIDC_CLIENT_SECRET:-<generate one>}>'
|
||||||
|
public: false
|
||||||
|
authorization_policy: two_factor
|
||||||
|
redirect_uris:
|
||||||
|
- https://${STALWART_HOSTNAME}/auth/oauth
|
||||||
|
scopes: [openid, profile, email, groups]
|
||||||
|
>>> admin + ${SMTP_USER} keep password auth as break-glass — SSO can't lock you out.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
main(){
|
||||||
|
echo "Provisioning Stalwart at ${SW_BASE} from ${ENV_FILE}"
|
||||||
|
command -v jq >/dev/null || die "jq is required"
|
||||||
|
curl -s -m 8 -o /dev/null "${SW_BASE}/jmap/session" || die "cannot reach ${SW_BASE} (is the stack up?)"
|
||||||
|
sw_auth
|
||||||
|
ensure_stores
|
||||||
|
ensure_domain
|
||||||
|
ensure_admin
|
||||||
|
re_auth
|
||||||
|
ensure_accounts
|
||||||
|
ensure_listeners
|
||||||
|
ensure_system
|
||||||
|
ensure_dns_tier
|
||||||
|
provision_oidc
|
||||||
|
print_dns_records
|
||||||
|
echo "Done. Listeners rebind on container restart if newly created: docker compose restart stalwart"
|
||||||
|
}
|
||||||
|
# Run only when executed directly — so the file can be sourced for testing.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi
|
||||||
Loading…
Reference in New Issue
Block a user