Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# SMTP relay (Postfix + sasl-xoauth2 → Microsoft 365)
|
||||
|
||||
One internal SMTP endpoint that in-cluster apps use for outbound mail. It authenticates
|
||||
to Exchange Online with **OAuth2 (XOAUTH2)** via [`mauroreggio/postfix-365`], which bundles
|
||||
[`sasl-xoauth2`] — tokens are refreshed **inside the SASL layer**, no sidecar/cron. Basic-auth
|
||||
SMTP (app passwords) is being retired by Microsoft; this is the modern replacement.
|
||||
|
||||
```
|
||||
Authelia / Gitea / … ──plain SMTP :25 (in-cluster, no auth)──▶ smtp-relay ──587 STARTTLS + XOAUTH2──▶ smtp.office365.com
|
||||
```
|
||||
|
||||
Reach it at: `smtp-relay.smtp-relay.svc.cluster.local:25`. Sends **as** `[email protected]`.
|
||||
|
||||
[`mauroreggio/postfix-365`]: https://github.com/mauroreggio/postfix-365
|
||||
[`sasl-xoauth2`]: https://github.com/tarickb/sasl-xoauth2
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (Microsoft 365 / Entra — mostly interactive, one-time)
|
||||
|
||||
1. **`ddupan.top` is a verified domain** in the tenant (Admin center → Settings → Domains,
|
||||
Status = Healthy). If not, add it and complete the TXT verification (DNS is Cloudflare —
|
||||
can be done in `../../../infrastructure/cloudflared/terraform/`).
|
||||
2. **Mailbox `[email protected]`** exists with an **Exchange Online license**.
|
||||
3. **Authenticated SMTP enabled** on it: Admin center → Users → that user → Mail →
|
||||
*Manage email apps* → tick **Authenticated SMTP**. (OAuth won't work without this.)
|
||||
|
||||
## Step 1 — Entra app registration (as code)
|
||||
|
||||
```bash
|
||||
cd terraform
|
||||
az login # account that can create app regs AND grant admin consent (Global Admin)
|
||||
terraform init
|
||||
terraform apply # creates the app, delegated Graph SMTP.Send + admin consent, a client secret
|
||||
```
|
||||
|
||||
Grab the three values for the k8s secret:
|
||||
|
||||
```bash
|
||||
terraform output -raw client_id
|
||||
terraform output -raw tenant_id
|
||||
```
|
||||
|
||||
> This is a **PUBLIC** client (device-code flow) — there is **no client secret**. Leave
|
||||
> `CLIENT_SECRET` empty in `secret.yaml`. Presenting a secret makes Entra reject the token
|
||||
> refresh with `AADSTS700025 "Client is public..."`.
|
||||
|
||||
## Step 2 — Deploy the relay
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
cp secret.example.yaml secret.yaml # paste CLIENT_ID / CLIENT_SECRET / TENANT_ID
|
||||
kubectl apply -f namespace.yaml
|
||||
kubectl apply -f secret.yaml -f pvc.yaml
|
||||
kubectl apply -f deployment.yaml -f service.yaml
|
||||
kubectl -n smtp-relay rollout status deploy/smtp-relay
|
||||
```
|
||||
|
||||
At this point Postfix runs but has **no token yet**, so relaying fails until step 3.
|
||||
|
||||
## Step 3 — Bootstrap the token (one-time, interactive device-code)
|
||||
|
||||
The image's `sasl-xoauth2-tool` needs the `msal` Python module, which isn't bundled.
|
||||
Install it ephemerally (only needed for this one mint; the C++ SASL plugin refreshes
|
||||
without it):
|
||||
|
||||
```bash
|
||||
POD=$(kubectl get pod -n smtp-relay -l app=smtp-relay -o name | head -1 | cut -d/ -f2)
|
||||
kubectl exec -n smtp-relay $POD -- sh -c 'python3 -m ensurepip >/dev/null 2>&1; python3 -m pip install -q msal'
|
||||
```
|
||||
|
||||
Mint the token (env vars come from the pod's secret; `CLIENT_SECRET` is empty → public flow):
|
||||
|
||||
```bash
|
||||
kubectl exec -n smtp-relay -it deploy/smtp-relay -- sh -c \
|
||||
'sasl-xoauth2-tool get-token outlook /etc/tokens/[email protected] \
|
||||
--client-id="$CLIENT_ID" --tenant="$TENANT_ID" --client-secret="$CLIENT_SECRET" --use-device-flow'
|
||||
```
|
||||
|
||||
It prints a URL + code — open <https://microsoft.com/devicelogin> and **sign in as the SENDER
|
||||
mailbox `[email protected]`** (NOT yourself/the admin — a token minted for the wrong user gives
|
||||
`535 5.7.3`). If prompted for a client secret, press Enter. Then fix ownership so Postfix can
|
||||
read/rewrite it:
|
||||
|
||||
```bash
|
||||
kubectl exec -n smtp-relay $POD -- chown postfix:postfix /etc/tokens/[email protected]
|
||||
```
|
||||
|
||||
Verify identity if unsure: decode the token and check `upn` == `[email protected]`. The token
|
||||
persists on the PVC; sasl-xoauth2 refreshes it automatically thereafter.
|
||||
|
||||
## Step 4 — Test
|
||||
|
||||
```bash
|
||||
kubectl exec -n smtp-relay $POD -- sh -c \
|
||||
'echo "Subject: relay test\n\nhello" | sendmail -f [email protected] [email protected]'
|
||||
kubectl exec -n smtp-relay $POD -- tail -n 40 /var/log/maillog # look for "status=sent"
|
||||
```
|
||||
|
||||
## Step 5 — Point apps at it
|
||||
|
||||
- **Authelia** — replace the filesystem notifier with SMTP in `../authelia/values.yaml`:
|
||||
address `smtp://smtp-relay.smtp-relay.svc.cluster.local:25`, sender `[email protected]`,
|
||||
`disable_require_tls: true` (plain in-cluster hop). 2FA enrollment codes then go to real email.
|
||||
- Any future app: same address, **From = `[email protected]`** (O365 rejects other senders
|
||||
with `5.7.60` unless a send-as alias is configured in Exchange).
|
||||
|
||||
## Deliverability (keep mail out of Junk)
|
||||
|
||||
- **SPF / MX / DMARC** for `ddupan.top` already exist (M365 domain setup).
|
||||
- **DKIM**: ✅ **enabled 2026-07-28** (`Enabled: True`, `Status: Valid`). CNAMEs are in
|
||||
`../../infrastructure/cloudflared/terraform/` (`selector1/2._domainkey`); signing was turned on with
|
||||
`scripts/enable-dkim.ps1` then `scripts/enable-dkim-finish.ps1`.
|
||||
- ⚠️ **The CNAME target is NXDOMAIN until signing is enabled.** Microsoft creates the
|
||||
tenant host (`<tenant>.d-v1.dkim.mail.microsoft`) only at enable time, so a correct
|
||||
CNAME looks broken beforehand and `Get-DkimSigningConfig` reports `CnameMissing`.
|
||||
**Do not go hunting for the "real" CNAME value** — run step 2 and re-check DNS.
|
||||
- ⚠️ **Both scripts deadlock if run via `!` or with output redirected to a file** — the
|
||||
device code never becomes visible. Run under a PTY:
|
||||
`DOTNET_SYSTEM_NET_DISABLEIPV6=1 script -qfc "pwsh -NoProfile -File scripts/enable-dkim.ps1" /tmp/dkim.log`
|
||||
The `DISABLEIPV6` is required on the laptop — see the IPv6 trap in the root `CLAUDE.md`;
|
||||
without it `Connect-ExchangeOnline` hangs in `SYN-SENT` with no output at all.
|
||||
- **Still soft**: SPF is `~all` and DMARC is `p=none`. Harden to `-all` / `p=quarantine`
|
||||
once aggregate reports confirm DKIM passes — not before, or you quarantine your own mail.
|
||||
|
||||
## Notes / gotchas
|
||||
|
||||
- **PUBLIC client, no secret** — see Step 1. `CLIENT_SECRET` stays empty.
|
||||
- **Sign in as `noreply@` (the sender), not the admin**, during the Step 3 device login — a
|
||||
token minted for the wrong identity fails with `535 5.7.3`.
|
||||
- **`msal` is ephemeral** — reinstall it in the pod (Step 3) before any re-mint; it's gone after
|
||||
a restart but only the one-time mint needs it.
|
||||
- **Token PVC is writable state, not in Git.** The refresh token rotates; it lives only on
|
||||
the PVC. Back it up if you want to avoid re-running step 3.
|
||||
- **Refresh-token longevity**: Azure AD refresh tokens renew on use but can expire under
|
||||
Conditional Access / long idle — if relaying suddenly fails auth, re-run step 3.
|
||||
Reference in New Issue
Block a user