Compare commits

8 Commits

Author SHA1 Message Date
mix a4cfa11323 docs: retarget the CHANGELOG pointer to the renamed security.md section
test / test (push) Has been cancelled
The 0.5.0 entry pointed at docs/security.md § "Резервная копия и экспорт
домена", a heading that no longer exists after the file was translated. The
name is updated to the current heading; the entry otherwise stands as written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:45:19 +03:00
mix 236cb07769 docs: translate security.md and the last Russian source comments
security.md is linked from the README documentation table and now from
SECURITY.md, so a reader following either link landed in a Russian document
while everything around it was English. Translated in full; the requirements,
the accepted risks, and the CSRF ADR are unchanged in substance.

The reviewing model is no longer named in the text — that the pre-release
review ran, and when, is what a reader needs; who ran it is process detail
kept in development.md.

extract-cert.sh keeps its spec 10.3 quotation, translated. In sasl.go the
quotation from the closed plan is dropped rather than translated: rendered in
English it restated the sentence it hung off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:39:53 +03:00
mix 04993e0da3 docs: add security policy
A public repository with no stated disclosure channel routes a finder into
opening a public issue, which discloses a relay flaw to everyone the moment it
is filed. SECURITY.md points at the repository private vulnerability reporting
instead, with public@mixeme.ru as fallback, and states scope so operator-side
configuration (blocked port 25, missing PTR, proxy TLS) does not arrive as a
report.

