Commit Graph

33 Commits

Author SHA1 Message Date
mix 3eb3e94bce feat: implement B.3 — fatal SELFPOST_HOSTNAME check in entrypoint.sh
Unset or malformed hostname makes the panel and Postfix diverge on SASL
realm silently (auth breaks for every application) and breaks HELO/PTR
matching (spam), so entrypoint.sh now exits before postfix-config.sh /
supervisord with an explanatory error, plus a syntax check rejecting
missing dots, schemes, ports, and whitespace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:53: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 db6abaefc7 docs: move the accepted security risks into docs/security.md
The plan holds undone work; an accepted risk is a decision, not a task
— it has no place in a queue, only a condition for revisiting it. Both
risks (POST with neither Sec-Fetch-Site nor Origin, no session-bound
CSRF tokens) move verbatim into a new docs/security.md, which also
states where D.5 findings land. Section letters and item numbering in
the plan stay as they were, since progress.md and the commit history
reference them; a note in their place points at the new file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:12:50 +03:00
mix cfd546000a docs: add item D.5 — pre-release vulnerability review by Fable
A single security pass once B.1-B.3 and C.4 are in, not four per-item
passes: those four rewrite authentication, log-file handling, the
entrypoint gate and the release workflow, so what matters is the final
state. Scope is the whole diff since v1.0.0 (phases 12-14 included)
plus a fresh walk over spec 7.6, run by Fable rather than Opus so the
reviewer is not the author. Findings are either fixed before the tag
or recorded in section A as accepted risks; together with the C.4 e2e
this gates tagging. The old section D (2.x pointer) becomes E, its
items renumbered 6 and 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:09:26 +03:00
mix e8605e68fa docs: decide item D.6 — domain-administrator role instead of 2FA
2FA is dropped from the 2.x candidate list. "Multiple administrators"
is narrowed to one concrete role: an admin scoped to a single domain
(its applications, DKIM/DNS status, filtered send log), with the
globally scoped actions — domain add/delete, reload, full backup,
queue and mail.log tail — left out of it. Still 2.x: a second panel
subject contradicts the out-of-scope list in spec section 3, so it
needs agreement and a spec change before any code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:05:23 +03:00
mix de4cc26cdb docs: decide item C.4 — hermetic container e2e as a release gate
The unit tests all fake the process boundary, so the class of failure that
actually broke this project — container wiring: chroot vs DNS, milter socket
permissions, the SASL realm, the Postfix reload path, cap_add — is invisible
to them. Record the decision to close it with a hermetic containerised e2e:
a separate Go module under test/e2e/, driven against the shipped compose file
plus an override, with a fake DNS zone and an smtp-sink standing in for the
outside world.

It runs before tagging (make e2e on the dev server, plus workflow_dispatch)
and gates image publication on the tag itself, which pulls release.yml off
qemu onto a native amd64/arm64 matrix: build, test, push per-arch tags, merge
the manifest — so the bytes published are the bytes that were tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:01:19 +03:00
mix 25cc34426c docs: decide item B.2 — rotate mail.log by rename + postfix reload
copytruncate loses log records twice per rotation: everything written
since the tailer's last poll (kept in mail.log.1, but skipped because the
descriptor points at the truncated inode) and whatever lands between the
copy and the truncate (gone for good). Those records carry the final
delivery statuses the send log is reconciled from, so a dropped line
means a row stuck in "queued" — not just a gap in the monitoring view,
as the item previously assumed.

Decision recorded, implementation deferred to its own step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:23:35 +03:00
mix e51917fcb5 docs: decide item B.1 — persistent sessions with a sliding idle window
Sessions move from the in-memory map to a `sessions` table (migration
0002), so a restart, a redeploy or a restore from a full backup no longer
signs the administrator out. The row holds a SHA-256 of the token rather
than the token itself: a stolen database file or backup archive cannot be
replayed into a login, while the browser that still holds the cookie keeps
working across a restore.

The 12-hour absolute TTL becomes a sliding 7-day idle window, configurable
through PANEL_SESSION_IDLE_DAYS (whole days, mirroring
SEND_LOG_RETENTION_DAYS). No absolute cap: for an administrator who visits
regularly the session lasts indefinitely, which is the accepted trade-off.
The four `every 5s` monitoring fragments deliberately do not renew it —
otherwise a forgotten open tab would hold the session open forever and the
window would mean "seven days without an open tab" rather than "seven days
without the administrator".

