commit 8ce050ef7ffed9c3731b1b48094c618f17b72c10 Author: Mix Date: Tue Sep 22 15:24:51 2026 +0300 release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`). Added: - Panel (Go, single static binary) with SQLite persistence, one-time - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and - Application (sender identity) management: SASL credentials via `sasldb2`, - Full Postfix relay config generated from env at container start: SMTPS 465, - Journal milter (pure Go, `go-milter`) recording every send to `send_log`; … (6 more in CHANGELOG) Full detail: CHANGELOG.md (0.1.0). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e7b8881 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Keep the build context minimal and free of dev-only material. +.git +.gitignore +.gitattributes +dev +docs +bin +*.exe +README.md +LICENSE +Makefile diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c9ce6b3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,32 @@ +# Normalize all text files to LF in the working tree. +# Critical: shell scripts, Dockerfile and configs run inside a Linux container +# and MUST NOT get CRLF line endings (a "#!/bin/sh\r" shebang breaks startup). +* text=auto eol=lf + +# Explicit LF for files copied into / executed in the image. +*.go text eol=lf +*.sh text eol=lf +*.conf text eol=lf +*.cf text eol=lf +*.cnf text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.js text eol=lf +*.html text eol=lf +*.tmpl text eol=lf +Dockerfile text eol=lf +Makefile text eol=lf +go.mod text eol=lf +go.sum text eol=lf +LICENSE text eol=lf + +# Binary assets — never touch. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b64c5c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: release + +# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). +# Ordinary commits do not publish anything — only a pushed tag matching +# vX.Y.Z does. That tag is the single source the version comes from: it goes +# into both the image tag and the panel binary's -ldflags version, so the two +# can never drift apart (the invariant restore's version check in spec 7.5.A +# depends on). +# +# Native per-architecture builds (plan implementation-plan.md C.4), not qemu: +# running the full Postfix/OpenDKIM stack under emulation for the e2e gate +# below is impractically slow. Each arch builds, e2e-gates and pushes its own +# tag on its own native runner; a merge job then combines them into the one +# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not +# push-by-digest then test) is deliberate: it means the bytes that get tagged +# are exactly the bytes that passed e2e, at the cost of per-arch tags lingering +# in the registry as a side effect (harmless — the version tag's immutability, +# spec 10.1, is about that tag, not these). +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Derive version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build image (native, loaded locally for the e2e gate) + uses: docker/build-push-action@v6 + with: + context: . + file: build/Dockerfile + load: true + provenance: false + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + tags: selfpost:e2e + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache-dependency-path: test/e2e/go.sum + + - name: e2e (gates publishing — see docs/implementation-plan.md C.4) + run: cd test/e2e && go test -v -timeout 20m ./... + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push per-arch tag + run: | + docker tag selfpost:e2e "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }}" + docker push "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }}" + + merge: + needs: [prepare, build] + runs-on: ubuntu-latest + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Merge per-arch tags into the version manifest + run: | + docker buildx imagetools create \ + -t "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}" \ + "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-amd64" \ + "ghcr.io/${{ github.repository }}:${{ needs.prepare.outputs.version }}-arm64" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..859ed79 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b204197 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Development-only material (secrets, server access notes) — never published. +dev/ + +# Build artifacts (binaries are built on the dev server, not committed). +/bin/ +*.exe + +# Local Go caches / test output, if ever present. +*.out + +# e2e scratch stand (generated cert, DKIM zone, sink dump, /data bind mount) +# — recreated fresh by every `make e2e` run, never checked in. +/test/e2e/.stage/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20613df --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,180 @@ +# Changelog + +All notable changes to this project are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/). + +## [Unreleased] + +- panel: every authenticated page now ends with the running version + (`SelfPost 1.1.0`) in a small footer. It is the value a backup manifest is + checked against on restore, and the first thing to establish when the panel + behaves unexpectedly. The login and setup pages deliberately do not show it. + +- panel: the domain page now shows the **SPF and DMARC records it expects**, + with host, value and a Copy button, next to the DKIM record it already + showed — previously it only said "also configure SPF and DMARC (see the + documentation)" and the concrete example appeared only once a check had + already failed. The SPF value names the addresses this server's hostname + resolves to (falling back to an `a:` mechanism if it does not resolve), and + the DNS checks below build their remediation advice from the same source, so + the page and its checks cannot recommend different records. +- panel: one appearance for actions. Several controls — a POST wrapped in an + inline form (Re-check, Export domain, Sign out, New password…), the + `
` toggles in the applications table, the delete links — used to + render as bold blue text while everything else was a button, so the same + kind of control looked like two different things, sometimes within one card. + They are all buttons now: filled for a card's own action, compact and + outlined where actions cluster in a table row or the nav bar. The two + actions that are really navigations — "Delete domain" and the status page's + "Full queue" — are anchors carrying the same button styling. A bare link is + left only where it reads as part of a sentence, a table cell or the nav. +- panel: on the domain page **Add an application** now sits directly above the + **Applications** list — the same order the domains page uses for its own add + form — instead of being stranded below the domain rate limit. +- ci: hermetic container e2e suite (`test/e2e`, a separate Go module) gates + image publishing — `make e2e` locally, and `go test ./...` in `test/e2e` as + a required step in `release.yml` before a version tag's image is pushed. + It builds the real image, brings up the shipped `deploy/docker-compose.yml` + plus a test-only override (self-signed cert, low ports, a fake DNS zone + served by CoreDNS, a `smtp-sink` sink-MX) on an isolated compose project, + then drives the panel over HTTP exactly like an administrator: setup → + login → add a domain → publish the DKIM record it prints into the fake zone + → add an application → send over SMTP AUTH → verify the delivered message's + DKIM signature against the record the panel published → poll the send log + to `sent`. Negative coverage: no-AUTH and unauthenticated-relay rejection, + sender/login mismatch, the level-1 (anvil) and level-2 (panel-configured) + rate limits, the journal-milter's fail-open behaviour when the panel process + is stopped, a missing/malformed `SELFPOST_HOSTNAME` failing the container + fast, and a login session surviving `docker restart`. `release.yml` moved + off qemu to a native per-architecture build (`ubuntu-latest` / + `ubuntu-24.04-arm`), each gated by this suite before its tag is pushed and + merged into the version manifest — running the full Postfix/OpenDKIM stack + under emulation for the gate was impractically slow. +- ops: `mail.log` rotation switched from `copytruncate` to rename + + `postfix reload` (the same mechanism `postfix logrotate` itself uses), + eliminating the up-to-one-second window in which `copytruncate` could drop + in-flight delivery lines — a lost line meant a send-log row stuck at + `queued` forever. `logrotate-mail.conf` keeps `create 0644 root root` + rather than `nocreate`: verified on a live container that letting Postfix + recreate the file itself on reload produces `0600`, which the unprivileged + panel process cannot read, breaking the mail-log view until the next + restart. The panel's log-tailer (`internal/logtail`) re-drains the old file + descriptor once more right before switching to the rotated one, closing a + similar small window between polls; a missing `mail.log` right after + rotation is now a normal empty screen rather than a logged error. +- panel: login sessions now persist in SQLite instead of memory, so an + administrator's login survives a container restart or redeploy. Only the + SHA-256 of the session token is stored, never the token itself. The + absolute 12-hour TTL is replaced by a **sliding idle timeout** + (`PANEL_SESSION_IDLE_DAYS`, default 7 days, no absolute cap): the + monitoring screens' background polling does not count as activity, so a + forgotten open tab does not keep a session alive forever. Changing the + password still signs out every other session. +- panel: security headers on every response — `Content-Security-Policy`, + `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and + `Strict-Transport-Security` where the deployment is HTTPS-only. They are + emitted by the panel itself, so the reverse proxy still needs no security + configuration of its own. +- panel: state-changing requests are now checked against the panel's own + origin (`Sec-Fetch-Site`, falling back to `Origin` vs `Host`). This closes + cross-site request forgery from a *neighbouring host on the same domain* — + a CMS or a forgotten staging subdomain next to the panel — which the session + cookie's `SameSite=Lax` counts as same-site and therefore cannot stop. A + request that sends neither header is still let through, so genuinely ancient + browsers keep working. **The reverse proxy must pass the original `Host` + header through** (every shipped fragment already does); one that rewrites it + makes the panel refuse every form submission, and the log line names both + the `Origin` and the `Host` it compared. +- panel: the session cookie is now named `__Host-selfpost_session` wherever it + is `Secure` (the standard deployment), which makes the browser enforce that + no other host can set or overwrite it. **Upgrading signs the administrator + out once.** With `PANEL_COOKIE_SECURE=false` the old name is kept, because + the prefix is invalid without TLS. Signing out clears both names. +- panel: if a request arrives with two cookies of the session cookie's name — + what a neighbouring host does when it overwrites the session — the request + counts as signed out and the log says so, instead of the panel silently + picking the other host's value and looping back to the login form forever. +- panel: the layout's stylesheet moved to `/static/panel.css` and the + confirmation prompts on destructive buttons moved into `/static/panel.js`. + No visible change; the panel's CSP allows no inline script or style, and + this is what keeps that policy free of exemptions. +- docs: the first-run setup link is also written to `/data/setup-token` + (`0600`) — documented in the README as the way to read it without the token + passing through a container-log pipeline. +- panel: new **Status** page — supervised processes, mail queue, TLS + certificate expiry, milter sockets and the server's own hostname/reverse-DNS + (FCrDNS) check — and it is now the panel's landing page. The local checks + refresh by polling; the DNS lookup is cached with a *Re-check* button. +- panel: the domain page shows a **DNS status** card: the published DKIM record + compared against the key this server actually signs with, plus SPF and DMARC. + The SPF check is deliberately shallow — it looks for a mechanism literally + covering this server's address and does not follow `include:`/`redirect=`, so + a record that authorises the server through an include is reported as "cannot + tell", not as a failure. +- panel: the domain list moved from `/` to `/domains`; `/` redirects to the + status page. The **Reload** button moved from the domain list to the status + page and now explains what it regenerates and when to use it. +- fix: the panel could never read the mail queue in the documented deployment. + `postqueue` relies on its setgid-`postdrop` bit, which `no-new-privileges` + (set in the shipped compose file) disables, so the *Queue* screen always said + "Could not read the mail queue". The `panel` user is now a real member of + `postdrop`. +- panel: navigation bar is now rendered once from the shared layout, so every + authenticated page has it — including the domain page and the delete + confirmation, which had no navigation links at all — and the current page is + highlighted instead of silently missing from the list. +- panel: new *Account* page to change the administrator's username and/or + password (the current password is required, throttled on the same limiter as + the login form). Changing the password invalidates all other sessions. +- panel: *Backup & migration* moved off the domain list onto its own *Backup* + page, with the full backup and the domain import as two separate cards. +- panel: the domain page now shows the *Sending server settings* (server, + port and encryption) needed to configure a mail client; port 587 is listed + only when `SUBMISSION_ENABLE=true` for this deployment. +- panel: *Copy* buttons on the DKIM record, on a newly issued application + login/password and on the sending server name. +- panel: the *Addresses* field is hidden while an application's address mode is + *Any address of the domain*, where the server ignores it. +- ci: disable provenance attestation on release image push, so the ghcr.io + manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`). +- ci: run `go vet` and `go test ./...` on every push to `main` and every pull + request, not only the image build on a release tag. +- security: optionally honour `X-Forwarded-For` for login/setup rate-limiting + when the request's direct peer is in the new `TRUSTED_PROXY_CIDR` list, + giving real per-client limits behind a reverse proxy instead of one global + bucket. Unset by default (unchanged `RemoteAddr`-only behaviour). + +## [0.1.0] - 2026-07-15 + +Initial feature-complete implementation of the v1.0 specification (phases 0-11 +of `docs/implementation-plan.md`). + +### Added + +- Panel (Go, single static binary) with SQLite persistence, one-time + crypto-random setup link, bcrypt admin auth, session cookies. +- Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and + OpenDKIM KeyTable/SigningTable regeneration + privilege-safe reload. +- Application (sender identity) management: SASL credentials via `sasldb2`, + `smtpd_sender_login_maps` enforcing sender/domain ownership, no open relay. +- Full Postfix relay config generated from env at container start: SMTPS 465, + optional STARTTLS submission 587, SASL auth, TLS for outbound delivery, + anvil-based rate limiting (level 1). +- Journal milter (pure Go, `go-milter`) recording every send to `send_log`; + fail-open by design so a milter fault never blocks mail. +- Monitoring UI: send log, Postfix queue, and mail.log tail, all + HTMX-polling, HTML-escaped. +- Per-domain/per-application sending rate limit (level 2), enforced in the + journal milter at `MAIL FROM`, fail-open on the limiter's own errors. +- Full backup/restore (`tar.gz` of `/data`, consistent SQLite snapshot via + `VACUUM INTO`) with a version guard that refuses to start on a + manifest/binary version mismatch. Per-domain export/import for moving a + single domain between hosts without re-issuing DNS records. +- Deployment: Docker image + compose, reverse-proxy fragments for Apache + (default), nginx, Caddy, and Traefik; CI workflow publishing tagged, + multi-arch images to `ghcr.io` on `vX.Y.Z` tags. +- Security pass against spec 7.6 (exec safety, config-write sanitization, + server-side validation, rate limiting, session/cookie hardening, output + escaping, non-root panel) — full compliance, no code changes required. +- Live production deployment on `selfpost.example.com` with a real Let's + Encrypt certificate; end-to-end delivery confirmed (DKIM pass, SPF pass). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ac55d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# SelfPost build. +# +# The version is stamped into both binaries and MUST match the Docker image tag +# (spec 7.5.A: backup/restore compatibility check). Override on the command line: +# +# make build VERSION=1.3.0 +# +# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure +# Go, so no cgo is required) — see spec 7.1. + +VERSION ?= dev +MODULE := codeberg.org/mix/selfpost +LDFLAGS := -X $(MODULE)/internal/buildinfo.Version=$(VERSION) +GOFLAGS := -trimpath + +.PHONY: all build vet test clean e2e + +all: vet build + +build: + CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/panel ./cmd/panel + CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/selfpost-backup ./cmd/selfpost-backup + +vet: + go vet ./... + +test: + go test ./... + +clean: + rm -rf bin + +# Hermetic container e2e (plan implementation-plan.md C.4): separate Go module +# under test/e2e so its test-only dependencies (DKIM verification) never enter +# this module's build graph. Builds the image fresh from this checkout, brings +# up deploy/docker-compose.yml plus a test-only override on high ports and an +# isolated compose project (-p selfpost-e2e) so it never collides with a real +# deployment on the same host, then tears the stand down whether the suite +# passed or not. +e2e: + cd test/e2e && go test -v -timeout 20m ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..cccca31 --- /dev/null +++ b/README.md @@ -0,0 +1,197 @@ +# SelfPost + +Self-hosted outbound SMTP relay with a web control panel, shipped as a single +Docker image. Postfix + OpenDKIM + a small Go panel run together under +`supervisord`; the panel manages multiple sending domains, per-domain DKIM keys +and SASL-authenticated applications bound to their domain. + +SelfPost sends mail straight to the internet from your own IP, with DKIM +signing, and is configured once through the panel. It is **outbound only** — it +does not receive mail, provide mailboxes, or offer webmail. + +> **Status: under active development.** See [docs/specification.md](docs/specification.md) +> for the full requirements, [docs/implementation-plan.md](docs/implementation-plan.md) +> for the phased build plan, and [docs/security.md](docs/security.md) for the +> security trade-offs that were accepted knowingly. + +## Requirements (site checklist) + +Providing these is the operator's job, not a feature of SelfPost — the panel +can't fix a blocked port or a missing PTR record for you. + +- [ ] A static IP address. +- [ ] Outbound TCP port 25 unblocked (many consumer/cloud hosts block it by + default — check with your provider before anything else). +- [ ] PTR/rDNS for that IP set to your mail hostname (see [DNS setup](#dns-setup)). +- [ ] Reasonable starting IP reputation — a fresh IP still needs [warmup](#ip-warmup). +- [ ] A reverse proxy in front of the panel (see [Reverse proxy](#reverse-proxy-mandatory)) — SelfPost never terminates HTTPS itself. +- [ ] Docker + Compose v2 on the host. + +## Quick start + +```sh +mkdir -p selfpost && cd selfpost +curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml +curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example +mv .env.example .env # then edit SELFPOST_HOSTNAME etc. +docker compose up -d +``` + +`SELFPOST_HOSTNAME` is required — the container exits immediately with an +explanatory error if it's unset, since it doubles as the Postfix HELO name, +the SASL realm, and must match both the PTR record and the certificate +CN/SAN. + +This starts SelfPost alone; it assumes Apache is already installed on the host +as the reverse proxy (see below) and expects certificates at `./certs`. The +first log line (`docker compose logs -f`) prints the one-time setup link — +open it to create the admin account. That username and password can be changed +later from the panel's *Account* page (changing the password signs out every +other session). + +The same link is also written to `/data/setup-token` inside the container — +`./data/setup-token` on the host, mode `0600` — and deleted the moment setup +completes. If this host ships its container logs to a central aggregator, +prefer the file: the link is a bearer token valid for ten minutes, and reading +it this way keeps it out of the log pipeline (and out of whatever retains it +afterwards) entirely. + +```sh +docker compose exec selfpost cat /data/setup-token +``` + +## Reverse proxy (mandatory) + +SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse +proxy in front of it is not optional. The proxy is also the project's only +source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets +bind-mounted **read-only** into the SelfPost container, and Postfix uses those +same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail +service share one hostname — the common case — it's genuinely one certificate +serving both. + +SelfPost isn't tied to a specific proxy; pick whichever fits your host: + +| Proxy | Where certs live | Fragment | +|---|---|---| +| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) | +| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](deploy/nginx/) | +| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](deploy/caddy/) | +| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](deploy/traefik/) | + +Apache is the recommended default because the certbot Apache plugin already +writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no +extra moving parts between "certificate issued" and "Postfix can read it." + +**The proxy needs no security configuration of its own.** The panel emits its +own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, +`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part +that's easy to get wrong lives in the service rather than in a config file +somebody edits under pressure. There is exactly one thing the proxy must do: +**pass the original `Host` header through**. All four fragments above already +do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy +and Traefik by default). A proxy that rewrites `Host` instead makes the panel +reject every form submission as cross-origin — the log says so explicitly, +printing the `Origin` and `Host` it compared. + +## DNS setup + +Two different scopes — don't confuse them: + +**Server level (once, for the machine itself):** +- **PTR/rDNS** for the server's IP, pointing at its mail hostname. Most + receiving mail servers weigh this heavily; get it from whoever assigns the IP + (hosting provider's panel/support), not from your own DNS zone. + +**Domain level (for *every* sending domain you add in the panel):** +- **SPF** — a TXT record on the domain authorizing this server to send on its + behalf (e.g. `v=spf1 a mx ip4: -all`, adjusted to your setup). +- **DKIM** — a TXT record with the exact value the panel shows on that + domain's page (`domain page → DKIM TXT record`), one selector per domain. +- **DMARC** — a `_dmarc` TXT record (even a conservative `p=none` starts + building reporting/reputation history). + +Skipping any of the three per-domain records is the single most common reason +mail lands in spam even though SelfPost delivered it correctly — DKIM passing +doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the +panel, add its DNS records at the same time**, not later. + +The panel checks both scopes for you and tells you what is actually published: +the *Status* page verifies the server's hostname and its reverse record +(forward-confirmed reverse DNS), and each domain's page shows a *DNS status* +card comparing the published DKIM record against the key this server signs with, +plus the domain's SPF and DMARC records. Results are cached for a few minutes; +use *Re-check* right after publishing a record. The SPF check is deliberately +shallow — it looks for a mechanism that literally covers this server's address +and does not follow `include:` or `redirect=`, so a record that authorizes the +server through an include is reported as "cannot tell" rather than as a failure. + +## IP warmup + +A brand-new IP has no sending history, so receiving servers are cautious with +it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to +a domain, increase gradually over days/weeks rather than sending everything on +day one, and check the IP against major blocklists (Spamhaus and similar) +before and during warmup. This is inherent to how mail reputation works on the +public internet, not something SelfPost's configuration can shortcut. + +## Backup, restore, and moving a single domain + +Two related but distinct operations — spec 7.5: + +- **Full backup** (whole `/data`: SQLite, all domains' DKIM keys, all + applications' SASL credentials, `manifest.json` with the version that + created it): panel button (*Backup* → *Full backup*), or from the + host: + ```sh + docker exec selfpost-backup > selfpost-backup.tar.gz + ``` + **Restore** means unpacking that archive into a fresh `/data` bind mount and + starting a container of the **exact same image version** that created it — + SelfPost refuses to start otherwise and tells you which tag to use. This is + why the compose file below pins a fixed tag rather than `:latest`: without a + known version, there'd be no way to tell which image restoring a given + backup actually requires. + +- **Export/import a single domain** (domain page → *Export domain* to write the + file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working** + SASL passwords — to a different SelfPost instance without regenerating + anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full + restore, this works across different hostnames/instances. + +Both files are **secrets** — they contain the admin password hash (full +backup) or working application credentials (domain export) in the clear or in +directly reversible form. Treat them like any other credential material: +encrypt at rest, restrict who can read them, don't email them around. + +## Fixed image tag + +`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), +deliberately never `:latest`. This is a direct consequence of the backup +version check above: the panel binary's embedded version and the image tag +that produced it are the same value by construction (the release CI stamps +both from one git tag — see `.github/workflows/release.yml`), so pinning the +tag is what makes "restore into the same version" a checkable fact rather than +a guess. Upgrade by bumping the tag deliberately, not by riding a moving +target. + +## Machine requirements + +Rough guide, not a hard floor: **1 vCPU**, **512MB–1GB RAM** (the stack — three +processes plus SQLite — idles around 100–150MB; the rest is headroom for +backups, log-tailer/retention sweeps and concurrent TLS handshakes coinciding), +**8–10GB disk**. Disk usage grows mainly from the send log (bounded by +`SEND_LOG_RETENTION_DAYS`, default 90) and the rotated `mail.log` (kept 14 days +in-image), not from the application itself. On boxes with little RAM, a small +swap file is cheap insurance against those occasional coincident spikes. + +## Repository + +- Primary: +- Mirror: + +## License + +[AGPL-3.0](LICENSE). The AGPL closes the "SaaS loophole": if you run a modified +version as a network-accessible service, you must make the modified source +available to its users — not only when you distribute copies of the code. diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..d45394f --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,109 @@ +# syntax=docker/dockerfile:1 +# +# SelfPost — single Debian-slim image running postfix + opendkim + panel under +# supervisord (spec 4). Build from the repository root: +# +# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . + +# ---- build stage ------------------------------------------------------------- +FROM golang:1.26-bookworm AS build + +WORKDIR /src + +# Version stamped into both binaries; MUST match the image tag (spec 7.5.A). +ARG VERSION=dev + +# Module metadata first for layer caching. go.sum arrived in Phase 2 with the +# SQLite driver and bcrypt. +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd ./cmd +COPY internal ./internal + +ENV CGO_ENABLED=0 +RUN go vet ./... \ + && go build -trimpath \ + -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \ + -o /out/panel ./cmd/panel \ + && go build -trimpath \ + -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \ + -o /out/selfpost-backup ./cmd/selfpost-backup + +# ---- runtime stage ----------------------------------------------------------- +FROM debian:bookworm-slim AS runtime + +ENV DEBIAN_FRONTEND=noninteractive + +# Preseed Postfix so its install is non-interactive and yields a working +# main.cf. The real relay configuration is generated by the panel in Phase 5. +RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ + && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + postfix \ + opendkim \ + opendkim-tools \ + sasl2-bin \ + libsasl2-modules \ + db-util \ + supervisor \ + logrotate \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Unprivileged user for the panel process (spec 7.6.8). +RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel + +# Shared group bridging the unprivileged services (spec 5.1, 6): the panel +# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the +# Postfix sender map, while OpenDKIM and Postfix (different users) must read +# them. Membership in this group — plus setgid dirs under /data (set up in +# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read +# the sasldb2/sender map, and lets the panel reach the supervisor control socket +# to signal OpenDKIM/Postfix reloads without any process running as root. +RUN groupadd --system selfpost \ + && usermod -aG selfpost panel \ + && usermod -aG selfpost opendkim \ + && usermod -aG selfpost postfix + +# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the +# status page's queue card). postqueue is setgid postdrop, which normally gives +# it the group needed to reach Postfix's showq socket — but the documented +# deployment runs with `no-new-privileges`, which disables setgid transitions, +# so the panel would always see "Permission denied". Making `panel` a real +# member of postdrop grants the same access without relying on a setgid +# escalation the hardening deliberately forbids. postdrop membership is +# read-side only: it does not let the panel bypass any Postfix restriction that +# a local user does not already have through the world-executable sendmail. +RUN usermod -aG postdrop panel + +# Runtime directories: milter sockets and the consolidated persistent root. +RUN mkdir -p /run/opendkim /run/selfpost /data \ + && chown opendkim:opendkim /run/opendkim \ + && chown panel:panel /run/selfpost /data + +COPY --from=build /out/panel /usr/local/bin/panel +COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup + +COPY build/opendkim.conf /etc/opendkim.conf +COPY build/logrotate-mail.conf /etc/logrotate.d/mail +COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh +COPY build/postfix-config.sh /usr/local/bin/postfix-config.sh +COPY build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh +COPY build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh +COPY build/crashexit.py /usr/local/bin/crashexit.py +COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY build/supervisord.conf /etc/supervisor/supervisord.conf +RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/postfix-config.sh \ + /usr/local/bin/postfix-cert-reload.sh /usr/local/bin/logrotate-loop.sh \ + /usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh + +# Published submission ports: 465 (smtps, primary) and 587 (submission, optional) +# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client, +# which needs no inbound listener or EXPOSE. +EXPOSE 8080 465 587 + +# The entrypoint fixes /data ownership (bind mount) as root, then execs +# supervisord, which becomes PID 1 and owns process supervision (spec 4). +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/build/crashexit.py b/build/crashexit.py new file mode 100644 index 0000000..368d2d3 --- /dev/null +++ b/build/crashexit.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Supervisor event listener: bring the container down on unrecoverable failure. + +If any managed program exhausts its restart retries and enters FATAL, this +listener signals supervisord (its parent) to terminate, so the whole container +exits and Docker's `restart` policy can recreate it cleanly — rather than +lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4). + +Communication with supervisord uses the event listener protocol over stdin and +stdout, so stdout must carry only protocol tokens. +""" +import os +import signal +import sys + + +def write_stdout(s): + sys.stdout.write(s) + sys.stdout.flush() + + +def main(): + while True: + # Tell supervisord we are ready for the next event. + write_stdout("READY\n") + + line = sys.stdin.readline() + if not line: + return + headers = dict(pair.split(":", 1) for pair in line.split()) + payload_len = int(headers.get("len", 0)) + if payload_len: + sys.stdin.read(payload_len) + + # We only subscribe to PROCESS_STATE_FATAL, so any event means a managed + # program can no longer be restarted. Take the container down. + sys.stderr.write( + "crashexit: a managed process entered FATAL; shutting down container\n" + ) + sys.stderr.flush() + os.kill(os.getppid(), signal.SIGTERM) + + write_stdout("RESULT 2\nOK") + + +if __name__ == "__main__": + main() diff --git a/build/entrypoint.sh b/build/entrypoint.sh new file mode 100644 index 0000000..bcfaa40 --- /dev/null +++ b/build/entrypoint.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# Container entrypoint (runs as root, PID 1 until it execs supervisord). +# +# The persistent root /data is a host bind mount (spec 9), so it arrives owned +# by the host user (typically root), not by the unprivileged panel user that +# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8). +# Fix its ownership here — the one place still running as root — before handing +# off to supervisord, which starts the panel as the panel user. +set -e + +chown panel:panel /data +# Restored backups or previously-created state may contain panel-owned files +# under /data; make sure they stay writable without disturbing anything that a +# later phase deliberately hands to another service. +find /data -mindepth 1 -maxdepth 1 ! -user panel -exec chown -R panel:panel {} + + +# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes +# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the +# tree on every start so it is correct whether /data is fresh, restarted, or +# just restored from a backup: +# - group `selfpost` + setgid on directories so anything the panel creates +# inherits the shared group OpenDKIM traverses; +# - private keys and tables group-readable (0640); +# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the +# daemon comes up cleanly with no domains yet. +mkdir -p /data/opendkim/keys +for t in /data/opendkim/KeyTable /data/opendkim/SigningTable; do + [ -e "$t" ] || : > "$t" +done +chown -R panel:selfpost /data/opendkim +find /data/opendkim -type d -exec chmod 2750 {} + +chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable +find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + + +# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the +# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP +# clients. Share it through the `selfpost` group the same way as the DKIM tree: +# setgid directory so new files inherit the group, and the database itself +# group-readable (0640). Postfix wiring to actually consult it lands in Phase 5. +mkdir -p /data/sasl +chown -R panel:selfpost /data/sasl +chmod 2750 /data/sasl +[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 + +# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it. +# Ensure the file exists (empty is fine) before Postfix starts so a reload that +# references it never fails on a missing file, and keep it group-readable. +mkdir -p /data/postfix +[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps +chown -R panel:selfpost /data/postfix +chmod 2750 /data/postfix +chmod 0640 /data/postfix/sender_login_maps + +# Milter socket directories (spec 5 p.3, 7.3). From Phase 5 Postfix (user +# `postfix`) must actually CONNECT to both milter sockets — OpenDKIM's and the +# panel's journal-milter — not just probe them at start-up. The sockets are +# created by the opendkim and panel users respectively, so bridge them to +# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean +# each socket created inside inherits group `selfpost`, and group-traversable +# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and, +# because signing is strict (default_action=tempfail), rejects all mail. +mkdir -p /run/opendkim /run/selfpost +chown opendkim:selfpost /run/opendkim +chown panel:selfpost /run/selfpost +chmod 2750 /run/opendkim /run/selfpost + +# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must +# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the +# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and +# postfix-config.sh each fall back independently when it's unset — to +# `localhost` and to the container hostname respectively — so accounts get +# written under one realm and looked up under another and authentication +# silently fails for every application, while HELO also stops matching the +# PTR record and mail that does go out lands in spam. No fallback can be +# correct, so fail loudly here, before either side of that split has a chance +# to run, rather than leave a green panel with broken mail. +if [ -z "$SELFPOST_HOSTNAME" ]; then + cat >&2 <<'EOF' +FATAL: SELFPOST_HOSTNAME is not set. + +This is the mail server's identity: it becomes the Postfix HELO/EHLO name, +the Cyrus SASL realm that application passwords are looked up under, and it +must match the TLS certificate's CN/SAN as well as this server's PTR (reverse +DNS) record. There is no safe default — guessing any one of these wrong +breaks authentication for every application or sends outgoing mail to spam, +silently. + +Set it to the mail server's fully-qualified domain name, e.g.: + + SELFPOST_HOSTNAME=mail.example.com + +in the .env file next to your docker-compose.yml (see deploy/.env.example). +EOF + exit 1 +fi + +case "$SELFPOST_HOSTNAME" in + *[\ \ ]* | *://* | *:* ) + echo "FATAL: SELFPOST_HOSTNAME must be a bare hostname (no scheme, port, or spaces): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; + *.*) + ;; + *) + echo "FATAL: SELFPOST_HOSTNAME must be a fully-qualified domain name (at least one dot): \"$SELFPOST_HOSTNAME\"" >&2 + echo 'Example: SELFPOST_HOSTNAME=mail.example.com' >&2 + exit 1 + ;; +esac + +# Generate the outbound-relay Postfix configuration from the environment (spec +# 5). Kept out of the image build so cert paths, rate limits, hostname and the +# optional 587 service are all driven by env at run time, and re-derived on every +# start the same way the /data normalisation above is. +/usr/local/bin/postfix-config.sh + +exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf diff --git a/build/logrotate-loop.sh b/build/logrotate-loop.sh new file mode 100644 index 0000000..e016a6c --- /dev/null +++ b/build/logrotate-loop.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Periodic logrotate for /var/log/mail.log (spec 9, 10). Rotation renames the +# file, recreates it (`create 0644 root root`, matching a cold container +# start), then runs `postfix reload` (the same mechanism `postfix logrotate` +# uses): postlogd keeps writing to the renamed inode until reload, and the +# panel's log-tailer holds its own descriptor on that inode, so nothing +# written before the reload is lost. `create` (rather than `nocreate`) matters +# here beyond timing: a reload-triggered recreate lands the file at 0600, +# which the unprivileged panel process cannot read — confirmed on a live +# container — so logrotate must be the one to create it at 0644. +# +# logrotate itself only rotates once the configured "daily" period has elapsed +# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more +# often than daily — polling merely bounds how late a legitimate rotation runs. +set -eu + +INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}" + +while true; do + if logrotate /etc/logrotate.d/mail; then + : + else + echo "logrotate-loop: logrotate failed, will retry after ${INTERVAL}s" >&2 + fi + sleep "${INTERVAL}" +done diff --git a/build/logrotate-mail.conf b/build/logrotate-mail.conf new file mode 100644 index 0000000..5f4e402 --- /dev/null +++ b/build/logrotate-mail.conf @@ -0,0 +1,12 @@ +/var/log/mail.log { + daily + rotate 14 + missingok + notifempty + compress + delaycompress + create 0644 root root + postrotate + /usr/sbin/postfix reload + endscript +} diff --git a/build/opendkim.conf b/build/opendkim.conf new file mode 100644 index 0000000..4a6898a --- /dev/null +++ b/build/opendkim.conf @@ -0,0 +1,30 @@ +# OpenDKIM — per-domain signing (spec 6). +# +# Signing is strictly per-domain: each sending domain has its own key pair and +# selector. The panel generates keys under /data/opendkim/keys// and +# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks +# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5). +# +# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts, +# so the daemon comes up cleanly on a fresh /data with no domains yet and simply +# signs nothing until the first domain is added. + +Syslog no +UMask 007 +Mode s + +# SigningTable uses refile: so the "*@example.com" left-hand patterns match any +# local-part for a domain; KeyTable maps each domain to its key and selector. +KeyTable /data/opendkim/KeyTable +SigningTable refile:/data/opendkim/SigningTable + +# The private keys are owned by `panel` and read by `opendkim` through the shared +# `selfpost` group, so they are deliberately group-readable (mode 0640). That is +# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default +# key-safety check, so the check is disabled here by design. +RequireSafeKeys no + +Socket local:/run/opendkim/opendkim.sock +PidFile /run/opendkim/opendkim.pid +UserID opendkim +Background no diff --git a/build/postfix-cert-reload.sh b/build/postfix-cert-reload.sh new file mode 100644 index 0000000..145a5c1 --- /dev/null +++ b/build/postfix-cert-reload.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec +# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every +# few months; Postfix only re-reads them on reload. A simple daily reload is +# more than enough (a day of staleness is harmless) and far simpler than an +# inotify watcher — the spec explicitly prefers this. +# +# Runs under supervisord as root, so it can reload Postfix directly. It sleeps +# first, then reloads in a loop: no reload at container start (the wrapper is +# still bringing Postfix up then) and none until at least one interval has +# passed. A reload is harmless when nothing changed. +set -eu + +INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}" + +while true; do + sleep "${INTERVAL}" + if postfix reload; then + echo "cert-reload: postfix reloaded (periodic TLS refresh)" + else + # Never exit non-zero: a transient reload failure must not trip the + # crashexit listener and take the container down. Log and retry next cycle. + echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2 + fi +done diff --git a/build/postfix-config.sh b/build/postfix-config.sh new file mode 100644 index 0000000..1ee6b83 --- /dev/null +++ b/build/postfix-config.sh @@ -0,0 +1,184 @@ +#!/bin/sh +# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2). +# +# Run as root from entrypoint.sh on every container start, BEFORE supervisord +# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is +# re-derived from the environment on each start rather than persisted, so a +# fresh, restarted or restored container always ends up with the same config +# (the only panel-edited Postfix file, sender_login_maps, lives under /data and +# is generated separately by the panel — spec 5.1). +# +# main.cf is written with `postconf -e`, the master.cf submission services with +# `postconf -M`/`-P`. No user input is interpolated: every value here comes from +# a fixed literal or a container environment variable (spec 7.6.3). +set -eu + +# --- environment knobs (spec 8) ---------------------------------------------- +# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the +# sasldb2 accounts are looked up under. The panel creates accounts under realm +# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication +# fails. Fall back to the container hostname only outside a real deployment. +HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" + +# TLS material supplied by the reverse-proxy through a read-only bind mount +# (spec 5.2). The relay requires TLS on 465; if these files are absent the +# master still starts but TLS handshakes on 465 fail until they appear. +TLS_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}" +TLS_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}" + +# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative +# defaults, sensible during IP warm-up (spec 10). +RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}" +RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}" + +# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter +# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh. +OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}" +JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" + +# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so +# the unprivileged panel can rewrite it and just ask for a reload. +SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" +SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}" + +# Optional submission service on 587 (spec 5 p.1: off by default, enabled only +# when a client library needs STARTTLS on 587 instead of implicit TLS on 465). +SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" + +# --- main.cf ----------------------------------------------------------------- +postconf -e \ + "myhostname=${HOSTNAME_VALUE}" \ + "maillog_file=/var/log/mail.log" \ + "mydestination=" \ + "relayhost=" \ + "inet_interfaces=all" \ + "inet_protocols=all" + +# This is an outbound relay: no local delivery, no per-user aliases. Empty +# these so a misfiled recipient never gets delivered locally. +postconf -e \ + "local_recipient_maps=" \ + "alias_maps=" \ + "alias_database=" + +# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2). +postconf -e \ + "smtp_tls_security_level=may" \ + "smtp_tls_note_starttls_offer=yes" + +# TLS server material shared by every inbound service (spec 5.2). auth_only +# guarantees credentials are never accepted before TLS is up on any port. +postconf -e \ + "smtpd_tls_cert_file=${TLS_CERT}" \ + "smtpd_tls_key_file=${TLS_KEY}" \ + "smtpd_tls_security_level=may" \ + "smtpd_tls_auth_only=yes" \ + "smtpd_tls_loglevel=1" + +# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm +# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name +# Postfix uses for sender_login_maps is the BARE login the panel writes into the +# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname +# as the server realm, which equals the realm the accounts were created under. +postconf -e \ + "smtpd_sasl_auth_enable=yes" \ + "smtpd_sasl_type=cyrus" \ + "smtpd_sasl_path=smtpd" \ + "smtpd_sasl_local_domain=" \ + "smtpd_sasl_security_options=noanonymous" \ + "smtpd_sasl_tls_security_options=noanonymous" \ + "broken_sasl_auth_clients=yes" + +# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash +# resolves the full address first, then the "@domain" wildcard, so both address +# modes work from the same map. +postconf -e \ + "smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" + +# Restrictions: authenticated clients only, no relay to foreign destinations, +# and every authenticated sender address must be owned by its login. NO +# permit_mynetworks anywhere — authorisation is by credentials, never by network +# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible. +postconf -e \ + "smtpd_helo_required=yes" \ + "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ + "smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ + "smtpd_sender_restrictions=reject_sender_login_mismatch, permit" + +# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even +# if the journal-milter (level 2, Phase 8) is down. +postconf -e \ + "smtpd_client_message_rate_limit=${RATE_MSGS}" \ + "anvil_rate_time_unit=${RATE_WINDOW}s" + +# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly +# (default_action=tempfail: if it is unreachable, defer rather than send +# unsigned). The journal-milter is monitoring only and is fail-open +# (default_action=accept): its failure must never block the relay. Per-milter +# settings use Postfix 3.0+ brace syntax. +postconf -e \ + "milter_protocol=6" \ + "milter_default_action=tempfail" \ + "smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \ + "non_smtpd_milters=" + +# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never +# replies) must fail open just like a crash, not stall mail acceptance until the +# Postfix defaults (300s content) elapse. With default_action per milter, a +# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, +# but within seconds rather than minutes. Values are well above any healthy +# response time (signing/DB insert are sub-second), so they never fire in normal +# operation. +postconf -e \ + "milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \ + "milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \ + "milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}" + +# --- master.cf: inbound submission services ---------------------------------- +# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service +# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data +# and the Cyrus config outside any chroot. +postconf -M "smtps/inet=smtps inet n - n - - smtpd" +postconf -P \ + "smtps/inet/smtpd_tls_wrappermode=yes" \ + "smtps/inet/smtpd_sasl_auth_enable=yes" \ + "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" + +# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits; +# the only difference is TLS is negotiated via STARTTLS, so require encryption +# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a +# restart after disabling it does not leave the port listening. +if [ "${SUBMISSION_ENABLE}" = "true" ]; then + postconf -M "submission/inet=submission inet n - n - - smtpd" + postconf -P \ + "submission/inet/smtpd_tls_security_level=encrypt" \ + "submission/inet/smtpd_sasl_auth_enable=yes" \ + "submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" +else + postconf -MX "submission/inet" 2>/dev/null || true +fi + +# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery +# agent and others chrooted to /var/spool/postfix, where they cannot read +# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail +# never leaves. Inside a container the chroot buys little (the container is the +# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off +# uniformly. Our own smtps/submission services are already n; this covers the +# delivery agents and the rest. +postconf -F "*/*/chroot=n" + +# --- Cyrus SASL app config for smtpd ----------------------------------------- +# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify +# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN +# only — both are safe because TLS is mandatory before auth on every port. +mkdir -p /etc/postfix/sasl +cat > /etc/postfix/sasl/smtpd.conf <&2 + exit 1 + fi + sleep "$INTERVAL" + elapsed=$((elapsed + INTERVAL)) + done + echo "postfix-wrapper: milter socket ready: $sock" +done + +echo "postfix-wrapper: both milter sockets ready, starting postfix" +exec postfix start-fg diff --git a/build/supervisord.conf b/build/supervisord.conf new file mode 100644 index 0000000..9b15635 --- /dev/null +++ b/build/supervisord.conf @@ -0,0 +1,124 @@ +; SelfPost process supervision (spec 4). +; +; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens +; the journal-milter socket), then the Postfix wrapper — which additionally +; blocks until both milter sockets are ready before starting Postfix. +; +; If any managed process exhausts its restart retries (FATAL), the crashexit +; event listener brings the whole container down so Docker's restart policy can +; recreate it cleanly, rather than lingering with a dead component. + +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/supervisord.pid +loglevel=info + +; The control socket is reachable by the shared `selfpost` group so the panel +; (running as the unprivileged `panel` user) can ask supervisord to send +; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is +; still unreachable by world; only root and the two SelfPost services are in the +; group. No HTTP auth is configured because access is gated by socket +; permissions, not the network. +[unix_http_server] +file=/run/supervisor.sock +chown=root:selfpost +chmod=0770 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///run/supervisor.sock + +[program:opendkim] +command=/usr/sbin/opendkim -f -x /etc/opendkim.conf +priority=100 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:panel] +command=/usr/local/bin/panel +user=panel +priority=200 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:postfix] +command=/usr/local/bin/postfix-wrapper.sh +priority=300 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and +; cannot run `postfix reload` itself, and it cannot signal the Postfix master +; directly: `postfix start-fg` forks a separate master process, so a signal to +; the supervised foreground process never reaches it (unlike OpenDKIM, which +; runs in the foreground as the supervised process itself). Instead the panel +; asks supervisord — over the group-accessible control socket — to run this +; program, which executes the canonical `postfix reload` as root. autostart is +; off (it only runs on demand) and a fast, clean exit is expected, so it never +; trips the crashexit listener. +[program:postfix-reload] +command=/usr/sbin/postfix reload +autostart=false +autorestart=false +startsecs=0 +startretries=1 +exitcodes=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload` +; so certificates the reverse-proxy renews in the read-only mount are picked up. +; Long-running (it loops), runs as root so it can reload Postfix, and never exits +; non-zero, so it neither trips the crashexit listener nor needs restarting. +[program:cert-reload] +command=/usr/local/bin/postfix-cert-reload.sh +priority=400 +autostart=true +autorestart=true +startsecs=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; Periodic logrotate for /var/log/mail.log (spec 9, 10: daily, 7-14 files kept +; in the image). Runs as root so logrotate can read/rotate the log; never exits +; non-zero, so it neither trips the crashexit listener nor needs restarting. +[program:logrotate] +command=/usr/local/bin/logrotate-loop.sh +priority=400 +autostart=true +autorestart=true +startsecs=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[eventlistener:crashexit] +command=/usr/local/bin/crashexit.py +events=PROCESS_STATE_FATAL +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go new file mode 100644 index 0000000..72ca803 --- /dev/null +++ b/cmd/panel/httpserver.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "time" + + "codeberg.org/mix/selfpost/internal/app" + "codeberg.org/mix/selfpost/internal/buildinfo" + "codeberg.org/mix/selfpost/internal/domain" + "codeberg.org/mix/selfpost/internal/postfix" + "codeberg.org/mix/selfpost/internal/store" + "codeberg.org/mix/selfpost/internal/web" +) + +// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using +// the database handle shared by all roles. From Phase 2 this serves the real +// setup, login and authenticated panel surface (spec 7.6). +func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { + // Applications own the SASL accounts and the Postfix sender map; the domain + // service delegates to them when a domain (and its applications) is deleted. + pf := postfix.New(cfg.postfixDir) + apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) + domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef) + + srvApp, err := web.New(st, domains, apps, web.Config{ + Hostname: cfg.hostname, + CookieSecure: cfg.cookieSecure, + SubmissionEnabled: cfg.submissionEnabled, + MailLogPath: cfg.mailLog, + DataDir: cfg.dataDir, + DBPath: cfg.dbPath, + Version: buildinfo.Version, + TrustedProxyCIDRs: cfg.trustedProxies, + TLSCertFile: cfg.tlsCertFile, + OpenDKIMSocket: cfg.opendkimSocket, + JournalSocket: cfg.journalSocket, + SessionIdleDays: cfg.sessionIdleDays, + }, cfg.setupTokenPath) + if err != nil { + return err + } + if err := srvApp.Start(); err != nil { + return err + } + + srv := &http.Server{ + Addr: cfg.httpAddr, + Handler: srvApp.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + // Shut the server down cleanly when the process is asked to stop. + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + log.Printf("http panel listening on %s", cfg.httpAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/cmd/panel/journal.go b/cmd/panel/journal.go new file mode 100644 index 0000000..e15dbdc --- /dev/null +++ b/cmd/panel/journal.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "errors" + "log" + "net" + "os" + "path/filepath" + + "codeberg.org/mix/selfpost/internal/milter" + "codeberg.org/mix/selfpost/internal/store" +) + +// serveJournal opens the journal-milter Unix socket and runs the real milter +// (spec 7.3), recording accepted messages into the send log. Socket lifecycle +// (creation, stale cleanup, group permissions) lives here; the protocol handler +// lives in internal/milter. +func serveJournal(ctx context.Context, cfg config, st *store.Store) error { + socketPath := cfg.journalSocket + if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { + return err + } + // Clear a stale socket left behind by an unclean shutdown, otherwise the + // listen below fails with "address already in use". + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + ln, err := net.Listen("unix", socketPath) + if err != nil { + return err + } + // Postfix (user `postfix`) connects to this socket as a milter and needs + // group access. The socket inherits group `selfpost` from the setgid parent + // dir entrypoint.sh prepares; make it group read/write so postfix can reach + // it (connecting to a Unix socket needs write permission on the node). + if err := os.Chmod(socketPath, 0o660); err != nil { + ln.Close() + return err + } + + log.Printf("journal-milter listening on %s", socketPath) + return milter.Serve(ctx, ln, st) +} diff --git a/cmd/panel/main.go b/cmd/panel/main.go new file mode 100644 index 0000000..6d6daa2 --- /dev/null +++ b/cmd/panel/main.go @@ -0,0 +1,255 @@ +// Command panel is the SelfPost control panel. In the finished product this +// single binary combines several roles (spec 7.1): the HTTP panel server, +// the journal-milter, the mail.log tailer and the rate-limit checks. +// +// Phase 1 wires those roles up as a supervised process with a minimal HTTP +// stub, a journal-milter socket stub (so the Postfix start wrapper's readiness +// probe passes) and a log-tailer stub. Real behaviour lands in later phases. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + + "codeberg.org/mix/selfpost/internal/backup" + "codeberg.org/mix/selfpost/internal/buildinfo" + "codeberg.org/mix/selfpost/internal/logtail" + "codeberg.org/mix/selfpost/internal/store" +) + +func main() { + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Println(buildinfo.Version) + return + } + + log.SetFlags(log.LstdFlags | log.LUTC) + log.SetPrefix("panel: ") + + if err := run(); err != nil { + log.Fatalf("fatal: %v", err) + } +} + +// config holds the runtime knobs the panel reads from the environment. Every +// value has a safe default so the binary also runs outside the container. +type config struct { + httpAddr string + journalSocket string + mailLog string + retentionDays int + + dataDir string + dbPath string + manifestPath string + setupTokenPath string + hostname string + cookieSecure bool + submissionEnabled bool + trustedProxies []*net.IPNet + sessionIdleDays int + + // Read-only inputs to the panel's status page: the certificate Postfix + // serves and the two milter sockets it connects to. The defaults mirror + // build/postfix-config.sh, so the status page checks exactly what Postfix + // was configured with. + tlsCertFile string + opendkimSocket string + + opendkimDir string + dkimSelectorDef string + + saslDBPath string + saslRealm string + postfixDir string +} + +func loadConfig() config { + dataDir := envDefault("SELFPOST_DATA_DIR", "/data") + return config{ + httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"), + journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"), + mailLog: envDefault("MAIL_LOG", "/var/log/mail.log"), + // Send-log retention window (spec 7.3). Non-positive/invalid falls back + // to the 90-day default inside the log-tailer. + retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90), + + dataDir: dataDir, + dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")), + manifestPath: filepath.Join(dataDir, backup.ManifestName), + setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")), + hostname: os.Getenv("SELFPOST_HOSTNAME"), + // Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a + // development-only escape hatch for testing over plain HTTP. + cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false", + // Whether this deployment also runs the 587 submission listener. The + // panel only displays it as a client connection setting; the comparison + // matches postfix-config.sh, which enables the listener on "true" alone. + submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true", + // Reverse-proxy addresses allowed to supply X-Forwarded-For for + // rate-limiting. Empty by default: an untrusted peer's + // XFF header is trivially forgeable, so it's ignored unless the panel is + // told which proxy to trust. + trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")), + // Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid + // falls back to the 7-day default inside internal/web. + sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7), + + tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), + opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), + + // Per-domain DKIM state (spec 6). The directory layout matches what + // entrypoint.sh prepares (setgid, shared `selfpost` group). + opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")), + dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"), + + // Application SASL accounts and the Postfix sender map (spec 5.1, 9), + // both under /data so they survive restarts. The SASL realm defaults to + // the server hostname so account identities line up with Postfix's SASL + // configuration; it falls back to localhost outside the container. + saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")), + saslRealm: saslRealm(), + postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), + } +} + +// saslRealm chooses the realm new SASL accounts live under. It mirrors the +// hostname Postfix's SASL layer uses so a client authenticating with a bare +// login resolves to the right account (finalised in Phase 5). +func saslRealm() string { + if r := os.Getenv("SASL_REALM"); r != "" { + return r + } + if h := os.Getenv("SELFPOST_HOSTNAME"); h != "" { + return h + } + return "localhost" +} + +func envDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// envInt reads an integer environment variable, returning def if it is unset or +// not a valid integer. +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + log.Printf("ignoring invalid %s=%q, using %d", key, v, def) + } + return def +} + +// parseTrustedProxies parses a comma-separated list of CIDRs (bare IPs are +// accepted and treated as /32 or /128). Invalid entries are logged and +// skipped rather than failing startup, matching envInt's tolerance of +// misconfiguration. +func parseTrustedProxies(raw string) []*net.IPNet { + if raw == "" { + return nil + } + var nets []*net.IPNet + for _, part := range strings.Split(raw, ",") { + cidr := strings.TrimSpace(part) + if cidr == "" { + continue + } + if !strings.Contains(cidr, "/") { + if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil { + cidr += "/32" + } else { + cidr += "/128" + } + } + _, n, err := net.ParseCIDR(cidr) + if err != nil { + log.Printf("ignoring invalid TRUSTED_PROXY_CIDR entry %q: %v", part, err) + continue + } + nets = append(nets, n) + } + return nets +} + +// 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 roles; +// a role error cancels the others and is returned so the process exits non-zero +// (letting supervisord/Docker see the failure — spec 4). +func run() error { + cfg := loadConfig() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + log.Printf("starting selfpost panel %s", buildinfo.Version) + + // Restore version guard (spec 7.5.A): if a backup was extracted into /data, + // its manifest version must match this binary before we touch the database, + // so schema/format skew between versions cannot corrupt the restored state. + // A match consumes the manifest; its absence is the normal (non-restore) case. + if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil { + return err + } + + // One database handle shared by every role. The store serialises writes + // (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer + // can all use it without stepping on each other under WAL. + st, err := store.Open(cfg.dbPath) + if err != nil { + return err + } + defer st.Close() + + var wg sync.WaitGroup + errc := make(chan error, 3) + + roles := []struct { + name string + fn func(context.Context) error + }{ + {"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }}, + {"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }}, + {"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }}, + } + + for _, r := range roles { + wg.Add(1) + go func(name string, fn func(context.Context) error) { + defer wg.Done() + if err := fn(ctx); err != nil { + errc <- fmt.Errorf("%s: %w", name, err) + } + }(r.name, r.fn) + } + + select { + case <-ctx.Done(): + log.Printf("shutdown signal received, stopping") + wg.Wait() + log.Printf("panel stopped cleanly") + return nil + case err := <-errc: + log.Printf("role failed: %v", err) + stop() // cancel ctx so the other roles wind down + wg.Wait() + return err + } +} diff --git a/cmd/selfpost-backup/main.go b/cmd/selfpost-backup/main.go new file mode 100644 index 0000000..a84434e --- /dev/null +++ b/cmd/selfpost-backup/main.go @@ -0,0 +1,73 @@ +// Command selfpost-backup produces the full persistent-state archive from inside +// the container, invoked via `docker exec` for scripted/cron backups — the CLI +// equivalent of the panel's backup button (spec 7.5.A, 11.6). +// +// By default the gzip-compressed tar is written to stdout, so the usual form is: +// +// docker exec selfpost-backup > selfpost-backup.tar.gz +// +// Use -o to write to a file instead. The resulting archive contains DKIM private +// keys, the admin password hash and SASL credentials — treat it as a secret +// (spec 7.5.A). +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "codeberg.org/mix/selfpost/internal/backup" + "codeberg.org/mix/selfpost/internal/buildinfo" +) + +func main() { + showVersion := flag.Bool("version", false, "print version and exit") + out := flag.String("o", "", "write the archive to this file instead of stdout") + flag.Parse() + + if *showVersion { + fmt.Println(buildinfo.Version) + return + } + + if err := run(*out); err != nil { + fmt.Fprintf(os.Stderr, "selfpost-backup: %v\n", err) + os.Exit(1) + } +} + +func run(outPath string) error { + dataDir := envDefault("SELFPOST_DATA_DIR", "/data") + dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")) + + w := os.Stdout + if outPath != "" { + // Backups are secret; create them owner-only. + f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer f.Close() + w = f + } + + if err := backup.Create(w, backup.Params{ + DataDir: dataDir, + DBPath: dbPath, + Version: buildinfo.Version, + }); err != nil { + return err + } + if outPath != "" { + fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (SelfPost %s)\n", outPath, buildinfo.Version) + } + return nil +} + +func envDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..de7dadb --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,31 @@ +# Copy to .env next to docker-compose.yml and fill in. + +# Required — the container refuses to start without it. Hostname used both for +# the panel's TLS vhost and for Postfix's SASL realm (SASL_REALM defaults to +# this) and myhostname; must also match your PTR record. Must match the +# certificate your reverse proxy obtains. Bare FQDN only — no scheme or port. +SELFPOST_HOSTNAME=mail.example.com + +# Set to true to also enable RFC 6409 submission (587/STARTTLS) alongside the +# primary 465/smtps listener. +SUBMISSION_ENABLE=false + +# Level-1 backstop rate limit (anvil) — see README "Rate limiting". +RATE_LIMIT_MESSAGES_PER_IP=100 +RATE_LIMIT_WINDOW_SECONDS=3600 + +# Days of send-log history kept before the background sweep deletes rows. +SEND_LOG_RETENTION_DAYS=90 + +# Sliding idle timeout for the panel login session, in days. No absolute cap: +# an admin who keeps coming back stays signed in indefinitely. Polling by the +# monitoring screens does not count as activity, only navigation/actions do. +PANEL_SESSION_IDLE_DAYS=7 + +# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to +# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless +# you know the exact address of your reverse proxy — trusting the wrong +# source lets a client spoof its rate-limit key. Behind the default Apache +# host-network setup this is typically the Docker bridge gateway, e.g. +# TRUSTED_PROXY_CIDR=172.18.0.1 + diff --git a/deploy/apache/selfpost-vhost.conf b/deploy/apache/selfpost-vhost.conf new file mode 100644 index 0000000..556d6a1 --- /dev/null +++ b/deploy/apache/selfpost-vhost.conf @@ -0,0 +1,41 @@ +# Example host Apache vhost for the SelfPost panel (spec 10.3, default +# scenario). Install on the host running Apache — NOT inside the SelfPost +# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled +# (`a2enmod ssl proxy proxy_http`). +# +# Certificates: obtain with the Apache certbot plugin against THIS vhost +# (`certbot --apache -d mail.example.com`). Certbot edits this file in place +# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a +# :80 -> :443 redirect vhost, and renews in the background via its own timer. +# The resulting PEM files land at +# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host +# — bind-mount that directory read-only into the SelfPost container (see +# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix +# can present the very same certificate on 465/587. One certificate, two +# consumers (spec 10 p.4) — no separate cert just for the panel. + + + ServerName mail.example.com + + # Filled in by `certbot --apache`; shown here for clarity. + # SSLEngine on + # SSLCertificateFile /etc/letsencrypt/live/mail.example.com/fullchain.pem + # SSLCertificateKeyFile /etc/letsencrypt/live/mail.example.com/privkey.pem + + ProxyPreserveHost On + ProxyPass "/" "http://127.0.0.1:8080/" + ProxyPassReverse "/" "http://127.0.0.1:8080/" + + # The panel is plain HTTP behind the proxy; it never terminates TLS + # itself (spec 10 p.2). Cookies are still marked Secure by the panel + # (spec 7.6.6) because the browser only ever sees this HTTPS vhost. + + + + ServerName mail.example.com + # certbot's http-01 challenge needs this on plain :80; everything else + # redirects to HTTPS once certbot adds the RewriteRule/Redirect block. + RewriteEngine On + RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L] + diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..99757ce --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,3 @@ +mail.example.com { + reverse_proxy selfpost:8080 +} diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml new file mode 100644 index 0000000..50c8088 --- /dev/null +++ b/deploy/caddy/docker-compose.caddy.yml @@ -0,0 +1,49 @@ + +# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully +# automatic ACME with no separate certbot container. Caddy writes certificates +# as plain PEM under its own data directory, which this fragment bind-mounts +# to the host so SelfPost can read the same files. +# +# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes +# the ACME CA's name as a path segment, e.g. +# /caddy/certificates/acme-v02.api.letsencrypt.org-directory//.crt +# This has been stable across recent Caddy releases but is an internal +# storage detail, not a documented public API — VERIFY the exact path against +# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`) +# before wiring it into the volume mount below. +# +# Merge with the base file — run this from the deploy/ directory (Compose +# resolves every relative path in both files against the directory of the +# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...): +# +# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d + +services: + selfpost: + ports: !override + - "465:465" + - "587:587" + environment: + # Caddy names certificate files after the hostname, not + # fullchain.pem/privkey.pem like certbot — override the base file's + # paths to match. + TLS_CERT_FILE: /etc/postfix/tls/.crt + TLS_KEY_FILE: /etc/postfix/tls/.key + volumes: !override + - ./data:/data + # Replace with your actual mail/panel hostname, matching + # the Caddyfile below and SELFPOST_HOSTNAME. + - ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/:/etc/postfix/tls:ro + + caddy: + image: caddy:2 + restart: unless-stopped + depends_on: + - selfpost + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/caddy-data:/data + - ./caddy/caddy-config:/config diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..03ff977 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,101 @@ + +# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5). +# +# This file only runs SelfPost itself. Apache is assumed to already be +# installed on the HOST (the target audience for this project typically runs +# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel; +# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready +# vhost fragment, and the "Reverse proxy" section of ../../README.md for the +# certbot steps that produce the PEM files this compose file mounts. +# +# Usage: +# 1. Copy this file (and .env.example as .env) next to your own ./data and +# ./certs directories, or adjust the paths below. +# 2. Fill in .env (hostname, at least one strong TLS_CERT/KEY path). +# 3. docker compose up -d +# +# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup +# restore compares the manifest version against the running binary's version, +# so ":latest" would make that check meaningless. Bump the tag deliberately +# when you want to upgrade. + +services: + selfpost: + image: ghcr.io/mixeme/selfpost:0.1.0 + restart: unless-stopped + environment: + SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" + # Path Postfix reads inside the container — matches the certs bind mount + # below. Point these at your reverse-proxy's PEM output (spec 10 p.2). + TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem + TLS_KEY_FILE: /etc/postfix/tls/privkey.pem + # Set to true to also publish RFC 6409 submission (587/STARTTLS) + # alongside the primary 465/smtps listener (spec 5). + SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" + # Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app + # limits (level 2) are configured later from the panel itself. + RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}" + RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}" + # How long the send log keeps rows before the background sweep deletes + # them (spec 7.3, 9) — the main driver of /data growth over time. + SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}" + # Sliding idle timeout for the panel login session, in days (spec 7.6.6). + PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}" + volumes: + # All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, + # sasldb2, sender map, setup token. Back this up (panel button or the + # selfpost-backup CLI) before you touch it directly. + - ./data:/data + # Read-only: SelfPost only ever reads certificates, never manages them + # (spec 10 p.2). Point this at wherever your reverse-proxy/certbot + # writes PEM files, e.g. /etc/letsencrypt on the host. + - ./certs:/etc/postfix/tls:ro + ports: + # 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are + # published directly — mail traffic bypasses Apache entirely, it only + # ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself + # (8080) is intentionally NOT published here: Apache reaches it over + # the host network at 127.0.0.1:8080 (see the vhost fragment), so the + # panel is never directly reachable from the internet without TLS. + - "465:465" + - "587:587" + - "127.0.0.1:8080:8080" + # Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as + # root very briefly to fix /data ownership and normalise permissions + # under the shared `selfpost` group (see build/entrypoint.sh) before + # supervisord drops the panel to an unprivileged user — so this cannot be + # `user: panel` or a fully read-only rootfs without breaking that startup + # self-healing. What IS applied: no privilege escalation past what the + # image already grants, and every Linux capability dropped except the + # small set the root startup phase and Postfix/OpenDKIM genuinely need: + # - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024; + # - CHOWN — entrypoint re-owns /data (bind mount) to `panel`; + # - FOWNER — entrypoint then chmods those now panel-owned /data + # dirs/files while still root (owner-check bypass); + # - FSETID — set the setgid bit (2750) on the shared /data dirs + # when the process gid differs from the dir's group; + # - SETUID/SETGID — supervisord drops the panel to the unprivileged + # `panel` user; Postfix switches to its own users; + # - DAC_OVERRIDE — cross-user file access within the `selfpost` group. + # - KILL — supervisord (root) sends SIGUSR1 to opendkim (a + # different uid) on domain add/remove to reload its + # KeyTable/SigningTable; without it os.kill() fails + # with EPERM even though the caller is root, because + # the kernel's signal permission check for + # cross-uid kill() still consults CAP_KILL. + # FOWNER/FSETID are required by build/entrypoint.sh's permission + # self-healing; without them chmod fails with EPERM and the container + # crash-loops on start. + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + - CHOWN + - FOWNER + - FSETID + - SETUID + - SETGID + - DAC_OVERRIDE + - KILL diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml new file mode 100644 index 0000000..83e7892 --- /dev/null +++ b/deploy/nginx/docker-compose.nginx.yml @@ -0,0 +1,55 @@ + +# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host +# exactly like the Apache+certbot scenario — nginx and certbot are close +# cousins here, both writing/reading plain PEM under /etc/letsencrypt. +# +# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it +# adds an nginx + certbot pair and removes the panel's host port publish (nginx +# takes over 80/443 and proxies to the panel over the compose network +# instead). Merge it with the base file — run this from the deploy/ directory +# (Compose resolves every relative path in both files against the directory of +# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...): +# +# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d +# +# First-run certificate issuance (webroot method, before nginx has a cert to +# serve — run once): +# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \ +# run --rm certbot certonly --webroot -w /var/www/certbot \ +# -d mail.example.com --email you@example.com --agree-tos --no-eff-email + +services: + selfpost: + ports: !override + - "465:465" + - "587:587" + # No host publish for 8080 here: nginx reaches it over the compose + # network at selfpost:8080 instead (see nginx.conf.example). + volumes: !override + - ./data:/data + # Same host directory certbot below writes into — plain bind mount, + # no named volume, so the PEM files are as directly inspectable as in + # the Apache scenario (spec 10.3). + - ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro + + nginx: + image: nginx:1.27 + restart: unless-stopped + depends_on: + - selfpost + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf.example:/etc/nginx/conf.d/selfpost.conf:ro + - ./nginx/certbot-etc:/etc/letsencrypt:ro + - ./nginx/certbot-www:/var/www/certbot:ro + + certbot: + image: certbot/certbot:latest + volumes: + - ./nginx/certbot-etc:/etc/letsencrypt + - ./nginx/certbot-www:/var/www/certbot + # Renewal twice a day is certbot's own recommended cadence; it no-ops + # until a certificate is within its renewal window. + entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done' diff --git a/deploy/nginx/nginx.conf.example b/deploy/nginx/nginx.conf.example new file mode 100644 index 0000000..a5e9ee3 --- /dev/null +++ b/deploy/nginx/nginx.conf.example @@ -0,0 +1,32 @@ +# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel +# over the compose network; mail (465/587) is published directly by the +# selfpost container and never touches nginx. + +server { + listen 80; + server_name mail.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name mail.example.com; + + ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem; + + location / { + proxy_pass http://selfpost:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml new file mode 100644 index 0000000..79edc9c --- /dev/null +++ b/deploy/traefik/docker-compose.traefik.yml @@ -0,0 +1,52 @@ + +# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain +# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost +# needs separate PEM files for Postfix, so an extraction step is required; see +# extract-cert.sh in this directory and the cron/systemd-timer note below. +# +# Merge with the base file — run this from the deploy/ directory (Compose +# resolves every relative path in both files against the directory of the +# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...): +# +# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d + +services: + selfpost: + ports: !override + - "465:465" + - "587:587" + volumes: !override + - ./data:/data + # Populated by extract-cert.sh from traefik's acme.json — see below. + - ./traefik/extracted-certs:/etc/postfix/tls:ro + labels: + # Traefik only proxies the panel; it never sees the mail ports. + # These live on the selfpost service (not traefik's) because the + # docker provider reads routing labels off the container being routed + # to, not off traefik itself. + - traefik.enable=true + - traefik.http.routers.selfpost.rule=Host(`mail.example.com`) + - traefik.http.routers.selfpost.entrypoints=websecure + - traefik.http.routers.selfpost.tls.certresolver=le + - traefik.http.services.selfpost.loadbalancer.server.port=8080 + + traefik: + image: traefik:v3.1 + restart: unless-stopped + depends_on: + - selfpost + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --certificatesresolvers.le.acme.email=you@example.com + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./traefik/letsencrypt:/letsencrypt diff --git a/deploy/traefik/extract-cert.sh b/deploy/traefik/extract-cert.sh new file mode 100644 index 0000000..b2c04fa --- /dev/null +++ b/deploy/traefik/extract-cert.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json +# (spec 10.3: "Traefik — сертификаты в acme.json, потребуется шаг извлечения +# PEM"). Run this on the host, after Traefik has issued or renewed the +# certificate, and again on a schedule (cron/systemd timer) since acme.json +# is not itself watched by SelfPost/Postfix. +# +# Requires jq. Usage: ./extract-cert.sh +set -eu + +ACME_JSON="${1:?path to acme.json}" +DOMAIN="${2:?domain name, e.g. mail.example.com}" +OUT_DIR="${3:?output directory, e.g. ./extracted-certs}" + +mkdir -p "$OUT_DIR" + +jq -r --arg domain "$DOMAIN" ' + .le.Certificates[] + | select(.domain.main == $domain) + | .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem" + +jq -r --arg domain "$DOMAIN" ' + .le.Certificates[] + | select(.domain.main == $domain) + | .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem" + +chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem" +echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem" diff --git a/docs/assets/icon.png b/docs/assets/icon.png new file mode 100644 index 0000000..f21a361 Binary files /dev/null and b/docs/assets/icon.png differ diff --git a/docs/assets/selfpost-icon-16.svg b/docs/assets/selfpost-icon-16.svg new file mode 100644 index 0000000..e6a77e7 --- /dev/null +++ b/docs/assets/selfpost-icon-16.svg @@ -0,0 +1,7 @@ + + + + +SP + \ No newline at end of file diff --git a/docs/assets/selfpost-icon.svg b/docs/assets/selfpost-icon.svg new file mode 100644 index 0000000..365fee4 --- /dev/null +++ b/docs/assets/selfpost-icon.svg @@ -0,0 +1,8 @@ + + + + +Self +Post + \ No newline at end of file diff --git a/docs/assets/selfpost-proof.html b/docs/assets/selfpost-proof.html new file mode 100644 index 0000000..11a688f --- /dev/null +++ b/docs/assets/selfpost-proof.html @@ -0,0 +1,246 @@ + + + +SelfPost — знак на утверждение + + + +
+ +
+

