feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)

Backend:
- Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date)
- NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle)
- System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler)
- Domain-Response-Headers + Rate-Limit (Migration 0024)
- Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out
- apt-Service für Update-Banner (apt-get update + Versionsprüfung)
- Backup-Retry mit exponential backoff (retry_apt 3×)
- publish.sh fail-fast + cleanup-old.sh (max 10 Versionen)

Frontend:
- Audit-Log-Page (/audit) mit Filter + Pagination
- ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+)
- Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix)
- EmptyState-Komponente überall ausgerollt
- SSL: Aggregate-Karte (total/expiring/expired/errors)
- Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now
- NTP: Sync-Status-Karte (chronyc tracking live)
- Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats
- Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN)
- Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler)
- Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention
- Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint
- System-Regeln im Firewall als eigener Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-19 16:18:41 +02:00
parent 3178e25e78
commit 35b7308ce2
82 changed files with 8408 additions and 392 deletions

View File

@@ -0,0 +1,239 @@
// Package apt kapselt die APT-Operationen die der Update-Banner braucht:
// gedrosseltes `apt-get update`, locale-stabiler `apt-cache policy` und
// ein Background-Refresh-Timer der die Apt-Indexe warm hält.
//
// Pattern 1:1 von mail-gateway/internal/services/apt/apt.go übernommen
// (siehe dortige Doc-Kommentare für die Bug-Historie). Kurzfassung:
//
// - `LC_ALL=C` zwingt apt auf englischen Output — sonst gibt apt auf
// einem deutschen Locale „Installationskandidat:" und unser
// Prefix-Match auf „Candidate:" failt silent.
// - Eigener Timeout-Kontext für apt-cache policy, damit ein
// gecancelltes Request den Read nicht killt.
// - 5-min-Throttle für `apt-get update` damit der UI-Poll-Tick (30 s)
// nicht jedes Mal das Mirror-Update tritt. Operator kann via
// RefreshNow den Throttle überspringen.
// - Background-Refresh-Goroutine die alle 5 min einen apt-get update
// fährt, damit kurz nach `make publish` der Banner aufgeht ohne
// dass jemand erst die UI öffnen muss.
package apt
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"runtime/debug"
"strings"
"sync"
"time"
)
// edgePackages: die deb-Pakete deren Versionen der UI-Banner anzeigt.
// Reihenfolge bestimmt was der Banner als "primary" zeigt — `edgeguard`
// ist das Meta-Paket, das wir bevorzugen.
var edgePackages = []string{"edgeguard", "edgeguard-api", "edgeguard-ui"}
// PackageVersions liefert installed/available pro edgeguard-Paket im
// flachen Key-Format das die UI bereits konsumiert.
//
// Wenn force=true wird der 5-min-Throttle für apt-get update übersprungen.
func PackageVersions(ctx context.Context, force bool) map[string]string {
if force {
RefreshNow(ctx)
} else {
RefreshIfStale(ctx)
}
out := map[string]string{}
// Eigener Background-Kontext für apt-cache policy: wenn der
// Request canceln sollte (Gateway-Timeout etc), wollen wir den
// Cache-Read trotzdem durchziehen — Lists sind lokal, das dauert
// Millisekunden.
policyCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, pkg := range edgePackages {
installed, candidate := aptCachePolicy(policyCtx, pkg)
out[pkg+"_installed"] = installed
out[pkg+"_available"] = candidate
}
return out
}
// ── apt-get update Throttle ────────────────────────────────────────────
var (
aptUpdateMu sync.Mutex
aptUpdateLastAt time.Time
)
// aptUpdateThrottle: wie oft maximal `apt-get update` ausgeführt werden
// darf. Der Background-Timer (StartBackgroundRefresh) tickt im selben
// Intervall, das deckt den UI-Banner-Fall (30-s-Poll) bequem ab.
const aptUpdateThrottle = 5 * time.Minute
// RefreshIfStale: führt apt-get update aus wenn der letzte Lauf älter
// als aptUpdateThrottle ist; sonst No-Op.
func RefreshIfStale(ctx context.Context) { refreshInternal(ctx, false) }
// RefreshNow: erzwingt apt-get update sofort, bypassed den Throttle.
// Wird vom UI-„Jetzt prüfen"-Button via `?force=1` getriggert, damit
// der Operator nach einem `make publish` nicht 5 min warten muss.
func RefreshNow(ctx context.Context) { refreshInternal(ctx, true) }
func refreshInternal(ctx context.Context, force bool) {
aptUpdateMu.Lock()
doUpdate := force || time.Since(aptUpdateLastAt) >= aptUpdateThrottle
if doUpdate {
aptUpdateLastAt = time.Now()
}
aptUpdateMu.Unlock()
if !doUpdate {
return
}
// 25-s-Budget — kürzer als der typische 30-s-Gateway-Timeout, damit
// ein hängendes apt-get update einen WARN log gibt statt SIGKILL.
updCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
// edgeguard-api läuft als unprivilegierter User; /var/lib/apt/lists
// ist root-owned. postinst pinned `sudo -n /usr/bin/apt-get update -qq`
// in /etc/sudoers.d/edgeguard.
cmd := exec.CommandContext(updCtx, "sudo", "-n", "/usr/bin/apt-get", "update", "-qq")
out, err := cmd.CombinedOutput()
if err != nil {
slog.Warn("apt: update failed (best-effort, falling back to cached lists)",
"error", err,
"output", strings.TrimSpace(string(out)))
}
}
// StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim
// Start + dann periodisch apt-get update fährt, damit der Apt-Cache
// auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach
// `make publish` ein verfügbares Update, statt 5 min auf die nächste
// UI-Poll-Welle zu warten.
func StartBackgroundRefresh(ctx context.Context) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("apt background refresh panic",
"panic", r, "stack", string(debug.Stack()))
}
}()
RefreshIfStale(ctx) // Initial warm-up
slog.Info("apt: background refresh timer started",
"interval", aptUpdateThrottle.String())
t := time.NewTicker(aptUpdateThrottle)
defer t.Stop()
for {
select {
case <-ctx.Done():
slog.Info("apt: background refresh timer stopping")
return
case <-t.C:
RefreshIfStale(ctx)
}
}
}()
}
// ── apt-cache policy ──────────────────────────────────────────────────
// aptCachePolicy: pullt Installed:/Candidate: aus `apt-cache policy <pkg>`.
// (none) wird zu "" gemappt.
//
// LC_ALL=C zwingt englischen Output — auf de_DE.UTF-8 spuckt apt sonst
// „Installationskandidat:" und das Match unten failed silent. Bug-Befund
// aus mail-gateway 2026-05-03, siehe internal/services/apt/apt.go dort.
func aptCachePolicy(ctx context.Context, pkg string) (installed, candidate string) {
cmd := exec.CommandContext(ctx, "apt-cache", "policy", pkg)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
out, err := cmd.CombinedOutput()
if err != nil {
slog.Warn("apt-cache policy failed",
"pkg", pkg, "error", err,
"output", strings.TrimSpace(string(out)))
return "", ""
}
// Diagnose: wenn weder Installed: noch Candidate: gefunden wird,
// dump den raw output — sonst rätselt man warum die UI leer bleibt.
defer func() {
if installed == "" && candidate == "" {
slog.Warn("apt-cache policy returned without Installed/Candidate match — raw output follows",
"pkg", pkg, "raw_output", strings.TrimSpace(string(out)))
}
}()
for _, raw := range strings.Split(string(out), "\n") {
line := strings.TrimSpace(raw)
if strings.HasPrefix(line, "Installed:") {
v := strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
if v != "(none)" {
installed = v
}
}
if strings.HasPrefix(line, "Candidate:") {
v := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
if v != "(none)" {
candidate = v
}
}
}
return
}
// ── Auto-Update (unattended-upgrades-Whitelist) ───────────────────────
// AutoUpdateConfPath: APT-Preferences-File das die edgeguard-Pakete in
// die unattended-upgrades-Whitelist setzt. Setup unattended-upgrades
// + apt-listchanges sind nicht-Pflicht; ohne diese Pakete hat das File
// keinen Effekt, bricht aber auch nichts.
const AutoUpdateConfPath = "/etc/apt/apt.conf.d/52edgeguard-auto-updates"
// autoUpdateConfBody whitelisted die drei edgeguard-Paketnamen für die
// unattended-upgrades-Periodic. Pattern 1:1 aus mail-gateway; sicheres
// Default — nur edgeguard-* werden automatisch aktualisiert, andere
// Pakete bleiben unter manueller Kontrolle.
const autoUpdateConfBody = `// Generated by edgeguard-api. Toggle via Settings → "Automatische Updates".
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Unattended-Upgrade::Allowed-Origins {
"origin=netcell-it.de,suite=trixie";
};
Unattended-Upgrade::Package-Whitelist {
"edgeguard";
"edgeguard-api";
"edgeguard-ui";
};
`
// SetAutoUpdate schreibt (enabled=true) oder entfernt (false) das
// /etc/apt/apt.conf.d-File. /etc/apt/apt.conf.d ist root-owned und die
// edgeguard-api läuft als non-root unter ProtectSystem=strict (siehe
// auch [[deploy/systemd/edgeguard-api.service]]). Daher geht der Write
// über sudo tee — der exakte Pfad ist in postinst-sudoers gepinnt.
func SetAutoUpdate(enabled bool) error {
if enabled {
cmd := exec.Command("sudo", "-n", "/usr/bin/tee", AutoUpdateConfPath)
cmd.Stdin = strings.NewReader(autoUpdateConfBody)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("sudo tee %s: %w: %s", AutoUpdateConfPath, err, strings.TrimSpace(string(out)))
}
return nil
}
out, err := exec.Command("sudo", "-n", "/bin/rm", "-f", AutoUpdateConfPath).CombinedOutput()
if err != nil {
return fmt.Errorf("sudo rm %s: %w: %s", AutoUpdateConfPath, err, strings.TrimSpace(string(out)))
}
return nil
}
// AutoUpdateEnabled liefert true wenn das Conf-File existiert.
func AutoUpdateEnabled() bool {
_, err := os.Stat(AutoUpdateConfPath)
return err == nil
}

