Split internal/web into subpackages before domain-admin growth.
test / test (push) Has been cancelled
test / test (push) Has been cancelled
Lay out view, auth, validate, and handlers under internal/web while keeping the cmd/panel API unchanged; update roadmap and changelog for web-split closure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// Package handlers implements the panel's authenticated HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"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"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// Config holds handler-specific panel configuration.
|
||||
type Config struct {
|
||||
Hostname string
|
||||
SubmissionEnabled bool
|
||||
MailLogPath string
|
||||
DataDir string
|
||||
DBPath string
|
||||
Version string
|
||||
TLSCertFile string
|
||||
OpenDKIMSocket string
|
||||
JournalSocket string
|
||||
}
|
||||
|
||||
// Handlers holds dependencies for authenticated panel routes.
|
||||
type Handlers struct {
|
||||
store *store.Store
|
||||
domains *domain.Service
|
||||
apps *app.Service
|
||||
cfg Config
|
||||
view *view.Engine
|
||||
dns *dnscheck.Checker
|
||||
machine *health.MachineSampler
|
||||
auth *auth.Module
|
||||
}
|
||||
|
||||
// New builds authenticated panel handlers.
|
||||
func New(
|
||||
st *store.Store,
|
||||
domains *domain.Service,
|
||||
apps *app.Service,
|
||||
cfg Config,
|
||||
v *view.Engine,
|
||||
dns *dnscheck.Checker,
|
||||
machine *health.MachineSampler,
|
||||
a *auth.Module,
|
||||
) *Handlers {
|
||||
return &Handlers{
|
||||
store: st,
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
cfg: cfg,
|
||||
view: v,
|
||||
dns: dns,
|
||||
machine: machine,
|
||||
auth: a,
|
||||
}
|
||||
}
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"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
|
||||
// (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 (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
admin, err := h.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: account: get admin failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail)
|
||||
case http.MethodPost:
|
||||
h.submitAccount(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// renderAccount draws the settings form. formUsername and formDMARCEmail
|
||||
// repopulate fields after a rejected submission; password fields are never
|
||||
// repopulated.
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) {
|
||||
var reportAuth dnscheck.Result
|
||||
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
reportAuth = h.dns.ReportAuth(ctx, hub)
|
||||
cancel()
|
||||
}
|
||||
h.view.Render(w, status, "account", map[string]any{
|
||||
"Title": "SelfPost — settings",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "account",
|
||||
"FormUsername": formUsername,
|
||||
"FormDMARCEmail": formDMARCEmail,
|
||||
"ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)),
|
||||
"ReportAuthExample": dnscheck.ReportAuthExample(),
|
||||
"ReportAuthDNS": reportAuth,
|
||||
"ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail),
|
||||
"Error": formErr,
|
||||
"Flash": accountFlash(r),
|
||||
})
|
||||
}
|
||||
|
||||
// accountFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
func accountFlash(r *http.Request) string {
|
||||
switch r.URL.Query().Get("updated") {
|
||||
case "username":
|
||||
return "Username changed."
|
||||
case "password":
|
||||
return "Password changed. Any other signed-in sessions were signed out."
|
||||
case "both":
|
||||
return "Username and password changed. Any other signed-in sessions were signed out."
|
||||
case "email":
|
||||
return "DMARC report address updated."
|
||||
case "username-email":
|
||||
return "Username and DMARC report address updated."
|
||||
case "password-email":
|
||||
return "Password and DMARC report address updated. Any other signed-in sessions were signed out."
|
||||
case "all":
|
||||
return "Settings updated. Any other signed-in sessions were signed out."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// submitAccount applies a username and/or password change. The current password
|
||||
// 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 (security.md).
|
||||
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.auth.AllowLoginAttempt(r) {
|
||||
h.renderAccount(w, r, http.StatusTooManyRequests,
|
||||
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
current := r.PostFormValue("current_password")
|
||||
password := r.PostFormValue("new_password")
|
||||
confirm := r.PostFormValue("new_password_confirm")
|
||||
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||
|
||||
admin, err := h.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: account: get admin failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if username == "" {
|
||||
username = admin.Username
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
|
||||
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
renaming := username != admin.Username
|
||||
if renaming {
|
||||
if err := validate.Username(username); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := validate.Email(dmarcEmail); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
emailChanging := dmarcEmail != admin.DMARCReportEmail
|
||||
|
||||
repassword := password != "" || confirm != ""
|
||||
if repassword {
|
||||
if password != confirm {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !renaming && !repassword && !emailChanging {
|
||||
h.renderAccount(w, r, http.StatusBadRequest,
|
||||
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
hash := admin.PasswordHash
|
||||
if repassword {
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: account: hashing password failed: %v", err)
|
||||
h.renderAccount(w, r, http.StatusInternalServerError,
|
||||
"Internal error. Please try again.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil {
|
||||
logf("panel: account: update admin failed: %v", err)
|
||||
msg := "Could not save the changes. Please check the logs and try again."
|
||||
if errors.Is(err, store.ErrNoAdmin) {
|
||||
msg = "There is no administrator account to update."
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
if token, ok := h.auth.SessionToken(r); ok {
|
||||
if renaming {
|
||||
h.auth.RenameSession(token, username)
|
||||
}
|
||||
if repassword {
|
||||
h.auth.DestroyOtherSessions(token)
|
||||
}
|
||||
}
|
||||
|
||||
logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging)
|
||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func updatedFlag(renamed, repassword, emailChanged bool) string {
|
||||
switch {
|
||||
case renamed && repassword && emailChanged:
|
||||
return "all"
|
||||
case renamed && emailChanged:
|
||||
return "username-email"
|
||||
case repassword && emailChanged:
|
||||
return "password-email"
|
||||
case renamed && repassword:
|
||||
return "both"
|
||||
case renamed:
|
||||
return "username"
|
||||
case repassword:
|
||||
return "password"
|
||||
default:
|
||||
return "email"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// newCred carries a freshly generated login/password to the template so it can
|
||||
// be shown exactly once (security.md). It is never read back from storage.
|
||||
type newCred struct {
|
||||
Login string
|
||||
Password string
|
||||
}
|
||||
|
||||
// detailView holds the one-shot, request-specific extras layered on top of a
|
||||
// domain's persistent state when rendering its page: an application-form error,
|
||||
// the values to repopulate that form, and any just-issued credential to show
|
||||
// once.
|
||||
type detailView struct {
|
||||
FormErr string
|
||||
FormLogin string
|
||||
FormMode string
|
||||
FormAddrs string
|
||||
NewCred *newCred
|
||||
// RateLimitErr surfaces a validation error from a domain- or
|
||||
// application-level rate-limit form (guide § 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
|
||||
// settings for the domain page. store.Application is embedded so the existing
|
||||
// template fields (Login, AddressMode, Addresses, ID) resolve unchanged.
|
||||
type appRateLimitView struct {
|
||||
store.Application
|
||||
HasLimit bool // an active limit is configured
|
||||
IPsText string // allowed IPs, newline-joined for the textarea
|
||||
MaxText string // message ceiling, blank when unset
|
||||
WindowVal string // window seconds, defaulted when unset
|
||||
}
|
||||
|
||||
// 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 (product.md).
|
||||
func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
|
||||
}
|
||||
|
||||
// renderDomainDetail renders the domain page. view supplies request-specific
|
||||
// extras (form error/values, a one-time credential); everything else is loaded
|
||||
// fresh from the stores so the page always reflects committed state.
|
||||
func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
||||
record, err := h.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
apps, err := h.apps.List(d.ID)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: list applications: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
appViews := make([]appRateLimitView, 0, len(apps))
|
||||
for _, a := range apps {
|
||||
rl, ok, err := h.apps.RateLimit(a.ID)
|
||||
if err != nil {
|
||||
logf("panel: application %d: rate limit: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
appViews = append(appViews, appRateLimitView{
|
||||
Application: a,
|
||||
HasLimit: ok && rl.Active(),
|
||||
IPsText: strings.Join(rl.AllowedIPs, "\n"),
|
||||
MaxText: intOrBlank(rl.MaxMessages),
|
||||
WindowVal: windowOrDefault(rl.WindowSeconds),
|
||||
})
|
||||
}
|
||||
|
||||
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with. Cached by the checker, so re-rendering the page
|
||||
// after a form post costs nothing.
|
||||
admin, err := h.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: domain %d: get admin: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profileEmail := admin.DMARCReportEmail
|
||||
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
dmarcMode := "inherit"
|
||||
dmarcCustom := ""
|
||||
if d.DMARCRua.Valid {
|
||||
if d.DMARCRua.String == "" {
|
||||
dmarcMode = "none"
|
||||
} else {
|
||||
dmarcMode = "custom"
|
||||
dmarcCustom = d.DMARCRua.String
|
||||
}
|
||||
}
|
||||
dmarcSource := "policy"
|
||||
switch {
|
||||
case dmarcMode == "custom":
|
||||
dmarcSource = "custom"
|
||||
case dmarcMode == "none":
|
||||
dmarcSource = "none"
|
||||
case profileEmail != "":
|
||||
dmarcSource = "settings"
|
||||
}
|
||||
|
||||
h.view.Render(w, status, "domain_detail", map[string]any{
|
||||
"Title": "SelfPost — " + d.Name,
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
"Record": record,
|
||||
"DNS": dns,
|
||||
// SPF and DMARC are the operator's to write — SelfPost cannot generate
|
||||
// them the way it generates the DKIM record — so the page shows what
|
||||
// this server expects rather than leaving it to the documentation. The
|
||||
// same builders phrase the suggestions in the check messages, so the
|
||||
// page and the checks below it never recommend different records.
|
||||
"SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs),
|
||||
"DMARCName": dnscheck.DMARCRecordName(d.Name),
|
||||
"DMARCExample": dnscheck.DMARCExample(reportEmail),
|
||||
"DMARCSource": dmarcSource,
|
||||
"ProfileDMARCEmail": profileEmail,
|
||||
"ResolvedDMARCEmail": reportEmail,
|
||||
"DMARCRuaMode": dmarcMode,
|
||||
"DMARCRuaCustom": dmarcCustom,
|
||||
"ReportAuthName": reportAuthName,
|
||||
"ReportAuthValue": reportAuthValue,
|
||||
"NeedsReportAuth": needsReportAuth,
|
||||
"SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name),
|
||||
// Client connection settings (the same for every domain on this
|
||||
// instance): the hostname clients connect to, and whether the optional
|
||||
// submission listener is enabled in this deployment.
|
||||
"Hostname": h.cfg.Hostname,
|
||||
"SubmissionEnabled": h.cfg.SubmissionEnabled,
|
||||
"Apps": appViews,
|
||||
"Error": view.FormErr,
|
||||
"FormLogin": view.FormLogin,
|
||||
"FormMode": view.FormMode,
|
||||
"FormAddrs": view.FormAddrs,
|
||||
"NewCred": view.NewCred,
|
||||
"Flash": detailFlash(r),
|
||||
"Wildcard": store.AddressModeWildcard,
|
||||
"List": store.AddressModeList,
|
||||
"RateLimitErr": view.RateLimitErr,
|
||||
"ExportErr": view.ExportErr,
|
||||
"MinPwLen": validate.MinSecretFilePasswordLen,
|
||||
"DomainHasRL": domainRLok && domainRL.Active(),
|
||||
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
||||
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
||||
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
|
||||
})
|
||||
}
|
||||
|
||||
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
|
||||
// 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
|
||||
// addresses. force bypasses the cache, for the Re-check button.
|
||||
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
return h.dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: record.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
|
||||
}, force), srv
|
||||
}
|
||||
|
||||
// HandleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
|
||||
// returns to its page, which then renders the fresh result.
|
||||
func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record, err := h.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
admin, err := h.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: domain %d: get admin: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.domainDNS(d, record, admin.DMARCReportEmail, true)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// intOrBlank renders a non-positive number as an empty string so an unset field
|
||||
// shows blank rather than "0".
|
||||
func intOrBlank(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// windowOrDefault renders the window seconds, substituting the default when
|
||||
// unset so the form always suggests a sensible value.
|
||||
func windowOrDefault(n int) string {
|
||||
if n <= 0 {
|
||||
return strconv.Itoa(defaultRateLimitWindowSeconds)
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// detailFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
func detailFlash(r *http.Request) string {
|
||||
switch {
|
||||
case r.URL.Query().Get("appdeleted") != "":
|
||||
return "Application deleted."
|
||||
case r.URL.Query().Get("modeupdated") != "":
|
||||
return "Application address mode updated."
|
||||
case r.URL.Query().Get("ratelimit") != "":
|
||||
return "Rate limit updated."
|
||||
case r.URL.Query().Get("dmarc") != "":
|
||||
return "DMARC report settings updated."
|
||||
case r.URL.Query().Get("imported") != "":
|
||||
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
|
||||
case r.URL.Query().Get("rechecked") != "":
|
||||
return "DNS re-checked."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAddApplication creates an application on a domain and renders the page
|
||||
// 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 (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
|
||||
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
|
||||
return
|
||||
}
|
||||
login := strings.TrimSpace(r.PostFormValue("login"))
|
||||
mode := r.PostFormValue("mode")
|
||||
addrs := splitAddresses(r.PostFormValue("addresses"))
|
||||
|
||||
repopulate := detailView{
|
||||
FormLogin: login,
|
||||
FormMode: mode,
|
||||
FormAddrs: r.PostFormValue("addresses"),
|
||||
}
|
||||
|
||||
a, password, err := h.apps.Create(d.ID, login, mode, addrs)
|
||||
if err != nil {
|
||||
repopulate.FormErr = applicationErrorMessage(err)
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, store.ErrLoginExists) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
h.renderDomainDetail(w, r, status, d, repopulate)
|
||||
return
|
||||
}
|
||||
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
NewCred: &newCred{Login: a.Login, Password: password},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleUpdateAppMode switches an application's address mode / list (product.md).
|
||||
func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mode := r.PostFormValue("mode")
|
||||
addrs := splitAddresses(r.PostFormValue("addresses"))
|
||||
|
||||
if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
||||
d, derr := h.domains.Get(a.DomainID)
|
||||
if derr != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
|
||||
FormMode: store.AddressModeWildcard,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleRegenPassword issues a new password for an application and shows it once
|
||||
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
|
||||
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := h.domains.Get(a.DomainID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
password, err := h.apps.RegeneratePassword(a.ID)
|
||||
if err != nil {
|
||||
logf("panel: regenerate password for application %d: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
NewCred: &newCred{Login: a.Login, Password: password},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleDeleteApplication removes an application and returns to its domain page
|
||||
// (product.md).
|
||||
func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.apps.Delete(a.ID); err != nil {
|
||||
logf("panel: delete application %d: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// lookupApplication resolves the {aid} path value to an application, writing a
|
||||
// 404 for a bad id or missing application.
|
||||
func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return store.Application{}, false
|
||||
}
|
||||
a, err := h.apps.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrApplicationNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return store.Application{}, false
|
||||
}
|
||||
logf("panel: get application %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.Application{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
// 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 (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 == ';'
|
||||
})
|
||||
}
|
||||
|
||||
// applicationErrorMessage turns a service error into a user-facing message,
|
||||
// passing through the validation errors (which are safe, fixed strings) and
|
||||
// masking anything unexpected.
|
||||
func applicationErrorMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrLoginExists):
|
||||
return "That login is already in use. Choose another."
|
||||
case errors.Is(err, store.ErrDomainNotFound), errors.Is(err, store.ErrApplicationNotFound):
|
||||
return "The item no longer exists."
|
||||
default:
|
||||
// Validation errors from the app service are safe to surface verbatim;
|
||||
// they describe what the admin must fix (login/address rules).
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// 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. 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
|
||||
// and the domain import are separate actions with different risk, so each gets
|
||||
// its own card here rather than sharing a block on the domain list.
|
||||
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderBackupPage(w, r, http.StatusOK, "")
|
||||
}
|
||||
|
||||
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
||||
// (architecture.md § Persistence) next to the form that produced it.
|
||||
func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
||||
h.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 (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
||||
h.view.Render(w, status, "backup", map[string]any{
|
||||
"Title": "SelfPost — backup",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "backup",
|
||||
"ImportErr": importErr,
|
||||
"BackupErr": backupErr,
|
||||
"MinPwLen": validate.MinSecretFilePasswordLen,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
// 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: h.cfg.DataDir,
|
||||
DBPath: h.cfg.DBPath,
|
||||
Version: h.cfg.Version,
|
||||
}); err != nil {
|
||||
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
|
||||
// (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 (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
ExportErr: pwErr,
|
||||
})
|
||||
return
|
||||
}
|
||||
exp, err := h.domains.Export(d.ID)
|
||||
if err != nil {
|
||||
logf("panel: export domain %d: %v", d.ID, err)
|
||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := json.MarshalIndent(exp, "", " ")
|
||||
if err != nil {
|
||||
logf("panel: export domain %d: encode: %v", d.ID, err)
|
||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("selfpost-domain-%s.json", d.Name)
|
||||
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 (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 (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
||||
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
h.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 == "" {
|
||||
h.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 {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||
return
|
||||
}
|
||||
if env.Type() != secretfile.TypeDomainExport {
|
||||
h.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 {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
|
||||
return
|
||||
}
|
||||
source = bytes.NewReader(plain)
|
||||
} else if password != "" {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
|
||||
return
|
||||
}
|
||||
|
||||
var exp domain.DomainExport
|
||||
dec := json.NewDecoder(source)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&exp); err != nil {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
|
||||
return
|
||||
}
|
||||
|
||||
// Normalise and validate the domain name before it reaches the service, the
|
||||
// same gate the add-domain form uses (security.md).
|
||||
exp.Domain = validate.NormalizeDomain(exp.Domain)
|
||||
if err := validate.Domain(exp.Domain); err != nil {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
d, err := h.domains.Import(exp)
|
||||
if err != nil {
|
||||
logf("panel: import domain %q: %v", exp.Domain, err)
|
||||
status, msg := importErrorMessage(err)
|
||||
h.renderBackupPage(w, r, status, msg)
|
||||
return
|
||||
}
|
||||
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)) < validate.MinSecretFilePasswordLen {
|
||||
return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.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
|
||||
// with the file, or an internal write/reload problem — are surfaced verbatim to
|
||||
// this admin-only panel so the operator can act on them.
|
||||
func importErrorMessage(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrDomainExists):
|
||||
return http.StatusConflict, "A domain with that name already exists here. Delete it first, or import into a fresh instance."
|
||||
case errors.Is(err, store.ErrLoginExists):
|
||||
return http.StatusConflict, "One of the application logins in the file is already in use on this instance. Application logins must be unique across all domains."
|
||||
default:
|
||||
return http.StatusBadRequest, "Could not import the domain: " + err.Error()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// 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", validate.MinSecretFilePasswordLen)
|
||||
short := strings.Repeat("x", validate.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) {
|
||||
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
|
||||
rec := httptest.NewRecorder()
|
||||
h.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",
|
||||
"data-import-password-fields",
|
||||
fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen),
|
||||
"The two passwords do not match.",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("backup page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// HandleDomainDMARC saves per-domain DMARC rua= settings.
|
||||
func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
|
||||
return
|
||||
}
|
||||
|
||||
var rua sql.NullString
|
||||
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
|
||||
case "inherit":
|
||||
rua = sql.NullString{}
|
||||
case "none":
|
||||
rua = sql.NullString{Valid: true, String: ""}
|
||||
case "custom":
|
||||
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||
if err := validate.Email(email); err != nil {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
|
||||
return
|
||||
}
|
||||
rua = sql.NullString{Valid: true, String: email}
|
||||
default:
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
|
||||
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
|
||||
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||
return
|
||||
}
|
||||
h.dns.Forget(d.Name)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
||||
// verdict of its published DNS records, so the operator sees which domains still
|
||||
// need a record published without opening each one.
|
||||
type domainRow struct {
|
||||
store.Domain
|
||||
DNS health.Status
|
||||
}
|
||||
|
||||
// HandleDashboard is the authenticated landing page: the list of sending
|
||||
// domains with their DKIM/selector and application counts, plus the add-domain
|
||||
// form (product.md).
|
||||
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderDashboard(w, r, http.StatusOK, "", "")
|
||||
}
|
||||
|
||||
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||||
domains, err := h.domains.List()
|
||||
if err != nil {
|
||||
logf("panel: dashboard: list domains: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.view.Render(w, status, "dashboard", map[string]any{
|
||||
"Title": "SelfPost",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domains": h.domainRows(domains),
|
||||
"Error": formErr,
|
||||
"FormName": formName,
|
||||
"Flash": dashboardFlash(r),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||
profileEmail := ""
|
||||
if admin, err := h.store.GetAdmin(); err == nil {
|
||||
profileEmail = admin.DMARCReportEmail
|
||||
}
|
||||
rows := make([]domainRow, len(domains))
|
||||
var wg sync.WaitGroup
|
||||
for i, d := range domains {
|
||||
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
record, err := h.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := h.domainDNS(d, record, profileEmail, false)
|
||||
rows[i].DNS = dns.Overall
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return rows
|
||||
}
|
||||
|
||||
func dashboardFlash(r *http.Request) string {
|
||||
if r.URL.Query().Get("deleted") != "" {
|
||||
return "Domain deleted."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
|
||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||
// publish is shown (product.md).
|
||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||
return
|
||||
}
|
||||
raw := r.PostFormValue("name")
|
||||
name := validate.NormalizeDomain(raw)
|
||||
if err := validate.Domain(name); err != nil {
|
||||
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
||||
return
|
||||
}
|
||||
|
||||
d, err := h.domains.Add(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrDomainExists) {
|
||||
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
||||
return
|
||||
}
|
||||
logf("panel: add domain %q: %v", name, err)
|
||||
h.renderDashboard(w, r, http.StatusInternalServerError,
|
||||
"Could not add the domain. Please check the logs and try again.", raw)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
|
||||
"Title": "SelfPost — delete " + d.Name,
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
||||
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseDomainID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if d, err := h.domains.Get(id); err == nil {
|
||||
defer h.dns.Forget(d.Name)
|
||||
}
|
||||
if err := h.domains.Delete(id); err != nil {
|
||||
if errors.Is(err, store.ErrDomainNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: delete domain %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||
// sender map on demand (architecture.md § Panel HTTP surface).
|
||||
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.domains.Resync(); err != nil {
|
||||
logf("panel: manual reload (opendkim): %v", err)
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.apps.Resync(); err != nil {
|
||||
logf("panel: manual reload (postfix): %v", err)
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
||||
id, ok := parseDomainID(w, r)
|
||||
if !ok {
|
||||
return store.Domain{}, false
|
||||
}
|
||||
d, err := h.domains.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrDomainNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return store.Domain{}, false
|
||||
}
|
||||
logf("panel: get domain %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.Domain{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/mailhdr"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// 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, and deliveryLogLines how many of one message's own lines
|
||||
// its page shows.
|
||||
const (
|
||||
sendLogPageSize = 50
|
||||
logTailLines = 200
|
||||
deliveryLogLines = 200
|
||||
)
|
||||
|
||||
// HandleDeliveries renders the Deliveries page over the send log: server-side
|
||||
// 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 (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.sendLogData(r)
|
||||
if err != nil {
|
||||
logf("panel: send log: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data["Title"] = "SelfPost — deliveries"
|
||||
data["User"] = auth.CurrentUser(r)
|
||||
data["Active"] = "deliveries"
|
||||
h.view.Render(w, http.StatusOK, "deliveries", data)
|
||||
}
|
||||
|
||||
// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table
|
||||
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
|
||||
// JSON).
|
||||
func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.sendLogData(r)
|
||||
if err != nil {
|
||||
logf("panel: send log rows: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data)
|
||||
}
|
||||
|
||||
// HandleDelivery renders one send-log row in full. The log itself carries only
|
||||
// what identifies a message at a glance — when, who to and from, what about,
|
||||
// how it ended — and every remaining field (domain, application, queue id, when
|
||||
// the status was last reported) lives here, one page per row, so widening the
|
||||
// journal never costs the table a column.
|
||||
//
|
||||
// The page answers the question the log raises rather than restating it: what
|
||||
// the journal recorded, in what order it happened, and what Postfix itself
|
||||
// wrote about the message. So it is three blocks — the message's own facts and
|
||||
// its history side by side, and the mail.log lines for its queue id under both.
|
||||
// The queue id used to be printed here as something to go and search the system
|
||||
// log for by hand; the search is done for the operator instead.
|
||||
func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
row, err := h.store.GetSendLog(id)
|
||||
if err != nil {
|
||||
// A row pruned on the retention window is gone, not broken.
|
||||
if errors.Is(err, store.ErrSendLogNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: delivery %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||
logRows, logNote := h.deliveryLog(row)
|
||||
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "deliveries",
|
||||
"Row": row,
|
||||
// The status in the panel's own badge vocabulary, so the headline reads
|
||||
// the same way as every other health signal in the panel.
|
||||
"Level": deliveryLevel(row.Status),
|
||||
"Events": deliveryEvents(row),
|
||||
// The mail.log lines for this message, and — when there are none — the
|
||||
// reason, which is a normal outcome rather than a failure.
|
||||
"LogRows": logRows,
|
||||
"LogNote": logNote,
|
||||
// Where the row came from, so "Back" returns to the page and filters
|
||||
// the operator was looking at rather than the top of an unfiltered log.
|
||||
"BackURL": deliveriesBackURL(r),
|
||||
})
|
||||
}
|
||||
|
||||
// deliveryLevel maps a send-log status onto the ok/warn/error/unknown badge
|
||||
// vocabulary the status page and the DNS checks already use (see .st in
|
||||
// panel.css), so a colour means the same thing on every page: delivered is the
|
||||
// good outcome, deferred is not settled yet, and the two refusals are failures.
|
||||
// A queued row is "unknown" rather than "warn" — nothing has gone wrong, it is
|
||||
// simply that nothing has been reported.
|
||||
func deliveryLevel(status string) string {
|
||||
switch status {
|
||||
case store.StatusSent:
|
||||
return "ok"
|
||||
case store.StatusDeferred:
|
||||
return "warn"
|
||||
case store.StatusBounced, store.StatusRejected:
|
||||
return "error"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// deliveryEvent is one step of a message's history, as the timeline on the
|
||||
// delivery page draws it. At is zero for the step that has not happened yet —
|
||||
// the delivery report a queued message is still waiting for.
|
||||
type deliveryEvent struct {
|
||||
At time.Time
|
||||
Level string // ok / warn / error / unknown, as deliveryLevel returns
|
||||
Status string // the send-log status value this step reached
|
||||
Title string
|
||||
Detail string
|
||||
}
|
||||
|
||||
// deliveryEvents turns a row's two timestamps into the history the page shows.
|
||||
// The journal keeps no event table — a row is created when the message is
|
||||
// accepted and updated once when Postfix reports the attempt — so the two
|
||||
// timestamps *are* the history, and stating them as steps is what makes a row
|
||||
// whose created_at and updated_at differ by six hours legible as "queued for
|
||||
// six hours, then delivered" rather than as two dates in a list of fields.
|
||||
func deliveryEvents(row store.SendLogRow) []deliveryEvent {
|
||||
// A rejected message has no second step, and its first one is not an
|
||||
// acceptance: the journal-milter refused it, so Postfix never queued it.
|
||||
if row.Status == store.StatusRejected {
|
||||
return []deliveryEvent{{
|
||||
At: row.CreatedAt,
|
||||
Level: "error",
|
||||
Status: store.StatusRejected,
|
||||
Title: "Refused before queueing",
|
||||
Detail: "The journal-milter refused the message under a rate limit. It was never queued, so there is no queue id and Postfix never attempted delivery.",
|
||||
}}
|
||||
}
|
||||
|
||||
events := []deliveryEvent{{
|
||||
At: row.CreatedAt,
|
||||
Level: "unknown",
|
||||
Status: store.StatusQueued,
|
||||
Title: "Accepted and queued",
|
||||
Detail: "Postfix accepted the message over an authenticated submission and the journal-milter recorded it. Delivery to the recipient had not been attempted yet.",
|
||||
}}
|
||||
|
||||
switch row.Status {
|
||||
case store.StatusQueued:
|
||||
// The step that has not happened. Drawn as an open dot with no time.
|
||||
return append(events, deliveryEvent{
|
||||
Level: "unknown",
|
||||
Status: store.StatusQueued,
|
||||
Title: "Waiting for a delivery report",
|
||||
Detail: "Postfix has not reported an attempt for this recipient yet. The Mail queue page shows what it is still holding.",
|
||||
})
|
||||
case store.StatusSent:
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: "ok",
|
||||
Status: store.StatusSent,
|
||||
Title: "Delivered",
|
||||
Detail: "The receiving server accepted the message. That is as far as this server can see — what the recipient's mailbox then did with it is not reported back.",
|
||||
})
|
||||
case store.StatusDeferred:
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: "warn",
|
||||
Status: store.StatusDeferred,
|
||||
Title: "Deferred, will be retried",
|
||||
Detail: "The receiving server could not take the message yet. Postfix keeps it queued and retries until it is delivered or the queue lifetime runs out.",
|
||||
})
|
||||
case store.StatusBounced:
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: "error",
|
||||
Status: store.StatusBounced,
|
||||
Title: "Bounced",
|
||||
Detail: "Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after the queue lifetime. The reason is in the delivery log below.",
|
||||
})
|
||||
default:
|
||||
// A status the log-tailer learns to write before this switch does.
|
||||
return append(events, deliveryEvent{
|
||||
At: row.UpdatedAt,
|
||||
Level: deliveryLevel(row.Status),
|
||||
Status: row.Status,
|
||||
Title: "Status reported",
|
||||
Detail: "The last state Postfix reported for this recipient.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// deliveryLogRow is one mail.log line split for the table on the delivery
|
||||
// page: when it was written, and what it says. Time is empty for a line whose
|
||||
// head is not a timestamp the log format recognises — the line still shows, in
|
||||
// full, under Message.
|
||||
type deliveryLogRow struct {
|
||||
Time string
|
||||
Text string
|
||||
}
|
||||
|
||||
// deliveryLog reads the mail.log lines Postfix wrote about one message and
|
||||
// splits each into the two columns the page shows it in. The second return
|
||||
// value is what to say when there are none: every reason for an empty result
|
||||
// here is an ordinary one — the message never reached the queue, or its lines
|
||||
// have aged out of the log — so none of them is an error on the page. Only a
|
||||
// log that cannot be read at all is reported as a fault, and that one is
|
||||
// logged for the operator as well.
|
||||
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
|
||||
if row.QueueID == "" {
|
||||
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
|
||||
}
|
||||
lines, err := logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
logf("panel: delivery log %s: %v", row.QueueID, err)
|
||||
return nil, "Could not read the mail log."
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
// Send-log rows outlive mail.log: retention is ninety days by default
|
||||
// and rotation keeps fourteen files, so an older message having nothing
|
||||
// left to show is the normal end state, not a fault.
|
||||
return nil, "Nothing for this queue id in the current mail log. Its lines have most likely been rotated away."
|
||||
}
|
||||
out := make([]deliveryLogRow, len(lines))
|
||||
for i, line := range lines {
|
||||
stamp, rest := logtail.SplitTimestamp(line)
|
||||
out[i] = deliveryLogRow{Time: stamp, Text: rest}
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// deliveriesBackURL rebuilds the delivery-log URL a detail page was opened
|
||||
// from. Only the log's own parameters are carried over, and each is re-encoded
|
||||
// by url.Values, so nothing a visitor appends to the link can travel back into
|
||||
// the page as markup or as a different destination.
|
||||
func deliveriesBackURL(r *http.Request) string {
|
||||
q := r.URL.Query()
|
||||
back := url.Values{}
|
||||
for _, k := range []string{"domain", "app", "p"} {
|
||||
if v := q.Get(k); v != "" {
|
||||
back.Set(k, v)
|
||||
}
|
||||
}
|
||||
if len(back) == 0 {
|
||||
return "/deliveries"
|
||||
}
|
||||
return "/deliveries?" + back.Encode()
|
||||
}
|
||||
|
||||
// sendLogData reads the domain/app filters and page number off the query
|
||||
// string, queries the store, and assembles everything the template needs
|
||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
q := r.URL.Query()
|
||||
filter := store.SendLogFilter{
|
||||
Domain: q.Get("domain"),
|
||||
AppLogin: q.Get("app"),
|
||||
}
|
||||
page := parsePage(q.Get("p"))
|
||||
|
||||
total, err := h.store.CountSendLog(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Decode on the way out as well as on the way in: rows the journal-milter
|
||||
// wrote before it decoded subjects itself still hold the raw header, and
|
||||
// they are the ones an operator is most likely to be looking at.
|
||||
for i := range rows {
|
||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||
}
|
||||
domains, err := h.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainNames := make([]string, len(domains))
|
||||
for i, d := range domains {
|
||||
domainNames[i] = d.Name
|
||||
}
|
||||
logins, err := h.store.ListApplicationLogins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastPage := 1
|
||||
if total > 0 {
|
||||
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
||||
}
|
||||
return map[string]any{
|
||||
"Rows": rows,
|
||||
"FilterDomains": domainNames,
|
||||
"FilterApps": logins,
|
||||
"FilterDomain": filter.Domain,
|
||||
"FilterApp": filter.AppLogin,
|
||||
"Page": page,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
"LastPage": lastPage,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < lastPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parsePage clamps the "p" query parameter to a valid page number, defaulting
|
||||
// to 1 for anything missing or malformed rather than rejecting the request.
|
||||
func parsePage(v string) int {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
||||
// surface).
|
||||
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
|
||||
out, errText := readQueue()
|
||||
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
|
||||
"Title": "SelfPost — mail queue",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "mail_queue",
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
|
||||
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
||||
out, errText := readQueue()
|
||||
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
// readQueue runs postqueue -p, returning a friendly message instead of the
|
||||
// error itself: a transient postqueue failure should degrade the monitoring
|
||||
// view, not surface internals to the panel.
|
||||
func readQueue() (string, string) {
|
||||
out, err := postfix.Queue()
|
||||
if err != nil {
|
||||
logf("panel: postqueue -p: %v", err)
|
||||
return "", "Could not read the mail queue."
|
||||
}
|
||||
return out, ""
|
||||
}
|
||||
|
||||
// HandleSystemLog renders the System log page over mail.log (architecture.md §
|
||||
// Panel HTTP surface).
|
||||
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
|
||||
lines, errText := h.readLogTail()
|
||||
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
|
||||
"Title": "SelfPost — system log",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "system_log",
|
||||
"Lines": lines,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
||||
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
||||
lines, errText := h.readLogTail()
|
||||
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
||||
"Lines": lines,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) readLogTail() ([]string, string) {
|
||||
lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// Rotation renamed the file away; Postfix recreates it on reload
|
||||
// (within about a second), so this is a normal, brief gap rather
|
||||
// than a failure worth alarming the operator about.
|
||||
return nil, ""
|
||||
}
|
||||
logf("panel: tail %s: %v", h.cfg.MailLogPath, err)
|
||||
return nil, "Could not read the mail log."
|
||||
}
|
||||
return lines, ""
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// After log rotation renames mail.log away, Postfix takes about a second to
|
||||
// recreate it on reload (spec B.2); a missing file in that window is a normal,
|
||||
// transient gap, not an operator-facing failure.
|
||||
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
||||
h := &Handlers{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
|
||||
|
||||
lines, errText := h.readLogTail()
|
||||
if lines != nil {
|
||||
t.Errorf("lines = %v, want nil", lines)
|
||||
}
|
||||
if errText != "" {
|
||||
t.Errorf("errText = %q, want empty (missing file is not an error)", errText)
|
||||
}
|
||||
}
|
||||
|
||||
// The delivery log is a list of messages, not a dump of the journal: it shows
|
||||
// when, from, to, subject and status, and links each row to the page carrying
|
||||
// the rest. A column added back here is one the table has no width for.
|
||||
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
|
||||
out := getBody(t, h.HandleDeliveries, "/deliveries")
|
||||
for _, want := range []string{
|
||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"noreply@bs.example.ru", "public@example.ru",
|
||||
"Проверка", ">sent<", `href="/deliveries/` + itoa(row.ID),
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery log is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Domain and application stay available as filters; what the table must not
|
||||
// carry is a column of them per row.
|
||||
for _, unwanted := range []string{"<th>Domain</th>", "<th>App</th>", "Queuer3C"} {
|
||||
if strings.Contains(out, unwanted) {
|
||||
t.Errorf("delivery log still shows %q; that detail belongs on the delivery page", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subjects reached the journal as RFC 2047 encoded-words before the milter
|
||||
// decoded them, and those rows are still in the send log. Decoding on the way
|
||||
// out is what keeps them readable, so the encoding must not survive to the page.
|
||||
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
|
||||
for name, out := range map[string]string{
|
||||
"log": getBody(t, h.HandleDeliveries, "/deliveries"),
|
||||
"rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
|
||||
} {
|
||||
if strings.Contains(out, "=?utf-8?Q?") {
|
||||
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
|
||||
}
|
||||
if !strings.Contains(out, "Проверка") {
|
||||
t.Errorf("%s does not show the decoded subject:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything the log dropped has to be somewhere, and that somewhere is the
|
||||
// per-row page — including for a row still holding an encoded subject.
|
||||
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
|
||||
for _, want := range []string{
|
||||
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
||||
"noreply@bs.example.ru", "public@example.ru", "sent",
|
||||
`href="/deliveries?domain=bs.example.ru&p=2"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "=?utf-8?Q?") {
|
||||
t.Errorf("delivery page shows the subject's MIME encoding instead of its text:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The page's second column is the message's history: the two timestamps the
|
||||
// journal holds, stated as the steps they stand for, so a row is readable as
|
||||
// what happened to the message rather than as a list of fields.
|
||||
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
for _, want := range []string{
|
||||
"Accepted and queued", "Delivered",
|
||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
row.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
// A delivered message is "ok" in the panel's own badge vocabulary, the
|
||||
// same one the status page and the DNS checks use.
|
||||
`class="st st-ok"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Accepted comes before delivered: a history read in the wrong order is
|
||||
// worse than none.
|
||||
if strings.Index(out, "Accepted and queued") > strings.Index(out, "Delivered") {
|
||||
t.Errorf("the history is not in the order it happened:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A queued message has no second timestamp to state, so the step it is waiting
|
||||
// for is drawn as one that has not happened rather than dated with the moment
|
||||
// the row was written.
|
||||
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
if err := h.store.InsertQueued(store.SendLogEntry{
|
||||
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
||||
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
|
||||
}); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID))
|
||||
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The queue id used to be printed as something to go and search the system log
|
||||
// for by hand; the page does that search now, and shows only this message's
|
||||
// lines — as a table of when and what, so the seconds between the connection
|
||||
// and the reply line up down one edge.
|
||||
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
h.cfg.MailLogPath = writeMailLog(t,
|
||||
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
|
||||
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
|
||||
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
|
||||
)
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
for _, want := range []string{
|
||||
"<th>Time</th>", "<th>Message</th>",
|
||||
// The stamp is split off into its own cell, without the microseconds
|
||||
// and the offset that make it the widest thing on the line.
|
||||
`<td class="time muted">2026-08-03 05:15:52</td>`,
|
||||
`<td class="time muted">2026-08-03 05:16:03</td>`,
|
||||
"client=mail.example.com", "status=sent (250 OK)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery log table is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "99999999") {
|
||||
t.Errorf("delivery page shows another message's log line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A line whose head is not a timestamp still has to show in full; the format is
|
||||
// the log's, not ours, and a line we cannot split is a line we must not drop.
|
||||
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") {
|
||||
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows outlive mail.log, and a message the milter refused never reached the
|
||||
// queue at all. Neither is a fault, so neither may render as an error.
|
||||
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
if !strings.Contains(out, "rotated away") {
|
||||
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `class="error"`) || strings.Contains(out, "Could not read the mail log") {
|
||||
t.Errorf("an aged-out delivery log is reported as a failure:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
||||
// that no longer exists is a 404, not a 500.
|
||||
func TestDeliveryPageNotFound(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
|
||||
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
|
||||
h.HandleDelivery(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverWithDelivery builds a panel over a store holding one delivery, written
|
||||
// the way the journal-milter wrote them before it decoded subjects itself.
|
||||
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
if err := st.InsertQueued(store.SendLogEntry{
|
||||
QueueID: "4A1B2C3D",
|
||||
Domain: "bs.example.ru",
|
||||
AppLogin: "Queuer3C",
|
||||
From: "noreply@bs.example.ru",
|
||||
To: "public@example.ru",
|
||||
Subject: "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=",
|
||||
}); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
|
||||
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
|
||||
}
|
||||
|
||||
// getBody runs one handler over a GET and returns the page it wrote, failing
|
||||
// the test on any non-200. The path's {id} is bound by hand because these calls
|
||||
// bypass the router that would otherwise fill it in.
|
||||
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
||||
req.SetPathValue("id", rest)
|
||||
}
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
// writeMailLog creates a mail.log holding the given lines and returns its path,
|
||||
// for the pages that read the log rather than the journal.
|
||||
func writeMailLog(t *testing.T, lines ...string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "mail.log")
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write mail.log: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const defaultRateLimitWindowSeconds = 3600
|
||||
|
||||
type rateLimitInput struct {
|
||||
clear bool
|
||||
ips []string
|
||||
maxMessages int
|
||||
windowSeconds int
|
||||
}
|
||||
|
||||
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||
}
|
||||
if r.PostFormValue("clear") != "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
||||
if err != nil {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
||||
if err != nil || maxMessages <= 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||
}
|
||||
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||||
if err != nil || windowSeconds <= 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||
}
|
||||
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||
}
|
||||
|
||||
func parseIPList(raw string) ([]string, error) {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
||||
})
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
for _, f := range fields {
|
||||
ip := net.ParseIP(f)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("%q is not a valid IP address", f)
|
||||
}
|
||||
c := ip.String()
|
||||
if !seen[c] {
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, def int) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return def, nil
|
||||
}
|
||||
return strconv.Atoi(raw)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, err := parseRateLimitForm(r)
|
||||
if err != nil {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
RateLimitErr: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
|
||||
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := h.domains.Get(a.DomainID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
in, err := parseRateLimitForm(r)
|
||||
if err != nil {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
|
||||
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) applyRateLimit(
|
||||
in rateLimitInput,
|
||||
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
|
||||
clear func(id int64) error,
|
||||
id int64,
|
||||
) error {
|
||||
if in.clear {
|
||||
return clear(id)
|
||||
}
|
||||
return save(id, in.ips, in.maxMessages, in.windowSeconds)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data := h.statusBody()
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
|
||||
data["Title"] = "SelfPost — status"
|
||||
data["User"] = auth.CurrentUser(r)
|
||||
data["Active"] = "status"
|
||||
data["Flash"] = statusFlash(r)
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["PTR"] = srv.PTR
|
||||
h.view.Render(w, http.StatusOK, "status", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) {
|
||||
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
h.dns.Server(h.cfg.Hostname, true)
|
||||
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) statusBody() map[string]any {
|
||||
procs, procErr := health.Processes()
|
||||
procStatus := health.StatusUnknown
|
||||
if procErr != nil {
|
||||
logf("panel: status: supervisorctl: %v", procErr)
|
||||
} else {
|
||||
for _, p := range procs {
|
||||
procStatus = health.Worst(procStatus, p.Status)
|
||||
}
|
||||
}
|
||||
|
||||
queueText, queueErr := readQueue()
|
||||
queueStatus := health.StatusOK
|
||||
if queueErr != "" {
|
||||
queueStatus = health.StatusWarn
|
||||
}
|
||||
|
||||
cert := health.CheckCertificate(h.cfg.TLSCertFile)
|
||||
sockets := []health.Socket{
|
||||
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
|
||||
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
|
||||
}
|
||||
socketStatus := health.StatusUnknown
|
||||
for _, sock := range sockets {
|
||||
socketStatus = health.Worst(socketStatus, sock.Status)
|
||||
}
|
||||
|
||||
machine := h.machine.Sample()
|
||||
|
||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
||||
return map[string]any{
|
||||
"Processes": procs,
|
||||
"ProcessError": procErr != nil,
|
||||
"ProcessStatus": procStatus,
|
||||
"QueueSummary": queueSummary(queueText),
|
||||
"QueueError": queueErr,
|
||||
"QueueStatus": queueStatus,
|
||||
"Machine": machine,
|
||||
"Cert": cert,
|
||||
"Sockets": sockets,
|
||||
"SocketStatus": socketStatus,
|
||||
"OverallStatus": overall,
|
||||
"OverallHeading": overallHeading(overall),
|
||||
}
|
||||
}
|
||||
|
||||
func queueSummary(out string) string {
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if line := strings.TrimSpace(lines[i]); line != "" {
|
||||
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func overallHeading(worst health.Status) string {
|
||||
switch worst {
|
||||
case health.StatusError:
|
||||
return "A component needs attention — see the details below."
|
||||
case health.StatusWarn:
|
||||
return "Running, with warnings below."
|
||||
case health.StatusOK:
|
||||
return "All components are running normally."
|
||||
default:
|
||||
return "Some checks could not be performed."
|
||||
}
|
||||
}
|
||||
|
||||
func statusFlash(r *http.Request) string {
|
||||
switch {
|
||||
case r.URL.Query().Get("reloaded") != "":
|
||||
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
|
||||
case r.URL.Query().Get("rechecked") != "":
|
||||
return "DNS re-checked."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
func mustView(t *testing.T) *view.Engine {
|
||||
t.Helper()
|
||||
v, err := view.New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("view: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user