на утверждение · кирпич #7A3B2E

+

SelfPost

+

Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.

+
+ +
+

Эталон

+
+ + + +SelfPost + +SELF-HOSTED SMTP RELAY +
+
+
#7A3B2E · печать
+
#F3EDE1 · поле
+
#F6EDE2 · краска по полю
+
#12161C · контур 0.8
+
контраст 7,3:1 · подзаголовок 4,6:1
+
+
+ +
+

README на GitHub

+
+
+
mixeme / selfpost
+
+ + + +SelfPost + +SELF-HOSTED SMTP RELAY + +

SelfPost

+

Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.

+
light
Тёплое поле отделяет марку от белого GitHub, контур ловит край.
+
+
mixeme / selfpost
+
+ + + +SelfPost + +SELF-HOSTED SMTP RELAY + +

SelfPost

+

Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.

+
dark
Тот же файл: на тёмном работает поле, контур просто исчезает.
+
+
+ +
+

Шапка панели

+
+
+ + + +SelfPost + +
+
+
example.org   14:22:07   delivered
+
shop.example.org   14:21:55   bounced
+
example.org   14:19:03   delivered
+
+
+
проверка на конфликт
Ровно тот экран, ради которого мы отбраковывали краски: кирпич рядом с зелёным «delivered» и красным «bounced» не читается ни как один из них.
+
+ +
+