Decision only; no code yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:04:25 +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 7a09e62bf1 docs: decide cookie item A.3 — __Host- prefix plus duplicate detection
Records both decisions and, more usefully, what the item was actually about.
The prefix was filed as a free nicety ("мелочь, но бесплатная"), which is why
it sat undecided: nothing said what it prevents. It prevents the same-site
neighbour from the CSRF item using its other lever — setting a Domain-scoped
cookie of the same name. The browser then sends two, r.Cookie returns the
older one, and the admin logs in successfully into an endless login loop. That
is denial of service rather than compromise (no valid token can be forged with
a single account), but it is close to undiagnosable from the panel's side, and
the origin check decided in A.2 does nothing about it — the request comes from
the admin's own origin.

Phase 14 gains section B: the cookie name becomes conditional on CookieSecure,
because a __Host- cookie over plain HTTP is rejected outright and would break
the dev mode silently; logout clears both names; and requireAuth switches to
r.Cookies() so a duplicate is refused and logged instead of silently picked.
The setup-token documentation moves to 14.C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:33:31 +03:00
mix 75e9fef037 docs: decide CSRF item A.2 in favour of the origin check
Records variant (b): the panel will check Origin / Sec-Fetch-Site in the same
middleware as the security headers, and will not carry CSRF tokens. The
coverage table above the decision already says what that buys; what the item
was missing is what it does not buy, so both are now written down — the
accepted risk (a client sending neither header still gets through, which is
exactly the old-browser row) and the two escalation paths with their price,
tightening the policy to reject those requests, or session-bound tokens.

Phase 14.A grows the implementation rules: which requests are checked, the
three-way decision, and the fact that only the host is compared because the
panel sits behind a proxy and never sees its own external scheme. The rule
depends on r.Host being the external name — all four shipped proxy fragments
preserve it (checked), but a proxy that rewrites Host would turn every POST
into a 403, so the rejection has to log both sides of the comparison and the
container test has to run through a real proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:24:59 +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 d82d9736bd docs: plan Phase 12 - service status page + domain DNS checks
Adds a new agreed-upon phase covering /status (supervisord processes,
Postfix queue, TLS cert expiry, milter sockets, PTR/FCrDNS check) and
per-domain DNS correctness status (DKIM/SPF-heuristic/DMARC) on the
domain page. Not part of v1.0 spec scope; scoped and agreed with the
user before implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:12:56 +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
mix fe2844441b deploy: add CAP_FOWNER/CAP_FSETID so entrypoint permission-fix works
Bringing up the production Apache stack for real surfaced a latent bug
in the Phase 10 hardening: cap_drop: ALL with only NET_BIND_SERVICE/
CHOWN/SETUID/SETGID/DAC_OVERRIDE left the root startup phase unable to
chmod the /data dirs it had just chowned to the panel user (needs
CAP_FOWNER) or set their setgid bit (needs CAP_FSETID). The container
crash-looped on "chmod: Operation not permitted". Phase 10 never caught
this because its compose up hit a port conflict before full boot.

Add FOWNER and FSETID to cap_add and document what each capability is
for. Verified: container now starts clean under the hardened compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:18:44 +03:00
mix 65a420d230 Phase 11: final security pass + acceptance (spec 7.6, 12.2-3)
Line-by-line audit of all 8 points of spec 7.6 against the code: full
compliance, no code changes required. Acceptance verified on the dev
server (image selfpost:p11): gofmt/vet/build/test green, docker build
ok, clean container start (all processes RUNNING, panel as non-root
uid 999, setup link + 0600 token, bogus token 404, unauth 303, healthz
200). Baseline v1.0 plan (phases 0->11) complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:42:27 +03:00
mix 61f525e2d7 Phase 10: deployment (Apache compose + proxy fragments, CI release) + docs
- deploy/docker-compose.yml: pinned-tag ghcr image, hardened (cap_drop ALL +
  minimal cap_add, no-new-privileges, panel bound to 127.0.0.1 only). Apache
  itself runs on the host (spec 10.5), fragment at deploy/apache/.
