release: 0.5.0: Pre-1.0 security review, domain-admin role, doc consolidation
A bounce could be recorded as a successful delivery. The log-tailer's Fixed: - A bounce could be recorded as a successful delivery. The log-tailer's - Log-tailer resumes where it stopped instead of jumping to end-of-file on - Level-2 rate limit no longer overshoots under concurrency: messages that Changed: - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg - Code comments no longer cite the archived specification. References like - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the - `docs/architecture.md` gained a *Code layers* section: a diagram of - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale … (1 more in CHANGELOG) Security: - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass - `saslpasswd2` argv: the application login is now passed after a `--` Added: - Optional password encryption for the two secret-bearing downloads (plan - Domain import accepts an encrypted export: the envelope is detected by its - `selfpost-backup` writes encrypted archives and reads them back: - `TestParseDelivery` covers the exotic mail.log shapes the review asked for - docs: README *Encrypting a backup or export*; `docs/security.md` § … (14 more in CHANGELOG) Removed: - docs: `security.md` — accepted risk «restore old backup revives session rows» Changed: - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release - docs: `architecture.md` — sessions (SQLite, idle renew, password change), - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow. - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail. … (10 more in CHANGELOG) Full detail: CHANGELOG.md (0.5.0).
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,9 +29,12 @@ 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
|
||||
}
|
||||
|
||||
// appRateLimitView pairs an application with its differentiated rate-limit
|
||||
@@ -45,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 {
|
||||
@@ -97,8 +100,8 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
}
|
||||
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with (phase 13.B). Cached by the checker, so
|
||||
// re-rendering the page after a form post costs nothing.
|
||||
// this server signs with. Cached by the checker, so re-rendering the page
|
||||
// after a form post costs nothing.
|
||||
dns, srv := s.domainDNS(d, record, false)
|
||||
|
||||
s.render(w, status, "domain_detail", map[string]any{
|
||||
@@ -131,6 +134,8 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
"Wildcard": store.AddressModeWildcard,
|
||||
"List": store.AddressModeList,
|
||||
"RateLimitErr": view.RateLimitErr,
|
||||
"ExportErr": view.ExportErr,
|
||||
"MinPwLen": minSecretFilePasswordLen,
|
||||
"DomainHasRL": domainRLok && domainRL.Active(),
|
||||
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
||||
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
||||
@@ -139,7 +144,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
}
|
||||
|
||||
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
|
||||
// records (phase 13.B). The server's own address comes from the (separately
|
||||
// records. The server's own address comes from the (separately
|
||||
// cached) hostname check, so the SPF heuristic knows which IP it is looking for
|
||||
// and no extra environment variable is needed. That server result is returned
|
||||
// alongside, because the page's suggested SPF record is built from the same
|
||||
@@ -210,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) {
|
||||
@@ -249,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 {
|
||||
@@ -278,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 {
|
||||
@@ -302,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 {
|
||||
@@ -339,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 == ';'
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
|
||||
// what the cookie's attributes merely promise into something the browser
|
||||
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
|
||||
// attribute, so no other host may set a cookie by this name (phase 14.B).
|
||||
// attribute, so no other host may set a cookie by this name.
|
||||
// The prefix is only valid on a Secure cookie, so a development instance on
|
||||
// plain HTTP has to keep the bare name: with the prefix the browser would
|
||||
// discard the Set-Cookie outright and logging in would silently never stick.
|
||||
@@ -42,7 +42,7 @@ func (s *Server) sessionCookie() string {
|
||||
// is denial of service, not compromise; refusing the request and saying so in
|
||||
// the log is what makes it diagnosable instead of an endless login loop. The
|
||||
// __Host- prefix prevents this outright, but only where it applies — this
|
||||
// check also covers the plain-HTTP development shape (phase 14.B).
|
||||
// check also covers the plain-HTTP development shape.
|
||||
func (s *Server) sessionToken(r *http.Request) (string, bool) {
|
||||
name := s.sessionCookie()
|
||||
var token string
|
||||
@@ -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
|
||||
|
||||
+195
-28
@@ -1,20 +1,25 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/backup"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"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
|
||||
// document (a DKIM key and a handful of credentials); this leaves generous head
|
||||
// room while refusing anything large enough to be an abuse attempt.
|
||||
// room while refusing anything large enough to be an abuse attempt. An
|
||||
// encrypted export adds only a header and per-chunk tags, so the same ceiling
|
||||
// covers both forms.
|
||||
const maxImportBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// handleBackupPage renders the backup/migration screen: the full-server backup
|
||||
@@ -25,50 +30,108 @@ 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, "")
|
||||
}
|
||||
|
||||
// renderBackupPageWith is renderBackupPage with the second of the page's two
|
||||
// error slots: backupErr belongs to the full-backup card (a rejected encryption
|
||||
// password), importErr to the import card, so neither message appears under the
|
||||
// wrong form.
|
||||
func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
||||
s.render(w, status, "backup", map[string]any{
|
||||
"Title": "SelfPost — backup",
|
||||
"User": currentUser(r),
|
||||
"Active": "backup",
|
||||
"ImportErr": importErr,
|
||||
"BackupErr": backupErr,
|
||||
"MinPwLen": minSecretFilePasswordLen,
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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) {
|
||||
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
stamp := time.Now().UTC().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp)
|
||||
contentType := "application/gzip"
|
||||
if password != "" {
|
||||
filename = fmt.Sprintf("selfpost-backup-%s%s", stamp, secretfile.ExtBackup)
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if err := backup.Create(w, backup.Params{
|
||||
// Everything below streams: past this point headers (and possibly some
|
||||
// bytes) are already on the wire, so a failure cannot switch to a clean
|
||||
// error page. Log it and let the truncated download fail loudly on the
|
||||
// client side — for an encrypted archive that is a missing end-of-stream
|
||||
// chunk, which decryption refuses outright.
|
||||
sink := io.Writer(w)
|
||||
var env *secretfile.Writer
|
||||
if password != "" {
|
||||
var err error
|
||||
// The only failures here are key derivation (which happens before
|
||||
// anything is written) and writing the envelope header, which fails only
|
||||
// if the client is already gone.
|
||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||
if err != nil {
|
||||
logf("panel: full backup: encrypt: %v", err)
|
||||
http.Error(w, "backup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sink = env
|
||||
}
|
||||
|
||||
if err := backup.Create(sink, backup.Params{
|
||||
DataDir: s.cfg.DataDir,
|
||||
DBPath: s.cfg.DBPath,
|
||||
Version: s.cfg.Version,
|
||||
}); err != nil {
|
||||
// Headers (and possibly some bytes) may already be on the wire, so we
|
||||
// cannot switch to a clean error page; log it and let the truncated
|
||||
// download fail loudly on the client side.
|
||||
logf("panel: full backup failed: %v", err)
|
||||
return
|
||||
}
|
||||
if env != nil {
|
||||
if err := env.Close(); err != nil {
|
||||
logf("panel: full backup failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
ExportErr: pwErr,
|
||||
})
|
||||
return
|
||||
}
|
||||
exp, err := s.domains.Export(d.ID)
|
||||
if err != nil {
|
||||
logf("panel: export domain %d: %v", d.ID, err)
|
||||
@@ -83,18 +146,41 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("selfpost-domain-%s.json", d.Name)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
contentType := "application/json"
|
||||
if password != "" {
|
||||
// An export is small, so it is sealed in memory: the response is only
|
||||
// started once the ciphertext is complete and nothing can half-fail.
|
||||
var buf bytes.Buffer
|
||||
env, err := secretfile.NewWriter(&buf, secretfile.TypeDomainExport, password)
|
||||
if err == nil {
|
||||
_, err = env.Write(body)
|
||||
}
|
||||
if err == nil {
|
||||
err = env.Close()
|
||||
}
|
||||
if err != nil {
|
||||
logf("panel: export domain %d: encrypt: %v", d.ID, err)
|
||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body = buf.Bytes()
|
||||
filename = fmt.Sprintf("selfpost-domain-%s%s", d.Name, secretfile.ExtDomainExport)
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -108,8 +194,49 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// An encrypted export announces itself with the envelope magic, so the file
|
||||
// decides which path it takes; the password field is only consulted when the
|
||||
// file actually needs it, and a password typed for a plain file is a plain
|
||||
// mistake worth reporting.
|
||||
head := make([]byte, secretfile.MagicLen)
|
||||
n, err := io.ReadFull(file, head)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
|
||||
return
|
||||
}
|
||||
source := io.MultiReader(bytes.NewReader(head[:n]), file)
|
||||
password := r.PostFormValue("import_password")
|
||||
|
||||
if secretfile.HasMagic(head[:n]) {
|
||||
if password == "" {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
|
||||
return
|
||||
}
|
||||
env, err := secretfile.NewReader(source, password)
|
||||
if err != nil {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||
return
|
||||
}
|
||||
if env.Type() != secretfile.TypeDomainExport {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
|
||||
return
|
||||
}
|
||||
// Read the whole plaintext first: authentication of the last chunk is
|
||||
// what proves the file is intact, and a streaming JSON decoder could
|
||||
// accept a truncated document before ever reaching it.
|
||||
plain, err := io.ReadAll(env)
|
||||
if err != nil {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||
return
|
||||
}
|
||||
source = bytes.NewReader(plain)
|
||||
} else if password != "" {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
|
||||
return
|
||||
}
|
||||
|
||||
var exp domain.DomainExport
|
||||
dec := json.NewDecoder(file)
|
||||
dec := json.NewDecoder(source)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&exp); err != nil {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
||||
@@ -117,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())
|
||||
@@ -134,6 +261,46 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// secretFilePassword reads the "encrypt this download with a password" controls
|
||||
// shared by the full-backup and domain-export forms. It returns the password to
|
||||
// encrypt with — empty when the box is not ticked, which keeps the plain
|
||||
// .tar.gz/.json behaviour of earlier versions — or a message to show above the
|
||||
// form. The confirmation field is checked here rather than in the browser
|
||||
// because a typo in an encryption password is unrecoverable: the archive would
|
||||
// be sealed with a secret the operator does not know.
|
||||
func secretFilePassword(r *http.Request) (password, errMsg string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return "", "Invalid form submission."
|
||||
}
|
||||
if r.PostFormValue("encrypt") == "" {
|
||||
return "", ""
|
||||
}
|
||||
password = r.PostFormValue("password")
|
||||
if len([]rune(password)) < minSecretFilePasswordLen {
|
||||
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen)
|
||||
}
|
||||
if password != r.PostFormValue("password_confirm") {
|
||||
return "", "The two passwords do not match."
|
||||
}
|
||||
return password, ""
|
||||
}
|
||||
|
||||
// decryptErrorMessage phrases an envelope failure for the operator. A wrong
|
||||
// password and a damaged file are deliberately indistinguishable to the code,
|
||||
// so the message names both possibilities.
|
||||
func decryptErrorMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, secretfile.ErrWrongPassword):
|
||||
return "Wrong password, or the file has been altered since it was exported."
|
||||
case errors.Is(err, secretfile.ErrCorrupt):
|
||||
return "That file is damaged or incomplete."
|
||||
case errors.Is(err, secretfile.ErrNotEncrypted):
|
||||
return "That file is not a SelfPost export."
|
||||
default:
|
||||
return "Could not decrypt the file."
|
||||
}
|
||||
}
|
||||
|
||||
// importErrorMessage maps a domain-import failure (already logged by the caller)
|
||||
// to an HTTP status and a user-facing message. Duplicate domain/login are called
|
||||
// out specifically; other failures — validation errors describing what is wrong
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
)
|
||||
|
||||
// postForm builds the kind of request the backup and export forms submit.
|
||||
func postForm(values url.Values) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/backup", strings.NewReader(values.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
|
||||
// The encryption password is only ever typed once into a file nobody can
|
||||
// recover without it, so every way of getting it wrong has to be caught before
|
||||
// the archive is sealed — and leaving the box unticked has to keep producing
|
||||
// the plain archive earlier versions produced.
|
||||
func TestSecretFilePassword(t *testing.T) {
|
||||
long := strings.Repeat("x", minSecretFilePasswordLen)
|
||||
short := strings.Repeat("x", minSecretFilePasswordLen-1)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
form url.Values
|
||||
wantPass string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "unticked box means no encryption",
|
||||
form: url.Values{"password": {long}, "password_confirm": {long}},
|
||||
wantPass: "",
|
||||
},
|
||||
{
|
||||
name: "ticked with a matching password",
|
||||
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long}},
|
||||
wantPass: long,
|
||||
},
|
||||
{
|
||||
name: "mistyped confirmation",
|
||||
form: url.Values{"encrypt": {"1"}, "password": {long}, "password_confirm": {long + "!"}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
form: url.Values{"encrypt": {"1"}, "password": {short}, "password_confirm": {short}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ticked but empty",
|
||||
form: url.Values{"encrypt": {"1"}},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pass, errMsg := secretFilePassword(postForm(tt.form))
|
||||
if tt.wantErr {
|
||||
if errMsg == "" {
|
||||
t.Fatalf("password %q accepted, want a rejection", pass)
|
||||
}
|
||||
if pass != "" {
|
||||
t.Errorf("a rejected form still yielded password %q", pass)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errMsg != "" {
|
||||
t.Fatalf("unexpected rejection: %s", errMsg)
|
||||
}
|
||||
if pass != tt.wantPass {
|
||||
t.Errorf("password = %q, want %q", pass, tt.wantPass)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The messages the import form shows must distinguish the operator's likely
|
||||
// mistakes; a wrong password and a tampered file stay deliberately merged.
|
||||
func TestDecryptErrorMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{secretfile.ErrWrongPassword, "Wrong password"},
|
||||
{fmt.Errorf("read: %w", secretfile.ErrCorrupt), "damaged"},
|
||||
{secretfile.ErrNotEncrypted, "not a SelfPost export"},
|
||||
{errors.New("something else"), "Could not decrypt"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := decryptErrorMessage(tt.err); !strings.Contains(got, tt.want) {
|
||||
t.Errorf("decryptErrorMessage(%v) = %q, want it to mention %q", tt.err, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The encryption controls are shared markup pulled into two pages; a page that
|
||||
// forgets to include the partial (or the data it needs) loses the option
|
||||
// silently, since the plain download still works.
|
||||
func TestBackupPageOffersEncryption(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
|
||||
http.StatusOK, "", "The two passwords do not match.")
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
|
||||
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
|
||||
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
|
||||
"The two passwords do not match.",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("backup page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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). Applications and the send log arrive in later phases.
|
||||
// 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 (phase 13.D): 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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,12 +4,12 @@ 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
|
||||
// one screen that answers "is the service healthy and will mail be accepted"
|
||||
// (phase 13.A). The cheap local checks live in the polled "status_body"
|
||||
// one screen that answers "is the service healthy and will mail be accepted".
|
||||
// The cheap local checks live in the polled "status_body"
|
||||
// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
|
||||
// because neither belongs on a five-second timer.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -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-- {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
// contentSecurityPolicy is the panel's CSP. Everything the pages load —
|
||||
// stylesheet, HTMX, the panel's own script, the favicon — is served from
|
||||
// /static by this same origin, and no template carries an inline <script>,
|
||||
// an inline event handler or a style="..." attribute (phase 14.A; the
|
||||
// an inline event handler or a style="..." attribute (the
|
||||
// template guard test enforces that), so 'self' needs no exemptions:
|
||||
//
|
||||
// - default-src 'self' covers scripts, styles, images and the XHR that
|
||||
@@ -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'; " +
|
||||
@@ -38,8 +38,8 @@ const contentSecurityPolicy = "default-src 'self'; " +
|
||||
// subdomain of that domain for a year, which is not SelfPost's call to make.
|
||||
const strictTransportSecurity = "max-age=31536000"
|
||||
|
||||
// secure wraps the whole router with the panel's two transport-level defences
|
||||
// (phase 14.A): the security response headers, and an origin check on every
|
||||
// secure wraps the whole router with the panel's two transport-level
|
||||
// defences: the security response headers, and an origin check on every
|
||||
// state-changing request.
|
||||
//
|
||||
// It sits outside the authentication middleware on purpose, so that POST
|
||||
@@ -81,7 +81,7 @@ func (s *Server) secure(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// originAllowed reports whether a state-changing request came from the panel's
|
||||
// own origin (phase 14.A). This is what the session cookie's
|
||||
// own origin. This is what the session cookie's
|
||||
// SameSite=Lax attribute cannot do on its own: SameSite is judged per *site*
|
||||
// (registrable domain), so a neighbouring subdomain — a CMS on the same
|
||||
// domain, a forgotten staging host — counts as same-site and its forged POST
|
||||
@@ -111,9 +111,9 @@ func originAllowed(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Neither header. A client this old cannot be checked at all; it is
|
||||
// let through as the risk consciously accepted for phase 14.A
|
||||
// (single-admin panel, the administrator picks the browser). Turning
|
||||
// this return into false is the whole of the stricter policy.
|
||||
// let through as the risk consciously accepted (single-admin panel,
|
||||
// the administrator picks the browser). Turning this return into
|
||||
// false is the whole of the stricter policy.
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
|
||||
@@ -32,7 +32,7 @@ func post(secFetchSite, origin string) *http.Request {
|
||||
return r
|
||||
}
|
||||
|
||||
// The full matrix the origin check has to get right (phase 14.A). The row that
|
||||
// The full matrix the origin check has to get right. The row that
|
||||
// matters most is "same-site": a neighbouring host on example.com is same-site
|
||||
// as far as the session cookie's SameSite=Lax is concerned, so this check is
|
||||
// the only thing standing between it and a forged POST.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,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,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,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 |
@@ -1,21 +1,60 @@
|
||||
/* Panel stylesheet. It lives in a file rather than in a <style> block in the
|
||||
layout so the panel's Content-Security-Policy can be a plain
|
||||
"default-src 'self'" with no inline-style exemption (phase 14.A). Any rule
|
||||
"default-src 'self'" with no inline-style exemption. Any rule
|
||||
added here must therefore stay here: an inline style="..." attribute in a
|
||||
template is blocked by that policy and silently does nothing. */
|
||||
|
||||
:root { color-scheme: light dark; }
|
||||
/* Colour tokens. Light values live on :root; the dark media query below
|
||||
reassigns the same names rather than re-declaring every rule that uses
|
||||
them, so a rule needs !important nowhere in this file — the custom
|
||||
property already carries the right value for the active scheme. */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9; --fg: #1b1f24;
|
||||
--card-bg: #fff;
|
||||
--border: #e2e5e9; /* dividers: card, table, app, nav, code, encrypt-fields */
|
||||
--control-border: #cfd4da; /* input, select, textarea, action buttons */
|
||||
--input-bg: #fff;
|
||||
--code-bg: #f0f2f4;
|
||||
--surface-bg: #eef1f5; --surface-bg-hover: #e2e7ee; --surface-open-bg: #dde3ec;
|
||||
--nav-active-bg: #e6ebf5;
|
||||
--flash-bg: #ecfdf3; --flash-border: #abefc6; --flash-fg: #067647;
|
||||
--credential-bg: #fffbeb; --credential-border: #f5c518;
|
||||
--danger-bg: #fef3f2; --danger-border: #fecdca; --danger-fg: #b42318; --danger-bg-hover: #fee4e2;
|
||||
--st-ok-bg: #ecfdf3; --st-ok-fg: #067647; --st-ok-border: #abefc6;
|
||||
--st-warn-bg: #fffaeb; --st-warn-fg: #b54708; --st-warn-border: #fedf89;
|
||||
--st-error-bg: #fef3f2; --st-error-fg: #b42318; --st-error-border: #fecdca;
|
||||
--st-unknown-bg: #f0f2f4; --st-unknown-fg: #6b7280; --st-unknown-border: #e2e5e9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14171a; --fg: #e6e8eb;
|
||||
--card-bg: #1d2125;
|
||||
--border: #2b3138;
|
||||
--control-border: #2b3138;
|
||||
--input-bg: #14171a;
|
||||
--code-bg: #14171a;
|
||||
--surface-bg: #22262b; --surface-bg-hover: #2b3138; --surface-open-bg: #313841;
|
||||
--nav-active-bg: #22303f;
|
||||
--flash-bg: #0d2818; --flash-border: #1a5336; --flash-fg: #75d99b;
|
||||
--credential-bg: #2a2408; --credential-border: #6b5a10;
|
||||
--danger-bg: #2d1211; --danger-border: #6b201a; --danger-fg: #f5a29b; --danger-bg-hover: #3d1a18;
|
||||
--st-ok-bg: #0d2818; --st-ok-fg: #75d99b; --st-ok-border: #1a5336;
|
||||
--st-warn-bg: #2e2308; --st-warn-fg: #f5c86b; --st-warn-border: #6b5210;
|
||||
--st-error-bg: #2d1211; --st-error-fg: #f5a29b; --st-error-border: #6b201a;
|
||||
--st-unknown-bg: #22262b; --st-unknown-fg: #9aa3ad; --st-unknown-border: #2b3138;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24;
|
||||
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #14171a; color: #e6e8eb; }
|
||||
.card { background: #1d2125 !important; border-color: #2b3138 !important; }
|
||||
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
main { max-width: 42rem; margin: 0 auto; }
|
||||
/* 48rem is a reading measure: right for the forms and prose that make up most
|
||||
of the panel, and the width the navigation bar's two rows were sized for.
|
||||
Page-specific overrides below widen or narrow it for the pages that need
|
||||
something else. */
|
||||
main { max-width: 48rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
/* The full mark, on the two pages that have no navigation bar to carry the
|
||||
compact one. It takes the column's width so its edges line up with the card
|
||||
@@ -25,14 +64,14 @@ h1 { font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
once the body's padding is taken off. */
|
||||
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
|
||||
.card {
|
||||
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px;
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1.5rem; margin: 0 auto;
|
||||
}
|
||||
.card.narrow { max-width: 24rem; }
|
||||
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||
input {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid #cfd4da; border-radius: 6px; background: #fff;
|
||||
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
|
||||
}
|
||||
/* One vocabulary for actions. Anything that performs an action looks like a
|
||||
button: a <button>, or an <a> carrying .btn/.danger where the action is a
|
||||
@@ -56,14 +95,12 @@ button:hover, a.btn:hover { background: #1d4ed8; }
|
||||
/* form.inline only exists so a POST can sit next to other content without a
|
||||
form's block layout; its button is styled like any other. */
|
||||
form.inline { display: inline; margin: 0; }
|
||||
main { max-width: 48rem; }
|
||||
/* 48rem is a reading measure: right for the forms and prose that make up most
|
||||
of the panel, and the width the navigation bar's two rows were sized for. The
|
||||
three monitoring pages hold data instead — seven columns of send-log, and raw
|
||||
mail.log lines that are long by nature — and at that width the send-log's
|
||||
Subject and Status were fighting over the last inch while the log pages wrapped
|
||||
every second line. They get a wider measure; every other page keeps the narrow
|
||||
one. The class comes from the layout, which stamps the page name onto <main>. */
|
||||
/* The three monitoring pages hold data instead of prose — seven columns of
|
||||
send-log, and raw mail.log lines that are long by nature — and at the
|
||||
default measure the send-log's Subject and Status were fighting over the
|
||||
last inch while the log pages wrapped every second line. They get a wider
|
||||
measure; every other page keeps the default one. The class comes from the
|
||||
layout, which stamps the page name onto <main>. */
|
||||
main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64rem; }
|
||||
/* The signed-out pages are a single card and nothing else, and .card.narrow
|
||||
centres itself inside whatever holds it — so at the panel's usual width the
|
||||
@@ -73,19 +110,17 @@ main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64
|
||||
the middle of the page. */
|
||||
main.page-login, main.page-setup { max-width: 24rem; }
|
||||
.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; } }
|
||||
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
/* A table column is at least as wide as the longest unbreakable run inside it,
|
||||
and the panel's tables are full of runs with nothing to break on: email
|
||||
addresses, domains, queue ids. One 40-character recipient was enough to widen
|
||||
the send-log past its card and hang Status over the edge. Cells may break mid
|
||||
word, so a column can always be squeezed to the width available. */
|
||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; overflow-wrap: anywhere; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
||||
/* The exception: a timestamp broken across two lines is unreadable, and it is
|
||||
short enough to never be the reason a row does not fit. */
|
||||
td.time { white-space: nowrap; }
|
||||
@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; }
|
||||
/* Subject is the one cell whose text we do not control. Breaking mid word (the
|
||||
@@ -99,8 +134,7 @@ td.subject span {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.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; } }
|
||||
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
.back { display: inline-block; margin-bottom: 1rem; }
|
||||
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
||||
@@ -108,13 +142,10 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
|
||||
select, textarea {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit;
|
||||
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
select, textarea { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
button.danger, a.danger { background: #b42318; }
|
||||
button.danger:hover, a.danger:hover { background: #912018; }
|
||||
/* The disclosure toggle is an action too, so it is drawn as a button (see the
|
||||
@@ -124,15 +155,14 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
.actions summary::-webkit-details-marker { display: none; }
|
||||
/* Applications are a list of blocks, not table rows. As a table it fell apart:
|
||||
four columns of which the last held six controls — two of them <details>
|
||||
panels with textareas — never fit the panel's 48rem. The controls wrapped
|
||||
into a staircase, .code on the login cell grew into a slab as tall as the
|
||||
row, and the two text cells sat on the baseline halfway down it. One block
|
||||
per application gives the identity a line of its own and the controls a row
|
||||
of their own, at the width they actually need. */
|
||||
panels with textareas — never fit the panel's default width. The controls
|
||||
wrapped into a staircase, .code on the login cell grew into a slab as tall
|
||||
as the row, and the two text cells sat on the baseline halfway down it. One
|
||||
block per application gives the identity a line of its own and the
|
||||
controls a row of their own, at the width they actually need. */
|
||||
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
|
||||
.app { padding: 0.9rem 0; border-top: 1px solid #e2e5e9; }
|
||||
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
||||
.app:last-child { padding-bottom: 0; }
|
||||
@media (prefers-color-scheme: dark) { .app { border-color: #2b3138 !important; } }
|
||||
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
||||
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
|
||||
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
||||
@@ -140,8 +170,7 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
the block's full width, not the width of the summary that opened them. */
|
||||
.app .actions > details[open] { flex: 1 0 100%; }
|
||||
details form { margin-top: 0.6rem; }
|
||||
.credential { border-color: #f5c518; background: #fffbeb; }
|
||||
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
|
||||
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||
authenticated page without each content template having to include it. */
|
||||
/* Two deliberate rows, not one. The six page entries need about 660px and the
|
||||
@@ -159,9 +188,8 @@ details form { margin-top: 0.6rem; }
|
||||
.nav {
|
||||
display: flex; flex-direction: column; align-items: stretch;
|
||||
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid #e2e5e9;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
|
||||
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
|
||||
/* The top row: mark at one edge, session block at the other. Splitting them to
|
||||
the edges is what makes the two rows read as two blocks rather than as one
|
||||
@@ -187,10 +215,7 @@ details form { margin-top: 0.6rem; }
|
||||
padding: 0.2rem 0.5rem; border-radius: 6px;
|
||||
}
|
||||
.nav [aria-current] {
|
||||
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
|
||||
font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 0 -2px 0 #2563eb;
|
||||
}
|
||||
/* Sign out carries an icon too, so it needs the same row layout; its padding
|
||||
and colours come from the compact button rule further down. */
|
||||
@@ -206,16 +231,10 @@ details form { margin-top: 0.6rem; }
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
vertical-align: middle; border: 1px solid transparent;
|
||||
}
|
||||
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; }
|
||||
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; }
|
||||
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; }
|
||||
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
|
||||
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
|
||||
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
|
||||
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
||||
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
||||
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
||||
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code { flex: 1; min-width: 0; }
|
||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||
@@ -227,25 +246,31 @@ details form { margin-top: 0.6rem; }
|
||||
button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
|
||||
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||
border-radius: 6px; white-space: nowrap;
|
||||
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
|
||||
background: var(--surface-bg); color: #2563eb; border: 1px solid var(--control-border);
|
||||
}
|
||||
button.copy:hover, .actions button:hover, .actions summary:hover,
|
||||
.actions a.danger:hover, .nav button:hover { background: #e2e7ee; }
|
||||
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
||||
button.copy { flex: none; margin-top: 0.3rem; }
|
||||
.actions details[open] > summary { background: #dde3ec; }
|
||||
.actions details[open] > summary { background: var(--surface-open-bg); }
|
||||
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||
color: #b42318; background: #fef3f2; border-color: #fecdca;
|
||||
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
||||
}
|
||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #fee4e2; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
button.copy, .actions button, .actions summary, .actions a.danger, .nav button {
|
||||
background: #22262b !important; border-color: #2b3138 !important;
|
||||
}
|
||||
button.copy:hover, .actions button:hover, .actions summary:hover,
|
||||
.actions a.danger:hover, .nav button:hover { background: #2b3138 !important; }
|
||||
.actions details[open] > summary { background: #313841 !important; }
|
||||
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||
color: #f5a29b !important; background: #2d1211 !important; border-color: #6b201a !important;
|
||||
}
|
||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #3d1a18 !important; }
|
||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
|
||||
|
||||
/* The optional "encrypt this download" block on the backup, export and import
|
||||
forms. Its label is the one checkbox in the panel, so it opts out of the
|
||||
block-level label rule above and sits on one line with its box; the fields it
|
||||
reveals are indented under it to read as its consequence rather than as three
|
||||
more fields of the form. panel.js hides the inner block until the box is
|
||||
ticked (and empties it when unticked); without JavaScript everything stays
|
||||
visible, which the server handles identically. */
|
||||
.encrypt { margin-top: 1.2rem; }
|
||||
.encrypt label.check {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600;
|
||||
}
|
||||
.encrypt label.check input { width: auto; margin: 0; }
|
||||
.encrypt-fields {
|
||||
margin-left: 1.6rem; padding-left: 0.9rem; border-left: 2px solid var(--border);
|
||||
}
|
||||
.encrypt-fields label { margin-top: 0.7rem; }
|
||||
.encrypt-fields .muted { margin: 0.5rem 0 0; font-size: 0.85rem; }
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
// Forms that delete something or invalidate a working credential carry a
|
||||
// data-confirm message. The prompt lives here rather than in an inline
|
||||
// onsubmit attribute because the panel's Content-Security-Policy allows no
|
||||
// inline script (phase 14.A). The listener is delegated from the document,
|
||||
// inline script. The listener is delegated from the document,
|
||||
// so it also covers markup swapped in by HTMX. With JavaScript disabled the
|
||||
// form submits without asking — exactly as the inline handler behaved.
|
||||
document.addEventListener("submit", function (ev) {
|
||||
@@ -72,7 +72,54 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Encryption password fields shown only when asked for --------------
|
||||
// The backup, export and import forms carry an optional password block. It
|
||||
// is hidden until the checkbox next to it is ticked, and cleared when it is
|
||||
// unticked, so a password typed and then abandoned is never submitted. With
|
||||
// JavaScript blocked the block stays visible and the forms behave exactly as
|
||||
// the server reads them: the checkbox alone decides whether encryption
|
||||
// happens.
|
||||
function syncEncryptFields(box) {
|
||||
var form = box.closest("form");
|
||||
var fields = form && form.querySelector("[data-encrypt-fields]");
|
||||
if (!fields) {
|
||||
return;
|
||||
}
|
||||
fields.hidden = !box.checked;
|
||||
if (!box.checked) {
|
||||
fields.querySelectorAll("input").forEach(function (input) {
|
||||
input.value = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initEncryptFields(root) {
|
||||
root.querySelectorAll("input[data-encrypt-toggle]").forEach(function (box) {
|
||||
syncEncryptFields(box);
|
||||
box.addEventListener("change", function () {
|
||||
syncEncryptFields(box);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initAddressFields(document);
|
||||
initEncryptFields(document);
|
||||
});
|
||||
|
||||
// --- Skip polling while the tab is hidden ------------------------------
|
||||
// The monitoring pages (status, mail queue, system log, deliveries) poll
|
||||
// every 5s via hx-trigger="every 5s". htmx has a built-in way to make that
|
||||
// conditional (an event filter, hx-trigger="every 5s [expr]"), but it
|
||||
// evaluates the filter with `new Function`, which the panel's CSP
|
||||
// (default-src 'self', no 'unsafe-eval') would silently break. Skipping the
|
||||
// request here instead needs nothing beyond what the CSP already allows: a
|
||||
// request due while the tab is hidden is simply not sent, and the next
|
||||
// request after it becomes visible again picks up on schedule as usual.
|
||||
document.body.addEventListener("htmx:beforeRequest", function (ev) {
|
||||
var trigger = ev.target.getAttribute && ev.target.getAttribute("hx-trigger");
|
||||
if (document.hidden && trigger && trigger.indexOf("every") !== -1) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
+15
-10
@@ -9,26 +9,30 @@ 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.
|
||||
// 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"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"account": {"templates/account.html"},
|
||||
"backup": {"templates/backup.html"},
|
||||
"domain_detail": {"templates/domain_detail.html"},
|
||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
@@ -99,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 {
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
start. TLS certificates and the mail queue are not included.</p>
|
||||
<p class="muted"><strong>The backup file is a secret</strong> (it contains
|
||||
private keys and credentials). Store and transfer it securely and delete it
|
||||
once the restore succeeds.</p>
|
||||
<form class="inline" method="post" action="/backup">
|
||||
once the restore succeeds. Encrypting it below is the simplest way to do that:
|
||||
the download is then a <code>.spbk</code> file that only the password opens.</p>
|
||||
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
|
||||
<form method="post" action="/backup">
|
||||
{{template "encryptfields" .}}
|
||||
<button type="submit">Download full backup</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -25,7 +28,17 @@
|
||||
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/import" enctype="multipart/form-data">
|
||||
<label for="importfile">Domain export file</label>
|
||||
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
|
||||
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required>
|
||||
<div class="encrypt">
|
||||
<label class="check">
|
||||
<input type="checkbox" data-encrypt-toggle>
|
||||
<span>The file is encrypted (<code>.spde</code>)</span>
|
||||
</label>
|
||||
<div class="encrypt-fields" data-encrypt-fields>
|
||||
<label for="importpw">Password</label>
|
||||
<input id="importpw" name="import_password" type="password" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Import domain</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -283,8 +283,11 @@
|
||||
import the DNS record stays the same, so no DNS change is needed.</p>
|
||||
<p class="muted"><strong>The export file is a secret</strong> — it contains the
|
||||
private DKIM key and application passwords. Transfer it securely and delete it
|
||||
after the import.</p>
|
||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/export">
|
||||
after the import, or encrypt it below and move a <code>.spde</code> file
|
||||
instead; the import form asks for the password.</p>
|
||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
||||
{{template "encryptfields" .}}
|
||||
<button type="submit">Export domain</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{{/* Password fields shared by the full-backup and domain-export forms. The
|
||||
checkbox is the switch: unticked, the download keeps its historic plain
|
||||
form (.tar.gz / .json); ticked, the file is sealed in a password-encrypted
|
||||
envelope (.spbk / .spde). The fields start hidden and are revealed by
|
||||
panel.js — with JavaScript blocked they are simply always visible, and the
|
||||
server still decides from the checkbox alone. */}}
|
||||
{{define "encryptfields"}}
|
||||
<div class="encrypt">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="encrypt" value="1" data-encrypt-toggle>
|
||||
<span>Encrypt with a password</span>
|
||||
</label>
|
||||
<div class="encrypt-fields" data-encrypt-fields>
|
||||
<label for="encpw">Password</label>
|
||||
<input id="encpw" name="password" type="password" autocomplete="new-password"
|
||||
minlength="{{.MinPwLen}}" placeholder="at least {{.MinPwLen}} characters">
|
||||
<label for="encpw2">Repeat password</label>
|
||||
<input id="encpw2" name="password_confirm" type="password" autocomplete="new-password">
|
||||
<p class="muted">Keep this password: without it the file cannot be opened,
|
||||
and SelfPost does not store it anywhere.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -15,7 +15,7 @@
|
||||
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
|
||||
the head for the request-indicator classes. The panel uses no
|
||||
hx-indicator, and that injected element would be the one thing the
|
||||
Content-Security-Policy (phase 14.A) has to make an exception for, so it
|
||||
Content-Security-Policy has to make an exception for, so it
|
||||
is switched off here. */}}
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
|
||||
@@ -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
|
||||
@@ -142,9 +142,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Since the panel root now redirects to the status page, a link left pointing at
|
||||
// Since the panel root redirects to the status page, a link left pointing at
|
||||
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||
// contain one (phase 13.C).
|
||||
// contain one.
|
||||
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if strings.Contains(body, `href="/"`) {
|
||||
@@ -154,7 +154,7 @@ func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
// The reload action is a server-health control and lives only on the status
|
||||
// page (phase 13.D).
|
||||
// page.
|
||||
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if strings.Contains(body, `action="/reload"`) && name != "status.html" {
|
||||
@@ -164,7 +164,7 @@ func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
|
||||
}
|
||||
|
||||
// The panel's Content-Security-Policy is a plain default-src 'self' with no
|
||||
// inline exemption (phase 14.A), which makes inline script and inline style a
|
||||
// inline exemption, which makes inline script and inline style a
|
||||
// failure mode rather than a style question: an onclick= handler or a
|
||||
// style="..." attribute added to a template does not error, it silently stops
|
||||
// working in the browser. Behaviour belongs in static/panel.js (triggered from
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
@@ -15,7 +15,13 @@ const (
|
||||
maxUsernameLen = 64
|
||||
)
|
||||
|
||||
// validateUsername enforces a strict server-side whitelist (spec 7.6.2):
|
||||
// minSecretFilePasswordLen is the floor for the password protecting an
|
||||
// encrypted backup or domain export. Such a file is offline and can be attacked
|
||||
// at leisure, so the floor matches the administrator password's rather than the
|
||||
// weaker "any password is better than none".
|
||||
const minSecretFilePasswordLen = minAdminPasswordLen
|
||||
|
||||
// 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 {
|
||||
@@ -52,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.
|
||||
|
||||
+41
-31
@@ -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,10 +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/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/*
|
||||
@@ -23,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
|
||||
@@ -50,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
|
||||
@@ -88,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 {
|
||||
@@ -109,12 +114,12 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
|
||||
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
|
||||
// Published-DNS checks for the status page and the domain pages. The
|
||||
// checker caches its own results, so page views do not each pay for a
|
||||
// round of lookups (phase 13).
|
||||
// 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,
|
||||
@@ -124,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()
|
||||
@@ -140,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.
|
||||
@@ -151,7 +156,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// above falls through to this sub-mux, wrapped once in the auth middleware.
|
||||
authed := http.NewServeMux()
|
||||
|
||||
// The landing page is the server status (phase 13.C): the first thing an
|
||||
// The landing page is the server status: the first thing an
|
||||
// administrator should see after logging in is whether the service is
|
||||
// healthy, not the domain list. handleLogin still redirects to "/".
|
||||
authed.HandleFunc("GET /{$}", redirectToStatus)
|
||||
@@ -178,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)
|
||||
@@ -195,18 +201,22 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("/", s.requireAuth(authed))
|
||||
|
||||
// Security headers and the origin check wrap everything, including the
|
||||
// unauthenticated login and setup routes (phase 14.A).
|
||||
// unauthenticated login and setup routes.
|
||||
return s.secure(mux)
|
||||
}
|
||||
|
||||
// redirectToStatus points the panel root at the status page, so there is one
|
||||
// canonical URL for that content instead of two (phase 13.C).
|
||||
// canonical URL for that content instead of two.
|
||||
func redirectToStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/status", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if err := health.Liveness(); err != nil {
|
||||
http.Error(w, "unhealthy\n", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user