panel-redesign: the application form is one POST, under its domain

Stage 1, step 4 (plan § Routes).

Service (internal/app)
  - Settings{Mode, Addresses, AuthIPRestrict, AuthAllowedIPs, Limit} is what
    the form holds besides the login. CreateWithSettings creates the
    application with all of it and removes it again if a later write fails;
    SaveSettings validates the sender rule first and, if a later write
    fails, puts back what it had already changed. The form produces a whole
    application or leaves things as they were.
  - Limit.Mode "" is "use the domain limit"; auto takes its ceiling from a
    recalculation the caller supplies.

Routes and handlers
  - GET, POST /outbound/domains/{id}/applications/new
    GET, POST /outbound/domains/{id}/applications/{aid}
    POST .../{aid}/password, .../{aid}/ratelimit/recalc, .../{aid}/delete
    replace POST /domains/{id}/applications and the six /applications/{aid}/*
    routes. lookupDomainApplication answers 404 when the application is not
    in the domain named by the path - also when the caller may open that
    domain.
  - A generated password is the response to its POST only (no GET path) and
    is sent with Cache-Control: no-store.
  - The login is not renamed by the form; a changed login is a 400.

Old domain page: the three per-application forms (mode, IP restriction,
limit) are one form with one Save; "Remove limit" became the "Use the domain
limit" choice. The e2e client follows (edited, not run here).

Tests: service - create and save in every limit mode, and all-or-nothing on
a refused sender rule, a failed recalculation and a failed sender-map
rebuild; handlers - the form's parsing, one POST creating sender + IPs +
limit, four kinds of refused form creating nothing, save, the no-store
header on both password pages, and five application routes against another
tenant's application addressed through its own domain and through the
caller's.

With @routes removed by hand TestRoutesFollowNavigation now reports only
GET /server/health (step 5).

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 14/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 22:02:26 +03:00
co-authored by Claude Opus 5.5
parent bd746c61d7
commit b6feac2200
15 changed files with 792 additions and 157 deletions
+12 -3
View File
@@ -54,6 +54,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
(username, e-mail), password, and the default DMARC report address (username, e-mail), password, and the default DMARC report address
(SelfPost hosted, my account e-mail, another address, none). Saving the (SelfPost hosted, my account e-mail, another address, none). Saving the
profile no longer asks for the password; changing the password still does. profile no longer asks for the password; changing the password still does.
- panel-redesign (stage 1): the application form is one POST. Who the
application may send as, its client-IP allow-list and its rate limit
("use the domain limit", manual or auto) are validated together and saved
together or not at all — also at creation, so an application can be added
with its limit already in place. The page that shows a generated password
is the response to its POST only and is sent with `Cache-Control: no-store`.
### Changed ### Changed
@@ -62,8 +68,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
old paths are removed, not redirected — see *Removed*. Polled fragments all old paths are removed, not redirected — see *Removed*. Polled fragments all
answer `<page>/fragment`. Everything under `/server/` is guarded for the answer `<page>/fragment`. Everything under `/server/` is guarded for the
global role where the routes are registered, in addition to each handler's global role where the routes are registered, in addition to each handler's
own check. The application routes and Server › Health follow in the next own check. An application is addressed under its domain
two steps. (`/outbound/domains/{id}/applications/{aid}`), and an application id that
does not belong to the domain in the path is a 404.
- **Breaking — the database starts from zero.** The 1.x migration chain - **Breaking — the database starts from zero.** The 1.x migration chain
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x (`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
`/data/selfpost.db` is refused at start-up with an explicit error and left `/data/selfpost.db` is refused at start-up with an explicit error and left
@@ -89,7 +96,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
`/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`, `/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`,
`/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users` `/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users`
and everything under it, `/settings`, and the `/account` → `/settings` and everything under it, `/settings`, and the `/account` → `/settings`
redirect. redirect; `POST /domains/{id}/applications` and `/applications/{aid}/mode`,
`/authips`, `/ratelimit`, `/ratelimit/recalc`, `/password`, `/delete` — the
three separate saves of an application are one form now.
- The instance setting `dmarc_report_email` (a mirror of the global user's - The instance setting `dmarc_report_email` (a mirror of the global user's
profile field) and the unused `rate_limits.allowed_ips` column. profile field) and the unused `rate_limits.allowed_ips` column.
+1 -1
View File
@@ -508,7 +508,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus** - [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus**
- [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus** - [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus**
- [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus** - [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
- [ ] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus** - [x] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus**
- [ ] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus** - [ ] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus**
**Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs) **Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs)
+1 -1
View File
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — | | panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 13/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 14/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
+8
View File
@@ -183,3 +183,11 @@ func dumpSASLDB(path string) ([]byte, error) {
} }
return out, nil return out, nil
} }
// WithRunner replaces the command that runs saslpasswd2 and returns the same
// manager. It exists for tests outside this package that need an application
// service without the real binary; the panel never calls it.
func (s *SASLDB) WithRunner(run func(args []string, stdin []byte) error) *SASLDB {
s.run = run
return s
}
+114
View File
@@ -275,3 +275,117 @@ func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddre
} }
return parseAddresses(rawAddresses, d.Name) return parseAddresses(rawAddresses, d.Name)
} }
// Limit is an application's level-2 rate-limit choice on the application form.
// An empty Mode means "use the domain limit": the application has no limit of
// its own.
type Limit struct {
Mode string // "", store.RateLimitModeManual or store.RateLimitModeAuto
MaxMessages int
WindowSeconds int
AutoMultiplier float64
}
// Settings is everything the application form holds besides the login: who the
// application may send as, which client IPs may use it, and its rate limit.
// The form is one POST, so the three are validated and saved together.
type Settings struct {
Mode string
Addresses []string // raw; list mode only
AuthIPRestrict bool
AuthAllowedIPs []string
Limit Limit
}
// Recalc derives an auto limit's ceiling from send statistics. The numbers it
// needs (retention, the level-1 backstop) are the panel's configuration, so
// the caller supplies it.
type Recalc func(appID int64) error
// CreateWithSettings adds an application with all of its settings in one step
// and returns the password to show once. If anything after the creation fails
// the application is removed again: the form either produces a complete
// application or none.
func (s *Service) CreateWithSettings(domainID int64, login string, set Settings, recalc Recalc) (store.Application, string, error) {
a, password, err := s.Create(domainID, login, set.Mode, set.Addresses)
if err != nil {
return store.Application{}, "", err
}
if err := s.applyAccess(a.ID, set, recalc); err != nil {
_ = s.ClearRateLimit(a.ID)
s.rollbackCreate(a.ID, login)
_ = s.Resync()
return store.Application{}, "", err
}
a, err = s.store.GetApplication(a.ID)
if err != nil {
return store.Application{}, "", err
}
return a, password, nil
}
// SaveSettings replaces an application's settings in one step. The sender
// rules are validated before anything is written; if a later write fails, what
// was already changed is put back, so the application is left as it was rather
// than half-saved. The login and the password are untouched.
func (s *Service) SaveSettings(id int64, set Settings, recalc Recalc) error {
before, err := s.store.GetApplication(id)
if err != nil {
return err
}
addresses, err := s.validateForDomain(before.DomainID, before.Login, set.Mode, set.Addresses)
if err != nil {
return err
}
limitBefore, hadLimit, err := s.RateLimit(id)
if err != nil {
return err
}
restore := func() {
_ = s.store.UpdateApplicationMode(id, before.AddressMode, before.Addresses)
_ = s.store.UpdateApplicationAuthIPs(id, before.AuthIPRestrict, before.AuthAllowedIPs)
if hadLimit {
_ = s.SaveRateLimit(id, limitBefore)
} else {
_ = s.ClearRateLimit(id)
}
_ = s.Resync()
}
if err := s.store.UpdateApplicationMode(id, set.Mode, addresses); err != nil {
restore()
return err
}
if err := s.applyAccess(id, set, recalc); err != nil {
restore()
return err
}
if err := s.Resync(); err != nil {
restore()
return err
}
return nil
}
// applyAccess writes the client-IP restriction and the rate limit.
func (s *Service) applyAccess(id int64, set Settings, recalc Recalc) error {
if err := s.UpdateAuthIPs(id, set.AuthIPRestrict, set.AuthAllowedIPs); err != nil {
return err
}
switch set.Limit.Mode {
case "":
return s.ClearRateLimit(id)
case store.RateLimitModeAuto:
rl := store.RateLimit{Mode: store.RateLimitModeAuto, AutoMultiplier: set.Limit.AutoMultiplier, WindowSeconds: set.Limit.WindowSeconds}
if err := s.SaveRateLimit(id, rl); err != nil {
return err
}
if recalc == nil {
return nil
}
return recalc(id)
default:
return s.SaveRateLimit(id, store.RateLimit{
Mode: store.RateLimitModeManual, MaxMessages: set.Limit.MaxMessages, WindowSeconds: set.Limit.WindowSeconds,
})
}
}
+172
View File
@@ -318,3 +318,175 @@ func TestServicePurgeDomainSASL(t *testing.T) {
t.Errorf("deleted %d logins, want 2", len(rec.deleted)) t.Errorf("deleted %d logins, want 2", len(rec.deleted))
} }
} }
// The application form is one POST: sender, client IPs and rate limit arrive
// together and are saved together.
func TestCreateWithSettings(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
recalced := 0
a, pw, err := svc.CreateWithSettings(d.ID, "prod", Settings{
Mode: store.AddressModeList, Addresses: []string{"alerts@example.com"},
AuthIPRestrict: true, AuthAllowedIPs: []string{"203.0.113.10"},
Limit: Limit{Mode: store.RateLimitModeManual, MaxMessages: 200, WindowSeconds: 3600},
}, func(int64) error { recalced++; return nil })
if err != nil {
t.Fatalf("CreateWithSettings: %v", err)
}
if rec.set["prod"] != pw || pw == "" {
t.Errorf("sasl password %q != returned %q", rec.set["prod"], pw)
}
if a.AddressMode != store.AddressModeList || len(a.Addresses) != 1 || !a.AuthIPRestrict || len(a.AuthAllowedIPs) != 1 {
t.Errorf("application = %+v", a)
}
rl, ok, err := svc.RateLimit(a.ID)
if err != nil || !ok || rl.MaxMessages != 200 || rl.WindowSeconds != 3600 || rl.IsAuto() {
t.Errorf("rate limit = %+v, %v, %v", rl, ok, err)
}
if recalced != 0 {
t.Error("a manual limit was recalculated")
}
// Auto: the ceiling comes from the caller's recalculation.
b, _, err := svc.CreateWithSettings(d.ID, "auto", Settings{
Mode: store.AddressModeWildcard, Limit: Limit{Mode: store.RateLimitModeAuto, AutoMultiplier: 2.5, WindowSeconds: 3600},
}, func(int64) error { recalced++; return nil })
if err != nil {
t.Fatalf("CreateWithSettings auto: %v", err)
}
if rl, ok, _ := svc.RateLimit(b.ID); !ok || !rl.IsAuto() || rl.AutoMultiplier != 2.5 || recalced != 1 {
t.Errorf("auto limit = %+v, %v; recalculated %d time(s)", rl, ok, recalced)
}
// No limit of its own: the domain limit applies.
c, _, err := svc.CreateWithSettings(d.ID, "plain", Settings{Mode: store.AddressModeWildcard}, nil)
if err != nil {
t.Fatalf("CreateWithSettings plain: %v", err)
}
if _, ok, _ := svc.RateLimit(c.ID); ok {
t.Error("an application created without a limit has one")
}
}
// A failure after the application row exists must leave no application: not in
// the registry, not in sasldb2, and no orphaned limit.
func TestCreateWithSettingsIsAllOrNothing(t *testing.T) {
svc, st, rec, _ := newServiceHarness(t)
d := addDomain(t, st, "example.com")
_, _, err := svc.CreateWithSettings(d.ID, "prod", Settings{
Mode: store.AddressModeWildcard, AuthIPRestrict: true, AuthAllowedIPs: []string{"203.0.113.10"},
Limit: Limit{Mode: store.RateLimitModeAuto, AutoMultiplier: 2, WindowSeconds: 3600},
}, func(int64) error { return errors.New("no statistics") })
if err == nil {
t.Fatal("CreateWithSettings succeeded although the recalculation failed")
}
if apps, _ := st.ListApplicationsByDomain(d.ID); len(apps) != 0 {
t.Errorf("%d application(s) left behind", len(apps))
}
if _, ok := rec.set["prod"]; ok {
t.Error("the SASL account was left behind")
}
// The id is free again and carries nothing over.
a, _, err := svc.CreateWithSettings(d.ID, "prod", Settings{Mode: store.AddressModeWildcard}, nil)
if err != nil {
t.Fatalf("create again: %v", err)
}
if _, ok, _ := svc.RateLimit(a.ID); ok || a.AuthIPRestrict {
t.Errorf("the new application inherited settings of the failed one: %+v", a)
}
// A sender rule that does not validate creates nothing either.
if _, _, err := svc.CreateWithSettings(d.ID, "bad", Settings{Mode: store.AddressModeList, Addresses: []string{"a@evil.com"}}, nil); err == nil {
t.Error("a cross-domain address was accepted")
}
if _, ok := rec.set["bad"]; ok {
t.Error("a SASL account was written for a refused application")
}
}
func TestSaveSettings(t *testing.T) {
svc, st, rec, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, pw, err := svc.CreateWithSettings(d.ID, "prod", Settings{
Mode: store.AddressModeWildcard, Limit: Limit{Mode: store.RateLimitModeManual, MaxMessages: 100, WindowSeconds: 3600},
}, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
err = svc.SaveSettings(a.ID, Settings{
Mode: store.AddressModeList, Addresses: []string{"alerts@example.com", "noreply@example.com"},
AuthIPRestrict: true, AuthAllowedIPs: []string{"203.0.113.10", "203.0.113.11"},
}, nil)
if err != nil {
t.Fatalf("SaveSettings: %v", err)
}
got, _ := st.GetApplication(a.ID)
if got.AddressMode != store.AddressModeList || len(got.Addresses) != 2 || !got.AuthIPRestrict || len(got.AuthAllowedIPs) != 2 {
t.Errorf("application = %+v", got)
}
if _, ok, _ := svc.RateLimit(a.ID); ok {
t.Error("choosing the domain limit left the application's own limit in place")
}
if len(maps.last) != 2 {
t.Errorf("sender map has %d binding(s), want the two listed addresses", len(maps.last))
}
if rec.set["prod"] != pw {
t.Error("saving the settings touched the password")
}
}
// Nothing is half-saved: a sender rule that does not validate changes nothing,
// and a failure in a later write puts the earlier ones back.
func TestSaveSettingsIsAllOrNothing(t *testing.T) {
svc, st, _, maps := newServiceHarness(t)
d := addDomain(t, st, "example.com")
a, _, err := svc.CreateWithSettings(d.ID, "prod", Settings{
Mode: store.AddressModeList, Addresses: []string{"alerts@example.com"},
AuthIPRestrict: true, AuthAllowedIPs: []string{"203.0.113.10"},
Limit: Limit{Mode: store.RateLimitModeManual, MaxMessages: 100, WindowSeconds: 3600},
}, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
unchanged := func(when string) {
t.Helper()
got, _ := st.GetApplication(a.ID)
rl, ok, _ := svc.RateLimit(a.ID)
if got.AddressMode != store.AddressModeList || len(got.Addresses) != 1 || got.Addresses[0] != "alerts@example.com" ||
!got.AuthIPRestrict || len(got.AuthAllowedIPs) != 1 || !ok || rl.MaxMessages != 100 || rl.IsAuto() {
t.Errorf("%s: the application changed: %+v, limit %+v (%v)", when, got, rl, ok)
}
}
change := Settings{
Mode: store.AddressModeWildcard, AuthIPRestrict: false,
Limit: Limit{Mode: store.RateLimitModeAuto, AutoMultiplier: 3, WindowSeconds: 3600},
}
bad := change
bad.Mode, bad.Addresses = store.AddressModeList, []string{"a@evil.com"}
if err := svc.SaveSettings(a.ID, bad, nil); err == nil {
t.Fatal("a cross-domain address was accepted")
}
unchanged("after a refused sender rule")
if err := svc.SaveSettings(a.ID, change, func(int64) error { return errors.New("no statistics") }); err == nil {
t.Fatal("SaveSettings succeeded although the recalculation failed")
}
unchanged("after a failed recalculation")
maps.failNext = true
if err := svc.SaveSettings(a.ID, change, func(int64) error { return nil }); err == nil {
t.Fatal("SaveSettings succeeded although the sender map could not be rebuilt")
}
unchanged("after a failed sender-map rebuild")
if len(maps.last) != 1 || maps.last[0].Address != "alerts@example.com" {
t.Errorf("the sender map was left at %+v", maps.last)
}
if err := svc.SaveSettings(a.ID+100, change, nil); !errors.Is(err, store.ErrApplicationNotFound) {
t.Errorf("unknown application = %v, want ErrApplicationNotFound", err)
}
}
+164 -44
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dmarc" "github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
@@ -53,7 +54,10 @@ type appRateLimitView struct {
AutoMultiplier string AutoMultiplier string
AutoUpdated string AutoUpdated string
IsAuto bool IsAuto bool
Stats sendStatsView // RLMode is the form's limit choice: "domain" (no limit of its own),
// "manual" or "auto".
RLMode string
Stats sendStatsView
} }
// sendStatsView is the template-facing send statistics block. // sendStatsView is the template-facing send statistics block.
@@ -122,6 +126,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
Mode: mode, Mode: mode,
AutoMultiplier: formatMultiplier(mult), AutoMultiplier: formatMultiplier(mult),
IsAuto: ok && rl.IsAuto(), IsAuto: ok && rl.IsAuto(),
RLMode: limitChoice(rl, ok),
AutoUpdated: formatAutoUpdated(rl.AutoUpdatedAt), AutoUpdated: formatAutoUpdated(rl.AutoUpdatedAt),
Stats: formatSendStats(appStats), Stats: formatSendStats(appStats),
}) })
@@ -293,6 +298,19 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
} }
// limitChoice names which of the form's three limit choices an application has:
// the domain limit (none of its own), manual, or auto.
func limitChoice(rl store.RateLimit, configured bool) string {
switch {
case !configured:
return "domain"
case rl.IsAuto():
return store.RateLimitModeAuto
default:
return store.RateLimitModeManual
}
}
// intOrBlank renders a non-positive number as an empty string so an unset field // intOrBlank renders a non-positive number as an empty string so an unset field
// shows blank rather than "0". // shows blank rather than "0".
func intOrBlank(n int) string { func intOrBlank(n int) string {
@@ -336,12 +354,10 @@ func detailFlash(r *http.Request) string {
switch { switch {
case r.URL.Query().Get("appdeleted") != "": case r.URL.Query().Get("appdeleted") != "":
return "Application deleted." return "Application deleted."
case r.URL.Query().Get("modeupdated") != "": case r.URL.Query().Get("appsaved") != "":
return "Application address mode updated." return "Application saved."
case r.URL.Query().Get("ratelimit") != "": case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated." return "Rate limit updated."
case r.URL.Query().Get("authips") != "":
return "Client IP restriction updated."
case r.URL.Query().Get("recalculated") != "": case r.URL.Query().Get("recalculated") != "":
return "Auto rate limit recalculated." return "Auto rate limit recalculated."
case r.URL.Query().Get("dmarc") != "": case r.URL.Query().Get("dmarc") != "":
@@ -355,11 +371,79 @@ func detailFlash(r *http.Request) string {
} }
} }
// HandleAddApplication creates an application on a domain and renders the page // The application form is ONE post (plan § Routes): who the application may
// back with the generated password shown once (product.md, security.md). Because the // send as, which client IPs may use it and its rate limit are parsed and
// password cannot be recovered later, this deliberately renders inline rather // validated together and saved together, or not at all (app.Service
// than redirecting. // CreateWithSettings / SaveSettings). The same form, empty, adds an
func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) { // application; filled, it edits one. The login is given once, at creation.
// parseApplicationForm reads the form's three parts. Every field is checked
// here or by the service before anything is written.
func (h *Handlers) parseApplicationForm(r *http.Request) (app.Settings, error) {
set := app.Settings{
Mode: r.PostFormValue("mode"),
Addresses: splitAddresses(r.PostFormValue("addresses")),
}
restrict, ips, err := parseAppAuthIPsForm(r)
if err != nil {
return app.Settings{}, err
}
set.AuthIPRestrict, set.AuthAllowedIPs = restrict, ips
switch mode := strings.TrimSpace(r.PostFormValue("rl_mode")); mode {
case "", "domain":
// Use the domain limit: the application has none of its own.
case store.RateLimitModeAuto:
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
if err != nil {
return app.Settings{}, err
}
set.Limit = app.Limit{Mode: mode, AutoMultiplier: mult, WindowSeconds: h.l1Window()}
case store.RateLimitModeManual:
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
if err != nil || maxMessages <= 0 {
return app.Settings{}, fmt.Errorf("enter a message limit greater than zero, or use the domain limit")
}
if l1 := h.l1Messages(); maxMessages > l1 {
return app.Settings{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1)
}
window, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
if err != nil || window <= 0 {
return app.Settings{}, fmt.Errorf("enter a time window greater than zero seconds")
}
set.Limit = app.Limit{Mode: mode, MaxMessages: maxMessages, WindowSeconds: window}
default:
return app.Settings{}, fmt.Errorf("choose the domain limit, manual or auto")
}
return set, nil
}
func (h *Handlers) recalcApp(appID int64) error {
return h.recalcRateLimit(store.RateLimitScopeApp, appID)
}
// noStore keeps a page that shows a secret out of every cache and out of the
// browser's history copy: the password on it is shown once.
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
}
// HandleApplicationNew shows the form that adds an application. Until the
// domain page is split (stage 2) the form is a card of that page.
func (h *Handlers) HandleApplicationNew(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
}
// HandleApplicationCreate creates an application with all of its settings and
// answers with the generated password, shown once (product.md, security.md).
// The password cannot be recovered later, so the page is the response to this
// POST — it has no GET path — and is never stored by the browser.
func (h *Handlers) HandleApplicationCreate(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
return return
@@ -370,16 +454,18 @@ func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request)
return return
} }
login := strings.TrimSpace(r.PostFormValue("login")) login := strings.TrimSpace(r.PostFormValue("login"))
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
repopulate := detailView{ repopulate := detailView{
FormLogin: login, FormLogin: login,
FormMode: mode, FormMode: r.PostFormValue("mode"),
FormAddrs: r.PostFormValue("addresses"), FormAddrs: r.PostFormValue("addresses"),
} }
set, err := h.parseApplicationForm(r)
a, password, err := h.apps.Create(d.ID, login, mode, addrs) if err != nil {
repopulate.FormErr = err.Error()
h.renderDomainDetail(w, r, http.StatusBadRequest, d, repopulate)
return
}
a, password, err := h.apps.CreateWithSettings(d.ID, login, set, h.recalcApp)
if err != nil { if err != nil {
repopulate.FormErr = applicationErrorMessage(err) repopulate.FormErr = applicationErrorMessage(err)
status := http.StatusBadRequest status := http.StatusBadRequest
@@ -389,58 +475,71 @@ func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request)
h.renderDomainDetail(w, r, status, d, repopulate) h.renderDomainDetail(w, r, status, d, repopulate)
return return
} }
noStore(w)
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{ h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password}, NewCred: &newCred{Login: a.Login, Password: password},
}) })
} }
// HandleUpdateAppMode switches an application's address mode / list (product.md). // HandleApplicationEdit shows an application's form. Until the domain page is
func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) { // split (stage 2) that is the application's Edit panel on the domain page.
a, ok := h.lookupApplication(w, r) func (h *Handlers) HandleApplicationEdit(w http.ResponseWriter, r *http.Request) {
_, d, ok := h.lookupDomainApplication(w, r)
if !ok { if !ok {
return return
} }
if err := r.ParseForm(); err != nil { h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
http.Error(w, "invalid form", http.StatusBadRequest) }
return
}
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil { // HandleApplicationSave saves an application's form: sender, client IPs and
d, derr := h.domains.Get(a.DomainID) // rate limit in one step.
if derr != nil { func (h *Handlers) HandleApplicationSave(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError) a, d, ok := h.lookupDomainApplication(w, r)
return if !ok {
}
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
FormMode: store.AddressModeWildcard,
})
return return
} }
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther) fail := func(msg string) {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, msg),
})
}
if err := r.ParseForm(); err != nil {
fail("invalid form submission")
return
}
// The login is the SASL account's name and is not renamed by this form.
if login := strings.TrimSpace(r.PostFormValue("login")); login != "" && login != a.Login {
fail("the login of an application cannot be changed; add a new application instead")
return
}
set, err := h.parseApplicationForm(r)
if err != nil {
fail(err.Error())
return
}
if err := h.apps.SaveSettings(a.ID, set, h.recalcApp); err != nil {
fail(applicationErrorMessage(err))
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?appsaved=1", d.ID), http.StatusSeeOther)
} }
// HandleRegenPassword issues a new password for an application and shows it once // HandleRegenPassword issues a new password for an application and shows it once
// (product.md, security.md). Rendered inline, like creation, so the password is visible. // (product.md, security.md): the response to the POST, never cached.
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r) a, d, ok := h.lookupDomainApplication(w, r)
if !ok { if !ok {
return return
} }
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
password, err := h.apps.RegeneratePassword(a.ID) password, err := h.apps.RegeneratePassword(a.ID)
if err != nil { if err != nil {
logf("panel: regenerate password for application %d: %v", a.ID, err) logf("panel: regenerate password for application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
noStore(w)
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{ h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password}, NewCred: &newCred{Login: a.Login, Password: password},
@@ -450,7 +549,7 @@ func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
// HandleDeleteApplication removes an application and returns to its domain page // HandleDeleteApplication removes an application and returns to its domain page
// (product.md). // (product.md).
func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r) a, _, ok := h.lookupDomainApplication(w, r)
if !ok { if !ok {
return return
} }
@@ -462,6 +561,27 @@ func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Reques
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
} }
// lookupDomainApplication resolves /outbound/domains/{id}/applications/{aid}
// to the application and its domain. The application must belong to the domain
// named in the path: an id from another domain is answered 404 like a missing
// one, whoever asks, so a URL can never address an application through a
// domain it is not in.
func (h *Handlers) lookupDomainApplication(w http.ResponseWriter, r *http.Request) (store.Application, store.Domain, bool) {
d, ok := h.lookupDomain(w, r)
if !ok {
return store.Application{}, store.Domain{}, false
}
a, ok := h.lookupApplication(w, r)
if !ok {
return store.Application{}, store.Domain{}, false
}
if a.DomainID != d.ID {
http.NotFound(w, r)
return store.Application{}, store.Domain{}, false
}
return a, d, true
}
// lookupApplication resolves the {aid} path value to an application, writing a // lookupApplication resolves the {aid} path value to an application, writing a
// 404 for a bad id or missing application. // 404 for a bad id or missing application.
func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) { func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
+258
View File
@@ -0,0 +1,258 @@
package handlers
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// offlineDNS answers every check from a resolver nobody listens on. It is
// shared, so the one slow failure is paid once and cached for the other tests.
var offlineDNS = dnscheck.New([]string{"127.0.0.1:9"})
type nopSenderMaps struct{}
func (nopSenderMaps) RebuildSenderLoginMaps([]postfix.Binding) error { return nil }
// appStand is a panel with one sending domain and a working application
// service whose SASL writes are recorded instead of run.
type appStand struct {
h *Handlers
d store.Domain
sasl map[string]string
paths map[string]string
}
func newAppStand(t *testing.T) appStand {
t.Helper()
h, _ := settingsServer(t)
s := appStand{h: h, sasl: map[string]string{}}
sasl := app.NewSASLDB("/data/sasl/sasldb2", "mail.example.org").WithRunner(func(args []string, stdin []byte) error {
login := args[len(args)-1]
for _, a := range args {
if a == "-d" {
delete(s.sasl, login)
return nil
}
}
s.sasl[login] = string(stdin)
return nil
})
h.apps = app.NewService(h.store, sasl, nopSenderMaps{})
// The domain page is rendered with the domain's DKIM record and its DNS
// state: a real key in a temporary directory, and a resolver nobody
// listens on, so every check answers at once without leaving the machine.
odk := domain.NewOpenDKIM(t.TempDir())
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
t.Fatalf("EnsureKey: %v", err)
}
h.domains = domain.NewService(h.store, odk, h.apps, "mail")
h.dns = offlineDNS
h.cfg.RateLimitMessagesPerIP, h.cfg.RateLimitWindowSeconds = 600, 3600
var err error
if s.d, err = h.store.AddDomain("example.org", "mail"); err != nil {
t.Fatal(err)
}
s.paths = map[string]string{"id": idStr(s.d.ID)}
return s
}
func (s appStand) app(t *testing.T, login string) (store.Application, store.RateLimit, bool) {
t.Helper()
a, err := s.h.store.GetApplicationByLogin(login)
if err != nil {
return store.Application{}, store.RateLimit{}, false
}
rl, ok, err := s.h.store.GetRateLimit(store.RateLimitScopeApp, a.ID)
if err != nil {
t.Fatal(err)
}
return a, rl, ok
}
func TestParseApplicationForm(t *testing.T) {
h := &Handlers{cfg: Config{RateLimitMessagesPerIP: 600, RateLimitWindowSeconds: 3600}}
parse := func(v url.Values) (app.Settings, error) {
r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(v.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return h.parseApplicationForm(r)
}
set, err := parse(url.Values{
"mode": {"list"}, "addresses": {"a@example.org\nb@example.org"},
"auth_ip_restrict": {"1"}, "auth_allowed_ips": {"203.0.113.10, 203.0.113.11"},
"rl_mode": {"manual"}, "max_messages": {"200"}, "window_seconds": {"1800"},
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if set.Mode != "list" || len(set.Addresses) != 2 || !set.AuthIPRestrict || len(set.AuthAllowedIPs) != 2 ||
set.Limit.Mode != store.RateLimitModeManual || set.Limit.MaxMessages != 200 || set.Limit.WindowSeconds != 1800 {
t.Errorf("settings = %+v", set)
}
// The limit choice: nothing or "domain" means no limit of its own; auto
// takes the level-1 window and a default multiplier.
for _, v := range []url.Values{{"mode": {"wildcard"}}, {"mode": {"wildcard"}, "rl_mode": {"domain"}, "max_messages": {"50"}}} {
if set, err := parse(v); err != nil || set.Limit.Mode != "" || set.Limit.MaxMessages != 0 {
t.Errorf("%v = %+v, %v; want the domain limit", v, set.Limit, err)
}
}
if set, err := parse(url.Values{"rl_mode": {"auto"}}); err != nil || set.Limit.Mode != store.RateLimitModeAuto ||
set.Limit.AutoMultiplier != store.DefaultAutoMultiplier || set.Limit.WindowSeconds != 3600 {
t.Errorf("auto = %+v, %v", set.Limit, err)
}
for name, v := range map[string]url.Values{
"manual without a number": {"rl_mode": {"manual"}},
"manual above level 1": {"rl_mode": {"manual"}, "max_messages": {"601"}},
"manual with a zero window": {"rl_mode": {"manual"}, "max_messages": {"10"}, "window_seconds": {"0"}},
"an unknown limit mode": {"rl_mode": {"always"}},
"a multiplier out of range": {"rl_mode": {"auto"}, "auto_multiplier": {"99"}},
"the allow-list without IPs": {"auth_ip_restrict": {"1"}},
"something that is not an IP": {"auth_ip_restrict": {"1"}, "auth_allowed_ips": {"not-an-ip"}},
} {
if _, err := parse(v); err == nil {
t.Errorf("%s was accepted", name)
}
}
}
// One POST creates the application with its sender rule, client IPs and limit,
// and the response is the page with the password — shown once, never cached.
func TestApplicationCreateIsOnePost(t *testing.T) {
s := newAppStand(t)
rec := postFormAs(s.h.HandleApplicationCreate, globalPrincipal, "/outbound/domains/1/applications/new", s.paths, url.Values{
"login": {"prod-server"}, "mode": {"list"}, "addresses": {"alerts@example.org"},
"auth_ip_restrict": {"1"}, "auth_allowed_ips": {"203.0.113.10"},
"rl_mode": {"manual"}, "max_messages": {"200"}, "window_seconds": {"3600"},
})
if rec.Code != http.StatusCreated {
t.Fatalf("create = %d:\n%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("Cache-Control = %q; the page shows a password once and must not be stored", got)
}
password := s.sasl["prod-server"]
if password == "" || !strings.Contains(rec.Body.String(), password) {
t.Errorf("the response does not show the generated password")
}
a, rl, ok := s.app(t, "prod-server")
if a.AddressMode != store.AddressModeList || len(a.Addresses) != 1 || !a.AuthIPRestrict || len(a.AuthAllowedIPs) != 1 ||
!ok || rl.MaxMessages != 200 || rl.WindowSeconds != 3600 {
t.Errorf("application = %+v, limit %+v (%v)", a, rl, ok)
}
// The password page has no GET of its own: the form's address, read
// again, shows the form and not the secret.
again := postFormAs(s.h.HandleApplicationEdit, globalPrincipal, "/outbound/domains/1/applications/1",
map[string]string{"id": idStr(s.d.ID), "aid": idStr(a.ID)}, nil)
if strings.Contains(again.Body.String(), password) {
t.Error("a later request shows the password again")
}
}
// A form that does not validate creates nothing — whichever of its three parts
// is wrong, and although the other two are fine.
func TestApplicationCreateIsAllOrNothing(t *testing.T) {
s := newAppStand(t)
good := url.Values{
"login": {"prod-server"}, "mode": {"wildcard"},
"auth_ip_restrict": {"1"}, "auth_allowed_ips": {"203.0.113.10"},
"rl_mode": {"manual"}, "max_messages": {"200"},
}
for name, change := range map[string][2]string{
"a bad client IP": {"auth_allowed_ips", "nope"},
"a limit above level 1": {"max_messages", "100000"},
"an address of another domain": {"addresses", "a@evil.example"},
"a bad login": {"login", "has space"},
} {
form := url.Values{}
for k, v := range good {
form[k] = v
}
form.Set(change[0], change[1])
if change[0] == "addresses" {
form.Set("mode", "list")
}
rec := postFormAs(s.h.HandleApplicationCreate, globalPrincipal, "/outbound/domains/1/applications/new", s.paths, form)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: %d, want 400", name, rec.Code)
}
}
apps, _ := s.h.store.ListApplicationsByDomain(s.d.ID)
if len(apps) != 0 || len(s.sasl) != 0 {
t.Fatalf("the refused forms left %d application(s) and %d SASL account(s)", len(apps), len(s.sasl))
}
}
func TestApplicationSaveIsOnePost(t *testing.T) {
s := newAppStand(t)
a, pw, err := s.h.apps.CreateWithSettings(s.d.ID, "prod-server", app.Settings{
Mode: store.AddressModeWildcard, Limit: app.Limit{Mode: store.RateLimitModeManual, MaxMessages: 100, WindowSeconds: 3600},
}, nil)
if err != nil {
t.Fatal(err)
}
paths := map[string]string{"id": idStr(s.d.ID), "aid": idStr(a.ID)}
save := func(form url.Values) int {
return postFormAs(s.h.HandleApplicationSave, globalPrincipal, "/outbound/domains/1/applications/1", paths, form).Code
}
if code := save(url.Values{
"mode": {"list"}, "addresses": {"alerts@example.org"},
"auth_ip_restrict": {"1"}, "auth_allowed_ips": {"203.0.113.10"}, "rl_mode": {"domain"},
}); code != http.StatusSeeOther {
t.Fatalf("save = %d", code)
}
got, _, hasLimit := s.app(t, "prod-server")
if got.AddressMode != store.AddressModeList || !got.AuthIPRestrict || hasLimit {
t.Errorf("after the save: %+v, own limit: %v", got, hasLimit)
}
if s.sasl["prod-server"] != pw {
t.Error("saving the form changed the password")
}
// One wrong part refuses the whole form and leaves all three as they were.
if code := save(url.Values{
"mode": {"wildcard"}, "rl_mode": {"manual"}, "max_messages": {"100000"},
}); code != http.StatusBadRequest {
t.Errorf("a limit above level 1 = %d, want 400", code)
}
if got, _, hasLimit := s.app(t, "prod-server"); got.AddressMode != store.AddressModeList || !got.AuthIPRestrict || hasLimit {
t.Errorf("a refused form changed the application: %+v, own limit: %v", got, hasLimit)
}
// The login is not renamed through this form.
if code := save(url.Values{"login": {"renamed"}, "mode": {"wildcard"}}); code != http.StatusBadRequest {
t.Errorf("a changed login = %d, want 400", code)
}
if _, err := s.h.store.GetApplicationByLogin("renamed"); err == nil {
t.Error("the application was renamed")
}
}
// A new password is the response to its POST, shown once and never cached.
func TestRegeneratedPasswordIsNotCached(t *testing.T) {
s := newAppStand(t)
a, old, err := s.h.apps.CreateWithSettings(s.d.ID, "prod-server", app.Settings{Mode: store.AddressModeWildcard}, nil)
if err != nil {
t.Fatal(err)
}
paths := map[string]string{"id": idStr(s.d.ID), "aid": idStr(a.ID)}
rec := postFormAs(s.h.HandleRegenPassword, globalPrincipal, "/outbound/domains/1/applications/1/password", paths, url.Values{})
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("new password = %d, Cache-Control %q", rec.Code, rec.Header().Get("Cache-Control"))
}
now := s.sasl["prod-server"]
if now == old || !strings.Contains(rec.Body.String(), now) {
t.Error("the response does not show the new password")
}
}
@@ -272,7 +272,8 @@ func TestInboundListWithNothingLeftIs404(t *testing.T) {
} }
// The outbound side of the same rule, route by route: a domain administrator // The outbound side of the same rule, route by route: a domain administrator
// asking for another tenant's sending domain or application by id gets 404, // asking for another tenant's sending domain or application by id gets 404 —
// also when the application is addressed through the caller's own domain —
// and so does a user whose reach is inbound only. // and so does a user whose reach is inbound only.
func TestOutboundRoutesAnswerAnotherTenant404(t *testing.T) { func TestOutboundRoutesAnswerAnotherTenant404(t *testing.T) {
h, domains := serverWithTwoDomains(t) h, domains := serverWithTwoDomains(t)
@@ -291,25 +292,35 @@ func TestOutboundRoutesAnswerAnotherTenant404(t *testing.T) {
inboundOnly := userWith(t, h.store, "inbound-only", store.RoleDomain, store.Reach{InboundDomainIDs: []int64{in.ID}}) inboundOnly := userWith(t, h.store, "inbound-only", store.RoleDomain, store.Reach{InboundDomainIDs: []int64{in.ID}})
did := map[string]string{"id": idStr(second.ID)} did := map[string]string{"id": idStr(second.ID)}
aid := map[string]string{"aid": idStr(foreignApp.ID)} // Another tenant's application, addressed through its own domain — and
routes := []struct { // through the caller's own domain, which it is not in: the URL names a
// domain the caller may open, but the application still is not theirs.
aid := map[string]string{"id": idStr(second.ID), "aid": idStr(foreignApp.ID)}
smuggled := map[string]string{"id": idStr(first.ID), "aid": idStr(foreignApp.ID)}
type outRoute struct {
method, target string method, target string
handler http.HandlerFunc handler http.HandlerFunc
pathValues map[string]string pathValues map[string]string
}{ }
routes := []outRoute{
{"GET", "/outbound/domains/{id}", h.HandleDomainDetail, did}, {"GET", "/outbound/domains/{id}", h.HandleDomainDetail, did},
{"GET", "/outbound/domains/{id}/settings", h.HandleDomainDetail, did},
{"POST", "/outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did}, {"POST", "/outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
{"POST", "/outbound/domains/{id}/applications", h.HandleAddApplication, did},
{"POST", "/outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit, did}, {"POST", "/outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit, did},
{"POST", "/outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did}, {"POST", "/outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
{"POST", "/outbound/domains/{id}/settings/reports", h.HandleDomainDMARC, did}, {"POST", "/outbound/domains/{id}/settings/reports", h.HandleDomainDMARC, did},
{"POST", "/outbound/domains/{id}/settings/export", h.HandleExportDomain, did}, {"POST", "/outbound/domains/{id}/settings/export", h.HandleExportDomain, did},
{"POST", "/applications/{aid}/mode", h.HandleUpdateAppMode, aid}, {"GET", "/outbound/domains/{id}/applications/new", h.HandleApplicationNew, did},
{"POST", "/applications/{aid}/authips", h.HandleAppAuthIPs, aid}, {"POST", "/outbound/domains/{id}/applications/new", h.HandleApplicationCreate, did},
{"POST", "/applications/{aid}/password", h.HandleRegenPassword, aid}, }
{"POST", "/applications/{aid}/ratelimit", h.HandleAppRateLimit, aid}, for _, pv := range []map[string]string{aid, smuggled} {
{"POST", "/applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc, aid}, routes = append(routes,
{"POST", "/applications/{aid}/delete", h.HandleDeleteApplication, aid}, outRoute{"GET", "/outbound/domains/{id}/applications/{aid}", h.HandleApplicationEdit, pv},
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}", h.HandleApplicationSave, pv},
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}/password", h.HandleRegenPassword, pv},
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc, pv},
outRoute{"POST", "/outbound/domains/{id}/applications/{aid}/delete", h.HandleDeleteApplication, pv},
)
} }
for _, rt := range routes { for _, rt := range routes {
for who, p := range map[string]*auth.Principal{ for who, p := range map[string]*auth.Principal{
@@ -443,7 +443,7 @@ func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil) req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, p) req = auth.RequestWithPrincipal(req, p)
if rest, ok := strings.CutPrefix(req.URL.Path, "/outbound/log/"); ok && rest != "rows" { if rest, ok := strings.CutPrefix(req.URL.Path, "/outbound/log/"); ok && rest != "fragment" {
req.SetPathValue("id", rest) req.SetPathValue("id", rest)
} }
h(rec, req) h(rec, req)
+1 -54
View File
@@ -165,58 +165,6 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
} }
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in, err := parseRateLimitForm(r, h.l1Messages())
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
if err := h.applyRateLimit(in, store.RateLimitScopeApp, a.ID, h.apps); err != nil {
logf("panel: application %d: save rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppAuthIPs(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
restrict, ips, err := parseAppAuthIPsForm(r)
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
if err := h.apps.UpdateAuthIPs(a.ID, restrict, ips); err != nil {
logf("panel: application %d: save auth IPs: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?authips=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
@@ -234,12 +182,11 @@ func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Re
} }
func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r) a, d, ok := h.lookupDomainApplication(w, r)
if !ok { if !ok {
return return
} }
if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil { if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil {
d, _ := h.domains.Get(a.DomainID)
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()), RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
+19 -29
View File
@@ -228,7 +228,7 @@
above the domains list. */}} above the domains list. */}}
<div class="card" id="add-application"> <div class="card" id="add-application">
<h2>Add an application</h2> <h2>Add an application</h2>
<form method="post" action="/outbound/domains/{{.Domain.ID}}/applications"> <form method="post" action="/outbound/domains/{{.Domain.ID}}/applications/new">
<label for="login">Login</label> <label for="login">Login</label>
<input id="login" name="login" type="text" placeholder="prod-server" <input id="login" name="login" type="text" placeholder="prod-server"
autocomplete="off" autocapitalize="none" spellcheck="false" autocomplete="off" autocapitalize="none" spellcheck="false"
@@ -301,20 +301,22 @@
<div class="actions"> <div class="actions">
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox"> <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> <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" <form class="inline" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately."> data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button> <button type="submit">New password</button>
</form> </form>
<form class="inline" method="post" action="/applications/{{.ID}}/delete" <form class="inline" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}/delete"
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately."> data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button> <button type="submit" class="danger">Delete</button>
</form> </form>
<div class="panel panel-edit"> <div class="panel panel-edit">
{{/* The application form is one POST: sender, client IPs and rate
limit are saved together by the one button under the columns. */}}
<form id="app-{{.ID}}" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}">
<div class="check-cols"> <div class="check-cols">
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Address mode</p> <p class="check-col-title">Address mode</p>
<p class="muted">Which From addresses this application may use.</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"> <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="{{$.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> <option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
@@ -324,8 +326,6 @@
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}} <textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a}}{{end}}</textarea> {{end}}{{$a}}{{end}}</textarea>
</div> </div>
<button type="submit">Save mode</button>
</form>
</div> </div>
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Client IP allow-list <p class="check-col-title">Client IP allow-list
@@ -335,7 +335,6 @@
<code>MAIL FROM</code>. When off, any client IP is allowed. Like the <code>MAIL FROM</code>. When off, any client IP is allowed. Like the
level-2 rate limit this runs in the journal-milter and is fail-open — level-2 rate limit this runs in the journal-milter and is fail-open —
it is a sending control, not an authentication boundary.</p> it is a sending control, not an authentication boundary.</p>
<form method="post" action="/applications/{{.ID}}/authips">
<label class="check-row"> <label class="check-row">
<input type="checkbox" name="auth_ip_restrict" value="1" <input type="checkbox" name="auth_ip_restrict" value="1"
{{if .AuthIPRestrict}}checked{{end}}> {{if .AuthIPRestrict}}checked{{end}}>
@@ -343,8 +342,6 @@
</label> </label>
<textarea name="auth_allowed_ips" rows="2" placeholder="203.0.113.10" <textarea name="auth_allowed_ips" rows="2" placeholder="203.0.113.10"
aria-label="Allowed client IPs">{{.AuthIPsText}}</textarea> aria-label="Allowed client IPs">{{.AuthIPsText}}</textarea>
<button type="submit">Save IP restriction</button>
</form>
</div> </div>
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Level-2 rate limit <p class="check-col-title">Level-2 rate limit
@@ -353,11 +350,11 @@
ceiling may be <em>higher or lower</em> than the domain setting ceiling may be <em>higher or lower</em> than the domain setting
(≤ level&nbsp;1). When unset, the domain limit{{if $.DomainHasRL}} (≤ level&nbsp;1). When unset, the domain limit{{if $.DomainHasRL}}
({{$.DomainRLMaxNum}}){{end}} or level&nbsp;1 applies.</p> ({{$.DomainRLMaxNum}}){{end}} or level&nbsp;1 applies.</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label for="mode-{{.ID}}">Limit mode</label> <label for="mode-{{.ID}}">Limit mode</label>
<select id="mode-{{.ID}}" name="mode" data-ratelimit-mode> <select id="mode-{{.ID}}" name="rl_mode" data-ratelimit-mode>
<option value="manual" {{if not .IsAuto}}selected{{end}}>Manual</option> <option value="domain" {{if eq .RLMode "domain"}}selected{{end}}>Use the domain limit</option>
<option value="auto" {{if .IsAuto}}selected{{end}}>Auto (from statistics)</option> <option value="manual" {{if eq .RLMode "manual"}}selected{{end}}>Manual</option>
<option value="auto" {{if eq .RLMode "auto"}}selected{{end}}>Auto (from statistics)</option>
</select> </select>
<div data-manual-fields> <div data-manual-fields>
<div class="field-pair"> <div class="field-pair">
@@ -381,24 +378,17 @@
messages are sent.{{if .AutoUpdated}} Last recalculated {{.AutoUpdated}}.{{end}} messages are sent.{{if .AutoUpdated}} Last recalculated {{.AutoUpdated}}.{{end}}
Peak {{.Stats.PeakPerHour}} msg/h shown for comparison.</p> Peak {{.Stats.PeakPerHour}} msg/h shown for comparison.</p>
</div> </div>
</form>
<div class="panel-buttons">
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .IsAuto}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
{{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> </div>
</div> </div>
</form>
<div class="panel-buttons">
<button type="submit" form="app-{{.ID}}">Save application</button>
{{if .IsAuto}}
<form class="inline" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
</div>
</div> </div>
</div> </div>
</li> </li>
+10 -7
View File
@@ -202,13 +202,16 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit) authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit)
authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc) authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc)
authed.HandleFunc("POST /outbound/domains/{id}/settings/export", h.HandleExportDomain) authed.HandleFunc("POST /outbound/domains/{id}/settings/export", h.HandleExportDomain)
authed.HandleFunc("POST /outbound/domains/{id}/applications", h.HandleAddApplication) // An application lives under its domain, and its form is one POST: sender,
authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode) // client IPs and rate limit are saved together. The page that shows a new
authed.HandleFunc("POST /applications/{aid}/authips", h.HandleAppAuthIPs) // password is the response to its POST and has no GET path.
authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword) authed.HandleFunc("GET /outbound/domains/{id}/applications/new", h.HandleApplicationNew)
authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit) authed.HandleFunc("POST /outbound/domains/{id}/applications/new", h.HandleApplicationCreate)
authed.HandleFunc("POST /applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc) authed.HandleFunc("GET /outbound/domains/{id}/applications/{aid}", h.HandleApplicationEdit)
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}", h.HandleApplicationSave)
authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}/password", h.HandleRegenPassword)
authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc)
authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("GET /outbound/log", h.HandleDeliveries) authed.HandleFunc("GET /outbound/log", h.HandleDeliveries)
authed.HandleFunc("GET /outbound/log/fragment", h.HandleDeliveriesRows) authed.HandleFunc("GET /outbound/log/fragment", h.HandleDeliveriesRows)
+1 -1
View File
@@ -26,7 +26,7 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := sc.panel.setRateLimit(fmt.Sprintf("/applications/%s/ratelimit", appID), 1, 3600); err != nil { if err := sc.panel.setApplicationLimit(sc.domainID, appID, 1, 3600); err != nil {
t.Fatalf("save application rate limit: %v", err) t.Fatalf("save application rate limit: %v", err)
} }
+8 -5
View File
@@ -172,7 +172,7 @@ func (c *panelClient) dkimRecord(domainID string) (name, value string, err error
// login/password the panel renders inline (security.md — never recoverable // login/password the panel renders inline (security.md — never recoverable
// later, so this is the only place to read it). // later, so this is the only place to read it).
func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) { func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) {
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications", url.Values{ resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications/new", url.Values{
"login": {login}, "login": {login},
"mode": {mode}, "mode": {mode},
"addresses": {addresses}, "addresses": {addresses},
@@ -194,14 +194,17 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
return appLogin, password, nil return appLogin, password, nil
} }
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application // setApplicationLimit saves a manual level-2 limit (guide § Rate limiting) on
// or domain (/applications/{id}/ratelimit or /outbound/domains/{id}/settings/ratelimit). // an application. The application form is one POST, so the sender rule goes
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error { // with it: the application stays on the domain wildcard it was created with.
func (c *panelClient) setApplicationLimit(domainID, appID string, maxMessages, windowSeconds int) error {
vals := url.Values{ vals := url.Values{
"mode": {"wildcard"},
"rl_mode": {"manual"},
"max_messages": {fmt.Sprintf("%d", maxMessages)}, "max_messages": {fmt.Sprintf("%d", maxMessages)},
"window_seconds": {fmt.Sprintf("%d", windowSeconds)}, "window_seconds": {fmt.Sprintf("%d", windowSeconds)},
} }
resp, body, err := c.postForm(path, vals) resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications/"+appID, vals)
if err != nil { if err != nil {
return err return err
} }