docs: D6-D9 — HEALTHCHECK, env regression test, new docs, archive spec

Add Docker HEALTHCHECK and mail-path /healthz liveness; env-doc regression
test; architecture.md and development.md; product.md and expanded security.md;
retire live specification.md to docs/archive/.

Co-Authored-By: Claude <claude-opus-5-thinking-high@noreply@anthropic.com>
This commit is contained in:
2026-08-05 00:33:49 +03:00
parent e335526162
commit 995bd5db84
16 changed files with 703 additions and 28 deletions
+18
View File
@@ -7,6 +7,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Added ### Added
- docs (D6): Docker `HEALTHCHECK` probes `/healthz`; endpoint returns 503 unless
opendkim, panel, and postfix are RUNNING (`internal/health.Liveness`).
- docs (D6): README *Container health* — scope of `/healthz` vs authenticated Status.
- docs (D7): `cmd/panel/envdoc_test.go` — regression test that every
`loadConfig` and build-script env key is listed in README documentation.
- docs (D8): `docs/architecture.md` — as-built processes, mail path, routes,
persistence (verified against code).
- docs (D8): `docs/development.md` — local Go workflow, `make e2e`, dev-server
loop, commit/CHANGELOG protocol, agent rules.
- docs (D9): `docs/product.md` — product purpose, assumptions, out-of-scope,
multi-domain model.
- docs (D9): `docs/security.md` — self-contained mandatory security checklist
(former spec §7.6).
- docs (D9): `specification.md` archived to
`docs/archive/specification-v1.0.md`; live docs updated (`progress.md`,
`implementation-plan.md`, `roadmap.md`, `documentation-plan.md`).
- docs (D1): README *Operations* — panel screens (`/status`, domains, - docs (D1): README *Operations* — panel screens (`/status`, domains,
deliveries, mail queue, system log, backup, account), upgrade procedure, deliveries, mail queue, system log, backup, account), upgrade procedure,
session behaviour (sliding idle, monitoring polls do not extend, password session behaviour (sliding idle, monitoring polls do not extend, password
@@ -22,6 +38,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed ### Changed
- `/healthz` now checks supervisord mail-path processes, not HTTP alone.
- `build/Dockerfile`: `curl` for `HEALTHCHECK`; probe on port 8080.
- docs (D3): README backup — stopped-container `tar` of `./data` (with live-container - docs (D3): README backup — stopped-container `tar` of `./data` (with live-container
WAL warning), `manifest.json` consumed after a matching restore. WAL warning), `manifest.json` consumed after a matching restore.
- docs (D4): README status banner (v1.0 implemented, links to open questions and - docs (D4): README status banner (v1.0 implemented, links to open questions and
+9
View File
@@ -230,6 +230,15 @@ release, then `docker compose up -d`. The backup version check requires the
running image to match the version that created a full backup — see [Fixed image running image to match the version that created a full backup — see [Fixed image
tag](#fixed-image-tag). tag](#fixed-image-tag).
**Container health.** The image declares a Docker `HEALTHCHECK` that probes
`GET /healthz` on port 8080 (unauthenticated). It returns `200 ok` when
OpenDKIM, the panel, and Postfix are all `RUNNING` under supervisord;
otherwise `503 unhealthy`. This catches a dead mail path that would still leave
the HTTP server up, but it does **not** verify TLS certificates, DNS records,
or end-to-end delivery — use the authenticated **Status** page for that. External
monitoring can use the same endpoint through the reverse proxy if you expose it,
or poll `docker inspect` health state on the host.
## Rate limiting ## Rate limiting
SelfPost applies two independent limits; both can refuse a submission, but only SelfPost applies two independent limits; both can refuse a submission, but only
+6
View File
@@ -50,6 +50,7 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
supervisor \ supervisor \
logrotate \ logrotate \
ca-certificates \ ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Unprivileged user for the panel process (spec 7.6.8). # Unprivileged user for the panel process (spec 7.6.8).
@@ -104,6 +105,11 @@ RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh
# which needs no inbound listener or EXPOSE. # which needs no inbound listener or EXPOSE.
EXPOSE 8080 465 587 EXPOSE 8080 465 587
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see README Operations.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
# The entrypoint fixes /data ownership (bind mount) as root, then execs # The entrypoint fixes /data ownership (bind mount) as root, then execs
# supervisord, which becomes PID 1 and owns process supervision (spec 4). # supervisord, which becomes PID 1 and owns process supervision (spec 4).
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"slices"
"testing"
)
// documentedPublic matches the README "Environment variables" table.
var documentedPublic = []string{
"SELFPOST_HOSTNAME",
"SUBMISSION_ENABLE",
"RATE_LIMIT_MESSAGES_PER_IP",
"RATE_LIMIT_WINDOW_SECONDS",
"SEND_LOG_RETENTION_DAYS",
"PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS",
"TRUSTED_PROXY_CIDR",
}
// documentedInternal matches README "Internal variables (not part of the operator interface)".
var documentedInternal = []string{
"SELFPOST_DATA_DIR",
"SELFPOST_DB_PATH",
"SELFPOST_SETUP_TOKEN_FILE",
"PANEL_HTTP_ADDR",
"JOURNAL_MILTER_SOCKET",
"MAIL_LOG",
"PANEL_COOKIE_SECURE",
"OPENDKIM_SOCKET",
"OPENDKIM_DIR",
"DKIM_SELECTOR_DEFAULT",
"SASL_DB_PATH",
"SASL_REALM",
"POSTFIX_DIR",
"POSTFIX_SENDER_LOGIN_MAPS",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
"MILTER_WAIT_TIMEOUT",
"TLS_RELOAD_INTERVAL_SECONDS",
"LOGROTATE_INTERVAL_SECONDS",
}
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
var documentedComposeFixed = []string{
"TLS_CERT_FILE",
"TLS_KEY_FILE",
}
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
// Update together with loadConfig when adding a new key.
var loadConfigKeys = []string{
"SELFPOST_DATA_DIR",
"PANEL_HTTP_ADDR",
"JOURNAL_MILTER_SOCKET",
"MAIL_LOG",
"SEND_LOG_RETENTION_DAYS",
"SELFPOST_DB_PATH",
"SELFPOST_SETUP_TOKEN_FILE",
"SELFPOST_HOSTNAME",
"PANEL_COOKIE_SECURE",
"SUBMISSION_ENABLE",
"TRUSTED_PROXY_CIDR",
"PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS",
"TLS_CERT_FILE",
"OPENDKIM_SOCKET",
"OPENDKIM_DIR",
"DKIM_SELECTOR_DEFAULT",
"SASL_DB_PATH",
"SASL_REALM",
"POSTFIX_DIR",
}
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
var buildScriptKeys = []string{
"SELFPOST_HOSTNAME",
"TLS_CERT_FILE",
"TLS_KEY_FILE",
"RATE_LIMIT_MESSAGES_PER_IP",
"RATE_LIMIT_WINDOW_SECONDS",
"OPENDKIM_SOCKET",
"JOURNAL_MILTER_SOCKET",
"POSTFIX_SENDER_LOGIN_MAPS",
"SASL_DB_PATH",
"SUBMISSION_ENABLE",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
"MILTER_WAIT_TIMEOUT",
"TLS_RELOAD_INTERVAL_SECONDS",
"LOGROTATE_INTERVAL_SECONDS",
}
func documentedKeys() []string {
keys := append([]string{}, documentedPublic...)
keys = append(keys, documentedInternal...)
keys = append(keys, documentedComposeFixed...)
slices.Sort(keys)
return slices.Compact(keys)
}
func TestLoadConfigKeysDocumented(t *testing.T) {
doc := documentedKeys()
for _, key := range loadConfigKeys {
if !slices.Contains(doc, key) {
t.Errorf("loadConfig reads %s but it is not listed in README public, internal, or compose-fixed env docs", key)
}
}
}
func TestBuildScriptKeysDocumented(t *testing.T) {
doc := documentedKeys()
for _, key := range buildScriptKeys {
if !slices.Contains(doc, key) {
t.Errorf("build scripts read %s but it is not listed in README env documentation", key)
}
}
}
func TestDocumentedKeysAreRead(t *testing.T) {
read := append([]string{}, loadConfigKeys...)
read = append(read, buildScriptKeys...)
slices.Sort(read)
read = slices.Compact(read)
for _, key := range documentedKeys() {
if !slices.Contains(read, key) {
t.Errorf("README documents %s but no code in loadConfig or build scripts reads it", key)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
# SelfPost — architecture (as-built)
**Source of truth:** the code tree, not historical specs. Synchronise this file
when env keys, routes, or mail-path behaviour change. Verification method:
[documentation-plan.md](documentation-plan.md) §2.
User install/operations: [README.md](../README.md). Product boundaries:
[product.md](product.md).
---
## Image and processes
Single Debian slim image. `entrypoint.sh` (root) fixes `/data` ownership and
milter socket directories, validates `SELFPOST_HOSTNAME`, then execs
`supervisord` as PID 1.
Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
| Program | User | Priority | Role |
|---|---|---|---|
| `opendkim` | opendkim | 100 | DKIM signing milter |
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then
`postfix start-fg`.
`crashexit` event listener exits the container on any managed process FATAL so
Docker `restart: unless-stopped` recreates a broken instance.
**Liveness:** `GET /healthz` (unauthenticated) returns 200 when opendkim,
panel, and postfix are RUNNING; Docker `HEALTHCHECK` uses the same probe.
---
## Mail path
```
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
├─► OpenDKIM milter (sign, tempfail on failure)
├─► journal-milter (send log + L2 rate limits, fail-open)
└─► outbound MX delivery (port 25 client)
```
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
- **465/smtps** — implicit TLS, SASL required; primary listener.
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`smtpd_tls_security_level=encrypt`.
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
`anvil_rate_time_unit` from `RATE_LIMIT_*` env vars; independent of milter.
- **Chroot disabled** for all services (DNS/TLS inside container).
- **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`; daily
reload via `cert-reload`.
### OpenDKIM
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
by the panel. Socket `/run/opendkim/opendkim.sock`.
### Panel binary ([cmd/panel](../cmd/panel))
One process, three roles:
1. **HTTP server**`:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail.
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id.
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
---
## Panel HTTP surface
Route table: [internal/web/web.go](../internal/web/web.go). Authenticated
unless noted.
| Route | Purpose |
|---|---|
| `/healthz` | Liveness (no auth) |
| `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth |
| `/status` | Process, cert, socket, PTR checks |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits |
| `/deliveries` | Send log with filters |
| `/mail-queue` | Postfix queue view |
| `/system-log` | `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps |
| `/backup` | Full backup download, domain import |
| `/account` | Admin username/password |
HTMX polling refreshes monitoring fragments; polling does not extend session
idle timeout.
---
## Persistence (`/data` bind mount)
| Path | Contents |
|---|---|
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits |
| `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials |
| `postfix/sender_login_maps` | Login → From binding |
| `manifest.json` | Backup version stamp (consumed on restore) |
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
(transit mail not migrated by design).
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 files, check every 6h, `postfix reload` on rotate).
**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
`/data` tree; version check on restore. Stopped-container `tar` of `./data` is
safe (see README).
---
## Security (summary)
Mandatory checklist: [security.md](security.md). Accepted trade-offs (CSRF
origin check, no CSRF tokens) are documented there separately.
---
## Configuration
Public and internal env vars: [README § Environment variables](../README.md#environment-variables).
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
@@ -1,3 +1,8 @@
> **Исторический снимок v1.0, не источник истины.** Актуальные документы:
> [product.md](../product.md), [architecture.md](../architecture.md),
> [development.md](../development.md), [security.md](../security.md),
> [README.md](../../README.md).
# Техническое задание: SelfPost # Техническое задание: SelfPost
**Версия:** 1.0 **Версия:** 1.0
+130
View File
@@ -0,0 +1,130 @@
# SelfPost — development
**What this file is.** How to build, test, and ship changes. Current sprint
state lives in [progress.md](progress.md) — read that first after `/clear`.
Product boundaries: [product.md](product.md). As-built layout:
[architecture.md](architecture.md).
---
## Repository layout
- `cmd/panel` — panel HTTP server + milter + log-tailer
- `cmd/selfpost-backup` — CLI backup (`docker exec … selfpost-backup`)
- `internal/` — domain logic, store, web handlers, health checks
- `build/` — Dockerfile, supervisord, Postfix/OpenDKIM wiring, entrypoint
- `deploy/``docker-compose.yml`, proxy examples, `.env.example`
- `test/e2e/`**separate Go module**; container integration tests
---
## Local Go workflow
Requires Go 1.26+ and `CGO_ENABLED=0` (pure Go SQLite).
```sh
make vet # go vet ./...
make test # go test ./...
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
make build VERSION=1.0.0
```
Or directly:
```sh
go vet ./...
go test ./...
go build -trimpath -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=dev" -o bin/panel ./cmd/panel
```
**Env documentation regression:** `go test ./cmd/panel -run TestLoadConfig`
new `loadConfig` keys must appear in README env lists
([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
---
## End-to-end tests
Hermetic container suite (plan C.4):
```sh
make e2e
# equivalent: cd test/e2e && go test -v -timeout 20m ./...
```
Uses `deploy/docker-compose.yml` + `test/e2e/compose.override.yml` (high
ports, test hostname, fake DNS zone, smtp-sink). Requires **Docker + Compose v2**
on the machine running tests. Not included in `go test ./...` of the main module.
CI (`release.yml`): matrix build → e2e per arch → push `ghcr.io` on tag
`vX.Y.Z`.
---
## Dev server (full container)
When unit tests are not enough — Postfix, OpenDKIM, supervisord, real SMTP:
**Typical setup:** edit locally → sync tree to dev host → build/test there.
Documented dev host: `selfpost.mixfed.ru` (Debian 12). Sync example from
[progress.md](progress.md):
```sh
tar -czf - --exclude=.git . | ssh root@selfpost.mixfed.ru \
'rm -rf /root/selfpost-src && mkdir -p /root/selfpost-src && tar -xzf - -C /root/selfpost-src'
```
On the server (Go in `/usr/local/go/bin` if not in PATH):
```sh
cd /root/selfpost-src
/usr/local/go/bin/go vet ./...
/usr/local/go/bin/go test ./...
docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
```
Manual smoke on production-like host: panel at `https://selfpost.mixfed.ru`,
real LE cert, live deliverability — not replaceable by e2e alone (no outbound
25 on CI runners, no real PTR/reputation).
---
## Commit and changelog protocol
From [progress.md](progress.md):
1. Meaningful step → entry under `[Unreleased]` in [CHANGELOG.md](../CHANGELOG.md)
(Keep a Changelog format).
2. Commit on `main` unless asked for a branch. **Do not commit unless the user
asks.**
3. Before `/clear` at end of a phase: update `progress.md`, verify acceptance
criteria, CHANGELOG, final commit.
Documentation changes that add/rename env vars, panel routes, or observable
mail behaviour ship in the **same commit** as the code change.
Release tagging and image push — only on explicit request.
---
## Agent rules (formerly spec §12)
1. **No git commits** without explicit instruction in the prompt.
2. After Go changes: `go build`, `go vet`; fix all issues. Run `go test` when
tests exist.
3. Before calling a container task done: image builds and container starts.
4. Iterate: minimal skeleton first, then features.
5. Security requirements in [security.md](security.md) — implement with the feature,
not deferred.
6. Do not implement out-of-scope items ([product.md](product.md)) or change
fixed assumptions without agreement.
7. For large tasks: propose a plan before coding unless the user already approved
one.
8. Check licence compatibility of new Go dependencies (permissive or
GPL-family for AGPL-3.0 project).
Model routing (from progress): security/infra → Opus; UI/docs → Sonnet;
trivial mechanics → Haiku. Pre-release security **review** (not authorship) →
Fable.
+5 -11
View File
@@ -1,22 +1,18 @@
# План документации SelfPost # План документации SelfPost
**Зачем этот файл.** Документация — часть поставки (deliverables v1.0, пп. 5, 7, 9 **Зачем этот файл.** Документация — часть поставки (deliverables v1.0, пп. 5, 7, 9
в [specification.md](specification.md)), а не сопроводительный текст. Перед тегом в [archive/specification-v1.0.md](archive/specification-v1.0.md)), а не сопроводительный текст. Перед тегом
релиза она обязана описывать **то, что делает код**, а не то, что задумывалось: релиза она обязана описывать **то, что делает код**, а не то, что задумывалось:
расхождение здесь — такой же дефект, как несоответствие требованиям, только расхождение здесь — такой же дефект, как несоответствие требованиям, только
обнаруживает его пользователь на своём проде. План описывает, из чего состоит обнаруживает его пользователь на своём проде. План описывает, из чего состоит
пакет, как он сверяется с кодом, что уже разошлось (первый проход выполнен, пакет, как он сверяется с кодом, что уже разошлось (первый проход выполнен,
результаты ниже) и что с этим делать. результаты ниже) и что с этим делать.
**Цель после выполнения плана:** [specification.md](specification.md) (ТЗ v1.0) **Цель (достигнута в D9):** ТЗ v1.0 **выведено из обращения** v1.0
**выводится из обращения** — не потому что требования исчезли, а потому что v1.0 реализован и каждый блок ТЗ получил постоянный дом: пользовательское — в
реализован и каждый блок ТЗ получает постоянный дом: пользовательское — в
README, устройство — в `architecture.md`, продуктовые границы — в README, устройство — в `architecture.md`, продуктовые границы — в
`product.md`, обязательная безопасность — в `security.md`, процесс разработки — `product.md`, обязательная безопасность — в `security.md`, процесс разработки —
в `development.md`. Живой `specification.md` после этого держит два риска: в `development.md`. Архив: [archive/specification-v1.0.md](archive/specification-v1.0.md).
дублирование с кодом и ложное ощущение «источника истины», который уже не
сверяют. Задача **D9** — миграция остатков и снятие файла; до её закрытия
ссылки на ТЗ в этом файле — исторические.
**Место в общем плане:** документационный проход идёт вместе с **D.5** **Место в общем плане:** документационный проход идёт вместе с **D.5**
([implementation-plan.md](implementation-plan.md)) — до тега следующего релиза, ([implementation-plan.md](implementation-plan.md)) — до тега следующего релиза,
@@ -43,9 +39,7 @@ README, устройство — в `architecture.md`, продуктовые г
### Рабочие документы проекта (не поставка, но обязаны быть верны) ### Рабочие документы проекта (не поставка, но обязаны быть верны)
[specification.md](specification.md) — ТЗ v1.0; **на вывод после D9** (см. карту [archive/specification-v1.0.md](archive/specification-v1.0.md) — исторический снимок ТЗ v1.0 (D9 закрыт).
миграции ниже). До закрытия D9 — ещё используется как историческая ссылка в
находках раздела 3.
[implementation-plan.md](implementation-plan.md) — открытые вопросы для v1.0/v1.x. [implementation-plan.md](implementation-plan.md) — открытые вопросы для v1.0/v1.x.
[roadmap.md](roadmap.md) — линия 2.x.x. [roadmap.md](roadmap.md) — линия 2.x.x.
[progress.md](progress.md) — живой трекер, читается первым после `/clear`. [progress.md](progress.md) — живой трекер, читается первым после `/clear`.
+1 -1
View File
@@ -7,7 +7,7 @@ v1.0/v1.x: открытые вопросы для согласования. Об
(входящий релей, роль администратора домена) вынесен в (входящий релей, роль администратора домена) вынесен в
[roadmap.md](roadmap.md). [roadmap.md](roadmap.md).
**Основа:** [specification.md](specification.md) v1.0. **Основа:** [product.md](product.md) v1.0.
--- ---
+102
View File
@@ -0,0 +1,102 @@
# SelfPost — product boundaries
**What this file is.** Stable product definition for SelfPost v1.0: purpose,
deployment assumptions, explicit out-of-scope items, and the multi-domain
model. User-facing install and operations live in [README.md](../README.md);
as-built technical detail in [architecture.md](architecture.md).
---
## Purpose
SelfPost is a self-hosted **outbound SMTP relay** with a web control panel,
shipped as a single Docker image. It sends mail directly to the internet from
your own IP with per-domain DKIM signing.
**Primary workflow:** configure the relay once (domains, DKIM, SASL
applications), then point scripts and apps at the SMTP endpoint. SelfPost
delivers as the configured sending domain.
---
## Deployment context (fixed assumptions)
These constraints are intentional; changing them requires an explicit product
decision:
1. **VPS or home server** — one image for both. Raspberry Pi and similar boards
are not a current target but not ruled out forever.
2. **Send from your own IP (DIY)** — no intermediate relay provider. Requires
outbound port 25, static IP, and configurable PTR/rDNS from the operator.
3. **Single container** — Postfix, OpenDKIM, and the panel run under one
`supervisord` inside one image.
4. **Panel is internet-facing** — mandatory security requirements in
[security.md](security.md).
**Infrastructure the operator provides (not SelfPost features):** unblocked
outbound TCP 25, static IP, PTR/rDNS, acceptable IP reputation. SelfPost does
not detect, bypass, or compensate for missing prerequisites; mail simply fails
to deliver when they are absent.
---
## Out of scope
Explicitly excluded to prevent scope creep:
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
- Webmail
- Multi-user panel / organisations / roles — one administrator; managing
**multiple sending domains** is in scope (see below)
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
- 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.
---
## Multi-domain model
SelfPost is a **multi-domain outbound relay**. Two linked entities:
### Sending domain
Example: `example.com`. Has its own DKIM key and selector.
### Application (account)
A SASL login/password bound to **one** domain. A domain may have several
applications (e.g. newsletter vs alerts), each with its own credentials, but
each may send **only from its own domain**, never from another.
### From-address mode (per application)
Set when creating or editing an application:
1. **Any address in the domain**`*@example.com` (wildcard in
`smtpd_sender_login_maps`).
2. **Explicit address list** — only listed From addresses within the domain;
anything else is rejected even if it belongs to the same domain.
In both modes the From address must belong to the application's domain.
### What the panel manages per domain
- DKIM key + selector (shared by all applications on that domain)
- One or more applications (SASL credentials + From mode + optional rate limits)
- DNS guidance (DKIM TXT; reminders for SPF/DMARC)
Adding a domain does **not** create an application automatically.
### Lifecycle
- **Add domain** — record, DKIM key, DNS instructions; no application yet.
- **Add application** — SASL pair (password shown once), From mode, map entries,
`postfix reload`.
- **Delete domain** — removes DKIM key and **all** its applications.
- **Delete application** — removes only that app's SASL and map entries.
This is not multi-tenancy (one admin); it is one owner operating several
sending domains with independent application credentials.
+4 -3
View File
@@ -3,7 +3,8 @@
Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы. Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы.
План (открытые вопросы для v1.0/v1.x): [implementation-plan.md](implementation-plan.md). План (открытые вопросы для v1.0/v1.x): [implementation-plan.md](implementation-plan.md).
Линия 2.x.x (входящий релей, роль администратора домена): [roadmap.md](roadmap.md). Линия 2.x.x (входящий релей, роль администратора домена): [roadmap.md](roadmap.md).
ТЗ: [specification.md](specification.md). Принятые риски безопасности: [security.md](security.md). Продукт: [product.md](product.md), устройство: [architecture.md](architecture.md).
Процесс разработки: [development.md](development.md). Принятые риски безопасности: [security.md](security.md).
История релизов: [CHANGELOG.md](../CHANGELOG.md). История релизов: [CHANGELOG.md](../CHANGELOG.md).
История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется. История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется.
@@ -11,7 +12,7 @@
1. Прочитать этот файл (текущее состояние, что дальше). 1. Прочитать этот файл (текущее состояние, что дальше).
2. Открыть `implementation-plan.md` — там нерешённые вопросы для v1.0/v1.x; линия 2.x.x (Фаза O1+, роль администратора домена) — в `roadmap.md`; принятые риски безопасности — в `security.md`. 2. Открыть `implementation-plan.md` — там нерешённые вопросы для v1.0/v1.x; линия 2.x.x (Фаза O1+, роль администратора домена) — в `roadmap.md`; принятые риски безопасности — в `security.md`.
3. При необходимости — детали в `specification.md`. 3. При необходимости — [architecture.md](architecture.md) и [product.md](product.md).
4. Продолжить с пункта «Следующий шаг». 4. Продолжить с пункта «Следующий шаг».
## Модель по типу работы ## Модель по типу работы
@@ -43,7 +44,7 @@
- **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.mixfed.ru`, отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на dev-сервере; локально на Windows `TestFollowTailsAndRotates` падает — rename открытого файла запрещён ОС, к делу не относится). - **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.mixfed.ru`, отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на dev-сервере; локально на Windows `TestFollowTailsAndRotates` падает — rename открытого файла запрещён ОС, к делу не относится).
- **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.mixfed.ru`, отдельный образ `selfpost:b3test`, cap-list как в поставляемом compose): без переменной — `exit 1` с ожидаемым текстом, без бесконечного тихого retry; `https://mail.example.com:465` и `localhost` отклонены с понятными сообщениями; валидный `mail.example.com` — обычный старт, все процессы supervisord поднимаются. `go vet`/`go test ./...` чистые. - **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.mixfed.ru`, отдельный образ `selfpost:b3test`, cap-list как в поставляемом compose): без переменной — `exit 1` с ожидаемым текстом, без бесконечного тихого retry; `https://mail.example.com:465` и `localhost` отклонены с понятными сообщениями; валидный `mail.example.com` — обычный старт, все процессы supervisord поднимаются. `go vet`/`go test ./...` чистые.
- **C.4 реализован** (не выкачен на прод — это CI/тестовая инфраструктура, а не образ): герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` (свой `go.mod`, не подхватывается `go test ./...` основного модуля) поверх поставляемого `deploy/docker-compose.yml` плюс `test/e2e/compose.override.yml` (самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, `SELFPOST_HOSTNAME=mail.e2e.test`, высокие порты `20465/20587/20080`, изолированный compose-проект `selfpost-e2e`, свой `--project-directory` — прод на том же хосте не задет). Герметичная почта: CoreDNS (`test/e2e/dns/Corefile` — авторитетна только для `e2e.test`, `file`-плагин с саб-директивой `reload` перечитывает `db.zone` по mtime, без сигналов) плюс `smtp-sink` из пакета postfix (`test/e2e/sink/`) как sink-MX. Сценарий (`test/e2e/*_test.go`): старт контейнера → все supervisord-программы `RUNNING` (`postfix-reload``STOPPED`) → токен из `/data/setup-token` → setup → login → добавление домена → DKIM-запись **скраплена со страницы панели** и опубликована в фейковую зону → добавление приложения → SMTP AUTH на 465 → письмо на sink → DKIM-подпись проверена (`go-msgauth/dkim` с кастомным `LookupTXT` через CoreDNS) против ключа **из DNS**, не из панели напрямую → send-log `queued → sent`. Негативы: без AUTH, relay на чужой домен без AUTH, sender/login mismatch (`reject_sender_login_mismatch` репортится Postfix'ом на RCPT, не MAIL — `smtpd_delay_reject=yes` по умолчанию), L1-лимит (anvil, override `RATE_LIMIT_MESSAGES_PER_IP=50` — специально высокий, чтобы остальные под-тесты не расходовали общий бюджет по IP раньше времени; сам тест шлёт до 60 раз, ждёт отказа), L2-лимит через панель (домен/приложение → `rejected`-строка в send-log), fail-open journal-milter'а (`supervisorctl stop panel`, письмо всё равно принято, контейнер жив), пустой/синтаксически неверный `SELFPOST_HOSTNAME` (отдельный один-разовый контейнер, не общий стенд), сессия переживает `docker restart` (плюс явное ожидание готовности smtps-порта после рестарта — панель и Postfix поднимаются независимо). `make e2e` — локальный/dev-server прогон. Найдено и исправлено по ходу стендовой проверки: `reload` — саб-директива `file`-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левел `reload` следит за самим Corefile, не за зоной); `docker compose build.context` резолвится относительно `--project-directory`, а не относительно файла, где объявлен; `smtp-sink` отказывается стартовать от root без `-u`; `html/template` эскейпит `+` в `&#43;` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на dev-сервере (`selfpost.mixfed.ru`)**: `make e2e` — зелёный (`go vet`/`gofmt -l` тоже чистые в обоих модулях). `release.yml` переработан: job `prepare` (версия из тега) → матрица `[ubuntu-latest, ubuntu-24.04-arm]` — каждая нативно собирает образ (`--load`), прогоняет e2e, пушит тег `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge``docker buildx imagetools create` в единый тег `X.Y.Z`; `setup-qemu-action` убран. Не проверено вживую (нельзя без реального тега): сам workflow на GitHub Actions — синтаксис вычитан, логика идентична локальному `make e2e` пути. - **C.4 реализован** (не выкачен на прод — это CI/тестовая инфраструктура, а не образ): герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` (свой `go.mod`, не подхватывается `go test ./...` основного модуля) поверх поставляемого `deploy/docker-compose.yml` плюс `test/e2e/compose.override.yml` (самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, `SELFPOST_HOSTNAME=mail.e2e.test`, высокие порты `20465/20587/20080`, изолированный compose-проект `selfpost-e2e`, свой `--project-directory` — прод на том же хосте не задет). Герметичная почта: CoreDNS (`test/e2e/dns/Corefile` — авторитетна только для `e2e.test`, `file`-плагин с саб-директивой `reload` перечитывает `db.zone` по mtime, без сигналов) плюс `smtp-sink` из пакета postfix (`test/e2e/sink/`) как sink-MX. Сценарий (`test/e2e/*_test.go`): старт контейнера → все supervisord-программы `RUNNING` (`postfix-reload``STOPPED`) → токен из `/data/setup-token` → setup → login → добавление домена → DKIM-запись **скраплена со страницы панели** и опубликована в фейковую зону → добавление приложения → SMTP AUTH на 465 → письмо на sink → DKIM-подпись проверена (`go-msgauth/dkim` с кастомным `LookupTXT` через CoreDNS) против ключа **из DNS**, не из панели напрямую → send-log `queued → sent`. Негативы: без AUTH, relay на чужой домен без AUTH, sender/login mismatch (`reject_sender_login_mismatch` репортится Postfix'ом на RCPT, не MAIL — `smtpd_delay_reject=yes` по умолчанию), L1-лимит (anvil, override `RATE_LIMIT_MESSAGES_PER_IP=50` — специально высокий, чтобы остальные под-тесты не расходовали общий бюджет по IP раньше времени; сам тест шлёт до 60 раз, ждёт отказа), L2-лимит через панель (домен/приложение → `rejected`-строка в send-log), fail-open journal-milter'а (`supervisorctl stop panel`, письмо всё равно принято, контейнер жив), пустой/синтаксически неверный `SELFPOST_HOSTNAME` (отдельный один-разовый контейнер, не общий стенд), сессия переживает `docker restart` (плюс явное ожидание готовности smtps-порта после рестарта — панель и Postfix поднимаются независимо). `make e2e` — локальный/dev-server прогон. Найдено и исправлено по ходу стендовой проверки: `reload` — саб-директива `file`-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левел `reload` следит за самим Corefile, не за зоной); `docker compose build.context` резолвится относительно `--project-directory`, а не относительно файла, где объявлен; `smtp-sink` отказывается стартовать от root без `-u`; `html/template` эскейпит `+` в `&#43;` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на dev-сервере (`selfpost.mixfed.ru`)**: `make e2e` — зелёный (`go vet`/`gofmt -l` тоже чистые в обоих модулях). `release.yml` переработан: job `prepare` (версия из тега) → матрица `[ubuntu-latest, ubuntu-24.04-arm]` — каждая нативно собирает образ (`--load`), прогоняет e2e, пушит тег `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge``docker buildx imagetools create` в единый тег `X.Y.Z`; `setup-qemu-action` убран. Не проверено вживую (нельзя без реального тега): сам workflow на GitHub Actions — синтаксис вычитан, логика идентична локальному `make e2e` пути.
- **Документация (D1–D5 закрыты):** [documentation-plan.md](documentation-plan.md) — D1 Operations/Rate limiting, D2 env-справочник, D3 бэкап через остановленный контейнер + `tar` и потребление `manifest.json`, D4 точечные правки README/compose (баннер, 587, комментарии), D5 синхронизация `implementation-plan.md` (B.1 — смена пароля гасит остальные сессии, не текущую). Остаются D6D9 (HEALTHCHECK, регресс-тест env, architecture/development, вывод specification). Часть предрелизного гейта наравне с e2e и ревизией безопасности. - **Документация (D1–D9 закрыты):** [documentation-plan.md](documentation-plan.md) — D6 HEALTHCHECK/`/healthz`, D7 env-doc regression test, D8 `architecture.md`/`development.md`, D9 вывод `specification.md` (архив в `docs/archive/`, живые документы `product.md` + расширенный `security.md`). Часть предрелизного гейта наравне с e2e и ревизией безопасности.
- **Дальше:** пункт **D.5** плана — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e (C.4, готов) это гейт перед тегом релиза. - **Дальше:** пункт **D.5** плана — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e (C.4, готов) это гейт перед тегом релиза.
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования). - **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
- **Прод:** `selfpost.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). - **Прод:** `selfpost.mixfed.ru`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
+1 -1
View File
@@ -8,7 +8,7 @@
проекта, а не доработка по своей инициативе. Присутствие пункта здесь проекта, а не доработка по своей инициативе. Присутствие пункта здесь
фиксирует намерение и дизайн; кодирование начинается отдельным решением. фиксирует намерение и дизайн; кодирование начинается отдельным решением.
**Основа:** [specification.md](specification.md) v1.0. Несделанное для v1.0/v1.x **Основа:** [product.md](product.md) v1.0. Несделанное для v1.0/v1.x
— в [implementation-plan.md](implementation-plan.md). — в [implementation-plan.md](implementation-plan.md).
--- ---
+71 -12
View File
@@ -1,19 +1,78 @@
# Безопасность: принятые риски # Безопасность
**Что здесь.** Обязательные требования к безопасности — [ТЗ 7.6](specification.md); **Что здесь.** (1) **Обязательные требования** — чеклист, который v1.0 обязан
соответствие им проверено полным аудитом на v1.0 и здесь не пересказывается. выполнять; полный аудит на v1.0 пройден. (2) **Принятые риски** — сознательные
Hardening сверх обязательного 7.6 (security-заголовки, проверка origin, cookie отступления сверх обязательного, чтобы решение не потерялось.
`__Host-` с обнаружением дублей — Фаза 14) тоже закрыт, история — в
[CHANGELOG.md](../CHANGELOG.md) и `git log`. Этот документ держит третью
категорию: то, что закрыто **сознательно не было**, чтобы решение не потерялось
и не переоткрывалось заново.
Здесь, а не в [implementation-plan.md](implementation-plan.md), потому что план — Hardening сверх обязательного (security-заголовки, проверка origin, cookie
только про несделанную работу, а принятый риск — не работа, а решение: у него нет `__Host-` с обнаружением дублей — Фаза 14) закрыт; история — в
состояния «в очереди», есть условие, при котором к нему возвращаются. [CHANGELOG.md](../CHANGELOG.md) и `git log`.
Продуктовые границы: [product.md](product.md). Устройство as-built:
[architecture.md](architecture.md).
---
## Обязательные требования
Панель публична из интернета — пункты ниже **не опциональны**.
### Первичная инициализация администратора
- Одноразовая 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 **не используются**.
### SASL-пароли приложений
- Панель **генерирует** пароль при создании/перевыпуске, показывает **один раз**.
- В `sasldb2` — в форме, требуемой SASL (не plaintext в панели); утерян — только
перевыпуск.
### Ввод и конфигурация
- Серверная валидация email/доменов (whitelist символов); клиентская не считается
защитой.
- Режим «список адресов» — каждый адрес принадлежит домену приложения до записи.
- `postfix reload` и любой `exec`**без** shell-интерполяции пользовательского
ввода; аргументы отдельными элементами.
- Запись в конфиг-файлы — с экранированием (нет инъекции директив Postfix).
### Аутентификация и сессии
- Rate limiting на логин (по IP, с блокировкой/задержкой).
- Сессии: криптографически случайный токен; cookie `HttpOnly`, `Secure`, `SameSite`.
- Сессии в SQLite (SHA-256 токена, не сам токен); скользящий idle
(`PANEL_SESSION_IDLE_DAYS`).
### Вывод и процесс
- Рендер через `html/template` с автоэкранированием (очередь, лог, журнал, темы).
- Процесс панели **не root** (`user=panel` в supervisord); доступ к путям через
группу `selfpost` и минимальные права.
### Почтовый тракт (связанное с безопасностью)
- **Не open relay** — только SASL; `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 игнорируется.
---
## Принятые риски ## Принятые риски
Здесь, а не в [implementation-plan.md](implementation-plan.md): план — про
несделанную работу, принятый риск — решение с условием возврата.
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.** - **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
@@ -28,7 +87,7 @@ Hardening сверх обязательного 7.6 (security-заголовки
вопросу считать появление требования «устойчиво независимо от браузера». вопросу считать появление требования «устойчиво независимо от браузера».
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
панели, отправит запрос сам — против этого работают автоэкранирование панели, отправит запрос сам — против этого работают автоэкранирование
`html/template` (7.6.7) и CSP, поэтому шаблоны не должны содержать `html/template` и CSP, поэтому шаблоны не должны содержать
inline-скриптов и inline-стилей. inline-скриптов и inline-стилей.
## Как этот список пополняется ## Как этот список пополняется
+25
View File
@@ -163,3 +163,28 @@ func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestLivenessFromParsedProcesses(t *testing.T) {
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix RUNNING pid 23, uptime 0:04:08
postfix-reload STOPPED Not started
`
procs := parseProcesses(allRunning)
for _, p := range procs {
if mailPathPrograms[p.Name] && p.Status != StatusOK {
t.Fatalf("%s should be ok for liveness, got %q", p.Name, p.Status)
}
}
postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly
`
procs = parseProcesses(postfixDead)
for _, p := range procs {
if p.Name == "postfix" && p.Status == StatusOK {
t.Fatal("postfix FATAL should not grade as OK")
}
}
}
+46
View File
@@ -0,0 +1,46 @@
package health
import (
"fmt"
"strings"
)
// mailPathPrograms are the supervised processes whose absence means the
// container should not report healthy to orchestrators.
var mailPathPrograms = map[string]bool{
"opendkim": true,
"panel": true,
"postfix": true,
}
// Liveness reports whether the mail path is healthy enough for container
// probes. It requires opendkim, panel, and postfix to be RUNNING under
// supervisord.
func Liveness() error {
procs, err := Processes()
if err != nil {
return err
}
seen := make(map[string]Status, len(mailPathPrograms))
for _, p := range procs {
if mailPathPrograms[p.Name] {
seen[p.Name] = p.Status
}
}
var unhealthy []string
for name := range mailPathPrograms {
switch seen[name] {
case StatusOK:
case StatusUnknown:
unhealthy = append(unhealthy, name+": missing")
default:
unhealthy = append(unhealthy, fmt.Sprintf("%s: %s", name, seen[name]))
}
}
if len(unhealthy) > 0 {
return fmt.Errorf("unhealthy: %s", strings.Join(unhealthy, ", "))
}
return nil
}
+5
View File
@@ -14,6 +14,7 @@ import (
"codeberg.org/mix/selfpost/internal/app" "codeberg.org/mix/selfpost/internal/app"
"codeberg.org/mix/selfpost/internal/dnscheck" "codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/health"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
) )
@@ -207,6 +208,10 @@ func redirectToStatus(w http.ResponseWriter, r *http.Request) {
func handleHealth(w http.ResponseWriter, _ *http.Request) { func handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if err := health.Liveness(); err != nil {
http.Error(w, "unhealthy\n", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n")) _, _ = w.Write([]byte("ok\n"))
} }