From be98efa549454fa59b4596079ffc8ce8c69d861e Mon Sep 17 00:00:00 2001 From: Mix Date: Sat, 17 Oct 2026 02:07:55 +0300 Subject: [PATCH] docs-audit: architecture, schema, security and the rest against the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's "проверь, что документация актуальна с кодом" (2026-10-16). Read-only reviews checked every present-tense claim against the code; each finding was read in the source before it was fixed here. architecture.md: held inbound mail has its row from the journal milter, not the tailer; the antispam action is accept or tempfail, anything else stops the start; tls-internal's certificate is 0644; the signer logs to stderr; the log's fingerprint is the inode, not the device; a mail.log postlogd makes is 0640 (maillog_file_permissions); an unreadable stored place starts at end-of-file too; the test-message route; no map in page data; design-first compares whitespace aside; the queue and sasldb2.new are not backed up; antispam/ under /data; POSTFIX_QUEUE_DIR's desync; SELFPOST_BACKUP_PASSWORD (the CLI's). schema-migrations.md: inbound_spam_log's upstream columns (v12), send_log's sender reason, row 9's client_ip. security.md: /data/opendkim since 2.9.0 (removed, or the start refused), sasldb2 under /data/sasl, the trigger for mandatory encryption, the password minimum counted in characters. development.md: Staticcheck in CI, the e2e ports 20025 and 20053/udp. README: the client IP list refuses at MAIL FROM, overrides are per application. product.md: the optional rspamd sidecar. Code: validate.AdminPassword counted bytes, so six Cyrillic letters passed for twelve characters; it counts runes, as the backup password always did (TestAdminPasswordCountsCharacters). envdoc reads cmd/selfpost-backup too, and a name kept in a constant. A stale comment of cmd/panel's run ("three roles"). go vet + go test (26 packages) and make e2e on the test server for this tree. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++++ README.md | 4 +- cmd/panel/envdoc_test.go | 14 ++++-- cmd/panel/main.go | 4 +- docs/architecture.md | 63 +++++++++++++++----------- docs/development.md | 9 ++-- docs/product.md | 5 +- docs/schema-migrations.md | 6 +-- docs/security.md | 26 +++++------ internal/web/validate/validate.go | 6 ++- internal/web/validate/validate_test.go | 11 +++++ 11 files changed, 104 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb5167..55c17d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Fixed + +- **A panel password's twelve characters are characters**: the minimum + counted bytes, so six letters of a non-Latin alphabet passed for twelve. + It counts characters now, as the backup password always did. Passwords + already set are not affected. + +### Documentation + +- The documents checked against the code (docs-audit, 2026-10-17): the + guide, architecture, schema-migrations, security, development, README and + product say what the code does. + ## [2.13.0] - 2026-10-17 The panel in the container's time zone. No migration; schema 12. diff --git a/README.md b/README.md index 4c5ca0b..e02cfba 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ send log and DNS checks in the panel, encrypted backups. - Optional inbound relay on port 25 (backup-MX / forwarder; off by default) - Optional DMARC aggregate report ingest on port 25 (off by default) - In-panel Help: a page of topics, opened from the `?` in the head of a box -- Per-application client IP allow-list for SMTP AUTH (optional) +- Per-application client IP allow-list for submitting mail (optional; refused at MAIL FROM, not an authentication boundary) - Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings) - Multi-domain relay — each SASL application is bound to one sending domain - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check -- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides +- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and per-application overrides - Full-server backup and single-domain export/import (optional password encryption) - Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead) diff --git a/cmd/panel/envdoc_test.go b/cmd/panel/envdoc_test.go index 4352388..c12ea60 100644 --- a/cmd/panel/envdoc_test.go +++ b/cmd/panel/envdoc_test.go @@ -14,13 +14,15 @@ import ( // are compared as they are, not as lists kept by hand beside them (audit // 2026-10-12, stage 5 - the three lists this file held were compared with each // other and with nothing else). Read: what the panel's code asks of the -// environment (cmd/panel and internal, tests aside) and what the start-up +// environment (cmd/panel, cmd/selfpost-backup and internal, tests aside) and what the start-up // scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the // operator's table in guide.md § Environment variables, with the paragraph // under it, and the internal list in architecture.md § Configuration. var ( - goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`) + goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`) + // A name kept in a constant, as the backup CLI keeps its password's. + goEnvConst = regexp.MustCompile(`\w+Env\s*=\s*"([A-Z][A-Z0-9_]+)"`) scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`) docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`") ) @@ -38,12 +40,16 @@ func readFile(t *testing.T, path string) string { func envRead(t *testing.T) map[string]bool { t.Helper() keys := map[string]bool{} - for _, root := range []string{".", filepath.Join("..", "..", "internal")} { + for _, root := range []string{".", filepath.Join("..", "selfpost-backup"), filepath.Join("..", "..", "internal")} { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { return err } - for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) { + text := readFile(t, path) + for _, m := range goEnvRead.FindAllStringSubmatch(text, -1) { + keys[m[1]] = true + } + for _, m := range goEnvConst.FindAllStringSubmatch(text, -1) { keys[m[1]] = true } return nil diff --git a/cmd/panel/main.go b/cmd/panel/main.go index f682ea9..d41a0aa 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -330,8 +330,8 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res return st, restored, nil } -// run starts the panel's three roles and blocks until a shutdown signal or the -// first fatal error from any role. A signal triggers a clean stop of all +// run starts the panel's roles (the list below) and blocks until a shutdown +// signal or the first fatal error from any role. A signal triggers a clean stop of all // roles; a role error cancels the others and is returned so the process exits // non-zero (letting supervisord/Docker see the failure — architecture.md § // Image and processes). diff --git a/docs/architecture.md b/docs/architecture.md index 05b138d..ab4220d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -135,9 +135,10 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the Refusals come from `mail.log`: this listener logs as `postfix/inbound` (`syslog_name`), and the log tailer turns a `reject:` line of `postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` / - `milter-hold` / `milter-discard` line of `cleanup` (the filter's verdict, - counted only for a queue id that listener opened) into a row, dated by the - line, when the recipient is at an inbound domain + `milter-discard` line of `cleanup` (the filter's verdict, counted only for + a queue id that listener opened) into a row, dated by the line, when the + recipient is at an inbound domain; a message the filter holds goes on to + the inbound journal milter, which writes its row as `held` ([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line names one recipient (the last), so a message refused at end-of-message is one row whatever its recipient count. The tailer saves its place every five seconds, @@ -183,8 +184,9 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the an empty upstream host are omitted from the maps. Optional `INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on DMARC-only port 25); it is attached with its own `default_action`, - `INBOUND_ANTISPAM_MILTER_ACTION`, which is `accept` (fail-open) unless set - to `tempfail` (main.cf's `milter_default_action` is `tempfail`). + `INBOUND_ANTISPAM_MILTER_ACTION`, `accept` (fail-open, the default) or + `tempfail`, any other value stopping the start (main.cf's + `milter_default_action` is `tempfail`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed report addresses via `check_recipient_access` on `postfix/dmarc_recipients`; messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are @@ -217,8 +219,8 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the and to smtpd's `client=` line in mail.log. - **Chroot disabled** for all services (DNS/TLS inside container). - **TLS certs** — read-only mount at `TLS_CERT_FILE` / `TLS_KEY_FILE`, copied - at start into `/etc/postfix/tls-internal`, owned by root and `0600` as - Postfix's TLS_README advises (Postfix itself reads the key as root and does + at start into `/etc/postfix/tls-internal`, owned by root, the key `0600` + (the certificate `0644`) as Postfix's TLS_README advises (Postfix itself reads the key as root and does not check its mode), which the mount cannot give; Postfix reads the copy. `cert-reload` compares the mount with the copy daily; when they differ and the certificate and key belong together it renews the copy (each file @@ -310,8 +312,8 @@ then OpenDKIM did, in a program of its own message has an hour from the end of its headers to its end - go-milter v0.4.1 tells a session nothing of a dropped connection - after which it is deferred, never signed over part of its body. -- **Logs** go to the panel's stdout, the container's log, as `dkim-signer:` - lines; nothing is written to `mail.log` (nor did OpenDKIM write there). +- **Logs** go to the panel's stderr, the container's log, as + `panel: … dkim-signer:` lines; nothing is written to `mail.log` (nor did OpenDKIM write there). - **The old tree,** `/data/opendkim` - the keys, `KeyTable` and `SigningTable` until `2.8.0`, shared with OpenDKIM through group `selfpost`. `2.8.x` copied its keys to `/data/dkim/keys` at its first @@ -574,9 +576,12 @@ and `MAIL_LOG` in [cmd/panel/main.go](../cmd/panel/main.go). Backups exclude Rotation uses rename + `postfix reload` ([build/logrotate-mail.conf](../build/logrotate-mail.conf)), not `copytruncate` — the latter can drop `status=sent` lines and leave send-log rows stuck at -`queued`. After rename, logrotate runs `create 0640 postfix selfpost` (postlogd -recreates the file on its first line after the reload, as root and mode -`0600`, which the unprivileged panel user cannot read). `follow()` drains the +`queued`. After rename, logrotate runs `create 0640 postfix selfpost`, so the +file is there, readable by the panel through group `selfpost`, before postlogd +writes its first line after the reload; a file postlogd makes itself (one +removed while the container runs) is `0640` too, through +`maillog_file_permissions=0640` - Postfix's default is `0600`, which the +unprivileged panel user could not read. `follow()` drains the old inode once more before switching descriptors, and keeps it open and reads it for a minute after: postlogd keeps writing to the renamed file until the reload makes it exit, seconds later (Postfix 3.10.13, src/postlogd/postlogd.c; @@ -625,8 +630,9 @@ is mail of its own queue id and matches no row. **Read offset is persisted** (`logtail_state` table, in the baseline schema): the tailer stores its position plus a fingerprint of the log - the hash of its first -512 bytes and the file's device and inode, so two logs that begin alike are two -files and a renamed file keeps its fingerprint - and on start resumes from it, +512 bytes and the file's inode (not the device, which can change across a +host's reboot), so two logs that begin alike are two files and a renamed file +keeps its fingerprint - and on start resumes from it, parsing the tail written while the panel was down. If the fingerprint no longer matches, the log was rotated in the meantime: the tailer first opens `mail.log.1` (left plain by `delaycompress`), and if that is the file the saved @@ -635,7 +641,7 @@ start - what Postfix wrote between the stop and the rotation is not lost. A `mail.log.1` that is another file (rotated twice) is logged with the time from which nothing was read. Re-parsing lines is harmless because `UpdateStatus` writes the same status onto the same row. Only a first-ever start, with nothing -stored, begins at end-of-file, so installing the panel does not replay a +stored (or a stored place that cannot be read), begins at end-of-file, so installing the panel does not replay a pre-existing log. **A refusal's line, and a refusal the panel did not write.** A message refused @@ -838,7 +844,7 @@ only reads (the delete confirmation at `GET …/delete` renders a form). | Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` | | Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` | | Inbound › Log, Filter lists | `GET /inbound/log` · `GET, POST /inbound/filter-lists` · `POST /inbound/filter-lists/{id}/delete` — registered only with `INBOUND_RELAY_ENABLE=true` | -| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` | +| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload`, `/server/health/test-message` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `POST /server/settings/dns-recheck` · `GET /server/components` | | Account | `GET /account` · `POST /account/profile`, `/account/password` | | User menu | `GET /help` · `POST /logout` (public mux) | @@ -940,9 +946,8 @@ is one layout and one way to render. package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is derived from `Meta` and the feature flags (`SetInboundEnabled`, `SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu - has is decided once. No template reads a `map[string]any`; the one map left - in the handlers is the health sampler's, which is turned into a typed - `Health` before rendering. + has is decided once. No page's data is a map: the health sampler too + returns a typed `health.Machine`. - **The component kit.** [components.html](../internal/web/view/templates/components.html) holds one `{{define}}` per partial (page head, postmark, box and its head and foot, flash, health card, DNS record, facts, side menu, timeline, log pane, @@ -982,8 +987,8 @@ has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`, ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink; the CI step [design-first](../.github/scripts/design-first.sh) rejects a commit that -changes a guard together with the code it judges, and a template that is a -byte-for-byte return of one from history. The design contract and the process +changes a guard together with the code it judges, and a template that is +the same as one from history, whitespace aside. The design contract and the process are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement. ### Security headers, CSP and scripts @@ -1151,8 +1156,9 @@ single-connection trade-off that follows from it. | `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses | | `postfix/dmarc_transport` | Pipe transport for report ingest | | `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery | -| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate. Handed to `postfix` / `postdrop` by name at every start, before `postfix check` (`own_queue` in `build/entrypoint.sh`): the package accounts' numbers moved in `2.8.0` (postfix 101 → 100, postdrop 103 → 102) | +| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate; excluded from backups. Handed to `postfix` / `postdrop` by name at every start, before `postfix check` (`own_queue` in `build/entrypoint.sh`): the package accounts' numbers moved in `2.8.0` (postfix 101 → 100, postdrop 103 → 102) | | `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) | +| `antispam/` | The filter lists (six `.map` files) and the filter's key `filter.key`, which the spam filter's container mounts (`ANTISPAM_DIR`) | | `manifest.json` | Backup version stamp; removed by the first start after a restore once its Resync is done | | `contents.json` | A backup's last entry: the size and SHA-256 of each file under `data/`, compared by the first start after a restore before it opens the database (renamed `contents.checked.json` once compared, removed with the manifest) | @@ -1221,8 +1227,9 @@ the stored log lines follow; `postfix reload` in `postrotate` — see § Log tailer above). **Restore:** panel button or `selfpost-backup` CLI — self-contained archive: -`data/` (SQLite snapshot + tree minus `log/`, the setup token and any -`tls/` under `/data`), `docker-compose.yml`, `.env`, +`data/` (SQLite snapshot + tree minus `log/`, the Postfix queue +`postfix/queue`, the copy `sasl/sasldb2.new` a password change is made on, the +setup token and any `tls/` under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present; version check on restore. Requires the project directory mounted read-only at `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the @@ -1299,11 +1306,15 @@ unsupported rather than as a missing doc: `POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`), `POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`), `POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`), - `POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`), + `POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — read in `build/postfix-config.sh` + for `queue_directory`; `build/entrypoint.sh` hands `/data/postfix/queue` to + Postfix by that path, so overriding it desyncs the queue's ownership), `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for full backups; mount `.:/selfpost-deploy:ro` in compose), `ANTISPAM_DIR` (`/data/antispam` — the filter lists and the filter's key, which the spam - filter's container mounts). + filter's container mounts). `SELFPOST_BACKUP_PASSWORD` is read by the + `selfpost-backup` CLI, not the panel: the encryption password, the other + way being `-password-file` (guide § Encrypting a backup). - **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`), `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), `MILTER_WAIT_TIMEOUT` (`30` seconds). diff --git a/docs/development.md b/docs/development.md index 7154392..ce27182 100644 --- a/docs/development.md +++ b/docs/development.md @@ -483,7 +483,8 @@ make e2e **Stack:** [deploy/docker-compose.yml](../deploy/docker-compose.yml) + [test/e2e/compose.override.yml](../test/e2e/compose.override.yml) — same `cap_drop`/`cap_add`/`no-new-privileges` as production. Override: high ports -(`20465`/`20587`/`20080`), test hostname, self-signed TLS, +(`20465`/`20587`/`20080`, port 25 at `20025`, the stand's CoreDNS at +`20053/udp`), test hostname, self-signed TLS, `PANEL_COOKIE_SECURE=false`, isolated compose project, and the image's `VERSION` from `SELFPOST_E2E_VERSION` (unset locally: `e2e`). Mail is hermetic: CoreDNS (fake zone) + Postfix `smtp-sink` as sink-MX; DKIM TXT is scraped from @@ -536,10 +537,10 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs — ### `test.yml` — every push and PR to `main` `design-first` (needs the full history) → `gofmt -l` → `go vet ./...` → -`go test ./...` (main module, no e2e; includes the panel guard tests, see +Staticcheck 2026.1 → `go test ./...` (main module, no e2e; includes the panel guard tests, see [§ Panel pages](#panel-pages-the-component-kit-and-its-guards)) → the e2e -module compiled, not run (`go vet ./...` and `go test -c -o /dev/null .` in -`test/e2e`). +module compiled, not run (`go vet ./...`, Staticcheck and +`go test -c -o /dev/null .` in `test/e2e`). ### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer diff --git a/docs/product.md b/docs/product.md index 831a3d9..c111161 100644 --- a/docs/product.md +++ b/docs/product.md @@ -62,8 +62,9 @@ Explicitly excluded to prevent scope creep: - Organisations / tenancy beyond the `global` and `domain` roles; managing **multiple sending domains** under one global administrator is in scope (see below) -- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may - expose a milter hook; it does not ship or start a filter +- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — the image + ships no filter, only a milter hook; `deploy/antispam` offers an optional + rspamd sidecar that the panel's filter lists feed - A custom MTA — Postfix is used as-is - Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only diff --git a/docs/schema-migrations.md b/docs/schema-migrations.md index 041b945..5609475 100644 --- a/docs/schema-migrations.md +++ b/docs/schema-migrations.md @@ -86,7 +86,7 @@ audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and | 6 | `0006_sender_check.sql` | 2.4.0 | DDL | `send_log.reason` and `send_log.header_from` (both default `''`: a row refused earlier keeps no reason), and `sender_check_days` | | 7 | `0007_delivery_log_days.sql` | 2.5.0 | DDL | `delivery_log_days`: what the reconcile sweep and the log tailer did, a row per UTC day, kept a year, shown nowhere - the measurement that decides the sweep's fate (audit 2026-10-12, stage 1). `send_log.reason` gains the values `reconcile` and `reconcile-contradicted` without a schema change | | 8 | `0008_tenant_boundary.sql` | 2.5.0 | DDL + data | `idx_applications_login_nocase`: an application login is unique without regard to case (audit 2026-10-12, H4). `send_log.domain_id` and `inbound_spam_log.inbound_domain_id` (`ON DELETE SET NULL`), each with an index, filled once by name from the registry as it was then: a row belongs to a domain by id, and a domain deleted and added again starts with an empty log (L12) | -| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) | +| 9 | `0009_refusal_line.sql` | 2.5.1 | DDL | `send_log.refusal_line`: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row, found by `send_log.client_ip`, the client the refusal came from; `send_log.recovered`, a refusal row the tailer wrote from `mail.log` because the panel stopped before writing it; `sender_check_days.messages` (messages deferred mid-message, counted apart from the connections turned away) and `sender_check_days.estimated` (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) | | 10 | `0010_send_log_indexes.sql` | 2.5.3 | DDL | Four indexes of `send_log`, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): `idx_send_log_open`, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); `idx_send_log_domain_id` and `idx_send_log_app_login`, a page of one domain's or one application's log in id order; `idx_send_log_refusal_line`, partial, whether a refusal line is already on a row. `TestSendLogQueriesUseTheirIndexes` checks the plans of these and of the other by-time and by-scope statements the store runs | | 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row | | 12 | `0012_inbound_upstream.sql` | 2.10.0 | DDL | `inbound_spam_log` gains `queue_id`, `upstream_status` (`''`, `queued`, `held`, `sent`, `deferred`, `bounced`; CHECK), `upstream_relay`, `upstream_reply` and `upstream_at`, and the partial index `idx_inbound_spam_log_queue_id` on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none | @@ -108,7 +108,7 @@ for operators upgrading from older 2.x images. | `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) | | `application_addresses` | Explicit From addresses (`list` mode) | | `rate_limits` | Level-2 limits per domain / application, manual or auto | -| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) | +| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`, and `sender` since 2.8.0) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: or Sender: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) | | `logtail_state` | Log-tailer read position | | `inbound_domains` | Inbound relay domains | | `inbound_transports` | Upstream host / port / TLS per inbound domain | @@ -116,7 +116,7 @@ for operators upgrading from older 2.x images. | `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones | | `dmarc_reports` | Parsed aggregate report summaries | | `dmarc_report_records` | Per-source rows inside a report | -| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only. `inbound_domain_id` (v8) ties a row to its domain by id (v2) | +| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2). `inbound_domain_id` (v8) ties a row to its domain by id; `queue_id` and `upstream_status`, `upstream_relay`, `upstream_reply`, `upstream_at` (v12) are what the upstream answered for an accepted message | | `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) | | `notify_state` | What the panel has already written to its users about, one row per subject (`dns:`, `cert`, `queue`, `limit:`), so that each thing is said once and a restart says nothing again (v4) | | `sender_check_days` | What the sender check costs, a row per day of the container's zone (`TZ`) that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) | diff --git a/docs/security.md b/docs/security.md index 51743b1..c2f0249 100644 --- a/docs/security.md +++ b/docs/security.md @@ -229,8 +229,8 @@ operator can act on it. `selfpost` for files and does not reach it (`2.6.0`; OpenDKIM shared `selfpost` too until `2.8.0`). - **Group `selfpost` (gid 10998) has two members, `panel` and `postfix`.** - Through it Postfix reads what the panel writes - `sasldb2` and the maps - under `/data/postfix` (2750, files 0640) - and connects to the panel's + Through it Postfix reads what the panel writes - `sasldb2` under + `/data/sasl` and the maps under `/data/postfix` (both 2750, files 0640) - and connects to the panel's milter sockets in `/run/selfpost` (2750, sockets 0660); the panel reads `mail.log`, which `postfix` writes, through it the other way ([build/Dockerfile](../build/Dockerfile), `build/entrypoint.sh`). The DKIM @@ -286,14 +286,12 @@ operator can act on it. signer reads them and never the database. The entrypoint sets these modes at every start (e2e `checkDKIMTree` holds the tree to them). Until `2.8.0` the keys were under `/data/opendkim`, 2750/0640 in group `selfpost` so - that OpenDKIM could read them - and so could every `postfix` process. That - tree is kept for a rollback to 2.7.x until the next release; since `2.8.0` - the entrypoint makes it the panel's alone as well (0700/0600, panel:panel) - at every start, copies its keys as the panel user (`setpriv`) while - `/data/dkim` holds none, and a backup leaves it out (§ Backup and domain - export). A root step of the entrypoint follows no symbolic link the panel - could plant in either tree (`drop_links`; `cp -RP`; `chown -R` and `find` - do not follow one). + that OpenDKIM could read them - and so could every `postfix` process. + 2.8.x kept that tree, the panel's alone, for a rollback to 2.7.x and copied + its keys over; since `2.9.0` the entrypoint removes it at start, and refuses + to start when the keys are only there (start 2.8.x once first). A root step + of the entrypoint follows no symbolic link the panel could plant in the + tree (`drop_links`; `rm -rf`, `chown -R` and `find` do not follow one). - **An application signs only as itself (`2.4.0`).** The signature's `d=` is the login's domain, and a receiver holds it to the domain of the `From:` line (DMARC, RFC 7489 § 3.1.1): without a check of that line an @@ -498,7 +496,8 @@ operator can act on it. CLI the password comes only from `SELFPOST_BACKUP_PASSWORD` or `-password-file`, never as an argument (the process list is readable by any process in the container). -- Minimum password length matches the administrator password (12): the file +- Minimum password length matches the administrator password (12 + characters, counted as characters, not bytes, in both): the file sits offline and can be attacked without a time limit. The panel's forms and, since `2.5.3`, the CLI hold an encrypting password to it; decrypting takes any. @@ -550,8 +549,9 @@ deferred item from the roadmap. keep a password would lose the ability to take a backup at all, and a permanently undecryptable archive is worse than an unencrypted one: SelfPost does not store the password. The trigger to make encryption - mandatory is a second administrator (at which point "who downloaded it" - stops being one person). + mandatory is a second person who takes backups or exports - a second + global user, or a domain user exporting their domain - at which point "who + downloaded it" stops being one person. - **A journal row left without delivery lines is closed as `bounced` rather than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to `/data/log/` and survives container recreation, and the log tailer keeps its diff --git a/internal/web/validate/validate.go b/internal/web/validate/validate.go index e22ab09..27dd219 100644 --- a/internal/web/validate/validate.go +++ b/internal/web/validate/validate.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "unicode" + "unicode/utf8" ) // MinAdminPasswordLen is the floor for the administrator password. The panel is @@ -39,7 +40,10 @@ func Username(u string) error { // AdminPassword enforces a minimum length. Composition rules beyond length tend // to reduce entropy in practice, so length is the sole gate. func AdminPassword(p string) error { - if len(p) < MinAdminPasswordLen { + // Characters, as the message says and as the backup password counts + // them (secretFilePassword, cmd/selfpost-backup): len would let six + // Cyrillic letters, twelve bytes, pass for twelve. + if utf8.RuneCountInString(p) < MinAdminPasswordLen { return fmt.Errorf("password must be at least %d characters", MinAdminPasswordLen) } return nil diff --git a/internal/web/validate/validate_test.go b/internal/web/validate/validate_test.go index 6a20bf4..aa5ebdc 100644 --- a/internal/web/validate/validate_test.go +++ b/internal/web/validate/validate_test.go @@ -65,3 +65,14 @@ func TestValidateDomainLongLabelRejected(t *testing.T) { t.Error("expected error for over-long label") } } + +// The minimum counts characters, not bytes: six Cyrillic letters are twelve +// bytes and six characters. +func TestAdminPasswordCountsCharacters(t *testing.T) { + if err := AdminPassword("пароль"); err == nil { + t.Error("six Cyrillic letters passed for twelve characters") + } + if err := AdminPassword("парольпароль"); err != nil { + t.Errorf("twelve Cyrillic letters: %v", err) + } +}