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:
239
internal/services/apt/apt.go
Normal file
239
internal/services/apt/apt.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
125
internal/services/domainheaders/domainheaders.go
Normal file
125
internal/services/domainheaders/domainheaders.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user