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:
187
internal/aggregator/aggregator.go
Normal file
187
internal/aggregator/aggregator.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package aggregator führt parallele Cluster-Reads gegen alle Peer-Nodes
|
||||
// via mTLS aus.
|
||||
//
|
||||
// Pattern: ein Aggregator-Endpoint auf der Main-API (z.B.
|
||||
// /api/v1/cluster/system/load) ruft Aggregator.FanOut() — das verteilt
|
||||
// die Request parallel an alle Peers' Agent-Listener (:8443 mTLS) und
|
||||
// sammelt die Antworten in einer Map[node_id]→Ergebnis. Timeouts pro
|
||||
// Peer (3s default) verhindern dass ein hängender Peer die ganze Antwort
|
||||
// blockt; partielle Ergebnisse + per-Peer-Fehler werden zurückgegeben.
|
||||
//
|
||||
// mTLS-Auth: ClientTLSConfig aus clustertls.Store. CA muss auf beiden
|
||||
// Seiten dieselbe sein — sonst RequireAndVerifyClientCert lehnt ab.
|
||||
package aggregator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// DefaultAgentPort: alle Peers exposen ihren mTLS-Listener auf diesem
|
||||
// Port. api_url in ha_nodes zeigt typischerweise auf den Public-3443-
|
||||
// Port — wir derive'n den Agent-Port daraus, statt eine zweite Spalte
|
||||
// in ha_nodes zu führen.
|
||||
const DefaultAgentPort = 8443
|
||||
|
||||
// DefaultPeerTimeout: pro-Peer-Timeout. Aggregat-Caller sollten eine
|
||||
// Obergrenze von max(N×PeerTimeout/parallel) im Kopf haben; in der
|
||||
// Praxis ist alles parallel, also bestimmt der langsamste Peer die
|
||||
// Latenz.
|
||||
const DefaultPeerTimeout = 3 * time.Second
|
||||
|
||||
// Aggregator: dünner Wrapper mit ClientTLSConfig + http.Client.
|
||||
type Aggregator struct {
|
||||
HTTPClient *http.Client
|
||||
AgentPort int
|
||||
}
|
||||
|
||||
// New: liefert einen Aggregator der ClientTLSConfig verwendet. Wenn
|
||||
// clientTLS == nil, geht der Client auf normales TLS-Verify zurück —
|
||||
// für Tests nützlich, in Prod aber unsicher (würde Cert-Verify gegen
|
||||
// System-Trust laufen, das den Cluster-CA nicht kennt).
|
||||
func New(clientTLS *tls.Config) *Aggregator {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: clientTLS,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 3 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return &Aggregator{
|
||||
HTTPClient: &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: DefaultPeerTimeout,
|
||||
},
|
||||
AgentPort: DefaultAgentPort,
|
||||
}
|
||||
}
|
||||
|
||||
// PeerResult kapselt das Ergebnis eines parallelen Fan-Out-Calls.
|
||||
// Wenn Err != nil ist Data leer; sonst enthält Data den raw-JSON-Body
|
||||
// (Aufrufer entscheidet ob es per-Peer typed-unmarshalled oder als
|
||||
// map[string]any belassen wird).
|
||||
type PeerResult struct {
|
||||
NodeID string `json:"node_id"`
|
||||
FQDN string `json:"fqdn"`
|
||||
OK bool `json:"ok"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Err string `json:"error,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
// FanOut: ruft GET <agent-url>/<path> für jeden Peer in `peers` parallel
|
||||
// und sammelt die Ergebnisse in einer slice (stabile Sortierung nach
|
||||
// Peer-FQDN für deterministisches UI-Rendering).
|
||||
//
|
||||
// `path` ist relativ, z.B. "/agent/system/load". `localID` wird als
|
||||
// Marker übergeben damit der Aufrufer den eigenen Node von der Map
|
||||
// ausschließen kann.
|
||||
func (a *Aggregator) FanOut(ctx context.Context, peers []models.HANode, path, localID string) []PeerResult {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
results := make([]PeerResult, len(peers))
|
||||
var wg sync.WaitGroup
|
||||
for i, p := range peers {
|
||||
if p.ID == localID {
|
||||
// Eigener Node nicht über mTLS dial'n — wäre teuer + im
|
||||
// Aufrufer wahrscheinlich der lokale Path
|
||||
results[i] = PeerResult{NodeID: p.ID, FQDN: p.FQDN, OK: false, Err: "skipped: local node"}
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
i := i
|
||||
p := p
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[i] = a.callPeer(ctx, p, path)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// callPeer macht den Einzel-Call. Wandelt p.APIURL in https://host:8443/
|
||||
// um (Port übersteuert, Pfad ersetzt). Bei Connection-Fehler / Timeout
|
||||
// liefert ein PeerResult mit OK=false zurück.
|
||||
func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string) PeerResult {
|
||||
start := time.Now()
|
||||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||||
|
||||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||||
if err != nil {
|
||||
res.Err = "bad api_url: " + err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, DefaultPeerTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
resp, err := a.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Data = body
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
|
||||
// agentURL: nimmt z.B. "https://node1.example.com:3443" + port=8443 +
|
||||
// path="/agent/system/load" und liefert "https://node1.example.com:8443/agent/system/load".
|
||||
// Wir tauschen den Port aus, behalten Schema + Host (nur).
|
||||
func agentURL(apiURL string, agentPort int, path string) (string, error) {
|
||||
if apiURL == "" {
|
||||
return "", errors.New("empty api_url")
|
||||
}
|
||||
u, err := url.Parse(apiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return "", errors.New("api_url has no host")
|
||||
}
|
||||
u.Host = net.JoinHostPort(host, fmt.Sprint(agentPort))
|
||||
u.Path = path
|
||||
u.RawQuery = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// Compile-time check dass cluster importiert wird (für Drift-Detection
|
||||
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
|
||||
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
|
||||
// die Abhängigkeit.
|
||||
var _ = cluster.ComputeConfigHash
|
||||
438
internal/cluster/clustertls/clustertls.go
Normal file
438
internal/cluster/clustertls/clustertls.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Package clustertls verwaltet die per-Cluster Certificate Authority
|
||||
// für Node-to-Node mTLS-Kommunikation.
|
||||
//
|
||||
// Layout on disk:
|
||||
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
|
||||
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
|
||||
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
|
||||
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
|
||||
//
|
||||
// Workflow:
|
||||
// * Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
|
||||
// erstellt + signiert ihren eigenen peer.crt mit eigener CA.
|
||||
// * Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
|
||||
// ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary
|
||||
// signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.)
|
||||
// * Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
|
||||
// idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
|
||||
//
|
||||
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go,
|
||||
// nur Dir + Filenamen angepasst (kein /etc/nmg → /var/lib/edgeguard).
|
||||
package clustertls
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDir = "/var/lib/edgeguard/cluster-tls"
|
||||
|
||||
caCertFile = "ca.crt"
|
||||
caKeyFile = "ca.key"
|
||||
peerCertFile = "peer.crt"
|
||||
peerKeyFile = "peer.key"
|
||||
|
||||
caValidity = 10 * 365 * 24 * time.Hour // 10 Jahre — Cluster-Lifetime
|
||||
peerValidity = 365 * 24 * time.Hour // 1 Jahr — Renewal künftig automatisch
|
||||
)
|
||||
|
||||
// Store kapselt die CA + Peer-Cert-Persistierung unter Dir.
|
||||
type Store struct{ Dir string }
|
||||
|
||||
func New(dir string) *Store {
|
||||
if dir == "" {
|
||||
dir = DefaultDir
|
||||
}
|
||||
return &Store{Dir: dir}
|
||||
}
|
||||
|
||||
// HasCA prüft ob ca.crt + ca.key existieren.
|
||||
func (s *Store) HasCA() bool {
|
||||
_, e1 := os.Stat(filepath.Join(s.Dir, caCertFile))
|
||||
_, e2 := os.Stat(filepath.Join(s.Dir, caKeyFile))
|
||||
return e1 == nil && e2 == nil
|
||||
}
|
||||
|
||||
// HasPeer prüft ob peer.crt + peer.key existieren.
|
||||
func (s *Store) HasPeer() bool {
|
||||
_, e1 := os.Stat(filepath.Join(s.Dir, peerCertFile))
|
||||
_, e2 := os.Stat(filepath.Join(s.Dir, peerKeyFile))
|
||||
return e1 == nil && e2 == nil
|
||||
}
|
||||
|
||||
// InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent.
|
||||
// organisation landet im Subject — typischerweise die FQDN-Domain.
|
||||
func (s *Store) InitCA(organisation string, now func() time.Time) error {
|
||||
if s.HasCA() {
|
||||
return nil
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "EdgeGuard Cluster CA",
|
||||
Organization: []string{organisation},
|
||||
},
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(caValidity).UTC(),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, pub, priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePEM(filepath.Join(s.Dir, caCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||
return err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePEM(filepath.Join(s.Dir, caKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||
}
|
||||
|
||||
// EnsureSelfSigned: erstellt peer.crt + peer.key signiert mit der eigenen
|
||||
// CA, falls noch nicht vorhanden. Verwendet für den "Cluster-Founder"-Pfad
|
||||
// (erste Node generiert CA + ihren eigenen Cert). commonName ist meist
|
||||
// die FQDN; dnsNames + ips landen in SubjectAlternativeName, damit der
|
||||
// TLS-Handshake gegen IP-Adressen + Hostnamen funktioniert.
|
||||
//
|
||||
// Idempotent — vorhandenes peer.crt wird NICHT überschrieben (sonst
|
||||
// würden andere Peers ihre Vertrauenskette verlieren nach jedem Boot).
|
||||
func (s *Store) EnsureSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error {
|
||||
if s.HasPeer() {
|
||||
return nil
|
||||
}
|
||||
if !s.HasCA() {
|
||||
return errors.New("clustertls: peer cert requested but no CA in place — run InitCA first")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
caCert, caKey, err := s.LoadCA()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(peerValidity).UTC(),
|
||||
// Server-auth + Client-auth — ein Cert nutzbar für Listener und Outbound.
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||
return err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||
}
|
||||
|
||||
// LoadCA parst CA-Cert + Ed25519 Private Key vom Disk.
|
||||
func (s *Store) LoadCA() (*x509.Certificate, ed25519.PrivateKey, error) {
|
||||
certPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, nil, errors.New("ca cert: invalid PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse CA cert: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(filepath.Join(s.Dir, caKeyFile))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read CA key: %w", err)
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, nil, errors.New("ca key: invalid PEM")
|
||||
}
|
||||
raw, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse CA key: %w", err)
|
||||
}
|
||||
priv, ok := raw.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("CA key is not ed25519 (got %T)", raw)
|
||||
}
|
||||
return cert, priv, nil
|
||||
}
|
||||
|
||||
// SignCSR signiert einen joining-peer CSR. Caller hat den one-shot
|
||||
// cluster-join-Token bereits geprüft (Phase 3.4 — Aufrufer in handlers).
|
||||
func (s *Store) SignCSR(csrPEM string, now func() time.Time) (string, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
||||
return "", errors.New("invalid CSR PEM")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return "", fmt.Errorf("bad CSR signature: %w", err)
|
||||
}
|
||||
caCert, caKey, err := s.LoadCA()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: csr.Subject,
|
||||
DNSNames: csr.DNSNames,
|
||||
IPAddresses: csr.IPAddresses,
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(peerValidity).UTC(),
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, csr.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pemString("CERTIFICATE", der), nil
|
||||
}
|
||||
|
||||
// NewPeerKeyAndCSR generiert einen frischen Ed25519 Key + CSR. Verwendet
|
||||
// vom joining peer bevor er an /cluster/issue-cert POSTet.
|
||||
func NewPeerKeyAndCSR(commonName string, dnsNames []string, ips []net.IP) (keyPEM, csrPEM string, err error) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tpl, priv)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return pemString("PRIVATE KEY", keyDER), pemString("CERTIFICATE REQUEST", csrDER), nil
|
||||
}
|
||||
|
||||
// WriteOwnPeerMaterial persistiert peer.key + peer.crt. Wird vom
|
||||
// cluster-join-Flow gerufen wenn der Primary das Cert signiert hat.
|
||||
func (s *Store) WriteOwnPeerMaterial(keyPEM, certPEM string) error {
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.Dir, peerKeyFile), []byte(keyPEM), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(s.Dir, peerCertFile), []byte(certPEM), 0o644)
|
||||
}
|
||||
|
||||
// ServerTLSConfig: tls.Config für den mTLS-Listener (z.B. :8443).
|
||||
// peer.crt/peer.key als Identity, ca.crt als einzige ClientCA.
|
||||
// ClientAuth=RequireAndVerifyClientCert.
|
||||
func (s *Store) ServerTLSConfig() (*tls.Config, error) {
|
||||
pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := s.caPool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
ClientCAs: pool,
|
||||
RootCAs: pool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClientTLSConfig: tls.Config für outbound peer-to-peer Calls.
|
||||
// Präsentiert peer.crt, verifiziert Server gegen ca.crt.
|
||||
func (s *Store) ClientTLSConfig() (*tls.Config, error) {
|
||||
pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := s.caPool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CACertPEM gibt die CA-Cert als PEM-String zurück (für Join-Token-Export).
|
||||
func (s *Store) CACertPEM() (string, error) {
|
||||
b, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining
|
||||
// kann negativ sein wenn der Cert schon abgelaufen ist.
|
||||
type CertInfo struct {
|
||||
CommonName string `json:"common_name"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
SerialHex string `json:"serial_hex"`
|
||||
}
|
||||
|
||||
// PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner
|
||||
// existiert: (nil, error).
|
||||
func (s *Store) PeerCertInfo() (*CertInfo, error) {
|
||||
return parseCertInfo(filepath.Join(s.Dir, peerCertFile))
|
||||
}
|
||||
|
||||
// CACertInfo liefert die Metadata der Cluster-CA. Wenn keiner
|
||||
// existiert: (nil, error).
|
||||
func (s *Store) CACertInfo() (*CertInfo, error) {
|
||||
return parseCertInfo(filepath.Join(s.Dir, caCertFile))
|
||||
}
|
||||
|
||||
func parseCertInfo(path string) (*CertInfo, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days := int(time.Until(cert.NotAfter) / (24 * time.Hour))
|
||||
return &CertInfo{
|
||||
CommonName: cert.Subject.CommonName,
|
||||
NotBefore: cert.NotBefore.UTC(),
|
||||
NotAfter: cert.NotAfter.UTC(),
|
||||
DaysRemaining: days,
|
||||
IsCA: cert.IsCA,
|
||||
SerialHex: cert.SerialNumber.Text(16),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewSelfSigned überschreibt peer.crt + peer.key mit einem frisch
|
||||
// erzeugten Paar, signiert mit der eigenen CA. Nur sinnvoll auf dem
|
||||
// Founder/Primary — Joiner sollten ihren Cert über den /cluster/
|
||||
// issue-cert-Flow ablösen, sobald wir Renewal-Tokens bauen.
|
||||
//
|
||||
// Anders als EnsureSelfSigned wird hier ÜBERSCHRIEBEN. Caller (Handler
|
||||
// + CLI) ist verantwortlich für vorherigen Restart-Hinweis bzw.
|
||||
// Service-Restart nach dem Call.
|
||||
func (s *Store) RenewSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error {
|
||||
if !s.HasCA() {
|
||||
return errors.New("clustertls: RenewSelfSigned requires own CA")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
caCert, caKey, err := s.LoadCA()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(peerValidity).UTC(),
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||
return err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||
}
|
||||
|
||||
func (s *Store) caPool() (*x509.CertPool, error) {
|
||||
caPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("failed to add CA to pool")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func writePEM(path string, mode os.FileMode, kind string, der []byte) error {
|
||||
return os.WriteFile(path, []byte(pemString(kind, der)), mode)
|
||||
}
|
||||
|
||||
func pemString(kind string, der []byte) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: kind, Bytes: der}))
|
||||
}
|
||||
72
internal/cluster/heartbeat.go
Normal file
72
internal/cluster/heartbeat.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package cluster
|
||||
|
||||
// Phase-3.2: periodischer Heartbeat + Stale-Sweeper.
|
||||
//
|
||||
// Hintergrund: EnsureSelfRegistered schreibt last_seen einmal beim
|
||||
// API-Boot. Ohne periodisches Re-Schreiben friert last_seen auf den
|
||||
// Boot-Zeitpunkt ein — Peers (im Multi-Node-Setup) hätten keinen Weg
|
||||
// zu erkennen ob dieser Node noch lebt. Die Heartbeat-Goroutine in der
|
||||
// API bumpt das alle 30s; der Scheduler räumt mit SweepStaleNodes Peers
|
||||
// die länger als <threshold> nicht gemeldet haben auf status='offline'.
|
||||
//
|
||||
// Single-Node-Effekt: Cluster-UI zeigt korrekt "last seen 12s" statt
|
||||
// "last seen 3h" weil last_seen frisch ist. UI-Drift-Banner-Logik im
|
||||
// ClusterHandler.Status nutzt die selben Felder.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Heartbeat bumpt last_seen + status='online' für die eigene Node-Row
|
||||
// und aktualisiert version + config_hash. Idempotent. UPDATE-only — die
|
||||
// Row muss existieren (wird via EnsureSelfRegistered beim Boot angelegt).
|
||||
//
|
||||
// Hash-Berechnung läuft synchron — typisch <50ms auf einer realistischen
|
||||
// DB-Größe; falls die compute-SQL fehlt (Migration im Flux) wird der
|
||||
// vorhandene config_hash via COALESCE behalten.
|
||||
func Heartbeat(ctx context.Context, pool *pgxpool.Pool, localID, version string) error {
|
||||
if pool == nil || localID == "" {
|
||||
return nil
|
||||
}
|
||||
hash, _ := ComputeConfigHash(ctx, pool)
|
||||
_, err := pool.Exec(ctx, `
|
||||
UPDATE ha_nodes SET
|
||||
last_seen = NOW(),
|
||||
status = 'online',
|
||||
version = COALESCE(NULLIF($1, ''), version),
|
||||
config_hash = COALESCE(NULLIF($2, ''), config_hash),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`, version, hash, localID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SweepStaleNodes flippt status='online' → 'offline' für Peers deren
|
||||
// last_seen älter als threshold ist. Liefert die Anzahl gefliptpter
|
||||
// Rows zurück (für Logging). Idempotent — markiert keine Rows die
|
||||
// schon offline sind.
|
||||
//
|
||||
// Threshold-Konvention: 4× Heartbeat-Intervall = 2 min bei 30s-Tick.
|
||||
// Lässt Platz für eine verpasste API-Tick (Restart, GC-Pause, kurzer
|
||||
// Network-Glitch) ohne false-positive Offline.
|
||||
func SweepStaleNodes(ctx context.Context, pool *pgxpool.Pool, threshold time.Duration) (int64, error) {
|
||||
if pool == nil || threshold <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Wir bauen das Interval als String — pgx kann time.Duration nicht
|
||||
// direkt als INTERVAL serialisieren.
|
||||
interval := fmt.Sprintf("%d seconds", int(threshold.Seconds()))
|
||||
tag, err := pool.Exec(ctx, `
|
||||
UPDATE ha_nodes SET
|
||||
status = 'offline',
|
||||
updated_at = NOW()
|
||||
WHERE last_seen < NOW() - $1::interval
|
||||
AND status = 'online'`, interval)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
237
internal/cluster/jointoken/jointoken.go
Normal file
237
internal/cluster/jointoken/jointoken.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Package jointoken implementiert one-shot Cluster-Join-Token für
|
||||
// Phase 3.4.
|
||||
//
|
||||
// Format: "eg-join-v1.<base64url(payload)>.<base64url(hmac)>"
|
||||
//
|
||||
// Payload (CBOR-ähnlich, kompakt JSON): {n: nonce(16B base64),
|
||||
// e: expires_at_unix, f: ca_fingerprint_hex_16chars}
|
||||
//
|
||||
// HMAC: SHA-256 über payload-bytes, Key aus /var/lib/edgeguard/cluster-join-secret
|
||||
// (auto-generiert beim ersten Generate-Call, 32B random, 0600).
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Admin POSTet /cluster/join-tokens → Server generiert Token, schickt
|
||||
// ihn als Klartext-String (einmalig). Server speichert NICHTS
|
||||
// (token ist self-contained).
|
||||
// 2. Joiner POSTet /cluster/issue-cert mit {token, csr}.
|
||||
// 3. Server Verify(token): HMAC ok? Nicht abgelaufen? CA-Fingerprint
|
||||
// passt zum eigenen? Nonce nicht schon in join_tokens_used?
|
||||
// 4. Bei Erfolg: nonce in join_tokens_used inserten (PK-Conflict =
|
||||
// Re-Use → reject). Dann CSR signieren + zurückgeben.
|
||||
//
|
||||
// One-Shot-Semantik: PG-Insert mit ON CONFLICT DO NOTHING; wenn keine
|
||||
// Row inserted wurde → schon konsumiert. Das ersetzt einen sonst
|
||||
// nötigen Distributed-Lock.
|
||||
package jointoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
// SecretPath: HMAC-Key. Auto-Generated beim ersten Generate.
|
||||
DefaultSecretPath = "/var/lib/edgeguard/cluster-join-secret"
|
||||
|
||||
tokenPrefix = "eg-join-v1."
|
||||
|
||||
// DefaultTTL — wie lange ein generierter Token gültig bleibt.
|
||||
DefaultTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Service kapselt Token-Gen + -Verify mit DB-One-Shot-Tracking.
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
SecretPath string
|
||||
GetCAFinger func() (string, error) // liefert 16-hex-fingerprint der eigenen CA
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *Service {
|
||||
return &Service{
|
||||
Pool: pool,
|
||||
SecretPath: DefaultSecretPath,
|
||||
GetCAFinger: getCAFinger,
|
||||
TTL: DefaultTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// payload ist das JSON inside-the-token.
|
||||
type payload struct {
|
||||
Nonce string `json:"n"`
|
||||
Expires int64 `json:"e"`
|
||||
CAFinger16 string `json:"f"`
|
||||
}
|
||||
|
||||
// Generate erzeugt einen frischen Token. Caller (Handler) muss bereits
|
||||
// Admin-Auth geprüft haben.
|
||||
func (s *Service) Generate() (string, time.Time, error) {
|
||||
if s.GetCAFinger == nil {
|
||||
return "", time.Time{}, errors.New("jointoken: GetCAFinger unset")
|
||||
}
|
||||
finger, err := s.GetCAFinger()
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("ca fingerprint: %w", err)
|
||||
}
|
||||
secret, err := s.ensureSecret()
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("load secret: %w", err)
|
||||
}
|
||||
|
||||
nonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
ttl := s.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultTTL
|
||||
}
|
||||
exp := time.Now().Add(ttl).UTC()
|
||||
p := payload{
|
||||
Nonce: base64.RawURLEncoding.EncodeToString(nonceBytes),
|
||||
Expires: exp.Unix(),
|
||||
CAFinger16: finger,
|
||||
}
|
||||
pj, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write(pj)
|
||||
sig := mac.Sum(nil)
|
||||
|
||||
return tokenPrefix +
|
||||
base64.RawURLEncoding.EncodeToString(pj) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(sig), exp, nil
|
||||
}
|
||||
|
||||
// Consume verifiziert den Token UND markiert die nonce als verbraucht
|
||||
// — wenn die nonce schon in join_tokens_used steht (oder das INSERT
|
||||
// nichts geändert hat), lehnen wir ab. Erfolgreicher Consume liefert
|
||||
// die CA-Fingerprint aus dem Payload (Caller kann gegen die eigene
|
||||
// CA vergleichen).
|
||||
//
|
||||
// `consumedBy` ist freier String der zum Audit ins DB-Row landet
|
||||
// (typisch: peer.fqdn oder peer.api_url).
|
||||
func (s *Service) Consume(ctx context.Context, token, consumedBy string) (payload, error) {
|
||||
var zero payload
|
||||
if !strings.HasPrefix(token, tokenPrefix) {
|
||||
return zero, errors.New("invalid token format")
|
||||
}
|
||||
rest := strings.TrimPrefix(token, tokenPrefix)
|
||||
parts := strings.SplitN(rest, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return zero, errors.New("invalid token format")
|
||||
}
|
||||
pj, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("decode sig: %w", err)
|
||||
}
|
||||
secret, err := s.loadSecret()
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("load secret: %w", err)
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write(pj)
|
||||
want := mac.Sum(nil)
|
||||
if !hmac.Equal(want, sig) {
|
||||
return zero, errors.New("bad signature")
|
||||
}
|
||||
var p payload
|
||||
if err := json.Unmarshal(pj, &p); err != nil {
|
||||
return zero, fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if now.Unix() > p.Expires {
|
||||
return zero, errors.New("token expired")
|
||||
}
|
||||
if s.GetCAFinger != nil {
|
||||
ownFinger, err := s.GetCAFinger()
|
||||
if err == nil && ownFinger != p.CAFinger16 {
|
||||
return zero, errors.New("ca fingerprint mismatch (wrong cluster?)")
|
||||
}
|
||||
}
|
||||
// One-shot-INSERT — wenn die nonce schon da ist (Re-Use), schlägt
|
||||
// das fehl und wir lehnen ab.
|
||||
exp := time.Unix(p.Expires, 0).UTC()
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO join_tokens_used (nonce, expires_at, consumed_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (nonce) DO NOTHING`, p.Nonce, exp, consumedBy)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("track nonce: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return zero, errors.New("token already used")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CleanupExpired löscht abgelaufene Nonce-Rows. Idempotent; Aufruf vom
|
||||
// Scheduler einmal pro Stunde reicht.
|
||||
func (s *Service) CleanupExpired(ctx context.Context) (int64, error) {
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`DELETE FROM join_tokens_used WHERE expires_at < NOW() - INTERVAL '7 days'`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// ── secret handling ───────────────────────────────────────────────────
|
||||
|
||||
func (s *Service) ensureSecret() ([]byte, error) {
|
||||
if b, err := s.loadSecret(); err == nil && len(b) >= 32 {
|
||||
return b, nil
|
||||
}
|
||||
// Generate fresh secret. Dir wird in postinst erstellt (0700,
|
||||
// edgeguard-owned), wir schreiben direkt rein.
|
||||
dir := filepath.Dir(s.path())
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(s.path(), key, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadSecret() ([]byte, error) {
|
||||
return os.ReadFile(s.path())
|
||||
}
|
||||
|
||||
func (s *Service) path() string {
|
||||
if s.SecretPath != "" {
|
||||
return s.SecretPath
|
||||
}
|
||||
return DefaultSecretPath
|
||||
}
|
||||
|
||||
// CAFingerprint16 liefert die ersten 16 Hex-Chars vom SHA-256 der CA-
|
||||
// Certificate DER-Bytes. Eindeutig genug für den Cluster-Trust-Check
|
||||
// (64 bit Entropie); kürzer als 64 Hex damit Token nicht aufbläst.
|
||||
func CAFingerprint16(caCertDER []byte) string {
|
||||
sum := sha256.Sum256(caCertDER)
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
@@ -100,6 +100,21 @@ RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
// Delete entfernt einen Peer aus ha_nodes. Caller (Handler) verhindert
|
||||
// dass die lokale Node sich selbst löscht — sonst geht der nächste
|
||||
// Heartbeat-Tick die Row wieder anlegen UND der Cluster-Status zeigt
|
||||
// für 2 min "weg" obwohl der Node noch läuft.
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM ha_nodes WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureSelfRegistered mints the node-id if needed, builds the row
|
||||
// from setup.json + os.Hostname + node.conf, and upserts it. Called
|
||||
// on edgeguard-api boot AFTER the DB pool is reachable.
|
||||
|
||||
39
internal/database/migrations/0023_domains_settings.sql
Normal file
39
internal/database/migrations/0023_domains_settings.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Domain-Settings die HAProxy pro Domain rendern kann. Bisher
|
||||
-- hatten Domains nur ein binäres hsts_enabled — jetzt ist HSTS
|
||||
-- granular (max-age, subdomains, preload) plus Maintenance-Mode
|
||||
-- und Canonical-www-Redirect.
|
||||
|
||||
ALTER TABLE domains
|
||||
ADD COLUMN IF NOT EXISTS hsts_max_age INTEGER NOT NULL DEFAULT 31536000,
|
||||
ADD COLUMN IF NOT EXISTS hsts_subdomains BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS hsts_preload BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS maintenance_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS maintenance_message TEXT,
|
||||
ADD COLUMN IF NOT EXISTS www_redirect TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- www_redirect-Werte:
|
||||
-- '' — kein Redirect (Default; Domain wird direkt geserved)
|
||||
-- 'to-naked'— www.foo.com → foo.com (canonical naked-domain)
|
||||
-- 'to-www' — foo.com → www.foo.com (canonical www)
|
||||
ALTER TABLE domains
|
||||
DROP CONSTRAINT IF EXISTS domains_www_redirect_check;
|
||||
ALTER TABLE domains
|
||||
ADD CONSTRAINT domains_www_redirect_check
|
||||
CHECK (www_redirect IN ('', 'to-naked', 'to-www'));
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_www_redirect_check;
|
||||
ALTER TABLE domains
|
||||
DROP COLUMN IF EXISTS www_redirect,
|
||||
DROP COLUMN IF EXISTS maintenance_message,
|
||||
DROP COLUMN IF EXISTS maintenance_mode,
|
||||
DROP COLUMN IF EXISTS hsts_preload,
|
||||
DROP COLUMN IF EXISTS hsts_subdomains,
|
||||
DROP COLUMN IF EXISTS hsts_max_age;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,58 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Phase 2 Domain-Settings:
|
||||
-- * rate_limit_rps — Requests pro Sekunde Cap je Client-IP (0 = aus).
|
||||
-- HAProxy implementiert das via per-Domain Stick-Table (siehe
|
||||
-- internal/haproxy/haproxy.cfg.tpl).
|
||||
-- * max_body_kb — max. Request-Body-Größe in KiB (0 = aus).
|
||||
-- HAProxy verweigert größere Requests mit 413.
|
||||
-- * domain_response_headers — beliebige Set-Header Pairs die je
|
||||
-- Domain auf jede Response gesetzt werden (CORS, CSP, X-Frame-
|
||||
-- Options, …). HAProxy rendert pro Eintrag einen `http-response
|
||||
-- set-header` mit Host-ACL.
|
||||
|
||||
ALTER TABLE domains
|
||||
ADD COLUMN IF NOT EXISTS rate_limit_rps INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS max_body_kb INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE domains
|
||||
DROP CONSTRAINT IF EXISTS domains_rate_limit_rps_check;
|
||||
ALTER TABLE domains
|
||||
ADD CONSTRAINT domains_rate_limit_rps_check CHECK (rate_limit_rps >= 0);
|
||||
|
||||
ALTER TABLE domains
|
||||
DROP CONSTRAINT IF EXISTS domains_max_body_kb_check;
|
||||
ALTER TABLE domains
|
||||
ADD CONSTRAINT domains_max_body_kb_check CHECK (max_body_kb >= 0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_response_headers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ein Header-Name darf pro Domain nur einmal auftauchen (case-insensitive,
|
||||
-- HTTP-Header sind per RFC nicht case-sensitive). Verhindert dass zwei
|
||||
-- konkurrierende Werte für "X-Frame-Options" gesetzt werden.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_drh_domain_name_ci
|
||||
ON domain_response_headers (domain_id, lower(name));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drh_domain
|
||||
ON domain_response_headers (domain_id, position);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS domain_response_headers;
|
||||
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_max_body_kb_check;
|
||||
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_rate_limit_rps_check;
|
||||
ALTER TABLE domains
|
||||
DROP COLUMN IF EXISTS max_body_kb,
|
||||
DROP COLUMN IF EXISTS rate_limit_rps;
|
||||
-- +goose StatementEnd
|
||||
28
internal/database/migrations/0025_join_tokens_used.sql
Normal file
28
internal/database/migrations/0025_join_tokens_used.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Phase 3.4: Cluster-Join-Token One-Shot-Consume-Tracking.
|
||||
--
|
||||
-- Token-Format ist HMAC-self-contained (siehe internal/cluster/jointoken):
|
||||
-- nonce + expires_at + ca-fingerprint, signiert mit dem Cluster-Join-
|
||||
-- Secret. Damit der Token wirklich nur EINMAL benutzbar ist, speichern
|
||||
-- wir die nonce beim Consume — Re-Use → 409. expires_at hilft beim
|
||||
-- Cleanup (Tokens älter als ihre eigene Expiry können gelöscht werden,
|
||||
-- auch ungebraucht).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS join_tokens_used (
|
||||
nonce TEXT PRIMARY KEY,
|
||||
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_join_tokens_used_expires
|
||||
ON join_tokens_used (expires_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS join_tokens_used;
|
||||
-- +goose StatementEnd
|
||||
@@ -29,6 +29,9 @@ table inet edgeguard {
|
||||
# erreichbar.
|
||||
tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH (rate-limited)"
|
||||
tcp dport 443 accept comment "anti-lockout: HAProxy public HTTPS"
|
||||
# HTTP/3 läuft als QUIC über UDP/443. HAProxy bindet quic4@:443
|
||||
# parallel zum TCP-Listener; ohne diese Regel droppt der policy.
|
||||
udp dport 443 accept comment "anti-lockout: HAProxy HTTP/3 (QUIC)"
|
||||
tcp dport 3443 accept comment "anti-lockout: Management-UI (HAProxy admin HTTPS)"
|
||||
|
||||
# Stateful baseline
|
||||
|
||||
@@ -22,9 +22,47 @@ func NewAuditHandler(repo *audit.Repo) *AuditHandler { return &AuditHandler{Repo
|
||||
func (h *AuditHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/audit")
|
||||
g.GET("/recent", h.Recent)
|
||||
g.GET("/search", h.Search)
|
||||
g.GET("/live", h.Live)
|
||||
}
|
||||
|
||||
// Search filtert audit_log nach Actor/Action/Subject/Date-Range.
|
||||
// Query-Params: actor, action, subject (alle ILIKE-Substring),
|
||||
// since/until (RFC3339), limit (max 500), offset.
|
||||
func (h *AuditHandler) Search(c *gin.Context) {
|
||||
f := audit.SearchFilter{
|
||||
Actor: c.Query("actor"),
|
||||
Action: c.Query("action"),
|
||||
Subject: c.Query("subject"),
|
||||
}
|
||||
if s := c.Query("since"); s != "" {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
f.Since = &t
|
||||
}
|
||||
}
|
||||
if s := c.Query("until"); s != "" {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
f.Until = &t
|
||||
}
|
||||
}
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
if v := c.Query("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
f.Offset = n
|
||||
}
|
||||
}
|
||||
rows, err := h.Repo.Search(c.Request.Context(), f)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"entries": rows, "limit": f.Limit, "offset": f.Offset})
|
||||
}
|
||||
|
||||
// Recent returns the most recent audit_log entries — used by the
|
||||
// dashboard fallback path (z.B. wenn WebSocket nicht verbinden kann).
|
||||
// ?limit=N (1–100, default 10).
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
@@ -19,12 +20,22 @@ import (
|
||||
type AuthHandler struct {
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||
return &AuthHandler{Setup: s, Signer: sig}
|
||||
}
|
||||
|
||||
// WithAudit: Audit-Repo + NodeID damit Password-Operationen (change,
|
||||
// reset, login-success/fail) ins audit_log fließen.
|
||||
func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||
// (gated by requireAuth, passed in as a per-route middleware).
|
||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
@@ -33,6 +44,7 @@ func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", requireAuth, h.Me)
|
||||
g.POST("/reset-password", h.ResetPassword)
|
||||
g.POST("/change-password", requireAuth, h.ChangePassword)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -122,6 +134,60 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
// ResetPassword: keine Session, deshalb "self-reset" als Actor
|
||||
// damit der Audit-Trail zeigt dass es kein admin-mediated Reset war.
|
||||
_ = h.Audit.Log(c.Request.Context(), "self-reset", "auth.password.reset",
|
||||
"", gin.H{"remote": c.ClientIP()}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=12"`
|
||||
}
|
||||
|
||||
// ChangePassword: authenticated User wechselt sein eigenes Passwort.
|
||||
// Anders als ResetPassword (CLI-Token-Flow für vergessenes Passwort)
|
||||
// braucht das hier das current_password als Confirmation — verhindert
|
||||
// dass eine kompromittierte Session den Account übernimmt ohne dass
|
||||
// das alte Passwort bekannt ist.
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req changePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
st, err := h.Setup.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if st == nil || !st.Completed {
|
||||
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||
return
|
||||
}
|
||||
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
|
||||
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
|
||||
// nur der eine Admin-User; trotzdem prüfen wir das current_password
|
||||
// gegen die persistierte Hash.
|
||||
if !st.VerifyAdminPassword(req.CurrentPassword) {
|
||||
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||
}
|
||||
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
|
||||
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
|
||||
// sicherheitsbewusster: clearSession + force re-login. Wir
|
||||
// halten's hier ruhig.
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,149 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// ClusterHandler exposes cluster-state endpoints. v1 ist read-only;
|
||||
// /status liefert eine strukturierte UI-Sicht (local + peers + health),
|
||||
// /nodes bleibt als simpler list-endpoint für Tools/Scripts.
|
||||
// ClusterHandler exposes cluster-state endpoints. /status ist die
|
||||
// strukturierte UI-Sicht (local + peers + health), /nodes ist die
|
||||
// simple list, /system/load fan-outet via mTLS-Aggregator zu allen
|
||||
// Peers und liefert pro Node die /proc-Metriken.
|
||||
//
|
||||
// Aggregator kann nil sein (clustertls nicht initialisiert) — dann
|
||||
// liefert /cluster/system/load nur den lokalen Wert.
|
||||
type ClusterHandler struct {
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Aggregator *aggregator.Aggregator
|
||||
|
||||
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
|
||||
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
|
||||
TLSStore *clustertls.Store
|
||||
Tokens *jointoken.Service
|
||||
|
||||
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
|
||||
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
|
||||
PeerReloader PeerReloader
|
||||
}
|
||||
|
||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
return &ClusterHandler{Store: store, LocalID: localID}
|
||||
}
|
||||
|
||||
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden
|
||||
// wird /cluster/system/load die Peers via mTLS abklappern.
|
||||
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
|
||||
h.Aggregator = a
|
||||
return h
|
||||
}
|
||||
|
||||
// WithJoinFlow: Cluster-CA + Join-Token-Service. Nur wenn beide gesetzt
|
||||
// sind exposen wir /cluster/join-tokens (admin) + /cluster/issue-cert (public).
|
||||
func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken.Service) *ClusterHandler {
|
||||
h.TLSStore = store
|
||||
h.Tokens = tokens
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/cluster")
|
||||
g.GET("/nodes", h.ListNodes)
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/system/load", h.SystemLoad)
|
||||
g.DELETE("/nodes/:id", h.DeleteNode)
|
||||
if h.TLSStore != nil {
|
||||
g.GET("/cert-status", h.CertStatus)
|
||||
g.POST("/renew-self", h.RenewSelf)
|
||||
}
|
||||
if h.TLSStore != nil && h.Tokens != nil {
|
||||
g.POST("/join-tokens", h.GenerateJoinToken)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die
|
||||
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
|
||||
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder
|
||||
// die Cluster-Page wird inkonsistent.
|
||||
//
|
||||
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
|
||||
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
|
||||
// Peer kann nicht mehr auf :8443/:16379 connecten.
|
||||
func (h *ClusterHandler) DeleteNode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, simpleError("missing id"))
|
||||
return
|
||||
}
|
||||
if id == h.LocalID {
|
||||
response.BadRequest(c, simpleError("cannot remove the local node — would auto-recreate on next heartbeat"))
|
||||
return
|
||||
}
|
||||
if err := h.Store.Delete(c.Request.Context(), id); err != nil {
|
||||
if err == cluster.ErrNotFound {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: firewall render after peer delete failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
slog.Info("cluster: peer removed", "id", id, "actor", actorOf(c))
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
|
||||
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
|
||||
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
|
||||
// (NICHT auf authed).
|
||||
func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||
if h.TLSStore == nil || h.Tokens == nil {
|
||||
return
|
||||
}
|
||||
g := rg.Group("/cluster")
|
||||
g.POST("/issue-cert", h.IssueCert)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die Peer-only-Endpoints auf dem mTLS-Agent-
|
||||
// Listener. Auth läuft über das Peer-Cert (RequireAndVerifyClientCert
|
||||
// im ServerTLSConfig); der CN des Cert ist die FQDN des Peers.
|
||||
//
|
||||
// /agent/cluster/peers — joining Peer trägt sich nach erfolgreichem
|
||||
// issue-cert hier ein, damit der Primary ihn in ha_nodes hat (mit
|
||||
// status='joining') und der nächste Firewall-Render-Lauf seine IP
|
||||
// ins peer_ipv4-Set aufnimmt.
|
||||
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/cluster")
|
||||
g.POST("/peers", h.AgentRegisterPeer)
|
||||
}
|
||||
|
||||
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
||||
// Firewall + ggfs. andere Configs regeneriert (damit peer_ipv4-Set
|
||||
// frisch ist). Wird vom main.go gesetzt.
|
||||
type PeerReloader func(ctx context.Context) error
|
||||
|
||||
// WithPeerReloader: nach jedem AgentRegisterPeer-Aufruf gefeuert.
|
||||
func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
|
||||
h.PeerReloader = r
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
||||
@@ -116,3 +233,292 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// SystemLoad aggregiert /proc-Metriken aller Peers via mTLS-Aggregator.
|
||||
// Liefert ein Array { node_id, fqdn, ok, data, error, duration_ms }.
|
||||
// Lokaler Node wird IMMER eingefügt (direkter Call statt mTLS-Roundtrip).
|
||||
func (h *ClusterHandler) SystemLoad(c *gin.Context) {
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
results := make([]aggregator.PeerResult, 0, len(all))
|
||||
// Lokalen Node selbst befragen: wir rufen die Resources-Funktion
|
||||
// inline statt einen mTLS-Loopback aufzubauen — auch wenn der
|
||||
// Agent-Listener läuft, ist ein direkter Call billiger.
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
local := localSystemLoad()
|
||||
raw, _ := json.Marshal(local)
|
||||
results = append(results, aggregator.PeerResult{
|
||||
NodeID: n.ID,
|
||||
FQDN: n.FQDN,
|
||||
OK: true,
|
||||
Data: raw,
|
||||
Duration: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
if h.Aggregator != nil {
|
||||
peers := make([]models.HANode, 0, len(all))
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, n)
|
||||
}
|
||||
// /agent/system/resources auf dem Peer-Agent-Listener (:8443 mTLS).
|
||||
fan := h.Aggregator.FanOut(c.Request.Context(), peers, "/agent/system/resources", h.LocalID)
|
||||
results = append(results, fan...)
|
||||
}
|
||||
response.OK(c, gin.H{"nodes": results})
|
||||
}
|
||||
|
||||
// localSystemLoad ruft die selben Werte wie /system/resources, aber
|
||||
// als bare struct (kein gin.Context). Damit liefert SystemLoad pro Node
|
||||
// dasselbe Format wie der Agent-Endpoint.
|
||||
func localSystemLoad() any {
|
||||
// SystemHandler.Resources nutzt ein internes `resources` struct.
|
||||
// Wir duplizieren die /proc-Reads nicht — der Agent-Listener mountet
|
||||
// denselben Handler und liefert die JSON-Struktur. Für den lokalen
|
||||
// Path liefern wir das Snapshot über computeLocalSystemResources.
|
||||
return computeLocalSystemResources()
|
||||
}
|
||||
|
||||
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
||||
|
||||
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
||||
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
|
||||
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
|
||||
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
|
||||
token, exp, err := h.Tokens.Generate()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
|
||||
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
|
||||
// kann.
|
||||
caCert, _, err := h.TLSStore.LoadCA()
|
||||
caFP := ""
|
||||
if err == nil && caCert != nil {
|
||||
caFP = jointoken.CAFingerprint16(caCert.Raw)
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"token": token,
|
||||
"expires_at": exp.UTC().Format(time.RFC3339),
|
||||
"ca_fingerprint": caFP,
|
||||
})
|
||||
}
|
||||
|
||||
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
|
||||
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
|
||||
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
|
||||
// Auth nötig (der Joiner hat noch keine).
|
||||
type issueCertRequest struct {
|
||||
Token string `json:"token"`
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
type issueCertResponse struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
PeerCert string `json:"peer_cert"`
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||
var req issueCertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Token == "" || req.CSR == "" {
|
||||
response.BadRequest(c, errInvalidJoinRequest)
|
||||
return
|
||||
}
|
||||
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
|
||||
// und vom falschen Host einlöst.
|
||||
consumedBy := c.ClientIP()
|
||||
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, consumedBy); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
// CSR signieren.
|
||||
peerCert, err := h.TLSStore.SignCSR(req.CSR, nil)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
caPEM, err := h.TLSStore.CACertPEM()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, issueCertResponse{
|
||||
CACert: caPEM,
|
||||
PeerCert: peerCert,
|
||||
})
|
||||
}
|
||||
|
||||
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||
|
||||
type simpleError string
|
||||
|
||||
func (e simpleError) Error() string { return string(e) }
|
||||
|
||||
// ── Cluster-Cert-Status + Renewal ─────────────────────────────────────
|
||||
|
||||
// CertStatus liefert Metadata zu CA + Peer-Cert (Common Name, Expiry,
|
||||
// days_remaining). UI nutzt das für Expiry-Warnungen.
|
||||
func (h *ClusterHandler) CertStatus(c *gin.Context) {
|
||||
out := gin.H{
|
||||
"has_ca": h.TLSStore.HasCA(),
|
||||
"has_peer": h.TLSStore.HasPeer(),
|
||||
}
|
||||
if h.TLSStore.HasCA() {
|
||||
if info, err := h.TLSStore.CACertInfo(); err == nil {
|
||||
out["ca"] = info
|
||||
}
|
||||
}
|
||||
if h.TLSStore.HasPeer() {
|
||||
if info, err := h.TLSStore.PeerCertInfo(); err == nil {
|
||||
out["peer"] = info
|
||||
}
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// RenewSelf re-signed das eigene peer.crt mit der eigenen CA. Nur
|
||||
// sinnvoll auf einer Founder-Box; Joiner haben keine eigene CA.
|
||||
//
|
||||
// Nach Renew muss edgeguard-api restartet werden damit der Agent-
|
||||
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir
|
||||
// triggern das NICHT automatisch (würde die HTTP-Response abreißen);
|
||||
// stattdessen liefern wir einen Hinweis im Response.
|
||||
func (h *ClusterHandler) RenewSelf(c *gin.Context) {
|
||||
if !h.TLSStore.HasCA() {
|
||||
response.BadRequest(c, simpleError("no local CA — joiners cannot self-renew"))
|
||||
return
|
||||
}
|
||||
// Common-Name aus dem existierenden Peer-Cert übernehmen damit der
|
||||
// Cert weiterhin auf die aktuelle FQDN passt.
|
||||
cn := "edgeguard-node"
|
||||
if info, err := h.TLSStore.PeerCertInfo(); err == nil && info.CommonName != "" {
|
||||
cn = info.CommonName
|
||||
}
|
||||
if err := h.TLSStore.RenewSelfSigned(cn, []string{cn}, nil, nil); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
info, err := h.TLSStore.PeerCertInfo()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"peer": info,
|
||||
"restart_hint": "systemctl restart edgeguard-api",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Phase 3.5: Auto-Register beim Cluster-Join ────────────────────────
|
||||
|
||||
// registerPeerRequest: vom Joiner via mTLS-POST an /agent/cluster/peers.
|
||||
// CN des Client-Cert authentifiziert den Peer. Wir nehmen nur die Felder
|
||||
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
|
||||
// ha_nodes-Felder überschreiben.
|
||||
type registerPeerRequest struct {
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||
// Validiert dass der Client-Cert-CN zur fqdn passt (verhindert Cross-
|
||||
// Peer-Hijack) und upsertet die Row in ha_nodes mit status='joining'.
|
||||
// Phase 3.2 Heartbeat wird die Status auf 'online' ändern sobald der
|
||||
// Joiner seinen eigenen Heartbeat-Tick startet.
|
||||
func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
var req registerPeerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.ID == "" || req.FQDN == "" {
|
||||
response.BadRequest(c, simpleError("id + fqdn required"))
|
||||
return
|
||||
}
|
||||
// Cert-CN-Check: TLS-Layer hat den Cert bereits gegen unsere CA
|
||||
// verifiziert; jetzt prüfen wir dass der CN zur claimed FQDN passt.
|
||||
// Sonst könnte ein peer1.example.com Cert nutzen um peer2.example.com
|
||||
// in ha_nodes zu schreiben.
|
||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||
response.Forbidden(c, simpleError("no client cert presented"))
|
||||
return
|
||||
}
|
||||
cn := c.Request.TLS.PeerCertificates[0].Subject.CommonName
|
||||
if cn != req.FQDN {
|
||||
slog.Warn("cluster: peer cert CN does not match registration FQDN",
|
||||
"cn", cn, "fqdn", req.FQDN)
|
||||
response.Forbidden(c, simpleError("cert CN does not match fqdn"))
|
||||
return
|
||||
}
|
||||
|
||||
// Wir bauen ein models.HANode zusammen + nutzen den existierenden
|
||||
// UpsertSelf. (UpsertSelf nimmt eine HANode für einen registrierenden
|
||||
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
|
||||
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
|
||||
// wir brauchen.)
|
||||
n := models.HANode{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
FQDN: req.FQDN,
|
||||
APIURL: req.APIURL,
|
||||
Role: "peer",
|
||||
Status: "joining",
|
||||
}
|
||||
if req.PublicIP != "" {
|
||||
v := req.PublicIP
|
||||
n.PublicIP = &v
|
||||
}
|
||||
if req.InternalIP != "" {
|
||||
v := req.InternalIP
|
||||
n.InternalIP = &v
|
||||
}
|
||||
if req.MgmtIP != "" {
|
||||
v := req.MgmtIP
|
||||
n.MgmtIP = &v
|
||||
}
|
||||
if req.Version != "" {
|
||||
v := req.Version
|
||||
n.Version = &v
|
||||
}
|
||||
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
||||
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
||||
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
||||
// nachrendern.
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: PeerReloader failed after AgentRegisterPeer", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("cluster: peer registered via mTLS",
|
||||
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
|
||||
"client_cn", cn, "remote", c.ClientIP())
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"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"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
type DomainsHandler struct {
|
||||
Repo *domains.Repo
|
||||
Routing *routingrules.Repo
|
||||
Headers *domainheaders.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
|
||||
@@ -29,8 +31,8 @@ type DomainsHandler struct {
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
||||
return &DomainsHandler{Repo: repo, Routing: routing, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, headers *domainheaders.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
||||
return &DomainsHandler{Repo: repo, Routing: routing, Headers: headers, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) reload(ctx context.Context, op string) {
|
||||
@@ -50,6 +52,11 @@ func (h *DomainsHandler) Register(rg *gin.RouterGroup) {
|
||||
g.PUT("/:id", h.Update)
|
||||
g.DELETE("/:id", h.Delete)
|
||||
g.GET("/:id/routing-rules", h.ListRoutingRules)
|
||||
|
||||
g.GET("/:id/headers", h.ListHeaders)
|
||||
g.POST("/:id/headers", h.CreateHeader)
|
||||
g.PUT("/:id/headers/:hid", h.UpdateHeader)
|
||||
g.DELETE("/:id/headers/:hid", h.DeleteHeader)
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) List(c *gin.Context) {
|
||||
@@ -161,6 +168,127 @@ func parseID(c *gin.Context) (int64, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
// ── Response-Header CRUD ──────────────────────────────────────────────
|
||||
//
|
||||
// Header werden über `http-response set-header` von HAProxy gesetzt
|
||||
// (siehe internal/haproxy/haproxy.cfg.tpl). Name ist case-insensitive
|
||||
// unique pro Domain — Doppel-Eintrag erkennt PG selbst via Unique-Index
|
||||
// und liefert 409 zurück.
|
||||
|
||||
func (h *DomainsHandler) ListHeaders(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.ListForDomain(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"headers": out})
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) CreateHeader(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DomainResponseHeader
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
req.DomainID = id
|
||||
if !validHeaderName(req.Name) {
|
||||
response.BadRequest(c, errors.New("invalid header name"))
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.create", out.Name, out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "header.create")
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) UpdateHeader(c *gin.Context) {
|
||||
_, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid header id"))
|
||||
return
|
||||
}
|
||||
var req models.DomainResponseHeader
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if !validHeaderName(req.Name) {
|
||||
response.BadRequest(c, errors.New("invalid header name"))
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.Update(c.Request.Context(), hid, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.update", out.Name, out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "header.update")
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) DeleteHeader(c *gin.Context) {
|
||||
_, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid header id"))
|
||||
return
|
||||
}
|
||||
if err := h.Headers.Delete(c.Request.Context(), hid); err != nil {
|
||||
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.delete",
|
||||
strconv.FormatInt(hid, 10), gin.H{"id": hid}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "header.delete")
|
||||
}
|
||||
|
||||
// validHeaderName: RFC 7230 token chars. Wir engen das auf eine
|
||||
// vernünftige Untermenge ein (a-z A-Z 0-9 + `-`), damit niemand
|
||||
// versehentlich Newlines oder Quotes in den HAProxy-Renderer schiebt
|
||||
// und die Config-Datei sprengt.
|
||||
func validHeaderName(name string) bool {
|
||||
if name == "" || len(name) > 100 {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-':
|
||||
// ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func actorOf(c *gin.Context) string {
|
||||
if t := CurrentToken(c); t != nil {
|
||||
return t.Actor
|
||||
|
||||
@@ -3,7 +3,10 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -37,6 +40,7 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/ntp")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
g.GET("/status", h.Status)
|
||||
|
||||
p := g.Group("/pools")
|
||||
p.GET("", h.ListPools)
|
||||
@@ -46,6 +50,74 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
p.DELETE("/:id", h.DeletePool)
|
||||
}
|
||||
|
||||
// Status liefert den aktuellen chrony-Tracking-Status via `chronyc tracking`.
|
||||
// Output-Felder: synced (bool), reference (Quell-Server), stratum (int),
|
||||
// offset_ms (float), freq_ppm (float), rms_offset_ms (float), error (string
|
||||
// wenn chronyc nicht verfügbar oder System-Takt nicht sync'd).
|
||||
func (h *NTPHandler) Status(c *gin.Context) {
|
||||
out, err := exec.Command("chronyc", "tracking").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"synced": false,
|
||||
"error": "chronyc nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp := parseChronyTracking(string(out))
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
type chronyStatus struct {
|
||||
Synced bool `json:"synced"`
|
||||
Reference string `json:"reference"`
|
||||
Stratum int `json:"stratum"`
|
||||
OffsetMs float64 `json:"offset_ms"`
|
||||
FreqPPM float64 `json:"freq_ppm"`
|
||||
RMSOffsetMs float64 `json:"rms_offset_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func parseChronyTracking(out string) chronyStatus {
|
||||
s := chronyStatus{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Reference ID":
|
||||
// "Reference ID : A29FC801 (time.cloudflare.com)"
|
||||
if i := strings.Index(val, "("); i >= 0 {
|
||||
s.Reference = strings.Trim(val[i:], "()")
|
||||
} else {
|
||||
s.Reference = val
|
||||
}
|
||||
s.Synced = val != "00000000 ()"
|
||||
case "Stratum":
|
||||
fmt.Sscanf(val, "%d", &s.Stratum)
|
||||
if s.Stratum > 0 && s.Stratum < 16 {
|
||||
s.Synced = true
|
||||
}
|
||||
case "System time":
|
||||
// "0.000012345 seconds fast of NTP time"
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
s.OffsetMs = v * 1000
|
||||
case "Frequency":
|
||||
// "-12.345 ppm slow" or "+12.345 ppm fast"
|
||||
fmt.Sscanf(val, "%f", &s.FreqPPM)
|
||||
case "RMS offset":
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
s.RMSOffsetMs = v * 1000
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *NTPHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
@@ -11,22 +12,40 @@ import (
|
||||
// are mounted before SetupGate so they remain reachable while the API
|
||||
// is in setup mode.
|
||||
type SetupHandler struct {
|
||||
Store *setup.Store
|
||||
Store *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
||||
return &SetupHandler{Store: store}
|
||||
}
|
||||
|
||||
// WithAudit injiziert Audit-Repo + Node-ID damit Mutationen (contact-emails)
|
||||
// in audit_log landen. Optional — wenn Audit nil bleibt, läuft die
|
||||
// Mutation, aber ohne Log-Eintrag.
|
||||
func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.GET("/status", h.Status)
|
||||
g.POST("/complete", h.Complete)
|
||||
}
|
||||
|
||||
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||
// den Admin-Modus brauchen — Aufrufer hat requireAuth schon dran.
|
||||
func (h *SetupHandler) RegisterAuthed(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.POST("/contact-emails", h.SetContactEmails)
|
||||
}
|
||||
|
||||
// Status returns just the public bits of the setup state: whether
|
||||
// it's done and (if so) the configured admin_email + fqdn. Never
|
||||
// exposes the password hash.
|
||||
// it's done and (if so) the configured admin_email + acme_email +
|
||||
// fqdn. Never exposes the password hash.
|
||||
func (h *SetupHandler) Status(c *gin.Context) {
|
||||
st, err := h.Store.Load()
|
||||
if err != nil {
|
||||
@@ -36,10 +55,45 @@ func (h *SetupHandler) Status(c *gin.Context) {
|
||||
response.OK(c, gin.H{
|
||||
"completed": st.Completed,
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// SetContactEmails: Admin-only Update der zwei E-Mail-Felder.
|
||||
// Sessions bleiben aktiv (Cookie referenziert den alten Actor); auf
|
||||
// nächstem Login zählt der neue Wert.
|
||||
func (h *SetupHandler) SetContactEmails(c *gin.Context) {
|
||||
var req struct {
|
||||
AdminEmail string `json:"admin_email" binding:"required,email"`
|
||||
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Store.SetContactEmails(req.AdminEmail, req.ACMEEmail); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
st, err := h.Store.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "setup.contact_emails",
|
||||
st.AdminEmail, gin.H{
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Complete(c *gin.Context) {
|
||||
var req setup.Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -16,20 +15,55 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
// SystemHandler covers /system/health, /system/package-versions and
|
||||
// /system/upgrade. Wired from day 1 because the management UI carries
|
||||
// an update banner that polls package-versions.
|
||||
// SystemHandler covers /system/health, /system/package-versions,
|
||||
// /system/upgrade, /system/auto-update + /system/maintenance.
|
||||
//
|
||||
// Setup + HAProxyReloader sind optional — werden für /maintenance
|
||||
// gebraucht. Wenn nil sind die Maintenance-Endpoints noch verfügbar
|
||||
// aber returnen 503 (Setup nicht abgeschlossen).
|
||||
type SystemHandler struct {
|
||||
Version string
|
||||
Version string
|
||||
Setup *setup.Store
|
||||
HAProxyReloader func(stdcontext.Context) error
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewSystemHandler(version string) *SystemHandler {
|
||||
return &SystemHandler{Version: version}
|
||||
}
|
||||
|
||||
// WithMaintenance: setup + reloader injection. Aufrufer in main.go
|
||||
// nach DB-Pool-Open.
|
||||
func (h *SystemHandler) WithMaintenance(setupStore *setup.Store, reloader func(stdcontext.Context) error) *SystemHandler {
|
||||
h.Setup = setupStore
|
||||
h.HAProxyReloader = reloader
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAudit: Audit-Repo + NodeID damit Toggle-Aktionen (Maintenance,
|
||||
// Auto-Update) ins audit_log fließen.
|
||||
func (h *SystemHandler) WithAudit(a *audit.Repo, nodeID string) *SystemHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// WithDB: Pool für DB-Size + andere PG-introspection-Endpoints.
|
||||
func (h *SystemHandler) WithDB(pool *pgxpool.Pool) *SystemHandler {
|
||||
h.Pool = pool
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/system")
|
||||
g.GET("/health", h.Health)
|
||||
@@ -38,6 +72,32 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g.GET("/interfaces", h.Interfaces)
|
||||
g.GET("/services", h.Services)
|
||||
g.GET("/resources", h.Resources)
|
||||
g.GET("/auto-update", h.AutoUpdate)
|
||||
g.POST("/auto-update", h.ToggleAutoUpdate)
|
||||
g.GET("/maintenance", h.Maintenance)
|
||||
g.POST("/maintenance", h.ToggleMaintenance)
|
||||
g.GET("/backup-retention", h.BackupRetention)
|
||||
g.POST("/backup-retention", h.SetBackupRetention)
|
||||
g.GET("/audit-retention", h.AuditRetention)
|
||||
g.POST("/audit-retention", h.SetAuditRetention)
|
||||
g.GET("/db-size", h.DBSize)
|
||||
g.POST("/haproxy-reload", h.HAProxyReload)
|
||||
g.POST("/render-configs", h.RenderConfigs)
|
||||
g.POST("/service-restart", h.ServiceRestart)
|
||||
g.GET("/upgrade-status", h.UpgradeStatus)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
|
||||
// Agent-Engine (Port :8443). Der Cluster-Aggregator auf der Main-API
|
||||
// ruft diese Endpoints parallel auf allen Peers ab und kompiliert das
|
||||
// Ergebnis für /cluster/system/load.
|
||||
//
|
||||
// Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update
|
||||
// auf jedem Peer triggern), KEIN /upgrade.
|
||||
func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/system")
|
||||
g.GET("/health", h.Health)
|
||||
g.GET("/resources", h.Resources)
|
||||
}
|
||||
|
||||
// servicesToCheck is the curated list shown on the dashboard
|
||||
@@ -141,6 +201,14 @@ type resources struct {
|
||||
// Resources reads /proc + statfs for the box-level metrics card.
|
||||
// All best-effort — missing files just leave the field at zero.
|
||||
func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
response.OK(c, computeLocalSystemResources())
|
||||
}
|
||||
|
||||
// computeLocalSystemResources extrahiert die /proc + statfs reads aus
|
||||
// dem Handler damit der Cluster-Aggregator denselben Snapshot ohne
|
||||
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
|
||||
// die jeweiligen Felder einfach auf 0.
|
||||
func computeLocalSystemResources() resources {
|
||||
r := resources{}
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(data))
|
||||
@@ -195,7 +263,383 @@ func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, r)
|
||||
return r
|
||||
}
|
||||
|
||||
// Maintenance liefert den aktuellen Whole-Box-Maintenance-Status.
|
||||
// Wenn Setup nicht initialisiert: enabled=false.
|
||||
func (h *SystemHandler) Maintenance(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.OK(c, gin.H{"enabled": false, "message": ""})
|
||||
return
|
||||
}
|
||||
st, err := h.Setup.Load()
|
||||
if err != nil || st == nil {
|
||||
response.OK(c, gin.H{"enabled": false, "message": ""})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"enabled": st.MaintenanceMode,
|
||||
"message": st.MaintenanceMessage,
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleMaintenance schaltet whole-box maintenance an/aus.
|
||||
// Persistiert in setup-State + triggert HAProxy-Render damit das
|
||||
// 503-Block aktiv wird (oder verschwindet).
|
||||
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable,
|
||||
simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetMaintenanceMode(req.Enabled, req.Message); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.HAProxyReloader != nil {
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
slog.Warn("system: haproxy reload after maintenance toggle failed", "error", err)
|
||||
}
|
||||
}
|
||||
if h.Audit != nil {
|
||||
action := "system.maintenance.off"
|
||||
if req.Enabled {
|
||||
action = "system.maintenance.on"
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
|
||||
"", gin.H{"enabled": req.Enabled, "message": req.Message}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": req.Enabled, "message": req.Message})
|
||||
}
|
||||
|
||||
type simpleErr string
|
||||
|
||||
func (e simpleErr) Error() string { return string(e) }
|
||||
|
||||
// BackupRetention liefert keep_n. 0 = Default (backup.DefaultKeepN).
|
||||
func (h *SystemHandler) BackupRetention(c *gin.Context) {
|
||||
keep := 0
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
keep = st.BackupRetentionKeep
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"keep": keep, "default": 14})
|
||||
}
|
||||
|
||||
// SetBackupRetention persistiert die Operator-gewählte Retention.
|
||||
// keep=0 → wieder Default, keep=1..365 → custom.
|
||||
func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetBackupRetention(req.Keep); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.backup_retention",
|
||||
"", gin.H{"keep": req.Keep}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"keep": req.Keep})
|
||||
}
|
||||
|
||||
// AuditRetention liefert die konfigurierte Retention in Tagen.
|
||||
// 0 = Default (90 — siehe scheduler/main.go).
|
||||
func (h *SystemHandler) AuditRetention(c *gin.Context) {
|
||||
days := 0
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
days = st.AuditRetentionDays
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"days": days, "default": 90})
|
||||
}
|
||||
|
||||
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
|
||||
func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetAuditRetention(req.Days); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.audit_retention",
|
||||
"", gin.H{"days": req.Days}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"days": req.Days})
|
||||
}
|
||||
|
||||
// DBSize liefert pg_database_size + Top-N Tabellen-Größen für Capacity-
|
||||
// Planning. Operator sieht so welche Tabellen Disk fressen (typisch
|
||||
// audit_log + firewall_log → siehe Retention-Settings).
|
||||
type dbSizeTable struct {
|
||||
Name string `json:"name"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
HumanSz string `json:"human_size"`
|
||||
}
|
||||
type dbSizeResponse struct {
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
HumanTotal string `json:"human_total"`
|
||||
Tables []dbSizeTable `json:"top_tables"`
|
||||
}
|
||||
|
||||
func (h *SystemHandler) DBSize(c *gin.Context) {
|
||||
if h.Pool == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("db pool unavailable"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
var total int64
|
||||
var totalH string
|
||||
if err := h.Pool.QueryRow(ctx, `
|
||||
SELECT pg_database_size(current_database()),
|
||||
pg_size_pretty(pg_database_size(current_database()))`).Scan(&total, &totalH); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Top 10 user-tables nach total_relation_size (inkl. Indizes + TOAST).
|
||||
rows, err := h.Pool.Query(ctx, `
|
||||
SELECT c.relname,
|
||||
pg_total_relation_size(c.oid),
|
||||
pg_size_pretty(pg_total_relation_size(c.oid))
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r' AND n.nspname = 'public'
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC
|
||||
LIMIT 10`)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := dbSizeResponse{TotalBytes: total, HumanTotal: totalH, Tables: []dbSizeTable{}}
|
||||
for rows.Next() {
|
||||
var t dbSizeTable
|
||||
if err := rows.Scan(&t.Name, &t.Bytes, &t.HumanSz); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
out.Tables = append(out.Tables, t)
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
|
||||
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
|
||||
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
|
||||
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte.
|
||||
func (h *SystemHandler) HAProxyReload(c *gin.Context) {
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
|
||||
if err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.haproxy_reload",
|
||||
"", gin.H{}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// restartAllowlist sind die Dienste die der Operator über die UI neu
|
||||
// starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde
|
||||
// die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad).
|
||||
var restartAllowlist = map[string]bool{
|
||||
"haproxy": true,
|
||||
"squid": true,
|
||||
"unbound": true,
|
||||
"chrony": true,
|
||||
"nftables": true,
|
||||
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
|
||||
"edgeguard-scheduler": true,
|
||||
}
|
||||
|
||||
// ServiceRestart startet einen Dienst aus der Allowlist via
|
||||
// `systemctl restart`. Gibt 400 zurück wenn der Dienst nicht auf der
|
||||
// Allowlist steht, 500 wenn systemctl fehlschlägt.
|
||||
func (h *SystemHandler) ServiceRestart(c *gin.Context) {
|
||||
var req struct {
|
||||
Service string `json:"service" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service required"))
|
||||
return
|
||||
}
|
||||
svc := strings.TrimSpace(req.Service)
|
||||
if !restartAllowlist[svc] {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service not in allowlist: "+svc))
|
||||
return
|
||||
}
|
||||
unitName := svc + ".service"
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "restart", unitName).CombinedOutput()
|
||||
if err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_restart",
|
||||
svc, gin.H{"service": svc}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "service": svc})
|
||||
}
|
||||
|
||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
|
||||
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
|
||||
// dass die generierte Config nicht mehr mit der DB übereinstimmt
|
||||
// (Drift, Manual-Edit, etc.).
|
||||
//
|
||||
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
|
||||
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
|
||||
// gekoppelt; für die fehlt aktuell ein generisches "render all".
|
||||
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
|
||||
if h.HAProxyReloader == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
|
||||
"", gin.H{}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "rendered": []string{"haproxy"}})
|
||||
}
|
||||
|
||||
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
|
||||
// Reads systemctl show + journalctl der edgeguard-upgrade.service
|
||||
// transient unit. Hilft beim Debuggen wenn der Update-Banner nach
|
||||
// einem Click nicht weg geht — typische Ursachen (apt-Resolver-fail,
|
||||
// dpkg-broken) sind direkt im Log sichtbar.
|
||||
type upgradeStatusResponse struct {
|
||||
State string `json:"state"` // inactive / activating / failed / ...
|
||||
Result string `json:"result"` // success / exit-code / ...
|
||||
ExecMainPID int `json:"exec_main_pid"` // 0 wenn nie gelaufen
|
||||
ExitCode int `json:"exit_code"` // exit-status des letzten Laufs
|
||||
StartedAt string `json:"started_at"` // RFC3339 oder leer
|
||||
FinishedAt string `json:"finished_at"` // RFC3339 oder leer
|
||||
Log []string `json:"log"` // letzte N Zeilen aus journalctl
|
||||
}
|
||||
|
||||
func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
|
||||
out := upgradeStatusResponse{Log: []string{}}
|
||||
|
||||
// systemctl show liefert key=value pairs für die transient unit.
|
||||
// Wenn die Unit nie existiert hat → leeres Output / inactive.
|
||||
if data, err := exec.Command("systemctl", "show", "edgeguard-upgrade.service",
|
||||
"--no-page",
|
||||
"-p", "ActiveState",
|
||||
"-p", "Result",
|
||||
"-p", "ExecMainPID",
|
||||
"-p", "ExecMainStatus",
|
||||
"-p", "ExecMainStartTimestamp",
|
||||
"-p", "ExecMainExitTimestamp",
|
||||
).CombinedOutput(); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "ActiveState":
|
||||
out.State = kv[1]
|
||||
case "Result":
|
||||
out.Result = kv[1]
|
||||
case "ExecMainPID":
|
||||
out.ExecMainPID, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStatus":
|
||||
out.ExitCode, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStartTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.StartedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
case "ExecMainExitTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.FinishedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Letzte 200 Zeilen Journal — reicht für apt-output + Stack-Traces.
|
||||
if data, err := exec.Command("journalctl",
|
||||
"-u", "edgeguard-upgrade.service",
|
||||
"--no-pager", "-n", "200", "-o", "cat",
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
// Leere "no entries"-Antwort als leeres Log zurückgeben.
|
||||
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// AutoUpdate liefert den aktuellen Status (Conf-File existiert?).
|
||||
func (h *SystemHandler) AutoUpdate(c *gin.Context) {
|
||||
response.OK(c, gin.H{"enabled": aptsvc.AutoUpdateEnabled()})
|
||||
}
|
||||
|
||||
// ToggleAutoUpdate schaltet automatische Updates an/aus. Schreibt
|
||||
// /etc/apt/apt.conf.d/52edgeguard-auto-updates (sudo tee) bzw.
|
||||
// entfernt es (sudo rm). Idempotent.
|
||||
func (h *SystemHandler) ToggleAutoUpdate(c *gin.Context) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := aptsvc.SetAutoUpdate(req.Enabled); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
action := "system.auto_update.off"
|
||||
if req.Enabled {
|
||||
action = "system.auto_update.on"
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
|
||||
"", gin.H{"enabled": req.Enabled}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": req.Enabled})
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Health(c *gin.Context) {
|
||||
@@ -207,29 +651,16 @@ func (h *SystemHandler) Health(c *gin.Context) {
|
||||
|
||||
// PackageVersions reports installed and available versions for the
|
||||
// edgeguard-* APT packages. Called by the UI's update banner — it
|
||||
// polls every few minutes and lights up when available > installed.
|
||||
// polls every 30s and lights up when available > installed.
|
||||
//
|
||||
// `apt-get update -qq` is fired first (best-effort, no error if it
|
||||
// fails — we'd still return the cached candidate). Then `apt-cache
|
||||
// policy` is parsed for each package.
|
||||
// Implementierung delegiert an internal/services/apt: dort sitzt der
|
||||
// 5-min-Throttle für apt-get update, der LC_ALL=C-Fix für deutsche
|
||||
// Locales und das Background-Refresh-Timer. `?force=1` (UI-Button
|
||||
// „Jetzt prüfen") bypassed den Throttle, damit nach einem `make publish`
|
||||
// nicht 5 min auf das nächste Tick gewartet werden muss.
|
||||
func (h *SystemHandler) PackageVersions(c *gin.Context) {
|
||||
// API läuft als edgeguard-User; ohne sudo schreibt apt-get update
|
||||
// nicht in /var/lib/apt/lists und der candidate bleibt veraltet.
|
||||
// Sudoers-Eintrag in postinst whitelisted exakt diese Zeile.
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/apt-get", "update", "-qq").Run()
|
||||
|
||||
out := map[string]string{}
|
||||
for _, pkg := range []string{"edgeguard-api", "edgeguard-ui", "edgeguard"} {
|
||||
raw, err := exec.Command("apt-cache", "policy", pkg).CombinedOutput()
|
||||
if err != nil {
|
||||
out[pkg+"_installed"] = ""
|
||||
out[pkg+"_available"] = ""
|
||||
continue
|
||||
}
|
||||
installed, candidate := parseAptPolicy(string(raw))
|
||||
out[pkg+"_installed"] = installed
|
||||
out[pkg+"_available"] = candidate
|
||||
}
|
||||
force := c.Query("force") == "1" || c.Query("force") == "true"
|
||||
out := aptsvc.PackageVersions(c.Request.Context(), force)
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -248,16 +679,42 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
|
||||
// /var/lib/edgeguard ist edgeguard-owned + persistent + von
|
||||
// beiden Namespaces aus zugänglich.
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
|
||||
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent
|
||||
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
|
||||
// Index-Datei) → apt-resolver-fail mit "no choices are installable".
|
||||
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
|
||||
// nachzuziehen. Befund 2026-05-17.
|
||||
const script = `#!/bin/bash
|
||||
set -e
|
||||
sleep 2
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "[upgrade] dpkg --configure -a"
|
||||
dpkg --configure -a || true
|
||||
echo "[upgrade] apt-get update"
|
||||
apt-get update -qq
|
||||
echo "[upgrade] apt-get install -y edgeguard-api edgeguard-ui edgeguard"
|
||||
apt-get install -y -qq -o Dpkg::Options::=--force-confold edgeguard-api edgeguard-ui edgeguard
|
||||
|
||||
retry_apt() {
|
||||
local attempt=0
|
||||
local max=3
|
||||
local wait_for=15
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
echo "[upgrade] attempt $attempt/$max: apt-get update + install"
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then
|
||||
return 0
|
||||
fi
|
||||
if [ $attempt -lt $max ]; then
|
||||
echo "[upgrade] failed (likely Packages-index race), waiting ${wait_for}s before retry"
|
||||
sleep $wait_for
|
||||
wait_for=$((wait_for * 2))
|
||||
fi
|
||||
done
|
||||
echo "[upgrade] all $max attempts failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_apt
|
||||
echo "[upgrade] complete"
|
||||
rm -f /var/lib/edgeguard/upgrade.sh
|
||||
`
|
||||
@@ -401,27 +858,3 @@ func flagsToList(f net.Flags) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAptPolicy extracts "Installed: x" and "Candidate: y" from
|
||||
// apt-cache policy output. Both can be "(none)"; we normalise that to
|
||||
// empty string.
|
||||
var aptPolicyLine = regexp.MustCompile(`^\s+(Installed|Candidate):\s+(.+)\s*$`)
|
||||
|
||||
func parseAptPolicy(out string) (installed, candidate string) {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
m := aptPolicyLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
val := m[2]
|
||||
if val == "(none)" {
|
||||
val = ""
|
||||
}
|
||||
switch m[1] {
|
||||
case "Installed":
|
||||
installed = val
|
||||
case "Candidate":
|
||||
candidate = val
|
||||
}
|
||||
}
|
||||
return installed, candidate
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ global
|
||||
daemon
|
||||
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
|
||||
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||
# HAProxy 3.0.x in Trixie ist mit USE_QUIC=1 + USE_QUIC_OPENSSL_COMPAT=1
|
||||
# gebaut; die System-OpenSSL kann QUIC aber nur über den Kompat-Layer.
|
||||
# Ohne dieses Flag verweigert HAProxy `bind quic4@…` mit "this SSL
|
||||
# library does not support the QUIC protocol".
|
||||
limited-quic
|
||||
|
||||
defaults
|
||||
log global
|
||||
@@ -50,8 +55,14 @@ frontend public_http
|
||||
# Admin-Panel fällt. mailgateway/enconf-Pattern.
|
||||
frontend public_https
|
||||
bind :443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||
# HTTP/3 via QUIC (UDP/443). HAProxy 3.0.x ist mit USE_QUIC=1
|
||||
# gebaut; Browser fallen via Alt-Svc-Header (siehe unten) für
|
||||
# Folge-Requests auf h3 zurück.
|
||||
bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
||||
|
||||
http-response set-header Strict-Transport-Security "max-age=31536000"
|
||||
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
|
||||
# ma=86400 = Browser darf den Hinweis 24h cachen.
|
||||
http-response set-header Alt-Svc "h3=\":443\"; ma=86400"
|
||||
|
||||
# Client-IP-Weiterleitung an Backends. `option forwardfor` (defaults)
|
||||
# setzt X-Forwarded-For; wir ergänzen Proto + RealIP damit Apps
|
||||
@@ -60,7 +71,47 @@ frontend public_https
|
||||
http-request set-header X-Forwarded-Proto https
|
||||
http-request set-header X-Real-IP %[src]
|
||||
|
||||
{{- if .GlobalMaintenance}}
|
||||
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
|
||||
# Block kommt VOR allen per-Domain ACLs und blockt JEDEN Customer-
|
||||
# Request. Mgmt-UI auf :3443 (mgmt_https) ist davon nicht betroffen.
|
||||
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{.GlobalMaintenanceMessage}}"
|
||||
{{- end}}
|
||||
|
||||
{{- range $d := .Domains}}
|
||||
{{- if $d.RedirectFromHost}}
|
||||
# www-Redirect: {{$d.RedirectFromHost}} → {{$d.Name}}
|
||||
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
|
||||
{{- end}}
|
||||
{{- if $d.MaintenanceMode}}
|
||||
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
|
||||
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }
|
||||
{{- end}}
|
||||
{{- if $d.MaxBodyBytes}}
|
||||
# Body-Size-Cap für {{$d.Name}}: {{$d.MaxBodyBytes}} Bytes.
|
||||
# Nur Content-Length wird geprüft — Chunked-Bodies wären erst nach
|
||||
# http-buffer-request abgreifbar, das wollen wir auf Frontend-Level
|
||||
# nicht aktivieren (verbraucht RAM pro Connection).
|
||||
http-request deny deny_status 413 if { hdr(host) -i {{$d.Name}} } { req.hdr_val(content-length) -m int gt {{$d.MaxBodyBytes}} }
|
||||
{{- end}}
|
||||
{{- if $d.RateLimitThreshold}}
|
||||
# Rate-Limit für {{$d.Name}}: {{$d.RateLimitRPS}} req/s pro Client-IP
|
||||
# (Schwelle = rps × 10s-Fenster = {{$d.RateLimitThreshold}} hits).
|
||||
http-request track-sc0 src table rl_{{$d.ID}} if { hdr(host) -i {{$d.Name}} }
|
||||
http-request deny deny_status 429 if { hdr(host) -i {{$d.Name}} } { sc_http_req_rate(0) gt {{$d.RateLimitThreshold}} }
|
||||
{{- end}}
|
||||
{{- if $d.HSTSHeader}}
|
||||
# del + set damit ein vom Backend gesetztes HSTS (z. B. nginx mit
|
||||
# eigener add_header-Direktive) garantiert von unserer Policy
|
||||
# überschrieben wird — sonst tauchen 2 Strict-Transport-Security
|
||||
# Header in der Response auf.
|
||||
http-response del-header Strict-Transport-Security if { hdr(host) -i {{$d.Name}} }
|
||||
http-response set-header Strict-Transport-Security "{{$d.HSTSHeader}}" if { hdr(host) -i {{$d.Name}} }
|
||||
{{- end}}
|
||||
{{- range $h := $d.ResponseHeaders}}
|
||||
http-response del-header {{$h.Name}} if { hdr(host) -i {{$d.Name}} }
|
||||
http-response set-header {{$h.Name}} "{{$h.Value}}" if { hdr(host) -i {{$d.Name}} }
|
||||
{{- end}}
|
||||
{{- range $r := $d.Routes}}
|
||||
use_backend eg_backend_{{$r.BackendID}} if { hdr(host) -i {{$d.Name}} } { path_beg {{$r.PathPrefix}} }
|
||||
{{- end}}
|
||||
@@ -99,6 +150,20 @@ backend api_backend
|
||||
timeout tunnel 1h
|
||||
server api1 127.0.0.1:9443 check
|
||||
|
||||
{{- /* Per-Domain Rate-Limit Stick-Tables.
|
||||
Eigenes Backend pro Domain damit verschiedene Schwellen nicht
|
||||
miteinander interferieren; HAProxy 3.0 erlaubt mehrere
|
||||
stick-table-Backends die per Frontend mit track-sc0 angesteuert
|
||||
werden. expire 10s hält den Speicher knapp. */ -}}
|
||||
{{- range $d := .Domains}}
|
||||
{{- if $d.RateLimitThreshold}}
|
||||
|
||||
backend rl_{{$d.ID}}
|
||||
# Rate-Limit-Counter für {{$d.Name}}; gefüllt von public_https.
|
||||
stick-table type ip size 100k expire 10s store http_req_rate(10s)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- range $b := .Backends}}
|
||||
|
||||
backend eg_backend_{{$b.ID}}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
|
||||
"backend api_backend",
|
||||
"server api1 127.0.0.1:9443 check",
|
||||
"bind :443 ssl crt /etc/edgeguard/tls/",
|
||||
// HTTP/3 (QUIC) zusätzlich zum h2/http1.1-Listener.
|
||||
"bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3",
|
||||
// limited-quic global muss gesetzt sein, sonst weigert sich
|
||||
// HAProxy 3.0 das quic4-bind anzunehmen (OpenSSL-Kompat-Layer).
|
||||
"limited-quic",
|
||||
// Alt-Svc damit Browser auf h3 upgraden.
|
||||
`Alt-Svc "h3=\":443\"; ma=86400"`,
|
||||
"path_beg /.well-known/acme-challenge/",
|
||||
"http-request redirect scheme https",
|
||||
// Client-IP-Weiterleitung an Backends — XFF kommt aus
|
||||
@@ -54,6 +61,326 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
|
||||
t.Errorf("missing %q in baseline output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
// Globales HSTS auf public_https darf NICHT mehr drin sein —
|
||||
// das wird jetzt pro Domain via ACL gesetzt (siehe HSTS-Test).
|
||||
// mgmt_https hat aber weiterhin ein globales HSTS.
|
||||
publicIdx := strings.Index(out, "frontend public_https")
|
||||
mgmtIdx := strings.Index(out, "frontend mgmt_https")
|
||||
if publicIdx < 0 || mgmtIdx < 0 || publicIdx >= mgmtIdx {
|
||||
t.Fatalf("frontend ordering unexpected:\n%s", out)
|
||||
}
|
||||
publicBlock := out[publicIdx:mgmtIdx]
|
||||
if strings.Contains(publicBlock, "set-header Strict-Transport-Security") {
|
||||
t.Errorf("public_https soll KEIN globales HSTS mehr enthalten (pro-Domain ACL):\n%s", publicBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_HSTSPerDomain(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "a.example.com", Active: true,
|
||||
HSTSEnabled: true, HSTSMaxAge: 63072000,
|
||||
HSTSSubdomains: true, HSTSPreload: true,
|
||||
},
|
||||
HSTSHeader: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 2, Name: "b.example.com", Active: true,
|
||||
HSTSEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
for _, w := range []string{
|
||||
// Erst löschen (gegen Backend-set HSTS), dann setzen.
|
||||
`http-response del-header Strict-Transport-Security if { hdr(host) -i a.example.com }`,
|
||||
`http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" if { hdr(host) -i a.example.com }`,
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") ||
|
||||
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") &&
|
||||
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 {
|
||||
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden.
|
||||
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MaintenanceModeBlocksWith503(t *testing.T) {
|
||||
msg := `Wartung — bitte später wiederkommen.`
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "down.example.com", Active: true,
|
||||
MaintenanceMode: true, MaintenanceMessage: &msg,
|
||||
},
|
||||
MaintMessage: msg,
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung — bitte später wiederkommen." if { hdr(host) -i down.example.com }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing maintenance-503 line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_WWWRedirectToNaked(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "example.com", Active: true,
|
||||
WWWRedirect: "to-naked",
|
||||
},
|
||||
RedirectFromHost: "www.example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request redirect prefix https://example.com code 301 if { hdr(host) -i www.example.com }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing www→naked redirect line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_WWWRedirectToWWW(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "www.example.com", Active: true,
|
||||
WWWRedirect: "to-www",
|
||||
},
|
||||
RedirectFromHost: "example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request redirect prefix https://www.example.com code 301 if { hdr(host) -i example.com }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing naked→www redirect line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHSTSHeader(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
d models.Domain
|
||||
want string
|
||||
}{
|
||||
{"disabled", models.Domain{HSTSEnabled: false}, ""},
|
||||
{"defaults", models.Domain{HSTSEnabled: true}, "max-age=31536000"},
|
||||
{"explicit", models.Domain{HSTSEnabled: true, HSTSMaxAge: 7200}, "max-age=7200"},
|
||||
{"sub", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true}, "max-age=60; includeSubDomains"},
|
||||
{"sub+preload", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true, HSTSPreload: true}, "max-age=60; includeSubDomains; preload"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := buildHSTSHeader(tc.d); got != tc.want {
|
||||
t.Errorf("buildHSTSHeader: got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMaintMessage(t *testing.T) {
|
||||
msg := `He said "hi"` + "\n" + `and left`
|
||||
d := models.Domain{MaintenanceMode: true, MaintenanceMessage: &msg}
|
||||
got := buildMaintMessage(d)
|
||||
// Quotes durch ' ersetzt, Newline → Space.
|
||||
want := `He said 'hi' and left`
|
||||
if got != want {
|
||||
t.Errorf("buildMaintMessage: got %q want %q", got, want)
|
||||
}
|
||||
if buildMaintMessage(models.Domain{MaintenanceMode: false}) != "" {
|
||||
t.Errorf("buildMaintMessage should be empty when MaintenanceMode is off")
|
||||
}
|
||||
if got := buildMaintMessage(models.Domain{MaintenanceMode: true}); got == "" {
|
||||
t.Errorf("buildMaintMessage should emit fallback text when no message set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_GlobalMaintenanceBlocksAllCustomerTraffic(t *testing.T) {
|
||||
v := View{
|
||||
GlobalMaintenance: true,
|
||||
GlobalMaintenanceMessage: "Wartung läuft.",
|
||||
Domains: []DomainView{
|
||||
{Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung läuft."`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing global-maintenance 503 block:\n%s", out)
|
||||
}
|
||||
// Block muss VOR den use_backend-Zeilen IM SELBEN public_https-
|
||||
// Frontend stehen (sonst ineffektiv — HAProxy execut'ed in-order,
|
||||
// return-actions terminieren die Chain). public_http hat ein
|
||||
// frühes use_backend api_backend für ACME — das hier nicht
|
||||
// matchen.
|
||||
pubIdx := strings.Index(out, "frontend public_https")
|
||||
mgmtIdxStart := strings.Index(out, "frontend mgmt_https")
|
||||
if pubIdx < 0 || mgmtIdxStart < 0 {
|
||||
t.Fatalf("frontends not found in output:\n%s", out)
|
||||
}
|
||||
publicBlock := out[pubIdx:mgmtIdxStart]
|
||||
idxBlock := strings.Index(publicBlock, want)
|
||||
idxUseBackend := strings.Index(publicBlock, "use_backend")
|
||||
if idxBlock < 0 || (idxUseBackend > 0 && idxBlock > idxUseBackend) {
|
||||
t.Errorf("global-maintenance block must precede use_backend in public_https\n block at %d, use_backend at %d", idxBlock, idxUseBackend)
|
||||
}
|
||||
// mgmt_https darf NICHT betroffen sein.
|
||||
mgmtIdx := strings.Index(out, "frontend mgmt_https")
|
||||
if mgmtIdx > 0 {
|
||||
mgmtBlock := out[mgmtIdx:]
|
||||
if strings.Contains(mgmtBlock, want) {
|
||||
t.Errorf("mgmt_https must NOT contain global-maintenance block:\n%s", mgmtBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_GlobalMaintenanceOff_NoBlock(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
if strings.Contains(out, "Whole-Box-Maintenance") {
|
||||
t.Errorf("global-maintenance comment should not be rendered when off:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_RateLimitEmitsStickTableAndDeny(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 7, Name: "api.example.com", Active: true,
|
||||
RateLimitRPS: 50,
|
||||
},
|
||||
RateLimitThreshold: 500, // 50 rps × 10s
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
for _, w := range []string{
|
||||
"backend rl_7",
|
||||
"stick-table type ip size 100k expire 10s store http_req_rate(10s)",
|
||||
`http-request track-sc0 src table rl_7 if { hdr(host) -i api.example.com }`,
|
||||
`http-request deny deny_status 429 if { hdr(host) -i api.example.com } { sc_http_req_rate(0) gt 500 }`,
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q in rate-limit output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_NoRateLimitNoStickTable(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{ID: 9, Name: "a.example.com", Active: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
if strings.Contains(out, "backend rl_9") {
|
||||
t.Errorf("stick-table backend rendered for domain without rate-limit:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "track-sc0") {
|
||||
t.Errorf("track-sc0 emitted without rate-limit:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_BodySizeDeny413(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "upload.example.com", Active: true,
|
||||
MaxBodyKB: 2048,
|
||||
},
|
||||
MaxBodyBytes: 2048 * 1024,
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request deny deny_status 413 if { hdr(host) -i upload.example.com } { req.hdr_val(content-length) -m int gt 2097152 }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing 413 body-size deny:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_CustomResponseHeaders(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{ID: 1, Name: "x.example.com", Active: true},
|
||||
ResponseHeaders: []ResponseHeaderView{
|
||||
{Name: "X-Frame-Options", Value: "DENY"},
|
||||
{Name: "Content-Security-Policy", Value: "default-src 'self'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
for _, w := range []string{
|
||||
// del + set pro Custom-Header, damit Upstream-Werte
|
||||
// garantiert überschrieben werden.
|
||||
`http-response del-header X-Frame-Options if { hdr(host) -i x.example.com }`,
|
||||
`http-response set-header X-Frame-Options "DENY" if { hdr(host) -i x.example.com }`,
|
||||
`http-response del-header Content-Security-Policy if { hdr(host) -i x.example.com }`,
|
||||
`http-response set-header Content-Security-Policy "default-src 'self'" if { hdr(host) -i x.example.com }`,
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHeaderValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`plain`: `plain`,
|
||||
`with "quotes"`: `with 'quotes'`,
|
||||
"with\nnewline": "with newline",
|
||||
"crlf\r\nattack": "crlf attack",
|
||||
`csp default-src 'self'`: `csp default-src 'self'`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeHeaderValue(in); got != want {
|
||||
t.Errorf("sanitizeHeaderValue(%q) = %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedirectFromHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
d models.Domain
|
||||
want string
|
||||
}{
|
||||
{"none", models.Domain{Name: "example.com"}, ""},
|
||||
{"to-naked", models.Domain{Name: "example.com", WWWRedirect: "to-naked"}, "www.example.com"},
|
||||
{"to-naked invalid (name already has www)", models.Domain{Name: "www.example.com", WWWRedirect: "to-naked"}, ""},
|
||||
{"to-www", models.Domain{Name: "www.example.com", WWWRedirect: "to-www"}, "example.com"},
|
||||
{"to-www invalid (name lacks www)", models.Domain{Name: "example.com", WWWRedirect: "to-www"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := buildRedirectFromHost(tc.d); got != tc.want {
|
||||
t.Errorf("buildRedirectFromHost: got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
|
||||
|
||||
@@ -3,15 +3,23 @@ package models
|
||||
import "time"
|
||||
|
||||
type Domain struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
PrimaryBackendID *int64 `gorm:"column:primary_backend_id" json:"primary_backend_id,omitempty"`
|
||||
HTTPToHTTPS bool `gorm:"column:http_to_https" json:"http_to_https"`
|
||||
HSTSEnabled bool `gorm:"column:hsts_enabled" json:"hsts_enabled"`
|
||||
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
PrimaryBackendID *int64 `gorm:"column:primary_backend_id" json:"primary_backend_id,omitempty"`
|
||||
HTTPToHTTPS bool `gorm:"column:http_to_https" json:"http_to_https"`
|
||||
HSTSEnabled bool `gorm:"column:hsts_enabled" json:"hsts_enabled"`
|
||||
HSTSMaxAge int `gorm:"column:hsts_max_age" json:"hsts_max_age"`
|
||||
HSTSSubdomains bool `gorm:"column:hsts_subdomains" json:"hsts_subdomains"`
|
||||
HSTSPreload bool `gorm:"column:hsts_preload" json:"hsts_preload"`
|
||||
MaintenanceMode bool `gorm:"column:maintenance_mode" json:"maintenance_mode"`
|
||||
MaintenanceMessage *string `gorm:"column:maintenance_message" json:"maintenance_message,omitempty"`
|
||||
WWWRedirect string `gorm:"column:www_redirect" json:"www_redirect"` // ""|"to-naked"|"to-www"
|
||||
RateLimitRPS int `gorm:"column:rate_limit_rps" json:"rate_limit_rps"`
|
||||
MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"`
|
||||
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Domain) TableName() string { return "domains" }
|
||||
|
||||
19
internal/models/domain_response_header.go
Normal file
19
internal/models/domain_response_header.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DomainResponseHeader stellt einen einzelnen HTTP-Response-Header dar,
|
||||
// den HAProxy für alle Antworten einer Domain mit `http-response set-header`
|
||||
// setzt. Mehrere Einträge pro Domain möglich; Name ist case-insensitive
|
||||
// unique (siehe Migration 0024).
|
||||
type DomainResponseHeader struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
DomainID int64 `gorm:"column:domain_id" json:"domain_id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Value string `gorm:"column:value" json:"value"`
|
||||
Position int `gorm:"column:position" json:"position"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DomainResponseHeader) TableName() string { return "domain_response_headers" }
|
||||
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