Commit Graph

20 Commits

Author SHA1 Message Date
mix 669867cd2d docs: cut CHANGELOG 0.3.0, reopen Unreleased
Renames the Unreleased section to 0.3.0 per the versioning convention in
docs/progress.md. What accumulated since 0.2.0 is a coherent release: the
PTR check fix, the monitoring pages moving to URLs that match their nav
labels, and the navigation bar getting icons and a two-row layout. A
SemVer minor bump -- the URL move is breaking for bookmarks, but the
project is still pre-1.0.

The version lives only in the build arg (ldflags into
internal/buildinfo.Version), so this heading is the whole bump; there is
no constant in the tree to follow it.

No git tag and no ghcr push here -- a vX.Y.Z tag is what makes CI publish
images, and that stays an explicit step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:57:24 +03:00
mix 50d2624985 panel: put the session row on top of the nav bar
The two rows were the right call; the order was not. The session block
belongs at the top right, where a signed-in user expects it, with the
page entries under it and the active one sitting against the bar's
bottom border.

Done by moving the session div ahead of the links div in the layout
rather than with a CSS `order`, so the reading and tab order still follow
what the eye sees. That does make Sign out the bar's first tab stop --
the same as on any site with a user menu up there, and nothing activates
on focus, so it is a reordering rather than a hazard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:43:52 +03:00
mix 08943caae1 panel: lay the nav bar out as two rows on purpose
It stopped fitting on one line once the entries grew icons: six of them
need about 660px and the session block another 260px, against the 738px
the panel is wide. Flex was already wrapping it, but the wrap put the
session left-aligned directly under the entries, where it read as a third
row of navigation.

So: entries on the first row, session pushed to the right edge on the
second. The two blocks now read as two blocks. Buying the ~200px back by
shortening labels was the other option and would have undone the recent
work naming each entry after its page.

Rows rather than columns -- a two-column grid of entries measured a third
taller (107px against 83px) and was no easier to read. Entries stay first
in document order, so tabbing still reaches the pages before Sign out.

Checked at 1200, 768, 600 and 375px: no horizontal overflow, and below
768 the entries wrap to two rows as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:39:08 +03:00
mix 610f74d6a4 panel: give each nav entry an icon
The bar was six similar-length words; nothing distinguished them at a
glance. Each entry now leads with a 16px inline SVG.

Inline rather than an icon font or a sprite file: the icons inherit the
entry's colour through currentColor, so the active pill's darker text and
a link's blue need no second rule, they cost no request, and they need no
exemption from the panel's default-src 'self' policy. Each is aria-hidden
because the entry's text is already its accessible name.

Account is included -- it is a page like the others and would otherwise
be the one bare word left in the bar -- so the highlight and padding
rules now cover the session block too, and the bar centres its items
instead of aligning them on the text baseline now that an entry is an
icon-plus-label row rather than a run of text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:11:51 +03:00
mix 4003a299a6 dnscheck: query recursive resolvers directly, not the system one
The PTR check reported a correctly published record as wrong. The lookups
went through the container's resolver (127.0.0.11) which forwards to the
host's systemd-resolved, and systemd-resolved synthesises the reverse
lookup of the machine's own addresses from the local hostname rather than
asking public DNS. On the production host that meant

    203.0.113.10 -> provider-assigned-hostname (does not match)

while public DNS has had 203.0.113.10 -> selfpost.example.com all along.

These checks exist to report what a receiving mail server sees, so they
now dial recursive resolvers themselves, defaulting to 1.1.1.1, 8.8.8.8
and 9.9.9.9 and overridable with SELFPOST_DNS_RESOLVERS. The e2e stand
sets it to its CoreDNS, which the `dns:` directive alone no longer covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:53:24 +03:00
mix bd4b226953 panel: match monitoring URLs to their nav labels
/sendlog -> /deliveries, /queue -> /mail-queue, /logtail -> /system-log,
along with the HTMX polling fragments under each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:41:52 +03:00
mix 9c1dc4050f docs: cut CHANGELOG 0.2.0, reopen Unreleased
Renames the Unreleased section to 0.2.0 per the versioning convention in
docs/progress.md — the accumulated panel/CI/ops work since 0.1.0 is a
coherent release (SemVer minor bump; project is still pre-1.0). No git
tag/image push yet; that stays an explicit step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:35:41 +03:00
mix f113f44d65 panel: show the running version in the layout footer
Nothing in the UI said which build was running, though it is the value a
backup manifest is compared against on restore and the first thing worth
knowing when the panel misbehaves — it was only in the startup log line
and `panel -version`.

