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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.example.com` (Debian 12). Sync example from
|
||||||
|
[progress.md](progress.md):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tar -czf - --exclude=.git . | ssh root@selfpost.example.com \
|
||||||
|
'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.example.com`,
|
||||||
|
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.
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.example.com`, отдельный контейнер `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.example.com`, отдельный контейнер `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.example.com`, отдельный образ `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.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 реализован** (не выкачен на прод — это 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` эскейпит `+` в `+` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на dev-сервере (`selfpost.example.com`)**: `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` эскейпит `+` в `+` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на dev-сервере (`selfpost.example.com`)**: `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 — смена пароля гасит остальные сессии, не текущую). Остаются D6–D9 (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.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
|
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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-стилей.
|
||||||
|
|
||||||
## Как этот список пополняется
|
## Как этот список пополняется
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user