View File

@@ -98,6 +98,98 @@ LIMIT $1`, limit)
return out, rows.Err()
}
// SearchFilter beschreibt einen filter-gestützten Audit-Log-Abruf.
// Alle Felder optional — leere Werte werden vom Query ignoriert. Such-
// Strings sind case-insensitive ILIKE-Substring-Matches. Limit wird auf
// 500 gedeckelt (UI-Schutz vor versehentlichem Full-Scan), Offset für
// einfaches Paging.
type SearchFilter struct {
Actor string
Action string
Subject string
Since *time.Time
Until *time.Time
Limit int
Offset int
}
// Search liefert audit_log-Einträge nach Filter, newest first. Filter-
// Felder werden via dynamisch zusammengesetzter WHERE-Klausel angewendet
// — Parametrisiert (kein String-Concat von User-Input).
func (r *Repo) Search(ctx context.Context, f SearchFilter) ([]Entry, error) {
if r == nil || r.Pool == nil {
return []Entry{}, nil
}
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
offset := f.Offset
if offset < 0 {
offset = 0
}
args := []any{}
where := ""
add := func(cond string, val any) {
args = append(args, val)
if where == "" {
where = " WHERE " + cond + "$" + itoa(len(args))
} else {
where += " AND " + cond + "$" + itoa(len(args))
}
}
if f.Actor != "" {
add("actor ILIKE ", "%"+f.Actor+"%")
}
if f.Action != "" {
add("action ILIKE ", "%"+f.Action+"%")
}
if f.Subject != "" {
add("subject ILIKE ", "%"+f.Subject+"%")
}
if f.Since != nil {
add("created_at >= ", *f.Since)
}
if f.Until != nil {
add("created_at <= ", *f.Until)
}
args = append(args, limit, offset)
q := "SELECT id, actor, action, subject, detail, node_id, created_at FROM audit_log" +
where +
" ORDER BY created_at DESC, id DESC LIMIT $" + itoa(len(args)-1) +
" OFFSET $" + itoa(len(args))
rows, err := r.Pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Entry, 0, limit)
for rows.Next() {
var e Entry
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Subject, &e.Detail, &e.NodeID, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func itoa(n int) string {
// kleiner local-Helper damit wir nicht strconv für Single-Digit-
// Parameter-Indizes importieren müssen.
if n < 10 {
return string(rune('0' + n))
}
// >9 Parameter ist hier in der Praxis nicht möglich (Filter <= 5 +
// LIMIT/OFFSET = 7), aber Fallback für Robustness.
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
// Log writes one audit_log row. detail is JSON-encodable (typically a
// map[string]any) — empty map means "no payload". If pool is nil
// (e.g. dev env without DB), Log silently no-ops so handlers don't
@@ -153,3 +245,24 @@ RETURNING id, created_at`,
r.broadcast(e)
return nil
}
// Cleanup löscht alle audit_log-Rows die älter als keepDays sind.
// Schutz vor unbounded growth bei langlebigen Boxen (audit_log kann
// sonst nach 1-2 Jahren mehrere GB Disk + entsprechende Query-Latenz
// haben). Liefert die Anzahl gelöschter Rows.
//
// keepDays <= 0 → no-op (Cleanup deaktiviert, alles bleibt erhalten).
// keepDays sollte deutlich über Audit-Anforderungen liegen — 90 Tage
// ist ein vernünftiger Default für Self-Service-Boxen.
func (r *Repo) Cleanup(ctx context.Context, keepDays int) (int64, error) {
if r == nil || r.Pool == nil || keepDays <= 0 {
return 0, nil
}
tag, err := r.Pool.Exec(ctx, `
DELETE FROM audit_log
WHERE created_at < NOW() - ($1::text || ' days')::interval`, keepDays)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}