Add it as a small footer in the shared layout, supplied from render()
alongside .Active so no handler has to pass it, and gated on .User: the
login and setup pages face the internet and should not advertise a
version. Tests cover both the footer and render() supplying the key,
since neither is visible from any single handler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:32:58 +03:00
mix e87a55b657 panel: make the status page's "Full queue" a button
It was a bare link trailing the queue-summary sentence, while the two
other card actions on the same page (Re-check DNS, Reload configuration)
are buttons. Pull it out of the paragraph and give it the filled button
style through a new a.btn class — the same base rule a.danger already
used, so an action that happens to be a navigation still looks like
every other action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:26:20 +03:00
mix 337c8e8f0e panel: stack Add an application above the list, drop the two-column row
The side-by-side .split row read badly: main is capped at 48rem, so the
applications table had to live in roughly 27rem and its actions column
squeezed four controls into it.

Put the create form directly above the list instead — the order the
domains page already uses for "Add a sending domain" above "Domains" —
and delete .split, which nothing else used. The empty-state text follows
the same page's wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:21:09 +03:00
mix e6910c2c2d panel: show the SPF and DMARC records the domain page expects
The domain page generated and displayed the DKIM record but said only
"also configure SPF and DMARC for the domain (see the documentation)".
The concrete example existed — buried in the check's remediation text,
and only visible once the check had already failed. Show both records up
front, host and value with a Copy button, the way the DKIM record is
shown, plus the two things that actually bite: a domain may carry only
one SPF record (add the mechanism to the existing one rather than
publishing a second), and p=none is safe to publish immediately.

