panel: server status page, per-domain DNS checks, /domains move

Phase 13. Two new packages and one new screen.

internal/health owns the shared status vocabulary (ok/warn/error/unknown)
and the local checks: supervisord's process table, TLS certificate expiry
and the two milter sockets. Each check reports a problem as a status rather
than an error, so one broken component costs a line and not the page.

internal/dnscheck does the read-only lookups: forward-confirmed reverse DNS
for SELFPOST_HOSTNAME, and per-domain DKIM (compared against the key this
server actually signs with), SPF and DMARC. Every check is bounded by a
timeout and cached, and the resolver sits behind an interface so the tests
drive every branch without touching the network. The SPF check is
deliberately shallow: it looks for a mechanism literally covering the
server's address and does not follow include:/redirect=, so a record that
authorises us through an include is reported as "cannot tell" rather than
as a failure.

/status renders both, with the local checks in an HTMX-polled fragment and
the DNS lookups behind a Re-check button, and becomes the panel's landing
page: / now redirects there and the domain list lives at /domains. The
Reload button moves onto /status, where it reads as what it is — a
drift-recovery for the daemons — with text explaining what it regenerates.
A template test fails on any remaining href="/" so a stale link cannot
silently land on the wrong screen.

Also fixes a defect this made visible: the panel could never read the mail
queue in the documented deployment. postqueue relies on its setgid-postdrop
bit, which the compose file's no-new-privileges disables, so the Queue
screen always said "Could not read the mail queue" — including in the
released 1.0.0 image. The panel user is now a real member of postdrop,
which needs no setgid transition.

