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] ## [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 ### Fixed
- Release CI: retry `docker push` / `imagetools create` on transient GHCR - 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 null recipient of a double bounce, `orig_to=` alongside `to=`, an
unrecognised status word, a capitalised one, and a cleanup line. unrecognised status word, a capitalised one, and a cleanup line.
- docs: README *Encrypting a backup or export*; `docs/security.md` § - 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/architecture.md` persistence § envelope summary.
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the - docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
release commit (move to `docs/archive/`, retarget its references in README, 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 | | [Architecture](docs/architecture.md) | As-built technical design |
| [Security](docs/security.md) | Accepted security trade-offs and requirements | | [Security](docs/security.md) | Accepted security trade-offs and requirements |
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits | | [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 | | [CHANGELOG](CHANGELOG.md) | Release history |
Repository: <https://github.com/mixeme/selfpost> — source, issues, releases, and 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 #!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json # Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения # (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
# PEM"). Run this on the host, after Traefik has issued or renewed the # step is required"). Run this on the host, after Traefik has issued or
# certificate, and again on a schedule (cron/systemd timer) since acme.json # renewed the certificate, and again on a schedule (cron/systemd timer) since
# is not itself watched by SelfPost/Postfix. # acme.json is not itself watched by SelfPost/Postfix.
# #
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir> # Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
set -eu set -eu
+10 -6
View File
@@ -1,8 +1,9 @@
# SelfPost — development # SelfPost — development
**What this file is.** How to build, test, document, and ship changes. Open **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: work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
[product.md](product.md). As-built layout: [architecture.md](architecture.md). [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: After `/clear` or a fresh chat:
1. Read this file (process, docs rules, model routing). 1. Read this file (process, docs rules, model routing).
2. Open [roadmap.md](roadmap.md) for open work. Accepted risks — 2. Open [roadmap.md](roadmap.md) for the index of open work; follow the linked
[security.md](security.md); as-built — [architecture.md](architecture.md). 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. 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), History of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md),
not duplicated here. not duplicated here.
@@ -273,7 +276,8 @@ There is no `docs/archive/` directory.
| As-built design | [architecture.md](architecture.md) | | As-built design | [architecture.md](architecture.md) |
| Development process (this file) | [development.md](development.md) | | Development process (this file) | [development.md](development.md) |
| Security requirements and accepted risks | [security.md](security.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) | | Release history | [CHANGELOG.md](../CHANGELOG.md) |
### User-facing deliverables ### 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 - **Backup** (`/backup`) — download a full-server backup; the same page hosts
the domain-import form (`POST /domains/import`). See the domain-import form (`POST /domains/import`). See
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain). [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. Application SASL logins are separate and are not changed here.
**Sessions.** A login survives a container restart: sessions live in SQLite, not **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 - A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only - 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 Agreed **1.x+** extensions (optional inbound relay, domain-admin role) are
[roadmap.md](roadmap.md) and requires explicit approval before implementation. 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.0/v1.x (v1.x — только исходящий релей). Реализация — ([product.md](product.md), [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
только после явного согласования ([product.md](product.md), Детальный дизайн — в [plans/](plans/). Пункты со статусом `кандидат` требуют OK
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)): до кодирования.
[product.md](product.md) явно исключает часть этого объёма (приём входящей
почты; несколько пользователей/роли), поэтому включение — сознательное
расширение границ проекта, а не доработка по своей инициативе. Присутствие
пункта здесь фиксирует намерение и дизайн; кодирование начинается отдельным
решением.
**Основа:** [product.md](product.md) v1.0. Процесс и правила документации — **Версионирование:** по умолчанию SemVer MINOR в линии **1.x+** (`1.1.0`…), если
[development.md](development.md). История закрытых фаз v1.x — в `git log` и дефолты и миграции совместимы с `1.0.0`. Major `2.x` — только при явном breaking.
[CHANGELOG.md](../CHANGELOG.md).
**Процесс:** [development.md](development.md). История закрытых фаз — в `git log`
и [CHANGELOG.md](../CHANGELOG.md).
--- ---
## v1.x — хвост документации и деплоя ## Индекс
**Статус: закрыто** в релизе `1.0.0` / git-тег `v1.0.0` | ID | Тема | Статус | План |
(`ghcr.io/mixeme/selfpost:1.0.0`). План закрытия и `implementation-plan.md` |---|---|---|---|
удалены — история в git и CHANGELOG; `docs/archive/` не храним. | 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` | кандидат | — |
| Тема | Итог | **Рекомендуемый порядок** (не обязателен): **web-split → domain-admin →
|---|---| inbound-relay** — сначала разрез пакета, затем сквозная авторизация роли, затем
| Адаптивный опрос мониторинга | 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` в одном релизном коммите |
Открытая работа дальше — только секции 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`); исходящий тракт без флага не меняется.
**Зачем это нужно (сценарии):** **Граница:** расширение v1.0 — [product.md](product.md) исключает приём входящей
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся. почты и mailbox'ы. Это relay/forward, не IMAP/POP3/webmail; антиспам-движок — вне
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер. образа, только точка подключения.
**Граница объёма (критично — что это НЕ):** **Готово, когда:** см. критерии в [plans/inbound-relay.md](plans/inbound-relay.md).
- **ЭТО:** приём на 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 (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
**Почему как опция/плагин:** **Зависимости / риски:** готовый исходящий тракт; open relay/backscatter;
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора. расширение поверхности атаки (порт 25 на приём). Модель: Opus.
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен. **Порядок:** рекомендуется после [web-split](plans/web-split.md) и
[domain-admin](plans/domain-admin.md).
**Что делать:** **Версия:** целевой bump `1.x`; возможен `2.x` — уточнить по итогам реализации.
- 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)) до кодирования.
--- ---
## Роль администратора домена — кандидат на 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) относит «несколько пользователей **Граница:** расширение v1.0 — [product.md](product.md) фиксирует одного
панели, роли» к out of scope (один администратор), поэтому появление второго администратора. Не вторая копия всевластного админа, а ограниченный доступ к
субъекта — расширение границ проекта, как и Фаза O1: сначала согласование назначенным доменам (одному или нескольким).
([.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)), только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
*(Прежняя формулировка этого пункта — «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, маршрутизация **Цель:** осознанно разрезать `internal/web` (или зафиксировать плоский пакет)
моделей по типу работы, протокол коммитов, требование перед ростом от inbound-relay и domain-admin.
`gofmt`/`vet`/`test`/`make e2e` до PR. Сейчас всё это есть в
[development.md](development.md) (английский процесс) и в этом файле (открытая
работа, русский).
**Почему 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 файлов **Цель:** `CONTRIBUTING.md` в корне — dev loop, проверки перед PR, протокол
(включая шаблоны и static), ~25 `.go` / ~4000 строк Go, в одной плоскости коммитов; [development.md](development.md) ссылается, не дублирует.
лежат хендлеры всех разделов панели, сессии,
security-заголовки, проверка Origin, валидация форм и рендер шаблонов.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
**Почему 2.x, а не сейчас.** На нынешнем размере плоский пакет читается: имена **Граница:** документация процесса; уместна при внешнем потоке PR.
файлов (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`)
работают не хуже каталогов, а разбиение потянуло бы за собой экспорт того, что
сейчас пакетно-приватно, — то есть расширение внутреннего API ради
косметики. Смысл появляется ровно тогда, когда пакет начнёт расти: обе задачи
2.x выше добавляют в него код — роль администратора домена приносит
авторизацию в каждый хендлер, входящий релей — отдельные страницы и хендлеры
входящих доменов. Рефакторинг дешевле делать перед этим ростом, чем после.
**Готово, когда:** решение принято осознанно в момент старта 2.x — либо пакет **Готово, когда:** файл в корне; development.md не дублирует его.
разрезан, либо зафиксировано, что он остаётся плоским.
**Зависимости / риски:** пока один разработчик и нет PR — низкий приоритет.
**Версия:** без значения для semver.
+157 -141
View File
@@ -1,177 +1,193 @@
# Безопасность # Security
**Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан **What is here.** (1) **Mandatory requirements** — the checklist v1.0 has to
выполнять; полный аудит на v1.0 пройден. Предрелизная ревизия (план § D, meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
модель Fable, 2026-08-06) прошла по всему дифу от аудита v1.0 (Фаза 11) до covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
HEAD и по чек-листу целиком: эксплуатируемых находок нет; одна правка in full: no exploitable findings; one defence-in-depth change — `--` before the
defence-in-depth — `--` перед логином в argv `saslpasswd2` login in the `saslpasswd2` argv
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Принятые риски** ([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 Hardening beyond the mandatory (security headers, origin checking, `__Host-`
`__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в cookie with duplicate detection — Phase 14) is done; the history is in
[CHANGELOG.md](../CHANGELOG.md) и `git log`. [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). [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 с готовым хэшем пароля. - A one-time secret link `/setup/<token>`, **not** an env variable holding a
- Токен ≥128 бит (`crypto/rand`); дублируется в `/data/setup-token`. ready-made password hash.
- Срок жизни токена — **10 минут**; после истечения или рестарта без завершённой - Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
настройки — перегенерация и новый вывод в лог. - Token lifetime — **10 minutes**; after expiry, or after a restart with setup
- Rate limiting на `/setup/<token>` по IP, отдельно от логина. unfinished, it is regenerated and logged again.
- Сравнение токена — **константное по времени** (`subtle.ConstantTimeCompare`). - Rate limiting on `/setup/<token>` per IP, separate from login.
- Неудачные попытки **не** инвалидируют токен досрочно (защита от DoS настройки). - Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
- После создания администратора — токен навсегда недействителен, `/setup/*` → 404. - Failed attempts do **not** invalidate the token early (protects setup from
- Пароль администратора — только bcrypt (или argon2) в SQLite; без plaintext/MD5. being DoS-ed).
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` в env **не используются**. - 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
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**. - The panel **generates** the password on creation or reissue and shows it
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только **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 символов); клиентская не считается - 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
- `postfix reload` и любой `exec`**без** shell-интерполяции пользовательского domain before it is written.
ввода; аргументы отдельными элементами. - `postfix reload` and any `exec` run **without** shell interpolation of user
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix). input; arguments are passed as separate elements.
- Writes to config files are escaped (no injection of Postfix directives).
### Аутентификация и сессии ### Authentication and sessions
- Rate limiting на логин (по IP, с блокировкой/задержкой). - Rate limiting on login (per IP, with lockout or delay).
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`. - Sessions: cryptographically random token; cookie `HttpOnly`, `Secure`,
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle `SameSite`.
(`PANEL_SESSION_IDLE_DAYS`). - 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` с автоэкранированием (очередь, лог, журнал, темы). - Rendering goes through `html/template` with auto-escaping (queue, log,
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через journal, themes).
группу `selfpost` и минимальные права. - 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`. `smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- TLS обязателен до передачи кредов (465 wrapper / 587 `encrypt`). - TLS is mandatory before credentials are transmitted (465 wrapper / 587
- `TRUSTED_PROXY_CIDR` — только явно доверенные прокси для `X-Forwarded-For` `encrypt`).
при rate-limit логина; пусто = XFF игнорируется. - `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` и хеш пароля - Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
админа; экспорт домена — DKIM-ключ и **рабочие** пароли приложений открытым administrator's password hash; a domain export carries the DKIM key and
текстом (иначе перенос без пересоздания кредов невозможен). **working** application passwords in the clear (otherwise a transfer without
- Оба скачивания можно зашифровать паролем (чекбокс в форме): scrypt recreating credentials would be impossible).
(N=2¹⁵, r=8, p=1) → AES-256-GCM, поток из 64 KiB чанков, каждый - 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 —
Формат и обёртка: [internal/secretfile](../internal/secretfile/secretfile.go). a truncated or substituted file fails to open instead of silently restoring a
- Расширения: `.spbk` (**S**elf**P**ost **b**ac**k**up — полный бэкап), partial "tail". Format and wrapper:
`.spde` (**S**elf**P**ost **d**omain **e**xport — экспорт домена); [internal/secretfile](../internal/secretfile/secretfile.go).
незашифрованные остаются `.tar.gz` / `.json`. Импорт домена определяет - Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
шифрование по magic файла, а не по расширению. (**S**elf**P**ost **d**omain **e**xport — domain export); unencrypted files
- Пароль нигде не сохраняется: восстановить файл без него нельзя. Пароль в CLI — stay `.tar.gz` / `.json`. Domain import detects encryption by the file's magic
только через `SELFPOST_BACKUP_PASSWORD` или `-password-file`, никогда bytes, not by extension.
аргументом (список процессов читается любым процессом контейнера). - The password is never stored: without it the file cannot be recovered. In the
- Минимальная длина пароля — как у пароля администратора (12): файл лежит CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or
offline и подбирается без ограничений по времени. `-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` пропускается.** - **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
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. 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
панель. Ужесточение — одна строка в `originAllowed` panel in it. Tightening is one line in `originAllowed`
([internal/web/security.go](../internal/web/security.go)): вернуть `false` ([internal/web/security.go](../internal/web/security.go)): return `false`
вместо `true` в ветке «нет обоих заголовков». instead of `true` in the "neither header present" branch.
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin - **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.
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin 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`
`html/template` и CSP, поэтому шаблоны не должны содержать auto-escaping and CSP do the work, which is why templates must contain no
inline-скриптов и inline-стилей. inline scripts and no inline styles.
- **Шифрование бэкапа и экспорта — опция, а не умолчание.** Галочка снята — - **Encrypting backups and exports is an option, not the default.** With the
файл скачивается открытым, как в 1.0. Иначе оператор, у которого нет места 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
безвозвратно нерасшифровываемый архив хуже незашифрованного: пароль SelfPost 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
- **Строка журнала, оставшаяся без delivery-строк, закрывается как `bounced`, а downloaded it" stops being one person).
не как есть.** Риск «вечный `queued`» снят: `mail.log` переехал в - **A journal row left without delivery lines is closed as `bounced` rather
`/data/log/` и переживает пересоздание контейнера, а log-tailer сохраняет than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
позицию чтения (`logtail_state`, миграция `0003`), так что после старта хвост `/data/log/` and survives container recreation, and the log tailer keeps its
дочитывается. Остаток — строки, delivery-строки которых потеряны read position (`logtail_state`, migration `0003`), so the tail is read after a
безвозвратно (лог провернулся дальше 14 файлов, пока панель лежала, либо был start. What remains are rows whose delivery lines are lost for good (the log
удалён): сверка с `postqueue -p` видит, что письма в очереди нет, и через rotated past 14 files while the panel was down, or was deleted): the
2 минуты grace ставит `bounced`. Если письмо на самом деле ушло, статус 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
`queued` не отличим от «висит прямо сейчас». Сверка не срабатывает, пока cannot confirm must not be shown as confirmed, and a permanent `queued` is
tailer не дочитал лог до конца, и не трогает ничего, если `postqueue` не indistinguishable from "in flight right now". Reconciliation does not run
читается. См. [architecture.md](architecture.md) § Log tailer. until the tailer has read the log to the end, and touches nothing if
- **Доступ к `mail.log` из-под непривилегированной панели.** Каталог `postqueue` is unreadable. See [architecture.md](architecture.md) § Log
`/data/log``2750 postfix:selfpost`, файл — `0640`: пишет `postlogd` tailer.
(пользователь `postfix`), читает панель по общей группе `selfpost`, миру файл - **Access to `mail.log` from the unprivileged panel.** The `/data/log`
недоступен. Лог содержит envelope-адреса и IP клиентов, но не тела и не directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
заголовки писем; в бэкап он не попадает (`log/` исключён), чтобы выгрузка `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` в **Decision.** `originAllowed` in
[internal/web/security.go](../internal/web/security.go) сверяет `Sec-Fetch-Site` [internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
(если браузер его шлёт) либо `Origin` (fallback) с хостом панели; запрос без (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.
**Почему не токены.** Панель однопользовательская (один администратор на **Why not tokens.** The panel is single-user (one administrator per instance) —
инстанс) — модель угроз не включает межпользовательский CSRF внутри самой 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
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,
новой форме, а от XSS внутри панели токен всё равно не защищает — код, and it would still not protect against XSS inside the panel — code executing in
исполняющийся в origin панели, читает токен и отправляет запрос сам. От XSS the panel's origin reads the token and sends the request itself. XSS is handled
защищают автоэкранирование `html/template` и CSP, поэтому это отдельная линия by `html/template` auto-escaping and CSP, so that is a separate line of defence,
обороны, не CSRF-токен. not a CSRF token.
**Компромисс.** Клиент, не посылающий ни `Sec-Fetch-Site`, ни `Origin` **Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
(по-настоящему старый браузер или webview с замороженным движком), остаётся 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 The pre-release vulnerability review (history — CHANGELOG `[0.5.0]` Security)
`[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 // SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
// for application credentials (architecture.md § Mail path). The panel is the // for application credentials (architecture.md § Mail path). The panel is the
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are // only writer; Postfix reads it to authenticate SMTP clients. Accounts are
// created and removed with the standard saslpasswd2 tool ("эквивалент // created and removed with the standard saslpasswd2 tool.
// saslpasswd2", per the plan).
type SASLDB struct { type SASLDB struct {
path string // sasldb2 file, under /data so it survives restarts path string // sasldb2 file, under /data so it survives restarts
realm string // SASL realm, so lookups match what Postfix's SASL uses 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. // 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) { func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
s.render(w, status, "account", map[string]any{ s.render(w, status, "account", map[string]any{
"Title": "SelfPost — account", "Title": "SelfPost — settings",
"User": currentUser(r), "User": currentUser(r),
"Active": "account", "Active": "account",
"FormUsername": formUsername, "FormUsername": formUsername,
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "content"}} {{define "content"}}
<h1>Account</h1> <h1>Settings</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
+2 -2
View File
@@ -69,8 +69,8 @@
</div> </div>
{{template "sections" .}} {{template "sections" .}}
<div class="session"> <div class="session">
<span class="muted">{{.User}}</span> <span class="muted">User: {{.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}} {{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"> <form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button> <button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form> </form>