diff --git a/README.md b/README.md index 3b9389f..cccca31 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ mv .env.example .env # then edit SELFPOST_HOSTNAME etc. docker compose up -d ``` +`SELFPOST_HOSTNAME` is required — the container exits immediately with an +explanatory error if it's unset, since it doubles as the Postfix HELO name, +the SASL realm, and must match both the PTR record and the certificate +CN/SAN. + This starts SelfPost alone; it assumes Apache is already installed on the host as the reverse proxy (see below) and expects certificates at `./certs`. The first log line (`docker compose logs -f`) prints the one-time setup link — diff --git a/build/entrypoint.sh b/build/entrypoint.sh index a2854b8..bcfaa40 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -64,6 +64,51 @@ chown opendkim:selfpost /run/opendkim chown panel:selfpost /run/selfpost chmod 2750 /run/opendkim /run/selfpost +# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must +# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the +# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and +# postfix-config.sh each fall back independently when it's unset — to +# `localhost` and to the container hostname respectively — so accounts get +# written under one realm and looked up under another and authentication +# silently fails for every application, while HELO also stops matching the +# PTR record and mail that does go out lands in spam. No fallback can be +# correct, so fail loudly here, before either side of that split has a chance +# to run, rather than leave a green panel with broken mail. +if [ -z "$SELFPOST_HOSTNAME" ]; then + cat >&2 <<'EOF' +FATAL: SELFPOST_HOSTNAME is not set. + +This is the mail server's identity: it becomes the Postfix HELO/EHLO name, +the Cyrus SASL realm that application passwords are looked up under, and it +must match the TLS certificate's CN/SAN as well as this server's PTR (reverse +DNS) record. There is no safe default — guessing any one of these wrong +breaks authentication for every application or sends outgoing mail to spam, +silently. + +Set it to the mail server's fully-qualified domain name, e.g.: + + SELFPOST_HOSTNAME=mail.example.com + +in the .env file next to your docker-compose.yml (see deploy/.env.example). +EOF + exit 1 +fi + +case "$SELFPOST_HOSTNAME" in + *[\ \ ]* | *://* | *:* ) + echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; + *.*) + ;; + *) + echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; +esac + # Generate the outbound-relay Postfix configuration from the environment (spec # 5). Kept out of the image build so cert paths, rate limits, hostname and the # optional 587 service are all driven by env at run time, and re-derived on every diff --git a/deploy/.env.example b/deploy/.env.example index 9ed5d19..de7dadb 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,8 +1,9 @@ # Copy to .env next to docker-compose.yml and fill in. -# Hostname used both for the panel's TLS vhost and for Postfix's SASL realm -# (SASL_REALM defaults to this) and myhostname. Must match the certificate -# your reverse proxy obtains. +# Required — the container refuses to start without it. Hostname used both for +# the panel's TLS vhost and for Postfix's SASL realm (SASL_REALM defaults to +# this) and myhostname; must also match your PTR record. Must match the +# certificate your reverse proxy obtains. Bare FQDN only — no scheme or port. SELFPOST_HOSTNAME=mail.example.com # Set to true to also enable RFC 6409 submission (587/STARTTLS) alongside the diff --git a/docs/progress.md b/docs/progress.md index 0fa998c..61cb5c9 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -40,7 +40,8 @@ - **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX), Фаза 13 (страница `/status`, DNS-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется. - **B.1 реализован** (не выкачен на прод): сессии переехали в SQLite (`internal/store/migrations/0002_sessions.sql`, `internal/store/sessions.go`, `internal/web/session.go`) — хранится SHA-256 токена, не сам токен; скользящий срок бездействия `PANEL_SESSION_IDLE_DAYS` (по умолчанию 7 дней, без абсолютного потолка); запись в БД продлевается не чаще раза в час (`renewThreshold`); опросы мониторинга (`GET` с `HX-Request`) продление не триггерят (`isSessionActivity` в `internal/web/middleware.go`); `Max-Age` cookie выставляется тем же значением при логине и при продлении (`setSessionCookie`); смена пароля разлогинивает все сессии кроме текущей (уже было, теперь через БД). Проверено на стенде: логин → рестарт процесса панели → сессия жива по старой cookie; HX-Request-опрос и повторный GET внутри часового окна не шлют `Set-Cookie`. `go vet`/`go test ./...`/`gofmt -l .` чистые. - **B.2 реализован** (не выкачен на прод): ротация `mail.log` ушла с `copytruncate` на «переименовать + `postfix reload`» — `build/logrotate-mail.conf` (`nocreate` заменён на `create 0644 root root` **не по плану, а по стендовой проверке**: после reload Postfix пересоздаёт лог сам только в момент следующей фактической записи и с режимом `0600`, недоступным непривилегированной панели, — `create` в logrotate закрывает это, отдавая файл ей же на 644 сразу после переименования); `follow()` в `internal/logtail/logtail.go` при обнаружении смены inode дочитывает старый дескриптор ещё раз перед переключением; `readLogTail()` в `internal/web/handlers_monitor.go` считает отсутствующий файл пустым экраном, а не ошибкой. Проверено на стенде (`selfpost.example.com`, отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на dev-сервере; локально на Windows `TestFollowTailsAndRotates` падает — rename открытого файла запрещён ОС, к делу не относится). -- **Решено, но ещё не реализовано:** пункты **B.3** и **C.4** плана, именно в этом порядке. B.3 — незаданный `SELFPOST_HOSTNAME` роняет контейнер в `entrypoint.sh` с развёрнутым текстом ошибки плюс синтаксическая проверка значения. C.4 — герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` поверх поставляемого compose, гейт перед публикацией образа по тегу, нативная матрица amd64/arm64 вместо qemu в `release.yml`; делается **после** B.1–B.3, стендовые проверки B.1/B.3 переезжают в него регрессиями. Замыкает очередь **D.5** — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e это гейт перед тегом. +- **B.3 реализован** (не выкачен на прод): `build/entrypoint.sh` проверяет `SELFPOST_HOSTNAME` до `postfix-config.sh` и до `supervisord` — при пустом значении `exit 1` с развёрнутым текстом ошибки (что это за имя, почему обязательно, пример, где задаётся); плюс синтаксическая проверка через `case`: минимум одна точка, без схемы/порта/пробелов (`*://*`, `*:*`, пробел/таб — тот же класс тихого спам-отказа, что и пустое значение). `saslRealm()` и fallback в `postfix-config.sh` не тронуты — после гейта эти ветки мертвы. Заодно отмечена обязательность переменной в `README.md` и `deploy/.env.example`. Проверено на стенде (`selfpost.example.com`, отдельный образ `selfpost:b3test`, cap-list как в поставляемом compose): без переменной — `exit 1` с ожидаемым текстом, без бесконечного тихого retry; `https://mail.example.com:465` и `localhost` отклонены с понятными сообщениями; валидный `mail.example.com` — обычный старт, все процессы supervisord поднимаются. `go vet`/`go test ./...` чистые. +- **Решено, но ещё не реализовано:** пункт **C.4** плана. Герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` поверх поставляемого compose, гейт перед публикацией образа по тегу, нативная матрица amd64/arm64 вместо qemu в `release.yml`; делается **после** B.1–B.3 (сделаны), стендовые проверки B.1/B.3 переезжают в него регрессиями. Замыкает очередь **D.5** — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e это гейт перед тегом. - **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). - **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).