View File

@@ -0,0 +1,125 @@
// Package domainheaders implements CRUD against the
// `domain_response_headers` table. Pro-Domain Response-Header die
// HAProxy via `http-response set-header` setzt.
package domainheaders
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrNotFound = errors.New("domain response header not found")
type Repo struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = `
SELECT id, domain_id, name, value, position, created_at, updated_at
FROM domain_response_headers
`
// ListForDomain liefert alle Header einer Domain in stabiler Position-Sortierung.
func (r *Repo) ListForDomain(ctx context.Context, domainID int64) ([]models.DomainResponseHeader, error) {
rows, err := r.Pool.Query(ctx,
baseSelect+` WHERE domain_id = $1 ORDER BY position ASC, id ASC`, domainID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DomainResponseHeader, 0, 4)
for rows.Next() {
h, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *h)
}
return out, rows.Err()
}
// ListAll holt alle Header (über alle Domains) — der HAProxy-Renderer
// braucht das, um pro Domain die Einträge zu gruppieren.
func (r *Repo) ListAll(ctx context.Context) ([]models.DomainResponseHeader, error) {
rows, err := r.Pool.Query(ctx, baseSelect+` ORDER BY domain_id, position, id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DomainResponseHeader, 0, 16)
for rows.Next() {
h, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, *h)
}
return out, rows.Err()
}
func (r *Repo) Get(ctx context.Context, id int64) (*models.DomainResponseHeader, error) {
row := r.Pool.QueryRow(ctx, baseSelect+` WHERE id = $1`, id)
h, err := scan(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return h, nil
}
func (r *Repo) Create(ctx context.Context, h models.DomainResponseHeader) (*models.DomainResponseHeader, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO domain_response_headers (domain_id, name, value, position)
VALUES ($1, $2, $3, $4)
RETURNING id, domain_id, name, value, position, created_at, updated_at`,
h.DomainID, h.Name, h.Value, h.Position)
return scan(row)
}
func (r *Repo) Update(ctx context.Context, id int64, h models.DomainResponseHeader) (*models.DomainResponseHeader, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE domain_response_headers SET
name = $1, value = $2, position = $3, updated_at = NOW()
WHERE id = $4
RETURNING id, domain_id, name, value, position, created_at, updated_at`,
h.Name, h.Value, h.Position, id)
out, err := scan(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return out, nil
}
func (r *Repo) Delete(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM domain_response_headers WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func scan(row interface{ Scan(...any) error }) (*models.DomainResponseHeader, error) {
var h models.DomainResponseHeader
if err := row.Scan(
&h.ID, &h.DomainID, &h.Name, &h.Value, &h.Position,
&h.CreatedAt, &h.UpdatedAt,
); err != nil {
return nil, err
}
return &h, nil
}

View File

@@ -20,7 +20,10 @@ type Repo struct {
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
const baseSelect = `
SELECT id, name, active, primary_backend_id, http_to_https, hsts_enabled,
SELECT id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb,
notes, created_at, updated_at
FROM domains
`
@@ -55,16 +58,31 @@ func (r *Repo) Get(ctx context.Context, id int64) (*models.Domain, error) {
}
func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, error) {
if d.HSTSMaxAge == 0 {
d.HSTSMaxAge = 31536000
}
row := r.Pool.QueryRow(ctx, `
INSERT INTO domains (name, active, primary_backend_id, http_to_https, hsts_enabled, notes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled,
INSERT INTO domains (name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb,
notes, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.Notes)
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.Notes)
return scanDomain(row)
}
func (r *Repo) Update(ctx context.Context, id int64, d models.Domain) (*models.Domain, error) {
if d.HSTSMaxAge == 0 {
d.HSTSMaxAge = 31536000
}
row := r.Pool.QueryRow(ctx, `
UPDATE domains SET
name = $1,
@@ -72,12 +90,26 @@ UPDATE domains SET
primary_backend_id = $3,
http_to_https = $4,
hsts_enabled = $5,
notes = $6,
hsts_max_age = $6,
hsts_subdomains = $7,
hsts_preload = $8,
maintenance_mode = $9,
maintenance_message = $10,
www_redirect = $11,
rate_limit_rps = $12,
max_body_kb = $13,
notes = $14,
updated_at = NOW()
WHERE id = $7
RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled,
WHERE id = $15
RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb,
notes, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.Notes, id)
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.Notes, id)
out, err := scanDomain(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -99,15 +131,14 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
return nil
}
// scanDomain accepts both pgx.Row (Get/Create/Update) and pgx.Rows
// (List, via the Scanner shape). pgx exposes both as a single
// Scan(...any) error method.
func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) {
var d models.Domain
if err := row.Scan(
&d.ID, &d.Name, &d.Active, &d.PrimaryBackendID,
&d.HTTPToHTTPS, &d.HSTSEnabled, &d.Notes,
&d.CreatedAt, &d.UpdatedAt,
&d.ID, &d.Name, &d.Active, &d.PrimaryBackendID, &d.HTTPToHTTPS,
&d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
&d.RateLimitRPS, &d.MaxBodyKB,
&d.Notes, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}

View File

@@ -38,6 +38,25 @@ type State struct {
LicenseKey string `json:"license_key,omitempty"`
Completed bool `json:"completed"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
// MaintenanceMode (whole-box): wenn true rendert HAProxy auf
// :443 nur einen 503-Block für ALLE Customer-Domains. Mgmt-UI
// auf :3443 bleibt unverändert. MaintenanceMessage landet im
// Response-Body.
MaintenanceMode bool `json:"maintenance_mode"`
MaintenanceMessage string `json:"maintenance_message,omitempty"`
// BackupRetentionKeep: wie viele scheduled Backups behalten
// werden. 0 = Default (backup.DefaultKeepN=14). Operator kann den
// Wert in den Settings hochsetzen wenn er längere History will
// und genug Disk hat.
BackupRetentionKeep int `json:"backup_retention_keep,omitempty"`
// AuditRetentionDays: nach wie vielen Tagen audit_log-Rows
// gelöscht werden. 0 = Default (90). Operator kann erhöhen für
// Compliance (z. B. SOX = 7 Jahre = 2555) oder reduzieren falls
// /var-Disk-Druck.
AuditRetentionDays int `json:"audit_retention_days,omitempty"`
}
// Request is the JSON body POST /api/v1/setup/complete accepts.
@@ -136,6 +155,89 @@ func (st *State) VerifyAdminPassword(plaintext string) bool {
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil
}
// SetBackupRetention setzt die Anzahl Backups die scheduled-Run nach
// jedem Lauf behält. 0 = Default. Range 1-365 — alles drüber ist
// wahrscheinlich ein Tippfehler.
func (s *Store) SetBackupRetention(keep int) error {
if keep < 0 || keep > 365 {
return errors.New("backup_retention_keep must be 0 (default) or 1..365")
}
prev, err := s.Load()
if err != nil {
return err
}
if prev == nil {
return errors.New("setup not completed — cannot edit backup retention before initial setup")
}
prev.BackupRetentionKeep = keep
return s.Save(prev)
}
// SetAuditRetention setzt die Tage, nach denen audit_log-Rows gelöscht
// werden. 0 = Default (90). Range 1-3650 (10 Jahre als Cap, reicht
// für SOX 7y mit etwas Puffer). Setzt der Operator auf einen sehr
// hohen Wert, muss er an die Disk-Auswirkung denken.
func (s *Store) SetAuditRetention(days int) error {
if days < 0 || days > 3650 {
return errors.New("audit_retention_days must be 0 (default) or 1..3650")
}
prev, err := s.Load()
if err != nil {
return err
}
if prev == nil {
return errors.New("setup not completed — cannot edit audit retention before initial setup")
}
prev.AuditRetentionDays = days
return s.Save(prev)
}
// SetContactEmails aktualisiert AdminEmail + ACMEEmail. Beide werden
// gegen mail.ParseAddress validiert. Lower-case + trim wie beim Setup-
// Wizard, damit Login-Vergleich (EqualFold) konsistent bleibt.
//
// Wirkung:
// * AdminEmail: nur das Login-Anzeigeformat ändert sich; Sessions
// bleiben aktiv (Cookie referenziert den alten Actor-String — auf
// dem nächsten Login ist der neue Wert wirksam).
// * ACMEEmail: nächster certrenewer-Lauf nutzt die neue Email als
// Account-Contact bei Let's Encrypt. Bestehende Certs unverändert.
func (s *Store) SetContactEmails(adminEmail, acmeEmail string) error {
adminEmail = strings.ToLower(strings.TrimSpace(adminEmail))
acmeEmail = strings.ToLower(strings.TrimSpace(acmeEmail))
if _, err := mail.ParseAddress(adminEmail); err != nil {
return fmt.Errorf("invalid admin_email: %w", err)
}
if _, err := mail.ParseAddress(acmeEmail); err != nil {
return fmt.Errorf("invalid acme_email: %w", err)
}
prev, err := s.Load()
if err != nil {
return err
}
if prev == nil {
return errors.New("setup not completed — cannot edit contact emails before initial setup")
}
prev.AdminEmail = adminEmail
prev.ACMEEmail = acmeEmail
return s.Save(prev)
}
// SetMaintenanceMode persistiert MaintenanceMode + MaintenanceMessage.
// Idempotent. Caller (Handler) triggert anschließend HAProxy-Reload.
func (s *Store) SetMaintenanceMode(enabled bool, message string) error {
prev, err := s.Load()
if err != nil {
return err
}
if prev == nil {
return errors.New("setup not completed — cannot toggle maintenance mode")
}
prev.MaintenanceMode = enabled
prev.MaintenanceMessage = message
return s.Save(prev)
}
// SetAdminPassword hash't ein neues Plaintext-Passwort und persistiert
// es. Verwendet vom Self-Service-Reset (CLI-Token-Flow).
func (s *Store) SetAdminPassword(plaintext string) error {