release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image
Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`). Added: - Panel (Go, single static binary) with SQLite persistence, one-time - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and - Application (sender identity) management: SASL credentials via `sasldb2`, - Full Postfix relay config generated from env at container start: SMTPS 465, - Journal milter (pure Go, `go-milter`) recording every send to `send_log`; … (6 more in CHANGELOG) Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// handleAccount serves the administrator's own account settings: the username
|
||||
// and password chosen during setup are the only panel credentials (spec 7.6.1),
|
||||
// and until now they could be changed only by recreating the state. Changing
|
||||
// them here never touches application SASL logins, which are a separate
|
||||
// identity system (spec 5.1).
|
||||
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
|
||||
case http.MethodPost:
|
||||
s.submitAccount(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// renderAccount draws the settings form. formUsername repopulates the username
|
||||
// field after a rejected submission; the password fields are never repopulated.
|
||||
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
||||
s.render(w, status, "account", map[string]any{
|
||||
"Title": "SelfPost — account",
|
||||
"User": currentUser(r),
|
||||
"Active": "account",
|
||||
"FormUsername": formUsername,
|
||||
"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."
|
||||
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 (spec 7.6.5).
|
||||
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
||||
s.renderAccount(w, r, http.StatusTooManyRequests,
|
||||
"Too many attempts. Please wait and try again.", currentUser(r))
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", 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")
|
||||
|
||||
admin, err := s.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 {
|
||||
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
|
||||
return
|
||||
}
|
||||
|
||||
renaming := username != admin.Username
|
||||
if renaming {
|
||||
if err := validateUsername(username); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An empty pair of new-password fields means "leave the password alone", so
|
||||
// the username can be changed on its own.
|
||||
repassword := password != "" || confirm != ""
|
||||
if repassword {
|
||||
if password != confirm {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(password); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !renaming && !repassword {
|
||||
s.renderAccount(w, r, http.StatusBadRequest,
|
||||
"Nothing to change: enter a new username, a new password, or both.", username)
|
||||
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)
|
||||
s.renderAccount(w, r, http.StatusInternalServerError,
|
||||
"Internal error. Please try again.", username)
|
||||
return
|
||||
}
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAdmin(username, hash); 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."
|
||||
}
|
||||
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep this session usable under the new name, and — when the password
|
||||
// changed — drop every other session so a cookie captured under the old
|
||||
// password stops working.
|
||||
if token, ok := s.sessionToken(r); ok {
|
||||
if renaming {
|
||||
s.sessions.Rename(token, username)
|
||||
}
|
||||
if repassword {
|
||||
s.sessions.DestroyOthers(token)
|
||||
}
|
||||
}
|
||||
|
||||
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
|
||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// updatedFlag names what changed, for the fixed post-redirect flash message.
|
||||
func updatedFlag(renamed, repassword bool) string {
|
||||
switch {
|
||||
case renamed && repassword:
|
||||
return "both"
|
||||
case renamed:
|
||||
return "username"
|
||||
default:
|
||||
return "password"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/dnscheck"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// newCred carries a freshly generated login/password to the template so it can
|
||||
// be shown exactly once (spec 7.6.1). It is never read back from storage.
|
||||
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 (spec 7.4) as a page banner.
|
||||
RateLimitErr 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 (spec 7.2.10)
|
||||
// and its applications with the controls to add, edit, delete and re-issue
|
||||
// credentials (spec 7.2.5-9).
|
||||
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.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 (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
|
||||
record, err := s.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
apps, err := s.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 := s.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 := s.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 (phase 13.B). Cached by the checker, so
|
||||
// re-rendering the page after a form post costs nothing.
|
||||
dns, srv := s.domainDNS(d, record, false)
|
||||
|
||||
s.render(w, status, "domain_detail", map[string]any{
|
||||
"Title": "SelfPost — " + d.Name,
|
||||
"User": 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(s.cfg.Hostname, srv.IPs),
|
||||
"DMARCName": dnscheck.DMARCRecordName(d.Name),
|
||||
"DMARCExample": dnscheck.DMARCExample(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": s.cfg.Hostname,
|
||||
"SubmissionEnabled": s.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,
|
||||
"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 (phase 13.B). 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 (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
||||
return s.dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: record.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
}, 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 (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record, err := s.domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dkim record: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.domainDNS(d, record, 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("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 (spec 7.2.5, 7.6.1). Because the
|
||||
// password cannot be recovered later, this deliberately renders inline rather
|
||||
// than redirecting.
|
||||
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.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 := s.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
|
||||
}
|
||||
s.renderDomainDetail(w, r, status, d, repopulate)
|
||||
return
|
||||
}
|
||||
s.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 (spec 7.2.7).
|
||||
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.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 := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
|
||||
d, derr := s.domains.Get(a.DomainID)
|
||||
if derr != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.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
|
||||
// (spec 7.2.9, 7.6.1). Rendered inline, like creation, so the password is visible.
|
||||
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := s.domains.Get(a.DomainID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
password, err := s.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
|
||||
}
|
||||
s.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
|
||||
// (spec 7.2.8).
|
||||
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.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 (s *Server) 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 := s.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 (spec 7.6.2).
|
||||
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,193 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// The panel session cookie's two possible names. In the production shape —
|
||||
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
|
||||
// what the cookie's attributes merely promise into something the browser
|
||||
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
|
||||
// attribute, so no other host may set a cookie by this name (phase 14.B).
|
||||
// The prefix is only valid on a Secure cookie, so a development instance on
|
||||
// plain HTTP has to keep the bare name: with the prefix the browser would
|
||||
// discard the Set-Cookie outright and logging in would silently never stick.
|
||||
const (
|
||||
sessionCookieBase = "selfpost_session"
|
||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||
)
|
||||
|
||||
// sessionCookie is the session cookie's name for this deployment.
|
||||
func (s *Server) sessionCookie() string {
|
||||
if s.cfg.CookieSecure {
|
||||
return sessionCookiePrefixed
|
||||
}
|
||||
return sessionCookieBase
|
||||
}
|
||||
|
||||
// sessionToken returns the session token the request carries, if exactly one
|
||||
// cookie of that name is present.
|
||||
//
|
||||
// It walks r.Cookies() rather than calling r.Cookie, which silently returns
|
||||
// the first match. Two cookies with the same name mean somebody other than
|
||||
// this panel set one of them — a host on the same registrable domain can,
|
||||
// with Domain=example.com, and the browser will then send both — and RFC 6265
|
||||
// makes the older one come first, so "the first match" is precisely the
|
||||
// attacker's. The value cannot be forged into a valid session, so the effect
|
||||
// is denial of service, not compromise; refusing the request and saying so in
|
||||
// the log is what makes it diagnosable instead of an endless login loop. The
|
||||
// __Host- prefix prevents this outright, but only where it applies — this
|
||||
// check also covers the plain-HTTP development shape (phase 14.B).
|
||||
func (s *Server) sessionToken(r *http.Request) (string, bool) {
|
||||
name := s.sessionCookie()
|
||||
var token string
|
||||
var n int
|
||||
for _, c := range r.Cookies() {
|
||||
if c.Name == name {
|
||||
n++
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
switch n {
|
||||
case 0:
|
||||
return "", false
|
||||
case 1:
|
||||
return token, true
|
||||
default:
|
||||
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
|
||||
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
||||
r.Method, r.URL.Path, n, name)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// clearSessionCookies expires the session cookie under both names, so an
|
||||
// upgrade that switches to the __Host- prefix does not leave the old cookie
|
||||
// sitting in the browser until it is closed.
|
||||
func (s *Server) clearSessionCookies(w http.ResponseWriter) {
|
||||
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
// The prefixed name is only accepted at all when Secure is set,
|
||||
// including on the expiring copy.
|
||||
Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleLogin serves the login form (GET) and authenticates (POST). Until an
|
||||
// administrator exists there is nobody to log in, so it points at setup.
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
exists, err := s.store.AdminExists()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
// No admin yet: login is meaningless. Send a clear message rather than
|
||||
// a failing form.
|
||||
s.render(w, http.StatusOK, "login", map[string]any{
|
||||
"Title": "SelfPost — Sign in",
|
||||
"SetupHint": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.renderLogin(w, http.StatusOK, "")
|
||||
case http.MethodPost:
|
||||
s.submitLogin(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
|
||||
s.render(w, status, "login", map[string]any{
|
||||
"Title": "SelfPost — Sign in",
|
||||
"Error": formErr,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Brute-force throttle by client IP (spec 7.6.5).
|
||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
||||
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
|
||||
admin, err := s.store.GetAdmin()
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNoAdmin) {
|
||||
logf("panel: login: get admin failed: %v", err)
|
||||
}
|
||||
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||
return
|
||||
}
|
||||
|
||||
// Always run bcrypt so timing does not distinguish "wrong user" from
|
||||
// "wrong password", and compare the username too.
|
||||
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
||||
if username != admin.Username || pwErr != nil {
|
||||
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||
return
|
||||
}
|
||||
|
||||
token := s.sessions.Create(admin.Username)
|
||||
s.setSessionCookie(w, token)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
|
||||
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
|
||||
// whatever the database row was just set to — at login, and again whenever
|
||||
// requireAuth extends an active session.
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.sessionCookie(),
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: s.sessions.MaxAge(),
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogout destroys the session and clears the cookie.
|
||||
func (s *Server) handleLogout(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
|
||||
}
|
||||
// Destroy every token presented under the session cookie's name: if a
|
||||
// shadowing duplicate is present (see sessionToken) one of them is the
|
||||
// real session, and a value that names no session is simply not found.
|
||||
name := s.sessionCookie()
|
||||
for _, c := range r.Cookies() {
|
||||
if c.Name == name {
|
||||
s.sessions.Destroy(c.Value)
|
||||
}
|
||||
}
|
||||
s.clearSessionCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The __Host- prefix is only valid on a Secure cookie: getting this condition
|
||||
// backwards would make the development instance fail to log in at all, and
|
||||
// silently — the browser discards the Set-Cookie and the panel just shows the
|
||||
// login form again.
|
||||
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
|
||||
secure := &Server{cfg: Config{CookieSecure: true}}
|
||||
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
|
||||
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
|
||||
}
|
||||
plain := &Server{cfg: Config{CookieSecure: false}}
|
||||
if got := plain.sessionCookie(); got != "selfpost_session" {
|
||||
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A neighbouring host on the same registrable domain can set a cookie by the
|
||||
// same name; the browser then sends both, oldest first. Picking one at random
|
||||
// would leave the administrator in a login loop with no explanation, so the
|
||||
// request counts as signed out instead.
|
||||
func TestSessionTokenRejectsDuplicates(t *testing.T) {
|
||||
s := &Server{cfg: Config{CookieSecure: false}}
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
|
||||
|
||||
if token, ok := s.sessionToken(r); ok {
|
||||
t.Fatalf("duplicate cookies accepted, token = %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokenReadsOneCookie(t *testing.T) {
|
||||
s := &Server{cfg: Config{CookieSecure: true}}
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
|
||||
|
||||
token, ok := s.sessionToken(r)
|
||||
if !ok || token != "the-real-session" {
|
||||
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A cookie under the other deployment's name is not this deployment's session:
|
||||
// after an upgrade the pre-14 cookie must not be honoured as if it were the
|
||||
// prefixed one.
|
||||
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
|
||||
s := &Server{cfg: Config{CookieSecure: true}}
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
|
||||
|
||||
if _, ok := s.sessionToken(r); ok {
|
||||
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
|
||||
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)}
|
||||
token := s.sessions.Create("admin")
|
||||
|
||||
reached := false
|
||||
h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
|
||||
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, r)
|
||||
|
||||
if reached {
|
||||
t.Fatal("the handler ran even though the session cookie was shadowed")
|
||||
}
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// Signing out has to expire the cookie under both names, or the cookie left
|
||||
// over from a pre-__Host- build stays in the browser for the rest of its life.
|
||||
func TestLogoutClearsBothCookieNames(t *testing.T) {
|
||||
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)}
|
||||
token := s.sessions.Create("admin")
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
|
||||
r.Host = "panel.example.com"
|
||||
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleLogout(rec, r)
|
||||
|
||||
if _, ok := s.sessions.Lookup(token); ok {
|
||||
t.Error("the session survived sign-out")
|
||||
}
|
||||
set := rec.Header().Values("Set-Cookie")
|
||||
for _, name := range []string{"selfpost_session=", "__Host-selfpost_session="} {
|
||||
var found bool
|
||||
for _, c := range set {
|
||||
if strings.HasPrefix(c, name) && strings.Contains(c, "Max-Age=0") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("sign-out does not expire a cookie named %q: %v", strings.TrimSuffix(name, "="), set)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/backup"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
|
||||
// document (a DKIM key and a handful of credentials); this leaves generous head
|
||||
// room while refusing anything large enough to be an abuse attempt.
|
||||
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 (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderBackupPage(w, r, http.StatusOK, "")
|
||||
}
|
||||
|
||||
// renderBackupPage draws the page; importErr surfaces a failed domain import
|
||||
// (spec 7.5.B) next to the form that produced it.
|
||||
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
|
||||
s.render(w, status, "backup", map[string]any{
|
||||
"Title": "SelfPost — backup",
|
||||
"User": currentUser(r),
|
||||
"Active": "backup",
|
||||
"ImportErr": importErr,
|
||||
})
|
||||
}
|
||||
|
||||
// handleBackup streams a full-server backup as a download (spec 7.5.A). It is an
|
||||
// authenticated admin action (this handler sits behind the auth middleware). The
|
||||
// archive carries DKIM private keys, the admin password hash and SASL
|
||||
// credentials, so it is served with no-store and as an attachment to discourage
|
||||
// caching of secret material.
|
||||
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if err := backup.Create(w, backup.Params{
|
||||
DataDir: s.cfg.DataDir,
|
||||
DBPath: s.cfg.DBPath,
|
||||
Version: s.cfg.Version,
|
||||
}); err != nil {
|
||||
// Headers (and possibly some bytes) may already be on the wire, so we
|
||||
// cannot switch to a clean error page; log it and let the truncated
|
||||
// download fail loudly on the client side.
|
||||
logf("panel: full backup failed: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportDomain streams a single-domain export as a secret download (spec
|
||||
// 7.5.B). Like the full backup it is POST-only (state is not changed, but the
|
||||
// response contains the domain's DKIM private key and application passwords, so
|
||||
// it must not be prefetchable or cached).
|
||||
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
exp, err := s.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)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// handleImportDomain accepts an uploaded domain-export file and re-creates the
|
||||
// domain on this instance (spec 7.5.B). The domain name is normalised and
|
||||
// validated here (spec 7.6.2); the domain service validates the selector, each
|
||||
// login and address, and the DKIM key before writing anything. On success it
|
||||
// redirects to the new domain's page; on failure it re-renders the backup page,
|
||||
// where the import form lives, with a friendly message.
|
||||
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
||||
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
|
||||
s.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 {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var exp domain.DomainExport
|
||||
dec := json.NewDecoder(file)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&exp); err != nil {
|
||||
s.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 (spec 7.6.2).
|
||||
exp.Domain = normalizeDomain(exp.Domain)
|
||||
if err := validateDomain(exp.Domain); err != nil {
|
||||
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
d, err := s.domains.Import(exp)
|
||||
if err != nil {
|
||||
logf("panel: import domain %q: %v", exp.Domain, err)
|
||||
status, msg := importErrorMessage(err)
|
||||
s.renderBackupPage(w, r, status, msg)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// 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,165 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// handleDashboard is the authenticated landing page: the list of sending
|
||||
// domains with their DKIM/selector and application counts, plus the add-domain
|
||||
// form (spec 7.2.2). Applications and the send log arrive in later phases.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDashboard(w, r, http.StatusOK, "", "")
|
||||
}
|
||||
|
||||
// renderDashboard renders the domain list. formErr and formName repopulate the
|
||||
// add-domain form after a rejected submission; flash surfaces a one-shot status
|
||||
// message keyed by a redirect query flag (never reflected user input).
|
||||
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||||
domains, err := s.domains.List()
|
||||
if err != nil {
|
||||
logf("panel: dashboard: list domains: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, status, "dashboard", map[string]any{
|
||||
"Title": "SelfPost",
|
||||
"User": currentUser(r),
|
||||
"Active": "domains",
|
||||
"Domains": domains,
|
||||
"Error": formErr,
|
||||
"FormName": formName,
|
||||
"Flash": dashboardFlash(r),
|
||||
})
|
||||
}
|
||||
|
||||
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
func dashboardFlash(r *http.Request) string {
|
||||
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 (spec 7.2.3).
|
||||
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||
return
|
||||
}
|
||||
raw := r.PostFormValue("name")
|
||||
name := normalizeDomain(raw)
|
||||
if err := validateDomain(name); err != nil {
|
||||
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
||||
return
|
||||
}
|
||||
|
||||
d, err := s.domains.Add(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrDomainExists) {
|
||||
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
||||
return
|
||||
}
|
||||
logf("panel: add domain %q: %v", name, err)
|
||||
s.renderDashboard(w, r, http.StatusInternalServerError,
|
||||
"Could not add the domain. Please check the logs and try again.", raw)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
|
||||
// panel must explicitly state that all bound applications go with it (spec 7.2.4).
|
||||
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "domain_delete", map[string]any{
|
||||
"Title": "SelfPost — delete " + d.Name,
|
||||
"User": currentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
|
||||
// and returns to the domain list.
|
||||
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseDomainID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Drop any cached DNS verdict for the name while it is still resolvable, so
|
||||
// re-adding the domain later starts from a fresh check instead of a stale
|
||||
// one from before it was removed.
|
||||
if d, err := s.domains.Get(id); err == nil {
|
||||
defer s.dns.Forget(d.Name)
|
||||
}
|
||||
if err := s.domains.Delete(id); err != nil {
|
||||
if errors.Is(err, store.ErrDomainNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: delete domain %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||
// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the
|
||||
// database and reloads its daemon, so the button doubles as a drift-recovery.
|
||||
// The button lives on the status page (phase 13.D): it is a "put the daemons
|
||||
// back in the state the database describes" action, which belongs with the rest
|
||||
// of the server-health screen rather than in the domain list's top bar.
|
||||
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.domains.Resync(); err != nil {
|
||||
logf("panel: manual reload (opendkim): %v", err)
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.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)
|
||||
}
|
||||
|
||||
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
|
||||
// bad id or a missing domain and reporting ok=false in that case.
|
||||
func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
||||
id, ok := parseDomainID(w, r)
|
||||
if !ok {
|
||||
return store.Domain{}, false
|
||||
}
|
||||
d, err := s.domains.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrDomainNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return store.Domain{}, false
|
||||
}
|
||||
logf("panel: get domain %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.Domain{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/logtail"
|
||||
"codeberg.org/mix/selfpost/internal/postfix"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// sendLogPageSize bounds each send-log page (spec 7.2's monitoring screens
|
||||
// call for pagination); logTailLines bounds how much of mail.log the log view
|
||||
// shows per refresh.
|
||||
const (
|
||||
sendLogPageSize = 50
|
||||
logTailLines = 200
|
||||
)
|
||||
|
||||
// handleSendLog renders the send-log monitoring page: server-side filters by
|
||||
// domain/application and pagination (spec 7.3.3). The row table itself is the
|
||||
// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the
|
||||
// initial page and its HTMX-polled refreshes never diverge.
|
||||
func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.sendLogData(r)
|
||||
if err != nil {
|
||||
logf("panel: send log: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data["Title"] = "SelfPost — send log"
|
||||
data["User"] = currentUser(r)
|
||||
data["Active"] = "sendlog"
|
||||
s.render(w, http.StatusOK, "sendlog", data)
|
||||
}
|
||||
|
||||
// handleSendLogRows serves the HTMX polling fragment for the send-log table
|
||||
// (spec 7.1: fragment endpoints return HTML, not JSON).
|
||||
func (s *Server) handleSendLogRows(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.sendLogData(r)
|
||||
if err != nil {
|
||||
logf("panel: send log rows: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderFragment(w, http.StatusOK, "sendlog_rows", data)
|
||||
}
|
||||
|
||||
// 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 (s *Server) 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 := s.store.CountSendLog(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainNames := make([]string, len(domains))
|
||||
for i, d := range domains {
|
||||
domainNames[i] = d.Name
|
||||
}
|
||||
logins, err := s.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
|
||||
}
|
||||
|
||||
// handleQueue renders the mail-queue monitoring page (spec 7.2.11).
|
||||
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
|
||||
out, errText := readQueue()
|
||||
s.render(w, http.StatusOK, "queue", map[string]any{
|
||||
"Title": "SelfPost — mail queue",
|
||||
"User": currentUser(r),
|
||||
"Active": "queue",
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
// handleQueueBody serves the HTMX polling fragment for the queue view.
|
||||
func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) {
|
||||
out, errText := readQueue()
|
||||
s.renderFragment(w, http.StatusOK, "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, ""
|
||||
}
|
||||
|
||||
// handleLogTail renders the mail.log monitoring page (spec 7.2.13).
|
||||
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
|
||||
lines, errText := s.readLogTail()
|
||||
s.render(w, http.StatusOK, "logtail", map[string]any{
|
||||
"Title": "SelfPost — mail log",
|
||||
"User": currentUser(r),
|
||||
"Active": "logtail",
|
||||
"Lines": lines,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogTailBody serves the HTMX polling fragment for the log-tail view.
|
||||
func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) {
|
||||
lines, errText := s.readLogTail()
|
||||
s.renderFragment(w, http.StatusOK, "logtail_body", map[string]any{
|
||||
"Lines": lines,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) readLogTail() ([]string, string) {
|
||||
lines, err := logtail.TailLines(s.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", s.cfg.MailLogPath, err)
|
||||
return nil, "Could not read the mail log."
|
||||
}
|
||||
return lines, ""
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
s := &Server{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
|
||||
|
||||
lines, errText := s.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// defaultRateLimitWindowSeconds is the sliding-window length used when an admin
|
||||
// sets a message ceiling but leaves the window blank (spec 7.4, matching the
|
||||
// level-1 default hour, spec 8: RATE_LIMIT_WINDOW_SECONDS).
|
||||
const defaultRateLimitWindowSeconds = 3600
|
||||
|
||||
// rateLimitInput is the validated result of a rate-limit form submission. clear
|
||||
// means "remove the differentiated limit" (spec 7.4: an empty IP binding leaves
|
||||
// only level 1).
|
||||
type rateLimitInput struct {
|
||||
clear bool
|
||||
ips []string
|
||||
maxMessages int
|
||||
windowSeconds int
|
||||
}
|
||||
|
||||
// parseRateLimitForm validates a rate-limit submission on the server (spec
|
||||
// 7.6.2). It returns clear=true when the admin removes the limit or leaves the
|
||||
// IP binding empty; otherwise it requires a positive ceiling and window. The
|
||||
// returned error's message is safe to show to the admin.
|
||||
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 {
|
||||
// No IP binding: the differentiated limit does not apply (spec 7.4).
|
||||
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
|
||||
}
|
||||
|
||||
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
|
||||
// whitespace) into a deduplicated list of canonical addresses, rejecting any
|
||||
// token that is not a valid IP (spec 7.6.2). The values are only ever stored as
|
||||
// SQLite parameters and compared in the milter, never written to a config file.
|
||||
func parseIPList(raw string) ([]string, error) {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
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
|
||||
}
|
||||
|
||||
// parsePositiveInt parses a trimmed integer field, returning def when it is
|
||||
// blank. A non-numeric value returns an error.
|
||||
func parsePositiveInt(raw string, def int) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return def, nil
|
||||
}
|
||||
return strconv.Atoi(raw)
|
||||
}
|
||||
|
||||
// handleDomainRateLimit saves or clears a domain-level differentiated rate limit
|
||||
// (spec 7.4). No reload is needed — the milter reads the row live.
|
||||
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, err := parseRateLimitForm(r)
|
||||
if err != nil {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
RateLimitErr: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.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)
|
||||
}
|
||||
|
||||
// handleAppRateLimit saves or clears an application-level differentiated rate
|
||||
// limit (spec 7.4).
|
||||
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := s.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := s.domains.Get(a.DomainID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
in, err := parseRateLimitForm(r)
|
||||
if err != nil {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.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)
|
||||
}
|
||||
|
||||
// applyRateLimit dispatches a validated input to the save or clear method of the
|
||||
// relevant service, keyed by the domain or application id.
|
||||
func (s *Server) 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,98 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// handleSetup serves the one-time administrator creation flow at
|
||||
// /setup/<token> (spec 7.6.1). Once an administrator exists the whole route
|
||||
// returns 404; an invalid or expired token is indistinguishable from a missing
|
||||
// page, also 404.
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
// Route-specific rate limit, separate from login (spec 7.6.1).
|
||||
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(r.URL.Path, "/setup/")
|
||||
// Reject nested/garbage paths outright.
|
||||
if token == "" || strings.Contains(token, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.setup.validate(token) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.renderSetupForm(w, http.StatusOK, token, "")
|
||||
case http.MethodPost:
|
||||
s.submitSetup(w, r, token)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
|
||||
s.render(w, status, "setup", map[string]any{
|
||||
"Title": "SelfPost — Create administrator",
|
||||
"Token": token,
|
||||
"Error": formErr,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
confirm := r.PostFormValue("password_confirm")
|
||||
|
||||
if err := validateUsername(username); err != nil {
|
||||
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||
return
|
||||
}
|
||||
if password != confirm {
|
||||
s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(password); err != nil {
|
||||
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: setup: hashing password failed: %v", err)
|
||||
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.CreateAdmin(username, string(hash)); err != nil {
|
||||
// A concurrent submission may have already created the admin; the
|
||||
// id=1 / non-empty-table guard makes this the second writer. Treat it
|
||||
// as "setup already done" rather than an error.
|
||||
if exists, _ := s.store.AdminExists(); exists {
|
||||
s.setup.complete()
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
logf("panel: setup: create admin failed: %v", err)
|
||||
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Setup is now permanently complete: burn the token (spec 7.6.1).
|
||||
s.setup.complete()
|
||||
logf("panel: administrator %q created; setup link is now disabled", username)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// handleStatus renders the server status page: the panel's landing page and the
|
||||
// one screen that answers "is the service healthy and will mail be accepted"
|
||||
// (phase 13.A). The cheap local checks live in the polled "status_body"
|
||||
// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
|
||||
// because neither belongs on a five-second timer.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.statusBody()
|
||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
||||
|
||||
data["Title"] = "SelfPost — status"
|
||||
data["User"] = currentUser(r)
|
||||
data["Active"] = "status"
|
||||
data["Flash"] = statusFlash(r)
|
||||
data["Hostname"] = s.cfg.Hostname
|
||||
data["PTR"] = srv.PTR
|
||||
s.render(w, http.StatusOK, "status", data)
|
||||
}
|
||||
|
||||
// handleStatusFragment serves the HTMX polling fragment for the local checks
|
||||
// (spec 7.1: fragment endpoints return HTML, not JSON).
|
||||
func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
|
||||
s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
|
||||
}
|
||||
|
||||
// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache,
|
||||
// and returns to the page. DNS is the one part of this screen that talks to the
|
||||
// network, so it refreshes on demand rather than with the poll.
|
||||
func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
s.dns.Server(s.cfg.Hostname, true)
|
||||
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// statusBody collects the four local checks the fragment renders. Each one
|
||||
// reports its own problem rather than failing the page, so a broken component
|
||||
// costs one line and not the whole screen.
|
||||
func (s *Server) statusBody() map[string]any {
|
||||
procs, procErr := health.Processes()
|
||||
procStatus := health.StatusUnknown
|
||||
if procErr != nil {
|
||||
// Outside the container (or if the control socket is gone) there is
|
||||
// nothing to report — "unknown", not "everything is broken".
|
||||
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(s.cfg.TLSCertFile)
|
||||
sockets := []health.Socket{
|
||||
// OpenDKIM signs every outgoing message and Postfix is configured to
|
||||
// tempfail without it: a missing socket stops mail.
|
||||
health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true),
|
||||
// The journal-milter only records the send log and fails open.
|
||||
health.CheckSocket("send-log", s.cfg.JournalSocket, false),
|
||||
}
|
||||
socketStatus := health.StatusUnknown
|
||||
for _, sock := range sockets {
|
||||
socketStatus = health.Worst(socketStatus, sock.Status)
|
||||
}
|
||||
|
||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus)
|
||||
return map[string]any{
|
||||
"Processes": procs,
|
||||
"ProcessError": procErr != nil,
|
||||
"ProcessStatus": procStatus,
|
||||
"QueueSummary": queueSummary(queueText),
|
||||
"QueueError": queueErr,
|
||||
"QueueStatus": queueStatus,
|
||||
"Cert": cert,
|
||||
"Sockets": sockets,
|
||||
"SocketStatus": socketStatus,
|
||||
"OverallStatus": overall,
|
||||
"OverallHeading": overallHeading(overall),
|
||||
}
|
||||
}
|
||||
|
||||
// queueSummary reduces postqueue's listing to the one line worth showing on the
|
||||
// status page; the full listing has its own screen (spec 7.2.11). postqueue
|
||||
// prints either "Mail queue is empty" or a trailing "-- N Kbytes in M Requests."
|
||||
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 ""
|
||||
}
|
||||
|
||||
// overallHeading turns the worst check into the page's one-line verdict.
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
// statusFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
// after a redirect is never attacker-influenced.
|
||||
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,54 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const usernameKey ctxKey = 0
|
||||
|
||||
// requireAuth wraps a handler so only requests with a valid session cookie
|
||||
// reach it; everyone else is redirected to the login page. The authenticated
|
||||
// username is stashed in the request context for downstream handlers.
|
||||
//
|
||||
// It also extends the sliding session (plan B.1) on activity, defined as
|
||||
// everything except a GET request carrying HX-Request: the four monitoring
|
||||
// fragments (/status/fragment, /queue/body, /logtail/body, /sendlog/rows)
|
||||
// poll every 5s regardless of whether anyone is looking at the tab, so
|
||||
// counting those as activity would make "N days idle" mean "N days since a
|
||||
// browser tab was last open" instead.
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := s.sessionToken(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
username, ok := s.sessions.Lookup(token)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if isSessionActivity(r) && s.sessions.Touch(token) {
|
||||
s.setSessionCookie(w, token)
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), usernameKey, username)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// isSessionActivity reports whether a request counts as administrator
|
||||
// activity for the sliding session timeout, per requireAuth's doc comment.
|
||||
func isSessionActivity(r *http.Request) bool {
|
||||
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
|
||||
}
|
||||
|
||||
// currentUser returns the authenticated username from the request context.
|
||||
func currentUser(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(usernameKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||
// setup and login routes (spec 7.6.1, 7.6.5). Keys are client IPs. It is not a
|
||||
// precise sliding window — a coarse backstop against brute-force and log noise
|
||||
// is all these routes need.
|
||||
type rateLimiter struct {
|
||||
max int
|
||||
window time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
count int
|
||||
windowEnds time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
max: max,
|
||||
window: window,
|
||||
buckets: make(map[string]*rlBucket),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow records an attempt for key and reports whether it is within the limit.
|
||||
// The current window is reset lazily once it elapses.
|
||||
func (r *rateLimiter) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
b := r.buckets[key]
|
||||
if b == nil || now.After(b.windowEnds) {
|
||||
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
|
||||
r.sweep(now)
|
||||
return true
|
||||
}
|
||||
if b.count >= r.max {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// sweep drops expired buckets so the map cannot grow without bound. Called
|
||||
// under the lock while a window is being reset, which is often enough given the
|
||||
// low request volume of these routes.
|
||||
func (r *rateLimiter) sweep(now time.Time) {
|
||||
for k, b := range r.buckets {
|
||||
if now.After(b.windowEnds) {
|
||||
delete(r.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// contentSecurityPolicy is the panel's CSP. Everything the pages load —
|
||||
// stylesheet, HTMX, the panel's own script, the favicon — is served from
|
||||
// /static by this same origin, and no template carries an inline <script>,
|
||||
// an inline event handler or a style="..." attribute (phase 14.A; the
|
||||
// template guard test enforces that), so 'self' needs no exemptions:
|
||||
//
|
||||
// - default-src 'self' covers scripts, styles, images and the XHR that
|
||||
// HTMX's polling fragments use;
|
||||
// - object-src/frame-ancestors 'none' remove plugin embedding and framing
|
||||
// (clickjacking) outright;
|
||||
// - base-uri 'none' stops an injected <base> from re-pointing every
|
||||
// relative URL on the page, including the form actions;
|
||||
// - form-action 'self' keeps a form from being retargeted at another host.
|
||||
//
|
||||
// This is a second line of defence: XSS is already prevented by
|
||||
// html/template's contextual auto-escaping (spec 7.6.7).
|
||||
const contentSecurityPolicy = "default-src 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'"
|
||||
|
||||
// strictTransportSecurity is sent only when the deployment is HTTPS-only
|
||||
// (CookieSecure, the same condition that gates the cookie's Secure attribute):
|
||||
// on the plain-HTTP development setup it would be meaningless at best and
|
||||
// would pin the browser to a scheme the dev instance does not speak at worst.
|
||||
//
|
||||
// Deliberately without includeSubDomains. The panel commonly lives on a
|
||||
// subdomain of a domain used for other things, but it may also sit at the
|
||||
// apex — and there the directive would force HTTPS on every unrelated
|
||||
// subdomain of that domain for a year, which is not SelfPost's call to make.
|
||||
const strictTransportSecurity = "max-age=31536000"
|
||||
|
||||
// secure wraps the whole router with the panel's two transport-level defences
|
||||
// (phase 14.A): the security response headers, and an origin check on every
|
||||
// state-changing request.
|
||||
//
|
||||
// It sits outside the authentication middleware on purpose, so that POST
|
||||
// /login and POST /setup/{token} are covered too.
|
||||
func (s *Server) secure(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", contentSecurityPolicy)
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
// Equivalent to the CSP's frame-ancestors directive, kept for browsers
|
||||
// that predate it. The two never disagree: both mean "never framed".
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
// The panel's behaviour never depends on the referrer, so the strictest
|
||||
// value is free: panel URLs carry domain names and record identifiers
|
||||
// that need not leak to anything the administrator clicks through to.
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
if s.cfg.CookieSecure {
|
||||
h.Set("Strict-Transport-Security", strictTransportSecurity)
|
||||
}
|
||||
|
||||
if !originAllowed(r) {
|
||||
// Log both sides of the comparison. The check depends on r.Host
|
||||
// being the name the browser actually used, which every reverse
|
||||
// proxy fragment this project ships preserves (Apache
|
||||
// ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy
|
||||
// and Traefik by default) — but a proxy that rewrites Host turns
|
||||
// every form submission into a 403, and without these two values
|
||||
// side by side the symptom just looks like "the panel stopped
|
||||
// saving anything".
|
||||
logf("panel: rejected %s %s: cross-origin request (Sec-Fetch-Site=%q Origin=%q Host=%q); "+
|
||||
"if this was a legitimate request, check that the reverse proxy passes the original Host header through",
|
||||
r.Method, r.URL.Path, r.Header.Get("Sec-Fetch-Site"), r.Header.Get("Origin"), r.Host)
|
||||
http.Error(w, "cross-origin request rejected", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// originAllowed reports whether a state-changing request came from the panel's
|
||||
// own origin (phase 14.A). This is what the session cookie's
|
||||
// SameSite=Lax attribute cannot do on its own: SameSite is judged per *site*
|
||||
// (registrable domain), so a neighbouring subdomain — a CMS on the same
|
||||
// domain, a forgotten staging host — counts as same-site and its forged POST
|
||||
// would arrive with the session cookie attached. Sec-Fetch-Site and Origin are
|
||||
// judged per *origin*, so they tell that neighbour apart from the panel.
|
||||
//
|
||||
// Read-only methods are exempt: every GET route in the panel is a read (the
|
||||
// delete confirmation at GET /domains/{id}/delete only renders a form), so
|
||||
// there is nothing for a cross-origin GET to change.
|
||||
func originAllowed(r *http.Request) bool {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
|
||||
// The browser's own account of where the request came from. It cannot be
|
||||
// set by page script (a forbidden header name), so when it is present it
|
||||
// is the answer: anything but same-origin — including "none", a
|
||||
// navigation with no initiator — is refused.
|
||||
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
|
||||
return site == "same-origin"
|
||||
}
|
||||
|
||||
// Older browsers send only Origin. Compare hosts, not full origins: the
|
||||
// panel speaks plain HTTP behind the proxy and does not know its own
|
||||
// external scheme, while Origin will say https.
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// Neither header. A client this old cannot be checked at all; it is
|
||||
// let through as the risk consciously accepted for phase 14.A
|
||||
// (single-admin panel, the administrator picks the browser). Turning
|
||||
// this return into false is the whole of the stricter policy.
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
// Unparseable, or the opaque "null" origin (sandboxed iframe,
|
||||
// redirected cross-origin POST) — not this panel either way.
|
||||
return false
|
||||
}
|
||||
return u.Host == r.Host
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serveSecured runs one request through the security middleware and reports
|
||||
// what came out. The wrapped handler answers 200, so any other status is the
|
||||
// middleware's doing.
|
||||
func serveSecured(cookieSecure bool, r *http.Request) *httptest.ResponseRecorder {
|
||||
s := &Server{cfg: Config{CookieSecure: cookieSecure}}
|
||||
h := s.secure(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
// post builds a request as a browser would send it to the panel's own host.
|
||||
func post(secFetchSite, origin string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/domains", nil)
|
||||
r.Host = "panel.example.com"
|
||||
if secFetchSite != "" {
|
||||
r.Header.Set("Sec-Fetch-Site", secFetchSite)
|
||||
}
|
||||
if origin != "" {
|
||||
r.Header.Set("Origin", origin)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// The full matrix the origin check has to get right (phase 14.A). The row that
|
||||
// matters most is "same-site": a neighbouring host on example.com is same-site
|
||||
// as far as the session cookie's SameSite=Lax is concerned, so this check is
|
||||
// the only thing standing between it and a forged POST.
|
||||
func TestOriginCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
wantRejected bool
|
||||
}{
|
||||
{"no headers at all is let through (accepted risk)", post("", ""), false},
|
||||
{"same-origin", post("same-origin", "https://panel.example.com"), false},
|
||||
{"same-site, i.e. a neighbouring subdomain", post("same-site", ""), true},
|
||||
{"cross-site", post("cross-site", "https://evil.example.net"), true},
|
||||
{"none, a navigation with no initiator", post("none", ""), true},
|
||||
{"Sec-Fetch-Site outranks a matching Origin", post("cross-site", "https://panel.example.com"), true},
|
||||
{"Origin host matches", post("", "https://panel.example.com"), false},
|
||||
{"Origin host differs", post("", "https://evil.example.net"), true},
|
||||
{"Origin differs from a neighbour only in host", post("", "https://cms.example.com"), true},
|
||||
{"opaque null Origin", post("", "null"), true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := serveSecured(true, tc.req)
|
||||
rejected := rec.Code == http.StatusForbidden
|
||||
if rejected != tc.wantRejected {
|
||||
t.Fatalf("status = %d, rejected = %t, want rejected = %t", rec.Code, rejected, tc.wantRejected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy terminates TLS and forwards plain HTTP, so the panel never learns
|
||||
// its own external scheme: comparing it against the https in Origin would
|
||||
// reject every legitimate form submission in the documented deployment.
|
||||
func TestOriginCheckIgnoresScheme(t *testing.T) {
|
||||
rec := serveSecured(true, post("", "https://panel.example.com"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: an https Origin against a plain-HTTP panel must pass", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Every GET route in the panel is a read, so cross-origin reads have nothing
|
||||
// to change and must not be blocked — the HTMX polling fragments included.
|
||||
func TestOriginCheckExemptsReads(t *testing.T) {
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
r := httptest.NewRequest(method, "http://panel.example.com/status/fragment", nil)
|
||||
r.Host = "panel.example.com"
|
||||
r.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
if rec := serveSecured(true, r); rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want 200", method, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
rec := serveSecured(true, httptest.NewRequest(http.MethodGet, "http://panel.example.com/status", nil))
|
||||
want := map[string]string{
|
||||
"Content-Security-Policy": contentSecurityPolicy,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Strict-Transport-Security": strictTransportSecurity,
|
||||
}
|
||||
for name, value := range want {
|
||||
if got := rec.Header().Get(name); got != value {
|
||||
t.Errorf("%s = %q, want %q", name, got, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HSTS is the one header that must not be sent unconditionally: on the
|
||||
// plain-HTTP development instance it would pin the browser to a scheme that
|
||||
// instance does not speak.
|
||||
func TestHSTSOnlyWhenSecure(t *testing.T) {
|
||||
rec := serveSecured(false, httptest.NewRequest(http.MethodGet, "http://panel.example.com/status", nil))
|
||||
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("Strict-Transport-Security = %q on a non-secure deployment, want none", got)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Security-Policy"); got == "" {
|
||||
t.Error("the other security headers must still be sent without TLS")
|
||||
}
|
||||
}
|
||||
|
||||
// A rejected request is still a response the browser renders, so it carries
|
||||
// the same headers as any other.
|
||||
func TestRejectedRequestKeepsSecurityHeaders(t *testing.T) {
|
||||
rec := serveSecured(true, post("cross-site", ""))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Security-Policy"); got != contentSecurityPolicy {
|
||||
t.Errorf("Content-Security-Policy = %q on the 403 response, want the policy", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// renewThreshold bounds how often an active session's expiry is written back
|
||||
// to the database. Renewing on every request would mean a write (and a new
|
||||
// Set-Cookie) per click; renewing at most once an hour keeps that cost low
|
||||
// while still keeping a busy admin's session alive indefinitely (plan B.1).
|
||||
const renewThreshold = time.Hour
|
||||
|
||||
// sessionStore persists login sessions in the database (plan B.1): a login
|
||||
// survives a container restart or redeploy. Only the SHA-256 of the token is
|
||||
// stored, never the token itself (spec 7.6.6's crypto-random bearer token), so
|
||||
// a stolen database file or backup archive cannot be replayed as a session —
|
||||
// it only extends the login of whichever browser still holds the original
|
||||
// cookie.
|
||||
type sessionStore struct {
|
||||
store *store.Store
|
||||
// idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is
|
||||
// no absolute cap: an administrator who keeps coming back stays signed in
|
||||
// indefinitely, deliberately.
|
||||
idle time.Duration
|
||||
}
|
||||
|
||||
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
|
||||
return &sessionStore{store: st, idle: idle}
|
||||
}
|
||||
|
||||
// MaxAge is the session cookie's Max-Age in seconds, kept equal to the
|
||||
// sliding idle window so the browser drops the cookie no later than the
|
||||
// server would have expired it anyway.
|
||||
func (s *sessionStore) MaxAge() int {
|
||||
return int(s.idle.Seconds())
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Create issues a new session for username and returns its token.
|
||||
func (s *sessionStore) Create(username string) string {
|
||||
token := randomToken(32)
|
||||
now := time.Now()
|
||||
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
|
||||
logf("panel: session: create failed: %v", err)
|
||||
}
|
||||
// Opportunistic cleanup: a session nobody ever came back to otherwise sits
|
||||
// in the table forever. Piggybacking on Create (the one write every login
|
||||
// already pays for) avoids a dedicated background sweep for what is, on a
|
||||
// single-admin panel, a handful of rows at most.
|
||||
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
|
||||
logf("panel: session: prune expired failed: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// Lookup returns the session username for a token if it exists and is
|
||||
// unexpired.
|
||||
func (s *sessionStore) Lookup(token string) (string, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
hash := hashToken(token)
|
||||
row, found, err := s.store.LookupSession(hash)
|
||||
if err != nil {
|
||||
logf("panel: session: lookup failed: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
if err := s.store.DeleteSession(hash); err != nil {
|
||||
logf("panel: session: delete expired failed: %v", err)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return row.Username, true
|
||||
}
|
||||
|
||||
// Touch extends a session's sliding expiry if it has been at least
|
||||
// renewThreshold since the last extension, and reports whether it did so —
|
||||
// the caller uses that to decide whether the response needs a fresh
|
||||
// Set-Cookie. It assumes the caller has just confirmed the session is valid
|
||||
// (e.g. via Lookup); it does nothing for a token that no longer exists.
|
||||
func (s *sessionStore) Touch(token string) bool {
|
||||
hash := hashToken(token)
|
||||
row, found, err := s.store.LookupSession(hash)
|
||||
if err != nil {
|
||||
logf("panel: session: touch lookup failed: %v", err)
|
||||
return false
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
// expiresAt = lastRenewal + idle, so this recovers when the session was
|
||||
// last extended without a separate column.
|
||||
lastRenewal := row.ExpiresAt.Add(-s.idle)
|
||||
now := time.Now()
|
||||
if now.Sub(lastRenewal) < renewThreshold {
|
||||
return false
|
||||
}
|
||||
if err := s.store.RenewSession(hash, now.Add(s.idle)); err != nil {
|
||||
logf("panel: session: renew failed: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Rename updates the username carried by a session, keeping its expiry. It is
|
||||
// used when the administrator renames their own account so the current
|
||||
// session keeps working under the new name.
|
||||
func (s *sessionStore) Rename(token, username string) {
|
||||
if err := s.store.RenameSession(hashToken(token), username); err != nil {
|
||||
logf("panel: session: rename failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DestroyOthers invalidates every session except keep. It is called when the
|
||||
// administrator changes their password: a stolen cookie issued under the old
|
||||
// password must stop working, while the admin performing the change stays
|
||||
// signed in.
|
||||
func (s *sessionStore) DestroyOthers(keep string) {
|
||||
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
|
||||
logf("panel: session: destroy others failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy invalidates a session token (logout).
|
||||
func (s *sessionStore) Destroy(token string) {
|
||||
if err := s.store.DeleteSession(hashToken(token)); err != nil {
|
||||
logf("panel: session: destroy failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func newTestSessionStore(t *testing.T) *sessionStore {
|
||||
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() })
|
||||
return newSessionStore(st, 7*24*time.Hour)
|
||||
}
|
||||
|
||||
func TestSessionRename(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
token := s.Create("admin")
|
||||
|
||||
s.Rename(token, "operator")
|
||||
|
||||
name, ok := s.Lookup(token)
|
||||
if !ok {
|
||||
t.Fatal("session lost after rename")
|
||||
}
|
||||
if name != "operator" {
|
||||
t.Fatalf("session username = %q, want %q", name, "operator")
|
||||
}
|
||||
}
|
||||
|
||||
// A password change must invalidate every other session (so a cookie captured
|
||||
// under the old password stops working) while keeping the one performing the
|
||||
// change signed in.
|
||||
func TestSessionDestroyOthers(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
keep := s.Create("admin")
|
||||
other := s.Create("admin")
|
||||
|
||||
s.DestroyOthers(keep)
|
||||
|
||||
if _, ok := s.Lookup(keep); !ok {
|
||||
t.Fatal("current session was destroyed")
|
||||
}
|
||||
if _, ok := s.Lookup(other); ok {
|
||||
t.Fatal("other session survived")
|
||||
}
|
||||
}
|
||||
|
||||
// A session past its sliding idle expiry must not be honoured.
|
||||
func TestSessionLookupRejectsExpired(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
s.idle = -time.Minute // already expired the instant it's created
|
||||
token := s.Create("admin")
|
||||
|
||||
if _, ok := s.Lookup(token); ok {
|
||||
t.Fatal("expired session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Touch must not rewrite the expiry (or report a renewal) inside the
|
||||
// once-an-hour throttle window, so an active tab's polling doesn't turn into
|
||||
// a database write per request.
|
||||
func TestSessionTouchThrottled(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
token := s.Create("admin")
|
||||
|
||||
if s.Touch(token) {
|
||||
t.Fatal("touch renewed a session created moments ago")
|
||||
}
|
||||
|
||||
// Back-date the session's last renewal by rewriting its expiry, as if it
|
||||
// had been created (or last renewed) 2 hours ago rather than moments ago.
|
||||
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
|
||||
t.Fatalf("renew session: %v", err)
|
||||
}
|
||||
if !s.Touch(token) {
|
||||
t.Fatal("touch did not renew a session past the throttle window")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// setupTokenTTL is the lifetime of a setup token (spec 7.6.1). After it
|
||||
// elapses the token is regenerated and re-announced on the next /setup hit.
|
||||
const setupTokenTTL = 10 * time.Minute
|
||||
|
||||
// setupManager owns the one-time administrator setup token. The token itself is
|
||||
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
|
||||
// persistent "setup complete" fact is the presence of the admin row in the
|
||||
// store, so once that exists the token is gone for good (spec 7.6.1).
|
||||
type setupManager struct {
|
||||
store *store.Store
|
||||
hostname string
|
||||
tokenPath string
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager {
|
||||
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
|
||||
}
|
||||
|
||||
// bootstrap runs once at startup. If setup is already complete it clears any
|
||||
// stale token file; otherwise it mints and announces the first token.
|
||||
func (m *setupManager) bootstrap() error {
|
||||
done, err := m.store.AdminExists()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
m.clearTokenFile()
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.regenerateLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeToken returns the current valid setup token, regenerating and
|
||||
// re-announcing it if none exists or it has expired. It returns ("", false)
|
||||
// once setup is complete — callers must treat that as "route gone" (404).
|
||||
func (m *setupManager) activeToken() (string, bool) {
|
||||
done, err := m.store.AdminExists()
|
||||
if err != nil {
|
||||
logf("panel: setup: admin check failed: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if done {
|
||||
return "", false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.token == "" || time.Now().After(m.expiresAt) {
|
||||
m.regenerateLocked()
|
||||
}
|
||||
return m.token, true
|
||||
}
|
||||
|
||||
// validate reports whether provided matches the active token, using a
|
||||
// constant-time comparison to avoid leaking a correct prefix via timing
|
||||
// (spec 7.6.1). A mismatch does NOT regenerate or invalidate the token: failed
|
||||
// attempts must not let an attacker DoS a legitimate setup (spec 7.6.1).
|
||||
func (m *setupManager) validate(provided string) bool {
|
||||
token, ok := m.activeToken()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
||||
}
|
||||
|
||||
// complete marks setup as finished: the admin row now exists, so drop the
|
||||
// in-memory token and remove the on-disk copy.
|
||||
func (m *setupManager) complete() {
|
||||
m.mu.Lock()
|
||||
m.token = ""
|
||||
m.expiresAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
m.clearTokenFile()
|
||||
}
|
||||
|
||||
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
|
||||
// Caller holds m.mu.
|
||||
func (m *setupManager) regenerateLocked() {
|
||||
m.token = randomToken(16) // 128 bits of entropy (spec 7.6.1)
|
||||
m.expiresAt = time.Now().Add(setupTokenTTL)
|
||||
m.announce(m.token)
|
||||
}
|
||||
|
||||
// announce prints the setup link to the container log and writes it to the
|
||||
// token file so it can be read either way (spec 7.6.1).
|
||||
func (m *setupManager) announce(token string) {
|
||||
url := m.setupURL(token)
|
||||
logf("panel: ==================================================================")
|
||||
logf("panel: SelfPost first-run setup — open this one-time link within %s:", setupTokenTTL)
|
||||
logf("panel: %s", url)
|
||||
logf("panel: (also written to %s)", m.tokenPath)
|
||||
logf("panel: ==================================================================")
|
||||
|
||||
if m.tokenPath == "" {
|
||||
return
|
||||
}
|
||||
// 0600: the token is a bearer secret for creating the admin.
|
||||
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
|
||||
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *setupManager) setupURL(token string) string {
|
||||
host := m.hostname
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
return fmt.Sprintf("https://%s/setup/%s", host, token)
|
||||
}
|
||||
|
||||
func (m *setupManager) clearTokenFile() {
|
||||
if m.tokenPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(m.tokenPath); err != nil && !os.IsNotExist(err) {
|
||||
logf("panel: setup: could not remove token file %s: %v", m.tokenPath, err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,153 @@
|
||||
/* Panel stylesheet. It lives in a file rather than in a <style> block in the
|
||||
layout so the panel's Content-Security-Policy can be a plain
|
||||
"default-src 'self'" with no inline-style exemption (phase 14.A). Any rule
|
||||
added here must therefore stay here: an inline style="..." attribute in a
|
||||
template is blocked by that policy and silently does nothing. */
|
||||
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #14171a; color: #e6e8eb; }
|
||||
.card { background: #1d2125 !important; border-color: #2b3138 !important; }
|
||||
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
main { max-width: 42rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
.card {
|
||||
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px;
|
||||
padding: 1.5rem; margin: 0 auto;
|
||||
}
|
||||
.card.narrow { max-width: 24rem; }
|
||||
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||
input {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid #cfd4da; border-radius: 6px; background: #fff;
|
||||
}
|
||||
/* One vocabulary for actions. Anything that performs an action looks like a
|
||||
button: a <button>, or an <a> carrying .btn/.danger where the action is a
|
||||
plain navigation (the delete confirmation page, the full queue view).
|
||||
Several of these used to render as bold blue text instead — a POST wrapped
|
||||
in form.inline, a <details> toggle, the delete links — which read as links
|
||||
and left two appearances for the same kind of control. They all get the
|
||||
button look now: filled for a card's own action, and the compact outlined
|
||||
variant further down where actions cluster (table rows, the nav bar). Bare
|
||||
<a> is left for links that read as part of a sentence or a list. */
|
||||
button, a.btn, a.danger {
|
||||
display: inline-block; margin-top: 1.2rem; padding: 0.6rem 1.1rem;
|
||||
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||
color: #fff; background: #2563eb; border: 0; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
button:hover, a.btn:hover { background: #1d4ed8; }
|
||||
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
|
||||
.muted { color: #6b7280; }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
|
||||
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
|
||||
/* form.inline only exists so a POST can sit next to other content without a
|
||||
form's block layout; its button is styled like any other. */
|
||||
form.inline { display: inline; margin: 0; }
|
||||
main { max-width: 48rem; }
|
||||
.card + .card { margin-top: 1.2rem; }
|
||||
.flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
|
||||
@media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; }
|
||||
@media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } }
|
||||
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
|
||||
td.actions { text-align: right; }
|
||||
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
||||
@media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
.back { display: inline-block; margin-bottom: 1rem; }
|
||||
/* Build version, closing every authenticated page. Quiet on purpose: it is
|
||||
reference material, not something to read on the way past. */
|
||||
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
|
||||
select, textarea {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
select, textarea { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
button.danger, a.danger { background: #b42318; }
|
||||
button.danger:hover, a.danger:hover { background: #912018; }
|
||||
td.actions form.inline, td.actions details { margin: 0.4rem 0 0 0.5rem; }
|
||||
/* The disclosure toggle is an action too, so it is drawn as a button (see the
|
||||
compact rule below); the marker is dropped because the pressed background
|
||||
already shows the open state. */
|
||||
td.actions summary { display: inline-block; list-style: none; cursor: pointer; }
|
||||
td.actions summary::-webkit-details-marker { display: none; }
|
||||
details form { margin-top: 0.6rem; }
|
||||
.credential { border-color: #f5c518; background: #fffbeb; }
|
||||
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
|
||||
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||
authenticated page without each content template having to include it. */
|
||||
.nav {
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
|
||||
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid #e2e5e9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
|
||||
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; }
|
||||
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
|
||||
.nav .links [aria-current] {
|
||||
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
|
||||
}
|
||||
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
|
||||
status page and the per-domain DNS checks, so a colour means the same thing
|
||||
everywhere. The class suffix is the check's own status value. */
|
||||
.st {
|
||||
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
vertical-align: middle; border: 1px solid transparent;
|
||||
}
|
||||
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; }
|
||||
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; }
|
||||
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; }
|
||||
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
|
||||
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
|
||||
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
|
||||
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code { flex: 1; min-width: 0; }
|
||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||
that several can sit together without shouting — the Copy buttons beside a
|
||||
value, the per-application actions in a table row. Sign out overrides this
|
||||
with .danger below since signing out is a deliberate, singular action. */
|
||||
button.copy, td.actions button, td.actions summary, td.actions a.danger, .nav button {
|
||||
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||
border-radius: 6px; white-space: nowrap;
|
||||
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
|
||||
}
|
||||
button.copy:hover, td.actions button:hover, td.actions summary:hover,
|
||||
td.actions a.danger:hover, .nav button:hover { background: #e2e7ee; }
|
||||
button.copy { flex: none; margin-top: 0.3rem; }
|
||||
td.actions details[open] > summary { background: #dde3ec; }
|
||||
td.actions button.danger, td.actions a.danger, .nav button.danger {
|
||||
color: #b42318; background: #fef3f2; border-color: #fecdca;
|
||||
}
|
||||
td.actions button.danger:hover, td.actions a.danger:hover, .nav button.danger:hover { background: #fee4e2; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
button.copy, td.actions button, td.actions summary, td.actions a.danger, .nav button {
|
||||
background: #22262b !important; border-color: #2b3138 !important;
|
||||
}
|
||||
button.copy:hover, td.actions button:hover, td.actions summary:hover,
|
||||
td.actions a.danger:hover, .nav button:hover { background: #2b3138 !important; }
|
||||
td.actions details[open] > summary { background: #313841 !important; }
|
||||
td.actions button.danger, td.actions a.danger, .nav button.danger {
|
||||
color: #f5a29b !important; background: #2d1211 !important; border-color: #6b201a !important;
|
||||
}
|
||||
td.actions button.danger:hover, td.actions a.danger:hover, .nav button.danger:hover { background: #3d1a18 !important; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Panel progressive enhancement. Everything here is optional convenience: the
|
||||
// pages are fully usable with JavaScript disabled or blocked, and nothing is
|
||||
// sent to the server from this file.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// --- Copy buttons on .code values ------------------------------------
|
||||
// Values that get carried into another interface (a DNS panel, a mail
|
||||
// client) sit in a .code-row wrapper next to a Copy button. The text is read
|
||||
// from the .code element itself, so it can never drift from what is shown.
|
||||
// navigator.clipboard needs a secure context (HTTPS or localhost); over plain
|
||||
// HTTP in development it is simply absent, in which case the value stays
|
||||
// selectable by hand.
|
||||
document.addEventListener("click", function (ev) {
|
||||
var button = ev.target.closest("button.copy");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
var row = button.closest(".code-row");
|
||||
var code = row && row.querySelector(".code");
|
||||
if (!code || !navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(code.textContent).then(function () {
|
||||
var original = button.textContent;
|
||||
button.textContent = "Copied";
|
||||
setTimeout(function () {
|
||||
button.textContent = original;
|
||||
}, 1500);
|
||||
}, function () {
|
||||
/* Clipboard refused (permissions, insecure context): leave the page be. */
|
||||
});
|
||||
});
|
||||
|
||||
// --- Confirmation on destructive forms --------------------------------
|
||||
// Forms that delete something or invalidate a working credential carry a
|
||||
// data-confirm message. The prompt lives here rather than in an inline
|
||||
// onsubmit attribute because the panel's Content-Security-Policy allows no
|
||||
// inline script (phase 14.A). The listener is delegated from the document,
|
||||
// so it also covers markup swapped in by HTMX. With JavaScript disabled the
|
||||
// form submits without asking — exactly as the inline handler behaved.
|
||||
document.addEventListener("submit", function (ev) {
|
||||
var form = ev.target.closest("form[data-confirm]");
|
||||
if (form && !window.confirm(form.dataset.confirm)) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Address list shown only in list mode -----------------------------
|
||||
// The "Addresses" field applies to list mode only; in wildcard mode the
|
||||
// server ignores it, so hiding it removes a field that does nothing. The
|
||||
// toggle runs on load too, because the edit form of an existing application
|
||||
// may already be set to list mode.
|
||||
function syncAddressField(select) {
|
||||
var form = select.closest("form");
|
||||
var field = form && form.querySelector("[data-addresses]");
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
// The mode values come from the server (store.AddressModeList), so the
|
||||
// select carries the one that means "list" rather than this script
|
||||
// hard-coding it.
|
||||
field.hidden = select.value !== select.dataset.listMode;
|
||||
}
|
||||
|
||||
function initAddressFields(root) {
|
||||
root.querySelectorAll("select[data-list-mode]").forEach(function (select) {
|
||||
syncAddressField(select);
|
||||
select.addEventListener("change", function () {
|
||||
syncAddressField(select);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initAddressFields(document);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,118 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// templates holds the parsed page and fragment templates. Each page is parsed
|
||||
// together with the shared base layout so {{ template "base" . }} works.
|
||||
// Fragments (HTMX polling targets, spec 7.1) are parsed standalone, without
|
||||
// the layout, so they can be swapped into an existing page as an HTML snippet
|
||||
// rather than a full document. Rendering always goes through html/template,
|
||||
// which auto-escapes all interpolated data regardless (spec 7.6.7).
|
||||
type templates struct {
|
||||
pages map[string]*template.Template
|
||||
fragments map[string]*template.Template
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
// composes with layout.html; pages that embed a polling fragment (spec 7.1)
|
||||
// list that fragment's file too, so the same {{define}} block renders both
|
||||
// the initial page and the fragment's own refresh responses identically.
|
||||
var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"account": {"templates/account.html"},
|
||||
"backup": {"templates/backup.html"},
|
||||
"domain_detail": {"templates/domain_detail.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
|
||||
"queue": {"templates/queue.html", "templates/queue_body.html"},
|
||||
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
// template file, for standalone rendering by the HTMX polling endpoints.
|
||||
var fragmentFiles = map[string]string{
|
||||
"sendlog_rows": "templates/sendlog_rows.html",
|
||||
"queue_body": "templates/queue_body.html",
|
||||
"logtail_body": "templates/logtail_body.html",
|
||||
"status_body": "templates/status_body.html",
|
||||
}
|
||||
|
||||
func loadTemplates() (*templates, error) {
|
||||
t := &templates{
|
||||
pages: make(map[string]*template.Template),
|
||||
fragments: make(map[string]*template.Template),
|
||||
}
|
||||
for name, files := range pageFiles {
|
||||
patterns := append([]string{"templates/layout.html"}, files...)
|
||||
tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
}
|
||||
t.pages[name] = tmpl
|
||||
}
|
||||
for name, file := range fragmentFiles {
|
||||
tmpl, err := template.ParseFS(assetsFS, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
|
||||
}
|
||||
t.fragments[name] = tmpl
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// render writes a page using the base layout. Rendering to a buffer first means
|
||||
// a template error yields a clean 500 instead of a half-written page.
|
||||
func (s *Server) render(w http.ResponseWriter, status int, page string, data any) {
|
||||
tmpl, ok := s.tmpl.pages[page]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// The layout's navigation compares .Active against each item, so the key
|
||||
// must exist on every authenticated page. Defaulting it here keeps a page
|
||||
// that forgets it from failing to render — it simply highlights nothing.
|
||||
// .Version, shown in the layout's footer, is supplied the same way: it is
|
||||
// the same value on every page, so no handler should have to pass it.
|
||||
if m, ok := data.(map[string]any); ok {
|
||||
if _, has := m["Active"]; !has {
|
||||
m["Active"] = ""
|
||||
}
|
||||
m["Version"] = s.cfg.Version
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
logf("panel: render %s: %v", page, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
|
||||
// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with
|
||||
// no surrounding layout (spec 7.1: fragment endpoints return HTML, not JSON).
|
||||
func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) {
|
||||
tmpl, ok := s.tmpl.fragments[name]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
logf("panel: render fragment %s: %v", name, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<h1>Account</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card narrow">
|
||||
<h2>Panel credentials</h2>
|
||||
<p class="muted">These are the credentials for this control panel only.
|
||||
Applications keep their own logins and passwords, which are not affected.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/account">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" name="current_password" type="password"
|
||||
autocomplete="current-password" required>
|
||||
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
||||
|
||||
<label for="new_password_confirm">Confirm new password</label>
|
||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
</form>
|
||||
<p class="muted">Leave both new-password fields empty to change the username
|
||||
only. Changing the password signs out every other session; this one stays
|
||||
signed in.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content"}}
|
||||
<h1>Backup & migration</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Full backup</h2>
|
||||
<p class="muted">Download a full backup of all persistent state — the database,
|
||||
every domain's DKIM key and the application credentials. Use it to move the
|
||||
whole server to a new machine: restore it into a container of the
|
||||
<strong>same SelfPost version</strong>, with the same data mount, before first
|
||||
start. TLS certificates and the mail queue are not included.</p>
|
||||
<p class="muted"><strong>The backup file is a secret</strong> (it contains
|
||||
private keys and credentials). Store and transfer it securely and delete it
|
||||
once the restore succeeds.</p>
|
||||
<form class="inline" method="post" action="/backup">
|
||||
<button type="submit">Download full backup</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Import a domain</h2>
|
||||
<p class="muted">Move a single domain here from another SelfPost instance using
|
||||
a domain export file (from that domain's page). Its DKIM key and application
|
||||
passwords come across, so the published DNS record needs no change. The export
|
||||
file is a secret, like a full backup.</p>
|
||||
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/import" enctype="multipart/form-data">
|
||||
<label for="importfile">Domain export file</label>
|
||||
<input id="importfile" name="file" type="file" accept=".json,application/json" required>
|
||||
<button type="submit">Import domain</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
<h1>Domains</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Add a sending domain</h2>
|
||||
<form method="post" action="/domains">
|
||||
<label for="name">Domain</label>
|
||||
<input id="name" name="name" type="text" placeholder="example.com"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormName}}" autofocus required>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Add domain</button>
|
||||
</form>
|
||||
<p class="muted">A DKIM key is generated for the domain; you then publish the
|
||||
shown DNS record. Adding a domain does not create an application.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Domains</h2>
|
||||
{{if .Domains}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Domain</th><th>Selector</th><th>Apps</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Domains}}
|
||||
<tr>
|
||||
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
|
||||
<td class="muted">{{.DKIMSelector}}</td>
|
||||
<td>{{.AppCount}}</td>
|
||||
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No domains yet. Add one above to get started.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/domains/{{.Domain.ID}}">← Back to {{.Domain.Name}}</a>
|
||||
|
||||
<div class="card">
|
||||
<h2>Confirm deletion</h2>
|
||||
<p>You are about to delete <strong>{{.Domain.Name}}</strong>. This will:</p>
|
||||
<ul>
|
||||
<li>permanently delete its DKIM signing key;</li>
|
||||
<li>delete
|
||||
{{if eq .Domain.AppCount 0}}its (currently none) bound applications{{else}}
|
||||
<strong>all {{.Domain.AppCount}} bound application(s)</strong>, including
|
||||
their SASL credentials and sender bindings{{end}};</li>
|
||||
<li>reload OpenDKIM so the domain is no longer signed.</li>
|
||||
</ul>
|
||||
<p class="muted">This cannot be undone. The published DKIM DNS record becomes
|
||||
invalid; remove it from DNS if you do not plan to re-add the domain.</p>
|
||||
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/delete">
|
||||
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,305 @@
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/domains">← All domains</a>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
||||
|
||||
{{if .NewCred}}
|
||||
<div class="card credential">
|
||||
<h2>New application password</h2>
|
||||
<p class="muted">This password is shown <strong>once only</strong> and is not
|
||||
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
||||
<label>Login</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.NewCred.Login}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
<label>Password</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.NewCred.Password}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>DKIM DNS record</h2>
|
||||
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
|
||||
It is not a secret and can be viewed at any time.</p>
|
||||
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>SPF and DMARC records</h2>
|
||||
<p class="muted">These two are not generated the way the DKIM record above is —
|
||||
they are policy, and the domain may already publish an SPF record for other
|
||||
senders. What follows is what this server expects to see and what the checks
|
||||
below look for. Publish both as TXT records.</p>
|
||||
|
||||
<label>SPF — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>SPF — value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.SPFExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">A domain may have only one SPF record. If it already has one,
|
||||
do not add a second — add this server's mechanism to the existing record
|
||||
instead, before its <code>all</code> term. <code>-all</code> at the end means
|
||||
nothing else may send as the domain; use <code>~all</code> while other senders
|
||||
are still being sorted out.</p>
|
||||
|
||||
<label>DMARC — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>DMARC — value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Point <code>rua=</code> at a mailbox that is actually read —
|
||||
the aggregate reports are how you find out who else sends as the domain.
|
||||
<code>p=none</code> changes nothing about delivery, so it is safe to publish
|
||||
straight away; tighten it to <code>p=quarantine</code> and then
|
||||
<code>p=reject</code> once the reports come back clean.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
||||
<p class="muted">What DNS publishes for <strong>{{.Domain.Name}}</strong> right
|
||||
now, checked against the key this server signs with. Results are cached for a
|
||||
few minutes — after publishing a record, use <em>Re-check</em>.</p>
|
||||
|
||||
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
||||
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
||||
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
||||
{{end}}</span>{{end}}
|
||||
|
||||
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
||||
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
||||
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
||||
{{end}}</span>{{end}}
|
||||
<p class="muted">The SPF check is deliberately shallow: it looks for a
|
||||
mechanism that literally covers this server's address and does not follow
|
||||
<code>include:</code> or <code>redirect=</code>, so a record that authorises
|
||||
the server through an include is reported as “cannot tell”, not as a failure.</p>
|
||||
|
||||
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
||||
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
||||
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||
{{end}}</span>{{end}}
|
||||
|
||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
||||
<button type="submit">Re-check</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sending server settings</h2>
|
||||
<p class="muted">Point the mail client or script at these settings and
|
||||
authenticate with an application login and password from the
|
||||
<strong>Applications</strong> section below. They are the same for every
|
||||
domain on this server.</p>
|
||||
|
||||
<label>Server</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Hostname}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Port and encryption</label>
|
||||
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
||||
587 — STARTTLS (submission){{end}}</span>
|
||||
|
||||
<p class="muted">Authentication is required on every port. The username is the
|
||||
application's login (see the table below) and the password is the one shown
|
||||
once when that application was created or its password regenerated — if it was
|
||||
lost, generate a new one.</p>
|
||||
</div>
|
||||
|
||||
{{/* Create form above the list, the same order the domains page uses for
|
||||
"Add a sending domain" above "Domains". */}}
|
||||
<div class="card">
|
||||
<h2>Add an application</h2>
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||
<label for="login">Login</label>
|
||||
<input id="login" name="login" type="text" placeholder="prod-server"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormLogin}}" required>
|
||||
|
||||
<label for="mode">Address mode</label>
|
||||
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
||||
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
|
||||
<div data-addresses>
|
||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="3"
|
||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Create application</button>
|
||||
</form>
|
||||
<p class="muted">A strong password is generated and shown once. The login must
|
||||
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Applications</h2>
|
||||
<p class="muted">Each application is a SASL login/password an app or script
|
||||
uses to send mail as this domain. A login may send from any address of the
|
||||
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
||||
|
||||
{{if .Apps}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Login</th><th>Mode</th><th>Addresses</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Apps}}
|
||||
<tr>
|
||||
<td class="code">{{.Login}}</td>
|
||||
<td>{{if eq .AddressMode $.Wildcard}}Any address (@{{$.Domain.Name}}){{else}}List{{end}}</td>
|
||||
<td class="muted">
|
||||
{{if eq .AddressMode $.Wildcard}}*@{{$.Domain.Name}}{{else}}
|
||||
{{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<details>
|
||||
<summary>Edit mode</summary>
|
||||
<form method="post" action="/applications/{{.ID}}/mode">
|
||||
<label>Address mode</label>
|
||||
<select name="mode" data-list-mode="{{$.List}}">
|
||||
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
<div data-addresses>
|
||||
<label>Addresses (one per line or comma-separated)</label>
|
||||
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
|
||||
{{end}}{{$a}}{{end}}</textarea>
|
||||
</div>
|
||||
<button type="submit">Save mode</button>
|
||||
</form>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Rate limit{{if .HasLimit}} (active){{end}}</summary>
|
||||
<form method="post" action="/applications/{{.ID}}/ratelimit">
|
||||
<label>Expected client IPs (one per line or comma-separated)</label>
|
||||
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
||||
<label>Message limit</label>
|
||||
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
|
||||
<label>Window (seconds)</label>
|
||||
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
||||
<button type="submit">Save limit</button>
|
||||
</form>
|
||||
{{if .HasLimit}}
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
||||
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
|
||||
<input type="hidden" name="clear" value="1">
|
||||
<button type="submit" class="danger">Remove limit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</details>
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
|
||||
<button type="submit">New password</button>
|
||||
</form>
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
|
||||
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No applications yet. Add one above to get started.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sending rate limit (domain)</h2>
|
||||
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this
|
||||
domain may send from its expected client IP(s) within a time window, summed
|
||||
across all its applications. It counts messages — one message to many
|
||||
recipients counts once. Leave the IP list empty to disable it and rely only on
|
||||
the global level-1 limit. Applications that send from changing IPs should be
|
||||
left unbound here.</p>
|
||||
|
||||
<p class="muted">Status:
|
||||
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
|
||||
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
||||
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
|
||||
<textarea id="d_ips" name="allowed_ips" rows="2"
|
||||
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
|
||||
|
||||
<label for="d_max">Message limit</label>
|
||||
<input id="d_max" name="max_messages" type="number" min="1"
|
||||
value="{{.DomainRLMax}}" placeholder="1000">
|
||||
|
||||
<label for="d_win">Window (seconds)</label>
|
||||
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||
|
||||
<button type="submit">Save limit</button>
|
||||
</form>
|
||||
{{if .DomainHasRL}}
|
||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
|
||||
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
|
||||
<input type="hidden" name="clear" value="1">
|
||||
<button type="submit" class="danger">Remove limit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Export domain</h2>
|
||||
<p class="muted">Download this domain to move it to another SelfPost instance:
|
||||
its DKIM key, selector and every application with its working password. On
|
||||
import the DNS record stays the same, so no DNS change is needed.</p>
|
||||
<p class="muted"><strong>The export file is a secret</strong> — it contains the
|
||||
private DKIM key and application passwords. Transfer it securely and delete it
|
||||
after the import.</p>
|
||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/export">
|
||||
<button type="submit">Export domain</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="muted">Deleting this domain also deletes its DKIM key and every
|
||||
application bound to it.</p>
|
||||
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,54 @@
|
||||
{{define "layout.html"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="icon" href="/static/favicon.png" type="image/png">
|
||||
<link rel="stylesheet" href="/static/panel.css">
|
||||
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
|
||||
the head for the request-indicator classes. The panel uses no
|
||||
hx-indicator, and that injected element would be the one thing the
|
||||
Content-Security-Policy (phase 14.A) has to make an exception for, so it
|
||||
is switched off here. */}}
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{if .User}}{{template "nav" .}}{{end}}
|
||||
{{template "content" .}}
|
||||
{{/* The running version, on every authenticated page: it is what a backup
|
||||
manifest is checked against on restore and the first thing to establish
|
||||
when something behaves unexpectedly. Only for signed-in administrators —
|
||||
the login and setup pages must not advertise it to the internet. */}}
|
||||
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
|
||||
every authenticated page has it without the page's own template having to
|
||||
remember to include it; .Active names the current page so it is highlighted
|
||||
instead of linking to itself. Unauthenticated pages (login, setup) carry no
|
||||
.User and get no nav. */}}
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
<div class="links">
|
||||
{{if eq .Active "status"}}<span aria-current="page">Status</span>{{else}}<a href="/status">Status</a>{{end}}
|
||||
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/domains">Domains</a>{{end}}
|
||||
{{if eq .Active "sendlog"}}<span aria-current="page">Deliveries</span>{{else}}<a href="/sendlog">Deliveries</a>{{end}}
|
||||
{{if eq .Active "queue"}}<span aria-current="page">Mail queue</span>{{else}}<a href="/queue">Mail queue</a>{{end}}
|
||||
{{if eq .Active "logtail"}}<span aria-current="page">System log</span>{{else}}<a href="/logtail">System log</a>{{end}}
|
||||
{{if eq .Active "backup"}}<span aria-current="page">Backup</span>{{else}}<a href="/backup">Backup</a>{{end}}
|
||||
</div>
|
||||
<div class="session">
|
||||
<span class="muted">{{.User}}</span>
|
||||
{{if eq .Active "account"}}<span aria-current="page">Account</span>{{else}}<a href="/account">Account</a>{{end}}
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit" class="danger">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "content"}}
|
||||
<h1>Sign in</h1>
|
||||
<div class="card narrow">
|
||||
{{if .SetupHint}}
|
||||
<p class="muted">No administrator has been created yet. Open the one-time
|
||||
setup link printed in the container log to get started.</p>
|
||||
{{else}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" autofocus required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "content"}}
|
||||
<h1>Mail log</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Recent log entries</h2>
|
||||
{{template "logtail_body" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "logtail_body"}}
|
||||
<div id="logtail-body" hx-get="/logtail/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
||||
{{end}}{{else}}No log lines yet.{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "content"}}
|
||||
<h1>Mail queue</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Pending messages</h2>
|
||||
{{template "queue_body" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{{define "queue_body"}}
|
||||
<div id="queue-body" hx-get="/queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content"}}
|
||||
<h1>Deliveries</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Filter</h2>
|
||||
<form method="get" action="/sendlog">
|
||||
<label for="domain">Domain</label>
|
||||
<select id="domain" name="domain">
|
||||
<option value="">All domains</option>
|
||||
{{range .FilterDomains}}<option value="{{.}}" {{if eq . $.FilterDomain}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<label for="app">Application</label>
|
||||
<select id="app" name="app">
|
||||
<option value="">All applications</option>
|
||||
{{range .FilterApps}}<option value="{{.}}" {{if eq . $.FilterApp}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{{template "sendlog_rows" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "sendlog_rows"}}
|
||||
<div id="sendlog-rows" hx-get="/sendlog/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
|
||||
hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
{{if .Rows}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Domain</th><th>App</th><th>From</th><th>To</th><th>Subject</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<td class="muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
|
||||
<td>{{.Domain}}</td>
|
||||
<td>{{.AppLogin}}</td>
|
||||
<td>{{.From}}</td>
|
||||
<td>{{.To}}</td>
|
||||
<td>{{.Subject}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">
|
||||
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
|
||||
{{if .HasPrev}} · <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||
{{if .HasNext}} · <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="muted">No messages logged yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "content"}}
|
||||
<h1>Create administrator</h1>
|
||||
<div class="card narrow">
|
||||
<p class="muted">This one-time link creates the single panel administrator.
|
||||
After you submit, the link stops working for good.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/setup/{{.Token}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" autofocus required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||
|
||||
<label for="password_confirm">Confirm password</label>
|
||||
<input id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required>
|
||||
|
||||
<button type="submit">Create administrator</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content"}}
|
||||
<h1>Server status</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
{{template "status_body" .}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
|
||||
<p class="muted">Receiving servers check that the name this server announces
|
||||
resolves to its address <em>and</em> that the address resolves back to the same
|
||||
name (forward-confirmed reverse DNS). A missing or mismatched reverse record is
|
||||
the most common reason self-hosted mail is rejected or scored as spam. The
|
||||
reverse record is set at the hosting provider, not in the domain's DNS zone.</p>
|
||||
|
||||
<label>Server hostname</label>
|
||||
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
|
||||
|
||||
{{if .PTR.Records}}
|
||||
<label>Forward and reverse lookup</label>
|
||||
<span class="code">{{range .PTR.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
|
||||
<p class="{{if eq .PTR.Status "ok"}}muted{{else}}error{{end}}">{{.PTR.Detail}}</p>
|
||||
|
||||
<form class="inline" method="post" action="/status/recheck">
|
||||
<button type="submit">Re-check DNS</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Configuration</h2>
|
||||
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||
database and reloads both daemons. Use it if you edited the files by hand,
|
||||
restored a backup, or the running configuration looks out of step with the
|
||||
domain and application lists. It does not touch the mail queue or the TLS
|
||||
certificate, and it is safe to run at any time.</p>
|
||||
<form class="inline" method="post" action="/reload">
|
||||
<button type="submit">Reload configuration</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{define "status_body"}}
|
||||
<div id="status-body" hx-get="/status/fragment" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
<div class="card">
|
||||
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
||||
<p class="muted">{{.OverallHeading}}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
|
||||
{{if .ProcessError}}
|
||||
<p class="error">Could not ask supervisord for the process list.</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Processes}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
|
||||
<td class="muted">{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
|
||||
{{if .QueueError}}
|
||||
<p class="error">{{.QueueError}}</p>
|
||||
{{else}}
|
||||
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
||||
<a class="btn" href="/queue">Full queue</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
|
||||
<p class="muted">The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}.
|
||||
It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.</p>
|
||||
{{if not .Cert.NotAfter.IsZero}}
|
||||
<label>Expires</label>
|
||||
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
|
||||
{{end}}
|
||||
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
||||
<table>
|
||||
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sockets}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="muted">{{.Path}}</td>
|
||||
<td><span class="st st-{{.Status}}">{{.Status}}</span> {{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,283 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// The navigation is rendered from the layout, not copied into each page, so
|
||||
// every page template must resolve it. This is what makes "the nav is on every
|
||||
// authenticated page" a structural property instead of a checklist item.
|
||||
func TestEveryPageResolvesNav(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
for name, page := range tmpl.pages {
|
||||
if page.Lookup("nav") == nil {
|
||||
t.Errorf("page %q does not resolve the shared nav template", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The version comes from render(), not from each handler's data map, so the
|
||||
// footer is only correct as long as every page composes with the layout and
|
||||
// render keeps supplying the key. Both are asserted here rather than trusted.
|
||||
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
rendered := 0
|
||||
for name := range tmpl.pages {
|
||||
var buf bytes.Buffer
|
||||
err := tmpl.pages[name].ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
|
||||
})
|
||||
if err != nil {
|
||||
// Pages whose content block needs more data than this cannot be
|
||||
// rendered here; the footer is in the shared layout, so one page
|
||||
// that does render proves it for all of them.
|
||||
continue
|
||||
}
|
||||
rendered++
|
||||
if !strings.Contains(buf.String(), "SelfPost 9.9.9-test") {
|
||||
t.Errorf("page %q does not show the version in the layout footer", name)
|
||||
}
|
||||
}
|
||||
if rendered == 0 {
|
||||
t.Fatal("no page rendered, so the footer was never actually checked")
|
||||
}
|
||||
|
||||
// Signed out (login, setup) the version must not be advertised.
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.pages["login"].ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
"Title": "t", "Active": "", "Version": "9.9.9-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("execute login: %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "9.9.9-test") {
|
||||
t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSuppliesTheVersion(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
s := &Server{tmpl: tmpl, cfg: Config{Version: "9.9.9-test"}}
|
||||
rec := httptest.NewRecorder()
|
||||
data := map[string]any{"Title": "t", "User": "admin"}
|
||||
s.render(rec, http.StatusOK, "backup", data)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := data["Version"]; got != "9.9.9-test" {
|
||||
t.Errorf("render did not supply Version (got %v)", got)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "SelfPost 9.9.9-test") {
|
||||
t.Errorf("rendered page does not show the version:\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavMarksActivePage(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "queue",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `<span aria-current="page">Mail queue</span>`) {
|
||||
t.Errorf("active page is not marked:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `href="/queue"`) {
|
||||
t.Errorf("active page still links to itself:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `href="/sendlog"`) {
|
||||
t.Errorf("inactive pages are not linked:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.pages["status"].ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `<span aria-current="page">Status</span>`) {
|
||||
t.Errorf("the status page is not marked active:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `href="/domains"`) {
|
||||
t.Errorf("Domains does not link to /domains:\n%s", out)
|
||||
}
|
||||
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
|
||||
t.Errorf("Status is not the first navigation entry:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Since the panel root now redirects to the status page, a link left pointing at
|
||||
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||
// contain one (phase 13.C).
|
||||
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if strings.Contains(body, `href="/"`) {
|
||||
t.Errorf(`%s links to "/", which is now the status redirect; link to /domains (or the intended page) instead`, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The reload action is a server-health control and lives only on the status
|
||||
// page (phase 13.D).
|
||||
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if strings.Contains(body, `action="/reload"`) && name != "status.html" {
|
||||
t.Errorf("%s still posts to /reload; the reload control belongs on the status page", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The panel's Content-Security-Policy is a plain default-src 'self' with no
|
||||
// inline exemption (phase 14.A), which makes inline script and inline style a
|
||||
// failure mode rather than a style question: an onclick= handler or a
|
||||
// style="..." attribute added to a template does not error, it silently stops
|
||||
// working in the browser. Behaviour belongs in static/panel.js (triggered from
|
||||
// a data- attribute), appearance in static/panel.css.
|
||||
func TestNoTemplateUsesInlineScriptOrStyle(t *testing.T) {
|
||||
inlineHandler := regexp.MustCompile(`\son[a-z]+\s*=`)
|
||||
inlineStyle := regexp.MustCompile(`\sstyle\s*=|<style[\s>]`)
|
||||
scriptTag := regexp.MustCompile(`<script[^>]*>`)
|
||||
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if m := inlineHandler.FindString(body); m != "" {
|
||||
t.Errorf("%s has an inline event handler (%q); the CSP blocks it — move the behaviour into static/panel.js",
|
||||
name, strings.TrimSpace(m))
|
||||
}
|
||||
if m := inlineStyle.FindString(body); m != "" {
|
||||
t.Errorf("%s has an inline style (%q); the CSP blocks it — move the rule into static/panel.css",
|
||||
name, strings.TrimSpace(m))
|
||||
}
|
||||
for _, tag := range scriptTag.FindAllString(body, -1) {
|
||||
if !strings.Contains(tag, "src=") {
|
||||
t.Errorf("%s has an inline script (%q); the CSP blocks it — put the code in static/panel.js", name, tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// default-src 'self' also means every asset a page pulls in must be one this
|
||||
// server actually serves, so a typo in a /static path is a blocked request,
|
||||
// not a 404 in the page's own colours.
|
||||
func TestLayoutReferencesOnlyEmbeddedAssets(t *testing.T) {
|
||||
body, err := fs.ReadFile(assetsFS, "templates/layout.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read layout: %v", err)
|
||||
}
|
||||
refs := regexp.MustCompile(`(?:src|href)="/static/([^"]+)"`).FindAllStringSubmatch(string(body), -1)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("the layout references no static assets at all")
|
||||
}
|
||||
for _, m := range refs {
|
||||
if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
|
||||
t.Errorf("layout references /static/%s, which is not embedded: %v", m[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPageRendersEveryCheck(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
"Title": "SelfPost — status",
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"Processes": []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
|
||||
},
|
||||
"ProcessStatus": health.StatusError,
|
||||
"QueueSummary": "Mail queue is empty",
|
||||
"QueueStatus": health.StatusOK,
|
||||
"Cert": health.Certificate{
|
||||
Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com",
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour), DaysLeft: 30,
|
||||
Status: health.StatusOK, Detail: "Valid for another 30 day(s).",
|
||||
},
|
||||
"Sockets": []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
|
||||
},
|
||||
"SocketStatus": health.StatusOK,
|
||||
"OverallStatus": health.StatusError,
|
||||
"OverallHeading": "A component needs attention — see the details below.",
|
||||
"Hostname": "mail.example.com",
|
||||
"PTR": dnscheckResult{
|
||||
Status: health.StatusError,
|
||||
Detail: "No address has a reverse record.",
|
||||
Records: []string{"203.0.113.10 → no PTR record"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute status page: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dnscheckResult mirrors dnscheck.Result's shape for the template test, so the
|
||||
// web package's template tests do not depend on the checker's constructor.
|
||||
type dnscheckResult struct {
|
||||
Status health.Status
|
||||
Detail string
|
||||
Records []string
|
||||
}
|
||||
|
||||
// forEachTemplate runs fn over every embedded template's source.
|
||||
func forEachTemplate(t *testing.T, fn func(name, body string)) {
|
||||
t.Helper()
|
||||
entries, err := fs.ReadDir(assetsFS, "templates")
|
||||
if err != nil {
|
||||
t.Fatalf("read templates: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
body, err := fs.ReadFile(assetsFS, path.Join("templates", e.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
fn(e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// randomToken returns a URL-safe token with at least nBytes*8 bits of entropy
|
||||
// drawn from crypto/rand. Setup and session tokens both use this; the setup
|
||||
// token needs >=128 bits (spec 7.6.1), so callers pass nBytes >= 16.
|
||||
//
|
||||
// It panics if the system RNG fails: that is unrecoverable and must never be
|
||||
// papered over with a weak fallback for a security token.
|
||||
func randomToken(nBytes int) string {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// minAdminPasswordLen is the floor for the administrator password. The panel is
|
||||
// public (spec 7.6), so this is deliberately not tiny.
|
||||
const minAdminPasswordLen = 12
|
||||
|
||||
const (
|
||||
minUsernameLen = 3
|
||||
maxUsernameLen = 64
|
||||
)
|
||||
|
||||
// validateUsername enforces a strict server-side whitelist (spec 7.6.2):
|
||||
// letters, digits, dot, dash, underscore. Client validation is never trusted.
|
||||
func validateUsername(u string) error {
|
||||
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
|
||||
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
|
||||
}
|
||||
for _, r := range u {
|
||||
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
|
||||
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAdminPassword enforces a minimum length. Composition rules beyond
|
||||
// length tend to reduce entropy in practice, so length is the sole gate.
|
||||
func validateAdminPassword(p string) error {
|
||||
if len(p) < minAdminPasswordLen {
|
||||
return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isASCIILetterOrDigit(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||
}
|
||||
|
||||
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
|
||||
|
||||
// normalizeDomain lower-cases and trims a domain name. Domain names are
|
||||
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
|
||||
// lower-case form, so we normalise before both validation and storage.
|
||||
func normalizeDomain(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
// validateDomain enforces a strict server-side whitelist for sending-domain
|
||||
// names (spec 7.6.2). The result is safe to write verbatim into the OpenDKIM
|
||||
// KeyTable/SigningTable and to use as a filesystem path segment: only
|
||||
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
|
||||
// shape. Input must already be normalised with normalizeDomain.
|
||||
//
|
||||
// This is deliberately stricter than "any string DNS might accept" — no
|
||||
// leading/trailing dots or hyphens, no empty or over-long labels, and at least
|
||||
// two labels so single-word hostnames cannot be registered as sending domains.
|
||||
func validateDomain(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("domain is required")
|
||||
}
|
||||
if len(name) > maxDomainLen {
|
||||
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
|
||||
}
|
||||
labels := strings.Split(name, ".")
|
||||
if len(labels) < 2 {
|
||||
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
|
||||
}
|
||||
for _, label := range labels {
|
||||
if err := validateDomainLabel(label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDomainLabel(label string) error {
|
||||
if len(label) == 0 {
|
||||
return fmt.Errorf("domain must not contain an empty label")
|
||||
}
|
||||
if len(label) > 63 {
|
||||
return fmt.Errorf("each domain label must be at most 63 characters")
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return fmt.Errorf("domain labels must not start or end with '-'")
|
||||
}
|
||||
for i := 0; i < len(label); i++ {
|
||||
c := label[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '-' {
|
||||
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDomain(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" Example.COM ": "example.com",
|
||||
"MAIL.Example.Org": "mail.example.org",
|
||||
"example.com": "example.com",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeDomain(in); got != want {
|
||||
t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainValid(t *testing.T) {
|
||||
valid := []string{
|
||||
"example.com",
|
||||
"mail.example.com",
|
||||
"a.co",
|
||||
"sub-domain.example.co.uk",
|
||||
"x1.y2.z3",
|
||||
"1example.com",
|
||||
}
|
||||
for _, d := range valid {
|
||||
if err := validateDomain(d); err != nil {
|
||||
t.Errorf("validateDomain(%q) unexpected error: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"", // empty
|
||||
"localhost", // single label
|
||||
"example", // single label
|
||||
".example.com", // leading dot -> empty label
|
||||
"example.com.", // trailing dot -> empty label
|
||||
"exa mple.com", // space
|
||||
"example..com", // empty label
|
||||
"-example.com", // label starts with '-'
|
||||
"example-.com", // label ends with '-'
|
||||
"example.com\n", // newline (config injection attempt)
|
||||
"exa*mple.com", // disallowed char
|
||||
"exa_mple.com", // underscore not allowed in domains
|
||||
"Example.com", // upper-case (must be normalised first)
|
||||
"пример.рф", // non-ASCII
|
||||
"example.c/m", // slash (path-traversal attempt)
|
||||
}
|
||||
for _, d := range invalid {
|
||||
if err := validateDomain(d); err == nil {
|
||||
t.Errorf("validateDomain(%q) = nil, want error", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainLongLabelRejected(t *testing.T) {
|
||||
label := make([]byte, 64)
|
||||
for i := range label {
|
||||
label[i] = 'a'
|
||||
}
|
||||
if err := validateDomain(string(label) + ".com"); err == nil {
|
||||
t.Error("expected error for over-long label")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Package web implements the SelfPost control panel's HTTP surface: the
|
||||
// one-time administrator setup flow (spec 7.6.1), login/session handling
|
||||
// (spec 7.6.5-6) and the authenticated shell the later phases build on.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/app"
|
||||
"codeberg.org/mix/selfpost/internal/dnscheck"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Config holds the panel's HTTP-facing configuration.
|
||||
type Config struct {
|
||||
// Hostname is the server's external hostname, used to build the absolute
|
||||
// setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME).
|
||||
Hostname string
|
||||
// CookieSecure sets the Secure attribute on the session cookie. It defaults
|
||||
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
|
||||
// over plain HTTP in development, never for production.
|
||||
CookieSecure bool
|
||||
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
|
||||
// runs the 587/STARTTLS submission listener next to the primary 465 one
|
||||
// (spec 5). The panel only reports it on the domain page's connection
|
||||
// settings; it is a deploy-time flag, not something the panel can verify.
|
||||
SubmissionEnabled bool
|
||||
// MailLogPath is where Postfix's delivery log lives, read by the mail.log
|
||||
// monitoring view (spec 7.2.13). It is the same path the log-tailer role
|
||||
// follows in cmd/panel.
|
||||
MailLogPath string
|
||||
// DataDir and DBPath locate the persistent state a full backup archives
|
||||
// (spec 7.5.A); Version is stamped into the backup manifest. They mirror the
|
||||
// panel's own configuration.
|
||||
DataDir string
|
||||
DBPath string
|
||||
Version string
|
||||
// TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply
|
||||
// X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose
|
||||
// direct peer (RemoteAddr) is not in this list never has its XFF header
|
||||
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
|
||||
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
|
||||
TrustedProxyCIDRs []*net.IPNet
|
||||
// TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read
|
||||
// read-only by the status page to report how much validity is left.
|
||||
TLSCertFile string
|
||||
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
|
||||
// connects to. The status page stats them: the first is required for mail
|
||||
// to leave at all (OpenDKIM runs with default_action=tempfail), the second
|
||||
// only for the send log (the journal-milter fails open).
|
||||
OpenDKIMSocket string
|
||||
JournalSocket string
|
||||
// SessionIdleDays is the sliding inactivity window after which a login
|
||||
// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
|
||||
// falls back to the 7-day default.
|
||||
SessionIdleDays int
|
||||
}
|
||||
|
||||
// Server is the panel HTTP application.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
domains *domain.Service
|
||||
apps *app.Service
|
||||
cfg Config
|
||||
tmpl *templates
|
||||
sessions *sessionStore
|
||||
setup *setupManager
|
||||
dns *dnscheck.Checker
|
||||
|
||||
loginLimiter *rateLimiter
|
||||
setupLimiter *rateLimiter
|
||||
|
||||
trustedProxies []*net.IPNet
|
||||
}
|
||||
|
||||
// New builds the panel server. setupTokenPath is where the current setup token
|
||||
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
|
||||
// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL
|
||||
// accounts and the Postfix sender map (spec 5.1).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idleDays := cfg.SessionIdleDays
|
||||
if idleDays <= 0 {
|
||||
idleDays = 7
|
||||
}
|
||||
s := &Server{
|
||||
store: st,
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
cfg: cfg,
|
||||
tmpl: tmpl,
|
||||
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
|
||||
// Published-DNS checks for the status page and the domain pages. The
|
||||
// checker caches its own results, so page views do not each pay for a
|
||||
// round of lookups (phase 13).
|
||||
dns: dnscheck.New(),
|
||||
// Setup: a handful of attempts per minute per IP is plenty for a
|
||||
// legitimate admin and blunts automated probing (spec 7.6.1).
|
||||
setupLimiter: newRateLimiter(10, time.Minute),
|
||||
// Login: throttle brute-force by IP (spec 7.6.5).
|
||||
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
||||
|
||||
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||
}
|
||||
s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start performs first-run bootstrapping: if there is no administrator yet, it
|
||||
// generates and announces the setup link (spec 7.6.1). Safe to call once at
|
||||
// server startup.
|
||||
func (s *Server) Start() error {
|
||||
return s.setup.bootstrap()
|
||||
}
|
||||
|
||||
// Handler returns the panel's HTTP handler (router).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check stays unauthenticated for the container/orchestrator.
|
||||
mux.HandleFunc("/healthz", handleHealth)
|
||||
|
||||
// Vendored static assets (HTMX). Served from the embedded FS.
|
||||
mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))
|
||||
|
||||
// One-time administrator setup (spec 7.6.1).
|
||||
mux.HandleFunc("/setup/", s.handleSetup)
|
||||
|
||||
// Authentication.
|
||||
mux.HandleFunc("/login", s.handleLogin)
|
||||
mux.HandleFunc("/logout", s.handleLogout)
|
||||
|
||||
// Authenticated panel. Everything not matched by a more specific pattern
|
||||
// above falls through to this sub-mux, wrapped once in the auth middleware.
|
||||
authed := http.NewServeMux()
|
||||
|
||||
// The landing page is the server status (phase 13.C): the first thing an
|
||||
// administrator should see after logging in is whether the service is
|
||||
// healthy, not the domain list. handleLogin still redirects to "/".
|
||||
authed.HandleFunc("GET /{$}", redirectToStatus)
|
||||
authed.HandleFunc("GET /status", s.handleStatus)
|
||||
authed.HandleFunc("GET /status/fragment", s.handleStatusFragment)
|
||||
authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck)
|
||||
|
||||
authed.HandleFunc("GET /domains", s.handleDashboard)
|
||||
authed.HandleFunc("POST /domains", s.handleAddDomain)
|
||||
authed.HandleFunc("POST /domains/import", s.handleImportDomain)
|
||||
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
|
||||
authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck)
|
||||
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
|
||||
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
|
||||
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
|
||||
authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit)
|
||||
authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain)
|
||||
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
|
||||
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
|
||||
authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit)
|
||||
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
|
||||
authed.HandleFunc("POST /reload", s.handleReload)
|
||||
|
||||
// Administrator's own panel credentials.
|
||||
authed.HandleFunc("/account", s.handleAccount)
|
||||
|
||||
// Backup and migration: the page with both actions (spec 7.5.A-B), and the
|
||||
// full-server backup download itself.
|
||||
authed.HandleFunc("GET /backup", s.handleBackupPage)
|
||||
authed.HandleFunc("POST /backup", s.handleBackup)
|
||||
|
||||
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
|
||||
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
|
||||
authed.HandleFunc("GET /sendlog", s.handleSendLog)
|
||||
authed.HandleFunc("GET /sendlog/rows", s.handleSendLogRows)
|
||||
authed.HandleFunc("GET /queue", s.handleQueue)
|
||||
authed.HandleFunc("GET /queue/body", s.handleQueueBody)
|
||||
authed.HandleFunc("GET /logtail", s.handleLogTail)
|
||||
authed.HandleFunc("GET /logtail/body", s.handleLogTailBody)
|
||||
|
||||
mux.Handle("/", s.requireAuth(authed))
|
||||
|
||||
// Security headers and the origin check wrap everything, including the
|
||||
// unauthenticated login and setup routes (phase 14.A).
|
||||
return s.secure(mux)
|
||||
}
|
||||
|
||||
// redirectToStatus points the panel root at the status page, so there is one
|
||||
// canonical URL for that content instead of two (phase 13.C).
|
||||
func redirectToStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/status", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
// clientIP extracts the peer IP for rate-limiting. By default it is the
|
||||
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
|
||||
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
|
||||
// that is the address the trusted proxy itself appended, so a client can't
|
||||
// forge it by sending its own XFF header. With no trusted
|
||||
// proxies configured, behind a reverse proxy this is the proxy's own address,
|
||||
// which is an acceptable backstop for a single-admin panel.
|
||||
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
if len(trustedProxies) > 0 {
|
||||
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
|
||||
for _, n := range nets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// logf is a thin wrapper so handlers log with a consistent prefix.
|
||||
func logf(format string, args ...any) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user