docs: drop completed work from the plan and progress tracker
The plan is meant to hold only what is still open, but three of its numbered items had already been implemented and were still being read as pending work: the TRUSTED_PROXY_CIDR-gated X-Forwarded-For handling (A.1), the account settings page (A.6) and the go vet/go test CI workflow (C.10). Remove them and renumber; the residual scope note from A.6 (2FA, multiple admins) moves to section D, which is where deliberately deferred scope belongs. Same for the "done" notices at the top of the plan and the phase-by-phase retellings in progress.md: phases 12 and 13 are described in full in the CHANGELOG and git history, so the tracker now states what is closed and what is next, and nothing else. Three code comments cited plan item numbers that this renumbering would have silently pointed at a different item, and one cited a phase 13 section that no longer exists; they now state the fact instead of the reference. The CI test workflow was never recorded in the CHANGELOG, so its entry is added there before the plan item describing it goes away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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")),
|
||||
|
||||
+19
-32
@@ -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.
|
||||
**Зависимости:** нет.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-7
@@ -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.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, требует согласования).
|
||||
- **Выполнено и принято:** базовый линейный план 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.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass).
|
||||
|
||||
## Рабочая петля (dev loop) — ВАЖНО
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user