Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6af1a7866 | |||
| aab35f0679 | |||
| 0570608738 |
@@ -44,12 +44,53 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
still authenticate to Postfix. This matches the order domain deletion already
|
||||
used.
|
||||
|
||||
- panel (GUI): a rejected rate-limit change on a domain's page now renders on
|
||||
the danger surface (`.flash.error`) instead of the success one — it was
|
||||
green with red text, reading as good news. Deleting a panel user now goes
|
||||
through a confirmation page, the same pattern as domain deletion, instead of
|
||||
a plain submit button next to Save with no confirmation at all.
|
||||
|
||||
### Changed
|
||||
|
||||
- test: the authorization and sign-in surfaces that had no tests now have them.
|
||||
The login limiter is covered for its ceiling, its per-address scope, the reset
|
||||
at the end of a window and the sweep that keeps finished buckets out of
|
||||
memory; sign-in for a successful session, for refusals that do not reveal
|
||||
which usernames exist, and for a lockout that a correct password cannot
|
||||
bypass; the one-time setup link for creating the first administrator, closing
|
||||
afterwards, rejecting a wrong or expired token, and refusing credentials the
|
||||
panel would not accept later. Every global-only route (`/users`, `/backup`,
|
||||
domain import, `/status`, `/mail-queue`, `/system-log`, domain add and delete,
|
||||
reload) is checked to answer a domain administrator — and a request with no
|
||||
principal — with 404, the check that would have caught the send-log leak.
|
||||
|
||||
- test (e2e): the CoreDNS image is pinned to `1.14.6` instead of `latest`, so
|
||||
the release gate cannot change under a commit between two runs. The level-1
|
||||
rate-limit failure message quoted `RATE_LIMIT_MESSAGES_PER_IP=5` while the
|
||||
stand sets `50`.
|
||||
|
||||
- ci: gofmt on eight files that failed the formatting workflow check (panel
|
||||
config, DNS check, domain transfer export, rate-limit tests, auth principal,
|
||||
domain and delivery handlers, web package doc comment).
|
||||
|
||||
- panel (templates): the repeated Host/Type/Value DNS record markup on a
|
||||
domain's page and the duplicated credentials form on Settings are now
|
||||
shared partials (`host_type`, `host_type_copy`, `field_value`,
|
||||
`field_values`, `credentials_fields`) instead of copy-pasted blocks. No
|
||||
behaviour or visible change.
|
||||
|
||||
- panel (GUI, accessibility): the Deliveries fragment's `hx-get` and pagination
|
||||
links now `urlquery`-encode the `domain`/`app` filters instead of splicing
|
||||
them into the query string raw. The four polled regions (deliveries rows,
|
||||
status, mail queue, system log) carry `aria-live="polite"` so a screen
|
||||
reader announces the refreshed content.
|
||||
|
||||
- docs: [security.md](docs/security.md) accepted risks now note that
|
||||
`data-confirm` prompts on destructive forms are JavaScript-only — with
|
||||
JavaScript disabled the form submits immediately, the same as before the
|
||||
prompts existed — and why that is acceptable (the prompt is a mis-click
|
||||
guard, not an authorization boundary).
|
||||
|
||||
- docs: full-tree review plan ([docs/plans/code-review.md](docs/plans/code-review.md))
|
||||
— architecture, quality, GUI, tests, licence; P0 is domain-admin send-log
|
||||
authorization. Roadmap queues that plan ahead of inbound-relay and records
|
||||
|
||||
+19
-11
@@ -545,12 +545,13 @@ roles, and security.md no longer calls the panel single-user.
|
||||
|
||||
**Model: Sonnet.**
|
||||
|
||||
- [ ] `.flash.error` (or stop using `.flash` for `RateLimitErr`) — danger
|
||||
- [x] `.flash.error` (or stop using `.flash` for `RateLimitErr`) — danger
|
||||
surface, not success.
|
||||
- [ ] User delete: `data-confirm` at minimum; prefer a confirm page like
|
||||
domain delete.
|
||||
- [ ] DNS field partial; settings credentials partial.
|
||||
- [ ] Optional: `urlquery` on deliveries fragment params; `aria-live` on
|
||||
- [x] User delete: `data-confirm` at minimum; prefer a confirm page like
|
||||
domain delete. Done as a confirm page (`GET/POST /users/{uid}/delete`),
|
||||
matching `domain_delete.html`.
|
||||
- [x] DNS field partial; settings credentials partial.
|
||||
- [x] Optional: `urlquery` on deliveries fragment params; `aria-live` on
|
||||
polled regions; confirm-without-JS note next to the CSRF accepted risks.
|
||||
|
||||
**Done when:** a rate-limit validation error is visually an error; user delete
|
||||
@@ -561,12 +562,19 @@ cannot be a single unmarked click.
|
||||
**Model: Opus** for auth/RBAC/limiter tests; **Haiku** for the L1 fatal-string
|
||||
typo; **Sonnet** if e2e docs need a paragraph.
|
||||
|
||||
- [ ] `auth/ratelimit.go` unit tests (window, lockout, sweep).
|
||||
- [ ] Login/setup handler tests (happy path + lockout + setup expiry).
|
||||
- [ ] Domain-admin 404 on `/users`, `/backup`, `/mail-queue`, `/system-log`,
|
||||
`/status` (if not already implied by P0 tests).
|
||||
- [ ] Fix e2e L1 fatal string (`50`, not `5`).
|
||||
- [ ] Pin `coredns` image digest or a major version.
|
||||
- [x] `auth/ratelimit.go` unit tests (window, lockout, sweep). Also the
|
||||
per-key scope: one locked-out address must not lock out the others.
|
||||
- [x] Login/setup handler tests (happy path + lockout + setup expiry). The
|
||||
lockout test also states that a correct password does not bypass it, and
|
||||
that the two refusals are byte-identical (no username enumeration).
|
||||
- [x] Domain-admin 404 on `/users`, `/backup`, `/mail-queue`, `/system-log`,
|
||||
`/status` — as a table of every global-only route (`internal/web/handlers/authz_test.go`),
|
||||
including the write routes, plus the same 404 for a request with no
|
||||
principal and a positive control so the table cannot pass on a handler
|
||||
that always 404s.
|
||||
- [x] Fix e2e L1 fatal string (`50`, not `5`).
|
||||
- [x] Pin `coredns` image: tag `1.14.6`, not a digest — the tag is a multi-arch
|
||||
manifest and the stand has to come up on arm64 developer machines.
|
||||
- [ ] Optional later: backup extract + `CheckRestore` + panel boot (heavy;
|
||||
e2e or integration). Not a P4 blocker.
|
||||
|
||||
|
||||
@@ -127,6 +127,19 @@ deferred item from the roadmap.
|
||||
either: code executing in the panel's origin sends the request itself —
|
||||
against that, `html/template` auto-escaping and CSP do the work, which is why
|
||||
templates must contain no inline scripts and no inline styles.
|
||||
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
|
||||
Delete, regenerate-password, and clear-rate-limit forms carry a
|
||||
`data-confirm` prompt handled entirely in
|
||||
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
|
||||
or blocked the form submits immediately, exactly as it did before the
|
||||
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
|
||||
not an authorization boundary — the same origin check and session/RBAC
|
||||
gate every one of these `POST`s whether or not JavaScript ran. Progressive
|
||||
enhancement means the panel must work with JavaScript off; a
|
||||
server-rendered confirmation step would need a second page (or a `?confirm=1`
|
||||
round trip) for every one of these forms, which is what
|
||||
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
|
||||
`domain_delete.html` already do for the two highest-blast-radius deletes.
|
||||
- **Encrypting backups and exports is an option, not the default.** With the
|
||||
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
|
||||
operator with nowhere to keep a password would lose the ability to take a
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const testPassword = "correct-horse-battery"
|
||||
|
||||
// moduleWithAdmin returns a panel that has already been through setup, with one
|
||||
// global administrator whose password is testPassword.
|
||||
func moduleWithAdmin(t *testing.T) *Module {
|
||||
t.Helper()
|
||||
m := testModule(t, false)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
if err := m.store.CreateGlobalUser("admin", string(hash)); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// postLogin submits the sign-in form from remoteAddr (the limiter's key) and
|
||||
// returns what the handler wrote.
|
||||
func postLogin(m *Module, remoteAddr, username, password string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"username": {username}, "password": {password}}
|
||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login",
|
||||
strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.RemoteAddr = remoteAddr
|
||||
rec := httptest.NewRecorder()
|
||||
m.HandleLogin(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
// sessionCookieValue returns the session token the response issued, or "" if it
|
||||
// issued none.
|
||||
func sessionCookieValue(t *testing.T, m *Module, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == m.sessionCookie() {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestLoginSignsInWithTheRightPassword(t *testing.T) {
|
||||
m := moduleWithAdmin(t)
|
||||
|
||||
rec := postLogin(m, "203.0.113.7:5000", "admin", testPassword)
|
||||
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("status = %d, Location = %q; want a redirect to /", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
token := sessionCookieValue(t, m, rec)
|
||||
if token == "" {
|
||||
t.Fatal("no session cookie was issued")
|
||||
}
|
||||
name, ok := m.sessions.Lookup(token)
|
||||
if !ok || name != "admin" {
|
||||
t.Fatalf("the cookie's session resolves to %q, %t; want admin", name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused sign-in must not say which half was wrong: the panel is public, and
|
||||
// distinguishable answers would turn the form into a list of usernames.
|
||||
func TestLoginRefusesBadCredentialsWithoutSayingWhy(t *testing.T) {
|
||||
m := moduleWithAdmin(t)
|
||||
|
||||
bodies := make(map[string]string, 2)
|
||||
for name, creds := range map[string][2]string{
|
||||
"wrong password": {"admin", "not-the-password"},
|
||||
"unknown user": {"nobody", testPassword},
|
||||
} {
|
||||
rec := postLogin(m, "203.0.113.7:5000", creds[0], creds[1])
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s: status = %d, want 401", name, rec.Code)
|
||||
}
|
||||
if got := sessionCookieValue(t, m, rec); got != "" {
|
||||
t.Errorf("%s: a session cookie was issued: %q", name, got)
|
||||
}
|
||||
bodies[name] = rec.Body.String()
|
||||
}
|
||||
if bodies["wrong password"] != bodies["unknown user"] {
|
||||
t.Error("the two refusals differ, so the form tells an attacker which usernames exist")
|
||||
}
|
||||
}
|
||||
|
||||
// The lockout is what makes online guessing pointless, so it has to hold even
|
||||
// for the request that finally carries the right password — and it has to be
|
||||
// scoped to the address that spent the attempts.
|
||||
func TestLoginLocksOutAfterTooManyAttempts(t *testing.T) {
|
||||
m := moduleWithAdmin(t)
|
||||
const attacker = "203.0.113.7:5000"
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if rec := postLogin(m, attacker, "admin", "guess"); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: status = %d, want 401 (still under the limit)", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
rec := postLogin(m, attacker, "admin", testPassword)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status = %d, want 429; the lockout was bypassed by guessing right", rec.Code)
|
||||
}
|
||||
if got := sessionCookieValue(t, m, rec); got != "" {
|
||||
t.Errorf("a locked-out request was signed in: %q", got)
|
||||
}
|
||||
|
||||
if rec := postLogin(m, "198.51.100.9:5000", "admin", testPassword); rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("another address got %d; one guesser locked out the whole internet", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Before the first administrator exists there is nothing to sign in as, so the
|
||||
// form is replaced by a pointer to the setup link rather than a password box
|
||||
// that can never succeed.
|
||||
func TestLoginPointsAtSetupBeforeTheFirstAdministrator(t *testing.T) {
|
||||
m := testModule(t, false)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
m.HandleLogin(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/login", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "No administrator has been created yet") {
|
||||
t.Errorf("the login page does not point at the setup link:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `name="password"`) {
|
||||
t.Errorf("the login page offers a password field with no account to use it:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// getSetup performs the GET the operator's browser makes when it follows the
|
||||
// one-time link.
|
||||
func getSetup(m *Module, token string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
m.HandleSetup(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/setup/"+token, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func postSetup(m *Module, token string, form url.Values) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/setup/"+token,
|
||||
strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
m.HandleSetup(rec, r)
|
||||
return rec
|
||||
}
|
||||
|
||||
func setupForm(username, password, confirm string) url.Values {
|
||||
return url.Values{
|
||||
"username": {username},
|
||||
"password": {password},
|
||||
"password_confirm": {confirm},
|
||||
}
|
||||
}
|
||||
|
||||
// The setup link creates the first global administrator and then stops
|
||||
// existing: the persistent fact is the user row, so the link is dead after a
|
||||
// restart too, not only for the process that served it.
|
||||
func TestSetupCreatesTheFirstAdministratorAndThenCloses(t *testing.T) {
|
||||
m := testModule(t, false)
|
||||
token, ok := m.setup.activeToken()
|
||||
if !ok {
|
||||
t.Fatal("no setup token on a panel with no users")
|
||||
}
|
||||
|
||||
if rec := getSetup(m, token); rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET the setup link = %d, want the form", rec.Code)
|
||||
}
|
||||
|
||||
rec := postSetup(m, token, setupForm("operator", "a-long-enough-password", "a-long-enough-password"))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
u, err := m.store.GetUserByUsername("operator")
|
||||
if err != nil {
|
||||
t.Fatalf("the administrator was not created: %v", err)
|
||||
}
|
||||
if u.Role != RoleGlobal {
|
||||
t.Errorf("the first administrator has role %q, want global", u.Role)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("a-long-enough-password")); err != nil {
|
||||
t.Errorf("the stored hash does not match the password that was set: %v", err)
|
||||
}
|
||||
|
||||
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("the setup link still answers %d after setup completed, want 404", rec.Code)
|
||||
}
|
||||
if rec := postSetup(m, token, setupForm("second", "a-long-enough-password", "a-long-enough-password")); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("a second administrator could be created through the setup link (%d)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A token that is wrong, or one that has aged out and been replaced, is not a
|
||||
// hint that setup exists: both answer 404, the same as any unknown path.
|
||||
func TestSetupRejectsAWrongOrExpiredToken(t *testing.T) {
|
||||
m := testModule(t, false)
|
||||
token, ok := m.setup.activeToken()
|
||||
if !ok {
|
||||
t.Fatal("no setup token on a panel with no users")
|
||||
}
|
||||
|
||||
if rec := getSetup(m, token+"x"); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("a wrong token answered %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
expireSetupToken(m)
|
||||
|
||||
if rec := getSetup(m, token); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("the expired token still opens setup (%d)", rec.Code)
|
||||
}
|
||||
fresh, _ := m.setup.activeToken()
|
||||
if fresh == token {
|
||||
t.Fatal("the expired token was not replaced")
|
||||
}
|
||||
if rec := getSetup(m, fresh); rec.Code != http.StatusOK {
|
||||
t.Errorf("the reissued token does not open setup (%d)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The first account is the one that can never be locked out of the panel from
|
||||
// outside, so the rules that apply to every other user apply here too — before
|
||||
// anything is written.
|
||||
func TestSetupRejectsCredentialsItWouldNotAcceptLater(t *testing.T) {
|
||||
for name, form := range map[string]url.Values{
|
||||
"username too short": setupForm("op", "a-long-enough-password", "a-long-enough-password"),
|
||||
"username not ASCII": setupForm("оператор", "a-long-enough-password", "a-long-enough-password"),
|
||||
"passwords differ": setupForm("operator", "a-long-enough-password", "a-long-enough-passwerd"),
|
||||
"password too short": setupForm("operator", "short", "short"),
|
||||
"no password at all": setupForm("operator", "", ""),
|
||||
"no username at all": setupForm("", "a-long-enough-password", "a-long-enough-password"),
|
||||
} {
|
||||
m := testModule(t, false)
|
||||
token, _ := m.setup.activeToken()
|
||||
|
||||
rec := postSetup(m, token, form)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status = %d, want 400", name, rec.Code)
|
||||
}
|
||||
if exists, err := m.store.UserExists(); err != nil || exists {
|
||||
t.Errorf("%s: an administrator was created anyway (err=%v)", name, err)
|
||||
}
|
||||
if rec := getSetup(m, token); rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: the setup link was burned by a rejected form (%d)", name, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup is unauthenticated by definition, so the only thing between the token
|
||||
// and an offline guesser is the limiter in front of it.
|
||||
func TestSetupIsRateLimited(t *testing.T) {
|
||||
m := testModule(t, false)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("attempt %d: status = %d, want 404 (still under the limit)", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status = %d, want 429 after the eleventh attempt", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// expireSetupToken ages the current token out, the state the panel reaches when
|
||||
// nobody follows the link within setupTokenTTL.
|
||||
func expireSetupToken(m *Module) {
|
||||
m.setup.mu.Lock()
|
||||
defer m.setup.mu.Unlock()
|
||||
m.setup.expiresAt = m.setup.expiresAt.Add(-2 * setupTokenTTL)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The limiter is what stands between the public login form and an unlimited
|
||||
// guessing rate (security.md), so the ceiling has to be exact: the configured
|
||||
// number of attempts go through and the next one does not, however often it is
|
||||
// repeated.
|
||||
func TestRateLimiterStopsAtTheCeiling(t *testing.T) {
|
||||
r := newRateLimiter(3, time.Minute)
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
if !r.Allow("203.0.113.7") {
|
||||
t.Fatalf("attempt %d of 3 was refused before the ceiling", i)
|
||||
}
|
||||
}
|
||||
for i := 4; i <= 6; i++ {
|
||||
if r.Allow("203.0.113.7") {
|
||||
t.Fatalf("attempt %d passed after the ceiling of 3", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buckets are per key, so one locked-out address must not lock out the rest of
|
||||
// the internet — a shared counter would turn a single guesser into a denial of
|
||||
// service against every operator.
|
||||
func TestRateLimiterKeepsKeysApart(t *testing.T) {
|
||||
r := newRateLimiter(1, time.Minute)
|
||||
|
||||
if !r.Allow("203.0.113.7") || r.Allow("203.0.113.7") {
|
||||
t.Fatal("the first key did not use up its single attempt")
|
||||
}
|
||||
if !r.Allow("198.51.100.9") {
|
||||
t.Fatal("a second address was refused because another one was locked out")
|
||||
}
|
||||
}
|
||||
|
||||
// The window is fixed, not sliding: once it has elapsed the count starts again
|
||||
// from zero rather than being carried over. Time is moved by ageing the bucket
|
||||
// instead of sleeping, so the test states the boundary rather than approaching
|
||||
// it.
|
||||
func TestRateLimiterReopensAfterTheWindow(t *testing.T) {
|
||||
r := newRateLimiter(2, time.Minute)
|
||||
r.Allow("203.0.113.7")
|
||||
r.Allow("203.0.113.7")
|
||||
if r.Allow("203.0.113.7") {
|
||||
t.Fatal("the ceiling was not reached")
|
||||
}
|
||||
|
||||
expire(r, "203.0.113.7")
|
||||
|
||||
if !r.Allow("203.0.113.7") {
|
||||
t.Fatal("the key is still locked out after its window ended")
|
||||
}
|
||||
if !r.Allow("203.0.113.7") {
|
||||
t.Fatal("the new window did not start from an empty count")
|
||||
}
|
||||
if r.Allow("203.0.113.7") {
|
||||
t.Fatal("the new window allowed more than the ceiling")
|
||||
}
|
||||
}
|
||||
|
||||
// Every address that ever tried to sign in gets a bucket, and the only thing
|
||||
// that removes the finished ones is the sweep on a new window. It runs on the
|
||||
// key that triggered it as well as on the others, so a long-running panel does
|
||||
// not accumulate a bucket per source address for ever.
|
||||
func TestRateLimiterSweepsFinishedBuckets(t *testing.T) {
|
||||
r := newRateLimiter(2, time.Minute)
|
||||
for _, key := range []string{"203.0.113.7", "198.51.100.9"} {
|
||||
r.Allow(key)
|
||||
expire(r, key)
|
||||
}
|
||||
r.Allow("192.0.2.5") // still inside its window
|
||||
|
||||
r.Allow("203.0.113.7") // new window for this key: sweeps the rest
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.buckets["198.51.100.9"]; ok {
|
||||
t.Error("a bucket whose window ended is still held")
|
||||
}
|
||||
for _, key := range []string{"203.0.113.7", "192.0.2.5"} {
|
||||
if _, ok := r.buckets[key]; !ok {
|
||||
t.Errorf("the sweep dropped %s, whose window is still open", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expire moves a key's window into the past, the same state it would reach by
|
||||
// waiting for the window to elapse.
|
||||
func expire(r *rateLimiter, key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if b := r.buckets[key]; b != nil {
|
||||
b.windowEnds = time.Now().Add(-time.Second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// route is one entry of the authenticated mux, named the way web.go registers
|
||||
// it so a route added there without a guard is visible as a missing case here.
|
||||
type route struct {
|
||||
method string
|
||||
target string
|
||||
handler func(*Handlers) http.HandlerFunc
|
||||
// pathValues are the {id}-style segments the router would have bound.
|
||||
pathValues map[string]string
|
||||
}
|
||||
|
||||
// globalOnlyRoutes is every page and action that only a global administrator
|
||||
// may reach: the panel's users, the whole-server backup and domain import, the
|
||||
// machine-wide status and log views, and the domain lifecycle. A domain
|
||||
// administrator is answered 404 rather than 403 so the panel does not confirm
|
||||
// that the page exists (security.md).
|
||||
var globalOnlyRoutes = []route{
|
||||
{"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
|
||||
{"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
|
||||
{"POST", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
|
||||
{"GET", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
|
||||
{"POST", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
|
||||
{"GET", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
|
||||
{"POST", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
|
||||
|
||||
{"GET", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
|
||||
{"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
|
||||
{"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
|
||||
|
||||
{"GET", "/status", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
|
||||
{"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
|
||||
{"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
|
||||
|
||||
{"GET", "/mail-queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
|
||||
{"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
|
||||
{"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
|
||||
{"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
|
||||
|
||||
{"POST", "/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
|
||||
{"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
|
||||
{"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
|
||||
{"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
|
||||
}
|
||||
|
||||
// A domain administrator has an account on the panel, so authentication is not
|
||||
// what keeps them off these pages — the per-handler role check is. Each of them
|
||||
// is reached here with a valid session for a principal that owns a domain, the
|
||||
// case the send-log leak (P0, code-review.md) showed is easy to get wrong.
|
||||
func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) {
|
||||
h, domains := serverWithTwoDomains(t)
|
||||
p := domainAdmin(domains["first.example.ru"].ID)
|
||||
|
||||
for _, rt := range globalOnlyRoutes {
|
||||
rec := call(h, rt, p)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s as a domain administrator = %d, want 404:\n%s",
|
||||
rt.method, rt.target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same 404 covers a request that carries no principal at all: the auth
|
||||
// middleware normally redirects those, but a handler must not depend on
|
||||
// middleware it cannot see for the role it enforces itself.
|
||||
func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) {
|
||||
h, _ := serverWithTwoDomains(t)
|
||||
|
||||
for _, rt := range globalOnlyRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(rt.method, rt.target, nil)
|
||||
for k, v := range rt.pathValues {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
rt.handler(h)(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s with no principal = %d, want 404", rt.method, rt.target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 404s above would also pass if a handler were broken into always returning
|
||||
// one, so at least the two pages that need nothing but the store and the view
|
||||
// have to be shown opening for a global administrator.
|
||||
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
|
||||
h, _ := serverWithTwoDomains(t)
|
||||
|
||||
for _, target := range []string{"/users", "/backup"} {
|
||||
rt := getRoute(t, target)
|
||||
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
|
||||
target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getRoute(t *testing.T, target string) route {
|
||||
t.Helper()
|
||||
for _, rt := range globalOnlyRoutes {
|
||||
if rt.method == http.MethodGet && rt.target == target {
|
||||
return rt
|
||||
}
|
||||
}
|
||||
t.Fatalf("no GET %s among the global-only routes", target)
|
||||
return route{}
|
||||
}
|
||||
|
||||
func call(h *Handlers, rt route, p auth.Principal) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(rt.method, rt.target, nil)
|
||||
req = auth.RequestWithPrincipal(req, p)
|
||||
for k, v := range rt.pathValues {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
rt.handler(h)(rec, req)
|
||||
return rec
|
||||
}
|
||||
@@ -102,10 +102,6 @@ func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
@@ -291,6 +287,58 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
|
||||
// removed — the same pattern as HandleDeleteConfirm for domains, so a single
|
||||
// mis-click on Delete cannot remove a user (P3, code-review.md).
|
||||
func (h *Handlers) HandleUserDeleteConfirm(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
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — delete " + u.Username
|
||||
data["Active"] = "users"
|
||||
data["TargetID"] = u.ID
|
||||
data["TargetUsername"] = u.Username
|
||||
h.view.Render(w, http.StatusOK, "user_delete", data)
|
||||
}
|
||||
|
||||
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
|
||||
// and returns to the user list.
|
||||
func (h *Handlers) HandleUserDelete(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
|
||||
}
|
||||
h.submitUserDelete(w, r, u)
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
|
||||
@@ -245,6 +245,11 @@ form.inline { display: inline; margin: 0; }
|
||||
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: 6px; margin-bottom: 1.2rem; }
|
||||
/* RateLimitErr renders as .flash.error: a validation failure, not a success
|
||||
notice. Without this rule it inherited the success surface (green) and only
|
||||
.error's text colour, so a rejected rate limit read as good news in red
|
||||
text. Same box, danger palette. */
|
||||
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{{define "deliveries_rows"}}
|
||||
<div id="deliveries-rows" data-poll hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
|
||||
<div id="deliveries-rows" data-poll aria-live="polite"
|
||||
hx-get="/deliveries/rows?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.Page}}"
|
||||
hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Rows}}
|
||||
<table>
|
||||
@@ -14,15 +15,15 @@
|
||||
<td>{{.To}}</td>
|
||||
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></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>
|
||||
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain | urlquery}}&app={{$.FilterApp | urlquery}}&p={{$.Page}}">Details</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">
|
||||
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
|
||||
{{if .HasPrev}} · <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||
{{if .HasNext}} · <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||
{{if .HasPrev}} · <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||
{{if .HasNext}} · <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="muted">No messages logged yet.</p>
|
||||
|
||||
@@ -2,6 +2,61 @@
|
||||
measure (same pattern as Status). */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{/* The Host/name ‖ Type field-pair repeats for every DNS record this page
|
||||
shows (DKIM, SPF, DMARC, report authorization) in both the status card
|
||||
and the publishable-record cards below it — only the host and whether it
|
||||
carries a Copy button change. Two variants rather than one templated
|
||||
Copy flag: the DNS status card never offers Copy (its host is derived,
|
||||
not something to paste), the record cards always do. */}}
|
||||
{{define "host_type"}}
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "host_type_copy"}}
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Value partials pair with the two host_type variants above: field_values
|
||||
is the DNS status card's raw record dump (no Copy — those values are for
|
||||
comparison, not for pasting), field_value is the single publishable
|
||||
value on the record cards below (always has Copy). Callers still guard
|
||||
the empty case, since "no records yet" and "one blank record" read
|
||||
differently. */}}
|
||||
{{define "field_values"}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
|
||||
{{define "field_value"}}
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
@@ -37,21 +92,8 @@
|
||||
<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}}
|
||||
{{template "host_type" .Record.Name}}
|
||||
{{if .DNS.DKIM.Records}}{{template "field_values" .DNS.DKIM.Records}}{{end}}
|
||||
{{if ne .DNS.DKIM.Status "ok"}}
|
||||
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
||||
{{end}}
|
||||
@@ -59,21 +101,8 @@
|
||||
|
||||
<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}}
|
||||
{{template "host_type" .Domain.Name}}
|
||||
{{if .DNS.SPF.Records}}{{template "field_values" .DNS.SPF.Records}}{{end}}
|
||||
{{if ne .DNS.SPF.Status "ok"}}
|
||||
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
|
||||
{{end}}
|
||||
@@ -83,21 +112,8 @@
|
||||
|
||||
<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}}
|
||||
{{template "host_type" .DMARCName}}
|
||||
{{if .DNS.DMARC.Records}}{{template "field_values" .DNS.DMARC.Records}}{{end}}
|
||||
{{if ne .DNS.DMARC.Status "ok"}}
|
||||
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
||||
{{else}}
|
||||
@@ -108,21 +124,8 @@
|
||||
<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}}
|
||||
{{template "host_type" .ReportAuthName}}
|
||||
{{if .DNS.DMARCReportAuth.Records}}{{template "field_values" .DNS.DMARCReportAuth.Records}}{{end}}
|
||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||
{{else}}
|
||||
<label>Report authorization</label>
|
||||
@@ -141,49 +144,15 @@
|
||||
<h2>DKIM and SPF records</h2>
|
||||
|
||||
<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>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
{{template "host_type_copy" .Record.Name}}
|
||||
{{template "field_value" .Record.Value}}
|
||||
|
||||
<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>
|
||||
{{template "host_type_copy" .Domain.Name}}
|
||||
{{template "field_value" .SPFExample}}
|
||||
|
||||
<p class="muted">Merge into an existing SPF if the domain already has one —
|
||||
do not publish a second record.</p>
|
||||
@@ -192,19 +161,7 @@
|
||||
<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>
|
||||
{{template "host_type_copy" .DMARCName}}
|
||||
|
||||
<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">
|
||||
@@ -220,25 +177,8 @@
|
||||
|
||||
{{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>
|
||||
{{template "host_type_copy" .ReportAuthName}}
|
||||
{{template "field_value" .ReportAuthValue}}
|
||||
{{end}}
|
||||
|
||||
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "mail_queue_body"}}
|
||||
<div id="mail-queue-body" data-poll hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div id="mail-queue-body" data-poll aria-live="polite" hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
in panel.css, as on a delivery's page). */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{/* The username/password fields are identical for a global administrator
|
||||
(split card, DMARC alongside) and a domain administrator (narrow card,
|
||||
no DMARC card) — only the surrounding form and card differ. */}}
|
||||
{{define "credentials_fields"}}
|
||||
<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">
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Settings</h1>
|
||||
|
||||
@@ -16,19 +35,7 @@
|
||||
<p class="muted">These are the credentials for this control panel only.
|
||||
Applications keep their own logins and passwords, which are not affected.</p>
|
||||
|
||||
<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">
|
||||
{{template "credentials_fields" .}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -81,19 +88,7 @@ this one stays signed in.</p>
|
||||
<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">
|
||||
{{template "credentials_fields" .}}
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "status_body"}}
|
||||
<div id="status-body" data-poll hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div id="status-body" data-poll aria-live="polite" hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="card" id="overall">
|
||||
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
||||
<p class="muted">{{.OverallHeading}}</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "system_log_body"}}
|
||||
<div id="system-log-body" data-poll hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div id="system-log-body" data-poll aria-live="polite" hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
||||
{{end}}{{else}}No log lines yet.{{end}}</span>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "content"}}
|
||||
<h1>Delete {{.TargetUsername}}</h1>
|
||||
|
||||
{{template "back_link" (back (printf "/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}}
|
||||
|
||||
<div class="card narrow">
|
||||
<h2>Confirm deletion</h2>
|
||||
<p>You are about to delete the panel user <strong>{{.TargetUsername}}</strong>.
|
||||
This cannot be undone; a signed-in session for this user stops working
|
||||
immediately.</p>
|
||||
|
||||
<form method="post" action="/users/{{.TargetID}}/delete">
|
||||
<button type="submit" class="danger">Delete {{.TargetUsername}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -33,10 +33,13 @@
|
||||
</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>
|
||||
{{if .IsEdit}}
|
||||
{{if .LastGlobalLocked}}
|
||||
<p class="muted">The only global administrator cannot be deleted.</p>
|
||||
{{else}}
|
||||
<a class="danger" href="/users/{{.UserID}}/delete">Delete user</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -282,6 +282,7 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
drillDown := map[string]bool{
|
||||
"user_form.html": true,
|
||||
"user_delete.html": true,
|
||||
"domain_detail.html": true,
|
||||
"domain_delete.html": true,
|
||||
"delivery.html": true,
|
||||
|
||||
@@ -37,6 +37,7 @@ var pageFiles = map[string][]string{
|
||||
"settings": {"templates/settings.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"user_delete": {"templates/user_delete.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"},
|
||||
|
||||
@@ -166,6 +166,8 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("POST /users/new", h.HandleUserNew)
|
||||
authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit)
|
||||
authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit)
|
||||
authed.HandleFunc("GET /users/{uid}/delete", h.HandleUserDeleteConfirm)
|
||||
authed.HandleFunc("POST /users/{uid}/delete", h.HandleUserDelete)
|
||||
|
||||
authed.HandleFunc("GET /backup", h.HandleBackupPage)
|
||||
authed.HandleFunc("POST /backup", h.HandleBackup)
|
||||
|
||||
@@ -60,8 +60,13 @@ services:
|
||||
# records the test harness publishes, so Postfix's outbound MX lookup for
|
||||
# the sink and the panel's own dnscheck resolve inside the isolated network
|
||||
# instead of hitting the real internet.
|
||||
#
|
||||
# Pinned to a release tag rather than `latest`, so a CoreDNS upload cannot
|
||||
# change what the release gate runs against between two runs of the same
|
||||
# commit. Not a digest: the tag is a multi-arch manifest and the stand has to
|
||||
# come up on arm64 developer machines as well as amd64 CI.
|
||||
coredns:
|
||||
image: coredns/coredns:latest
|
||||
image: coredns/coredns:1.14.6
|
||||
command: ["-conf", "/dns/Corefile"]
|
||||
volumes:
|
||||
- ./dns-stage:/dns
|
||||
|
||||
@@ -219,5 +219,5 @@ func testLevel1RateLimit(t *testing.T, sc *scenario) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=5) never tripped after %d sends", maxAttempts)
|
||||
t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user