Фавикон и аватар

+
+
+
+
+ + + +SP +SelfPost — Domains
+
+ + + +Self +Post +docs
+
+
https://mail.example.org/domains
+
16 px
Слева версия для мелкого размера: зубцы крупнее и реже, вместо двух строк — SP. Справа для сравнения полная иконка в том же размере: превращается в кашу.
+
+
+
+ + + +
+
80 / 48 / 32 px
Аватар организации на Codeberg и GitHub. Порог перехода на версию SP — 32 px.
+
+
+
+ +
+

Размеры и охранное поле

+
+
+ + + +SelfPost + +SELF-HOSTED SMTP RELAY +полная · от 280 px
+
+ + + +SelfPost +компактная · от 100 px
+
+ + + +Self +Post +иконка · до 100 px
+
+
Числа не на глаз. Полная версия живёт до 280 px по ширине — ниже подзаголовок опускается под 6 px и перестаёт читаться. Компактная — до 100 px. Охранное поле (пунктир) равно трём шагам зубцовки, ≈ 21 % высоты марки: мера взята из самого знака, а не назначена.
+ +
+ +
+

Чего не делать

+
Не растягиватьПропорции 1:2,3 — часть знака: зубцовка рассчитана на них и при неравном масштабе зубцы становятся овалами.
Не менять краскуКирпич выбран по контрасту (7,3:1) и по тому, что он не спорит со статусами панели. Другой цвет ломает оба расчёта.
Не класть на пёстроеВолосяной контур рассчитан на ровный фон. На фото и градиенте край пропадает.
Не добавлять рамок и тенейМарка держит край зубцовкой и контуром. Всё остальное — уже наклейка, а не марка.
Не набирать подзаголовок мельче 6 pxНиже полная версия перестаёт читаться — там уже компактная.
+
+ +
+

Файлы

