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

SelfPost

-
-
- -
-
-
+

Domains

{{if .Flash}}
{{.Flash}}
{{end}} diff --git a/internal/web/templates/domain_detail.html b/internal/web/templates/domain_detail.html index 67972a6..25f8ffe 100644 --- a/internal/web/templates/domain_detail.html +++ b/internal/web/templates/domain_detail.html @@ -1,7 +1,7 @@ {{define "content"}}

{{.Domain.Name}}

-← All domains +← All domains {{if .Flash}}
{{.Flash}}
{{end}} {{if .RateLimitErr}}
{{.RateLimitErr}}
{{end}} @@ -48,6 +48,36 @@ documentation). Mail is signed with selector {{.Domain.DKIMSelector}}.

+
+

DNS status {{.DNS.Overall}}

+

What DNS publishes for {{.Domain.Name}} right + now, checked against the key this server signs with. Results are cached for a + few minutes — after publishing a record, use Re-check.

+ + +

{{.DNS.DKIM.Detail}}

+ {{if .DNS.DKIM.Records}}{{range .DNS.DKIM.Records}}{{.}} +{{end}}{{end}} + + +

{{.DNS.SPF.Detail}}

+ {{if .DNS.SPF.Records}}{{range .DNS.SPF.Records}}{{.}} +{{end}}{{end}} +

The SPF check is deliberately shallow: it looks for a + mechanism that literally covers this server's address and does not follow + include: or redirect=, so a record that authorises + the server through an include is reported as “cannot tell”, not as a failure.

+ + +

{{.DNS.DMARC.Detail}}

+ {{if .DNS.DMARC.Records}}{{range .DNS.DMARC.Records}}{{.}} +{{end}}{{end}} + +
+ +
+
+

Sending server settings

Point the mail client or script at these settings and diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 2205193..a3e78ed 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -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"}}