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).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
commit 8ce050ef7f
157 changed files with 17631 additions and 0 deletions
+11
View File
@@ -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
+32
View File
@@ -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
+104
View File
@@ -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"
+23
View File
@@ -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 ./...
+13
View File
@@ -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/
+180
View File
@@ -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
`<details>` 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).
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.
+41
View File
@@ -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 ./...
+197
View File
@@ -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:<server IP> -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 <container> 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: <https://codeberg.org/mix/selfpost>
- Mirror: <https://github.com/mixeme/selfpost>
## 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.
+109
View File
@@ -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"]
+47
View File
@@ -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()
+118
View File
@@ -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
+26
View File
@@ -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
+12
View File
@@ -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
}
+30
View File
@@ -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/<domain>/ 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
+25
View File
@@ -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
+184
View File
@@ -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 <<EOF
pwcheck_method: auxprop
auxprop_plugin: sasldb
sasldb_path: ${SASLDB_PATH}
mech_list: PLAIN LOGIN
EOF
# Validate the generated configuration; fail loudly if postconf produced
# anything Postfix rejects, before the wrapper tries to start it.
postfix check
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
# try to reach the milter sockets before they are listening.
#
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
# — are present, then execs `postfix start-fg`. If they are not ready within the
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
# an explicit startup failure instead of a relay running blind.
#
# This handles cold start only. Runtime milter failures after a successful start
# are governed by fail-open (milter_default_action), configured in Phase 5.
set -eu
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
INTERVAL=1
elapsed=0
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
while [ ! -S "$sock" ]; do
if [ "$elapsed" -ge "$TIMEOUT" ]; then
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&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
+124
View File
@@ -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
+68
View File
@@ -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
}
+45
View File
@@ -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)
}
+255
View File
@@ -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
}
}
+73
View File
@@ -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 <container> 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
}
+31
View File
@@ -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
+41
View File
@@ -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.
<VirtualHost *:443>
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.
</VirtualHost>
<VirtualHost *:80>
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]
</VirtualHost>
+3
View File
@@ -0,0 +1,3 @@
mail.example.com {
reverse_proxy selfpost:8080
}
+49
View File
@@ -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.
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.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/<hostname>.crt
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
volumes: !override
- ./data:/data
# Replace <hostname> with your actual mail/panel hostname, matching
# the Caddyfile below and SELFPOST_HOSTNAME.
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/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
+101
View File
@@ -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
+55
View File
@@ -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'
+32
View File
@@ -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;
}
}
+52
View File
@@ -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
+28
View File
@@ -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 <acme.json path> <domain> <output dir>
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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="200">S</tspan><tspan font-weight="600">P</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — знак на утверждение</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--ink:#12161C;--paper:#F7F6F4;--brick:#7A3B2E;--slate:#6B7684;--line:#DEDCD7}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font-family:'IBM Plex Sans',system-ui,sans-serif;font-weight:300;-webkit-font-smoothing:antialiased}
.wrap{max-width:960px;margin:0 auto;padding:56px 24px 96px}
header{border-bottom:2px solid var(--brick);padding-bottom:28px}
.eyebrow{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--brick);margin:0 0 14px}
h1{font-size:clamp(28px,5vw,40px);font-weight:200;letter-spacing:-.02em;margin:0 0 10px}
h1 b{font-weight:600}
header p{margin:0;max-width:62ch;color:#3C444F;line-height:1.55;font-size:15px}
section{border-bottom:1px solid var(--line);padding:40px 0}
h2{font-size:13px;font-weight:500;margin:0 0 20px;letter-spacing:.1em;text-transform:uppercase;font-family:'IBM Plex Mono',monospace;color:var(--slate)}
.hero{background:#fff;border:1px solid var(--line);border-radius:3px;padding:44px;display:flex;justify-content:center}
.hero svg{width:100%;max-width:430px;height:auto}
.swatches{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
.sw{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border:1px solid var(--line);border-radius:2px;padding:6px 10px;display:flex;align-items:center;gap:7px;background:#fff}
.sw i{width:14px;height:14px;border:1px solid rgba(0,0,0,.12);display:block}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
.cap{font-size:12.5px;line-height:1.5;color:#5A6470;margin-top:8px}
.cap b{font-family:'IBM Plex Mono',monospace;font-size:11px;font-weight:600;color:var(--ink)}
/* mock: github */
.gh{border:1px solid #D1D9E0;border-radius:6px;padding:18px;background:#fff;font-size:13px}
.gh.dk{background:#0D1117;border-color:#30363D;color:#E6EDF3}
.gh .bc{font-size:14px;color:#0969DA;margin-bottom:14px}
.gh.dk .bc{color:#4493F8}
.gh .bc b{font-weight:600}
.gh .rd{border:1px solid #D1D9E0;border-radius:6px;padding:20px}
.gh.dk .rd{border-color:#30363D}
.gh .rd svg{width:280px;max-width:100%;height:auto;display:block;margin-bottom:16px}
.gh h3{font-size:15px;font-weight:600;margin:0 0 8px;padding-bottom:6px;border-bottom:1px solid #D1D9E0}
.gh.dk h3{border-color:#30363D}
.gh p{margin:0;color:#59636E;line-height:1.6;font-size:12.5px}
.gh.dk p{color:#9198A1}
/* mock: browser tab */
.tabs{background:#DEE1E6;border-radius:8px 8px 0 0;padding:8px 8px 0;display:flex;gap:4px}
.tab{background:#fff;border-radius:8px 8px 0 0;padding:8px 12px;display:flex;align-items:center;gap:7px;font-size:12px;color:#3C4043;max-width:190px}
.tab.off{background:#C9CDD3;color:#5F6368}
.tab svg{width:16px;height:16px;flex:none}
.tab span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.url{background:#fff;padding:10px 12px;border-radius:0 0 8px 8px;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border-top:1px solid #DEE1E6}
/* mock: panel */
.panel{border:1px solid var(--line);border-radius:4px;overflow:hidden;background:#fff}
.ph{background:#12161C;padding:12px 16px;display:flex;align-items:center;gap:20px}
.ph svg{height:34px;width:auto}
.nav{display:flex;gap:16px;font-size:12.5px;color:#9AA3AE}
.nav b{color:#fff;font-weight:500}
.pb{padding:18px 16px;display:flex;flex-direction:column;gap:9px}
.row{display:flex;align-items:center;gap:10px;font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#3C444F}
.badge{font-size:10px;padding:2px 7px;border-radius:10px;font-weight:600;letter-spacing:.03em}
.ok{background:#DCFCE7;color:#166534}
.er{background:#FEE2E2;color:#991B1B}
/* avatars */
.avs{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap}
.av{border-radius:6px;overflow:hidden;border:1px solid var(--line);display:block}
.sizes{display:flex;gap:22px;align-items:flex-end;flex-wrap:wrap;background:#fff;border:1px solid var(--line);border-radius:3px;padding:24px}
.sizes .u{text-align:center}
.sizes svg{display:block;margin:0 auto 8px}
.sizes .u span{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--slate)}
.rules{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden}
@media(max-width:760px){.rules{grid-template-columns:1fr}}
.rule{background:#fff;padding:16px 18px}
.rule b{display:block;font-weight:500;font-size:13.5px;margin-bottom:5px;color:var(--brick)}
.rule span{font-size:12.5px;line-height:1.55;color:#5A6470}
.files{font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:2;color:#3C444F}
.files b{color:var(--ink);font-weight:600}
.note{background:#F1EAE4;border-left:2px solid var(--brick);padding:14px 16px;font-size:13.5px;line-height:1.6;color:#3C444F;max-width:64ch;margin-top:20px}
code{font-family:'IBM Plex Mono',monospace;font-size:12px;background:#EFEDE9;padding:1px 5px;border-radius:2px}
</style></head>
<body><div class="wrap">
<header>
<p class="eyebrow">на утверждение · кирпич #7A3B2E</p>
<h1>Self<b>Post</b></h1>
<p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p>
</header>
<section>
<h2>Эталон</h2>
<div class="hero"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg></div>
<div class="swatches">
<div class="sw"><i style="background:#7A3B2E"></i>#7A3B2E · печать</div>
<div class="sw"><i style="background:#F3EDE1"></i>#F3EDE1 · поле</div>
<div class="sw"><i style="background:#F6EDE2"></i>#F6EDE2 · краска по полю</div>
<div class="sw"><i style="background:#12161C"></i>#12161C · контур 0.8</div>
<div class="sw">контраст 7,3:1 · подзаголовок 4,6:1</div>
</div>
</section>
<section>
<h2>README на GitHub</h2>
<div class="grid2">
<div><div class="gh">
<div class="bc">mixeme / <b>selfpost</b></div>
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg>
<h3>SelfPost</h3>
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
</div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div>
<div><div class="gh dk">
<div class="bc">mixeme / <b>selfpost</b></div>
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg>
<h3>SelfPost</h3>
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
</div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div>
</div>
</section>
<section>
<h2>Шапка панели</h2>
<div class="panel">
<div class="ph"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
</svg>
<div class="nav"><b>Domains</b><span>Applications</span><span>Send log</span><span>Backup</span></div></div>
<div class="pb">
<div class="row">example.org &nbsp; 14:22:07 &nbsp; <span class="badge ok">delivered</span></div>
<div class="row">shop.example.org &nbsp; 14:21:55 &nbsp; <span class="badge er">bounced</span></div>
<div class="row">example.org &nbsp; 14:19:03 &nbsp; <span class="badge ok">delivered</span></div>
</div>
</div>
<div class="cap"><b>проверка на конфликт</b><br>Ровно тот экран, ради которого мы отбраковывали краски: кирпич рядом с зелёным «delivered» и красным «bounced» не читается ни как один из них.</div>
</section>
<section>
<h2>Фавикон и аватар</h2>
<div class="grid2">
<div>
<div class="tabs">
<div class="tab"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="200">S</tspan><tspan font-weight="600">P</tspan></text>
</svg><span>SelfPost — Domains</span></div>
<div class="tab off"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
</svg><span>docs</span></div>
</div>
<div class="url">https://mail.example.org/domains</div>
<div class="cap"><b>16 px</b><br>Слева версия для мелкого размера: зубцы крупнее и реже, вместо двух строк — SP. Справа для сравнения полная иконка в том же размере: превращается в кашу.</div>
</div>
<div>
<div class="avs">
<img class="av" width="80" height="80" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
<img class="av" width="48" height="48" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
<img class="av" width="32" height="32" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.8' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='26' letter-spacing='-1' fill='%23F6EDE2'%3E%3Ctspan font-weight='200'%3ES%3C/tspan%3E%3Ctspan font-weight='600'%3EP%3C/tspan%3E%3C/text%3E%3C/svg%3E">
</div>
<div class="cap"><b>80 / 48 / 32 px</b><br>Аватар организации на Codeberg и GitHub. Порог перехода на версию SP — 32 px.</div>
</div>
</div>
</section>
<section>
<h2>Размеры и охранное поле</h2>
<div class="sizes">
<div class="u"><svg class="s1" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<rect x="-4" y="-12" width="338" height="174" fill="none" stroke="#7A3B2E" stroke-width="1" stroke-dasharray="4 3" opacity=".55"/><path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg><span>полная · от 280 px</span></div>
<div class="u"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
</svg><span>компактная · от 100 px</span></div>
<div class="u"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
</svg><span>иконка · до 100 px</span></div>
</div>
<div class="note"><b>Числа не на глаз.</b> Полная версия живёт до 280 px по ширине — ниже подзаголовок опускается под 6 px и перестаёт читаться. Компактная — до 100 px. Охранное поле (пунктир) равно <b>трём шагам зубцовки</b>, ≈ 21 % высоты марки: мера взята из самого знака, а не назначена.</div>
<style>.s1{width:330px;max-width:100%;height:auto} .sizes .u:nth-child(2) svg{width:220px;max-width:100%;height:auto} .sizes .u:nth-child(3) svg{width:64px;height:64px}</style>
</section>
<section>
<h2>Чего не делать</h2>
<div class="rules"><div class="rule"><b>Не растягивать</b><span>Пропорции 1:2,3 — часть знака: зубцовка рассчитана на них и при неравном масштабе зубцы становятся овалами.</span></div><div class="rule"><b>Не менять краску</b><span>Кирпич выбран по контрасту (7,3:1) и по тому, что он не спорит со статусами панели. Другой цвет ломает оба расчёта.</span></div><div class="rule"><b>Не класть на пёстрое</b><span>Волосяной контур рассчитан на ровный фон. На фото и градиенте край пропадает.</span></div><div class="rule"><b>Не добавлять рамок и теней</b><span>Марка держит край зубцовкой и контуром. Всё остальное — уже наклейка, а не марка.</span></div><div class="rule"><b>Не набирать подзаголовок мельче 6 px</b><span>Ниже полная версия перестаёт читаться — там уже компактная.</span></div></div>
</section>
<section style="border-bottom:none">
<h2>Файлы</h2>
<div class="files">
<b>docs/brand/selfpost-stamp.svg</b> — эталон, README, документация<br>
<b>docs/brand/selfpost-stamp-compact.svg</b> — шапка панели<br>
<b>docs/brand/selfpost-icon.svg</b> — аватар, фавикон от 32 px<br>
<b>docs/brand/selfpost-icon-16.svg</b> — фавикон 16–32 px
</div>
<div class="note"><b>Осталось одно действие.</b> В контейнере нет ни IBM Plex, ни сети, поэтому текст в файлах остался текстом. Перед коммитом его надо перевести в контуры, иначе на машине без Plex набор поедет:<br><code>inkscape --actions="select-all;object-to-path;export-filename:out.svg;export-do" in.svg</code><br>Пока в файлах прописан фолбэк <code>'IBM Plex Sans','Helvetica Neue',sans-serif</code> — не спасёт вёрстку, но не даст ей развалиться совсем.</div>
</section>
</div></body></html>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+248
View File
@@ -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 — желателен, но тег не
блокирует. Находки, обнаруженные позже, дописываются сюда, а не исправляются
молча: этот файл — журнал состояния документации, а не одноразовый список дел.
+174
View File
@@ -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/<token>` ([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) до кодирования.
+52
View File
@@ -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 <модель> <noreply@anthropic.com>` — с той моделью, которая этот шаг делала (на момент Фазы 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` эскейпит `+` в `&#43;` даже в тексте — скрапер значений со страницы обязан `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, `<repo>`) **нет 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` (по ключу).
+39
View File
@@ -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) закрывает
каждую находку одним из двух способов: правка до тега — либо запись сюда, с
обоснованием и условием возврата, как у двух пунктов выше. Третьего варианта
(«посмотрели и ладно») нет.
+443
View File
@@ -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-сокет>` для 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** — единственная фронтенд-зависимость, подключается одним статическим `<script>` (вендорится в бинарник/образ, без npm). Используется для:
- частичных обновлений на страницах-настройках (добавить/удалить отправителя без полной перезагрузки);
- **автообновления экранов мониторинга** (очередь, лог) через периодический polling — `hx-trigger="every Ns"` подтягивает свежий фрагмент раз в несколько секунд (интервал сделать конфигурируемым или разумно зашитым, порядка 2–5 сек).
- **Мониторинг реализуется через HTMX-polling, НЕ через SSE/WebSocket.** Осознанный выбор в пользу максимальной простоты: обновление раз в несколько секунд достаточно для этой задачи, а polling не требует stream-инфраструктуры, управления соединениями и усложнения кода. Мгновенность не требуется.
- Эндпоинты, отдающие фрагменты для polling (например, `/queue/fragment`, `/log/fragment`), возвращают готовый HTML-кусок для замены блока на странице, а не JSON.
### 7.2. Функциональные требования
Панель предоставляет **ограниченный** набор операций:
1. **Аутентификация** — вход администратора по логину/паролю (раздел 7.6).
2. **Список доменов** — показать все настроенные отправляющие домены, их статус (есть ли DKIM-ключ, показана ли DNS-запись) и количество привязанных приложений.
3. **Добавить домен** — по имени домена панель (см. раздел 4.1):
- создаёт запись домена;
- генерирует DKIM-ключ + селектор;
- применяет изменения (reload OpenDKIM);
- показывает DKIM TXT-запись для DNS.
Добавление домена **не создаёт приложение** — это отдельное действие (п. 5).
4. **Удалить домен** — удаляет DKIM-ключ домена **и все привязанные к нему приложения** (SASL-креды и привязки), применяет изменения. Панель должна явно предупредить о каскадном удалении приложений перед подтверждением.
5. **Добавить приложение к домену** — внутри карточки домена, за одну операцию:
- генерирует SASL-пару логин/пароль;
- принимает выбор **режима адресов**: «любой адрес домена» либо «список конкретных адресов» (при выборе списка — ввод одного или нескольких адресов, каждый валидируется на принадлежность домену приложения, см. 7.6);
- регистрирует соответствующую(ие) запись(и) в `smtpd_sender_login_maps` (wildcard или по адресам — раздел 5.1);
- применяет изменения (`postfix reload`);
- **показывает сгенерированный пароль ОДИН РАЗ** (см. 7.6, п. про пароли).
6. **Список приложений домена** — показать все приложения (логины), привязанные к конкретному домену, вместе с их текущим режимом адресов (для режима «список» — сами адреса).
7. **Редактировать режим адресов приложения** — переключить «любой адрес» ↔ «список», либо изменить сам список адресов для существующего приложения; пересобирает соответствующие записи в `smtpd_sender_login_maps` и применяет изменения.
8. **Удалить приложение** — удаляет SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
9. **Перевыпустить пароль приложения** — сгенерировать новую SASL-пару для существующего приложения (старый пароль инвалидируется, режим адресов сохраняется), показать новый пароль один раз.
10. **Просмотр DKIM DNS-записи** — для любого домена показать его TXT-запись для копирования в DNS (доступно в любой момент — это не секрет).
11. **Просмотр очереди** — вывод `mailq` / `postqueue -p` в читаемом виде (сколько писем в очереди, статусы). Экран **автообновляется** через HTMX-polling раз в несколько секунд (см. 7.1).
12. **Reload** — кнопка, применяющая изменения конфига вручную (`postfix reload` / reload OpenDKIM), если нужно.
13. **Просмотр хвоста почтового лога** для диагностики — последние N строк, с автообновлением через polling.
14. **Журнал отправки писем** — таблица отправленных писем с фильтрами по домену и приложению (подробности — раздел 7.3).
15. **Настройка лимитов отправки** — для домена и/или приложения задать привязанные IP и лимит писем за окно времени (подробности — раздел 7.4).
16. **Скачать полную резервную копию** — кнопка формирует и отдаёт архив всего персистентного состояния (подробности — раздел 7.5.А).
17. **Экспортировать домен** / **Импортировать домен** — перенос одного домена на другой экземпляр SelfPost, включая рабочие пароли приложений (перевыпуск не требуется); файл экспорта содержит секреты и должен обрабатываться как чувствительный (подробности — раздел 7.5.Б).
### 7.3. Журнал отправки (Send Log)
Отдельный от «просмотра очереди»/«просмотра лога» экран: не сырой `mailq`/hвост файла, а структурированная, фильтруемая история отправленных писем.
**Что фиксируется в журнале для каждого письма:**
- время приёма;
- домен и приложение (SASL-логин), от имени которого отправлено;
- From-адрес и To-адрес(а);
- Subject (тема);
- статус: в очереди / отправлено / отклонено получателем / отложено (bounced/deferred), с обновлением по мере продвижения по очереди Postfix;
- queue-id Postfix (для сопоставления с сырыми логами при более глубокой диагностике).
**Технический механизм получения данных:**
1. **Тема письма не попадает в стандартные логи Postfix** (`mail.log` содержит envelope-данные и статус доставки, но не заголовки письма). Чтобы получить Subject, From, To и SASL-логин на этапе приёма, panel-бинарник реализует **лёгкий milter** (например, через библиотеку `github.com/emersion/go-milter` или аналог), подключённый в `smtpd_milters` Postfix вместе с OpenDKIM. На этапе приёма письма (EOH/заголовки) milter читает нужные поля и создаёт запись в журнале со статусом «в очереди», привязанную к queue-id.
2. **Финальный статус доставки** panel получает отдельно: горутина внутри того же бинарника хвостит `mail.log` (аналогично экрану из п. 13), парсит строки со статусами `sent`/`bounced`/`deferred` по queue-id и обновляет соответствующую запись журнала.
3. Если письмо направлено нескольким получателям, Postfix может завершать доставку по каждому получателю с разным статусом и в разное время — журнал хранит **отдельную запись на пару (queue-id, получатель)**, чтобы точно отражать различающиеся статусы; в UI такие записи можно опционально визуально группировать по queue-id, но это не обязательно для первой версии.
> **⚠️ Известный риск реализации: milter на Go — менее протоптанный путь, чем остальной проект.** В отличие от конфигурации Postfix/OpenDKIM (где документации и готовых примеров множество, что снижает риск ошибок агента), библиотек реализации milter-протокола на Go немного, и по ним заметно меньше примеров и обсуждений edge-case'ов. Конкретные точки риска:
> - **Протокол milter завязан на конкретные версии** (v2/v6 и т.п.) и не всегда единообразно документирован в доступных Go-библиотеках — есть шанс несовместимости с версией Postfix в образе.
> - **Обработка ошибок/таймаутов milter'а влияет на приём почты напрямую.** Если journal-milter зависнет или упадёт некорректно, Postfix (в зависимости от `milter_default_action`) может либо пропускать письма без логирования, либо, что хуже, начать отклонять всю входящую почту. Это самый чувствительный узел: баг здесь не просто ломает журнал, а потенциально ломает сам релей.
> - **Меньше протестированных Go-примеров** milter-серверов, читающих именно Subject/From/To на этапе EOH, — агенту придётся писать эту часть с меньшей опорой на устоявшиеся паттерны, чем остальной проект.
>
> **Требование по снижению риска:** `milter_default_action` для journal-milter должен быть настроен так, чтобы **сбой journal-milter не блокировал приём и отправку почты** (fail-open для этого конкретного milter’а — в отличие от OpenDKIM, где отказ в подписи можно и нужно трактовать строже). Журнал — вспомогательная функция мониторинга; релей не должен переставать работать из-за него. Этот компонент нуждается в более внимательном тестировании (в т.ч. поведения при падении/таймауте) перед тем, как считаться готовым.
**Хранение:** таблица в той же SQLite (см. раздел 9), не файл лога — чтобы поддерживались фильтры и запросы без парсинга на лету.
**Retention (обязательно предусмотреть, иначе журнал растёт бесконечно):** конфигурируемый срок хранения записей (например, `SEND_LOG_RETENTION_DAYS`, по умолчанию 90 дней) с периодической фоновой очисткой устаревших записей. Значение по умолчанию и наличие настройки — обязательны; исполнитель может уточнить конкретную реализацию очистки (периодическая задача в той же горутине, что и log-tailer).
**UI:**
- Таблица: время, домен, приложение, From, To, Subject, статус.
- **Фильтры — обязательны:** по домену (выпадающий список из существующих доменов) и по приложению (список приложений, опционально зависящий от выбранного домена). Фильтры применяются на сервере (запрос к SQLite с `WHERE`), не на клиенте.
- Пагинация или ограничение количества строк на экран (журнал может быть большим).
- Автообновление свежих записей — через HTMX-polling, тот же подход, что у очереди и лога (раздел 7.1), не обязательно, но желательно для консистентности UX.
**Приватность и безопасность — важное замечание:** журнал хранит адреса получателей и темы писем — это метаданные переписки, потенциально чувствительные. Он доступен только через панель (за логином/паролем администратора, раздел 7.6) — отдельного уровня доступа для журнала не предусмотрено, весь объём защищён общей аутентификацией панели. Тема и адреса должны экранироваться при рендере (раздел 7.6, п.7) как и остальной вывод из логов.
### 7.4. Rate limiting (лимиты отправки)
Двухуровневая защита от «взбесившегося» приложения или скомпрометированных кред — цель — оградить репутацию IP, общего для всех доменов проекта.
**Уровень 1 — базовый, нативный Postfix, независимый от milter (backstop).** Описан в разделе 5, п. 5: `smtpd_client_message_rate_limit`/`anvil_rate_time_unit`, ключ — client IP. Работает всегда, включая ситуацию, когда journal-milter недоступен. Это гарантирует, что даже при полном отказе дифференцированного уровня 2 грубая защита от резкого всплеска отправки с одного IP не исчезает.
**Уровень 2 — дифференцированные лимиты «на домен» и «на приложение», настраиваемые в панели.** Реализованы в journal-milter (том же компоненте, что и журнал отправки, раздел 7.3), поскольку milter уже разбирает каждое соединение и имеет доступ к client IP на этапе приёма.
- **Ключ лимита — client IP, а не SASL-логин.** Причина: один логин/пароль потенциально может использоваться несколькими физическими отправителями (например, кластер серверов с общими кредами) — SASL-идентичность не гарантированно соответствует одному источнику, а IP — более надёжный практический прокси для «кто на самом деле шлёт».
- **Настройка:** при добавлении/редактировании домена и приложения (раздел 7.2) админ может указать один или несколько ожидаемых IP-адресов и лимит (N писем за скользящее окно) — отдельно на уровне домена (суммарно по всем его приложениям и IP) и отдельно на уровне приложения (для его конкретных IP). Оба необязательны — если не заданы, работает только базовый уровень 1.
- **Проверка:** при приёме письма milter сверяет client IP с зарегистрированными для соответствующего домена/приложения и запрашивает текущий счётчик из SQLite (переиспользует данные журнала отправки); при превышении — отклоняет письмо (временный отказ 4xx — семантически корректно для rate-limit, ожидается, что отправитель повторит попытку позже) и **опционально фиксирует это как отдельную запись в журнале со статусом «отклонено по лимиту»** — для видимости в UI.
- **Fail-open допустим на этом уровне.** Поскольку уровень 1 не зависит от milter и продолжает работать самостоятельно, при сбое/недоступности journal-milter уровень 2 может безопасно отключаться (fail-open, тот же принцип, что и для журнала, раздел 7.3) — теряется точность «по домену/приложению», но грубая защита от IP не пропадает.
> **⚠️ Оговорка: дифференцированный уровень требует предсказуемого IP приложения.** Если приложение шлёт из окружения с динамическими/меняющимися IP (например, serverless с ротацией адресов), привязка «IP → домен/приложение» не может быть настроена содержательно — для таких приложений реальной защитой остаётся только базовый уровень 1 (глобальный, без разбивки по домену/приложению). Панель должна явно допускать оставить IP-привязку пустой (тогда дифференцированный лимит для этого приложения просто не применяется), а не требовать её обязательно.
> **⚠️ Известный нюанс: IPv6 ослабляет точность IP-ключа.** Весь дизайн уровней 1 и 2 держится на «IP как надёжный прокси для одного отправителя» — для IPv4 это разумное допущение, но для IPv6 не совсем так: провайдеры часто выдают целый префикс (обычно /64) одному клиенту, и адрес в его пределах может меняться чаще, чем у IPv4-адреса. Формально это может ослаблять точность обоих уровней лимитов именно в IPv6-сетях. Осознанно фиксируется как известное ограничение и оставляется как есть — не требует изменения дизайна на данном этапе.
### 7.5. Резервное копирование и миграция
Два разных сценария с разной механикой — не путать друг с другом ни в реализации, ни в UI.
**А. Полный бэкап сервера (миграция на новую машину целиком).**
- **Что входит в архив:** консолидированное персистентное состояние — SQLite (`selfpost.db`: домены, приложения, администратор, журнал, настройки лимитов), DKIM-ключи всех доменов, база SASL (`sasldb2`), и **манифест с версией SelfPost**, которой создан бэкап (см. ниже). **Рекомендация исполнителю:** организовать все три под единым корневым путём (например, всё под `/data/`), чтобы бэкап буквально сводился к архивации одной директории, а не сборке путей из разных мест контейнера — это прямое следствие цели «переезд прост как архив».
- **Что НЕ входит:** TLS-сертификаты (ответственность reverse-proxy, раздел 5.2) и очередь Postfix (`/var/spool/postfix`, транзитные недоставленные письма — не переносятся; это осознанный компромисс ради простоты, а не недосмотр).
- **Версионирование бэкапа — обязательно.** Архив содержит файл-манифест (например, `manifest.json`) с версией SelfPost, зашитой в бинарник на этапе сборки (Go build-time ldflags, `-X main.version=...`, согласуется с тегом Docker-образа). Восстановление **должно происходить в тот же самый Docker-образ той же версии**, которым был создан бэкап — это устраняет риск несовместимости схемы SQLite, путей DKIM-ключей или формата `sasldb2` между версиями, а не полагается на то, что миграции схемы «как-нибудь сработают» задним числом.
- **Проверка при восстановлении:** при старте контейнера с распакованным бэкапом (или на отдельном явном шаге restore) panel сверяет версию из манифеста с версией собственного бинарника. При несовпадении — **отказ от запуска/восстановления с понятным сообщением**, каким именно тегом образа нужно воспользоваться (например: «бэкап создан версией 1.3.0, запущена версия 1.5.2 — используйте `selfpost:1.3.0` для восстановления»), а не тихая попытка продолжить с риском повреждения состояния.
- Эта версия **не про автомиграцию на лету между версиями** — если пользователь хочет перейти на более новую версию, это делается отдельно (обычное обновление образа на живом инстансе, вне контура backup/restore), а не как часть восстановления бэкапа.
- **Следствие для деплоя:** `docker-compose.yml` должен использовать **фиксированный тег версии образа**, не `:latest` — иначе невозможно достоверно определить, какой версией был создан бэкап, и вся эта защита теряет смысл. Отметить это явно в разделе 10 и README.
- **Создание бэкапа — двумя равнозначными способами:**
1. Кнопка в панели «Скачать резервную копию» — аутентифицированное действие администратора, формирует архив на лету и отдаёт на скачивание.
2. Эквивалентная CLI-утилита внутри контейнера (например, `selfpost-backup`, вызываемая через `docker exec`) — для скриптовых/cron-бэкапов без захода в веб-интерфейс.
- **Восстановление на новом сервере:** поднять пустой контейнер SelfPost **той же версии образа**, что указана в манифесте бэкапа, с теми же путями bind mount, распаковать архив в них **до первого старта** (либо тем же шагом, что и обычная инициализация — специального «режима восстановления» не требуется), затем запустить контейнер как обычно. Panel/Postfix/OpenDKIM конфигурация перегенерируется из восстановленного SQLite-состояния тем же механизмом, что и при каждом обычном старте (не отдельная ветка кода для restore) — не нужно заново проходить secret-link, заново заводить домены или получать новые DKIM-ключи.
- **Прямое следствие для DNS:** поскольку DKIM-ключи переносятся побитово, **DKIM TXT-записи в DNS не нужно менять** после переезда — только A/PTR-записи на новый IP сервера. Это существенно упрощает миграцию по сравнению с «начать с нуля».
- **Безопасность архива:** архив содержит крайне чувствительные данные — приватные DKIM-ключи, хэш пароля администратора, хэши SASL-кредов. Скачивание через панель уже защищено аутентификацией (раздел 7.6), но сам файл после скачивания нужно хранить и передавать как секрет (не по HTTP, удалять после успешного восстановления) — отметить это в документации.
**Б. Экспорт/импорт отдельного домена (перенос одного домена между двумя независимо работающими экземплярами SelfPost).**
- **Экспорт домена** формирует файл с: именем домена, DKIM-ключом и селектором, режимом адресов, списком приложений (логинами и их режимом/списком адресов) и **соответствующими записями `sasldb2` для приложений этого домена**. Технически это возможно, потому что `sasldb2` (в отличие от bcrypt-хэша пароля администратора, раздел 7.6) хранит секрет в форме, допускающей challenge-response механизмы (CRAM-MD5/DIGEST-MD5) — это обратимая/эквивалентная паролю форма, а не строгий необратимый хэш, и её записи можно выборочно переносить между экземплярами так же, как это уже происходит с файлом `sasldb2` целиком при полном бэкапе (раздел 7.5.А). Не путать эту сущность с bcrypt-хэшем администратора — они устроены принципиально по-разному, и только последний действительно невосстановим.
- **Импорт на другом экземпляре** восстанавливает домен, DKIM-ключ (DNS-запись остаётся той же — менять не нужно) и приложения **с рабочими паролями без перевыпуска** — по тому же принципу, что и полный бэкап.
- **Безопасность экспортного файла — как у полного бэкапа.** Поскольку файл теперь содержит секреты приложений (записи `sasldb2`) и приватный DKIM-ключ домена, он **настолько же чувствителен**, как архив полного бэкапа (раздел 7.5.А), и должен передаваться/храниться так же — не по HTTP, удаляться после использования, не рассматриваться как «просто конфиг».
### 7.6. Нефункциональные требования — БЕЗОПАСНОСТЬ (обязательно)
Поскольку панель публична, следующее — **не опционально**:
1. **Первичная инициализация администратора — через одноразовую секретную ссылку**, не через env-переменную с готовым хэшем:
- При первом запуске (в персистентном состоянии ещё нет ни одного администратора) панель генерирует криптографически случайный токен и выводит ссылку вида `https://<host>/setup/<token>` в лог/stdout контейнера. Токен дополнительно пишется в файл в смонтированной директории (например, `/data/setup-token`) — на случай, если удобнее прочитать файл, чем логи.
- **Энтропия токена — не менее 128 бит** (например, 16+ случайных байт из `crypto/rand`, представленные в hex/base64url). Это единственное, что делает подбор математически неосуществимым в принципе — комбинаторное пространство ~3.4×10^38 вариантов; остальные меры (короткое окно, rate limit) — defense-in-depth поверх этого, а не замена ему.
- **Срок жизни токена — 10 минут** (сокращено с изначально предложенного часа). Если контейнер стартовал, а установка не завершена за это время, токен истекает; при следующем обращении к `/setup` (после истечения) или при рестарте без завершённой настройки панель перегенерирует токен и заново выводит его в лог.
- **Rate limiting на маршрут `/setup/<token>`** — обязателен, отдельно от общего rate limiting на логин (п. 5): ограниченное число попыток обращения в единицу времени по IP (например, несколько в минуту), с отклонением/задержкой сверх лимита. При заданной энтропии токена это не является единственной защитой, но снижает шум в логах и защищает от тривиального автоматического перебора.
- **Сравнение токена — константное по времени** (`crypto/subtle.ConstantTimeCompare` или аналог), чтобы исключить timing-атаку, которая могла бы подсказывать правильные префиксы токена по разнице во времени ответа.
- **Неудачные попытки НЕ инвалидируют и не перегенерируют токен досрочно.** Это осознанное решение: если бы ошибочные попытки заставляли токен перевыпускаться, атакующий получил бы возможность DoS'ить легитимную настройку, постоянно обнуляя токен раньше, чем администратор успеет им воспользоваться. Раз энтропия уже делает подбор неосуществимым, довешивать авто-инвалидацию на неудачу — риск без пользы.
- Переход по ссылке открывает одноразовую форму создания администратора (логин + пароль).
- После успешного создания администратора токен **инвалидируется навсегда** (флаг в персистентном состоянии), маршрут `/setup/*` перестаёт быть доступен (404).
- Пароль администратора, введённый на этой форме, хранится только в виде bcrypt-хэша (или argon2) в персистентном состоянии. Никакого plaintext, MD5, SHA1-без-соли.
- `PANEL_USERNAME`/`PANEL_PASSWORD_HASH` как env-переменные **не используются** для основного сценария — см. обновлённый раздел 8.
- **SASL-пароли приложений** — отдельная сущность (не путать с паролем администратора панели). Панель **генерирует** сильный случайный пароль сама при создании/перевыпуске приложения, показывает его пользователю **ровно один раз**, и НЕ хранит его в открытом виде для повторного показа (в `sasldb2` он лежит в хэшированном виде, как того требует механизм SASL). Если пароль утерян — только перевыпуск (п. 8 раздела 7.2).
2. **Валидация ввода на стороне сервера** (не только в UI). Для email/доменов — строгий whitelist допустимых символов (буквы, цифры, `.`, `-`, `@`). Клиентская валидация не считается защитой. Для режима «список конкретных адресов» (раздел 4.1) — отдельная обязательная проверка: **каждый вводимый адрес должен строго принадлежать домену того приложения, к которому он добавляется** (совпадение части после `@` с доменом); адрес из чужого домена отклоняется до записи в конфиг, а не полагается на то, что это отловит `smtpd_sender_login_maps` уже во время доставки.
3. **`postfix reload` и любые вызовы `os/exec`** — БЕЗ интерполяции пользовательского ввода в команду. Аргументы передаются как отдельные элементы (`exec.Command("postfix", "reload")`), никогда через shell-строку. Пользовательский ввод не должен попадать в аргументы команд вообще; он идёт только в конфиг-файлы (после валидации).
4. **Запись в конфиг-файлы** — с экранированием/санитизацией, чтобы инъекция спецсимволов (перенос строки и т.п.) не могла добавить произвольную директиву в конфиг Postfix.
5. **Rate limiting** на эндпоинт логина (защита от brute-force). Простая реализация (счётчик попыток по IP с временной блокировкой) достаточна.
6. **Сессии** — токен криптографически случайный, cookie с флагами `HttpOnly`, `Secure`, `SameSite`.
7. **Экранирование вывода** — данные из очереди/логов (тема письма, адреса) рендерятся через `html/template` с автоэкранированием (защита от XSS).
8. **Процесс панели не должен работать от root** (в supervisord запускать под непривилегированным пользователем, с доступом только к нужным путям через группу/права).
---
## 8. Конфигурация (переменные окружения)
Минимальный набор env-переменных для настройки (исполнитель может расширить):
- `SELFPOST_HOSTNAME` — hostname самого сервера (должен совпадать с PTR/rDNS и с CN/SAN TLS-сертификата). Это hostname сервера, **не** отправляющий домен — домены добавляются через панель динамически.
- `DKIM_SELECTOR_DEFAULT` — селектор DKIM по умолчанию для новых доменов (например, `selfpost`)
- `TLS_CERT_FILE` — путь к PEM-файлу сертификата (цепочка), поставляемому reverse-proxy через read-only bind mount
- `TLS_KEY_FILE` — путь к PEM-файлу приватного ключа, поставляемому reverse-proxy
- `SEND_LOG_RETENTION_DAYS` — срок хранения записей журнала отправки (раздел 7.3), по умолчанию 90
- `RATE_LIMIT_MESSAGES_PER_IP` — базовый лимит сообщений с одного client IP за окно (уровень 1, раздел 5 п.5 и 7.4), консервативное значение по умолчанию
- `RATE_LIMIT_WINDOW_SECONDS` — окно времени для базового лимита (`anvil_rate_time_unit`), по умолчанию 3600 (час)
**Администратор панели не задаётся через env-переменные.** Создаётся один раз через одноразовую secret-ссылку при первом запуске (см. раздел 7.6, п. 1). Это сделано осознанно: пароль/хэш в env-переменных виден через `docker inspect`, оркестраторы и логи окружения — secret-link избегает этой поверхности и не требует от пользователя вручную считать bcrypt-хэш до старта.
Отправляющие домены, их SASL-креды, DKIM-ключи и привязки — тоже **не** в env-переменных, а в персистентном состоянии, управляемом панелью (см. разделы 4.1 и 9).
---
## 9. Персистентность (bind mount на хосте)
Должны переживать перезапуск/пересоздание контейнера:
- **DKIM-ключи всех доменов** (`/etc/opendkim/keys` + `KeyTable`/`SigningTable`) — критично, иначе при рестарте подписи перестанут совпадать с DNS.
- **SASL-база** (`sasldb2`) — учётные записи всех приложений всех доменов; без неё после рестарта приложения не смогут аутентифицироваться.
- **Конфиги Postfix**, изменяемые панелью: список доменов, карта привязки `smtpd_sender_login_maps` (логины приложений → домены) и т.п.
- **База состояния панели — SQLite** (единый файл, например `/data/selfpost.db`), содержит: реестр доменов и приложений (домены, привязанные приложения, режим адресов, селекторы, метаданные), учётную запись администратора (логин + bcrypt-хэш), флаг «первичная настройка завершена» / текущий setup-токен, **журнал отправки писем** (раздел 7.3) с retention-политикой, и **настройки дифференцированных лимитов отправки** — привязанные IP и лимиты на домен/приложение (раздел 7.4). Формат зафиксирован как SQLite (не «на усмотрение исполнителя», так как журнал и лимиты требуют фильтруемых запросов). Должна переживать рестарт — иначе при каждом перезапуске контейнера пришлось бы заново создавать администратора, терялась бы история отправки и настройки лимитов.
- **Очередь Postfix** (`/var/spool/postfix`) — чтобы недоставленные письма не терялись при рестарте.
**TLS-сертификаты в этот список не входят** — они поставляются reverse-proxy через read-only bind mount, и их хранение/выживание при рестарте — ответственность reverse-proxy (см. раздел 5.2).
**Ротация `mail.log` — обязательна.** В отличие от структурированного журнала отправки в SQLite (раздел 7.3), у которого есть retention-политика (`SEND_LOG_RETENTION_DAYS`), сырой лог Postfix (`mail.log`, который читает log-tailer для панели, раздел 7.2 п.13) ничем не ограничен по умолчанию и будет расти неограниченно на протяжении месяцев/лет работы — на небольшом диске (раздел 10) это реальный риск исчерпания места, в отличие от остального состояния, которое ограничено by design. Настроить `logrotate` внутри контейнера (ежедневная/еженедельная ротация, ограниченное число хранимых файлов, например 7–14) как часть образа.
**Механизм — bind mount на хосте, не именованный Docker volume.** Все перечисленные выше пути монтируются из директории на файловой системе хоста (например, `./data` рядом с `docker-compose.yml`, или зафиксированный абсолютный путь вроде `/opt/selfpost/data`) в консолидированный корень внутри контейнера (тот же `/data`, что уже рекомендован в разделе 7.5.А). Причина — та же цель простоты бэкапа и миграции: с именованным volume для доступа к данным нужно либо идти через `docker volume inspect`/`docker cp`, либо временно монтировать volume в служебный контейнер; с bind mount данные — это просто директория на диске, видимая и доступная напрямую средствами хоста (`tar`, `rsync`, `scp`) без обращения к Docker вообще. `docker-compose.yml` должен использовать синтаксис bind mount (`./data:/data`), а не секцию `volumes:` с именованным томом.
**Оговорка про прямое копирование данных хостовым `tar` (в обход панели):** риск неконсистентного снимка SQLite (WAL-режим, незавершённая запись) существует, только если копировать директорию **во время работы контейнера** — тогда возможна гонка между записью и чтением файла. Если контейнер на момент копирования **остановлен**, наивный `tar` полностью безопасен и эквивалентен встроенному механизму — писать в SQLite в этот момент физически некому. Встроенный бэкап через кнопку панели/CLI-утилиту (раздел 7.5.А) остаётся предпочтительным способом именно потому, что не требует останавливать сервис — он использует корректный снимок SQLite (`VACUUM INTO`/Backup API) и безопасен на живом контейнере. В документации отразить оба варианта: «бэкап на лету — через панель/CLI» и «прямой `tar` директории — безопасен, если сервис перед этим остановлен».
---
## 10. Развёртывание
1. Поставка — `Dockerfile` + `docker-compose.yml` + документация.
2. **Reverse-proxy обязателен** и является единым источником TLS-сертификатов. Он:
- терминирует HTTPS для панели (HTTPS **не** реализуется в коде панели);
- выпускает и автообновляет сертификаты через ACME/Let's Encrypt;
- поставляет PEM-файлы сертификата в контейнер SelfPost через **bind mount с хоста** (read-only для SelfPost, тот же принцип, что и для остального состояния — раздел 9), откуда их читает Postfix для TLS на портах 465/25 (и 587, если включён) — см. раздел 5.2.
3. **Проект не привязан к конкретному reverse-proxy.** Документация должна давать примеры интеграции для нескольких распространённых вариантов, а не навязывать один. Основной (по умолчанию) — **Apache**; остальные — как альтернативные фрагменты:
- **Apache (httpd)** — *основной сценарий, готовый `docker-compose.yml` из коробки.* Терминация HTTPS для панели через `mod_ssl` + `mod_proxy`/`mod_proxy_http`. Сертификаты — через `certbot` (Apache-плагин) либо встроенный `mod_md`. При использовании certbot PEM-файлы лежат готовыми в `/etc/letsencrypt/live/<hostname>/` на хосте и монтируются в SelfPost bind mount'ом напрямую (read-only) — прозрачный путь для потребления Postfix'ом, без промежуточного извлечения. Для `mod_md` показать, как отдать сертификат в PEM в смонтированную директорию.
- **nginx** (+ certbot/acme.sh) — PEM-файлы также лежат на диске хоста готовыми, монтируются напрямую bind mount'ом. Близкий по прозрачности к Apache+certbot.
- **Caddy** — простейшая автоматика ACME. Пишет сертификаты как PEM в своём data-каталоге на хосте; смонтировать его bind mount'ом read-only и указать пути в `TLS_CERT_FILE`/`TLS_KEY_FILE`. Нюанс: путь включает внутреннюю раскладку хранилища Caddy (с именем ACME-CA) — исполнителю **проверить актуальный путь хранения в текущей версии Caddy**.
- **Traefik** — сертификаты в `acme.json`, потребуется шаг извлечения PEM.
Для каждого варианта показать: связку в `docker-compose.yml` (или фрагмент конфига), какая директория хоста монтируется в SelfPost bind mount'ом и по каким путям (`TLS_CERT_FILE`/`TLS_KEY_FILE`). Отличия форматов хранения сертификатов у разных прокси — ключевой практический момент, который документация обязана прояснить.
4. При использовании общего hostname для панели и почты это **один сертификат**, обслуживающий оба тракта — самый простой вариант, его стоит показать как основной сценарий в каждом примере.
5. **Рекомендуемый дефолт — Apache** (готовый `docker-compose.yml`, заводящийся «из коробки»); остальные варианты — как альтернативные фрагменты. Обоснование: целевая площадка пользователя уже использует Apache, а связка Apache+certbot даёт готовые PEM-файлы без промежуточных шагов извлечения — минимум движущихся частей в потреблении сертификата Postfix'ом.
6. В docker-compose для контейнера панели/приложения заложить hardening: непривилегированный запуск, при возможности `cap_drop`, ограничение доступной ФС.
7. Документация должна включать раздел **«Требования к площадке»** — краткий чеклист инфраструктурных предпосылок (разблокированный порт 25, статический IP, PTR/rDNS), которые оператор обеспечивает до развёртывания. Без подробного разбора ограничений — только чеклист «что должно быть готово».
8. Документация должна включать раздел **«Настройка DNS»**, явно разделяя записи уровня сервера и уровня домена:
- **Уровень сервера (один раз):** PTR/rDNS для IP сервера.
- **Уровень домена (для КАЖДОГО добавленного отправляющего домена):** SPF-запись, указывающая на этот сервер; DKIM TXT-запись (берётся из панели, своя на каждый домен); DMARC-запись. Без корректных per-domain записей почта соответствующего домена будет попадать в спам. Подчеркнуть, что при добавлении нового домена в панели пользователь обязан внести его DNS-записи.
9. Документация должна включать раздел **«Прогрев IP»** — предупреждение, что свежий IP требует постепенного наращивания объёма отправки и проверки блоклистов (Spamhaus и т.п.).
10. **`docker-compose.yml` должен использовать фиксированный тег версии образа** (например, `selfpost:1.3.0`), не `:latest`. Это прямое следствие требования версионирования бэкапов (раздел 7.5.А) — без явного тега невозможно достоверно определить, какой версией был создан конкретный бэкап, и проверка совместимости при восстановлении теряет смысл.
11. Документация должна указывать **ориентировочные минимальные требования к машине**: 1 vCPU, ~512МБ–1ГБ RAM (при простое стек занимает ориентировочно 100–150МБ, с запасом под нагрузку), 8–10ГБ диска — с оговоркой, что диск растёт в первую очередь за счёт журнала отправки (ограничен `SEND_LOG_RETENTION_DAYS`) и ротируемого `mail.log` (раздел 9), а не самого приложения. Отдельно упомянуть рекомендацию настроить небольшой swap на машинах с малым объёмом RAM — дешёвая страховка на случай одновременного всплеска (бэкап + фильтрация журнала + несколько TLS-хендшейков одновременно).
### 10.1. Сборка и публикация образа (CI)
Образ собирается автоматически в CI и публикуется в container registry как **неизменяемый артефакт под тегом версии**. Именно это делает реально работающими два уже принятых требования: проверку совместимости при восстановлении бэкапа (раздел 7.5.А) и фиксированный тег образа в `docker-compose.yml` (п. 10 выше). Если бы пользователь пересобирал образ локально из `Dockerfile`, две сборки «одной и той же версии» на разных машинах/в разное время не были бы идентичны (дрейф базового `debian:bookworm-slim` и версий apt-пакетов), и гарантия «восстанавливай в ту же версию» держалась бы на честном слове. Публикуемый по тегу immutable-образ снимает это: любой инстанс тянет побитово один и тот же образ.
**Механика:**
1. **Триггер — git-тег вида `vX.Y.Z`.** Push тега запускает CI-сборку. Обычные коммиты в ветку образ не публикуют.
2. **Версия выводится из тега в одном месте** и прокидывается в сборку двумя связанными способами: в бинарник панели через `-ldflags "-X main.version=X.Y.Z"` (раздел 11.1) и в тег самого образа. Это структурно гарантирует инвариант «тег образа == вшитая версия бинарника», на который опирается 7.5.А, — версия не задаётся вручную в двух местах, где легко разойтись.
3. **Публикация** — `<registry>/<owner>/selfpost:X.Y.Z`, тег иммутабельный. `:latest` для потребления в `docker-compose.yml` не используется (п. 10 выше).
**CI и registry — GitHub.** Workflow (GitHub Actions) лежит в самом репозитории (`.github/workflows/`), зеркалится на GitHub вместе с остальным кодом (раздел 11.7) и **выполняется там**; образ публикуется в **GitHub Container Registry (`ghcr.io`)**. Обоснование: для public-образов `ghcr` бесплатен и **без rate-limit на анонимный pull** — в отличие от Docker Hub, где анонимный pull лимитирован, а образ тянут на голом VPS при деплое, часто без `docker login`. Связка Actions + `ghcr` не добавляет внешних сервисов сверх уже используемого GitHub-зеркала.
**Альтернатива — `Quay.io` (задокументировать, но по умолчанию не реализовывать).** Нейтральный registry, тоже без лимитов на анонимный pull для public-образов, со встроенным сканированием уязвимостей, и не завязанный на конкретного CI-вендора (пушить в него можно из любого CI, включая тот же GitHub Actions). Зафиксирован как готовый путь отхода на случай, если в будущем захочется отвязать хранение образа от GitHub или уйти от моновендорности, — переезд не затрагивает остальной дизайн (меняется только адрес registry в workflow и в примере `docker-compose.yml`). На текущем этапе выбран `ghcr` ради минимума движущихся частей.
---
## 11. Deliverables (что должно быть на выходе)
1. `Dockerfile` — сборка единого образа (Debian slim). Версия SelfPost зашивается в бинарник панели на этапе сборки (`go build -ldflags "-X main.version=..."`) и должна совпадать с тегом самого Docker-образа — используется для проверки совместимости при восстановлении бэкапа (раздел 7.5.А).
2. `supervisord.conf` — конфигурация процессов (Postfix, OpenDKIM, панель) с корректным `priority=` и стартовым скриптом-обёрткой для Postfix, ожидающим готовности milter-сокетов перед запуском (раздел 4).
3. Конфигурационные шаблоны Postfix (включая SASL, `smtpd_sender_login_maps`, milter-цепочку `smtpd_milters` с OpenDKIM + journal-milter) и OpenDKIM (`KeyTable`/`SigningTable`).
4. Исходный код панели на Go (структурированный проект), с вендоренным HTMX. Включает: HTTP-сервер панели, journal-milter (приём From/To/Subject/SASL-user), log-tailer (обновление статусов доставки), rate-limit проверку, работу с SQLite, логику полного бэкапа/восстановления и экспорта/импорта домена (раздел 7.5).
5. `docker-compose.yml` — основной с Apache как reverse-proxy; альтернативные фрагменты для nginx/Caddy/Traefik.
6. **CLI-утилита резервного копирования** (например, `selfpost-backup`) внутри образа, вызываемая через `docker exec`, — эквивалент кнопки бэкапа в панели, для скриптовых/cron-сценариев (раздел 7.5.А).
7. `README.md` — установка, требования к площадке, per-domain настройка DNS, прогрев IP, эксплуатация, **процедура полного бэкапа/восстановления и процедура экспорта/импорта домена** (с явным указанием, что оба типа файлов содержат секреты и требуют бережного обращения, как пароль). Также ссылка на репозиторий: **Codeberg — основной, GitHub — зеркало** (зеркалирование кода настраивается push-зеркалированием средствами Codeberg; поверх зеркала на стороне GitHub работает CI-workflow сборки и публикации образа — см. раздел 10.1 и п. 10 ниже). README также указывает, откуда тянуть образ (`ghcr.io`), и что `docker-compose.yml` использует фиксированный тег версии (раздел 10, п. 10).
8. Первичная инициализация — реализована как secret-link при первом запуске (раздел 7.6, п. 1), отдельного скрипта для задания пароля администратора не требуется. DKIM-ключи генерируются панелью per-domain при добавлении домена, а не на этом шаге.
9. **`LICENSE` — AGPL-3.0.** Выбор осознанный: в отличие от GPL, AGPL закрывает «SaaS-лазейку» — обязывает раскрывать исходники изменённой версии, если её разворачивают как сервис, доступный через сеть, а не только при распространении копии кода. Файл лицензии — полный текст AGPL-3.0, без сокращений. В `README.md` — явное упоминание лицензии и её смысла в двух-трёх предложениях.
10. **CI-workflow сборки и публикации образа (GitHub Actions)** — файл в `.github/workflows/`, запускаемый по git-тегу `vX.Y.Z`: собирает образ, прокидывает версию из тега в `-ldflags "-X main.version=..."` и в тег образа, публикует в `ghcr.io` как `ghcr.io/<owner>/selfpost:X.Y.Z` (раздел 10.1). Workflow зеркалится на GitHub и выполняется там. Публикация в `Quay.io` — как задокументированная альтернатива (раздел 10.1), не как основной путь.
---
## 12. Инструкции исполняющему агенту
1. **Не делать git-коммитов без явной инструкции в промпте.** Изменения вносятся в рабочее дерево; коммит — только по прямому указанию.
2. После написания Go-кода — обязательно выполнять `go build` и `go vet`; исправлять все ошибки компиляции и предупреждения до завершения задачи. При наличии тестов — `go test`.
3. Проверять, что образ собирается (`docker build`) и контейнер стартует, прежде чем считать задачу выполненной.
4. Двигаться итеративно: сначала минимальный работающий скелет (сборка образа, запуск трёх процессов, пустая панель с логином), затем наращивать функциональность.
5. Требования безопасности из раздела 7.6 — не откладывать «на потом», закладывать сразу при написании соответствующих эндпоинтов.
6. Любое отклонение от ограничений раздела 2 или добавление сущностей из раздела 3 (Out of Scope) — согласовывать, не реализовывать по своей инициативе.
7. **Перед тем как приступать к реализации, агент должен предложить конкретный план (список задач/этапов) на основе этого ТЗ** и дать возможность свериться/скорректировать план до начала написания кода — не начинать кодить сразу по первому сообщению без явного подтверждения плана.
8. **Лицензии внешних Go-зависимостей** (в частности, библиотеки для milter-протокола, раздел 7.3) — проверять перед добавлением. Postfix/OpenDKIM/Cyrus SASL/supervisord запускаются отдельными процессами и не ограничивают лицензию проекта (раздел 11, п.9, AGPL-3.0) независимо от своих лицензий. Библиотеки, которые компилируются непосредственно в Go-бинарник панели, должны быть permissive (MIT/BSD/Apache-2.0) либо GPL-семейства (GPL/LGPL — совместимы с AGPL-3.0 по построению); избегать зависимостей с иными, не проверенными на совместимость лицензиями.
+22
View File
@@ -0,0 +1,22 @@
module codeberg.org/mix/selfpost
go 1.26
require (
github.com/emersion/go-milter v0.4.1
golang.org/x/crypto v0.54.0
modernc.org/sqlite v1.53.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+88
View File
@@ -0,0 +1,88 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M=
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+27
View File
@@ -0,0 +1,27 @@
package app
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// passwordBytes is the number of random bytes behind a generated application
// password. 24 bytes = 192 bits of entropy, well above any brute-force concern
// for a SASL credential the panel shows exactly once (spec 7.6.1).
const passwordBytes = 24
// generatePassword returns a strong, URL-safe random password for an
// application's SASL account. The panel generates it, shows it once and never
// stores the plaintext (spec 7.6.1); sasldb2 keeps only the hashed form.
//
// base64url output keeps the password to a safe ASCII alphabet with no shell or
// SMTP-special characters, so it survives being typed into client configuration
// and passed to saslpasswd2 over stdin unchanged.
func generatePassword() (string, error) {
buf := make([]byte, passwordBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate password: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
+181
View File
@@ -0,0 +1,181 @@
package app
import (
"bufio"
"bytes"
"encoding/hex"
"fmt"
"os/exec"
"strings"
)
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
// for application credentials (spec 5.1). The panel is the only writer; Postfix
// reads it to authenticate SMTP clients. Accounts are created and removed with
// the standard saslpasswd2 tool ("эквивалент saslpasswd2", per the plan).
type SASLDB struct {
path string // sasldb2 file, under /data so it survives restarts (spec 9)
realm string // SASL realm, so lookups match what Postfix's SASL uses
// run executes saslpasswd2. It is a field so tests can substitute a fake;
// the default shells out to the real binary via runSaslpasswd2.
run func(args []string, stdin []byte) error
// dump reads the raw sasldb2 as db_dump key/value pairs (Berkeley DB). It is
// a field so tests can substitute a fake; the default runs db_dump.
dump func(path string) ([]byte, error)
}
// NewSASLDB builds a manager for the sasldb2 at path with the given realm. The
// realm should match SELFPOST_HOSTNAME so the account identity lines up with
// Postfix's SASL configuration in Phase 5.
func NewSASLDB(path, realm string) *SASLDB {
return &SASLDB{path: path, realm: realm, run: runSaslpasswd2, dump: dumpSASLDB}
}
// Set creates or updates an application's SASL account with the given password
// (spec 5.1, 7.2.9). Used both at creation and when a password is regenerated;
// saslpasswd2 overwrites an existing entry in place.
//
// The password is passed to saslpasswd2 on stdin (never as an argument, so it
// cannot leak through the process table or logs). The login is passed as a
// separate argv element after being whitelisted by validateLogin — it never
// goes through a shell and is never interpolated into a command string (spec
// 7.6.3).
func (s *SASLDB) Set(login, password string) error {
if err := validateLogin(login); err != nil {
return err
}
// -p: read the passphrase from stdin (pipe mode, no tty prompt).
// -c: create the account / set the password.
// -f: operate on our sasldb2 rather than the system default path.
// -u: the realm the account lives under.
args := []string{"-p", "-c", "-f", s.path, "-u", s.realm, login}
if err := s.run(args, []byte(password)); err != nil {
return fmt.Errorf("saslpasswd2 set %q: %w", login, err)
}
return nil
}
// Delete removes an application's SASL account (spec 7.2.8). A missing account
// is not treated as an error, so deletion is idempotent and safe to retry.
func (s *SASLDB) Delete(login string) error {
if err := validateLogin(login); err != nil {
return err
}
// -d: delete the account.
args := []string{"-d", "-f", s.path, "-u", s.realm, login}
if err := s.run(args, nil); err != nil {
return fmt.Errorf("saslpasswd2 delete %q: %w", login, err)
}
return nil
}
// ErrSecretNotFound is returned by Secret when the sasldb2 has no password entry
// for the login under this realm.
var ErrSecretNotFound = fmt.Errorf("sasl secret not found")
// Secret returns an application's stored password so it can be carried in a
// domain export and re-created verbatim on another instance (spec 7.5.B). This
// is possible because sasldb2 keeps the SASL secret in a password-equivalent
// form (the plaintext userPassword property, to serve challenge-response
// mechanisms) — unlike the admin's one-way bcrypt hash (spec 7.6). The value is
// realm-independent, so the importer can re-key it under its own realm.
//
// It reads the database with db_dump (Berkeley DB), passing only our own file
// path as a fixed argument (no shell, no user input — spec 7.6.3), and returns
// ErrSecretNotFound if the login has no entry.
func (s *SASLDB) Secret(login string) (string, error) {
if err := validateLogin(login); err != nil {
return "", err
}
out, err := s.dump(s.path)
if err != nil {
return "", fmt.Errorf("read sasldb2 for %q: %w", login, err)
}
secret, ok, err := parseSASLSecret(out, login, s.realm)
if err != nil {
return "", fmt.Errorf("parse sasldb2 for %q: %w", login, err)
}
if !ok {
return "", fmt.Errorf("login %q: %w", login, ErrSecretNotFound)
}
return secret, nil
}
// parseSASLSecret scans db_dump's byte-value output for the userPassword entry
// keyed by (login, realm). sasldb2 keys are NUL-separated tuples
// "<login>\0<realm>\0<property>"; the matching value is the stored password.
func parseSASLSecret(dump []byte, login, realm string) (string, bool, error) {
sc := bufio.NewScanner(bytes.NewReader(dump))
// sasldb2 records are tiny, but raise the line cap so a long hex line is
// never silently truncated.
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
inData := false
var keyBytes []byte
haveKey := false
for sc.Scan() {
line := sc.Text()
if !inData {
if line == "HEADER=END" {
inData = true
}
continue
}
if line == "DATA=END" {
break
}
// Each data line is a single leading space followed by hex.
hexStr := strings.TrimPrefix(line, " ")
raw, err := hex.DecodeString(hexStr)
if err != nil {
return "", false, fmt.Errorf("bad db_dump hex line: %w", err)
}
if !haveKey {
keyBytes = raw
haveKey = true
continue
}
// raw is the value for keyBytes.
haveKey = false
parts := bytes.Split(keyBytes, []byte{0})
if len(parts) != 3 {
continue
}
if string(parts[0]) == login && string(parts[1]) == realm && string(parts[2]) == "userPassword" {
return string(raw), true, nil
}
}
if err := sc.Err(); err != nil {
return "", false, err
}
return "", false, nil
}
// runSaslpasswd2 executes the real saslpasswd2 with the given arguments and
// stdin. Arguments are passed as a fixed argv (no shell), so no user input is
// ever interpreted as a command (spec 7.6.3).
func runSaslpasswd2(args []string, stdin []byte) error {
cmd := exec.Command("saslpasswd2", args...)
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// dumpSASLDB runs db_dump to export the sasldb2 as key/value hex pairs. The path
// is our own sasldb2 file (never user input) and is passed as a fixed argument
// with no shell (spec 7.6.3).
func dumpSASLDB(path string) ([]byte, error) {
cmd := exec.Command("db_dump", path)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("db_dump: %w: %s", err, strings.TrimSpace(string(out)))
}
return out, nil
}
+151
View File
@@ -0,0 +1,151 @@
package app
import (
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
)
type fakeRun struct {
args []string
stdin string
calls int
}
func newFakeSASL() (*SASLDB, *fakeRun) {
fr := &fakeRun{}
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.run = func(args []string, stdin []byte) error {
fr.calls++
fr.args = args
fr.stdin = string(stdin)
return nil
}
return s, fr
}
func TestSASLSetPassesPasswordOnStdinNotArgv(t *testing.T) {
s, fr := newFakeSASL()
const secret = "s3cr3t-p4ss"
if err := s.Set("alerts", secret); err != nil {
t.Fatalf("Set: %v", err)
}
if fr.stdin != secret {
t.Errorf("password not passed on stdin: got %q", fr.stdin)
}
joined := strings.Join(fr.args, " ")
if strings.Contains(joined, secret) {
t.Errorf("password leaked into argv: %q", joined)
}
// Expected fixed flags and the login as its own trailing argument.
want := []string{"-p", "-c", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "alerts"}
if len(fr.args) != len(want) {
t.Fatalf("args = %v, want %v", fr.args, want)
}
for i := range want {
if fr.args[i] != want[i] {
t.Fatalf("args = %v, want %v", fr.args, want)
}
}
}
func TestSASLDeleteArgs(t *testing.T) {
s, fr := newFakeSASL()
if err := s.Delete("alerts"); err != nil {
t.Fatalf("Delete: %v", err)
}
want := []string{"-d", "-f", "/data/sasl/sasldb2", "-u", "mail.example.com", "alerts"}
if strings.Join(fr.args, " ") != strings.Join(want, " ") {
t.Errorf("delete args = %v, want %v", fr.args, want)
}
if fr.stdin != "" {
t.Errorf("delete should not send stdin, got %q", fr.stdin)
}
}
// makeDump builds a db_dump byte-value document from key/value byte pairs, the
// same shape `db_dump <sasldb2>` emits.
func makeDump(pairs [][2][]byte) []byte {
var b strings.Builder
b.WriteString("VERSION=3\nformat=bytevalue\ntype=hash\nHEADER=END\n")
for _, p := range pairs {
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[0]))
fmt.Fprintf(&b, " %s\n", hex.EncodeToString(p[1]))
}
b.WriteString("DATA=END\n")
return []byte(b.String())
}
func saslKey(login, realm, prop string) []byte {
return []byte(login + "\x00" + realm + "\x00" + prop)
}
func TestSecretExtractsPassword(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(path string) ([]byte, error) {
if path != "/data/sasl/sasldb2" {
t.Errorf("dump path = %q", path)
}
return makeDump([][2][]byte{
{saslKey("other", "mail.example.com", "userPassword"), []byte("otherpw")},
{saslKey("alerts", "mail.example.com", "userPassword"), []byte("hunter2-pass")},
}), nil
}
got, err := s.Secret("alerts")
if err != nil {
t.Fatalf("Secret: %v", err)
}
if got != "hunter2-pass" {
t.Errorf("Secret = %q, want %q", got, "hunter2-pass")
}
}
func TestSecretRealmMismatchNotFound(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(string) ([]byte, error) {
// Same login but a different realm must not match.
return makeDump([][2][]byte{
{saslKey("alerts", "other.host", "userPassword"), []byte("hunter2")},
}), nil
}
if _, err := s.Secret("alerts"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
}
}
func TestSecretMissingLoginNotFound(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
s.dump = func(string) ([]byte, error) {
return makeDump(nil), nil
}
if _, err := s.Secret("ghost"); !errors.Is(err, ErrSecretNotFound) {
t.Errorf("Secret err = %v, want ErrSecretNotFound", err)
}
}
func TestSecretRejectsInvalidLoginBeforeDump(t *testing.T) {
s := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
called := false
s.dump = func(string) ([]byte, error) { called = true; return nil, nil }
if _, err := s.Secret("bad login"); err == nil {
t.Error("Secret accepted invalid login")
}
if called {
t.Error("db_dump invoked for an invalid login")
}
}
func TestSASLRejectsInvalidLoginBeforeExec(t *testing.T) {
s, fr := newFakeSASL()
if err := s.Set("bad login", "pw"); err == nil {
t.Error("Set accepted invalid login")
}
if err := s.Delete("bad@login"); err == nil {
t.Error("Delete accepted invalid login")
}
if fr.calls != 0 {
t.Errorf("saslpasswd2 invoked %d times for invalid logins, want 0", fr.calls)
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package app owns application accounts (spec 4.1, 5.1): the SASL credentials in
// sasldb2, the per-application sender address mode, and the
// smtpd_sender_login_maps bindings that tie each login to the addresses it may
// send from. It keeps those three stores — the SQLite registry, sasldb2 and the
// Postfix map — in agreement and drives the Postfix reload.
package app
import (
"codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store"
)
// SenderMaps is the slice of the Postfix manager the application service needs:
// rebuilding the sender_login_maps from the current bindings and reloading.
// *postfix.Postfix satisfies it; tests substitute a fake.
type SenderMaps interface {
RebuildSenderLoginMaps(bindings []postfix.Binding) error
}
// Service coordinates application state across SQLite, sasldb2 and the Postfix
// sender_login_maps. Web handlers validate raw input first; the Service performs
// the domain-ownership checks that must not be skipped (spec 7.6.2) and keeps
// the stores consistent.
type Service struct {
store *store.Store
sasl *SASLDB
pf SenderMaps
}
// NewService builds the application service over the shared store, the sasldb2
// manager and the Postfix manager.
func NewService(st *store.Store, sasl *SASLDB, pf SenderMaps) *Service {
return &Service{store: st, sasl: sasl, pf: pf}
}
// List returns a domain's applications with their address lists (spec 7.2.6).
func (s *Service) List(domainID int64) ([]store.Application, error) {
return s.store.ListApplicationsByDomain(domainID)
}
// Get returns one application by id (store.ErrApplicationNotFound if absent).
func (s *Service) Get(id int64) (store.Application, error) {
return s.store.GetApplication(id)
}
// Create adds an application to a domain: it validates the login and (in list
// mode) that every address belongs to the domain (spec 7.6.2), generates a
// strong password, writes the SASL account and rebuilds the sender map (spec
// 7.2.5). The generated password is returned so the caller can show it exactly
// once (spec 7.6.1) — it is never persisted in plaintext.
//
// The registry row is written first so its UNIQUE constraint is the sole arbiter
// of a duplicate login (avoiding a check-then-act race and, crucially, avoiding
// clobbering an existing account's password in sasldb2). If the SASL write or
// the map rebuild fails, everything is rolled back so we never leave an
// application the panel cannot fully account for.
func (s *Service) Create(domainID int64, login, mode string, rawAddresses []string) (store.Application, string, error) {
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
if err != nil {
return store.Application{}, "", err
}
password, err := generatePassword()
if err != nil {
return store.Application{}, "", err
}
a, err := s.store.AddApplication(domainID, login, mode, addresses)
if err != nil {
return store.Application{}, "", err
}
if err := s.sasl.Set(login, password); err != nil {
s.rollbackCreate(a.ID, "") // login has no SASL account yet; nothing to unset
return store.Application{}, "", err
}
if err := s.Resync(); err != nil {
s.rollbackCreate(a.ID, login)
return store.Application{}, "", err
}
return a, password, nil
}
// rollbackCreate best-effort undoes a partially created application after a
// downstream failure: it removes the SASL account (if one was written) and the
// registry row. Errors here are subordinate to the original failure the caller
// returns.
func (s *Service) rollbackCreate(id int64, login string) {
if login != "" {
_ = s.sasl.Delete(login)
}
_, _ = s.store.DeleteApplication(id)
}
// ImportApplication re-creates an application from a domain-export file (spec
// 7.5.B): it validates the login and (in list mode) that every address belongs
// to the domain, inserts the registry row and writes the SASL account with the
// imported password verbatim, re-keyed under this instance's realm so the
// credential keeps working without regeneration. It deliberately does not
// rebuild the sender map — the caller (domain import) does that once after all
// applications are in — and returns store.ErrLoginExists if the login collides.
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
if err != nil {
return err
}
if err := validateImportedPassword(password); err != nil {
return err
}
a, err := s.store.AddApplication(domainID, login, mode, addresses)
if err != nil {
return err // ErrLoginExists surfaces to the caller as a friendly message
}
if err := s.sasl.Set(login, password); err != nil {
_, _ = s.store.DeleteApplication(a.ID)
return err
}
return nil
}
// Secret returns an application's stored password for a domain export (spec
// 7.5.B). See SASLDB.Secret for why this is possible and safe.
func (s *Service) Secret(login string) (string, error) {
return s.sasl.Secret(login)
}
// UpdateMode switches an application's address mode / list and rebuilds the
// sender map (spec 7.2.7). The login and password are untouched. Addresses are
// re-validated against the application's domain.
func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error {
a, err := s.store.GetApplication(id)
if err != nil {
return err
}
addresses, err := s.validateForDomain(a.DomainID, a.Login, mode, rawAddresses)
if err != nil {
return err
}
if err := s.store.UpdateApplicationMode(id, mode, addresses); err != nil {
return err
}
return s.Resync()
}
// RegeneratePassword issues a fresh password for an existing application (spec
// 7.2.9). The old password is invalidated by overwriting the SASL account; the
// address mode and bindings are unchanged, so no map rebuild is needed. The new
// password is returned to be shown once.
func (s *Service) RegeneratePassword(id int64) (string, error) {
a, err := s.store.GetApplication(id)
if err != nil {
return "", err
}
password, err := generatePassword()
if err != nil {
return "", err
}
if err := s.sasl.Set(a.Login, password); err != nil {
return "", err
}
return password, nil
}
// Delete removes an application: its SASL account, its registry row (and address
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
// 7.2.8). The domain and other applications are untouched.
func (s *Service) Delete(id int64) error {
a, err := s.store.DeleteApplication(id)
if err != nil {
return err
}
if err := s.sasl.Delete(a.Login); err != nil {
return err
}
// Drop the application's level-2 limit, if any (spec 7.4); rate_limits has no
// cascade of its own.
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
return err
}
return s.Resync()
}
// RateLimit returns the application-level differentiated rate limit (spec 7.4),
// and whether one is configured, for the application's edit form.
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
}
// SaveRateLimit stores the application-level rate limit. The caller has validated
// the IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload
// is needed.
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeApp,
RefID: appID,
AllowedIPs: ips,
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
}
// ClearRateLimit removes the application-level rate limit (spec 7.4).
func (s *Service) ClearRateLimit(appID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
}
// PurgeDomainSASL removes the SASL accounts of every application bound to a
// domain. It must be called before the domain's registry rows are cascade-
// deleted, while the logins are still known (spec 7.2.4). The registry rows and
// the sender map are handled by the domain deletion path; this only clears
// sasldb2, which has no cascade of its own.
func (s *Service) PurgeDomainSASL(domainID int64) error {
logins, err := s.store.ListLoginsByDomain(domainID)
if err != nil {
return err
}
for _, login := range logins {
if err := s.sasl.Delete(login); err != nil {
return err
}
}
return nil
}
// Resync rebuilds smtpd_sender_login_maps from the full set of application
// bindings and reloads Postfix (spec 5.1). It is the single idempotent apply
// path shared by create/edit/delete and is also reachable from the manual
// reload button; it doubles as recovery if the map ever drifts from the
// database.
func (s *Service) Resync() error {
bindings, err := s.store.ListBindings()
if err != nil {
return err
}
pfBindings := make([]postfix.Binding, 0, len(bindings))
for _, b := range bindings {
pfBindings = append(pfBindings, postfix.Binding{Address: b.Address, Login: b.Login})
}
return s.pf.RebuildSenderLoginMaps(pfBindings)
}
// validateForDomain resolves the domain, validates the login and address mode,
// and — in list mode — validates that every address belongs to the domain
// (spec 7.6.2). It returns the cleaned address list, which is empty in wildcard
// mode. Resolving the domain here also confirms it exists before any write.
func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddresses []string) ([]string, error) {
d, err := s.store.GetDomain(domainID)
if err != nil {
return nil, err
}
if err := validateLogin(login); err != nil {
return nil, err
}
if err := validateAddressMode(mode); err != nil {
return nil, err
}
if mode == store.AddressModeWildcard {
return nil, nil
}
return parseAddresses(rawAddresses, d.Name)
}
+289
View File
@@ -0,0 +1,289 @@
package app
import (
"errors"
"path/filepath"
"testing"
"codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store"
)
// fakeMaps records the last set of bindings passed to a rebuild and can be told
// to fail, so we can exercise the rollback paths.
type fakeMaps struct {
last []postfix.Binding
calls int
failNext bool
}
func (f *fakeMaps) RebuildSenderLoginMaps(b []postfix.Binding) error {
f.calls++
if f.failNext {
f.failNext = false
return errors.New("boom")
}
f.last = b
return nil
}
// saslRecorder is a fake sasldb2 backend recording set/delete calls.
type saslRecorder struct {
set map[string]string // login -> password
deleted []string
failNext bool
}
func newServiceHarness(t *testing.T) (*Service, *store.Store, *saslRecorder, *fakeMaps) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
rec := &saslRecorder{set: map[string]string{}}
sasl := NewSASLDB("/data/sasl/sasldb2", "mail.example.com")
sasl.run = func(args []string, stdin []byte) error {
if rec.failNext {
rec.failNext = false
return errors.New("saslpasswd2 failed")
}
// args end with the login; a "-d" anywhere means delete.
login := args[len(args)-1]
del := false
for _, a := range args {
if a == "-d" {
del = true
}
}
if del {
rec.deleted = append(rec.deleted, login)
delete(rec.set, login)
} else {
rec.set[login] = string(stdin)
}
return nil
}
maps := &fakeMaps{}
return NewService(st, sasl, maps), st, rec, maps
}
func addDomain(t *testing.T, st *store.Store, name string) store.Domain {
t.Helper()
d, err := st.AddDomain(name, "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
return d
}
func TestServiceCreateWildcard(t *testing.T) {
svc, st, rec, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, pw, err := svc.Create(d.ID, "alerts", store.AddressModeWildcard, nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
if a.Login != "alerts" {
t.Errorf("login = %q", a.Login)
}
if rec.set["alerts"] != pw {
t.Errorf("sasl password %q != returned %q", rec.set["alerts"], pw)
}
if len(maps.last) != 1 || maps.last[0].Address != "@example.com" || maps.last[0].Login != "alerts" {
t.Errorf("map bindings = %+v", maps.last)
}
}
func TestServiceCreateListValidatesDomain(t *testing.T) {
svc, st, _, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
// A cross-domain address is rejected before anything is written.
_, _, err := svc.Create(d.ID, "app1", store.AddressModeList, []string{"a@evil.com"})
if err == nil {
t.Fatal("Create accepted cross-domain address")
}
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 0 {
t.Errorf("application persisted despite validation failure: %+v", apps)
}
}
func TestServiceCreateDuplicateLogin(t *testing.T) {
svc, st, _, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
if _, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
_, _, err := svc.Create(d.ID, "dup", store.AddressModeWildcard, nil)
if !errors.Is(err, store.ErrLoginExists) {
t.Fatalf("duplicate create = %v, want ErrLoginExists", err)
}
}
func TestServiceCreateRollsBackOnSASLFailure(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
rec.failNext = true // saslpasswd2 fails on the first (set) call
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
if err == nil {
t.Fatal("expected Create to fail when SASL set fails")
}
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 0 {
t.Errorf("registry row not rolled back: %+v", apps)
}
}
func TestServiceCreateRollsBackOnMapFailure(t *testing.T) {
svc, st, rec, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
maps.failNext = true
_, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
if err == nil {
t.Fatal("expected Create to fail when map rebuild fails")
}
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 0 {
t.Errorf("registry row not rolled back: %+v", apps)
}
if _, ok := rec.set["app1"]; ok {
t.Errorf("SASL account not rolled back")
}
}
func TestServiceDelete(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
if err := svc.Delete(a.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, ok := rec.set["app1"]; ok {
t.Error("SASL account not deleted")
}
if len(rec.deleted) != 1 || rec.deleted[0] != "app1" {
t.Errorf("deleted logins = %v", rec.deleted)
}
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 0 {
t.Errorf("application not deleted: %+v", apps)
}
}
func TestServiceUpdateMode(t *testing.T) {
svc, st, _, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
if err := svc.UpdateMode(a.ID, store.AddressModeList, []string{"alerts@example.com"}); err != nil {
t.Fatalf("UpdateMode: %v", err)
}
if len(maps.last) != 1 || maps.last[0].Address != "alerts@example.com" {
t.Errorf("map after mode switch = %+v", maps.last)
}
got, _ := st.GetApplication(a.ID)
if got.AddressMode != store.AddressModeList || len(got.Addresses) != 1 {
t.Errorf("stored app after switch = %+v", got)
}
}
func TestServiceRegeneratePassword(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, pw1, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
pw2, err := svc.RegeneratePassword(a.ID)
if err != nil {
t.Fatalf("RegeneratePassword: %v", err)
}
if pw1 == pw2 {
t.Error("regenerated password equals the old one")
}
if rec.set["app1"] != pw2 {
t.Errorf("sasl password not updated to new value")
}
}
func TestImportApplicationWritesRowAndSASL(t *testing.T) {
svc, st, rec, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList,
[]string{"a@example.com"}, "imported-pw"); err != nil {
t.Fatalf("ImportApplication: %v", err)
}
// Registry row and SASL account written with the imported password verbatim.
apps, _ := st.ListApplicationsByDomain(d.ID)
if len(apps) != 1 || apps[0].Login != "mailer" {
t.Fatalf("apps = %+v", apps)
}
if rec.set["mailer"] != "imported-pw" {
t.Errorf("SASL password = %q, want the imported one", rec.set["mailer"])
}
// Import does not rebuild the sender map itself (the caller batches that).
if maps.calls != 0 {
t.Errorf("ImportApplication rebuilt the map %d times, want 0", maps.calls)
}
}
func TestImportApplicationRejectsBadInput(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
// Empty password.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, ""); err == nil {
t.Error("accepted empty imported password")
}
// Password with an embedded newline would truncate on the saslpasswd2 stdin.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeWildcard, nil, "line1\nline2"); err == nil {
t.Error("accepted password with control characters")
}
// Cross-domain address.
if err := svc.ImportApplication(d.ID, "mailer", store.AddressModeList, []string{"x@evil.com"}, "pw"); err == nil {
t.Error("accepted cross-domain address")
}
if apps, _ := st.ListApplicationsByDomain(d.ID); len(apps) != 0 {
t.Errorf("rows persisted despite validation failure: %+v", apps)
}
if len(rec.set) != 0 {
t.Errorf("SASL accounts written despite validation failure: %v", rec.set)
}
}
func TestServicePurgeDomainSASL(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
if _, _, err := svc.Create(d.ID, "app-a", store.AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Create(d.ID, "app-b", store.AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if err := svc.PurgeDomainSASL(d.ID); err != nil {
t.Fatalf("PurgeDomainSASL: %v", err)
}
if len(rec.set) != 0 {
t.Errorf("SASL accounts remain after purge: %v", rec.set)
}
if len(rec.deleted) != 2 {
t.Errorf("deleted %d logins, want 2", len(rec.deleted))
}
}
+139
View File
@@ -0,0 +1,139 @@
package app
import (
"fmt"
"strings"
"codeberg.org/mix/selfpost/internal/store"
)
const (
minLoginLen = 3
maxLoginLen = 64
)
// validateLogin enforces a strict server-side whitelist for the SASL login
// (spec 7.6.2). It intentionally excludes '@': the login is stored in sasldb2,
// where '@' separates the user from the realm, so allowing it would change the
// account's identity. Client validation is never trusted.
//
// The login is the one piece of user input that is passed to saslpasswd2 as a
// command argument (never through a shell, spec 7.6.3); this whitelist is what
// makes that safe.
func validateLogin(login string) error {
if len(login) < minLoginLen || len(login) > maxLoginLen {
return fmt.Errorf("login must be %d-%d characters", minLoginLen, maxLoginLen)
}
for _, r := range login {
lower := r >= 'a' && r <= 'z'
upper := r >= 'A' && r <= 'Z'
digit := r >= '0' && r <= '9'
if !lower && !upper && !digit && r != '.' && r != '-' && r != '_' {
return fmt.Errorf("login may contain only letters, digits, '.', '-' and '_'")
}
}
return nil
}
// validateImportedPassword guards a password taken from a domain-export file
// (spec 7.5.B) before it is written to sasldb2. Our own exports carry base64url
// passwords, but the file is untrusted input, so we reject an empty value or one
// containing control characters — saslpasswd2 reads the passphrase from stdin
// and a newline would silently truncate it (spec 7.6.2).
func validateImportedPassword(password string) error {
if password == "" {
return fmt.Errorf("imported application password is empty")
}
if len(password) > 1024 {
return fmt.Errorf("imported application password is too long")
}
for _, r := range password {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("imported application password contains control characters")
}
}
return nil
}
// validateAddressMode checks the submitted mode is one of the two known values.
func validateAddressMode(mode string) error {
if mode != store.AddressModeWildcard && mode != store.AddressModeList {
return fmt.Errorf("invalid address mode")
}
return nil
}
// normalizeAddress lower-cases and trims a sender address. Both the local part
// and domain are treated case-insensitively for the ownership check and for the
// generated map, matching how addresses are compared elsewhere.
func normalizeAddress(addr string) string {
return strings.ToLower(strings.TrimSpace(addr))
}
// validateSenderAddress enforces that a list-mode address is well-formed and,
// critically, belongs to the application's own domain (spec 7.6.2). The domain
// check is done here, before anything is written to a config file — not left to
// smtpd_sender_login_maps to catch at delivery time. domain must already be a
// validated, normalised domain name.
func validateSenderAddress(addr, domain string) error {
at := strings.LastIndexByte(addr, '@')
if at < 0 {
return fmt.Errorf("%q is not a valid email address", addr)
}
local, host := addr[:at], addr[at+1:]
if host != domain {
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
}
if err := validateLocalPart(local); err != nil {
return fmt.Errorf("%q: %w", addr, err)
}
return nil
}
// validateLocalPart applies a conservative whitelist to the part before '@'.
// This is deliberately stricter than RFC 5321 (no quoted local parts) so the
// value is always safe to write verbatim into the Postfix map (spec 7.6.4).
func validateLocalPart(local string) error {
if local == "" {
return fmt.Errorf("missing the part before '@'")
}
if local[0] == '.' || local[len(local)-1] == '.' {
return fmt.Errorf("local part must not start or end with '.'")
}
for i := 0; i < len(local); i++ {
c := local[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
}
}
return nil
}
// parseAddresses normalises, validates and de-duplicates a list of submitted
// sender addresses for a list-mode application. It requires at least one address
// and that each belongs to domain. The returned slice is de-duplicated but keeps
// submission order stable for display; the store sorts on read.
func parseAddresses(raw []string, domain string) ([]string, error) {
seen := make(map[string]bool)
out := make([]string, 0, len(raw))
for _, r := range raw {
addr := normalizeAddress(r)
if addr == "" {
continue
}
if err := validateSenderAddress(addr, domain); err != nil {
return nil, err
}
if seen[addr] {
continue
}
seen[addr] = true
out = append(out, addr)
}
if len(out) == 0 {
return nil, fmt.Errorf("list mode requires at least one address")
}
return out, nil
}
+92
View File
@@ -0,0 +1,92 @@
package app
import "testing"
func TestValidateLogin(t *testing.T) {
good := []string{"alerts", "prod-server", "app_1", "News.Letter"}
for _, l := range good {
if err := validateLogin(l); err != nil {
t.Errorf("validateLogin(%q) = %v, want nil", l, err)
}
}
bad := []string{
"ab", // too short
"alerts@example.com", // '@' not allowed (sasldb realm separator)
"has space", // whitespace
"inject\nline", // newline
"comma,login", // map value separator
"colon:login", // config separator
}
for _, l := range bad {
if err := validateLogin(l); err == nil {
t.Errorf("validateLogin(%q) = nil, want error", l)
}
}
}
func TestValidateSenderAddressDomainOwnership(t *testing.T) {
// The critical check (spec 7.6.2): an address must belong to the app's domain.
if err := validateSenderAddress("alerts@example.com", "example.com"); err != nil {
t.Errorf("same-domain address rejected: %v", err)
}
if err := validateSenderAddress("alerts@evil.com", "example.com"); err == nil {
t.Error("cross-domain address accepted, want rejection")
}
// A trailing-domain trick must not pass as ownership.
if err := validateSenderAddress("a@notexample.com", "example.com"); err == nil {
t.Error("suffix-domain address accepted, want rejection")
}
}
func TestValidateSenderAddressForm(t *testing.T) {
bad := []string{
"noat.example.com", // no '@'
"@example.com", // empty local part
".dot@example.com", // leading dot
"dot.@example.com", // trailing dot
"in ject@example.com", // space
"quote\"@example.com", // disallowed char
}
for _, a := range bad {
if err := validateSenderAddress(a, "example.com"); err == nil {
t.Errorf("validateSenderAddress(%q) = nil, want error", a)
}
}
}
func TestParseAddresses(t *testing.T) {
// Normalises case, trims, drops blanks, de-duplicates.
got, err := parseAddresses([]string{" Alerts@Example.com ", "", "noreply@example.com", "alerts@example.com"}, "example.com")
if err != nil {
t.Fatalf("parseAddresses: %v", err)
}
if len(got) != 2 || got[0] != "alerts@example.com" || got[1] != "noreply@example.com" {
t.Fatalf("parseAddresses = %v", got)
}
// Empty list in list mode is an error.
if _, err := parseAddresses([]string{"", " "}, "example.com"); err == nil {
t.Error("empty address list accepted, want error")
}
// A cross-domain address rejects the whole submission.
if _, err := parseAddresses([]string{"ok@example.com", "bad@other.com"}, "example.com"); err == nil {
t.Error("cross-domain address in list accepted, want error")
}
}
func TestGeneratePasswordStrength(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 50; i++ {
p, err := generatePassword()
if err != nil {
t.Fatalf("generatePassword: %v", err)
}
if len(p) < 30 {
t.Fatalf("password too short: %d chars", len(p))
}
if seen[p] {
t.Fatalf("duplicate password generated: %q", p)
}
seen[p] = true
}
}
+290
View File
@@ -0,0 +1,290 @@
// Package backup implements SelfPost's full-server backup and the restore
// version guard (spec 7.5.A). A full backup is a gzip-compressed tar of the
// consolidated persistent state under /data — the SQLite database (as a
// consistent snapshot), the per-domain DKIM keys and the SASL database — plus a
// manifest recording the SelfPost version that produced it. TLS certificates
// (the reverse proxy's responsibility) and the Postfix queue are deliberately
// excluded (spec 7.5.A).
//
// Restore is intentionally not a separate code path: a backup is extracted into
// the /data bind mount before first start, and the panel regenerates Postfix and
// OpenDKIM from the restored SQLite state exactly as on any normal start. The
// only restore-specific step is CheckRestore, which refuses to boot if the
// manifest's version does not match the running binary, so schema/format skew
// between versions cannot silently corrupt state (spec 7.5.A).
package backup
import (
"archive/tar"
"compress/gzip"
"database/sql"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver, for the VACUUM INTO snapshot
)
// FormatFull identifies a full-server backup manifest.
const FormatFull = "selfpost-full-backup"
// ManifestName is the manifest's filename, both inside the archive and, after a
// restore extraction, at the root of the data directory where CheckRestore
// looks for it.
const ManifestName = "manifest.json"
// Manifest is the small JSON document embedded in every backup archive. Its
// Version is the single fact that makes restore safe: the panel refuses to boot
// a data directory whose manifest version does not match its own binary (spec
// 7.5.A).
type Manifest struct {
Format string `json:"format"`
Version string `json:"version"`
CreatedAt string `json:"created_at"`
}
// Params configures a backup. DataDir is the consolidated state root (/data);
// DBPath is the live SQLite file within it, snapshotted consistently rather than
// copied byte-for-byte while it may be mid-write; Version is stamped into the
// manifest.
type Params struct {
DataDir string
DBPath string
Version string
}
// excludedFromArchive lists the data-directory entries a backup never carries.
// The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap
// state; a stale manifest from a previous restore must not be re-captured (a
// fresh one is written instead); and a "tls" directory holds the reverse
// proxy's certificates, which are explicitly out of scope for a SelfPost backup
// (spec 7.5.A) — excluding it keeps that guarantee even when an operator points
// TLS_CERT_FILE inside /data.
var excludedFromArchive = map[string]bool{
"selfpost.db": true,
"selfpost.db-wal": true,
"selfpost.db-shm": true,
"selfpost.db-journal": true,
"setup-token": true,
"tls": true,
ManifestName: true,
}
// Create writes a gzip-compressed tar backup to w. Archive entries are named
// relative to DataDir, so extracting the archive into the /data bind mount
// reconstructs the state in place (spec 7.5.A). The SQLite database is added as
// a consistent snapshot under "selfpost.db"; everything else under DataDir is
// copied as-is except the entries in excludedFromArchive.
func Create(w io.Writer, p Params) error {
if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required")
}
snapshot, cleanup, err := snapshotDB(p.DBPath)
if err != nil {
return err
}
defer cleanup()
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
manifest := Manifest{
Format: FormatFull,
Version: p.Version,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("backup: encode manifest: %w", err)
}
if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil {
return err
}
// The consistent SQLite snapshot, under the canonical filename the panel
// opens on start (the live file and its WAL/SHM are excluded from the walk).
if err := writeTarFile(tw, "selfpost.db", 0o640, snapshot); err != nil {
return err
}
if err := addTree(tw, p.DataDir); err != nil {
return err
}
if err := tw.Close(); err != nil {
return fmt.Errorf("backup: close tar: %w", err)
}
if err := gz.Close(); err != nil {
return fmt.Errorf("backup: close gzip: %w", err)
}
return nil
}
// addTree walks dataDir and adds every regular file (and directory, to preserve
// empty ones and modes) to tw under its path relative to dataDir, skipping the
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are
// skipped: /data holds none in normal operation, and copying them into a backup
// would be meaningless or unsafe.
func addTree(tw *tar.Writer, dataDir string) error {
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dataDir, path)
if err != nil {
return err
}
if rel == "." {
return nil // the data root itself is implicit
}
// filepath.Rel yields OS separators; tar names use forward slashes.
name := filepath.ToSlash(rel)
// Exclude by top-level name (the live DB, setup token and stale manifest
// all live at the data root).
if excludedFromArchive[name] {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil {
return err
}
switch {
case d.IsDir():
hdr := &tar.Header{
Typeflag: tar.TypeDir,
Name: name + "/",
Mode: int64(info.Mode().Perm()),
ModTime: info.ModTime(),
}
return tw.WriteHeader(hdr)
case info.Mode().IsRegular():
return writeTarFile(tw, name, info.Mode().Perm(), path)
default:
return nil // skip symlinks/sockets/devices
}
})
}
// writeTarBytes writes an in-memory file entry.
func writeTarBytes(tw *tar.Writer, name string, mode int64, data []byte) error {
hdr := &tar.Header{
Typeflag: tar.TypeReg,
Name: name,
Mode: mode,
Size: int64(len(data)),
ModTime: time.Now().UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("backup: write header %s: %w", name, err)
}
if _, err := tw.Write(data); err != nil {
return fmt.Errorf("backup: write %s: %w", name, err)
}
return nil
}
// writeTarFile streams a file from disk into the archive under name.
func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string) error {
f, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("backup: open %s: %w", srcPath, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return fmt.Errorf("backup: stat %s: %w", srcPath, err)
}
hdr := &tar.Header{
Typeflag: tar.TypeReg,
Name: name,
Mode: int64(mode.Perm()),
Size: info.Size(),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("backup: write header %s: %w", name, err)
}
if _, err := io.Copy(tw, f); err != nil {
return fmt.Errorf("backup: copy %s: %w", name, err)
}
return nil
}
// snapshotDB produces a consistent copy of the SQLite database at dbPath using
// VACUUM INTO, so the backup captures a coherent point-in-time image even while
// the panel is writing to the live file under WAL. It returns the snapshot path
// and a cleanup function the caller must defer.
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
dir, err := os.MkdirTemp("", "selfpost-backup-")
if err != nil {
return "", nil, fmt.Errorf("backup: temp dir: %w", err)
}
cleanup = func() { _ = os.RemoveAll(dir) }
target := filepath.Join(dir, "selfpost.db")
// A short busy timeout lets VACUUM INTO wait out a brief writer rather than
// failing immediately if the panel happens to be mid-write.
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
if err != nil {
cleanup()
return "", nil, fmt.Errorf("backup: open database: %w", err)
}
defer db.Close()
db.SetMaxOpenConns(1)
// VACUUM INTO takes a string literal, not a bound parameter. target is a path
// we generated (never user input); single quotes are doubled defensively.
stmt := "VACUUM INTO '" + strings.ReplaceAll(target, "'", "''") + "'"
if _, err := db.Exec(stmt); err != nil {
cleanup()
return "", nil, fmt.Errorf("backup: snapshot database: %w", err)
}
return target, cleanup, nil
}
// CheckRestore enforces the backup version guard (spec 7.5.A). If manifestPath
// exists (a backup was extracted into the data directory), its version must
// match binaryVersion or the panel refuses to start, telling the operator which
// image tag to use. On a match the manifest is consumed (deleted) so it guards
// only the first boot after a restore and never blocks a later in-place image
// upgrade. Absence of the manifest is the normal case and returns nil.
func CheckRestore(manifestPath, binaryVersion string) error {
data, err := os.ReadFile(manifestPath)
if os.IsNotExist(err) {
return nil // ordinary start, not a restore
}
if err != nil {
return fmt.Errorf("backup: read restore manifest: %w", err)
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err)
}
if m.Format != FormatFull {
return fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format)
}
if m.Version != binaryVersion {
return fmt.Errorf(
"backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)",
m.Version, binaryVersion, m.Version)
}
// Version matches: consume the manifest so subsequent normal starts (and
// in-place upgrades) are not gated by it.
if err := os.Remove(manifestPath); err != nil {
return fmt.Errorf("backup: consume restore manifest: %w", err)
}
return nil
}
+189
View File
@@ -0,0 +1,189 @@
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/store"
)
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
// DKIM key, SASL and transient files a backup must include or exclude.
func seedDataDir(t *testing.T) (dataDir, dbPath string) {
t.Helper()
dataDir = t.TempDir()
dbPath = filepath.Join(dataDir, "selfpost.db")
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatalf("add domain: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
// Transient files that must NOT be archived.
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
return dataDir, dbPath
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// readArchive returns the set of regular-file entries (name -> content) in a
// gzip tar produced by Create.
func readArchive(t *testing.T, data []byte) map[string]string {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
out := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
b, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("tar read %s: %v", hdr.Name, err)
}
out[hdr.Name] = string(b)
}
return out
}
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
dataDir, dbPath := seedDataDir(t)
var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
// Present.
for _, name := range []string{
ManifestName,
"selfpost.db",
"opendkim/keys/example.com/selfpost.private",
"sasl/sasldb2",
"postfix/sender_login_maps",
} {
if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name)
}
}
// Excluded.
for _, name := range []string{"setup-token", "selfpost.db-wal", "selfpost.db-shm"} {
if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name)
}
}
// Manifest is well-formed and carries the version.
var m Manifest
if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil {
t.Fatalf("manifest json: %v", err)
}
if m.Format != FormatFull || m.Version != "1.2.3" {
t.Errorf("manifest = %+v, want format=%s version=1.2.3", m, FormatFull)
}
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
snapPath := filepath.Join(t.TempDir(), "restored.db")
if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil {
t.Fatalf("write snapshot: %v", err)
}
st, err := store.Open(snapPath)
if err != nil {
t.Fatalf("open snapshot: %v", err)
}
defer st.Close()
domains, err := st.ListDomains()
if err != nil {
t.Fatalf("list domains from snapshot: %v", err)
}
if len(domains) != 1 || domains[0].Name != "example.com" {
t.Errorf("snapshot domains = %+v, want one example.com", domains)
}
}
func writeManifest(t *testing.T, dir, format, version string) string {
t.Helper()
path := filepath.Join(dir, ManifestName)
b, _ := json.Marshal(Manifest{Format: format, Version: version, CreatedAt: "now"})
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
return path
}
func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) {
if err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0"); err != nil {
t.Errorf("CheckRestore with no manifest = %v, want nil", err)
}
}
func TestCheckRestoreMatchConsumesManifest(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
if err := CheckRestore(path, "1.0.0"); err != nil {
t.Fatalf("CheckRestore matching = %v, want nil", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err)
}
}
func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, FormatFull, "1.0.0")
err := CheckRestore(path, "2.0.0")
if err == nil {
t.Fatal("CheckRestore mismatch = nil, want error")
}
if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") {
t.Errorf("error should name both versions: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("manifest must be kept on mismatch so the operator can switch images: %v", statErr)
}
}
func TestCheckRestoreWrongFormatRejected(t *testing.T) {
dir := t.TempDir()
path := writeManifest(t, dir, "something-else", "1.0.0")
if err := CheckRestore(path, "1.0.0"); err == nil {
t.Error("CheckRestore accepted a non-backup manifest")
}
}
+10
View File
@@ -0,0 +1,10 @@
// Package buildinfo exposes build-time metadata stamped via -ldflags.
package buildinfo
// Version is the SelfPost release. It is set at build time with
//
// -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=<tag>"
//
// and must match the Docker image tag; it is used for the backup/restore
// compatibility check (spec 7.5.A). Defaults to "dev" for local/unstamped builds.
var Version = "dev"
+233
View File
@@ -0,0 +1,233 @@
// Package dnscheck performs the read-only DNS lookups behind the panel's
// deliverability checks: forward-confirmed reverse DNS (FCrDNS) for the
// server's own hostname, and the DKIM/SPF/DMARC records published for each
// sending domain.
//
// Every lookup is bounded by a timeout and results are cached, because DNS is
// the one part of the status page that talks to the network: a slow or dead
// resolver must degrade a single card to "could not check", never hang the
// page. Nothing here changes state — the panel only reports what the world can
// see about this server.
package dnscheck
import (
"context"
"errors"
"net"
"strings"
"sync"
"time"
"codeberg.org/mix/selfpost/internal/health"
)
const (
// lookupTimeout bounds all the lookups of a single check together, so a
// dead resolver costs one wait and not one per record type.
lookupTimeout = 5 * time.Second
// serverTTL/domainTTL are how long a cached result stays fresh. The
// server's own hostname/PTR is cheap and rarely changes; a domain's
// records are three lookups, and the operator has just published them, so
// a few minutes plus an explicit Re-check button is the right trade.
serverTTL = time.Minute
domainTTL = 5 * time.Minute
)
// Result is the outcome of one published-record check.
type Result struct {
Status health.Status
// Detail is a full sentence for the operator: what was found and, when
// something is wrong, what to do about it.
Detail string
// Records is what was actually found in DNS, shown verbatim so the
// operator can compare it with what they published.
Records []string
}
// Server is the state of the server's own name in DNS: the addresses
// SELFPOST_HOSTNAME resolves to, and whether their PTR records point back at
// it. Receiving servers weigh this heavily, so a mismatch is an error.
type Server struct {
Hostname string
IPs []string // forward-resolved addresses, reused for the SPF check
PTR Result
CheckedAt time.Time
}
// Domain is the published-DNS state of one sending domain.
type Domain struct {
Name string
DKIM Result
SPF Result
DMARC Result
Overall health.Status
CheckedAt time.Time
}
// Query describes the domain to check. ExpectedDKIM is the TXT value the panel
// tells the operator to publish (domain.DKIMRecord.Value), so the check
// compares DNS against the key this server actually signs with. Hostname and
// ServerIPs identify this server and come from a preceding Server check.
type Query struct {
Name string
Selector string
ExpectedDKIM string
Hostname string
ServerIPs []string
}
// resolver is the slice of *net.Resolver this package uses, as an interface so
// tests can drive the checks without touching the network.
type resolver interface {
LookupTXT(ctx context.Context, name string) ([]string, error)
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
LookupAddr(ctx context.Context, addr string) ([]string, error)
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
}
// Checker runs the checks and caches their results. The zero value is not
// usable; call New.
type Checker struct {
resolver resolver
timeout time.Duration
serverTTL time.Duration
domainTTL time.Duration
mu sync.Mutex
servers map[string]cached[Server]
domains map[string]cached[Domain]
}
type cached[T any] struct {
value T
expires time.Time
}
// New returns a Checker using the process resolver and the package's default
// timeout and cache lifetimes.
func New() *Checker {
return newChecker(net.DefaultResolver, lookupTimeout, serverTTL, domainTTL)
}
func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
return &Checker{
resolver: r,
timeout: timeout,
serverTTL: srvTTL,
domainTTL: domTTL,
servers: make(map[string]cached[Server]),
domains: make(map[string]cached[Domain]),
}
}
// Server checks the server's own hostname. force skips the cache, for the
// Re-check button.
func (c *Checker) Server(hostname string, force bool) Server {
if !force {
c.mu.Lock()
entry, ok := c.servers[hostname]
c.mu.Unlock()
if ok && time.Now().Before(entry.expires) {
return entry.value
}
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
srv := c.checkServer(ctx, hostname)
c.mu.Lock()
c.servers[hostname] = cached[Server]{value: srv, expires: srv.CheckedAt.Add(c.serverTTL)}
c.mu.Unlock()
return srv
}
// Domain checks one sending domain's published records. force skips the cache,
// for the Re-check button on the domain page.
func (c *Checker) Domain(q Query, force bool) Domain {
if !force {
c.mu.Lock()
entry, ok := c.domains[q.Name]
c.mu.Unlock()
if ok && time.Now().Before(entry.expires) {
return entry.value
}
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
d := c.checkDomain(ctx, q)
c.mu.Lock()
c.domains[q.Name] = cached[Domain]{value: d, expires: d.CheckedAt.Add(c.domainTTL)}
c.mu.Unlock()
return d
}
// Forget drops a domain's cached result, so the next page view re-checks it.
// Used when a domain is removed or re-imported.
func (c *Checker) Forget(domainName string) {
c.mu.Lock()
delete(c.domains, domainName)
c.mu.Unlock()
}
// checkDomain runs the three record checks concurrently: they are independent,
// and in series three timeouts would stack up into a page that looks hung.
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
d := Domain{Name: q.Name, CheckedAt: time.Now()}
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); d.DKIM = c.checkDKIM(ctx, q) }()
go func() { defer wg.Done(); d.SPF = c.checkSPF(ctx, q) }()
go func() { defer wg.Done(); d.DMARC = c.checkDMARC(ctx, q.Name) }()
wg.Wait()
d.Overall = health.Worst(d.DKIM.Status, d.SPF.Status, d.DMARC.Status)
return d
}
// lookupTXT wraps the resolver's TXT lookup, separating "the name does not
// exist / has no TXT records" (a finding to report) from "the lookup failed"
// (nothing was learned).
func (c *Checker) lookupTXT(ctx context.Context, name string) (records []string, found bool, err error) {
txt, err := c.resolver.LookupTXT(ctx, name)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return nil, false, nil
}
return nil, false, err
}
if len(txt) == 0 {
return nil, false, nil
}
return txt, true, nil
}
// lookupFailed is the shared shape for "the resolver did not answer": unknown,
// not an accusation against the domain's configuration.
func lookupFailed(what string, err error) Result {
return Result{
Status: health.StatusUnknown,
Detail: "Could not check " + what + ": the DNS lookup failed (" + dnsErrorText(err) + "). Try Re-check in a moment.",
}
}
// dnsErrorText reduces a resolver error to its message, without the internals
// (Go wraps the name and server into the string form).
func dnsErrorText(err error) string {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsTimeout {
return "timed out"
}
return dnsErr.Err
}
return err.Error()
}
// normalizeName lowercases a DNS name and drops the root label, so a PTR answer
// ("mail.example.com.") compares equal to a configured hostname.
func normalizeName(name string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
}
+359
View File
@@ -0,0 +1,359 @@
package dnscheck
import (
"context"
"net"
"strings"
"testing"
"time"
"codeberg.org/mix/selfpost/internal/health"
)
// fakeResolver serves a fixed zone, so the checks can be driven through every
// branch without touching the network. An absent name resolves to the same
// "not found" DNSError the standard resolver returns for NXDOMAIN.
type fakeResolver struct {
txt map[string][]string
addr map[string][]net.IPAddr
ptr map[string][]string
mx map[string][]*net.MX
// fail names that must return a transient failure instead of an answer.
fail map[string]bool
// lookups counts every query, for the cache tests.
lookups int
}
func notFound(name string) error {
return &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func (f *fakeResolver) LookupTXT(_ context.Context, name string) ([]string, error) {
f.lookups++
if f.fail[name] {
return nil, &net.DNSError{Err: "server misbehaving", Name: name, IsTemporary: true}
}
if v, ok := f.txt[name]; ok {
return v, nil
}
return nil, notFound(name)
}
func (f *fakeResolver) LookupIPAddr(_ context.Context, host string) ([]net.IPAddr, error) {
f.lookups++
if v, ok := f.addr[host]; ok {
return v, nil
}
return nil, notFound(host)
}
func (f *fakeResolver) LookupAddr(_ context.Context, addr string) ([]string, error) {
f.lookups++
if v, ok := f.ptr[addr]; ok {
return v, nil
}
return nil, notFound(addr)
}
func (f *fakeResolver) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
f.lookups++
if v, ok := f.mx[name]; ok {
return v, nil
}
return nil, notFound(name)
}
func ipAddrs(ips ...string) []net.IPAddr {
out := make([]net.IPAddr, 0, len(ips))
for _, s := range ips {
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
}
return out
}
func newTestChecker(f *fakeResolver) *Checker {
return newChecker(f, time.Second, time.Minute, time.Minute)
}
func TestServerPTRMatches(t *testing.T) {
f := &fakeResolver{
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
}
srv := newTestChecker(f).Server("mail.example.com", false)
if srv.PTR.Status != health.StatusOK {
t.Fatalf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
}
if len(srv.IPs) != 1 || srv.IPs[0] != "203.0.113.10" {
t.Errorf("IPs = %v, want the forward-resolved address for the SPF check", srv.IPs)
}
}
func TestServerPTRMismatchIsAnError(t *testing.T) {
f := &fakeResolver{
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
ptr: map[string][]string{"203.0.113.10": {"static-10.provider.net."}},
}
srv := newTestChecker(f).Server("mail.example.com", false)
if srv.PTR.Status != health.StatusError {
t.Fatalf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
}
if len(srv.PTR.Records) != 1 || !strings.Contains(srv.PTR.Records[0], "static-10.provider.net") {
t.Errorf("records = %v, want the PTR name that was actually found", srv.PTR.Records)
}
}
func TestServerPTRMissing(t *testing.T) {
f := &fakeResolver{addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")}}
srv := newTestChecker(f).Server("mail.example.com", false)
if srv.PTR.Status != health.StatusError {
t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
}
}
func TestServerPartialPTRWarns(t *testing.T) {
f := &fakeResolver{
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10", "2001:db8::1")},
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
}
srv := newTestChecker(f).Server("mail.example.com", false)
if srv.PTR.Status != health.StatusWarn {
t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
}
}
func TestServerHostnameDoesNotResolve(t *testing.T) {
srv := newTestChecker(&fakeResolver{}).Server("mail.example.com", false)
if srv.PTR.Status != health.StatusError {
t.Errorf("status = %q (%s)", srv.PTR.Status, srv.PTR.Detail)
}
if len(srv.IPs) != 0 {
t.Errorf("IPs = %v, want none", srv.IPs)
}
}
func TestServerHostnameUnset(t *testing.T) {
srv := newTestChecker(&fakeResolver{}).Server("", false)
if srv.PTR.Status != health.StatusUnknown {
t.Errorf("status = %q, want unknown when SELFPOST_HOSTNAME is unset", srv.PTR.Status)
}
}
const testDKIMValue = "v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqTESTKEY"
func dkimQuery(records map[string][]string) (*fakeResolver, Query) {
q := Query{
Name: "example.com",
Selector: "selfpost",
ExpectedDKIM: testDKIMValue,
ServerIPs: []string{"203.0.113.10"},
}
return &fakeResolver{txt: records}, q
}
func TestDKIMPublishedAndMatching(t *testing.T) {
f, q := dkimQuery(map[string][]string{
// Published with different spacing and a line break in the base64, as
// DNS providers and TXT chunking produce.
"selfpost._domainkey.example.com": {"v=DKIM1;h=sha256;k=rsa;p=MIIBIjANBgkq TESTKEY"},
})
got := newTestChecker(f).Domain(q, false)
if got.DKIM.Status != health.StatusOK {
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
}
}
func TestDKIMMissing(t *testing.T) {
f, q := dkimQuery(nil)
got := newTestChecker(f).Domain(q, false)
if got.DKIM.Status != health.StatusError {
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
}
}
func TestDKIMWrongKey(t *testing.T) {
f, q := dkimQuery(map[string][]string{
"selfpost._domainkey.example.com": {"v=DKIM1; h=sha256; k=rsa; p=SOMEOTHERKEY"},
})
got := newTestChecker(f).Domain(q, false)
if got.DKIM.Status != health.StatusError {
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
}
if !strings.Contains(got.DKIM.Detail, "not the one this server signs with") {
t.Errorf("detail does not explain the mismatch: %s", got.DKIM.Detail)
}
}
func TestDKIMRevoked(t *testing.T) {
f, q := dkimQuery(map[string][]string{
"selfpost._domainkey.example.com": {"v=DKIM1; h=sha256; k=rsa; p="},
})
got := newTestChecker(f).Domain(q, false)
if got.DKIM.Status != health.StatusError || !strings.Contains(got.DKIM.Detail, "revokes") {
t.Errorf("status = %q (%s)", got.DKIM.Status, got.DKIM.Detail)
}
}
func TestDKIMLookupFailureIsUnknown(t *testing.T) {
f, q := dkimQuery(nil)
f.fail = map[string]bool{"selfpost._domainkey.example.com": true}
got := newTestChecker(f).Domain(q, false)
if got.DKIM.Status != health.StatusUnknown {
t.Errorf("status = %q (%s), want unknown when the resolver fails", got.DKIM.Status, got.DKIM.Detail)
}
}
func TestSPF(t *testing.T) {
cases := []struct {
name string
record []string
want health.Status
}{
{"literal ip4", []string{"v=spf1 ip4:203.0.113.10 -all"}, health.StatusOK},
{"covering CIDR", []string{"v=spf1 ip4:203.0.113.0/24 -all"}, health.StatusOK},
{"other address only", []string{"v=spf1 ip4:198.51.100.7 -all"}, health.StatusError},
{"include cannot be followed", []string{"v=spf1 include:_spf.provider.net -all"}, health.StatusWarn},
{"plus all", []string{"v=spf1 +all"}, health.StatusWarn},
{"negative qualifier does not authorise", []string{"v=spf1 -ip4:203.0.113.10 -all"}, health.StatusError},
{"two records", []string{"v=spf1 ip4:203.0.113.10 -all", "v=spf1 -all"}, health.StatusError},
{"no record", nil, health.StatusError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
txt := map[string][]string{}
if c.record != nil {
txt["example.com"] = c.record
}
f := &fakeResolver{txt: txt}
got := newTestChecker(f).checkSPF(context.Background(), Query{
Name: "example.com",
ServerIPs: []string{"203.0.113.10"},
})
if got.Status != c.want {
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
}
})
}
}
func TestSPFAMechanism(t *testing.T) {
f := &fakeResolver{
txt: map[string][]string{"example.com": {"v=spf1 a -all"}},
addr: map[string][]net.IPAddr{"example.com": ipAddrs("203.0.113.10")},
}
got := newTestChecker(f).checkSPF(context.Background(), Query{
Name: "example.com",
ServerIPs: []string{"203.0.113.10"},
})
if got.Status != health.StatusOK {
t.Errorf("status = %q (%s)", got.Status, got.Detail)
}
}
func TestSPFMXMechanism(t *testing.T) {
f := &fakeResolver{
txt: map[string][]string{"example.com": {"v=spf1 mx -all"}},
mx: map[string][]*net.MX{"example.com": {{Host: "mail.example.com.", Pref: 10}}},
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
}
got := newTestChecker(f).checkSPF(context.Background(), Query{
Name: "example.com",
ServerIPs: []string{"203.0.113.10"},
})
if got.Status != health.StatusOK {
t.Errorf("status = %q (%s)", got.Status, got.Detail)
}
}
func TestSPFWithoutServerIPIsUnknown(t *testing.T) {
f := &fakeResolver{txt: map[string][]string{"example.com": {"v=spf1 -all"}}}
got := newTestChecker(f).checkSPF(context.Background(), Query{Name: "example.com"})
if got.Status != health.StatusUnknown {
t.Errorf("status = %q (%s)", got.Status, got.Detail)
}
}
func TestDMARC(t *testing.T) {
cases := []struct {
name string
record []string
want health.Status
}{
{"reject", []string{"v=DMARC1; p=reject; rua=mailto:dmarc@example.com"}, health.StatusOK},
{"none", []string{"v=DMARC1; p=none"}, health.StatusOK},
{"no policy tag", []string{"v=DMARC1; rua=mailto:dmarc@example.com"}, health.StatusWarn},
{"absent", nil, health.StatusWarn},
{"duplicated", []string{"v=DMARC1; p=none", "v=DMARC1; p=reject"}, health.StatusError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
txt := map[string][]string{}
if c.record != nil {
txt["_dmarc.example.com"] = c.record
}
f := &fakeResolver{txt: txt}
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
if got.Status != c.want {
t.Errorf("status = %q, want %q (%s)", got.Status, c.want, got.Detail)
}
})
}
}
func TestDMARCNonePolicyIsExplained(t *testing.T) {
f := &fakeResolver{txt: map[string][]string{"_dmarc.example.com": {"v=DMARC1; p=none"}}}
got := newTestChecker(f).checkDMARC(context.Background(), "example.com")
if !strings.Contains(got.Detail, "monitoring only") {
t.Errorf("p=none is not explained: %s", got.Detail)
}
}
func TestResultsAreCachedAndForceBypassesTheCache(t *testing.T) {
f := &fakeResolver{
addr: map[string][]net.IPAddr{"mail.example.com": ipAddrs("203.0.113.10")},
ptr: map[string][]string{"203.0.113.10": {"mail.example.com."}},
}
c := newTestChecker(f)
c.Server("mail.example.com", false)
after := f.lookups
if after == 0 {
t.Fatal("the first check did not query the resolver")
}
c.Server("mail.example.com", false)
if f.lookups != after {
t.Errorf("a second check re-queried DNS: %d lookups, want %d", f.lookups, after)
}
c.Server("mail.example.com", true)
if f.lookups == after {
t.Error("force did not bypass the cache")
}
}
func TestDomainOverallIsTheWorstOfTheThree(t *testing.T) {
f, q := dkimQuery(map[string][]string{
"selfpost._domainkey.example.com": {testDKIMValue},
"example.com": {"v=spf1 ip4:203.0.113.10 -all"},
// No DMARC: a warning.
})
got := newTestChecker(f).Domain(q, false)
if got.DKIM.Status != health.StatusOK || got.SPF.Status != health.StatusOK {
t.Fatalf("DKIM=%q SPF=%q", got.DKIM.Status, got.SPF.Status)
}
if got.Overall != health.StatusWarn {
t.Errorf("overall = %q, want the DMARC warning to surface", got.Overall)
}
}
func TestForgetDropsTheCachedDomain(t *testing.T) {
f, q := dkimQuery(map[string][]string{"selfpost._domainkey.example.com": {testDKIMValue}})
c := newTestChecker(f)
c.Domain(q, false)
before := f.lookups
c.Forget(q.Name)
c.Domain(q, false)
if f.lookups == before {
t.Error("Forget did not drop the cached result")
}
}
+49
View File
@@ -0,0 +1,49 @@
package dnscheck
import (
"net"
"strings"
)
// SPF and DMARC are the two records SelfPost cannot generate for the operator:
// unlike the DKIM record they are policy, not a key, and a domain may already
// publish one for other senders. The panel still has to say what "correct"
// looks like, and the checks below have to suggest the same thing when a record
// is missing — so both take their example from here rather than each spelling
// out its own.
// DMARCRecordName is the name a DMARC record is published at. (SPF has no such
// helper: it is published at the domain itself.)
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
// SPFExample is the SPF record this server expects for a sending domain: the
// addresses its mail actually leaves from, and "-all" to say that nothing else
// is authorised. When the server's own addresses are not known (its hostname
// does not resolve) it falls back to an "a:" mechanism naming the host, so the
// panel always has something concrete to show.
func SPFExample(hostname string, serverIPs []string) string {
var mechanisms []string
for _, s := range serverIPs {
ip := net.ParseIP(strings.TrimSpace(s))
switch {
case ip == nil:
continue
case ip.To4() != nil:
mechanisms = append(mechanisms, "ip4:"+ip.String())
default:
mechanisms = append(mechanisms, "ip6:"+ip.String())
}
}
if len(mechanisms) == 0 {
mechanisms = []string{"a:" + hostname}
}
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
}
// DMARCExample is the least a domain should publish: monitoring only, with an
// address the aggregate reports go to. p=none is deliberate — it changes
// nothing about delivery, so it is safe to publish before the reports have
// shown that DKIM and SPF pass everywhere.
func DMARCExample(domainName string) string {
return "v=DMARC1; p=none; rua=mailto:dmarc@" + domainName
}
+60
View File
@@ -0,0 +1,60 @@
package dnscheck
import (
"context"
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/health"
)
func TestSPFExample(t *testing.T) {
cases := []struct {
name string
hostname string
ips []string
want string
}{
{"ipv4", "mail.example.com", []string{"203.0.113.10"}, "v=spf1 ip4:203.0.113.10 -all"},
{"both families", "mail.example.com", []string{"203.0.113.10", "2001:db8::1"},
"v=spf1 ip4:203.0.113.10 ip6:2001:db8::1 -all"},
// The hostname does not resolve, so there is no address to name; an "a:"
// mechanism still gives the operator a publishable record.
{"no addresses", "mail.example.com", nil, "v=spf1 a:mail.example.com -all"},
{"unparsable addresses", "mail.example.com", []string{"not-an-ip"}, "v=spf1 a:mail.example.com -all"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := SPFExample(c.hostname, c.ips); got != c.want {
t.Errorf("SPFExample = %q, want %q", got, c.want)
}
})
}
}
// The record the panel shows and the one a failed check suggests must be the
// same string, or the operator is told two different things on one page.
func TestMissingRecordChecksSuggestTheShownExample(t *testing.T) {
f := &fakeResolver{}
c := newTestChecker(f)
spf := c.checkSPF(context.Background(), Query{
Name: "example.com",
Hostname: "mail.example.com",
ServerIPs: []string{"203.0.113.10"},
})
if spf.Status != health.StatusError {
t.Fatalf("SPF status = %q, want error (%s)", spf.Status, spf.Detail)
}
if want := SPFExample("mail.example.com", []string{"203.0.113.10"}); !strings.Contains(spf.Detail, want) {
t.Errorf("SPF advice %q does not suggest %q", spf.Detail, want)
}
dmarc := c.checkDMARC(context.Background(), "example.com")
if dmarc.Status != health.StatusWarn {
t.Fatalf("DMARC status = %q, want warn (%s)", dmarc.Status, dmarc.Detail)
}
if want := DMARCExample("example.com"); !strings.Contains(dmarc.Detail, want) {
t.Errorf("DMARC advice %q does not suggest %q", dmarc.Detail, want)
}
}
+130
View File
@@ -0,0 +1,130 @@
package dnscheck
import (
"context"
"fmt"
"strings"
"codeberg.org/mix/selfpost/internal/health"
)
// checkDKIM compares the TXT record published at <selector>._domainkey.<domain>
// with the key this server signs with. A wrong or absent record means every
// message fails DKIM at the receiver, so both are errors.
func (c *Checker) checkDKIM(ctx context.Context, q Query) Result {
name := q.Selector + "._domainkey." + q.Name
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DKIM record", err)
}
expected := publicKeyTag(q.ExpectedDKIM)
if !found {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No TXT record is published at %s. Publish the record shown above — until then every message fails DKIM.", name),
}
}
for _, rec := range txt {
got := publicKeyTag(rec)
if got == "" {
continue
}
if got == expected {
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("Published at %s and matching the key this server signs with.", name),
Records: txt,
}
}
}
// Something is published, but it is not our key. Separate the revoked case
// (empty p=), which reads as a deliberate act rather than a typo.
for _, rec := range txt {
if v, ok := tagValue(rec, "p"); ok && v == "" {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("The record at %s has an empty p= tag, which revokes the key. Replace it with the record shown above.", name),
Records: txt,
}
}
}
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("A TXT record exists at %s but its public key is not the one this server signs with — mail will fail DKIM. Replace it with the record shown above (an old record from a previous server is the usual cause).", name),
Records: txt,
}
}
// checkDMARC reports whether the domain publishes a DMARC policy. DMARC is not
// required for delivery, so its absence is advice (warn), not a fault.
func (c *Checker) checkDMARC(ctx context.Context, domainName string) Result {
name := DMARCRecordName(domainName)
txt, found, err := c.lookupTXT(ctx, name)
if err != nil {
return lookupFailed("the DMARC record", err)
}
var records []string
for _, rec := range txt {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
records = append(records, rec)
}
}
if !found || len(records) == 0 {
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No DMARC record at %s. Delivery works without one, but publishing at least %q tells receivers what to do with mail that fails DKIM and gets you reports.", name, DMARCExample(domainName)),
}
}
if len(records) > 1 {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("More than one DMARC record is published at %s. Receivers treat that as no policy at all — keep exactly one.", name),
Records: records,
}
}
policy, ok := tagValue(records[0], "p")
if !ok || policy == "" {
return Result{
Status: health.StatusWarn,
Detail: "A DMARC record is published but has no p= policy tag, so receivers ignore it. Add p=none, p=quarantine or p=reject.",
Records: records,
}
}
detail := fmt.Sprintf("Published with policy p=%s.", policy)
if strings.EqualFold(policy, "none") {
detail += " That is monitoring only — tighten it to quarantine or reject once the reports look clean."
}
return Result{Status: health.StatusOK, Detail: detail, Records: records}
}
// publicKeyTag extracts the p= (public key) tag of a DKIM record, with all
// whitespace removed: DNS providers and TXT chunking freely insert spaces and
// line breaks into the base64, none of which are part of the key.
func publicKeyTag(record string) string {
v, ok := tagValue(record, "p")
if !ok {
return ""
}
return strings.Join(strings.Fields(v), "")
}
// tagValue reads one tag from a DKIM/DMARC-style "tag=value; tag=value" record.
// Tag names are case-sensitive per RFC 6376/7489, and values keep their case.
func tagValue(record, tag string) (string, bool) {
for _, part := range strings.Split(record, ";") {
part = strings.TrimSpace(part)
key, value, found := strings.Cut(part, "=")
if !found {
continue
}
if strings.TrimSpace(key) == tag {
return strings.TrimSpace(value), true
}
}
return "", false
}
+89
View File
@@ -0,0 +1,89 @@
package dnscheck
import (
"context"
"fmt"
"time"
"codeberg.org/mix/selfpost/internal/health"
)
// checkServer resolves the panel's own hostname and confirms the reverse
// lookup of each address points back at that name (FCrDNS). A missing or
// mismatched PTR is the single most common reason mail from a self-hosted
// server is rejected or scored as spam, which is why it is an error and not
// advice.
func (c *Checker) checkServer(ctx context.Context, hostname string) Server {
srv := Server{Hostname: hostname, CheckedAt: time.Now()}
if hostname == "" {
srv.PTR = Result{
Status: health.StatusUnknown,
Detail: "SELFPOST_HOSTNAME is not set, so the server's own name in DNS cannot be checked. Set it in the deployment environment.",
}
return srv
}
addrs, err := c.resolver.LookupIPAddr(ctx, hostname)
if err != nil || len(addrs) == 0 {
srv.PTR = Result{
Status: health.StatusError,
Detail: fmt.Sprintf("%s does not resolve to any address. Publish an A (or AAAA) record for it — receiving servers check the name this server announces in HELO.", hostname),
}
return srv
}
want := normalizeName(hostname)
matched, total := 0, len(addrs)
var records []string
for _, a := range addrs {
ip := a.IP.String()
srv.IPs = append(srv.IPs, ip)
names, err := c.resolver.LookupAddr(ctx, ip)
if err != nil || len(names) == 0 {
records = append(records, ip+" → no PTR record")
continue
}
hit := false
for _, n := range names {
if normalizeName(n) == want {
hit = true
}
}
if hit {
matched++
records = append(records, ip+" → "+normalizeName(names[0]))
} else {
records = append(records, ip+" → "+normalizeName(names[0])+" (does not match)")
}
}
srv.PTR.Records = records
switch {
case matched == total:
srv.PTR.Status = health.StatusOK
srv.PTR.Detail = fmt.Sprintf("%s resolves to %s and the reverse lookup points back at it.", hostname, joinIPs(srv.IPs))
case matched > 0:
srv.PTR.Status = health.StatusWarn
srv.PTR.Detail = fmt.Sprintf("Only %d of %d addresses of %s have a matching PTR record. Mail sent from the others may be rejected — set the reverse DNS of every address at your hosting provider.", matched, total, hostname)
default:
srv.PTR.Status = health.StatusError
srv.PTR.Detail = fmt.Sprintf("No address of %s has a reverse (PTR) record pointing back at it. Many receiving servers reject or spam-score mail from such a host — set the reverse DNS of the server's IP to %s at your hosting provider.", hostname, hostname)
}
return srv
}
func joinIPs(ips []string) string {
switch len(ips) {
case 0:
return "no address"
case 1:
return ips[0]
default:
out := ips[0]
for _, ip := range ips[1:] {
out += ", " + ip
}
return out
}
}
+285
View File
@@ -0,0 +1,285 @@
package dnscheck
import (
"context"
"fmt"
"net"
"strings"
"codeberg.org/mix/selfpost/internal/health"
)
// spfLookupBudget caps how many DNS lookups one SPF evaluation may spend on
// a/mx mechanisms. RFC 7208 allows a receiver ten; staying under the same
// ceiling keeps a hostile or careless record from turning a page view into a
// long chain of queries.
const spfLookupBudget = 10
// checkSPF reports whether the domain's SPF record authorises this server.
//
// This is deliberately a shallow check (documented as such in the README): it
// looks for a mechanism that literally covers the server's address —
// ip4:/ip6:, or a/mx resolving to it — and does not recurse into include: or
// redirect=, nor evaluate the record the way a receiver would. That is why a
// record which does not obviously cover us but does use include: is reported as
// a warning ("cannot tell") rather than a failure.
func (c *Checker) checkSPF(ctx context.Context, q Query) Result {
ips := parseIPs(q.ServerIPs)
if len(ips) == 0 {
return Result{
Status: health.StatusUnknown,
Detail: "The server's own address is not known (its hostname does not resolve), so SPF coverage cannot be checked. Fix the hostname/PTR check first.",
}
}
txt, found, err := c.lookupTXT(ctx, q.Name)
if err != nil {
return lookupFailed("the SPF record", err)
}
var records []string
for _, rec := range txt {
if isSPF(rec) {
records = append(records, strings.TrimSpace(rec))
}
}
switch {
case !found || len(records) == 0:
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as %q — without it receivers have nothing authorising this server to send as the domain.", q.Name, SPFExample(q.Hostname, q.ServerIPs)),
}
case len(records) > 1:
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("More than one SPF record is published for %s. RFC 7208 allows exactly one; receivers treat several as a permanent error and the domain gets no SPF pass at all. Merge them into a single record.", q.Name),
Records: records,
}
}
matched, unfollowed := c.evaluateSPF(ctx, records[0], q.Name, ips)
switch {
case matched == "+all" || matched == "all":
return Result{
Status: health.StatusWarn,
Detail: "The SPF record ends with \"+all\", which authorises every server on the internet to send as this domain. Replace it with an explicit ip4:/ip6: or a mechanism plus \"-all\".",
Records: records,
}
case matched != "":
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("The SPF record authorises this server through its %q mechanism.", matched),
Records: records,
}
case len(unfollowed) > 0:
return Result{
Status: health.StatusWarn,
Detail: fmt.Sprintf("No mechanism in the SPF record lists %s directly, but the record uses %s, which this check does not follow — the server may still be authorised through it. Verify with an external SPF validator, or add \"ip4:%s\" to be sure.",
ips[0], strings.Join(unfollowed, ", "), ips[0]),
Records: records,
}
default:
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("The SPF record does not authorise %s, so mail sent from this server fails SPF. Add \"ip4:%s\" (or an \"a\" mechanism resolving here) to the record.", ips[0], ips[0]),
Records: records,
}
}
}
// evaluateSPF walks the record's mechanisms, returning the first one that
// covers one of the server's addresses, plus the mechanisms this shallow check
// cannot resolve (include:/redirect=/exists:/ptr and anything past the lookup
// budget) so the caller can say "cannot tell" instead of "fails".
func (c *Checker) evaluateSPF(ctx context.Context, record, domainName string, ips []net.IP) (matched string, unfollowed []string) {
budget := spfLookupBudget
seenUnfollowed := make(map[string]bool)
note := func(kind string) {
if !seenUnfollowed[kind] {
seenUnfollowed[kind] = true
unfollowed = append(unfollowed, kind)
}
}
terms := strings.Fields(record)
if len(terms) > 0 {
terms = terms[1:] // drop the v=spf1 version token
}
for _, term := range terms {
qualifier, mech := splitQualifier(term)
lower := strings.ToLower(mech)
name, hasArg := mechanismArg(mech)
switch {
case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
if qualifier != '+' {
continue
}
if coversAny(mech[4:], ips) {
return term, unfollowed
}
case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
if strings.Contains(mech, "/") { // prefix-length form: not evaluated
note("a/<prefix>")
continue
}
target := domainName
if hasArg {
target = name
}
if budget <= 0 {
note("further lookups")
continue
}
budget--
if qualifier == '+' && c.resolvesTo(ctx, target, ips) {
return term, unfollowed
}
case lower == "mx" || strings.HasPrefix(lower, "mx:") || strings.HasPrefix(lower, "mx/"):
if strings.Contains(mech, "/") {
note("mx/<prefix>")
continue
}
target := domainName
if hasArg {
target = name
}
if budget <= 0 {
note("further lookups")
continue
}
budget--
if qualifier == '+' && c.mxResolvesTo(ctx, target, ips, &budget) {
return term, unfollowed
}
case strings.HasPrefix(lower, "include:"):
note("include:")
case strings.HasPrefix(lower, "redirect="):
note("redirect=")
case strings.HasPrefix(lower, "exists:"):
note("exists:")
case lower == "ptr" || strings.HasPrefix(lower, "ptr:"):
note("ptr")
case lower == "all":
if qualifier == '+' {
return "+all", unfollowed
}
// "-all"/"~all"/"?all" terminates the record: nothing after it is
// evaluated by a receiver either.
return "", unfollowed
}
}
return "", unfollowed
}
// resolvesTo reports whether name resolves to one of the server's addresses.
func (c *Checker) resolvesTo(ctx context.Context, name string, ips []net.IP) bool {
addrs, err := c.resolver.LookupIPAddr(ctx, name)
if err != nil {
return false
}
for _, a := range addrs {
for _, ip := range ips {
if a.IP.Equal(ip) {
return true
}
}
}
return false
}
// mxResolvesTo reports whether any of name's MX hosts resolves to one of the
// server's addresses, spending at most the remaining lookup budget.
func (c *Checker) mxResolvesTo(ctx context.Context, name string, ips []net.IP, budget *int) bool {
mxs, err := c.resolver.LookupMX(ctx, name)
if err != nil {
return false
}
for _, mx := range mxs {
if *budget <= 0 {
return false
}
*budget--
if c.resolvesTo(ctx, strings.TrimSuffix(mx.Host, "."), ips) {
return true
}
}
return false
}
// coversAny reports whether an ip4:/ip6: value — a bare address or a CIDR —
// contains one of the server's addresses.
func coversAny(value string, ips []net.IP) bool {
value = strings.TrimSpace(value)
if strings.Contains(value, "/") {
_, network, err := net.ParseCIDR(value)
if err != nil {
return false
}
for _, ip := range ips {
if network.Contains(ip) {
return true
}
}
return false
}
listed := net.ParseIP(value)
if listed == nil {
return false
}
for _, ip := range ips {
if listed.Equal(ip) {
return true
}
}
return false
}
// splitQualifier peels the optional +/-/~/? qualifier off a mechanism,
// defaulting to "+" (pass) as RFC 7208 does.
func splitQualifier(term string) (byte, string) {
if term == "" {
return '+', ""
}
switch term[0] {
case '+', '-', '~', '?':
return term[0], term[1:]
default:
return '+', term
}
}
// mechanismArg returns the ":" argument of a mechanism, if it has one.
func mechanismArg(mech string) (string, bool) {
_, arg, found := strings.Cut(mech, ":")
if !found || arg == "" {
return "", false
}
return arg, true
}
// isSPF reports whether a TXT record is an SPF record (the version token must
// be the whole first term, so "v=spf10" is not one).
func isSPF(record string) bool {
rec := strings.TrimSpace(record)
if len(rec) < 6 || !strings.EqualFold(rec[:6], "v=spf1") {
return false
}
return len(rec) == 6 || rec[6] == ' ' || rec[6] == '\t'
}
// parseIPs converts the string addresses carried on a Query back into net.IPs,
// dropping anything unparsable.
func parseIPs(in []string) []net.IP {
var ips []net.IP
for _, s := range in {
if ip := net.ParseIP(s); ip != nil {
ips = append(ips, ip)
}
}
return ips
}
+127
View File
@@ -0,0 +1,127 @@
// Package domain owns SelfPost's sending-domain model: per-domain DKIM key
// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the
// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM
// in agreement (spec 4.1, 6). Key material lives under /data so it survives
// container restarts (spec 6.1, 9).
package domain
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"os"
"path/filepath"
)
// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM
// interoperability sweet spot: strong, and short enough that the published
// public key still fits comfortably in a DNS TXT record.
const dkimKeyBits = 2048
// generateDKIMKey creates a fresh RSA private key for signing a domain.
func generateDKIMKey() (*rsa.PrivateKey, error) {
key, err := rsa.GenerateKey(rand.Reader, dkimKeyBits)
if err != nil {
return nil, fmt.Errorf("generate dkim key: %w", err)
}
return key, nil
}
// writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM,
// atomically and group-readable (0640). The file is owned by the panel user and
// read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf
// and entrypoint.sh); the parent directory carries setgid so the group is
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
// a half-written key.
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o640)
}
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
// writePrivateKeyPEM. It is used to recompute the public DNS record on demand,
// keeping the private key file the single source of truth (spec 7.2.10).
func loadPrivateKeyPEM(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return nil, fmt.Errorf("dkim key %s: not a PKCS#1 RSA private key", path)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse dkim key %s: %w", path, err)
}
return key, nil
}
// DKIMRecord is the DNS TXT record a user must publish for a domain (spec 7.2.10).
type DKIMRecord struct {
// Name is the record's host, e.g. "selfpost._domainkey.example.com".
Name string
// Value is the TXT payload, e.g. "v=DKIM1; h=sha256; k=rsa; p=MIIB...".
Value string
}
// dkimRecord builds the published DKIM DNS record for a public key. The value
// mirrors what opendkim-genkey emits: v=DKIM1, sha256, RSA, and the public key
// as base64-encoded SubjectPublicKeyInfo (PKIX) DER.
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return DKIMRecord{}, fmt.Errorf("marshal dkim public key: %w", err)
}
p := base64.StdEncoding.EncodeToString(der)
return DKIMRecord{
Name: fmt.Sprintf("%s._domainkey.%s", selector, domainName),
Value: fmt.Sprintf("v=DKIM1; h=sha256; k=rsa; p=%s", p),
}, nil
}
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so readers only ever see the complete old or new file.
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables
// (spec 7.6.4).
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp %s: %w", tmpName, err)
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return fmt.Errorf("chmod temp %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync temp %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
}
cleanup = false
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package domain
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) {
key, err := generateDKIMKey()
if err != nil {
t.Fatalf("generateDKIMKey: %v", err)
}
path := filepath.Join(t.TempDir(), "selfpost.private")
if err := writePrivateKeyPEM(path, key); err != nil {
t.Fatalf("writePrivateKeyPEM: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o640 {
t.Errorf("key perm = %o, want 0640", perm)
}
loaded, err := loadPrivateKeyPEM(path)
if err != nil {
t.Fatalf("loadPrivateKeyPEM: %v", err)
}
if loaded.N.Cmp(key.N) != 0 || loaded.E != key.E {
t.Error("loaded key does not match generated key")
}
}
func TestLoadPrivateKeyRejectsGarbage(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.private")
if err := os.WriteFile(path, []byte("not a pem"), 0o640); err != nil {
t.Fatal(err)
}
if _, err := loadPrivateKeyPEM(path); err == nil {
t.Error("expected error for non-PEM key file")
}
}
func TestDKIMRecord(t *testing.T) {
key, err := generateDKIMKey()
if err != nil {
t.Fatal(err)
}
rec, err := dkimRecord("selfpost", "example.com", &key.PublicKey)
if err != nil {
t.Fatalf("dkimRecord: %v", err)
}
if rec.Name != "selfpost._domainkey.example.com" {
t.Errorf("record name = %q", rec.Name)
}
for _, want := range []string{"v=DKIM1", "h=sha256", "k=rsa", "p="} {
if !strings.Contains(rec.Value, want) {
t.Errorf("record value %q missing %q", rec.Value, want)
}
}
}
func TestWriteFileAtomicOverwrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "f")
if err := writeFileAtomic(path, []byte("one"), 0o640); err != nil {
t.Fatal(err)
}
if err := writeFileAtomic(path, []byte("two"), 0o640); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "two" {
t.Errorf("content = %q, want %q", got, "two")
}
// No stray temp files left behind in the directory.
entries, _ := os.ReadDir(filepath.Dir(path))
if len(entries) != 1 {
t.Errorf("expected 1 file after atomic writes, found %d", len(entries))
}
}
+226
View File
@@ -0,0 +1,226 @@
package domain
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
// (spec 6): per-domain signing keys under keysDir and the KeyTable/SigningTable
// that map domains to those keys. After rewriting the tables it asks OpenDKIM to
// reload them.
type OpenDKIM struct {
keysDir string
keyTablePath string
signingTablePath string
// reload sends the running OpenDKIM a reload signal. It is a field so tests
// can substitute a no-op; the default drives supervisord (see reloadViaSupervisor).
reload func() error
}
// NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the
// same layout entrypoint.sh prepares. The default reload path signals OpenDKIM
// through supervisord.
func NewOpenDKIM(dir string) *OpenDKIM {
return &OpenDKIM{
keysDir: filepath.Join(dir, "keys"),
keyTablePath: filepath.Join(dir, "KeyTable"),
signingTablePath: filepath.Join(dir, "SigningTable"),
reload: reloadViaSupervisor,
}
}
// SigningDomain is one row's worth of signing configuration.
type SigningDomain struct {
Name string
Selector string
}
// keyPath is the private-key path for a domain/selector, matching the KeyTable.
func (o *OpenDKIM) keyPath(domainName, selector string) string {
return filepath.Join(o.keysDir, domainName, selector+".private")
}
// EnsureKey makes sure a signing key exists for the domain. An existing key is
// reused untouched — critical because overwriting it would silently invalidate
// the DKIM record already published in DNS (spec 6.1). Returns whether a new key
// was generated.
func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return false, err
}
path := o.keyPath(domainName, selector)
if _, err := os.Stat(path); err == nil {
return false, nil // reuse existing key
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("stat dkim key: %w", err)
}
// setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the
// shared `selfpost` group so OpenDKIM can traverse into it.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return false, fmt.Errorf("create key dir: %w", err)
}
key, err := generateDKIMKey()
if err != nil {
return false, err
}
if err := writePrivateKeyPEM(path, key); err != nil {
return false, err
}
return true, nil
}
// RemoveKey deletes a domain's key directory (spec 6.5). A missing directory is
// not an error.
func (o *OpenDKIM) RemoveKey(domainName string) error {
if err := assertConfigSafe(domainName, "x"); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
}
return nil
}
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in a
// domain export so the receiving instance signs with the same key and the DNS
// TXT record never has to change (spec 7.5.B). It re-marshals the parsed key
// rather than returning the raw file, so a malformed on-disk key is caught here.
func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
if err := assertConfigSafe(domainName, selector); err != nil {
return nil, err
}
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return nil, err
}
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
return pem.EncodeToMemory(block), nil
}
// ImportKey writes an imported DKIM private key to disk for a domain (spec
// 7.5.B). The PEM is parsed and re-marshalled through the same writer used for
// generated keys, so only a well-formed PKCS#1 RSA key is ever stored. Unlike
// EnsureKey it overwrites: an import (re-)creates the domain with exactly this
// key, which is the whole point of keeping the published DNS record valid.
func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
if err := assertConfigSafe(domainName, selector); err != nil {
return err
}
block, _ := pem.Decode(pemKey)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
}
path := o.keyPath(domainName, selector)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create key dir: %w", err)
}
return writePrivateKeyPEM(path, key)
}
// Record returns the published DKIM DNS record for a domain, recomputed from the
// private key on disk (spec 7.2.10).
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
if err != nil {
return DKIMRecord{}, err
}
return dkimRecord(selector, domainName, &key.PublicKey)
}
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
// reloads OpenDKIM (spec 6.2). Full regeneration (rather than incremental
// edits) keeps the files a pure function of the registry, so add and delete
// share one idempotent path. Both files are written atomically before the
// reload signal is sent.
func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
keyTable, signingTable, err := renderTables(o.keysDir, domains)
if err != nil {
return err
}
if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil {
return err
}
if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil {
return err
}
return o.reload()
}
// Reload asks OpenDKIM to re-read its tables without regenerating them. It backs
// the panel's manual reload button (spec 7.2.12).
func (o *OpenDKIM) Reload() error {
return o.reload()
}
// renderTables builds the KeyTable and SigningTable byte contents for a domain
// set, sorted by name so the output is deterministic. Every domain is
// re-checked for shell/config-injection safety before being written (spec
// 7.6.4) — validation upstream already guarantees this, but the table writer
// refuses to emit anything unsafe as a hard backstop.
func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) {
sorted := append([]SigningDomain(nil), domains...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
var kt, st strings.Builder
for _, d := range sorted {
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
return nil, nil, err
}
keyName := d.Name // one key per domain; the domain name is a fine handle
// Absolute key path so OpenDKIM resolves it independently of its CWD.
keyFile := filepath.Join(keysDir, d.Name, d.Selector+".private")
// KeyTable: <key-name> <domain>:<selector>:<key-path>
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
// SigningTable (refile): <address-pattern> <key-name>
fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName)
}
return []byte(kt.String()), []byte(st.String()), nil
}
// assertConfigSafe rejects any domain/selector value that could break out of a
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
// to a similar set before they reach here (spec 7.6.2); this is defence in depth
// against a validation gap ever letting whitespace, a newline or a field
// separator through into a config file (spec 7.6.4).
func assertConfigSafe(domainName, selector string) error {
for _, v := range []string{domainName, selector} {
if v == "" {
return fmt.Errorf("opendkim: empty domain or selector")
}
if strings.ContainsAny(v, " \t\r\n:/\\") {
return fmt.Errorf("opendkim: unsafe character in %q", v)
}
}
return nil
}
// reloadViaSupervisor asks supervisord (PID 1, running as root) to send the
// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable
// (opendkim's documented reload signal). The panel runs unprivileged and cannot
// signal another user's process directly, so it goes through the supervisor
// control socket, reachable via the shared `selfpost` group (spec 7.6.3, 7.6.8).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (spec 7.6.3).
func reloadViaSupervisor() error {
cmd := exec.Command("supervisorctl",
"-c", "/etc/supervisor/supervisord.conf",
"signal", "USR1", "opendkim")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("reload opendkim via supervisor: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package domain
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRenderTables(t *testing.T) {
keysDir := "/data/opendkim/keys"
// Deliberately out of order to exercise the deterministic sort.
domains := []SigningDomain{
{Name: "zeta.example", Selector: "selfpost"},
{Name: "alpha.example", Selector: "sel2"},
}
kt, st, err := renderTables(keysDir, domains)
if err != nil {
t.Fatalf("renderTables: %v", err)
}
wantKT := "alpha.example alpha.example:sel2:/data/opendkim/keys/alpha.example/sel2.private\n" +
"zeta.example zeta.example:selfpost:/data/opendkim/keys/zeta.example/selfpost.private\n"
if string(kt) != wantKT {
t.Errorf("KeyTable =\n%q\nwant\n%q", kt, wantKT)
}
wantST := "*@alpha.example alpha.example\n*@zeta.example zeta.example\n"
if string(st) != wantST {
t.Errorf("SigningTable =\n%q\nwant\n%q", st, wantST)
}
}
func TestRenderTablesEmpty(t *testing.T) {
kt, st, err := renderTables("/keys", nil)
if err != nil {
t.Fatalf("renderTables(nil): %v", err)
}
if len(kt) != 0 || len(st) != 0 {
t.Errorf("expected empty tables, got kt=%q st=%q", kt, st)
}
}
func TestAssertConfigSafeRejectsInjection(t *testing.T) {
bad := []struct{ name, sel string }{
{"exa mple.com", "selfpost"},
{"example.com\nInject yes", "selfpost"},
{"example.com", "sel:evil"},
{"../etc", "selfpost"},
{"", "selfpost"},
{"example.com", ""},
}
for _, b := range bad {
if err := assertConfigSafe(b.name, b.sel); err == nil {
t.Errorf("assertConfigSafe(%q,%q) = nil, want error", b.name, b.sel)
}
}
if err := assertConfigSafe("example.com", "selfpost"); err != nil {
t.Errorf("assertConfigSafe of a clean pair errored: %v", err)
}
}
// newTestOpenDKIM builds a manager rooted at a temp dir with reload stubbed out.
func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "keys"), 0o750); err != nil {
t.Fatal(err)
}
reloads := 0
o := NewOpenDKIM(dir)
o.reload = func() error { reloads++; return nil }
return o, &reloads
}
func TestEnsureKeyReusesExisting(t *testing.T) {
o, _ := newTestOpenDKIM(t)
created, err := o.EnsureKey("example.com", "selfpost")
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
if !created {
t.Fatal("expected a new key to be created")
}
first, err := os.ReadFile(o.keyPath("example.com", "selfpost"))
if err != nil {
t.Fatal(err)
}
created, err = o.EnsureKey("example.com", "selfpost")
if err != nil {
t.Fatalf("EnsureKey (second): %v", err)
}
if created {
t.Error("expected existing key to be reused, not regenerated")
}
second, _ := os.ReadFile(o.keyPath("example.com", "selfpost"))
if string(first) != string(second) {
t.Error("key file changed on reuse — published DNS record would break")
}
}
func TestRebuildWritesTablesAndReloads(t *testing.T) {
o, reloads := newTestOpenDKIM(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil {
t.Fatalf("Rebuild: %v", err)
}
if *reloads != 1 {
t.Errorf("reload called %d times, want 1", *reloads)
}
kt, err := os.ReadFile(o.keyTablePath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(kt), "example.com:selfpost:") {
t.Errorf("KeyTable missing entry: %q", kt)
}
}
func TestRemoveKey(t *testing.T) {
o, _ := newTestOpenDKIM(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
if err := o.RemoveKey("example.com"); err != nil {
t.Fatalf("RemoveKey: %v", err)
}
if _, err := os.Stat(filepath.Join(o.keysDir, "example.com")); !os.IsNotExist(err) {
t.Error("key directory still present after RemoveKey")
}
// Removing a non-existent key is not an error.
if err := o.RemoveKey("example.com"); err != nil {
t.Errorf("RemoveKey on missing dir errored: %v", err)
}
}
func TestRecordFromWrittenKey(t *testing.T) {
o, _ := newTestOpenDKIM(t)
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
rec, err := o.Record("example.com", "selfpost")
if err != nil {
t.Fatalf("Record: %v", err)
}
if rec.Name != "selfpost._domainkey.example.com" {
t.Errorf("record name = %q", rec.Name)
}
if !strings.HasPrefix(rec.Value, "v=DKIM1;") {
t.Errorf("record value = %q", rec.Value)
}
}
+180
View File
@@ -0,0 +1,180 @@
package domain
import (
"fmt"
"codeberg.org/mix/selfpost/internal/store"
)
// Applications is the slice of the application service the domain service needs
// to keep the SASL database and sender map consistent when a domain (and its
// applications, via cascade) is deleted. *app.Service satisfies it; it is an
// interface here to avoid a package import cycle and to keep domain deletion
// testable in isolation.
type Applications interface {
// PurgeDomainSASL removes the SASL accounts of the domain's applications.
// It must run before the registry cascade so the logins are still known.
PurgeDomainSASL(domainID int64) error
// Resync rebuilds smtpd_sender_login_maps from the remaining applications
// and reloads Postfix.
Resync() error
// Secret returns an application's stored password, for a domain export
// (spec 7.5.B).
Secret(login string) (string, error)
// ImportApplication re-creates an application (registry row + SASL account)
// from a domain-export file, without rebuilding the sender map (spec 7.5.B).
ImportApplication(domainID int64, login, mode string, addresses []string, password string) error
}
// Service coordinates the places a sending domain lives: the SQLite registry,
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL
// database and Postfix sender map its applications touch. Callers (the web
// handlers) validate user input first; Service keeps the stores in agreement and
// drives the OpenDKIM/Postfix reloads (spec 6, 7.2.2-4, 7.2.10).
type Service struct {
store *store.Store
odk *OpenDKIM
apps Applications
selector string
}
// NewService builds the domain service. selectorDefault is the DKIM selector
// assigned to new domains (spec 8: DKIM_SELECTOR_DEFAULT); it is
// operator-configured, not user input. apps is used only on deletion, to clear
// the SASL accounts and sender-map bindings of the domain's applications.
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service {
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault}
}
// List returns all domains with application counts (spec 7.2.2).
func (s *Service) List() ([]store.Domain, error) {
return s.store.ListDomains()
}
// Get returns one domain by id (store.ErrDomainNotFound if absent).
func (s *Service) Get(id int64) (store.Domain, error) {
return s.store.GetDomain(id)
}
// Add registers a new sending domain: it records the row, ensures a DKIM key
// exists on disk, and regenerates + reloads the OpenDKIM tables (spec 7.2.3).
// name must already be normalised and validated by the caller. A duplicate
// returns store.ErrDomainExists.
//
// The registry row is written first so its UNIQUE constraint is the single
// arbiter of "already exists" (avoiding a check-then-act race). An existing
// on-disk key is reused rather than overwritten, so re-adding a domain whose DB
// row was lost keeps its published DNS record valid. If key generation or the
// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered
// domain that OpenDKIM cannot sign.
func (s *Service) Add(name string) (store.Domain, error) {
d, err := s.store.AddDomain(name, s.selector)
if err != nil {
return store.Domain{}, err
}
if _, err := s.odk.EnsureKey(d.Name, d.DKIMSelector); err != nil {
s.rollbackAdd(d.ID)
return store.Domain{}, err
}
if err := s.resync(); err != nil {
s.rollbackAdd(d.ID)
return store.Domain{}, err
}
return d, nil
}
// rollbackAdd best-effort removes a half-created domain after a downstream
// failure. Errors here are logged by the caller's returned error path; the key
// (if freshly generated) is left in place harmlessly and reused on retry.
func (s *Service) rollbackAdd(id int64) {
_ = s.store.DeleteDomain(id)
}
// Delete removes a domain and everything bound to it (spec 7.2.4, 6.5). The
// order matters: the applications' SASL accounts are cleared first, while their
// logins are still in the registry; then the registry rows (applications and
// their addresses) go via the DB cascade; then the OpenDKIM tables and the
// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing
// and Postfix stops authorising the domain's senders — before the DKIM key is
// deleted.
func (s *Service) Delete(id int64) error {
d, err := s.store.GetDomain(id)
if err != nil {
return err
}
if err := s.apps.PurgeDomainSASL(id); err != nil {
return fmt.Errorf("clear SASL accounts for %s: %w", d.Name, err)
}
// Drop the domain's own level-2 limit and those of its applications while the
// application rows still exist (the cleanup query joins them). rate_limits has
// no cascade of its own (ref_id is a plain integer, spec 7.4/9).
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
}
if err := s.store.DeleteDomain(id); err != nil {
return err
}
if err := s.resync(); err != nil {
return err
}
if err := s.apps.Resync(); err != nil {
return fmt.Errorf("rebuild sender map after deleting %s: %w", d.Name, err)
}
if err := s.odk.RemoveKey(d.Name); err != nil {
// The domain is gone from the registry and tables; a leftover key
// directory is harmless. Surface it so it is not silently ignored.
return fmt.Errorf("domain deleted but key cleanup failed: %w", err)
}
return nil
}
// DKIMRecord returns the DNS TXT record to publish for a domain (spec 7.2.10).
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
return s.odk.Record(d.Name, d.DKIMSelector)
}
// RateLimit returns the domain-level differentiated rate limit (spec 7.4), and
// whether one is configured, for the domain's edit form.
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
}
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
// IPs and numbers (spec 7.6.2); the milter reads the row live, so no reload is
// needed.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeDomain,
RefID: domainID,
AllowedIPs: ips,
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
}
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
// only (spec 7.4).
func (s *Service) ClearRateLimit(domainID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
}
// Resync regenerates the OpenDKIM tables from the registry and reloads OpenDKIM.
// It backs the manual reload button (spec 7.2.12) and doubles as a recovery path
// if the tables ever drift from the database.
func (s *Service) Resync() error {
return s.resync()
}
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
func (s *Service) resync() error {
domains, err := s.store.ListDomains()
if err != nil {
return err
}
signing := make([]SigningDomain, 0, len(domains))
for _, d := range domains {
signing = append(signing, SigningDomain{Name: d.Name, Selector: d.DKIMSelector})
}
return s.odk.Rebuild(signing)
}
+131
View File
@@ -0,0 +1,131 @@
package domain
import (
"fmt"
"codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/store"
)
// FormatDomainExport identifies a single-domain export file (spec 7.5.B).
const FormatDomainExport = "selfpost-domain-export"
// DomainExport is the serialisable form of one sending domain, for moving it
// between two independently running SelfPost instances (spec 7.5.B). It carries
// the DKIM private key (so the published DNS record stays valid) and each
// application's working password (so credentials transfer without regeneration).
// The file is therefore as sensitive as a full backup and must be handled as a
// secret.
type DomainExport struct {
Format string `json:"format"`
Version string `json:"version"`
Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
Applications []AppExport `json:"applications"`
}
// AppExport is one application within a DomainExport.
type AppExport struct {
Login string `json:"login"`
AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"`
}
// Export builds the transferable representation of a domain: its DKIM key, its
// selector and every application with its address mode and working password
// (spec 7.5.B). The returned struct is marshalled to JSON by the caller and
// offered as a secret download.
func (s *Service) Export(id int64) (DomainExport, error) {
d, err := s.store.GetDomain(id)
if err != nil {
return DomainExport{}, err
}
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector)
if err != nil {
return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err)
}
apps, err := s.store.ListApplicationsByDomain(id)
if err != nil {
return DomainExport{}, err
}
exp := DomainExport{
Format: FormatDomainExport,
Version: buildinfo.Version,
Domain: d.Name,
DKIMSelector: d.DKIMSelector,
DKIMPrivateKey: string(pem),
Applications: make([]AppExport, 0, len(apps)),
}
for _, a := range apps {
password, err := s.apps.Secret(a.Login)
if err != nil {
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
}
exp.Applications = append(exp.Applications, AppExport{
Login: a.Login,
AddressMode: a.AddressMode,
Addresses: a.Addresses,
Password: password,
})
}
return exp, nil
}
// Import re-creates a domain from an export file on this instance (spec 7.5.B):
// it stores the imported DKIM key (so the published DNS record needs no change),
// registers the domain and rebuilds the OpenDKIM tables, then re-creates each
// application with its working password and rebuilds the Postfix sender map.
//
// exp.Domain must already be normalised and validated by the caller (spec
// 7.6.2); the selector is checked for config-injection safety here. A domain or
// login that already exists is rejected (store.ErrDomainExists /
// store.ErrLoginExists) rather than merged. If any step fails, everything the
// import created is rolled back, so a partial import never leaves the instance
// in an inconsistent state.
func (s *Service) Import(exp DomainExport) (store.Domain, error) {
if exp.Format != FormatDomainExport {
return store.Domain{}, fmt.Errorf("not a SelfPost domain export (format %q)", exp.Format)
}
if err := assertConfigSafe(exp.Domain, exp.DKIMSelector); err != nil {
return store.Domain{}, err
}
// Registry row first, so its UNIQUE constraint is the sole arbiter of a
// duplicate domain before we touch the filesystem.
d, err := s.store.AddDomain(exp.Domain, exp.DKIMSelector)
if err != nil {
return store.Domain{}, err // ErrDomainExists surfaces to the caller
}
if err := s.odk.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
if err := s.resync(); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
for _, a := range exp.Applications {
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
}
}
if err := s.apps.Resync(); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
return d, nil
}
// importRollback best-effort tears down a partially imported domain by running
// the normal deletion path, which clears the SASL accounts of any applications
// already created, removes the registry rows (cascade), rebuilds both maps and
// removes the DKIM key. Any error here is subordinate to the original failure
// the caller returns.
func (s *Service) importRollback(id int64) {
_ = s.Delete(id)
}
+190
View File
@@ -0,0 +1,190 @@
package domain
import (
"errors"
"fmt"
"testing"
"codeberg.org/mix/selfpost/internal/store"
)
// fakeApps stands in for *app.Service in the domain-transfer tests: it records
// the import calls and hands back canned SASL secrets.
type fakeApps struct {
secrets map[string]string
imported []importedApp
importErr error
}
type importedApp struct {
domainID int64
login string
mode string
addresses []string
password string
}
func (f *fakeApps) PurgeDomainSASL(int64) error { return nil }
func (f *fakeApps) Resync() error { return nil }
func (f *fakeApps) Secret(login string) (string, error) {
pw, ok := f.secrets[login]
if !ok {
return "", fmt.Errorf("no secret for %q", login)
}
return pw, nil
}
func (f *fakeApps) ImportApplication(domainID int64, login, mode string, addresses []string, password string) error {
if f.importErr != nil {
return f.importErr
}
f.imported = append(f.imported, importedApp{domainID, login, mode, addresses, password})
return nil
}
// newTestService builds a Service over a fresh SQLite store and OpenDKIM tree in
// a temp dir, with the OpenDKIM reload signal stubbed out.
func newTestService(t *testing.T, apps Applications) (*Service, *OpenDKIM) {
t.Helper()
st, err := store.Open(t.TempDir() + "/selfpost.db")
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
odk := NewOpenDKIM(t.TempDir())
odk.reload = func() error { return nil }
return NewService(st, odk, apps, "selfpost"), odk
}
func TestExportImportRoundTrip(t *testing.T) {
// Source instance: a domain with two applications and their secrets.
srcApps := &fakeApps{secrets: map[string]string{"mailer": "pw-mailer", "alerts": "pw-alerts"}}
src, srcOdk := newTestService(t, srcApps)
d, err := src.Add("example.com")
if err != nil {
t.Fatalf("add domain: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add mailer: %v", err)
}
if _, err := src.store.AddApplication(d.ID, "alerts", store.AddressModeList, []string{"a@example.com"}); err != nil {
t.Fatalf("add alerts: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
if exp.Format != FormatDomainExport || exp.Domain != "example.com" || exp.DKIMSelector != "selfpost" {
t.Fatalf("export header = %+v", exp)
}
if len(exp.Applications) != 2 {
t.Fatalf("exported %d apps, want 2", len(exp.Applications))
}
srcKey, err := srcOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read source key: %v", err)
}
if exp.DKIMPrivateKey != string(srcKey) {
t.Error("export DKIM key does not match the on-disk key")
}
// Target instance: import the file.
dstApps := &fakeApps{}
dst, dstOdk := newTestService(t, dstApps)
nd, err := dst.Import(exp)
if err != nil {
t.Fatalf("Import: %v", err)
}
// Domain row landed with the exported selector.
got, err := dst.Get(nd.ID)
if err != nil {
t.Fatalf("get imported domain: %v", err)
}
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got)
}
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
if err != nil {
t.Fatalf("read imported key: %v", err)
}
if string(dstKey) != string(srcKey) {
t.Error("imported DKIM key differs from the source key")
}
// Applications were re-created with their working passwords.
if len(dstApps.imported) != 2 {
t.Fatalf("imported %d apps, want 2", len(dstApps.imported))
}
byLogin := map[string]importedApp{}
for _, a := range dstApps.imported {
byLogin[a.login] = a
}
if byLogin["mailer"].password != "pw-mailer" || byLogin["alerts"].password != "pw-alerts" {
t.Errorf("imported passwords = %+v", dstApps.imported)
}
if byLogin["alerts"].mode != store.AddressModeList {
t.Errorf("alerts mode = %q", byLogin["alerts"].mode)
}
}
func TestImportRejectsWrongFormat(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Import(DomainExport{Format: "nope", Domain: "example.com"}); err == nil {
t.Error("Import accepted a non-export file")
}
}
func TestImportRejectsDuplicateDomain(t *testing.T) {
dst, _ := newTestService(t, &fakeApps{})
if _, err := dst.Add("example.com"); err != nil {
t.Fatalf("seed domain: %v", err)
}
// A minimal but well-formed export of the same domain.
src, srcOdk := newTestService(t, &fakeApps{})
d, _ := src.Add("example.com")
key, _ := srcOdk.ExportKey("example.com", "selfpost")
exp := DomainExport{
Format: FormatDomainExport, Domain: "example.com", DKIMSelector: "selfpost",
DKIMPrivateKey: string(key),
}
_ = d
if _, err := dst.Import(exp); !errors.Is(err, store.ErrDomainExists) {
t.Errorf("Import duplicate = %v, want ErrDomainExists", err)
}
}
func TestImportRollsBackOnAppFailure(t *testing.T) {
// Build a valid export from a source instance.
src, _ := newTestService(t, &fakeApps{secrets: map[string]string{"mailer": "pw"}})
d, _ := src.Add("example.com")
if _, err := src.store.AddApplication(d.ID, "mailer", store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add app: %v", err)
}
exp, err := src.Export(d.ID)
if err != nil {
t.Fatalf("Export: %v", err)
}
// Target rejects the application import; the whole domain must roll back.
dstApps := &fakeApps{importErr: errors.New("boom")}
dst, dstOdk := newTestService(t, dstApps)
if _, err := dst.Import(exp); err == nil {
t.Fatal("Import succeeded despite an application failure")
}
// Domain row removed.
domains, err := dst.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(domains) != 0 {
t.Errorf("expected rollback to remove the domain, got %+v", domains)
}
// DKIM key removed.
if _, err := dstOdk.ExportKey("example.com", "selfpost"); err == nil {
t.Error("expected rollback to remove the imported DKIM key")
}
}
+83
View File
@@ -0,0 +1,83 @@
package health
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"time"
)
// certWarnDays is how close to expiry the certificate has to be before the
// status page complains. Let's Encrypt renews at 30 days left, so two weeks is
// comfortably past the point where automatic renewal should have happened.
const certWarnDays = 14
// Certificate is the state of the TLS certificate Postfix serves on 465/587
// (spec 8: TLS_CERT_FILE). The panel only reads it — the file is supplied by
// the reverse proxy through a read-only mount.
type Certificate struct {
Path string
Subject string
NotAfter time.Time
DaysLeft int
Status Status
Detail string
}
// CheckCertificate parses the leaf certificate at path and reports how much
// validity is left. A missing or unparsable file is an error status rather than
// an error return: the status page reports it in place, like every other check.
func CheckCertificate(path string) Certificate {
c := Certificate{Path: path}
if path == "" {
c.Status = StatusUnknown
c.Detail = "No certificate path is configured (TLS_CERT_FILE)."
return c
}
data, err := os.ReadFile(path)
if err != nil {
c.Status = StatusError
c.Detail = fmt.Sprintf("Could not read the certificate at %s.", path)
return c
}
leaf, err := parseLeaf(data)
if err != nil {
c.Status = StatusError
c.Detail = fmt.Sprintf("%s does not contain a readable certificate.", path)
return c
}
c.Subject = leaf.Subject.CommonName
c.NotAfter = leaf.NotAfter
c.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
switch {
case !time.Now().Before(leaf.NotAfter):
c.Status = StatusError
c.Detail = "The certificate has expired. Senders will refuse the TLS connection."
case c.DaysLeft < certWarnDays:
c.Status = StatusWarn
c.Detail = fmt.Sprintf("Expires in %d day(s). Check that renewal on the host still works.", c.DaysLeft)
default:
c.Status = StatusOK
c.Detail = fmt.Sprintf("Valid for another %d day(s).", c.DaysLeft)
}
return c
}
// parseLeaf returns the first certificate in a PEM chain — the leaf, which is
// the one whose validity clients see.
func parseLeaf(data []byte) (*x509.Certificate, error) {
for rest := data; len(rest) > 0; {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
return x509.ParseCertificate(block.Bytes)
}
return nil, fmt.Errorf("no CERTIFICATE block found")
}
+53
View File
@@ -0,0 +1,53 @@
// Package health reports the running container's own operating state for the
// panel's status screen: the supervised processes, the TLS certificate Postfix
// serves, and the milter sockets delivery depends on.
//
// Every check is read-only and reports a problem as a Status value rather than
// an error return, so one broken component degrades a single line of the status
// page instead of blanking the whole thing. The package also owns the Status
// vocabulary shared with internal/dnscheck, so the panel renders every check —
// local or DNS — through one set of badges.
package health
// Status is the outcome of a single check, in the order the status page treats
// them: unknown < ok < warn < error, worst wins for a group.
type Status string
const (
// StatusUnknown means the check could not be performed at all (a missing
// setting, an unreachable resolver) — not evidence of a problem.
StatusUnknown Status = "unknown"
// StatusOK means the checked component is in its expected state.
StatusOK Status = "ok"
// StatusWarn means something is off but mail still flows.
StatusWarn Status = "warn"
// StatusError means mail delivery is (or soon will be) affected.
StatusError Status = "error"
)
// severity orders statuses so a group can report its worst member.
func (s Status) severity() int {
switch s {
case StatusError:
return 3
case StatusWarn:
return 2
case StatusOK:
return 1
default:
return 0
}
}
// Worst returns the most severe of the given statuses, or StatusUnknown when
// there are none. It is how the status page rolls a list of checks up into one
// headline.
func Worst(statuses ...Status) Status {
worst := StatusUnknown
for _, s := range statuses {
if s.severity() > worst.severity() {
worst = s
}
}
return worst
}
+165
View File
@@ -0,0 +1,165 @@
package health
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
)
func TestWorstPicksMostSevere(t *testing.T) {
cases := []struct {
in []Status
want Status
}{
{nil, StatusUnknown},
{[]Status{StatusOK, StatusOK}, StatusOK},
{[]Status{StatusOK, StatusWarn}, StatusWarn},
{[]Status{StatusWarn, StatusError, StatusOK}, StatusError},
{[]Status{StatusUnknown, StatusOK}, StatusOK},
}
for _, c := range cases {
if got := Worst(c.in...); got != c.want {
t.Errorf("Worst(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseProcesses(t *testing.T) {
// Real supervisorctl output: column-aligned, one line per program.
out := `opendkim RUNNING pid 21, uptime 0:04:10
panel RUNNING pid 22, uptime 0:04:09
postfix FATAL Exited too quickly (process log may have details)
postfix-reload STOPPED Not started
logrotate RUNNING pid 25, uptime 0:04:08
`
procs := parseProcesses(out)
if len(procs) != 5 {
t.Fatalf("parsed %d processes, want 5: %+v", len(procs), procs)
}
want := map[string]Status{
"opendkim": StatusOK,
"panel": StatusOK,
"postfix": StatusError,
"postfix-reload": StatusOK, // one-shot: idle is its healthy state
"logrotate": StatusOK,
}
for _, p := range procs {
if want[p.Name] != p.Status {
t.Errorf("%s (%s): status %q, want %q", p.Name, p.State, p.Status, want[p.Name])
}
}
if procs[0].Detail != "pid 21, uptime 0:04:10" {
t.Errorf("detail = %q", procs[0].Detail)
}
}
func TestParseProcessesSkipsNonStatusLines(t *testing.T) {
out := `error: <class 'socket.error'>, [Errno 2] No such file or directory
unix:///run/supervisor.sock refused connection
`
if procs := parseProcesses(out); len(procs) != 0 {
t.Errorf("error output parsed as processes: %+v", procs)
}
}
func TestCheckCertificate(t *testing.T) {
dir := t.TempDir()
valid := filepath.Join(dir, "valid.pem")
writeCert(t, valid, "mail.example.com", 90*24*time.Hour)
if got := CheckCertificate(valid); got.Status != StatusOK {
t.Errorf("valid certificate: status %q (%s)", got.Status, got.Detail)
} else if got.Subject != "mail.example.com" {
t.Errorf("subject = %q", got.Subject)
}
soon := filepath.Join(dir, "soon.pem")
writeCert(t, soon, "mail.example.com", 3*24*time.Hour)
if got := CheckCertificate(soon); got.Status != StatusWarn {
t.Errorf("nearly expired certificate: status %q (%s)", got.Status, got.Detail)
}
expired := filepath.Join(dir, "expired.pem")
writeCert(t, expired, "mail.example.com", -24*time.Hour)
if got := CheckCertificate(expired); got.Status != StatusError {
t.Errorf("expired certificate: status %q (%s)", got.Status, got.Detail)
}
if got := CheckCertificate(filepath.Join(dir, "absent.pem")); got.Status != StatusError {
t.Errorf("missing certificate: status %q", got.Status)
}
junk := filepath.Join(dir, "junk.pem")
if err := os.WriteFile(junk, []byte("not a certificate\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := CheckCertificate(junk); got.Status != StatusError {
t.Errorf("unparsable certificate: status %q", got.Status)
}
if got := CheckCertificate(""); got.Status != StatusUnknown {
t.Errorf("unconfigured certificate: status %q", got.Status)
}
}
func TestCheckSocket(t *testing.T) {
dir := t.TempDir()
sock := filepath.Join(dir, "opendkim.sock")
l, err := net.Listen("unix", sock)
if err != nil {
t.Skipf("unix sockets unavailable here: %v", err)
}
defer l.Close()
if got := CheckSocket("OpenDKIM", sock, true); got.Status != StatusOK || !got.Present {
t.Errorf("live socket: status %q present=%v", got.Status, got.Present)
}
missing := filepath.Join(dir, "journal.sock")
if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
t.Errorf("missing optional socket: status %q", got.Status)
}
if got := CheckSocket("OpenDKIM", missing, true); got.Status != StatusError {
t.Errorf("missing required socket: status %q", got.Status)
}
plain := filepath.Join(dir, "plain")
if err := os.WriteFile(plain, nil, 0o600); err != nil {
t.Fatal(err)
}
if got := CheckSocket("OpenDKIM", plain, true); got.Status != StatusError || got.Present {
t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present)
}
}
// writeCert writes a self-signed certificate expiring after validFor (negative
// for an already-expired one).
func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(validFor),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err := os.WriteFile(path, body, 0o600); err != nil {
t.Fatal(err)
}
}
+99
View File
@@ -0,0 +1,99 @@
package health
import (
"fmt"
"os/exec"
"strings"
)
// supervisorConf is the supervisord configuration the panel's other control
// calls already address (see internal/postfix and internal/domain).
const supervisorConf = "/etc/supervisor/supervisord.conf"
// oneShotPrograms are supervisord entries that are meant to sit idle: they are
// started on demand and exit immediately, so STOPPED/EXITED is their healthy
// state rather than a fault (see build/supervisord.conf).
var oneShotPrograms = map[string]bool{
"postfix-reload": true,
}
// Process is one supervised program as supervisord reports it.
type Process struct {
Name string
State string // supervisord's own state word, e.g. RUNNING
Detail string // the rest of the line: pid/uptime, or exit information
Status Status
}
// Processes returns the state of every supervised program (spec 4's three
// processes plus the reload/cert/logrotate helpers).
//
// The command takes fixed arguments and no user input, so it never goes through
// a shell (spec 7.6.3). `supervisorctl status` deliberately exits non-zero when
// some program is not running, so the output is parsed first and the exit status
// only matters when nothing could be parsed from it.
func Processes() ([]Process, error) {
cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
out, err := cmd.CombinedOutput()
procs := parseProcesses(string(out))
if len(procs) == 0 {
if err != nil {
return nil, fmt.Errorf("supervisorctl status: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil, fmt.Errorf("supervisorctl status: no programs reported")
}
return procs, nil
}
// supervisorStates are the state words supervisord prints. Lines whose second
// field is not one of them are not status lines (banners, error text) and are
// skipped, so unexpected output cannot masquerade as a process.
var supervisorStates = map[string]bool{
"STOPPED": true,
"STARTING": true,
"RUNNING": true,
"BACKOFF": true,
"STOPPING": true,
"EXITED": true,
"FATAL": true,
"UNKNOWN": true,
}
// parseProcesses turns supervisorctl's tabular output into Process values. Each
// status line is "<name> <STATE> <detail...>", column-aligned with spaces.
func parseProcesses(out string) []Process {
var procs []Process
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || !supervisorStates[fields[1]] {
continue
}
name, state := fields[0], fields[1]
procs = append(procs, Process{
Name: name,
State: state,
Detail: strings.Join(fields[2:], " "),
Status: processStatus(name, state),
})
}
return procs
}
// processStatus grades a supervisord state. A one-shot program that is not
// running is healthy; anything else that is not RUNNING means a component of
// the mail path is down or flapping.
func processStatus(name, state string) Status {
switch state {
case "RUNNING":
return StatusOK
case "STARTING", "STOPPING":
return StatusWarn
case "STOPPED", "EXITED":
if oneShotPrograms[name] {
return StatusOK
}
return StatusError
default: // BACKOFF, FATAL, UNKNOWN
return StatusError
}
}
+56
View File
@@ -0,0 +1,56 @@
package health
import (
"fmt"
"os"
)
// Socket is the state of one milter socket Postfix connects to.
type Socket struct {
Name string
Path string
Present bool
Status Status
Detail string
}
// CheckSocket stats a milter socket. required distinguishes the two milters:
// OpenDKIM runs with default_action=tempfail, so a missing socket stops mail
// leaving the server, while the journal-milter fails open — mail still goes out,
// only the send log stops being written.
func CheckSocket(name, path string, required bool) Socket {
s := Socket{Name: name, Path: path}
if path == "" {
s.Status = StatusUnknown
s.Detail = "No socket path is configured."
return s
}
fi, err := os.Stat(path)
switch {
case err != nil:
s.Status = missingStatus(required)
s.Detail = missingDetail(name, required)
case fi.Mode()&os.ModeSocket == 0:
s.Status = missingStatus(required)
s.Detail = fmt.Sprintf("%s exists but is not a socket.", path)
default:
s.Present = true
s.Status = StatusOK
s.Detail = "Listening."
}
return s
}
func missingStatus(required bool) Status {
if required {
return StatusError
}
return StatusWarn
}
func missingDetail(name string, required bool) string {
if required {
return fmt.Sprintf("The %s socket is missing. Postfix rejects mail with a temporary error until it is back.", name)
}
return fmt.Sprintf("The %s socket is missing. Mail still goes out, but the send log is not being written.", name)
}
+259
View File
@@ -0,0 +1,259 @@
// Package logtail follows Postfix's mail.log and reconciles the send-log
// delivery statuses the journal-milter could not know at receive time (spec
// 7.3). A milter row starts life as "queued"; Postfix only decides sent /
// deferred / bounced later, per recipient, and reports it in mail.log. This
// package parses those lines by queue-id + recipient and advances the matching
// rows, and prunes rows past the retention window.
package logtail
import (
"bufio"
"bytes"
"context"
"io"
"log"
"os"
"regexp"
"strings"
"time"
"codeberg.org/mix/selfpost/internal/store"
)
// StatusStore is the slice of the store the log-tailer needs. *store.Store
// satisfies it.
type StatusStore interface {
UpdateStatus(queueID, recipient, status string) (int64, error)
DeleteSendLogBefore(cutoff time.Time) (int64, error)
}
// pollInterval is how often the tail loop checks for new bytes / rotation. It
// is a var so tests can shorten it.
var pollInterval = time.Second
const (
// retentionInterval is how often the retention sweep runs (also once at
// startup). The window itself is configurable; the cadence need not be.
retentionInterval = 6 * time.Hour
// defaultRetentionDays applies when the configured value is unset/invalid
// (spec 7.3).
defaultRetentionDays = 90
)
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient
// and status, e.g.
//
// postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=…, dsn=2.0.0, status=sent (250 OK)
//
// The "<queue-id>: to=<addr>, …, status=<word>" shape is specific to the
// delivery agents; qmgr/smtpd/cleanup lines do not match.
var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*\bstatus=(\w+)`)
// parseDelivery extracts (queue-id, recipient, status) from a mail.log line.
// ok is false for lines that are not recognised delivery results.
func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
m := deliveryRe.FindStringSubmatch(line)
if m == nil {
return "", "", "", false
}
switch m[3] {
case "sent":
status = store.StatusSent
case "deferred":
status = store.StatusDeferred
case "bounced":
status = store.StatusBounced
case "expired":
// Postfix gave up after the queue lifetime; a final failure for us.
status = store.StatusBounced
default:
return "", "", "", false
}
return m[1], m[2], status, true
}
// Run follows path and updates send-log statuses until ctx is cancelled, while
// a background sweep prunes rows older than retentionDays. It returns nil on a
// clean shutdown.
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error {
go retentionLoop(ctx, st, retentionDays)
return follow(ctx, path, func(line string) {
queueID, recipient, status, ok := parseDelivery(line)
if !ok {
return
}
if _, err := st.UpdateStatus(queueID, recipient, status); err != nil {
log.Printf("log-tailer: update %s/%s -> %s: %v", queueID, recipient, status, err)
}
})
}
// retentionLoop prunes expired send-log rows immediately and then periodically.
func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
if retentionDays <= 0 {
retentionDays = defaultRetentionDays
}
prune := func() {
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
n, err := st.DeleteSendLogBefore(cutoff)
if err != nil {
log.Printf("log-tailer: retention prune: %v", err)
return
}
if n > 0 {
log.Printf("log-tailer: pruned %d send-log rows older than %d days", n, retentionDays)
}
}
prune()
t := time.NewTicker(retentionInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
prune()
}
}
}
// TailLines returns up to n of the most recent lines from path, for the
// panel's mail.log monitoring view (spec 7.2.13). It is a one-shot,
// point-in-time read on request — unrelated to the background follow loop
// above — that reads backwards in chunks so it stays cheap against a
// multi-megabyte log rather than reading the whole file every poll.
func TailLines(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
const chunkSize = 8192
var (
buf []byte
offset = info.Size()
)
for offset > 0 && bytes.Count(buf, []byte("\n")) <= n {
size := int64(chunkSize)
if size > offset {
size = offset
}
offset -= size
chunk := make([]byte, size)
if _, err := f.ReadAt(chunk, offset); err != nil {
return nil, err
}
buf = append(chunk, buf...)
}
text := strings.TrimRight(string(buf), "\n")
if text == "" {
return nil, nil
}
lines := strings.Split(text, "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines, nil
}
// follow tails path line by line, calling handle for each complete line, until
// ctx is cancelled. It starts at end-of-file (so a restart does not reprocess
// history) and reopens the file when it is rotated (inode change from
// logrotate's create, or truncation from copytruncate) so nothing is missed.
func follow(ctx context.Context, path string, handle func(string)) error {
var (
f *os.File
r *bufio.Reader
info os.FileInfo
pending string
)
openAt := func(offset int64, whence int) error {
nf, err := os.Open(path)
if err != nil {
return err
}
if _, err := nf.Seek(offset, whence); err != nil {
nf.Close()
return err
}
ni, err := nf.Stat()
if err != nil {
nf.Close()
return err
}
if f != nil {
f.Close()
}
f, r, info, pending = nf, bufio.NewReader(nf), ni, ""
return nil
}
// The container may start before Postfix has created mail.log; wait for it.
for {
if err := openAt(0, io.SeekEnd); err == nil {
break
}
select {
case <-ctx.Done():
return nil
case <-time.After(pollInterval):
}
}
defer func() {
if f != nil {
f.Close()
}
}()
drain := func() {
for {
line, err := r.ReadString('\n')
if err == io.EOF {
pending += line // hold the partial line until it completes
return
}
if err != nil {
log.Printf("log-tailer: read %s: %v", path, err)
return
}
full := pending + line
pending = ""
handle(strings.TrimRight(full, "\r\n"))
}
}
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
drain()
ni, err := os.Stat(path)
if err != nil {
continue // file briefly gone mid-rotation; try again next tick
}
pos, _ := f.Seek(0, io.SeekCurrent)
if !os.SameFile(info, ni) || ni.Size() < pos {
// Rotated away or truncated: the old (renamed) inode may have
// gained lines between the drain() above and this check, since
// Postfix keeps writing to it until it reloads. Drain it once
// more before switching so nothing in that gap is lost.
drain()
if err := openAt(0, io.SeekStart); err != nil {
log.Printf("log-tailer: reopen %s: %v", path, err)
}
}
}
}
}
+176
View File
@@ -0,0 +1,176 @@
package logtail
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"codeberg.org/mix/selfpost/internal/store"
)
func TestParseDelivery(t *testing.T) {
cases := []struct {
name string
line string
wantOK bool
queueID, recipient, status string
}{
{
name: "sent",
line: "2026-07-11T11:55:34 host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, delay=0.5, dsn=2.0.0, status=sent (250 OK)",
wantOK: true,
queueID: "41E862C00D9E",
recipient: "a@example.net",
status: store.StatusSent,
},
{
name: "deferred",
line: "host postfix/smtp[26]: 5900C2C00D9E: to=<y@example.net>, relay=none, delay=30, dsn=4.4.1, status=deferred (connect timed out)",
wantOK: true,
queueID: "5900C2C00D9E",
recipient: "y@example.net",
status: store.StatusDeferred,
},
{
name: "bounced",
line: "host postfix/smtp[26]: ABC: to=<no@example.net>, relay=…, dsn=5.1.1, status=bounced (user unknown)",
wantOK: true,
queueID: "ABC",
recipient: "no@example.net",
status: store.StatusBounced,
},
{
name: "expired maps to bounced",
line: "host postfix/smtp[26]: DEF: to=<slow@example.net>, relay=none, status=expired (delivery temporarily suspended)",
wantOK: true,
queueID: "DEF",
recipient: "slow@example.net",
status: store.StatusBounced,
},
{
name: "qmgr from-line ignored",
line: "host postfix/qmgr[10]: 41E862C00D9E: from=<noreply@example.com>, size=500, nrcpt=1 (queue active)",
wantOK: false,
},
{
name: "smtpd client-line ignored",
line: "host postfix/smtpd[10]: 41E862C00D9E: client=unknown[203.0.113.7]",
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, r, s, ok := parseDelivery(c.line)
if ok != c.wantOK {
t.Fatalf("ok = %v, want %v", ok, c.wantOK)
}
if !ok {
return
}
if q != c.queueID || r != c.recipient || s != c.status {
t.Fatalf("got (%q,%q,%q), want (%q,%q,%q)", q, r, s, c.queueID, c.recipient, c.status)
}
})
}
}
// captureStore records UpdateStatus calls for the follow integration test.
type captureStore struct {
mu sync.Mutex
calls []string
}
func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.calls = append(c.calls, queueID+"|"+recipient+"|"+status)
return 1, nil
}
func (c *captureStore) DeleteSendLogBefore(time.Time) (int64, error) { return 0, nil }
func (c *captureStore) snapshot() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.calls...)
}
// TestFollowTailsAndRotates writes delivery lines to a log file, then rotates
// it (rename + fresh create, as logrotate does) and writes more, asserting the
// tailer picks up lines from both the original and rotated file.
func TestFollowTailsAndRotates(t *testing.T) {
old := pollInterval
pollInterval = 10 * time.Millisecond
t.Cleanup(func() { pollInterval = old })
dir := t.TempDir()
path := filepath.Join(dir, "mail.log")
if err := os.WriteFile(path, []byte("preexisting line, ignored on start\n"), 0o644); err != nil {
t.Fatalf("seed log: %v", err)
}
cs := &captureStore{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- Run(ctx, path, cs, 90) }()
// Give follow() time to open at EOF (it seeks to end immediately on start,
// so the seed line above is ignored), then append a delivery line.
time.Sleep(50 * time.Millisecond)
appendLine(t, path, "host postfix/smtp[1]: Q1: to=<a@example.net>, dsn=2.0.0, status=sent (ok)")
waitFor(t, func() bool { return contains(cs.snapshot(), "Q1|a@example.net|sent") })
// Rotate: move the current file aside and create a fresh one (logrotate
// "create"), then append to the new file.
if err := os.Rename(path, path+".1"); err != nil {
t.Fatalf("rotate: %v", err)
}
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("recreate: %v", err)
}
appendLine(t, path, "host postfix/smtp[1]: Q2: to=<b@example.net>, dsn=5.1.1, status=bounced (nope)")
waitFor(t, func() bool { return contains(cs.snapshot(), "Q2|b@example.net|bounced") })
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
}
func appendLine(t *testing.T, path, line string) {
t.Helper()
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
t.Fatalf("open for append: %v", err)
}
defer f.Close()
if _, err := f.WriteString(line + "\n"); err != nil {
t.Fatalf("append: %v", err)
}
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition not met within timeout")
}
+188
View File
@@ -0,0 +1,188 @@
// Package milter implements the SelfPost journal-milter: a lightweight milter
// (spec 7.3) attached to Postfix's smtpd_milters alongside OpenDKIM. On the
// receive path it reads the SASL login, From, recipients and Subject of each
// accepted message and records one send-log row per (queue-id, recipient),
// giving the panel a structured, filterable history that raw mail.log cannot.
//
// It is monitoring only: it never rejects, and every callback returns Continue
// or Accept so a failure of this milter can never block the relay. Postfix is
// configured with default_action=accept for this milter's socket, so even a
// crash or hang fails open (spec 7.3).
package milter
import (
"context"
"log"
"net"
"net/textproto"
"strings"
"time"
"github.com/emersion/go-milter"
"codeberg.org/mix/selfpost/internal/store"
)
// Store is the persistence the milter needs on the receive path: recording
// accepted messages (spec 7.3) and, for level-2 rate limiting (spec 7.4),
// looking up the configured limits and counting recent messages. *store.Store
// satisfies it; tests substitute a fake.
type Store interface {
InsertQueued(e store.SendLogEntry) error
InsertRejected(e store.SendLogEntry) error
RateLimit(scope, ref string) (store.RateLimit, bool, error)
CountMessages(scope, ref string, since time.Time) (int64, error)
}
// session accumulates the fields of one message as the milter callbacks fire.
// Milter macros arrive per-stage and do not accumulate, so each value is
// captured at the stage that carries it (spec 7.3 / Phase 0 spike): SASL login
// and From at MAIL, each recipient at RCPT, Subject in the headers, and the
// queue-id at end-of-message. go-milter creates one session per connection; a
// connection may carry several messages, so per-message fields are reset at
// MailFrom (the start of every transaction).
type session struct {
milter.NoOpMilter
rec Store
clientIP string // captured once per connection
login string
from string
rcpts []string
subject string
}
// Connect captures the client IP, which comes from the addr parameter rather
// than a macro (the {client_addr} macro was empty in the spike). It is the
// rate-limit key for Phase 8; here it is recorded for completeness.
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
if addr != nil {
s.clientIP = addr.String()
}
return milter.RespContinue, nil
}
// MailFrom starts a new message: reset per-message state, then capture the
// envelope sender and the SASL login ({auth_authen}, carried by the MAIL-stage
// macros). This is also the earliest stage where both the sending domain (from
// the sender) and the application (the login) are known, so the level-2 rate
// limit is enforced here: over the limit, the message is refused with a 4xx
// tempfail before recipients are even offered (spec 7.4). Enforcement is
// fail-open — see overLimit.
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts = nil
s.subject = ""
if s.overLimit() {
s.recordRejected()
return milter.RespTempFail, nil
}
return milter.RespContinue, nil
}
// RcptTo records each recipient. Postfix calls this once per recipient, which
// is what lets the journal keep a separate row per (queue-id, recipient).
func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
return milter.RespContinue, nil
}
// Header captures the Subject. Only the first Subject header is kept.
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
s.subject = value
}
return milter.RespContinue, nil
}
// Body fires at end-of-message, when the queue-id macro {i} is set and the
// message is about to be committed to the queue. This is where the "queued"
// rows are written. We accept (this milter is done) without ever rejecting.
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
s.record(macro(m, "i"))
return milter.RespAccept, nil
}
// macro reads a milter macro, tolerating Postfix's convention of wrapping
// multi-character macro names in curly braces (e.g. {auth_authen}) while
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
// Postfix sends verbatim, so a lookup must try both forms — this is exactly the
// distinction the SASL-less Phase 0 spike could not observe.
func macro(m *milter.Modifier, name string) string {
if v, ok := m.Macros[name]; ok {
return v
}
return m.Macros["{"+name+"}"]
}
// record writes one send-log row per recipient. Failures are logged, never
// propagated: journalling must not affect mail acceptance (spec 7.3).
func (s *session) record(queueID string) {
domain := domainOf(s.from)
rcpts := s.rcpts
if len(rcpts) == 0 {
// No recipient seen (unusual) — still record the message so it is
// visible in the log rather than silently dropped.
rcpts = []string{""}
}
for _, to := range rcpts {
err := s.rec.InsertQueued(store.SendLogEntry{
QueueID: queueID,
Domain: domain,
AppLogin: s.login,
From: s.from,
To: to,
Subject: s.subject,
})
if err != nil {
log.Printf("journal-milter: record %s -> %s: %v", queueID, to, err)
}
}
}
// cleanAddress strips the angle brackets and any ESMTP parameters Postfix may
// pass with an address, leaving the bare mailbox.
func cleanAddress(a string) string {
a = strings.TrimSpace(a)
if i := strings.IndexByte(a, ' '); i >= 0 { // drop "addr SIZE=… BODY=…" params
a = a[:i]
}
a = strings.TrimPrefix(a, "<")
a = strings.TrimSuffix(a, ">")
return a
}
// domainOf returns the lower-cased domain of an email address, or "" if there
// is no domain part. Sender binding (Phase 4) guarantees the From domain equals
// the application's domain, so this is the sending domain (spec 7.3).
func domainOf(addr string) string {
if i := strings.LastIndexByte(addr, '@'); i >= 0 {
return strings.ToLower(addr[i+1:])
}
return ""
}
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
srv := &milter.Server{
NewMilter: func() milter.Milter { return &session{rec: rec} },
Actions: 0, // read-only: we make no message modifications
Protocol: milter.OptNoBody, // the journal needs headers/EOM, not the body
}
go func() {
<-ctx.Done()
_ = srv.Close()
}()
if err := srv.Serve(ln); err != nil {
if ctx.Err() != nil {
return nil // expected: Close() during shutdown unblocks Serve
}
return err
}
return nil
}
+317
View File
@@ -0,0 +1,317 @@
package milter
import (
"errors"
"net"
"testing"
"time"
"github.com/emersion/go-milter"
"codeberg.org/mix/selfpost/internal/store"
)
// fakeRecorder captures inserts and can be made to fail, to prove the milter
// swallows recorder errors and still accepts the message. By default it reports
// no configured rate limit, so the level-2 check is inert unless a test sets
// limits (see fakeRecorder fields).
type fakeRecorder struct {
entries []store.SendLogEntry
rejected []store.SendLogEntry
fail bool
// limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts
// gives the recent-message count returned for a "scope|ref". lookupErr and
// countErr force the store errors that must fail open.
limits map[string]store.RateLimit
counts map[string]int64
lookupErr error
countErr error
}
func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error {
if f.fail {
return errors.New("boom")
}
f.entries = append(f.entries, e)
return nil
}
func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error {
f.rejected = append(f.rejected, e)
return nil
}
func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, error) {
if f.lookupErr != nil {
return store.RateLimit{}, false, f.lookupErr
}
rl, ok := f.limits[scope+"|"+ref]
return rl, ok, nil
}
func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, error) {
if f.countErr != nil {
return 0, f.countErr
}
return f.counts[scope+"|"+ref], nil
}
func mods(kv map[string]string) *milter.Modifier {
return &milter.Modifier{Macros: kv}
}
// drive replays a typical message through one session and returns the recorder.
func drive(t *testing.T, rec Store) *session {
t.Helper()
s := &session{rec: rec}
if _, err := s.Connect("localhost", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil)); err != nil {
t.Fatalf("Connect: %v", err)
}
if _, err := s.MailFrom("noreply@example.com", mods(map[string]string{"auth_authen": "app1"})); err != nil {
t.Fatalf("MailFrom: %v", err)
}
if _, err := s.RcptTo("<a@example.net>", mods(nil)); err != nil {
t.Fatalf("RcptTo: %v", err)
}
if _, err := s.RcptTo("b@example.net", mods(nil)); err != nil {
t.Fatalf("RcptTo: %v", err)
}
if _, err := s.Header("Subject", "Hello there", mods(nil)); err != nil {
t.Fatalf("Header: %v", err)
}
if _, err := s.Body(mods(map[string]string{"i": "ABC123"})); err != nil {
t.Fatalf("Body: %v", err)
}
return s
}
func TestSessionRecordsRowPerRecipient(t *testing.T) {
rec := &fakeRecorder{}
s := drive(t, rec)
if s.clientIP != "203.0.113.7" {
t.Fatalf("clientIP = %q, want 203.0.113.7", s.clientIP)
}
if len(rec.entries) != 2 {
t.Fatalf("want 2 entries, got %d: %+v", len(rec.entries), rec.entries)
}
got := rec.entries[0]
want := store.SendLogEntry{
QueueID: "ABC123",
Domain: "example.com",
AppLogin: "app1",
From: "noreply@example.com",
To: "a@example.net", // angle brackets stripped
Subject: "Hello there",
}
if got != want {
t.Fatalf("entry[0]\n got %+v\nwant %+v", got, want)
}
if rec.entries[1].To != "b@example.net" {
t.Fatalf("entry[1].To = %q", rec.entries[1].To)
}
}
func TestBodyAcceptsEvenWhenRecorderFails(t *testing.T) {
rec := &fakeRecorder{fail: true}
s := &session{rec: rec}
_, _ = s.MailFrom("x@example.com", mods(map[string]string{"auth_authen": "app1"}))
_, _ = s.RcptTo("y@example.net", mods(nil))
resp, err := s.Body(mods(map[string]string{"i": "Q9"}))
if err != nil {
t.Fatalf("Body returned error, must fail open: %v", err)
}
if resp != milter.RespAccept {
t.Fatalf("Body response = %v, want Accept", resp)
}
}
// A single connection may carry several messages; the second must not inherit
// the first's recipients or subject.
func TestSessionResetsBetweenMessages(t *testing.T) {
rec := &fakeRecorder{}
s := &session{rec: rec}
_, _ = s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
_, _ = s.RcptTo("one@example.net", mods(nil))
_, _ = s.Header("Subject", "first", mods(nil))
_, _ = s.Body(mods(map[string]string{"i": "Q1"}))
_, _ = s.MailFrom("b@example.com", mods(map[string]string{"auth_authen": "app2"}))
_, _ = s.RcptTo("two@example.net", mods(nil))
_, _ = s.Body(mods(map[string]string{"i": "Q2"}))
if len(rec.entries) != 2 {
t.Fatalf("want 2 entries, got %d", len(rec.entries))
}
second := rec.entries[1]
if second.QueueID != "Q2" || second.To != "two@example.net" || second.Subject != "" || second.AppLogin != "app2" {
t.Fatalf("second message leaked state: %+v", second)
}
}
// Postfix sends multi-character macro names wrapped in braces ({auth_authen},
// {i} for some versions), so the milter must resolve those too — this is the
// case the SASL-less spike missed and that produced empty app_login at first.
func TestBracedMacros(t *testing.T) {
rec := &fakeRecorder{}
s := &session{rec: rec}
_, _ = s.MailFrom("app@example.com", mods(map[string]string{"{auth_authen}": "app1"}))
_, _ = s.RcptTo("to@example.net", mods(nil))
_, _ = s.Body(mods(map[string]string{"{i}": "QBRACE"}))
if len(rec.entries) != 1 {
t.Fatalf("want 1 entry, got %d", len(rec.entries))
}
e := rec.entries[0]
if e.AppLogin != "app1" {
t.Fatalf("AppLogin = %q, want app1 (braced {auth_authen} not resolved)", e.AppLogin)
}
if e.QueueID != "QBRACE" {
t.Fatalf("QueueID = %q, want QBRACE (braced {i} not resolved)", e.QueueID)
}
}
// limitAt is the client IP the rate-limit tests connect from; the limits below
// register it so the differentiated check applies.
const limitIP = "203.0.113.7"
func activeLimit(ips ...string) store.RateLimit {
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
}
// mailFrom drives just the connect + MAIL FROM stages and returns the response,
// which is where the level-2 limit is enforced.
func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
t.Helper()
s := &session{rec: rec}
if _, err := s.Connect("h", "tcp4", 0, net.ParseIP(ip), mods(nil)); err != nil {
t.Fatalf("Connect: %v", err)
}
resp, err := s.MailFrom(from, mods(map[string]string{"auth_authen": login}))
if err != nil {
t.Fatalf("MailFrom: %v", err)
}
return resp
}
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("over-limit MAIL FROM = %v, want TempFail (4xx)", resp)
}
if len(rec.rejected) != 1 || rec.rejected[0].Domain != "example.com" {
t.Fatalf("want one rejected send-log row for example.com, got %+v", rec.rejected)
}
}
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
},
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
t.Fatalf("over app limit = %v, want TempFail", resp)
}
}
func TestRateLimitAllowsUnderLimit(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("under limit = %v, want Continue", resp)
}
if len(rec.rejected) != 0 {
t.Fatalf("under limit must not record a rejection: %+v", rec.rejected)
}
}
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
// The sender's IP is not in the domain's registered set, so level-2 does not
// apply even though the count is huge (level-1 anvil would still cover it).
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
}
}
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
rec := &fakeRecorder{
// IP registered but no ceiling/window: an inert draft, must not enforce.
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("inactive limit = %v, want Continue", resp)
}
}
func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
rec := &fakeRecorder{lookupErr: errors.New("db down")}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("lookup error = %v, want Continue (fail-open)", resp)
}
}
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
},
countErr: errors.New("db down"),
}
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
t.Fatalf("count error = %v, want Continue (fail-open)", resp)
}
}
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
rec := &fakeRecorder{
limits: map[string]store.RateLimit{
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
},
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
}
// A session with no client IP (e.g. local submission) cannot be keyed.
s := &session{rec: rec}
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
if err != nil {
t.Fatalf("MailFrom: %v", err)
}
if resp != milter.RespContinue {
t.Fatalf("no-IP session = %v, want Continue", resp)
}
}
func TestDomainOf(t *testing.T) {
cases := map[string]string{
"user@Example.COM": "example.com",
"no-domain": "",
"": "",
"a@b@c.com": "c.com",
}
for in, want := range cases {
if got := domainOf(in); got != want {
t.Fatalf("domainOf(%q) = %q, want %q", in, got, want)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
package milter
import (
"log"
"time"
"codeberg.org/mix/selfpost/internal/store"
)
// overLimit reports whether the message currently being received should be
// refused under a level-2 differentiated limit (spec 7.4). It checks the
// domain-level and application-level limits in turn; either being exceeded is
// enough to refuse.
//
// It is deliberately fail-open: any store error, or the absence of a usable
// limit, is treated as "not over limit" so a malfunction of the level-2 limiter
// can never block mail — Postfix's level-1 anvil limit (spec 5) remains the
// backstop, and it does not depend on this milter at all. Only a clean count at
// or above a configured ceiling returns true.
func (s *session) overLimit() bool {
if s.clientIP == "" {
return false // no client IP to key on; level-2 does not apply
}
checks := []struct{ scope, ref string }{
{store.RateLimitScopeDomain, domainOf(s.from)},
{store.RateLimitScopeApp, s.login},
}
for _, c := range checks {
if c.ref == "" {
continue
}
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
if err != nil {
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
}
// No limit configured, an inert draft, or a client IP outside the
// registered set: the differentiated limit does not apply here.
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
continue
}
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
n, err := s.rec.CountMessages(c.scope, c.ref, since)
if err != nil {
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
continue
}
if n >= int64(rl.MaxMessages) {
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
return true
}
}
return false
}
// recordRejected writes a send-log row for a message refused by a level-2 limit
// (spec 7.4, "опционально фиксирует ... для видимости в UI"), so the rejection
// shows up in the monitoring screen. Only MAIL-stage fields are known; the write
// is best-effort and never affects the response.
func (s *session) recordRejected() {
err := s.rec.InsertRejected(store.SendLogEntry{
Domain: domainOf(s.from),
AppLogin: s.login,
From: s.from,
})
if err != nil {
log.Printf("journal-milter: record rejected %s: %v", s.from, err)
}
}
+159
View File
@@ -0,0 +1,159 @@
// Package postfix owns the Postfix configuration files the panel edits at
// runtime and the privileged reload that applies them (spec 5.1, 7.6.3-4). In
// Phase 4 that is the smtpd_sender_login_maps table binding each application's
// SASL login to the sender addresses it may use; the full relay configuration
// lands in Phase 5.
package postfix
import (
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// Postfix manages the on-disk Postfix state the panel is responsible for. After
// rewriting a map it asks Postfix to reload.
type Postfix struct {
senderLoginMapsPath string
// reload asks the running Postfix to re-read its configuration. It is a
// field so tests can substitute a no-op; the default drives supervisord.
reload func() error
}
// New builds a manager rooted at dir (typically /data/postfix), the same layout
// entrypoint.sh prepares. The default reload path signals Postfix through
// supervisord.
func New(dir string) *Postfix {
return &Postfix{
senderLoginMapsPath: filepath.Join(dir, "sender_login_maps"),
reload: reloadViaSupervisor,
}
}
// SenderLoginMapsPath is the absolute path of the generated map, so the Postfix
// main.cf written in Phase 5 can point smtpd_sender_login_maps at it.
func (p *Postfix) SenderLoginMapsPath() string {
return p.senderLoginMapsPath
}
// Binding is one sender-address → login pair (spec 5.1). Address is either a
// domain wildcard "@example.com" or a specific address "alerts@example.com".
type Binding struct {
Address string
Login string
}
// RebuildSenderLoginMaps regenerates the sender_login_maps file from the full
// set of bindings and reloads Postfix (spec 5.1). Full regeneration (rather than
// incremental edits) keeps the file a pure function of the registry, so add,
// edit and delete share one idempotent path. The file is written atomically
// before the reload.
//
// Several applications may be authorised for the same address (many-to-one,
// spec 5.1 §4) — their logins are merged onto a single line as a comma-separated
// list, which is how Postfix expects multiple owners of one sender.
func (p *Postfix) RebuildSenderLoginMaps(bindings []Binding) error {
content, err := renderSenderLoginMaps(bindings)
if err != nil {
return err
}
if err := writeFileAtomic(p.senderLoginMapsPath, content, 0o640); err != nil {
return err
}
return p.reload()
}
// Reload asks Postfix to re-read its configuration without regenerating any
// file. It backs the panel's manual reload button (spec 7.2.12).
func (p *Postfix) Reload() error {
return p.reload()
}
// renderSenderLoginMaps builds the sender_login_maps file contents. Keys are
// sorted for deterministic output and the logins under each key are sorted and
// de-duplicated. Every address and login is re-checked for injection safety
// before being written (spec 7.6.4) — upstream validation already guarantees
// this, but the writer refuses to emit anything unsafe as a hard backstop.
func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
byAddr := make(map[string][]string)
order := make([]string, 0)
for _, b := range bindings {
if err := assertMapSafe(b.Address, b.Login); err != nil {
return nil, err
}
if _, seen := byAddr[b.Address]; !seen {
order = append(order, b.Address)
}
byAddr[b.Address] = appendUnique(byAddr[b.Address], b.Login)
}
sort.Strings(order)
var sb strings.Builder
for _, addr := range order {
logins := byAddr[addr]
sort.Strings(logins)
// texthash format: <key><whitespace><value>. A comma-separated value
// lists every login permitted to use this sender (spec 5.1 §4).
fmt.Fprintf(&sb, "%s %s\n", addr, strings.Join(logins, ","))
}
return []byte(sb.String()), nil
}
func appendUnique(list []string, v string) []string {
for _, x := range list {
if x == v {
return list
}
}
return append(list, v)
}
// assertMapSafe rejects any address/login value that could break out of a single
// map line or inject a directive. Addresses are validated to a strict whitelist
// (letters, digits, '@', '.', '-', '_', '+') and logins to an even stricter one
// upstream (spec 7.6.2); this is defence in depth against a validation gap ever
// letting whitespace, a newline or a comma (the value separator) through into
// the file (spec 7.6.4).
func assertMapSafe(address, login string) error {
if address == "" || login == "" {
return fmt.Errorf("postfix: empty address or login")
}
if strings.ContainsAny(address, " \t\r\n,:\\") {
return fmt.Errorf("postfix: unsafe character in address %q", address)
}
if strings.ContainsAny(login, " \t\r\n,:@\\") {
return fmt.Errorf("postfix: unsafe character in login %q", login)
}
return nil
}
// reloadViaSupervisor asks supervisord (PID 1, running as root) to run the
// one-shot `postfix-reload` program, which executes the canonical
// `postfix reload` and re-reads main.cf/master.cf and the lookup tables they
// reference. The panel runs unprivileged: it cannot run `postfix reload` itself,
// and it cannot signal the Postfix master directly because `postfix start-fg`
// forks a separate master whose PID supervisord does not track (a SIGHUP to the
// supervised process would never reach it). Going through supervisord's
// group-accessible control socket runs the reload as root without any panel
// privilege (spec 5.2, 7.2.12, 7.6.3, 7.6.8).
//
// Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (spec 7.6.3).
func reloadViaSupervisor() error {
cmd := exec.Command("supervisorctl",
"-c", "/etc/supervisor/supervisord.conf",
"start", "postfix-reload")
out, err := cmd.CombinedOutput()
if err != nil {
// A reload already in flight is not a failure: that pending run reloads
// Postfix after our file is in place (the file is written before this).
if strings.Contains(string(out), "already started") {
return nil
}
return fmt.Errorf("reload postfix via supervisor: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
+118
View File
@@ -0,0 +1,118 @@
package postfix
import (
"os"
"testing"
)
func TestRenderSenderLoginMaps(t *testing.T) {
// Deliberately unsorted, with two logins sharing one wildcard key
// (many-to-one, spec 5.1 §4) to exercise merge + sort.
bindings := []Binding{
{"@zeta.example", "z1"},
{"alerts@alpha.example", "a-listed"},
{"@alpha.example", "a2"},
{"@alpha.example", "a1"},
}
got, err := renderSenderLoginMaps(bindings)
if err != nil {
t.Fatalf("renderSenderLoginMaps: %v", err)
}
want := "@alpha.example a1,a2\n" +
"@zeta.example z1\n" +
"alerts@alpha.example a-listed\n"
if string(got) != want {
t.Errorf("map =\n%q\nwant\n%q", got, want)
}
}
func TestRenderSenderLoginMapsEmpty(t *testing.T) {
got, err := renderSenderLoginMaps(nil)
if err != nil {
t.Fatalf("renderSenderLoginMaps(nil): %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty map, got %q", got)
}
}
func TestRenderSenderLoginMapsDedupesLogin(t *testing.T) {
bindings := []Binding{
{"@a.example", "dup"},
{"@a.example", "dup"},
}
got, err := renderSenderLoginMaps(bindings)
if err != nil {
t.Fatal(err)
}
if string(got) != "@a.example dup\n" {
t.Errorf("map = %q, want single deduped login", got)
}
}
func TestAssertMapSafeRejectsInjection(t *testing.T) {
bad := []struct{ addr, login string }{
{"@exa mple.com", "log"},
{"@example.com\nx y z", "log"},
{"@example.com", "log,evil"},
{"@example.com", "log in"},
{"@example.com", "log@realm"}, // '@' would confuse sasldb realm handling
{"", "log"},
{"@example.com", ""},
}
for _, b := range bad {
if err := assertMapSafe(b.addr, b.login); err == nil {
t.Errorf("assertMapSafe(%q,%q) = nil, want error", b.addr, b.login)
}
}
if err := assertMapSafe("alerts@example.com", "app_1-x"); err != nil {
t.Errorf("assertMapSafe of a clean pair errored: %v", err)
}
}
func newTestPostfix(t *testing.T) (*Postfix, *int) {
t.Helper()
dir := t.TempDir()
reloads := 0
p := New(dir)
p.reload = func() error { reloads++; return nil }
return p, &reloads
}
func TestRebuildSenderLoginMapsWritesAndReloads(t *testing.T) {
p, reloads := newTestPostfix(t)
if err := p.RebuildSenderLoginMaps([]Binding{{"@example.com", "app1"}}); err != nil {
t.Fatalf("RebuildSenderLoginMaps: %v", err)
}
if *reloads != 1 {
t.Errorf("reload called %d times, want 1", *reloads)
}
data, err := os.ReadFile(p.senderLoginMapsPath)
if err != nil {
t.Fatal(err)
}
if string(data) != "@example.com app1\n" {
t.Errorf("map file = %q", data)
}
}
func TestRebuildRejectsUnsafeWithoutWriting(t *testing.T) {
p, reloads := newTestPostfix(t)
// Seed a known-good file so we can prove the failed rebuild left it untouched.
if err := p.RebuildSenderLoginMaps([]Binding{{"@good.example", "ok"}}); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(p.senderLoginMapsPath)
err := p.RebuildSenderLoginMaps([]Binding{{"@bad.example", "evil\nlogin"}})
if err == nil {
t.Fatal("expected rebuild to reject unsafe login")
}
after, _ := os.ReadFile(p.senderLoginMapsPath)
if string(after) != string(before) {
t.Errorf("map file changed on failed rebuild: %q", after)
}
if *reloads != 1 {
t.Errorf("reload called %d times, want 1 (no reload on failure)", *reloads)
}
}
+22
View File
@@ -0,0 +1,22 @@
package postfix
import (
"fmt"
"os/exec"
"strings"
)
// Queue returns Postfix's own human-readable mail-queue listing (spec 7.2.11):
// active, deferred and held messages, exactly as an administrator would see
// via the CLI. The command takes a single fixed flag and no user input, so it
// never goes through a shell (spec 7.6.3). The panel is responsible for
// escaping the output before display (spec 7.6.7); this function returns it
// as-is.
func Queue() (string, error) {
cmd := exec.Command("postqueue", "-p")
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("postqueue -p: %w: %s", err, strings.TrimSpace(string(out)))
}
return string(out), nil
}
+47
View File
@@ -0,0 +1,47 @@
package postfix
import (
"fmt"
"os"
"path/filepath"
)
// writeFileAtomic writes data to path via a temp file in the same directory
// followed by a rename, so a concurrent Postfix reload only ever sees the
// complete old or new map, never a partial write (spec 7.6.4). It mirrors the
// same primitive used for the OpenDKIM tables.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp %s: %w", tmpName, err)
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return fmt.Errorf("chmod temp %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync temp %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
}
cleanup = false
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
var ErrNoAdmin = errors.New("no administrator account")
// Admin is the single panel administrator (spec 7.6.1).
type Admin struct {
Username string
PasswordHash string
CreatedAt time.Time
}
// AdminExists reports whether the administrator account has been created. This
// doubles as the "primary setup complete" flag: once true, the /setup route is
// permanently gone (spec 7.6.1).
func (s *Store) AdminExists() (bool, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
return false, fmt.Errorf("count admin: %w", err)
}
return n > 0, nil
}
// CreateAdmin inserts the administrator row. It fails if one already exists,
// which — combined with the id=1 constraint — makes admin creation one-shot
// even under a race between two setup submissions.
func (s *Store) CreateAdmin(username, passwordHash string) error {
_, err := s.db.Exec(
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("create admin: %w", err)
}
return nil
}
// UpdateAdmin replaces the administrator's username and password hash. It
// touches only the admin row (id = 1): panel credentials are unrelated to the
// SASL logins applications authenticate with, which live in their own table.
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
// silently create an account.
func (s *Store) UpdateAdmin(username, passwordHash string) error {
res, err := s.db.Exec(
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
username, passwordHash,
)
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
if n == 0 {
return ErrNoAdmin
}
return nil
}
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
func (s *Store) GetAdmin() (Admin, error) {
var (
a Admin
createdAt string
)
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
Scan(&a.Username, &a.PasswordHash, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return Admin{}, ErrNoAdmin
}
if err != nil {
return Admin{}, fmt.Errorf("get admin: %w", err)
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return a, nil
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"errors"
"testing"
)
func TestUpdateAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
t.Fatalf("CreateAdmin: %v", err)
}
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
t.Fatalf("UpdateAdmin: %v", err)
}
a, err := st.GetAdmin()
if err != nil {
t.Fatalf("GetAdmin: %v", err)
}
if a.Username != "operator" || a.PasswordHash != "hash-two" {
t.Fatalf("unexpected admin after update: %+v", a)
}
if a.CreatedAt.IsZero() {
t.Fatal("update dropped created_at")
}
}
// An update before setup must not create the account: only the one-time setup
// flow may do that (spec 7.6.1).
func TestUpdateAdminWithoutAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
}
exists, err := st.AdminExists()
if err != nil {
t.Fatalf("AdminExists: %v", err)
}
if exists {
t.Fatal("UpdateAdmin created an administrator")
}
}
+323
View File
@@ -0,0 +1,323 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrLoginExists is returned when an application login is already taken. The
// login is globally unique because it is the SASL identity Postfix authenticates
// (one sasldb2 across all domains, spec 5.1).
var ErrLoginExists = errors.New("application login already exists")
// ErrApplicationNotFound is returned when an application id does not exist.
var ErrApplicationNotFound = errors.New("application not found")
// Address modes (spec 4.1). Kept in sync with the CHECK constraint in the schema.
const (
AddressModeWildcard = "wildcard" // any address within the application's domain
AddressModeList = "list" // only the explicitly listed addresses
)
// Application is a SASL account bound to a single domain (spec 4.1, 5.1). The
// password is never stored here — only in sasldb2, hashed — so it can be shown
// exactly once at creation/regeneration (spec 7.6.1). Addresses is populated only
// in 'list' mode.
type Application struct {
ID int64
DomainID int64
Login string
AddressMode string
CreatedAt time.Time
Addresses []string
}
// Binding is one sender-address → login pair, as consumed by the
// smtpd_sender_login_maps generator (spec 5.1). For a wildcard application the
// Address is the domain wildcard "@example.com"; for a list application there is
// one Binding per listed address.
type Binding struct {
Address string
Login string
}
// AddApplication inserts an application and, in list mode, its addresses, in a
// single transaction. The caller must have validated login and every address
// (spec 7.6.2) beforehand; the query is parameterised regardless. A duplicate
// login maps to ErrLoginExists.
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
now := time.Now().UTC()
tx, err := s.db.Begin()
if err != nil {
return Application{}, fmt.Errorf("begin add application: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, ?, ?)",
domainID, login, mode, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return Application{}, ErrLoginExists
}
return Application{}, fmt.Errorf("insert application: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return Application{}, fmt.Errorf("application id: %w", err)
}
if err := insertAddresses(tx, id, mode, addresses); err != nil {
return Application{}, err
}
if err := tx.Commit(); err != nil {
return Application{}, fmt.Errorf("commit add application: %w", err)
}
return Application{
ID: id, DomainID: domainID, Login: login, AddressMode: mode,
CreatedAt: now, Addresses: normalizedList(mode, addresses),
}, nil
}
// UpdateApplicationMode switches an application's address mode and replaces its
// address list atomically (spec 7.2.7). The login and password are untouched.
// Returns ErrApplicationNotFound if the id does not exist.
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update application: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec("UPDATE applications SET address_mode = ? WHERE id = ?", mode, id)
if err != nil {
return fmt.Errorf("update application mode: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update application rows: %w", err)
}
if n == 0 {
return ErrApplicationNotFound
}
if _, err := tx.Exec("DELETE FROM application_addresses WHERE application_id = ?", id); err != nil {
return fmt.Errorf("clear addresses: %w", err)
}
if err := insertAddresses(tx, id, mode, addresses); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit update application: %w", err)
}
return nil
}
// insertAddresses writes the address rows for a list-mode application. In
// wildcard mode no address rows are stored (the wildcard is derived from the
// domain at map-generation time).
func insertAddresses(tx *sql.Tx, appID int64, mode string, addresses []string) error {
if mode != AddressModeList {
return nil
}
for _, addr := range addresses {
if _, err := tx.Exec(
"INSERT INTO application_addresses (application_id, address) VALUES (?, ?)",
appID, addr,
); err != nil {
if isUniqueViolation(err) {
continue // a repeated address in the same submission is harmless
}
return fmt.Errorf("insert address %q: %w", addr, err)
}
}
return nil
}
func normalizedList(mode string, addresses []string) []string {
if mode != AddressModeList {
return nil
}
return addresses
}
// GetApplication returns one application (with its addresses) by id, or
// ErrApplicationNotFound.
func (s *Store) GetApplication(id int64) (Application, error) {
row := s.db.QueryRow(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE id = ?", id)
a, err := scanApplication(row)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrApplicationNotFound
}
if err != nil {
return Application{}, err
}
addrs, err := s.applicationAddresses(a.ID)
if err != nil {
return Application{}, err
}
a.Addresses = addrs
return a, nil
}
// ListApplicationsByDomain returns a domain's applications ordered by login,
// each with its address list populated (spec 7.2.6).
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
rows, err := s.db.Query(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login",
domainID)
if err != nil {
return nil, fmt.Errorf("list applications: %w", err)
}
defer rows.Close()
var out []Application
for rows.Next() {
a, err := scanApplication(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Fill address lists after the first query is drained (MaxOpenConns is 1).
for i := range out {
addrs, err := s.applicationAddresses(out[i].ID)
if err != nil {
return nil, err
}
out[i].Addresses = addrs
}
return out, nil
}
// ListApplicationLogins returns every application login across all domains,
// ordered, for the send-log monitoring screen's filter dropdown (spec 7.2).
// Logins are globally unique (spec 5.1), so no domain qualifier is needed.
func (s *Store) ListApplicationLogins() ([]string, error) {
rows, err := s.db.Query("SELECT login FROM applications ORDER BY login")
if err != nil {
return nil, fmt.Errorf("list application logins: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var login string
if err := rows.Scan(&login); err != nil {
return nil, err
}
out = append(out, login)
}
return out, rows.Err()
}
// ListLoginsByDomain returns the SASL logins of a domain's applications. Used to
// purge sasldb2 entries before a domain (and its applications via cascade) is
// deleted, while the logins are still known (spec 7.2.4).
func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) {
rows, err := s.db.Query("SELECT login FROM applications WHERE domain_id = ? ORDER BY login", domainID)
if err != nil {
return nil, fmt.Errorf("list logins: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var login string
if err := rows.Scan(&login); err != nil {
return nil, err
}
out = append(out, login)
}
return out, rows.Err()
}
// ListBindings returns every sender-address → login pair across all domains, the
// raw material for the smtpd_sender_login_maps file (spec 5.1). Wildcard
// applications yield a single "@domain" binding; list applications yield one
// binding per address. Ordered deterministically so the generated map is stable.
func (s *Store) ListBindings() ([]Binding, error) {
rows, err := s.db.Query(`
SELECT '@' || d.name, a.login
FROM applications a
JOIN domains d ON d.id = a.domain_id
WHERE a.address_mode = 'wildcard'
UNION ALL
SELECT aa.address, a.login
FROM application_addresses aa
JOIN applications a ON a.id = aa.application_id
WHERE a.address_mode = 'list'
ORDER BY 1, 2`)
if err != nil {
return nil, fmt.Errorf("list bindings: %w", err)
}
defer rows.Close()
var out []Binding
for rows.Next() {
var b Binding
if err := rows.Scan(&b.Address, &b.Login); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// DeleteApplication removes an application and its addresses (via cascade),
// returning the deleted application so the caller can drop its sasldb2 entry
// (spec 7.2.8). Returns ErrApplicationNotFound if no such row existed.
func (s *Store) DeleteApplication(id int64) (Application, error) {
a, err := s.GetApplication(id)
if err != nil {
return Application{}, err
}
res, err := s.db.Exec("DELETE FROM applications WHERE id = ?", id)
if err != nil {
return Application{}, fmt.Errorf("delete application: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return Application{}, fmt.Errorf("delete application rows: %w", err)
}
if n == 0 {
return Application{}, ErrApplicationNotFound
}
return a, nil
}
func (s *Store) applicationAddresses(appID int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT address FROM application_addresses WHERE application_id = ? ORDER BY address", appID)
if err != nil {
return nil, fmt.Errorf("application addresses: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func scanApplication(r scanRow) (Application, error) {
var (
a Application
createdAt string
)
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt); err != nil {
return Application{}, err
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return a, nil
}
+189
View File
@@ -0,0 +1,189 @@
package store
import (
"errors"
"testing"
)
func addTestDomain(t *testing.T, st *Store, name string) Domain {
t.Helper()
d, err := st.AddDomain(name, "selfpost")
if err != nil {
t.Fatalf("AddDomain(%q): %v", name, err)
}
return d
}
func TestAddApplicationWildcard(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "alerts", AddressModeWildcard, nil)
if err != nil {
t.Fatalf("AddApplication: %v", err)
}
if a.ID == 0 || a.Login != "alerts" || a.AddressMode != AddressModeWildcard {
t.Fatalf("unexpected application: %+v", a)
}
if len(a.Addresses) != 0 {
t.Errorf("wildcard app should have no addresses, got %v", a.Addresses)
}
got, err := st.GetApplication(a.ID)
if err != nil {
t.Fatalf("GetApplication: %v", err)
}
if got.Login != "alerts" || len(got.Addresses) != 0 {
t.Fatalf("round-trip mismatch: %+v", got)
}
}
func TestAddApplicationListStoresAddresses(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
addrs := []string{"noreply@example.com", "alerts@example.com"}
a, err := st.AddApplication(d.ID, "app1", AddressModeList, addrs)
if err != nil {
t.Fatalf("AddApplication: %v", err)
}
got, err := st.GetApplication(a.ID)
if err != nil {
t.Fatal(err)
}
// Addresses come back sorted.
if len(got.Addresses) != 2 || got.Addresses[0] != "alerts@example.com" || got.Addresses[1] != "noreply@example.com" {
t.Fatalf("addresses = %v", got.Addresses)
}
}
func TestAddApplicationDuplicateLogin(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
d2 := addTestDomain(t, st, "other.com")
if _, err := st.AddApplication(d.ID, "shared", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
// Same login under a different domain must still collide (global uniqueness).
_, err := st.AddApplication(d2.ID, "shared", AddressModeWildcard, nil)
if !errors.Is(err, ErrLoginExists) {
t.Fatalf("duplicate login error = %v, want ErrLoginExists", err)
}
}
func TestUpdateApplicationMode(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"})
if err != nil {
t.Fatal(err)
}
// list -> wildcard drops the addresses.
if err := st.UpdateApplicationMode(a.ID, AddressModeWildcard, nil); err != nil {
t.Fatalf("UpdateApplicationMode: %v", err)
}
got, _ := st.GetApplication(a.ID)
if got.AddressMode != AddressModeWildcard || len(got.Addresses) != 0 {
t.Fatalf("after wildcard switch: %+v", got)
}
// wildcard -> list adds a fresh set.
if err := st.UpdateApplicationMode(a.ID, AddressModeList, []string{"b@example.com", "c@example.com"}); err != nil {
t.Fatal(err)
}
got, _ = st.GetApplication(a.ID)
if got.AddressMode != AddressModeList || len(got.Addresses) != 2 {
t.Fatalf("after list switch: %+v", got)
}
}
func TestUpdateApplicationModeNotFound(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateApplicationMode(999, AddressModeWildcard, nil); !errors.Is(err, ErrApplicationNotFound) {
t.Fatalf("UpdateApplicationMode(missing) = %v, want ErrApplicationNotFound", err)
}
}
func TestDeleteApplication(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"})
if err != nil {
t.Fatal(err)
}
deleted, err := st.DeleteApplication(a.ID)
if err != nil {
t.Fatalf("DeleteApplication: %v", err)
}
if deleted.Login != "app1" {
t.Errorf("deleted login = %q, want app1", deleted.Login)
}
assertCount(t, st, "applications", 0)
assertCount(t, st, "application_addresses", 0)
if _, err := st.DeleteApplication(a.ID); !errors.Is(err, ErrApplicationNotFound) {
t.Fatalf("second delete = %v, want ErrApplicationNotFound", err)
}
}
func TestListBindingsMixedModes(t *testing.T) {
st := openTestStore(t)
d1 := addTestDomain(t, st, "example.com")
d2 := addTestDomain(t, st, "other.com")
if _, err := st.AddApplication(d1.ID, "wild", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(d1.ID, "listed", AddressModeList,
[]string{"alerts@example.com", "noreply@example.com"}); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(d2.ID, "wild2", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
bindings, err := st.ListBindings()
if err != nil {
t.Fatalf("ListBindings: %v", err)
}
want := []Binding{
{"@example.com", "wild"},
{"@other.com", "wild2"},
{"alerts@example.com", "listed"},
{"noreply@example.com", "listed"},
}
if len(bindings) != len(want) {
t.Fatalf("bindings = %+v, want %+v", bindings, want)
}
for i := range want {
if bindings[i] != want[i] {
t.Errorf("binding[%d] = %+v, want %+v", i, bindings[i], want[i])
}
}
}
func TestListLoginsByDomain(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
other := addTestDomain(t, st, "other.com")
if _, err := st.AddApplication(d.ID, "a", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(d.ID, "b", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(other.ID, "c", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
logins, err := st.ListLoginsByDomain(d.ID)
if err != nil {
t.Fatal(err)
}
if len(logins) != 2 || logins[0] != "a" || logins[1] != "b" {
t.Fatalf("logins = %v, want [a b]", logins)
}
}
+138
View File
@@ -0,0 +1,138 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// ErrDomainExists is returned by AddDomain when the domain is already managed.
var ErrDomainExists = errors.New("domain already exists")
// ErrDomainNotFound is returned when a domain id/name does not exist.
var ErrDomainNotFound = errors.New("domain not found")
// Domain is a sending domain managed through the panel (spec 4.1). The DKIM key
// material itself lives on disk under /data; this row records the selector and
// metadata. AppCount is populated by the listing queries, not stored.
type Domain struct {
ID int64
Name string
DKIMSelector string
CreatedAt time.Time
AppCount int
}
// AddDomain inserts a new sending domain. The caller is responsible for having
// validated name (spec 7.6.2) before it reaches SQL; the query is parameterised
// regardless. A duplicate name maps to ErrDomainExists.
func (s *Store) AddDomain(name, selector string) (Domain, error) {
now := time.Now().UTC()
res, err := s.db.Exec(
"INSERT INTO domains (name, dkim_selector, created_at) VALUES (?, ?, ?)",
name, selector, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return Domain{}, ErrDomainExists
}
return Domain{}, fmt.Errorf("insert domain: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return Domain{}, fmt.Errorf("domain id: %w", err)
}
return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil
}
// ListDomains returns every domain with its bound-application count (spec 7.2.2),
// ordered by name.
func (s *Store) ListDomains() ([]Domain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d
ORDER BY d.name`)
if err != nil {
return nil, fmt.Errorf("list domains: %w", err)
}
defer rows.Close()
var out []Domain
for rows.Next() {
d, err := scanDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// GetDomain returns a single domain (with its application count) by id, or
// ErrDomainNotFound.
func (s *Store) GetDomain(id int64) (Domain, error) {
row := s.db.QueryRow(`
SELECT d.id, d.name, d.dkim_selector, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d
WHERE d.id = ?`, id)
d, err := scanDomain(row)
if errors.Is(err, sql.ErrNoRows) {
return Domain{}, ErrDomainNotFound
}
if err != nil {
return Domain{}, err
}
return d, nil
}
// DeleteDomain removes a domain. Its applications and their address/binding rows
// go with it via ON DELETE CASCADE (spec 7.2.4). Returns ErrDomainNotFound if no
// such row existed.
func (s *Store) DeleteDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete domain: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete domain rows: %w", err)
}
if n == 0 {
return ErrDomainNotFound
}
return nil
}
// scanRow is the minimal surface shared by *sql.Row and *sql.Rows.
type scanRow interface {
Scan(dest ...any) error
}
func scanDomain(r scanRow) (Domain, error) {
var (
d Domain
createdAt string
)
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil {
return Domain{}, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return d, nil
}
// isUniqueViolation reports whether err is a SQLite UNIQUE/PRIMARY-KEY conflict,
// so callers can turn a duplicate insert into a friendly domain-level error.
func isUniqueViolation(err error) bool {
var se *sqlite.Error
if errors.As(err, &se) {
code := se.Code()
return code == sqlite3.SQLITE_CONSTRAINT_UNIQUE || code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY
}
return false
}
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"errors"
"path/filepath"
"testing"
"time"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestAddAndListDomains(t *testing.T) {
st := openTestStore(t)
d, err := st.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
if d.ID == 0 || d.Name != "example.com" || d.DKIMSelector != "selfpost" {
t.Fatalf("unexpected domain: %+v", d)
}
list, err := st.ListDomains()
if err != nil {
t.Fatalf("ListDomains: %v", err)
}
if len(list) != 1 || list[0].Name != "example.com" || list[0].AppCount != 0 {
t.Fatalf("unexpected list: %+v", list)
}
}
func TestAddDomainDuplicate(t *testing.T) {
st := openTestStore(t)
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
_, err := st.AddDomain("example.com", "other")
if !errors.Is(err, ErrDomainExists) {
t.Fatalf("duplicate AddDomain error = %v, want ErrDomainExists", err)
}
}
func TestGetDomainNotFound(t *testing.T) {
st := openTestStore(t)
if _, err := st.GetDomain(999); !errors.Is(err, ErrDomainNotFound) {
t.Fatalf("GetDomain(missing) = %v, want ErrDomainNotFound", err)
}
}
func TestDeleteDomainCascadesApplications(t *testing.T) {
st := openTestStore(t)
d, err := st.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatal(err)
}
// Insert an application + address directly (the AddApplication API lands in
// Phase 4); this verifies the ON DELETE CASCADE wiring now.
now := time.Now().UTC().Format(time.RFC3339)
res, err := st.db.Exec(
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, 'wildcard', ?)",
d.ID, "app1", now)
if err != nil {
t.Fatal(err)
}
appID, _ := res.LastInsertId()
if _, err := st.db.Exec(
"INSERT INTO application_addresses (application_id, address) VALUES (?, ?)",
appID, "noreply@example.com"); err != nil {
t.Fatal(err)
}
got, err := st.GetDomain(d.ID)
if err != nil {
t.Fatal(err)
}
if got.AppCount != 1 {
t.Fatalf("AppCount = %d, want 1", got.AppCount)
}
if err := st.DeleteDomain(d.ID); err != nil {
t.Fatalf("DeleteDomain: %v", err)
}
assertCount(t, st, "applications", 0)
assertCount(t, st, "application_addresses", 0)
if _, err := st.GetDomain(d.ID); !errors.Is(err, ErrDomainNotFound) {
t.Errorf("domain still present after delete: %v", err)
}
}
func TestDeleteDomainNotFound(t *testing.T) {
st := openTestStore(t)
if err := st.DeleteDomain(123); !errors.Is(err, ErrDomainNotFound) {
t.Fatalf("DeleteDomain(missing) = %v, want ErrDomainNotFound", err)
}
}
func assertCount(t *testing.T, st *Store, table string, want int) {
t.Helper()
var n int
// table is a trusted literal from the test, not user input.
if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
if n != want {
t.Errorf("%s count = %d, want %d", table, n, want)
}
}
+76
View File
@@ -0,0 +1,76 @@
-- Initial SelfPost schema (spec 9). One SQLite file under /data holds the whole
-- panel state so a single directory backup/restore is sufficient (spec 7.5.A).
-- Single administrator account (spec 7.6.1). Exactly one row is allowed; the
-- presence of that row is what marks primary setup as complete, which is why
-- the /setup route disappears once it exists.
CREATE TABLE admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Free-form key/value panel settings (retention overrides, misc flags).
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Sending domains managed through the panel (spec 4.1). DKIM keys themselves
-- live on disk under /data; this row records the selector and metadata.
CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
dkim_selector TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Applications bound to a domain (spec 4.1). address_mode is either the domain
-- wildcard or an explicit address list; the SASL login is globally unique.
CREATE TABLE applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
login TEXT NOT NULL UNIQUE,
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
created_at TEXT NOT NULL
);
-- Explicit sender addresses for applications in 'list' mode. Each address must
-- belong to the application's domain (validated in the panel, spec 7.6.2).
CREATE TABLE application_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
address TEXT NOT NULL,
UNIQUE (application_id, address)
);
-- Structured send log (spec 7.3). One row per (queue-id, recipient); the
-- log-tailer advances status from queued to a final state.
CREATE TABLE send_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
queue_id TEXT,
domain TEXT,
app_login TEXT,
from_addr TEXT,
to_addr TEXT,
subject TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
CREATE INDEX idx_send_log_domain ON send_log (domain);
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
-- Differentiated rate limits per domain/application (spec 7.4). Both the IP
-- binding and the message limit are optional.
CREATE TABLE rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
ref_id INTEGER NOT NULL,
allowed_ips TEXT,
max_messages INTEGER,
window_seconds INTEGER,
UNIQUE (scope, ref_id)
);
@@ -0,0 +1,12 @@
-- Panel login sessions (spec 7.6.6, plan B.1). Persisted so a login survives a
-- container restart or redeploy; only the SHA-256 of the session token is
-- stored, never the token itself, so a stolen database file cannot be used to
-- sign in. expires_at implements the sliding idle timeout: it is pushed
-- forward on activity rather than being fixed at creation time.
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
+214
View File
@@ -0,0 +1,214 @@
package store
import (
"database/sql"
"errors"
"fmt"
"net"
"strings"
"time"
)
// Rate-limit scopes (spec 7.4). A level-2 limit is attached either to a domain
// (counted across all its applications and IPs) or to a single application.
const (
RateLimitScopeDomain = "domain"
RateLimitScopeApp = "application"
)
// RateLimit is a differentiated level-2 rate limit (spec 7.4): an optional set
// of expected client IPs plus a message ceiling over a sliding window, attached
// to a domain or an application. It is enforced in the journal-milter; level 1
// (Postfix anvil, spec 5) is the IP backstop that always applies even when this
// is absent or the milter is down.
//
// Both the IP binding and the ceiling are optional in the schema, but a limit is
// only enforced when it is Active(): the design deliberately allows an admin to
// leave the IP binding empty for apps that send from changing IPs, in which case
// only level 1 protects them (spec 7.4's caveat).
type RateLimit struct {
Scope string
RefID int64
AllowedIPs []string // canonical client IPs this limit applies to
MaxMessages int
WindowSeconds int
}
// Active reports whether the limit is fully configured and should be enforced.
// A missing IP binding, ceiling or window leaves the differentiated limit inert
// (spec 7.4): the IP binding is what scopes the limit to a known sender.
func (r RateLimit) Active() bool {
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
}
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
// comparison parses both sides so equivalent textual forms of the same address
// match; a client IP outside the list means the differentiated limit does not
// apply to it (level 1 still does).
func (r RateLimit) AllowsIP(ip string) bool {
c := net.ParseIP(ip)
if c == nil {
return false
}
for _, a := range r.AllowedIPs {
if p := net.ParseIP(a); p != nil && p.Equal(c) {
return true
}
}
return false
}
// GetRateLimit loads the level-2 limit configured for a domain or application by
// its id, for the panel's edit form. ok is false when none is configured.
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
row := s.db.QueryRow(
`SELECT allowed_ips, max_messages, window_seconds
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
rl, err := scanRateLimit(row)
if errors.Is(err, sql.ErrNoRows) {
return RateLimit{Scope: scope, RefID: refID}, false, nil
}
if err != nil {
return RateLimit{}, false, fmt.Errorf("get rate limit: %w", err)
}
rl.Scope, rl.RefID = scope, refID
return rl, true, nil
}
// SetRateLimit upserts the level-2 limit for a domain or application. The caller
// (panel) has already validated the IPs and numbers (spec 7.6.2); values are
// stored via bound parameters and read back live by the milter.
func (s *Store) SetRateLimit(rl RateLimit) error {
_, err := s.db.Exec(
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scope, ref_id) DO UPDATE SET
allowed_ips = excluded.allowed_ips,
max_messages = excluded.max_messages,
window_seconds = excluded.window_seconds`,
rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds,
)
if err != nil {
return fmt.Errorf("set rate limit: %w", err)
}
return nil
}
// DeleteRateLimit removes the level-2 limit for a domain or application, so the
// admin can clear it and fall back to level 1 only.
func (s *Store) DeleteRateLimit(scope string, refID int64) error {
if _, err := s.db.Exec(`DELETE FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID); err != nil {
return fmt.Errorf("delete rate limit: %w", err)
}
return nil
}
// DeleteRateLimitsForDomain removes the domain's own limit and the limits of all
// its applications in one statement. It is called on domain deletion, before the
// application rows are cascade-deleted, to avoid leaving orphan limit rows.
func (s *Store) DeleteRateLimitsForDomain(domainID int64) error {
_, err := s.db.Exec(
`DELETE FROM rate_limits
WHERE (scope = 'domain' AND ref_id = ?)
OR (scope = 'application' AND ref_id IN (SELECT id FROM applications WHERE domain_id = ?))`,
domainID, domainID,
)
if err != nil {
return fmt.Errorf("delete domain rate limits: %w", err)
}
return nil
}
// RateLimit resolves the level-2 limit that applies to a message, keyed by the
// human-readable reference the milter has on the receive path: the sending
// domain name (scope "domain") or the SASL login (scope "application"). ok is
// false when no limit is configured for that reference.
func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
var query string
switch scope {
case RateLimitScopeDomain:
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
WHERE rl.scope = 'domain' AND d.name = ?`
case RateLimitScopeApp:
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
WHERE rl.scope = 'application' AND a.login = ?`
default:
return RateLimit{}, false, fmt.Errorf("unknown rate-limit scope %q", scope)
}
rl, err := scanRateLimit(s.db.QueryRow(query, ref))
if errors.Is(err, sql.ErrNoRows) {
return RateLimit{}, false, nil
}
if err != nil {
return RateLimit{}, false, fmt.Errorf("rate limit for %s %q: %w", scope, ref, err)
}
rl.Scope = scope
return rl, true, nil
}
// CountMessages returns how many distinct messages the reference (a domain name
// or an application login) has queued since t, for the level-2 sliding window
// (spec 7.4). It counts distinct queue-ids — one message with many recipients is
// one message, matching level 1's per-message semantics — and excludes rows that
// were themselves rejected by a limit (they were never sent). It reuses the send
// log the journal already writes (spec 7.4: "переиспользует данные журнала").
func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) {
var column string
switch scope {
case RateLimitScopeDomain:
column = "domain"
case RateLimitScopeApp:
column = "app_login"
default:
return 0, fmt.Errorf("unknown rate-limit scope %q", scope)
}
var n int64
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
// same format is chronologically correct (as in DeleteSendLogBefore).
err := s.db.QueryRow(
`SELECT COUNT(DISTINCT queue_id) FROM send_log
WHERE `+column+` = ? AND status != ? AND created_at >= ?`,
ref, StatusRejected, since.UTC().Format(time.RFC3339),
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count messages for %s %q: %w", scope, ref, err)
}
return n, nil
}
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
// (an IP-only draft) by leaving the corresponding field zero, which makes the
// limit inert via Active().
func scanRateLimit(r scanRow) (RateLimit, error) {
var (
ips sql.NullString
maxMsgs sql.NullInt64
windowSecs sql.NullInt64
)
if err := r.Scan(&ips, &maxMsgs, &windowSecs); err != nil {
return RateLimit{}, err
}
return RateLimit{
AllowedIPs: splitIPs(ips.String),
MaxMessages: int(maxMsgs.Int64),
WindowSeconds: int(windowSecs.Int64),
}, nil
}
// splitIPs parses the comma-separated storage form back into a slice, dropping
// empties so an empty column yields nil (an inactive limit).
func splitIPs(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+178
View File
@@ -0,0 +1,178 @@
package store
import (
"testing"
"time"
)
func TestRateLimitSetGetDelete(t *testing.T) {
st := openTestStore(t)
d, err := st.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
if _, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID); err != nil || ok {
t.Fatalf("GetRateLimit on empty: ok=%v err=%v", ok, err)
}
want := RateLimit{
Scope: RateLimitScopeDomain,
RefID: d.ID,
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
MaxMessages: 100,
WindowSeconds: 3600,
}
if err := st.SetRateLimit(want); err != nil {
t.Fatalf("SetRateLimit: %v", err)
}
got, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID)
if err != nil || !ok {
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
}
if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 ||
got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" {
t.Fatalf("roundtrip mismatch: %+v", got)
}
// Upsert replaces in place (UNIQUE(scope, ref_id)).
want.MaxMessages = 5
want.AllowedIPs = []string{"198.51.100.9"}
if err := st.SetRateLimit(want); err != nil {
t.Fatalf("SetRateLimit upsert: %v", err)
}
got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID)
if got.MaxMessages != 5 || len(got.AllowedIPs) != 1 || got.AllowedIPs[0] != "198.51.100.9" {
t.Fatalf("upsert did not replace: %+v", got)
}
if err := st.DeleteRateLimit(RateLimitScopeDomain, d.ID); err != nil {
t.Fatalf("DeleteRateLimit: %v", err)
}
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
t.Fatalf("limit still present after delete")
}
}
func TestRateLimitByNameAndLogin(t *testing.T) {
st := openTestStore(t)
d, _ := st.AddDomain("example.com", "selfpost")
a, err := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
if err != nil {
t.Fatalf("AddApplication: %v", err)
}
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}); err != nil {
t.Fatalf("set domain limit: %v", err)
}
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}); err != nil {
t.Fatalf("set app limit: %v", err)
}
// The milter resolves limits by domain name and by SASL login.
rl, ok, err := st.RateLimit(RateLimitScopeDomain, "example.com")
if err != nil || !ok || rl.MaxMessages != 10 {
t.Fatalf("RateLimit domain: ok=%v err=%v rl=%+v", ok, err, rl)
}
rl, ok, err = st.RateLimit(RateLimitScopeApp, "app1")
if err != nil || !ok || rl.MaxMessages != 3 {
t.Fatalf("RateLimit app: ok=%v err=%v rl=%+v", ok, err, rl)
}
if _, ok, _ := st.RateLimit(RateLimitScopeDomain, "unknown.example"); ok {
t.Fatalf("RateLimit for unknown domain should be not-ok")
}
}
func TestCountMessagesDistinctAndWindowed(t *testing.T) {
st := openTestStore(t)
// Two recipients share a queue-id → one message. A second message → two.
for _, to := range []string{"a@x.net", "b@x.net"} {
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", Domain: "example.com", AppLogin: "app1", To: to}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
}
if err := st.InsertQueued(SendLogEntry{QueueID: "Q2", Domain: "example.com", AppLogin: "app1", To: "c@x.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
// A rejected row must not count toward the window.
if err := st.InsertRejected(SendLogEntry{Domain: "example.com", AppLogin: "app1", From: "s@example.com"}); err != nil {
t.Fatalf("InsertRejected: %v", err)
}
n, err := st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("CountMessages: %v", err)
}
if n != 2 {
t.Fatalf("distinct-message count = %d, want 2 (two queue-ids, rejected excluded)", n)
}
n, _ = st.CountMessages(RateLimitScopeApp, "app1", time.Now().Add(-time.Hour))
if n != 2 {
t.Fatalf("app count = %d, want 2", n)
}
// Backdate Q1 beyond the window: only Q2 remains inside a 30-minute window.
old := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
if _, err := st.db.Exec(`UPDATE send_log SET created_at = ? WHERE queue_id = 'Q1'`, old); err != nil {
t.Fatalf("backdate: %v", err)
}
n, _ = st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-30*time.Minute))
if n != 1 {
t.Fatalf("windowed count = %d, want 1 (Q1 aged out)", n)
}
}
func TestDeleteRateLimitsForDomain(t *testing.T) {
st := openTestStore(t)
d, _ := st.AddDomain("example.com", "selfpost")
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
other, _ := st.AddDomain("other.example", "selfpost")
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60})
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60})
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, AllowedIPs: []string{"203.0.113.9"}, MaxMessages: 1, WindowSeconds: 60})
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
}
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
t.Fatalf("domain limit survived")
}
if _, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID); ok {
t.Fatalf("application limit survived")
}
// The unrelated domain's limit is untouched.
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, other.ID); !ok {
t.Fatalf("unrelated domain limit was deleted")
}
}
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
inactive := []RateLimit{
{},
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
}
for i, rl := range inactive {
if rl.Active() {
t.Fatalf("case %d: %+v should be inactive", i, rl)
}
}
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
if !active.Active() {
t.Fatalf("should be active: %+v", active)
}
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
t.Fatalf("registered IPs should match")
}
// Equivalent textual form of the IPv6 address must still match.
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatalf("expanded IPv6 form should match")
}
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
t.Fatalf("unregistered/invalid IPs must not match")
}
}
+188
View File
@@ -0,0 +1,188 @@
package store
import (
"fmt"
"strings"
"time"
)
// Send-log status values (spec 7.3). "queued" is written by the journal-milter
// when a message is accepted; the log-tailer advances it to one of the final
// states as Postfix reports delivery per recipient.
const (
StatusQueued = "queued"
StatusSent = "sent"
StatusDeferred = "deferred"
StatusBounced = "bounced"
// StatusRejected marks a message the journal-milter refused with a 4xx under
// a level-2 rate limit (spec 7.4). Such a row never gets a queue-id and is
// excluded from the level-2 message count (it was never sent).
StatusRejected = "rejected"
)
// SendLogEntry is a single queued send-log row. The journal-milter creates one
// per (queue-id, recipient) pair at end-of-message (spec 7.3.3); every field
// except the status/timestamps comes from the accepted message.
type SendLogEntry struct {
QueueID string
Domain string
AppLogin string
From string
To string
Subject string
}
// InsertQueued records an accepted message in the send log with status
// "queued". It is called from the journal-milter hot path, so it returns any
// error for the caller to log rather than deciding policy here; the milter must
// stay fail-open regardless (spec 7.3).
func (s *Store) InsertQueued(e SendLogEntry) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO send_log
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusQueued, now, now,
)
if err != nil {
return fmt.Errorf("insert send_log: %w", err)
}
return nil
}
// InsertRejected records a message the journal-milter refused under a level-2
// rate limit (spec 7.4), so the rejection is visible in the send-log UI. Only
// the fields known at MAIL FROM are set (domain, sender, app login); there is no
// queue-id or recipient because the message was rejected before it was queued.
func (s *Store) InsertRejected(e SendLogEntry) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO send_log
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusRejected, now, now,
)
if err != nil {
return fmt.Errorf("insert rejected send_log: %w", err)
}
return nil
}
// UpdateStatus advances the delivery status of the send-log rows matching a
// (queue-id, recipient) pair, which the log-tailer parses out of mail.log.
// Recipient matching is case-insensitive because Postfix may normalise address
// case between the milter (envelope) and the delivery log. It returns the
// number of rows updated so the caller can tell whether the line matched a
// journal entry.
func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
`UPDATE send_log SET status = ?, updated_at = ?
WHERE queue_id = ? AND to_addr = ? COLLATE NOCASE`,
status, now, queueID, recipient,
)
if err != nil {
return 0, fmt.Errorf("update send_log status: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a
// SendLogEntry plus the fields that only exist once a row has been written
// (id, current status, timestamps).
type SendLogRow struct {
ID int64
QueueID string
Domain string
AppLogin string
From string
To string
Subject string
Status string
CreatedAt time.Time
}
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
// application login. An empty field matches everything.
type SendLogFilter struct {
Domain string
AppLogin string
}
// QuerySendLog returns send-log rows matching filter, newest first, for the
// monitoring screen's server-side pagination (spec 7.2's send-log view).
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
where, args := sendLogWhere(filter)
args = append(args, limit, offset)
rows, err := s.db.Query(
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at
FROM send_log`+where+`
ORDER BY id DESC
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, fmt.Errorf("query send_log: %w", err)
}
defer rows.Close()
var out []SendLogRow
for rows.Next() {
var (
row SendLogRow
createdAt string
)
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
&row.From, &row.To, &row.Subject, &row.Status, &createdAt); err != nil {
return nil, fmt.Errorf("scan send_log row: %w", err)
}
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
out = append(out, row)
}
return out, rows.Err()
}
// CountSendLog returns how many send-log rows match filter, so the monitoring
// screen can render page numbers/next-prev links.
func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
where, args := sendLogWhere(filter)
var n int64
if err := s.db.QueryRow(`SELECT COUNT(*) FROM send_log`+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("count send_log: %w", err)
}
return n, nil
}
func sendLogWhere(f SendLogFilter) (string, []any) {
var clauses []string
var args []any
if f.Domain != "" {
clauses = append(clauses, "domain = ?")
args = append(args, f.Domain)
}
if f.AppLogin != "" {
clauses = append(clauses, "app_login = ?")
args = append(args, f.AppLogin)
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// DeleteSendLogBefore removes send-log rows created before cutoff, implementing
// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It
// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a
// lexical comparison against the same format is chronologically correct.
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM send_log WHERE created_at < ?`,
cutoff.UTC().Format(time.RFC3339),
)
if err != nil {
return 0, fmt.Errorf("prune send_log: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+140
View File
@@ -0,0 +1,140 @@
package store
import (
"testing"
"time"
)
// readSendLog returns every send_log row ordered by id. Phase 6 has no read
// query yet (the monitoring UI is Phase 7), so tests read the table directly.
type sendLogRow struct {
QueueID string
Domain string
AppLogin string
From string
To string
Subject string
Status string
}
func readSendLog(t *testing.T, s *Store) []sendLogRow {
t.Helper()
rows, err := s.db.Query(
`SELECT queue_id, domain, app_login, from_addr, to_addr, subject, status
FROM send_log ORDER BY id`)
if err != nil {
t.Fatalf("query send_log: %v", err)
}
defer rows.Close()
var out []sendLogRow
for rows.Next() {
var r sendLogRow
if err := rows.Scan(&r.QueueID, &r.Domain, &r.AppLogin, &r.From, &r.To, &r.Subject, &r.Status); err != nil {
t.Fatalf("scan: %v", err)
}
out = append(out, r)
}
return out
}
func TestInsertQueuedAndUpdateStatus(t *testing.T) {
st := openTestStore(t)
// Two recipients on the same queue-id → two independent rows (spec 7.3.3).
for _, to := range []string{"a@example.net", "b@example.net"} {
if err := st.InsertQueued(SendLogEntry{
QueueID: "ABC123",
Domain: "example.com",
AppLogin: "app1",
From: "noreply@example.com",
To: to,
Subject: "Hello",
}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
}
rows := readSendLog(t, st)
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d: %+v", len(rows), rows)
}
for _, r := range rows {
if r.Status != StatusQueued {
t.Fatalf("new row should be queued, got %q", r.Status)
}
}
// One recipient goes to sent; the other stays queued.
n, err := st.UpdateStatus("ABC123", "a@example.net", StatusSent)
if err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
if n != 1 {
t.Fatalf("want 1 row updated, got %d", n)
}
rows = readSendLog(t, st)
if rows[0].Status != StatusSent || rows[1].Status != StatusQueued {
t.Fatalf("unexpected statuses: %+v", rows)
}
}
func TestUpdateStatusRecipientCaseInsensitive(t *testing.T) {
st := openTestStore(t)
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "User@Example.NET"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
// mail.log may report a differently-cased recipient; matching must still hit.
n, err := st.UpdateStatus("Q1", "user@example.net", StatusBounced)
if err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
if n != 1 {
t.Fatalf("case-insensitive match failed, updated %d rows", n)
}
}
func TestUpdateStatusNoMatch(t *testing.T) {
st := openTestStore(t)
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "a@example.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
// A queue-id/recipient the milter never recorded must be a no-op, not an error.
n, err := st.UpdateStatus("Q1", "unknown@example.net", StatusSent)
if err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
if n != 0 {
t.Fatalf("want 0 rows updated, got %d", n)
}
}
func TestDeleteSendLogBefore(t *testing.T) {
st := openTestStore(t)
// Insert one row, then backdate it beyond the retention window by rewriting
// created_at directly (InsertQueued always stamps "now").
if err := st.InsertQueued(SendLogEntry{QueueID: "OLD", To: "a@example.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
old := time.Now().UTC().AddDate(0, 0, -100).Format(time.RFC3339)
if _, err := st.db.Exec(`UPDATE send_log SET created_at = ? WHERE queue_id = 'OLD'`, old); err != nil {
t.Fatalf("backdate: %v", err)
}
if err := st.InsertQueued(SendLogEntry{QueueID: "NEW", To: "b@example.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
cutoff := time.Now().UTC().AddDate(0, 0, -90)
n, err := st.DeleteSendLogBefore(cutoff)
if err != nil {
t.Fatalf("DeleteSendLogBefore: %v", err)
}
if n != 1 {
t.Fatalf("want 1 row pruned, got %d", n)
}
rows := readSendLog(t, st)
if len(rows) != 1 || rows[0].QueueID != "NEW" {
t.Fatalf("retention kept wrong rows: %+v", rows)
}
}
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// SessionRow is a persisted login session, keyed by the SHA-256 of its token
// (see internal/web, which owns the token itself).
type SessionRow struct {
Username string
ExpiresAt time.Time
}
// CreateSession inserts a new session row.
func (s *Store) CreateSession(tokenHash, username string, expiresAt time.Time) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO sessions (token_hash, username, created_at, expires_at) VALUES (?, ?, ?, ?)`,
tokenHash, username, now, expiresAt.UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("insert session: %w", err)
}
return nil
}
// LookupSession returns the session for tokenHash, if any. It does not check
// expiry itself — callers compare ExpiresAt against time.Now() and call
// DeleteSession on an expired row, keeping the read side lock-free.
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
var (
row SessionRow
expiresAt string
)
err := s.db.QueryRow(
`SELECT username, expires_at FROM sessions WHERE token_hash = ?`,
tokenHash,
).Scan(&row.Username, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return SessionRow{}, false, nil
}
if err != nil {
return SessionRow{}, false, fmt.Errorf("lookup session: %w", err)
}
row.ExpiresAt, err = time.Parse(time.RFC3339, expiresAt)
if err != nil {
return SessionRow{}, false, fmt.Errorf("parse session expiry: %w", err)
}
return row, true, nil
}
// RenewSession pushes a session's expiry forward, implementing the sliding
// idle timeout.
func (s *Store) RenewSession(tokenHash string, expiresAt time.Time) error {
_, err := s.db.Exec(
`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`,
expiresAt.UTC().Format(time.RFC3339), tokenHash,
)
if err != nil {
return fmt.Errorf("renew session: %w", err)
}
return nil
}
// RenameSession updates the username carried by a session, keeping its
// expiry, so a session stays usable after the administrator renames their own
// account.
func (s *Store) RenameSession(tokenHash, username string) error {
_, err := s.db.Exec(
`UPDATE sessions SET username = ? WHERE token_hash = ?`,
username, tokenHash,
)
if err != nil {
return fmt.Errorf("rename session: %w", err)
}
return nil
}
// DeleteSession removes a session row (logout, or a lookup finding it
// expired).
func (s *Store) DeleteSession(tokenHash string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// DeleteOtherSessions removes every session except keepHash. It is called
// when the administrator changes their password: a stolen cookie issued
// under the old password must stop working, while the session performing the
// change stays signed in.
func (s *Store) DeleteOtherSessions(keepHash string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash != ?`, keepHash); err != nil {
return fmt.Errorf("delete other sessions: %w", err)
}
return nil
}
// DeleteExpiredSessions prunes rows whose expiry has already passed, so an
// abandoned session (cookie never presented again) does not sit in the table
// forever. It piggybacks on session creation rather than running as its own
// background loop.
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
if err != nil {
return 0, fmt.Errorf("prune sessions: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+100
View File
@@ -0,0 +1,100 @@
// Package store owns the SelfPost SQLite database: the single file under /data
// that persists the administrator account, sending domains and applications,
// the send log and rate-limit settings (spec 9). It exposes typed queries so
// the rest of the panel never builds SQL by hand.
package store
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo), keeps the static build
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Store wraps the database connection pool.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path, enables WAL and
// foreign keys, and applies any pending migrations. The caller owns Close.
func Open(path string) (*Store, error) {
// _pragma parameters are applied on every pooled connection by the driver,
// so foreign-key enforcement and WAL survive connection churn.
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// modernc's driver serializes writes anyway; a small pool avoids
// "database is locked" surprises under WAL.
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close closes the underlying database.
func (s *Store) Close() error {
return s.db.Close()
}
// migrate applies embedded migrations in filename order, tracking progress via
// SQLite's PRAGMA user_version so each migration runs at most once.
func (s *Store) migrate() error {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
var version int
if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read schema version: %w", err)
}
for i, name := range names {
target := i + 1
if target <= version {
continue
}
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
if _, err := tx.Exec(string(sqlBytes)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
// PRAGMA does not accept a bound parameter, and target is a trusted
// loop index, so formatting it in is safe.
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
tx.Rollback()
return fmt.Errorf("bump schema version for %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package web
import (
"errors"
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// handleAccount serves the administrator's own account settings: the username
// and password chosen during setup are the only panel credentials (spec 7.6.1),
// and until now they could be changed only by recreating the state. Changing
// them here never touches application SASL logins, which are a separate
// identity system (spec 5.1).
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
case http.MethodPost:
s.submitAccount(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// renderAccount draws the settings form. formUsername repopulates the username
// field after a rejected submission; the password fields are never repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
s.render(w, status, "account", map[string]any{
"Title": "SelfPost — account",
"User": currentUser(r),
"Active": "account",
"FormUsername": formUsername,
"Error": formErr,
"Flash": accountFlash(r),
})
}
// accountFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") {
case "username":
return "Username changed."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
case "both":
return "Username and password changed. Any other signed-in sessions were signed out."
default:
return ""
}
}
// submitAccount applies a username and/or password change. The current password
// is always required, so a stolen session alone cannot lock the administrator
// out of their own panel, and the attempt is throttled on the same limiter as
// the login form so this route cannot be used to brute-force the password past
// that limit (spec 7.6.5).
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", currentUser(r))
return
}
if err := r.ParseForm(); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
admin, err := s.store.GetAdmin()
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if username == "" {
username = admin.Username
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
return
}
renaming := username != admin.Username
if renaming {
if err := validateUsername(username); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
// An empty pair of new-password fields means "leave the password alone", so
// the username can be changed on its own.
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
return
}
if err := validateAdminPassword(password); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
if !renaming && !repassword {
s.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, a new password, or both.", username)
return
}
hash := admin.PasswordHash
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: account: hashing password failed: %v", err)
s.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username)
return
}
hash = string(newHash)
}
if err := s.store.UpdateAdmin(username, hash); err != nil {
logf("panel: account: update admin failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) {
msg = "There is no administrator account to update."
}
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
return
}
// Keep this session usable under the new name, and — when the password
// changed — drop every other session so a cookie captured under the old
// password stops working.
if token, ok := s.sessionToken(r); ok {
if renaming {
s.sessions.Rename(token, username)
}
if repassword {
s.sessions.DestroyOthers(token)
}
}
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
}
// updatedFlag names what changed, for the fixed post-redirect flash message.
func updatedFlag(renamed, repassword bool) string {
switch {
case renamed && repassword:
return "both"
case renamed:
return "username"
default:
return "password"
}
}
+363
View File
@@ -0,0 +1,363 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
)
// newCred carries a freshly generated login/password to the template so it can
// be shown exactly once (spec 7.6.1). It is never read back from storage.
type newCred struct {
Login string
Password string
}
// detailView holds the one-shot, request-specific extras layered on top of a
// domain's persistent state when rendering its page: an application-form error,
// the values to repopulate that form, and any just-issued credential to show
// once.
type detailView struct {
FormErr string
FormLogin string
FormMode string
FormAddrs string
NewCred *newCred
// RateLimitErr surfaces a validation error from a domain- or application-level
// rate-limit form (spec 7.4) as a page banner.
RateLimitErr string
}
// appRateLimitView pairs an application with its differentiated rate-limit
// settings for the domain page. store.Application is embedded so the existing
// template fields (Login, AddressMode, Addresses, ID) resolve unchanged.
type appRateLimitView struct {
store.Application
HasLimit bool // an active limit is configured
IPsText string // allowed IPs, newline-joined for the textarea
MaxText string // message ceiling, blank when unset
WindowVal string // window seconds, defaulted when unset
}
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
// and its applications with the controls to add, edit, delete and re-issue
// credentials (spec 7.2.5-9).
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
}
// renderDomainDetail renders the domain page. view supplies request-specific
// extras (form error/values, a one-time credential); everything else is loaded
// fresh from the stores so the page always reflects committed state.
func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
record, err := s.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
apps, err := s.apps.List(d.ID)
if err != nil {
logf("panel: domain %d: list applications: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appViews := make([]appRateLimitView, 0, len(apps))
for _, a := range apps {
rl, ok, err := s.apps.RateLimit(a.ID)
if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appViews = append(appViews, appRateLimitView{
Application: a,
HasLimit: ok && rl.Active(),
IPsText: strings.Join(rl.AllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds),
})
}
domainRL, domainRLok, err := s.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// What DNS actually publishes for the domain today, checked against the key
// this server signs with (phase 13.B). Cached by the checker, so
// re-rendering the page after a form post costs nothing.
dns, srv := s.domainDNS(d, record, false)
s.render(w, status, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name,
"User": currentUser(r),
"Active": "domains",
"Domain": d,
"Record": record,
"DNS": dns,
// SPF and DMARC are the operator's to write — SelfPost cannot generate
// them the way it generates the DKIM record — so the page shows what
// this server expects rather than leaving it to the documentation. The
// same builders phrase the suggestions in the check messages, so the
// page and the checks below it never recommend different records.
"SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs),
"DMARCName": dnscheck.DMARCRecordName(d.Name),
"DMARCExample": dnscheck.DMARCExample(d.Name),
// Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment.
"Hostname": s.cfg.Hostname,
"SubmissionEnabled": s.cfg.SubmissionEnabled,
"Apps": appViews,
"Error": view.FormErr,
"FormLogin": view.FormLogin,
"FormMode": view.FormMode,
"FormAddrs": view.FormAddrs,
"NewCred": view.NewCred,
"Flash": detailFlash(r),
"Wildcard": store.AddressModeWildcard,
"List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr,
"DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
})
}
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
// records (phase 13.B). The server's own address comes from the (separately
// cached) hostname check, so the SPF heuristic knows which IP it is looking for
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button.
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := s.dns.Server(s.cfg.Hostname, false)
return s.dns.Domain(dnscheck.Query{
Name: d.Name,
Selector: d.DKIMSelector,
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
}, force), srv
}
// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
// returns to its page, which then renders the fresh result.
func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
record, err := s.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.domainDNS(d, record, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
// intOrBlank renders a non-positive number as an empty string so an unset field
// shows blank rather than "0".
func intOrBlank(n int) string {
if n <= 0 {
return ""
}
return strconv.Itoa(n)
}
// windowOrDefault renders the window seconds, substituting the default when
// unset so the form always suggests a sensible value.
func windowOrDefault(n int) string {
if n <= 0 {
return strconv.Itoa(defaultRateLimitWindowSeconds)
}
return strconv.Itoa(n)
}
// detailFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func detailFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("appdeleted") != "":
return "Application deleted."
case r.URL.Query().Get("modeupdated") != "":
return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated."
case r.URL.Query().Get("imported") != "":
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
// handleAddApplication creates an application on a domain and renders the page
// back with the generated password shown once (spec 7.2.5, 7.6.1). Because the
// password cannot be recovered later, this deliberately renders inline rather
// than redirecting.
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d,
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
return
}
login := strings.TrimSpace(r.PostFormValue("login"))
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
repopulate := detailView{
FormLogin: login,
FormMode: mode,
FormAddrs: r.PostFormValue("addresses"),
}
a, password, err := s.apps.Create(d.ID, login, mode, addrs)
if err != nil {
repopulate.FormErr = applicationErrorMessage(err)
status := http.StatusBadRequest
if errors.Is(err, store.ErrLoginExists) {
status = http.StatusConflict
}
s.renderDomainDetail(w, r, status, d, repopulate)
return
}
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// handleUpdateAppMode switches an application's address mode / list (spec 7.2.7).
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
d, derr := s.domains.Get(a.DomainID)
if derr != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
FormMode: store.AddressModeWildcard,
})
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
}
// handleRegenPassword issues a new password for an application and shows it once
// (spec 7.2.9, 7.6.1). Rendered inline, like creation, so the password is visible.
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
return
}
d, err := s.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
password, err := s.apps.RegeneratePassword(a.ID)
if err != nil {
logf("panel: regenerate password for application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// handleDeleteApplication removes an application and returns to its domain page
// (spec 7.2.8).
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
return
}
if err := s.apps.Delete(a.ID); err != nil {
logf("panel: delete application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
}
// lookupApplication resolves the {aid} path value to an application, writing a
// 404 for a bad id or missing application.
func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.Application{}, false
}
a, err := s.apps.Get(id)
if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) {
http.NotFound(w, r)
return store.Application{}, false
}
logf("panel: get application %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Application{}, false
}
return a, true
}
// splitAddresses turns the textarea/field input (addresses separated by
// newlines, commas or whitespace) into a raw slice. Normalisation and
// validation happen in the app service (spec 7.6.2).
func splitAddresses(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
})
}
// applicationErrorMessage turns a service error into a user-facing message,
// passing through the validation errors (which are safe, fixed strings) and
// masking anything unexpected.
func applicationErrorMessage(err error) string {
switch {
case errors.Is(err, store.ErrLoginExists):
return "That login is already in use. Choose another."
case errors.Is(err, store.ErrDomainNotFound), errors.Is(err, store.ErrApplicationNotFound):
return "The item no longer exists."
default:
// Validation errors from the app service are safe to surface verbatim;
// they describe what the admin must fix (login/address rules).
return err.Error()
}
}
+193
View File
@@ -0,0 +1,193 @@
package web
import (
"errors"
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// The panel session cookie's two possible names. In the production shape —
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
// what the cookie's attributes merely promise into something the browser
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
// attribute, so no other host may set a cookie by this name (phase 14.B).
// The prefix is only valid on a Secure cookie, so a development instance on
// plain HTTP has to keep the bare name: with the prefix the browser would
// discard the Set-Cookie outright and logging in would silently never stick.
const (
sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase
)
// sessionCookie is the session cookie's name for this deployment.
func (s *Server) sessionCookie() string {
if s.cfg.CookieSecure {
return sessionCookiePrefixed
}
return sessionCookieBase
}
// sessionToken returns the session token the request carries, if exactly one
// cookie of that name is present.
//
// It walks r.Cookies() rather than calling r.Cookie, which silently returns
// the first match. Two cookies with the same name mean somebody other than
// this panel set one of them — a host on the same registrable domain can,
// with Domain=example.com, and the browser will then send both — and RFC 6265
// makes the older one come first, so "the first match" is precisely the
// attacker's. The value cannot be forged into a valid session, so the effect
// is denial of service, not compromise; refusing the request and saying so in
// the log is what makes it diagnosable instead of an endless login loop. The
// __Host- prefix prevents this outright, but only where it applies — this
// check also covers the plain-HTTP development shape (phase 14.B).
func (s *Server) sessionToken(r *http.Request) (string, bool) {
name := s.sessionCookie()
var token string
var n int
for _, c := range r.Cookies() {
if c.Name == name {
n++
token = c.Value
}
}
switch n {
case 0:
return "", false
case 1:
return token, true
default:
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name)
return "", false
}
}
// clearSessionCookies expires the session cookie under both names, so an
// upgrade that switches to the __Host- prefix does not leave the old cookie
// sitting in the browser until it is closed.
func (s *Server) clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
// The prefixed name is only accepted at all when Secure is set,
// including on the expiring copy.
Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
SameSite: http.SameSiteLaxMode,
})
}
}
// handleLogin serves the login form (GET) and authenticates (POST). Until an
// administrator exists there is nobody to log in, so it points at setup.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := s.store.AdminExists()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !exists {
// No admin yet: login is meaningless. Send a clear message rather than
// a failing form.
s.render(w, http.StatusOK, "login", map[string]any{
"Title": "SelfPost — Sign in",
"SetupHint": true,
})
return
}
switch r.Method {
case http.MethodGet:
s.renderLogin(w, http.StatusOK, "")
case http.MethodPost:
s.submitLogin(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
s.render(w, status, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Error": formErr,
})
}
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
// Brute-force throttle by client IP (spec 7.6.5).
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
if err := r.ParseForm(); err != nil {
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
admin, err := s.store.GetAdmin()
if err != nil {
if !errors.Is(err, store.ErrNoAdmin) {
logf("panel: login: get admin failed: %v", err)
}
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
// Always run bcrypt so timing does not distinguish "wrong user" from
// "wrong password", and compare the username too.
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
if username != admin.Username || pwErr != nil {
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
token := s.sessions.Create(admin.Username)
s.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
// whatever the database row was just set to — at login, and again whenever
// requireAuth extends an active session.
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: s.sessionCookie(),
Value: token,
Path: "/",
MaxAge: s.sessions.MaxAge(),
HttpOnly: true,
Secure: s.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
// handleLogout destroys the session and clears the cookie.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Destroy every token presented under the session cookie's name: if a
// shadowing duplicate is present (see sessionToken) one of them is the
// real session, and a value that names no session is simply not found.
name := s.sessionCookie()
for _, c := range r.Cookies() {
if c.Name == name {
s.sessions.Destroy(c.Value)
}
}
s.clearSessionCookies(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}

Some files were not shown because too many files have changed in this diff Show More