release: 1.2.0: Panel 1.2.0-1.2.5: layout, RBAC, tests, GUI and CI fixes
[1.2.0] The second MINOR after 1.0.0: domain administrators with per-domain scope, a panel visual refresh on the SelfPost palette, and refinements to navigation and the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the single administrator becomes a global user. Added: - panel: **domain-admin role** — global administrators manage panel users and Fixed: - Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the - panel: on signed-in pages with a narrow card (**Settings**, the user form) Changed: - panel: navigation session icons are distinct — **Settings** uses a gear, - panel: `/` redirects domain administrators to `/domains`; global users still - panel: **Sign out** in the navigation column uses the same type size and - panel: **visual style** brought in line with the SelfPost mark — brick accent - panel: the send log's **status is a badge**, in the same ok/warn/error/unknown … (1 more in CHANGELOG) [1.2.1] Panel refinements after 1.2.0: navigation icons, status-badge centreing, drill-down back-link placement, and safeguards for the sole global administrator. Upgrading is a tag bump; no migration. Fixed: - panel: status badge text sat low in the box (and below the heading or label - panel: the **Users** navigation icon was two full silhouettes with staggered - panel: the **Settings** navigation icon was a sun-with-rays (circle plus - panel: the user create/edit form placed «Back to users» at the bottom of the Changed: - panel: the user edit form disables role change and delete for the only global - panel: the user create/edit form hides **Assigned domains** when the role is - panel: **Settings** shows panel credentials and DMARC aggregate reports side [1.2.2] Status page layout after 1.2.1: paired cards in a wide column, denser machine and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no migration. Changed: - panel: **Status** is wide again so paired cards fill the column. Layout: Fixed: - panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid - panel: on the user create/edit form, **Assigned domains** checkboxes stacked [1.2.3] Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no migration. Changed: - panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC; - panel: Domains list — **Add domain** sits beside the domain field; the - panel: page URLs, browser titles, and headings are aligned — **Settings** is Fixed: - image: `mail.log` rotation no longer silently stops when the build context [1.2.4] Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field height fix. Upgrading is a tag bump; no migration. Changed: - rate limiting (level 2): domain ceilings apply to every client IP (no IP Fixed: - panel: on the domain DNS status grid, the Type (TXT) field height matches [1.2.5] Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration. Changed: - panel: rate-limit forms refined — level-1 backstop as muted copy on the Squashed CHANGELOG sections: [1.2.0], [1.2.1], [1.2.2], [1.2.3], [1.2.4], [1.2.5]. Full detail: CHANGELOG.md (1.2.0, 1.2.1, 1.2.2, 1.2.3, 1.2.4, 1.2.5).
This commit is contained in:
@@ -189,9 +189,9 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
||||
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||
}
|
||||
|
||||
// SaveRateLimit stores the application-level rate limit. The caller has validated
|
||||
// the IPs and numbers (security.md); the milter reads the row live, so no reload
|
||||
// is needed.
|
||||
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
|
||||
// limiting). The caller has validated the IPs and numbers (security.md); the
|
||||
// milter reads the row live, so no reload is needed.
|
||||
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||
return s.store.SetRateLimit(store.RateLimit{
|
||||
Scope: store.RateLimitScopeApp,
|
||||
|
||||
@@ -145,13 +145,13 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
||||
}
|
||||
|
||||
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
||||
// IPs and numbers (security.md); the milter reads the row live, so no reload is
|
||||
// needed.
|
||||
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
||||
// Domain limits do not use an IP allowlist.
|
||||
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
||||
return s.store.SetRateLimit(store.RateLimit{
|
||||
Scope: store.RateLimitScopeDomain,
|
||||
RefID: domainID,
|
||||
AllowedIPs: ips,
|
||||
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
|
||||
MaxMessages: maxMessages,
|
||||
WindowSeconds: windowSeconds,
|
||||
})
|
||||
|
||||
+101
-36
@@ -62,10 +62,14 @@ type CPU struct {
|
||||
// nothing until it is true.
|
||||
Measured bool
|
||||
BusyPct float64
|
||||
Cores int
|
||||
// Cores and Threads are physical cores and logical CPUs (SMT). Both come
|
||||
// from /proc/cpuinfo when it is readable; otherwise Threads falls back to
|
||||
// the cpuN lines in /proc/stat and Cores matches Threads.
|
||||
Cores int
|
||||
Threads int
|
||||
// Load is the 1/5/15-minute load average, present when /proc/loadavg
|
||||
// could be read. Unlike BusyPct it needs no previous sample, so it is
|
||||
// there on the very first page load.
|
||||
// could be read. Unlike BusyPct it needs no previous sample. It is kept
|
||||
// for the sampler but is no longer shown on the Status card.
|
||||
Load [3]float64
|
||||
HasLoad bool
|
||||
Status Status
|
||||
@@ -176,7 +180,14 @@ type netCounters struct {
|
||||
func (m *MachineSampler) Sample() Machine {
|
||||
root := m.root()
|
||||
now := time.Now()
|
||||
cpuNow, cores, cpuErr := readCPUTimes(root)
|
||||
cpuNow, logical, cpuErr := readCPUTimes(root)
|
||||
cores, threads := readCPUTopology(root)
|
||||
if threads == 0 {
|
||||
threads = logical
|
||||
}
|
||||
if cores == 0 {
|
||||
cores = threads
|
||||
}
|
||||
netNow, netErr := readNetDev(root)
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -198,7 +209,7 @@ func (m *MachineSampler) Sample() Machine {
|
||||
fresh := !prevAt.IsZero() && window > 0 && window <= machineSampleWindow
|
||||
|
||||
mach := Machine{
|
||||
CPU: cpuUsage(prevCPU, cpuNow, cores, readLoadAvg(root), fresh, cpuErr),
|
||||
CPU: cpuUsage(prevCPU, cpuNow, cores, threads, readLoadAvg(root), fresh, cpuErr),
|
||||
Memory: readMemory(root),
|
||||
Network: networkUsage(prevNet, netNow, window, fresh, netErr),
|
||||
}
|
||||
@@ -209,12 +220,11 @@ func (m *MachineSampler) Sample() Machine {
|
||||
return mach
|
||||
}
|
||||
|
||||
// cpuUsage grades the processor over the window. The load average is reported
|
||||
// alongside it because the two answer different questions — how busy the
|
||||
// processor was, and how many tasks were waiting for it — and a machine can
|
||||
// look idle while work queues up behind a slow disk.
|
||||
func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err error) CPU {
|
||||
c := CPU{Cores: cores}
|
||||
// cpuUsage grades the processor over the window. Detail carries only the
|
||||
// core and thread counts — load average and busy prose stay out of the Status
|
||||
// card (see roadmap panel-docs for operator-facing explanation later).
|
||||
func cpuUsage(prev, cur cpuTimes, cores, threads int, load [3]float64, fresh bool, err error) CPU {
|
||||
c := CPU{Cores: cores, Threads: threads}
|
||||
if !isZeroLoad(load) {
|
||||
c.Load, c.HasLoad = load, true
|
||||
}
|
||||
@@ -223,9 +233,9 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
|
||||
c.Detail = "The kernel's processor counters (/proc/stat) could not be read here."
|
||||
return c
|
||||
}
|
||||
c.Detail = c.contextText()
|
||||
if !fresh || cur.total <= prev.total {
|
||||
c.Status = StatusUnknown
|
||||
c.Detail = joinDetail("Measuring — this reading sets the baseline; the next refresh has the figure.", c.contextText())
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -242,25 +252,24 @@ func cpuUsage(prev, cur cpuTimes, cores int, load [3]float64, fresh bool, err er
|
||||
|
||||
if c.BusyPct >= cpuWarnPct {
|
||||
c.Status = StatusWarn
|
||||
c.Detail = joinDetail(c.contextText(), "The processor is close to fully busy, which slows queue processing and every panel page.")
|
||||
} else {
|
||||
c.Status = StatusOK
|
||||
c.Detail = c.contextText()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// contextText is the CPU's supporting figures: what the percentage is a
|
||||
// percentage of, and how deep the run queue is.
|
||||
// contextText is the CPU detail column: physical cores and logical threads.
|
||||
func (c CPU) contextText() string {
|
||||
var parts []string
|
||||
if c.Cores > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d core(s)", c.Cores))
|
||||
switch {
|
||||
case c.Cores > 0 && c.Threads > 0:
|
||||
return fmt.Sprintf("%d cores · %d threads", c.Cores, c.Threads)
|
||||
case c.Threads > 0:
|
||||
return fmt.Sprintf("%d threads", c.Threads)
|
||||
case c.Cores > 0:
|
||||
return fmt.Sprintf("%d cores", c.Cores)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if c.HasLoad {
|
||||
parts = append(parts, fmt.Sprintf("load average %.2f, %.2f, %.2f", c.Load[0], c.Load[1], c.Load[2]))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// readMemory reports main memory from /proc/meminfo. Used is derived from
|
||||
@@ -296,8 +305,7 @@ func readMemory(root string) Memory {
|
||||
m.SwapUsedBytes = m.SwapTotalBytes - swapFree
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%s used of %s; %s available to new work.",
|
||||
humanBytes(m.UsedBytes), humanBytes(total), humanBytes(available))
|
||||
detail := fmt.Sprintf("%s used of %s.", humanBytes(m.UsedBytes), humanBytes(total))
|
||||
if m.SwapTotalBytes > 0 {
|
||||
detail += fmt.Sprintf(" Swap: %s of %s.", humanBytes(m.SwapUsedBytes), humanBytes(m.SwapTotalBytes))
|
||||
}
|
||||
@@ -418,6 +426,74 @@ func readCPUTimes(root string) (cpuTimes, int, error) {
|
||||
return times, cores, nil
|
||||
}
|
||||
|
||||
// readCPUTopology returns physical core and logical thread counts from
|
||||
// /proc/cpuinfo. Zeroes mean the file was missing or empty; the caller falls
|
||||
// back to the cpuN count from /proc/stat.
|
||||
func readCPUTopology(root string) (cores, threads int) {
|
||||
data, err := os.ReadFile(filepath.Join(root, "cpuinfo"))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
type coreKey struct{ phys, core int }
|
||||
seen := map[coreKey]bool{}
|
||||
var (
|
||||
inCPU bool
|
||||
phys = -1
|
||||
core = -1
|
||||
idx int
|
||||
)
|
||||
flush := func() {
|
||||
if !inCPU {
|
||||
return
|
||||
}
|
||||
p, c := phys, core
|
||||
if p < 0 {
|
||||
p = idx
|
||||
}
|
||||
if c < 0 {
|
||||
c = idx
|
||||
}
|
||||
seen[coreKey{p, c}] = true
|
||||
inCPU, phys, core = false, -1, -1
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "processor":
|
||||
flush()
|
||||
inCPU = true
|
||||
threads++
|
||||
idx = threads - 1
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
idx = n
|
||||
}
|
||||
case "physical id":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
phys = n
|
||||
}
|
||||
case "core id":
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
core = n
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if threads == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return len(seen), threads
|
||||
}
|
||||
|
||||
// readLoadAvg reads the 1/5/15-minute load averages. A machine without
|
||||
// /proc/loadavg simply has none reported, so the failure is a zero value rather
|
||||
// than an error.
|
||||
@@ -544,14 +620,3 @@ func humanRate(perSec float64) string {
|
||||
}
|
||||
return humanBytes(uint64(perSec+0.5)) + "/s"
|
||||
}
|
||||
|
||||
// joinDetail joins the non-empty parts of a detail line.
|
||||
func joinDetail(parts ...string) string {
|
||||
var kept []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, " ")
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
|
||||
// 1000 ticks pass, 250 of them idle: 75% busy.
|
||||
prev := cpuTimes{total: 10000, idle: 8000}
|
||||
cur := cpuTimes{total: 11000, idle: 8250}
|
||||
got := cpuUsage(prev, cur, 4, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
||||
got := cpuUsage(prev, cur, 4, 8, [3]float64{0.5, 0.4, 0.3}, true, nil)
|
||||
if !got.Measured {
|
||||
t.Fatalf("reading not marked measured: %+v", got)
|
||||
}
|
||||
@@ -57,16 +57,22 @@ func TestCPUUsageOverTwoReadings(t *testing.T) {
|
||||
if got.Status != StatusOK {
|
||||
t.Errorf("status = %q, want ok", got.Status)
|
||||
}
|
||||
if got.Cores != 4 || !got.HasLoad {
|
||||
t.Errorf("cores/load not reported: %+v", got)
|
||||
if got.Cores != 4 || got.Threads != 8 || !got.HasLoad {
|
||||
t.Errorf("cores/threads/load not reported: %+v", got)
|
||||
}
|
||||
if got.Detail != "4 cores · 8 threads" {
|
||||
t.Errorf("detail = %q, want cores and threads only", got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUUsageWarnsWhenFullyBusy(t *testing.T) {
|
||||
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, [3]float64{}, true, nil)
|
||||
got := cpuUsage(cpuTimes{total: 10000, idle: 5000}, cpuTimes{total: 11000, idle: 5010}, 1, 1, [3]float64{}, true, nil)
|
||||
if got.Status != StatusWarn {
|
||||
t.Errorf("99%% busy graded %q, want warn (%s)", got.Status, got.Detail)
|
||||
}
|
||||
if got.Detail != "1 cores · 1 threads" {
|
||||
t.Errorf("detail = %q, want topology only (no warn prose)", got.Detail)
|
||||
}
|
||||
if got.HasLoad {
|
||||
t.Error("a missing load average should not be reported as zeros")
|
||||
}
|
||||
@@ -84,17 +90,44 @@ func TestCPUUsageWithoutAUsableWindow(t *testing.T) {
|
||||
{"no previous reading", cpuTimes{}, cpuTimes{total: 11000, idle: 8250}, false},
|
||||
{"counters did not advance", cpuTimes{total: 11000, idle: 8250}, cpuTimes{total: 11000, idle: 8250}, true},
|
||||
} {
|
||||
got := cpuUsage(c.prev, c.cur, 2, [3]float64{}, c.fresh, nil)
|
||||
got := cpuUsage(c.prev, c.cur, 2, 2, [3]float64{}, c.fresh, nil)
|
||||
if got.Measured || got.Status != StatusUnknown {
|
||||
t.Errorf("%s: measured=%v status=%q, want unmeasured/unknown", c.name, got.Measured, got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
||||
if got := cpuUsage(cpuTimes{}, cpuTimes{}, 0, 0, [3]float64{}, false, os.ErrNotExist); got.Status != StatusUnknown {
|
||||
t.Errorf("unreadable /proc/stat: status %q, want unknown", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCPUTopology(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const cpuinfo = `processor : 0
|
||||
physical id : 0
|
||||
core id : 0
|
||||
|
||||
processor : 1
|
||||
physical id : 0
|
||||
core id : 0
|
||||
|
||||
processor : 2
|
||||
physical id : 0
|
||||
core id : 1
|
||||
|
||||
processor : 3
|
||||
physical id : 0
|
||||
core id : 1
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "cpuinfo"), []byte(cpuinfo), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cores, threads := readCPUTopology(dir)
|
||||
if cores != 2 || threads != 4 {
|
||||
t.Errorf("topology = %d cores / %d threads, want 2/4", cores, threads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMemory(t *testing.T) {
|
||||
dir := fakeProc(t, "cpu 1 1 1 1 1 1 1 1\n", meminfoSample, "0.1 0.2 0.3 1/2 3\n", netdevSample)
|
||||
got := readMemory(dir)
|
||||
@@ -104,8 +137,7 @@ func TestReadMemory(t *testing.T) {
|
||||
if got.TotalBytes != 4*1024*1024*1024 {
|
||||
t.Errorf("total = %d bytes (%s), want 4 GiB", got.TotalBytes, got.TotalText())
|
||||
}
|
||||
// 4 GiB total, 2 GiB available to new work: half used, cache included in
|
||||
// what is available.
|
||||
// 4 GiB total, 2 GiB MemAvailable: half used (cache counted as available).
|
||||
if got.Percent() != 50 {
|
||||
t.Errorf("used = %.1f%% (%s), want 50%%", got.UsedPct, got.PctText())
|
||||
}
|
||||
@@ -221,8 +253,11 @@ func TestMachineSamplerNeedsTwoReadings(t *testing.T) {
|
||||
if !first.Memory.Measured {
|
||||
t.Error("memory is a level, not a rate: it must be reported on the first sample")
|
||||
}
|
||||
if first.CPU.Cores != 2 {
|
||||
t.Errorf("cores = %d, want 2", first.CPU.Cores)
|
||||
if first.CPU.Cores != 2 || first.CPU.Threads != 2 {
|
||||
t.Errorf("cores/threads = %d/%d, want 2/2", first.CPU.Cores, first.CPU.Threads)
|
||||
}
|
||||
if first.CPU.Detail != "2 cores · 2 threads" {
|
||||
t.Errorf("detail = %q, want topology only", first.CPU.Detail)
|
||||
}
|
||||
if !first.CPU.HasLoad || first.CPU.Load[0] != 0.42 {
|
||||
t.Errorf("load average not read: %+v", first.CPU.Load)
|
||||
|
||||
@@ -36,7 +36,7 @@ func CheckSocket(name, path string, required bool) Socket {
|
||||
default:
|
||||
s.Present = true
|
||||
s.Status = StatusOK
|
||||
s.Detail = "Listening."
|
||||
s.Detail = "Listening"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ func (f *fakeRecorder) RateLimit(scope, ref string) (store.RateLimit, bool, erro
|
||||
return store.RateLimit{}, false, f.lookupErr
|
||||
}
|
||||
rl, ok := f.limits[scope+"|"+ref]
|
||||
if ok {
|
||||
rl.Scope = scope
|
||||
}
|
||||
return rl, ok, nil
|
||||
}
|
||||
|
||||
@@ -186,11 +189,14 @@ func TestBracedMacros(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// limitAt is the client IP the rate-limit tests connect from; the limits below
|
||||
// register it so the differentiated check applies.
|
||||
// limitIP is the client IP rate-limit tests connect from.
|
||||
const limitIP = "203.0.113.7"
|
||||
|
||||
func activeLimit(ips ...string) store.RateLimit {
|
||||
func domainLimit() store.RateLimit {
|
||||
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
|
||||
}
|
||||
|
||||
func appLimit(ips ...string) store.RateLimit {
|
||||
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
||||
}
|
||||
|
||||
@@ -212,7 +218,7 @@ func mailFrom(t *testing.T, rec Store, ip, from, login string) milter.Response {
|
||||
func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 5}, // == max
|
||||
}
|
||||
@@ -227,7 +233,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
||||
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
||||
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
||||
}
|
||||
@@ -239,7 +245,7 @@ func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||
func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 4}, // < max
|
||||
}
|
||||
@@ -251,25 +257,76 @@ func TestRateLimitAllowsUnderLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitIgnoresUnregisteredIP(t *testing.T) {
|
||||
func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit("198.51.100.1"), // not limitIP
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
// The sender's IP is not in the domain's registered set, so level-2 does not
|
||||
// apply even though the count is huge (level-1 anvil would still cover it).
|
||||
// Domain ceilings apply to every client IP; leftover AllowedIPs on the row
|
||||
// are ignored.
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("domain over limit from any IP = %v, want TempFail", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||
store.RateLimitScopeApp + "|app1": {
|
||||
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
|
||||
},
|
||||
},
|
||||
counts: map[string]int64{
|
||||
store.RateLimitScopeDomain + "|example.com": 5, // over domain
|
||||
store.RateLimitScopeApp + "|app1": 2, // under app
|
||||
},
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||
t.Fatalf("unregistered IP = %v, want Continue (level-2 n/a)", resp)
|
||||
t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||
store.RateLimitScopeApp + "|app1": {
|
||||
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600,
|
||||
},
|
||||
},
|
||||
counts: map[string]int64{
|
||||
store.RateLimitScopeDomain + "|example.com": 1,
|
||||
store.RateLimitScopeApp + "|app1": 0,
|
||||
},
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs
|
||||
},
|
||||
counts: map[string]int64{
|
||||
store.RateLimitScopeDomain + "|example.com": 1,
|
||||
store.RateLimitScopeApp + "|app1": 0,
|
||||
},
|
||||
}
|
||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitInactiveWithoutCeiling(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
// IP registered but no ceiling/window: an inert draft, must not enforce.
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}},
|
||||
store.RateLimitScopeDomain + "|example.com": {AllowedIPs: []string{limitIP}}, // no max/window
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
@@ -288,7 +345,7 @@ func TestRateLimitFailsOpenOnLookupError(t *testing.T) {
|
||||
func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
countErr: errors.New("db down"),
|
||||
}
|
||||
@@ -300,7 +357,7 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
||||
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||
}
|
||||
@@ -333,7 +390,7 @@ func mailFromIn(t *testing.T, rec Store, fl *inflight, ip, from, login string) (
|
||||
func limitedRecorder(count int64) *fakeRecorder {
|
||||
return &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
},
|
||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": count},
|
||||
}
|
||||
@@ -395,17 +452,17 @@ func TestReservationReleasedOnAbort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A refused message must not leave the slots it claimed for the limits checked
|
||||
// before the one that tripped, or every refusal would tighten the ceiling.
|
||||
func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
||||
// A trusted app at its ceiling refuses without touching the domain counter;
|
||||
// no domain reservation should linger after the refusal.
|
||||
func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
|
||||
rec := &fakeRecorder{
|
||||
limits: map[string]store.RateLimit{
|
||||
store.RateLimitScopeDomain + "|example.com": activeLimit(limitIP),
|
||||
store.RateLimitScopeApp + "|app1": activeLimit(limitIP),
|
||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
||||
},
|
||||
counts: map[string]int64{
|
||||
store.RateLimitScopeDomain + "|example.com": 0, // domain: plenty of room
|
||||
store.RateLimitScopeApp + "|app1": 5, // app: at the ceiling
|
||||
store.RateLimitScopeDomain + "|example.com": 0,
|
||||
store.RateLimitScopeApp + "|app1": 5, // app at ceiling
|
||||
},
|
||||
}
|
||||
fl := &inflight{}
|
||||
@@ -415,6 +472,9 @@ func TestRefusalReleasesEarlierReservation(t *testing.T) {
|
||||
if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 0 {
|
||||
t.Fatalf("domain reservation left behind after refusal: %d", n)
|
||||
}
|
||||
if n := fl.count(store.RateLimitScopeApp+"|app1", time.Now().Add(-time.Hour)); n != 0 {
|
||||
t.Fatalf("app reservation left behind after refusal: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The in-flight count only covers the limit's own window: a reservation older
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
)
|
||||
|
||||
// overLimit reports whether the message currently being received should be
|
||||
// refused under a level-2 differentiated limit (guide § Rate limiting). It
|
||||
// checks the domain-level and application-level limits in turn; either being
|
||||
// exceeded is enough to refuse.
|
||||
// refused under a level-2 differentiated limit (guide § Rate limiting).
|
||||
//
|
||||
// Trusted application IPs (app limit active and client IP listed) use only the
|
||||
// app ceiling and skip the domain check. Everyone else is under the domain
|
||||
// ceiling when one is configured; otherwise only level 1 applies.
|
||||
//
|
||||
// It is deliberately fail-open: any store error, or the absence of a usable
|
||||
// limit, is treated as "not over limit" so a malfunction of the level-2
|
||||
@@ -26,46 +28,48 @@ func (s *session) overLimit() bool {
|
||||
if s.clientIP == "" {
|
||||
return false // no client IP to key on; level-2 does not apply
|
||||
}
|
||||
checks := []struct{ scope, ref string }{
|
||||
{store.RateLimitScopeDomain, domainOf(s.from)},
|
||||
{store.RateLimitScopeApp, s.login},
|
||||
}
|
||||
var taken []*reservation
|
||||
for _, c := range checks {
|
||||
if c.ref == "" {
|
||||
continue
|
||||
}
|
||||
rl, ok, err := s.rec.RateLimit(c.scope, c.ref)
|
||||
|
||||
if s.login != "" {
|
||||
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: rate-limit lookup %s %q: %v (fail-open)", c.scope, c.ref, err)
|
||||
continue
|
||||
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
|
||||
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) {
|
||||
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
|
||||
}
|
||||
// No limit configured, an inert draft, or a client IP outside the
|
||||
// registered set: the differentiated limit does not apply here.
|
||||
if !ok || !rl.Active() || !rl.AllowsIP(s.clientIP) {
|
||||
continue
|
||||
}
|
||||
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
||||
n, err := s.rec.CountMessages(c.scope, c.ref, since)
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", c.scope, c.ref, err)
|
||||
continue
|
||||
}
|
||||
key := c.scope + "|" + c.ref
|
||||
n += s.flight.count(key, since)
|
||||
if n >= int64(rl.MaxMessages) {
|
||||
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
||||
c.scope, c.ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
||||
// The message is refused, so the slots claimed for the limits
|
||||
// checked before this one must not stay claimed.
|
||||
for _, r := range taken {
|
||||
s.flight.release(r)
|
||||
}
|
||||
return true
|
||||
}
|
||||
taken = append(taken, s.flight.reserve(key))
|
||||
}
|
||||
s.reserved = append(s.reserved, taken...)
|
||||
|
||||
domain := domainOf(s.from)
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeDomain, domain)
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: rate-limit lookup domain %q: %v (fail-open)", domain, err)
|
||||
return false
|
||||
}
|
||||
if !ok || !rl.Active() {
|
||||
return false
|
||||
}
|
||||
return s.enforceLimit(store.RateLimitScopeDomain, domain, rl)
|
||||
}
|
||||
|
||||
// enforceLimit counts recent messages for scope/ref and refuses when at or
|
||||
// above the ceiling. On admit it reserves an in-flight slot on the session.
|
||||
func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool {
|
||||
since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second)
|
||||
n, err := s.rec.CountMessages(scope, ref, since)
|
||||
if err != nil {
|
||||
log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err)
|
||||
return false
|
||||
}
|
||||
key := scope + "|" + ref
|
||||
n += s.flight.count(key, since)
|
||||
if n >= int64(rl.MaxMessages) {
|
||||
log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx",
|
||||
scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP)
|
||||
return true
|
||||
}
|
||||
s.reserved = append(s.reserved, s.flight.reserve(key))
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
|
||||
var ErrNoAdmin = errors.New("no administrator account")
|
||||
|
||||
// Admin is the single panel administrator (security.md).
|
||||
type Admin struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
DMARCReportEmail string // default rua= for all sending domains; empty = none
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminExists reports whether the administrator account has been created. This
|
||||
// doubles as the "primary setup complete" flag: once true, the /setup route is
|
||||
// permanently gone (security.md).
|
||||
func (s *Store) AdminExists() (bool, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
|
||||
return false, fmt.Errorf("count admin: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CreateAdmin inserts the administrator row. It fails if one already exists,
|
||||
// which — combined with the id=1 constraint — makes admin creation one-shot
|
||||
// even under a race between two setup submissions.
|
||||
func (s *Store) CreateAdmin(username, passwordHash string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
|
||||
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create admin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAdmin replaces the administrator's username and password hash. It
|
||||
// touches only the admin row (id = 1): panel credentials are unrelated to the
|
||||
// SASL logins applications authenticate with, which live in their own table.
|
||||
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
|
||||
// silently create an account.
|
||||
func (s *Store) UpdateAdmin(username, passwordHash, dmarcReportEmail string) error {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE admin SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = 1",
|
||||
username, passwordHash, dmarcReportEmail,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update admin: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update admin: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNoAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
|
||||
func (s *Store) GetAdmin() (Admin, error) {
|
||||
var (
|
||||
a Admin
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow("SELECT username, password_hash, dmarc_report_email, created_at FROM admin WHERE id = 1").
|
||||
Scan(&a.Username, &a.PasswordHash, &a.DMARCReportEmail, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Admin{}, ErrNoAdmin
|
||||
}
|
||||
if err != nil {
|
||||
return Admin{}, fmt.Errorf("get admin: %w", err)
|
||||
}
|
||||
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return a, nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
|
||||
t.Fatalf("CreateAdmin: %v", err)
|
||||
}
|
||||
if err := st.UpdateAdmin("operator", "hash-two", "reports@hub.example"); err != nil {
|
||||
t.Fatalf("UpdateAdmin: %v", err)
|
||||
}
|
||||
|
||||
a, err := st.GetAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdmin: %v", err)
|
||||
}
|
||||
if a.DMARCReportEmail != "reports@hub.example" {
|
||||
t.Fatalf("dmarc email = %q", a.DMARCReportEmail)
|
||||
}
|
||||
|
||||
if err := st.UpdateAdmin("operator", "hash-three", ""); err != nil {
|
||||
t.Fatalf("clear dmarc email: %v", err)
|
||||
}
|
||||
a, err = st.GetAdmin()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAdmin: %v", err)
|
||||
}
|
||||
if a.Username != "operator" || a.PasswordHash != "hash-three" {
|
||||
t.Fatalf("unexpected admin after update: %+v", a)
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
t.Fatal("update dropped created_at")
|
||||
}
|
||||
}
|
||||
|
||||
// An update before setup must not create the account: only the one-time setup
|
||||
// flow may do that (security.md).
|
||||
func TestUpdateAdminWithoutAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.UpdateAdmin("operator", "hash", ""); !errors.Is(err, ErrNoAdmin) {
|
||||
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
|
||||
}
|
||||
exists, err := st.AdminExists()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminExists: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("UpdateAdmin created an administrator")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Panel users and domain-admin assignments. Migrates the single admin row into
|
||||
-- a global user; drops the admin table.
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain_admin')),
|
||||
dmarc_report_email TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE user_domains (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, domain_id)
|
||||
);
|
||||
|
||||
INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at)
|
||||
SELECT username, password_hash, 'global', dmarc_report_email, created_at
|
||||
FROM admin WHERE id = 1;
|
||||
|
||||
INSERT OR REPLACE INTO settings (key, value)
|
||||
SELECT 'dmarc_report_email', dmarc_report_email FROM admin WHERE id = 1;
|
||||
|
||||
DROP TABLE admin;
|
||||
@@ -18,36 +18,40 @@ const (
|
||||
)
|
||||
|
||||
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
|
||||
// an optional set of expected client IPs plus a message ceiling over a sliding
|
||||
// window, attached to a domain or an application. It is enforced in the
|
||||
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
|
||||
// IP backstop that always applies even when this is absent or the milter is
|
||||
// down.
|
||||
// a message ceiling over a sliding window, attached to a domain or an
|
||||
// application. It is enforced in the journal-milter; level 1 (Postfix anvil,
|
||||
// architecture.md § Mail path) is the IP backstop that always applies even
|
||||
// when this is absent or the milter is down.
|
||||
//
|
||||
// Both the IP binding and the ceiling are optional in the schema, but a limit
|
||||
// is only enforced when it is Active(): the design deliberately allows an
|
||||
// admin to leave the IP binding empty for apps that send from changing IPs, in
|
||||
// which case only level 1 protects them (guide § Rate limiting).
|
||||
// Domain limits apply to every client IP once max and window are set. Application
|
||||
// limits additionally require AllowedIPs: those trusted addresses get the app
|
||||
// ceiling (above the domain) and skip the domain check; other IPs stay under
|
||||
// the domain limit or level 1 alone (guide § Rate limiting).
|
||||
type RateLimit struct {
|
||||
Scope string
|
||||
RefID int64
|
||||
AllowedIPs []string // canonical client IPs this limit applies to
|
||||
AllowedIPs []string // trusted client IPs for an application override
|
||||
MaxMessages int
|
||||
WindowSeconds int
|
||||
}
|
||||
|
||||
// Active reports whether the limit is fully configured and should be enforced.
|
||||
// A missing IP binding, ceiling or window leaves the differentiated limit
|
||||
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
|
||||
// known sender.
|
||||
// Domain: max and window only. Application: also needs at least one trusted IP
|
||||
// (the privilege that raises the ceiling above the domain).
|
||||
func (r RateLimit) Active() bool {
|
||||
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
||||
if r.MaxMessages <= 0 || r.WindowSeconds <= 0 {
|
||||
return false
|
||||
}
|
||||
if r.Scope == RateLimitScopeApp {
|
||||
return len(r.AllowedIPs) > 0
|
||||
}
|
||||
// Domain (and any unset/legacy scope treated as domain-style): no IP list.
|
||||
return true
|
||||
}
|
||||
|
||||
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
|
||||
// comparison parses both sides so equivalent textual forms of the same address
|
||||
// match; a client IP outside the list means the differentiated limit does not
|
||||
// apply to it (level 1 still does).
|
||||
// AllowsIP reports whether ip is one of the application's trusted client IPs.
|
||||
// Used only for application overrides; domain limits do not consult this list.
|
||||
// Equivalent textual forms of the same address match.
|
||||
func (r RateLimit) AllowsIP(ip string) bool {
|
||||
c := net.ParseIP(ip)
|
||||
if c == nil {
|
||||
@@ -183,8 +187,8 @@ func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
|
||||
// (an IP-only draft) by leaving the corresponding field zero, which makes the
|
||||
// limit inert via Active().
|
||||
// by leaving the corresponding field zero, which makes the limit inert via
|
||||
// Active() until max and window are both set.
|
||||
func scanRateLimit(r scanRow) (RateLimit, error) {
|
||||
var (
|
||||
ips sql.NullString
|
||||
|
||||
@@ -152,27 +152,35 @@ func TestDeleteRateLimitsForDomain(t *testing.T) {
|
||||
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
|
||||
inactive := []RateLimit{
|
||||
{},
|
||||
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
||||
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
|
||||
{Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
|
||||
{Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window
|
||||
{Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs
|
||||
{Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
|
||||
}
|
||||
for i, rl := range inactive {
|
||||
if rl.Active() {
|
||||
t.Fatalf("case %d: %+v should be inactive", i, rl)
|
||||
}
|
||||
}
|
||||
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
|
||||
if !active.Active() {
|
||||
t.Fatalf("should be active: %+v", active)
|
||||
domainActive := RateLimit{Scope: RateLimitScopeDomain, MaxMessages: 5, WindowSeconds: 60}
|
||||
if !domainActive.Active() {
|
||||
t.Fatalf("domain without IPs should be active: %+v", domainActive)
|
||||
}
|
||||
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
|
||||
appActive := RateLimit{
|
||||
Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1", "2001:db8::1"},
|
||||
MaxMessages: 5, WindowSeconds: 60,
|
||||
}
|
||||
if !appActive.Active() {
|
||||
t.Fatalf("should be active: %+v", appActive)
|
||||
}
|
||||
if !appActive.AllowsIP("203.0.113.1") || !appActive.AllowsIP("2001:db8::1") {
|
||||
t.Fatalf("registered IPs should match")
|
||||
}
|
||||
// Equivalent textual form of the IPv6 address must still match.
|
||||
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||
if !appActive.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||
t.Fatalf("expanded IPv6 form should match")
|
||||
}
|
||||
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
|
||||
if appActive.AllowsIP("198.51.100.7") || appActive.AllowsIP("not-an-ip") || appActive.AllowsIP("") {
|
||||
t.Fatalf("unregistered/invalid IPs must not match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetSetting returns a settings value or empty string when missing.
|
||||
func (s *Store) GetSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get setting %s: %w", key, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SetSetting upserts a settings key.
|
||||
func (s *Store) SetSetting(key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
key, value,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set setting %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoUser is returned when primary setup has not happened yet.
|
||||
var ErrNoUser = errors.New("no panel user")
|
||||
|
||||
// ErrUserNotFound is returned when a panel user id or username does not exist.
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrUserExists is returned when a username is already taken.
|
||||
var ErrUserExists = errors.New("username already taken")
|
||||
|
||||
// ErrLastGlobal is returned when deleting or demoting the last global user.
|
||||
var ErrLastGlobal = errors.New("cannot remove last global administrator")
|
||||
|
||||
// Role identifies a panel user's access level.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleGlobal Role = "global"
|
||||
RoleDomainAdmin Role = "domain_admin"
|
||||
)
|
||||
|
||||
// User is a panel login (not an application SASL account).
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
DMARCReportEmail string
|
||||
CreatedAt time.Time
|
||||
DomainIDs []int64
|
||||
}
|
||||
|
||||
// UserExists reports whether any panel user exists (setup complete).
|
||||
func (s *Store) UserExists() (bool, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n); err != nil {
|
||||
return false, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CreateGlobalUser inserts the first global user during setup.
|
||||
func (s *Store) CreateGlobalUser(username, passwordHash string) error {
|
||||
exists, err := s.UserExists()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("create global user: users already exist")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||
username, passwordHash, RoleGlobal, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create global user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByUsername returns a user with domain assignments loaded.
|
||||
func (s *Store) GetUserByUsername(username string) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE username = ?",
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user by username: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUser returns a user by id with domain assignments.
|
||||
func (s *Store) GetUser(id int64) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users WHERE id = ?",
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
u.DomainIDs, err = s.listUserDomainIDs(u.ID)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsers returns every panel user without domain ids.
|
||||
func (s *Store) ListUsers() ([]User, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, username, password_hash, role, dmarc_report_email, created_at FROM users ORDER BY id",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.DMARCReportEmail, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("list users scan: %w", err)
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// UserRow is a user plus assigned domain names for the management list.
|
||||
type UserRow struct {
|
||||
User User
|
||||
DomainNames []string
|
||||
}
|
||||
|
||||
// ListUserRows returns users with assigned domain names for the management UI.
|
||||
func (s *Store) ListUserRows() ([]UserRow, error) {
|
||||
users, err := s.ListUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]UserRow, len(users))
|
||||
for i, u := range users {
|
||||
rows[i].User = u
|
||||
if u.Role == RoleGlobal {
|
||||
continue
|
||||
}
|
||||
names, err := s.listUserDomainNames(u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows[i].DomainNames = names
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// CountGlobalUsers returns how many global-role users exist.
|
||||
func (s *Store) CountGlobalUsers() (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = ?", RoleGlobal).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count global users: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CreateUser inserts a panel user and optional domain assignments.
|
||||
func (s *Store) CreateUser(username, passwordHash string, role Role, domainIDs []int64) (int64, error) {
|
||||
if role == RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
return 0, fmt.Errorf("create user: domain_admin requires domains")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_report_email, created_at) VALUES (?, ?, ?, '', ?)",
|
||||
username, passwordHash, role, now,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return 0, ErrUserExists
|
||||
}
|
||||
return 0, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user id: %w", err)
|
||||
}
|
||||
if role == RoleDomainAdmin {
|
||||
if err := s.setUserDomains(id, domainIDs); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateUser replaces username, password hash, and DMARC email for a user.
|
||||
func (s *Store) UpdateUser(id int64, username, passwordHash, dmarcReportEmail string) error {
|
||||
u, err := s.GetUser(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET username = ?, password_hash = ?, dmarc_report_email = ? WHERE id = ?",
|
||||
username, passwordHash, dmarcReportEmail, id,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return ErrUserExists
|
||||
}
|
||||
return fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if u.Role == RoleGlobal {
|
||||
if err := s.SetSetting("dmarc_report_email", dmarcReportEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserRole updates a user's role.
|
||||
func (s *Store) SetUserRole(userID int64, role Role) error {
|
||||
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set user role: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set user role: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearUserDomains removes all domain assignments for a user.
|
||||
func (s *Store) ClearUserDomains(userID int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM user_domains WHERE user_id = ?", userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear user domains: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserDomains replaces domain assignments for a domain_admin user.
|
||||
func (s *Store) SetUserDomains(userID int64, domainIDs []int64) error {
|
||||
u, err := s.GetUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role != RoleDomainAdmin {
|
||||
return fmt.Errorf("set user domains: user is not domain_admin")
|
||||
}
|
||||
if len(domainIDs) == 0 {
|
||||
return fmt.Errorf("set user domains: at least one domain required")
|
||||
}
|
||||
return s.setUserDomains(userID, domainIDs)
|
||||
}
|
||||
|
||||
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global user.
|
||||
func (s *Store) DeleteUser(id int64) error {
|
||||
u, err := s.GetUser(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role == RoleGlobal {
|
||||
n, err := s.CountGlobalUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastGlobal
|
||||
}
|
||||
}
|
||||
res, err := s.db.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GlobalDMARCReportEmail returns the shared default rua= for domain inherit mode.
|
||||
func (s *Store) GlobalDMARCReportEmail() (string, error) {
|
||||
return s.GetSetting("dmarc_report_email")
|
||||
}
|
||||
|
||||
func (s *Store) listUserDomainIDs(userID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query("SELECT domain_id FROM user_domains WHERE user_id = ? ORDER BY domain_id", userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user domains: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("list user domains scan: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) listUserDomainNames(userID int64) ([]string, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT d.name FROM user_domains ud JOIN domains d ON d.id = ud.domain_id WHERE ud.user_id = ? ORDER BY d.name",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user domain names: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("list user domain names scan: %w", err)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) setUserDomains(userID int64, domainIDs []int64) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set user domains begin: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM user_domains WHERE user_id = ?", userID); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("set user domains clear: %w", err)
|
||||
}
|
||||
for _, did := range domainIDs {
|
||||
if _, err := tx.Exec("INSERT INTO user_domains (user_id, domain_id) VALUES (?, ?)", userID, did); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("set user domains insert: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.CreateGlobalUser("admin", "hash-one"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
u, err := st.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
if err := st.UpdateUser(u.ID, "operator", "hash-two", "reports@hub.example"); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
|
||||
got, err := st.GetUser(u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if got.DMARCReportEmail != "reports@hub.example" {
|
||||
t.Fatalf("dmarc email = %q", got.DMARCReportEmail)
|
||||
}
|
||||
email, err := st.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
t.Fatalf("GlobalDMARCReportEmail: %v", err)
|
||||
}
|
||||
if email != "reports@hub.example" {
|
||||
t.Fatalf("settings dmarc = %q", email)
|
||||
}
|
||||
|
||||
if err := st.UpdateUser(u.ID, "operator", "hash-three", ""); err != nil {
|
||||
t.Fatalf("clear dmarc email: %v", err)
|
||||
}
|
||||
got, err = st.GetUser(u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if got.Username != "operator" || got.PasswordHash != "hash-three" {
|
||||
t.Fatalf("unexpected user after update: %+v", got)
|
||||
}
|
||||
if got.CreatedAt.IsZero() {
|
||||
t.Fatal("update dropped created_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserWithoutUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
if err := st.UpdateUser(1, "operator", "hash", ""); !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("UpdateUser without user = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
exists, err := st.UserExists()
|
||||
if err != nil {
|
||||
t.Fatalf("UserExists: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("UpdateUser created a user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDomainAdminUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
d, err := st.AddDomain("example.com", "s1")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
id, err := st.CreateUser("domainop", "hash2", RoleDomainAdmin, []int64{d.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
u, err := st.GetUser(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if len(u.DomainIDs) != 1 || u.DomainIDs[0] != d.ID {
|
||||
t.Fatalf("domain ids = %v, want [%d]", u.DomainIDs, d.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteLastGlobalUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
t.Fatalf("CreateGlobalUser: %v", err)
|
||||
}
|
||||
u, err := st.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
if err := st.DeleteUser(u.ID); !errors.Is(err, ErrLastGlobal) {
|
||||
t.Fatalf("DeleteUser = %v, want ErrLastGlobal", err)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func (m *Module) clearSessionCookies(w http.ResponseWriter) {
|
||||
|
||||
// HandleLogin serves the login form (GET) and authenticates (POST).
|
||||
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
exists, err := m.store.AdminExists()
|
||||
exists, err := m.store.UserExists()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -106,22 +106,21 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
|
||||
admin, err := m.store.GetAdmin()
|
||||
user, err := m.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNoAdmin) {
|
||||
logf("panel: login: get admin failed: %v", err)
|
||||
if !errors.Is(err, store.ErrUserNotFound) {
|
||||
logf("panel: login: get user failed: %v", err)
|
||||
}
|
||||
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||
return
|
||||
}
|
||||
|
||||
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
|
||||
if username != admin.Username || pwErr != nil {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
|
||||
return
|
||||
}
|
||||
|
||||
token := m.sessions.Create(admin.Username)
|
||||
token := m.sessions.Create(user.Username)
|
||||
m.setSessionCookie(w, token)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
@@ -222,13 +221,13 @@ func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token strin
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.store.CreateAdmin(username, string(hash)); err != nil {
|
||||
if exists, _ := m.store.AdminExists(); exists {
|
||||
if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
|
||||
if exists, _ := m.store.UserExists(); exists {
|
||||
m.setup.complete()
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
logf("panel: setup: create admin failed: %v", err)
|
||||
logf("panel: setup: create user failed: %v", err)
|
||||
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
package auth
|
||||
|
||||
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.
|
||||
// principal is stashed in the request context for downstream handlers.
|
||||
func (m *Module) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := m.sessionToken(r)
|
||||
@@ -27,7 +22,13 @@ func (m *Module) RequireAuth(next http.Handler) http.Handler {
|
||||
if isSessionActivity(r) && m.sessions.Touch(token) {
|
||||
m.setSessionCookie(w, token)
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), usernameKey, username)
|
||||
u, err := m.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
p := principalFromUser(u)
|
||||
ctx := withPrincipal(r.Context(), p)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -43,3 +44,15 @@ func CurrentUser(r *http.Request) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireGlobal wraps a handler that only global administrators may reach.
|
||||
func RequireGlobal(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := CurrentPrincipal(r.Context())
|
||||
if !ok || !p.IsGlobal() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
usernameKey ctxKey = 0
|
||||
principalKey ctxKey = 1
|
||||
)
|
||||
|
||||
// Role is a panel user's access level.
|
||||
type Role = store.Role
|
||||
|
||||
const (
|
||||
RoleGlobal = store.RoleGlobal
|
||||
RoleDomainAdmin = store.RoleDomainAdmin
|
||||
)
|
||||
|
||||
// Principal is the authenticated panel user attached to a request.
|
||||
type Principal struct {
|
||||
ID int64
|
||||
Username string
|
||||
Role Role
|
||||
Domains []int64 // assigned domain IDs; empty for global (all domains)
|
||||
}
|
||||
|
||||
// IsGlobal reports whether the principal has full panel access.
|
||||
func (p Principal) IsGlobal() bool {
|
||||
return p.Role == RoleGlobal
|
||||
}
|
||||
|
||||
// CanAccessDomain reports whether the principal may access a domain id.
|
||||
func (p Principal) CanAccessDomain(domainID int64) bool {
|
||||
if p.IsGlobal() {
|
||||
return true
|
||||
}
|
||||
for _, id := range p.Domains {
|
||||
if id == domainID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanAccessApp reports whether the principal may access an application.
|
||||
func (p Principal) CanAccessApp(app store.Application) bool {
|
||||
return p.CanAccessDomain(app.DomainID)
|
||||
}
|
||||
|
||||
func principalFromUser(u store.User) Principal {
|
||||
return Principal{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
Domains: u.DomainIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func withPrincipal(ctx context.Context, p Principal) context.Context {
|
||||
ctx = context.WithValue(ctx, usernameKey, p.Username)
|
||||
return context.WithValue(ctx, principalKey, p)
|
||||
}
|
||||
|
||||
// CurrentPrincipal returns the authenticated principal from the request context.
|
||||
func CurrentPrincipal(ctx context.Context) (Principal, bool) {
|
||||
if v, ok := ctx.Value(principalKey).(Principal); ok {
|
||||
return v, true
|
||||
}
|
||||
return Principal{}, false
|
||||
}
|
||||
|
||||
// PrincipalFromRequest returns the authenticated principal from an HTTP request.
|
||||
func PrincipalFromRequest(r *http.Request) (Principal, bool) {
|
||||
return CurrentPrincipal(r.Context())
|
||||
}
|
||||
|
||||
// RequestWithPrincipal attaches a principal for middleware-equivalent tests.
|
||||
func RequestWithPrincipal(r *http.Request, p Principal) *http.Request {
|
||||
return r.WithContext(withPrincipal(r.Context(), p))
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
|
||||
}
|
||||
|
||||
func (m *setupManager) bootstrap() error {
|
||||
done, err := m.store.AdminExists()
|
||||
done, err := m.store.UserExists()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -48,9 +48,9 @@ func (m *setupManager) bootstrap() error {
|
||||
}
|
||||
|
||||
func (m *setupManager) activeToken() (string, bool) {
|
||||
done, err := m.store.AdminExists()
|
||||
done, err := m.store.UserExists()
|
||||
if err != nil {
|
||||
logf("panel: setup: admin check failed: %v", err)
|
||||
logf("panel: setup: user check failed: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if done {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
func (h *Handlers) principal(r *http.Request) (auth.Principal, bool) {
|
||||
return auth.PrincipalFromRequest(r)
|
||||
}
|
||||
|
||||
func (h *Handlers) requireGlobal(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok || !p.IsGlobal() {
|
||||
http.NotFound(w, r)
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func (h *Handlers) pageBase(r *http.Request) map[string]any {
|
||||
p, _ := h.principal(r)
|
||||
return map[string]any{
|
||||
"User": auth.CurrentUser(r),
|
||||
"IsGlobal": p.IsGlobal(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
|
||||
if p.IsGlobal() {
|
||||
return h.store.ListDomains()
|
||||
}
|
||||
all, err := h.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []store.Domain
|
||||
for _, d := range all {
|
||||
if p.CanAccessDomain(d.ID) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||
m := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
m[d.Name] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func domainIDSet(p auth.Principal) map[int64]bool {
|
||||
if p.IsGlobal() {
|
||||
return nil
|
||||
}
|
||||
m := make(map[int64]bool, len(p.Domains))
|
||||
for _, id := range p.Domains {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type Config struct {
|
||||
TLSCertFile string
|
||||
OpenDKIMSocket string
|
||||
JournalSocket string
|
||||
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
|
||||
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
|
||||
RateLimitMessagesPerIP int
|
||||
RateLimitWindowSeconds int
|
||||
}
|
||||
|
||||
// Handlers holds dependencies for authenticated panel routes.
|
||||
|
||||
@@ -14,21 +14,22 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HandleAccount serves the administrator's own account settings: the username
|
||||
// and password chosen during setup are the only panel credentials
|
||||
// (security.md), and until now they could be changed only by recreating the
|
||||
// state. Changing them here never touches application SASL logins, which are a
|
||||
// separate identity system (architecture.md § Mail path).
|
||||
// HandleAccount serves the signed-in user's account settings.
|
||||
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
admin, err := h.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: account: get admin failed: %v", err)
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail)
|
||||
u, err := h.store.GetUser(p.ID)
|
||||
if err != nil {
|
||||
logf("panel: account: get user failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
|
||||
case http.MethodPost:
|
||||
h.submitAccount(w, r)
|
||||
default:
|
||||
@@ -37,33 +38,32 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderAccount draws the settings form. formUsername and formDMARCEmail
|
||||
// repopulate fields after a rejected submission; password fields are never
|
||||
// repopulated.
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) {
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
|
||||
var reportAuth dnscheck.Result
|
||||
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
reportAuth = h.dns.ReportAuth(ctx, hub)
|
||||
cancel()
|
||||
if showDMARC && formDMARCEmail != "" {
|
||||
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
reportAuth = h.dns.ReportAuth(ctx, hub)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
h.view.Render(w, status, "account", map[string]any{
|
||||
"Title": "SelfPost — settings",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "account",
|
||||
"FormUsername": formUsername,
|
||||
"FormDMARCEmail": formDMARCEmail,
|
||||
"ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)),
|
||||
"ReportAuthExample": dnscheck.ReportAuthExample(),
|
||||
"ReportAuthDNS": reportAuth,
|
||||
"ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail),
|
||||
"Error": formErr,
|
||||
"Flash": accountFlash(r),
|
||||
})
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — settings"
|
||||
data["Active"] = "settings"
|
||||
data["FormUsername"] = formUsername
|
||||
data["FormDMARCEmail"] = formDMARCEmail
|
||||
data["ShowDMARC"] = showDMARC
|
||||
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
|
||||
data["ReportAuthExample"] = dnscheck.ReportAuthExample()
|
||||
data["ReportAuthDNS"] = reportAuth
|
||||
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
||||
data["Error"] = formErr
|
||||
data["Flash"] = accountFlash(r)
|
||||
data["L1Messages"] = h.l1Messages()
|
||||
data["L1Window"] = h.l1Window()
|
||||
h.view.Render(w, status, "settings", data)
|
||||
}
|
||||
|
||||
// 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":
|
||||
@@ -85,19 +85,28 @@ func accountFlash(r *http.Request) string {
|
||||
}
|
||||
}
|
||||
|
||||
// submitAccount applies a username and/or password change. The current password
|
||||
// is always required, so a stolen session alone cannot lock the administrator
|
||||
// out of their own panel, and the attempt is throttled on the same limiter as
|
||||
// the login form so this route cannot be used to brute-force the password past
|
||||
// that limit (security.md).
|
||||
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.auth.AllowLoginAttempt(r) {
|
||||
p, _ := h.principal(r)
|
||||
h.renderAccount(w, r, http.StatusTooManyRequests,
|
||||
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "")
|
||||
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "")
|
||||
p, _ := h.principal(r)
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||
return
|
||||
}
|
||||
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user, err := h.store.GetUser(p.ID)
|
||||
if err != nil {
|
||||
logf("panel: account: get user failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,73 +115,76 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.PostFormValue("new_password")
|
||||
confirm := r.PostFormValue("new_password_confirm")
|
||||
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||
|
||||
admin, err := h.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: account: get admin failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
if !p.IsGlobal() {
|
||||
dmarcEmail = user.DMARCReportEmail
|
||||
}
|
||||
if username == "" {
|
||||
username = admin.Username
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
|
||||
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail)
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
|
||||
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
|
||||
renaming := username != admin.Username
|
||||
renaming := username != user.Username
|
||||
if renaming {
|
||||
if err := validate.Username(username); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := validate.Email(dmarcEmail); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
return
|
||||
if p.IsGlobal() {
|
||||
if err := validate.Email(dmarcEmail); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
emailChanging := dmarcEmail != admin.DMARCReportEmail
|
||||
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
|
||||
|
||||
repassword := password != "" || confirm != ""
|
||||
if repassword {
|
||||
if password != confirm {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail)
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
}
|
||||
if !renaming && !repassword && !emailChanging {
|
||||
h.renderAccount(w, r, http.StatusBadRequest,
|
||||
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail)
|
||||
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
|
||||
hash := admin.PasswordHash
|
||||
hash := user.PasswordHash
|
||||
if repassword {
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: account: hashing password failed: %v", err)
|
||||
h.renderAccount(w, r, http.StatusInternalServerError,
|
||||
"Internal error. Please try again.", username, dmarcEmail)
|
||||
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil {
|
||||
logf("panel: account: update admin failed: %v", err)
|
||||
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
|
||||
logf("panel: account: update user 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."
|
||||
if errors.Is(err, store.ErrUserNotFound) {
|
||||
msg = "There is no user account to update."
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail)
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
msg = "That username is already in use."
|
||||
h.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,8 +197,8 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging)
|
||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||
logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
|
||||
http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func updatedFlag(renamed, repassword, emailChanged bool) string {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
@@ -104,13 +103,12 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with. Cached by the checker, so re-rendering the page
|
||||
// after a form post costs nothing.
|
||||
admin, err := h.store.GetAdmin()
|
||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
logf("panel: domain %d: get admin: %v", d.ID, err)
|
||||
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profileEmail := admin.DMARCReportEmail
|
||||
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
@@ -134,52 +132,45 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
dmarcSource = "settings"
|
||||
}
|
||||
|
||||
h.view.Render(w, status, "domain_detail", map[string]any{
|
||||
"Title": "SelfPost — " + d.Name,
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
"Record": record,
|
||||
"DNS": dns,
|
||||
// SPF and DMARC are the operator's to write — SelfPost cannot generate
|
||||
// them the way it generates the DKIM record — so the page shows what
|
||||
// this server expects rather than leaving it to the documentation. The
|
||||
// same builders phrase the suggestions in the check messages, so the
|
||||
// page and the checks below it never recommend different records.
|
||||
"SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs),
|
||||
"DMARCName": dnscheck.DMARCRecordName(d.Name),
|
||||
"DMARCExample": dnscheck.DMARCExample(reportEmail),
|
||||
"DMARCSource": dmarcSource,
|
||||
"ProfileDMARCEmail": profileEmail,
|
||||
"ResolvedDMARCEmail": reportEmail,
|
||||
"DMARCRuaMode": dmarcMode,
|
||||
"DMARCRuaCustom": dmarcCustom,
|
||||
"ReportAuthName": reportAuthName,
|
||||
"ReportAuthValue": reportAuthValue,
|
||||
"NeedsReportAuth": needsReportAuth,
|
||||
"SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name),
|
||||
// Client connection settings (the same for every domain on this
|
||||
// instance): the hostname clients connect to, and whether the optional
|
||||
// submission listener is enabled in this deployment.
|
||||
"Hostname": h.cfg.Hostname,
|
||||
"SubmissionEnabled": h.cfg.SubmissionEnabled,
|
||||
"Apps": appViews,
|
||||
"Error": view.FormErr,
|
||||
"FormLogin": view.FormLogin,
|
||||
"FormMode": view.FormMode,
|
||||
"FormAddrs": view.FormAddrs,
|
||||
"NewCred": view.NewCred,
|
||||
"Flash": detailFlash(r),
|
||||
"Wildcard": store.AddressModeWildcard,
|
||||
"List": store.AddressModeList,
|
||||
"RateLimitErr": view.RateLimitErr,
|
||||
"ExportErr": view.ExportErr,
|
||||
"MinPwLen": validate.MinSecretFilePasswordLen,
|
||||
"DomainHasRL": domainRLok && domainRL.Active(),
|
||||
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
|
||||
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
|
||||
"DomainRLWin": windowOrDefault(domainRL.WindowSeconds),
|
||||
})
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — " + d.Name
|
||||
data["Active"] = "domains"
|
||||
data["Domain"] = d
|
||||
data["Record"] = record
|
||||
data["DNS"] = dns
|
||||
data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs)
|
||||
data["DMARCName"] = dnscheck.DMARCRecordName(d.Name)
|
||||
data["DMARCExample"] = dnscheck.DMARCExample(reportEmail)
|
||||
data["DMARCSource"] = dmarcSource
|
||||
data["ProfileDMARCEmail"] = profileEmail
|
||||
data["ResolvedDMARCEmail"] = reportEmail
|
||||
data["DMARCRuaMode"] = dmarcMode
|
||||
data["DMARCRuaCustom"] = dmarcCustom
|
||||
data["ReportAuthName"] = reportAuthName
|
||||
data["ReportAuthValue"] = reportAuthValue
|
||||
data["NeedsReportAuth"] = needsReportAuth
|
||||
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name)
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
|
||||
data["Apps"] = appViews
|
||||
data["Error"] = view.FormErr
|
||||
data["FormLogin"] = view.FormLogin
|
||||
data["FormMode"] = view.FormMode
|
||||
data["FormAddrs"] = view.FormAddrs
|
||||
data["NewCred"] = view.NewCred
|
||||
data["Flash"] = detailFlash(r)
|
||||
data["Wildcard"] = store.AddressModeWildcard
|
||||
data["List"] = store.AddressModeList
|
||||
data["RateLimitErr"] = view.RateLimitErr
|
||||
data["ExportErr"] = view.ExportErr
|
||||
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||
data["DomainHasRL"] = domainRLok && domainRL.Active()
|
||||
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
|
||||
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
|
||||
data["DomainRLMaxNum"] = domainRL.MaxMessages
|
||||
data["L1Messages"] = h.l1Messages()
|
||||
data["L1Window"] = h.l1Window()
|
||||
h.view.Render(w, status, "domain_detail", data)
|
||||
}
|
||||
|
||||
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
|
||||
@@ -213,13 +204,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
admin, err := h.store.GetAdmin()
|
||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
logf("panel: domain %d: get admin: %v", d.ID, err)
|
||||
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.domainDNS(d, record, admin.DMARCReportEmail, true)
|
||||
h.domainDNS(d, record, profileEmail, true)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -387,6 +378,11 @@ func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (st
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.Application{}, false
|
||||
}
|
||||
p, ok := h.principal(r)
|
||||
if !ok || !p.CanAccessApp(a) {
|
||||
http.NotFound(w, r)
|
||||
return store.Application{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
@@ -28,6 +27,9 @@ const maxImportBytes = 1 << 20 // 1 MiB
|
||||
// and the domain import are separate actions with different risk, so each gets
|
||||
// its own card here rather than sharing a block on the domain list.
|
||||
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.renderBackupPage(w, r, http.StatusOK, "")
|
||||
}
|
||||
|
||||
@@ -42,14 +44,13 @@ func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, stat
|
||||
// password), importErr to the import card, so neither message appears under the
|
||||
// wrong form.
|
||||
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
|
||||
h.view.Render(w, status, "backup", map[string]any{
|
||||
"Title": "SelfPost — backup",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "backup",
|
||||
"ImportErr": importErr,
|
||||
"BackupErr": backupErr,
|
||||
"MinPwLen": validate.MinSecretFilePasswordLen,
|
||||
})
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — backup & migration"
|
||||
data["Active"] = "backup"
|
||||
data["ImportErr"] = importErr
|
||||
data["BackupErr"] = backupErr
|
||||
data["MinPwLen"] = validate.MinSecretFilePasswordLen
|
||||
h.view.Render(w, status, "backup", data)
|
||||
}
|
||||
|
||||
// HandleBackup streams a full-server backup as a download (architecture.md §
|
||||
@@ -61,6 +62,9 @@ func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request,
|
||||
// way out, so the file that lands on their disk — wherever it is copied
|
||||
// afterwards — is useless without the password.
|
||||
func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
password, pwErr := secretFilePassword(r)
|
||||
if pwErr != "" {
|
||||
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||
@@ -184,6 +188,9 @@ func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
// re-renders the backup page, where the import form lives, with a friendly
|
||||
// message.
|
||||
func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
||||
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
|
||||
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
|
||||
|
||||
@@ -29,27 +29,31 @@ func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||||
domains, err := h.domains.List()
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
domains, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
logf("panel: dashboard: list domains: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.view.Render(w, status, "dashboard", map[string]any{
|
||||
"Title": "SelfPost",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domains": h.domainRows(domains),
|
||||
"Error": formErr,
|
||||
"FormName": formName,
|
||||
"Flash": dashboardFlash(r),
|
||||
})
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — domains"
|
||||
data["Active"] = "domains"
|
||||
data["Domains"] = h.domainRows(domains)
|
||||
data["Error"] = formErr
|
||||
data["FormName"] = formName
|
||||
data["Flash"] = dashboardFlash(r)
|
||||
h.view.Render(w, status, "dashboard", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||
profileEmail := ""
|
||||
if admin, err := h.store.GetAdmin(); err == nil {
|
||||
profileEmail = admin.DMARCReportEmail
|
||||
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
|
||||
profileEmail = email
|
||||
}
|
||||
rows := make([]domainRow, len(domains))
|
||||
var wg sync.WaitGroup
|
||||
@@ -82,6 +86,9 @@ func dashboardFlash(r *http.Request) string {
|
||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||
// publish is shown (product.md).
|
||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||
return
|
||||
@@ -109,6 +116,9 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -118,11 +128,15 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
"IsGlobal": true,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
||||
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parseDomainID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -145,6 +159,9 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
|
||||
// sender map on demand (architecture.md § Panel HTTP surface).
|
||||
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.domains.Resync(); err != nil {
|
||||
logf("panel: manual reload (opendkim): %v", err)
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
@@ -173,6 +190,11 @@ func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.D
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.Domain{}, false
|
||||
}
|
||||
p, ok := h.principal(r)
|
||||
if !ok || !p.CanAccessDomain(d.ID) {
|
||||
http.NotFound(w, r)
|
||||
return store.Domain{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -38,7 +39,9 @@ func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
data["Title"] = "SelfPost — deliveries"
|
||||
data["User"] = auth.CurrentUser(r)
|
||||
for k, v := range h.pageBase(r) {
|
||||
data[k] = v
|
||||
}
|
||||
data["Active"] = "deliveries"
|
||||
h.view.Render(w, http.StatusOK, "deliveries", data)
|
||||
}
|
||||
@@ -85,12 +88,29 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !p.IsGlobal() {
|
||||
allowed, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !domainNameSet(allowed)[row.Domain] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||
logRows, logNote := h.deliveryLog(row)
|
||||
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "deliveries",
|
||||
"IsGlobal": p.IsGlobal(),
|
||||
"Row": row,
|
||||
// The status in the panel's own badge vocabulary, so the headline reads
|
||||
// the same way as every other health signal in the panel.
|
||||
@@ -125,6 +145,17 @@ func deliveryLevel(status string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// sendLogRow is a row of the send log as the table draws it: the stored row
|
||||
// plus the badge level its status maps onto. The level is carried rather than
|
||||
// derived in the template because deliveryLevel is the one place that decides
|
||||
// what a status means — the delivery page already reads it, and a second
|
||||
// mapping written in the template or the stylesheet would be free to drift
|
||||
// from it.
|
||||
type sendLogRow struct {
|
||||
store.SendLogRow
|
||||
Level string // ok / warn / error / unknown, as deliveryLevel returns
|
||||
}
|
||||
|
||||
// deliveryEvent is one step of a message's history, as the timeline on the
|
||||
// delivery page draws it. At is zero for the step that has not happened yet —
|
||||
// the delivery report a queued message is still waiting for.
|
||||
@@ -269,11 +300,31 @@ func deliveriesBackURL(r *http.Request) string {
|
||||
// string, queries the store, and assembles everything the template needs
|
||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
return nil, errors.New("no principal")
|
||||
}
|
||||
q := r.URL.Query()
|
||||
filter := store.SendLogFilter{
|
||||
Domain: q.Get("domain"),
|
||||
AppLogin: q.Get("app"),
|
||||
}
|
||||
|
||||
assigned, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowedNames := domainNameSet(assigned)
|
||||
|
||||
if !p.IsGlobal() {
|
||||
if filter.Domain != "" && !allowedNames[filter.Domain] {
|
||||
filter.Domain = ""
|
||||
}
|
||||
if filter.Domain == "" && len(assigned) == 1 {
|
||||
filter.Domain = assigned[0].Name
|
||||
}
|
||||
}
|
||||
|
||||
page := parsePage(q.Get("p"))
|
||||
|
||||
total, err := h.store.CountSendLog(filter)
|
||||
@@ -284,23 +335,35 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Decode on the way out as well as on the way in: rows the journal-milter
|
||||
// wrote before it decoded subjects itself still hold the raw header, and
|
||||
// they are the ones an operator is most likely to be looking at.
|
||||
view := make([]sendLogRow, len(rows))
|
||||
for i := range rows {
|
||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
|
||||
}
|
||||
domains, err := h.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
domainNames := make([]string, 0, len(assigned))
|
||||
for _, d := range assigned {
|
||||
domainNames = append(domainNames, d.Name)
|
||||
}
|
||||
domainNames := make([]string, len(domains))
|
||||
for i, d := range domains {
|
||||
domainNames[i] = d.Name
|
||||
|
||||
loginSet := make(map[string]bool)
|
||||
for _, d := range assigned {
|
||||
apps, err := h.store.ListApplicationsByDomain(d.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range apps {
|
||||
loginSet[a.Login] = true
|
||||
}
|
||||
}
|
||||
logins, err := h.store.ListApplicationLogins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
logins := make([]string, 0, len(loginSet))
|
||||
for login := range loginSet {
|
||||
logins = append(logins, login)
|
||||
}
|
||||
sort.Strings(logins)
|
||||
|
||||
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
||||
filter.AppLogin = ""
|
||||
}
|
||||
|
||||
lastPage := 1
|
||||
@@ -308,7 +371,7 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
||||
}
|
||||
return map[string]any{
|
||||
"Rows": rows,
|
||||
"Rows": view,
|
||||
"FilterDomains": domainNames,
|
||||
"FilterApps": logins,
|
||||
"FilterDomain": filter.Domain,
|
||||
@@ -335,18 +398,25 @@ func parsePage(v string) int {
|
||||
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
|
||||
// surface).
|
||||
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
out, errText := readQueue()
|
||||
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
|
||||
"Title": "SelfPost — mail queue",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "mail_queue",
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
"Title": "SelfPost — mail queue",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "mail_queue",
|
||||
"IsGlobal": true,
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
|
||||
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
out, errText := readQueue()
|
||||
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
|
||||
"Output": out,
|
||||
@@ -369,18 +439,25 @@ func readQueue() (string, string) {
|
||||
// HandleSystemLog renders the System log page over mail.log (architecture.md §
|
||||
// Panel HTTP surface).
|
||||
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
lines, errText := h.readLogTail()
|
||||
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
|
||||
"Title": "SelfPost — system log",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "system_log",
|
||||
"Lines": lines,
|
||||
"Error": errText,
|
||||
"Title": "SelfPost — system log",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "system_log",
|
||||
"IsGlobal": true,
|
||||
"Lines": lines,
|
||||
"Error": errText,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
|
||||
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
lines, errText := h.readLogTail()
|
||||
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
|
||||
"Lines": lines,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// After log rotation renames mail.log away, Postfix takes about a second to
|
||||
@@ -252,6 +253,11 @@ func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req = auth.RequestWithPrincipal(req, auth.Principal{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
Role: auth.RoleGlobal,
|
||||
})
|
||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
||||
req.SetPathValue("id", rest)
|
||||
}
|
||||
|
||||
@@ -19,24 +19,73 @@ type rateLimitInput struct {
|
||||
windowSeconds int
|
||||
}
|
||||
|
||||
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
|
||||
func (h *Handlers) l1Messages() int {
|
||||
if h.cfg.RateLimitMessagesPerIP > 0 {
|
||||
return h.cfg.RateLimitMessagesPerIP
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
func (h *Handlers) l1Window() int {
|
||||
if h.cfg.RateLimitWindowSeconds > 0 {
|
||||
return h.cfg.RateLimitWindowSeconds
|
||||
}
|
||||
return defaultRateLimitWindowSeconds
|
||||
}
|
||||
|
||||
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||
}
|
||||
if r.PostFormValue("clear") != "" {
|
||||
return rateLimitInput{clear: true}, 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")
|
||||
}
|
||||
if maxMessages > l1Max {
|
||||
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||
}
|
||||
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{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||||
}
|
||||
|
||||
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||||
}
|
||||
if r.PostFormValue("clear") != "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||||
if rawMax == "" {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
}
|
||||
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
|
||||
if err != nil {
|
||||
return rateLimitInput{}, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return rateLimitInput{clear: true}, nil
|
||||
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
|
||||
}
|
||||
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
||||
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||||
if err != nil || maxMessages <= 0 {
|
||||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||||
}
|
||||
if maxMessages > l1Max {
|
||||
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||||
}
|
||||
if domainActive && maxMessages <= domainMax {
|
||||
return rateLimitInput{}, fmt.Errorf("application override must be greater than the domain limit (%d)", domainMax)
|
||||
}
|
||||
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")
|
||||
@@ -77,7 +126,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, err := parseRateLimitForm(r)
|
||||
in, err := parseDomainRateLimitForm(r, h.l1Messages())
|
||||
if err != nil {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
@@ -103,7 +152,14 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
in, err := parseRateLimitForm(r)
|
||||
domainRL, domainOK, err := h.domains.RateLimit(d.ID)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: rate limit: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
domainActive := domainOK && domainRL.Active()
|
||||
in, err := parseAppRateLimitForm(r, h.l1Messages(), domainRL.MaxMessages, domainActive)
|
||||
if err != nil {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||||
FormMode: store.AddressModeWildcard,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDomainRateLimitForm(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{
|
||||
"max_messages": {"50"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100)
|
||||
if err != nil || in.clear || in.maxMessages != 50 || in.windowSeconds != 3600 || len(in.ips) != 0 {
|
||||
t.Fatalf("valid domain = %+v err=%v", in, err)
|
||||
}
|
||||
|
||||
in, err = parseDomainRateLimitForm(form(url.Values{"max_messages": {""}}), 100)
|
||||
if err != nil || !in.clear {
|
||||
t.Fatalf("empty max should clear: %+v err=%v", in, err)
|
||||
}
|
||||
|
||||
_, err = parseDomainRateLimitForm(form(url.Values{
|
||||
"max_messages": {"150"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100)
|
||||
if err == nil || !strings.Contains(err.Error(), "level-1") {
|
||||
t.Fatalf("over L1 want error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppRateLimitForm(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 := parseAppRateLimitForm(form(url.Values{
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"80"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100, 40, true)
|
||||
if err != nil || in.maxMessages != 80 || len(in.ips) != 1 {
|
||||
t.Fatalf("valid app override = %+v err=%v", in, err)
|
||||
}
|
||||
|
||||
_, err = parseAppRateLimitForm(form(url.Values{
|
||||
"max_messages": {"80"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100, 40, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "trusted client IP") {
|
||||
t.Fatalf("missing IPs want error, got %v", err)
|
||||
}
|
||||
|
||||
_, err = parseAppRateLimitForm(form(url.Values{
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"40"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100, 40, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "greater than the domain") {
|
||||
t.Fatalf("app <= domain want error, got %v", err)
|
||||
}
|
||||
|
||||
_, err = parseAppRateLimitForm(form(url.Values{
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"150"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100, 0, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "level-1") {
|
||||
t.Fatalf("over L1 want error, got %v", err)
|
||||
}
|
||||
|
||||
// No domain limit: any app ceiling ≤ L1 is fine.
|
||||
in, err = parseAppRateLimitForm(form(url.Values{
|
||||
"allowed_ips": {"203.0.113.10"},
|
||||
"max_messages": {"50"},
|
||||
"window_seconds": {"3600"},
|
||||
}), 100, 0, false)
|
||||
if err != nil || in.maxMessages != 50 {
|
||||
t.Fatalf("app without domain = %+v err=%v", in, err)
|
||||
}
|
||||
}
|
||||
@@ -9,23 +9,29 @@ import (
|
||||
)
|
||||
|
||||
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
data := h.statusBody()
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
|
||||
data["Title"] = "SelfPost — status"
|
||||
data["User"] = auth.CurrentUser(r)
|
||||
data["Active"] = "status"
|
||||
data["IsGlobal"] = true
|
||||
data["Flash"] = statusFlash(r)
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["PTR"] = srv.PTR
|
||||
h.view.Render(w, http.StatusOK, "status", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.dns.Server(h.cfg.Hostname, true)
|
||||
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
@@ -58,6 +64,7 @@ func (h *Handlers) statusBody() map[string]any {
|
||||
}
|
||||
|
||||
machine := h.machine.Sample()
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
|
||||
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
|
||||
return map[string]any{
|
||||
@@ -71,6 +78,8 @@ func (h *Handlers) statusBody() map[string]any {
|
||||
"Cert": cert,
|
||||
"Sockets": sockets,
|
||||
"SocketStatus": socketStatus,
|
||||
"Hostname": h.cfg.Hostname,
|
||||
"PTR": srv.PTR,
|
||||
"OverallStatus": overall,
|
||||
"OverallHeading": overallHeading(overall),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type userFormView struct {
|
||||
FormErr string
|
||||
FormUsername string
|
||||
FormRole string
|
||||
FormDomains map[int64]bool
|
||||
FormPassword string
|
||||
}
|
||||
|
||||
// HandleUsers lists panel users (global only).
|
||||
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.store.ListUserRows()
|
||||
if err != nil {
|
||||
logf("panel: list users: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — users"
|
||||
data["Active"] = "users"
|
||||
data["Users"] = rows
|
||||
data["Flash"] = usersFlash(r)
|
||||
h.view.Render(w, http.StatusOK, "users", data)
|
||||
}
|
||||
|
||||
func usersFlash(r *http.Request) string {
|
||||
switch r.URL.Query().Get("done") {
|
||||
case "created":
|
||||
return "User created."
|
||||
case "updated":
|
||||
return "User updated."
|
||||
case "deleted":
|
||||
return "User deleted."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserNew creates a panel user (global only).
|
||||
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
|
||||
case http.MethodPost:
|
||||
h.submitUserCreate(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserEdit edits or deletes a panel user (global only).
|
||||
func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
uid, ok := parseUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u, err := h.store.GetUser(uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUserNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: get user %d: %v", uid, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
selected := make(map[int64]bool, len(u.DomainIDs))
|
||||
for _, id := range u.DomainIDs {
|
||||
selected[id] = true
|
||||
}
|
||||
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
|
||||
FormUsername: u.Username,
|
||||
FormRole: string(u.Role),
|
||||
FormDomains: selected,
|
||||
})
|
||||
case http.MethodPost:
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
if r.PostFormValue("action") == "delete" {
|
||||
h.submitUserDelete(w, r, u)
|
||||
return
|
||||
}
|
||||
h.submitUserUpdate(w, r, u)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, view userFormView) {
|
||||
domains, err := h.store.ListDomains()
|
||||
if err != nil {
|
||||
logf("panel: user form: list domains: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
if userID != 0 {
|
||||
data["Title"] = "SelfPost — edit user"
|
||||
} else {
|
||||
data["Title"] = "SelfPost — create user"
|
||||
}
|
||||
data["Active"] = "users"
|
||||
data["UserID"] = userID
|
||||
data["Domains"] = domains
|
||||
data["Error"] = view.FormErr
|
||||
data["FormUsername"] = view.FormUsername
|
||||
data["FormRole"] = view.FormRole
|
||||
data["GlobalRole"] = store.RoleGlobal
|
||||
data["FormDomains"] = view.FormDomains
|
||||
data["FormPassword"] = view.FormPassword
|
||||
data["IsEdit"] = userID != 0
|
||||
data["LastGlobalLocked"] = lastGlobalLocked(h, userID, view.FormRole)
|
||||
h.view.Render(w, status, "user_form", data)
|
||||
}
|
||||
|
||||
func lastGlobalLocked(h *Handlers, userID int64, formRole string) bool {
|
||||
if userID == 0 || formRole != string(store.RoleGlobal) {
|
||||
return false
|
||||
}
|
||||
n, err := h.store.CountGlobalUsers()
|
||||
return err == nil && n <= 1
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
role := store.Role(r.PostFormValue("role"))
|
||||
domainIDs := parseDomainIDs(r)
|
||||
|
||||
if err := validate.Username(username); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: create user hash: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
|
||||
return
|
||||
}
|
||||
if _, err := h.store.CreateUser(username, string(hash), role, domainIDs); err != nil {
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
logf("panel: create user: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/users?done=created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
role := store.Role(r.PostFormValue("role"))
|
||||
domainIDs := parseDomainIDs(r)
|
||||
selected := domainIDSetFromForm(r)
|
||||
|
||||
if username == "" {
|
||||
username = u.Username
|
||||
}
|
||||
if err := validate.Username(username); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
|
||||
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||
n, err := h.store.CountGlobalUsers()
|
||||
if err != nil || n <= 1 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot demote the last global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if u.ID == p.ID && u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||
n, err := h.store.CountGlobalUsers()
|
||||
if err != nil || n <= 1 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot demote yourself without another global administrator.", FormUsername: username, FormRole: string(u.Role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash := u.PasswordHash
|
||||
if password != "" {
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: update user hash: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
logf("panel: update user: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
|
||||
if role != u.Role {
|
||||
if err := h.store.SetUserRole(u.ID, role); err != nil {
|
||||
logf("panel: set user role: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not update role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role == store.RoleGlobal {
|
||||
if err := h.store.ClearUserDomains(u.ID); err != nil {
|
||||
logf("panel: clear user domains: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if role == store.RoleDomainAdmin {
|
||||
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
|
||||
logf("panel: set user domains: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if u.ID == p.ID {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteUser(u.ID); err != nil {
|
||||
if errors.Is(err, store.ErrLastGlobal) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
logf("panel: delete user %d: %v", u.ID, err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseDomainIDs(r *http.Request) []int64 {
|
||||
var ids []int64
|
||||
for _, v := range r.PostForm["domain_ids"] {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func domainIDSetFromForm(r *http.Request) map[int64]bool {
|
||||
m := make(map[int64]bool)
|
||||
for _, id := range parseDomainIDs(r) {
|
||||
m[id] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+347
-129
@@ -2,7 +2,36 @@
|
||||
layout so the panel's Content-Security-Policy can be a plain
|
||||
"default-src 'self'" with no inline-style exemption. Any rule
|
||||
added here must therefore stay here: an inline style="..." attribute in a
|
||||
template is blocked by that policy and silently does nothing. */
|
||||
template is blocked by that policy and silently does nothing.
|
||||
|
||||
The palette and the type below come from the mark's own approval sheet
|
||||
(docs/assets/selfpost-proof.html) — brick, warm paper, IBM Plex. Before it
|
||||
the panel was a default blue-on-cool-grey admin theme with the stamp pasted
|
||||
onto it, and the two read as different pieces of work. */
|
||||
|
||||
/* The fonts ship with the panel rather than being asked for by name. Two
|
||||
reasons, and the second is the one that matters. A named family resolves to
|
||||
whatever the operator's machine has, so the mark — Plex converted to
|
||||
outlines — would be the only Plex on the page. And ui-monospace is Consolas,
|
||||
SF Mono or DejaVu Sans Mono depending on that machine, each with its own
|
||||
advance width: the six-column send log and the mail.log tables would lay out
|
||||
differently for every operator. Served from the panel's own origin, so the
|
||||
policy above already covers them and needs no font-src of its own.
|
||||
One variable file for the sans (100–700 in 46 KB, less than the four static
|
||||
weights it replaces); mono has no variable release, so it is two files.
|
||||
swap, so a cold load shows the system stack for a frame instead of nothing. */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans"; font-style: normal; font-weight: 100 700;
|
||||
font-display: swap; src: url("/static/ibm-plex-sans.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400;
|
||||
font-display: swap; src: url("/static/ibm-plex-mono-400.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600;
|
||||
font-display: swap; src: url("/static/ibm-plex-mono-600.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* Colour tokens. Light values live on :root; the dark media query below
|
||||
reassigns the same names rather than re-declaring every rule that uses
|
||||
@@ -10,39 +39,57 @@
|
||||
property already carries the right value for the active scheme. */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9; --fg: #1b1f24;
|
||||
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--bg: #f4f2ed; --fg: #12161c;
|
||||
--muted: #6b7684; /* secondary text: .muted, th, timestamps, footers */
|
||||
--card-bg: #fff;
|
||||
--border: #e2e5e9; /* dividers: card, table, app, nav, code, encrypt-fields */
|
||||
--control-border: #cfd4da; /* input, select, textarea, action buttons */
|
||||
--border: #dedcd7; /* dividers: card, table, app, nav, code, encrypt-fields */
|
||||
--control-border: #cbc8c1; /* input, select, textarea, action buttons */
|
||||
--input-bg: #fff;
|
||||
--code-bg: #f0f2f4;
|
||||
--surface-bg: #eef1f5; --surface-bg-hover: #e2e7ee; --surface-open-bg: #dde3ec;
|
||||
--nav-active-bg: #e6ebf5;
|
||||
--flash-bg: #ecfdf3; --flash-border: #abefc6; --flash-fg: #067647;
|
||||
--credential-bg: #fffbeb; --credential-border: #f5c518;
|
||||
--danger-bg: #fef3f2; --danger-border: #fecdca; --danger-fg: #b42318; --danger-bg-hover: #fee4e2;
|
||||
--st-ok-bg: #ecfdf3; --st-ok-fg: #067647; --st-ok-border: #abefc6;
|
||||
--st-warn-bg: #fffaeb; --st-warn-fg: #b54708; --st-warn-border: #fedf89;
|
||||
--st-error-bg: #fef3f2; --st-error-fg: #b42318; --st-error-border: #fecdca;
|
||||
--st-unknown-bg: #f0f2f4; --st-unknown-fg: #6b7280; --st-unknown-border: #e2e5e9;
|
||||
--code-bg: #efede9;
|
||||
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
|
||||
--nav-active-bg: #ede4de;
|
||||
/* Brick, the mark's own colour, is the panel's accent — but as two values,
|
||||
not one. A filled control carries --accent-fill with white on it; a link or
|
||||
an active entry carries --accent-text, because the fill on a page
|
||||
background is a colour to sit behind text, not to be text. In the light
|
||||
scheme the two coincide. */
|
||||
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
|
||||
--on-accent: #fff;
|
||||
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
|
||||
--credential-bg: #fcf6e4; --credential-border: #e0c874;
|
||||
--danger-fill: #b42318; --danger-fill-hover: #912018;
|
||||
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
|
||||
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
|
||||
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
|
||||
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
|
||||
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14171a; --fg: #e6e8eb;
|
||||
--card-bg: #1d2125;
|
||||
--border: #2b3138;
|
||||
--control-border: #2b3138;
|
||||
--input-bg: #14171a;
|
||||
--code-bg: #14171a;
|
||||
--surface-bg: #22262b; --surface-bg-hover: #2b3138; --surface-open-bg: #313841;
|
||||
--nav-active-bg: #22303f;
|
||||
--flash-bg: #0d2818; --flash-border: #1a5336; --flash-fg: #75d99b;
|
||||
--credential-bg: #2a2408; --credential-border: #6b5a10;
|
||||
--danger-bg: #2d1211; --danger-border: #6b201a; --danger-fg: #f5a29b; --danger-bg-hover: #3d1a18;
|
||||
--st-ok-bg: #0d2818; --st-ok-fg: #75d99b; --st-ok-border: #1a5336;
|
||||
--st-warn-bg: #2e2308; --st-warn-fg: #f5c86b; --st-warn-border: #6b5210;
|
||||
--st-error-bg: #2d1211; --st-error-fg: #f5a29b; --st-error-border: #6b201a;
|
||||
--st-unknown-bg: #22262b; --st-unknown-fg: #9aa3ad; --st-unknown-border: #2b3138;
|
||||
--bg: #16181b; --fg: #e9e6e0;
|
||||
--muted: #9aa1a9;
|
||||
--card-bg: #1d2024;
|
||||
--border: #2c2f34;
|
||||
--control-border: #3a3e44;
|
||||
--input-bg: #14161a;
|
||||
--code-bg: #14161a;
|
||||
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||
--nav-active-bg: #2a1f1b;
|
||||
/* Brick as drawn is about 2:1 against this background — a link nobody can
|
||||
read. The fill lightens just enough to keep white on it above 4.5:1
|
||||
(6.9:1 as set), and the text value lightens further to clear the same bar
|
||||
as text on the page. This is where the two values earn their keep. */
|
||||
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
@@ -54,7 +101,7 @@
|
||||
read as the layout jumping. */
|
||||
html { scrollbar-gutter: stable; }
|
||||
body {
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
font: 400 15px/1.5 var(--font-sans);
|
||||
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
|
||||
}
|
||||
/* The two columns — navigation on the left, page on the right — centred as a
|
||||
@@ -93,7 +140,11 @@ main.wide > * { max-width: none; }
|
||||
/* Longhands, not the margin shorthand: the shorthand would re-zero the auto
|
||||
side margins above and pin the heading to the column's edge, a measure's
|
||||
worth to the left of the card under it. */
|
||||
h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; }
|
||||
/* Light rather than bold, which is the sheet's own setting for the name and
|
||||
the one thing a variable font makes free. It is the page's title, not an
|
||||
alarm: the size already says which line is the heading, and at 700 it shouted
|
||||
over the card under it. */
|
||||
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin-top: 0; margin-bottom: 1rem; }
|
||||
/* The full mark, on the two pages that have no navigation bar to carry the
|
||||
compact one. It takes the column's width so its edges line up with the card
|
||||
below it, capped at that column's own 24rem; height stays automatic because
|
||||
@@ -101,16 +152,50 @@ h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; }
|
||||
The subtitle stops resolving below 280px, which a 320px viewport still clears
|
||||
once the body's padding is taken off. */
|
||||
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
|
||||
/* The radius comes down from 10px across the panel: the mark is a stamp with a
|
||||
square edge and a hairline, and a card rounded like a phone widget sat in
|
||||
front of it as a different kind of object. 6px on the containers, 5px on the
|
||||
controls inside them. */
|
||||
.card {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 1.5rem; margin: 0 auto;
|
||||
}
|
||||
.card.narrow { max-width: 24rem; }
|
||||
/* A narrow card as a direct child of main means the page is a short form:
|
||||
every sibling in the column (heading, flash, footer) must share that 24rem
|
||||
measure, or they centre on 48rem while the card centres on 24rem and the
|
||||
left edges drift by half the difference (12rem / 192px at a 16px root —
|
||||
measured). The column itself stays 64rem so the navigation does not move;
|
||||
only the stack inside the column narrows. :has tracks the card rather than
|
||||
a per-page class, so a new signed-in page that adds .card.narrow inherits
|
||||
the alignment without a second stylesheet rule.
|
||||
login/setup already shrink main to 24rem (below); this rule is then a no-op
|
||||
relative to the parent. Do not replace that with :has alone: narrowing main
|
||||
is what centres the signed-out block on the page when there is no nav. */
|
||||
main:has(> .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 var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
|
||||
/* Checkbox rows (domain assignment, encrypt toggle) opt out of the block
|
||||
label and full-width input rules above: the box and its caption sit on one
|
||||
line. Without this the caption drops under a stretched checkbox. */
|
||||
label.check {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
label.check input { width: auto; margin: 0; padding: 0; border: 0; border-radius: 0; background: none; }
|
||||
input {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
|
||||
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||
}
|
||||
/* Domain assignment on the user form: a bordered group under Role, not another
|
||||
stacked field label. Legend replaces the usual label; the muted line is the
|
||||
hint that used to sit where a field caption would. */
|
||||
fieldset {
|
||||
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
|
||||
border: 1px solid var(--control-border); border-radius: 5px;
|
||||
}
|
||||
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
|
||||
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
|
||||
fieldset > label.check:first-of-type { margin-top: 0.15rem; }
|
||||
/* 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).
|
||||
@@ -123,11 +208,25 @@ input {
|
||||
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;
|
||||
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
|
||||
}
|
||||
button:hover, a.btn:hover { background: #1d4ed8; }
|
||||
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
|
||||
.muted { color: #6b7280; }
|
||||
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
|
||||
button:disabled, button.danger:disabled {
|
||||
opacity: 0.45; cursor: not-allowed;
|
||||
}
|
||||
button:disabled:hover { background: var(--accent-fill); }
|
||||
button.danger:disabled:hover { background: var(--danger-fill); }
|
||||
select:disabled {
|
||||
opacity: 0.65; cursor: not-allowed; background: var(--code-bg);
|
||||
}
|
||||
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
|
||||
.muted { color: var(--muted); }
|
||||
/* Links take the accent. The panel never set a colour here and ran on the
|
||||
browser's own blue, which passed unnoticed while the accent was itself blue;
|
||||
against brick it left every domain name and back link belonging to a palette
|
||||
the panel no longer uses. The controls that are links dressed as buttons
|
||||
(a.btn, a.danger, a.st) all carry a class and out-specify this. */
|
||||
a { color: var(--accent-text); }
|
||||
.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
|
||||
@@ -138,10 +237,14 @@ form.inline { display: inline; margin: 0; }
|
||||
card floated in the middle while the mark and the heading stayed at the far
|
||||
left, three alignments on a page with four elements. Narrowing the column to
|
||||
the card's own width makes the three line up and puts the block as a whole in
|
||||
the middle of the page. */
|
||||
the middle of the page.
|
||||
By page name and not by "the page holds a narrow card": signed-in pages that
|
||||
hold a narrow card keep the 64rem column (see main:has(> .card.narrow)
|
||||
above) so the navigation stays put; only these two pages have no nav and
|
||||
need the column itself narrowed. */
|
||||
main.page-login, main.page-setup { max-width: 24rem; }
|
||||
.card + .card { margin-top: 1.2rem; }
|
||||
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
|
||||
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
/* A table column is at least as wide as the longest unbreakable run inside it,
|
||||
and the panel's tables are full of runs with nothing to break on: email
|
||||
@@ -149,11 +252,26 @@ table { width: 100%; border-collapse: collapse; }
|
||||
the send-log past its card and hang Status over the edge. Cells may break mid
|
||||
word, so a column can always be squeezed to the width available. */
|
||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
||||
/* The exception: a timestamp broken across two lines is unreadable, and it is
|
||||
short enough to never be the reason a row does not fit. */
|
||||
td.time { white-space: nowrap; }
|
||||
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
|
||||
td.actions { text-align: right; }
|
||||
/* The exceptions: a timestamp broken across two lines is unreadable, and so is
|
||||
a send-log status — "deferred" was being hyphenless-broken into "deferre" and
|
||||
"d" whenever the row was tight. Both are short enough to never be the reason
|
||||
a row does not fit. */
|
||||
td.time, td.status { white-space: nowrap; }
|
||||
/* Column headings are set in the mono face: they are labels for machine data
|
||||
rather than prose, and the wider tracking a small monospaced capital wants
|
||||
also holds them apart from the first row of values. Same treatment on
|
||||
.fact-label, so a heading of that size reads as one thing wherever it
|
||||
appears. */
|
||||
th {
|
||||
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
||||
}
|
||||
/* The controls at the end of a row are short and fixed — Details, Delete — and
|
||||
the cell is the last one, so it is the one the automatic layout squeezes
|
||||
first: in the send log it was breaking "Details" after "Detail". Holding it
|
||||
on one line costs the row nothing and hands the width back to the status
|
||||
column, which was wrapping single words for the same reason. */
|
||||
td.actions { text-align: right; white-space: nowrap; }
|
||||
/* Subject is the one cell whose text we do not control. Breaking mid word (the
|
||||
rule above) keeps it inside the card, but a long subject would do it by
|
||||
growing the row several lines tall, which buries the rows around it. So the
|
||||
@@ -164,9 +282,11 @@ td.subject span {
|
||||
display: block; max-width: 18rem;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: var(--font-mono);
|
||||
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
|
||||
/* Semibold, not the browser's bold: a card's title sits a step under the page's
|
||||
own heading, and 700 against a 300 h1 read as the louder of the two. */
|
||||
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
|
||||
/* The back link is a block so it takes the measure and lines up with the
|
||||
heading above it and the card below: auto side margins centre a block, but
|
||||
they do nothing for an inline-block, which would have stayed at the column's
|
||||
@@ -176,7 +296,7 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
.back { display: 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; line-height: 1.45; }
|
||||
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); line-height: 1.45; }
|
||||
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.version a:hover { color: var(--fg); }
|
||||
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
|
||||
@@ -184,12 +304,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
|
||||
main.page-login .version, main.page-setup .version { text-align: center; }
|
||||
select, textarea {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
|
||||
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
button.danger, a.danger { background: #b42318; }
|
||||
button.danger:hover, a.danger:hover { background: #912018; }
|
||||
button.danger, a.danger { background: var(--danger-fill); }
|
||||
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
||||
/* The disclosure toggle is an action too, so it is drawn as a button (see the
|
||||
compact rule below); it carries no marker because the pressed background
|
||||
already shows the open state. */
|
||||
@@ -204,28 +324,24 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
|
||||
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
||||
.app:last-child { padding-bottom: 0; }
|
||||
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
||||
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
|
||||
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
|
||||
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
|
||||
/* Mode and rate limit open under the whole row rather than inside it. As
|
||||
<details> the panel sat where its summary was, so opening one cut the row of
|
||||
four controls in half and pushed the rest below a block of fields — the
|
||||
buttons moved every time a panel opened or closed. The panels are therefore
|
||||
the last children of the row, and the checkbox that opens each one is
|
||||
visually hidden earlier in the row with its label drawn as the button. The
|
||||
checkbox stays in the tab order and keeps its focus ring on the label, so it
|
||||
works from the keyboard, and being pure CSS it also works with JavaScript
|
||||
blocked, as <details> did. */
|
||||
controls in half and pushed the rest below a block of fields — the buttons
|
||||
moved every time a panel opened or closed. The panel is therefore the last
|
||||
child of the row, and the checkbox that opens it is visually hidden earlier
|
||||
in the row with its label drawn as the button. The checkbox stays in the tab
|
||||
order and keeps its focus ring on the label, so it works from the keyboard,
|
||||
and being pure CSS it also works with JavaScript blocked, as <details> did. */
|
||||
.app .actions > .panel-toggle {
|
||||
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||
}
|
||||
.app .actions > .panel { display: none; flex: 1 0 100%; }
|
||||
.app .actions > .t-mode:checked ~ .panel-mode,
|
||||
.app .actions > .t-limit:checked ~ .panel-limit { display: block; }
|
||||
.app .actions > .t-mode:checked ~ .for-mode,
|
||||
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
|
||||
.app .actions > .t-mode:focus-visible ~ .for-mode,
|
||||
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid #2563eb; outline-offset: 2px; }
|
||||
.app .actions > .t-edit:checked ~ .panel-edit { display: block; }
|
||||
.app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
|
||||
.app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
|
||||
.panel form { margin-top: 0.6rem; }
|
||||
/* A panel's own submit is a form button, not one of the controls in the row
|
||||
above, so it takes back the spacing the compact .actions rule zeroes out:
|
||||
@@ -242,6 +358,21 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
}
|
||||
.app .actions > .panel .panel-buttons button,
|
||||
.app .actions > .panel .panel-buttons form { margin-top: 0; }
|
||||
.app .actions > .panel .check-cols { margin-top: 0.6rem; }
|
||||
.app .actions > .panel .check-col > form { margin-top: 0; }
|
||||
.app .actions > .panel .check-col > .check-col-title { margin-top: 0; }
|
||||
.app .actions > .panel .check-col > .muted { margin: 0; }
|
||||
/* Address mode select ‖ trusted-IP field: same top offset and control height. */
|
||||
.app .actions > .panel .check-col > .muted + form > select:first-of-type,
|
||||
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
|
||||
margin-top: 0.45rem;
|
||||
min-height: 2.55rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
|
||||
field-sizing: fixed;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||
/* Panel navigation: rendered once from the layout, so it is present on every
|
||||
authenticated page without each content template having to include it. */
|
||||
@@ -250,11 +381,9 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
738px the panel was wide, so as a bar it had to be split over two rows — and
|
||||
even then it cost the top of every page. Standing it up removes that: the
|
||||
entries share one left edge to scan down, the session sits at the foot where
|
||||
it is out of the way, and there is room between them for the current page's
|
||||
own sections (.sections below), which is what makes the long pages navigable.
|
||||
Sticky, so both lists stay in view while the page scrolls past them. The
|
||||
layout template lists the blocks in the order they are drawn, so reading and
|
||||
tab order follow the eye without a CSS `order`. */
|
||||
it is out of the way. Sticky, so the list stays in view while the page
|
||||
scrolls past it. The layout template lists the blocks in the order they are
|
||||
drawn, so reading and tab order follow the eye without a CSS `order`. */
|
||||
.nav {
|
||||
position: sticky; top: 2rem; align-self: flex-start;
|
||||
flex: none; width: 13.5rem;
|
||||
@@ -272,6 +401,9 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
of them. It may be long and there is nothing to break it on, so it is allowed
|
||||
to break mid word rather than widen the column. */
|
||||
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||
.nav .session-user {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
/* The mark is a link, but not one of the column's entries: it takes none of the
|
||||
padding and rounding the entry rule below applies, so its own edge lines up
|
||||
with the entries' icons rather than sitting half a step inside them. */
|
||||
@@ -293,48 +425,30 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
underline was what marked them as links, but a column of them reads as a
|
||||
list of links already, and eight underlines down the left edge is a lot of
|
||||
line for no added meaning. The hover background carries the affordance. */
|
||||
/* The entries are page text, not accented links: a column of eight brick rows
|
||||
would leave nothing for the current one to stand out from — that is what the
|
||||
active entry's colour is for. */
|
||||
.nav a, .nav [aria-current] {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.35rem 0.6rem; border-radius: 6px; text-decoration: none;
|
||||
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
.nav a:hover { background: var(--surface-bg); }
|
||||
/* The marker moved with the bar: a rule under the entry made sense in a row,
|
||||
where the entries sat side by side; standing up, the current entry is marked
|
||||
down its leading edge, which is the edge every entry shares. */
|
||||
.nav [aria-current] {
|
||||
font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 2px 0 0 #2563eb;
|
||||
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||
}
|
||||
/* Sign out carries an icon too, so it needs the same row layout; its padding
|
||||
and colours come from the compact button rule further down. */
|
||||
/* Sign out carries an icon too, so it needs the same row layout; its colours
|
||||
come from the compact button rule further down, and its type is put back to
|
||||
the column's beside it (see the rule after that one). */
|
||||
.nav button { display: flex; align-items: center; gap: 0.5rem; }
|
||||
/* The icons draw in the entry's own colour, so the active entry's darker text,
|
||||
a link's blue and Sign out's red all carry through without a rule apiece. */
|
||||
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||
/* The current page's own sections, listed under the page entries. The domain
|
||||
page and the status page are eight and nine cards tall, and the only way to
|
||||
the card you came for was to scroll past all the ones you did not. Only pages
|
||||
that long carry an index — it comes from the page's own "sections" template
|
||||
(see layout.html), so a page with two cards renders nothing here. */
|
||||
.sections {
|
||||
display: flex; flex-direction: column; gap: 0.1rem;
|
||||
padding-top: 0.75rem; border-top: 1px solid var(--border);
|
||||
}
|
||||
.sections-title {
|
||||
margin: 0 0 0.25rem 0.6rem;
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: #6b7280;
|
||||
}
|
||||
/* Quieter and a step in from the page entries: this is an index of one page,
|
||||
subordinate to the list of pages above it. */
|
||||
.nav .sections a {
|
||||
padding: 0.2rem 0.6rem 0.2rem 0.9rem;
|
||||
font-size: 0.85rem; color: #6b7280; text-decoration: none;
|
||||
}
|
||||
.nav .sections a:hover { color: var(--fg); }
|
||||
/* panel.js marks the section the page is scrolled to. Without JavaScript
|
||||
nothing is marked and the list is still a working index. */
|
||||
.nav .sections a.current { color: var(--fg); font-weight: 600; background: var(--nav-active-bg); }
|
||||
/* Jumping to a card should not leave it touching the top edge of the window. */
|
||||
/* Jumping to a card (in-page links) should not leave it touching the top edge. */
|
||||
.card[id] { scroll-margin-top: 1rem; }
|
||||
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
|
||||
and the 48rem measure, plus the body's padding — the measure, not the column
|
||||
@@ -358,18 +472,30 @@ button.danger:hover, a.danger:hover { background: #912018; }
|
||||
.nav .brand img { width: 110px; }
|
||||
/* Each block keeps its own group of entries together and wraps as one; the
|
||||
rules that separated the blocks vertically become the space between them. */
|
||||
.nav .links, .nav .session, .nav .sections {
|
||||
.nav .links, .nav .session {
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center;
|
||||
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
|
||||
}
|
||||
.sections-title { margin: 0; }
|
||||
}
|
||||
/* 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. */
|
||||
/* Mono and squared off rather than a bold uppercase pill: the badge carries a
|
||||
value the server produced (ok, warn, delivered, bounced), and the mono face
|
||||
is where the panel puts machine output everywhere else. Uppercasing it was
|
||||
the pill's own emphasis — dropped with the pill, since the colour already
|
||||
says how loud the badge is. */
|
||||
/* line-height 1 and the padding doing the centring, rather than the 1.5 the
|
||||
badge would inherit from the body. IBM Plex Mono sits a shade low in its em
|
||||
square, so equal padding leaves "ok" / "warn" sunk in the box (and visibly
|
||||
below the heading or label it sits next to). The bottom padding is therefore
|
||||
heavier than the top — enough to lift the word to the optical centre. A
|
||||
top-heavy pad was tried earlier and made the sink worse. Words with a
|
||||
descender (queued) hang into that bottom pad rather than growing the badge. */
|
||||
.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;
|
||||
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
|
||||
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: middle; border: 1px solid transparent;
|
||||
}
|
||||
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
||||
@@ -395,8 +521,20 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
||||
beside them would otherwise win the width and leave "Memory" broken across
|
||||
two lines. */
|
||||
.metric { white-space: nowrap; }
|
||||
/* Copy sits at the top of the value — right for a long DKIM key. The row's
|
||||
.code uses the compact button's vertical padding so a one-line value is the
|
||||
same height as Copy and does not leave the button hanging under an empty
|
||||
band of padding. */
|
||||
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||
.code-row .code { flex: 1; min-width: 0; }
|
||||
.code-row .code {
|
||||
flex: 1; min-width: 0; margin-top: 0;
|
||||
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||
}
|
||||
/* A short field with its submit on the same row (Add domain). The button
|
||||
keeps the card's usual filled look; its top margin is for stacked forms. */
|
||||
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
|
||||
.input-row input { flex: 1; min-width: 0; }
|
||||
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
|
||||
|
||||
/* Two cards abreast, on the one page that has a pair of them worth reading
|
||||
together (a delivery's facts beside its history). auto-fit with a minimum
|
||||
@@ -407,12 +545,85 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
gap: 1.2rem; margin-top: 1.2rem;
|
||||
}
|
||||
/* Cards use margin: 0 auto so a lone card centres in the column. Inside the
|
||||
grid those auto side margins absorb free space and shrink each card to its
|
||||
content instead of filling the 1fr track — so cancel them here, and allow
|
||||
the cell to shrink below the card's intrinsic minimum when the column is
|
||||
tight. */
|
||||
.split > .card { margin-left: 0; margin-right: 0; min-width: 0; }
|
||||
/* The gap already spaces the cards inside the grid; the general .card + .card
|
||||
rule would add a second gap's worth on top of it, and only to the second
|
||||
card, which in one column reads as an uneven stack. The blocks around the
|
||||
grid keep their spacing from it instead. */
|
||||
.split > .card + .card { margin-top: 0; }
|
||||
.split + .card { margin-top: 1.2rem; }
|
||||
.card + .split { margin-top: 1.2rem; }
|
||||
/* Configuration sits outside the polled fragment; without this it would
|
||||
touch the last .split inside #status-body (a nested .split is not a sibling). */
|
||||
#status-body + .card { margin-top: 1.2rem; }
|
||||
/* Peer checks or paired forms inside one card (domain DNS status: two rows of
|
||||
two; domain settings; application edit). Same auto-fit idea as .split, so a
|
||||
wide card gets two columns — not three — and falls to one when narrow. */
|
||||
.check-cols {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
gap: 1rem 1.2rem; margin-top: 1rem;
|
||||
}
|
||||
.check-col { min-width: 0; }
|
||||
.check-col > label:first-child { margin-top: 0; }
|
||||
.check-col-title {
|
||||
margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600;
|
||||
}
|
||||
.card > h2 + .check-col-title { margin-top: 0.55rem; }
|
||||
/* Domain settings pairs DMARC ‖ rate limit: share four row tracks so titles,
|
||||
leads, fields and action buttons line up across columns. */
|
||||
@supports (grid-template-rows: subgrid) {
|
||||
.check-cols-rows {
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
.check-cols-rows > .check-col {
|
||||
display: grid;
|
||||
grid-template-rows: subgrid;
|
||||
grid-row: span 4;
|
||||
}
|
||||
}
|
||||
.check-cols-rows > .check-col > .check-col-title { margin-top: 0; }
|
||||
.check-cols-rows > .check-col > .muted { margin: 0; }
|
||||
.check-col-fields { min-width: 0; }
|
||||
.check-col-fields > form { margin: 0; }
|
||||
.check-col-fields .field-pair { margin-top: 0; }
|
||||
.check-col-fields .field-pair > div > label { margin-top: 0.9rem; }
|
||||
.check-col-actions {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.75rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
.check-col-actions > button { margin-top: 1.2rem; }
|
||||
.check-col-actions > form.inline { margin: 0; }
|
||||
.check-col-actions > form.inline > button { margin-top: 1.2rem; }
|
||||
/* Two short fields on one row (rate-limit message count ‖ window). Falls to
|
||||
one column when the parent is too narrow for both. */
|
||||
.field-pair {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0 1rem; margin-top: 0.45rem;
|
||||
}
|
||||
.field-pair > div { min-width: 0; }
|
||||
.field-pair label { margin-top: 0.45rem; }
|
||||
/* Hint under a field-pair sits tight above the form's submit button (the
|
||||
global button margin-top would leave a blank line). */
|
||||
.field-pair + p.muted { margin: 0.45rem 0 0; }
|
||||
.field-pair + p.muted + button { margin-top: 0.45rem; }
|
||||
/* Host / name beside a DNS Type that is always TXT — size the type column to
|
||||
that token rather than giving it half the row. Vertical padding matches
|
||||
.code-row .code so TXT is the same height as Host when Copy sits beside it. */
|
||||
.field-pair.host-type {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.field-pair.host-type .code {
|
||||
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||
}
|
||||
.field-type { width: max-content; }
|
||||
.field-type .code {
|
||||
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
|
||||
}
|
||||
/* The subject heads a delivery's page, and it is the one heading in the panel
|
||||
whose text we do not control: it may be a hundred characters with nothing to
|
||||
break on. It wraps to as many lines as it needs (this is the page's name, not
|
||||
@@ -428,8 +639,8 @@ h1.subject { overflow-wrap: anywhere; }
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
|
||||
margin-top: -0.5rem; margin-bottom: 1rem;
|
||||
}
|
||||
.route .addr { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; overflow-wrap: anywhere; }
|
||||
.route .arrow { color: #6b7280; }
|
||||
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; overflow-wrap: anywhere; }
|
||||
.route .arrow { color: var(--muted); }
|
||||
/* A card's short facts, as a grid of tiles rather than the label-over-value
|
||||
stack the rest of the panel uses. That stack is right for the pages where a
|
||||
value is a DNS record or a password to copy — one per line, full width — and
|
||||
@@ -437,16 +648,16 @@ h1.subject { overflow-wrap: anywhere; }
|
||||
would be a page of mostly empty rows. Same auto-fit as .split: as many
|
||||
columns as fit, and one when nothing else does. */
|
||||
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
||||
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 8px; background: var(--surface-bg); }
|
||||
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
|
||||
.fact-label {
|
||||
display: block; font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280;
|
||||
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
|
||||
}
|
||||
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
|
||||
ids are all one run — so a value may break mid word rather than push its tile
|
||||
wider than the column it shares. */
|
||||
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
|
||||
.fact-value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
|
||||
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
/* A message's history: the steps down a line, each with the time it happened,
|
||||
the status it reached and what that means. The line is the list's own left
|
||||
border and the dots sit on top of it, so nothing has to be positioned against
|
||||
@@ -470,7 +681,7 @@ h1.subject { overflow-wrap: anywhere; }
|
||||
of anything. */
|
||||
.event.pending { opacity: 0.7; }
|
||||
.event.pending::before { background: var(--card-bg); border-style: dashed; }
|
||||
.event-time { margin: 0; font-size: 0.75rem; color: #6b7280; }
|
||||
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
||||
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
||||
/* A message's own mail.log lines, as a table of when and what. The time column
|
||||
@@ -486,7 +697,7 @@ table.log th:first-child, table.log td.time { width: 1%; }
|
||||
while still letting a long line wrap inside the cell instead of widening it
|
||||
(the th/td rule above allows the break). */
|
||||
table.log td.log-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem; white-space: pre-wrap;
|
||||
}
|
||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||
@@ -497,31 +708,38 @@ table.log td.log-text {
|
||||
the control row of an application. */
|
||||
button.copy, .actions button, .actions > label.toggle, .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: var(--surface-bg); color: #2563eb; border: 1px solid var(--control-border);
|
||||
border-radius: 5px; white-space: nowrap;
|
||||
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||
}
|
||||
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
|
||||
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
|
||||
button.copy { flex: none; margin-top: 0.3rem; }
|
||||
button.copy { flex: none; }
|
||||
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
|
||||
}
|
||||
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
|
||||
/* Sign out takes the compact button's colours but not its type. The rule above
|
||||
is written for controls that cluster — a row of them beside a value or in a
|
||||
table cell — where 0.8rem/600 is what keeps four of them from shouting. In
|
||||
the navigation column it has no cluster to belong to: it stands under
|
||||
Settings, among entries set 0.95rem/400, and being smaller and heavier than
|
||||
every word around it made it read as a different kind of object rather than
|
||||
as the last item of the list. Type and padding go back to the entries';
|
||||
the red, the border and the background stay, and are what say it acts. */
|
||||
.nav button {
|
||||
padding: 0.35rem 0.6rem; font-size: 0.95rem; font-weight: 400;
|
||||
}
|
||||
|
||||
/* The optional "encrypt this download" block on the backup and export forms.
|
||||
Its label is the one checkbox in the panel, so it opts out of the
|
||||
block-level label rule above and sits on one line with its box; the fields it
|
||||
reveals are indented under it to read as its consequence rather than as three
|
||||
more fields of the form. panel.js hides the inner block until the box is
|
||||
ticked (and empties it when unticked); without JavaScript everything stays
|
||||
visible, which the server handles identically. The import form reuses the
|
||||
same indented .encrypt-fields look for its password field, but reveals it
|
||||
by file extension instead of a checkbox (see panel.js). */
|
||||
The checkbox uses the shared label.check rule above; the fields it reveals
|
||||
are indented under it to read as its consequence rather than as three more
|
||||
fields of the form. panel.js hides the inner block until the box is ticked
|
||||
(and empties it when unticked); without JavaScript everything stays visible,
|
||||
which the server handles identically. The import form reuses the same
|
||||
indented .encrypt-fields look for its password field, but reveals it by
|
||||
file extension instead of a checkbox (see panel.js). */
|
||||
.encrypt { margin-top: 1.2rem; }
|
||||
.encrypt label.check {
|
||||
display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600;
|
||||
}
|
||||
.encrypt label.check input { width: auto; margin: 0; }
|
||||
.encrypt label.check { margin: 0; }
|
||||
.encrypt-fields {
|
||||
margin-top: 1.2rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
||||
border-left: 2px solid var(--border);
|
||||
|
||||
@@ -72,6 +72,50 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Custom DMARC rua address shown only for "custom" mode ------------
|
||||
// Same idea as the address list: the email field only applies when the
|
||||
// operator picks Custom address. With JavaScript blocked the field stays
|
||||
// visible and the server still ignores it for inherit/none.
|
||||
function syncCustomAddressField(select) {
|
||||
var form = select.closest("form");
|
||||
var field = form && form.querySelector("[data-custom-address]");
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
field.hidden = select.value !== select.dataset.customMode;
|
||||
}
|
||||
|
||||
function initCustomAddressFields(root) {
|
||||
root.querySelectorAll("select[data-custom-mode]").forEach(function (select) {
|
||||
syncCustomAddressField(select);
|
||||
select.addEventListener("change", function () {
|
||||
syncCustomAddressField(select);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Domain pick shown only for domain administrators ------------------
|
||||
// Global administrators manage every domain, so the assignment checkboxes
|
||||
// are irrelevant for that role. The toggle runs on load too, because the
|
||||
// edit form of an existing global user should not flash the fieldset.
|
||||
function syncDomainPickField(select) {
|
||||
var form = select.closest("form");
|
||||
var field = form && form.querySelector("[data-domain-pick]");
|
||||
if (!field) {
|
||||
return;
|
||||
}
|
||||
field.hidden = select.value === select.dataset.globalRole;
|
||||
}
|
||||
|
||||
function initDomainPickFields(root) {
|
||||
root.querySelectorAll("select[data-global-role]").forEach(function (select) {
|
||||
syncDomainPickField(select);
|
||||
select.addEventListener("change", function () {
|
||||
syncDomainPickField(select);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Encryption password fields shown only when asked for --------------
|
||||
// The backup, export and import forms carry an optional password block. It
|
||||
// is hidden until the checkbox next to it is ticked, and cleared when it is
|
||||
@@ -135,73 +179,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Section index follows the page -----------------------------------
|
||||
// The long pages list their own sections in the navigation column (the
|
||||
// "sections" template). Marking the one currently in view turns that list
|
||||
// from an index into a position, which is the whole point of it on a page
|
||||
// nine cards tall. The links work without any of this; only the highlight
|
||||
// depends on it.
|
||||
//
|
||||
// Each pass looks its targets up by id rather than holding on to elements
|
||||
// found once: the status page replaces its cards wholesale every five
|
||||
// seconds (adaptive polling on #status-body), and anything remembered here would be
|
||||
// measuring boxes that had left the document.
|
||||
var sectionLinks = [];
|
||||
|
||||
function markCurrentSection() {
|
||||
var current = null;
|
||||
sectionLinks.forEach(function (link) {
|
||||
var target = document.getElementById(link.hash.slice(1));
|
||||
// The section in view is the last one whose top has passed the reading
|
||||
// line; the links are in document order, so the last match wins.
|
||||
if (target && target.getBoundingClientRect().top <= 100) {
|
||||
current = link;
|
||||
}
|
||||
});
|
||||
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
|
||||
// At the foot of the page there is no scroll left to bring the last
|
||||
// cards up to the reading line, so without this they could never be
|
||||
// marked however far down you are — and the last card of the domain
|
||||
// page is the one that deletes it.
|
||||
current = sectionLinks[sectionLinks.length - 1];
|
||||
} else if (!current) {
|
||||
// Above the first heading nothing has been passed yet, and the page is
|
||||
// still on its first section.
|
||||
current = sectionLinks[0];
|
||||
}
|
||||
sectionLinks.forEach(function (link) {
|
||||
link.classList.toggle("current", link === current);
|
||||
});
|
||||
}
|
||||
|
||||
function initSectionIndex() {
|
||||
sectionLinks = Array.prototype.slice.call(
|
||||
document.querySelectorAll(".sections a[href^='#']")
|
||||
);
|
||||
if (!sectionLinks.length) {
|
||||
return;
|
||||
}
|
||||
var pending = false;
|
||||
// Scroll fires far more often than the highlight can change, so the work
|
||||
// is collapsed onto the next frame.
|
||||
window.addEventListener("scroll", function () {
|
||||
if (pending) {
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
window.requestAnimationFrame(function () {
|
||||
pending = false;
|
||||
markCurrentSection();
|
||||
});
|
||||
}, { passive: true });
|
||||
markCurrentSection();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initAddressFields(document);
|
||||
initCustomAddressFields(document);
|
||||
initDomainPickFields(document);
|
||||
initEncryptFields(document);
|
||||
initImportPasswordField(document);
|
||||
initSectionIndex();
|
||||
});
|
||||
|
||||
// --- Adaptive monitoring polling ---------------------------------------
|
||||
|
||||
@@ -21,7 +21,12 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
|
||||
// prompted this: a browser given no ETag and no Last-Modified caches it on a
|
||||
// guess, and a rebranded panel keeps serving the old mark from the tab.
|
||||
func TestStaticAssetsCarryETag(t *testing.T) {
|
||||
for _, name := range []string{"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js"} {
|
||||
for _, name := range []string{
|
||||
"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js",
|
||||
// The fonts are the assets this matters most for: they are the largest
|
||||
// thing the panel serves and the ones a browser is most willing to keep.
|
||||
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
|
||||
} {
|
||||
rec := serveStatic("/static/"+name, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: got status %d, want 200", name, rec.Code)
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
{{if .IsGlobal}}
|
||||
<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>
|
||||
<div class="input-row">
|
||||
<input id="name" name="name" type="text" placeholder="example.com"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormName}}" autofocus required>
|
||||
<button type="submit">Add domain</button>
|
||||
</div>
|
||||
{{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>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Domains</h2>
|
||||
@@ -31,7 +33,7 @@
|
||||
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
||||
<td class="muted">{{.DKIMSelector}}</td>
|
||||
<td>{{.AppCount}}</td>
|
||||
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
|
||||
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<td>{{.From}}</td>
|
||||
<td>{{.To}}</td>
|
||||
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
|
||||
<td>{{.Status}}</td>
|
||||
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
|
||||
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<span class="st st-{{.Level}}">{{.Row.Status}}</span>
|
||||
</p>
|
||||
|
||||
<a class="back" href="{{.BackURL}}">← Back to deliveries</a>
|
||||
{{template "back_link" (back .BackURL "Back to deliveries")}}
|
||||
|
||||
{{/* The two columns: what was recorded on the left, in what order it happened
|
||||
on the right. They are a pair — the facts are only worth reading against
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content"}}
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/domains/{{.Domain.ID}}">← Back to {{.Domain.Name}}</a>
|
||||
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Confirm deletion</h2>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{{/* Wide so the .split pairs fill the column rather than the 48rem reading
|
||||
measure (same pattern as Status). */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/domains">← All domains</a>
|
||||
{{template "back_link" (back "/domains" "All domains")}}
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
||||
@@ -9,8 +13,8 @@
|
||||
{{if .NewCred}}
|
||||
<div class="card credential" id="new-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>
|
||||
<p class="muted">Shown <strong>once only</strong> and 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>
|
||||
@@ -24,202 +28,279 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card" id="dkim">
|
||||
<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" id="spf-dmarc">
|
||||
<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. SelfPost is a send-only relay — most operators have no inbox on the
|
||||
sending domain, so the suggested DMARC record omits <code>rua=</code> unless a
|
||||
report address is configured below or in <a href="/account">Settings</a>.
|
||||
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.</p>
|
||||
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
||||
<label for="dmarc_rua_mode">DMARC aggregate reports (rua=)</label>
|
||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
|
||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||
</select>
|
||||
|
||||
<label for="dmarc_rua_email">Custom report address</label>
|
||||
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
||||
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
||||
placeholder="reports@your-mail-domain.com">
|
||||
|
||||
<button type="submit">Save DMARC report settings</button>
|
||||
</form>
|
||||
|
||||
<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{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
||||
reception in a future release.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
<label>Report authorization — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Report authorization — value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthValue}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted"><code>p=none</code> changes nothing about delivery. Omit
|
||||
<code>rua=</code> on a send-only domain, or point it at a mailbox that
|
||||
receives inbound mail. Tighten to <code>p=quarantine</code> and then
|
||||
<code>p=reject</code> once aggregate reports look clean.</p>
|
||||
</div>
|
||||
|
||||
{{/* Two rows of two checks (.check-cols). */}}
|
||||
<div class="card" id="dns-status">
|
||||
<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>
|
||||
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
||||
publishing.</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}}
|
||||
<div class="check-cols">
|
||||
<div class="check-col">
|
||||
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.DKIM.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
{{if ne .DNS.DKIM.Status "ok"}}
|
||||
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="check-col">
|
||||
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.SPF.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
{{if ne .DNS.SPF.Status "ok"}}
|
||||
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
||||
{{end}}
|
||||
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
|
||||
<code>redirect=</code>.</p>
|
||||
</div>
|
||||
|
||||
<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}}
|
||||
<div class="check-col">
|
||||
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.DMARCName}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.DMARC.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
{{if ne .DNS.DMARC.Status "ok"}}
|
||||
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
||||
{{else}}
|
||||
<p class="muted">{{.DNS.DMARC.Detail}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .DNS.DMARCReportAuth.Status}}
|
||||
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||
{{end}}</span>{{end}}
|
||||
{{end}}
|
||||
<div class="check-col">
|
||||
{{if .DNS.DMARCReportAuth.Status}}
|
||||
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.DMARCReportAuth.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||
{{else}}
|
||||
<label>Report authorization</label>
|
||||
<p class="muted">Not required (no external <code>rua=</code>).</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
||||
<button type="submit">Re-check</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="settings">
|
||||
<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>
|
||||
<div class="split">
|
||||
<div class="card" id="dkim-spf">
|
||||
<h2>DKIM and SPF records</h2>
|
||||
|
||||
<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" id="add-application">
|
||||
<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>
|
||||
<p class="check-col-title">DKIM</p>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</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>
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Not a secret. Signed with selector
|
||||
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||
|
||||
<p class="check-col-title">SPF</p>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.SPFExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Merge into an existing SPF if the domain already has one —
|
||||
do not publish a second record.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="dmarc">
|
||||
<h2>DMARC record</h2>
|
||||
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
||||
reception in a future release.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
<p class="check-col-title">Report authorization</p>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthValue}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
|
||||
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.
|
||||
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="card" id="connection">
|
||||
<h2>Connection settings</h2>
|
||||
<p class="muted">Same for every domain. Authenticate with an application
|
||||
login from below.</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">Auth required on every port. The password is shown once at
|
||||
create or regenerate.</p>
|
||||
</div>
|
||||
|
||||
{{/* Create form beside connection settings, mirroring "Add a sending domain"
|
||||
above the domains list. */}}
|
||||
<div class="card" id="add-application">
|
||||
<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">Password shown once. Login unique across domains; letters,
|
||||
digits, '.', '-' and '_'.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="applications">
|
||||
<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>
|
||||
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
|
||||
address list.</p>
|
||||
|
||||
{{if .Apps}}
|
||||
<ul class="apps">
|
||||
@@ -230,17 +311,12 @@
|
||||
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
||||
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
||||
</p>
|
||||
<!-- The two panels are opened by a checkbox and a label rather than by
|
||||
<details>, so that the controls stay a single row of buttons and
|
||||
what a panel reveals is laid out under all four of them instead of
|
||||
splitting the row in two. The checkbox precedes both its label and
|
||||
its panel, which is what lets the CSS alone show them (no script
|
||||
involved) — see .panel-toggle in panel.css. -->
|
||||
<!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
|
||||
rather than <details>, so the button row stays intact — see
|
||||
.panel-toggle in panel.css. -->
|
||||
<div class="actions">
|
||||
<input class="panel-toggle t-mode" id="mode-{{.ID}}" type="checkbox">
|
||||
<label class="toggle for-mode" for="mode-{{.ID}}">Edit mode</label>
|
||||
<input class="panel-toggle t-limit" id="limit-{{.ID}}" type="checkbox">
|
||||
<label class="toggle for-limit" for="limit-{{.ID}}">Rate limit{{if .HasLimit}} (active){{end}}</label>
|
||||
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
|
||||
<label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} <span class="st st-ok">limit</span>{{end}}</label>
|
||||
<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>
|
||||
@@ -249,44 +325,60 @@
|
||||
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
<div class="panel panel-mode">
|
||||
<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}}
|
||||
<div class="panel panel-edit">
|
||||
<div class="check-cols">
|
||||
<div class="check-col">
|
||||
<p class="check-col-title">Address mode</p>
|
||||
<p class="muted">Which From addresses this application may use.</p>
|
||||
<form method="post" action="/applications/{{.ID}}/mode">
|
||||
<select name="mode" data-list-mode="{{$.List}}" aria-label="Address mode">
|
||||
<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>
|
||||
</div>
|
||||
<div class="check-col">
|
||||
<p class="check-col-title">Optional trusted-IP override
|
||||
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
||||
<p class="muted">One per line or comma-separated.</p>
|
||||
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
|
||||
<textarea name="allowed_ips" rows="1" placeholder="203.0.113.10"
|
||||
aria-label="Trusted client IPs">{{.IPsText}}</textarea>
|
||||
<p class="muted">These IPs get a higher ceiling than the domain
|
||||
(≤ level 1) and skip the domain check; everyone else uses
|
||||
the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}}
|
||||
if set, otherwise level 1.</p>
|
||||
<div class="field-pair">
|
||||
<div>
|
||||
<label>Message limit (max {{$.L1Messages}})</label>
|
||||
<input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
|
||||
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
|
||||
</div>
|
||||
<div>
|
||||
<label>Window (seconds)</label>
|
||||
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Saving and removing the limit are two posts; the Save
|
||||
button is bound by form= id so both buttons share a row. -->
|
||||
<div class="panel-buttons">
|
||||
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
||||
{{if .HasLimit}}
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
||||
data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply.">
|
||||
<input type="hidden" name="clear" value="1">
|
||||
<button type="submit" class="danger">Remove limit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Save mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="panel panel-limit">
|
||||
<form id="rl-{{.ID}}" 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}}">
|
||||
</form>
|
||||
<!-- Saving and removing the limit are two posts, so they are two
|
||||
forms and could not share a line while the Save button sat
|
||||
inside the block form above. It is bound to that form by id
|
||||
instead (the form attribute, no script involved), which lets
|
||||
both buttons stand together in one row under the fields. -->
|
||||
<div class="panel-buttons">
|
||||
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
||||
{{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}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,84 +390,87 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="rate-limit">
|
||||
<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>
|
||||
<div class="card" id="domain-settings">
|
||||
<h2>Domain settings</h2>
|
||||
|
||||
<p class="muted">Status:
|
||||
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
|
||||
<div class="check-cols check-cols-rows">
|
||||
<div class="check-col">
|
||||
<p class="check-col-title">DMARC reports</p>
|
||||
<p class="muted">Default comes from <a href="/settings">Settings</a>;
|
||||
override per domain here.</p>
|
||||
<div class="check-col-fields">
|
||||
<form id="dmarc-domain" method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
||||
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||
</select>
|
||||
|
||||
<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>
|
||||
<div data-custom-address>
|
||||
<label for="dmarc_rua_email">Custom report address</label>
|
||||
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
||||
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
||||
placeholder="reports@your-mail-domain.com">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="check-col-actions">
|
||||
<button type="submit" form="dmarc-domain">Save DMARC report settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 class="check-col">
|
||||
<p class="check-col-title">Level-2 rate limit
|
||||
{{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
||||
<p class="muted">Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p>
|
||||
<div class="check-col-fields">
|
||||
<form id="rl-domain" method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
||||
<div class="field-pair">
|
||||
<div>
|
||||
<label for="d_max">Message limit (max {{.L1Messages}})</label>
|
||||
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
|
||||
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="d_win">Window (seconds)</label>
|
||||
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Applies to every client IP on this domain. Leave the
|
||||
message limit empty to use level 1 only.</p>
|
||||
</form>
|
||||
</div>
|
||||
<div class="check-col-actions">
|
||||
<button type="submit" form="rl-domain">Save limit</button>
|
||||
{{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="export">
|
||||
<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, or encrypt it below and move a <code>.spde</code> file
|
||||
(SelfPost domain export) instead; the import form asks for the password.</p>
|
||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
||||
{{template "encryptfields" .}}
|
||||
<button type="submit">Export domain</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card" id="export">
|
||||
<h2>Export domain</h2>
|
||||
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
||||
below as <code>.spde</code>.</p>
|
||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
||||
{{template "encryptfields" .}}
|
||||
<button type="submit">Export domain</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="danger">
|
||||
<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}}
|
||||
|
||||
{{/* The domain page's section index, shown in the navigation column (see the
|
||||
"sections" block in layout.html). This is the panel's longest page — the
|
||||
DNS records to publish, the checks on them, the applications and two rate
|
||||
limits — and setting a domain up means going back and forth between them.
|
||||
The freshly generated password is only listed while it is on the page: it
|
||||
is the one card that is not always there, and the one nothing should scroll
|
||||
away from silently. */}}
|
||||
{{define "sections"}}
|
||||
<div class="sections">
|
||||
<p class="sections-title">On this page</p>
|
||||
{{if .NewCred}}<a href="#new-credential">New application password</a>{{end}}
|
||||
<a href="#dkim">DKIM DNS record</a>
|
||||
<a href="#spf-dmarc">SPF and DMARC records</a>
|
||||
<a href="#dns-status">DNS status</a>
|
||||
<a href="#settings">Sending server settings</a>
|
||||
<a href="#add-application">Add an application</a>
|
||||
<a href="#applications">Applications</a>
|
||||
<a href="#rate-limit">Sending rate limit</a>
|
||||
<a href="#export">Export domain</a>
|
||||
<a href="#danger">Danger zone</a>
|
||||
<div class="card" id="danger">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -61,22 +61,26 @@
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
{{/* The order of the blocks is the order they are read in, top to bottom of
|
||||
the column: the mark, the panel's pages, the current page's own sections,
|
||||
and the session last. The mark goes to /status, the page the panel opens
|
||||
on — named outright rather than as "/", which is only a redirect to it. */}}
|
||||
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||
the column: the mark, the panel's pages, and the session last. The mark
|
||||
goes to /status, the page the panel opens on — named outright rather
|
||||
than as "/", which is only a redirect to it. */}}
|
||||
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||
<div class="links">
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
||||
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "sections" .}}
|
||||
<div class="session">
|
||||
<span class="muted">User: {{.User}}</span>
|
||||
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
|
||||
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
||||
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||
</form>
|
||||
@@ -84,24 +88,22 @@
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{/* The index of the current page's own sections, shown in the navigation
|
||||
column under the page entries. Most pages are one or two cards and need no
|
||||
index, so the block is empty here and only the pages that are genuinely
|
||||
long — the domain page, the status page — redefine it with their own list
|
||||
(see .sections in panel.css for what it looks like, and panel.js for the
|
||||
marking of the section in view). Each page's template file is parsed after
|
||||
this one, so its definition replaces this empty one; a page that defines
|
||||
nothing keeps it and renders no index. */}}
|
||||
{{define "sections"}}{{end}}
|
||||
|
||||
{{/* Whether the page takes the column whole. Empty here, so a page is held to
|
||||
the reading measure unless it says otherwise; the three pages that are
|
||||
tables of data or raw log lines redefine this as the word "wide", which
|
||||
lands in <main>'s class list (see main.wide in panel.css). Same mechanism
|
||||
as "sections" above: each page's template file is parsed after this one,
|
||||
so its definition replaces the empty one. */}}
|
||||
the reading measure unless it says otherwise; the pages that are tables of
|
||||
data, raw log lines or side-by-side cards redefine this as the word "wide",
|
||||
which lands in <main>'s class list (see main.wide in panel.css). Each
|
||||
page's template file is parsed after this one, so its definition replaces
|
||||
the empty one. */}}
|
||||
{{define "wide"}}{{end}}
|
||||
|
||||
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
||||
function, e.g. {{template "back_link" (back "/users" "Back to users")}}.
|
||||
Place it directly under the page <h1> and above the cards (see .back in
|
||||
panel.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
|
||||
{{define "back_link"}}
|
||||
<a class="back" href="{{.Href}}">← {{.Label}}</a>
|
||||
{{end}}
|
||||
|
||||
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
|
||||
inherit the link's colour through currentColor, cost no extra request, and
|
||||
need no exemption from the panel's "default-src 'self'" policy. Each is
|
||||
@@ -117,6 +119,15 @@
|
||||
two drawers with handles is the picture of an archive. */}}
|
||||
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
|
||||
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
|
||||
{{/* Two people: rear as a right-side crescent (head + shoulder) so it peeks
|
||||
from behind; front is a full silhouette matching icon-account. Heads share
|
||||
one baseline; shoulder arcs share one floor — otherwise the pair looks
|
||||
lopsided at 16 px. */}}
|
||||
{{define "icon-users"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></svg>{{end}}
|
||||
{{/* A gear (Feather "settings"), not a sun-with-rays: at 16 px spokes alone
|
||||
read as brightness, while the toothed rim is the picture of configuration.
|
||||
viewBox 0 0 24 matches the source path; CSS sizes the icon to 1 rem. */}}
|
||||
{{define "icon-settings"}}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>{{end}}
|
||||
{{/* Sign out is the one icon here that marks an action rather than a page: an
|
||||
open door with the arrow leading out of it. */}}
|
||||
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
|
||||
|
||||
+67
-8
@@ -1,14 +1,21 @@
|
||||
{{/* Wide enough for credentials and DMARC settings side by side (see .split
|
||||
in panel.css, as on a delivery's page). */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Settings</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
|
||||
{{if .ShowDMARC}}
|
||||
<form method="post" action="/settings">
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -22,8 +29,10 @@
|
||||
|
||||
<label for="new_password_confirm">Confirm new password</label>
|
||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<h3>DMARC aggregate reports</h3>
|
||||
<div class="card">
|
||||
<h2>DMARC aggregate reports</h2>
|
||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||
domain (can be overridden per domain). Use a mailbox on a domain that
|
||||
receives inbound mail. SelfPost is send-only today; a future release will
|
||||
@@ -58,11 +67,61 @@
|
||||
{{end}}</span>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
<p class="muted">Leave both new-password fields empty to change the username
|
||||
or DMARC address only. Changing the password signs out every other session;
|
||||
this one stays signed in.</p>
|
||||
</form>
|
||||
{{else}}
|
||||
<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>
|
||||
<form method="post" action="/settings">
|
||||
<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
|
||||
or DMARC address only. Changing the password signs out every other session;
|
||||
only. Changing the password signs out every other session;
|
||||
this one stays signed in.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card" id="rate-limits">
|
||||
<h2>Sending rate limits</h2>
|
||||
<p class="muted">Configured in <code>.env</code> / Compose; restart the
|
||||
container to change level 1. Domain and application ceilings are set on
|
||||
each domain's page.</p>
|
||||
|
||||
<label>Level 1 — per client IP (Postfix)</label>
|
||||
<p class="code-row"><span class="code">{{.L1Messages}} messages / {{.L1Window}} seconds</span></p>
|
||||
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> /
|
||||
<code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP;
|
||||
the panel cannot raise a domain or application limit above this.</p>
|
||||
|
||||
<label>Level 2 — domain</label>
|
||||
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When
|
||||
unset, only level 1 applies. Must be ≤ level 1.</p>
|
||||
|
||||
<label>Level 2 — application (trusted IPs)</label>
|
||||
<p class="muted">Optional override: list client IPs and a ceiling
|
||||
<em>strictly above</em> the domain limit (still ≤ level 1). Those IPs
|
||||
skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,34 +1,14 @@
|
||||
{{/* Wide so Machine|Processes and the other .split pairs fill the column
|
||||
rather than the 48rem reading measure. */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Server status</h1>
|
||||
<h1>Status</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
{{template "status_body" .}}
|
||||
|
||||
<div class="card" id="hostname">
|
||||
<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" id="configuration">
|
||||
<h2>Configuration</h2>
|
||||
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||
@@ -41,22 +21,3 @@
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* The status page's section index, shown in the navigation column (see the
|
||||
"sections" block in layout.html). The first six cards are the ones the
|
||||
polling fragment replaces on a timer; their ids are part of
|
||||
status_body.html and do not change with the reading, so the links here hold
|
||||
across a refresh. */}}
|
||||
{{define "sections"}}
|
||||
<div class="sections">
|
||||
<p class="sections-title">On this page</p>
|
||||
<a href="#overall">Overall</a>
|
||||
<a href="#processes">Processes</a>
|
||||
<a href="#machine">Machine</a>
|
||||
<a href="#queue">Mail queue</a>
|
||||
<a href="#certificate">TLS certificate</a>
|
||||
<a href="#sockets">Milter sockets</a>
|
||||
<a href="#hostname">Hostname and reverse DNS</a>
|
||||
<a href="#configuration">Configuration</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -5,108 +5,125 @@
|
||||
<p class="muted">{{.OverallHeading}}</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="processes">
|
||||
<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 class="split">
|
||||
<div class="card" id="machine">
|
||||
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
||||
<table>
|
||||
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="metric">CPU</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.CPU.Measured}}
|
||||
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
|
||||
{{.Machine.CPU.BusyText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.CPU.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Memory</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Memory.Measured}}
|
||||
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
|
||||
{{.Machine.Memory.PctText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.Memory.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Network</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Network.Measured}}
|
||||
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">
|
||||
{{range .Machine.Network.Interfaces}}
|
||||
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out</div>
|
||||
{{end}}
|
||||
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" id="processes">
|
||||
<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>
|
||||
|
||||
<div class="card" id="machine">
|
||||
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
||||
<p class="muted">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.</p>
|
||||
<table>
|
||||
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="metric">CPU</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.CPU.Measured}}
|
||||
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
|
||||
{{.Machine.CPU.BusyText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.CPU.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Memory</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Memory.Measured}}
|
||||
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
|
||||
{{.Machine.Memory.PctText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.Memory.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Network</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Network.Measured}}
|
||||
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">
|
||||
{{range .Machine.Network.Interfaces}}
|
||||
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}</div>
|
||||
{{end}}
|
||||
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="split">
|
||||
<div class="card" id="queue">
|
||||
<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="/mail-queue">View queue</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="certificate">
|
||||
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
|
||||
{{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>
|
||||
|
||||
<div class="card" id="queue">
|
||||
<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="/mail-queue">Full queue</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card" id="sockets">
|
||||
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
||||
<table>
|
||||
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sockets}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="st st-{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="muted">{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" id="certificate">
|
||||
<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" id="hostname">
|
||||
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
|
||||
|
||||
<div class="card" id="sockets">
|
||||
<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>
|
||||
<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}}
|
||||
|
||||
<form class="inline" method="post" action="/status/recheck">
|
||||
<button type="submit">Re-check DNS</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
|
||||
|
||||
{{template "back_link" (back "/users" "Back to users")}}
|
||||
|
||||
<div class="card narrow">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
|
||||
<label for="password">Password{{if .IsEdit}} (leave empty to keep){{end}}</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" {{if not .IsEdit}}required{{end}}>
|
||||
|
||||
<label for="role">Role</label>
|
||||
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
|
||||
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||
</select>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||
|
||||
<fieldset id="domain-pick" data-domain-pick>
|
||||
<legend>Assigned domains</legend>
|
||||
<p class="muted">Required for domain administrators.</p>
|
||||
{{range .Domains}}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="domain_ids" value="{{.ID}}" {{if index $.FormDomains .ID}}checked{{end}}>
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
|
||||
{{if .IsEdit}}
|
||||
<button type="submit" name="action" value="delete" class="danger"{{if .LastGlobalLocked}} disabled{{end}}>Delete user</button>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be deleted.</p>{{end}}
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "content"}}
|
||||
<h1>Users</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<p><a href="/users/new">Create user</a></p>
|
||||
{{if .Users}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.User.Username}}</td>
|
||||
<td>{{if eq .User.Role "global"}}Global{{else}}Domain admin{{end}}</td>
|
||||
<td class="muted">{{if eq .User.Role "global"}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
|
||||
<td class="actions"><a href="/users/{{.User.ID}}">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No panel users besides the global administrator yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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.
|
||||
`<meter value="12"`, `<meter value="50"`,
|
||||
"load average 0.31, 0.24, 0.19", "2.0 GiB used of 4.0 GiB",
|
||||
"4 cores · 4 threads", "2.0 GiB used of 4.0 GiB",
|
||||
"eth0: 1.0 MiB in, 512.0 KiB out",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(out, `class="split"`); got != 3 {
|
||||
t.Errorf("status page has %d .split rows, want 3", got)
|
||||
}
|
||||
// Hostname must live inside the fragment so a poll refresh keeps it beside
|
||||
// sockets; Configuration stays outside (static reload control).
|
||||
body := strings.Index(out, `id="status-body"`)
|
||||
conf := strings.Index(out, `id="configuration"`)
|
||||
if body < 0 || conf < 0 || conf < body {
|
||||
t.Fatal("status-body or configuration card missing or out of order")
|
||||
}
|
||||
frag := out[body:conf]
|
||||
if !strings.Contains(frag, `id="hostname"`) {
|
||||
t.Error("hostname card is outside the polled status-body fragment")
|
||||
}
|
||||
if strings.Contains(frag, `id="configuration"`) || strings.Contains(frag, `action="/reload"`) {
|
||||
t.Error("configuration reload must stay outside the polled fragment")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine whose counters could not be read — no /proc, or a first reading
|
||||
@@ -425,13 +470,13 @@ func statusPageData() map[string]any {
|
||||
},
|
||||
"Machine": health.Machine{
|
||||
CPU: health.CPU{
|
||||
Measured: true, BusyPct: 12.4, Cores: 4,
|
||||
Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
|
||||
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
|
||||
Status: health.StatusOK, Detail: "4 core(s) · load average 0.31, 0.24, 0.19",
|
||||
Status: health.StatusOK, Detail: "4 cores · 4 threads",
|
||||
},
|
||||
Memory: health.Memory{
|
||||
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
|
||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB; 2.0 GiB available to new work.",
|
||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB.",
|
||||
},
|
||||
Network: health.Network{
|
||||
Measured: true, RxRate: 2048, TxRate: 1024,
|
||||
@@ -444,7 +489,7 @@ func statusPageData() map[string]any {
|
||||
Status: health.StatusOK,
|
||||
},
|
||||
"Sockets": []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
|
||||
},
|
||||
"SocketStatus": health.StatusOK,
|
||||
"OverallStatus": health.StatusError,
|
||||
|
||||
@@ -34,7 +34,9 @@ var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"account": {"templates/account.html"},
|
||||
"settings": {"templates/settings.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
@@ -63,7 +65,7 @@ func New(version string) (*Engine, error) {
|
||||
}
|
||||
for name, files := range pageFiles {
|
||||
patterns := append([]string{"templates/layout.html"}, files...)
|
||||
tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...)
|
||||
tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
}
|
||||
@@ -79,6 +81,17 @@ func New(version string) (*Engine, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// templateFuncs supplies helpers shared across page templates.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// back builds the map back_link reads; keeps href and label paired at
|
||||
// the call site instead of repeating the <a class="back"> 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 {
|
||||
|
||||
+40
-12
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user