diff --git a/CHANGELOG.md b/CHANGELOG.md index 106f7f9..dc3455b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version *Any address of the domain*, where the server ignores it. - ci: disable provenance attestation on release image push, so the ghcr.io manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`). +- ci: run `go vet` and `go test ./...` on every push to `main` and every pull + request, not only the image build on a release tag. - security: optionally honour `X-Forwarded-For` for login/setup rate-limiting when the request's direct peer is in the new `TRUSTED_PROXY_CIDR` list, giving real per-client limits behind a reverse proxy instead of one global diff --git a/cmd/panel/main.go b/cmd/panel/main.go index ebf3797..9898424 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -99,7 +99,7 @@ func loadConfig() config { // matches postfix-config.sh, which enables the listener on "true" alone. submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true", // Reverse-proxy addresses allowed to supply X-Forwarded-For for - // rate-limiting (plan.md item A.1). Empty by default: an untrusted peer's + // rate-limiting. Empty by default: an untrusted peer's // XFF header is trivially forgeable, so it's ignored unless the panel is // told which proxy to trust. trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")), diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index cf58b39..823faf0 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1,19 +1,9 @@ # План реализации: SelfPost -**Статус:** базовый линейный план (фазы 0→11, v1.0) выполнен и принят — см. -[progress.md](progress.md) (текущее состояние) и [CHANGELOG.md](../CHANGELOG.md) -(история релизов). Он здесь не повторяется. - -Ниже остаётся только то, что **ещё не сделано**: открытые вопросы для -согласования и опциональная линия 2.x.x. - -**Фаза 12 (UI/UX: общий nav-partial, `/account`, `/backup`, параметры -подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в -[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется. - -**Фаза 13 (страница `/status`, DNS-статус домена, перенос списка доменов на -`/domains`, перенос кнопки Reload) выполнена** — детали в -[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется. +**Статус:** выполненные фазы здесь не описываются — текущее состояние в +[progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md) +и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы +для согласования, Фаза 14 и опциональная линия 2.x.x. **Основа:** [specification.md](specification.md) v1.0. @@ -30,27 +20,25 @@ ### A. Безопасность — hardening сверх обязательного 7.6 -1. **Rate-limit за обратным прокси кеился по `RemoteAddr`** ([internal/web/web.go](../internal/web/web.go) `clientIP`). **Решено и реализовано:** вариант (б) — парсить `X-Forwarded-For`, но только когда прямой peer (`RemoteAddr`) входит в `TRUSTED_PROXY_CIDR` (список CIDR через запятую, env, по умолчанию пусто); тогда используется последний элемент XFF (адрес, добавленный самим доверенным прокси). Без настройки `TRUSTED_PROXY_CIDR` поведение не меняется (лимит по `RemoteAddr`, глобальный за прокси). См. `deploy/.env.example`. -2. **Нет security-заголовков ответа** — панель не шлёт `Strict-Transport-Security`, `Content-Security-Policy`, `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`. XSS уже закрыт автоэкранированием `html/template` (7.6.7), CSRF — `SameSite=Lax`, но заголовки — дешёвый второй эшелон (clickjacking, downgrade, sniffing). **Решено:** эмитить из панели (единый мидлварь, ~10 строк), не перекладывать на reverse-proxy. Общий принцип: всю сложность стараемся держать в сервисе, а конфигурация reverse-proxy должна оставаться максимально простой, чтобы её было сложно сломать неудачной правкой. **Реализация — см. Фазу 14.A.** -3. **CSRF — только `SameSite=Lax`, без токенов.** Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». **Вопрос:** считать `SameSite=Lax` достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен. -4. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`). Префикс `__Host-` дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. **Вопрос:** переименовать (учесть dev-режим `PANEL_COOKIE_SECURE=false` — `__Host-` требует `Secure`, т.е. только когда secure включён). -5. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл `/data/setup-token` (0600) — альтернатива, уже реализована в коде ([internal/web/setup.go](../internal/web/setup.go) `announce`/token file). **Решено:** базовый вариант объявления — stdout (по ТЗ), код не меняется. Остаётся только осветить это в пользовательской документации и указать на уже существующий файл `/data/setup-token` как более защищённую альтернативу для тех, у кого логи контейнера уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.** -6. **Нет 2FA / смены пароля админа / нескольких админов из UI.** ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. **Решено и реализовано (Фаза 12):** добавлен раздел настроек аккаунта `/account` (логин + пароль, с проверкой текущего пароля и инвалидацией остальных сессий). 2FA и мульти-админ остаются явно 2.x/вне объёма. +1. **Нет security-заголовков ответа** — панель не шлёт `Strict-Transport-Security`, `Content-Security-Policy`, `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`. XSS уже закрыт автоэкранированием `html/template` (7.6.7), CSRF — `SameSite=Lax`, но заголовки — дешёвый второй эшелон (clickjacking, downgrade, sniffing). **Решено:** эмитить из панели (единый мидлварь, ~10 строк), не перекладывать на reverse-proxy. Общий принцип: всю сложность стараемся держать в сервисе, а конфигурация reverse-proxy должна оставаться максимально простой, чтобы её было сложно сломать неудачной правкой. **Реализация — см. Фазу 14.A.** +2. **CSRF — только `SameSite=Lax`, без токенов.** Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». **Вопрос:** считать `SameSite=Lax` достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен. +3. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`). Префикс `__Host-` дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. **Вопрос:** переименовать (учесть dev-режим `PANEL_COOKIE_SECURE=false` — `__Host-` требует `Secure`, т.е. только когда secure включён). +4. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. **Решено:** базовый вариант объявления остаётся stdout (по ТЗ), код не меняется — файл `/data/setup-token` (0600) уже пишется ([internal/web/setup.go](../internal/web/setup.go)). Остаётся документационная задача: указать этот файл как более защищённую альтернативу для тех, у кого логи уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.** ### B. Надёжность и эксплуатация -7. **Сессии только в памяти** — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README. -8. **Окно потери строк мониторингового лога при ротации** (`copytruncate`, Фаза 10) — несколько строк `mail.log` могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство. -9. **Поведение при незаданном `SELFPOST_HOSTNAME`** — realm SASL и хост setup-ссылки падают в `localhost`. Для реального деплоя hostname обязателен. **Вопрос:** делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать. +5. **Сессии только в памяти** — рестарт/редеплой разлогинивает админа (по ТЗ 9 допустимо). Плюс: absolute TTL 12ч без отдельного idle-timeout и без ротации токена при логине (session-fixation здесь неактуален, т.к. токен выдаётся только после аутентификации). Оставить; отметить поведение в README. +6. **Окно потери строк мониторингового лога при ротации** (`copytruncate`, Фаза 10) — несколько строк `mail.log` могут потеряться в момент ротации. Приемлемо для мониторинга; зафиксировать как известное свойство. +7. **Поведение при незаданном `SELFPOST_HOSTNAME`** — realm SASL и хост setup-ссылки падают в `localhost`. Для реального деплоя hostname обязателен. **Вопрос:** делать ли фатальную проверку «hostname обязателен» на старте (сейчас — мягкий fallback) — предложение: предупреждать громко в лог, но не падать. ### C. CI и тесты -10. **CI не гоняет `go test`.** [.github/workflows/release.yml](../.github/workflows/release.yml) на теге только собирает и пушит образ; `go vet` выполняется внутри Dockerfile-сборки, но юнит-тесты в CI не запускались — вся тестовая проверка шла вручную на dev-сервере. **Реализовано:** добавлен обычный workflow на push/PR (`go vet` + `go test ./...`). -11. **Нет интеграционного/e2e-теста в CI.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI. +8. **Нет интеграционного/e2e-теста в CI.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории, но не автоматизированы. Для v1.0 — вероятно, оставить ручными; для долгой поддержки — кандидат на smoke-тест (поднять контейнер, setup→login→add domain→auth SMTP) в CI. ### D. Указатель на объём 2.x -12. **Входящий релей и pluggable-антиспам** вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это **сознательно отложенный объём**, а не забытый. +9. **Входящий релей и pluggable-антиспам** вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это **сознательно отложенный объём**, а не забытый. +10. **2FA и несколько администраторов** — вне объёма v1.x (ТЗ этого не требует: один админ). Кандидаты на 2.x, если понадобятся. --- @@ -59,11 +47,10 @@ **Статус:** запланирована, не начата. **Цель:** довести до кода два уже принятых, но пока не реализованных решения из -раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) -(остальные пункты раздела A либо уже реализованы — п.1, либо являются открытыми -вопросами без решения — п.3/4, либо уже реализованы в Фазе 12 — п.6). +раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76) — +пункты A.1 и A.4 (оставшиеся A.2/A.3 — открытые вопросы без решения). -### A. Security-заголовки ответа (пункт A.2) +### A. Security-заголовки ответа (пункт A.1) Добавить в панель единый middleware, оборачивающий все ответы (кроме, возможно, уже застриманных HTMX-фрагментов, где это не мешает), выставляющий: @@ -80,7 +67,7 @@ **Модель:** Sonnet (небольшой, хорошо специфицированный мидлварь). -### B. Документация про `/data/setup-token` (пункт A.5) +### B. Документация про `/data/setup-token` (пункт A.4) Код уже пишет setup-токен в `/data/setup-token` (0600) в дополнение к stdout ([internal/web/setup.go](../internal/web/setup.go)) — это не требует изменений. @@ -94,7 +81,7 @@ **Модель:** Sonnet (документация). -**Зависимости:** нет, можно делать независимо от Фаз 12/13. +**Зависимости:** нет. --- diff --git a/docs/progress.md b/docs/progress.md index 7446628..1a288bd 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -3,12 +3,12 @@ Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы. План (открытые вопросы + опциональные фазы): [implementation-plan.md](implementation-plan.md). ТЗ: [specification.md](specification.md). История релизов: [CHANGELOG.md](../CHANGELOG.md). -История сделанного по фазам (0→11, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется. +История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется. ## Как возобновить после сброса контекста 1. Прочитать этот файл (текущее состояние, что дальше). -2. Открыть `implementation-plan.md` — там нерешённые вопросы и опциональная линия 2.x.x (Фаза O1+). +2. Открыть `implementation-plan.md` — там нерешённые вопросы, Фаза 14 и опциональная линия 2.x.x (Фаза O1+). 3. При необходимости — детали в `specification.md`. 4. Продолжить с пункта «Следующий шаг». @@ -34,11 +34,8 @@ ## Текущее состояние -- **Базовый линейный план 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` зелёные. -- **Фаза 13 (v1.x, статус + DNS) выполнена:** новый пакет `internal/health` (supervisorctl-статус процессов, срок действия TLS-сертификата, milter-сокеты + общий словарь статусов ok/warn/error/unknown) и `internal/dnscheck` (FCrDNS для `SELFPOST_HOSTNAME`, DKIM против реального ключа, поверхностная SPF-эвристика, DMARC; кеш + таймаут, резолвер за интерфейсом — юнит-тесты без сети). Страница `/status` (карточки + HTMX-фрагмент `/status/fragment` на 5с) стала стартовой: `GET /` → 303 на `/status`, список доменов переехал на `GET /domains`, кнопка Reload перенесена на `/status` с пояснением. На странице домена — карточка «DNS status» с кнопкой Re-check (`POST /domains/{id}/dns-recheck`). Проверено в контейнере на dev-сервере против реального DNS: PTR совпадает (`selfpost.example.com`) и не совпадает (`example.com`), DKIM отсутствует/не совпадает, SPF отсутствует/через `include:` («не могу сказать»), DMARC `p=quarantine`/`p=reject`/нет; таймаут DNS деградирует в «unknown», страница не виснет. -- **Попутно исправлен давний дефект:** панель никогда не могла прочитать очередь (`postqueue -p`) в штатном деплое — `postqueue` полагается на setgid-бит `postdrop`, а `no-new-privileges` из поставляемого compose его отключает, поэтому экран Queue всегда показывал «Could not read the mail queue» (в т.ч. в released 1.0.0). Пользователь `panel` теперь состоит в группе `postdrop` (`build/Dockerfile`). -- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (раздел A-D — hardening сверх обязательного 7.6, надёжность, CI/тесты), **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). +- **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX) и Фаза 13 (страница `/status`, DNS-проверки домена). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется. +- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы (разделы A-D — hardening сверх обязательного 7.6, надёжность, e2e в CI), **Фаза 14** — security-заголовки + документация про `/data/setup-token`; опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). ## Рабочая петля (dev loop) — ВАЖНО diff --git a/internal/dnscheck/spf.go b/internal/dnscheck/spf.go index 0ba1405..9215720 100644 --- a/internal/dnscheck/spf.go +++ b/internal/dnscheck/spf.go @@ -17,8 +17,8 @@ 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 — +// This is deliberately a shallow check (documented as such in the README): 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 diff --git a/internal/web/web.go b/internal/web/web.go index 6529700..7c27d1d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -45,7 +45,7 @@ type Config struct { DBPath string Version string // TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply - // X-Forwarded-For (plan.md item A.1: TRUSTED_PROXY_CIDR). A request whose + // X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose // direct peer (RemoteAddr) is not in this list never has its XFF header // honoured, so the header can't be spoofed by anyone but a trusted proxy. // Empty (the default) keeps rate-limiting keyed on RemoteAddr only. @@ -200,7 +200,7 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) { // transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches // one of trustedProxies, the last entry of X-Forwarded-For is used instead — // that is the address the trusted proxy itself appended, so a client can't -// forge it by sending its own XFF header (plan.md item A.1). With no trusted +// forge it by sending its own XFF header. With no trusted // proxies configured, behind a reverse proxy this is the proxy's own address, // which is an acceptable backstop for a single-admin panel. func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {