Could not ask supervisord for the process list.
-
Machine {{.Machine.Status}}
-
Processor, memory and network of the machine this container
- runs on, read from the kernel's counters. CPU and throughput are rates, so
- they describe{{if .Machine.WindowText}} the {{.Machine.WindowText}}{{end}}
- since the previous refresh rather than this instant.
-
- Resource Usage Detail
-
-
- CPU
-
- {{if .Machine.CPU.Measured}}
- {{.Machine.CPU.BusyText}}
- {{.Machine.CPU.BusyText}}
- {{else}}— {{end}}
-
- {{.Machine.CPU.Detail}}
-
-
- Memory
-
- {{if .Machine.Memory.Measured}}
- {{.Machine.Memory.PctText}}
- {{.Machine.Memory.PctText}}
- {{else}}— {{end}}
-
- {{.Machine.Memory.Detail}}
-
-
- Network
-
- {{if .Machine.Network.Measured}}
- ↓ {{.Machine.Network.InRateText}} ↑ {{.Machine.Network.OutRateText}}
- {{else}}— {{end}}
-
-
- {{range .Machine.Network.Interfaces}}
- {{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}
- {{end}}
- {{if .Machine.Network.Detail}}{{.Machine.Network.Detail}}
{{end}}
-
-
-
-
+
+
+
Mail queue {{.QueueStatus}}
+ {{if .QueueError}}
+
{{.QueueError}}
+ {{else}}
+
{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}
+
View queue
+ {{end}}
+
+
+
+
TLS certificate {{.Cert.Status}}
+ {{if not .Cert.NotAfter.IsZero}}
+
Expires
+
{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}
+ {{end}}
+
{{.Cert.Detail}}
+
-
-
Mail queue {{.QueueStatus}}
- {{if .QueueError}}
-
{{.QueueError}}
- {{else}}
-
{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}
-
Full queue
- {{end}}
-
+
+
+
Milter sockets {{.SocketStatus}}
+
+ Milter State Detail
+
+ {{range .Sockets}}
+
+ {{.Name}}
+ {{.Status}}
+ {{.Detail}}
+
+ {{end}}
+
+
+
-
-
TLS certificate {{.Cert.Status}}
-
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.
- {{if not .Cert.NotAfter.IsZero}}
-
Expires
-
{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}
- {{end}}
-
{{.Cert.Detail}}
-
+
+
Hostname and reverse DNS {{.PTR.Status}}
-
-
Milter sockets {{.SocketStatus}}
-
- Milter Socket State
-
- {{range .Sockets}}
-
- {{.Name}}
- {{.Path}}
- {{.Status}} {{.Detail}}
-
- {{end}}
-
-
+
Server hostname
+
{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}
+
+ {{if .PTR.Records}}
+
Forward and reverse lookup
+
{{range .PTR.Records}}{{.}}
+{{end}}
+ {{end}}
+
+
+
{{end}}
diff --git a/internal/web/view/templates/user_form.html b/internal/web/view/templates/user_form.html
new file mode 100644
index 0000000..2491d5d
--- /dev/null
+++ b/internal/web/view/templates/user_form.html
@@ -0,0 +1,42 @@
+{{define "content"}}
+
{{if .IsEdit}}Edit user{{else}}Create user{{end}}
+
+{{template "back_link" (back "/users" "Back to users")}}
+
+
+ {{if .Error}}
{{.Error}}
{{end}}
+
+
+{{end}}
diff --git a/internal/web/view/templates/users.html b/internal/web/view/templates/users.html
new file mode 100644
index 0000000..5e932b5
--- /dev/null
+++ b/internal/web/view/templates/users.html
@@ -0,0 +1,28 @@
+{{define "content"}}
+
Users
+
+{{if .Flash}}
{{.Flash}}
{{end}}
+
+
+
Create user
+ {{if .Users}}
+
+
+ Username Role Domains
+
+
+ {{range .Users}}
+
+ {{.User.Username}}
+ {{if eq .User.Role "global"}}Global{{else}}Domain admin{{end}}
+ {{if eq .User.Role "global"}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}
+ Edit
+
+ {{end}}
+
+
+ {{else}}
+
No panel users besides the global administrator yet.
+ {{end}}
+
+{{end}}
diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go
index 767a6dc..781c509 100644
--- a/internal/web/view/templates_test.go
+++ b/internal/web/view/templates_test.go
@@ -29,79 +29,6 @@ func TestEveryPageResolvesNav(t *testing.T) {
}
}
-// The section index each long page shows in the navigation column works by
-// overriding an empty "sections" block defined in the layout, which only holds
-// as long as the layout is parsed before the page's own files (see pageFiles).
-// Reverse that order and every index would silently disappear — the empty
-// definition would win and no page would fail to render — so the two ends are
-// asserted here: the long pages produce a list, and a page that defines nothing
-// produces nothing at all.
-func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
- engine, err := New("test")
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- // Anchors the index links to, taken from the page's own cards.
- wantAnchors := map[string]string{
- "status": `href="#certificate"`,
- "domain_detail": `href="#danger"`,
- }
- for name, page := range engine.Pages() {
- var buf bytes.Buffer
- // The domain page's index hides the freshly generated credential entry
- // unless one is on the page, so the data map carries the key it reads.
- if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil {
- t.Fatalf("execute sections for %q: %v", name, err)
- }
- out := buf.String()
- anchor, wanted := wantAnchors[name]
- switch {
- case wanted && !strings.Contains(out, anchor):
- t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out)
- case !wanted && strings.TrimSpace(out) != "":
- t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out)
- }
- }
-}
-
-// A section link that points at no card is a link that does nothing, and
-// nothing about rendering the page says so. Every anchor the index offers must
-// name an element the same page defines an id for.
-func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
- engine, err := New("test")
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- // The pages that carry an index; both are checked with a credential shown,
- // which is the domain page's one conditional entry.
- for _, name := range []string{"status", "domain_detail"} {
- var index bytes.Buffer
- if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
- t.Fatalf("execute sections for %q: %v", name, err)
- }
- // The cards are spread over the page's template files, so the ids are
- // collected from the files rather than from a rendered page — rendering
- // one would need the whole of a handler's data map.
- ids := map[string]bool{}
- for _, file := range pageFiles[name] {
- body, err := fs.ReadFile(assetsFS, file)
- if err != nil {
- t.Fatalf("read %s: %v", file, err)
- }
- // Cards only: a form field's id is not somewhere a section link may
- // land, so matching those too would weaken the check.
- for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) {
- ids[m[1]] = true
- }
- }
- for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) {
- if !ids[m[1]] {
- t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1])
- }
- }
- }
-}
-
// 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.
@@ -207,8 +134,9 @@ func TestNavMarksActivePage(t *testing.T) {
}
var buf bytes.Buffer
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
- "User": "admin",
- "Active": "mail_queue",
+ "User": "admin",
+ "Active": "mail_queue",
+ "IsGlobal": true,
})
if err != nil {
t.Fatalf("execute nav: %v", err)
@@ -234,8 +162,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
}
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
- "User": "admin",
- "Active": "status",
+ "User": "admin",
+ "Active": "status",
+ "IsGlobal": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
@@ -261,7 +190,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
if err != nil {
t.Fatalf("New: %v", err)
}
- wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true}
+ wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true}
for name, page := range engine.Pages() {
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
@@ -277,6 +206,101 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
}
}
+// The domain page pairs cards the same way Status does: three .split rows
+// (DKIM+SPF|DMARC, connection|add-app, export|danger). DNS status, Applications
+// and Domain settings are full-width; DNS status and Domain settings (and the
+// application Edit panel) use .check-cols. Losing a row silently stacks again.
+func TestDomainDetailPageHasPairedCards(t *testing.T) {
+ body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
+ if err != nil {
+ t.Fatalf("read domain_detail: %v", err)
+ }
+ src := string(body)
+ if got := strings.Count(src, `class="split"`); got != 3 {
+ t.Errorf("domain detail has %d .split rows, want 3", got)
+ }
+ if !strings.Contains(src, `class="check-cols"`) {
+ t.Error("domain detail is missing the check-cols grid")
+ }
+ if !strings.Contains(src, `class="panel-toggle t-edit"`) {
+ t.Error("application Edit should be a single panel-toggle")
+ }
+ if strings.Contains(src, `panel-toggle t-mode`) || strings.Contains(src, `panel-toggle t-limit`) ||
+ strings.Contains(src, `panel-mode`) || strings.Contains(src, `panel-limit`) {
+ t.Error("application Edit mode and Rate limit should be one Edit button")
+ }
+ for _, id := range []string{
+ `id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`,
+ `id="connection"`, `id="add-application"`, `id="applications"`,
+ `id="domain-settings"`, `id="export"`, `id="danger"`,
+ } {
+ if !strings.Contains(src, id) {
+ t.Errorf("domain detail is missing %s", id)
+ }
+ }
+ if strings.Contains(src, `id="rate-limit"`) {
+ t.Error("domain rate limit should live inside domain-settings, not its own card")
+ }
+ if strings.Contains(src, `id="d_ips"`) {
+ t.Error("domain rate limit must not ask for client IPs")
+ }
+ if !strings.Contains(src, "{{.L1Messages}}") && !strings.Contains(src, "{{$.L1Messages}}") {
+ t.Error("domain rate limit should show the L1 message count")
+ }
+ if !strings.Contains(src, "Level 1 backstop") {
+ t.Error("domain rate limit should show a Level 1 backstop line")
+ }
+ if !strings.Contains(src, "Trusted client IPs") {
+ t.Error("application override should ask for trusted client IPs")
+ }
+ if strings.Contains(src, `id="spf-dmarc"`) {
+ t.Error("SPF should sit with DKIM, not with DMARC")
+ }
+}
+
+func TestSettingsPageDocumentsRateLimits(t *testing.T) {
+ body, err := fs.ReadFile(assetsFS, "templates/settings.html")
+ if err != nil {
+ t.Fatalf("read settings: %v", err)
+ }
+ src := string(body)
+ if !strings.Contains(src, `id="rate-limits"`) {
+ t.Error("settings should include a sending rate limits card")
+ }
+ for _, want := range []string{
+ "RATE_LIMIT_MESSAGES_PER_IP",
+ "Level 2 — domain",
+ "trusted IPs",
+ "{{.L1Messages}} messages / {{.L1Window}} seconds",
+ } {
+ if !strings.Contains(src, want) {
+ t.Errorf("settings rate limits card missing %q", want)
+ }
+ }
+}
+
+func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
+ drillDown := map[string]bool{
+ "user_form.html": true,
+ "domain_detail.html": true,
+ "domain_delete.html": true,
+ "delivery.html": true,
+ }
+ forEachTemplate(t, func(name, body string) {
+ if !drillDown[name] {
+ return
+ }
+ if !strings.Contains(body, `template "back_link"`) {
+ t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
+ }
+ backIdx := strings.Index(body, `template "back_link"`)
+ cardIdx := strings.Index(body, `class="card`)
+ if cardIdx >= 0 && backIdx > cardIdx {
+ t.Errorf("%s places the back link after the first card", name)
+ }
+ })
+}
+
// Since the panel root redirects to the status page, a link left pointing at
// "/" silently lands on the wrong screen instead of failing — so no template may
// contain one.
@@ -351,17 +375,38 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
"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"`,
+ // Three .split rows inside the polled fragment: machine|processes,
+ // queue|certificate, and sockets|hostname. Ids stay on the cards.
+ `id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
+ `action="/status/recheck"`,
// The machine card: the bars carry their reading in an attribute
// (the CSP rules out sizing them with a style), and the figures are
// printed beside them for anything that does not render a meter.
`
markup.
+ "back": func(href, label string) map[string]string {
+ return map[string]string{"Href": href, "Label": label}
+ },
+ }
+}
+
// Page returns a parsed page template by logical name. It is exported for
// template guard tests that assert structural properties across all pages.
func (e *Engine) Page(name string) *template.Template {
diff --git a/internal/web/web.go b/internal/web/web.go
index 2341775..466150c 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -70,6 +70,11 @@ type Config struct {
// checks must not go through the system resolver — see dnscheck's
// externalResolver — so this is how a closed network points them at its own.
DNSResolvers []string
+ // RateLimitMessagesPerIP and RateLimitWindowSeconds are the level-1
+ // Postfix anvil backstop (env RATE_LIMIT_*), mirrored into the panel for
+ // display and to cap domain/app level-2 ceilings (guide § Rate limiting).
+ RateLimitMessagesPerIP int
+ RateLimitWindowSeconds int
}
// Server is the panel HTTP application.
@@ -96,15 +101,17 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}, v, setupTokenPath)
h := handlers.New(st, domains, apps, handlers.Config{
- Hostname: cfg.Hostname,
- SubmissionEnabled: cfg.SubmissionEnabled,
- MailLogPath: cfg.MailLogPath,
- DataDir: cfg.DataDir,
- DBPath: cfg.DBPath,
- Version: cfg.Version,
- TLSCertFile: cfg.TLSCertFile,
- OpenDKIMSocket: cfg.OpenDKIMSocket,
- JournalSocket: cfg.JournalSocket,
+ Hostname: cfg.Hostname,
+ SubmissionEnabled: cfg.SubmissionEnabled,
+ MailLogPath: cfg.MailLogPath,
+ DataDir: cfg.DataDir,
+ DBPath: cfg.DBPath,
+ Version: cfg.Version,
+ TLSCertFile: cfg.TLSCertFile,
+ OpenDKIMSocket: cfg.OpenDKIMSocket,
+ JournalSocket: cfg.JournalSocket,
+ RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
+ RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil
}
@@ -129,7 +136,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/logout", s.auth.HandleLogout)
authed := http.NewServeMux()
- authed.HandleFunc("GET /{$}", redirectToStatus)
+ authed.HandleFunc("GET /{$}", redirectHome)
authed.HandleFunc("GET /status", h.HandleStatus)
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)
@@ -151,7 +158,14 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", h.HandleReload)
- authed.HandleFunc("/account", h.HandleAccount)
+ authed.HandleFunc("/settings", h.HandleAccount)
+ authed.HandleFunc("/account", redirectSettings)
+
+ authed.HandleFunc("GET /users", h.HandleUsers)
+ authed.HandleFunc("GET /users/new", h.HandleUserNew)
+ authed.HandleFunc("POST /users/new", h.HandleUserNew)
+ authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit)
+ authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit)
authed.HandleFunc("GET /backup", h.HandleBackupPage)
authed.HandleFunc("POST /backup", h.HandleBackup)
@@ -168,7 +182,21 @@ func (s *Server) Handler() http.Handler {
return s.secure(mux)
}
-func redirectToStatus(w http.ResponseWriter, r *http.Request) {
+// redirectSettings sends legacy /account bookmarks to /settings (308 preserves POST).
+func redirectSettings(w http.ResponseWriter, r *http.Request) {
+ target := "/settings"
+ if r.URL.RawQuery != "" {
+ target += "?" + r.URL.RawQuery
+ }
+ http.Redirect(w, r, target, http.StatusPermanentRedirect)
+}
+
+func redirectHome(w http.ResponseWriter, r *http.Request) {
+ p, ok := auth.PrincipalFromRequest(r)
+ if ok && !p.IsGlobal() {
+ http.Redirect(w, r, "/domains", http.StatusSeeOther)
+ return
+ }
http.Redirect(w, r, "/status", http.StatusSeeOther)
}
diff --git a/test/e2e/logrotate_check.go b/test/e2e/logrotate_check.go
new file mode 100644
index 0000000..98fde8c
--- /dev/null
+++ b/test/e2e/logrotate_check.go
@@ -0,0 +1,97 @@
+package e2e
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// checkLogrotateConfigMode verifies the image pins /etc/logrotate.d/mail at 0644
+// so logrotate will not silently ignore it (docs/plans/logrotate-mode.md).
+func checkLogrotateConfigMode(s *stack) error {
+ mode, err := s.execIn("selfpost", "stat", "-c", "%a", "/etc/logrotate.d/mail")
+ if err != nil {
+ return fmt.Errorf("stat logrotate config: %w", err)
+ }
+ mode = strings.TrimSpace(mode)
+ if mode != "644" {
+ return fmt.Errorf("/etc/logrotate.d/mail mode is %q, want 644", mode)
+ }
+ return nil
+}
+
+// checkLogrotateRotation forces a rotation and checks the recreated mail.log is
+// panel-readable (0640 postfix:selfpost per build/logrotate-mail.conf).
+func checkLogrotateRotation(s *stack) error {
+ const logPath = "/data/log/mail.log"
+ marker := "e2e-logrotate-marker\n"
+ if _, err := s.execIn("selfpost", "sh", "-c",
+ fmt.Sprintf("printf %q >> %s", marker, logPath)); err != nil {
+ return fmt.Errorf("write mail.log: %w", err)
+ }
+ if _, err := s.execIn("selfpost", "logrotate", "-f", "/etc/logrotate.d/mail"); err != nil {
+ return fmt.Errorf("logrotate -f: %w", err)
+ }
+ rotated, err := s.execIn("selfpost", "sh", "-c", "test -f /data/log/mail.log.1 && echo yes")
+ if err != nil || strings.TrimSpace(rotated) != "yes" {
+ return fmt.Errorf("expected /data/log/mail.log.1 after forced rotation (out=%q err=%v)", rotated, err)
+ }
+ mode, err := s.execIn("selfpost", "stat", "-c", "%a", logPath)
+ if err != nil {
+ return fmt.Errorf("stat rotated mail.log: %w", err)
+ }
+ if strings.TrimSpace(mode) != "640" {
+ return fmt.Errorf("new %s mode is %q, want 640", logPath, strings.TrimSpace(mode))
+ }
+ body, err := s.execIn("selfpost", "grep", "-F", strings.TrimSpace(marker), "/data/log/mail.log.1")
+ if err != nil || !strings.Contains(body, strings.TrimSpace(marker)) {
+ return fmt.Errorf("rotated file does not contain marker (out=%q err=%v)", body, err)
+ }
+ return nil
+}
+
+// TestImageBuildPreservesLogrotateMode builds from a context where
+// logrotate-mail.conf is group-writable and checks the image still ships 0644.
+func TestImageBuildPreservesLogrotateMode(t *testing.T) {
+ conf := filepath.Join(h.repoRoot, "build", "logrotate-mail.conf")
+ info, err := os.Stat(conf)
+ if err != nil {
+ t.Fatalf("stat source config: %v", err)
+ }
+ origMode := info.Mode().Perm()
+
+ if err := os.Chmod(conf, origMode|0o020); err != nil {
+ t.Fatalf("chmod g+w source config: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = os.Chmod(conf, origMode)
+ })
+
+ tag := "selfpost:e2e-logrotate-mode"
+ build := exec.Command("docker", "build",
+ "-f", filepath.Join(h.repoRoot, "build", "Dockerfile"),
+ "-t", tag,
+ "--build-arg", "VERSION=e2e",
+ h.repoRoot,
+ )
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("docker build with group-writable context config: %v\n%s", err, out)
+ }
+ t.Cleanup(func() {
+ _, _ = exec.Command("docker", "rmi", "-f", tag).CombinedOutput()
+ })
+
+ run := exec.Command("docker", "run", "--rm", tag, "stat", "-c", "%a", "/etc/logrotate.d/mail")
+ run.Env = os.Environ()
+ out, err := run.CombinedOutput()
+ if err != nil {
+ t.Fatalf("stat in image: %v\n%s", err, out)
+ }
+ if strings.TrimSpace(string(out)) != "644" {
+ t.Fatalf("image logrotate config mode is %q, want 644", strings.TrimSpace(string(out)))
+ }
+}
diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go
index 4135598..8a0deb5 100644
--- a/test/e2e/main_test.go
+++ b/test/e2e/main_test.go
@@ -105,6 +105,12 @@ func TestE2E(t *testing.T) {
if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err)
}
+ if err := checkLogrotateConfigMode(h); err != nil {
+ t.Fatal(err)
+ }
+ if err := checkLogrotateRotation(h); err != nil {
+ t.Fatal(err)
+ }
if err := waitForPanelReady(); err != nil {
t.Fatal(err)
}
diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go
index 2b16495..80773f2 100644
--- a/test/e2e/panel_client.go
+++ b/test/e2e/panel_client.go
@@ -194,15 +194,18 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
return appLogin, password, nil
}
-// setRateLimit saves a level-2 differentiated limit (guide § Rate limiting)
-// on either a domain (/domains/{id}/ratelimit) or an application
-// (/applications/{id}/ratelimit).
+// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
+// (/applications/{id}/ratelimit). allowedIP is required for the trusted-IP
+// override; domain ceilings are posted without IPs.
func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSeconds int) error {
- resp, body, err := c.postForm(path, url.Values{
- "allowed_ips": {allowedIP},
+ vals := url.Values{
"max_messages": {fmt.Sprintf("%d", maxMessages)},
"window_seconds": {fmt.Sprintf("%d", windowSeconds)},
- })
+ }
+ if allowedIP != "" {
+ vals.Set("allowed_ips", allowedIP)
+ }
+ resp, body, err := c.postForm(path, vals)
if err != nil {
return err
}