- Alternative reverse-proxy fragments: nginx (+certbot sidecar), Caddy
  (automatic ACME), Traefik (+acme.json PEM extraction script).
- .github/workflows/release.yml: tag-triggered ghcr.io publish, version piped
  from the git tag into both the binary ldflags and the image tag (spec 10.1).
- Closed a gap from Phase 1: logrotate was installed but never invoked;
  wired up build/logrotate-mail.conf + logrotate-loop.sh + a supervisor
  program (copytruncate, since postlogd holds mail.log open with nothing to
  signal on rotation).
- README rewritten: site requirements checklist, reverse-proxy comparison,
  DNS setup (server- vs domain-level), IP warmup, backup/restore vs domain
  export/import, fixed-tag rationale, machine requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 21:23:19 +03:00
mix f88d8dabcb Phase 9: full backup/restore + domain export/import (spec 7.5, 11.6)
Full server backup (spec 7.5.A): internal/backup produces a tar.gz of all of
/data — a consistent SQLite snapshot via VACUUM INTO, DKIM keys, sasldb2 and a
version manifest; TLS certs (tls/) and the Postfix queue are excluded. Two equal
paths: the panel button (POST /backup, no-store) and the selfpost-backup CLI via
docker exec (spec 11.6). CheckRestore runs before store.Open: a manifest version
mismatch refuses to boot with the image tag to use; a match consumes the
manifest so it only guards the first post-restore boot. Restore is not a
separate branch — Postfix/OpenDKIM regenerate from the restored SQLite as on any
start.

Domain export/import (spec 7.5.B): DomainExport carries the DKIM private key and
each application's working password. SASL secrets are read from sasldb2 via
db_dump (the userPassword property is plaintext) and, on import, re-keyed under
the local realm with saslpasswd2 — so credentials keep working on an instance
with a different hostname, with no DKIM DNS change. Import validates and rolls
back atomically on any failure. db-util (db_dump) is now an explicit image dep.

Verified on the server (selfpost:p9): gofmt/vet/test green; container e2e for
cross-realm domain export/import (SMTP AUTH 235 under the new realm), CLI and
panel backups, same-version restore, and version-mismatch refusal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:33:07 +03:00
mix 56a4fa892d Phase 8: level-2 differentiated rate limits (spec 7.4)
The journal-milter, until now a pure monitor, now refuses a message with a
4xx tempfail (RespTempFail/451) at MAIL FROM when a per-domain or per-
application limit is exceeded. Key is the client IP; the count is
COUNT(DISTINCT queue_id) over a sliding window reusing the send log; the
limit applies only when a non-empty IP binding matches the client (empty
binding => level-1 only, per spec 7.4). Enforcement is fail-open on the
milter's own errors — a limiter malfunction never blocks mail, and Postfix's
level-1 anvil limit stays the independent backstop. Refused messages are
recorded in send_log with status "rejected" for UI visibility.

- store/ratelimits.go: RateLimit type (+Active/AllowsIP), id-keyed get/set/
  delete for the panel, name/login-keyed lookup + windowed distinct-message
  count for the milter, DeleteRateLimitsForDomain. No migration — the
  rate_limits table has existed since Phase 2.
- milter: enforce at MailFrom, fail-open helper overLimit, InsertRejected.
- web: server-side validated IP/ceiling/window forms on the domain page and
  per application; routes POST /domains/{id}/ratelimit and
  /applications/{aid}/ratelimit. Milter reads rows live, so no reload.
- domain/app services clear limits on deletion (rate_limits has no FK cascade).

Unit tests + container e2e (p8) green: refusal on both scopes, unregistered
IP ignored, fail-open with the panel stopped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:57:38 +03:00
mix cb25923a7b Phase 7: monitoring UI — send log, queue, mail.log tail
Three HTMX-polled monitoring screens (spec 7.2.11-13): send log with
server-side domain/application filters and pagination, Postfix queue
(postqueue -p), and a mail.log tail. Fragment endpoints return HTML
snippets, not JSON (spec 7.1); all output is auto-escaped via
html/template (spec 7.6.7).

