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.mixfed.ru) and not matching (mixfed.ru), 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:
@@ -5,6 +5,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- panel: new **Status** page — supervised processes, mail queue, TLS
|
||||
certificate expiry, milter sockets and the server's own hostname/reverse-DNS
|
||||
(FCrDNS) check — and it is now the panel's landing page. The local checks
|
||||
refresh by polling; the DNS lookup is cached with a *Re-check* button.
|
||||
- panel: the domain page shows a **DNS status** card: the published DKIM record
|
||||
compared against the key this server actually signs with, plus SPF and DMARC.
|
||||
The SPF check is deliberately shallow — it looks for a mechanism literally
|
||||
covering this server's address and does not follow `include:`/`redirect=`, so
|
||||
a record that authorises the server through an include is reported as "cannot
|
||||
tell", not as a failure.
|
||||
- panel: the domain list moved from `/` to `/domains`; `/` redirects to the
|
||||
status page. The **Reload** button moved from the domain list to the status
|
||||
page and now explains what it regenerates and when to use it.
|
||||
- fix: the panel could never read the mail queue in the documented deployment.
|
||||
`postqueue` relies on its setgid-`postdrop` bit, which `no-new-privileges`
|
||||
(set in the shipped compose file) disables, so the *Queue* screen always said
|
||||
"Could not read the mail queue". The `panel` user is now a real member of
|
||||
`postdrop`.
|
||||
- panel: navigation bar is now rendered once from the shared layout, so every
|
||||
authenticated page has it — including the domain page and the delete
|
||||
confirmation, which had no navigation links at all — and the current page is
|
||||
|
||||
@@ -88,6 +88,16 @@ mail lands in spam even though SelfPost delivered it correctly — DKIM passing
|
||||
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
|
||||
panel, add its DNS records at the same time**, not later.
|
||||
|
||||
The panel checks both scopes for you and tells you what is actually published:
|
||||
the *Status* page verifies the server's hostname and its reverse record
|
||||
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
|
||||
card comparing the published DKIM record against the key this server signs with,
|
||||
plus the domain's SPF and DMARC records. Results are cached for a few minutes;
|
||||
use *Re-check* right after publishing a record. The SPF check is deliberately
|
||||
shallow — it looks for a mechanism that literally covers this server's address
|
||||
and does not follow `include:` or `redirect=`, so a record that authorizes the
|
||||
server through an include is reported as "cannot tell" rather than as a failure.
|
||||
|
||||
## IP warmup
|
||||
|
||||
A brand-new IP has no sending history, so receiving servers are cautious with
|
||||
|
||||
@@ -67,6 +67,17 @@ RUN groupadd --system selfpost \
|
||||
&& usermod -aG selfpost opendkim \
|
||||
&& usermod -aG selfpost postfix
|
||||
|
||||
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
|
||||
# status page's queue card). postqueue is setgid postdrop, which normally gives
|
||||
# it the group needed to reach Postfix's showq socket — but the documented
|
||||
# deployment runs with `no-new-privileges`, which disables setgid transitions,
|
||||
# so the panel would always see "Permission denied". Making `panel` a real
|
||||
# member of postdrop grants the same access without relying on a setgid
|
||||
# escalation the hardening deliberately forbids. postdrop membership is
|
||||
# read-side only: it does not let the panel bypass any Postfix restriction that
|
||||
# a local user does not already have through the world-executable sendmail.
|
||||
RUN usermod -aG postdrop panel
|
||||
|
||||
# Runtime directories: milter sockets and the consolidated persistent root.
|
||||
RUN mkdir -p /run/opendkim /run/selfpost /data \
|
||||
&& chown opendkim:opendkim /run/opendkim \
|
||||
|
||||
@@ -34,6 +34,9 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
DBPath: cfg.dbPath,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
}, cfg.setupTokenPath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -61,6 +61,13 @@ type config struct {
|
||||
submissionEnabled bool
|
||||
trustedProxies []*net.IPNet
|
||||
|
||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||
// serves and the two milter sockets it connects to. The defaults mirror
|
||||
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
||||
// was configured with.
|
||||
tlsCertFile string
|
||||
opendkimSocket string
|
||||
|
||||
opendkimDir string
|
||||
dkimSelectorDef string
|
||||
|
||||
@@ -97,6 +104,9 @@ func loadConfig() config {
|
||||
// told which proxy to trust.
|
||||
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
|
||||
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
|
||||
// Per-domain DKIM state (spec 6). The directory layout matches what
|
||||
// entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в
|
||||
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
|
||||
|
||||
**Фаза 13 (страница `/status`, DNS-статус домена, перенос списка доменов на
|
||||
`/domains`, перенос кнопки Reload) выполнена** — детали в
|
||||
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
|
||||
|
||||
**Основа:** [specification.md](specification.md) v1.0.
|
||||
|
||||
---
|
||||
@@ -50,87 +54,6 @@
|
||||
|
||||
---
|
||||
|
||||
## Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов
|
||||
|
||||
**Статус:** запланирована, не начата.
|
||||
|
||||
**Цель:** дать оператору один взгляд на «сервис жив и почта не будет зарубаться из-за DNS» — два независимых экрана: здоровье процесса (`/status`) и корректность DNS для каждого отправляющего домена (встроено в существующую страницу домена). Выходит за рамки ТЗ v1.0 (не было в разделе 7.2), согласовано с пользователем как отдельная фаза. **`/status` также становится стартовой страницей панели** (см. пункт C) — первое, что видит админ после логина, вместо списка доменов — и получает кнопку «Reload», перенесённую с дашборда с понятным пояснением назначения (см. пункт D).
|
||||
|
||||
### A. `/status` — здоровье сервера (только сервер, без доменов)
|
||||
|
||||
1. **Процессы supervisord** — `supervisorctl status` (fixed-argv, без shell, как остальные exec-вызовы проекта), статус opendkim/postfix/panel/cert-reload/logrotate.
|
||||
2. **Очередь Postfix** — переиспользовать `internal/postfix.Queue()`.
|
||||
3. **TLS-сертификат** — распарсить `x509.NotAfter` из `TLS_CERT_FILE`, предупреждение при приближении срока истечения.
|
||||
4. **Milter-сокеты** (`os.Stat` на `opendkim.sock`/`journal.sock`): отсутствие `opendkim.sock` — **ошибка** (при `default_action=tempfail` почта не уходит, см. [progress.md:132](progress.md)); отсутствие `journal.sock` — **предупреждение** (fail-open, почта уходит, но Send Log не пишется).
|
||||
5. **PTR / прямое-обратное соответствие (FCrDNS) — ключевая проверка для доставляемости:**
|
||||
- резолвим A/AAAA `SELFPOST_HOSTNAME` → IP сервера;
|
||||
- резолвим PTR этого IP → имя;
|
||||
- сравниваем PTR-имя с `SELFPOST_HOSTNAME`.
|
||||
- **OK** — совпадают; **ошибка** — PTR отсутствует или не совпадает (многие принимающие сервера отклоняют/спамят почту без корректного PTR).
|
||||
- Резолвленный IP сервера переиспользуется в пункте B.2 (SPF) — отдельный env для IP не нужен.
|
||||
|
||||
Дешёвые проверки (1–4) — HTMX-polling как на остальных экранах мониторинга (~5с). PTR/hostname-lookup чуть дороже сети — кэш с TTL (например 1 мин) или отдельная кнопка «Recheck», не завязывать на 5-секундный polling.
|
||||
|
||||
### B. DNS-статус домена (на странице `domain_detail.html`, рядом с существующей DKIM TXT-записью)
|
||||
|
||||
1. **DKIM** — резолвим `<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.mixfed.ru`, у которого 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)
|
||||
|
||||
**Статус:** запланирована, не начата.
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@
|
||||
|
||||
- **Базовый линейный план 0→11 (v1.0) полностью выполнен и принят** (аудит безопасности ТЗ 7.6 — полное соответствие, деплой в проде подтверждён). Подробности — в git-истории и `CHANGELOG.md`.
|
||||
- **Фаза 12 (v1.x, UI/UX) выполнена:** общий `nav`-partial в `layout.html` (нав на каждой аутентифицированной странице + подсветка текущей через `Active`), `/account` (смена логина/пароля админа с проверкой текущего пароля и инвалидацией остальных сессий, `store.UpdateAdmin`), `/backup` (полный бэкап и импорт домена — две отдельные карточки, убраны с дашборда), карточка «Sending server settings» на странице домена (сервер/465/587 по `SUBMISSION_ENABLE`), кнопки Copy у DKIM-записи и нового пароля приложения, скрытие поля «Addresses» в режиме wildcard (`static/panel.js`). Проверено в контейнере на dev-сервере (setup→login→домен→приложение→смена пароля→импорт), `gofmt`/`vet`/`test`/`docker build` зелёные.
|
||||
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), **Фаза 13 (v1.x, запланирована, не начата)** — страница статуса `/status` (процессы/очередь/TLS/milter-сокеты/PTR) + DNS-статус домена (DKIM/SPF-эвристика/DMARC), `/status` как стартовая страница и перенос кнопки Reload; **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
|
||||
- **Фаза 13 (v1.x, статус + DNS) выполнена:** новый пакет `internal/health` (supervisorctl-статус процессов, срок действия TLS-сертификата, milter-сокеты + общий словарь статусов ok/warn/error/unknown) и `internal/dnscheck` (FCrDNS для `SELFPOST_HOSTNAME`, DKIM против реального ключа, поверхностная SPF-эвристика, DMARC; кеш + таймаут, резолвер за интерфейсом — юнит-тесты без сети). Страница `/status` (карточки + HTMX-фрагмент `/status/fragment` на 5с) стала стартовой: `GET /` → 303 на `/status`, список доменов переехал на `GET /domains`, кнопка Reload перенесена на `/status` с пояснением. На странице домена — карточка «DNS status» с кнопкой Re-check (`POST /domains/{id}/dns-recheck`). Проверено в контейнере на dev-сервере против реального DNS: PTR совпадает (`selfpost.mixfed.ru`) и не совпадает (`mixfed.ru`), 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.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
|
||||
|
||||
## Рабочая петля (dev loop) — ВАЖНО
|
||||
|
||||
@@ -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)), ".")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/dnscheck"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
@@ -100,6 +102,10 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
"Record": record,
|
||||
// What DNS actually publishes for the domain today, checked against the
|
||||
// key this server signs with (phase 13.B). Cached by the checker, so
|
||||
// re-rendering the page after a form post costs nothing.
|
||||
"DNS": s.domainDNS(d, record, false),
|
||||
// Client connection settings (the same for every domain on this
|
||||
// instance): the hostname clients connect to, and whether the optional
|
||||
// submission listener is enabled in this deployment.
|
||||
@@ -122,6 +128,38 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
})
|
||||
}
|
||||
|
||||
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
|
||||
// records (phase 13.B). The server's own address comes from the (separately
|
||||
// cached) hostname check, so the SPF heuristic knows which IP it is looking for
|
||||
// and no extra environment variable is needed. force bypasses the cache, for the
|
||||
// Re-check button.
|
||||
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) dnscheck.Domain {
|
||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
||||
return s.dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: record.Value,
|
||||
ServerIPs: srv.IPs,
|
||||
}, force)
|
||||
}
|
||||
|
||||
// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
|
||||
// returns to its page, which then renders the fresh result.
|
||||
func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record, err := s.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.domainDNS(d, record, true)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// intOrBlank renders a non-positive number as an empty string so an unset field
|
||||
// shows blank rather than "0".
|
||||
func intOrBlank(n int) string {
|
||||
@@ -152,6 +190,8 @@ func detailFlash(r *http.Request) string {
|
||||
return "Rate limit updated."
|
||||
case r.URL.Query().Get("imported") != "":
|
||||
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
|
||||
case r.URL.Query().Get("rechecked") != "":
|
||||
return "DNS re-checked."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -40,14 +40,10 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
|
||||
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
func dashboardFlash(r *http.Request) string {
|
||||
switch {
|
||||
case r.URL.Query().Get("reloaded") != "":
|
||||
return "Configuration reloaded."
|
||||
case r.URL.Query().Get("deleted") != "":
|
||||
if r.URL.Query().Get("deleted") != "" {
|
||||
return "Domain deleted."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
|
||||
@@ -101,6 +97,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Drop any cached DNS verdict for the name while it is still resolvable, so
|
||||
// re-adding the domain later starts from a fresh check instead of a stale
|
||||
// one from before it was removed.
|
||||
if d, err := s.domains.Get(id); err == nil {
|
||||
defer s.dns.Forget(d.Name)
|
||||
}
|
||||
if err := s.domains.Delete(id); err != nil {
|
||||
if errors.Is(err, store.ErrDomainNotFound) {
|
||||
http.NotFound(w, r)
|
||||
@@ -110,12 +112,15 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||
// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the
|
||||
// database and reloads its daemon, so the button doubles as a drift-recovery.
|
||||
// The button lives on the status page (phase 13.D): it is a "put the daemons
|
||||
// back in the state the database describes" action, which belongs with the rest
|
||||
// of the server-health screen rather than in the domain list's top bar.
|
||||
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.domains.Resync(); err != nil {
|
||||
logf("panel: manual reload (opendkim): %v", err)
|
||||
@@ -127,7 +132,7 @@ func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/?reloaded=1", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ var pageFiles = map[string][]string{
|
||||
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
|
||||
"queue": {"templates/queue.html", "templates/queue_body.html"},
|
||||
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
@@ -41,6 +42,7 @@ var fragmentFiles = map[string]string{
|
||||
"sendlog_rows": "templates/sendlog_rows.html",
|
||||
"queue_body": "templates/queue_body.html",
|
||||
"logtail_body": "templates/logtail_body.html",
|
||||
"status_body": "templates/status_body.html",
|
||||
}
|
||||
|
||||
func loadTemplates() (*templates, error) {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>SelfPost</h1>
|
||||
<div class="actions muted">
|
||||
<form class="inline" method="post" action="/reload">
|
||||
<button type="submit">Reload</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<h1>Domains</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/">← All domains</a>
|
||||
<a class="back" href="/domains">← All domains</a>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</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>
|
||||
</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">
|
||||
<h2>Sending server settings</h2>
|
||||
<p class="muted">Point the mail client or script at these settings and
|
||||
|
||||
@@ -92,6 +92,24 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.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 .code { flex: 1; min-width: 0; }
|
||||
button.copy {
|
||||
@@ -121,7 +139,8 @@
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
<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 "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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -2,8 +2,13 @@ package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/app"
|
||||
"codeberg.org/mix/selfpost/internal/dnscheck"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"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.
|
||||
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
|
||||
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.
|
||||
@@ -60,6 +70,7 @@ type Server struct {
|
||||
tmpl *templates
|
||||
sessions *sessionStore
|
||||
setup *setupManager
|
||||
dns *dnscheck.Checker
|
||||
|
||||
loginLimiter *rateLimiter
|
||||
setupLimiter *rateLimiter
|
||||
@@ -83,6 +94,10 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
|
||||
cfg: cfg,
|
||||
tmpl: tmpl,
|
||||
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
|
||||
// legitimate admin and blunts automated probing (spec 7.6.1).
|
||||
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
|
||||
// above falls through to this sub-mux, wrapped once in the auth middleware.
|
||||
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/import", s.handleImportDomain)
|
||||
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("POST /domains/{id}/delete", s.handleDeleteDomain)
|
||||
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
|
||||
@@ -159,6 +184,12 @@ func (s *Server) Handler() http.Handler {
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
Reference in New Issue
Block a user