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>
This commit is contained in:
2026-07-11 22:20:35 +03:00
parent f62c714e2b
commit bf477f4e6a
24 changed files with 1461 additions and 36 deletions
+172
View File
@@ -0,0 +1,172 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"codeberg.org/mix/selfpost/internal/store"
)
// handleDashboard is the authenticated landing page: the list of sending
// domains with their DKIM/selector and application counts, plus the add-domain
// form (spec 7.2.2). Applications and the send log arrive in later phases.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.renderDashboard(w, r, http.StatusOK, "", "")
}
// renderDashboard renders the domain list. formErr and formName repopulate the
// add-domain form after a rejected submission; flash surfaces a one-shot status
// message keyed by a redirect query flag (never reflected user input).
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
domains, err := s.domains.List()
if err != nil {
logf("panel: dashboard: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.render(w, status, "dashboard", map[string]any{
"Title": "SelfPost",
"User": currentUser(r),
"Domains": domains,
"Error": formErr,
"FormName": formName,
"Flash": dashboardFlash(r),
})
}
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func dashboardFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("reloaded") != "":
return "Configuration reloaded."
case r.URL.Query().Get("deleted") != "":
return "Domain deleted."
default:
return ""
}
}
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (spec 7.2.3).
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := normalizeDomain(raw)
if err := validateDomain(name); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := s.domains.Add(name)
if err != nil {
if errors.Is(err, store.ErrDomainExists) {
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
return
}
logf("panel: add domain %q: %v", name, err)
s.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
}
// handleDomainDetail shows a single domain and its DKIM DNS record (spec 7.2.10).
func (s *Server) handleDomainDetail(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.render(w, http.StatusOK, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name,
"User": currentUser(r),
"Domain": d,
"Record": record,
})
}
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
// panel must explicitly state that all bound applications go with it (spec 7.2.4).
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
s.render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name,
"User": currentUser(r),
"Domain": d,
})
}
// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
// and returns to the domain list.
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
id, ok := parseDomainID(w, r)
if !ok {
return
}
if err := s.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther)
}
// handleReload re-applies the OpenDKIM configuration on demand (spec 7.2.12).
// The Postfix side of the reload button lands in Phase 5.
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
if err := s.domains.Resync(); err != nil {
logf("panel: manual reload: %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?reloaded=1", http.StatusSeeOther)
}
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
// bad id or a missing domain and reporting ok=false in that case.
func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
id, ok := parseDomainID(w, r)
if !ok {
return store.Domain{}, false
}
d, err := s.domains.Get(id)
if err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return store.Domain{}, false
}
logf("panel: get domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Domain{}, false
}
return d, true
}
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
-13
View File
@@ -36,16 +36,3 @@ func currentUser(r *http.Request) string {
}
return ""
}
// handleDashboard is the authenticated landing page. Phase 2 shows a minimal
// shell; domains, applications and the send log arrive in later phases.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
s.render(w, http.StatusOK, "dashboard", map[string]any{
"Title": "SelfPost",
"User": currentUser(r),
})
}
+5 -3
View File
@@ -17,9 +17,11 @@ type templates struct {
// pageFiles maps a logical page name to its template file. Every page composes
// with layout.html.
var pageFiles = map[string]string{
"setup": "templates/setup.html",
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
"setup": "templates/setup.html",
"login": "templates/login.html",
"dashboard": "templates/dashboard.html",
"domain_detail": "templates/domain_detail.html",
"domain_delete": "templates/domain_delete.html",
}
func loadTemplates() (*templates, error) {
+42 -4
View File
@@ -1,15 +1,53 @@
{{define "content"}}
<div class="topbar">
<h1>SelfPost</h1>
<div class="muted">
{{.User}} &middot;
<div class="actions muted">
<span>{{.User}}</span>
<form class="inline" method="post" action="/reload">
<button type="submit">Reload</button>
</form>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card">
<p>You are signed in. Domains, applications and the send log arrive in the
next phases.</p>
<h2>Add a sending domain</h2>
<form method="post" action="/domains">
<label for="name">Domain</label>
<input id="name" name="name" type="text" placeholder="example.com"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormName}}" autofocus required>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Add domain</button>
</form>
<p class="muted">A DKIM key is generated for the domain; you then publish the
shown DNS record. Adding a domain does not create an application.</p>
</div>
<div class="card">
<h2>Domains</h2>
{{if .Domains}}
<table>
<thead>
<tr><th>Domain</th><th>Selector</th><th>Apps</th><th></th></tr>
</thead>
<tbody>
{{range .Domains}}
<tr>
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
<td class="muted">{{.DKIMSelector}}</td>
<td>{{.AppCount}}</td>
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">No domains yet. Add one above to get started.</p>
{{end}}
</div>
{{end}}
+32
View File
@@ -0,0 +1,32 @@
{{define "content"}}
<div class="topbar">
<h1>Delete {{.Domain.Name}}</h1>
<div class="actions muted">
<span>{{.User}}</span>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/domains/{{.Domain.ID}}">&larr; Back to {{.Domain.Name}}</a>
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete <strong>{{.Domain.Name}}</strong>. This will:</p>
<ul>
<li>permanently delete its DKIM signing key;</li>
<li>delete
{{if eq .Domain.AppCount 0}}its (currently none) bound applications{{else}}
<strong>all {{.Domain.AppCount}} bound application(s)</strong>, including
their SASL credentials and sender bindings{{end}};</li>
<li>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<p class="muted">This cannot be undone. The published DKIM DNS record becomes
invalid; remove it from DNS if you do not plan to re-add the domain.</p>
<form method="post" action="/domains/{{.Domain.ID}}/delete">
<button type="submit" style="background:#b42318">Delete {{.Domain.Name}}</button>
</form>
</div>
{{end}}
+44
View File
@@ -0,0 +1,44 @@
{{define "content"}}
<div class="topbar">
<h1>{{.Domain.Name}}</h1>
<div class="actions muted">
<span>{{.User}}</span>
<form class="inline" method="post" action="/logout">
<button type="submit">Sign out</button>
</form>
</div>
</div>
<a class="back" href="/">&larr; All domains</a>
<div class="card">
<h2>DKIM DNS record</h2>
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
It is not a secret and can be viewed at any time.</p>
<label>Host / name</label>
<span class="code">{{.Record.Name}}</span>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label>
<span class="code">{{.Record.Value}}</span>
<p class="muted">Also configure SPF and DMARC for the domain (see the
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card">
<h2>Applications</h2>
<p class="muted">{{.Domain.AppCount}} application(s) bound to this domain.
Creating and managing applications arrives in the next phase.</p>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Deleting this domain also deletes its DKIM key and every
application bound to it.</p>
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div>
{{end}}
+16
View File
@@ -37,9 +37,25 @@
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: #6b7280; }
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
form.inline { display: inline; margin: 0; }
form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; }
form.inline button:hover { text-decoration: underline; background: none; }
main { max-width: 48rem; }
.card + .card { margin-top: 1.2rem; }
.flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
@media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; }
@media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } }
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
td.actions { text-align: right; }
a.danger { color: #b42318; }
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: inline-block; margin-bottom: 1rem; }
</style>
</head>
<body>
+59
View File
@@ -2,6 +2,7 @@ package web
import (
"fmt"
"strings"
"unicode"
)
@@ -40,3 +41,61 @@ func validateAdminPassword(p string) error {
func isASCIILetterOrDigit(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
// normalizeDomain lower-cases and trims a domain name. Domain names are
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
// lower-case form, so we normalise before both validation and storage.
func normalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// validateDomain enforces a strict server-side whitelist for sending-domain
// names (spec 7.6.2). The result is safe to write verbatim into the OpenDKIM
// KeyTable/SigningTable and to use as a filesystem path segment: only
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
// shape. Input must already be normalised with normalizeDomain.
//
// This is deliberately stricter than "any string DNS might accept" — no
// leading/trailing dots or hyphens, no empty or over-long labels, and at least
// two labels so single-word hostnames cannot be registered as sending domains.
func validateDomain(name string) error {
if name == "" {
return fmt.Errorf("domain is required")
}
if len(name) > maxDomainLen {
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
}
for _, label := range labels {
if err := validateDomainLabel(label); err != nil {
return err
}
}
return nil
}
func validateDomainLabel(label string) error {
if len(label) == 0 {
return fmt.Errorf("domain must not contain an empty label")
}
if len(label) > 63 {
return fmt.Errorf("each domain label must be at most 63 characters")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("domain labels must not start or end with '-'")
}
for i := 0; i < len(label); i++ {
c := label[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '-' {
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
}
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
package web
import "testing"
func TestNormalizeDomain(t *testing.T) {
cases := map[string]string{
" Example.COM ": "example.com",
"MAIL.Example.Org": "mail.example.org",
"example.com": "example.com",
}
for in, want := range cases {
if got := normalizeDomain(in); got != want {
t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidateDomainValid(t *testing.T) {
valid := []string{
"example.com",
"mail.example.com",
"a.co",
"sub-domain.example.co.uk",
"x1.y2.z3",
"1example.com",
}
for _, d := range valid {
if err := validateDomain(d); err != nil {
t.Errorf("validateDomain(%q) unexpected error: %v", d, err)
}
}
}
func TestValidateDomainInvalid(t *testing.T) {
invalid := []string{
"", // empty
"localhost", // single label
"example", // single label
".example.com", // leading dot -> empty label
"example.com.", // trailing dot -> empty label
"exa mple.com", // space
"example..com", // empty label
"-example.com", // label starts with '-'
"example-.com", // label ends with '-'
"example.com\n", // newline (config injection attempt)
"exa*mple.com", // disallowed char
"exa_mple.com", // underscore not allowed in domains
"Example.com", // upper-case (must be normalised first)
"пример.рф", // non-ASCII
"example.c/m", // slash (path-traversal attempt)
}
for _, d := range invalid {
if err := validateDomain(d); err == nil {
t.Errorf("validateDomain(%q) = nil, want error", d)
}
}
}
func TestValidateDomainLongLabelRejected(t *testing.T) {
label := make([]byte, 64)
for i := range label {
label[i] = 'a'
}
if err := validateDomain(string(label) + ".com"); err == nil {
t.Error("expected error for over-long label")
}
}
+16 -4
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"time"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
)
@@ -30,6 +31,7 @@ type Config struct {
// Server is the panel HTTP application.
type Server struct {
store *store.Store
domains *domain.Service
cfg Config
tmpl *templates
sessions *sessionStore
@@ -40,14 +42,16 @@ type Server struct {
}
// New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (spec 7.6.1).
func New(st *store.Store, cfg Config, setupTokenPath string) (*Server, error) {
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
// owns DKIM keys and the OpenDKIM tables (spec 6).
func New(st *store.Store, domains *domain.Service, cfg Config, setupTokenPath string) (*Server, error) {
tmpl, err := loadTemplates()
if err != nil {
return nil, err
}
s := &Server{
store: st,
domains: domains,
cfg: cfg,
tmpl: tmpl,
sessions: newSessionStore(),
@@ -85,8 +89,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/logout", s.handleLogout)
// Authenticated panel.
mux.Handle("/", s.requireAuth(http.HandlerFunc(s.handleDashboard)))
// Authenticated panel. Everything not matched by a more specific pattern
// above falls through to this sub-mux, wrapped once in the auth middleware.
authed := http.NewServeMux()
authed.HandleFunc("GET /{$}", s.handleDashboard)
authed.HandleFunc("POST /domains", s.handleAddDomain)
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
authed.HandleFunc("POST /reload", s.handleReload)
mux.Handle("/", s.requireAuth(authed))
return mux
}