hub (unreleased): R-922 option A — a household's clear deletes its notification address (email_cleared); MAIL-HOLD — a restored hub sends no mail until released; two log lines drop the address; runbooks: mail hold is restore step 1; 07 §6.4 R-921 pre-check; R-921/R-922 narrowed
gates / gates (push) Successful in 5m25s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-09 12:37:28 +02:00
parent 2c48feb325
commit d55c590c5a
35 changed files with 861 additions and 23 deletions
+1
View File
@@ -50,6 +50,7 @@ func TestTemplatesParseWithFuncmap(t *testing.T) {
"memoryColor": memoryColor,
"accuracyClass": accuracyClass,
"gt": func(a, b int) bool { return false },
"mailHeld": func() bool { return false },
}).ParseFS(templateFS, "templates/*.html"); err != nil {
t.Fatalf("templates failed to parse: %v", err)
}
+135
View File
@@ -0,0 +1,135 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
)
// MAIL-HOLD on the operator UI: the banner on every operator page while the marker exists, and the release endpoint
// behind the operator's auth + the R-135 CSRF gate.
const mailHoldBannerText = "E-mail is on hold"
func holdIn(t *testing.T, held bool) (*mailhold.Hold, string) {
t.Helper()
dir := t.TempDir()
marker := filepath.Join(dir, mailhold.FileName)
if held {
if err := os.WriteFile(marker, nil, 0o644); err != nil {
t.Fatal(err)
}
}
return mailhold.New(dir, nil), marker
}
func getPage(t *testing.T, s *Server, path string) string {
t.Helper()
r := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("GET %s: %d", path, w.Code)
}
return w.Body.String()
}
// Both branches of the {{if mailHeld}} gate, on the dashboard and on Configuration (which also carries the button).
func TestMailHoldBanner_RendersOnlyWhileHeld(t *testing.T) {
for _, held := range []bool{true, false} {
s, _ := newTestServer(t)
h, _ := holdIn(t, held)
s.SetMailHold(h)
for _, p := range []string{"/", "/configuration", "/hosts", "/configs"} {
body := getPage(t, s, p)
if got := strings.Contains(body, mailHoldBannerText); got != held {
t.Errorf("held=%v GET %s: banner shown=%v", held, p, got)
}
}
cfg := getPage(t, s, "/configuration")
if got := strings.Contains(cfg, `action="/configuration/mail-hold/release"`); got != held {
t.Errorf("held=%v: release button shown=%v", held, got)
}
}
}
// No hold wired at all (nil): no banner, pages render.
func TestMailHoldBanner_NilHoldRendersNoBanner(t *testing.T) {
s, _ := newTestServer(t)
if strings.Contains(getPage(t, s, "/"), mailHoldBannerText) {
t.Fatal("a nil hold must render no banner")
}
}
// The operator's release, through the production wiring (RequireAuth around ServeHTTP) with a session and its CSRF
// token: the marker is gone, and the banner with it.
func TestMailHoldRelease_RemovesMarker(t *testing.T) {
s, h := r135Handler(t)
hold, marker := holdIn(t, true)
s.SetMailHold(hold)
s.sessionsMu.Lock()
s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
s.sessionsMu.Unlock()
w := r135Post(h, "/configuration/mail-hold/release", func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"})
r.Header.Set("X-CSRF-Token", "tok1")
})
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=mail_hold_released") {
t.Fatalf("release: %d Location=%q", w.Code, w.Header().Get("Location"))
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("the marker is still there after the release: %v", err)
}
if hold.Held() {
t.Fatal("still held after the release")
}
}
// Refusals: Basic auth without the operator header (the R-135 cross-site shape), a session without its token, and no
// credentials at all. The marker stays in every case.
func TestMailHoldRelease_RefusedWithoutOperatorAuthOrCSRF(t *testing.T) {
s, h := r135Handler(t)
hold, marker := holdIn(t, true)
s.SetMailHold(hold)
s.sessionsMu.Lock()
s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
s.sessionsMu.Unlock()
cases := []struct {
name string
mut func(*http.Request)
ok func(int) bool
}{
{"basic without operator header", func(r *http.Request) { r.SetBasicAuth("", "op-pass") }, func(c int) bool { return c == http.StatusForbidden }},
{"session without token", func(r *http.Request) { r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) }, func(c int) bool { return c == http.StatusForbidden }},
{"no credentials", func(r *http.Request) { r.Header.Set(OperatorCLIHeader, "cli") }, func(c int) bool { return c == http.StatusFound || c == http.StatusUnauthorized }},
}
for _, c := range cases {
w := r135Post(h, "/configuration/mail-hold/release", c.mut)
if !c.ok(w.Code) {
t.Errorf("%s: status %d", c.name, w.Code)
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("%s: the marker was removed by a refused request: %v", c.name, err)
}
}
// The CLI shape (Basic + the operator header) passes.
w := r135Post(h, "/configuration/mail-hold/release", func(r *http.Request) {
r.SetBasicAuth("", "op-pass")
r.Header.Set(OperatorCLIHeader, "cli")
})
if w.Code != http.StatusSeeOther {
t.Fatalf("operator CLI release: %d", w.Code)
}
if hold.Held() {
t.Fatal("operator CLI release left the hold in place")
}
}
+1
View File
@@ -42,6 +42,7 @@ var r135PostRoutes = []string{
"/configuration/global-floor",
"/configuration/artifacts",
"/configuration/password",
"/configuration/mail-hold/release",
"/configs/c1/delete",
"/configs/c1/edit",
"/configs/c1/offsite-reissue",
+39 -1
View File
@@ -20,6 +20,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
@@ -124,13 +125,20 @@ type Server struct {
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
// save. nil in production.
beforeCreateSave func(customerID string)
// mailHold is the hub-wide mail gate (internal/mailhold); nil never holds.
mailHold *mailhold.Hold
// cfAPIBase is a TEST seam for the Cloudflare token reach check (R-138 option C): "" = the real API.
cfAPIBase string
}
// New creates a new web server.
func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server {
// srv is assigned below; template funcs that read server state close over it (they run at render time only).
var srv *Server
funcMap := template.FuncMap{
// mailHeld drives the MAIL-HOLD banner on every operator page (internal/mailhold). Read at render time, so the
// banner disappears the moment the marker is gone. Pinned by TestMailHoldBanner_*.
"mailHeld": func() bool { return srv != nil && srv.mailHold.Held() },
"timeAgo": timeAgo,
"timeAgoPtr": func(t *time.Time) string {
if t == nil {
@@ -160,7 +168,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
return &Server{
srv = &Server{
store: store,
configPasswordHash: passwordHash,
apiKey: apiKey,
@@ -171,6 +179,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
sessions: make(map[string]*hubSession),
bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
}
return srv
}
// effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB
@@ -406,6 +415,29 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
}
// SetEventEmitter wires the notification dispatcher (R-604). INIT-ONLY.
// SetMailHold wires the hub-wide mail gate (internal/mailhold): the banner on every operator page and the release
// endpoint. nil = never held (no banner, the release is a no-op).
func (s *Server) SetMailHold(m *mailhold.Hold) {
s.mailHold = m
}
// handleMailHoldRelease lifts the MAIL-HOLD: it removes the marker, and the hub sends e-mail again from the next mail
// on. Mails dropped during the hold are NOT re-sent (internal/mailhold explains why). Operator-only: it sits behind
// RequireAuth and the R-135 CSRF gate in ServeHTTP like every other state-changing route.
func (s *Server) handleMailHoldRelease(w http.ResponseWriter, r *http.Request) {
if s.mailHold == nil || !s.mailHold.Held() {
http.Redirect(w, r, "/configuration?flash=mail_hold_not_held", http.StatusSeeOther)
return
}
if err := s.mailHold.Release(); err != nil {
s.logger.Printf("[ERROR] MAIL-HOLD: release failed — the marker is still in place: %v", err)
http.Redirect(w, r, "/configuration?flash=mail_hold_release_failed", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] MAIL-HOLD released by the operator from %s — the hub sends e-mail again; mail held until now was dropped", r.RemoteAddr)
http.Redirect(w, r, "/configuration?flash=mail_hold_released", http.StatusSeeOther)
}
func (s *Server) SetEventEmitter(f func(customerID, eventType, severity, message, detailsJSON, source string)) {
s.emit = f
}
@@ -668,6 +700,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/mail-hold/release":
if r.Method == http.MethodPost {
s.handleMailHoldRelease(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/password":
if r.Method == http.MethodPost {
s.handleChangePassword(w, r)
@@ -23,6 +23,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">&larr; Apps</a>
+1
View File
@@ -21,6 +21,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<h2 style="margin-bottom: 1rem;">App Telemetry</h2>
@@ -22,6 +22,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<a href="{{if .IsNew}}/configs{{else}}/customers/{{.Config.CustomerID}}{{end}}" class="back-link">&larr; Back</a>
<h2>{{if .IsNew}}Add Customer{{else}}Edit: {{.Config.CustomerID}}{{end}}</h2>
+1
View File
@@ -21,6 +21,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
{{if .Flash}}
<div class="flash flash-success">
@@ -21,9 +21,26 @@
<a href="/configuration" class="nav-link active">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<h2 style="margin-bottom: 1rem;">Configuration</h2>
{{if mailHeld}}
<form method="POST" action="/configuration/mail-hold/release" class="mail-hold-release">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="btn">Release the mail hold</button>
<span class="text-muted">Removes the marker. The hub sends e-mail again from the next mail on; mail held until now is not re-sent.</span>
</form>
{{end}}
{{if eq .Flash "mail_hold_released"}}
<div class="flash flash-success">Mail hold released — the hub sends e-mail again. Mail held until now was not re-sent.</div>
{{end}}
{{if eq .Flash "mail_hold_release_failed"}}
<div class="flash flash-error">The mail hold could not be released — the marker is still in place. Check the hub log.</div>
{{end}}
{{if eq .Flash "mail_hold_not_held"}}
<div class="flash flash-success">The mail hold was not on — nothing to release.</div>
{{end}}
{{if eq .Flash "assets_refreshed"}}
<div class="flash flash-success">Assets refreshed successfully from image seed.</div>
{{end}}
@@ -42,6 +42,7 @@
<p class="subtitle">No reports received yet</p>
{{end}}
</header>
{{template "mail_hold_banner"}}
{{if .Flash}}
<div class="flash flash-success">
@@ -22,6 +22,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
{{if or .OffsiteTile .PBSTile}}
<div class="offsite-gauges">
@@ -21,6 +21,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<a href="/hosts" class="back-link">&larr; Hosts</a>
+1
View File
@@ -22,6 +22,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<h2 style="margin-bottom: 1rem;">Hosts</h2>
+1
View File
@@ -21,6 +21,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<a href="/customers/{{.CustomerID}}" class="back-link">&larr; {{.CustomerID}}</a>
@@ -0,0 +1,8 @@
{{define "mail_hold_banner"}}{{if mailHeld}}
<div class="flash flash-warn" id="mail-hold" role="alert">
<strong>E-mail is on hold</strong> — this hub sends no e-mail at all, to households or to you, while the
<code>MAIL-HOLD</code> marker is in its data directory. Mail it would have sent is dropped, not queued.
Release the hold on the <a href="/configuration#mail-hold">Configuration</a> page once this is the only hub
that is running.
</div>
{{end}}{{end}}
+1
View File
@@ -21,6 +21,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<h2 style="margin-bottom: 0.75rem;">Offsite</h2>
+11
View File
@@ -1020,3 +1020,14 @@ body.js-tabs .tab-panel:not(.tab-panel-active) { display: none; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
/* MAIL-HOLD release (internal/mailhold): the button and its one-line explanation sit on one row under the banner. */
.mail-hold-release {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.mail-hold-release .text-muted { font-size: 0.8rem; }
#mail-hold a { color: var(--text-1); }
+1
View File
@@ -30,6 +30,7 @@
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
{{template "mail_hold_banner"}}
<h2 style="margin-bottom: 1rem;">System — versions and OS updates</h2>