+
+ docs/brand/selfpost-stamp.svg — эталон, README, документация
+ docs/brand/selfpost-stamp-compact.svg — шапка панели
+ docs/brand/selfpost-icon.svg — аватар, фавикон от 32 px
+ docs/brand/selfpost-icon-16.svg — фавикон 16–32 px +
+
Осталось одно действие. В контейнере нет ни IBM Plex, ни сети, поэтому текст в файлах остался текстом. Перед коммитом его надо перевести в контуры, иначе на машине без Plex набор поедет:
inkscape --actions="select-all;object-to-path;export-filename:out.svg;export-do" in.svg
Пока в файлах прописан фолбэк 'IBM Plex Sans','Helvetica Neue',sans-serif — не спасёт вёрстку, но не даст ей развалиться совсем.
+
+ +
\ No newline at end of file diff --git a/docs/assets/selfpost-stamp-compact.svg b/docs/assets/selfpost-stamp-compact.svg new file mode 100644 index 0000000..454ccb4 --- /dev/null +++ b/docs/assets/selfpost-stamp-compact.svg @@ -0,0 +1,7 @@ + + + + +SelfPost + \ No newline at end of file diff --git a/docs/assets/selfpost-stamp.svg b/docs/assets/selfpost-stamp.svg new file mode 100644 index 0000000..edc7ed1 --- /dev/null +++ b/docs/assets/selfpost-stamp.svg @@ -0,0 +1,9 @@ + + + + +SelfPost + +SELF-HOSTED SMTP RELAY + \ No newline at end of file diff --git a/docs/documentation-plan.md b/docs/documentation-plan.md new file mode 100644 index 0000000..8c5a858 --- /dev/null +++ b/docs/documentation-plan.md @@ -0,0 +1,248 @@ +# План документации SelfPost + +**Зачем этот файл.** Документация — часть поставки (ТЗ раздел 11, пп. 5, 7, 9), +а не сопроводительный текст. Перед тегом релиза она обязана описывать **то, что +делает код**, а не то, что задумывалось: расхождение здесь — такой же дефект, +как несоответствие ТЗ, только обнаруживает его пользователь на своём проде. +План описывает, из чего состоит пакет, как он сверяется с кодом, что уже +разошлось (первый проход выполнен, результаты ниже) и что с этим делать. + +**Место в общем плане:** документационный проход идёт вместе с **D.5** +([implementation-plan.md](implementation-plan.md)) — до тега следующего релиза, +после B.1–B.3 и C.4, потому что именно они изменили поведение, которое README +описывает (сессии, ротация лога, обязательность `SELFPOST_HOSTNAME`). + +**Модель:** документация → Sonnet (правило [progress.md](progress.md)). +Исключение — **D6** (HEALTHCHECK/эндпоинт мониторинга) и формулировки про +`TRUSTED_PROXY_CIDR`: инфра/безопасность → Opus. + +--- + +## 1. Состав пакета + +### Поставляемое пользователю (обязательно по ТЗ) + +| Артефакт | Требование | Состояние | +|---|---|---| +| [README.md](../README.md) | ТЗ 11 п. 7: установка, требования к площадке, per-domain DNS, прогрев IP, **эксплуатация**, бэкап/восстановление и экспорт/импорт домена (оба файла — секреты), репозиторий (Codeberg основной / GitHub зеркало), откуда образ (`ghcr.io`), фиксированный тег, минимальные требования к машине, лицензия в 2–3 предложениях | Есть всё, **кроме раздела «эксплуатация»**; см. находки 1–4, 10 | +| [LICENSE](../LICENSE) | ТЗ 11 п. 9: полный текст AGPL-3.0 | Полный текст на месте, правок не требует | +| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + фрагменты прокси | ТЗ 11 п. 5, ТЗ 10 п. 3: Apache основной, nginx/Caddy/Traefik альтернативами; для каждого — что монтируется и в какие пути | Все четыре есть ([apache](../deploy/apache/), [nginx](../deploy/nginx/), [caddy](../deploy/caddy/), [traefik](../deploy/traefik/)), в README сведены таблицей; см. находки 7, 8 | +| [deploy/.env.example](../deploy/.env.example) | Пользовательские переменные с пояснениями | Есть 6 переменных; полного справочника нет — находка 3 | +| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog, запись на каждом осмысленном шаге | Ведётся; `[Unreleased]` наполнен B.1–B.3, C.4 | + +### Рабочие документы проекта (не поставка, но обязаны быть верны) + +[specification.md](specification.md) — ТЗ v1.0, источник истины по требованиям. +[implementation-plan.md](implementation-plan.md) — открытые вопросы и линия 2.x. +[progress.md](progress.md) — живой трекер, читается первым после `/clear`. +[security.md](security.md) — принятые риски. Этот файл — план по документации. + +**Границы:** отдельные `CONTRIBUTING.md`, `docs/architecture.md`, man-страницы и +сайт документации ТЗ **не требует** и в объём v1.x не входят. Dev-петля (сборка +и тесты на dev-сервере) остаётся в `progress.md` и в память проекта — выносить +её в поставляемую документацию не нужно. + +--- + +## 2. Метод сверки с кодом + +Правило: у каждого утверждения в документации есть **ровно один источник истины +в дереве**, и сверка идёт от кода к тексту (что код делает → сказано ли об +этом), а не наоборот — иначе не видно того, что забыли описать. + +| Класс утверждений | Источник истины | +|---|---| +| Переменные окружения, значения по умолчанию | `loadConfig`/`envDefault`/`envInt` — [cmd/panel/main.go:80](../cmd/panel/main.go:80)–159; `${VAR:-default}` в [build/](../build/) (`postfix-config.sh`, `postfix-wrapper.sh`, `postfix-cert-reload.sh`, `logrotate-loop.sh`, `entrypoint.sh`) | +| Поведение почтового тракта (порты, TLS, SASL, анти-relay, лимиты, milter-цепочка) | [build/postfix-config.sh](../build/postfix-config.sh) | +| Экраны и действия панели (что вообще можно делать в эксплуатации) | таблица маршрутов [internal/web/web.go:133](../internal/web/web.go:133)–190 | +| Бэкап/восстановление, экспорт/импорт домена, проверка версии | [internal/backup/backup.go](../internal/backup/backup.go), [cmd/selfpost-backup/main.go](../cmd/selfpost-backup/main.go) | +| Сессии, вход, смена пароля | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go), [internal/web/handlers_account.go](../internal/web/handlers_account.go) | +| Ротация лога, периодический reload, интервалы | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) | +| Деплой: тег образа, порты, монтирования, capabilities | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) | +| Требования, а не реализация (что вообще должно быть описано) | [specification.md](specification.md) разделы 9, 10, 11 | + +Порядок прохода: сначала перечислить фактическое (env-ключи, маршруты, +`postconf`-настройки), потом искать каждый пункт в README — так находится и +неверное, и **отсутствующее**. + +--- + +## 3. Результаты первого прохода (выполнен) + +Сверено: env-переменные, маршруты панели, конфигурация Postfix, бэкап/CLI, +сессии, ротация, compose/Dockerfile. Ниже — всё найденное, по убыванию +приоритета. Нумерация сквозная, на неё ссылаются задачи в разделе 4. + +**Высокий (пробел против ТЗ или вводит в заблуждение)** + +1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7 + ([specification.md:427](specification.md)). В README нет ни слова про + `/status` (проверки PTR/hostname), `/sendlog` (журнал отправки, фильтры), + `/queue` (очередь Postfix), `/logtail` (хвост `mail.log`), `/reload`, + `/account`, `/backup` — при том что всё это реализовано + ([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и + процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел + «Fixed image tag» на неё намекает. +2. **Битая ссылка + недокументированные лимиты.** + [deploy/.env.example:13](../deploy/.env.example:13) отсылает к разделу README + «Rate limiting», которого в README нет. Сам двухуровневый лимит не описан + нигде для пользователя: L1 — `smtpd_client_message_rate_limit` + + `anvil_rate_time_unit` ([build/postfix-config.sh:110](../build/postfix-config.sh:110)), + L2 — per-domain/per-app из панели + ([internal/web/web.go:165](../internal/web/web.go:165), + [:169](../internal/web/web.go:169)), с записью `rejected` в журнал. +3. **Нет справочника переменных окружения.** В `.env.example` шесть штук; код + читает заметно больше. Панель: `SELFPOST_DATA_DIR`, `SELFPOST_DB_PATH`, + `SELFPOST_SETUP_TOKEN_FILE`, `PANEL_HTTP_ADDR`, `JOURNAL_MILTER_SOCKET`, + `MAIL_LOG`, `PANEL_COOKIE_SECURE`, `TLS_CERT_FILE`, `OPENDKIM_SOCKET`, + `OPENDKIM_DIR`, `DKIM_SELECTOR_DEFAULT`, `SASL_DB_PATH`, `SASL_REALM`, + `POSTFIX_DIR` ([cmd/panel/main.go:80](../cmd/panel/main.go:80)–127). + Обвязка: `TLS_KEY_FILE`, `POSTFIX_SENDER_LOGIN_MAPS`, + `MILTER_CONNECT_TIMEOUT`/`COMMAND`/`CONTENT` (15s/15s/30s, + [build/postfix-config.sh:133](../build/postfix-config.sh:133)), + `MILTER_WAIT_TIMEOUT`, `TLS_RELOAD_INTERVAL_SECONDS` (86400, + [build/postfix-cert-reload.sh:14](../build/postfix-cert-reload.sh:14)), + `LOGROTATE_INTERVAL_SECONDS` (21600, + [build/logrotate-loop.sh:17](../build/logrotate-loop.sh:17)). + Отдельно: **`TRUSTED_PROXY_CIDR`** — переменная с последствиями для + безопасности (доверие `X-Forwarded-For` при rate-limit логина), описана + только в `.env.example`, в README её нет вообще. + *Решение, которое надо принять в задаче D2:* какие переменные публичные + (таблица в README), а какие внутренние (достаточно комментария в коде) — + документировать все 20+ вредно, они станут «поддерживаемым интерфейсом». +4. **Оговорка ТЗ 9 про прямой `tar` не отражена.** + [specification.md:375](specification.md) требует описать оба пути: «бэкап на + лету — панель/CLI» и «прямой `tar` директории безопасен, если контейнер + остановлен». В README ([Backup, restore…](../README.md)) есть только первый, + поэтому у читателя нет ответа на очевидный вопрос «а можно просто + заархивировать `./data`?». + +**Средний (стало неверным после последних фаз)** + +5. **Статус-баннер устарел.** [README.md:12](../README.md:12)–15: «under active + development» и ссылка на `implementation-plan.md` как на «phased build plan» + — фазы 0→14 закрыты, план теперь про открытые вопросы и линию 2.x. Перед + тегом релиза баннер переформулировать (или снять), ссылки уточнить. +6. **`implementation-plan.md` разошёлся с кодом.** Пункт B.1 говорит: «смена + пароля завершает **все** сессии, включая ту, из которой её делают → редирект + на `/login`». Код делает иначе — гасит все, **кроме** текущей + ([internal/store/sessions.go:95](../internal/store/sessions.go:95) + `DeleteOtherSessions`, [internal/web/session.go:131](../internal/web/session.go:131)), + и панель так и пишет («Any other signed-in sessions were signed out», + [internal/web/handlers_account.go:49](../internal/web/handlers_account.go:49)). + README здесь **верен**. Расхождение в плане — зафиксировать как осознанное + изменение при реализации, а не молча переписать. +7. **Комментарий в compose вводит в заблуждение.** + [deploy/docker-compose.yml:14](../deploy/docker-compose.yml:14) велит + «fill in .env (hostname, at least one strong TLS_CERT/KEY path)», но + `TLS_CERT_FILE`/`TLS_KEY_FILE` захардкожены в самом файле + ([:30](../deploy/docker-compose.yml:30)–31) и в `.env.example` отсутствуют — + настраивается на деле **bind mount** `./certs`, а не переменные. +8. **Порт 587 публикуется всегда** ([deploy/docker-compose.yml:61](../deploy/docker-compose.yml:61)), + хотя listener появляется только при `SUBMISSION_ENABLE=true` + ([build/postfix-config.sh:151](../build/postfix-config.sh:151)). Безвредно + (слушать некому), но выглядит как лишний открытый порт — одна строка + пояснения в README/compose снимает вопрос. + +**Низкий / требует решения, а не только текста** + +9. **`/healthz` есть, `HEALTHCHECK` нет.** Эндпоинт реализован + ([internal/web/web.go:133](../internal/web/web.go:133)), в + [build/Dockerfile](../build/Dockerfile) объявления `HEALTHCHECK` нет (пробел + отмечен и в плане, п. B.3). Решить в D6: либо задокументировать `/healthz` + как точку внешнего мониторинга, либо добавить `HEALTHCHECK` в образ (это уже + код + строка в CHANGELOG). Документировать «здоровье контейнера» до принятия + решения нельзя — получится обещание, которого образ не даёт. +10. **Из B.1–B.3/C.4 в README попала только обязательность `SELFPOST_HOSTNAME`.** + Не описаны: сессия переживает рестарт и живёт по скользящему сроку + бездействия (`PANEL_SESSION_IDLE_DAYS`, «вкладка с автообновлением вход не + продлевает» — контринтуитивно и заслуживает строки), ротация `mail.log` + (14 файлов, проверка каждые 6 ч, суточный `postfix reload`) — README + упоминает только «kept 14 days in-image» в требованиях к машине. +11. **Мелочи, без правки кода:** Quick start тянет файлы с + `raw.githubusercontent.com` (зеркало), хотя основной репозиторий — + Codeberg; тег образа `1.0.0` в compose ([:24](../deploy/docker-compose.yml:24)) + придётся бампить в тот же коммит, что и релиз; пустой каталог `docs/logo`. + +**Проверено и расхождений не найдено** (важно не переделывать): требования к +площадке; DNS-раздел (совпадает с тем, что реально проверяет `/status` и +DNS-карточка домена); прогрев IP; смысл фиксированного тега; проверка версии при +восстановлении — README описывает её точно так, как ведёт себя `CheckRestore` +([internal/backup/backup.go](../internal/backup/backup.go)); форма вызова +`docker exec … selfpost-backup > …` ([cmd/selfpost-backup/main.go:7](../cmd/selfpost-backup/main.go:7)); +таблица reverse-proxy и требование пропускать `Host`; секретность архива и +экспорта домена; лицензия; ретенция журнала отправки (90 дней) и то, что она — +основной драйвер роста `/data`. + +--- + +## 4. Задачи + +Порядок — как перечислены; D1–D3 самые крупные. Каждая заканчивается записью в +`[Unreleased]` [CHANGELOG.md](../CHANGELOG.md) и коммитом. + +**D1. README: раздел «Operations» + «Rate limiting».** Закрывает находки 1, 2 и +часть 10. Что описать: экраны панели и зачем они нужны (`/status` и что именно +он проверяет; журнал отправки со статусами `queued/sent/rejected`; очередь; +хвост `mail.log`; `/reload`; смена учётных данных); двухуровневый лимит с +именами переменных L1 и указанием, что L2 задаётся в панели per-domain/app; +процедура апгрейда версии; поведение сессии (скользящий срок, опросы не +продлевают, смена пароля гасит остальные). Тон и объём — как в существующих +разделах: практика, а не пересказ ТЗ. +*Готово, когда:* каждый маршрут из [internal/web/web.go:152](../internal/web/web.go:152)–188, +кроме HTMX-фрагментов, либо описан, либо сознательно опущен; ссылка +«Rate limiting» из `.env.example` ведёт в существующий якорь. + +**D2. Справочник переменных окружения.** Закрывает находку 3. Сначала решение о +границе публичного набора, затем таблица (имя, назначение, дефолт, где +задаётся) в README + синхронизация `.env.example` и комментариев в compose. +`TRUSTED_PROXY_CIDR` описать с прямым предупреждением: неверное значение +позволяет подделать ключ rate-limit'а логина. +*Готово, когда:* каждый ключ из `loadConfig` и из `${VAR:-…}` в `build/*.sh` +попал либо в таблицу, либо в явный список «внутренние, не интерфейс»; дефолты в +таблице совпадают с кодом дословно. + +**D3. Бэкап: дописать путь «остановленный контейнер + `tar`».** Закрывает +находку 4 — по формулировке [specification.md:375](specification.md), с явным +«на живом контейнере так делать не надо, потому что SQLite в WAL». Заодно +упомянуть, что `manifest.json` после успешного восстановления потребляется. + +**D4. Точечные правки.** Находки 5, 7, 8, 11: баннер статуса и ссылки; комментарий +в шапке compose про `.env`; строка про 587; бамп тега образа (делается в +релизном коммите, не раньше). + +**D5. Синхронизация внутренних документов.** Находка 6 — поправить B.1 в +`implementation-plan.md` (с пометкой, что решение изменилось при реализации и +почему), заодно перечитать `progress.md` на предмет утверждений, которые код уже +опроверг. Не переписывать историю в CHANGELOG. + +**D6. Решение по `HEALTHCHECK`/`/healthz` (Opus).** Находка 9: либо строка в +`Dockerfile` + описание, либо только описание эндпоинта. Влияет на код образа — +поэтому отдельной задачей и не на Sonnet. + +**D7. Регресс-защита (см. раздел 5).** + +--- + +## 5. Чтобы не разошлось снова + +1. **Правило шага:** изменение, добавляющее/переименовывающее env-переменную, + маршрут панели или наблюдаемое поведение почтового тракта, закрывается + только вместе с правкой README/`.env.example` — в том же коммите, наравне с + записью в CHANGELOG (протокол закрытия шага в [progress.md](progress.md)). +2. **Дешёвая машинная проверка (D7):** тест или скрипт, сверяющий множество + ключей `loadConfig` со списком, объявленным в документации, и падающий на + новом недокументированном ключе. Ловит самый частый класс расхождений + (находка 3) без ручного прохода. Границу «внутренних» ключей задать явным + списком-исключением в самом тесте. +3. **Перед каждым тегом** — короткий проход по разделу 2 этого файла (семь + источников истины), а не полная ревизия текста. + +--- + +## 6. Гейт релиза + +Документационный проход — часть того же гейта, что e2e (C.4) и ревизия +безопасности (D.5): **D1–D6 закрыты до тега**. D7 — желателен, но тег не +блокирует. Находки, обнаруженные позже, дописываются сюда, а не исправляются +молча: этот файл — журнал состояния документации, а не одноразовый список дел. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..29c567c --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,174 @@ +# План реализации: SelfPost + +**Статус:** выполненные фазы здесь не описываются — текущее состояние в +[progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md) +и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы +для согласования и опциональная линия 2.x.x. + +**Основа:** [specification.md](specification.md) v1.0. + +--- + +## Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0) + +Ниже — то, что **выходит за букву ТЗ**, но заслуживает решения перед тем, как +считать v1.0 «финальным». Ничего из этого **не является дефектом +соответствия**; это осознанные компромиссы и потенциальные улучшения. Каждый +пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть», +принимается пользователем. + +**Раздел A (принятые риски безопасности) переехал в +[security.md](security.md)** — риск не задача, а решение, и в плане несделанной +работы ему делать нечего. Буквы разделов и сквозная нумерация пунктов ниже +оставлены как были: на них ссылаются `progress.md`, коммиты и обсуждения. + +### B. Надёжность и эксплуатация + +1. **Сессии — решено: хранить в БД, скользящий срок бездействия.** Прежнее поведение (только в памяти, absolute TTL 12 ч) заменяется на: + - таблица `sessions` в SQLite (миграция `0002`) — вход переживает рестарт, редеплой и восстановление из полного бэкапа. В БД лежит SHA-256 от токена, а не сам токен: украденный файл БД или архив бэкапа во вход не превращается, зато браузер, у которого есть исходная cookie, работает и после восстановления; + - срок — **скользящий, 7 дней бездействия**, задаётся `PANEL_SESSION_IDLE_DAYS` (целое число дней, как `SEND_LOG_RETENTION_DAYS`). Абсолютного потолка нет **сознательно**: у админа, заходящего регулярно, сессия живёт неограниченно долго; + - **мониторинговые опросы сессию не продлевают.** Четыре фрагмента (`/status/fragment`, `/queue/body`, `/logtail/body`, `/sendlog/rows`) опрашивают сервер `every 5s`; продлевай их — и забытая открытая вкладка держала бы вход вечно, а «7 дней бездействия» означало бы «7 дней без открытой вкладки». Активностью считается переход по странице или действие, то есть всё, кроме GET-запросов с заголовком `HX-Request`; + - `Max-Age` cookie равен сроку и переставляется ровно тогда, когда продлевается строка в БД (запись в БД — не чаще раза в час, чтобы не писать на каждый клик); + - смена пароля завершает **все** сессии, включая ту, из которой её делают → редирект на `/login`. + + Известное свойство, вытекающее из хранения в БД: восстановление старого бэкапа возвращает и строки сессий, поэтому сессия, разлогиненная уже после снятия бэкапа, оживёт — если её браузер всё ещё хранит cookie и срок не истёк. +2. **Ротация `mail.log` — решено: отказаться от `copytruncate` в пользу «переименовать + `postfix reload`».** Прежняя формулировка («несколько строк мониторинга, приемлемо как известное свойство») занижала проблему: тот же тейлер, что рисует экран лога, сверяет и финальные статусы доставки — `UpdateStatus` вызывается **только** из [internal/logtail](../internal/logtail/logtail.go), больше ниоткуда. Значит потерянная строка `status=sent` — это строка журнала отправки, навсегда застрявшая в `queued`, то есть тихая порча данных, а не пробел в мониторинге. Окон потери при `copytruncate` два: + - **до одного интервала опроса (1 с) строк** — записанное после последнего `drain()` и до `truncate` физически сохранено в `mail.log.1`, но дескриптор тейлера смотрит на уже обрезанный inode и это пропускает. Это доминирующее окно; + - **миллисекунды между «`cp` дочитал до EOF» и `truncate`** — эти строки не попадают никуда; для `copytruncate` устранить нельзя. + + **Решение** — ротация переименованием, ровно та механика, которую применяет сам Postfix в `postfix logrotate` (`mv`, затем `HUP` мастеру): rename атомарен, postlogd продолжает писать в переименованный inode до перезапуска, а тейлер держит дескриптор на том же inode и дочитывает хвост перед переключением на новый файл. Не теряется ничего ни на стороне записи, ни на стороне чтения. Правки: + - [build/logrotate-mail.conf](../build/logrotate-mail.conf): убрать `copytruncate`, добавить `postrotate /usr/sbin/postfix reload endscript`. `rotate 14`/`compress`/`delaycompress` остаются: удержание N файлов (ТЗ 9) — за logrotate, поэтому берётся не сам `postfix logrotate` (у него нет retention, он лишь переименовывает с меткой времени и жмёт), а его механика. **`create 0644 root root`, не `nocreate`** — см. стендовую проверку ниже, предположение о `nocreate` не подтвердилось; + - `follow()` в [internal/logtail/logtail.go](../internal/logtail/logtail.go): при обнаружении смены inode дочитать старый дескриптор ещё раз перед закрытием — иначе остаётся микроокно между `drain()` и проверкой смены файла. Проверка `ni.Size() < pos` сохраняется как страховка от обрезания посторонней схемой ротации, но перестаёт быть основным механизмом; + - `readLogTail()` в [internal/web/handlers_monitor.go](../internal/web/handlers_monitor.go): `fs.ErrNotExist` — не ошибка, а пустой экран. После rename файла нет, пока Postfix не запишет в него первую строку (порядка секунды в сутки), и баннер ошибки в этот момент — шум. + + **Цена:** один `postfix reload` в сутки — ровно то, что уже делает [postfix-cert-reload.sh](../build/postfix-cert-reload.sh) ради сертификатов, никакой новой машинерии. + + **Проверено на живом 1.0.0 до принятия решения:** Postfix 3.7.11 (команда `postfix logrotate` есть начиная с 3.4, её реализация в `postfix-script` — это `mv` + `master -t || kill -HUP` + `sleep 1` + компрессор); postlogd работает под uid `postfix`, `/var/log` принадлежит root, `/var/log/mail.log` — `root:root 0644` и пользователю `postfix` на запись недоступен; в образе файла нет — значит создаёт его привилегированная сторона. + + **Стендовая проверка при реализации (обязательная по плану) обнаружила, что предположение о `nocreate` неверно.** На живом контейнере (`selfpost.example.com`, тестовый образ) после `mv` + `postfix reload` файл действительно появляется — но не сразу и не на `0644`: сам HUP лог не пересоздаёт, это происходит лениво при следующей фактической записи, и создаётся он с режимом `0600` — непривилегированная панель (свой uid) такой файл читать не может, то есть просмотр `mail.log` в панели остаётся сломан до следующего холодного старта контейнера (там `0644` берётся из другого, не связанного с этим, пути создания). Решение — то, что план заранее указал как запасной вариант: `create 0644 root root` вместо `nocreate`. logrotate создаёт пустой файл на 644 сразу после `mv`, ещё до запуска `postrotate`, и Postfix при следующей записи просто открывает и дозаписывает уже существующий файл, не трогая его режим. Проверено многократно на стенде: после ротации файл сразу (без окна) читаем непривилегированным uid панели, и остаётся на 644 после того, как в него попадает новый трафик. + + **Смежное, не решённое (тот же класс потерь, вариантом выше не лечится):** при рестарте панели `follow()` стартует с конца файла, поэтому строки, записанные пока она не читала, пропускаются; при редеплое `mail.log` исчезает вместе с контейнером — `/var/log` не в volume. В обоих случаях статусы писем, бывших в полёте, остаются `queued` навсегда — вероятно, чаще, чем при ротации. Кандидаты, если решим закрывать: переживать рестарт (запоминать позицию), вынести лог в `/data`, либо досверять зависшие строки по `postqueue`. +3. **Поведение при незаданном `SELFPOST_HOSTNAME` — решено: фатальная проверка в [entrypoint.sh](../build/entrypoint.sh) с развёрнутым текстом ошибки.** Прежнее предложение («предупреждать громко в лог, но не падать») отклонено: оба отказа мягкого fallback'а тихие и отложенные, а предупреждение в лог для таких отказов не работает — оно печатается при старте, а последствие проявляется через часы и в другом месте. + + **Что на самом деле ломает мягкий fallback** (формулировка «падают в `localhost`» занижала проблему — два fallback'а расходятся между собой): + - панель берёт realm как `SASL_REALM` → `SELFPOST_HOSTNAME` → **`localhost`** ([main.go:128](../cmd/panel/main.go:128)), а `postfix-config.sh` берёт `myhostname` как `SELFPOST_HOSTNAME` → **`hostname -f`**, то есть ID контейнера ([postfix-config.sh:21](../build/postfix-config.sh:21)). При пустом `smtpd_sasl_local_domain` Cyrus резолвит голый логин против realm = `myhostname` (механика проверена на стенде в Фазе 5), значит аккаунты пишутся в один realm, а ищутся в другом → **аутентификация не работает ни для одного приложения**, при полностью зелёной панели, а видно это только как `535` в чужом приложении. Само расхождение выведено из проверенной механики, отдельно не воспроизводилось; + - `EHLO` = ID контейнера: не FQDN, не совпадает с PTR, SPF-проверка HELO падает → почта, если она всё же уходит, попадает в спам. Отказ, не видимый вообще нигде; + - побочная ловушка того же класса: `SASL_REALM` читает только панель, `postfix-config.sh` про него не знает — задать один `SASL_REALM` без `SELFPOST_HOSTNAME` ломает так же. + + **Почему фатально, а не мягко.** Это не настройка с разумным умолчанием, а идентичность, обязанная одновременно совпасть с PTR/rDNS, с CN/SAN сертификата и с SASL-realm (ТЗ 5.2 п.3, 8) — значения, удовлетворяющего всем трём, угадать нельзя, поэтому **любой** fallback заведомо неверен. Ужесточением это не является: штатный деплой уже требует переменную (`${SELFPOST_HOSTNAME:?...}`, [docker-compose.yml:27](../deploy/docker-compose.yml:27)), CI контейнер не поднимает ([test.yml](../.github/workflows/test.yml) — только `vet`/`test`), а локальный запуск стоит одной явной переменной (`SELFPOST_HOSTNAME=localhost`). Меняется поведение только вне поставляемого compose (`docker run`, k8s, свой compose). + + **Правки:** + - `entrypoint.sh`: проверка до `postfix-config.sh` и до `supervisord`; при пустом значении — `exit 1`. Текст ошибки развёрнутый, а не `SELFPOST_HOSTNAME is required`: что это за имя, почему обязательно (PTR + CN/SAN + SASL realm), пример значения, где задаётся (`.env`). Это и есть замена «баннера в панели» — объяснительность там, где её реально прочитают (вывод `docker compose up`); + - там же — синтаксическая проверка значения: минимум одна точка, без схемы, порта и пробелов. Ловит типовые `https://mail.example.com` и `mail.example.com:465`, которые realm не ломают (обе стороны берут одну переменную), но ломают HELO и совпадение с сертификатом, то есть дают тот же тихий спам-отказ; + - `saslRealm()` ([main.go:128](../cmd/panel/main.go:128)) и fallback в `postfix-config.sh` **оставляем как есть**: после гейта в контейнере эти ветки мертвы, а вне контейнера (запуск бинаря локально, без Postfix) расходиться не с чем. Существующие «(SELFPOST_HOSTNAME is not set)» на статус-странице и `PTR: unknown` ([server.go:21](../internal/dnscheck/server.go:21)) тоже остаются — они как раз про этот случай. + + **Почему отклонён вариант «панель поднимается с баннером, фатально только для почтового тракта»** (обсуждался как более мягкий): + - он отменяет инвариант Фазы 4 — listener `crashexit` роняет весь контейнер, когда любой managed-процесс уходит в FATAL, именно чтобы не оставался «живой контейнер с мёртвым компонентом» ([crashexit.py](../build/crashexit.py)). В наивной реализации он в фатальный вариант и вырождается, только на минуту позже и с тремя циклами ретраев в логе; + - у него нет обычного оправдания degraded-режима — «починить на живую». Для TLS-сертификата degraded-режим выбран сознательно (файл можно доложить в mount, `cert-reload` подхватит без рестарта), а hostname вшивается в `myhostname` при генерации конфига, поэтому лечение всё равно = правка `.env` + пересоздание контейнера; + - главное: в таком состоянии панель остаётся полноценным писателем состояния, и состояние будет неверным. Созданные аккаунты лягут в realm `localhost`, а после задания hostname и рестарта `Secret()` и `Delete` ищут по паре (login, realm) ([sasl.go:97](../internal/app/sasl.go:97), [sasl.go:67](../internal/app/sasl.go:67)) → экспорт их не видит, удаление приложения оставляет сироту в `sasldb2` навсегда, а в панели они выглядят существующими. Чтобы это было безопасно, пришлось бы ещё блокировать записывающие действия — третий режим работы вместо одной проверки; + - канал доставки баннера испорчен ровно тем условием, о котором он предупреждает: адрес панели на первом запуске берётся из setup-ссылки, а она в этом сценарии печатается как `https://localhost/setup/` ([setup.go:124](../internal/web/setup.go:124)); + - `HEALTHCHECK` в образе не объявлен, поэтому «панель жива, почта мертва» для внешнего мониторинга выглядит здоровым контейнером, а crash-loop виден любой системе. + + **Цена:** контейнер без `SELFPOST_HOSTNAME` не стартует — это и есть цель. **Проверка на стенде при реализации:** контейнер без переменной падает с ожидаемым текстом и не уходит в бесконечный тихий retry; обычный деплой из [deploy/docker-compose.yml](../deploy/docker-compose.yml) не меняется; значение с портом/схемой отклоняется. Обязательность отразить в [README](../README.md) и `deploy/.env.example` (там переменная уже первая в списке). + +### C. CI и тесты + +4. **Интеграционный e2e — решено: герметичный контейнерный прогон отдельным Go-модулем, гейт перед публикацией образа.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории; автоматизируется по сути тот же сценарий. + + **Что закрывается.** Не «интеграция вообще», а один класс отказов — **обвязка контейнера**, невидимая для `go test`: все 22 тестовых файла фейкуют границу процесса (`saslpasswd2` подменён хуком `s.run` — [sasl_test.go](../internal/app/sasl_test.go), milter гоняется против `fakeRecorder`, web — через `httptest`), реальные Postfix/OpenDKIM/supervisord/sasldb2 не стартуют нигде. Исторически ломалось ровно здесь: chroot ломал DNS ([postfix-config.sh:168](../build/postfix-config.sh:168)), Postfix не доставал до milter-сокетов (общая группа + setgid, [entrypoint.sh:62](../build/entrypoint.sh:62)), расходился SASL-realm (`535` для всех приложений), reload Postfix сигналом не работал (потребовалась one-shot program), а недостаточный `cap_add` в [docker-compose.yml](../deploy/docker-compose.yml) уронил контейнер **в проде**. Общее свойство всех пяти: панель зелёная, юнит-тесты зелёные, тракт мёртв. + + **Что автоматизировать нельзя и не пытаемся:** реальные PTR/rDNS, сертификат LE, репутация IP, доставка во внешний ящик (исходящий 25 на GitHub-раннерах закрыт). Это остаётся ручной проверкой на проде. + + **Форма:** + - `test/e2e/` — **отдельный Go-модуль со своим `go.mod`**: `go test ./...` не подхватывает его без build-тегов, а тестовые зависимости (проверка DKIM-подписи и прочее) не попадают в граф основного модуля, где сейчас три прямых зависимости; + - стенд — **поставляемый [deploy/docker-compose.yml](../deploy/docker-compose.yml) плюс override**, а не отдельный тестовый compose: иначе `cap_drop`/`cap_add`/`no-new-privileges` — ровно то, что сломалось в проде, — останутся непроверенными. Override задаёт самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, тестовый `SELFPOST_HOSTNAME`, заниженный `RATE_LIMIT_MESSAGES_PER_IP`, резолвер (`dns:`) и **высокие порты вместо 465/587/8080** — на dev-сервере они заняты продом, и без этого `make e2e` там падал бы на конфликте портов, работая при этом в CI; + - герметичная почта: фиктивная DNS-зона (CoreDNS/dnsmasq) + sink-MX на `smtp-sink` из пакета postfix — новых зависимостей ноль. DKIM-запись тест **берёт из панели и сам публикует в зону**, поэтому попутно проверяется, что запись, которую панель печатает пользователю, вообще рабочая; + - детерминизм обязателен: никаких `sleep N`, только опрос с таймаутом. Плавающий гейт перестают чинить, и тогда он хуже отсутствующего. + + **Объём проверок.** Позитив: старт контейнера (автостартующие `opendkim`/`panel`/`postfix`/`cert-reload`/`logrotate` в `RUNNING`, `postfix-reload` — `NOT STARTED`, он `autostart=false`) → setup по токену из `/data/setup-token` → login → домен → приложение → SMTP AUTH на 465 → письмо доставлено на sink → подпись проверяется против ключа из зоны → строка журнала переходит `queued → sent` (это же покрывает `logtail`, а после B.2 — и ротацию). Негативы: + 1. отправка без AUTH — отказ; + 2. чужой отправитель при валидном AUTH — отказ (`reject_sender_login_mismatch`, ТЗ 5.1 п.3); + 3. relay на чужой домен — отказ (`reject_unauth_destination`), прямая проверка «не open relay»; + 4. L1-лимит по IP (заниженный в override) — отказ после исчерпания; + 5. L2-лимит, выставленный **через панель**, с записью `rejected` — заодно путь панель→БД→milter; + 6. fail-open journal-milter'а: `supervisorctl stop panel` → письмо всё равно принято, контейнер жив. Это выполнимо, потому что `crashexit` подписан только на `PROCESS_STATE_FATAL` ([supervisord.conf:122](../build/supervisord.conf:122)), а штатный stop даёт `STOPPED`; + 7. пустой и синтаксически неверный `SELFPOST_HOSTNAME` — контейнер падает с ожидаемым текстом (проверка из B.3); + 8. сессия переживает `docker restart` (проверка из B.1). + + **Вне объёма:** «зависший», а не упавший milter — требует подставного сокета внутри контейнера, это уже chaos-тест ради одного таймаута. + + **Запуск.** Основной путь — `make e2e` на dev-сервере перед тегированием (там и так идёт вся сборка) плюс `workflow_dispatch`. В CI прогон вешается **на тег `vX.Y.Z` и блокирует публикацию образа**; на обычный push не вешается — `vet`/`test` в [test.yml](../.github/workflows/test.yml) остаются как есть. Уведомления специально не настраиваются: признак провала — отсутствие образа в `ghcr` после тега, смотрится вкладкой Actions. + + **Переработка [release.yml](../.github/workflows/release.yml)** — следствие требования покрыть обе архитектуры. Сейчас multi-arch собирается через qemu; гонять под эмуляцией полный стек Postfix мучительно долго, поэтому релиз переезжает на нативную сборку по архитектурам: job `prepare` (единственная точка деривации версии из тега — на ней держится инвариант ТЗ 7.5.А) → матрица `[ubuntu-latest, ubuntu-24.04-arm]`, в каждой сборка `--load` → e2e → push per-arch тега `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge`: `docker buildx imagetools create -t …:X.Y.Z`. `setup-qemu-action` уходит, `provenance: false` сохраняется. Порядок «сначала тест, потом push» выбран ради того, чтобы публиковались **ровно те байты, которые прогонялись**; альтернатива (push по digest → тест → сборка манифеста) даёт ту же гарантию, но оставляет в registry мусорные untagged-манифесты после красного прогона и требует второго пути для `workflow_dispatch`. Per-arch теги остаются в registry побочным продуктом; неизменяемость версионного тега (ТЗ 10.1) это не нарушает. Бесплатные arm-раннеры доступны, потому что зеркало `github.com/mixeme/selfpost` публичное. + + **Цена:** ~10–15 минут на релиз; переработка релизного workflow, который сейчас работает; новый модуль и compose-override на сопровождении. **Проверка при реализации:** `make e2e` на dev-сервере проходит, не задевая прод-порты; красный e2e действительно не даёт опубликовать образ — проверяется одноразовым тегом на заведомо сломанном прогоне (тег и per-arch пакеты после проверки удалить). + + **Порядок работ:** сначала B.1–B.3 (они полностью специфицированы, иначе харнесс пришлось бы переписывать под них), затем харнесс — и стендовые проверки B.1/B.3 переезжают в него постоянными регрессиями (пункты 7–8 выше), а не выбрасываются после однократного прогона. + +### D. Ревизия безопасности + +5. **Проверка на уязвимости моделью Fable — решено: отдельный проход после B.1–B.3 и C.4, до тега релиза.** + + **Почему после всех четырёх, а не по ходу каждого.** Каждый пункт трогает ровно ту поверхность, которую аудит ТЗ 7.6 на v1.0 видел в другом виде: B.1 переписывает аутентификацию (сессии в SQLite, SHA-256 от токена, скользящее продление, разлогин всех при смене пароля), B.2 меняет обращение с дескриптором лога и вешает `postfix reload` на logrotate, B.3 добавляет разбор значения переменной в shell до старта supervisord, C.4 приносит переработанный релизный workflow и compose-override с **сознательно ослабленными** настройками (`PANEL_COOKIE_SECURE=false`, самоподписанный сертификат, заниженные лимиты), которому нельзя утечь в прод. Ревизия по пунктам дала бы четыре среза, а смотреть надо итоговое состояние — и заведомо один раз, а не четыре. + + **Объём.** Диф от тега `v1.0.0` до состояния перед следующим тегом целиком — то есть вместе с Фазами 12–14, которых в аудите v1.0 не было, — плюс повторный проход по чек-листу ТЗ 7.6, а не только по изменённым строкам: регресс в 7.6 возможен и в нетронутом коде, если рядом поменялся вызывающий. Приоритет задаёт то, что панель публично доступна (ТЗ 2.4): аутентификация и сессии, валидация ввода, запись в конфиги и map-файлы (injection), `os/exec` без shell, права на файлы в `/data`, обращение с секретами (пароли приложений, `sasldb2`, архив бэкапа). + + **Модель — Fable, и это сознательно не Opus:** B и C пишет Opus, а проверка собственной работы систематически слабее независимой. Правило progress.md «безопасность/инфра → Opus» этим не отменяется — оно про написание кода, здесь речь про ревизию. Форма прогона: `/security-review` по изменениям, пока они ещё в ветке (скилл смотрит диф), плюс отдельный ручной проход по 7.6 целиком. + + **Что с находками.** Каждая закрывается явно: правка до тега либо запись в [security.md](security.md) как принятый риск с обоснованием (там уже два таких). «Посмотрели и ладно» закрытием не считается. **Гейт:** вместе с e2e из C.4 — до тега; находка класса «эксплуатируется снаружи» тег откладывает. + +### E. Указатель на объём 2.x + +6. **Входящий релей и pluggable-антиспам** вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это **сознательно отложенный объём**, а не забытый. +7. **Роль администратора домена** — кандидат на 2.x, вне объёма v1.x. Прежняя формулировка пункта («2FA и несколько администраторов») заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена. + + **Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go:182](../internal/web/web.go:182)), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([handlers_monitor.go:49](../internal/web/handlers_monitor.go:49)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны. + + **Почему 2.x, а не v1.x.** ТЗ 3 относит «несколько пользователей панели, роли» к явным не-целям (панель рассчитана на одного администратора), поэтому появление второго субъекта — расширение границ проекта, как и Фаза O1: сначала согласование (ТЗ 12.6) и правка ТЗ, только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена. + +--- + +## Опциональные фазы — целевой релиз 2.x.x (вне базового объёма v1.0) + +Эти фазы **не входят** в линейный базис 0→11 и не являются частью поставки v1.0 (v1.x — только исходящий релей). Они отнесены к **релизной линии 2.x.x** и добавлены в дорожную карту как согласуемые расширения. **Реализация — только после явного согласования (ТЗ 12.6):** ТЗ v1.0 раздел 3 явно исключает приём входящей почты из объёма, поэтому включение этой функциональности — сознательное расширение границ проекта (major-релиз 2.0), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением. + +### Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин + +**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея. + +**Зачем это нужно (сценарии):** +- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся. +- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер. + +**Граница объёма (критично — что это НЕ):** +- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки. +- **ЭТО НЕ (остаётся out of scope, ТЗ 3):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра. + +**Почему как опция/плагин:** +- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора. +- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен. + +**Что делать:** +- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`). +- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`. +- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны. +- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса. +- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell (ТЗ 7.6.2–4). +- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется. +- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd. + +**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения: +- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, ТЗ 3 не нарушается (SelfPost не реализует антиспам). +- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации. +- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend. +- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции. +- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить. +- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README. + +**Безопасность (ТЗ 7.6 распространяется полностью):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter. + +**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные. + +**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции. + +**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования. diff --git a/docs/progress.md b/docs/progress.md new file mode 100644 index 0000000..3b3d3fc --- /dev/null +++ b/docs/progress.md @@ -0,0 +1,52 @@ +# Прогресс реализации SelfPost + +Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы. +План (открытые вопросы + опциональные фазы): [implementation-plan.md](implementation-plan.md). +ТЗ: [specification.md](specification.md). Принятые риски безопасности: [security.md](security.md). +История релизов: [CHANGELOG.md](../CHANGELOG.md). +История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется. + +## Как возобновить после сброса контекста + +1. Прочитать этот файл (текущее состояние, что дальше). +2. Открыть `implementation-plan.md` — там нерешённые вопросы и опциональная линия 2.x.x (Фаза O1+); принятые риски безопасности — в `security.md`. +3. При необходимости — детали в `specification.md`. +4. Продолжить с пункта «Следующий шаг». + +## Модель по типу работы + +Правило: безопасность / инфра / риск-критичное → **Opus**; UI / документация / бойлерплейт → **Sonnet**; тривиальная механика → **Haiku**. + +Исключение — **ревизия** (не написание) кода: предрелизная проверка на уязвимости (пункт **D.5** плана) делается моделью **Fable**, чтобы проверял не тот, кто писал. + +## Коммиты + +Коммит на **каждом осмысленном шаге** (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка `main` (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером `Co-Authored-By: Claude <модель> ` — с той моделью, которая этот шаг делала (на момент Фазы 14 — `Claude Opus 5`). + +На каждом таком шаге — запись в [CHANGELOG.md](../CHANGELOG.md) под `[Unreleased]` (формат Keep a Changelog). При явном решении зарезать версию — секция `[Unreleased]` переименовывается в `[X.Y.Z] - дата`, заводится новая пустая `[Unreleased]`. Тег/пуш образа — только по явному запросу (см. workflow release.yml). + +## Протокол закрытия фазы/крупного шага + +Перед `/clear` в конце каждого законченного шага Claude: +1. Обновляет этот файл: «Текущее состояние» → что изменилось, что дальше. +2. Проверяет применимые критерии «Готово, когда…». +3. Дописывает `CHANGELOG.md` под `[Unreleased]`. +4. Делает финальный коммит шага. + +--- + +## Текущее состояние + +- **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX), Фаза 13 (страница `/status`, DNS-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется. +- **B.1 реализован** (не выкачен на прод): сессии переехали в SQLite (`internal/store/migrations/0002_sessions.sql`, `internal/store/sessions.go`, `internal/web/session.go`) — хранится SHA-256 токена, не сам токен; скользящий срок бездействия `PANEL_SESSION_IDLE_DAYS` (по умолчанию 7 дней, без абсолютного потолка); запись в БД продлевается не чаще раза в час (`renewThreshold`); опросы мониторинга (`GET` с `HX-Request`) продление не триггерят (`isSessionActivity` в `internal/web/middleware.go`); `Max-Age` cookie выставляется тем же значением при логине и при продлении (`setSessionCookie`); смена пароля разлогинивает все сессии кроме текущей (уже было, теперь через БД). Проверено на стенде: логин → рестарт процесса панели → сессия жива по старой cookie; HX-Request-опрос и повторный GET внутри часового окна не шлют `Set-Cookie`. `go vet`/`go test ./...`/`gofmt -l .` чистые. +- **B.2 реализован** (не выкачен на прод): ротация `mail.log` ушла с `copytruncate` на «переименовать + `postfix reload`» — `build/logrotate-mail.conf` (`nocreate` заменён на `create 0644 root root` **не по плану, а по стендовой проверке**: после reload Postfix пересоздаёт лог сам только в момент следующей фактической записи и с режимом `0600`, недоступным непривилегированной панели, — `create` в logrotate закрывает это, отдавая файл ей же на 644 сразу после переименования); `follow()` в `internal/logtail/logtail.go` при обнаружении смены inode дочитывает старый дескриптор ещё раз перед переключением; `readLogTail()` в `internal/web/handlers_monitor.go` считает отсутствующий файл пустым экраном, а не ошибкой. Проверено на стенде (`selfpost.example.com`, отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на dev-сервере; локально на Windows `TestFollowTailsAndRotates` падает — rename открытого файла запрещён ОС, к делу не относится). +- **B.3 реализован** (не выкачен на прод): `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` пути. +- **Дальше — документация:** [documentation-plan.md](documentation-plan.md) — состав пакета (ТЗ 11), метод сверки с кодом, результаты первого прохода (11 находок: нет раздела «эксплуатация» по ТЗ 11 п.7, нет справочника env-переменных, битая ссылка на «Rate limiting» из `.env.example`, не отражена оговорка ТЗ 9 про прямой `tar`) и задачи D1–D7. Часть предрелизного гейта наравне с e2e и ревизией безопасности. +- **Дальше:** пункт **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, требует согласования). +- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает). + +## Рабочая петля (dev loop) — ВАЖНО + +Локально (Windows, ``) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → залить дерево на сервер → `go build`/`go vet`/`docker build`/тесты там. `rsync` в локальном git-bash нет, поэтому дерево едет tar'ом по ssh: `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'`; Go на сервере — в `/usr/local/go/bin` (не в `PATH` по умолчанию); образ — `docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .`. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу). diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..f9c2b83 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,39 @@ +# Безопасность: принятые риски + +**Что здесь.** Обязательные требования к безопасности — [ТЗ 7.6](specification.md); +соответствие им проверено полным аудитом на v1.0 и здесь не пересказывается. +Hardening сверх обязательного 7.6 (security-заголовки, проверка origin, cookie +`__Host-` с обнаружением дублей — Фаза 14) тоже закрыт, история — в +[CHANGELOG.md](../CHANGELOG.md) и `git log`. Этот документ держит третью +категорию: то, что закрыто **сознательно не было**, чтобы решение не потерялось +и не переоткрывалось заново. + +Здесь, а не в [implementation-plan.md](implementation-plan.md), потому что план — +только про несделанную работу, а принятый риск — не работа, а решение: у него нет +состояния «в очереди», есть условие, при котором к нему возвращаются. + +## Принятые риски + +- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.** + Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или + webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта. + Принято сознательно: панель однопользовательская, админ выбирает браузер + сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём + панель. Ужесточение — одна строка в `originAllowed` + ([internal/web/security.go](../internal/web/security.go)): вернуть `false` + вместо `true` в ветке «нет обоих заголовков». +- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin + закрывает соседний поддомен, но зависит от поведения браузера; токен — нет. + Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к + вопросу считать появление требования «устойчиво независимо от браузера». + От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin + панели, отправит запрос сам — против этого работают автоэкранирование + `html/template` (7.6.7) и CSP, поэтому шаблоны не должны содержать + inline-скриптов и inline-стилей. + +## Как этот список пополняется + +Предрелизная проверка на уязвимости (пункт **D.5** плана, модель Fable) закрывает +каждую находку одним из двух способов: правка до тега — либо запись сюда, с +обоснованием и условием возврата, как у двух пунктов выше. Третьего варианта +(«посмотрели и ладно») нет. diff --git a/docs/specification.md b/docs/specification.md new file mode 100644 index 0000000..ac77da3 --- /dev/null +++ b/docs/specification.md @@ -0,0 +1,443 @@ +# Техническое задание: SelfPost + +**Версия:** 1.0 +**Тип проекта:** self-hosted SMTP relay с веб-панелью управления +**Формат поставки:** один Docker-образ + +--- + +## 1. Цель и назначение + +SelfPost — это self-hosted SMTP-релей для личного использования, предназначенный для **исходящей отправки** почты из скриптов, приложений и систем уведомлений. Релей отправляет почту напрямую в интернет с собственного IP, с подписью DKIM, и управляется через веб-панель. + +**Ключевой сценарий использования:** пользователь настраивает релей один раз (домен, DKIM, разрешённые отправители), после чего его приложения/скрипты отправляют почту через SMTP-endpoint релея, а релей доставляет её получателям от имени настроенного домена. + +--- + +## 2. Контекст и ограничения + +Эти ограничения — результат предварительного анализа и **не подлежат пересмотру** без явного согласования: + +1. **Развёртывание — VPS или домашний сервер** (одноплатники вроде Raspberry Pi пока вне рассмотрения, но не исключены в будущем). Оба варианта равноправны — образ один и тот же. Инфраструктурные предпосылки для отправки с собственного IP считаются обеспеченными оператором (см. заметку ниже). +2. **Отправка с собственного IP** (DIY), без промежуточного relay-провайдера. Требует, чтобы VPS-провайдер разблокировал порт 25 (обычно по тикету) и позволял настроить PTR/rDNS. +3. **Один контейнер.** Postfix, OpenDKIM и панель управления работают внутри одного образа под управлением process-supervisor. Это осознанное решение в пользу простоты развёртывания. +4. **Панель управления публично доступна** из интернета. Это накладывает обязательные требования к безопасности (раздел 7). + +> **Предпосылки инфраструктуры (вне зоны ответственности проекта).** SelfPost исходит из того, что площадка (VPS или домашний сервер) уже обеспечивает условия для отправки с собственного IP: разблокированный исходящий порт 25, статический IP, настраиваемый PTR/rDNS и приемлемая репутация IP. Обеспечение этих условий — задача оператора при развёртывании, а не функция SelfPost. Проект не пытается их детектировать, обходить или компенсировать; при их отсутствии почта просто не будет доставляться, и это ожидаемо. + +--- + +## 3. Что НЕ входит в объём (Out of Scope) + +Явно исключено, чтобы не было scope creep: + +- ❌ Приём входящей почты (IMAP/POP3, mailbox'ы, доставка в ящики) +- ❌ Веб-почта (webmail) +- ❌ Мультитенантность (организации, несколько **пользователей панели**, роли) — панель рассчитана на **одного** администратора. Управление несколькими отправляющими доменами — это НЕ мультитенантность и входит в объём (см. раздел 4.1). +- ❌ Антиспам/антивирус для входящей почты (rspamd, ClamAV) +- ❌ Реализация собственного SMTP-сервера или MTA — используется готовый Postfix +- ❌ Dovecot и любой полноценный mail-стек ради SASL — аутентификация делается на лёгком Cyrus SASL (`sasldb2`), см. раздел 5.1 + +--- + +## 4. Архитектура + +Единый Docker-образ на базе **Debian slim** (например, `debian:bookworm-slim`). Выбор зафиксирован в пользу простоты поддержки: glibc даёт более предсказуемое поведение DNS-резолвера при постоянных MX-lookup'ах (в отличие от musl в Alpine), а подавляющее большинство документации и примеров конфигов Postfix/OpenDKIM ориентировано на Debian/apt — что снижает риск ошибок, особенно при генерации кода агентом. Экономия размера на Alpine здесь непринципиальна (образ всё равно тянет Python ради supervisord, а деплой идёт на VPS/сервер, не на ресурсно-ограниченный одноплатник). Внутри — три процесса под управлением `supervisord`: + +``` +┌───────────────────────────────────────────────┐ +│ Docker-контейнер SelfPost │ +│ │ +│ supervisord (PID 1) │ +│ ├── postfix (start-fg) │ +│ ├── opendkim (foreground) │ +│ └── panel (Go-бинарник, один процесс, │ +│ совмещает несколько ролей: │ +│ • HTTP-сервер панели :8080 │ +│ • journal-milter (приём :internal │ +│ From/To/Subject/SASL-user на этапе │ +│ DATA, см. 7.3) │ +│ • log-tailer — горутина, следит за │ +│ mail.log и обновляет статус доставки) │ +│ │ +│ Общая файловая система: │ +│ /etc/postfix/... ← панель пишет │ +│ /etc/opendkim/keys ← DKIM-ключи │ +│ /data/selfpost.db ← SQLite: домены, │ +│ приложения, администратор, журнал │ +│ отправки (см. 7.3, 9) │ +│ │ +│ Порты: │ +│ 465 (smtps, implicit TLS — основной, только │ +│ SASL-аутентификация) │ +│ 587 (submission, STARTTLS — опционально, │ +│ добавляется по необходимости, раздел 5) │ +│ 25 (исходящий в интернет) │ +│ 8080 (панель, за reverse-proxy) │ +└───────────────────────────────────────────────┘ +``` + +**Взаимодействие панели и Postfix:** панель пишет конфиг-файлы напрямую в локальную ФС (`/etc/postfix/...`) и вызывает `postfix reload` как sibling-процесс. Поскольку всё в одном контейнере, inotify-вотчер и shared volumes между контейнерами **не нужны**. + +**Milter-цепочка Postfix теперь состоит из двух шагов:** OpenDKIM (подпись) и journal-milter (логирование для журнала отправки, раздел 7.3) — оба сконфигурированы в `smtpd_milters`/`non_smtpd_milters`. Это остаётся в рамках «одного контейнера, трёх процессов под supervisord»: journal-milter — не новый процесс, а дополнительная роль внутри уже существующего panel-бинарника. + +**Порядок старта — обязателен, чтобы исключить ошибки соединения при холодном старте контейнера.** `priority=` в supervisord задаёт только порядок отправки команд на запуск, а не готовность сокетов — сама по себе она не гарантирует, что milter-сокеты уже слушают к моменту, когда Postfix попытается к ним подключиться. Поэтому: +1. `priority`: OpenDKIM стартует первым, затем panel (включая journal-milter listener). +2. **Postfix оборачивается стартовым скриптом**, который блокируется в цикле опроса (например, `test -S ` для unix-сокетов OpenDKIM/journal-milter, с интервалом и таймаутом — например, до 30 секунд), и запускает `postfix start-fg` только после того, как **оба** milter-сокета подтверждённо готовы. +3. **Если готовность не наступает в пределах таймаута** — скрипт завершается с ошибкой (не запускает Postfix «на всякий случай»), чтобы supervisord/Docker увидели явный сбой старта в логах, а не тихо продолжили с сервисом, который будет отклонять почту из-за недоступного milter'а. +4. Это решает только **проблему холодного старта** (первые секунды жизни контейнера) и не отменяет уже принятое поведение для **runtime**-сбоев после успешного старта: если OpenDKIM или journal-milter упадёт во время работы (не при старте), продолжает действовать fail-open, описанный в разделах 7.3/7.4 — это два разных сценария с разной правильной реакцией: на старте лучше подождать и не запускать Postfix «в дырявом» состоянии, а во время работы — не ронять уже идущий приём почты из-за вспомогательного компонента. + +**Требование к supervisord:** если любой из трёх процессов падает и не может быть перезапущен, контейнер должен завершаться (а не оставаться «живым» с мёртвым Postfix), чтобы Docker с `restart: unless-stopped` корректно его перезапустил. + +### 4.1. Доменная модель (ядро продукта) + +SelfPost — **мультидоменный** релей. Две связанные сущности: + +- **Отправляющий домен** — например, `example.com`. Имеет свой DKIM-ключ/селектор. +- **Приложение (учётная запись)** — SASL-пара логин/пароль, привязанная к конкретному домену. **У одного домена может быть несколько приложений** (например, `newsletter@example.com` и `alerts@example.com`, или просто «прод-сервер» и «staging-сервер» под один и тот же домен) — каждое со своим отдельным логином/паролем, но все они имеют право слать **только от имени того домена, к которому привязаны**, никогда от имени другого домена. + +**Режим адресов отправки — настройка на уровне приложения**, не домена (разные приложения одного домена могут выбирать разный режим независимо). При создании или редактировании приложения выбирается один из двух вариантов: + +1. **Любой адрес домена** — приложению разрешено указывать в From любой адрес в пределах своего домена (`*@example.com`). Удобно, когда одно приложение шлёт от разных адресов (например, `noreply@`, `alerts@`, `billing@`) без необходимости заводить под каждый отдельное приложение. +2. **Список конкретных адресов** — приложению разрешены только явно перечисленные From-адреса в пределах своего домена (например, только `alerts@example.com`). Более строгий вариант; попытка отправить с адреса не из списка отклоняется, даже если адрес принадлежит тому же домену. + +В обоих случаях адрес обязан принадлежать домену приложения — выйти за пределы своего домена нельзя ни в одном режиме. + +Для каждого добавленного домена система управляет: + +1. **DKIM-ключ и селектор** — свои для каждого домена (см. раздел 6). Общие для всех приложений этого домена. +2. **Одно или несколько приложений** — у каждого своя SASL-пара логин/пароль и свой режим адресов (см. раздел 5.1). Приложения одного домена независимы: удаление/перевыпуск/смена режима одного не затрагивает другие. +3. **Разрешённые From-адреса** — определяются режимом каждого приложения (см. выше). В любом случае приложение не может отправлять от имени чужого домена — попытка залогиниться кредами приложения из домена A и отправить с адреса домена B отклоняется (см. привязку в разделе 5.1). + +**Жизненный цикл домена (что делает панель при добавлении):** +- создаёт запись домена; +- генерирует DKIM-ключ + селектор для домена; +- показывает пользователю всё, что нужно внести в DNS для этого домена (DKIM TXT, а также напоминание про SPF/DMARC — см. раздел 10); +- **не создаёт приложение автоматически** — добавление первого (и последующих) приложений для домена делается отдельным действием, см. ниже. + +**Жизненный цикл приложения (что делает панель при добавлении приложения к домену):** +- генерирует SASL-пару логин/пароль (пароль показывается **один раз**, см. 7.2); +- принимает выбор режима адресов: «любой адрес домена» либо «список конкретных адресов» (если выбран список — принимает сами адреса, с валидацией, что каждый принадлежит домену приложения); +- регистрирует привязку в `smtpd_sender_login_maps` соответственно режиму — либо wildcard-запись `@example.com → логин`, либо отдельная запись на каждый разрешённый адрес `конкретный-адрес@example.com → логин` (несколько логинов и/или несколько записей могут вести к одному домену — это штатный случай, а не исключение); +- применяет изменения (`postfix reload`). + +**Изменение режима существующего приложения** — доступно как отдельное действие (переключение «любой адрес» ↔ «список», редактирование списка адресов), с пересборкой соответствующих записей в `smtpd_sender_login_maps` и `postfix reload`. + +**При удалении домена** — удаляются его DKIM-ключ **и все приложения**, привязанные к этому домену. +**При удалении приложения** — удаляются только его SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются. + +Эта модель — не мультитенантность (панелью по-прежнему управляет один администратор); это управление несколькими отправляющими доменами одного владельца, каждый из которых может обслуживать несколько приложений с независимо настроенными правами на адреса отправки. + +--- + +## 5. Компонент: Postfix + +Конфигурация Postfix как исходящего релея: + +1. **Приём почты — порт 465 (`smtps`, implicit TLS) как основной.** TLS устанавливается сразу при подключении (wrapper mode, `smtpd_tls_wrappermode = yes` в `master.cf` для сервиса `smtps`), в отличие от STARTTLS — клиент не отправляет ни байта в открытом виде до установления TLS-сессии. **Только для аутентифицированных клиентов (SASL, см. 5.1).** `mynetworks` не используется как способ авторизации отправки — пускают креды, а не сеть. **Категорически недопустима конфигурация open relay.** + - **Порт 587 (submission, STARTTLS) — не входит в базовую поставку, добавляется по необходимости.** Многие SMTP-библиотеки в приложениях по умолчанию ожидают именно 587 — при необходимости совместимости сервис `submission` в `master.cf` включается тем же способом, что и `smtps` (аналогичная SASL/milter/rate-limit конфигурация, единственное отличие — механизм установления TLS, см. 5.1 п.2). Это осознанно не включено в основной сценарий, чтобы не открывать лишнюю поверхность без необходимости. +2. **Отправка** — напрямую в интернет (без `relayhost`), с MX-lookup и TLS (`smtp_tls_security_level = may` минимум). +3. **DKIM** — подключён как milter к OpenDKIM (через unix-socket или inet-socket внутри контейнера). Подпись — per-domain (раздел 6). +4. **Ограничения** — `smtpd_recipient_restrictions` запрещает релей для неаутентифицированных (`permit_sasl_authenticated`, `reject_unauth_destination`). Обязательна привязка отправителя к логину (раздел 5.1). +5. **Базовый rate-limiting по client IP** — встроенный механизм Postfix (демон `anvil`): `smtpd_client_message_rate_limit` + `anvil_rate_time_unit` ограничивают число сообщений с одного client IP за окно времени. Работает на уровне приёма соединения, **независимо от journal-milter** — это базовый backstop, который продолжает действовать даже при сбое milter'а (см. раздел 7.4). Значения — конфигурируемые (раздел 8), по умолчанию консервативные, особенно уместные на этапе прогрева IP (раздел 10). Применяется одинаково к обоим портам приёма (465 и, если включён, 587). +6. Домены, приложения (креды) и привязки — управляются панелью (файлы конфигурации, которые панель редактирует, с последующим `postfix reload`). + +Базовый образ-ориентир для понимания подхода: `boky/postfix` (env-driven relay), но здесь конфигурация собирается под DIY-отправку с SASL-аутентификацией и мультидоменом, а не под relay через провайдера. + +### 5.1. SASL-аутентификация и привязка приложений к домену + +1. **Бэкенд SASL — Cyrus SASL с локальной базой `sasldb2`** (лёгкий, без Dovecot и без полноценного mail-стека). Панель создаёт/удаляет учётные записи приложений (эквивалент `saslpasswd2`), Postfix проверяет их через `smtpd_sasl_auth_enable = yes`. +2. **TLS обязателен для аутентификации на обоих портах, но механизм различается:** + - На **465 (`smtps`)** — TLS уже установлен к моменту аутентификации по определению wrapper mode, отдельная настройка не требуется сверх `smtpd_tls_wrappermode = yes`. + - На **587 (`submission`, если включён)** — `smtpd_tls_auth_only = yes`, чтобы креды не могли быть отправлены до STARTTLS по незашифрованному каналу. + В обоих случаях результат одинаков: креды никогда не передаются в открытом виде. +3. **Привязка логина к разрешённым адресам (критично).** Настраивается `smtpd_sender_login_maps` + `reject_sender_login_mismatch` в `smtpd_sender_restrictions`. Без этого любой валидный аккаунт сможет слать от имени любого домена — это дыра, а не опция. Формат записи в карте зависит от режима адресов, выбранного для приложения (см. раздел 4.1): + - **режим «любой адрес домена»** — wildcard-запись `@example.com логин` (Postfix поддерживает доменные wildcard-записи в `sender_login_maps`); + - **режим «список конкретных адресов»** — отдельная запись на каждый разрешённый адрес: `alerts@example.com логин`, `noreply@example.com логин` и т.д. + Обе формы одновременно допустимы в карте для разных приложений/доменов. Панель поддерживает карту в актуальном состоянии при добавлении/удалении/изменении доменов и приложений, генерируя нужный тип записи в зависимости от режима. +4. **Многие-ко-одному.** У одного домена может быть несколько приложений (логинов), каждое со своим независимым режимом адресов; ни одно приложение не может получить право слать от имени домена, к которому оно не привязано. + +### 5.2. TLS-сертификаты для Postfix + +**SelfPost всегда работает за reverse-proxy, и именно reverse-proxy отвечает за выпуск, хранение и автообновление TLS-сертификатов** (ACME/Let's Encrypt). Проект **не** содержит собственного ACME-клиента и не выпускает сертификаты сам — он только потребляет готовые. + +Модель простая: + +1. **Источник** — reverse-proxy (Caddy/Traefik) кладёт PEM-файлы (цепочка + приватный ключ) для почтового hostname в директорию на хосте, смонтированную в контейнер SelfPost **bind mount'ом, только на чтение** (та же схема, что и для остального персистентного состояния — раздел 9). Пути к файлам задаются переменными окружения (см. раздел 8). +2. **Потребление** — Postfix настроен читать эти файлы (`smtpd_tls_cert_file` / `smtpd_tls_key_file`) для TLS на 465 (`smtps`, основной), 587 (`submission`, если включён) и opportunistic TLS на порту 25. Один и тот же сертификат обслуживает оба порта приёма. +3. **Соответствие hostname** — CN/SAN сертификата должен совпадать с HELO-hostname (`SELFPOST_HOSTNAME`) и PTR/rDNS. Проще всего использовать общий hostname для панели и почты, тогда это один и тот же сертификат от reverse-proxy. +4. **Обновление** — когда reverse-proxy обновляет сертификат, файлы в смонтированной директории меняются, и Postfix нужно перечитать их через `postfix reload`. Чтобы деплой оставался простым, применяется **периодический `postfix reload` (например, раз в сутки)** — этого достаточно, т.к. сертификаты обновляются раз в ~2-3 месяца, а суточная задержка применения некритична. Реализовать как отдельную периодическую задачу под supervisord (или cron внутри контейнера). Inotify-вотчер на файл сертификата допустим как альтернатива, но периодический reload проще и надёжнее. + +Персистентность сертификатов (хранение, ACME-account key) — **ответственность reverse-proxy**, не SelfPost (см. раздел 9). Контейнер SelfPost хранит сертификаты только как read-only mount и не заботится об их выживании при рестарте. + +--- + +## 6. Компонент: OpenDKIM + +DKIM-подпись — **строго per-domain**. Каждый отправляющий домен имеет собственную пару ключей и селектор. + +1. **Генерация при добавлении домена** — панель создаёт для нового домена пару DKIM-ключей и селектор. Все ключи должны **переживать перезапуск контейнера** (см. раздел 9). +2. **Подпись** — исходящая почта каждого домена подписывается его собственным ключом. OpenDKIM настраивается с `KeyTable` + `SigningTable`, которые панель поддерживает в актуальном состоянии (запись на каждый домен), с последующим reload OpenDKIM. +3. **Показ DNS-записи per-domain** — панель показывает публичную часть ключа в формате DNS TXT-записи **для каждого домена отдельно**, чтобы пользователь внёс её в DNS соответствующего домена. +4. **Селектор** — на домен; может быть общим значением по умолчанию (например, `selfpost`) для всех доменов, т.к. селекторы живут в пространстве имён каждого домена и не конфликтуют. Значение по умолчанию — конфигурируемое. +5. **При удалении домена** — его ключ и записи в `KeyTable`/`SigningTable` удаляются, OpenDKIM перечитывается. + +--- + +## 7. Компонент: Панель управления + +### 7.1. Технологический стек + +- **Язык: Go.** Обоснование: статический бинарник (минимум рантайм-зависимостей в образе), компилятор ловит ошибки до развёртывания (важно, т.к. код пишет ИИ-агент), простая упаковка. +- **Зависимости — минимальные.** Приоритет стандартной библиотеки: `net/http`, `html/template`, `os/exec`. Внешние зависимости допускаются только для того, что не покрыто stdlib (например, bcrypt из `golang.org/x/crypto`). Каждая внешняя зависимость должна быть обоснована. +- HTTP-сервер слушает `:8080` внутри контейнера (HTTPS терминируется reverse-proxy, см. раздел 10). + +**Front-end:** + +- **Server-rendered** через `html/template`. Никакого SPA, JS-фреймворка (React/Vue/Svelte) или build-шага (webpack/vite/npm). Весь UI отдаётся тем же Go-бинарником — единая точка деплоя, ноль `node_modules`. Это требование, а не рекомендация: оно прямо вытекает из приоритета простоты поддержки. +- **HTMX** — единственная фронтенд-зависимость, подключается одним статическим ` + + + +
+{{if .User}}{{template "nav" .}}{{end}} +{{template "content" .}} +{{/* The running version, on every authenticated page: it is what a backup + manifest is checked against on restore and the first thing to establish + when something behaves unexpectedly. Only for signed-in administrators — + the login and setup pages must not advertise it to the internet. */}} +{{if .User}}
SelfPost {{.Version}}
{{end}} +
+ +{{end}} + +{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so + every authenticated page has it without the page's own template having to + remember to include it; .Active names the current page so it is highlighted + instead of linking to itself. Unauthenticated pages (login, setup) carry no + .User and get no nav. */}} +{{define "nav"}} + +{{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..fd7c416 --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,20 @@ +{{define "content"}} +

Sign in

+
+ {{if .SetupHint}} +

No administrator has been created yet. Open the one-time + setup link printed in the container log to get started.

+ {{else}} + {{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + +
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/logtail.html b/internal/web/templates/logtail.html new file mode 100644 index 0000000..cede8aa --- /dev/null +++ b/internal/web/templates/logtail.html @@ -0,0 +1,8 @@ +{{define "content"}} +

Mail log

+ +
+

Recent log entries

+ {{template "logtail_body" .}} +
+{{end}} diff --git a/internal/web/templates/logtail_body.html b/internal/web/templates/logtail_body.html new file mode 100644 index 0000000..80ac0f2 --- /dev/null +++ b/internal/web/templates/logtail_body.html @@ -0,0 +1,7 @@ +{{define "logtail_body"}} +
+ {{if .Error}}

{{.Error}}

{{end}} + {{if .Lines}}{{range .Lines}}{{.}} +{{end}}{{else}}No log lines yet.{{end}} +
+{{end}} diff --git a/internal/web/templates/queue.html b/internal/web/templates/queue.html new file mode 100644 index 0000000..5fd1c8a --- /dev/null +++ b/internal/web/templates/queue.html @@ -0,0 +1,8 @@ +{{define "content"}} +

Mail queue

+ +
+

Pending messages

+ {{template "queue_body" .}} +
+{{end}} diff --git a/internal/web/templates/queue_body.html b/internal/web/templates/queue_body.html new file mode 100644 index 0000000..90c56a0 --- /dev/null +++ b/internal/web/templates/queue_body.html @@ -0,0 +1,6 @@ +{{define "queue_body"}} +
+ {{if .Error}}

{{.Error}}

{{end}} + {{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}} +
+{{end}} diff --git a/internal/web/templates/sendlog.html b/internal/web/templates/sendlog.html new file mode 100644 index 0000000..30655f0 --- /dev/null +++ b/internal/web/templates/sendlog.html @@ -0,0 +1,24 @@ +{{define "content"}} +

Deliveries

+ +
+

Filter

+
+ + + + + +
+
+ +
+ {{template "sendlog_rows" .}} +
+{{end}} diff --git a/internal/web/templates/sendlog_rows.html b/internal/web/templates/sendlog_rows.html new file mode 100644 index 0000000..c45bd5c --- /dev/null +++ b/internal/web/templates/sendlog_rows.html @@ -0,0 +1,32 @@ +{{define "sendlog_rows"}} +
+ {{if .Rows}} + + + + + + {{range .Rows}} + + + + + + + + + + {{end}} + +
TimeDomainAppFromToSubjectStatus
{{.CreatedAt.Format "2006-01-02 15:04:05"}}{{.Domain}}{{.AppLogin}}{{.From}}{{.To}}{{.Subject}}{{.Status}}
+

+ Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}} + {{if .HasPrev}} · ← Newer{{end}} + {{if .HasNext}} · Older →{{end}} +

+ {{else}} +

No messages logged yet.

+ {{end}} +
+{{end}} diff --git a/internal/web/templates/setup.html b/internal/web/templates/setup.html new file mode 100644 index 0000000..cfaf015 --- /dev/null +++ b/internal/web/templates/setup.html @@ -0,0 +1,20 @@ +{{define "content"}} +

Create administrator

+
+

This one-time link creates the single panel administrator. + After you submit, the link stops working for good.

+ {{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + + + + + +
+
+{{end}} diff --git a/internal/web/templates/status.html b/internal/web/templates/status.html new file mode 100644 index 0000000..21472e6 --- /dev/null +++ b/internal/web/templates/status.html @@ -0,0 +1,43 @@ +{{define "content"}} +

Server status

+ +{{if .Flash}}
{{.Flash}}
{{end}} + +{{template "status_body" .}} + +
+

Hostname and reverse DNS {{.PTR.Status}}

+

Receiving servers check that the name this server announces + resolves to its address and that the address resolves back to the same + name (forward-confirmed reverse DNS). A missing or mismatched reverse record is + the most common reason self-hosted mail is rejected or scored as spam. The + reverse record is set at the hosting provider, not in the domain's DNS zone.

+ + + {{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}} + + {{if .PTR.Records}} + + {{range .PTR.Records}}{{.}} +{{end}} + {{end}} + +

{{.PTR.Detail}}

+ +
+ +
+
+ +
+

Configuration

+

Regenerates the OpenDKIM and Postfix configuration from the + database and reloads both daemons. Use it if you edited the files by hand, + restored a backup, or the running configuration looks out of step with the + domain and application lists. It does not touch the mail queue or the TLS + certificate, and it is safe to run at any time.

+
+ +
+
+{{end}} diff --git a/internal/web/templates/status_body.html b/internal/web/templates/status_body.html new file mode 100644 index 0000000..d44f47a --- /dev/null +++ b/internal/web/templates/status_body.html @@ -0,0 +1,65 @@ +{{define "status_body"}} +
+
+

Overall {{.OverallStatus}}

+

{{.OverallHeading}}

+
+ +
+

Processes {{.ProcessStatus}}

+ {{if .ProcessError}} +

Could not ask supervisord for the process list.

+ {{else}} + + + + {{range .Processes}} + + + + + + {{end}} + +
ProgramStateDetail
{{.Name}}{{.State}}{{.Detail}}
+ {{end}} +
+ +
+

Mail queue {{.QueueStatus}}

+ {{if .QueueError}} +

{{.QueueError}}

+ {{else}} +

{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}

+ Full queue + {{end}} +
+ +
+

TLS certificate {{.Cert.Status}}

+

The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}. + It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.

+ {{if not .Cert.NotAfter.IsZero}} + + {{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}} + {{end}} +

{{.Cert.Detail}}

+
+ +
+

Milter sockets {{.SocketStatus}}

+ + + + {{range .Sockets}} + + + + + + {{end}} + +
MilterSocketState
{{.Name}}{{.Path}}{{.Status}} {{.Detail}}
+
+
+{{end}} diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go new file mode 100644 index 0000000..d2dc33a --- /dev/null +++ b/internal/web/templates_test.go @@ -0,0 +1,283 @@ +package web + +import ( + "bytes" + "io/fs" + "net/http" + "net/http/httptest" + "path" + "regexp" + "strings" + "testing" + "time" + + "codeberg.org/mix/selfpost/internal/health" +) + +// The navigation is rendered from the layout, not copied into each page, so +// every page template must resolve it. This is what makes "the nav is on every +// authenticated page" a structural property instead of a checklist item. +func TestEveryPageResolvesNav(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + for name, page := range tmpl.pages { + if page.Lookup("nav") == nil { + t.Errorf("page %q does not resolve the shared nav template", name) + } + } +} + +// The version comes from render(), not from each handler's data map, so the +// footer is only correct as long as every page composes with the layout and +// render keeps supplying the key. Both are asserted here rather than trusted. +func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + rendered := 0 + for name := range tmpl.pages { + var buf bytes.Buffer + err := tmpl.pages[name].ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test", + }) + if err != nil { + // Pages whose content block needs more data than this cannot be + // rendered here; the footer is in the shared layout, so one page + // that does render proves it for all of them. + continue + } + rendered++ + if !strings.Contains(buf.String(), "SelfPost 9.9.9-test") { + t.Errorf("page %q does not show the version in the layout footer", name) + } + } + if rendered == 0 { + t.Fatal("no page rendered, so the footer was never actually checked") + } + + // Signed out (login, setup) the version must not be advertised. + var buf bytes.Buffer + if err := tmpl.pages["login"].ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "t", "Active": "", "Version": "9.9.9-test", + }); err != nil { + t.Fatalf("execute login: %v", err) + } + if strings.Contains(buf.String(), "9.9.9-test") { + t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", buf.String()) + } +} + +func TestRenderSuppliesTheVersion(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + s := &Server{tmpl: tmpl, cfg: Config{Version: "9.9.9-test"}} + rec := httptest.NewRecorder() + data := map[string]any{"Title": "t", "User": "admin"} + s.render(rec, http.StatusOK, "backup", data) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := data["Version"]; got != "9.9.9-test" { + t.Errorf("render did not supply Version (got %v)", got) + } + if !strings.Contains(rec.Body.String(), "SelfPost 9.9.9-test") { + t.Errorf("rendered page does not show the version:\n%s", rec.Body.String()) + } +} + +func TestNavMarksActivePage(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + var buf bytes.Buffer + err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{ + "User": "admin", + "Active": "queue", + }) + if err != nil { + t.Fatalf("execute nav: %v", err) + } + out := buf.String() + if !strings.Contains(out, `Mail queue`) { + t.Errorf("active page is not marked:\n%s", out) + } + if strings.Contains(out, `href="/queue"`) { + t.Errorf("active page still links to itself:\n%s", out) + } + if !strings.Contains(out, `href="/sendlog"`) { + t.Errorf("inactive pages are not linked:\n%s", out) + } +} + +func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + var buf bytes.Buffer + if err := tmpl.pages["status"].ExecuteTemplate(&buf, "nav", map[string]any{ + "User": "admin", + "Active": "status", + }); err != nil { + t.Fatalf("execute nav: %v", err) + } + out := buf.String() + if !strings.Contains(out, `Status`) { + t.Errorf("the status page is not marked active:\n%s", out) + } + if !strings.Contains(out, `href="/domains"`) { + t.Errorf("Domains does not link to /domains:\n%s", out) + } + if strings.Index(out, "Status") > strings.Index(out, "Domains") { + t.Errorf("Status is not the first navigation entry:\n%s", out) + } +} + +// Since the panel root now redirects to the status page, a link left pointing at +// "/" silently lands on the wrong screen instead of failing — so no template may +// contain one (phase 13.C). +func TestNoTemplateLinksToTheBareRoot(t *testing.T) { + forEachTemplate(t, func(name, body string) { + if strings.Contains(body, `href="/"`) { + t.Errorf(`%s links to "/", which is now the status redirect; link to /domains (or the intended page) instead`, name) + } + }) +} + +// The reload action is a server-health control and lives only on the status +// page (phase 13.D). +func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) { + forEachTemplate(t, func(name, body string) { + if strings.Contains(body, `action="/reload"`) && name != "status.html" { + t.Errorf("%s still posts to /reload; the reload control belongs on the status page", name) + } + }) +} + +// The panel's Content-Security-Policy is a plain default-src 'self' with no +// inline exemption (phase 14.A), which makes inline script and inline style a +// failure mode rather than a style question: an onclick= handler or a +// style="..." attribute added to a template does not error, it silently stops +// working in the browser. Behaviour belongs in static/panel.js (triggered from +// a data- attribute), appearance in static/panel.css. +func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) { + inlineHandler := regexp.MustCompile(`\son[a-z]+\s*=`) + inlineStyle := regexp.MustCompile(`\sstyle\s*=|]`) + scriptTag := regexp.MustCompile(`]*>`) + + forEachTemplate(t, func(name, body string) { + if m := inlineHandler.FindString(body); m != "" { + t.Errorf("%s has an inline event handler (%q); the CSP blocks it — move the behaviour into static/panel.js", + name, strings.TrimSpace(m)) + } + if m := inlineStyle.FindString(body); m != "" { + t.Errorf("%s has an inline style (%q); the CSP blocks it — move the rule into static/panel.css", + name, strings.TrimSpace(m)) + } + for _, tag := range scriptTag.FindAllString(body, -1) { + if !strings.Contains(tag, "src=") { + t.Errorf("%s has an inline script (%q); the CSP blocks it — put the code in static/panel.js", name, tag) + } + } + }) +} + +// default-src 'self' also means every asset a page pulls in must be one this +// server actually serves, so a typo in a /static path is a blocked request, +// not a 404 in the page's own colours. +func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) { + body, err := fs.ReadFile(assetsFS, "templates/layout.html") + if err != nil { + t.Fatalf("read layout: %v", err) + } + refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1) + if len(refs) == 0 { + t.Fatal("the layout references no static assets at all") + } + for _, m := range refs { + if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil { + t.Errorf("layout references /static/%s, which is not embedded: %v", m[1], err) + } + } +} + +func TestStatusPageRendersEveryCheck(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + var buf bytes.Buffer + err = tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "SelfPost — status", + "User": "admin", + "Active": "status", + "Processes": []health.Process{ + {Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK}, + {Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError}, + }, + "ProcessStatus": health.StatusError, + "QueueSummary": "Mail queue is empty", + "QueueStatus": health.StatusOK, + "Cert": health.Certificate{ + Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com", + NotAfter: time.Now().Add(30 * 24 * time.Hour), DaysLeft: 30, + Status: health.StatusOK, Detail: "Valid for another 30 day(s).", + }, + "Sockets": []health.Socket{ + {Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."}, + }, + "SocketStatus": health.StatusOK, + "OverallStatus": health.StatusError, + "OverallHeading": "A component needs attention — see the details below.", + "Hostname": "mail.example.com", + "PTR": dnscheckResult{ + Status: health.StatusError, + Detail: "No address has a reverse record.", + Records: []string{"203.0.113.10 → no PTR record"}, + }, + }) + if err != nil { + t.Fatalf("execute status page: %v", err) + } + out := buf.String() + for _, want := range []string{ + "opendkim", "FATAL", "Mail queue is empty", "mail.example.com", + "203.0.113.10 → no PTR record", `action="/reload"`, + `hx-get="/status/fragment"`, `class="st st-error"`, + } { + if !strings.Contains(out, want) { + t.Errorf("status page is missing %q", want) + } + } +} + +// dnscheckResult mirrors dnscheck.Result's shape for the template test, so the +// web package's template tests do not depend on the checker's constructor. +type dnscheckResult struct { + Status health.Status + Detail string + Records []string +} + +// forEachTemplate runs fn over every embedded template's source. +func forEachTemplate(t *testing.T, fn func(name, body string)) { + t.Helper() + entries, err := fs.ReadDir(assetsFS, "templates") + if err != nil { + t.Fatalf("read templates: %v", err) + } + for _, e := range entries { + body, err := fs.ReadFile(assetsFS, path.Join("templates", e.Name())) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + fn(e.Name(), string(body)) + } +} diff --git a/internal/web/token.go b/internal/web/token.go new file mode 100644 index 0000000..6e9ba7e --- /dev/null +++ b/internal/web/token.go @@ -0,0 +1,20 @@ +package web + +import ( + "crypto/rand" + "encoding/base64" +) + +// randomToken returns a URL-safe token with at least nBytes*8 bits of entropy +// drawn from crypto/rand. Setup and session tokens both use this; the setup +// token needs >=128 bits (spec 7.6.1), so callers pass nBytes >= 16. +// +// It panics if the system RNG fails: that is unrecoverable and must never be +// papered over with a weak fallback for a security token. +func randomToken(nBytes int) string { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/web/validate.go b/internal/web/validate.go new file mode 100644 index 0000000..c720d93 --- /dev/null +++ b/internal/web/validate.go @@ -0,0 +1,101 @@ +package web + +import ( + "fmt" + "strings" + "unicode" +) + +// minAdminPasswordLen is the floor for the administrator password. The panel is +// public (spec 7.6), so this is deliberately not tiny. +const minAdminPasswordLen = 12 + +const ( + minUsernameLen = 3 + maxUsernameLen = 64 +) + +// validateUsername enforces a strict server-side whitelist (spec 7.6.2): +// letters, digits, dot, dash, underscore. Client validation is never trusted. +func validateUsername(u string) error { + if len(u) < minUsernameLen || len(u) > maxUsernameLen { + return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen) + } + for _, r := range u { + if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') { + return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'") + } + } + return nil +} + +// validateAdminPassword enforces a minimum length. Composition rules beyond +// length tend to reduce entropy in practice, so length is the sole gate. +func validateAdminPassword(p string) error { + if len(p) < minAdminPasswordLen { + return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen) + } + return nil +} + +func isASCIILetterOrDigit(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') +} + +const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name + +// normalizeDomain lower-cases and trims a domain name. Domain names are +// case-insensitive, and the generated OpenDKIM tables/keys use the canonical +// lower-case form, so we normalise before both validation and storage. +func normalizeDomain(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// validateDomain enforces a strict server-side whitelist for sending-domain +// names (spec 7.6.2). The result is safe to write verbatim into the OpenDKIM +// KeyTable/SigningTable and to use as a filesystem path segment: only +// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label +// shape. Input must already be normalised with normalizeDomain. +// +// This is deliberately stricter than "any string DNS might accept" — no +// leading/trailing dots or hyphens, no empty or over-long labels, and at least +// two labels so single-word hostnames cannot be registered as sending domains. +func validateDomain(name string) error { + if name == "" { + return fmt.Errorf("domain is required") + } + if len(name) > maxDomainLen { + return fmt.Errorf("domain must be at most %d characters", maxDomainLen) + } + labels := strings.Split(name, ".") + if len(labels) < 2 { + return fmt.Errorf("domain must include at least one dot (e.g. example.com)") + } + for _, label := range labels { + if err := validateDomainLabel(label); err != nil { + return err + } + } + return nil +} + +func validateDomainLabel(label string) error { + if len(label) == 0 { + return fmt.Errorf("domain must not contain an empty label") + } + if len(label) > 63 { + return fmt.Errorf("each domain label must be at most 63 characters") + } + if label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("domain labels must not start or end with '-'") + } + for i := 0; i < len(label); i++ { + c := label[i] + lower := c >= 'a' && c <= 'z' + digit := c >= '0' && c <= '9' + if !lower && !digit && c != '-' { + return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'") + } + } + return nil +} diff --git a/internal/web/validate_test.go b/internal/web/validate_test.go new file mode 100644 index 0000000..b7556e4 --- /dev/null +++ b/internal/web/validate_test.go @@ -0,0 +1,67 @@ +package web + +import "testing" + +func TestNormalizeDomain(t *testing.T) { + cases := map[string]string{ + " Example.COM ": "example.com", + "MAIL.Example.Org": "mail.example.org", + "example.com": "example.com", + } + for in, want := range cases { + if got := normalizeDomain(in); got != want { + t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateDomainValid(t *testing.T) { + valid := []string{ + "example.com", + "mail.example.com", + "a.co", + "sub-domain.example.co.uk", + "x1.y2.z3", + "1example.com", + } + for _, d := range valid { + if err := validateDomain(d); err != nil { + t.Errorf("validateDomain(%q) unexpected error: %v", d, err) + } + } +} + +func TestValidateDomainInvalid(t *testing.T) { + invalid := []string{ + "", // empty + "localhost", // single label + "example", // single label + ".example.com", // leading dot -> empty label + "example.com.", // trailing dot -> empty label + "exa mple.com", // space + "example..com", // empty label + "-example.com", // label starts with '-' + "example-.com", // label ends with '-' + "example.com\n", // newline (config injection attempt) + "exa*mple.com", // disallowed char + "exa_mple.com", // underscore not allowed in domains + "Example.com", // upper-case (must be normalised first) + "пример.рф", // non-ASCII + "example.c/m", // slash (path-traversal attempt) + } + for _, d := range invalid { + if err := validateDomain(d); err == nil { + t.Errorf("validateDomain(%q) = nil, want error", d) + } + } +} + +func TestValidateDomainLongLabelRejected(t *testing.T) { + label := make([]byte, 64) + for i := range label { + label[i] = 'a' + } + if err := validateDomain(string(label) + ".com"); err == nil { + t.Error("expected error for over-long label") + } +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..e075252 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,248 @@ +// Package web implements the SelfPost control panel's HTTP surface: the +// one-time administrator setup flow (spec 7.6.1), login/session handling +// (spec 7.6.5-6) and the authenticated shell the later phases build on. +package web + +import ( + "embed" + "log" + "net" + "net/http" + "strings" + "time" + + "codeberg.org/mix/selfpost/internal/app" + "codeberg.org/mix/selfpost/internal/dnscheck" + "codeberg.org/mix/selfpost/internal/domain" + "codeberg.org/mix/selfpost/internal/store" +) + +//go:embed templates/*.html static/* +var assetsFS embed.FS + +// Config holds the panel's HTTP-facing configuration. +type Config struct { + // Hostname is the server's external hostname, used to build the absolute + // setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME). + Hostname string + // CookieSecure sets the Secure attribute on the session cookie. It defaults + // to true (spec 7.6.6); it exists as a knob only so the panel can be tested + // over plain HTTP in development, never for production. + CookieSecure bool + // SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also + // runs the 587/STARTTLS submission listener next to the primary 465 one + // (spec 5). The panel only reports it on the domain page's connection + // settings; it is a deploy-time flag, not something the panel can verify. + SubmissionEnabled bool + // MailLogPath is where Postfix's delivery log lives, read by the mail.log + // monitoring view (spec 7.2.13). It is the same path the log-tailer role + // follows in cmd/panel. + MailLogPath string + // DataDir and DBPath locate the persistent state a full backup archives + // (spec 7.5.A); Version is stamped into the backup manifest. They mirror the + // panel's own configuration. + DataDir string + DBPath string + Version string + // TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply + // X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose + // direct peer (RemoteAddr) is not in this list never has its XFF header + // honoured, so the header can't be spoofed by anyone but a trusted proxy. + // Empty (the default) keeps rate-limiting keyed on RemoteAddr only. + TrustedProxyCIDRs []*net.IPNet + // TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read + // read-only by the status page to report how much validity is left. + TLSCertFile string + // OpenDKIMSocket and JournalSocket are the two milter sockets Postfix + // connects to. The status page stats them: the first is required for mail + // to leave at all (OpenDKIM runs with default_action=tempfail), the second + // only for the send log (the journal-milter fails open). + OpenDKIMSocket string + JournalSocket string + // SessionIdleDays is the sliding inactivity window after which a login + // session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive + // falls back to the 7-day default. + SessionIdleDays int +} + +// Server is the panel HTTP application. +type Server struct { + store *store.Store + domains *domain.Service + apps *app.Service + cfg Config + tmpl *templates + sessions *sessionStore + setup *setupManager + dns *dnscheck.Checker + + loginLimiter *rateLimiter + setupLimiter *rateLimiter + + trustedProxies []*net.IPNet +} + +// New builds the panel server. setupTokenPath is where the current setup token +// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that +// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL +// accounts and the Postfix sender map (spec 5.1). +func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { + tmpl, err := loadTemplates() + if err != nil { + return nil, err + } + idleDays := cfg.SessionIdleDays + if idleDays <= 0 { + idleDays = 7 + } + s := &Server{ + store: st, + domains: domains, + apps: apps, + cfg: cfg, + tmpl: tmpl, + sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour), + // Published-DNS checks for the status page and the domain pages. The + // checker caches its own results, so page views do not each pay for a + // round of lookups (phase 13). + dns: dnscheck.New(), + // Setup: a handful of attempts per minute per IP is plenty for a + // legitimate admin and blunts automated probing (spec 7.6.1). + setupLimiter: newRateLimiter(10, time.Minute), + // Login: throttle brute-force by IP (spec 7.6.5). + loginLimiter: newRateLimiter(10, 15*time.Minute), + + trustedProxies: cfg.TrustedProxyCIDRs, + } + s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath) + return s, nil +} + +// Start performs first-run bootstrapping: if there is no administrator yet, it +// generates and announces the setup link (spec 7.6.1). Safe to call once at +// server startup. +func (s *Server) Start() error { + return s.setup.bootstrap() +} + +// Handler returns the panel's HTTP handler (router). +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Health check stays unauthenticated for the container/orchestrator. + mux.HandleFunc("/healthz", handleHealth) + + // Vendored static assets (HTMX). Served from the embedded FS. + mux.Handle("/static/", http.FileServer(http.FS(assetsFS))) + + // One-time administrator setup (spec 7.6.1). + mux.HandleFunc("/setup/", s.handleSetup) + + // Authentication. + mux.HandleFunc("/login", s.handleLogin) + mux.HandleFunc("/logout", s.handleLogout) + + // Authenticated panel. Everything not matched by a more specific pattern + // above falls through to this sub-mux, wrapped once in the auth middleware. + authed := http.NewServeMux() + + // The landing page is the server status (phase 13.C): the first thing an + // administrator should see after logging in is whether the service is + // healthy, not the domain list. handleLogin still redirects to "/". + authed.HandleFunc("GET /{$}", redirectToStatus) + authed.HandleFunc("GET /status", s.handleStatus) + authed.HandleFunc("GET /status/fragment", s.handleStatusFragment) + authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck) + + authed.HandleFunc("GET /domains", s.handleDashboard) + authed.HandleFunc("POST /domains", s.handleAddDomain) + authed.HandleFunc("POST /domains/import", s.handleImportDomain) + authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) + authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck) + authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) + authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) + authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) + authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit) + authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain) + authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode) + authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword) + authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit) + authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) + authed.HandleFunc("POST /reload", s.handleReload) + + // Administrator's own panel credentials. + authed.HandleFunc("/account", s.handleAccount) + + // Backup and migration: the page with both actions (spec 7.5.A-B), and the + // full-server backup download itself. + authed.HandleFunc("GET /backup", s.handleBackupPage) + authed.HandleFunc("POST /backup", s.handleBackup) + + // Monitoring screens (spec 7.2.11-13): each page and its HTMX polling + // fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON). + authed.HandleFunc("GET /sendlog", s.handleSendLog) + authed.HandleFunc("GET /sendlog/rows", s.handleSendLogRows) + authed.HandleFunc("GET /queue", s.handleQueue) + authed.HandleFunc("GET /queue/body", s.handleQueueBody) + authed.HandleFunc("GET /logtail", s.handleLogTail) + authed.HandleFunc("GET /logtail/body", s.handleLogTailBody) + + mux.Handle("/", s.requireAuth(authed)) + + // Security headers and the origin check wrap everything, including the + // unauthenticated login and setup routes (phase 14.A). + return s.secure(mux) +} + +// redirectToStatus points the panel root at the status page, so there is one +// canonical URL for that content instead of two (phase 13.C). +func redirectToStatus(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/status", http.StatusSeeOther) +} + +func handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) +} + +// clientIP extracts the peer IP for rate-limiting. By default it is the +// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches +// one of trustedProxies, the last entry of X-Forwarded-For is used instead — +// that is the address the trusted proxy itself appended, so a client can't +// forge it by sending its own XFF header. With no trusted +// proxies configured, behind a reverse proxy this is the proxy's own address, +// which is an acceptable backstop for a single-admin panel. +func clientIP(r *http.Request, trustedProxies []*net.IPNet) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + if len(trustedProxies) > 0 { + if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil { + return ip.String() + } + } + } + } + + return host +} + +func ipInAny(ip net.IP, nets []*net.IPNet) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + +// logf is a thin wrapper so handlers log with a consistent prefix. +func logf(format string, args ...any) { + log.Printf(format, args...) +} diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml new file mode 100644 index 0000000..e913561 --- /dev/null +++ b/test/e2e/compose.override.yml @@ -0,0 +1,82 @@ + +# Hermetic e2e stand override for ../../deploy/docker-compose.yml (plan C.4). +# +# Applied as a second -f on top of the SHIPPED compose file, not a separate +# test compose — the whole point is to exercise cap_drop/cap_add/ +# no-new-privileges exactly as documented, not a laxer stand-in. Run only via +# `make e2e`, which also sets --project-directory to a scratch stage +# directory so the base file's relative ./data and ./certs bind mounts land +# there instead of next to a real deployment, and -p selfpost-e2e so the +# containers/network never collide with a prod stack on the same host. +# +# `ports:` and `networks:` use the compose-spec `!override` merge tag to fully +# replace the base file's list rather than concatenate with it (the default +# merge behaviour for sequences) — otherwise the real 465/587/8080 would stay +# published here too and collide with a prod container on the same host. + +services: + selfpost: + build: + # Resolved relative to --project-directory (.stage), NOT this file's own + # directory — compose build.context paths follow the project directory, + # unlike bind-mount volume paths, which is the same reason ./data and + # ./certs above need no path here at all. + context: ../../.. + dockerfile: build/Dockerfile + args: + VERSION: e2e + image: selfpost:e2e + environment: + SELFPOST_HOSTNAME: mail.e2e.test + # Plain HTTP panel for the test client — no reverse proxy in this stand. + PANEL_COOKIE_SECURE: "false" + # High enough that the whole suite's other ~10 sends (positive path, + # level-2 test, the various rejected attempts) never trip it by + # accident — only the dedicated level-1 test (negative check 4, which + # loops well past this count) is meant to hit it. + RATE_LIMIT_MESSAGES_PER_IP: "50" + RATE_LIMIT_WINDOW_SECONDS: "60" + ports: !override + - "20465:465" + - "20587:587" + - "127.0.0.1:20080:8080" + dns: + - 10.77.0.10 + networks: !override + e2e: + depends_on: + - coredns + - sink + + # Fake authoritative DNS for e2e.test (plan C.4): serves the DKIM/host + # records the test harness publishes, so Postfix's outbound MX lookup for + # the sink and the panel's own dnscheck resolve inside the isolated network + # instead of hitting the real internet. + coredns: + image: coredns/coredns:latest + command: ["-conf", "/dns/Corefile"] + volumes: + - ./dns-stage:/dns + networks: + e2e: + ipv4_address: 10.77.0.10 + ports: + - "20053:53/udp" + + # Sink-MX: accepts any inbound mail and dumps each transaction to its own + # file under ./mail-stage, which the harness reads directly to check the + # DKIM-Signature header Postfix/OpenDKIM produced. + sink: + build: + context: ../sink + volumes: + - ./mail-stage:/mail + networks: + e2e: + ipv4_address: 10.77.0.11 + +networks: + e2e: + ipam: + config: + - subnet: 10.77.0.0/24 diff --git a/test/e2e/dns/Corefile b/test/e2e/dns/Corefile new file mode 100644 index 0000000..89c2eb5 --- /dev/null +++ b/test/e2e/dns/Corefile @@ -0,0 +1,16 @@ +# Authoritative fake zone for the e2e stand (plan C.4). Only e2e.test and its +# subdomains are served — everything else is refused, which is fine: neither +# the panel nor Postfix ever needs to resolve anything outside this zone here. +# +# The `file` plugin's own `reload` sub-directive (NOT the top-level `reload` +# plugin, which only watches the Corefile itself) polls db.zone's mtime/serial +# and reloads with no signal needed, so the test harness can rewrite the zone +# file (to publish a domain's DKIM record) and just poll until the new record +# is visible — no container restart, no sleep. +e2e.test { + file /dns/db.zone { + reload 1s + } + log + errors +} diff --git a/test/e2e/dns_zone.go b/test/e2e/dns_zone.go new file mode 100644 index 0000000..82a8420 --- /dev/null +++ b/test/e2e/dns_zone.go @@ -0,0 +1,120 @@ +package e2e + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// dnsAddr is where compose.override.yml publishes CoreDNS's UDP port, so the +// test process (running on the host, not inside the e2e network) can resolve +// through the same fake zone Postfix and the panel see via the `dns:` compose +// directive pointing containers at CoreDNS's static 10.77.0.10. +const dnsAddr = "127.0.0.1:20053" + +// e2eResolver returns a *net.Resolver that only ever talks to the fake zone's +// CoreDNS, for both direct assertions in tests and as the DKIM verifier's +// LookupTXT. +func e2eResolver() *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, dnsAddr) + }, + } +} + +// txtRecord is one name -> TXT value pair the harness publishes into the fake +// zone (the DKIM record a domain's page told us to publish). +type txtRecord struct { + name string // relative to e2e.test, e.g. "selfpost._domainkey.sender" + value string +} + +// writeZone (re)writes the whole authoritative db.zone for e2e.test: the fixed +// records CoreDNS/sink need to be reachable, plus whatever TXT records the +// test has published so far. CoreDNS's `reload 2s` (see dns/Corefile) picks up +// the new mtime with no signal needed. +func writeZone(stageDir string, extra []txtRecord) error { + var b strings.Builder + fmt.Fprintf(&b, "$ORIGIN e2e.test.\n$TTL 300\n") + fmt.Fprintf(&b, "@ IN SOA ns.e2e.test. admin.e2e.test. ( %d 3600 900 604800 300 )\n", time.Now().Unix()) + fmt.Fprintf(&b, "@ IN NS ns.e2e.test.\n") + fmt.Fprintf(&b, "ns IN A 10.77.0.10\n") + // The sink-MX: Postfix's outbound delivery for the recipient domain used by + // every positive-path send resolves here via the domain's implicit MX + // fallback to its own A record (RFC 5321) — no explicit MX record needed. + fmt.Fprintf(&b, "sink IN A 10.77.0.11\n") + for _, r := range extra { + fmt.Fprintf(&b, "%s IN TXT %s\n", r.name, chunkTXT(r.value)) + } + return os.WriteFile(filepath.Join(stageDir, "dns-stage", "db.zone"), []byte(b.String()), 0o644) +} + +// chunkTXT splits a TXT value into <255-byte quoted strings (the DNS +// limit), which resolvers concatenate back into one value. +// A DKIM RSA-2048 public key's base64 comfortably exceeds 255 bytes on its +// own, so this is required, not defensive. +func chunkTXT(value string) string { + const max = 255 + var parts []string + for len(value) > max { + parts = append(parts, value[:max]) + value = value[max:] + } + parts = append(parts, value) + var quoted []string + for _, p := range parts { + quoted = append(quoted, `"`+strings.ReplaceAll(p, `"`, `\"`)+`"`) + } + return strings.Join(quoted, " ") +} + +// publishTXT rewrites the zone with rec added/replacing any prior record of +// the same name, then blocks until CoreDNS actually serves the new value — +// so the caller never has to sleep-and-hope for the reload interval to pass. +func publishTXT(stageDir string, existing []txtRecord, rec txtRecord) ([]txtRecord, error) { + updated := make([]txtRecord, 0, len(existing)+1) + for _, r := range existing { + if r.name != rec.name { + updated = append(updated, r) + } + } + updated = append(updated, rec) + if err := writeZone(stageDir, updated); err != nil { + return nil, err + } + res := e2eResolver() + fqdn := rec.name + ".e2e.test" + err := waitFor(fmt.Sprintf("CoreDNS to serve TXT %s", fqdn), 20*time.Second, 300*time.Millisecond, func() (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + txt, err := res.LookupTXT(ctx, fqdn) + if err != nil { + return false, err + } + for _, t := range txt { + if t == rec.value { + return true, nil + } + } + return false, fmt.Errorf("got %v, want %q", txt, rec.value) + }) + return updated, err +} + +// lookupTXTFunc adapts e2eResolver to the shape dkim.VerifyOptions.LookupTXT +// wants (a synchronous domain -> []string call, no context). +func lookupTXTFunc() func(string) ([]string, error) { + res := e2eResolver() + return func(domain string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return res.LookupTXT(ctx, domain) + } +} diff --git a/test/e2e/go.mod b/test/e2e/go.mod new file mode 100644 index 0000000..e88d05d --- /dev/null +++ b/test/e2e/go.mod @@ -0,0 +1,7 @@ +module codeberg.org/mix/selfpost/test/e2e + +go 1.26 + +require github.com/emersion/go-msgauth v0.6.8 + +require golang.org/x/crypto v0.15.0 // indirect diff --git a/test/e2e/go.sum b/test/e2e/go.sum new file mode 100644 index 0000000..2f7855e --- /dev/null +++ b/test/e2e/go.sum @@ -0,0 +1,4 @@ +github.com/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A= +github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= diff --git a/test/e2e/hostname_gate_test.go b/test/e2e/hostname_gate_test.go new file mode 100644 index 0000000..f44b0e3 --- /dev/null +++ b/test/e2e/hostname_gate_test.go @@ -0,0 +1,101 @@ +package e2e + +import ( + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// runEntrypoint runs the selfpost:e2e image's entrypoint standalone (no +// compose, no DNS/sink sidecars — the SELFPOST_HOSTNAME gate in +// build/entrypoint.sh runs and fails before anything else needs them) with +// hostnameEnv substituted for SELFPOST_HOSTNAME, and returns its combined +// output and whether it exited zero. +func runEntrypoint(t *testing.T, hostnameEnv string) (output string, exitedZero bool) { + t.Helper() + dataDir := t.TempDir() + + args := []string{ + "run", "--rm", + "-e", "SELFPOST_HOSTNAME=" + hostnameEnv, + "-v", dataDir + ":/data", + "selfpost:e2e", + } + cmd := exec.Command("docker", args...) + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + return string(out), err == nil +} + +// TestHostnameGate is plan C.4 negative check 7: an empty or syntactically +// invalid SELFPOST_HOSTNAME must fail the container fast with an explanatory +// message (plan B.3), never a silent bad fallback. It runs the image directly +// rather than through the shared stand — the whole point is to exercise +// entrypoint.sh's gate before anything else in the container has a chance to +// start, so it does not depend on TestE2E's stack at all. +func TestHostnameGate(t *testing.T) { + cases := []struct { + name string + hostname string + want string + }{ + {"empty", "", "SELFPOST_HOSTNAME is not set"}, + {"scheme_and_port", "https://mail.example.com:465", "must be a bare hostname"}, + {"bare_word_no_dot", "localhost", "fully-qualified domain name"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out, ok := runEntrypoint(t, c.hostname) + if ok { + t.Fatalf("container started with SELFPOST_HOSTNAME=%q, want a fatal exit\noutput:\n%s", c.hostname, out) + } + if !strings.Contains(out, c.want) { + t.Fatalf("SELFPOST_HOSTNAME=%q: output does not mention %q:\n%s", c.hostname, c.want, out) + } + }) + } + + t.Run("valid_hostname_starts", func(t *testing.T) { + out, ok := runEntrypointBackground(t, "mail.example.test") + if !ok { + t.Fatalf("container with a valid SELFPOST_HOSTNAME failed to start:\n%s", out) + } + }) +} + +// runEntrypointBackground starts the container detached and confirms +// supervisord came up (rather than crash-looping) within a short timeout, then +// removes it — the positive control for the gate cases above: a valid +// hostname must not be caught by the same checks. +func runEntrypointBackground(t *testing.T, hostnameEnv string) (output string, started bool) { + t.Helper() + dataDir := t.TempDir() + certDir := t.TempDir() + if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil { + t.Fatalf("generate throwaway TLS cert: %v", err) + } + name := "selfpost-e2e-hostname-check" + _ = exec.Command("docker", "rm", "-f", name).Run() + defer exec.Command("docker", "rm", "-f", name).Run() + + up := exec.Command("docker", "run", "-d", "--name", name, + "-e", "SELFPOST_HOSTNAME="+hostnameEnv, + "-v", dataDir+":/data", + "-v", certDir+":/etc/postfix/tls:ro", + "selfpost:e2e") + if out, err := up.CombinedOutput(); err != nil { + return string(out), false + } + + err := waitFor("supervisord to report RUNNING processes", 20*time.Second, 300*time.Millisecond, func() (bool, error) { + out, err := exec.Command("docker", "exec", name, "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status").CombinedOutput() + if strings.Contains(string(out), "RUNNING") { + return true, nil + } + return false, err + }) + logs, _ := exec.Command("docker", "logs", name).CombinedOutput() + return string(logs), err == nil +} diff --git a/test/e2e/mail_helpers.go b/test/e2e/mail_helpers.go new file mode 100644 index 0000000..2ac9ba8 --- /dev/null +++ b/test/e2e/mail_helpers.go @@ -0,0 +1,86 @@ +package e2e + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/emersion/go-msgauth/dkim" +) + +// findSinkMessage polls the sink-MX's dump directory (see test/e2e/sink) for +// a file whose contents contain token — the unique Subject-line marker every +// test send carries — and returns it. Polling a directory listing rather than +// sleeping a fixed duration is what keeps this deterministic even under a +// slow CI runner (plan C.4). +func findSinkMessage(stageDir, token string, timeout time.Duration) ([]byte, error) { + dir := filepath.Join(stageDir, "mail-stage") + var found []byte + err := waitFor(fmt.Sprintf("sink-MX to receive a message tagged %q", token), timeout, 300*time.Millisecond, func() (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, e := range entries { + if e.IsDir() { + continue + } + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + if bytes.Contains(b, []byte(token)) { + found = b + return true, nil + } + } + return false, fmt.Errorf("no dumped message contains %q yet (%d on disk)", token, len(entries)) + }) + return found, err +} + +// verifyDKIM checks that raw (a message dumped by the sink) carries a DKIM +// signature for domain that validates against the public key published in the +// fake zone — i.e. exactly what a real receiver would check, using DNS the +// harness itself controls instead of net.DefaultResolver (plan C.4). +func verifyDKIM(raw []byte, domain string) error { + verifications, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{ + LookupTXT: lookupTXTFunc(), + }) + if err != nil { + return fmt.Errorf("dkim verify: %w", err) + } + for _, v := range verifications { + if v.Domain == domain { + if v.Err != nil { + return fmt.Errorf("dkim signature for %s did not validate: %w", domain, v.Err) + } + return nil + } + } + return fmt.Errorf("no DKIM signature found for domain %s (got %d signature(s))", domain, len(verifications)) +} + +// connectFromPattern matches Postfix's smtpd "connect from ...[ADDR]" log +// line, which is how the harness learns the source address Postfix itself +// observed for a just-made connection (needed to configure a level-2 rate +// limit's allowed-IPs list — the address a host-published port is seen as +// inside the container depends on Docker's NAT and isn't worth hard-coding). +var connectFromPattern = regexp.MustCompile(`connect from [^\[]*\[([0-9a-fA-F.:]+)\]`) + +// lastSMTPClientIP reads mail.log inside the selfpost container and returns +// the most recent address Postfix's smtpd logged a connection from. +func lastSMTPClientIP(s *stack) (string, error) { + out, err := s.execIn("selfpost", "tail", "-n", "200", "/var/log/mail.log") + if err != nil { + return "", err + } + matches := connectFromPattern.FindAllStringSubmatch(out, -1) + if len(matches) == 0 { + return "", fmt.Errorf("no \"connect from\" line in mail.log yet") + } + return matches[len(matches)-1][1], nil +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go new file mode 100644 index 0000000..a445a3a --- /dev/null +++ b/test/e2e/main_test.go @@ -0,0 +1,236 @@ +// Package e2e is the hermetic container e2e gate (plan C.4): it drives the +// shipped deploy/docker-compose.yml (plus a test-only override) exactly as an +// administrator and their applications would, so the class of failure unit +// tests cannot see — broken container wiring — has one place to be caught +// before an image is published. +// +// It is a separate module on purpose (see ../../docs/implementation-plan.md, +// item C.4): `go test ./...` in the main module never pulls this in, and its +// test-only dependencies (DKIM verification) never enter the shipped +// binaries' build graph. +package e2e + +import ( + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" +) + +var statusCellPattern = regexp.MustCompile(`(queued|sent|deferred|bounced|rejected)`) + +// h is the single shared stand for the whole ordered scenario in TestE2E. +// TestHostnameGate does not use it — it spins its own disposable container. +var h *stack + +func TestMain(m *testing.M) { + s, err := newStack() + if err != nil { + fmt.Fprintln(os.Stderr, "e2e: build stack:", err) + os.Exit(1) + } + h = s + + if err := prepareStage(s); err != nil { + fmt.Fprintln(os.Stderr, "e2e: prepare stage:", err) + os.Exit(1) + } + if err := s.build(); err != nil { + fmt.Fprintln(os.Stderr, "e2e: build:", err) + os.Exit(1) + } + if err := s.up(); err != nil { + fmt.Fprintln(os.Stderr, "e2e: up:", err) + s.down() + os.Exit(1) + } + + code := m.Run() + + if code != 0 { + for _, svc := range []string{"selfpost", "coredns", "sink"} { + fmt.Fprintf(os.Stderr, "\n==== logs: %s ====\n%s\n", svc, s.logs(svc)) + } + } + s.down() + os.Exit(code) +} + +// senderDomain and recipient are fixed for the whole run: one sending domain +// registered in the panel, and the sink-MX as the single recipient every +// positive send targets. +const ( + senderDomain = "sender.e2e.test" + recipient = "rcpt@sink.e2e.test" +) + +// scenario carries state forward between the ordered subtests of TestE2E: +// each later step needs something an earlier one produced (the domain id, the +// DKIM record, the application credentials, an observed client IP). +type scenario struct { + panel *panelClient + + domainID string + dkimName string + dkimValue string + dkimZoneRelName string // dkimName without the trailing ".e2e.test." + appLogin string + appPassword string + l2AppLogin string + l2AppPassword string + clientIP string + zoneRecords []txtRecord +} + +func TestE2E(t *testing.T) { + sc := &scenario{} + + t.Run("startup_processes_running", func(t *testing.T) { + if err := checkSupervisorProcesses(h); err != nil { + t.Fatal(err) + } + }) + + t.Run("setup_and_login", func(t *testing.T) { + token, err := readSetupToken(h.stageDir) + if err != nil { + t.Fatalf("read setup token: %v", err) + } + p, err := newPanelClient() + if err != nil { + t.Fatal(err) + } + if err := p.setup(token, "e2eadmin", "correct-horse-battery-staple"); err != nil { + t.Fatalf("setup: %v", err) + } + if err := p.login("e2eadmin", "correct-horse-battery-staple"); err != nil { + t.Fatalf("login: %v", err) + } + sc.panel = p + }) + + t.Run("add_domain_and_publish_dkim", func(t *testing.T) { + id, err := sc.panel.addDomain(senderDomain) + if err != nil { + t.Fatalf("add domain: %v", err) + } + sc.domainID = id + + name, value, err := sc.panel.dkimRecord(id) + if err != nil { + t.Fatalf("read dkim record: %v", err) + } + sc.dkimName, sc.dkimValue = name, value + + relName, ok := trimZoneSuffix(name) + if !ok { + t.Fatalf("dkim record name %q is not under e2e.test", name) + } + sc.dkimZoneRelName = relName + + records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: relName, value: value}) + if err != nil { + t.Fatalf("publish dkim record to fake zone: %v", err) + } + sc.zoneRecords = records + }) + + t.Run("add_application", func(t *testing.T) { + login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "") + if err != nil { + t.Fatalf("add application: %v", err) + } + sc.appLogin, sc.appPassword = login, password + }) + + t.Run("send_verify_dkim_and_status", func(t *testing.T) { + token := uniqueToken("positive") + res := attemptSend(sendAttempt{ + authLogin: sc.appLogin, authPassword: sc.appPassword, + from: "alerts@" + senderDomain, to: recipient, + subject: "e2e " + token, body: "hello from selfpost e2e", + }) + if !res.ok() { + t.Fatalf("positive-path send failed: dial=%v auth=%v mail=%v rcpt=%v data=%v", + res.dialErr, res.authErr, res.mailErr, res.rcptErr, res.dataErr) + } + + raw, err := findSinkMessage(h.stageDir, token, 30*time.Second) + if err != nil { + t.Fatalf("message never reached the sink: %v", err) + } + if err := verifyDKIM(raw, senderDomain); err != nil { + t.Fatalf("DKIM did not verify against the record the panel published: %v", err) + } + + if err := waitFor("send-log row to reach status=sent", 30*time.Second, 500*time.Millisecond, func() (bool, error) { + rows, err := sc.panel.sendLogRows(senderDomain) + if err != nil { + return false, err + } + if containsCell(rows, "sent") { + return true, nil + } + return false, fmt.Errorf("send log not yet at status=sent (still %s)", firstStatusCell(rows)) + }); err != nil { + t.Fatal(err) + } + }) + + t.Run("negative_level2_ratelimit_via_panel", func(t *testing.T) { + testLevel2RateLimit(t, sc) + }) + t.Run("negative_sender_login_mismatch", func(t *testing.T) { + testSenderLoginMismatch(t, sc) + }) + t.Run("negative_no_auth_rejected", func(t *testing.T) { + testNoAuthRejected(t, sc) + }) + t.Run("negative_foreign_relay_rejected", func(t *testing.T) { + testForeignRelayRejected(t, sc) + }) + t.Run("negative_journal_milter_fail_open", func(t *testing.T) { + testJournalMilterFailOpen(t, sc) + }) + t.Run("session_survives_restart", func(t *testing.T) { + testSessionSurvivesRestart(t, sc) + }) + t.Run("negative_level1_ratelimit", func(t *testing.T) { + testLevel1RateLimit(t, sc) + }) +} + +// trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record +// name carries, returning the name relative to the zone db.zone's $ORIGIN +// expects (see dns_zone.go). +func trimZoneSuffix(fqdn string) (string, bool) { + const suffix = ".e2e.test" + if len(fqdn) > len(suffix) && fqdn[len(fqdn)-len(suffix):] == suffix { + return fqdn[:len(fqdn)-len(suffix)], true + } + return "", false +} + +var tokenCounter int + +// uniqueToken returns a short, human-readable, monotonically distinct marker +// safe to embed in a Subject header and grep for in the sink's dump +// directory. +func uniqueToken(label string) string { + tokenCounter++ + return fmt.Sprintf("%s-%d-%d", label, time.Now().UnixNano(), tokenCounter) +} + +func containsCell(html, needle string) bool { + return strings.Contains(html, ""+needle+"") +} + +func firstStatusCell(html string) string { + m := statusCellPattern.FindStringSubmatch(html) + if m == nil { + return "(no rows yet)" + } + return m[1] +} diff --git a/test/e2e/negative_test.go b/test/e2e/negative_test.go new file mode 100644 index 0000000..c828be9 --- /dev/null +++ b/test/e2e/negative_test.go @@ -0,0 +1,224 @@ +package e2e + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// testLevel2RateLimit is plan C.4 negative check 5: a limit set through the +// panel (not the environment) rejects the message that exceeds it, and the +// rejection is visible in the send log — the panel -> DB -> milter path. +func testLevel2RateLimit(t *testing.T, sc *scenario) { + ip, err := lastSMTPClientIP(h) + if err != nil { + t.Fatalf("determine observed SMTP client IP: %v", err) + } + sc.clientIP = ip + + login, password, err := sc.panel.addApplication(sc.domainID, "l2app", "wildcard", "") + if err != nil { + t.Fatalf("add application: %v", err) + } + sc.l2AppLogin, sc.l2AppPassword = login, password + + appID, err := sc.panel.applicationID(sc.domainID, login) + if err != nil { + t.Fatal(err) + } + if err := sc.panel.setRateLimit(fmt.Sprintf("/applications/%s/ratelimit", appID), ip, 1, 3600); err != nil { + t.Fatalf("save application rate limit: %v", err) + } + + from := "billing@" + senderDomain + + first := attemptSend(sendAttempt{ + authLogin: login, authPassword: password, + from: from, to: recipient, subject: "e2e " + uniqueToken("l2-first"), body: "ok", + }) + if !first.ok() { + t.Fatalf("first message under the level-2 limit was rejected: %v", first.firstErr()) + } + + second := attemptSend(sendAttempt{ + authLogin: login, authPassword: password, + from: from, to: recipient, subject: "e2e " + uniqueToken("l2-second"), body: "should be rejected", + }) + if second.ok() { + t.Fatal("second message exceeding the level-2 limit was accepted, want rejected") + } + if second.mailErr == nil { + t.Fatalf("expected the level-2 rejection at MAIL FROM, got: dial=%v auth=%v rcpt=%v data=%v", + second.dialErr, second.authErr, second.rcptErr, second.dataErr) + } + + if err := waitFor("a rejected row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) { + rows, err := sc.panel.sendLogRows(senderDomain) + if err != nil { + return false, err + } + if strings.Contains(rows, "l2app") && containsCell(rows, "rejected") { + return true, nil + } + return false, fmt.Errorf("no rejected row for l2app yet") + }); err != nil { + t.Fatal(err) + } +} + +// testSenderLoginMismatch is plan C.4 negative check 2: an authenticated +// application cannot send as a sender it does not own (spec 5.1 p.3, +// reject_sender_login_mismatch) — the core anti-spoofing control. +func testSenderLoginMismatch(t *testing.T, sc *scenario) { + res := attemptSend(sendAttempt{ + authLogin: sc.appLogin, authPassword: sc.appPassword, + from: "someone@not-" + senderDomain, to: recipient, + subject: "e2e " + uniqueToken("mismatch"), body: "should be rejected", + }) + if res.ok() { + t.Fatal("send with a sender the application does not own was accepted, want rejected") + } + // Postfix evaluates smtpd_sender_restrictions with smtpd_delay_reject=yes + // (the default): the mismatch is detected at MAIL FROM but the reject is + // only sent back at RCPT TO, so the error can land on either call here. + if res.mailErr == nil && res.rcptErr == nil { + t.Fatalf("expected the mismatch rejected at MAIL or RCPT, got: dial=%v auth=%v data=%v", + res.dialErr, res.authErr, res.dataErr) + } +} + +// testNoAuthRejected is plan C.4 negative check 1: no SASL session, no mail. +// The sender address used here belongs to no registered domain, so the +// rejection is unambiguously about the missing AUTH and not sender ownership. +func testNoAuthRejected(t *testing.T, sc *scenario) { + res := attemptSend(sendAttempt{ + from: "anyone@unregistered.e2e.test", to: recipient, + subject: "e2e " + uniqueToken("noauth"), body: "should be rejected", + }) + if res.ok() { + t.Fatal("unauthenticated send was accepted, want rejected") + } +} + +// testForeignRelayRejected is plan C.4 negative check 3: a direct proof that +// this is not an open relay — even the exact recipient the positive path just +// delivered to is refused without AUTH (reject_unauth_destination). +func testForeignRelayRejected(t *testing.T, sc *scenario) { + res := attemptSend(sendAttempt{ + from: "anyone@unregistered.e2e.test", to: recipient, + subject: "e2e " + uniqueToken("relay"), body: "should be rejected", + }) + if res.ok() { + t.Fatal("unauthenticated relay to an external destination was accepted, want reject_unauth_destination") + } + if res.rcptErr == nil && res.mailErr == nil { + t.Fatalf("expected rejection at MAIL or RCPT, got: dial=%v auth=%v data=%v", res.dialErr, res.authErr, res.dataErr) + } +} + +// testJournalMilterFailOpen is plan C.4 negative check 6: the journal-milter +// is monitoring-only and fails open (spec 7.3) — stopping the panel process +// (which owns the milter socket) must not block mail, and must not crash the +// container (crashexit only fires on PROCESS_STATE_FATAL, a clean supervisor +// stop is STOPPED, see build/crashexit.py). +func testJournalMilterFailOpen(t *testing.T, sc *scenario) { + if _, err := h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "stop", "panel"); err != nil { + t.Fatalf("stop panel: %v", err) + } + t.Cleanup(func() { + _, _ = h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "start", "panel") + }) + + res := attemptSend(sendAttempt{ + authLogin: sc.appLogin, authPassword: sc.appPassword, + from: "alerts@" + senderDomain, to: recipient, + subject: "e2e " + uniqueToken("failopen"), body: "must still be accepted", + }) + if !res.ok() { + t.Fatalf("send with the journal-milter down was rejected, want fail-open accept: dial=%v auth=%v mail=%v rcpt=%v data=%v", + res.dialErr, res.authErr, res.mailErr, res.rcptErr, res.dataErr) + } + + if err := checkContainerAlive(h); err != nil { + t.Fatalf("container did not survive the panel stopping: %v", err) + } + + // Restart the panel (also undone by t.Cleanup, redundantly and harmlessly, + // in case a later step needs it sooner than cleanup order guarantees). + if _, err := h.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "start", "panel"); err != nil { + t.Fatalf("restart panel: %v", err) + } + if err := waitFor("panel HTTP to answer again", 15*time.Second, 300*time.Millisecond, func() (bool, error) { + _, err := sc.panel.status() + return err == nil, err + }); err != nil { + t.Fatal(err) + } +} + +// checkContainerAlive confirms the container is still up after a component +// stop — the crashexit listener must not have brought it down. +func checkContainerAlive(s *stack) error { + out, err := s.execIn("selfpost", "true") + if err != nil { + return fmt.Errorf("container not responding to exec: %v (%s)", err, out) + } + return nil +} + +// testSessionSurvivesRestart is plan C.4 negative/regression check 8 (moved +// here from the manual B.1 stand check, plan item C.4's closing note): the +// login session (SQLite-backed, plan B.1) must survive `docker restart`. +func testSessionSurvivesRestart(t *testing.T, sc *scenario) { + if err := h.restartSelfpost(); err != nil { + t.Fatalf("restart selfpost: %v", err) + } + if err := waitFor("panel HTTP to answer after restart", 30*time.Second, 500*time.Millisecond, func() (bool, error) { + _, err := sc.panel.status() + return err == nil, err + }); err != nil { + t.Fatal(err) + } + // The panel and Postfix come up independently after a restart (see + // waitForSMTPSReady) — the next subtest sends mail, so make sure smtpd is + // actually listening before this one returns. + if err := waitForSMTPSReady(30 * time.Second); err != nil { + t.Fatal(err) + } + resp, err := sc.panel.status() + if err != nil { + t.Fatalf("GET /status after restart: %v", err) + } + if resp.Request.URL.Path != "/status" { + t.Fatalf("session did not survive restart: landed on %s instead of /status", resp.Request.URL.Path) + } +} + +// testLevel1RateLimit is plan C.4 negative check 4: the native Postfix anvil +// backstop (smtpd_client_message_rate_limit, spec 5 p.5), set by the override +// to RATE_LIMIT_MESSAGES_PER_IP=50, rejects once exceeded. It uses a dedicated +// application with no level-2 limit of its own, and retries well past that +// count, so the result is unambiguous regardless of how much of the shared +// per-IP budget earlier subtests already spent (they stay well under 50 +// between them). +func testLevel1RateLimit(t *testing.T, sc *scenario) { + login, password, err := sc.panel.addApplication(sc.domainID, "l1app", "wildcard", "") + if err != nil { + t.Fatalf("add application: %v", err) + } + + const maxAttempts = 60 + for i := 0; i < maxAttempts; i++ { + res := attemptSend(sendAttempt{ + authLogin: login, authPassword: password, + from: "l1@" + senderDomain, to: recipient, + subject: "e2e " + uniqueToken("l1"), body: "rate limit probe", + }) + if !res.ok() { + t.Logf("level-1 limit tripped on attempt %d/%d: %v", i+1, maxAttempts, res.firstErr()) + return + } + } + t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=5) never tripped after %d sends", maxAttempts) +} diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go new file mode 100644 index 0000000..8c38d29 --- /dev/null +++ b/test/e2e/panel_client.go @@ -0,0 +1,234 @@ +package e2e + +import ( + "fmt" + "html" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// panelBaseURL is where compose.override.yml publishes the panel's plain-HTTP +// port (PANEL_COOKIE_SECURE=false — no reverse proxy in this stand). +const panelBaseURL = "http://127.0.0.1:20080" + +// panelClient drives the panel exactly as a browser would: an HTTP client +// with a cookie jar, HTML forms posted as the templates render them, and +// responses scraped for the bits a later step needs (a domain's id, an +// application's one-shot password, the DKIM record to publish). This is +// deliberate — it is also the only way to prove that what the panel tells an +// administrator to publish is the record that actually verifies (plan C.4). +type panelClient struct { + http *http.Client +} + +func newPanelClient() (*panelClient, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + return &panelClient{http: &http.Client{ + Jar: jar, + Timeout: 15 * time.Second, + }}, nil +} + +// readSetupToken reads the one-time setup URL SelfPost wrote to /data (bind +// mounted at stageDir/data/setup-token) and returns just the token. +func readSetupToken(stageDir string) (string, error) { + var raw []byte + err := waitFor("setup-token to appear", 30*time.Second, 300*time.Millisecond, func() (bool, error) { + b, err := os.ReadFile(filepath.Join(stageDir, "data", "setup-token")) + if err != nil { + return false, err + } + raw = b + return len(b) > 0, nil + }) + if err != nil { + return "", err + } + u, err := url.Parse(strings.TrimSpace(string(raw))) + if err != nil { + return "", fmt.Errorf("parse setup token file: %w", err) + } + return strings.TrimPrefix(u.Path, "/setup/"), nil +} + +func (c *panelClient) postForm(path string, form url.Values) (*http.Response, string, error) { + resp, err := c.http.PostForm(panelBaseURL+path, form) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return resp, string(body), err +} + +func (c *panelClient) get(path string) (*http.Response, string, error) { + resp, err := c.http.Get(panelBaseURL + path) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return resp, string(body), err +} + +// setup creates the administrator through the one-time /setup/ form. +func (c *panelClient) setup(token, username, password string) error { + resp, body, err := c.postForm("/setup/"+token, url.Values{ + "username": {username}, + "password": {password}, + "password_confirm": {password}, + }) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("setup: status %d: %s", resp.StatusCode, body) + } + return nil +} + +// login authenticates and stores the session cookie in the client's jar. +func (c *panelClient) login(username, password string) error { + resp, body, err := c.postForm("/login", url.Values{ + "username": {username}, + "password": {password}, + }) + if err != nil { + return err + } + if resp.Request.URL.Path != "/status" { + return fmt.Errorf("login did not land on /status (landed on %s): %s", resp.Request.URL.Path, body) + } + return nil +} + +// addDomain submits the add-domain form and returns its assigned id, read +// back from the redirect target /domains/{id}. +func (c *panelClient) addDomain(name string) (string, error) { + resp, body, err := c.postForm("/domains", url.Values{"name": {name}}) + if err != nil { + return "", err + } + m := regexp.MustCompile(`^/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path) + if m == nil { + return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body) + } + return m[1], nil +} + +// dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells +// the administrator to publish. +func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) { + _, body, err := c.get("/domains/" + domainID) + if err != nil { + return "", "", err + } + name, ok := extractCodeRow(body, "Host / name") + if !ok { + return "", "", fmt.Errorf("could not find DKIM record name on domain page") + } + value, ok = extractCodeRow(body, "Value") + if !ok { + return "", "", fmt.Errorf("could not find DKIM record value on domain page") + } + return name, value, nil +} + +// addApplication submits the add-application form and returns the one-shot +// login/password the panel renders inline (spec 7.6.1 — never recoverable +// later, so this is the only place to read it). +func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) { + resp, body, err := c.postForm("/domains/"+domainID+"/applications", url.Values{ + "login": {login}, + "mode": {mode}, + "addresses": {addresses}, + }) + if err != nil { + return "", "", err + } + if resp.StatusCode != http.StatusCreated { + return "", "", fmt.Errorf("add application %q: status %d: %s", login, resp.StatusCode, body) + } + appLogin, ok := extractCodeRow(body, "Login") + if !ok { + return "", "", fmt.Errorf("could not find new application login in response") + } + password, ok = extractCodeRow(body, "Password") + if !ok { + return "", "", fmt.Errorf("could not find new application password in response") + } + return appLogin, password, nil +} + +// setRateLimit saves a level-2 differentiated limit (spec 7.4) on either a +// domain (/domains/{id}/ratelimit) or an application (/applications/{id}/ratelimit). +func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error { + resp, body, err := c.postForm(path, url.Values{ + "allowed_ips": {allowedIP}, + "max_messages": {fmt.Sprintf("%d", maxMessages)}, + "window_seconds": {fmt.Sprintf("%d", windowSeconds)}, + }) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("set rate limit: status %d: %s", resp.StatusCode, body) + } + return nil +} + +// sendLogRows returns the raw /sendlog/rows HTML fragment, filtered to one +// domain, for polling a row's status without parsing full HTML into structs. +func (c *panelClient) sendLogRows(domain string) (string, error) { + _, body, err := c.get("/sendlog/rows?domain=" + url.QueryEscape(domain)) + return body, err +} + +// status fetches the authenticated landing page — used after a container +// restart to confirm the session cookie is still accepted (plan C.4 check 8). +func (c *panelClient) status() (*http.Response, error) { + resp, _, err := c.get("/status") + return resp, err +} + +// applicationID scrapes an application's numeric id off its domain page row, +// keyed by login — needed to build /applications/{id}/ratelimit, which the +// add-application response (just the login/password) does not carry. +func (c *panelClient) applicationID(domainID, login string) (string, error) { + _, body, err := c.get("/domains/" + domainID) + if err != nil { + return "", err + } + pattern := `(?s)` + regexp.QuoteMeta(login) + `.*?/applications/(\d+)/mode` + m := regexp.MustCompile(pattern).FindStringSubmatch(body) + if m == nil { + return "", fmt.Errorf("could not find application id for login %q", login) + } + return m[1], nil +} + +// extractCodeRow scrapes the value of a " ... VALUE" pair from a rendered panel page (see +// internal/web/templates/domain_detail.html). It is deliberately anchored to +// the label text rather than position, so it survives unrelated template +// reordering. html/template's escaper is conservative about which characters +// it entity-encodes in text nodes — a DKIM value's base64 "+" comes back as +// "+" — so the match is HTML-unescaped before returning. +func extractCodeRow(body, label string) (string, bool) { + pattern := `\s*
\s*([^<]*)` + m := regexp.MustCompile(pattern).FindStringSubmatch(body) + if m == nil { + return "", false + } + return html.UnescapeString(strings.TrimSpace(m[1])), true +} diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go new file mode 100644 index 0000000..4b8ff05 --- /dev/null +++ b/test/e2e/process_check.go @@ -0,0 +1,61 @@ +package e2e + +import ( + "fmt" + "strings" + "time" +) + +// wantRunning are the supervisord programs that must be RUNNING once the +// container is up (build/supervisord.conf). postfix-reload is deliberately +// excluded: autostart=false, its healthy resting state is STOPPED. +var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"} + +// checkSupervisorProcesses shells into the container directly (not through the +// panel's /status page) so it works before an administrator account even +// exists — this is the first thing the harness checks after `docker compose +// up` (plan C.4). Programs take a moment to leave STARTING right after the +// container starts, so this polls rather than checking once. +func checkSupervisorProcesses(s *stack) error { + var lastErr error + err := waitFor("all supervised programs to reach their steady state", 20*time.Second, 500*time.Millisecond, func() (bool, error) { + out, execErr := s.execIn("selfpost", "supervisorctl", "-c", "/etc/supervisor/supervisord.conf", "status") + // supervisorctl exits non-zero when any program is not RUNNING, which + // is expected for postfix-reload — parse the output regardless of exit + // status. + states := parseSupervisorStatus(out) + if len(states) == 0 { + lastErr = fmt.Errorf("supervisorctl status produced nothing to parse: %v\n%s", execErr, out) + return false, lastErr + } + for _, name := range wantRunning { + state, ok := states[name] + if !ok { + lastErr = fmt.Errorf("program %q not reported by supervisorctl:\n%s", name, out) + return false, lastErr + } + if state != "RUNNING" { + lastErr = fmt.Errorf("program %q is %s, want RUNNING:\n%s", name, state, out) + return false, lastErr + } + } + if state, ok := states["postfix-reload"]; ok && state != "STOPPED" { + lastErr = fmt.Errorf("program postfix-reload is %s, want STOPPED (autostart=false):\n%s", state, out) + return false, lastErr + } + return true, nil + }) + return err +} + +func parseSupervisorStatus(out string) map[string]string { + states := make(map[string]string) + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + states[fields[0]] = fields[1] + } + return states +} diff --git a/test/e2e/sink/Dockerfile b/test/e2e/sink/Dockerfile new file mode 100644 index 0000000..016f405 --- /dev/null +++ b/test/e2e/sink/Dockerfile @@ -0,0 +1,26 @@ +# SMTP sink-MX for the e2e stand (plan C.4): accepts any mail on 25 and dumps +# each transaction to its own file under /mail, so the test harness can read +# the raw received message (headers + body, including the DKIM-Signature +# Postfix/OpenDKIM added) straight off disk. smtp-sink ships in the postfix +# package itself — no new dependency. +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ + && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ + && apt-get update \ + && apt-get install -y --no-install-recommends postfix \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /mail +WORKDIR /mail + +# -d: one file per transaction (a pseudo-random suffix is appended +# automatically), so the harness just watches the directory for a new file +# instead of parsing a shared multi-message dump. Binding :25 needs root, and +# smtp-sink refuses to run as root without an explicit -u to switch to after +# binding — "-u root" satisfies that check while staying root throughout, +# which is fine here: throwaway container, isolated e2e network, never +# exposed. +ENTRYPOINT ["/usr/sbin/smtp-sink"] +CMD ["-u", "root", "-d", "/mail/%Y%m%d%H%M%S.", "0.0.0.0:25", "100"] diff --git a/test/e2e/smtp_client.go b/test/e2e/smtp_client.go new file mode 100644 index 0000000..1fe805a --- /dev/null +++ b/test/e2e/smtp_client.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "time" +) + +// smtpsAddr is where compose.override.yml publishes the primary implicit-TLS +// submission port (465 in the shipped compose, remapped here to stay off the +// dev server's real ports). +const smtpsAddr = "127.0.0.1:20465" + +// sendAttempt is one SMTP transaction against the stand. An empty authLogin +// means "connect without AUTH" (negative checks 1/3); from/to are full +// mailbox addresses. +type sendAttempt struct { + authLogin, authPassword string + from, to string + subject, body string +} + +// sendResult records the error (if any) at each stage the transaction +// reached. Only the fields up to and including the first failure are set — +// the rest are nil because that stage was never attempted, not because it +// succeeded silently. +type sendResult struct { + dialErr error + authErr error + mailErr error + rcptErr error + dataErr error +} + +// ok reports whether the message was fully accepted (DATA closed cleanly). +func (r sendResult) ok() bool { + return r.dialErr == nil && r.authErr == nil && r.mailErr == nil && r.rcptErr == nil && r.dataErr == nil +} + +// firstErr returns whichever stage failed first, or nil if none did. +func (r sendResult) firstErr() error { + for _, e := range []error{r.dialErr, r.authErr, r.mailErr, r.rcptErr, r.dataErr} { + if e != nil { + return e + } + } + return nil +} + +// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly +// as an application client library would: TLS connect, EHLO, optional AUTH +// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed +// one (see stage.go), so verification is skipped — Postfix's +// smtpd_tls_security_level is "may" here just like production, this is purely +// about the test client trusting an unknown CA. +func attemptSend(a sendAttempt) sendResult { + var res sendResult + + dialer := &net.Dialer{Timeout: 10 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", smtpsAddr, &tls.Config{ + InsecureSkipVerify: true, + ServerName: selfpostHostname, + }) + if err != nil { + res.dialErr = fmt.Errorf("dial %s: %w", smtpsAddr, err) + return res + } + c, err := smtp.NewClient(conn, selfpostHostname) + if err != nil { + res.dialErr = fmt.Errorf("smtp handshake: %w", err) + _ = conn.Close() + return res + } + defer c.Close() + + if err := c.Hello("e2e-client.e2e.test"); err != nil { + res.dialErr = err + return res + } + + if a.authLogin != "" { + auth := smtp.PlainAuth("", a.authLogin, a.authPassword, selfpostHostname) + if err := c.Auth(auth); err != nil { + res.authErr = err + _ = c.Quit() + return res + } + } + + if err := c.Mail(a.from); err != nil { + res.mailErr = err + _ = c.Quit() + return res + } + if err := c.Rcpt(a.to); err != nil { + res.rcptErr = err + _ = c.Quit() + return res + } + w, err := c.Data() + if err != nil { + res.dataErr = err + _ = c.Quit() + return res + } + msg := buildMessage(a) + if _, err := w.Write([]byte(msg)); err != nil { + res.dataErr = err + } else if err := w.Close(); err != nil { + res.dataErr = err + } + _ = c.Quit() + return res +} + +// waitForSMTPSReady polls the implicit-TLS port until a bare TCP connect +// succeeds. Used after a container (re)start: the panel's HTTP port and +// Postfix's smtpd come up independently (postfix-wrapper.sh additionally +// waits on both milter sockets before starting Postfix at all), so a caller +// that only waited for the panel could still race a not-yet-listening smtpd. +func waitForSMTPSReady(timeout time.Duration) error { + return waitFor("smtps port to accept connections", timeout, 300*time.Millisecond, func() (bool, error) { + conn, err := net.DialTimeout("tcp", smtpsAddr, 2*time.Second) + if err != nil { + return false, err + } + _ = conn.Close() + return true, nil + }) +} + +// buildMessage renders a minimal, valid RFC 5322 message. subject is expected +// to carry a unique token so the harness can find this exact message in the +// sink-MX's dump directory afterwards. +func buildMessage(a sendAttempt) string { + return fmt.Sprintf( + "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", + a.from, a.to, a.subject, time.Now().UTC().Format(time.RFC1123Z), a.body, + ) +} diff --git a/test/e2e/stack.go b/test/e2e/stack.go new file mode 100644 index 0000000..f39bb06 --- /dev/null +++ b/test/e2e/stack.go @@ -0,0 +1,113 @@ +package e2e + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// project isolates this stand's containers/network from anything else on the +// host (in particular a prod SelfPost stack in an unrelated compose project — +// plan C.4). stageDir holds everything a single e2e run writes: the generated +// TLS cert, the /data bind mount, the DNS zone CoreDNS serves, and the mail +// sink's dump directory. It is never reused across runs. +const project = "selfpost-e2e" + +// stack drives the compose lifecycle. baseCompose is the shipped +// deploy/docker-compose.yml — deliberately the real one, not a parallel test +// compose, so cap_drop/cap_add/no-new-privileges are exercised as documented +// rather than a laxer stand-in (plan C.4). +type stack struct { + repoRoot string + baseComposeFile string + overrideFile string + stageDir string +} + +func newStack() (*stack, error) { + wd, err := os.Getwd() // test/e2e, where go.mod lives + if err != nil { + return nil, err + } + repoRoot, err := filepath.Abs(filepath.Join(wd, "..", "..")) + if err != nil { + return nil, err + } + stageDir, err := filepath.Abs(filepath.Join(wd, ".stage")) + if err != nil { + return nil, err + } + return &stack{ + repoRoot: repoRoot, + baseComposeFile: filepath.Join(repoRoot, "deploy", "docker-compose.yml"), + overrideFile: filepath.Join(wd, "compose.override.yml"), + stageDir: stageDir, + }, nil +} + +// compose runs `docker compose ` against this stand's two files and +// project directory/name, streaming output to the test log on failure. +func (s *stack) compose(args ...string) ([]byte, error) { + full := append([]string{ + "compose", + "-p", project, + "-f", s.baseComposeFile, + "-f", s.overrideFile, + "--project-directory", s.stageDir, + }, args...) + cmd := exec.Command("docker", full...) + // deploy/docker-compose.yml interpolates ${SELFPOST_HOSTNAME:?...} at + // compose-file parse time, straight from the process environment — that + // happens before the override file's `environment:` mapping is even + // looked at (that mapping only reaches the container, not compose's own + // variable interpolation), so it has to be set here too. + cmd.Env = append(os.Environ(), "SELFPOST_HOSTNAME="+selfpostHostname) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + if err != nil { + return out.Bytes(), fmt.Errorf("docker %v: %w\n%s", full, err, out.String()) + } + return out.Bytes(), nil +} + +// execIn runs a command inside the running service container via `docker +// compose exec -T`, the same way the harness checks supervisord/mail.log +// state without going through the panel (so those checks work even before an +// administrator account exists). +func (s *stack) execIn(service string, args ...string) (string, error) { + full := append([]string{"exec", "-T", service}, args...) + out, err := s.compose(full...) + return string(out), err +} + +func (s *stack) build() error { + _, err := s.compose("build") + return err +} + +func (s *stack) up() error { + _, err := s.compose("up", "-d", "--force-recreate") + return err +} + +// restartSelfpost restarts just the selfpost container the way `docker +// restart` would (plan C.4 check 8: a session must survive that), without +// tearing down the DNS/sink sidecars or their state. +func (s *stack) restartSelfpost() error { + _, err := s.compose("restart", "selfpost") + return err +} + +func (s *stack) down() { + _, _ = s.compose("down", "-v", "--remove-orphans") +} + +// logs returns a service's combined stdout/stderr, for failure diagnostics. +func (s *stack) logs(service string) string { + out, _ := s.compose("logs", "--no-color", service) + return string(out) +} diff --git a/test/e2e/stage.go b/test/e2e/stage.go new file mode 100644 index 0000000..c036a75 --- /dev/null +++ b/test/e2e/stage.go @@ -0,0 +1,86 @@ +package e2e + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +// selfpostHostname is SELFPOST_HOSTNAME for the whole e2e stand: the +// certificate's CN/SAN, the panel's SASL realm and Postfix's myhostname/HELO +// all have to agree on it (plan B.3), so it is defined once here. +const selfpostHostname = "mail.e2e.test" + +// prepareStage (re)creates the scratch directory compose.override.yml mounts +// everything from: /data, the TLS cert Postfix serves on 465, the DNS zone +// CoreDNS is authoritative for, and the sink-MX's dump directory. Called once +// per run before `docker compose up`, so every run starts from a clean slate. +func prepareStage(s *stack) error { + if err := os.RemoveAll(s.stageDir); err != nil { + return fmt.Errorf("clean stage dir: %w", err) + } + dirs := []string{"data", "certs", "dns-stage", "mail-stage"} + for _, d := range dirs { + if err := os.MkdirAll(filepath.Join(s.stageDir, d), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", d, err) + } + } + if err := writeSelfSignedCert( + filepath.Join(s.stageDir, "certs", "fullchain.pem"), + filepath.Join(s.stageDir, "certs", "privkey.pem"), + ); err != nil { + return err + } + corefile, err := os.ReadFile(filepath.Join("dns", "Corefile")) + if err != nil { + return fmt.Errorf("read Corefile: %w", err) + } + if err := os.WriteFile(filepath.Join(s.stageDir, "dns-stage", "Corefile"), corefile, 0o644); err != nil { + return fmt.Errorf("write Corefile: %w", err) + } + return writeZone(s.stageDir, nil) +} + +// writeSelfSignedCert generates a throwaway RSA key + self-signed certificate +// for selfpostHostname, valid for a day — this stand never outlives that. +// Postfix's smtpd_tls_security_level is "may" (opportunistic), not enforced, +// so the e2e SMTP client simply skips verification of it (plan C.4: "test +// dependencies... zero new dependencies" — no need for a real CA here). +func writeSelfSignedCert(certPath, keyPath string) error { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("generate TLS key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return err + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: selfpostHostname}, + DNSNames: []string{selfpostHostname}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return fmt.Errorf("create certificate: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + return os.WriteFile(keyPath, keyPEM, 0o600) +} diff --git a/test/e2e/wait.go b/test/e2e/wait.go new file mode 100644 index 0000000..22c890e --- /dev/null +++ b/test/e2e/wait.go @@ -0,0 +1,29 @@ +package e2e + +import ( + "fmt" + "time" +) + +// waitFor polls check every interval until it returns true or timeout elapses. +// Every wait in this suite goes through here — no fixed sleeps standing in for +// a readiness check (plan C.4): a passing check ends the wait immediately, and +// a timeout fails with what was being waited for, not a bare "timed out". +func waitFor(what string, timeout, interval time.Duration, check func() (bool, error)) error { + deadline := time.Now().Add(timeout) + var lastErr error + for { + ok, err := check() + if ok { + return nil + } + lastErr = err + if time.Now().After(deadline) { + if lastErr != nil { + return fmt.Errorf("timed out after %s waiting for %s: %w", timeout, what, lastErr) + } + return fmt.Errorf("timed out after %s waiting for %s", timeout, what) + } + time.Sleep(interval) + } +}