Adds store.QuerySendLog/CountSendLog/ListApplicationLogins,
postfix.Queue(), and logtail.TailLines (a point-in-time reverse read,
independent of the background follow loop). Verified on the dev server:
gofmt/vet/test green, docker build green, container e2e (filters,
60-row pagination, <script> escaping, real postqueue/mail.log output,
existing Reload button unaffected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:22:08 +03:00
mix 745f1c03d2 docs: close Phase 6 — journal-milter verified (records, statuses, fail-open x2, retention)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:58:44 +03:00
mix 4f1f7761a2 docs: close Phase 5 — real delivery confirmed (dkim=pass, spf=pass)
Live delivery verified end-to-end: dtester@test.example.org -> selfpost@mixeme.ru
accepted by mx.example.net with Authentication-Results dkim=pass
(d=test.example.org s=selfpost) and spf=pass, read back over IMAP. All Phase 5
"done when" criteria met. Records the own-domain-policy pitfall (can't test
delivery from a domain the receiver itself hosts) and the test.example.org sender
workaround for future delivery tests. Next: Phase 6 (journal-milter) on Opus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:20:36 +03:00
mix 49ab29b1ce docs: record Phase 5 state — relay verified, live delivery auto-retrying
Phase 5 code is complete, committed (2c7f0da) and verified on the server.
Everything at SelfPost's boundary is proven (auth, sender binding, no open
relay, valid DKIM signing, delivery to the recipient MX over TLS). The one
open item — the receiver accepting the message into its inbox — is gated by
mx.example.net's DNS cache / own-domain policy, not a relay defect; a background
loop retries until it lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:59:39 +03:00
mix c6eeb30258 Phase 4: applications + SASL (sasldb2) + sender_login_maps
Adds application accounts bound to domains: a SASL login/password in
sasldb2, a per-application address mode (wildcard @domain or an explicit
list), and matching smtpd_sender_login_maps bindings — with create,
list, edit-mode, delete and password regeneration (spec 4.1, 5.1,
7.2.5-9). Generated passwords are shown exactly once and never stored in
plaintext (7.6.1).

- internal/store/applications.go: transactional CRUD; globally unique
  login; ListBindings (address->login) as the map source; logins-by-
  domain for pre-cascade SASL cleanup.
- internal/app: saslpasswd2 wrapper (password via stdin, login as a
  whitelisted argv element, no shell — 7.6.3); strong base64url password;
  address validation that enforces domain ownership before any config
  write (7.6.2); service orchestrating store + sasldb2 + map with full
  rollback on partial failure.
- internal/postfix: sender_login_maps regenerated as a pure function of
  the registry (many-to-one logins merged per address), atomic write,
  injection backstop (7.6.4).
- Postfix reload, corrected: `postfix start-fg` forks a separate master,
  so signalling the supervised process never reaches it. Reload now runs
  the canonical `postfix reload` via a one-shot supervisord program the
  unprivileged panel triggers over the group control socket. Verified in
  mail.log.
- domain.Service.Delete purges the domain's SASL accounts, then cascades,
  then rebuilds the sender map and reloads; manual reload now covers both
  OpenDKIM and Postfix.
- web: application management in the domain page, one-time credential
  shown inline; postfix joins the selfpost group and entrypoint normalises
  /data/sasl and /data/postfix (setgid, group-readable) with self-heal.

Verified on the dev server: gofmt/vet/test green, image builds, and a
container e2e covers the full application lifecycle, domain-delete
cascade, restart persistence, and a real postfix reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:18:10 +03:00
mix a7a5ad3f91 Phase 3: sending domains + per-domain OpenDKIM signing
Add/list/delete of sending domains with per-domain DKIM keys and the
OpenDKIM tables that drive signing (spec 6, 7.2.2-4, 7.2.10).

internal/domain:
- Pure-Go RSA-2048 keygen; PKCS#1 PEM written atomically at 0640; the
  published DNS TXT record is derived from the key on disk (single source
  of truth) rather than persisted. No os/exec for key generation.
- KeyTable/SigningTable fully regenerated from the registry on every
  add/delete (idempotent), written atomically; SigningTable via refile:
  with *@domain, KeyTable with absolute key paths. Table writer refuses
  any unsafe character as a backstop (spec 7.6.4).
- Reload without root: the unprivileged panel signals OpenDKIM through
  supervisord (`supervisorctl signal USR1 opendkim`, fixed args, no
  shell, no user input — spec 7.6.3). An existing key is reused, never
  overwritten, so re-adding a domain keeps its published DNS valid.
