Add 30-day send statistics and auto level-2 rate limits on the domain page. Close Unreleased; pin compose and docs to 1.6.0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
@@ -43,10 +44,22 @@ type detailView struct {
|
||||
// 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
|
||||
HasLimit bool
|
||||
IPsText string
|
||||
MaxText string
|
||||
WindowVal string
|
||||
Mode string
|
||||
AutoMultiplier string
|
||||
AutoUpdated string
|
||||
IsAuto bool
|
||||
Stats sendStatsView
|
||||
}
|
||||
|
||||
// sendStatsView is the template-facing send statistics block.
|
||||
type sendStatsView struct {
|
||||
Total int64
|
||||
PeakPerHour int64
|
||||
AvgPerHour string
|
||||
}
|
||||
|
||||
// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md)
|
||||
@@ -77,6 +90,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
return
|
||||
}
|
||||
appViews := make([]appRateLimitView, 0, len(apps))
|
||||
retention := h.sendLogRetentionDays()
|
||||
for _, a := range apps {
|
||||
rl, ok, err := h.apps.RateLimit(a.ID)
|
||||
if err != nil {
|
||||
@@ -84,12 +98,31 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
appStats, err := h.store.AppSendStats(a.Login, retention, a.CreatedAt)
|
||||
if err != nil {
|
||||
logf("panel: application %d: send stats: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
mode := store.RateLimitModeManual
|
||||
if ok && rl.Mode != "" {
|
||||
mode = rl.Mode
|
||||
}
|
||||
mult := rl.AutoMultiplier
|
||||
if mult <= 0 {
|
||||
mult = store.DefaultAutoMultiplier
|
||||
}
|
||||
appViews = append(appViews, appRateLimitView{
|
||||
Application: a,
|
||||
HasLimit: ok && rl.Active(),
|
||||
IPsText: strings.Join(rl.AllowedIPs, "\n"),
|
||||
MaxText: intOrBlank(rl.MaxMessages),
|
||||
WindowVal: windowOrDefault(rl.WindowSeconds),
|
||||
Application: a,
|
||||
HasLimit: ok && rl.Active(),
|
||||
IPsText: strings.Join(rl.AllowedIPs, "\n"),
|
||||
MaxText: intOrBlank(rl.MaxMessages),
|
||||
WindowVal: windowOrDefault(rl.WindowSeconds),
|
||||
Mode: mode,
|
||||
AutoMultiplier: formatMultiplier(mult),
|
||||
IsAuto: ok && rl.IsAuto(),
|
||||
AutoUpdated: formatAutoUpdated(rl.AutoUpdatedAt),
|
||||
Stats: formatSendStats(appStats),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,6 +132,22 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
domainStats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: send stats: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
domainMode := store.RateLimitModeManual
|
||||
domainMult := store.DefaultAutoMultiplier
|
||||
if domainRLok {
|
||||
if domainRL.Mode != "" {
|
||||
domainMode = domainRL.Mode
|
||||
}
|
||||
if domainRL.AutoMultiplier > 0 {
|
||||
domainMult = domainRL.AutoMultiplier
|
||||
}
|
||||
}
|
||||
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with. Cached by the checker, so re-rendering the page
|
||||
@@ -168,8 +217,18 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
||||
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
||||
data["DomainRLMaxNum"] = domainRL.MaxMessages
|
||||
data["DomainRLMode"] = domainMode
|
||||
data["DomainRLAuto"] = domainRLok && domainRL.IsAuto()
|
||||
data["DomainRLMultiplier"] = formatMultiplier(domainMult)
|
||||
data["DomainRLAutoUpdated"] = formatAutoUpdated(domainRL.AutoUpdatedAt)
|
||||
data["DomainStats"] = formatSendStats(domainStats)
|
||||
data["StatsWindowDays"] = domainStats.WindowDays
|
||||
data["StatsRetentionWarning"] = retention < store.StatsWindowDays
|
||||
data["L1Messages"] = h.l1Messages()
|
||||
data["L1Window"] = h.l1Window()
|
||||
data["DefaultAutoMultiplier"] = store.DefaultAutoMultiplier
|
||||
data["MinAutoMultiplier"] = store.MinAutoMultiplier
|
||||
data["MaxAutoMultiplier"] = store.MaxAutoMultiplier
|
||||
h.view.Render(w, status, "domain_detail", data)
|
||||
}
|
||||
|
||||
@@ -232,6 +291,25 @@ func windowOrDefault(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
func formatSendStats(s store.SendStats) sendStatsView {
|
||||
return sendStatsView{
|
||||
Total: s.Total,
|
||||
PeakPerHour: s.PeakPerHour,
|
||||
AvgPerHour: fmt.Sprintf("%.1f", s.AvgPerHour),
|
||||
}
|
||||
}
|
||||
|
||||
func formatMultiplier(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', 1, 64)
|
||||
}
|
||||
|
||||
func formatAutoUpdated(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -242,6 +320,8 @@ func detailFlash(r *http.Request) string {
|
||||
return "Application address mode updated."
|
||||
case r.URL.Query().Get("ratelimit") != "":
|
||||
return "Rate limit updated."
|
||||
case r.URL.Query().Get("recalculated") != "":
|
||||
return "Auto rate limit recalculated."
|
||||
case r.URL.Query().Get("dmarc") != "":
|
||||
return "DMARC report settings updated."
|
||||
case r.URL.Query().Get("imported") != "":
|
||||
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
const defaultRateLimitWindowSeconds = 3600
|
||||
|
||||
type rateLimitInput struct {
|
||||
clear bool
|
||||
ips []string
|
||||
maxMessages int
|
||||
windowSeconds int
|
||||
clear bool
|
||||
mode string
|
||||
ips []string
|
||||
maxMessages int
|
||||
windowSeconds int
|
||||
autoMultiplier float64
|
||||
}
|
||||
|
||||
func (h *Handlers) l1Messages() int {
|
||||
@@ -33,6 +35,32 @@ func (h *Handlers) l1Window() int {
|
||||
return defaultRateLimitWindowSeconds
|
||||
}
|
||||
|
||||
func parseAutoMultiplier(raw string) (float64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return store.DefaultAutoMultiplier, nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("enter a valid multiplier (%.1f–%.1f)", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||||
}
|
||||
if v < store.MinAutoMultiplier || v > store.MaxAutoMultiplier {
|
||||
return 0, fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseRateLimitMode(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return store.RateLimitModeManual, nil
|
||||
}
|
||||
if raw != store.RateLimitModeManual && raw != store.RateLimitModeAuto {
|
||||
return "", fmt.Errorf("choose manual or auto mode")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||
@@ -40,6 +68,18 @@ func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error
|
||||
if r.PostFormValue("clear") != "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
mode, err := parseRateLimitMode(r.PostFormValue("mode"))
|
||||
if err != nil {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
if mode == store.RateLimitModeAuto {
|
||||
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
|
||||
if err != nil {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
return rateLimitInput{mode: mode, autoMultiplier: mult}, nil
|
||||
}
|
||||
|
||||
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||
if rawMax == "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
@@ -55,7 +95,7 @@ func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error
|
||||
if err != nil || windowSeconds <= 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||||
}
|
||||
return rateLimitInput{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||
return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||
}
|
||||
|
||||
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) {
|
||||
@@ -65,10 +105,11 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
|
||||
if r.PostFormValue("clear") != "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||
if rawMax == "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
mode, err := parseRateLimitMode(r.PostFormValue("mode"))
|
||||
if err != nil {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
|
||||
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
||||
if err != nil {
|
||||
return rateLimitInput{}, err
|
||||
@@ -76,6 +117,19 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
|
||||
if len(ips) == 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
|
||||
}
|
||||
|
||||
if mode == store.RateLimitModeAuto {
|
||||
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
|
||||
if err != nil {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
return rateLimitInput{mode: mode, ips: ips, autoMultiplier: mult}, nil
|
||||
}
|
||||
|
||||
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||
if rawMax == "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||||
if err != nil || maxMessages <= 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||
@@ -90,7 +144,7 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
|
||||
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
|
||||
return rateLimitInput{mode: store.RateLimitModeManual, ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||
}
|
||||
|
||||
func parseIPList(raw string) ([]string, error) {
|
||||
@@ -134,7 +188,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
|
||||
if err := h.applyDomainRateLimit(in, d.ID); err != nil {
|
||||
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -167,7 +221,7 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
|
||||
if err := h.applyAppRateLimit(in, a.ID); err != nil {
|
||||
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -175,14 +229,83 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) applyRateLimit(
|
||||
in rateLimitInput,
|
||||
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
|
||||
clear func(id int64) error,
|
||||
id int64,
|
||||
) error {
|
||||
if in.clear {
|
||||
return clear(id)
|
||||
func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
return save(id, in.ips, in.maxMessages, in.windowSeconds)
|
||||
if err := h.recalcRateLimit(store.RateLimitScopeDomain, d.ID); err != nil {
|
||||
logf("panel: domain %d: recalc rate limit: %v", d.ID, err)
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
RateLimitErr: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.lookupApplication(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil {
|
||||
d, _ := h.domains.Get(a.DomainID)
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) recalcRateLimit(scope string, refID int64) error {
|
||||
return h.store.RecalcAutoRateLimit(scope, refID, h.sendLogRetentionDays(), h.l1Messages(), h.l1Window())
|
||||
}
|
||||
|
||||
func (h *Handlers) applyDomainRateLimit(in rateLimitInput, domainID int64) error {
|
||||
if in.clear {
|
||||
return h.domains.ClearRateLimit(domainID)
|
||||
}
|
||||
rl := store.RateLimit{
|
||||
Scope: store.RateLimitScopeDomain,
|
||||
RefID: domainID,
|
||||
Mode: in.mode,
|
||||
MaxMessages: in.maxMessages,
|
||||
WindowSeconds: in.windowSeconds,
|
||||
AutoMultiplier: in.autoMultiplier,
|
||||
}
|
||||
if in.mode == store.RateLimitModeAuto {
|
||||
rl.WindowSeconds = h.l1Window()
|
||||
if err := h.domains.SaveRateLimit(domainID, rl); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.recalcRateLimit(store.RateLimitScopeDomain, domainID)
|
||||
}
|
||||
return h.domains.SaveRateLimit(domainID, rl)
|
||||
}
|
||||
|
||||
func (h *Handlers) applyAppRateLimit(in rateLimitInput, appID int64) error {
|
||||
if in.clear {
|
||||
return h.apps.ClearRateLimit(appID)
|
||||
}
|
||||
rl := store.RateLimit{
|
||||
Scope: store.RateLimitScopeApp,
|
||||
RefID: appID,
|
||||
AllowedIPs: in.ips,
|
||||
Mode: in.mode,
|
||||
MaxMessages: in.maxMessages,
|
||||
WindowSeconds: in.windowSeconds,
|
||||
AutoMultiplier: in.autoMultiplier,
|
||||
}
|
||||
if in.mode == store.RateLimitModeAuto {
|
||||
rl.WindowSeconds = h.l1Window()
|
||||
if err := h.apps.SaveRateLimit(appID, rl); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.recalcRateLimit(store.RateLimitScopeApp, appID)
|
||||
}
|
||||
return h.apps.SaveRateLimit(appID, rl)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestParseDomainRateLimitForm(t *testing.T) {
|
||||
}
|
||||
|
||||
in, err := parseDomainRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"max_messages": {"50"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100)
|
||||
@@ -30,6 +31,7 @@ func TestParseDomainRateLimitForm(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"max_messages": {"150"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100)
|
||||
@@ -47,6 +49,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
|
||||
}
|
||||
|
||||
in, err := parseAppRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"80"},
|
||||
"window_seconds": {"3600"},
|
||||
@@ -56,6 +59,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = parseAppRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"max_messages": {"80"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100, 40, true)
|
||||
@@ -64,6 +68,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = parseAppRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"40"},
|
||||
"window_seconds": {"3600"},
|
||||
@@ -73,6 +78,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = parseAppRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"150"},
|
||||
"window_seconds": {"3600"},
|
||||
@@ -83,6 +89,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
|
||||
|
||||
// No domain limit: any app ceiling ≤ L1 is fine.
|
||||
in, err = parseAppRateLimitForm(form(url.Values{
|
||||
"mode": {"manual"},
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"50"},
|
||||
"window_seconds": {"3600"},
|
||||
@@ -91,3 +98,26 @@ func TestParseAppRateLimitForm(t *testing.T) {
|
||||
t.Fatalf("app without domain = %+v err=%v", in, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDomainRateLimitFormAuto(t *testing.T) {
|
||||
t.Parallel()
|
||||
form := func(vals url.Values) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
in, err := parseDomainRateLimitForm(form(url.Values{
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"2.5"},
|
||||
}), 100)
|
||||
if err != nil || in.mode != "auto" || in.autoMultiplier != 2.5 {
|
||||
t.Fatalf("auto domain = %+v err=%v", in, err)
|
||||
}
|
||||
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||
"mode": {"auto"},
|
||||
"auto_multiplier": {"10"},
|
||||
}), 100)
|
||||
if err == nil {
|
||||
t.Fatal("multiplier out of range should fail")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user