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:
@@ -20,8 +20,10 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
//go:embed haproxy.cfg.tpl
|
||||
@@ -61,6 +63,12 @@ type Generator struct {
|
||||
BackendsRepo *backends.Repo
|
||||
ServersRepo *backendservers.Repo
|
||||
RoutingRepo *routingrules.Repo
|
||||
HeadersRepo *domainheaders.Repo
|
||||
|
||||
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
|
||||
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
|
||||
// ans Template weiter.
|
||||
SetupStore *setup.Store
|
||||
|
||||
OutputPath string
|
||||
SkipReload bool
|
||||
@@ -73,6 +81,8 @@ func New(pool *pgxpool.Pool) *Generator {
|
||||
BackendsRepo: backends.New(pool),
|
||||
ServersRepo: backendservers.New(pool),
|
||||
RoutingRepo: routingrules.New(pool),
|
||||
HeadersRepo: domainheaders.New(pool),
|
||||
SetupStore: setup.NewStore(setup.DefaultDir),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +120,53 @@ func (g *Generator) Render(ctx context.Context) error {
|
||||
type View struct {
|
||||
Domains []DomainView
|
||||
Backends []BackendView
|
||||
|
||||
// GlobalMaintenance: wenn true emittiert public_https einen
|
||||
// 503-Block ganz am Anfang (vor allen anderen ACLs), der
|
||||
// alle Customer-Domains gleichzeitig stilllegt. mgmt_https
|
||||
// (:3443) bleibt unverändert. Whole-Box-Mode für
|
||||
// OS-Upgrades / Wartungsfenster.
|
||||
GlobalMaintenance bool
|
||||
GlobalMaintenanceMessage string
|
||||
}
|
||||
|
||||
type DomainView struct {
|
||||
models.Domain
|
||||
Routes []RouteView
|
||||
|
||||
// HSTSHeader: fertige Header-Value `max-age=…; includeSubDomains; preload`,
|
||||
// nur gesetzt wenn HSTSEnabled — Template muss nichts mehr zusammenbauen.
|
||||
HSTSHeader string
|
||||
|
||||
// MaintMessage: aus MaintenanceMessage übernommen, leer → Default.
|
||||
// Bereits HAProxy-safe (kein " enthalten).
|
||||
MaintMessage string
|
||||
|
||||
// RedirectFromHost: aliasing-Host der nach Name umgeleitet werden soll.
|
||||
// Nur gesetzt wenn WWWRedirect != "":
|
||||
// to-naked → Name="example.com" → "www.example.com"
|
||||
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
|
||||
RedirectFromHost string
|
||||
|
||||
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
|
||||
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
|
||||
// (Quotes → ', Newlines → Space).
|
||||
ResponseHeaders []ResponseHeaderView
|
||||
|
||||
// MaxBodyBytes: Content-Length-Cap in Bytes (max_body_kb * 1024).
|
||||
// 0 = aus, kein Rendering.
|
||||
MaxBodyBytes int
|
||||
|
||||
// RateLimitThreshold: requests-pro-10s über die Stick-Table.
|
||||
// 0 = aus, kein Rendering. = RateLimitRPS * 10.
|
||||
RateLimitThreshold int
|
||||
}
|
||||
|
||||
// ResponseHeaderView: pre-escaped Name/Value damit das Template
|
||||
// nicht escapen muss.
|
||||
type ResponseHeaderView struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
type RouteView struct {
|
||||
@@ -144,6 +196,17 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list routing rules: %w", err)
|
||||
}
|
||||
headers, err := g.HeadersRepo.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list domain headers: %w", err)
|
||||
}
|
||||
headersByDomain := map[int64][]ResponseHeaderView{}
|
||||
for _, h := range headers {
|
||||
headersByDomain[h.DomainID] = append(headersByDomain[h.DomainID], ResponseHeaderView{
|
||||
Name: h.Name,
|
||||
Value: sanitizeHeaderValue(h.Value),
|
||||
})
|
||||
}
|
||||
|
||||
rulesByDomain := map[int64][]RouteView{}
|
||||
for _, r := range rules {
|
||||
@@ -180,11 +243,110 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if !d.Active {
|
||||
continue
|
||||
}
|
||||
domViews = append(domViews, DomainView{
|
||||
Domain: d,
|
||||
Routes: rulesByDomain[d.ID],
|
||||
})
|
||||
dv := DomainView{
|
||||
Domain: d,
|
||||
Routes: rulesByDomain[d.ID],
|
||||
HSTSHeader: buildHSTSHeader(d),
|
||||
MaintMessage: buildMaintMessage(d),
|
||||
RedirectFromHost: buildRedirectFromHost(d),
|
||||
ResponseHeaders: headersByDomain[d.ID],
|
||||
}
|
||||
if d.MaxBodyKB > 0 {
|
||||
dv.MaxBodyBytes = d.MaxBodyKB * 1024
|
||||
}
|
||||
if d.RateLimitRPS > 0 {
|
||||
// Stick-Table store-window ist 10s (siehe Template); deshalb
|
||||
// ist die Threshold-Größe rps * 10.
|
||||
dv.RateLimitThreshold = d.RateLimitRPS * 10
|
||||
}
|
||||
domViews = append(domViews, dv)
|
||||
}
|
||||
|
||||
return &View{Domains: domViews, Backends: activeBackends}, nil
|
||||
v := &View{Domains: domViews, Backends: activeBackends}
|
||||
if g.SetupStore != nil {
|
||||
if st, err := g.SetupStore.Load(); err == nil && st != nil {
|
||||
v.GlobalMaintenance = st.MaintenanceMode
|
||||
v.GlobalMaintenanceMessage = sanitizeHeaderValue(st.MaintenanceMessage)
|
||||
if v.GlobalMaintenanceMessage == "" && v.GlobalMaintenance {
|
||||
v.GlobalMaintenanceMessage = "EdgeGuard maintenance in progress."
|
||||
}
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// buildHSTSHeader baut den fertigen Strict-Transport-Security Wert.
|
||||
// Leer wenn HSTS für die Domain aus ist — das Template prüft dann.
|
||||
func buildHSTSHeader(d models.Domain) string {
|
||||
if !d.HSTSEnabled {
|
||||
return ""
|
||||
}
|
||||
maxAge := d.HSTSMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = 31536000
|
||||
}
|
||||
out := fmt.Sprintf("max-age=%d", maxAge)
|
||||
if d.HSTSSubdomains {
|
||||
out += "; includeSubDomains"
|
||||
}
|
||||
if d.HSTSPreload {
|
||||
out += "; preload"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMaintMessage liefert die Wartungs-Meldung HAProxy-safe (ohne ").
|
||||
// Default-Text wenn nichts gesetzt ist.
|
||||
func buildMaintMessage(d models.Domain) string {
|
||||
if !d.MaintenanceMode {
|
||||
return ""
|
||||
}
|
||||
msg := ""
|
||||
if d.MaintenanceMessage != nil {
|
||||
msg = *d.MaintenanceMessage
|
||||
}
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
msg = "Service temporarily unavailable for maintenance."
|
||||
}
|
||||
// HAProxy kennt kein Escape innerhalb von "…". Lieber " durch ' tauschen
|
||||
// und Newlines flatten, damit der Template-Output garantiert parst.
|
||||
msg = strings.ReplaceAll(msg, `"`, "'")
|
||||
msg = strings.ReplaceAll(msg, "\n", " ")
|
||||
msg = strings.ReplaceAll(msg, "\r", " ")
|
||||
return msg
|
||||
}
|
||||
|
||||
// sanitizeHeaderValue macht den Wert HAProxy-safe für `set-header "…"`.
|
||||
// HAProxy versteht innerhalb einer "…"-Sequenz keine Escapes, deshalb
|
||||
// werden Quotes durch ' ersetzt und CR/LF entfernt (sonst sprengt ein
|
||||
// böser Wert die Config oder ermöglicht Header-Injection).
|
||||
func sanitizeHeaderValue(v string) string {
|
||||
v = strings.ReplaceAll(v, `"`, "'")
|
||||
v = strings.ReplaceAll(v, "\n", " ")
|
||||
v = strings.ReplaceAll(v, "\r", " ")
|
||||
return v
|
||||
}
|
||||
|
||||
// buildRedirectFromHost gibt den Alias-Host zurück der nach Name umgeleitet
|
||||
// werden soll, oder "" wenn kein Redirect konfiguriert ist.
|
||||
func buildRedirectFromHost(d models.Domain) string {
|
||||
switch d.WWWRedirect {
|
||||
case "to-naked":
|
||||
// Name ist die nackte Form → wir leiten www.Name → Name um.
|
||||
if strings.HasPrefix(strings.ToLower(d.Name), "www.") {
|
||||
// User-Fehlkonfiguration — Name beginnt schon mit www. Skip.
|
||||
return ""
|
||||
}
|
||||
return "www." + d.Name
|
||||
case "to-www":
|
||||
// Name ist die www-Form → wir leiten Name-ohne-www → Name um.
|
||||
lower := strings.ToLower(d.Name)
|
||||
if !strings.HasPrefix(lower, "www.") {
|
||||
return ""
|
||||
}
|
||||
return d.Name[4:]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user