diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1606b4f..106f7f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
+- panel: new **Status** page — supervised processes, mail queue, TLS
+ certificate expiry, milter sockets and the server's own hostname/reverse-DNS
+ (FCrDNS) check — and it is now the panel's landing page. The local checks
+ refresh by polling; the DNS lookup is cached with a *Re-check* button.
+- panel: the domain page shows a **DNS status** card: the published DKIM record
+ compared against the key this server actually signs with, plus SPF and DMARC.
+ The SPF check is deliberately shallow — it looks for a mechanism literally
+ covering this server's address and does not follow `include:`/`redirect=`, so
+ a record that authorises the server through an include is reported as "cannot
+ tell", not as a failure.
+- panel: the domain list moved from `/` to `/domains`; `/` redirects to the
+ status page. The **Reload** button moved from the domain list to the status
+ page and now explains what it regenerates and when to use it.
+- fix: the panel could never read the mail queue in the documented deployment.
+ `postqueue` relies on its setgid-`postdrop` bit, which `no-new-privileges`
+ (set in the shipped compose file) disables, so the *Queue* screen always said
+ "Could not read the mail queue". The `panel` user is now a real member of
+ `postdrop`.
- panel: navigation bar is now rendered once from the shared layout, so every
authenticated page has it — including the domain page and the delete
confirmation, which had no navigation links at all — and the current page is
diff --git a/README.md b/README.md
index 685edb9..2922ba7 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,16 @@ mail lands in spam even though SelfPost delivered it correctly — DKIM passing
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
panel, add its DNS records at the same time**, not later.
+The panel checks both scopes for you and tells you what is actually published:
+the *Status* page verifies the server's hostname and its reverse record
+(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
+card comparing the published DKIM record against the key this server signs with,
+plus the domain's SPF and DMARC records. Results are cached for a few minutes;
+use *Re-check* right after publishing a record. The SPF check is deliberately
+shallow — it looks for a mechanism that literally covers this server's address
+and does not follow `include:` or `redirect=`, so a record that authorizes the
+server through an include is reported as "cannot tell" rather than as a failure.
+
## IP warmup
A brand-new IP has no sending history, so receiving servers are cautious with
diff --git a/build/Dockerfile b/build/Dockerfile
index 3810b8e..d45394f 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -67,6 +67,17 @@ RUN groupadd --system selfpost \
&& usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix
+# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
+# status page's queue card). postqueue is setgid postdrop, which normally gives
+# it the group needed to reach Postfix's showq socket — but the documented
+# deployment runs with `no-new-privileges`, which disables setgid transitions,
+# so the panel would always see "Permission denied". Making `panel` a real
+# member of postdrop grants the same access without relying on a setgid
+# escalation the hardening deliberately forbids. postdrop membership is
+# read-side only: it does not let the panel bypass any Postfix restriction that
+# a local user does not already have through the world-executable sendmail.
+RUN usermod -aG postdrop panel
+
# Runtime directories: milter sockets and the consolidated persistent root.
RUN mkdir -p /run/opendkim /run/selfpost /data \
&& chown opendkim:opendkim /run/opendkim \
diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go
index bf28d7f..cce384e 100644
--- a/cmd/panel/httpserver.go
+++ b/cmd/panel/httpserver.go
@@ -34,6 +34,9 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
DBPath: cfg.dbPath,
Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies,
+ TLSCertFile: cfg.tlsCertFile,
+ OpenDKIMSocket: cfg.opendkimSocket,
+ JournalSocket: cfg.journalSocket,
}, cfg.setupTokenPath)
if err != nil {
return err
diff --git a/cmd/panel/main.go b/cmd/panel/main.go
index 54f0456..ebf3797 100644
--- a/cmd/panel/main.go
+++ b/cmd/panel/main.go
@@ -61,6 +61,13 @@ type config struct {
submissionEnabled bool
trustedProxies []*net.IPNet
+ // Read-only inputs to the panel's status page: the certificate Postfix
+ // serves and the two milter sockets it connects to. The defaults mirror
+ // build/postfix-config.sh, so the status page checks exactly what Postfix
+ // was configured with.
+ tlsCertFile string
+ opendkimSocket string
+
opendkimDir string
dkimSelectorDef string
@@ -97,6 +104,9 @@ func loadConfig() config {
// told which proxy to trust.
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
+ tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
+ opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
+
// Per-domain DKIM state (spec 6). The directory layout matches what
// entrypoint.sh prepares (setgid, shared `selfpost` group).
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
index 4d28326..cf58b39 100644
--- a/docs/implementation-plan.md
+++ b/docs/implementation-plan.md
@@ -11,6 +11,10 @@
подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
+**Фаза 13 (страница `/status`, DNS-статус домена, перенос списка доменов на
+`/domains`, перенос кнопки Reload) выполнена** — детали в
+[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
+
**Основа:** [specification.md](specification.md) v1.0.
---
@@ -50,87 +54,6 @@
---
-## Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов
-
-**Статус:** запланирована, не начата.
-
-**Цель:** дать оператору один взгляд на «сервис жив и почта не будет зарубаться из-за DNS» — два независимых экрана: здоровье процесса (`/status`) и корректность DNS для каждого отправляющего домена (встроено в существующую страницу домена). Выходит за рамки ТЗ v1.0 (не было в разделе 7.2), согласовано с пользователем как отдельная фаза. **`/status` также становится стартовой страницей панели** (см. пункт C) — первое, что видит админ после логина, вместо списка доменов — и получает кнопку «Reload», перенесённую с дашборда с понятным пояснением назначения (см. пункт D).
-
-### A. `/status` — здоровье сервера (только сервер, без доменов)
-
-1. **Процессы supervisord** — `supervisorctl status` (fixed-argv, без shell, как остальные exec-вызовы проекта), статус opendkim/postfix/panel/cert-reload/logrotate.
-2. **Очередь Postfix** — переиспользовать `internal/postfix.Queue()`.
-3. **TLS-сертификат** — распарсить `x509.NotAfter` из `TLS_CERT_FILE`, предупреждение при приближении срока истечения.
-4. **Milter-сокеты** (`os.Stat` на `opendkim.sock`/`journal.sock`): отсутствие `opendkim.sock` — **ошибка** (при `default_action=tempfail` почта не уходит, см. [progress.md:132](progress.md)); отсутствие `journal.sock` — **предупреждение** (fail-open, почта уходит, но Send Log не пишется).
-5. **PTR / прямое-обратное соответствие (FCrDNS) — ключевая проверка для доставляемости:**
- - резолвим A/AAAA `SELFPOST_HOSTNAME` → IP сервера;
- - резолвим PTR этого IP → имя;
- - сравниваем PTR-имя с `SELFPOST_HOSTNAME`.
- - **OK** — совпадают; **ошибка** — PTR отсутствует или не совпадает (многие принимающие сервера отклоняют/спамят почту без корректного PTR).
- - Резолвленный IP сервера переиспользуется в пункте B.2 (SPF) — отдельный env для IP не нужен.
-
-Дешёвые проверки (1–4) — HTMX-polling как на остальных экранах мониторинга (~5с). PTR/hostname-lookup чуть дороже сети — кэш с TTL (например 1 мин) или отдельная кнопка «Recheck», не завязывать на 5-секундный polling.
-
-### B. DNS-статус домена (на странице `domain_detail.html`, рядом с существующей DKIM TXT-записью)
-
-1. **DKIM** — резолвим `._domainkey.` TXT, сравниваем с реальным ключом OpenDKIM (переиспользовать логику генерации записи из [dkim.go](../internal/domain/dkim.go)).
-2. **SPF (поверхностная проверка, по решению пользователя)** — резолвим TXT домена, ищем запись с `v=spf1`. Если найдена — проверяем **только присутствие** механизма, покрывающего IP сервера (`ip4:`/`ip6:` буквально, либо `a`/`mx` без аргумента, резолвящийся в IP сервера из A.5). **Без** рекурсии по `include`, без полной RFC 7208-оценки pass/fail/softfail — осознанное упрощение (без новых зависимостей).
-3. **DMARC** — TXT `_dmarc.`, наличие + политика (`none`/`quarantine`/`reject`).
-4. Статусы по каждому пункту: не найдено / найдено, но не покрывает наш сервис / корректно. Кэш на домен (TTL ~5–10 мин) + кнопка «Recheck» (без autopolling — DNS дороже, чем локальные проверки блока A).
-
-### C. `/status` — стартовая страница панели
-
-Сейчас корневой маршрут `GET /{$}` (`handleDashboard`, [web.go:109](../internal/web/web.go)) отдаёт список
-доменов — это же первая страница после логина. По решению пользователя стартовой страницей должна
-быть страница статуса, а не список доменов.
-
-- Список доменов (карточки «Add a sending domain» + «Domains» из [dashboard.html](../internal/web/templates/dashboard.html)) переезжает на новый маршрут `GET /domains` (тот же `handleDashboard`, просто перевешенный на другой путь; `POST /domains` для добавления домена остаётся как есть — коллизий с `GET /domains` нет, разные методы).
-- Корневой `GET /{$}` начинает рендерить `/status` (страницу из блока A) — либо редиректом `/{$}` → `/status`, либо status-хендлер напрямую вешается и на `/`, и на `/status` (без редиректа, чуть дешевле). Редирект проще и не создаёт двух путей для одного контента — предпочтительный вариант.
-- **Логин:** `handleLogin` после успешной аутентификации сейчас редиректит на `/` — поведение не меняется (просто `/` теперь означает статус, а не домены), правки в `handlers_auth.go` не требуется.
-- **Навигация:** пункт «Status» становится первым в общем `nav`-partial ([layout.html](../internal/web/templates/layout.html), Фаза 12) и получает `Active == "status"`; пункт «Domains» указывает на `/domains` вместо `/`. Это расширяет список `Active`-значений, заведённый в Фазе 12, а не меняет его архитектуру.
-- Везде, где по коду сейчас зашит редирект/ссылка на `/` как «страница доменов» (например, `← Domains` в `queue.html`, `sendlog.html`, `logtail.html`, `domain_detail.html`, `domain_delete.html`), ссылку нужно поменять на `/domains`.
-
-**Готово (доп. к критерию блока A/B):** `GET /` открывает `/status`; список доменов доступен по `GET /domains`; все ссылки «← Domains» и пункт навигации «Domains» ведут на `/domains`; логин после успешной аутентификации попадает на страницу статуса.
-
-**Риски:** нужно пройтись по всем захардкоженным `href="/"` в шаблонах (см. список выше) — пропущенная ссылка тихо ведёт на статус вместо доменов, а не ломается явно, поэтому стоит грепнуть `href="/"` после реализации и проверить каждое совпадение.
-
-### D. Кнопка «Reload» — назначение непонятно, присутствует непоследовательно
-
-`handleReload` ([handlers_domains.go:119](../internal/web/handlers_domains.go)) пересобирает конфигурацию OpenDKIM и Postfix-карту отправителей из БД и перечитывает оба демона (spec 7.2.12) — это **ручной drift-recovery** («на всякий случай пересобери конфиг с нуля»), а не элемент навигации. Сейчас кнопка:
-- есть **только** на дашборде ([dashboard.html:9](../internal/web/templates/dashboard.html)), в топбаре вперемешку с навигационными ссылками — на `queue`/`sendlog`/`logtail`/`domain_detail` её нет;
-- подписана просто «Reload» без единого слова, что она делает;
-- после нажатия даёт флеш «Configuration reloaded.» — тоже без объяснения, что было пересобрано и зачем это могло понадобиться.
-
-Итог: пользователю (да и админу, впервые видящему панель) неясно, что это за действие, когда его стоит нажимать, и почему оно недоступно с других страниц.
-
-**Решение:** перенести Reload на `/status` (блок A) — тематически это ровно то же самое действие, что и остальной блок здоровья сервера (проверка/восстановление рабочего состояния демонов), и после Фазы 13 `/status` и так становится стартовой страницей, так что кнопка не теряется, а оказывается на самом заметном месте.
-
-- Убрать кнопку/форму `POST /reload` из [dashboard.html](../internal/web/templates/dashboard.html) и топбара.
-- Добавить на `/status` карточку «Configuration» (или секцию рядом с блоком A.1–A.4) с формой `POST /reload` и явным пояснением: *«Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use this if you edited files manually, restored a backup, or the config looks out of sync with the domain/application list below — it does not affect the mail queue or TLS.»*
-- Редирект `handleReload` — сейчас `"/?reloaded=1"` ([handlers_domains.go:130](../internal/web/handlers_domains.go)); после переноса меняется на `"/status?reloaded=1"`, и флеш-сообщение обрабатывается на странице статуса (`dashboardFlash` для `reloaded` убирается — переносится в статус-хендлер, `deleted`/остальные флеши дашборда остаются на месте).
-- Логика `handleReload` (сам `Resync` обоих компонентов) не меняется — это чисто перенос UI и текста, не изменение поведения.
-
-**Готово (доп. к критерию блока A/B/C):** кнопка Reload есть только на `/status`, с текстом, объясняющим, что именно она пересобирает и когда это нужно; на дашборде/остальных страницах кнопки/флеша про reload больше нет.
-
-**Риски:** нет — чисто перенос существующего, уже проверенного действия; главное — не потерять флеш-сообщение при переносе редиректа.
-
-### Архитектура
-
-- Новый пакет `internal/dnscheck`: `ServerIP(hostname)` (A/AAAA lookup), `PTRCheck(hostname, ip)`, `DKIMCheck(domain, selector, expectedKey)`, `SPFCheck(domain, serverIP)`, `DMARCCheck(domain)` — все с таймаутом на DNS-запрос (~5с), через `net.DefaultResolver`/контекст.
-- Кэш результатов в памяти (мьютекс, TTL), без изменений схемы БД/миграций.
-- Веб: `GET /status` + `GET /status/fragment` (быстрый блок A, для polling); секция DNS-статуса на `domain_detail.html` + `POST /domains/{id}/dns-recheck` (форс, обход кэша).
-- Безопасность: ничего нового по ТЗ 7.6 не добавляет — DNS/exec-вызовы только за авторизованным админом, без пользовательского ввода в exec (доменные имена уже провалидированы при добавлении домена), таймауты на все сетевые вызовы (защита от зависания страницы).
-
-**Готово, когда:** `/status` показывает живой статус всех пяти пунктов блока A и обновляется polling'ом и является стартовой страницей (`GET /`); список доменов доступен на `GET /domains`; на странице домена отображается статус DKIM/SPF/DMARC с кнопкой Recheck; PTR-проверка корректно ловит и совпадение, и несовпадение (проверено на реальном домене `selfpost.example.com`, у которого PTR уже настроен, — см. [selfpost-prod-deployment.md]); кнопка «Reload» присутствует только на `/status`, с пояснением назначения; `gofmt`/`vet`/`test`/`docker build` зелёные.
-
-**Риски:** DNS-резолверы могут быть медленными/недоступными — все проверки таймаутят и не блокируют остальной UI (страница рендерится с "неизвестно/timeout", а не висит). Ложные срабатывания SPF-эвристики (например, домен покрывает IP сервера через `include:` стороннего сервиса, который в свою очередь резолвится в IP сервера) — задокументировать как известное ограничение поверхностной проверки. Плюс риск блока C — не пропустить захардкоженную ссылку на `/`.
-
-**Модель:** Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности).
-
-**Зависимости:** нет — общий `nav`-partial и `Active`-механизм навигации уже введены Фазой 12; использует также уже реализованные `internal/postfix.Queue()`, DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.
-
----
-
## Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A)
**Статус:** запланирована, не начата.
diff --git a/docs/progress.md b/docs/progress.md
index df5e7e3..7446628 100644
--- a/docs/progress.md
+++ b/docs/progress.md
@@ -36,7 +36,9 @@
- **Базовый линейный план 0→11 (v1.0) полностью выполнен и принят** (аудит безопасности ТЗ 7.6 — полное соответствие, деплой в проде подтверждён). Подробности — в git-истории и `CHANGELOG.md`.
- **Фаза 12 (v1.x, UI/UX) выполнена:** общий `nav`-partial в `layout.html` (нав на каждой аутентифицированной странице + подсветка текущей через `Active`), `/account` (смена логина/пароля админа с проверкой текущего пароля и инвалидацией остальных сессий, `store.UpdateAdmin`), `/backup` (полный бэкап и импорт домена — две отдельные карточки, убраны с дашборда), карточка «Sending server settings» на странице домена (сервер/465/587 по `SUBMISSION_ENABLE`), кнопки Copy у DKIM-записи и нового пароля приложения, скрытие поля «Addresses» в режиме wildcard (`static/panel.js`). Проверено в контейнере на dev-сервере (setup→login→домен→приложение→смена пароля→импорт), `gofmt`/`vet`/`test`/`docker build` зелёные.
-- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), **Фаза 13 (v1.x, запланирована, не начата)** — страница статуса `/status` (процессы/очередь/TLS/milter-сокеты/PTR) + DNS-статус домена (DKIM/SPF-эвристика/DMARC), `/status` как стартовая страница и перенос кнопки Reload; **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
+- **Фаза 13 (v1.x, статус + DNS) выполнена:** новый пакет `internal/health` (supervisorctl-статус процессов, срок действия TLS-сертификата, milter-сокеты + общий словарь статусов ok/warn/error/unknown) и `internal/dnscheck` (FCrDNS для `SELFPOST_HOSTNAME`, DKIM против реального ключа, поверхностная SPF-эвристика, DMARC; кеш + таймаут, резолвер за интерфейсом — юнит-тесты без сети). Страница `/status` (карточки + HTMX-фрагмент `/status/fragment` на 5с) стала стартовой: `GET /` → 303 на `/status`, список доменов переехал на `GET /domains`, кнопка Reload перенесена на `/status` с пояснением. На странице домена — карточка «DNS status» с кнопкой Re-check (`POST /domains/{id}/dns-recheck`). Проверено в контейнере на dev-сервере против реального DNS: PTR совпадает (`selfpost.example.com`) и не совпадает (`example.com`), DKIM отсутствует/не совпадает, SPF отсутствует/через `include:` («не могу сказать»), DMARC `p=quarantine`/`p=reject`/нет; таймаут DNS деградирует в «unknown», страница не виснет.
+- **Попутно исправлен давний дефект:** панель никогда не могла прочитать очередь (`postqueue -p`) в штатном деплое — `postqueue` полагается на setgid-бит `postdrop`, а `no-new-privileges` из поставляемого compose его отключает, поэтому экран Queue всегда показывал «Could not read the mail queue» (в т.ч. в released 1.0.0). Пользователь `panel` теперь состоит в группе `postdrop` (`build/Dockerfile`).
+- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
## Рабочая петля (dev loop) — ВАЖНО
diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go
new file mode 100644
index 0000000..f0775c2
--- /dev/null
+++ b/internal/dnscheck/dnscheck.go
@@ -0,0 +1,232 @@
+// Package dnscheck performs the read-only DNS lookups behind the panel's
+// deliverability checks: forward-confirmed reverse DNS (FCrDNS) for the
+// server's own hostname, and the DKIM/SPF/DMARC records published for each
+// sending domain.
+//
+// Every lookup is bounded by a timeout and results are cached, because DNS is
+// the one part of the status page that talks to the network: a slow or dead
+// resolver must degrade a single card to "could not check", never hang the
+// page. Nothing here changes state — the panel only reports what the world can
+// see about this server.
+package dnscheck
+
+import (
+ "context"
+ "errors"
+ "net"
+ "strings"
+ "sync"
+ "time"
+
+ "codeberg.org/mix/selfpost/internal/health"
+)
+
+const (
+ // lookupTimeout bounds all the lookups of a single check together, so a
+ // dead resolver costs one wait and not one per record type.
+ lookupTimeout = 5 * time.Second
+ // serverTTL/domainTTL are how long a cached result stays fresh. The
+ // server's own hostname/PTR is cheap and rarely changes; a domain's
+ // records are three lookups, and the operator has just published them, so
+ // a few minutes plus an explicit Re-check button is the right trade.
+ serverTTL = time.Minute
+ domainTTL = 5 * time.Minute
+)
+
+// Result is the outcome of one published-record check.
+type Result struct {
+ Status health.Status
+ // Detail is a full sentence for the operator: what was found and, when
+ // something is wrong, what to do about it.
+ Detail string
+ // Records is what was actually found in DNS, shown verbatim so the
+ // operator can compare it with what they published.
+ Records []string
+}
+
+// Server is the state of the server's own name in DNS: the addresses
+// SELFPOST_HOSTNAME resolves to, and whether their PTR records point back at
+// it. Receiving servers weigh this heavily, so a mismatch is an error.
+type Server struct {
+ Hostname string
+ IPs []string // forward-resolved addresses, reused for the SPF check
+ PTR Result
+ CheckedAt time.Time
+}
+
+// Domain is the published-DNS state of one sending domain.
+type Domain struct {
+ Name string
+ DKIM Result
+ SPF Result
+ DMARC Result
+ Overall health.Status
+ CheckedAt time.Time
+}
+
+// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
+// tells the operator to publish (domain.DKIMRecord.Value), so the check
+// compares DNS against the key this server actually signs with. ServerIPs comes
+// from a preceding Server check.
+type Query struct {
+ Name string
+ Selector string
+ ExpectedDKIM string
+ ServerIPs []string
+}
+
+// resolver is the slice of *net.Resolver this package uses, as an interface so
+// tests can drive the checks without touching the network.
+type resolver interface {
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+ LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
+}
+
+// Checker runs the checks and caches their results. The zero value is not
+// usable; call New.
+type Checker struct {
+ resolver resolver
+ timeout time.Duration
+ serverTTL time.Duration
+ domainTTL time.Duration
+
+ mu sync.Mutex
+ servers map[string]cached[Server]
+ domains map[string]cached[Domain]
+}
+
+type cached[T any] struct {
+ value T
+ expires time.Time
+}
+
+// New returns a Checker using the process resolver and the package's default
+// timeout and cache lifetimes.
+func New() *Checker {
+ return newChecker(net.DefaultResolver, lookupTimeout, serverTTL, domainTTL)
+}
+
+func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
+ return &Checker{
+ resolver: r,
+ timeout: timeout,
+ serverTTL: srvTTL,
+ domainTTL: domTTL,
+ servers: make(map[string]cached[Server]),
+ domains: make(map[string]cached[Domain]),
+ }
+}
+
+// Server checks the server's own hostname. force skips the cache, for the
+// Re-check button.
+func (c *Checker) Server(hostname string, force bool) Server {
+ if !force {
+ c.mu.Lock()
+ entry, ok := c.servers[hostname]
+ c.mu.Unlock()
+ if ok && time.Now().Before(entry.expires) {
+ return entry.value
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
+ defer cancel()
+ srv := c.checkServer(ctx, hostname)
+
+ c.mu.Lock()
+ c.servers[hostname] = cached[Server]{value: srv, expires: srv.CheckedAt.Add(c.serverTTL)}
+ c.mu.Unlock()
+ return srv
+}
+
+// Domain checks one sending domain's published records. force skips the cache,
+// for the Re-check button on the domain page.
+func (c *Checker) Domain(q Query, force bool) Domain {
+ if !force {
+ c.mu.Lock()
+ entry, ok := c.domains[q.Name]
+ c.mu.Unlock()
+ if ok && time.Now().Before(entry.expires) {
+ return entry.value
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
+ defer cancel()
+ d := c.checkDomain(ctx, q)
+
+ c.mu.Lock()
+ c.domains[q.Name] = cached[Domain]{value: d, expires: d.CheckedAt.Add(c.domainTTL)}
+ c.mu.Unlock()
+ return d
+}
+
+// Forget drops a domain's cached result, so the next page view re-checks it.
+// Used when a domain is removed or re-imported.
+func (c *Checker) Forget(domainName string) {
+ c.mu.Lock()
+ delete(c.domains, domainName)
+ c.mu.Unlock()
+}
+
+// checkDomain runs the three record checks concurrently: they are independent,
+// and in series three timeouts would stack up into a page that looks hung.
+func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
+ d := Domain{Name: q.Name, CheckedAt: time.Now()}
+ var wg sync.WaitGroup
+ wg.Add(3)
+ go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
+ go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
+ go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
+ wg.Wait()
+ d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
+ return d
+}
+
+// lookupTXT wraps the resolver's TXT lookup, separating "the name does not
+// exist / has no TXT records" (a finding to report) from "the lookup failed"
+// (nothing was learned).
+func (c *Checker) lookupTXT(ctx context.Context, name string) (records []string, found bool, err error) {
+ txt, err := c.resolver.LookupTXT(ctx, name)
+ if err != nil {
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+ if len(txt) == 0 {
+ return nil, false, nil
+ }
+ return txt, true, nil
+}
+
+// lookupFailed is the shared shape for "the resolver did not answer": unknown,
+// not an accusation against the domain's configuration.
+func lookupFailed(what string, err error) Result {
+ return Result{
+ Status: health.StatusUnknown,
+ Detail: "Could not check " + what + ": the DNS lookup failed (" + dnsErrorText(err) + "). Try Re-check in a moment.",
+ }
+}
+
+// dnsErrorText reduces a resolver error to its message, without the internals
+// (Go wraps the name and server into the string form).
+func dnsErrorText(err error) string {
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) {
+ if dnsErr.IsTimeout {
+ return "timed out"
+ }
+ return dnsErr.Err
+ }
+ return err.Error()
+}
+
+// normalizeName lowercases a DNS name and drops the root label, so a PTR answer
+// ("mail.example.com.") compares equal to a configured hostname.
+func normalizeName(name string) string {
+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
+}
diff --git a/internal/dnscheck/dnscheck_test.go b/internal/dnscheck/dnscheck_test.go
new file mode 100644
index 0000000..04eddb9
--- /dev/null
+++ b/internal/dnscheck/dnscheck_test.go
@@ -0,0 +1,359 @@
+package dnscheck
+
+import (
+ "context"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "codeberg.org/mix/selfpost/internal/health"
+)
+
+// fakeResolver serves a fixed zone, so the checks can be driven through every
+// branch without touching the network. An absent name resolves to the same
+// "not found" DNSError the standard resolver returns for NXDOMAIN.
+type fakeResolver struct {
+ txt map[string][]string
+ addr map[string][]net.IPAddr
+ ptr map[string][]string
+ mx map[string][]*net.MX
+
+ // fail names that must return a transient failure instead of an answer.
+ fail map[string]bool
+ // lookups counts every query, for the cache tests.
+ lookups int
+}
+
+func notFound(name string) error {
+ return &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
+}
+
+func (f *fakeResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
+ f.lookups++
+ if f.fail[name] {
+ return nil, &net.DNSError{Err: "server misbehaving", Name: name, IsTemporary: true}
+ }
+ if v, ok := f.txt[name]; ok {
+ return v, nil
+ }
+ return nil, notFound(name)
+}
+
+func (f *fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
+ f.lookups++
+ if v, ok := f.addr[host]; ok {
+ return v, nil
+ }
+ return nil, notFound(host)
+}
+
+func (f *fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
+ f.lookups++
+ if v, ok := f.ptr[addr]; ok {
+ return v, nil
+ }
+ return nil, notFound(addr)
+}
+
+func (f *fakeResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
+ f.lookups++
+ if v, ok := f.mx[name]; ok {
+ return v, nil
+ }
+ return nil, notFound(name)
+}
+
+func ipAddrs(ips ...string) []net.IPAddr {
+ out := make([]net.IPAddr, 0, len(ips))
+ for _, s := range ips {
+ out = append(out, net.IPAddr{IP: net.ParseIP(s)})
+ }
+ return out
+}
+
+func newTestChecker(f *fakeResolver) *Checker {
+ return newChecker(f, time.Second, time.Minute, time.Minute)
+}
+
+func TestServerPTRMatches(t *testing.T) {
+ f := &fakeResolver{
+ addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
+ ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
+ }
+ srv := newTestChecker(f).Server("mail.example.com", false)
+ if srv.PTR.Status != health.StatusOK {
+ t.Fatalf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
+ }
+ if len(srv.IPs) != 1 || srv.IPs[0] != "203.0.113.10" {
+ t.Errorf("IPs = %v, want the forward-resolved address for the SPF check", srv.IPs)
+ }
+}
+
+func TestServerPTRMismatchIsAnError(t *testing.T) {
+ f := &fakeResolver{
+ addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
+ ptr: map[string][]string{"203.0.113.10": {"static-10.provider.net."}},
+ }
+ srv := newTestChecker(f).Server("mail.example.com", false)
+ if srv.PTR.Status != health.StatusError {
+ t.Fatalf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
+ }
+ if len(srv.PTR.Records) != 1 || !strings.Contains(srv.PTR.Records[0], "static-10.provider.net") {
+ t.Errorf("records = %v, want the PTR name that was actually found", srv.PTR.Records)
+ }
+}
+
+func TestServerPTRMissing(t *testing.T) {
+ f := &fakeResolver{addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")}}
+ srv := newTestChecker(f).Server("mail.example.com", false)
+ if srv.PTR.Status != health.StatusError {
+ t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
+ }
+}
+
+func TestServerPartialPTRWarns(t *testing.T) {
+ f := &fakeResolver{
+ addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10", "2001:db8::1")},
+ ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
+ }
+ srv := newTestChecker(f).Server("mail.example.com", false)
+ if srv.PTR.Status != health.StatusWarn {
+ t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
+ }
+}
+
+func TestServerHostnameDoesNotResolve(t *testing.T) {
+ srv := newTestChecker(&fakeResolver{}).Server("mail.example.com", false)
+ if srv.PTR.Status != health.StatusError {
+ t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
+ }
+ if len(srv.IPs) != 0 {
+ t.Errorf("IPs = %v, want none", srv.IPs)
+ }
+}
+
+func TestServerHostnameUnset(t *testing.T) {
+ srv := newTestChecker(&fakeResolver{}).Server("", false)
+ if srv.PTR.Status != health.StatusUnknown {
+ t.Errorf("status = %q, want unknown when SELFPOST_HOSTNAME is unset", srv.PTR.Status)
+ }
+}
+
+const testDKIMValue = "v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqTESTKEY"
+
+func dkimQuery(records map[string][]string) (*fakeResolver, Query) {
+ q := Query{
+ Name: "example.com",
+ Selector: "selfpost",
+ ExpectedDKIM: testDKIMValue,
+ ServerIPs: []string{"203.0.113.10"},
+ }
+ return &fakeResolver{txt: records}, q
+}
+
+func TestDKIMPublishedAndMatching(t *testing.T) {
+ f, q := dkimQuery(map[string][]string{
+ // Published with different spacing and a line break in the base64, as
+ // DNS providers and TXT chunking produce.
+ "selfpost._domainkey.example.com": {"v=DKIM1;h=sha256;k=rsa;p=MIIBIjANBgkq TESTKEY"},
+ })
+ got := newTestChecker(f).Domain(q, false)
+ if got.DKIM.Status != health.StatusOK {
+ t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
+ }
+}
+
+func TestDKIMMissing(t *testing.T) {
+ f, q := dkimQuery(nil)
+ got := newTestChecker(f).Domain(q, false)
+ if got.DKIM.Status != health.StatusError {
+ t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
+ }
+}
+
+func TestDKIMWrongKey(t *testing.T) {
+ f, q := dkimQuery(map[string][]string{
+ "selfpost._domainkey.example.com": {"v=DKIM1; h=sha256; k=rsa; p=SOMEOTHERKEY"},
+ })
+ got := newTestChecker(f).Domain(q, false)
+ if got.DKIM.Status != health.StatusError {
+ t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
+ }
+ if !strings.Contains(got.DKIM.Detail, "not the one this server signs with") {
+ t.Errorf("detail does not explain the mismatch: %s", got.DKIM.Detail)
+ }
+}
+
+func TestDKIMRevoked(t *testing.T) {
+ f, q := dkimQuery(map[string][]string{
+ "selfpost._domainkey.example.com": {"v=DKIM1; h=sha256; k=rsa; p="},
+ })
+ got := newTestChecker(f).Domain(q, false)
+ if got.DKIM.Status != health.StatusError || !strings.Contains(got.DKIM.Detail, "revokes") {
+ t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
+ }
+}
+
+func TestDKIMLookupFailureIsUnknown(t *testing.T) {
+ f, q := dkimQuery(nil)
+ f.fail = map[string]bool{"selfpost._domainkey.example.com": true}
+ got := newTestChecker(f).Domain(q, false)
+ if got.DKIM.Status != health.StatusUnknown {
+ t.Errorf("status = %q (%s), want unknown when the resolver fails", got.DKIM.Status, got.DKIM.Detail)
+ }
+}
+
+func TestSPF(t *testing.T) {
+ cases := []struct {
+ name string
+ record []string
+ want health.Status
+ }{
+ {"literal ip4", []string{"v=spf1 ip4:203.0.113.10 -all"}, health.StatusOK},
+ {"covering CIDR", []string{"v=spf1 ip4:203.0.113.0/24 -all"}, health.StatusOK},
+ {"other address only", []string{"v=spf1 ip4:198.51.100.7 -all"}, health.StatusError},
+ {"include cannot be followed", []string{"v=spf1 include:_spf.provider.net -all"}, health.StatusWarn},
+ {"plus all", []string{"v=spf1 +all"}, health.StatusWarn},
+ {"negative qualifier does not authorise", []string{"v=spf1 -ip4:203.0.113.10 -all"}, health.StatusError},
+ {"two records", []string{"v=spf1 ip4:203.0.113.10 -all", "v=spf1 -all"}, health.StatusError},
+ {"no record", nil, health.StatusError},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ txt := map[string][]string{}
+ if c.record != nil {
+ txt["example.com"] = c.record
+ }
+ f := &fakeResolver{txt: txt}
+ got := newTestChecker(f).checkSPF(context.Background(), Query{
+ Name: "example.com",
+ ServerIPs: []string{"203.0.113.10"},
+ })
+ if got.Status != c.want {
+ t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
+ }
+ })
+ }
+}
+
+func TestSPFAMechanism(t *testing.T) {
+ f := &fakeResolver{
+ txt: map[string][]string{"example.com": {"v=spf1 a -all"}},
+ addr: map[string][]net.IPAddr{"example.com": ipAddrs("203.0.113.10")},
+ }
+ got := newTestChecker(f).checkSPF(context.Background(), Query{
+ Name: "example.com",
+ ServerIPs: []string{"203.0.113.10"},
+ })
+ if got.Status != health.StatusOK {
+ t.Errorf("status = %q (%s)", got.Status, got.Detail)
+ }
+}
+
+func TestSPFMXMechanism(t *testing.T) {
+ f := &fakeResolver{
+ txt: map[string][]string{"example.com": {"v=spf1 mx -all"}},
+ mx: map[string][]*net.MX{"example.com": {{Host: "mail.example.com.", Pref: 10}}},
+ addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
+ }
+ got := newTestChecker(f).checkSPF(context.Background(), Query{
+ Name: "example.com",
+ ServerIPs: []string{"203.0.113.10"},
+ })
+ if got.Status != health.StatusOK {
+ t.Errorf("status = %q (%s)", got.Status, got.Detail)
+ }
+}
+
+func TestSPFWithoutServerIPIsUnknown(t *testing.T) {
+ f := &fakeResolver{txt: map[string][]string{"example.com": {"v=spf1 -all"}}}
+ got := newTestChecker(f).checkSPF(context.Background(), Query{Name: "example.com"})
+ if got.Status != health.StatusUnknown {
+ t.Errorf("status = %q (%s)", got.Status, got.Detail)
+ }
+}
+
+func TestDMARC(t *testing.T) {
+ cases := []struct {
+ name string
+ record []string
+ want health.Status
+ }{
+ {"reject", []string{"v=DMARC1; p=reject; rua=mailto:dmarc@example.com"}, health.StatusOK},
+ {"none", []string{"v=DMARC1; p=none"}, health.StatusOK},
+ {"no policy tag", []string{"v=DMARC1; rua=mailto:dmarc@example.com"}, health.StatusWarn},
+ {"absent", nil, health.StatusWarn},
+ {"duplicated", []string{"v=DMARC1; p=none", "v=DMARC1; p=reject"}, health.StatusError},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ txt := map[string][]string{}
+ if c.record != nil {
+ txt["_dmarc.example.com"] = c.record
+ }
+ f := &fakeResolver{txt: txt}
+ got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
+ if got.Status != c.want {
+ t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
+ }
+ })
+ }
+}
+
+func TestDMARCNonePolicyIsExplained(t *testing.T) {
+ f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
+ got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
+ if !strings.Contains(got.Detail, "monitoring only") {
+ t.Errorf("p=none is not explained: %s", got.Detail)
+ }
+}
+
+func TestResultsAreCachedAndForceBypassesTheCache(t *testing.T) {
+ f := &fakeResolver{
+ addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
+ ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
+ }
+ c := newTestChecker(f)
+
+ c.Server("mail.example.com", false)
+ after := f.lookups
+ if after == 0 {
+ t.Fatal("the first check did not query the resolver")
+ }
+ c.Server("mail.example.com", false)
+ if f.lookups != after {
+ t.Errorf("a second check re-queried DNS: %d lookups, want %d", f.lookups, after)
+ }
+ c.Server("mail.example.com", true)
+ if f.lookups == after {
+ t.Error("force did not bypass the cache")
+ }
+}
+
+func TestDomainOverallIsTheWorstOfTheThree(t *testing.T) {
+ f, q := dkimQuery(map[string][]string{
+ "selfpost._domainkey.example.com": {testDKIMValue},
+ "example.com": {"v=spf1 ip4:203.0.113.10 -all"},
+ // No DMARC: a warning.
+ })
+ got := newTestChecker(f).Domain(q, false)
+ if got.DKIM.Status != health.StatusOK || got.SPF.Status != health.StatusOK {
+ t.Fatalf("DKIM=%q SPF=%q", got.DKIM.Status, got.SPF.Status)
+ }
+ if got.Overall != health.StatusWarn {
+ t.Errorf("overall = %q, want the DMARC warning to surface", got.Overall)
+ }
+}
+
+func TestForgetDropsTheCachedDomain(t *testing.T) {
+ f, q := dkimQuery(map[string][]string{"selfpost._domainkey.example.com": {testDKIMValue}})
+ c := newTestChecker(f)
+ c.Domain(q, false)
+ before := f.lookups
+ c.Forget(q.Name)
+ c.Domain(q, false)
+ if f.lookups == before {
+ t.Error("Forget did not drop the cached result")
+ }
+}
diff --git a/internal/dnscheck/records.go b/internal/dnscheck/records.go
new file mode 100644
index 0000000..bd15ba8
--- /dev/null
+++ b/internal/dnscheck/records.go
@@ -0,0 +1,130 @@
+package dnscheck
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "codeberg.org/mix/selfpost/internal/health"
+)
+
+// checkDKIM compares the TXT record published at ._domainkey.
+// with the key this server signs with. A wrong or absent record means every
+// message fails DKIM at the receiver, so both are errors.
+func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
+ name := q.Selector + "._domainkey." + q.Name
+ txt, found, err := c.lookupTXT(ctx, name)
+ if err != nil {
+ return lookupFailed("the DKIM record", err)
+ }
+
+ expected := publicKeyTag(q.ExpectedDKIM)
+ if !found {
+ return Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("No TXT record is published at %s. Publish the record shown above — until then every message fails DKIM.", name),
+ }
+ }
+
+ for _, rec := range txt {
+ got := publicKeyTag(rec)
+ if got == "" {
+ continue
+ }
+ if got == expected {
+ return Result{
+ Status: health.StatusOK,
+ Detail: fmt.Sprintf("Published at %s and matching the key this server signs with.", name),
+ Records: txt,
+ }
+ }
+ }
+
+ // Something is published, but it is not our key. Separate the revoked case
+ // (empty p=), which reads as a deliberate act rather than a typo.
+ for _, rec := range txt {
+ if v, ok := tagValue(rec, "p"); ok && v == "" {
+ return Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("The record at %s has an empty p= tag, which revokes the key. Replace it with the record shown above.", name),
+ Records: txt,
+ }
+ }
+ }
+ return Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("A TXT record exists at %s but its public key is not the one this server signs with — mail will fail DKIM. Replace it with the record shown above (an old record from a previous server is the usual cause).", name),
+ Records: txt,
+ }
+}
+
+// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
+// required for delivery, so its absence is advice (warn), not a fault.
+func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
+ name := "_dmarc." + domainName
+ txt, found, err := c.lookupTXT(ctx, name)
+ if err != nil {
+ return lookupFailed("the DMARC record", err)
+ }
+
+ var records []string
+ for _, rec := range txt {
+ if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
+ records = append(records, rec)
+ }
+ }
+ if !found || len(records) == 0 {
+ return Result{
+ Status: health.StatusWarn,
+ Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least \"v=DMARC1; p=none; rua=mailto:you@%s\" tells receivers what to do with mail that fails DKIM and gets you reports.", name, domainName),
+ }
+ }
+ if len(records) > 1 {
+ return Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("More than one DMARC record is published at %s. Receivers treat that as no policy at all — keep exactly one.", name),
+ Records: records,
+ }
+ }
+
+ policy, ok := tagValue(records[0], "p")
+ if !ok || policy == "" {
+ return Result{
+ Status: health.StatusWarn,
+ Detail: "A DMARC record is published but has no p= policy tag, so receivers ignore it. Add p=none, p=quarantine or p=reject.",
+ Records: records,
+ }
+ }
+ detail := fmt.Sprintf("Published with policy p=%s.", policy)
+ if strings.EqualFold(policy, "none") {
+ detail += " That is monitoring only — tighten it to quarantine or reject once the reports look clean."
+ }
+ return Result{Status: health.StatusOK, Detail: detail, Records: records}
+}
+
+// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
+// whitespace removed: DNS providers and TXT chunking freely insert spaces and
+// line breaks into the base64, none of which are part of the key.
+func publicKeyTag(record string) string {
+ v, ok := tagValue(record, "p")
+ if !ok {
+ return ""
+ }
+ return strings.Join(strings.Fields(v), "")
+}
+
+// tagValue reads one tag from a DKIM/DMARC-style "tag=value; tag=value" record.
+// Tag names are case-sensitive per RFC 6376/7489, and values keep their case.
+func tagValue(record, tag string) (string, bool) {
+ for _, part := range strings.Split(record, ";") {
+ part = strings.TrimSpace(part)
+ key, value, found := strings.Cut(part, "=")
+ if !found {
+ continue
+ }
+ if strings.TrimSpace(key) == tag {
+ return strings.TrimSpace(value), true
+ }
+ }
+ return "", false
+}
diff --git a/internal/dnscheck/server.go b/internal/dnscheck/server.go
new file mode 100644
index 0000000..b711d58
--- /dev/null
+++ b/internal/dnscheck/server.go
@@ -0,0 +1,89 @@
+package dnscheck
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "codeberg.org/mix/selfpost/internal/health"
+)
+
+// checkServer resolves the panel's own hostname and confirms the reverse
+// lookup of each address points back at that name (FCrDNS). A missing or
+// mismatched PTR is the single most common reason mail from a self-hosted
+// server is rejected or scored as spam, which is why it is an error and not
+// advice.
+func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
+ srv := Server{Hostname: hostname, CheckedAt: time.Now()}
+ if hostname == "" {
+ srv.PTR = Result{
+ Status: health.StatusUnknown,
+ Detail: "SELFPOST_HOSTNAME is not set, so the server's own name in DNS cannot be checked. Set it in the deployment environment.",
+ }
+ return srv
+ }
+
+ addrs, err := c.resolver.LookupIPAddr(ctx, hostname)
+ if err != nil || len(addrs) == 0 {
+ srv.PTR = Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("%s does not resolve to any address. Publish an A (or AAAA) record for it — receiving servers check the name this server announces in HELO.", hostname),
+ }
+ return srv
+ }
+
+ want := normalizeName(hostname)
+ matched, total := 0, len(addrs)
+ var records []string
+ for _, a := range addrs {
+ ip := a.IP.String()
+ srv.IPs = append(srv.IPs, ip)
+
+ names, err := c.resolver.LookupAddr(ctx, ip)
+ if err != nil || len(names) == 0 {
+ records = append(records, ip+" → no PTR record")
+ continue
+ }
+ hit := false
+ for _, n := range names {
+ if normalizeName(n) == want {
+ hit = true
+ }
+ }
+ if hit {
+ matched++
+ records = append(records, ip+" → "+normalizeName(names[0]))
+ } else {
+ records = append(records, ip+" → "+normalizeName(names[0])+" (does not match)")
+ }
+ }
+
+ srv.PTR.Records = records
+ switch {
+ case matched == total:
+ srv.PTR.Status = health.StatusOK
+ srv.PTR.Detail = fmt.Sprintf("%s resolves to %s and the reverse lookup points back at it.", hostname, joinIPs(srv.IPs))
+ case matched > 0:
+ srv.PTR.Status = health.StatusWarn
+ srv.PTR.Detail = fmt.Sprintf("Only %d of %d addresses of %s have a matching PTR record. Mail sent from the others may be rejected — set the reverse DNS of every address at your hosting provider.", matched, total, hostname)
+ default:
+ srv.PTR.Status = health.StatusError
+ srv.PTR.Detail = fmt.Sprintf("No address of %s has a reverse (PTR) record pointing back at it. Many receiving servers reject or spam-score mail from such a host — set the reverse DNS of the server's IP to %s at your hosting provider.", hostname, hostname)
+ }
+ return srv
+}
+
+func joinIPs(ips []string) string {
+ switch len(ips) {
+ case 0:
+ return "no address"
+ case 1:
+ return ips[0]
+ default:
+ out := ips[0]
+ for _, ip := range ips[1:] {
+ out += ", " + ip
+ }
+ return out
+ }
+}
diff --git a/internal/dnscheck/spf.go b/internal/dnscheck/spf.go
new file mode 100644
index 0000000..0ba1405
--- /dev/null
+++ b/internal/dnscheck/spf.go
@@ -0,0 +1,285 @@
+package dnscheck
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strings"
+
+ "codeberg.org/mix/selfpost/internal/health"
+)
+
+// spfLookupBudget caps how many DNS lookups one SPF evaluation may spend on
+// a/mx mechanisms. RFC 7208 allows a receiver ten; staying under the same
+// ceiling keeps a hostile or careless record from turning a page view into a
+// long chain of queries.
+const spfLookupBudget = 10
+
+// checkSPF reports whether the domain's SPF record authorises this server.
+//
+// This is deliberately a shallow check (see docs/implementation-plan.md, phase
+// 13.B.2): it looks for a mechanism that literally covers the server's address —
+// ip4:/ip6:, or a/mx resolving to it — and does not recurse into include: or
+// redirect=, nor evaluate the record the way a receiver would. That is why a
+// record which does not obviously cover us but does use include: is reported as
+// a warning ("cannot tell") rather than a failure.
+func (c *Checker) checkSPF(ctx context.Context, q Query) Result {
+ ips := parseIPs(q.ServerIPs)
+ if len(ips) == 0 {
+ return Result{
+ Status: health.StatusUnknown,
+ Detail: "The server's own address is not known (its hostname does not resolve), so SPF coverage cannot be checked. Fix the hostname/PTR check first.",
+ }
+ }
+
+ txt, found, err := c.lookupTXT(ctx, q.Name)
+ if err != nil {
+ return lookupFailed("the SPF record", err)
+ }
+
+ var records []string
+ for _, rec := range txt {
+ if isSPF(rec) {
+ records = append(records, strings.TrimSpace(rec))
+ }
+ }
+ switch {
+ case !found || len(records) == 0:
+ return Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as \"v=spf1 ip4:%s -all\" — without it receivers have nothing authorising this server to send as the domain.", q.Name, ips[0]),
+ }
+ case len(records) > 1:
+ return Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("More than one SPF record is published for %s. RFC 7208 allows exactly one; receivers treat several as a permanent error and the domain gets no SPF pass at all. Merge them into a single record.", q.Name),
+ Records: records,
+ }
+ }
+
+ matched, unfollowed := c.evaluateSPF(ctx, records[0], q.Name, ips)
+ switch {
+ case matched == "+all" || matched == "all":
+ return Result{
+ Status: health.StatusWarn,
+ Detail: "The SPF record ends with \"+all\", which authorises every server on the internet to send as this domain. Replace it with an explicit ip4:/ip6: or a mechanism plus \"-all\".",
+ Records: records,
+ }
+ case matched != "":
+ return Result{
+ Status: health.StatusOK,
+ Detail: fmt.Sprintf("The SPF record authorises this server through its %q mechanism.", matched),
+ Records: records,
+ }
+ case len(unfollowed) > 0:
+ return Result{
+ Status: health.StatusWarn,
+ Detail: fmt.Sprintf("No mechanism in the SPF record lists %s directly, but the record uses %s, which this check does not follow — the server may still be authorised through it. Verify with an external SPF validator, or add \"ip4:%s\" to be sure.",
+ ips[0], strings.Join(unfollowed, ", "), ips[0]),
+ Records: records,
+ }
+ default:
+ return Result{
+ Status: health.StatusError,
+ Detail: fmt.Sprintf("The SPF record does not authorise %s, so mail sent from this server fails SPF. Add \"ip4:%s\" (or an \"a\" mechanism resolving here) to the record.", ips[0], ips[0]),
+ Records: records,
+ }
+ }
+}
+
+// evaluateSPF walks the record's mechanisms, returning the first one that
+// covers one of the server's addresses, plus the mechanisms this shallow check
+// cannot resolve (include:/redirect=/exists:/ptr and anything past the lookup
+// budget) so the caller can say "cannot tell" instead of "fails".
+func (c *Checker) evaluateSPF(ctx context.Context, record, domainName string, ips []net.IP) (matched string, unfollowed []string) {
+ budget := spfLookupBudget
+ seenUnfollowed := make(map[string]bool)
+ note := func(kind string) {
+ if !seenUnfollowed[kind] {
+ seenUnfollowed[kind] = true
+ unfollowed = append(unfollowed, kind)
+ }
+ }
+
+ terms := strings.Fields(record)
+ if len(terms) > 0 {
+ terms = terms[1:] // drop the v=spf1 version token
+ }
+ for _, term := range terms {
+ qualifier, mech := splitQualifier(term)
+ lower := strings.ToLower(mech)
+ name, hasArg := mechanismArg(mech)
+
+ switch {
+ case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
+ if qualifier != '+' {
+ continue
+ }
+ if coversAny(mech[4:], ips) {
+ return term, unfollowed
+ }
+
+ case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
+ if strings.Contains(mech, "/") { // prefix-length form: not evaluated
+ note("a/")
+ continue
+ }
+ target := domainName
+ if hasArg {
+ target = name
+ }
+ if budget <= 0 {
+ note("further lookups")
+ continue
+ }
+ budget--
+ if qualifier == '+' && c.resolvesTo(ctx, target, ips) {
+ return term, unfollowed
+ }
+
+ case lower == "mx" || strings.HasPrefix(lower, "mx:") || strings.HasPrefix(lower, "mx/"):
+ if strings.Contains(mech, "/") {
+ note("mx/")
+ continue
+ }
+ target := domainName
+ if hasArg {
+ target = name
+ }
+ if budget <= 0 {
+ note("further lookups")
+ continue
+ }
+ budget--
+ if qualifier == '+' && c.mxResolvesTo(ctx, target, ips, &budget) {
+ return term, unfollowed
+ }
+
+ case strings.HasPrefix(lower, "include:"):
+ note("include:")
+ case strings.HasPrefix(lower, "redirect="):
+ note("redirect=")
+ case strings.HasPrefix(lower, "exists:"):
+ note("exists:")
+ case lower == "ptr" || strings.HasPrefix(lower, "ptr:"):
+ note("ptr")
+
+ case lower == "all":
+ if qualifier == '+' {
+ return "+all", unfollowed
+ }
+ // "-all"/"~all"/"?all" terminates the record: nothing after it is
+ // evaluated by a receiver either.
+ return "", unfollowed
+ }
+ }
+ return "", unfollowed
+}
+
+// resolvesTo reports whether name resolves to one of the server's addresses.
+func (c *Checker) resolvesTo(ctx context.Context, name string, ips []net.IP) bool {
+ addrs, err := c.resolver.LookupIPAddr(ctx, name)
+ if err != nil {
+ return false
+ }
+ for _, a := range addrs {
+ for _, ip := range ips {
+ if a.IP.Equal(ip) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// mxResolvesTo reports whether any of name's MX hosts resolves to one of the
+// server's addresses, spending at most the remaining lookup budget.
+func (c *Checker) mxResolvesTo(ctx context.Context, name string, ips []net.IP, budget *int) bool {
+ mxs, err := c.resolver.LookupMX(ctx, name)
+ if err != nil {
+ return false
+ }
+ for _, mx := range mxs {
+ if *budget <= 0 {
+ return false
+ }
+ *budget--
+ if c.resolvesTo(ctx, strings.TrimSuffix(mx.Host, "."), ips) {
+ return true
+ }
+ }
+ return false
+}
+
+// coversAny reports whether an ip4:/ip6: value — a bare address or a CIDR —
+// contains one of the server's addresses.
+func coversAny(value string, ips []net.IP) bool {
+ value = strings.TrimSpace(value)
+ if strings.Contains(value, "/") {
+ _, network, err := net.ParseCIDR(value)
+ if err != nil {
+ return false
+ }
+ for _, ip := range ips {
+ if network.Contains(ip) {
+ return true
+ }
+ }
+ return false
+ }
+ listed := net.ParseIP(value)
+ if listed == nil {
+ return false
+ }
+ for _, ip := range ips {
+ if listed.Equal(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// splitQualifier peels the optional +/-/~/? qualifier off a mechanism,
+// defaulting to "+" (pass) as RFC 7208 does.
+func splitQualifier(term string) (byte, string) {
+ if term == "" {
+ return '+', ""
+ }
+ switch term[0] {
+ case '+', '-', '~', '?':
+ return term[0], term[1:]
+ default:
+ return '+', term
+ }
+}
+
+// mechanismArg returns the ":" argument of a mechanism, if it has one.
+func mechanismArg(mech string) (string, bool) {
+ _, arg, found := strings.Cut(mech, ":")
+ if !found || arg == "" {
+ return "", false
+ }
+ return arg, true
+}
+
+// isSPF reports whether a TXT record is an SPF record (the version token must
+// be the whole first term, so "v=spf10" is not one).
+func isSPF(record string) bool {
+ rec := strings.TrimSpace(record)
+ if len(rec) < 6 || !strings.EqualFold(rec[:6], "v=spf1") {
+ return false
+ }
+ return len(rec) == 6 || rec[6] == ' ' || rec[6] == '\t'
+}
+
+// parseIPs converts the string addresses carried on a Query back into net.IPs,
+// dropping anything unparsable.
+func parseIPs(in []string) []net.IP {
+ var ips []net.IP
+ for _, s := range in {
+ if ip := net.ParseIP(s); ip != nil {
+ ips = append(ips, ip)
+ }
+ }
+ return ips
+}
diff --git a/internal/health/cert.go b/internal/health/cert.go
new file mode 100644
index 0000000..f529af5
--- /dev/null
+++ b/internal/health/cert.go
@@ -0,0 +1,83 @@
+package health
+
+import (
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "os"
+ "time"
+)
+
+// certWarnDays is how close to expiry the certificate has to be before the
+// status page complains. Let's Encrypt renews at 30 days left, so two weeks is
+// comfortably past the point where automatic renewal should have happened.
+const certWarnDays = 14
+
+// Certificate is the state of the TLS certificate Postfix serves on 465/587
+// (spec 8: TLS_CERT_FILE). The panel only reads it — the file is supplied by
+// the reverse proxy through a read-only mount.
+type Certificate struct {
+ Path string
+ Subject string
+ NotAfter time.Time
+ DaysLeft int
+ Status Status
+ Detail string
+}
+
+// CheckCertificate parses the leaf certificate at path and reports how much
+// validity is left. A missing or unparsable file is an error status rather than
+// an error return: the status page reports it in place, like every other check.
+func CheckCertificate(path string) Certificate {
+ c := Certificate{Path: path}
+ if path == "" {
+ c.Status = StatusUnknown
+ c.Detail = "No certificate path is configured (TLS_CERT_FILE)."
+ return c
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ c.Status = StatusError
+ c.Detail = fmt.Sprintf("Could not read the certificate at %s.", path)
+ return c
+ }
+ leaf, err := parseLeaf(data)
+ if err != nil {
+ c.Status = StatusError
+ c.Detail = fmt.Sprintf("%s does not contain a readable certificate.", path)
+ return c
+ }
+
+ c.Subject = leaf.Subject.CommonName
+ c.NotAfter = leaf.NotAfter
+ c.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
+ switch {
+ case !time.Now().Before(leaf.NotAfter):
+ c.Status = StatusError
+ c.Detail = "The certificate has expired. Senders will refuse the TLS connection."
+ case c.DaysLeft < certWarnDays:
+ c.Status = StatusWarn
+ c.Detail = fmt.Sprintf("Expires in %d day(s). Check that renewal on the host still works.", c.DaysLeft)
+ default:
+ c.Status = StatusOK
+ c.Detail = fmt.Sprintf("Valid for another %d day(s).", c.DaysLeft)
+ }
+ return c
+}
+
+// parseLeaf returns the first certificate in a PEM chain — the leaf, which is
+// the one whose validity clients see.
+func parseLeaf(data []byte) (*x509.Certificate, error) {
+ for rest := data; len(rest) > 0; {
+ var block *pem.Block
+ block, rest = pem.Decode(rest)
+ if block == nil {
+ break
+ }
+ if block.Type != "CERTIFICATE" {
+ continue
+ }
+ return x509.ParseCertificate(block.Bytes)
+ }
+ return nil, fmt.Errorf("no CERTIFICATE block found")
+}
diff --git a/internal/health/health.go b/internal/health/health.go
new file mode 100644
index 0000000..5da60a1
--- /dev/null
+++ b/internal/health/health.go
@@ -0,0 +1,53 @@
+// Package health reports the running container's own operating state for the
+// panel's status screen: the supervised processes, the TLS certificate Postfix
+// serves, and the milter sockets delivery depends on.
+//
+// Every check is read-only and reports a problem as a Status value rather than
+// an error return, so one broken component degrades a single line of the status
+// page instead of blanking the whole thing. The package also owns the Status
+// vocabulary shared with internal/dnscheck, so the panel renders every check —
+// local or DNS — through one set of badges.
+package health
+
+// Status is the outcome of a single check, in the order the status page treats
+// them: unknown < ok < warn < error, worst wins for a group.
+type Status string
+
+const (
+ // StatusUnknown means the check could not be performed at all (a missing
+ // setting, an unreachable resolver) — not evidence of a problem.
+ StatusUnknown Status = "unknown"
+ // StatusOK means the checked component is in its expected state.
+ StatusOK Status = "ok"
+ // StatusWarn means something is off but mail still flows.
+ StatusWarn Status = "warn"
+ // StatusError means mail delivery is (or soon will be) affected.
+ StatusError Status = "error"
+)
+
+// severity orders statuses so a group can report its worst member.
+func (s Status) severity() int {
+ switch s {
+ case StatusError:
+ return 3
+ case StatusWarn:
+ return 2
+ case StatusOK:
+ return 1
+ default:
+ return 0
+ }
+}
+
+// Worst returns the most severe of the given statuses, or StatusUnknown when
+// there are none. It is how the status page rolls a list of checks up into one
+// headline.
+func Worst(statuses ...Status) Status {
+ worst := StatusUnknown
+ for _, s := range statuses {
+ if s.severity() > worst.severity() {
+ worst = s
+ }
+ }
+ return worst
+}
diff --git a/internal/health/health_test.go b/internal/health/health_test.go
new file mode 100644
index 0000000..37994ee
--- /dev/null
+++ b/internal/health/health_test.go
@@ -0,0 +1,165 @@
+package health
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestWorstPicksMostSevere(t *testing.T) {
+ cases := []struct {
+ in []Status
+ want Status
+ }{
+ {nil, StatusUnknown},
+ {[]Status{StatusOK, StatusOK}, StatusOK},
+ {[]Status{StatusOK, StatusWarn}, StatusWarn},
+ {[]Status{StatusWarn, StatusError, StatusOK}, StatusError},
+ {[]Status{StatusUnknown, StatusOK}, StatusOK},
+ }
+ for _, c := range cases {
+ if got := Worst(c.in...); got != c.want {
+ t.Errorf("Worst(%v) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestParseProcesses(t *testing.T) {
+ // Real supervisorctl output: column-aligned, one line per program.
+ out := `opendkim RUNNING pid 21, uptime 0:04:10
+panel RUNNING pid 22, uptime 0:04:09
+postfix FATAL Exited too quickly (process log may have details)
+postfix-reload STOPPED Not started
+logrotate RUNNING pid 25, uptime 0:04:08
+`
+ procs := parseProcesses(out)
+ if len(procs) != 5 {
+ t.Fatalf("parsed %d processes, want 5: %+v", len(procs), procs)
+ }
+ want := map[string]Status{
+ "opendkim": StatusOK,
+ "panel": StatusOK,
+ "postfix": StatusError,
+ "postfix-reload": StatusOK, // one-shot: idle is its healthy state
+ "logrotate": StatusOK,
+ }
+ for _, p := range procs {
+ if want[p.Name] != p.Status {
+ t.Errorf("%s (%s): status %q, want %q", p.Name, p.State, p.Status, want[p.Name])
+ }
+ }
+ if procs[0].Detail != "pid 21, uptime 0:04:10" {
+ t.Errorf("detail = %q", procs[0].Detail)
+ }
+}
+
+func TestParseProcessesSkipsNonStatusLines(t *testing.T) {
+ out := `error: , [Errno 2] No such file or directory
+unix:///run/supervisor.sock refused connection
+`
+ if procs := parseProcesses(out); len(procs) != 0 {
+ t.Errorf("error output parsed as processes: %+v", procs)
+ }
+}
+
+func TestCheckCertificate(t *testing.T) {
+ dir := t.TempDir()
+
+ valid := filepath.Join(dir, "valid.pem")
+ writeCert(t, valid, "mail.example.com", 90*24*time.Hour)
+ if got := CheckCertificate(valid); got.Status != StatusOK {
+ t.Errorf("valid certificate: status %q (%s)", got.Status, got.Detail)
+ } else if got.Subject != "mail.example.com" {
+ t.Errorf("subject = %q", got.Subject)
+ }
+
+ soon := filepath.Join(dir, "soon.pem")
+ writeCert(t, soon, "mail.example.com", 3*24*time.Hour)
+ if got := CheckCertificate(soon); got.Status != StatusWarn {
+ t.Errorf("nearly expired certificate: status %q (%s)", got.Status, got.Detail)
+ }
+
+ expired := filepath.Join(dir, "expired.pem")
+ writeCert(t, expired, "mail.example.com", -24*time.Hour)
+ if got := CheckCertificate(expired); got.Status != StatusError {
+ t.Errorf("expired certificate: status %q (%s)", got.Status, got.Detail)
+ }
+
+ if got := CheckCertificate(filepath.Join(dir, "absent.pem")); got.Status != StatusError {
+ t.Errorf("missing certificate: status %q", got.Status)
+ }
+
+ junk := filepath.Join(dir, "junk.pem")
+ if err := os.WriteFile(junk, []byte("not a certificate\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got := CheckCertificate(junk); got.Status != StatusError {
+ t.Errorf("unparsable certificate: status %q", got.Status)
+ }
+
+ if got := CheckCertificate(""); got.Status != StatusUnknown {
+ t.Errorf("unconfigured certificate: status %q", got.Status)
+ }
+}
+
+func TestCheckSocket(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "opendkim.sock")
+ l, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Skipf("unix sockets unavailable here: %v", err)
+ }
+ defer l.Close()
+
+ if got := CheckSocket("OpenDKIM", sock, true); got.Status != StatusOK || !got.Present {
+ t.Errorf("live socket: status %q present=%v", got.Status, got.Present)
+ }
+
+ missing := filepath.Join(dir, "journal.sock")
+ if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
+ t.Errorf("missing optional socket: status %q", got.Status)
+ }
+ if got := CheckSocket("OpenDKIM", missing, true); got.Status != StatusError {
+ t.Errorf("missing required socket: status %q", got.Status)
+ }
+
+ plain := filepath.Join(dir, "plain")
+ if err := os.WriteFile(plain, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got := CheckSocket("OpenDKIM", plain, true); got.Status != StatusError || got.Present {
+ t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present)
+ }
+}
+
+// writeCert writes a self-signed certificate expiring after validFor (negative
+// for an already-expired one).
+func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: cn},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(validFor),
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+ if err := os.WriteFile(path, body, 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/health/processes.go b/internal/health/processes.go
new file mode 100644
index 0000000..bc8efd0
--- /dev/null
+++ b/internal/health/processes.go
@@ -0,0 +1,99 @@
+package health
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+// supervisorConf is the supervisord configuration the panel's other control
+// calls already address (see internal/postfix and internal/domain).
+const supervisorConf = "/etc/supervisor/supervisord.conf"
+
+// oneShotPrograms are supervisord entries that are meant to sit idle: they are
+// started on demand and exit immediately, so STOPPED/EXITED is their healthy
+// state rather than a fault (see build/supervisord.conf).
+var oneShotPrograms = map[string]bool{
+ "postfix-reload": true,
+}
+
+// Process is one supervised program as supervisord reports it.
+type Process struct {
+ Name string
+ State string // supervisord's own state word, e.g. RUNNING
+ Detail string // the rest of the line: pid/uptime, or exit information
+ Status Status
+}
+
+// Processes returns the state of every supervised program (spec 4's three
+// processes plus the reload/cert/logrotate helpers).
+//
+// The command takes fixed arguments and no user input, so it never goes through
+// a shell (spec 7.6.3). `supervisorctl status` deliberately exits non-zero when
+// some program is not running, so the output is parsed first and the exit status
+// only matters when nothing could be parsed from it.
+func Processes() ([]Process, error) {
+ cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
+ out, err := cmd.CombinedOutput()
+ procs := parseProcesses(string(out))
+ if len(procs) == 0 {
+ if err != nil {
+ return nil, fmt.Errorf("supervisorctl status: %w: %s", err, strings.TrimSpace(string(out)))
+ }
+ return nil, fmt.Errorf("supervisorctl status: no programs reported")
+ }
+ return procs, nil
+}
+
+// supervisorStates are the state words supervisord prints. Lines whose second
+// field is not one of them are not status lines (banners, error text) and are
+// skipped, so unexpected output cannot masquerade as a process.
+var supervisorStates = map[string]bool{
+ "STOPPED": true,
+ "STARTING": true,
+ "RUNNING": true,
+ "BACKOFF": true,
+ "STOPPING": true,
+ "EXITED": true,
+ "FATAL": true,
+ "UNKNOWN": true,
+}
+
+// parseProcesses turns supervisorctl's tabular output into Process values. Each
+// status line is "", column-aligned with spaces.
+func parseProcesses(out string) []Process {
+ var procs []Process
+ for _, line := range strings.Split(out, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 2 || !supervisorStates[fields[1]] {
+ continue
+ }
+ name, state := fields[0], fields[1]
+ procs = append(procs, Process{
+ Name: name,
+ State: state,
+ Detail: strings.Join(fields[2:], " "),
+ Status: processStatus(name, state),
+ })
+ }
+ return procs
+}
+
+// processStatus grades a supervisord state. A one-shot program that is not
+// running is healthy; anything else that is not RUNNING means a component of
+// the mail path is down or flapping.
+func processStatus(name, state string) Status {
+ switch state {
+ case "RUNNING":
+ return StatusOK
+ case "STARTING", "STOPPING":
+ return StatusWarn
+ case "STOPPED", "EXITED":
+ if oneShotPrograms[name] {
+ return StatusOK
+ }
+ return StatusError
+ default: // BACKOFF, FATAL, UNKNOWN
+ return StatusError
+ }
+}
diff --git a/internal/health/socket.go b/internal/health/socket.go
new file mode 100644
index 0000000..04cfcc0
--- /dev/null
+++ b/internal/health/socket.go
@@ -0,0 +1,56 @@
+package health
+
+import (
+ "fmt"
+ "os"
+)
+
+// Socket is the state of one milter socket Postfix connects to.
+type Socket struct {
+ Name string
+ Path string
+ Present bool
+ Status Status
+ Detail string
+}
+
+// CheckSocket stats a milter socket. required distinguishes the two milters:
+// OpenDKIM runs with default_action=tempfail, so a missing socket stops mail
+// leaving the server, while the journal-milter fails open — mail still goes out,
+// only the send log stops being written.
+func CheckSocket(name, path string, required bool) Socket {
+ s := Socket{Name: name, Path: path}
+ if path == "" {
+ s.Status = StatusUnknown
+ s.Detail = "No socket path is configured."
+ return s
+ }
+ fi, err := os.Stat(path)
+ switch {
+ case err != nil:
+ s.Status = missingStatus(required)
+ s.Detail = missingDetail(name, required)
+ case fi.Mode()&os.ModeSocket == 0:
+ s.Status = missingStatus(required)
+ s.Detail = fmt.Sprintf("%s exists but is not a socket.", path)
+ default:
+ s.Present = true
+ s.Status = StatusOK
+ s.Detail = "Listening."
+ }
+ return s
+}
+
+func missingStatus(required bool) Status {
+ if required {
+ return StatusError
+ }
+ return StatusWarn
+}
+
+func missingDetail(name string, required bool) string {
+ if required {
+ return fmt.Sprintf("The %s socket is missing. Postfix rejects mail with a temporary error until it is back.", name)
+ }
+ return fmt.Sprintf("The %s socket is missing. Mail still goes out, but the send log is not being written.", name)
+}
diff --git a/internal/web/handlers_apps.go b/internal/web/handlers_apps.go
index d381e70..cb690fd 100644
--- a/internal/web/handlers_apps.go
+++ b/internal/web/handlers_apps.go
@@ -7,6 +7,8 @@ import (
"strconv"
"strings"
+ "codeberg.org/mix/selfpost/internal/dnscheck"
+ "codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
)
@@ -100,6 +102,10 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
"Active": "domains",
"Domain": d,
"Record": record,
+ // What DNS actually publishes for the domain today, checked against the
+ // key this server signs with (phase 13.B). Cached by the checker, so
+ // re-rendering the page after a form post costs nothing.
+ "DNS": s.domainDNS(d, record, false),
// Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment.
@@ -122,6 +128,38 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
})
}
+// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
+// records (phase 13.B). The server's own address comes from the (separately
+// cached) hostname check, so the SPF heuristic knows which IP it is looking for
+// and no extra environment variable is needed. force bypasses the cache, for the
+// Re-check button.
+func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) dnscheck.Domain {
+ srv := s.dns.Server(s.cfg.Hostname, false)
+ return s.dns.Domain(dnscheck.Query{
+ Name: d.Name,
+ Selector: d.DKIMSelector,
+ ExpectedDKIM: record.Value,
+ ServerIPs: srv.IPs,
+ }, force)
+}
+
+// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
+// returns to its page, which then renders the fresh result.
+func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
+ d, ok := s.lookupDomain(w, r)
+ if !ok {
+ return
+ }
+ record, err := s.domains.DKIMRecord(d)
+ if err != nil {
+ logf("panel: domain %d: dkim record: %v", d.ID, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ s.domainDNS(d, record, true)
+ http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
+}
+
// intOrBlank renders a non-positive number as an empty string so an unset field
// shows blank rather than "0".
func intOrBlank(n int) string {
@@ -152,6 +190,8 @@ func detailFlash(r *http.Request) string {
return "Rate limit updated."
case r.URL.Query().Get("imported") != "":
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
+ case r.URL.Query().Get("rechecked") != "":
+ return "DNS re-checked."
default:
return ""
}
diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go
index 0c2feb2..bdb7e91 100644
--- a/internal/web/handlers_domains.go
+++ b/internal/web/handlers_domains.go
@@ -40,14 +40,10 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func dashboardFlash(r *http.Request) string {
- switch {
- case r.URL.Query().Get("reloaded") != "":
- return "Configuration reloaded."
- case r.URL.Query().Get("deleted") != "":
+ if r.URL.Query().Get("deleted") != "" {
return "Domain deleted."
- default:
- return ""
}
+ return ""
}
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
@@ -101,6 +97,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ // Drop any cached DNS verdict for the name while it is still resolvable, so
+ // re-adding the domain later starts from a fresh check instead of a stale
+ // one from before it was removed.
+ if d, err := s.domains.Get(id); err == nil {
+ defer s.dns.Forget(d.Name)
+ }
if err := s.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
@@ -110,12 +112,15 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
}
// handleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the
// database and reloads its daemon, so the button doubles as a drift-recovery.
+// The button lives on the status page (phase 13.D): it is a "put the daemons
+// back in the state the database describes" action, which belongs with the rest
+// of the server-health screen rather than in the domain list's top bar.
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
if err := s.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err)
@@ -127,7 +132,7 @@ func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, "/?reloaded=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
}
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
diff --git a/internal/web/handlers_status.go b/internal/web/handlers_status.go
new file mode 100644
index 0000000..fbe8c1d
--- /dev/null
+++ b/internal/web/handlers_status.go
@@ -0,0 +1,131 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+
+ "codeberg.org/mix/selfpost/internal/health"
+)
+
+// handleStatus renders the server status page: the panel's landing page and the
+// one screen that answers "is the service healthy and will mail be accepted"
+// (phase 13.A). The cheap local checks live in the polled "status_body"
+// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
+// because neither belongs on a five-second timer.
+func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
+ data := s.statusBody()
+ srv := s.dns.Server(s.cfg.Hostname, false)
+
+ data["Title"] = "SelfPost — status"
+ data["User"] = currentUser(r)
+ data["Active"] = "status"
+ data["Flash"] = statusFlash(r)
+ data["Hostname"] = s.cfg.Hostname
+ data["PTR"] = srv.PTR
+ s.render(w, http.StatusOK, "status", data)
+}
+
+// handleStatusFragment serves the HTMX polling fragment for the local checks
+// (spec 7.1: fragment endpoints return HTML, not JSON).
+func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
+ s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
+}
+
+// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache,
+// and returns to the page. DNS is the one part of this screen that talks to the
+// network, so it refreshes on demand rather than with the poll.
+func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
+ s.dns.Server(s.cfg.Hostname, true)
+ http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
+}
+
+// statusBody collects the four local checks the fragment renders. Each one
+// reports its own problem rather than failing the page, so a broken component
+// costs one line and not the whole screen.
+func (s *Server) statusBody() map[string]any {
+ procs, procErr := health.Processes()
+ procStatus := health.StatusUnknown
+ if procErr != nil {
+ // Outside the container (or if the control socket is gone) there is
+ // nothing to report — "unknown", not "everything is broken".
+ logf("panel: status: supervisorctl: %v", procErr)
+ } else {
+ for _, p := range procs {
+ procStatus = health.Worst(procStatus, p.Status)
+ }
+ }
+
+ queueText, queueErr := readQueue()
+ queueStatus := health.StatusOK
+ if queueErr != "" {
+ queueStatus = health.StatusWarn
+ }
+
+ cert := health.CheckCertificate(s.cfg.TLSCertFile)
+ sockets := []health.Socket{
+ // OpenDKIM signs every outgoing message and Postfix is configured to
+ // tempfail without it: a missing socket stops mail.
+ health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true),
+ // The journal-milter only records the send log and fails open.
+ health.CheckSocket("send-log", s.cfg.JournalSocket, false),
+ }
+ socketStatus := health.StatusUnknown
+ for _, sock := range sockets {
+ socketStatus = health.Worst(socketStatus, sock.Status)
+ }
+
+ overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus)
+ return map[string]any{
+ "Processes": procs,
+ "ProcessError": procErr != nil,
+ "ProcessStatus": procStatus,
+ "QueueSummary": queueSummary(queueText),
+ "QueueError": queueErr,
+ "QueueStatus": queueStatus,
+ "Cert": cert,
+ "Sockets": sockets,
+ "SocketStatus": socketStatus,
+ "OverallStatus": overall,
+ "OverallHeading": overallHeading(overall),
+ }
+}
+
+// queueSummary reduces postqueue's listing to the one line worth showing on the
+// status page; the full listing has its own screen (spec 7.2.11). postqueue
+// prints either "Mail queue is empty" or a trailing "-- N Kbytes in M Requests."
+func queueSummary(out string) string {
+ lines := strings.Split(strings.TrimSpace(out), "\n")
+ for i := len(lines) - 1; i >= 0; i-- {
+ if line := strings.TrimSpace(lines[i]); line != "" {
+ return strings.TrimSpace(strings.TrimPrefix(line, "--"))
+ }
+ }
+ return ""
+}
+
+// overallHeading turns the worst check into the page's one-line verdict.
+func overallHeading(worst health.Status) string {
+ switch worst {
+ case health.StatusError:
+ return "A component needs attention — see the details below."
+ case health.StatusWarn:
+ return "Running, with warnings below."
+ case health.StatusOK:
+ return "All components are running normally."
+ default:
+ return "Some checks could not be performed."
+ }
+}
+
+// statusFlash maps a fixed redirect flag to a fixed message, so status text
+// after a redirect is never attacker-influenced.
+func statusFlash(r *http.Request) string {
+ switch {
+ case r.URL.Query().Get("reloaded") != "":
+ return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
+ case r.URL.Query().Get("rechecked") != "":
+ return "DNS re-checked."
+ default:
+ return ""
+ }
+}
diff --git a/internal/web/templates.go b/internal/web/templates.go
index d08fff7..acf0518 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -33,6 +33,7 @@ var pageFiles = map[string][]string{
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
"queue": {"templates/queue.html", "templates/queue_body.html"},
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
+ "status": {"templates/status.html", "templates/status_body.html"},
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
@@ -41,6 +42,7 @@ var fragmentFiles = map[string]string{
"sendlog_rows": "templates/sendlog_rows.html",
"queue_body": "templates/queue_body.html",
"logtail_body": "templates/logtail_body.html",
+ "status_body": "templates/status_body.html",
}
func loadTemplates() (*templates, error) {
diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html
index 5d458a6..d9bcab5 100644
--- a/internal/web/templates/dashboard.html
+++ b/internal/web/templates/dashboard.html
@@ -1,12 +1,5 @@
{{define "content"}}
-
What DNS publishes for {{.Domain.Name}} right
+ now, checked against the key this server signs with. Results are cached for a
+ few minutes — after publishing a record, use Re-check.
The SPF check is deliberately shallow: it looks for a
+ mechanism that literally covers this server's address and does not follow
+ include: or redirect=, so a record that authorises
+ the server through an include is reported as “cannot tell”, not as a failure.