The SPF value names the addresses SELFPOST_HOSTNAME resolves to, taken
from the hostname check the page already runs, and falls back to an "a:"
mechanism when it does not resolve. New dnscheck.SPFExample/DMARCExample
are the single source for both the page and the checks' advice, so the
two cannot drift into recommending different records; dnscheck.Query
gains Hostname for the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:08:48 +03:00
mix f3bc24b638 feat: implement C.4 — hermetic container e2e gate + native release build
Separate test/e2e Go module drives the shipped deploy/docker-compose.yml
(plus a test-only override: self-signed cert, low ports, isolated compose
project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as
an administrator and their applications would over HTTP/SMTP — covering the
class of failure unit tests can't see (container wiring). Positive path:
setup -> login -> domain -> DKIM record published into the fake zone ->
application -> SMTP AUTH send -> DKIM verified against the DNS-published
key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay,
sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter
fail-open, SELFPOST_HOSTNAME gate, session survives docker restart.

release.yml moves off qemu to a native per-arch build (amd64/arm64), each
gated by this suite before its tag is pushed and merged into the version
manifest.

Verified green on selfpost.example.com via `make e2e`; go vet/gofmt clean in
both modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:29:14 +03:00
mix db1572d7ad feat: implement B.2 — rotate mail.log by rename + postfix reload
Replaces copytruncate with rename + `postfix reload` (the same mechanism
`postfix logrotate` itself uses), closing the up-to-one-second window where
copytruncate could drop in-flight delivery lines and leave a send-log row
stuck at "queued" forever.

logrotate-mail.conf keeps `create 0644 root root` rather than `nocreate` as
originally planned: verified on a live container that Postfix recreates the
file itself only lazily, on the next write after reload, and at mode 0600 —
unreadable by the unprivileged panel process. `create` hands the file back at
0644 immediately after rename, before Postfix ever touches it.

logtail.follow() re-drains the old file descriptor once more right before
switching to the rotated file, closing the residual gap between the last
poll's drain and the rotation check. readLogTail() treats a momentarily
missing mail.log as an empty screen rather than a logged error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:45:15 +03:00
mix 538a4b6603 feat: implement B.1 — persist login sessions in SQLite with sliding idle timeout
Sessions move from an in-memory map (absolute 12h TTL) to a `sessions`
table (migration 0002), storing only the SHA-256 of the token. Expiry is
now a sliding idle window (PANEL_SESSION_IDLE_DAYS, default 7, no
absolute cap), extended at most once an hour and never by the
monitoring screens' background polling (GET + HX-Request), so a
forgotten open tab doesn't keep a session alive indefinitely. A login
now survives a container restart or redeploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:21:49 +03:00
mix 6c61d53239 docs: document /data/setup-token and close phase 14
14.C needed no code: the setup link is already mirrored to /data/setup-token
at 0600 and removed once setup completes. What was missing is the reason to
prefer it — a deployment whose container logs ship to a central aggregator
otherwise leaves a live bearer token in that pipeline for ten minutes, and in
whatever retains it afterwards.

The reverse-proxy section gains the one requirement 14.A introduces: pass the
original Host header through. Everything else about security stays the
proxy's non-problem, which is the point of emitting the headers from the
panel.

Phase 14 leaves the plan (the file describes only unfinished work), but its
section A keeps what was deliberately left open: the accepted risk for clients
sending neither Sec-Fetch-Site nor Origin, the decision not to add
session-bound CSRF tokens and what would justify revisiting it, and the fact
that XSS inside the panel's own origin is answered by html/template and the
CSP rather than by either of those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:02:26 +03:00
mix 51e20ffc22 docs: drop completed work from the plan and progress tracker
The plan is meant to hold only what is still open, but three of its numbered
items had already been implemented and were still being read as pending work:
the TRUSTED_PROXY_CIDR-gated X-Forwarded-For handling (A.1), the account
settings page (A.6) and the go vet/go test CI workflow (C.10). Remove them
and renumber; the residual scope note from A.6 (2FA, multiple admins) moves
to section D, which is where deliberately deferred scope belongs.

Same for the "done" notices at the top of the plan and the phase-by-phase
retellings in progress.md: phases 12 and 13 are described in full in the
CHANGELOG and git history, so the tracker now states what is closed and what
is next, and nothing else.

Three code comments cited plan item numbers that this renumbering would have
silently pointed at a different item, and one cited a phase 13 section that
no longer exists; they now state the fact instead of the reference. The CI
test workflow was never recorded in the CHANGELOG, so its entry is added
there before the plan item describing it goes away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:10:56 +03:00
mix 7b4549a35d panel: server status page, per-domain DNS checks, /domains move
Phase 13. Two new packages and one new screen.

internal/health owns the shared status vocabulary (ok/warn/error/unknown)
and the local checks: supervisord's process table, TLS certificate expiry
and the two milter sockets. Each check reports a problem as a status rather
than an error, so one broken component costs a line and not the page.

internal/dnscheck does the read-only lookups: forward-confirmed reverse DNS
for SELFPOST_HOSTNAME, and per-domain DKIM (compared against the key this
server actually signs with), SPF and DMARC. Every check is bounded by a
timeout and cached, and the resolver sits behind an interface so the tests
drive every branch without touching the network. The SPF check is
deliberately shallow: it looks for a mechanism literally covering the
server's address and does not follow include:/redirect=, so a record that
authorises us through an include is reported as "cannot tell" rather than
as a failure.

/status renders both, with the local checks in an HTMX-polled fragment and
the DNS lookups behind a Re-check button, and becomes the panel's landing
page: / now redirects there and the domain list lives at /domains. The
Reload button moves onto /status, where it reads as what it is — a
drift-recovery for the daemons — with text explaining what it regenerates.
A template test fails on any remaining href="/" so a stale link cannot
silently land on the wrong screen.

Also fixes a defect this made visible: the panel could never read the mail
queue in the documented deployment. postqueue relies on its setgid-postdrop
bit, which the compose file's no-new-privileges disables, so the Queue
screen always said "Could not read the mail queue" — including in the
released 1.0.0 image. The panel user is now a real member of postdrop,
which needs no setgid transition.

Verified in a container on the dev server against real DNS: PTR matching
(selfpost.example.com) and not matching (example.com), DKIM absent and
mismatched, SPF absent and via include:, DMARC p=quarantine/p=reject/absent,
and a resolver timeout degrading to "unknown" without hanging the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 22:04:37 +03:00
mix fc53ae1314 panel: shared nav, account settings, backup page, connection settings
Phase 12 (UI/UX). The navigation bar now renders once from layout.html
instead of being copied into each content template, so it is present on
every authenticated page — including the domain page and its delete
confirmation, which had no links at all — and the current page is
highlighted via .Active rather than quietly dropping out of the list.

New /account page changes the administrator's username and/or password:
the current password is required and the attempt is throttled on the same
limiter as the login form, so this route cannot be used to brute-force
past that limit. A password change invalidates every other session while
keeping the one performing it; a rename carries that session over.

Backup and domain import move from a card in the middle of the domain
list to their own /backup page, one card each; the handlers themselves
are unchanged, only the page the import form renders its errors on.

The domain page gains a "Sending server settings" card (server, port,
encryption) so a client can be configured without reading the docs; 587
is listed only when SUBMISSION_ENABLE is true for this deployment, which
is a deploy-time flag the panel cannot verify at runtime.

Client-side (static/panel.js, no libraries): Copy buttons on the values
that get carried elsewhere (DKIM record, new application credentials,
server name), and the Addresses field is hidden while the address mode is
wildcard, where the server ignores it.

Verified in a container on the dev server: setup, login, every page's
nav and active item, domain and application creation, all account-form
paths including cross-session invalidation, import errors, full backup
download. gofmt/vet/test/docker build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:34:59 +03:00
mix dca83e9671 security: parse X-Forwarded-For from trusted proxies for rate-limit key
Resolves plan item A.1 (option б): login/setup rate-limiting used
RemoteAddr only, which behind the default reverse proxy is the proxy's own
address, making the limiter effectively global and enabling a lockout-DoS.
Now, when the request's direct peer matches the new TRUSTED_PROXY_CIDR list
(comma-separated CIDRs, env, empty by default), the last X-Forwarded-For
entry is used instead, giving a real per-client limit. Unset behaviour is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:53:59 +03:00
mix 278a88d1e1 docs: add CHANGELOG.md, trim completed work out of plan/progress
CHANGELOG.md now tracks version history (0.1.0 baseline); progress.md and
implementation-plan.md keep only live process and unfinished work (open
questions, optional 2.x.x phase O1) since phases 0-11 are fully closed and
already covered by git history and the changelog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:06:10 +03:00