No response time is promised: a deadline that cannot be honoured by a single
maintainer is worse than none. Silence is explicitly not a request for a
continued embargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:32:09 +03:00
mix 7eb168f418 feat(panel): label the nav username with "User:"
test / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:49 +03:00
mix a1b6209470 feat(panel): rename Account to Settings
The nav entry, page heading, and browser title now read Settings. The
route, template name, and Active key stay `account`, so existing links
and bookmarks keep working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:17:11 +03:00
mixeme 44683a4996 docs: domain-admin may cover multiple assigned domains
Clarify that the global administrator sets which domains a domain-admin can manage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:21:43 +03:00
mixeme 870012514a docs: recommend web-split before domain-admin and inbound-relay
Document the preferred implementation order in the roadmap and linked plans.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:17:54 +03:00
mixeme 012802d83d docs: restructure roadmap as 1.x+ tracker with plan files
Split detailed design into docs/plans/ and keep roadmap as a status index;
align product, development, and README with the 1.x+ release line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:14:43 +03:00
16 changed files with 640 additions and 262 deletions
+28 -1
View File
@@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
### Added
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
vulnerability reporting, `public@mixeme.ru` as fallback), which releases get
fixes, and what is in and out of scope for a relay. No response time is
promised. Without it a finder's default move is a public issue, which
discloses a relay flaw to everyone the moment it is filed.
### Changed
- [docs/security.md](docs/security.md) is now in English, matching the rest of
the published docs — it is linked from the README table and from
`SECURITY.md`, so a reader following either landed in Russian. Content is
unchanged: same requirements, same accepted risks, same ADR. The reviewing
model is no longer named in the text; the fact that a pre-release review ran,
and its date, stay.
- The two remaining Russian source comments are in English:
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
`internal/app/sasl.go`, where the quotation from the closed plan is dropped
rather than translated — rendered in English it restated the sentence it was
attached to.
- The panel's **Account** entry is now called **Settings** — nav link, page
heading, and browser title. The route stays `/account`, so existing links
and bookmarks are unaffected.
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
the current account rather than as a stray word above the Settings link.
### Fixed
- Release CI: retry `docker push` / `imagetools create` on transient GHCR
@@ -368,7 +395,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
null recipient of a double bounce, `orig_to=` alongside `to=`, an
unrecognised status word, a capitalised one, and a cleanup line.
- docs: README *Encrypting a backup or export*; `docs/security.md` §
*Резервная копия и экспорт домена* + accepted risk (encryption is opt-in);
*Backup and domain export* + accepted risk (encryption is opt-in);
`docs/architecture.md` persistence § envelope summary.
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
release commit (move to `docs/archive/`, retarget its references in README,
+1 -1
View File
@@ -37,7 +37,7 @@ send log and DNS checks in the panel, encrypted backups.
| [Architecture](docs/architecture.md) | As-built technical design |
| [Security](docs/security.md) | Accepted security trade-offs and requirements |
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
| [Roadmap](docs/roadmap.md) | Open work (v1.x tail, 2.x) — internal, Russian |
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — internal, Russian |
| [CHANGELOG](CHANGELOG.md) | Release history |
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and
+81
View File
@@ -0,0 +1,81 @@
# Security policy
## Supported versions
SelfPost follows SemVer. Fixes are issued for the **latest minor release of the
1.x line** only; there is no backporting to earlier minors. Upgrade before
reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z`.
| Version | Supported |
|---|---|
| latest 1.x | yes |
| earlier 1.x | no — upgrade first |
| 0.x | no (pre-release) |
## Reporting a vulnerability
**Do not open a public issue.** Use GitHub's private vulnerability reporting:
the *Report a vulnerability* button under the repository's
[Security tab](https://github.com/mixeme/selfpost/security). If you cannot use
it, mail `public@mixeme.ru` instead.
Useful in a report: the image tag, the reverse proxy in front of the panel, the
steps to reproduce, and what an attacker gains. A relevant excerpt of
`mail.log` or the panel's system log helps; strip recipient addresses first.
**No response time is promised.** SelfPost is maintained by one person, and a
deadline that cannot be honoured is worse than none. Reports are read and
answered as soon as the maintainer is able; a fix ships in a patch release,
with the timeline agreed in the thread.
Disclosure is coordinated by request, not by demand: please hold public details
until a patch is out. If you get no reply, that is not a request for a
continued embargo — disclose at your own discretion. Reporters are credited in
the CHANGELOG unless they ask not to be.
## In scope
The relay's job is to accept authenticated mail from an application and hand it
to the internet as the operator's domain, and nothing else. Breaking that is in
scope:
- **Open relay** — mail accepted from an unauthenticated sender, or relayed for
a domain the sending application is not bound to
- **SASL bypass** — sending without valid credentials, or credential recovery
from anything the container exposes
- **Cross-domain access** — an application or a panel session reaching a domain
it was not granted
- **Secret disclosure** — DKIM private keys, the admin password hash, session
tokens, or backup encryption material leaking to an unauthorised party
- **Panel authentication and session flaws** — login bypass, session fixation,
CSRF on state-changing routes, privilege escalation
- **Rate-limit bypass** — evading either the Postfix-level backstop or the
per-domain and per-application limits
- **Container escape** or privilege escalation from the panel's unprivileged
user to root
## Out of scope
These are the operator's responsibility or accepted trade-offs, documented in
[docs/security.md](docs/security.md) and the
[operator guide](docs/guide.md):
- Host configuration the operator controls: a blocked port 25, a missing or
wrong PTR record, DNS records not published, a self-signed or expired
certificate on the reverse proxy
- Anything requiring the attacker to already have root on the host or write
access to the `./data` bind mount
- Missing hardening headers or TLS options on the reverse proxy — SelfPost
never terminates HTTPS itself
- Deliverability outcomes: mail rejected or filtered by a receiving provider is
a policy decision of that provider, not a defect
- Denial of service through sheer volume against a single-tenant relay
- Vulnerabilities in upstream Postfix, OpenDKIM, or the base image — report
those upstream; if SelfPost's configuration makes an upstream issue
exploitable when it otherwise would not be, that *is* in scope
## Reports we cannot act on
Automated scanner output with no demonstrated impact, and reports whose only
content is a version number compared against a CVE list, are closed without
investigation.
+4 -4
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения
# PEM"). Run this on the host, after Traefik has issued or renewed the
# certificate, and again on a schedule (cron/systemd timer) since acme.json
# is not itself watched by SelfPost/Postfix.
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
# step is required"). Run this on the host, after Traefik has issued or
# renewed the certificate, and again on a schedule (cron/systemd timer) since
# acme.json is not itself watched by SelfPost/Postfix.
#
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
set -eu
+10 -6
View File
@@ -1,8 +1,9 @@
# SelfPost — development
**What this file is.** How to build, test, document, and ship changes. Open
work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
[product.md](product.md). As-built layout: [architecture.md](architecture.md).
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
[architecture.md](architecture.md).
---
@@ -11,10 +12,12 @@ work for 2.x lives in [roadmap.md](roadmap.md). Product boundaries:
After `/clear` or a fresh chat:
1. Read this file (process, docs rules, model routing).
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks —
[security.md](security.md); as-built — [architecture.md](architecture.md).
2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
plan file for the active item. Accepted risks — [security.md](security.md);
as-built — [architecture.md](architecture.md).
3. Skim [product.md](product.md) if scope is in doubt.
4. Continue from the next unchecked step in the active plan.
4. Continue from the next unchecked step in the **active** plan file (not the
roadmap index).
History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
not duplicated here.
@@ -273,7 +276,8 @@ There is no `docs/archive/` directory.
| As-built design | [architecture.md](architecture.md) |
| Development process (this file) | [development.md](development.md) |
| Security requirements and accepted risks | [security.md](security.md) |
| Internal roadmap (2.x) | [roadmap.md](roadmap.md) |
| Internal roadmap (1.x+) | [roadmap.md](roadmap.md) |
| Active design plans | [plans/](plans/) |
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
### User-facing deliverables
+1 -1
View File
@@ -223,7 +223,7 @@ service healthy and will mail be accepted?"
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
the domain-import form (`POST /domains/import`). See
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
- **Account** (`/account`) — change the administrator username and/or password.
- **Settings** (`/account`) — change the administrator username and/or password.
Application SASL logins are separate and are not changed here.
**Sessions.** A login survives a container restart: sessions live in SQLite, not
+70
View File
@@ -0,0 +1,70 @@
# План: domain-admin (роль администратора домена)
**Статус:** согласовано
**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа
в глобального.
**Порядок:** рекомендуется после [web-split](web-split.md), до
[inbound-relay](inbound-relay.md).
---
## Что это
Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль
([web.go](../../internal/web/web.go) — обёртка
`mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта
входа.
Роль выдаёт доступ к **явно назначенным доменам** (одному или нескольким);
перечень доменов определяет **глобальный администратор**. Для каждого домена из
списка:
- приложения этого домена (создание, режим отправителя, перегенерация пароля,
удаление, свой L2-лимит);
- DKIM/DNS-статус домена;
- журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть
([sendLogData](../../internal/web/handlers_monitor.go)).
Вне роли остаётся то, что глобально по своей природе:
- добавление и удаление доменов;
- создание domain-admin пользователей и назначение им доменов;
- `/reload`;
- полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены
сразу);
- очередь и хвост `mail.log` — они серверные и к домену не привязаны.
## Почему расширение v1.0
[product.md](../product.md) относит «несколько пользователей панели, роли» к
out of scope (один администратор). Появление второго субъекта — сознательное
расширение границ проекта, как и inbound-relay.
Цена — уровня фазы, а не патча:
- таблица пользователей и их привязка к доменам;
- роль в сессии;
- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}`
не сверяются ни с чем, кроме существования);
- пересмотр первичного setup'а и смены пароля под нескольких пользователей;
- учёт нового субъекта в бэкапе и экспорте домена.
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» —
заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до
одной конкретной роли, потому что нужна не вторая копия всевластного админа, а
ограниченный доступ владельца одного или нескольких доменов — перечень задаёт
глобальный администратор.)*
## Готово, когда
- Глобальный администратор и domain-admin с разными правами работают через
панель; domain-admin не может выйти за пределы **назначенных** доменов;
- текущий единственный админ мигрирует в глобального без потери доступа;
- бэкап/восстановление учитывает пользователей и привязки;
- `build`/`vet`/`test`/образ зелёные.
## Риски
- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому
домену;
- breaking setup/бэкап — тогда semver major, не 1.x.
+159
View File
@@ -0,0 +1,159 @@
# План: inbound-relay (входящий релей)
**Статус:** согласовано
**Версия:** целевой bump **1.x** MINOR; **возможен 2.x** — требует уточнения по
итогам реализации (не фиксировать major заранее).
**Модель:** Opus (инфра/безопасность, риск open relay).
**Порядок:** рекомендуется после [web-split](web-split.md) и
[domain-admin](domain-admin.md).
---
## Цель
Возможность принимать почту на порт 25 для явно настроенных доменов и пересылать
её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как
выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность
атаки базового исходящего релея.
## Зачем это нужно (сценарии)
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно
недоступен, и передать её, когда он вернётся.
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер,
который по каким-то причинам **сам не может принимать почту из интернета**
(нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на
входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает
публичным входным узлом для домена (MX указывает на него) и пересылает почту
на этот внутренний/недоступный извне сервер.
## Граница объёма (критично — что это НЕ)
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward)
на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`).
Postfix здесь — чистый пересыльщик, без локальной доставки.
- **ЭТО НЕ (out of scope, [product.md](../product.md)):** локальная доставка в
почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost
также **не реализует и не тянет в свой образ** движок антиспама/антивируса
(rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не**
перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет
точку подключения внешнего фильтра.
## Почему как опция/плагин
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter,
spam-ingress). Поэтому по умолчанию **выключено** флагом env
`INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели,
отдельная ветка генерации конфига. При выключенном флаге входной listener,
таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
## Что делать
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать
входной сервис и его конфиг из состояния панели тем же путём, что остальной
конфиг (`postfix-config.sh`).
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25
используется только на исходящую доставку). Отдельный от 465/587: на 25 **не**
предлагается SASL и **не** разрешается отправка наружу — только приём для
`relay_domains`.
- **Анти-open-relay для входящей (обязательно):**
`smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd
принимают почту **только** для доменов из `relay_domains` и **только** для
известных получателей (`relay_recipient_maps`); всё прочее —
`reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём
«для кого угодно» невозможны.
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown
recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
- **Панель управляет:** список входящих доменов; для каждого — upstream
destination (`host:port`, транспорт), опциональный список валидных
получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта
(whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе
4), `os/exec` без shell ([security.md](../security.md)).
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не
подписываем). journal-milter опционально переиспользовать для журнала входящих
(доп. работа) либо на первом этапе оставить входящий без него; поведение
fail-open сохраняется.
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и
`message_size_limit` на входном smtpd.
## Антиспам (важная, но опциональная возможность)
Это ценная опция, но она **не обязательна**: часть операторов вполне устроит
**слепая пересылка без фильтрации** — например, когда backend сам умеет
фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик.
Поэтому антиспам-хук по умолчанию **выключен** (пустой
`INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него.
Важно другое — где фильтрация возможна технически: при «слепом» relay целевой
backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя,
поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация
проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF
домена-отправителя). **Единственная точка, где ещё виден настоящий client IP —
входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть
*подключаема именно здесь*, а не переложена на backend, который эту информацию
уже потерял.
Дизайн подключения:
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.),
который оператор запускает **только если нужна эта опция** (тот же принцип,
что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его
**не содержит и не запускает** — образ и принцип «один контейнер, три
процесса» неизменны, [product.md](../product.md) out of scope не нарушается
(SelfPost не реализует антиспам).
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd.
Адрес движка задаётся env (например,
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и
добавляется в `smtpd_milters` **только входного** тракта (не на 465/587).
Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит
истинный origin. `milter_default_action` для этого milter'а — конфигурируемый
(fail-open vs tempfail); дефолт определить при реализации.
- **Нативный backstop без зависимостей:** на том же входном хопе доступны
средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки
HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение
аутентификации для downstream через ARC/`Received` там, где часть фильтрации
всё же остаётся на backend.
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара
(как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со
стеком только при включённой опции.
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный
бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей
конфигурацией — опционально, пометить.
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на
сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS
README.
## Безопасность
[security.md](../security.md): валидация ввода на сервере, экранирование записи
в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
## Готово, когда
При `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого
домена пересылается на заданный upstream; почта для ненастроенных
доменов/получателей отклоняется (не open relay, не backscatter); при заданном
`INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим
origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при
`INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый
исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
## Риски
- open relay/backscatter — снимается `relay_domains` + `relay_recipient_maps` +
`reject_unauth_destination`;
- потеря origin IP для фильтрации на backend'е при пересылке — снимается
milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё
виден;
- порт 25 на приём расширяет поверхность атаки (по умолчанию выключено);
- semver: при несовместимости контракта (порты, бэкап, поведение без флага) —
возможен major `2.x`; решение после реализации.
**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа
SelfPost, поднимается оператором при включении опции.
## Зависимости
Готовый исходящий тракт (уже реализован). Согласование получено — см. статус
выше.
+52
View File
@@ -0,0 +1,52 @@
# План: web-split (разбиение `internal/web`)
**Статус:** согласовано
**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет.
---
## Что это
`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и
static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех
разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и
рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
## Почему сейчас
На нынешнем размере плоский пакет читается: имена файлов (`handlers_domains.go`,
`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а
разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то
есть расширение внутреннего API ради косметики.
Смысл появляется, когда пакет начнёт расти: **domain-admin** и **inbound-relay**
добавляют в него код — роль приносит авторизацию в каждый хендлер, входящий
релей — отдельные страницы и хендлеры входящих доменов. Рефакторинг дешевле
делать перед этим ростом, чем после.
## Рекомендуемый порядок
**web-split → domain-admin → inbound-relay** (см. [roadmap](../roadmap.md)).
1. **web-split** — заложить структуру пакета (в т.ч. место под `web/auth`), пока
нет сквозных правок от роли и новых inbound-хендлеров.
2. **domain-admin** — авторизация в каждом хендлере опирается на уже выбранную
схему пакета.
3. **inbound-relay** — новый вертикальный срез; проще добавить в уже разрезанный
пакет, чем рефакторить вместе с двумя предыдущими фичами.
Порядок рекомендация, не блокер.
## Готово, когда
Решение принято осознанно в момент старта работ — либо пакет разрезан по
выбранной схеме, либо зафиксировано, что он остаётся плоским. После разрезки:
`build`/`vet`/`test` зелёные, поведение панели неизменно.
## Риски
- Преждевременное разбиение — лишний внутренний API и churn без выгоды;
- откладывание до после роста — сложнее рефакторинг в перемешку с фичами.
+5 -2
View File
@@ -53,8 +53,11 @@ Explicitly excluded to prevent scope creep:
- A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
Future line **2.x.x** (optional inbound relay, domain-admin role) is tracked in
[roadmap.md](roadmap.md) and requires explicit approval before implementation.
Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
tracked in [roadmap.md](roadmap.md) and [plans/](plans/). Inbound relay targets
a 1.x MINOR bump by default; a 2.x major remains possible pending
implementation. Items still marked *candidate* in the roadmap require explicit
approval before coding.
---
+67 -100
View File
@@ -1,133 +1,100 @@
# Дорожная карта: SelfPost 2.x.x
# Дорожная карта: открытая работа (1.x+)
**Статус:** здесь собран объём, отнесённый к релизной линии **2.x.x** — вне
базового объёма v1.0/v1.x (v1.x — только исходящий релей). Реализация —
только после явного согласования ([product.md](product.md),
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)):
[product.md](product.md) явно исключает часть этого объёма (приём входящей
почты; несколько пользователей/роли), поэтому включение — сознательное
расширение границ проекта, а не доработка по своей инициативе. Присутствие
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
решением.
**Статус:** внутренний трекер расширений границ v1.0 после явного согласования
([product.md](product.md), [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
Детальный дизайн — в [plans/](plans/). Пункты со статусом `кандидат` требуют OK
до кодирования.
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации —
[development.md](development.md). История закрытых фаз v1.x — в `git log` и
[CHANGELOG.md](../CHANGELOG.md).
**Версионирование:** по умолчанию SemVer MINOR в линии **1.x+** (`1.1.0`…), если
дефолты и миграции совместимы с `1.0.0`. Major `2.x` — только при явном breaking.
**Процесс:** [development.md](development.md). История закрытых фаз — в `git log`
и [CHANGELOG.md](../CHANGELOG.md).
---
## v1.x — хвост документации и деплоя
## Индекс
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0`
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md`
удалены — история в git и CHANGELOG; `docs/archive/` не храним.
| ID | Тема | Статус | План |
|---|---|---|---|
| web-split | Разбиение `internal/web` | **согласовано** | [plans/web-split.md](plans/web-split.md) |
| domain-admin | Роль администратора домена | **согласовано** | [plans/domain-admin.md](plans/domain-admin.md) |
| inbound-relay | Входящий релей (backup-MX / пересылка) | **согласовано** | [plans/inbound-relay.md](plans/inbound-relay.md) |
| contributing | `CONTRIBUTING.md` | кандидат | — |
| Тема | Итог |
|---|---|
| Адаптивный опрос мониторинга | 5 с / 30 с / 0 (скрытая вкладка) в `panel.js` |
| `mail.log` + reconcile | `/data/log/mail.log`; сверка с `postqueue -p` |
| Docs consolidation | процесс в [development.md](development.md); README Documentation |
| Compose pin + git tag | `1.0.0` / `v1.0.0` в одном релизном коммите |
**Рекомендуемый порядок** (не обязателен): **web-split → domain-admin →
inbound-relay** — сначала разрез пакета, затем сквозная авторизация роли, затем
новый вертикальный срез входящего релея. Отклонение допустимо; жёстких фаз нет.
Открытая работа дальше — только секции 2.x ниже.
После `/clear` — пункт со статусом `согласовано` или `в работе`, затем чеклист в
linked plan.
---
## Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
## inbound-relay
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
**Цель:** опциональный приём почты на порт 25 для явно настроенных доменов и
пересылка на upstream (backup-MX / relay-forwarder). По умолчанию выключено
(`INBOUND_RELAY_ENABLE=false`); исходящий тракт без флага не меняется.
**Зачем это нужно (сценарии):**
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
**Граница:** расширение v1.0 — [product.md](product.md) исключает приём входящей
почты и mailbox'ы. Это relay/forward, не IMAP/POP3/webmail; антиспам-движок — вне
образа, только точка подключения.
**Граница объёма (критично — что это НЕ):**
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
- **ЭТО НЕ (out of scope, [product.md](product.md)):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
**Готово, когда:** см. критерии в [plans/inbound-relay.md](plans/inbound-relay.md).
**Почему как опция/плагин:**
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
**Что делать:**
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell ([security.md](security.md)).
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, [product.md](product.md) out of scope не нарушается (SelfPost не реализует антиспам).
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
**Безопасность ([security.md](security.md)):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования ([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)) до кодирования.
**Зависимости / риски:** готовый исходящий тракт; open relay/backscatter;
расширение поверхности атаки (порт 25 на приём). Модель: Opus.
**Порядок:** рекомендуется после [web-split](plans/web-split.md) и
[domain-admin](plans/domain-admin.md).
**Версия:** целевой bump `1.x`; возможен `2.x` — уточнить по итогам реализации.
---
## Роль администратора домена — кандидат на 2.x
## domain-admin
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go](../internal/web/web.go) — обёртка `mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([sendLogData](../internal/web/handlers_monitor.go)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
**Цель:** роль с доступом к одному или нескольким назначенным доменам
(перечень задаёт глобальный администратор) — приложения, DKIM/DNS, журнал
отправки по каждому из них; без глобальных операций (добавление доменов, полный
бэкап, очередь, `mail.log`).
**Почему 2.x, а не v1.x.** [product.md](product.md) относит «несколько пользователей
панели, роли» к out of scope (один администратор), поэтому появление второго
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
**Граница:** расширение v1.0 — [product.md](product.md) фиксирует одного
администратора. Не вторая копия всевластного админа, а ограниченный доступ к
назначенным доменам (одному или нескольким).
*(Прежняя формулировка этого пункта — «2FA и несколько администраторов» — заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.)*
**Готово, когда:** см. [plans/domain-admin.md](plans/domain-admin.md).
**Зависимости / риски:** таблица пользователей, роль в сессии, авторизация в
каждом хендлере, setup/бэкап. **Порядок:** рекомендуется после
[web-split](plans/web-split.md), до [inbound-relay](plans/inbound-relay.md).
**Версия:** `1.x` MINOR при совместимой миграции текущего админа в глобального.
---
## `CONTRIBUTING.md` — кандидат на 2.x
## web-split
**Что это.** Точка входа для стороннего контрибьютора: dev loop, маршрутизация
моделей по типу работы, протокол коммитов, требование
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
[development.md](development.md) (английский процесс) и в этом файле (открытая
работа, русский).
**Цель:** осознанно разрезать `internal/web` (или зафиксировать плоский пакет)
перед ростом от inbound-relay и domain-admin.
**Почему 2.x, а не v1.x.** Файл имеет смысл, когда есть кому его читать: у
проекта один разработчик и внешнего потока PR нет, поэтому сейчас
`CONTRIBUTING.md` был бы документом без аудитории и ещё одним местом, где
расходится правда о dev loop. Уместен вместе с тем, что реально открывает
проект вовне: английская документация процесса ([development.md](development.md),
README, `architecture.md`; [roadmap.md](roadmap.md) — внутренний трекер, на
русском) и первый внешний интерес после публикации релиза.
**Граница:** внутренний рефакторинг; поведение панели для оператора не меняется.
**Готово, когда:** `CONTRIBUTING.md` в корне описывает dev loop, требования к
проверкам перед PR и протокол коммитов; [development.md](development.md) не
дублирует его, а ссылается.
**Готово, когда:** пакет разрезан по выбранной схеме или зафиксировано, что
остаётся плоским — см. [plans/web-split.md](plans/web-split.md).
**Зависимости / риски:** экспорт пакетно-приватного API. **Порядок:** рекомендуется
**первым** среди согласованных фич (до domain-admin и inbound-relay).
**Версия:** `1.x`, сам по себе breaking не тянет.
---
## Разбиение `internal/web` на подпакеты — кандидат на 2.x
## contributing
**Что это.** `internal/web` — самый крупный пакет проекта: ~50 файлов
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости
лежат хендлеры всех разделов панели, сессии,
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
**Цель:** `CONTRIBUTING.md` в корне — dev loop, проверки перед PR, протокол
коммитов; [development.md](development.md) ссылается, не дублирует.
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
2.x выше добавляют в него код — роль администратора домена приносит
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
**Граница:** документация процесса; уместна при внешнем потоке PR.
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет
разрезан, либо зафиксировано, что он остаётся плоским.
**Готово, когда:** файл в корне; development.md не дублирует его.
**Зависимости / риски:** пока один разработчик и нет PR — низкий приоритет.
**Версия:** без значения для semver.
+157 -141
View File
@@ -1,177 +1,193 @@
# Безопасность
# Security
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D,
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка
defence-in-depth — `--` перед логином в argv `saslpasswd2`
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски**
сознательные отступления сверх обязательного, чтобы решение не потерялось.
**What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
in full: no exploitable findings; one defence-in-depth change — `--` before the
login in the `saslpasswd2` argv
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks**
deliberate departures beyond the mandatory, recorded so the decision is not
lost.
Hardening сверх обязательного (security-заголовки, проверка origin, cookie
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
[CHANGELOG.md](../CHANGELOG.md) и `git log`.
Hardening beyond the mandatory (security headers, origin checking, `__Host-`
cookie with duplicate detection — Phase 14) is done; the history is in
[CHANGELOG.md](../CHANGELOG.md) and `git log`.
Продуктовые границы: [product.md](product.md). Устройство as-built:
Product boundaries: [product.md](product.md). As-built design:
[architecture.md](architecture.md).
---
## Обязательные требования
## Mandatory requirements
Панель публична из интернета — пункты ниже **не опциональны**.
The panel is exposed to the internet — the items below are **not optional**.
### Первичная инициализация администратора
### First-run administrator setup
- Одноразовая secret-ссылка `/setup/<token>`, **не** env с готовым хэшем пароля.
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`.
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой
настройки — перегенерация и новый вывод в лог.
- Rate limiting на `/setup/<token>` по IP, отдельно от логина.
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`).
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки).
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404.
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5.
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**.
- A one-time secret link `/setup/<token>`, **not** an env variable holding a
ready-made password hash.
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
unfinished, it is regenerated and logged again.
- Rate limiting on `/setup/<token>` per IP, separate from login.
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
- Failed attempts do **not** invalidate the token early (protects setup from
being DoS-ed).
- Once the administrator exists the token is void forever, `/setup/*` → 404.
- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext
and no MD5.
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
### SASL-пароли приложений
### Application SASL passwords
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
перевыпуск.
- The panel **generates** the password on creation or reissue and shows it
**once**.
- In `sasldb2` it is stored in the form SASL requires (not plaintext held by the
panel); a lost password can only be reissued.
### Ввод и конфигурация
### Input and configuration
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
защитой.
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
- `postfix reload` и любой `exec`**без** shell-интерполяции пользовательского
ввода; аргументы отдельными элементами.
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
- Server-side validation of addresses and domains (character whitelist);
client-side validation does not count as protection.
- In address-list mode every address is checked to belong to the application's
domain before it is written.
- `postfix reload` and any `exec` run **without** shell interpolation of user
input; arguments are passed as separate elements.
- Writes to config files are escaped (no injection of Postfix directives).
### Аутентификация и сессии
### Authentication and sessions
- Rate limiting на логин (по IP, с блокировкой/задержкой).
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
(`PANEL_SESSION_IDLE_DAYS`).
- Rate limiting on login (per IP, with lockout or delay).
- Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
`SameSite`.
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
idle timeout (`PANEL_SESSION_IDLE_DAYS`).
### Вывод и процесс
### Output and process
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
группу `selfpost` и минимальные права.
- Rendering goes through `html/template` with auto-escaping (queue, log,
journal, themes).
- The panel process is **not root** (`user=panel` in supervisord); path access
is granted through the `selfpost` group with minimal permissions.
### Почтовый тракт (связанное с безопасностью)
### Mail path (security-relevant)
- **Не open relay** — только SASL; `reject_unauth_destination`;
- **Not an open relay** — SASL only; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`).
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For`
при rate-limit логина; пусто = XFF игнорируется.
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
`encrypt`).
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
`X-Forwarded-For` for login rate limiting; empty means XFF is ignored.
### Резервная копия и экспорт домена
### Backup and domain export
- Оба файла — секреты: полный бэкап несёт DKIM-ключи, `sasldb2` и хеш пароля
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым
текстом (иначе перенос без пересоздания кредов невозможен).
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый
аутентифицирован заголовком, номером и флагом конца потока — обрезанный или
подменённый файл не открывается вместо тихого восстановления «хвоста».
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go).
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап),
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена);
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет
шифрование по magic файла, а не по расширению.
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI —
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда
аргументом (список процессов читается любым процессом контейнера).
- Минимальная длина пароля — как у пароля администратора (12): файл лежит
offline и подбирается без ограничений по времени.
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
administrator's password hash; a domain export carries the DKIM key and
**working** application passwords in the clear (otherwise a transfer without
recreating credentials would be impossible).
- Both downloads can be encrypted with a password (a checkbox on the form):
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
authenticated with the header, the chunk number, and an end-of-stream flag —
a truncated or substituted file fails to open instead of silently restoring a
partial "tail". Format and wrapper:
[internal/secretfile](../internal/secretfile/secretfile.go).
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
(**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
bytes, not by extension.
- The password is never stored: without it the file cannot be recovered. In the
CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
`-password-file`, never as an argument (the process list is readable by any
process in the container).
- Minimum password length matches the administrator password (12): the file
sits offline and can be attacked without a time limit.
---
## Принятые риски
## Accepted risks
Принятый риск — решение с условием возврата, а не отложенная задача из
дорожной карты.
An accepted risk is a decision with a condition for revisiting it, not a
deferred item from the roadmap.
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
Принято сознательно: панель однопользовательская, админ выбирает браузер
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
панель. Ужесточение — одна строка в `originAllowed`
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
вместо `true` в ветке «нет обоих заголовков».
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
вопросу считать появление требования «устойчиво независимо от браузера».
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
панели, отправит запрос сам — против этого работают автоэкранирование
`html/template` и CSP, поэтому шаблоны не должны содержать
inline-скриптов и inline-стилей.
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята —
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места
для хранения пароля, потерял бы возможность сделать бэкап вообще, а
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost
не хранит. Триггером сделать шифрование обязательным считать появление
второго администратора (тогда «кто скачал» перестаёт быть одним человеком).
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост
дочитывается. Остаток — строки, delivery-строки которых потеряны
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус
окажется ложно-отрицательным. Принято сознательно: доставка, которую панель
не может подтвердить, не должна показываться как подтверждённая, а вечный
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не
читается. См. [architecture.md](architecture.md) § Log tailer.
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог
`/data/log``2750 postfix:selfpost`, файл — `0640`: пишет `postlogd`
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка
оставалась состоянием, а не диагностикой.
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
A client that sends neither — a genuinely old browser, or a webview with a
frozen engine — stays vulnerable to CSRF from any site. Accepted
deliberately: the panel is single-user, the administrator picks the browser,
and a strict mode would not "protect" such a client, it would simply break the
panel in it. Tightening is one line in `originAllowed`
([internal/web/security.go](../internal/web/security.go)): return `false`
instead of `true` in the "neither header present" branch.
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
neighbouring-subdomain case but depends on browser behaviour; a token does
not. The price is a hidden field in roughly two dozen forms. The trigger to
revisit is a requirement for protection that holds regardless of the browser.
A token would not save the panel from XSS inside it either: code executing in
the panel's origin sends the request itself — against that, `html/template`
auto-escaping and CSP do the work, which is why templates must contain no
inline scripts and no inline styles.
- **Encrypting backups and exports is an option, not the default.** With the
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
operator with nowhere to keep a password would lose the ability to take a
backup at all, and a permanently undecryptable archive is worse than an
unencrypted one: SelfPost does not store the password. The trigger to make
encryption mandatory is a second administrator (at which point "who
downloaded it" stops being one person).
- **A journal row left without delivery lines is closed as `bounced` rather
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
`/data/log/` and survives container recreation, and the log tailer keeps its
read position (`logtail_state`, migration `0003`), so the tail is read after a
start. What remains are rows whose delivery lines are lost for good (the log
rotated past 14 files while the panel was down, or was deleted): the
reconciliation against `postqueue -p` sees the message is not in the queue and
after a 2-minute grace marks it `bounced`. If the message did in fact go out,
the status is a false negative. Accepted deliberately: a delivery the panel
cannot confirm must not be shown as confirmed, and a permanent `queued` is
indistinguishable from "in flight right now". Reconciliation does not run
until the tailer has read the log to the end, and touches nothing if
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log
tailer.
- **Access to `mail.log` from the unprivileged panel.** The `/data/log`
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
`postfix`) writes, the panel reads through the shared `selfpost` group, and
the file is inaccessible to others. The log holds envelope addresses and
client IPs, but neither message bodies nor headers; it is excluded from
backups (`log/` is skipped) so that a dump stays state rather than
diagnostics.
## ADR: CSRF через проверку Origin, без токенов
## ADR: CSRF via origin checking, without tokens
**Контекст.** Панель — формы (`POST`) с cookie-сессией; классическая CSRF-
поверхность. Нужен способ отличить запрос со страницы панели от запроса,
инициированного сторонним сайтом в браузере залогиненного админа.
**Context.** The panel is forms (`POST`) with a cookie session — the classic
CSRF surface. What is needed is a way to tell a request from the panel's own
page apart from one initiated by a third-party site in the logged-in
administrator's browser.
**Решение.** `originAllowed` в
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site`
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без
обоих заголовков **пропускается**, а не отклоняется. Токенов, привязанных к
сессии и встроенных в формы, нет.
**Decision.** `originAllowed` in
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
request carrying neither header is **allowed through** rather than rejected.
There are no session-bound tokens embedded in forms.
**Почему не токены.** Панель однопользовательская (один администратор на
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой
панели, только внешний сайт, заставляющий браузер админа отправить запрос.
Origin-проверка закрывает это без изменения ни одного шаблона: токен потребовал
бы скрытого поля примерно в двух десятках форм и синхронизации при каждой
новой форме, а от XSS внутри панели токен всё равно не защищает — код,
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия
обороны, не CSRF-токен.
**Why not tokens.** The panel is single-user (one administrator per instance) —
the threat model does not include cross-user CSRF inside the panel itself, only
an external site making the administrator's browser send a request. The origin
check covers that without touching a single template: a token would need a
hidden field in roughly two dozen forms and synchronisation with every new form,
and it would still not protect against XSS inside the panel — code executing in
the panel's origin reads the token and sends the request itself. XSS is handled
by `html/template` auto-escaping and CSP, so that is a separate line of defence,
not a CSRF token.
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin`
(по-настоящему старый браузер или webview с замороженным движком), остаётся
уязвим — см. «Принятые риски» выше. Это осознанный выбор в пользу не ломать
панель в таком клиенте ценой узкой остаточной поверхности.
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
"Accepted risks" above. This is a deliberate choice not to break the panel in
such a client, at the price of a narrow residual surface.
**Пересмотр, если:** появится требование защиты, не зависящей от поведения
браузера, или панель станет многопользовательской.
**Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or the panel becomes multi-user.
## Как этот список пополняется
## How this list grows
Предрелизная проверка на уязвимости (модель Fable; история — CHANGELOG
`[0.5.0]` Security) закрывает каждую находку одним из двух способов: правка до
тега — либо запись сюда, с обоснованием и условием возврата, как у пунктов выше.
Третьего варианта («посмотрели и ладно») нет.
The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
closes every finding in one of two ways: a fix before the tag, or an entry here
with its rationale and its condition for revisiting, like the items above.
There is no third option ("we looked at it and moved on").
+1 -2
View File
@@ -12,8 +12,7 @@ import (
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
// for application credentials (architecture.md § Mail path). The panel is the
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
// created and removed with the standard saslpasswd2 tool ("эквивалент
// saslpasswd2", per the plan).
// created and removed with the standard saslpasswd2 tool.
type SASLDB struct {
path string // sasldb2 file, under /data so it survives restarts
realm string // SASL realm, so lookups match what Postfix's SASL uses
+1 -1
View File
@@ -30,7 +30,7 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
// field after a rejected submission; the password fields are never repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
s.render(w, status, "account", map[string]any{
"Title": "SelfPost — account",
"Title": "SelfPost — settings",
"User": currentUser(r),
"Active": "account",
"FormUsername": formUsername,
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "content"}}
<h1>Account</h1>
<h1>Settings</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
+2 -2
View File
@@ -69,8 +69,8 @@
</div>
{{template "sections" .}}
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<span class="muted">User: {{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>