chore/docs: move to GitHub as the single home; drop archived-spec references

Codeberg is being retired as the project's public site, so every reference now
points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost →
github.com/mixeme/selfpost): leaving an import path on a host that is going
away would break `go get` and `go install`, so this is not only a docs change.
Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags
version stamp in build/Dockerfile and docs/development.md, the licence headers
in the SVG/HTML assets, and README (no more primary/mirror pair).

Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1"
and friends pointed into docs/archive/specification-v1.0.md, which is marked as
not a source of truth; each is now a reference to the live document that owns
the subject — architecture.md (with section), product.md, security.md or the
README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/
8/9 had the same defect, so they went too. Comments only, no behaviour change.

Also closes the remaining review items: architecture.md gained a Code layers
section with the layer diagram (A2), and TestParseDelivery gained the exotic
mail.log cases (§ 3).

Fixes a bug that last test found: the delivery-line pattern matched status=
greedily, taking the *last* occurrence on the line. Postfix appends the remote
server's reply verbatim, so a rejection whose reply quoted "status=sent" was
filed as a delivered message in the send log. It now takes the first status=
after the recipient, which is the real field.

R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow,
so the file would have no audience yet. R1 (compose image tag) and the git tag
stay in roadmap § v1.x as the release-commit steps.

gofmt/go vet clean on both modules; go test ./... green except the three known
Windows-only failures (file perms, backslash paths, renaming an open file).
Not exercised on the dev server — no Docker locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:14:13 +03:00
parent ed0a786739
commit d49351c022
87 changed files with 896 additions and 623 deletions
+6 -6
View File
@@ -5,15 +5,15 @@ import (
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/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).
// and password chosen during setup are the only panel credentials
// (security.md), 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 (architecture.md § Mail path).
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -58,7 +58,7 @@ func accountFlash(r *http.Request) string {
// 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).
// that limit (security.md).
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderAccount(w, r, http.StatusTooManyRequests,
+14 -13
View File
@@ -7,13 +7,13 @@ import (
"strconv"
"strings"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/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.
// be shown exactly once (security.md). It is never read back from storage.
type newCred struct {
Login string
Password string
@@ -29,8 +29,9 @@ type detailView struct {
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 surfaces a validation error from a domain- or
// application-level rate-limit form (README § Rate limiting) as a page
// banner.
RateLimitErr string
// ExportErr surfaces a rejected encryption password from the export card.
ExportErr string
@@ -47,9 +48,9 @@ type appRateLimitView struct {
WindowVal string // window seconds, defaulted when unset
}
// handleDomainDetail shows a single domain: its DKIM DNS record (spec 7.2.10)
// handleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// and its applications with the controls to add, edit, delete and re-issue
// credentials (spec 7.2.5-9).
// credentials (product.md).
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
@@ -214,7 +215,7 @@ func detailFlash(r *http.Request) string {
}
// 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
// back with the generated password shown once (product.md, security.md). Because the
// password cannot be recovered later, this deliberately renders inline rather
// than redirecting.
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
@@ -253,7 +254,7 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
})
}
// handleUpdateAppMode switches an application's address mode / list (spec 7.2.7).
// handleUpdateAppMode switches an application's address mode / list (product.md).
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
@@ -282,7 +283,7 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
}
// 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.
// (product.md, security.md). 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 {
@@ -306,7 +307,7 @@ func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
}
// handleDeleteApplication removes an application and returns to its domain page
// (spec 7.2.8).
// (product.md).
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
@@ -343,7 +344,7 @@ func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (stor
// 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).
// validation happen in the app service (security.md).
func splitAddresses(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
@@ -127,7 +127,7 @@ func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string)
}
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
// Brute-force throttle by client IP (spec 7.6.5).
// Brute-force throttle by client IP (security.md).
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
+26 -25
View File
@@ -9,10 +9,10 @@ import (
"net/http"
"time"
"codeberg.org/mix/selfpost/internal/backup"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/secretfile"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
)
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
@@ -30,7 +30,7 @@ func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
}
// renderBackupPage draws the page; importErr surfaces a failed domain import
// (spec 7.5.B) next to the form that produced it.
// (architecture.md § Persistence) next to the form that produced it.
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
s.renderBackupPageWith(w, r, status, importErr, "")
}
@@ -50,14 +50,14 @@ func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, st
})
}
// handleBackup streams a full-server backup as a download (spec 7.5.A). It is an
// authenticated admin action (this handler sits behind the auth middleware). The
// archive carries DKIM private keys, the admin password hash and SASL
// credentials, so it is served with no-store and as an attachment to discourage
// caching of secret material. When the operator ticks "encrypt with a
// password", the archive is wrapped in a .spbk envelope on the way out, so the
// file that lands on their disk — wherever it is copied afterwards — is useless
// without the password.
// handleBackup streams a full-server backup as a download (architecture.md §
// Persistence). It is an authenticated admin action (this handler sits behind
// the auth middleware). The archive carries DKIM private keys, the admin
// password hash and SASL credentials, so it is served with no-store and as an
// attachment to discourage caching of secret material. When the operator ticks
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
// way out, so the file that lands on their disk — wherever it is copied
// afterwards — is useless without the password.
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
password, pwErr := secretFilePassword(r)
if pwErr != "" {
@@ -113,12 +113,12 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
}
}
// handleExportDomain streams a single-domain export as a secret download (spec
// 7.5.B). Like the full backup it is POST-only (state is not changed, but the
// response contains the domain's DKIM private key and application passwords, so
// it must not be prefetchable or cached). Like the full backup it can be
// encrypted with a password, in which case the download is a .spde envelope
// instead of plain JSON.
// handleExportDomain streams a single-domain export as a secret download
// (architecture.md § Persistence). Like the full backup it is POST-only (state
// is not changed, but the response contains the domain's DKIM private key and
// application passwords, so it must not be prefetchable or cached). Like the
// full backup it can be encrypted with a password, in which case the download
// is a .spde envelope instead of plain JSON.
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
@@ -175,11 +175,12 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
}
// handleImportDomain accepts an uploaded domain-export file and re-creates the
// domain on this instance (spec 7.5.B). The domain name is normalised and
// validated here (spec 7.6.2); the domain service validates the selector, each
// login and address, and the DKIM key before writing anything. On success it
// redirects to the new domain's page; on failure it re-renders the backup page,
// where the import form lives, with a friendly message.
// domain on this instance (architecture.md § Persistence). The domain name is
// normalised and validated here (security.md); the domain service validates
// the selector, each login and address, and the DKIM key before writing
// anything. On success it redirects to the new domain's page; on failure it
// re-renders the backup page, where the import form lives, with a friendly
// message.
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
@@ -243,7 +244,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
}
// Normalise and validate the domain name before it reaches the service, the
// same gate the add-domain form uses (spec 7.6.2).
// same gate the add-domain form uses (security.md).
exp.Domain = normalizeDomain(exp.Domain)
if err := validateDomain(exp.Domain); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
"testing"
"codeberg.org/mix/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/secretfile"
)
// postForm builds the kind of request the backup and export forms submit.
+10 -9
View File
@@ -6,12 +6,12 @@ import (
"net/http"
"strconv"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/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).
// form (product.md).
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.renderDashboard(w, r, http.StatusOK, "", "")
}
@@ -48,7 +48,7 @@ func dashboardFlash(r *http.Request) string {
// 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).
// publish is shown (product.md).
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.", "")
@@ -76,7 +76,7 @@ func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
}
// 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).
// panel must explicitly state that all bound applications go with it (product.md).
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
@@ -116,11 +116,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
}
// handleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the
// database and reloads its daemon, so the button doubles as a drift-recovery.
// The button lives on the status page: it is a "put the daemons
// back in the state the database describes" action, which belongs with the rest
// of the server-health screen rather than in the domain list's top bar.
// sender map on demand (architecture.md § Panel HTTP surface). Each Resync
// regenerates its files from the database and reloads its daemon, so the
// button doubles as a drift-recovery. The button lives on the status page: it
// is a "put the daemons back in the state the database describes" action,
// which belongs with the rest of the server-health screen rather than in the
// domain list's top bar.
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
if err := s.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err)
+14 -11
View File
@@ -6,12 +6,12 @@ import (
"net/http"
"strconv"
"codeberg.org/mix/selfpost/internal/logtail"
"codeberg.org/mix/selfpost/internal/postfix"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// sendLogPageSize bounds each send-log page (spec 7.2's monitoring screens
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
// call for pagination); logTailLines bounds how much of mail.log the log view
// shows per refresh.
const (
@@ -20,10 +20,10 @@ const (
)
// handleDeliveries renders the Deliveries page over the send log: server-side
// filters by domain/application and pagination (spec 7.3.3). The row table
// itself is the "deliveries_rows" fragment, shared verbatim with
// handleDeliveriesRows so the initial page and its HTMX-polled refreshes never
// diverge.
// filters by domain/application and pagination (architecture.md §
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled
// refreshes never diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
@@ -38,7 +38,8 @@ func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
}
// handleDeliveriesRows serves the HTMX polling fragment for the delivery table
// (spec 7.1: fragment endpoints return HTML, not JSON).
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
@@ -110,7 +111,8 @@ func parsePage(v string) int {
return n
}
// handleMailQueue renders the Mail queue page (spec 7.2.11).
// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// surface).
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.render(w, http.StatusOK, "mail_queue", map[string]any{
@@ -143,7 +145,8 @@ func readQueue() (string, string) {
return out, ""
}
// handleSystemLog renders the System log page over mail.log (spec 7.2.13).
// handleSystemLog renders the System log page over mail.log (architecture.md §
// Panel HTTP surface).
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "system_log", map[string]any{
+19 -16
View File
@@ -7,17 +7,18 @@ import (
"strconv"
"strings"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// defaultRateLimitWindowSeconds is the sliding-window length used when an admin
// sets a message ceiling but leaves the window blank (spec 7.4, matching the
// level-1 default hour, spec 8: RATE_LIMIT_WINDOW_SECONDS).
// defaultRateLimitWindowSeconds is the sliding-window length used when an
// admin sets a message ceiling but leaves the window blank (README § Rate
// limiting, matching the level-1 default hour; README § Environment variables:
// RATE_LIMIT_WINDOW_SECONDS).
const defaultRateLimitWindowSeconds = 3600
// rateLimitInput is the validated result of a rate-limit form submission. clear
// means "remove the differentiated limit" (spec 7.4: an empty IP binding leaves
// only level 1).
// rateLimitInput is the validated result of a rate-limit form submission.
// clear means "remove the differentiated limit" (README § Rate limiting: an
// empty IP binding leaves only level 1).
type rateLimitInput struct {
clear bool
ips []string
@@ -25,10 +26,10 @@ type rateLimitInput struct {
windowSeconds int
}
// parseRateLimitForm validates a rate-limit submission on the server (spec
// 7.6.2). It returns clear=true when the admin removes the limit or leaves the
// IP binding empty; otherwise it requires a positive ceiling and window. The
// returned error's message is safe to show to the admin.
// parseRateLimitForm validates a rate-limit submission on the server
// (security.md). It returns clear=true when the admin removes the limit or
// leaves the IP binding empty; otherwise it requires a positive ceiling and
// window. The returned error's message is safe to show to the admin.
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
@@ -41,7 +42,8 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{}, err
}
if len(ips) == 0 {
// No IP binding: the differentiated limit does not apply (spec 7.4).
// No IP binding: the differentiated limit does not apply (README § Rate
// limiting).
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
@@ -57,7 +59,7 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
// whitespace) into a deduplicated list of canonical addresses, rejecting any
// token that is not a valid IP (spec 7.6.2). The values are only ever stored as
// token that is not a valid IP (security.md). The values are only ever stored as
// SQLite parameters and compared in the milter, never written to a config file.
func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool {
@@ -89,8 +91,9 @@ func parsePositiveInt(raw string, def int) (int, error) {
return strconv.Atoi(raw)
}
// handleDomainRateLimit saves or clears a domain-level differentiated rate limit
// (spec 7.4). No reload is needed — the milter reads the row live.
// handleDomainRateLimit saves or clears a domain-level differentiated rate
// limit (README § Rate limiting). No reload is needed — the milter reads the
// row live.
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
@@ -113,7 +116,7 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
}
// handleAppRateLimit saves or clears an application-level differentiated rate
// limit (spec 7.4).
// limit (README § Rate limiting).
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
+3 -3
View File
@@ -8,11 +8,11 @@ import (
)
// handleSetup serves the one-time administrator creation flow at
// /setup/<token> (spec 7.6.1). Once an administrator exists the whole route
// /setup/<token> (security.md). Once an administrator exists the whole route
// returns 404; an invalid or expired token is indistinguishable from a missing
// page, also 404.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
// Route-specific rate limit, separate from login (spec 7.6.1).
// Route-specific rate limit, separate from login (security.md).
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
@@ -92,7 +92,7 @@ func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token strin
return
}
// Setup is now permanently complete: burn the token (spec 7.6.1).
// Setup is now permanently complete: burn the token (security.md).
s.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", http.StatusSeeOther)
+7 -5
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"strings"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// handleStatus renders the server status page: the panel's landing page and the
@@ -26,7 +26,8 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
}
// handleStatusFragment serves the HTMX polling fragment for the local checks
// (spec 7.1: fragment endpoints return HTML, not JSON).
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
}
@@ -90,9 +91,10 @@ func (s *Server) statusBody() map[string]any {
}
}
// queueSummary reduces postqueue's listing to the one line worth showing on the
// status page; the full listing has its own screen (spec 7.2.11). postqueue
// prints either "Mail queue is empty" or a trailing "-- N Kbytes in M Requests."
// queueSummary reduces postqueue's listing to the one line worth showing on
// the status page; the full listing has its own screen (architecture.md §
// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a
// trailing "-- N Kbytes in M Requests."
func queueSummary(out string) string {
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
// rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (spec 7.6.1, 7.6.5). Keys are client IPs. It is not a
// setup and login routes (security.md). Keys are client IPs. It is not a
// precise sliding window — a coarse backstop against brute-force and log noise
// is all these routes need.
type rateLimiter struct {
+1 -1
View File
@@ -20,7 +20,7 @@ import (
// - form-action 'self' keeps a form from being retargeted at another host.
//
// This is a second line of defence: XSS is already prevented by
// html/template's contextual auto-escaping (spec 7.6.7).
// html/template's contextual auto-escaping (security.md).
const contentSecurityPolicy = "default-src 'self'; " +
"object-src 'none'; " +
"base-uri 'none'; " +
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// renewThreshold bounds how often an active session's expiry is written back
@@ -16,7 +16,7 @@ const renewThreshold = time.Hour
// sessionStore persists login sessions in the database (plan B.1): a login
// survives a container restart or redeploy. Only the SHA-256 of the token is
// stored, never the token itself (spec 7.6.6's crypto-random bearer token), so
// stored, never the token itself (security.md's crypto-random bearer token), so
// a stolen database file or backup archive cannot be replayed as a session —
// it only extends the login of whichever browser still holds the original
// cookie.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
func newTestSessionStore(t *testing.T) *sessionStore {
+7 -7
View File
@@ -7,17 +7,17 @@ import (
"sync"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// setupTokenTTL is the lifetime of a setup token (spec 7.6.1). After it
// setupTokenTTL is the lifetime of a setup token (security.md). After it
// elapses the token is regenerated and re-announced on the next /setup hit.
const setupTokenTTL = 10 * time.Minute
// setupManager owns the one-time administrator setup token. The token itself is
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
// persistent "setup complete" fact is the presence of the admin row in the
// store, so once that exists the token is gone for good (spec 7.6.1).
// store, so once that exists the token is gone for good (security.md).
type setupManager struct {
store *store.Store
hostname string
@@ -71,8 +71,8 @@ func (m *setupManager) activeToken() (string, bool) {
// validate reports whether provided matches the active token, using a
// constant-time comparison to avoid leaking a correct prefix via timing
// (spec 7.6.1). A mismatch does NOT regenerate or invalidate the token: failed
// attempts must not let an attacker DoS a legitimate setup (spec 7.6.1).
// (security.md). A mismatch does NOT regenerate or invalidate the token: failed
// attempts must not let an attacker DoS a legitimate setup (security.md).
func (m *setupManager) validate(provided string) bool {
token, ok := m.activeToken()
if !ok {
@@ -94,13 +94,13 @@ func (m *setupManager) complete() {
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
// Caller holds m.mu.
func (m *setupManager) regenerateLocked() {
m.token = randomToken(16) // 128 bits of entropy (spec 7.6.1)
m.token = randomToken(16) // 128 bits of entropy (security.md)
m.expiresAt = time.Now().Add(setupTokenTTL)
m.announce(m.token)
}
// announce prints the setup link to the container log and writes it to the
// token file so it can be read either way (spec 7.6.1).
// token file so it can be read either way (security.md).
func (m *setupManager) announce(token string) {
url := m.setupURL(token)
logf("panel: ==================================================================")
+1 -1
View File
@@ -1,5 +1,5 @@
<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
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
file needs no font installed to render as drawn. -->
<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" />

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<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
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
file needs no font installed to render as drawn. -->
<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" />

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
<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
<!-- SelfPost · https://github.com/mixeme/selfpost · AGPL-3.0
Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the
file needs no font installed to render as drawn. -->
<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" />

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+13 -10
View File
@@ -9,21 +9,23 @@ import (
// templates holds the parsed page and fragment templates. Each page is parsed
// together with the shared base layout so {{ template "base" . }} works.
// Fragments (HTMX polling targets, spec 7.1) are parsed standalone, without
// the layout, so they can be swapped into an existing page as an HTML snippet
// rather than a full document. Rendering always goes through html/template,
// which auto-escapes all interpolated data regardless (spec 7.6.7).
// Fragments (HTMX polling targets, architecture.md § Panel HTTP surface) are
// parsed standalone, without the layout, so they can be swapped into an
// existing page as an HTML snippet rather than a full document. Rendering
// always goes through html/template, which auto-escapes all interpolated data
// regardless (security.md).
type templates struct {
pages map[string]*template.Template
fragments map[string]*template.Template
}
// pageFiles maps a logical page name to its template files. Every page
// composes with layout.html; pages that embed a polling fragment (spec 7.1)
// list that fragment's file too, so the same {{define}} block renders both
// the initial page and the fragment's own refresh responses identically. Pages
// sharing a block of markup (the encryption fields on the two secret downloads)
// list that partial the same way.
// composes with layout.html; pages that embed a polling fragment
// (architecture.md § Panel HTTP surface) list that fragment's file too, so the
// same {{define}} block renders both the initial page and the fragment's own
// refresh responses identically. Pages sharing a block of markup (the
// encryption fields on the two secret downloads) list that partial the same
// way.
var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
@@ -101,7 +103,8 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
}
// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with
// no surrounding layout (spec 7.1: fragment endpoints return HTML, not JSON).
// no surrounding layout (architecture.md § Panel HTTP surface: fragment
// endpoints return HTML, not JSON).
func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) {
tmpl, ok := s.tmpl.fragments[name]
if !ok {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing"
"time"
"codeberg.org/mix/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/health"
)
// The navigation is rendered from the layout, not copied into each page, so
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// randomToken returns a URL-safe token with at least nBytes*8 bits of entropy
// drawn from crypto/rand. Setup and session tokens both use this; the setup
// token needs >=128 bits (spec 7.6.1), so callers pass nBytes >= 16.
// token needs >=128 bits (security.md), so callers pass nBytes >= 16.
//
// It panics if the system RNG fails: that is unrecoverable and must never be
// papered over with a weak fallback for a security token.
+3 -3
View File
@@ -7,7 +7,7 @@ import (
)
// minAdminPasswordLen is the floor for the administrator password. The panel is
// public (spec 7.6), so this is deliberately not tiny.
// public (security.md), so this is deliberately not tiny.
const minAdminPasswordLen = 12
const (
@@ -21,7 +21,7 @@ const (
// weaker "any password is better than none".
const minSecretFilePasswordLen = minAdminPasswordLen
// validateUsername enforces a strict server-side whitelist (spec 7.6.2):
// validateUsername enforces a strict server-side whitelist (security.md):
// letters, digits, dot, dash, underscore. Client validation is never trusted.
func validateUsername(u string) error {
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
@@ -58,7 +58,7 @@ func normalizeDomain(name string) string {
}
// 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
// names (security.md). 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.
+33 -28
View File
@@ -1,6 +1,6 @@
// Package web implements the SelfPost control panel's HTTP surface: the
// one-time administrator setup flow (spec 7.6.1), login/session handling
// (spec 7.6.5-6) and the authenticated shell the later phases build on.
// one-time administrator setup flow (security.md), login/session handling
// (security.md) and the authenticated shell the later phases build on.
package web
import (
@@ -11,11 +11,11 @@ import (
"strings"
"time"
"codeberg.org/mix/selfpost/internal/app"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/health"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
)
//go:embed templates/*.html static/*
@@ -24,24 +24,26 @@ var assetsFS embed.FS
// Config holds the panel's HTTP-facing configuration.
type Config struct {
// Hostname is the server's external hostname, used to build the absolute
// setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME).
// setup link shown in the logs (security.md; README § Environment
// variables for SELFPOST_HOSTNAME).
Hostname string
// CookieSecure sets the Secure attribute on the session cookie. It defaults
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
// to true (security.md); it exists as a knob only so the panel can be tested
// over plain HTTP in development, never for production.
CookieSecure bool
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
// runs the 587/STARTTLS submission listener next to the primary 465 one
// (spec 5). The panel only reports it on the domain page's connection
// settings; it is a deploy-time flag, not something the panel can verify.
// (architecture.md § Mail path). The panel only reports it on the domain
// page's connection settings; it is a deploy-time flag, not something the
// panel can verify.
SubmissionEnabled bool
// MailLogPath is where Postfix's delivery log lives, read by the mail.log
// monitoring view (spec 7.2.13). It is the same path the log-tailer role
// follows in cmd/panel.
// monitoring view (architecture.md § Panel HTTP surface). It is the same path
// the log-tailer role follows in cmd/panel.
MailLogPath string
// DataDir and DBPath locate the persistent state a full backup archives
// (spec 7.5.A); Version is stamped into the backup manifest. They mirror the
// panel's own configuration.
// (architecture.md § Persistence); Version is stamped into the backup
// manifest. They mirror the panel's own configuration.
DataDir string
DBPath string
Version string
@@ -51,8 +53,9 @@ type Config struct {
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
TrustedProxyCIDRs []*net.IPNet
// TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read
// read-only by the status page to report how much validity is left.
// TLSCertFile is the certificate Postfix serves on 465/587 (README §
// Environment variables), read read-only by the status page to report how
// much validity is left.
TLSCertFile string
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
// connects to. The status page stats them: the first is required for mail
@@ -89,9 +92,10 @@ type Server struct {
}
// New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL
// accounts and the Postfix sender map (spec 5.1).
// is mirrored on disk (security.md); domains is the sending-domain service
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
// apps owns application SASL accounts and the Postfix sender map
// (architecture.md § Mail path).
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
tmpl, err := loadTemplates()
if err != nil {
@@ -113,9 +117,9 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
// round of lookups.
dns: dnscheck.New(cfg.DNSResolvers),
// Setup: a handful of attempts per minute per IP is plenty for a
// legitimate admin and blunts automated probing (spec 7.6.1).
// legitimate admin and blunts automated probing (security.md).
setupLimiter: newRateLimiter(10, time.Minute),
// Login: throttle brute-force by IP (spec 7.6.5).
// Login: throttle brute-force by IP (security.md).
loginLimiter: newRateLimiter(10, 15*time.Minute),
trustedProxies: cfg.TrustedProxyCIDRs,
@@ -125,7 +129,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
}
// Start performs first-run bootstrapping: if there is no administrator yet, it
// generates and announces the setup link (spec 7.6.1). Safe to call once at
// generates and announces the setup link (security.md). Safe to call once at
// server startup.
func (s *Server) Start() error {
return s.setup.bootstrap()
@@ -141,7 +145,7 @@ func (s *Server) Handler() http.Handler {
// Vendored static assets (HTMX). Served from the embedded FS.
mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))
// One-time administrator setup (spec 7.6.1).
// One-time administrator setup (security.md).
mux.HandleFunc("/setup/", s.handleSetup)
// Authentication.
@@ -179,13 +183,14 @@ func (s *Server) Handler() http.Handler {
// Administrator's own panel credentials.
authed.HandleFunc("/account", s.handleAccount)
// Backup and migration: the page with both actions (spec 7.5.A-B), and the
// full-server backup download itself.
// Backup and migration: the page with both actions (architecture.md §
// Persistence-B), and the full-server backup download itself.
authed.HandleFunc("GET /backup", s.handleBackupPage)
authed.HandleFunc("POST /backup", s.handleBackup)
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
// Monitoring screens (architecture.md § Panel HTTP surface): each page and
// its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows
// and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
authed.HandleFunc("GET /mail-queue", s.handleMailQueue)