- Service orchestrates registry -> key -> table rebuild -> reload, with
  rollback of the row if a downstream step fails; delete cascades apps
  via the DB FK and removes the key + table entries.

Infra:
- Shared `selfpost` group bridges panel (writes keys) and opendkim
  (reads them); /data/opendkim is setgid so panel-created files inherit
  the group, keys are 0640, RequireSafeKeys is disabled by design.
- opendkim.conf moves from verify-only (Mode v) to signing (Mode s).
- entrypoint.sh normalises the DKIM tree on every start (ownership,
  setgid, perms, empty tables before opendkim starts) — self-healing
  after a restore.
- supervisord control socket opened to the `selfpost` group so the panel
  can request the reload.

web/store:
- Strict domain-name validation (whitelist [a-z0-9.-], DNS shape, >=2
  labels), lower-case normalisation (spec 7.6.2).
- Domain queries with application counts; delete relies on ON DELETE
  CASCADE. Dashboard lists domains + add form; domain page shows the
  DKIM record; a dedicated confirm page warns about the app cascade
  before deletion (spec 7.2.4); manual reload button (spec 7.2.12,
  OpenDKIM side; Postfix reload lands in Phase 5).
- Authenticated routes moved to a sub-mux using Go 1.22 method/wildcard
  patterns.

Tests: validateDomain, DKIM keygen/record roundtrip, table rendering +
injection-safety, key reuse, store cascade. Verified on the dev server:
gofmt/vet/test green, image builds, container e2e (add/delete a domain,
DKIM record shown, OpenDKIM reads panel keys and reloads, keys and
tables persist across a restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:20:35 +03:00
mix d72a383904 Phase 2: SQLite persistence, admin setup-link, login/sessions
Implements the secure single-admin panel entry (spec 7.6).

- internal/store: modernc.org/sqlite (pure Go, static build), WAL +
  foreign keys, embedded PRAGMA user_version migrations; schema 0001
  covers admin/settings/domains/applications/send_log/rate_limits (spec 9).
- Setup secret-link (spec 7.6.1): 128-bit crypto/rand token, printed to
  log + /data/setup-token (0600), 10-min TTL with regeneration, per-IP
  rate limit, subtle.ConstantTimeCompare, failures don't invalidate,
  one-time admin form, permanent invalidation once admin exists (/setup 404).
- bcrypt admin password; server-side username/password validation.
- Login + in-memory sessions, crypto-random token, cookie
  HttpOnly/Secure/SameSite (Secure toggleable for dev HTTP), login
  rate limit, auth middleware.
- html/template base layout + setup/login/dashboard, vendored htmx 2.0.4.
- build/entrypoint.sh: fix bind-mounted /data ownership as root before
  supervisord drops to the unprivileged panel user (found via container test).

Verified on selfpost.example.com: go vet/build/test/gofmt clean; e2e curl
of setup+login flows; docker build + run with -v ./data:/data creates the
DB and 0600 token owned by panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:24:09 +03:00
mix 85f3374ce1 Phase 1 done: close out and record verification results
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:55 +03:00
mix cc2d9d43c0 Phase 0 done: close out and record milter spike results
De-risk spike confirmed emersion/go-milter v0.4.1 (BSD-2) interoperates with
Postfix 3.7.11 (bookworm) over protocol v6: reads From/To(per-rcpt)/Subject/
queue-id, gets client IP from Connect(), and fails open when the milter dies.
Progress tracker updated; Phase 1 (Docker + supervisord) is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:58:34 +03:00
mix b39d1d0302 Phase 0: project scaffold and build pipeline
Go module (codeberg.org/mix/selfpost), two command skeletons (panel,
selfpost-backup) sharing internal/buildinfo for the -ldflags version stamp,
Makefile (static CGO_ENABLED=0 build), AGPL-3.0 LICENSE, README skeleton and
.gitattributes forcing LF (container scripts must not get CRLF).

Verified on the dev server: go vet clean, make build produces statically
linked binaries, version stamping works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:50:51 +03:00
mix 9c31649941 Add implementation plan and phase progress tracker
12-phase plan derived from the spec, plus a durable progress tracker
(model-per-phase, resume-after-reset protocol, commit conventions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:44:24 +03:00