Verified in a container on the dev server against real DNS: PTR matching
(selfpost.example.com) and not matching (example.com), DKIM absent and
mismatched, SPF absent and via include:, DMARC p=quarantine/p=reject/absent,
and a resolver timeout degrading to "unknown" without hanging the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:04:37 +03:00
parent fc53ae1314
commit 7b4549a35d
28 changed files with 2112 additions and 101 deletions
+18
View File
@@ -5,6 +5,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [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 - panel: navigation bar is now rendered once from the shared layout, so every
authenticated page has it — including the domain page and the delete authenticated page has it — including the domain page and the delete
confirmation, which had no navigation links at all — and the current page is confirmation, which had no navigation links at all — and the current page is
+10
View File
@@ -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 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. 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 ## IP warmup
A brand-new IP has no sending history, so receiving servers are cautious with A brand-new IP has no sending history, so receiving servers are cautious with
+11
View File
@@ -67,6 +67,17 @@ RUN groupadd --system selfpost \
&& usermod -aG selfpost opendkim \ && usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix && 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. # Runtime directories: milter sockets and the consolidated persistent root.
RUN mkdir -p /run/opendkim /run/selfpost /data \ RUN mkdir -p /run/opendkim /run/selfpost /data \
&& chown opendkim:opendkim /run/opendkim \ && chown opendkim:opendkim /run/opendkim \
+3
View File
@@ -34,6 +34,9 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
DBPath: cfg.dbPath, DBPath: cfg.dbPath,
Version: buildinfo.Version, Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies, TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile,
OpenDKIMSocket: cfg.opendkimSocket,
JournalSocket: cfg.journalSocket,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
if err != nil { if err != nil {
return err return err
+10
View File
@@ -61,6 +61,13 @@ type config struct {
submissionEnabled bool submissionEnabled bool
trustedProxies []*net.IPNet 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 opendkimDir string
dkimSelectorDef string dkimSelectorDef string
@@ -97,6 +104,9 @@ func loadConfig() config {
// told which proxy to trust. // told which proxy to trust.
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")), 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 // Per-domain DKIM state (spec 6). The directory layout matches what
// entrypoint.sh prepares (setgid, shared `selfpost` group). // entrypoint.sh prepares (setgid, shared `selfpost` group).
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")), opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
+4 -81
View File
@@ -11,6 +11,10 @@
подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется. [CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
**Фаза 13 (страница `/status`, DNS-статус домена, перенос списка доменов на
`/domains`, перенос кнопки Reload) выполнена** — детали в
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
**Основа:** [specification.md](specification.md) v1.0. **Основа:** [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** — резолвим `<selector>._domainkey.<domain>` 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.<domain>`, наличие + политика (`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, а не меняет его архитектуру.
- Везде, где по коду сейчас зашит редирект/ссылка на `/` как «страница доменов» (например, `<a class="back" href="/">← Domains</a>` в `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) ## Фаза 14 (v1.x) — Реализация принятых решений по hardening (раздел A)
**Статус:** запланирована, не начата. **Статус:** запланирована, не начата.
+3 -1
View File
@@ -36,7 +36,9 @@
- **Базовый линейный план 0→11 (v1.0) полностью выполнен и принят** (аудит безопасности ТЗ 7.6 — полное соответствие, деплой в проде подтверждён). Подробности — в git-истории и `CHANGELOG.md`. - **Базовый линейный план 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` зелёные. - **Фаза 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). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
## Рабочая петля (dev loop) — ВАЖНО ## Рабочая петля (dev loop) — ВАЖНО
+232
View File
@@ -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)), ".")
}
+359
View File
@@ -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")
}
}
+130
View File
@@ -0,0 +1,130 @@
package dnscheck
import (
"context"
"fmt"
"strings"
"codeberg.org/mix/selfpost/internal/health"
)
// checkDKIM compares the TXT record published at <selector>._domainkey.<domain>
// 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
}
+89
View File
@@ -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
}
}
+285
View File
@@ -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/<prefix>")
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/<prefix>")
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
}
+83
View File
@@ -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")
}
+53
View File
@@ -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
}
+165
View File
@@ -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: <class 'socket.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)
}
}
+99
View File
@@ -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 "<name> <STATE> <detail...>", 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
}
}
+56
View File
@@ -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)
}
+40
View File
@@ -7,6 +7,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
) )
@@ -100,6 +102,10 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
"Active": "domains", "Active": "domains",
"Domain": d, "Domain": d,
"Record": record, "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 // Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional // instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment. // 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 // intOrBlank renders a non-positive number as an empty string so an unset field
// shows blank rather than "0". // shows blank rather than "0".
func intOrBlank(n int) string { func intOrBlank(n int) string {
@@ -152,6 +190,8 @@ func detailFlash(r *http.Request) string {
return "Rate limit updated." return "Rate limit updated."
case r.URL.Query().Get("imported") != "": case r.URL.Query().Get("imported") != "":
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed." 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: default:
return "" return ""
} }
+13 -8
View File
@@ -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 // dashboardFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced. // after a redirect is never attacker-influenced.
func dashboardFlash(r *http.Request) string { func dashboardFlash(r *http.Request) string {
switch { if r.URL.Query().Get("deleted") != "" {
case r.URL.Query().Get("reloaded") != "":
return "Configuration reloaded."
case r.URL.Query().Get("deleted") != "":
return "Domain deleted." return "Domain deleted."
default:
return ""
} }
return ""
} }
// handleAddDomain validates the submitted name, creates the domain (DKIM key + // 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 { if !ok {
return 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 err := s.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) { if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r) 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) http.Error(w, "internal error", http.StatusInternalServerError)
return 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 // 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 // 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. // 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) { func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
if err := s.domains.Resync(); err != nil { if err := s.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err) 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) http.Error(w, "reload failed", http.StatusInternalServerError)
return 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 // lookupDomain resolves the {id} path value to a domain, writing a 404 for a
+131
View File
@@ -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 ""
}
}
+2
View File
@@ -33,6 +33,7 @@ var pageFiles = map[string][]string{
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"}, "sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
"queue": {"templates/queue.html", "templates/queue_body.html"}, "queue": {"templates/queue.html", "templates/queue_body.html"},
"logtail": {"templates/logtail.html", "templates/logtail_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 // 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", "sendlog_rows": "templates/sendlog_rows.html",
"queue_body": "templates/queue_body.html", "queue_body": "templates/queue_body.html",
"logtail_body": "templates/logtail_body.html", "logtail_body": "templates/logtail_body.html",
"status_body": "templates/status_body.html",
} }
func loadTemplates() (*templates, error) { func loadTemplates() (*templates, error) {
+1 -8
View File
@@ -1,12 +1,5 @@
{{define "content"}} {{define "content"}}
<div class="topbar"> <h1>Domains</h1>
<h1>SelfPost</h1>
<div class="actions muted">
<form class="inline" method="post" action="/reload">
<button type="submit">Reload</button>
</form>
</div>
</div>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
+31 -1
View File
@@ -1,7 +1,7 @@
{{define "content"}} {{define "content"}}
<h1>{{.Domain.Name}}</h1> <h1>{{.Domain.Name}}</h1>
<a class="back" href="/">&larr; All domains</a> <a class="back" href="/domains">&larr; All domains</a>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}} {{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
@@ -48,6 +48,36 @@
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p> documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div> </div>
<div class="card">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
<p class="muted">What DNS publishes for <strong>{{.Domain.Name}}</strong> right
now, checked against the key this server signs with. Results are cached for a
few minutes — after publishing a record, use <em>Re-check</em>.</p>
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
<p class="muted">The SPF check is deliberately shallow: it looks for a
mechanism that literally covers this server's address and does not follow
<code>include:</code> or <code>redirect=</code>, so a record that authorises
the server through an include is reported as “cannot tell”, not as a failure.</p>
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="card"> <div class="card">
<h2>Sending server settings</h2> <h2>Sending server settings</h2>
<p class="muted">Point the mail client or script at these settings and <p class="muted">Point the mail client or script at these settings and
+20 -1
View File
@@ -92,6 +92,24 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; } .nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
} }
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */
.st {
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; }
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; }
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; }
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; }
@media (prefers-color-scheme: dark) {
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; } .code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; } .code-row .code { flex: 1; min-width: 0; }
button.copy { button.copy {
@@ -121,7 +139,8 @@
{{define "nav"}} {{define "nav"}}
<nav class="nav"> <nav class="nav">
<div class="links"> <div class="links">
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/">Domains</a>{{end}} {{if eq .Active "status"}}<span aria-current="page">Status</span>{{else}}<a href="/status">Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/domains">Domains</a>{{end}}
{{if eq .Active "sendlog"}}<span aria-current="page">Send log</span>{{else}}<a href="/sendlog">Send log</a>{{end}} {{if eq .Active "sendlog"}}<span aria-current="page">Send log</span>{{else}}<a href="/sendlog">Send log</a>{{end}}
{{if eq .Active "queue"}}<span aria-current="page">Queue</span>{{else}}<a href="/queue">Queue</a>{{end}} {{if eq .Active "queue"}}<span aria-current="page">Queue</span>{{else}}<a href="/queue">Queue</a>{{end}}
{{if eq .Active "logtail"}}<span aria-current="page">Log</span>{{else}}<a href="/logtail">Log</a>{{end}} {{if eq .Active "logtail"}}<span aria-current="page">Log</span>{{else}}<a href="/logtail">Log</a>{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "content"}}
<h1>Server status</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{template "status_body" .}}
<div class="card">
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
<p class="muted">Receiving servers check that the name this server announces
resolves to its address <em>and</em> that the address resolves back to the same
name (forward-confirmed reverse DNS). A missing or mismatched reverse record is
the most common reason self-hosted mail is rejected or scored as spam. The
reverse record is set at the hosting provider, not in the domain's DNS zone.</p>
<label>Server hostname</label>
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
{{if .PTR.Records}}
<label>Forward and reverse lookup</label>
<span class="code">{{range .PTR.Records}}{{.}}
{{end}}</span>
{{end}}
<p class="{{if eq .PTR.Status "ok"}}muted{{else}}error{{end}}">{{.PTR.Detail}}</p>
<form class="inline" method="post" action="/status/recheck">
<button type="submit">Re-check DNS</button>
</form>
</div>
<div class="card">
<h2>Configuration</h2>
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
database and reloads both daemons. Use it if you edited the files by hand,
restored a backup, or the running configuration looks out of step with the
domain and application lists. It does not touch the mail queue or the TLS
certificate, and it is safe to run at any time.</p>
<form class="inline" method="post" action="/reload">
<button type="submit">Reload configuration</button>
</form>
</div>
{{end}}
+65
View File
@@ -0,0 +1,65 @@
{{define "status_body"}}
<div id="status-body" hx-get="/status/fragment" hx-trigger="every 5s" hx-swap="outerHTML">
<div class="card">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p>
</div>
<div class="card">
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
{{if .ProcessError}}
<p class="error">Could not ask supervisord for the process list.</p>
{{else}}
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
{{range .Processes}}
<tr>
<td>{{.Name}}</td>
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
<td class="muted">{{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
<div class="card">
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
{{if .QueueError}}
<p class="error">{{.QueueError}}</p>
{{else}}
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}
<a href="/queue">Full queue</a></p>
{{end}}
</div>
<div class="card">
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
<p class="muted">The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}.
It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.</p>
{{if not .Cert.NotAfter.IsZero}}
<label>Expires</label>
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
{{end}}
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
</div>
<div class="card">
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
<table>
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
<tbody>
{{range .Sockets}}
<tr>
<td>{{.Name}}</td>
<td class="muted">{{.Path}}</td>
<td><span class="st st-{{.Status}}">{{.Status}}</span> {{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
+124
View File
@@ -2,8 +2,13 @@ package web
import ( import (
"bytes" "bytes"
"io/fs"
"path"
"strings" "strings"
"testing" "testing"
"time"
"codeberg.org/mix/selfpost/internal/health"
) )
// The navigation is rendered from the layout, not copied into each page, so // The navigation is rendered from the layout, not copied into each page, so
@@ -45,3 +50,122 @@ func TestNavMarksActivePage(t *testing.T) {
t.Errorf("inactive pages are not linked:\n%s", out) t.Errorf("inactive pages are not linked:\n%s", out)
} }
} }
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
var buf bytes.Buffer
if err := tmpl.pages["status"].ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `<span aria-current="page">Status</span>`) {
t.Errorf("the status page is not marked active:\n%s", out)
}
if !strings.Contains(out, `href="/domains"`) {
t.Errorf("Domains does not link to /domains:\n%s", out)
}
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
t.Errorf("Status is not the first navigation entry:\n%s", out)
}
}
// Since the panel root now redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may
// contain one (phase 13.C).
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, `href="/"`) {
t.Errorf(`%s links to "/", which is now the status redirect; link to /domains (or the intended page) instead`, name)
}
})
}
// The reload action is a server-health control and lives only on the status
// page (phase 13.D).
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
forEachTemplate(t, func(name, body string) {
if strings.Contains(body, `action="/reload"`) && name != "status.html" {
t.Errorf("%s still posts to /reload; the reload control belongs on the status page", name)
}
})
}
func TestStatusPageRendersEveryCheck(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
var buf bytes.Buffer
err = tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "SelfPost — status",
"User": "admin",
"Active": "status",
"Processes": []health.Process{
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
},
"ProcessStatus": health.StatusError,
"QueueSummary": "Mail queue is empty",
"QueueStatus": health.StatusOK,
"Cert": health.Certificate{
Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com",
NotAfter: time.Now().Add(30 * 24 * time.Hour), DaysLeft: 30,
Status: health.StatusOK, Detail: "Valid for another 30 day(s).",
},
"Sockets": []health.Socket{
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
},
"SocketStatus": health.StatusOK,
"OverallStatus": health.StatusError,
"OverallHeading": "A component needs attention — see the details below.",
"Hostname": "mail.example.com",
"PTR": dnscheckResult{
Status: health.StatusError,
Detail: "No address has a reverse record.",
Records: []string{"203.0.113.10 → no PTR record"},
},
})
if err != nil {
t.Fatalf("execute status page: %v", err)
}
out := buf.String()
for _, want := range []string{
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
"203.0.113.10 → no PTR record", `action="/reload"`,
`hx-get="/status/fragment"`, `class="st st-error"`,
} {
if !strings.Contains(out, want) {
t.Errorf("status page is missing %q", want)
}
}
}
// dnscheckResult mirrors dnscheck.Result's shape for the template test, so the
// web package's template tests do not depend on the checker's constructor.
type dnscheckResult struct {
Status health.Status
Detail string
Records []string
}
// forEachTemplate runs fn over every embedded template's source.
func forEachTemplate(t *testing.T, fn func(name, body string)) {
t.Helper()
entries, err := fs.ReadDir(assetsFS, "templates")
if err != nil {
t.Fatalf("read templates: %v", err)
}
for _, e := range entries {
body, err := fs.ReadFile(assetsFS, path.Join("templates", e.Name()))
if err != nil {
t.Fatalf("read %s: %v", e.Name(), err)
}
fn(e.Name(), string(body))
}
}
+32 -1
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"codeberg.org/mix/selfpost/internal/app" "codeberg.org/mix/selfpost/internal/app"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
) )
@@ -49,6 +50,15 @@ type Config struct {
// honoured, so the header can't be spoofed by anyone but a trusted proxy. // honoured, so the header can't be spoofed by anyone but a trusted proxy.
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only. // Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
TrustedProxyCIDRs []*net.IPNet TrustedProxyCIDRs []*net.IPNet
// TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read
// read-only by the status page to report how much validity is left.
TLSCertFile string
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
// connects to. The status page stats them: the first is required for mail
// to leave at all (OpenDKIM runs with default_action=tempfail), the second
// only for the send log (the journal-milter fails open).
OpenDKIMSocket string
JournalSocket string
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
@@ -60,6 +70,7 @@ type Server struct {
tmpl *templates tmpl *templates
sessions *sessionStore sessions *sessionStore
setup *setupManager setup *setupManager
dns *dnscheck.Checker
loginLimiter *rateLimiter loginLimiter *rateLimiter
setupLimiter *rateLimiter setupLimiter *rateLimiter
@@ -83,6 +94,10 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
cfg: cfg, cfg: cfg,
tmpl: tmpl, tmpl: tmpl,
sessions: newSessionStore(), sessions: newSessionStore(),
// Published-DNS checks for the status page and the domain pages. The
// checker caches its own results, so page views do not each pay for a
// round of lookups (phase 13).
dns: dnscheck.New(),
// Setup: a handful of attempts per minute per IP is plenty for a // Setup: a handful of attempts per minute per IP is plenty for a
// legitimate admin and blunts automated probing (spec 7.6.1). // legitimate admin and blunts automated probing (spec 7.6.1).
setupLimiter: newRateLimiter(10, time.Minute), setupLimiter: newRateLimiter(10, time.Minute),
@@ -122,10 +137,20 @@ func (s *Server) Handler() http.Handler {
// Authenticated panel. Everything not matched by a more specific pattern // Authenticated panel. Everything not matched by a more specific pattern
// above falls through to this sub-mux, wrapped once in the auth middleware. // above falls through to this sub-mux, wrapped once in the auth middleware.
authed := http.NewServeMux() authed := http.NewServeMux()
authed.HandleFunc("GET /{$}", s.handleDashboard)
// The landing page is the server status (phase 13.C): the first thing an
// administrator should see after logging in is whether the service is
// healthy, not the domain list. handleLogin still redirects to "/".
authed.HandleFunc("GET /{$}", redirectToStatus)
authed.HandleFunc("GET /status", s.handleStatus)
authed.HandleFunc("GET /status/fragment", s.handleStatusFragment)
authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck)
authed.HandleFunc("GET /domains", s.handleDashboard)
authed.HandleFunc("POST /domains", s.handleAddDomain) authed.HandleFunc("POST /domains", s.handleAddDomain)
authed.HandleFunc("POST /domains/import", s.handleImportDomain) authed.HandleFunc("POST /domains/import", s.handleImportDomain)
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck)
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
@@ -159,6 +184,12 @@ func (s *Server) Handler() http.Handler {
return mux return mux
} }
// redirectToStatus points the panel root at the status page, so there is one
// canonical URL for that content instead of two (phase 13.C).
func redirectToStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/status", http.StatusSeeOther)
}
func handleHealth(w http.ResponseWriter, _ *http.Request) { func handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)