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:
2026-08-01 22:04:37 +03:00
parent 147072dbb9
commit ac5b37d1e2
28 changed files with 2112 additions and 101 deletions
+4 -81
View File
@@ -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)
**Статус:** запланирована, не начата.