feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,7 +61,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.2.13"
|
var version = "1.2.35"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -186,10 +186,8 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
|
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
|
||||||
}
|
}
|
||||||
// Logical Replication liefert Änderungen automatisch — aber Service-
|
// runSecondaryConfigRender wird weiter unten gestartet sobald
|
||||||
// Configs (haproxy.cfg, nftables …) müssen nach jeder Änderung neu
|
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
|
||||||
// gerendert werden. Diese Goroutine erkennt hash-Änderungen und rendert.
|
|
||||||
go runSecondaryConfigRender(context.Background(), pool)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
||||||
@@ -236,6 +234,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondary-Config-Render: jetzt wo der Aggregator bereit ist starten.
|
||||||
|
// Aggregator wird für Cert-Sync (mTLS GET /agent/cluster/tls-certs) benötigt.
|
||||||
|
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
|
||||||
|
go runSecondaryConfigRender(context.Background(), pool, secrets.New(""), clusterAggregator, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
auditRepo := audit.New(pool)
|
auditRepo := audit.New(pool)
|
||||||
domainsRepo := domains.New(pool)
|
domainsRepo := domains.New(pool)
|
||||||
domainHeadersRepo := domainheaders.New(pool)
|
domainHeadersRepo := domainheaders.New(pool)
|
||||||
@@ -706,14 +710,24 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio
|
|||||||
// Service-Configs wenn die Logical Replication Änderungen vom Primary
|
// Service-Configs wenn die Logical Replication Änderungen vom Primary
|
||||||
// geliefert hat. Erkennt das an einem geänderten config_hash.
|
// geliefert hat. Erkennt das an einem geänderten config_hash.
|
||||||
// Tick: 5 min — balanciert Reaktionszeit gegen Reload-Overhead.
|
// Tick: 5 min — balanciert Reaktionszeit gegen Reload-Overhead.
|
||||||
func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) {
|
//
|
||||||
|
// Cert-Sync läuft auf jedem Tick unabhängig vom config_hash, da certbot-
|
||||||
|
// Renewals auf dem Primary den Hash nicht ändern.
|
||||||
|
func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secrets.Box, agg *aggregator.Aggregator, localID string) {
|
||||||
const tick = 5 * time.Minute
|
const tick = 5 * time.Minute
|
||||||
t := time.NewTicker(tick)
|
t := time.NewTicker(tick)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
var lastHash string
|
var lastHash string
|
||||||
render := func() {
|
render := func() {
|
||||||
rCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
rCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// TLS-Zertifikate bei jedem Tick synchronisieren — unabhängig vom
|
||||||
|
// config_hash, da certbot-Renewals den Hash nicht berühren.
|
||||||
|
if err := handlers.SyncTLSCertsFromPrimary(rCtx, pool, agg, localID); err != nil {
|
||||||
|
slog.Warn("cluster: cert sync failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
hash, err := cluster.ComputeConfigHash(rCtx, pool)
|
hash, err := cluster.ComputeConfigHash(rCtx, pool)
|
||||||
if err != nil || hash == lastHash {
|
if err != nil || hash == lastHash {
|
||||||
return
|
return
|
||||||
@@ -728,10 +742,32 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) {
|
|||||||
if err := firewallrender.New(pool).Render(rCtx); err != nil {
|
if err := firewallrender.New(pool).Render(rCtx); err != nil {
|
||||||
slog.Warn("cluster: secondary nftables render failed", "error", err)
|
slog.Warn("cluster: secondary nftables render failed", "error", err)
|
||||||
}
|
}
|
||||||
// Weitere Dienste (Squid, Unbound, Chrony, WireGuard) werden bei
|
// WireGuard — Interface-Configs + wg-quick@<iface> reload
|
||||||
// Änderungen an ihren spezifischen Tabellen ebenfalls neu gerendert.
|
if err := wgrender.New(pool, box).Render(rCtx); err != nil {
|
||||||
// render-config ohne Reload: die Dienste merken Änderungen selbst
|
slog.Warn("cluster: secondary wireguard render failed", "error", err)
|
||||||
// (HAProxy/nftables über systemctl reload, der oben bereits läuft).
|
}
|
||||||
|
// Squid forward proxy
|
||||||
|
if err := squidrender.New(pool).Render(rCtx); err != nil {
|
||||||
|
slog.Warn("cluster: secondary squid render failed", "error", err)
|
||||||
|
}
|
||||||
|
// Unbound DNS
|
||||||
|
if err := unboundrender.New(pool).Render(rCtx); err != nil {
|
||||||
|
slog.Warn("cluster: secondary unbound render failed", "error", err)
|
||||||
|
}
|
||||||
|
// Chrony NTP
|
||||||
|
if err := chronyrender.New(pool).Render(rCtx); err != nil {
|
||||||
|
slog.Warn("cluster: secondary chrony render failed", "error", err)
|
||||||
|
}
|
||||||
|
// Netzwerk-Interfaces (VLAN/Bridge/Bond) — erstellt Interface-Objekte,
|
||||||
|
// weist aber KEINE IPs zu (das ist node-spezifisch und darf nicht aus
|
||||||
|
// der Replikation kommen — sonst IP-Konflikt mit dem Primary).
|
||||||
|
if err := networkifs.NewGenerator(networkifs.New(pool)).Render(rCtx); err != nil {
|
||||||
|
slog.Warn("cluster: secondary interfaces render failed", "error", err)
|
||||||
|
}
|
||||||
|
// IP-Adressen werden auf dem Secondary NICHT aus der Replikation
|
||||||
|
// angewendet. Jeder Node konfiguriert seine eigenen IPs statisch
|
||||||
|
// (z.B. /etc/network/interfaces). Floating-Service-IPs werden von
|
||||||
|
// Keepalived verwaltet — nicht vom Renderer.
|
||||||
}
|
}
|
||||||
// Initialer Check nach kurzem Delay (Replication braucht einen Moment)
|
// Initialer Check nach kurzem Delay (Replication braucht einen Moment)
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.2.15"
|
var version = "1.2.35"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -14,6 +14,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
@@ -51,6 +52,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pkg/sftp v1.13.10 // indirect
|
github.com/pkg/sftp v1.13.10 // indirect
|
||||||
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/tinylib/msgp v1.6.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
|
|||||||
5
go.sum
5
go.sum
@@ -1,3 +1,6 @@
|
|||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||||
|
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
@@ -107,6 +110,8 @@ github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1Hbe
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
|||||||
@@ -229,6 +229,43 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string)
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer.
|
||||||
|
// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd).
|
||||||
|
func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) 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()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
res.Err = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
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()
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
|
||||||
|
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.OK = true
|
||||||
|
res.Data = respBody
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
// Compile-time check dass cluster importiert wird (für Drift-Detection
|
// Compile-time check dass cluster importiert wird (für Drift-Detection
|
||||||
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
|
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
|
||||||
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
|
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type hashTable struct {
|
|||||||
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
|
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
|
||||||
MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…)
|
MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…)
|
||||||
// → zählt nicht als "user hat config" bei der Empty-DB-Erkennung
|
// → zählt nicht als "user hat config" bei der Empty-DB-Erkennung
|
||||||
|
CustomSQL string // wenn gesetzt: direkt als Hash-Query verwenden (überschreibt hashSQL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier
|
// hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier
|
||||||
@@ -70,9 +71,33 @@ var hashSpec = []hashTable{
|
|||||||
|
|
||||||
{Name: "ntp_pools", MigrationDefault: true},
|
{Name: "ntp_pools", MigrationDefault: true},
|
||||||
|
|
||||||
// network_interfaces, ip_addresses, static_routes, dns_settings, ntp_settings
|
// network_interfaces + ip_addresses werden seit 0030 repliziert —
|
||||||
// sind node-spezifisch (jeder Node hat eigene IPs/Routes/Listen-Adressen)
|
// VLAN/Bridge/Bond-Definitionen und Gateway-IPs müssen auf dem Secondary
|
||||||
// und fließen NICHT in den Drift-Hash ein.
|
// für Failover bereitstehen. Ethernet-IPs werden im Secondary-Renderer
|
||||||
|
// herausgefiltert (eth0 = cloud-init / Keepalived).
|
||||||
|
//
|
||||||
|
// ip_addresses.interface_id ist ein node-lokaler Autoincrement-PK, der
|
||||||
|
// zwischen zwei unabhängigen DBs divergiert (utm-1: eth0=6, utm-2: eth0=1).
|
||||||
|
// Wir hashen daher semantisch: address + prefix + flags + interface_name
|
||||||
|
// statt interface_id — sonst False-Positive-Drift auf logisch identischen Nodes.
|
||||||
|
{Name: "network_interfaces"},
|
||||||
|
{Name: "ip_addresses", CustomSQL: `
|
||||||
|
SELECT COALESCE(md5(string_agg(rh, '|' ORDER BY rh)), '')
|
||||||
|
FROM (
|
||||||
|
SELECT md5(jsonb_build_object(
|
||||||
|
'address', ia.address,
|
||||||
|
'prefix', ia.prefix,
|
||||||
|
'is_vip', ia.is_vip,
|
||||||
|
'active', ia.active,
|
||||||
|
'vip_priority', ia.vip_priority,
|
||||||
|
'description', ia.description,
|
||||||
|
'iface', ni.name
|
||||||
|
)::text) AS rh
|
||||||
|
FROM ip_addresses ia
|
||||||
|
JOIN network_interfaces ni ON ia.interface_id = ni.id
|
||||||
|
) sub`},
|
||||||
|
|
||||||
|
// static_routes, dns_settings, ntp_settings bleiben node-spezifisch.
|
||||||
}
|
}
|
||||||
|
|
||||||
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.
|
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.
|
||||||
@@ -112,7 +137,11 @@ func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error)
|
|||||||
hasUserConfig := false
|
hasUserConfig := false
|
||||||
for _, t := range hashSpec {
|
for _, t := range hashSpec {
|
||||||
var s string
|
var s string
|
||||||
if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil {
|
sql := t.CustomSQL
|
||||||
|
if sql == "" {
|
||||||
|
sql = hashSQL(t)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, sql).Scan(&s); err != nil {
|
||||||
// Migration fehlt o.ä. → leeren string nehmen, weiter.
|
// Migration fehlt o.ä. → leeren string nehmen, weiter.
|
||||||
s = ""
|
s = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
-- network_interfaces und ip_addresses werden in die Cluster-Replikation
|
||||||
|
-- aufgenommen. Das ALTER PUBLICATION erfordert den Superuser (postgres),
|
||||||
|
-- daher läuft es im postinst via `sudo -u postgres psql`, nicht hier.
|
||||||
|
-- Diese Migration dient nur als Versions-Marker für goose.
|
||||||
|
SELECT 1;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
SELECT 1;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
25
internal/database/migrations/0031_forward_proxy_settings.sql
Normal file
25
internal/database/migrations/0031_forward_proxy_settings.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
-- forward_proxy_settings — Singleton-Row für globale Squid-Einstellungen.
|
||||||
|
-- listen_addresses: Komma-separierte IPs auf denen Squid lauscht.
|
||||||
|
-- Leer = alle Interfaces (http_port 3128). Typisch: LAN/VLAN-Gateway-IPs.
|
||||||
|
CREATE TABLE IF NOT EXISTS forward_proxy_settings (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
listen_addresses TEXT NOT NULL DEFAULT '',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 3128,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT forward_proxy_settings_singleton CHECK (id = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO forward_proxy_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS forward_proxy_settings;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
ALTER TABLE forward_proxy_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS cache_mem_mb INTEGER NOT NULL DEFAULT 64,
|
||||||
|
ADD COLUMN IF NOT EXISTS cache_dir_mb INTEGER NOT NULL DEFAULT 100,
|
||||||
|
ADD COLUMN IF NOT EXISTS max_obj_size_mb INTEGER NOT NULL DEFAULT 4,
|
||||||
|
ADD COLUMN IF NOT EXISTS connect_timeout INTEGER NOT NULL DEFAULT 60,
|
||||||
|
ADD COLUMN IF NOT EXISTS read_timeout INTEGER NOT NULL DEFAULT 300,
|
||||||
|
ADD COLUMN IF NOT EXISTS request_timeout INTEGER NOT NULL DEFAULT 300;
|
||||||
|
|
||||||
|
ALTER TABLE dns_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS prefetch BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS serve_expired BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS msg_cache_size_mb INTEGER NOT NULL DEFAULT 64,
|
||||||
|
ADD COLUMN IF NOT EXISTS rrset_cache_size_mb INTEGER NOT NULL DEFAULT 128;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
ALTER TABLE forward_proxy_settings
|
||||||
|
DROP COLUMN IF EXISTS cache_mem_mb,
|
||||||
|
DROP COLUMN IF EXISTS cache_dir_mb,
|
||||||
|
DROP COLUMN IF EXISTS max_obj_size_mb,
|
||||||
|
DROP COLUMN IF EXISTS connect_timeout,
|
||||||
|
DROP COLUMN IF EXISTS read_timeout,
|
||||||
|
DROP COLUMN IF EXISTS request_timeout;
|
||||||
|
|
||||||
|
ALTER TABLE dns_settings
|
||||||
|
DROP COLUMN IF EXISTS prefetch,
|
||||||
|
DROP COLUMN IF EXISTS serve_expired,
|
||||||
|
DROP COLUMN IF EXISTS msg_cache_size_mb,
|
||||||
|
DROP COLUMN IF EXISTS rrset_cache_size_mb;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Dual-path VRRP + Gateway-Tracking für Split-Brain-Schutz.
|
||||||
|
-- hb_* = zweite VRRP-Instanz (VI_HB) auf dediziertem Heartbeat-Interface.
|
||||||
|
-- gw_check_ip = Gateway-IP die von chk_gateway angepingt wird (weight -110).
|
||||||
|
ALTER TABLE cluster_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS hb_interface VARCHAR,
|
||||||
|
ADD COLUMN IF NOT EXISTS hb_src_ip VARCHAR,
|
||||||
|
ADD COLUMN IF NOT EXISTS hb_peer_ip VARCHAR,
|
||||||
|
ADD COLUMN IF NOT EXISTS hb_router_id INTEGER NOT NULL DEFAULT 52,
|
||||||
|
ADD COLUMN IF NOT EXISTS gw_check_ip VARCHAR;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE cluster_settings
|
||||||
|
DROP COLUMN IF EXISTS hb_interface,
|
||||||
|
DROP COLUMN IF EXISTS hb_src_ip,
|
||||||
|
DROP COLUMN IF EXISTS hb_peer_ip,
|
||||||
|
DROP COLUMN IF EXISTS hb_router_id,
|
||||||
|
DROP COLUMN IF EXISTS gw_check_ip;
|
||||||
9
internal/database/migrations/0034_totp.sql
Normal file
9
internal/database/migrations/0034_totp.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN totp_secret TEXT,
|
||||||
|
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN totp_secret,
|
||||||
|
DROP COLUMN totp_enabled;
|
||||||
@@ -363,11 +363,24 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound
|
// Squid Forward-Proxy: lese Port + Listen-Adressen aus
|
||||||
// (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter).
|
// forward_proxy_settings. Für jede nicht-loopback IP eine
|
||||||
var aclCount int
|
// Auto-Rule; leere Liste = alle Interfaces (generische Regel).
|
||||||
if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 {
|
var squidAddrs string
|
||||||
out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"})
|
var squidPort int
|
||||||
|
if err := g.Pool.QueryRow(ctx,
|
||||||
|
`SELECT listen_addresses, listen_port FROM forward_proxy_settings WHERE id=1`,
|
||||||
|
).Scan(&squidAddrs, &squidPort); err == nil && squidPort > 0 {
|
||||||
|
addrs := splitCSV(squidAddrs)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, Comment: "Forward-Proxy (Squid)"})
|
||||||
|
} else {
|
||||||
|
for _, ip := range addrs {
|
||||||
|
if !isLoopback(ip) {
|
||||||
|
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, DstIP: ip, Comment: "Forward-Proxy (Squid) auf " + ip})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WireGuard server-mode: udp <listen_port> pro aktive iface.
|
// WireGuard server-mode: udp <listen_port> pro aktive iface.
|
||||||
|
|||||||
@@ -60,15 +60,22 @@ func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totpPendingCookie = "edgeguard_totp_pending"
|
||||||
|
|
||||||
// Register mounts /auth/login + /logout (public) and /auth/me
|
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||||
// (gated by requireAuth, passed in as a per-route middleware).
|
// (gated by requireAuth, passed in as a per-route middleware).
|
||||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||||
g := rg.Group("/auth")
|
g := rg.Group("/auth")
|
||||||
g.POST("/login", h.Login)
|
g.POST("/login", h.Login)
|
||||||
g.POST("/logout", h.Logout)
|
g.POST("/logout", h.Logout)
|
||||||
|
g.POST("/totp-verify", h.TOTPVerify)
|
||||||
g.GET("/me", requireAuth, h.Me)
|
g.GET("/me", requireAuth, h.Me)
|
||||||
g.POST("/reset-password", h.ResetPassword)
|
g.POST("/reset-password", h.ResetPassword)
|
||||||
g.POST("/change-password", requireAuth, h.ChangePassword)
|
g.POST("/change-password", requireAuth, h.ChangePassword)
|
||||||
|
// TOTP self-service (authenticated user manages own 2FA)
|
||||||
|
g.POST("/totp/setup", requireAuth, h.TOTPSetup)
|
||||||
|
g.POST("/totp/confirm", requireAuth, h.TOTPConfirm)
|
||||||
|
g.DELETE("/totp", requireAuth, h.TOTPDisable)
|
||||||
}
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
@@ -77,9 +84,10 @@ type loginRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type loginResponse struct {
|
type loginResponse struct {
|
||||||
Actor string `json:"actor"`
|
Actor string `json:"actor"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
TOTPRequired bool `json:"totp_required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
@@ -101,12 +109,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
email := strings.TrimSpace(req.Email)
|
email := strings.TrimSpace(req.Email)
|
||||||
actor, role := "", "admin"
|
actor, role := "", "admin"
|
||||||
remote := c.ClientIP()
|
remote := c.ClientIP()
|
||||||
|
var totpEnabled bool
|
||||||
|
|
||||||
// 1. Try DB users table first.
|
// 1. Try DB users table first.
|
||||||
if h.Users != nil {
|
if h.Users != nil {
|
||||||
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
|
ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email)
|
||||||
if dbErr == nil {
|
if dbErr == nil {
|
||||||
if !u.Active {
|
if !ai.Active {
|
||||||
if h.Audit != nil {
|
if h.Audit != nil {
|
||||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||||
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
|
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
|
||||||
@@ -114,7 +123,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
response.Unauthorized(c, errors.New("account_disabled"))
|
response.Unauthorized(c, errors.New("account_disabled"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !usersvc.VerifyPassword(hash, req.Password) {
|
if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) {
|
||||||
if h.Audit != nil {
|
if h.Audit != nil {
|
||||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||||
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
||||||
@@ -122,9 +131,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
actor = u.Email
|
actor = ai.Email
|
||||||
role = u.Role
|
role = ai.Role
|
||||||
h.Users.RecordLogin(c.Request.Context(), u.ID)
|
totpEnabled = ai.TOTPEnabled
|
||||||
|
h.Users.RecordLogin(c.Request.Context(), ai.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,16 +143,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
||||||
actor = st.AdminEmail
|
actor = st.AdminEmail
|
||||||
role = "admin"
|
role = "admin"
|
||||||
// Auto-migrate: insert the setup-store admin into the DB so it
|
|
||||||
// shows up in user management from this point on.
|
|
||||||
if h.Users != nil {
|
if h.Users != nil {
|
||||||
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Auth federation: cluster nodes forward failed auth to the primary
|
// 3. Auth federation: cluster nodes forward failed auth to the primary.
|
||||||
// via mTLS so users can log in with their primary credentials on any node.
|
|
||||||
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
|
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
|
||||||
if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil {
|
if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil {
|
||||||
actor = a
|
actor = a
|
||||||
@@ -161,6 +168,21 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTP gate: password OK but 2FA required → issue a short-lived pending
|
||||||
|
// cookie and tell the UI to show the TOTP input.
|
||||||
|
if totpEnabled {
|
||||||
|
pending, ptok, err := h.Signer.IssueWithRoleTTL(actor, "totp_pending", 2*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.SetSameSite(http.SameSiteStrictMode)
|
||||||
|
c.SetCookie(totpPendingCookie, pending, int(2*time.Minute/time.Second), "/", "", true, true)
|
||||||
|
_ = ptok
|
||||||
|
response.OK(c, loginResponse{TOTPRequired: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -179,6 +201,146 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type totpVerifyRequest struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTPVerify completes the two-step login: verifies the TOTP code from the
|
||||||
|
// pending cookie and, on success, issues a full session JWT.
|
||||||
|
func (h *AuthHandler) TOTPVerify(c *gin.Context) {
|
||||||
|
var req totpVerifyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingRaw, err := c.Cookie(totpPendingCookie)
|
||||||
|
if err != nil || pendingRaw == "" {
|
||||||
|
response.Unauthorized(c, errors.New("no_pending_totp"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ptok, err := h.Signer.Verify(pendingRaw)
|
||||||
|
if err != nil || ptok.Role != "totp_pending" {
|
||||||
|
response.Unauthorized(c, errors.New("invalid_pending_token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.Users == nil {
|
||||||
|
response.Internal(c, errors.New("users repo unavailable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ai, err := h.Users.FindForAuth(c.Request.Context(), ptok.Actor)
|
||||||
|
if err != nil || !ai.TOTPEnabled || ai.TOTPSecret == nil {
|
||||||
|
response.Unauthorized(c, errors.New("totp_not_configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !usersvc.VerifyTOTP(*ai.TOTPSecret, req.Code) {
|
||||||
|
if h.Audit != nil {
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.totp.failed",
|
||||||
|
ptok.Actor, gin.H{"remote": c.ClientIP()}, h.NodeID)
|
||||||
|
}
|
||||||
|
response.Unauthorized(c, errors.New("invalid_totp_code"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pending cookie, issue full session.
|
||||||
|
c.SetSameSite(http.SameSiteStrictMode)
|
||||||
|
c.SetCookie(totpPendingCookie, "", -1, "/", "", true, true)
|
||||||
|
|
||||||
|
raw, tok, err := h.Signer.IssueWithRole(ptok.Actor, ai.Role)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(c, raw, tok.Exp)
|
||||||
|
|
||||||
|
if h.Audit != nil {
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.login.success",
|
||||||
|
ptok.Actor, gin.H{"role": ai.Role, "remote": c.ClientIP(), "totp": true}, h.NodeID)
|
||||||
|
}
|
||||||
|
response.OK(c, loginResponse{
|
||||||
|
Actor: tok.Actor,
|
||||||
|
Role: tok.Role,
|
||||||
|
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTPSetup generates a new TOTP secret for the authenticated user and returns
|
||||||
|
// the provisioning URI (renders as QR code in the UI). Secret is not saved yet.
|
||||||
|
func (h *AuthHandler) TOTPSetup(c *gin.Context) {
|
||||||
|
tok := CurrentToken(c)
|
||||||
|
if tok == nil {
|
||||||
|
response.Unauthorized(c, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret, uri, err := usersvc.GenerateTOTPSecret(tok.Actor)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"secret": secret, "uri": uri})
|
||||||
|
}
|
||||||
|
|
||||||
|
type totpConfirmRequest struct {
|
||||||
|
Secret string `json:"secret" binding:"required"`
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTPConfirm verifies the code against the provisioned secret and, on success,
|
||||||
|
// enables TOTP for the user.
|
||||||
|
func (h *AuthHandler) TOTPConfirm(c *gin.Context) {
|
||||||
|
var req totpConfirmRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := CurrentToken(c)
|
||||||
|
if tok == nil || h.Users == nil {
|
||||||
|
response.Unauthorized(c, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Users.ConfirmTOTP(c.Request.Context(), u.ID, req.Secret, req.Code); err != nil {
|
||||||
|
if err.Error() == "invalid_totp_code" {
|
||||||
|
response.Err(c, http.StatusUnprocessableEntity, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Audit != nil {
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.enabled",
|
||||||
|
tok.Actor, nil, h.NodeID)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTPDisable disables TOTP for the authenticated user.
|
||||||
|
func (h *AuthHandler) TOTPDisable(c *gin.Context) {
|
||||||
|
tok := CurrentToken(c)
|
||||||
|
if tok == nil || h.Users == nil {
|
||||||
|
response.Unauthorized(c, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Users.DisableTOTP(c.Request.Context(), u.ID); err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Audit != nil {
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.disabled",
|
||||||
|
tok.Actor, nil, h.NodeID)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
clearSessionCookie(c)
|
clearSessionCookie(c)
|
||||||
response.OK(c, gin.H{"logged_out": true})
|
response.OK(c, gin.H{"logged_out": true})
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.PUT("/vip-settings", h.UpdateVIPSettings)
|
g.PUT("/vip-settings", h.UpdateVIPSettings)
|
||||||
g.POST("/rolling-update", h.RollingUpdate)
|
g.POST("/rolling-update", h.RollingUpdate)
|
||||||
g.GET("/rolling-update/status", h.RollingUpdateStatus)
|
g.GET("/rolling-update/status", h.RollingUpdateStatus)
|
||||||
|
g.GET("/vip-status", h.VIPStatus)
|
||||||
|
g.POST("/vip-test", h.VIPTest)
|
||||||
if h.TLSStore != nil {
|
if h.TLSStore != nil {
|
||||||
g.GET("/cert-status", h.CertStatus)
|
g.GET("/cert-status", h.CertStatus)
|
||||||
g.POST("/renew-self", h.RenewSelf)
|
g.POST("/renew-self", h.RenewSelf)
|
||||||
@@ -130,9 +132,12 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var cs vipSettingsRow
|
var cs vipSettingsRow
|
||||||
row := h.Store.Pool.QueryRow(c.Request.Context(),
|
row := h.Store.Pool.QueryRow(c.Request.Context(), `
|
||||||
`SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`)
|
SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
|
||||||
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil {
|
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
|
||||||
|
FROM cluster_settings WHERE id = 1`)
|
||||||
|
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
|
||||||
|
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,10 +159,14 @@ func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
_, err := h.Store.Pool.Exec(c.Request.Context(), `
|
_, err := h.Store.Pool.Exec(c.Request.Context(), `
|
||||||
UPDATE cluster_settings
|
UPDATE cluster_settings
|
||||||
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4, updated_at=NOW()
|
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4,
|
||||||
|
hb_interface=$5, hb_src_ip=$6, hb_peer_ip=$7, hb_router_id=$8, gw_check_ip=$9,
|
||||||
|
updated_at=NOW()
|
||||||
WHERE id=1`,
|
WHERE id=1`,
|
||||||
nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface),
|
nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface),
|
||||||
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID)
|
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID,
|
||||||
|
nullIfEmpty(req.HBInterface), nullIfEmpty(req.HBSrcIP),
|
||||||
|
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
return
|
||||||
@@ -181,6 +190,11 @@ type vipSettingsRow struct {
|
|||||||
VIPInterface *string `json:"vip_interface"`
|
VIPInterface *string `json:"vip_interface"`
|
||||||
VIPAuthPass *string `json:"vip_auth_pass"`
|
VIPAuthPass *string `json:"vip_auth_pass"`
|
||||||
VRRPRouterID int `json:"vrrp_router_id"`
|
VRRPRouterID int `json:"vrrp_router_id"`
|
||||||
|
HBInterface *string `json:"hb_interface"`
|
||||||
|
HBSrcIP *string `json:"hb_src_ip"`
|
||||||
|
HBPeerIP *string `json:"hb_peer_ip"`
|
||||||
|
HBRouterID int `json:"hb_router_id"`
|
||||||
|
GWCheckIP *string `json:"gw_check_ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func nullIfEmpty(s *string) *string {
|
func nullIfEmpty(s *string) *string {
|
||||||
@@ -218,6 +232,9 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
|||||||
g.GET("/master-key", h.AgentMasterKey)
|
g.GET("/master-key", h.AgentMasterKey)
|
||||||
g.GET("/version", h.AgentVersion)
|
g.GET("/version", h.AgentVersion)
|
||||||
g.POST("/trigger-update", h.AgentTriggerUpdate)
|
g.POST("/trigger-update", h.AgentTriggerUpdate)
|
||||||
|
g.GET("/active-ips", h.AgentActiveIPs)
|
||||||
|
g.POST("/vip-cmd", h.AgentVIPCmd)
|
||||||
|
g.GET("/tls-certs", h.AgentTLSCerts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
|
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
|
||||||
|
|||||||
118
internal/handlers/cluster_certsync.go
Normal file
118
internal/handlers/cluster_certsync.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tlsCertDir = "/etc/edgeguard/tls"
|
||||||
|
|
||||||
|
// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als
|
||||||
|
// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate
|
||||||
|
// des Primary zu spiegeln.
|
||||||
|
func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) {
|
||||||
|
entries, err := os.ReadDir(tlsCertDir)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
certs := make(map[string]string, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
certs[e.Name()] = base64.StdEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"certs": certs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS
|
||||||
|
// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy
|
||||||
|
// wenn mindestens ein Zertifikat aktualisiert wurde.
|
||||||
|
//
|
||||||
|
// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick —
|
||||||
|
// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern.
|
||||||
|
func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error {
|
||||||
|
if agg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Primary-Peer aus ha_nodes ermitteln
|
||||||
|
rows, err := pool.Query(ctx,
|
||||||
|
`SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var primary *models.HANode
|
||||||
|
for rows.Next() {
|
||||||
|
n := &models.HANode{}
|
||||||
|
if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
primary = n
|
||||||
|
}
|
||||||
|
if primary == nil {
|
||||||
|
return nil // kein Peer → Single-Node
|
||||||
|
}
|
||||||
|
|
||||||
|
results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID)
|
||||||
|
if len(results) == 0 || !results[0].OK {
|
||||||
|
return nil // Primary nicht erreichbar — nächster Tick
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Certs map[string]string `json:"certs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(results[0].Data, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(tlsCertDir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for name, b64 := range payload.Certs {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(tlsCertDir, name)
|
||||||
|
existing, readErr := os.ReadFile(path)
|
||||||
|
if readErr == nil && bytes.Equal(existing, data) {
|
||||||
|
continue // unverändert
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||||
|
slog.Warn("cert-sync: write failed", "file", name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
slog.Info("cert-sync: updated", "file", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
||||||
|
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
319
internal/handlers/cluster_viptest.go
Normal file
319
internal/handlers/cluster_viptest.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vipInfo enthält die für einen VIP-Schwenk notwendigen Daten.
|
||||||
|
type vipInfo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Prefix int `json:"prefix"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPStatusEntry kombiniert einen VIP mit den Nodes die ihn gerade halten.
|
||||||
|
type VIPStatusEntry struct {
|
||||||
|
VIP vipInfo `json:"vip"`
|
||||||
|
ActiveOn []string `json:"active_on"` // FQDNs der Nodes mit diesem VIP
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentActiveIPs gibt alle aktiven IPv4-Adressen dieses Nodes zurück.
|
||||||
|
// Wird vom Primary genutzt um zu prüfen welcher Node welchen VIP hält.
|
||||||
|
func (h *ClusterHandler) AgentActiveIPs(c *gin.Context) {
|
||||||
|
ips, err := localActiveIPs()
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"ips": ips})
|
||||||
|
}
|
||||||
|
|
||||||
|
// vipCmdRequest ist der Body für den AgentVIPCmd-Endpoint.
|
||||||
|
type vipCmdRequest struct {
|
||||||
|
Action string `json:"action"` // "add" | "del"
|
||||||
|
Address string `json:"address"` // z.B. "10.0.5.1"
|
||||||
|
Prefix int `json:"prefix"` // z.B. 24
|
||||||
|
Device string `json:"device"` // z.B. "vlan100"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentVIPCmd führt `ip addr add/del` auf diesem Node aus.
|
||||||
|
// Wird vom Primary via mTLS für VIP-Schwenk-Tests aufgerufen.
|
||||||
|
func (h *ClusterHandler) AgentVIPCmd(c *gin.Context) {
|
||||||
|
var req vipCmdRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action != "add" && req.Action != "del" {
|
||||||
|
response.BadRequest(c, simpleError("action must be 'add' or 'del'"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Address == "" || req.Device == "" || req.Prefix <= 0 || req.Prefix > 128 {
|
||||||
|
response.BadRequest(c, simpleError("address, device, prefix required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := runVIPCmd(req.Action, req.Address, req.Prefix, req.Device); err != nil {
|
||||||
|
slog.Warn("cluster: agent vip-cmd failed",
|
||||||
|
"action", req.Action, "addr", req.Address, "dev", req.Device, "error", err)
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("cluster: agent vip-cmd ok",
|
||||||
|
"action", req.Action, "addr", req.Address, "prefix", req.Prefix,
|
||||||
|
"dev", req.Device, "caller", c.ClientIP())
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPStatus liest alle VIPs (is_vip=true) aus der DB und fragt alle Nodes
|
||||||
|
// welche davon sie gerade aktiv haben. Nur sinnvoll im Cluster-Modus.
|
||||||
|
func (h *ClusterHandler) VIPStatus(c *gin.Context) {
|
||||||
|
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodeIPs := h.collectActiveIPs(c.Request.Context())
|
||||||
|
result := make([]VIPStatusEntry, 0, len(vips))
|
||||||
|
for _, v := range vips {
|
||||||
|
entry := VIPStatusEntry{VIP: v}
|
||||||
|
for fqdn, ips := range nodeIPs {
|
||||||
|
for _, ip := range ips {
|
||||||
|
if ip == v.Address {
|
||||||
|
entry.ActiveOn = append(entry.ActiveOn, fqdn)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"vips": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// vipTestRequest steuert einen VIP-Schwenk.
|
||||||
|
type vipTestRequest struct {
|
||||||
|
IPAddressID int64 `json:"ip_address_id"`
|
||||||
|
Action string `json:"action"` // "to_secondary" | "restore"
|
||||||
|
}
|
||||||
|
|
||||||
|
// vipTestStep beschreibt einen Schritt des Schwenk-Prozesses.
|
||||||
|
type vipTestStep struct {
|
||||||
|
Step string `json:"step"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPTest schwenkt einen VIP vom Primary auf den Secondary ("to_secondary")
|
||||||
|
// oder zurück ("restore"). Nur vom Primary aufzurufen.
|
||||||
|
func (h *ClusterHandler) VIPTest(c *gin.Context) {
|
||||||
|
var req vipTestRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action != "to_secondary" && req.Action != "restore" {
|
||||||
|
response.BadRequest(c, simpleError("action must be 'to_secondary' or 'restore'"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var target *vipInfo
|
||||||
|
for i := range vips {
|
||||||
|
if vips[i].ID == req.IPAddressID {
|
||||||
|
target = &vips[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
response.NotFound(c, simpleError("VIP not found or not marked as VIP"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := h.Store.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var peer *models.HANode
|
||||||
|
for i := range all {
|
||||||
|
if all[i].ID != h.LocalID {
|
||||||
|
peer = &all[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if peer == nil {
|
||||||
|
response.BadRequest(c, simpleError("kein Secondary-Peer gefunden"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var steps []vipTestStep
|
||||||
|
addrPfx := fmt.Sprintf("%s/%d", target.Address, target.Prefix)
|
||||||
|
|
||||||
|
if req.Action == "to_secondary" {
|
||||||
|
// 1. VIP auf Secondary via mTLS hinzufügen
|
||||||
|
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "add",
|
||||||
|
fmt.Sprintf("add %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
|
||||||
|
// 2. VIP vom Primary entfernen (nur wenn Secondary-Add erfolgreich)
|
||||||
|
if steps[0].OK {
|
||||||
|
steps = append(steps, localVIPStep(target, "del",
|
||||||
|
fmt.Sprintf("del %s dev %s lokal", addrPfx, target.Device)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 1. VIP auf Primary zurückholen
|
||||||
|
steps = append(steps, localVIPStep(target, "add",
|
||||||
|
fmt.Sprintf("add %s dev %s lokal", addrPfx, target.Device)))
|
||||||
|
// 2. VIP auf Secondary entfernen
|
||||||
|
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "del",
|
||||||
|
fmt.Sprintf("del %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("cluster: vip-test", "action", req.Action, "vip", target.Address,
|
||||||
|
"dev", target.Device, "peer", peer.FQDN, "actor", actorOf(c))
|
||||||
|
response.OK(c, gin.H{"steps": steps})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func loadVIPs(ctx context.Context, pool *pgxpool.Pool) ([]vipInfo, error) {
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT ia.id, ia.address, ia.prefix, ni.name
|
||||||
|
FROM ip_addresses ia
|
||||||
|
JOIN network_interfaces ni ON ni.id = ia.interface_id
|
||||||
|
WHERE ia.is_vip = true AND ia.active = true
|
||||||
|
ORDER BY ni.name, ia.address`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []vipInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var v vipInfo
|
||||||
|
if err := rows.Scan(&v.ID, &v.Address, &v.Prefix, &v.Device); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectActiveIPs fragt alle Cluster-Nodes (lokal + Peers via mTLS) nach
|
||||||
|
// ihren aktiven IPv4-Adressen und gibt eine Map[fqdn][]ip zurück.
|
||||||
|
func (h *ClusterHandler) collectActiveIPs(ctx context.Context) map[string][]string {
|
||||||
|
result := make(map[string][]string)
|
||||||
|
if h.Store == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
all, err := h.Store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
// Lokaler Node
|
||||||
|
if ips, err := localActiveIPs(); err == nil {
|
||||||
|
for _, n := range all {
|
||||||
|
if n.ID == h.LocalID {
|
||||||
|
result[n.FQDN] = ips
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Peers via mTLS-Aggregator
|
||||||
|
if h.Aggregator != nil {
|
||||||
|
var peers []models.HANode
|
||||||
|
for _, n := range all {
|
||||||
|
if n.ID != h.LocalID {
|
||||||
|
peers = append(peers, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(peers) > 0 {
|
||||||
|
peerResults := h.Aggregator.FanOut(ctx, peers, "/agent/cluster/active-ips", h.LocalID)
|
||||||
|
for _, pr := range peerResults {
|
||||||
|
if !pr.OK || len(pr.Data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
IPs []string `json:"ips"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pr.Data, &payload); err == nil {
|
||||||
|
result[pr.FQDN] = payload.IPs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// localActiveIPs liest alle aktiven IPv4-Adressen des lokalen Nodes via `ip`.
|
||||||
|
func localActiveIPs() ([]string, error) {
|
||||||
|
out, err := exec.Command("ip", "-4", "-o", "addr", "show").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ips []string
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
for i, p := range parts {
|
||||||
|
if p == "inet" && i+1 < len(parts) {
|
||||||
|
addr := strings.SplitN(parts[i+1], "/", 2)[0]
|
||||||
|
ips = append(ips, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// peerVIPCmd ruft AgentVIPCmd auf dem Peer via mTLS auf.
|
||||||
|
func (h *ClusterHandler) peerVIPCmd(ctx context.Context, peer models.HANode, vip *vipInfo, action, stepLabel string) vipTestStep {
|
||||||
|
step := vipTestStep{Step: stepLabel}
|
||||||
|
if h.Aggregator == nil {
|
||||||
|
step.Message = "aggregator nicht verfügbar"
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(vipCmdRequest{
|
||||||
|
Action: action,
|
||||||
|
Address: vip.Address,
|
||||||
|
Prefix: vip.Prefix,
|
||||||
|
Device: vip.Device,
|
||||||
|
})
|
||||||
|
res := h.Aggregator.PostPeerWithBody(ctx, peer, "/agent/cluster/vip-cmd", body)
|
||||||
|
step.OK = res.OK
|
||||||
|
if !res.OK {
|
||||||
|
step.Message = res.Err
|
||||||
|
}
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
|
||||||
|
// localVIPStep führt ip addr add/del auf dem lokalen Node aus.
|
||||||
|
func localVIPStep(vip *vipInfo, action, stepLabel string) vipTestStep {
|
||||||
|
step := vipTestStep{Step: stepLabel}
|
||||||
|
if err := runVIPCmd(action, vip.Address, vip.Prefix, vip.Device); err != nil {
|
||||||
|
step.Message = err.Error()
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
step.OK = true
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVIPCmd führt `sudo /usr/lib/edgeguard/vip-cmd.sh {action} {addr/prefix} {dev}` aus.
|
||||||
|
func runVIPCmd(action, address string, prefix int, device string) error {
|
||||||
|
addrPfx := fmt.Sprintf("%s/%d", address, prefix)
|
||||||
|
out, err := exec.Command("sudo", "-n", "/usr/lib/edgeguard/vip-cmd.sh", action, addrPfx, device).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("vip-cmd.sh %s %s %s: %s", action, addrPfx, device, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -306,7 +306,7 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
|||||||
// cached RRs from the resolver. Useful after DNS propagation or when
|
// cached RRs from the resolver. Useful after DNS propagation or when
|
||||||
// stale records need to be evicted immediately.
|
// stale records need to be evicted immediately.
|
||||||
func (h *DNSHandler) FlushCache(c *gin.Context) {
|
func (h *DNSHandler) FlushCache(c *gin.Context) {
|
||||||
out, err := exec.CommandContext(c.Request.Context(), "unbound-control", "flush_zone", ".").CombinedOutput()
|
out, err := exec.CommandContext(c.Request.Context(), "/usr/sbin/unbound-control", "flush_zone", ".").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("dns flush-cache failed", "err", err, "out", string(out))
|
slog.Error("dns flush-cache failed", "err", err, "out", string(out))
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -388,7 +388,7 @@ func validateZone(z *models.DNSZone) error {
|
|||||||
// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für
|
// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für
|
||||||
// wiederholte Aufrufe aus dem UI.
|
// wiederholte Aufrufe aus dem UI.
|
||||||
func (h *DNSHandler) Stats(c *gin.Context) {
|
func (h *DNSHandler) Stats(c *gin.Context) {
|
||||||
out, err := exec.Command("unbound-control", "stats_noreset").Output()
|
out, err := exec.Command("/usr/sbin/unbound-control", "stats_noreset").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.OK(c, gin.H{
|
response.OK(c, gin.H{
|
||||||
"error": "unbound-control nicht verfügbar: " + err.Error(),
|
"error": "unbound-control nicht verfügbar: " + err.Error(),
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
|
|||||||
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
||||||
base := rg.Group("/forward-proxy")
|
base := rg.Group("/forward-proxy")
|
||||||
base.GET("/stats", h.Stats)
|
base.GET("/stats", h.Stats)
|
||||||
|
base.GET("/settings", h.GetSettings)
|
||||||
|
base.PUT("/settings", h.UpdateSettings)
|
||||||
|
|
||||||
g := base.Group("/acls")
|
g := base.Group("/acls")
|
||||||
g.GET("", h.List)
|
g.GET("", h.List)
|
||||||
@@ -48,6 +50,34 @@ func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.DELETE("/:id", h.Delete)
|
g.DELETE("/:id", h.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ForwardProxyHandler) GetSettings(c *gin.Context) {
|
||||||
|
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ForwardProxyHandler) UpdateSettings(c *gin.Context) {
|
||||||
|
var req models.ForwardProxySettings
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ListenPort <= 0 || req.ListenPort > 65535 {
|
||||||
|
req.ListenPort = 3128
|
||||||
|
}
|
||||||
|
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "forward_proxy.settings.update", "settings", out, h.NodeID)
|
||||||
|
response.OK(c, out)
|
||||||
|
h.reload(c.Request.Context(), "settings.update")
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ForwardProxyHandler) List(c *gin.Context) {
|
func (h *ForwardProxyHandler) List(c *gin.Context) {
|
||||||
out, err := h.Repo.List(c.Request.Context())
|
out, err := h.Repo.List(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.GET("/ipv6", h.IPv6)
|
g.GET("/ipv6", h.IPv6)
|
||||||
g.POST("/ipv6", h.SetIPv6)
|
g.POST("/ipv6", h.SetIPv6)
|
||||||
g.GET("/config-preview", h.ConfigPreview)
|
g.GET("/config-preview", h.ConfigPreview)
|
||||||
|
g.GET("/vip-status", h.VIPStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
|
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
|
||||||
@@ -188,6 +189,7 @@ var servicesToCheck = []struct{ Label, Unit string }{
|
|||||||
{"edgeguard-scheduler", "edgeguard-scheduler"},
|
{"edgeguard-scheduler", "edgeguard-scheduler"},
|
||||||
{"haproxy", "haproxy"},
|
{"haproxy", "haproxy"},
|
||||||
{"nftables", "nftables"},
|
{"nftables", "nftables"},
|
||||||
|
{"keepalived", "keepalived"},
|
||||||
{"unbound", "unbound"},
|
{"unbound", "unbound"},
|
||||||
{"chrony", "chrony"},
|
{"chrony", "chrony"},
|
||||||
{"squid", "squid"},
|
{"squid", "squid"},
|
||||||
@@ -1077,6 +1079,87 @@ func classifyLinkType(ifc net.Interface) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VIPStatus returns the VRRP state and active VIPs for this node.
|
||||||
|
// Uses net.Interfaces() (no shell-out) to check which VIPs from
|
||||||
|
// ip_addresses WHERE is_vip=true are currently assigned locally.
|
||||||
|
// MASTER = at least one VIP is locally present; BACKUP = none present.
|
||||||
|
func (h *SystemHandler) VIPStatus(c *gin.Context) {
|
||||||
|
type vipEntry struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Prefix int `json:"prefix"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
type vipStatus struct {
|
||||||
|
VRRPState string `json:"vrrp_state"`
|
||||||
|
KeepalivedActive bool `json:"keepalived_active"`
|
||||||
|
VIPs []vipEntry `json:"vips"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// keepalived service active?
|
||||||
|
kaOut, _ := exec.CommandContext(ctx, "systemctl", "is-active", "keepalived").Output()
|
||||||
|
kaActive := strings.TrimSpace(string(kaOut)) == "active"
|
||||||
|
|
||||||
|
// query VIPs from DB
|
||||||
|
var dbVIPs []vipEntry
|
||||||
|
if h.Pool != nil {
|
||||||
|
rows, err := h.Pool.Query(ctx,
|
||||||
|
`SELECT a.address, a.prefix, COALESCE(i.name,'') AS device
|
||||||
|
FROM ip_addresses a
|
||||||
|
LEFT JOIN network_interfaces i ON i.id = a.interface_id
|
||||||
|
WHERE a.is_vip = true AND a.active = true
|
||||||
|
ORDER BY a.address`)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var e vipEntry
|
||||||
|
if err2 := rows.Scan(&e.Address, &e.Prefix, &e.Device); err2 == nil {
|
||||||
|
dbVIPs = append(dbVIPs, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build set of locally assigned IPs
|
||||||
|
localIPs := make(map[string]bool)
|
||||||
|
if ifaces, err := net.Interfaces(); err == nil {
|
||||||
|
for _, ifc := range ifaces {
|
||||||
|
if addrs, err2 := ifc.Addrs(); err2 == nil {
|
||||||
|
for _, a := range addrs {
|
||||||
|
if ipnet, ok := a.(*net.IPNet); ok {
|
||||||
|
localIPs[ipnet.IP.String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anyActive := false
|
||||||
|
for i := range dbVIPs {
|
||||||
|
dbVIPs[i].Active = localIPs[dbVIPs[i].Address]
|
||||||
|
if dbVIPs[i].Active {
|
||||||
|
anyActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state := "UNKNOWN"
|
||||||
|
if kaActive {
|
||||||
|
if anyActive {
|
||||||
|
state = "MASTER"
|
||||||
|
} else {
|
||||||
|
state = "BACKUP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, vipStatus{
|
||||||
|
VRRPState: state,
|
||||||
|
KeepalivedActive: kaActive,
|
||||||
|
VIPs: dbVIPs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func flagsToList(f net.Flags) []string {
|
func flagsToList(f net.Flags) []string {
|
||||||
var out []string
|
var out []string
|
||||||
if f&net.FlagUp != 0 {
|
if f&net.FlagUp != 0 {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func (h *UsersHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.PUT("/:id", h.Update)
|
g.PUT("/:id", h.Update)
|
||||||
g.POST("/:id/password", h.SetPassword)
|
g.POST("/:id/password", h.SetPassword)
|
||||||
g.DELETE("/:id", h.Delete)
|
g.DELETE("/:id", h.Delete)
|
||||||
|
g.DELETE("/:id/totp", h.DisableTOTP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UsersHandler) List(c *gin.Context) {
|
func (h *UsersHandler) List(c *gin.Context) {
|
||||||
@@ -164,3 +165,22 @@ func (h *UsersHandler) Delete(c *gin.Context) {
|
|||||||
c.Param("id"), nil, h.NodeID)
|
c.Param("id"), nil, h.NodeID)
|
||||||
response.OK(c, gin.H{"ok": true})
|
response.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisableTOTP allows an admin to disable 2FA for any user.
|
||||||
|
func (h *UsersHandler) DisableTOTP(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Repo.DisableTOTP(c.Request.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, users.ErrNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.totp.disabled",
|
||||||
|
c.Param("id"), nil, h.NodeID)
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ global_defs {
|
|||||||
router_id {{ .RouterID }}
|
router_id {{ .RouterID }}
|
||||||
script_user root
|
script_user root
|
||||||
enable_script_security
|
enable_script_security
|
||||||
vrrp_garp_interval 0
|
|
||||||
vrrp_gna_interval 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vrrp_script chk_edgeguard {
|
vrrp_script chk_edgeguard {
|
||||||
@@ -13,7 +11,23 @@ vrrp_script chk_edgeguard {
|
|||||||
fall 3
|
fall 3
|
||||||
rise 2
|
rise 2
|
||||||
}
|
}
|
||||||
|
{{ if .GWCheckIP }}
|
||||||
|
vrrp_script chk_gateway {
|
||||||
|
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
|
||||||
|
interval 5
|
||||||
|
weight -110
|
||||||
|
fall 2
|
||||||
|
rise 2
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
|
{{ if .HBInterface }}
|
||||||
|
vrrp_sync_group VG_1 {
|
||||||
|
group {
|
||||||
|
VI_1
|
||||||
|
VI_HB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
vrrp_instance VI_1 {
|
vrrp_instance VI_1 {
|
||||||
state {{ .State }}
|
state {{ .State }}
|
||||||
interface {{ .Interface }}
|
interface {{ .Interface }}
|
||||||
@@ -29,12 +43,30 @@ vrrp_instance VI_1 {
|
|||||||
auth_pass {{ .AuthPass }}
|
auth_pass {{ .AuthPass }}
|
||||||
}
|
}
|
||||||
virtual_ipaddress {
|
virtual_ipaddress {
|
||||||
{{ .VIP }}
|
{{ range .VIPs }} {{ .Address }}/{{ .Prefix }} dev {{ .Device }}
|
||||||
}
|
{{ end }} }
|
||||||
track_script {
|
track_script {
|
||||||
chk_edgeguard
|
chk_edgeguard
|
||||||
}
|
{{ if .GWCheckIP }} chk_gateway
|
||||||
|
{{ end }} }
|
||||||
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
|
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
|
||||||
notify_backup "/usr/lib/edgeguard/keepalived-backup.sh"
|
notify_backup "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||||
notify_fault "/usr/lib/edgeguard/keepalived-backup.sh"
|
notify_fault "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||||
}
|
}
|
||||||
|
{{ if .HBInterface }}
|
||||||
|
vrrp_instance VI_HB {
|
||||||
|
state {{ .State }}
|
||||||
|
interface {{ .HBInterface }}
|
||||||
|
virtual_router_id {{ .HBRouterID }}
|
||||||
|
priority {{ .Priority }}
|
||||||
|
advert_int 1
|
||||||
|
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||||
|
unicast_peer {
|
||||||
|
{{ .HBPeerIP }}
|
||||||
|
}
|
||||||
|
{{ end }} authentication {
|
||||||
|
auth_type PASS
|
||||||
|
auth_pass {{ .AuthPass }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -29,16 +30,30 @@ var cfgTpl string
|
|||||||
|
|
||||||
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
|
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
|
||||||
|
|
||||||
|
// VIPEntry ist eine einzelne VIP-Adresse die keepalived verwaltet.
|
||||||
|
type VIPEntry struct {
|
||||||
|
Address string // z.B. 89.163.205.100
|
||||||
|
Prefix int // z.B. 24
|
||||||
|
Device string // z.B. eth0
|
||||||
|
}
|
||||||
|
|
||||||
// View ist der Template-Kontext.
|
// View ist der Template-Kontext.
|
||||||
type View struct {
|
type View struct {
|
||||||
State string // MASTER | BACKUP
|
State string // MASTER | BACKUP
|
||||||
Interface string
|
Interface string // Interface für VRRP-Advertisements (VI_1)
|
||||||
RouterID int
|
RouterID int
|
||||||
Priority int // MASTER=200, BACKUP=100
|
Priority int // MASTER=200, BACKUP=100
|
||||||
SrcIP string // eigene Public-IP (für unicast_src_ip)
|
SrcIP string // eigene Public-IP (unicast_src_ip)
|
||||||
PeerIP string // Peer-Public-IP (für unicast_peer)
|
PeerIP string // Peer-Public-IP (unicast_peer)
|
||||||
AuthPass string
|
AuthPass string
|
||||||
VIP string
|
VIPs []VIPEntry // alle is_vip=true Einträge aus ip_addresses
|
||||||
|
// Dual-path VRRP (Split-Brain-Schutz, Migration 0033)
|
||||||
|
HBInterface string
|
||||||
|
HBSrcIP string
|
||||||
|
HBPeerIP string
|
||||||
|
HBRouterID int
|
||||||
|
// GW-Tracking
|
||||||
|
GWCheckIP string
|
||||||
}
|
}
|
||||||
|
|
||||||
type generator struct {
|
type generator struct {
|
||||||
@@ -53,15 +68,15 @@ func New(pool *pgxpool.Pool, localID string) configgen.Generator {
|
|||||||
func (g *generator) Name() string { return "keepalived" }
|
func (g *generator) Name() string { return "keepalived" }
|
||||||
|
|
||||||
func (g *generator) Render(ctx context.Context) error {
|
func (g *generator) Render(ctx context.Context) error {
|
||||||
cs, local, peer, err := g.loadData(ctx)
|
cs, vips, local, peer, err := g.loadData(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("keepalived: load: %w", err)
|
return fmt.Errorf("keepalived: load: %w", err)
|
||||||
}
|
}
|
||||||
if cs.VIPAddress == nil || *cs.VIPAddress == "" {
|
if len(vips) == 0 {
|
||||||
// Kein VIP konfiguriert → keepalived.conf nicht schreiben.
|
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
v := g.buildView(cs, local, peer)
|
v := g.buildView(cs, vips, local, peer)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := tpl.Execute(&buf, v); err != nil {
|
if err := tpl.Execute(&buf, v); err != nil {
|
||||||
return fmt.Errorf("keepalived: template: %w", err)
|
return fmt.Errorf("keepalived: template: %w", err)
|
||||||
@@ -75,23 +90,47 @@ func (g *generator) Render(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *models.HANode, *models.HANode, error) {
|
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, []VIPEntry, *models.HANode, *models.HANode, error) {
|
||||||
var cs models.ClusterSettings
|
var cs models.ClusterSettings
|
||||||
row := g.pool.QueryRow(ctx, `SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`)
|
row := g.pool.QueryRow(ctx, `
|
||||||
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil {
|
SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
|
||||||
return nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
|
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
|
||||||
|
FROM cluster_settings WHERE id = 1`)
|
||||||
|
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
|
||||||
|
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
|
// Alle VIPs aus ip_addresses (is_vip=true, active=true) inkl. Interface-Name.
|
||||||
|
vipRows, err := g.pool.Query(ctx, `
|
||||||
|
SELECT ia.address, ia.prefix, ni.name
|
||||||
|
FROM ip_addresses ia
|
||||||
|
JOIN network_interfaces ni ON ia.interface_id = ni.id
|
||||||
|
WHERE ia.is_vip = true AND ia.active = true
|
||||||
|
ORDER BY ni.name, ia.address`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, fmt.Errorf("ha_nodes: %w", err)
|
return nil, nil, nil, nil, fmt.Errorf("ip_addresses: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer vipRows.Close()
|
||||||
|
var vips []VIPEntry
|
||||||
|
for vipRows.Next() {
|
||||||
|
var v VIPEntry
|
||||||
|
if err := vipRows.Scan(&v.Address, &v.Prefix, &v.Device); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vips = append(vips, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeRows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("ha_nodes: %w", err)
|
||||||
|
}
|
||||||
|
defer nodeRows.Close()
|
||||||
|
|
||||||
var local, peer *models.HANode
|
var local, peer *models.HANode
|
||||||
for rows.Next() {
|
for nodeRows.Next() {
|
||||||
n := &models.HANode{}
|
n := &models.HANode{}
|
||||||
if err := rows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
|
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n.ID == g.localID {
|
if n.ID == g.localID {
|
||||||
@@ -101,17 +140,22 @@ func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *mod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if local == nil {
|
if local == nil {
|
||||||
return nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
|
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
|
||||||
}
|
}
|
||||||
return &cs, local, peer, nil
|
return &cs, vips, local, peer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HANode) View {
|
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
|
||||||
v := View{
|
v := View{
|
||||||
RouterID: cs.VRRPRouterID,
|
RouterID: cs.VRRPRouterID,
|
||||||
VIP: deref(cs.VIPAddress),
|
VIPs: vips,
|
||||||
Interface: deref(cs.VIPInterface),
|
Interface: deref(cs.VIPInterface),
|
||||||
AuthPass: deref(cs.VIPAuthPass),
|
AuthPass: deref(cs.VIPAuthPass),
|
||||||
|
HBInterface: deref(cs.HBInterface),
|
||||||
|
HBSrcIP: deref(cs.HBSrcIP),
|
||||||
|
HBPeerIP: deref(cs.HBPeerIP),
|
||||||
|
HBRouterID: cs.HBRouterID,
|
||||||
|
GWCheckIP: deref(cs.GWCheckIP),
|
||||||
}
|
}
|
||||||
if v.Interface == "" {
|
if v.Interface == "" {
|
||||||
v.Interface = "eth0"
|
v.Interface = "eth0"
|
||||||
@@ -119,9 +163,17 @@ func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HA
|
|||||||
if v.AuthPass == "" {
|
if v.AuthPass == "" {
|
||||||
v.AuthPass = "edgeguard"
|
v.AuthPass = "edgeguard"
|
||||||
}
|
}
|
||||||
|
if v.HBRouterID == 0 {
|
||||||
|
v.HBRouterID = 52
|
||||||
|
}
|
||||||
|
|
||||||
// Primary-Node bekommt höhere Priorität und startet als MASTER.
|
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
|
||||||
if local.PGRole == "primary" || local.Role == "primary" {
|
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt.
|
||||||
|
// Reihenfolge: standby → BACKUP; sonst primary-Check.
|
||||||
|
if local.PGRole == "standby" {
|
||||||
|
v.State = "BACKUP"
|
||||||
|
v.Priority = 100
|
||||||
|
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||||
v.State = "MASTER"
|
v.State = "MASTER"
|
||||||
v.Priority = 200
|
v.Priority = 200
|
||||||
} else {
|
} else {
|
||||||
@@ -143,7 +195,11 @@ func reloadKeepalived() error {
|
|||||||
// keepalived läuft noch nicht — erster Render beim Start.
|
// keepalived läuft noch nicht — erster Render beim Start.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return exec.Command("systemctl", "reload-or-restart", "keepalived").Run()
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload-or-restart", "keepalived.service")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("sudo systemctl reload-or-restart keepalived.service: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deref(s *string) string {
|
func deref(s *string) string {
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import "time"
|
|||||||
|
|
||||||
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
|
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
|
||||||
// und Replikations-Konfiguration. Angelegt in Migration 0029.
|
// und Replikations-Konfiguration. Angelegt in Migration 0029.
|
||||||
|
// hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033).
|
||||||
|
// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033).
|
||||||
type ClusterSettings struct {
|
type ClusterSettings struct {
|
||||||
ID int `gorm:"column:id;primaryKey" json:"id"`
|
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||||
VIPAddress *string `gorm:"column:vip_address" json:"vip_address,omitempty"`
|
VIPAddress *string `gorm:"column:vip_address" json:"vip_address,omitempty"`
|
||||||
VIPInterface *string `gorm:"column:vip_interface" json:"vip_interface,omitempty"`
|
VIPInterface *string `gorm:"column:vip_interface" json:"vip_interface,omitempty"`
|
||||||
VIPAuthPass *string `gorm:"column:vip_auth_pass" json:"vip_auth_pass,omitempty"`
|
VIPAuthPass *string `gorm:"column:vip_auth_pass" json:"vip_auth_pass,omitempty"`
|
||||||
VRRPRouterID int `gorm:"column:vrrp_router_id" json:"vrrp_router_id"`
|
VRRPRouterID int `gorm:"column:vrrp_router_id" json:"vrrp_router_id"`
|
||||||
|
HBInterface *string `gorm:"column:hb_interface" json:"hb_interface,omitempty"`
|
||||||
|
HBSrcIP *string `gorm:"column:hb_src_ip" json:"hb_src_ip,omitempty"`
|
||||||
|
HBPeerIP *string `gorm:"column:hb_peer_ip" json:"hb_peer_ip,omitempty"`
|
||||||
|
HBRouterID int `gorm:"column:hb_router_id" json:"hb_router_id"`
|
||||||
|
GWCheckIP *string `gorm:"column:gw_check_ip" json:"gw_check_ip,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,16 +40,20 @@ func (DNSRecord) TableName() string { return "dns_records" }
|
|||||||
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
|
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
|
||||||
// für die typische LAN-Resolver-Rolle).
|
// für die typische LAN-Resolver-Rolle).
|
||||||
type DNSSettings struct {
|
type DNSSettings struct {
|
||||||
ID int64 `gorm:"primaryKey" json:"id"`
|
ID int64 `gorm:"primaryKey" json:"id"`
|
||||||
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
|
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
|
||||||
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
|
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
|
||||||
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
|
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
|
||||||
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
|
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
|
||||||
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
|
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
|
||||||
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
|
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
|
||||||
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
|
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
|
||||||
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
|
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
Prefetch bool `gorm:"column:prefetch" json:"prefetch"`
|
||||||
|
ServeExpired bool `gorm:"column:serve_expired" json:"serve_expired"`
|
||||||
|
MsgCacheSizeMB int `gorm:"column:msg_cache_size_mb" json:"msg_cache_size_mb"`
|
||||||
|
RRSetCacheSizeMB int `gorm:"column:rrset_cache_size_mb" json:"rrset_cache_size_mb"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (DNSSettings) TableName() string { return "dns_settings" }
|
func (DNSSettings) TableName() string { return "dns_settings" }
|
||||||
|
|||||||
19
internal/models/forward_proxy_settings.go
Normal file
19
internal/models/forward_proxy_settings.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ForwardProxySettings struct {
|
||||||
|
ID int `gorm:"primaryKey" json:"id"`
|
||||||
|
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
|
||||||
|
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
|
||||||
|
CacheMemMB int `gorm:"column:cache_mem_mb" json:"cache_mem_mb"`
|
||||||
|
CacheDirMB int `gorm:"column:cache_dir_mb" json:"cache_dir_mb"`
|
||||||
|
MaxObjSizeMB int `gorm:"column:max_obj_size_mb" json:"max_obj_size_mb"`
|
||||||
|
ConnectTimeout int `gorm:"column:connect_timeout" json:"connect_timeout"`
|
||||||
|
ReadTimeout int `gorm:"column:read_timeout" json:"read_timeout"`
|
||||||
|
RequestTimeout int `gorm:"column:request_timeout" json:"request_timeout"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ForwardProxySettings) TableName() string { return "forward_proxy_settings" }
|
||||||
@@ -204,12 +204,16 @@ func (r *Repo) DeleteRecord(ctx context.Context, id int64) error {
|
|||||||
func (r *Repo) GetSettings(ctx context.Context) (*models.DNSSettings, error) {
|
func (r *Repo) GetSettings(ctx context.Context) (*models.DNSSettings, error) {
|
||||||
row := r.Pool.QueryRow(ctx, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
SELECT id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
SELECT id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
||||||
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at
|
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl,
|
||||||
|
prefetch, serve_expired, msg_cache_size_mb, rrset_cache_size_mb,
|
||||||
|
updated_at
|
||||||
FROM dns_settings WHERE id=1`)
|
FROM dns_settings WHERE id=1`)
|
||||||
var s models.DNSSettings
|
var s models.DNSSettings
|
||||||
if err := row.Scan(&s.ID, &s.ListenAddresses, &s.ListenPort, &s.UpstreamForwards,
|
if err := row.Scan(&s.ID, &s.ListenAddresses, &s.ListenPort, &s.UpstreamForwards,
|
||||||
&s.AccessACL, &s.DNSSEC, &s.QNameMinimisation,
|
&s.AccessACL, &s.DNSSEC, &s.QNameMinimisation,
|
||||||
&s.CacheMinTTL, &s.CacheMaxTTL, &s.UpdatedAt); err != nil {
|
&s.CacheMinTTL, &s.CacheMaxTTL,
|
||||||
|
&s.Prefetch, &s.ServeExpired, &s.MsgCacheSizeMB, &s.RRSetCacheSizeMB,
|
||||||
|
&s.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &s, nil
|
return &s, nil
|
||||||
@@ -220,16 +224,22 @@ func (r *Repo) UpdateSettings(ctx context.Context, s models.DNSSettings) (*model
|
|||||||
UPDATE dns_settings SET
|
UPDATE dns_settings SET
|
||||||
listen_addresses=$1, listen_port=$2, upstream_forwards=$3, access_acl=$4,
|
listen_addresses=$1, listen_port=$2, upstream_forwards=$3, access_acl=$4,
|
||||||
dnssec=$5, qname_minimisation=$6, cache_min_ttl=$7, cache_max_ttl=$8,
|
dnssec=$5, qname_minimisation=$6, cache_min_ttl=$7, cache_max_ttl=$8,
|
||||||
|
prefetch=$9, serve_expired=$10, msg_cache_size_mb=$11, rrset_cache_size_mb=$12,
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id=1
|
WHERE id=1
|
||||||
RETURNING id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
RETURNING id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
||||||
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at`,
|
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl,
|
||||||
|
prefetch, serve_expired, msg_cache_size_mb, rrset_cache_size_mb,
|
||||||
|
updated_at`,
|
||||||
s.ListenAddresses, s.ListenPort, s.UpstreamForwards, s.AccessACL,
|
s.ListenAddresses, s.ListenPort, s.UpstreamForwards, s.AccessACL,
|
||||||
s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL)
|
s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL,
|
||||||
|
s.Prefetch, s.ServeExpired, s.MsgCacheSizeMB, s.RRSetCacheSizeMB)
|
||||||
var out models.DNSSettings
|
var out models.DNSSettings
|
||||||
if err := row.Scan(&out.ID, &out.ListenAddresses, &out.ListenPort, &out.UpstreamForwards,
|
if err := row.Scan(&out.ID, &out.ListenAddresses, &out.ListenPort, &out.UpstreamForwards,
|
||||||
&out.AccessACL, &out.DNSSEC, &out.QNameMinimisation,
|
&out.AccessACL, &out.DNSSEC, &out.QNameMinimisation,
|
||||||
&out.CacheMinTTL, &out.CacheMaxTTL, &out.UpdatedAt); err != nil {
|
&out.CacheMinTTL, &out.CacheMaxTTL,
|
||||||
|
&out.Prefetch, &out.ServeExpired, &out.MsgCacheSizeMB, &out.RRSetCacheSizeMB,
|
||||||
|
&out.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package forwardproxy provides CRUD against the forward_proxy_acls
|
// Package forwardproxy provides CRUD against the forward_proxy_acls
|
||||||
// table. Renderer in internal/squid consumes the same rows to emit
|
// table and settings in forward_proxy_settings. Renderer in internal/squid
|
||||||
// /etc/edgeguard/squid/squid.conf.
|
// consumes both tables to emit /etc/edgeguard/squid/squid.conf.
|
||||||
package forwardproxy
|
package forwardproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -97,6 +97,52 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings returns the singleton forward_proxy_settings row.
|
||||||
|
func (r *Repo) GetSettings(ctx context.Context) (*models.ForwardProxySettings, error) {
|
||||||
|
var s models.ForwardProxySettings
|
||||||
|
if err := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, listen_addresses, listen_port,
|
||||||
|
cache_mem_mb, cache_dir_mb, max_obj_size_mb,
|
||||||
|
connect_timeout, read_timeout, request_timeout,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM forward_proxy_settings WHERE id=1`).Scan(
|
||||||
|
&s.ID, &s.ListenAddresses, &s.ListenPort,
|
||||||
|
&s.CacheMemMB, &s.CacheDirMB, &s.MaxObjSizeMB,
|
||||||
|
&s.ConnectTimeout, &s.ReadTimeout, &s.RequestTimeout,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpdateSettings(ctx context.Context, s models.ForwardProxySettings) (*models.ForwardProxySettings, error) {
|
||||||
|
var out models.ForwardProxySettings
|
||||||
|
if err := r.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE forward_proxy_settings SET
|
||||||
|
listen_addresses=$1, listen_port=$2,
|
||||||
|
cache_mem_mb=$3, cache_dir_mb=$4, max_obj_size_mb=$5,
|
||||||
|
connect_timeout=$6, read_timeout=$7, request_timeout=$8,
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE id=1
|
||||||
|
RETURNING id, listen_addresses, listen_port,
|
||||||
|
cache_mem_mb, cache_dir_mb, max_obj_size_mb,
|
||||||
|
connect_timeout, read_timeout, request_timeout,
|
||||||
|
created_at, updated_at`,
|
||||||
|
s.ListenAddresses, s.ListenPort,
|
||||||
|
s.CacheMemMB, s.CacheDirMB, s.MaxObjSizeMB,
|
||||||
|
s.ConnectTimeout, s.ReadTimeout, s.RequestTimeout,
|
||||||
|
).Scan(
|
||||||
|
&out.ID, &out.ListenAddresses, &out.ListenPort,
|
||||||
|
&out.CacheMemMB, &out.CacheDirMB, &out.MaxObjSizeMB,
|
||||||
|
&out.ConnectTimeout, &out.ReadTimeout, &out.RequestTimeout,
|
||||||
|
&out.CreatedAt, &out.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func scan(row interface{ Scan(...any) error }) (*models.ForwardProxyACL, error) {
|
func scan(row interface{ Scan(...any) error }) (*models.ForwardProxyACL, error) {
|
||||||
var a models.ForwardProxyACL
|
var a models.ForwardProxyACL
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
|
|||||||
@@ -22,19 +22,37 @@ func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} }
|
|||||||
// Render schreibt /etc/edgeguard/ip-addresses.conf (Format: dev|addr/prefix)
|
// Render schreibt /etc/edgeguard/ip-addresses.conf (Format: dev|addr/prefix)
|
||||||
// und triggert das apply-Skript via sudo.
|
// und triggert das apply-Skript via sudo.
|
||||||
func (g *Generator) Render(ctx context.Context) error {
|
func (g *Generator) Render(ctx context.Context) error {
|
||||||
|
return g.render(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSecondary wie Render, aber schließt Ethernet-Interface-IPs aus.
|
||||||
|
// Auf einem Secondary-Node werden eth0-IPs (Public-IP + VIP) von
|
||||||
|
// cloud-init bzw. Keepalived verwaltet — edgeguard soll sie nicht
|
||||||
|
// überschreiben oder entfernen.
|
||||||
|
func (g *Generator) RenderSecondary(ctx context.Context) error {
|
||||||
|
return g.render(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Generator) render(ctx context.Context, excludeEthernet bool) error {
|
||||||
type addrRow struct {
|
type addrRow struct {
|
||||||
dev string
|
dev string
|
||||||
addr string
|
addr string
|
||||||
prefix int
|
prefix int
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := g.Repo.Pool.Query(ctx, `
|
q := `
|
||||||
SELECT ni.name, ia.address, ia.prefix
|
SELECT ni.name, ia.address, ia.prefix
|
||||||
FROM ip_addresses ia
|
FROM ip_addresses ia
|
||||||
JOIN network_interfaces ni ON ni.id = ia.interface_id
|
JOIN network_interfaces ni ON ni.id = ia.interface_id
|
||||||
WHERE ia.active = true
|
WHERE ia.active = true`
|
||||||
ORDER BY ni.name, ia.address`,
|
if excludeEthernet {
|
||||||
)
|
q += `
|
||||||
|
AND ni.type != 'ethernet'`
|
||||||
|
}
|
||||||
|
q += `
|
||||||
|
ORDER BY ni.name, ia.address`
|
||||||
|
|
||||||
|
rows, err := g.Repo.Pool.Query(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("query: %w", err)
|
return fmt.Errorf("query: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ func (s *Signer) Issue(actor string) (string, *Token, error) {
|
|||||||
return s.IssueWithRole(actor, "")
|
return s.IssueWithRole(actor, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call).
|
||||||
|
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
|
||||||
|
orig := s.TTL
|
||||||
|
s.TTL = ttl
|
||||||
|
raw, tok, err := s.IssueWithRole(actor, role)
|
||||||
|
s.TTL = orig
|
||||||
|
return raw, tok, err
|
||||||
|
}
|
||||||
|
|
||||||
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
|
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
|
||||||
func (s *Signer) Verify(raw string) (*Token, error) {
|
func (s *Signer) Verify(raw string) (*Token, error) {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,22 +28,30 @@ type User struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthInfo is returned by FindForAuth — contains credentials needed during login.
|
||||||
|
type AuthInfo struct {
|
||||||
|
User
|
||||||
|
PasswordHash string
|
||||||
|
TOTPSecret *string
|
||||||
|
}
|
||||||
|
|
||||||
type Repo struct {
|
type Repo struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
|
||||||
|
|
||||||
const selectCols = `id, email, role, active, last_login_at, created_at, updated_at`
|
const selectCols = `id, email, role, active, totp_enabled, last_login_at, created_at, updated_at`
|
||||||
|
|
||||||
func scan(row pgx.Row) (User, error) {
|
func scan(row pgx.Row) (User, error) {
|
||||||
var u User
|
var u User
|
||||||
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active,
|
err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled,
|
||||||
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt)
|
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt)
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
@@ -71,7 +80,7 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err
|
|||||||
var hash string
|
var hash string
|
||||||
err := r.pool.QueryRow(ctx,
|
err := r.pool.QueryRow(ctx,
|
||||||
`SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`,
|
`SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`,
|
||||||
email).Scan(&u.ID, &u.Email, &u.Role, &u.Active,
|
email).Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled,
|
||||||
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash)
|
&u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return u, "", ErrNotFound
|
return u, "", ErrNotFound
|
||||||
@@ -79,6 +88,70 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err
|
|||||||
return u, hash, err
|
return u, hash, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindForAuth returns full auth credentials including TOTP secret. ErrNotFound if absent.
|
||||||
|
func (r *Repo) FindForAuth(ctx context.Context, email string) (*AuthInfo, error) {
|
||||||
|
var a AuthInfo
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+selectCols+`, password_hash, totp_secret FROM users WHERE lower(email)=lower($1)`,
|
||||||
|
email).Scan(&a.ID, &a.Email, &a.Role, &a.Active, &a.TOTPEnabled,
|
||||||
|
&a.LastLoginAt, &a.CreatedAt, &a.UpdatedAt, &a.PasswordHash, &a.TOTPSecret)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return &a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTOTPSecret creates a new TOTP secret for the given email and returns
|
||||||
|
// the secret + the otpauth:// provisioning URI (for QR code rendering in the UI).
|
||||||
|
// The secret is NOT saved yet — call ConfirmTOTP after the user verifies the code.
|
||||||
|
func GenerateTOTPSecret(email string) (secret, uri string, err error) {
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: "EdgeGuard",
|
||||||
|
AccountName: email,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return key.Secret(), key.URL(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmTOTP verifies the given TOTP code against the (not-yet-saved) secret
|
||||||
|
// and, on success, persists it and enables TOTP for the user.
|
||||||
|
func (r *Repo) ConfirmTOTP(ctx context.Context, userID int64, secret, code string) error {
|
||||||
|
if !totp.Validate(code, secret) {
|
||||||
|
return errors.New("invalid_totp_code")
|
||||||
|
}
|
||||||
|
tag, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET totp_secret=$1, totp_enabled=true, updated_at=NOW() WHERE id=$2`,
|
||||||
|
secret, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableTOTP clears the TOTP secret and disables 2FA for the given user.
|
||||||
|
func (r *Repo) DisableTOTP(ctx context.Context, userID int64) error {
|
||||||
|
tag, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET totp_secret=NULL, totp_enabled=false, updated_at=NOW() WHERE id=$1`,
|
||||||
|
userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyTOTP checks a live TOTP code against the stored secret.
|
||||||
|
func VerifyTOTP(secret, code string) bool {
|
||||||
|
return totp.Validate(code, secret)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repo) Count(ctx context.Context) (int, error) {
|
func (r *Repo) Count(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
# Source: internal/squid/squid.go (template: squid.cfg.tpl).
|
# Source: internal/squid/squid.go (template: squid.cfg.tpl).
|
||||||
# Re-generate via `edgeguard-ctl render-config --only=squid`.
|
# Re-generate via `edgeguard-ctl render-config --only=squid`.
|
||||||
|
|
||||||
http_port {{.ListenPort}}
|
{{range .ListenAddrs -}}
|
||||||
|
{{if .Addr}}http_port {{.Addr}}:{{.Port}}
|
||||||
|
{{else}}http_port {{.Port}}
|
||||||
|
{{end}}{{- end}}
|
||||||
|
|
||||||
# Standard cache directory + small in-memory cache. Forward proxy
|
cache_dir ufs /var/spool/squid {{.CacheDirMB}} 16 256
|
||||||
# isn't a CDN — we keep cache modest to avoid disk pressure.
|
cache_mem {{.CacheMemMB}} MB
|
||||||
cache_dir ufs /var/spool/squid 100 16 256
|
maximum_object_size {{.MaxObjSizeMB}} MB
|
||||||
cache_mem 64 MB
|
|
||||||
|
|
||||||
# Logging — combined access log, rotated by logrotate.
|
# Logging — combined access log, rotated by logrotate.
|
||||||
access_log /var/log/squid/access.log squid
|
access_log /var/log/squid/access.log squid
|
||||||
@@ -56,7 +58,9 @@ http_access allow localhost
|
|||||||
http_access allow localnet
|
http_access allow localnet
|
||||||
http_access deny all
|
http_access deny all
|
||||||
|
|
||||||
# Hostnames + visible name — operator can override via squid.conf
|
connect_timeout {{.ConnectTimeout}} seconds
|
||||||
# drop-in if needed.
|
read_timeout {{.ReadTimeout}} seconds
|
||||||
|
request_timeout {{.RequestTimeout}} seconds
|
||||||
|
|
||||||
visible_hostname edgeguard-proxy
|
visible_hostname edgeguard-proxy
|
||||||
forwarded_for on
|
forwarded_for on
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -21,8 +22,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
confPath = "/etc/edgeguard/squid/squid.conf"
|
confPath = "/etc/edgeguard/squid/squid.conf"
|
||||||
listenPort = 3128
|
defaultListenPort = 3128
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed squid.cfg.tpl
|
//go:embed squid.cfg.tpl
|
||||||
@@ -30,9 +31,20 @@ var cfgTpl string
|
|||||||
|
|
||||||
var tpl = template.Must(template.New("squid").Parse(cfgTpl))
|
var tpl = template.Must(template.New("squid").Parse(cfgTpl))
|
||||||
|
|
||||||
|
type ListenAddr struct {
|
||||||
|
Addr string // empty = all interfaces
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
type View struct {
|
type View struct {
|
||||||
ListenPort int
|
ListenAddrs []ListenAddr
|
||||||
ACLs []models.ForwardProxyACL
|
ACLs []models.ForwardProxyACL
|
||||||
|
CacheMemMB int
|
||||||
|
CacheDirMB int
|
||||||
|
MaxObjSizeMB int
|
||||||
|
ConnectTimeout int
|
||||||
|
ReadTimeout int
|
||||||
|
RequestTimeout int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Generator struct {
|
type Generator struct {
|
||||||
@@ -52,7 +64,45 @@ func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return bytes.Buffer{}, fmt.Errorf("list acls: %w", err)
|
return bytes.Buffer{}, fmt.Errorf("list acls: %w", err)
|
||||||
}
|
}
|
||||||
view := View{ListenPort: listenPort, ACLs: acls}
|
|
||||||
|
// Read all settings — fall back to defaults if table not migrated yet.
|
||||||
|
s := models.ForwardProxySettings{
|
||||||
|
ListenPort: defaultListenPort,
|
||||||
|
CacheMemMB: 64,
|
||||||
|
CacheDirMB: 100,
|
||||||
|
MaxObjSizeMB: 4,
|
||||||
|
ConnectTimeout: 60,
|
||||||
|
ReadTimeout: 300,
|
||||||
|
RequestTimeout: 300,
|
||||||
|
}
|
||||||
|
_ = g.Pool.QueryRow(ctx, `
|
||||||
|
SELECT listen_addresses, listen_port,
|
||||||
|
cache_mem_mb, cache_dir_mb, max_obj_size_mb,
|
||||||
|
connect_timeout, read_timeout, request_timeout
|
||||||
|
FROM forward_proxy_settings WHERE id=1`).Scan(
|
||||||
|
&s.ListenAddresses, &s.ListenPort,
|
||||||
|
&s.CacheMemMB, &s.CacheDirMB, &s.MaxObjSizeMB,
|
||||||
|
&s.ConnectTimeout, &s.ReadTimeout, &s.RequestTimeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
var listenAddrs []ListenAddr
|
||||||
|
for _, raw := range splitCSV(s.ListenAddresses) {
|
||||||
|
listenAddrs = append(listenAddrs, ListenAddr{Addr: raw, Port: s.ListenPort})
|
||||||
|
}
|
||||||
|
if len(listenAddrs) == 0 {
|
||||||
|
listenAddrs = []ListenAddr{{Addr: "", Port: s.ListenPort}}
|
||||||
|
}
|
||||||
|
|
||||||
|
view := View{
|
||||||
|
ListenAddrs: listenAddrs,
|
||||||
|
ACLs: acls,
|
||||||
|
CacheMemMB: s.CacheMemMB,
|
||||||
|
CacheDirMB: s.CacheDirMB,
|
||||||
|
MaxObjSizeMB: s.MaxObjSizeMB,
|
||||||
|
ConnectTimeout: s.ConnectTimeout,
|
||||||
|
ReadTimeout: s.ReadTimeout,
|
||||||
|
RequestTimeout: s.RequestTimeout,
|
||||||
|
}
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
if err := tpl.Execute(&body, view); err != nil {
|
if err := tpl.Execute(&body, view); err != nil {
|
||||||
return bytes.Buffer{}, fmt.Errorf("template: %w", err)
|
return bytes.Buffer{}, fmt.Errorf("template: %w", err)
|
||||||
@@ -60,6 +110,17 @@ func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) {
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||||
buf, err := g.renderBuf(ctx)
|
buf, err := g.renderBuf(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ server:
|
|||||||
do-tcp: yes
|
do-tcp: yes
|
||||||
cache-min-ttl: {{.Settings.CacheMinTTL}}
|
cache-min-ttl: {{.Settings.CacheMinTTL}}
|
||||||
cache-max-ttl: {{.Settings.CacheMaxTTL}}
|
cache-max-ttl: {{.Settings.CacheMaxTTL}}
|
||||||
msg-cache-size: 64m
|
msg-cache-size: {{.Settings.MsgCacheSizeMB}}m
|
||||||
rrset-cache-size: 128m
|
rrset-cache-size: {{.Settings.RRSetCacheSizeMB}}m
|
||||||
|
prefetch: {{if .Settings.Prefetch}}yes{{else}}no{{end}}
|
||||||
|
serve-expired: {{if .Settings.ServeExpired}}yes{{else}}no{{end}}
|
||||||
num-threads: 2
|
num-threads: 2
|
||||||
|
|
||||||
# Hardening
|
# Hardening
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ func stopWGQuick(iface string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enableWGQuick(iface string) error {
|
||||||
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "enable", "wg-quick@"+iface+".service")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("systemctl enable wg-quick@%s: %w: %s", iface, err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func disableWGQuick(iface string) error {
|
||||||
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "disable", "wg-quick@"+iface+".service")
|
||||||
|
// Ignore failures — unit may already be disabled.
|
||||||
|
_ = cmd.Run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// symlinkWGQuickConf creates (or atomically replaces) the symlink
|
// symlinkWGQuickConf creates (or atomically replaces) the symlink
|
||||||
// /etc/wireguard/<iface>.conf → target via sudo. /etc/wireguard/ is
|
// /etc/wireguard/<iface>.conf → target via sudo. /etc/wireguard/ is
|
||||||
// owned root:root 700 so the edgeguard user cannot write to it directly;
|
// owned root:root 700 so the edgeguard user cannot write to it directly;
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ func (g *Generator) Render(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
|
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
|
||||||
_ = stopWGQuick(ifaceName)
|
_ = stopWGQuick(ifaceName)
|
||||||
|
_ = disableWGQuick(ifaceName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -235,6 +236,7 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
|
|||||||
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
|
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
|
||||||
return fmt.Errorf("symlink: %w", err)
|
return fmt.Errorf("symlink: %w", err)
|
||||||
}
|
}
|
||||||
|
_ = enableWGQuick(ifc.Name)
|
||||||
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
||||||
return startWGQuick(ifc.Name)
|
return startWGQuick(ifc.Name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,10 @@
|
|||||||
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
|
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
|
||||||
"emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen.",
|
"emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen.",
|
||||||
"logEnabled": "Logging aktiv — gematchte Pakete werden ins Firewall-Log geschrieben",
|
"logEnabled": "Logging aktiv — gematchte Pakete werden ins Firewall-Log geschrieben",
|
||||||
"ruleDisabled": "Regel deaktiviert"
|
"ruleDisabled": "Regel deaktiviert",
|
||||||
|
"enabled": "Aktiv",
|
||||||
|
"unnamed": "(kein Name)",
|
||||||
|
"zeroHitHint": "Keine Treffer seit dem letzten Neustart — möglicherweise ungenutzte oder überlagerte Regel"
|
||||||
},
|
},
|
||||||
"kpi": {
|
"kpi": {
|
||||||
"policyRules": "Policy-Regeln",
|
"policyRules": "Policy-Regeln",
|
||||||
@@ -281,7 +284,12 @@
|
|||||||
"loggedInAs": "Angemeldet als",
|
"loggedInAs": "Angemeldet als",
|
||||||
"forgotPassword": "Passwort vergessen?",
|
"forgotPassword": "Passwort vergessen?",
|
||||||
"viewerBadge": "Nur lesen",
|
"viewerBadge": "Nur lesen",
|
||||||
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen."
|
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
|
||||||
|
"totp": {
|
||||||
|
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||||
|
"verify": "Code bestätigen",
|
||||||
|
"invalidCode": "Ungültiger Code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
"title": "Admin-Passwort zurücksetzen",
|
"title": "Admin-Passwort zurücksetzen",
|
||||||
@@ -392,6 +400,17 @@
|
|||||||
"backends": "Backends",
|
"backends": "Backends",
|
||||||
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
|
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
|
||||||
},
|
},
|
||||||
|
"vipCard": {
|
||||||
|
"title": "VIP / VRRP",
|
||||||
|
"noVips": "Keine VIPs konfiguriert",
|
||||||
|
"keepalivedInactive": "keepalived läuft nicht",
|
||||||
|
"state": {
|
||||||
|
"MASTER": "MASTER",
|
||||||
|
"BACKUP": "BACKUP",
|
||||||
|
"FAULT": "FAULT",
|
||||||
|
"UNKNOWN": "Unbekannt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"systemCard": {
|
"systemCard": {
|
||||||
"title": "System",
|
"title": "System",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -399,9 +418,16 @@
|
|||||||
"ifaces": "Interfaces",
|
"ifaces": "Interfaces",
|
||||||
"wg": "WireGuard"
|
"wg": "WireGuard"
|
||||||
},
|
},
|
||||||
|
"networkServicesCard": {
|
||||||
|
"title": "Netzwerk-Dienste",
|
||||||
|
"configure": "Konfigurieren"
|
||||||
|
},
|
||||||
"alertsCard": {
|
"alertsCard": {
|
||||||
"title": "Aktuelle Alerts",
|
"title": "Aktuelle Alerts",
|
||||||
"viewAll": "Alle anzeigen"
|
"viewAll": "Alle anzeigen",
|
||||||
|
"summary": "{{critical}} kritisch · {{warning}} Warnung",
|
||||||
|
"summaryWarning": "{{warning}} Warnung",
|
||||||
|
"summaryCritical": "{{critical}} kritisch"
|
||||||
},
|
},
|
||||||
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
|
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
|
||||||
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
|
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
|
||||||
@@ -664,7 +690,18 @@
|
|||||||
"hintPrimary": "Auf dem Primary: edgeguard-ctl cluster-init-replication",
|
"hintPrimary": "Auf dem Primary: edgeguard-ctl cluster-init-replication",
|
||||||
"hintStandby": "Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
"hintStandby": "Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
||||||
"hintKeepalived": "Keepalived auf beiden Nodes: sudo systemctl enable --now keepalived",
|
"hintKeepalived": "Keepalived auf beiden Nodes: sudo systemctl enable --now keepalived",
|
||||||
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)"
|
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)",
|
||||||
|
"splitBrainSection": "Split-Brain-Schutz (Dual-Path VRRP + Gateway-Tracking)",
|
||||||
|
"hbInterface": "Heartbeat-Interface",
|
||||||
|
"hbInterfaceHelp": "Zweites Interface für die VI_HB-Instanz — VRRP-Advertisements laufen hier unabhängig von VI_1. Leer lassen um zu deaktivieren.",
|
||||||
|
"hbSrcIp": "Heartbeat-Quell-IP",
|
||||||
|
"hbSrcIpHelp": "Eigene IP auf dem Heartbeat-Interface (unicast_src_ip für VI_HB).",
|
||||||
|
"hbPeerIp": "Heartbeat-Peer-IP",
|
||||||
|
"hbPeerIpHelp": "Peer-IP auf dem Heartbeat-Interface (unicast_peer für VI_HB).",
|
||||||
|
"hbRouterId": "Heartbeat Router-ID",
|
||||||
|
"hbRouterIdHelp": "VRRP virtual_router_id für VI_HB — muss sich von der Haupt-Router-ID unterscheiden. Standard: 52.",
|
||||||
|
"gwCheckIp": "Gateway-Check-IP",
|
||||||
|
"gwCheckIpHelp": "Upstream-Gateway-IP die alle 5 s angepingt wird. Nicht erreichbar → Priorität sinkt um 110 → Failover wird ausgelöst. Leer lassen um zu deaktivieren."
|
||||||
},
|
},
|
||||||
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
|
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
|
||||||
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
|
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
|
||||||
@@ -699,7 +736,27 @@
|
|||||||
"step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://<node-fqdn>:3443/setup), \"Vorhandenem Cluster beitreten\" wählen, Primary-FQDN ({{primaryFqdn}}) eingeben und den Token oben einfügen.",
|
"step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://<node-fqdn>:3443/setup), \"Vorhandenem Cluster beitreten\" wählen, Primary-FQDN ({{primaryFqdn}}) eingeben und den Token oben einfügen.",
|
||||||
"generateNewToken": "Neuen Token generieren",
|
"generateNewToken": "Neuen Token generieren",
|
||||||
"setupWizardHint": "Setup-Wizard auf dem neuen Knoten öffnen",
|
"setupWizardHint": "Setup-Wizard auf dem neuen Knoten öffnen",
|
||||||
"newNodeFqdnLabel": "FQDN des neuen Knotens"
|
"newNodeFqdnLabel": "FQDN des neuen Knotens",
|
||||||
|
"vipTest": {
|
||||||
|
"cardTitle": "VIP-Schwenk Test",
|
||||||
|
"cardDesc": "Verschiebt einen VIP temporär auf den Secondary um zu prüfen ob die Dienste korrekt antworten. Keepalived ist nicht beteiligt — reiner ip addr add/del Test.",
|
||||||
|
"colAddress": "VIP-Adresse",
|
||||||
|
"colInterface": "Interface",
|
||||||
|
"colActiveOn": "Aktiv auf",
|
||||||
|
"swingBtn": "→ Secondary",
|
||||||
|
"restoreBtn": "← Primary",
|
||||||
|
"swingOk": "VIP erfolgreich auf Secondary geschwenkt",
|
||||||
|
"restoreOk": "VIP zurück auf Primary",
|
||||||
|
"swingFailed": "VIP-Schwenk fehlgeschlagen",
|
||||||
|
"restoreFailed": "VIP-Rückschwenk fehlgeschlagen",
|
||||||
|
"noVips": "Keine VIPs konfiguriert (ip_addresses mit is_vip=true)",
|
||||||
|
"steps": "Schritte",
|
||||||
|
"stepOk": "OK",
|
||||||
|
"stepFail": "Fehler",
|
||||||
|
"confirmSwing": "{{addr}} auf Secondary verschieben?",
|
||||||
|
"confirmRestore": "{{addr}} zurück auf Primary?",
|
||||||
|
"unknown": "unbekannt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ssl": {
|
"ssl": {
|
||||||
"title": "SSL-Zertifikate",
|
"title": "SSL-Zertifikate",
|
||||||
@@ -1126,7 +1183,16 @@
|
|||||||
"flushCacheFailed": "Cache-Flush fehlgeschlagen",
|
"flushCacheFailed": "Cache-Flush fehlgeschlagen",
|
||||||
"upstreamForwardsInvalid": "Jeder Forwarder muss eine gültige IP sein (z.B. 1.1.1.1 oder 9.9.9.9)",
|
"upstreamForwardsInvalid": "Jeder Forwarder muss eine gültige IP sein (z.B. 1.1.1.1 oder 9.9.9.9)",
|
||||||
"accessACLInvalid": "Jeder Eintrag muss eine gültige IP oder CIDR sein (z.B. 10.0.0.0/8)",
|
"accessACLInvalid": "Jeder Eintrag muss eine gültige IP oder CIDR sein (z.B. 10.0.0.0/8)",
|
||||||
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein"
|
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein",
|
||||||
|
"cacheSection": "Cache",
|
||||||
|
"prefetch": "Häufige Records vorausladen",
|
||||||
|
"prefetchExtra": "Häufig abgefragte Records werden vor Ablauf der TTL neu aufgelöst — reduziert Latenz für bekannte Namen.",
|
||||||
|
"serveExpired": "Abgelaufene Records ausliefern",
|
||||||
|
"serveExpiredExtra": "Stale Cache-Einträge zurückgeben wenn Upstream-Resolver nicht erreichbar ist. Reduziert SERVFAIL bei Ausfällen.",
|
||||||
|
"msgCacheSizeMB": "Message-Cache (MB)",
|
||||||
|
"msgCacheSizeMBExtra": "RAM für DNS-Antwort-Cache (msg-cache-size). Standard 64 MB.",
|
||||||
|
"rrsetCacheSizeMB": "RRset-Cache (MB)",
|
||||||
|
"rrsetCacheSizeMBExtra": "RAM für Resource-Record-Cache (rrset-cache-size). Sollte ~2x Message-Cache sein. Standard 128 MB."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fwd": {
|
"fwd": {
|
||||||
@@ -1180,6 +1246,28 @@
|
|||||||
"dstdom_regex": "dstdom_regex — Domain-Regex",
|
"dstdom_regex": "dstdom_regex — Domain-Regex",
|
||||||
"srcdom_regex": "srcdom_regex — Quell-Domain-Regex",
|
"srcdom_regex": "srcdom_regex — Quell-Domain-Regex",
|
||||||
"browser": "browser — User-Agent-Regex"
|
"browser": "browser — User-Agent-Regex"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Proxy-Einstellungen",
|
||||||
|
"listenAddresses": "Listen-Adressen",
|
||||||
|
"listenAddressesExtra": "Komma-getrennte IPs auf denen Squid lauscht (z.B. 10.0.5.1, 10.0.20.1). Leer = alle Interfaces.",
|
||||||
|
"listenPort": "Port",
|
||||||
|
"listenPortExtra": "Standard: 3128.",
|
||||||
|
"saveFailed": "Einstellungen konnten nicht gespeichert werden.",
|
||||||
|
"cacheSection": "Cache",
|
||||||
|
"cacheMemMB": "RAM-Cache (MB)",
|
||||||
|
"cacheMemMBExtra": "RAM den Squid für Caching nutzt (cache_mem). Standard 64 MB.",
|
||||||
|
"cacheDirMB": "Disk-Cache (MB)",
|
||||||
|
"cacheDirMBExtra": "Speicherplatz für den UFS-Cache. Standard 100 MB.",
|
||||||
|
"maxObjSizeMB": "Max. Objektgröße (MB)",
|
||||||
|
"maxObjSizeMBExtra": "Größtes Objekt das Squid cached. Größere Objekte werden direkt durchgeleitet. Standard 4 MB.",
|
||||||
|
"timeoutSection": "Timeouts",
|
||||||
|
"connectTimeout": "Verbindungs-Timeout (s)",
|
||||||
|
"connectTimeoutExtra": "Sekunden, die Squid beim Verbindungsaufbau zum Upstream wartet.",
|
||||||
|
"readTimeout": "Lese-Timeout (s)",
|
||||||
|
"readTimeoutExtra": "Sekunden zwischen aufeinanderfolgenden Lesevorgängen vom Upstream.",
|
||||||
|
"requestTimeout": "Anfrage-Timeout (s)",
|
||||||
|
"requestTimeoutExtra": "Maximale Zeit für einen vollständigen Request/Response-Zyklus."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
@@ -1205,6 +1293,8 @@
|
|||||||
"retry": "Erneut versuchen",
|
"retry": "Erneut versuchen",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
"up": "UP",
|
"up": "UP",
|
||||||
"down": "DOWN",
|
"down": "DOWN",
|
||||||
"relTime": {
|
"relTime": {
|
||||||
@@ -1542,7 +1632,24 @@
|
|||||||
"errorEmailTaken": "Diese E-Mail-Adresse wird bereits verwendet.",
|
"errorEmailTaken": "Diese E-Mail-Adresse wird bereits verwendet.",
|
||||||
"cannotDeleteSelf": "Das eigene Konto kann nicht gelöscht werden.",
|
"cannotDeleteSelf": "Das eigene Konto kann nicht gelöscht werden.",
|
||||||
"you": "Ich",
|
"you": "Ich",
|
||||||
"never": "Nie"
|
"never": "Nie",
|
||||||
|
"totp": {
|
||||||
|
"on": "2FA",
|
||||||
|
"off": "–",
|
||||||
|
"setup": "2FA einrichten",
|
||||||
|
"manage": "2FA verwalten",
|
||||||
|
"disable": "2FA deaktivieren",
|
||||||
|
"disableFor": "2FA für {{email}} deaktivieren",
|
||||||
|
"enabled": "2FA wurde aktiviert",
|
||||||
|
"disabled": "2FA wurde deaktiviert",
|
||||||
|
"setupTitle": "Zwei-Faktor-Authentifizierung einrichten",
|
||||||
|
"manageTitle": "Zwei-Faktor-Authentifizierung",
|
||||||
|
"scanHint": "Scanne den QR-Code mit Google Authenticator, Authy oder einer kompatiblen App.",
|
||||||
|
"enterCode": "Gib den 6-stelligen Code aus deiner Authenticator-App ein:",
|
||||||
|
"confirm": "Bestätigen & aktivieren",
|
||||||
|
"alreadyEnabled": "2FA ist für diesen Account aktiv.",
|
||||||
|
"disableHint": "Klicke auf 'Deaktivieren' um 2FA für diesen Account zu entfernen."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"audit": {
|
"audit": {
|
||||||
"title": "Audit-Log",
|
"title": "Audit-Log",
|
||||||
|
|||||||
@@ -152,7 +152,10 @@
|
|||||||
"emptyTitle": "No custom firewall rules yet.",
|
"emptyTitle": "No custom firewall rules yet.",
|
||||||
"emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards.",
|
"emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards.",
|
||||||
"logEnabled": "Logging active — matched packets are written to the firewall log",
|
"logEnabled": "Logging active — matched packets are written to the firewall log",
|
||||||
"ruleDisabled": "Rule disabled"
|
"ruleDisabled": "Rule disabled",
|
||||||
|
"enabled": "Active",
|
||||||
|
"unnamed": "(unnamed)",
|
||||||
|
"zeroHitHint": "No hits since last restart — possibly unused or shadowed rule"
|
||||||
},
|
},
|
||||||
"kpi": {
|
"kpi": {
|
||||||
"policyRules": "Policy Rules",
|
"policyRules": "Policy Rules",
|
||||||
@@ -281,7 +284,12 @@
|
|||||||
"loggedInAs": "Signed in as",
|
"loggedInAs": "Signed in as",
|
||||||
"forgotPassword": "Forgot your password?",
|
"forgotPassword": "Forgot your password?",
|
||||||
"viewerBadge": "Read-only",
|
"viewerBadge": "Read-only",
|
||||||
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role."
|
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
|
||||||
|
"totp": {
|
||||||
|
"prompt": "Enter the 6-digit code from your authenticator app.",
|
||||||
|
"verify": "Verify code",
|
||||||
|
"invalidCode": "Invalid code"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
"title": "Reset admin password",
|
"title": "Reset admin password",
|
||||||
@@ -392,6 +400,17 @@
|
|||||||
"backends": "Backends",
|
"backends": "Backends",
|
||||||
"attached": "{{count}}/{{total}} domains have a primary backend"
|
"attached": "{{count}}/{{total}} domains have a primary backend"
|
||||||
},
|
},
|
||||||
|
"vipCard": {
|
||||||
|
"title": "VIP / VRRP",
|
||||||
|
"noVips": "No VIPs configured",
|
||||||
|
"keepalivedInactive": "keepalived not running",
|
||||||
|
"state": {
|
||||||
|
"MASTER": "MASTER",
|
||||||
|
"BACKUP": "BACKUP",
|
||||||
|
"FAULT": "FAULT",
|
||||||
|
"UNKNOWN": "Unknown"
|
||||||
|
}
|
||||||
|
},
|
||||||
"systemCard": {
|
"systemCard": {
|
||||||
"title": "System",
|
"title": "System",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
@@ -399,9 +418,16 @@
|
|||||||
"ifaces": "Interfaces",
|
"ifaces": "Interfaces",
|
||||||
"wg": "WireGuard"
|
"wg": "WireGuard"
|
||||||
},
|
},
|
||||||
|
"networkServicesCard": {
|
||||||
|
"title": "Network services",
|
||||||
|
"configure": "Configure"
|
||||||
|
},
|
||||||
"alertsCard": {
|
"alertsCard": {
|
||||||
"title": "Recent alerts",
|
"title": "Active alerts",
|
||||||
"viewAll": "View all"
|
"viewAll": "View all",
|
||||||
|
"summary": "{{critical}} critical · {{warning}} warning",
|
||||||
|
"summaryWarning": "{{warning}} warning",
|
||||||
|
"summaryCritical": "{{critical}} critical"
|
||||||
},
|
},
|
||||||
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
|
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
|
||||||
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
|
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
|
||||||
@@ -664,7 +690,18 @@
|
|||||||
"hintPrimary": "On primary: edgeguard-ctl cluster-init-replication",
|
"hintPrimary": "On primary: edgeguard-ctl cluster-init-replication",
|
||||||
"hintStandby": "On secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
"hintStandby": "On secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
||||||
"hintKeepalived": "Keepalived on both nodes: sudo systemctl enable --now keepalived",
|
"hintKeepalived": "Keepalived on both nodes: sudo systemctl enable --now keepalived",
|
||||||
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)"
|
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)",
|
||||||
|
"splitBrainSection": "Split-brain protection (dual-path VRRP + gateway tracking)",
|
||||||
|
"hbInterface": "Heartbeat interface",
|
||||||
|
"hbInterfaceHelp": "Second interface for VI_HB instance — VRRP advertisements run here independently of VI_1. Leave empty to disable.",
|
||||||
|
"hbSrcIp": "Heartbeat source IP",
|
||||||
|
"hbSrcIpHelp": "Own IP on the heartbeat interface (unicast_src_ip for VI_HB).",
|
||||||
|
"hbPeerIp": "Heartbeat peer IP",
|
||||||
|
"hbPeerIpHelp": "Peer IP on the heartbeat interface (unicast_peer for VI_HB).",
|
||||||
|
"hbRouterId": "Heartbeat router ID",
|
||||||
|
"hbRouterIdHelp": "VRRP virtual_router_id for VI_HB — must differ from main Router ID. Default: 52.",
|
||||||
|
"gwCheckIp": "Gateway check IP",
|
||||||
|
"gwCheckIpHelp": "Upstream gateway IP to ping every 5 s. If unreachable: priority drops by 110 → failover triggers. Leave empty to disable."
|
||||||
},
|
},
|
||||||
"loadTitle": "Per-node resources (mTLS aggregator)",
|
"loadTitle": "Per-node resources (mTLS aggregator)",
|
||||||
"loadEmpty": "No node resources available — agent listener unreachable?",
|
"loadEmpty": "No node resources available — agent listener unreachable?",
|
||||||
@@ -699,7 +736,27 @@
|
|||||||
"step3SetupDesc": "Open the setup wizard on the new node (https://<node-fqdn>:3443/setup), choose \"Join existing cluster\", enter the primary FQDN ({{primaryFqdn}}) and paste the token above.",
|
"step3SetupDesc": "Open the setup wizard on the new node (https://<node-fqdn>:3443/setup), choose \"Join existing cluster\", enter the primary FQDN ({{primaryFqdn}}) and paste the token above.",
|
||||||
"generateNewToken": "Generate new token",
|
"generateNewToken": "Generate new token",
|
||||||
"setupWizardHint": "Open the setup wizard on the new node",
|
"setupWizardHint": "Open the setup wizard on the new node",
|
||||||
"newNodeFqdnLabel": "New node FQDN"
|
"newNodeFqdnLabel": "New node FQDN",
|
||||||
|
"vipTest": {
|
||||||
|
"cardTitle": "VIP failover test",
|
||||||
|
"cardDesc": "Temporarily move a VIP to the secondary to test that services respond correctly. Keepalived is not involved — this is a raw ip addr add/del test.",
|
||||||
|
"colAddress": "VIP address",
|
||||||
|
"colInterface": "Interface",
|
||||||
|
"colActiveOn": "Active on",
|
||||||
|
"swingBtn": "→ Secondary",
|
||||||
|
"restoreBtn": "← Primary",
|
||||||
|
"swingOk": "VIP successfully moved to secondary",
|
||||||
|
"restoreOk": "VIP restored to primary",
|
||||||
|
"swingFailed": "VIP swing failed",
|
||||||
|
"restoreFailed": "VIP restore failed",
|
||||||
|
"noVips": "No VIPs configured (ip_addresses with is_vip=true)",
|
||||||
|
"steps": "Steps",
|
||||||
|
"stepOk": "OK",
|
||||||
|
"stepFail": "Failed",
|
||||||
|
"confirmSwing": "Move {{addr}} to secondary?",
|
||||||
|
"confirmRestore": "Restore {{addr}} to primary?",
|
||||||
|
"unknown": "unknown"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ssl": {
|
"ssl": {
|
||||||
"title": "SSL certificates",
|
"title": "SSL certificates",
|
||||||
@@ -1126,7 +1183,16 @@
|
|||||||
"flushCacheFailed": "Flush failed",
|
"flushCacheFailed": "Flush failed",
|
||||||
"upstreamForwardsInvalid": "Each forwarder must be a valid IP (e.g. 1.1.1.1 or 9.9.9.9)",
|
"upstreamForwardsInvalid": "Each forwarder must be a valid IP (e.g. 1.1.1.1 or 9.9.9.9)",
|
||||||
"accessACLInvalid": "Each entry must be a valid IP or CIDR (e.g. 10.0.0.0/8 or 192.168.1.0/24)",
|
"accessACLInvalid": "Each entry must be a valid IP or CIDR (e.g. 10.0.0.0/8 or 192.168.1.0/24)",
|
||||||
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL"
|
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL",
|
||||||
|
"cacheSection": "Cache",
|
||||||
|
"prefetch": "Prefetch popular records",
|
||||||
|
"prefetchExtra": "Re-fetch records before TTL expires if queried frequently — reduces latency for hot names.",
|
||||||
|
"serveExpired": "Serve expired records",
|
||||||
|
"serveExpiredExtra": "Return stale cache entries when upstream resolvers are unreachable. Reduces SERVFAIL during outages.",
|
||||||
|
"msgCacheSizeMB": "Message cache (MB)",
|
||||||
|
"msgCacheSizeMBExtra": "RAM for DNS response cache (msg-cache-size). Default 64 MB.",
|
||||||
|
"rrsetCacheSizeMB": "RRset cache (MB)",
|
||||||
|
"rrsetCacheSizeMBExtra": "RAM for resource-record cache (rrset-cache-size). Should be ~2x message cache. Default 128 MB."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fwd": {
|
"fwd": {
|
||||||
@@ -1180,6 +1246,28 @@
|
|||||||
"dstdom_regex": "dstdom_regex — destination domain regex",
|
"dstdom_regex": "dstdom_regex — destination domain regex",
|
||||||
"srcdom_regex": "srcdom_regex — source domain regex",
|
"srcdom_regex": "srcdom_regex — source domain regex",
|
||||||
"browser": "browser — User-Agent regex"
|
"browser": "browser — User-Agent regex"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Proxy settings",
|
||||||
|
"listenAddresses": "Listen addresses",
|
||||||
|
"listenAddressesExtra": "Comma-separated IPs Squid listens on (e.g. 10.0.5.1, 10.0.20.1). Leave empty to listen on all interfaces.",
|
||||||
|
"listenPort": "Port",
|
||||||
|
"listenPortExtra": "Default: 3128.",
|
||||||
|
"saveFailed": "Settings could not be saved.",
|
||||||
|
"cacheSection": "Cache",
|
||||||
|
"cacheMemMB": "In-memory cache (MB)",
|
||||||
|
"cacheMemMBExtra": "RAM used by Squid for caching (cache_mem). Default 64 MB.",
|
||||||
|
"cacheDirMB": "Disk cache (MB)",
|
||||||
|
"cacheDirMBExtra": "Disk space for the UFS cache. Default 100 MB.",
|
||||||
|
"maxObjSizeMB": "Max. object size (MB)",
|
||||||
|
"maxObjSizeMBExtra": "Largest object Squid will cache. Objects above this are fetched fresh. Default 4 MB.",
|
||||||
|
"timeoutSection": "Timeouts",
|
||||||
|
"connectTimeout": "Connect timeout (s)",
|
||||||
|
"connectTimeoutExtra": "Seconds to wait when opening a connection to the upstream server.",
|
||||||
|
"readTimeout": "Read timeout (s)",
|
||||||
|
"readTimeoutExtra": "Seconds Squid waits between consecutive reads from the upstream.",
|
||||||
|
"requestTimeout": "Request timeout (s)",
|
||||||
|
"requestTimeoutExtra": "Maximum time for a complete request/response cycle."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
@@ -1205,6 +1293,8 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
"up": "UP",
|
"up": "UP",
|
||||||
"down": "DOWN",
|
"down": "DOWN",
|
||||||
"relTime": {
|
"relTime": {
|
||||||
@@ -1542,7 +1632,24 @@
|
|||||||
"errorEmailTaken": "This email address is already in use.",
|
"errorEmailTaken": "This email address is already in use.",
|
||||||
"cannotDeleteSelf": "You cannot delete your own account.",
|
"cannotDeleteSelf": "You cannot delete your own account.",
|
||||||
"you": "You",
|
"you": "You",
|
||||||
"never": "Never"
|
"never": "Never",
|
||||||
|
"totp": {
|
||||||
|
"on": "2FA",
|
||||||
|
"off": "–",
|
||||||
|
"setup": "Set up 2FA",
|
||||||
|
"manage": "Manage 2FA",
|
||||||
|
"disable": "Disable 2FA",
|
||||||
|
"disableFor": "Disable 2FA for {{email}}",
|
||||||
|
"enabled": "2FA has been enabled",
|
||||||
|
"disabled": "2FA has been disabled",
|
||||||
|
"setupTitle": "Set up two-factor authentication",
|
||||||
|
"manageTitle": "Two-factor authentication",
|
||||||
|
"scanHint": "Scan the QR code with Google Authenticator, Authy, or any compatible app.",
|
||||||
|
"enterCode": "Enter the 6-digit code from your authenticator app:",
|
||||||
|
"confirm": "Confirm & activate",
|
||||||
|
"alreadyEnabled": "2FA is active for this account.",
|
||||||
|
"disableHint": "Click 'Disable 2FA' to remove two-factor authentication from this account."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"audit": {
|
"audit": {
|
||||||
"title": "Audit log",
|
"title": "Audit log",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Alert, Button, Card, Descriptions, Input, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
import { Alert, Button, Card, Descriptions, Input, List, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons'
|
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SwapOutlined } from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -87,6 +87,28 @@ interface CertStatus {
|
|||||||
peer?: CertInfo
|
peer?: CertInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VIPInfo {
|
||||||
|
id: number
|
||||||
|
address: string
|
||||||
|
prefix: number
|
||||||
|
device: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VIPStatusEntry {
|
||||||
|
vip: VIPInfo
|
||||||
|
active_on: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VIPTestStep {
|
||||||
|
step: string
|
||||||
|
ok: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VIPTestResult {
|
||||||
|
steps: VIPTestStep[]
|
||||||
|
}
|
||||||
|
|
||||||
function statusTag(s: HANode['status'], t: (k: string) => string) {
|
function statusTag(s: HANode['status'], t: (k: string) => string) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
|
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
|
||||||
@@ -272,6 +294,42 @@ export default function ClusterPage() {
|
|||||||
onError: () => message.error(t('cluster.joinTokenFailed')),
|
onError: () => message.error(t('cluster.joinTokenFailed')),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isClusterMode = data?.mode === 'cluster'
|
||||||
|
|
||||||
|
const vipStatusQuery = useQuery({
|
||||||
|
queryKey: ['cluster', 'vip-status'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await apiClient.get('/cluster/vip-status')
|
||||||
|
const payload = isEnvelope(r.data) ? (r.data.data as { vips?: VIPStatusEntry[] }) : null
|
||||||
|
return payload?.vips ?? []
|
||||||
|
},
|
||||||
|
enabled: isClusterMode,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [vipTestResult, setVipTestResult] = useState<{ id: number; steps: VIPTestStep[] } | null>(null)
|
||||||
|
|
||||||
|
const vipSwing = useMutation({
|
||||||
|
mutationFn: async ({ id, action }: { id: number; action: 'to_secondary' | 'restore' }) => {
|
||||||
|
const r = await apiClient.post('/cluster/vip-test', { ip_address_id: id, action })
|
||||||
|
return isEnvelope(r.data) ? (r.data.data as VIPTestResult) : null
|
||||||
|
},
|
||||||
|
onSuccess: (result, { id, action }) => {
|
||||||
|
if (result) setVipTestResult({ id, steps: result.steps })
|
||||||
|
const allOk = result?.steps.every(s => s.ok) ?? false
|
||||||
|
if (allOk) {
|
||||||
|
const key = action === 'to_secondary' ? 'cluster.vipTest.swingOk' : 'cluster.vipTest.restoreOk'
|
||||||
|
void message.success(t(key))
|
||||||
|
} else {
|
||||||
|
const key = action === 'to_secondary' ? 'cluster.vipTest.swingFailed' : 'cluster.vipTest.restoreFailed'
|
||||||
|
void message.error(t(key))
|
||||||
|
}
|
||||||
|
void vipStatusQuery.refetch()
|
||||||
|
},
|
||||||
|
onError: (e: Error) => void message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
||||||
|
|
||||||
const peerColumns: ColumnsType<HANode> = [
|
const peerColumns: ColumnsType<HANode> = [
|
||||||
@@ -641,6 +699,134 @@ export default function ClusterPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── VIP-Schwenk Test ─────────────────────────────────── */}
|
||||||
|
{isClusterMode && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><SwapOutlined />{t('cluster.vipTest.cardTitle')}</Space>}
|
||||||
|
className="mb-16"
|
||||||
|
extra={
|
||||||
|
<Button size="small" icon={<ReloadOutlined />} onClick={() => void vipStatusQuery.refetch()}>
|
||||||
|
{t('common.refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={t('cluster.vipTest.cardDesc')}
|
||||||
|
className="mb-12"
|
||||||
|
/>
|
||||||
|
{vipStatusQuery.isLoading ? (
|
||||||
|
<Spin />
|
||||||
|
) : (vipStatusQuery.data?.length ?? 0) === 0 ? (
|
||||||
|
<Text type="secondary">{t('cluster.vipTest.noVips')}</Text>
|
||||||
|
) : (
|
||||||
|
<Table<VIPStatusEntry>
|
||||||
|
size="small"
|
||||||
|
rowKey={r => String(r.vip.id)}
|
||||||
|
dataSource={vipStatusQuery.data ?? []}
|
||||||
|
pagination={false}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: r => {
|
||||||
|
const res = vipTestResult?.id === r.vip.id ? vipTestResult : null
|
||||||
|
if (!res) return null
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={res.steps}
|
||||||
|
renderItem={s => (
|
||||||
|
<List.Item>
|
||||||
|
<Space>
|
||||||
|
<Tag color={s.ok ? 'green' : 'red'}>{s.ok ? t('cluster.vipTest.stepOk') : t('cluster.vipTest.stepFail')}</Tag>
|
||||||
|
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.step}</Text>
|
||||||
|
{s.message && <Text type="danger" style={{ fontSize: 12 }}>{s.message}</Text>}
|
||||||
|
</Space>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
rowExpandable: r => vipTestResult?.id === r.vip.id,
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: t('cluster.vipTest.colAddress'),
|
||||||
|
key: 'address',
|
||||||
|
render: (_, r) => (
|
||||||
|
<Text style={{ fontFamily: 'monospace' }}>{r.vip.address}/{r.vip.prefix}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('cluster.vipTest.colInterface'),
|
||||||
|
key: 'device',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => <Tag>{r.vip.device}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('cluster.vipTest.colActiveOn'),
|
||||||
|
key: 'activeOn',
|
||||||
|
render: (_, r) => {
|
||||||
|
if (!r.active_on || r.active_on.length === 0) {
|
||||||
|
return <Tag color="red">{t('cluster.vipTest.unknown')}</Tag>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
{r.active_on.map(fqdn => <Tag key={fqdn} color="green">{fqdn}</Tag>)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('common.actions'),
|
||||||
|
key: 'actions',
|
||||||
|
width: 200,
|
||||||
|
render: (_, r) => {
|
||||||
|
const localFqdn = data?.local_node?.fqdn
|
||||||
|
const onLocal = r.active_on?.includes(localFqdn ?? '') ?? false
|
||||||
|
const onPeer = r.active_on?.some(f => f !== localFqdn) ?? false
|
||||||
|
const loading = vipSwing.isPending && (vipSwing.variables as { id: number })?.id === r.vip.id
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
{!isViewer && !onPeer && (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('cluster.vipTest.confirmSwing', { addr: r.vip.address })}
|
||||||
|
okText={t('common.yes')}
|
||||||
|
cancelText={t('common.no')}
|
||||||
|
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'to_secondary' })}
|
||||||
|
>
|
||||||
|
<Button size="small" loading={loading && onLocal}>
|
||||||
|
{t('cluster.vipTest.swingBtn')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
{!isViewer && onPeer && (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('cluster.vipTest.confirmRestore', { addr: r.vip.address })}
|
||||||
|
okText={t('common.yes')}
|
||||||
|
cancelText={t('common.no')}
|
||||||
|
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'restore' })}
|
||||||
|
>
|
||||||
|
<Button size="small" type="primary" loading={loading}>
|
||||||
|
{t('cluster.vipTest.restoreBtn')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
{isViewer && (
|
||||||
|
<Tooltip title={t('auth.viewerBadge')}>
|
||||||
|
<Button size="small" disabled>{t('cluster.vipTest.swingBtn')}</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Per-Node Resources ────────────────────────────────── */}
|
{/* ── Per-Node Resources ────────────────────────────────── */}
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Alert, Button, Card, Col, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
import { Alert, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
|
import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -43,6 +43,10 @@ interface Settings {
|
|||||||
qname_minimisation: boolean
|
qname_minimisation: boolean
|
||||||
cache_min_ttl: number
|
cache_min_ttl: number
|
||||||
cache_max_ttl: number
|
cache_max_ttl: number
|
||||||
|
prefetch: boolean
|
||||||
|
serve_expired: boolean
|
||||||
|
msg_cache_size_mb: number
|
||||||
|
rrset_cache_size_mb: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
|
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
|
||||||
@@ -464,6 +468,7 @@ function SettingsTab() {
|
|||||||
for (const i of sys ?? []) {
|
for (const i of sys ?? []) {
|
||||||
if (i.ifname === 'lo') continue
|
if (i.ifname === 'lo') continue
|
||||||
for (const a of i.addr_info ?? []) {
|
for (const a of i.addr_info ?? []) {
|
||||||
|
if (a.local.startsWith('fe80:')) continue
|
||||||
ipOptions.push({
|
ipOptions.push({
|
||||||
value: a.local,
|
value: a.local,
|
||||||
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
||||||
@@ -477,7 +482,20 @@ function SettingsTab() {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
} : undefined
|
} : {
|
||||||
|
listen_addresses: [],
|
||||||
|
listen_port: 53,
|
||||||
|
upstream_forwards: '1.1.1.1, 9.9.9.9',
|
||||||
|
access_acl: '10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
|
||||||
|
dnssec: false,
|
||||||
|
qname_minimisation: true,
|
||||||
|
cache_min_ttl: 60,
|
||||||
|
cache_max_ttl: 86400,
|
||||||
|
prefetch: false,
|
||||||
|
serve_expired: false,
|
||||||
|
msg_cache_size_mb: 64,
|
||||||
|
rrset_cache_size_mb: 128,
|
||||||
|
}
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async (v: SettingsForm) => {
|
mutationFn: async (v: SettingsForm) => {
|
||||||
@@ -566,9 +584,19 @@ function SettingsTab() {
|
|||||||
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
|
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Space>
|
<Form.Item label={t('dns.settings.prefetch')} name="prefetch" valuePropName="checked"
|
||||||
|
extra={t('dns.settings.prefetchExtra')}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.settings.serveExpired')} name="serve_expired" valuePropName="checked"
|
||||||
|
extra={t('dns.settings.serveExpiredExtra')}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider plain>{t('dns.settings.cacheSection')}</Divider>
|
||||||
|
<Space wrap>
|
||||||
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
|
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
|
||||||
<InputNumber min={0} style={{ width: 120 }} />
|
<InputNumber min={0} style={{ width: 130 }} addonAfter="s" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('dns.settings.cacheMax')}
|
label={t('dns.settings.cacheMax')}
|
||||||
@@ -586,7 +614,15 @@ function SettingsTab() {
|
|||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber min={60} style={{ width: 120 }} />
|
<InputNumber min={60} style={{ width: 130 }} addonAfter="s" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.settings.msgCacheSizeMB')} name="msg_cache_size_mb"
|
||||||
|
extra={t('dns.settings.msgCacheSizeMBExtra')}>
|
||||||
|
<InputNumber min={8} max={4096} style={{ width: 130 }} addonAfter="MB" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.settings.rrsetCacheSizeMB')} name="rrset_cache_size_mb"
|
||||||
|
extra={t('dns.settings.rrsetCacheSizeMBExtra')}>
|
||||||
|
<InputNumber min={16} max={8192} style={{ width: 130 }} addonAfter="MB" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -116,7 +116,17 @@ export default function NATRulesTab() {
|
|||||||
|
|
||||||
const columns: ColumnsType<NATRule> = [
|
const columns: ColumnsType<NATRule> = [
|
||||||
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
|
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
|
||||||
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
|
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 100, render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
|
||||||
|
{
|
||||||
|
title: t('fw.nat.name'), key: 'name', width: 200,
|
||||||
|
render: (_, r) => (
|
||||||
|
<div>
|
||||||
|
{r.name && <div style={{ fontWeight: 500 }}>{r.name}</div>}
|
||||||
|
{r.comment && <div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{r.comment}</div>}
|
||||||
|
{!r.name && !r.comment && <span style={{ color: 'var(--ant-color-text-quaternary)' }}>—</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('fw.nat.match'), key: 'match',
|
title: t('fw.nat.match'), key: 'match',
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
@@ -126,11 +136,11 @@ export default function NATRulesTab() {
|
|||||||
{r.proto && <Tag>{r.proto}</Tag>}
|
{r.proto && <Tag>{r.proto}</Tag>}
|
||||||
{r.match_src_cidr && <code>src={r.match_src_cidr}</code>}
|
{r.match_src_cidr && <code>src={r.match_src_cidr}</code>}
|
||||||
{r.match_dst_cidr && <code>dst={r.match_dst_cidr}</code>}
|
{r.match_dst_cidr && <code>dst={r.match_dst_cidr}</code>}
|
||||||
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
|
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) },
|
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
|
||||||
{
|
{
|
||||||
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
||||||
render: (v: boolean, row: NATRule) => (
|
render: (v: boolean, row: NATRule) => (
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { ColumnsType } from 'antd/es/table'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, EyeOutlined, FireOutlined, PlusOutlined,
|
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
||||||
|
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
@@ -159,10 +160,27 @@ export default function RulesTab() {
|
|||||||
if (cidr) return cidr
|
if (cidr) return cidr
|
||||||
return 'any'
|
return 'any'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS"
|
||||||
|
const autoDescription = (r: FwRule): string => {
|
||||||
|
const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)
|
||||||
|
const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)
|
||||||
|
const svc = r.service_object_id ? svLabel(r.service_object_id)
|
||||||
|
: r.service_group_id ? sgLabel(r.service_group_id)
|
||||||
|
: 'any'
|
||||||
|
return `${r.src_zone}/${src} → ${r.dst_zone}/${dst} · ${svc}`
|
||||||
|
}
|
||||||
|
|
||||||
const renderService = (objID?: number | null, grpID?: number | null) => {
|
const renderService = (objID?: number | null, grpID?: number | null) => {
|
||||||
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
|
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
|
||||||
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}>⊂ {sgLabel(grpID)}</Tag>
|
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}>⊂ {sgLabel(grpID)}</Tag>
|
||||||
return <Tag style={{ fontSize: 11, color: '#94A3B8' }}>any</Tag>
|
return <span className="fw-addr-any">any</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => {
|
||||||
|
const label = renderAddrCompact(objID, grpID, cidr)
|
||||||
|
if (label === 'any') return <span className="fw-addr-any">any</span>
|
||||||
|
return <Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>{label}</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filter state ─────────────────────────────────────────────
|
// ── Filter state ─────────────────────────────────────────────
|
||||||
@@ -294,11 +312,7 @@ export default function RulesTab() {
|
|||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<ZoneBadge zone={r.src_zone} />
|
<ZoneBadge zone={r.src_zone} />
|
||||||
{(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && (
|
{renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
||||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
|
||||||
{renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -311,11 +325,7 @@ export default function RulesTab() {
|
|||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<ZoneBadge zone={r.dst_zone} />
|
<ZoneBadge zone={r.dst_zone} />
|
||||||
{(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && (
|
{renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
||||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
|
||||||
{renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -327,11 +337,16 @@ export default function RulesTab() {
|
|||||||
title: t('fw.rule.name'), key: 'name', ellipsis: true,
|
title: t('fw.rule.name'), key: 'name', ellipsis: true,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div>
|
<div>
|
||||||
{r.name && <div style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>}
|
{r.name
|
||||||
{r.comment && (
|
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
||||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 1 }}>{r.comment}</div>
|
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>
|
||||||
)}
|
{t('fw.rule.unnamed')}
|
||||||
{!r.name && !r.comment && <Text type="secondary" style={{ fontSize: 11 }}>—</Text>}
|
</div>
|
||||||
|
}
|
||||||
|
{r.comment
|
||||||
|
? <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>
|
||||||
|
: <div className="fw-rule-desc">{autoDescription(r)}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -339,7 +354,13 @@ export default function RulesTab() {
|
|||||||
title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const,
|
title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const,
|
||||||
render: (_, r) => {
|
render: (_, r) => {
|
||||||
const c = counterByID.get(r.id)
|
const c = counterByID.get(r.id)
|
||||||
if (!c || c.packets === 0) return <Text type="secondary" style={{ fontSize: 11 }}>—</Text>
|
if (!c || c.packets === 0) return (
|
||||||
|
<Tooltip title={r.enabled ? t('fw.rule.zeroHitHint') : undefined}>
|
||||||
|
<span style={{ fontSize: 11, color: r.enabled ? '#FAAD14' : '#CBD5E1' }}>
|
||||||
|
{r.enabled ? <><WarningOutlined style={{ fontSize: 10, marginRight: 2 }} />0</> : '—'}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
|
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
|
||||||
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
|
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
|
||||||
@@ -372,19 +393,19 @@ export default function RulesTab() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '', key: 'move', width: 60,
|
title: '', key: 'move', width: 52,
|
||||||
render: (_, row) => {
|
render: (_, row) => {
|
||||||
const idx = sortedRules.findIndex(r => r.id === row.id)
|
const idx = sortedRules.findIndex(r => r.id === row.id)
|
||||||
const swapping = swap.isPending
|
const swapping = swap.isPending
|
||||||
return (
|
return (
|
||||||
<Space size={2}>
|
<Space size={1} className="fw-row-actions">
|
||||||
<Tooltip title={t('fw.rule.moveUp')}>
|
<Tooltip title={t('fw.rule.moveUp')}>
|
||||||
<Button size="small" icon={<ArrowUpOutlined />}
|
<Button type="text" size="small" icon={<ArrowUpOutlined />}
|
||||||
disabled={isViewer || idx <= 0 || swapping}
|
disabled={isViewer || idx <= 0 || swapping}
|
||||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
|
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t('fw.rule.moveDown')}>
|
<Tooltip title={t('fw.rule.moveDown')}>
|
||||||
<Button size="small" icon={<ArrowDownOutlined />}
|
<Button type="text" size="small" icon={<ArrowDownOutlined />}
|
||||||
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
|
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
|
||||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
|
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -393,30 +414,27 @@ export default function RulesTab() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '', key: 'actions', width: 120,
|
title: '', key: 'actions', width: 88,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4}>
|
<Space size={0} className="fw-row-actions">
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
|
||||||
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>
|
<Button type="text" size="small" icon={<EditOutlined />}
|
||||||
{t('common.edit')}
|
disabled={isViewer}
|
||||||
</Button>
|
onClick={() => editFromRow(row)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
|
||||||
<Button
|
<Button type="text" size="small" icon={<CopyOutlined />}
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
disabled={isViewer}
|
disabled={isViewer}
|
||||||
loading={duplicate.isPending && duplicate.variables?.id === row.id}
|
loading={duplicate.isPending && duplicate.variables?.id === row.id}
|
||||||
onClick={() => duplicate.mutate(row)}
|
onClick={() => duplicate.mutate(row)} />
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isViewer ? (
|
{isViewer ? (
|
||||||
<Tooltip title={t('auth.viewerBadge')}>
|
<Tooltip title={t('auth.viewerBadge')}>
|
||||||
<Button size="small" danger disabled>{t('common.delete')}</Button>
|
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Popconfirm title={t('fw.rule.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
|
<Popconfirm title={t('fw.rule.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
|
||||||
<Button size="small" danger>{t('common.delete')}</Button>
|
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -486,7 +504,14 @@ export default function RulesTab() {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
dataSource={filteredRules}
|
dataSource={filteredRules}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowClassName={(row: FwRule) => row.enabled ? '' : 'fw-rule-row--disabled'}
|
rowClassName={(row: FwRule) => {
|
||||||
|
const c = counterByID.get(row.id)
|
||||||
|
const zeroHit = row.enabled && (!c || c.packets === 0)
|
||||||
|
return [
|
||||||
|
!row.enabled ? 'fw-rule-row--disabled' : '',
|
||||||
|
zeroHit ? 'fw-rule-row--zero-hit' : '',
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
}}
|
||||||
emptyContent={
|
emptyContent={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FireOutlined />}
|
icon={<FireOutlined />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message,
|
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, Divider,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
@@ -31,6 +31,18 @@ interface ACL {
|
|||||||
|
|
||||||
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
||||||
|
|
||||||
|
interface ProxySettings {
|
||||||
|
id: number
|
||||||
|
listen_addresses: string
|
||||||
|
listen_port: number
|
||||||
|
cache_mem_mb: number
|
||||||
|
cache_dir_mb: number
|
||||||
|
max_obj_size_mb: number
|
||||||
|
connect_timeout: number
|
||||||
|
read_timeout: number
|
||||||
|
request_timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
name: string
|
name: string
|
||||||
acl_type: string
|
acl_type: string
|
||||||
@@ -98,6 +110,27 @@ export default function ForwardProxyPage() {
|
|||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
|
queryKey: ['fwd-proxy', 'settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await apiClient.get('/forward-proxy/settings')
|
||||||
|
if (!isEnvelope(r.data)) return null
|
||||||
|
return r.data.data as ProxySettings
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [settingsForm] = Form.useForm<ProxySettings>()
|
||||||
|
const saveSettings = useMutation({
|
||||||
|
mutationFn: async (v: ProxySettings) => {
|
||||||
|
await apiClient.put('/forward-proxy/settings', v)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'settings'] })
|
||||||
|
},
|
||||||
|
onError: () => message.error(t('fwd.settings.saveFailed')),
|
||||||
|
})
|
||||||
|
|
||||||
const [editing, setEditing] = useState<ACL | null>(null)
|
const [editing, setEditing] = useState<ACL | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [form] = Form.useForm<FormValues>()
|
const [form] = Form.useForm<FormValues>()
|
||||||
@@ -284,6 +317,97 @@ export default function ForwardProxyPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
className="mb-12"
|
||||||
|
title={t('fwd.settings.title')}
|
||||||
|
loading={settingsLoading}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={settingsForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={settings ?? {
|
||||||
|
listen_addresses: '', listen_port: 3128,
|
||||||
|
cache_mem_mb: 64, cache_dir_mb: 100, max_obj_size_mb: 4,
|
||||||
|
connect_timeout: 60, read_timeout: 300, request_timeout: 300,
|
||||||
|
}}
|
||||||
|
key={settings?.id ?? 'loading'}
|
||||||
|
onFinish={(v) => saveSettings.mutate(v)}
|
||||||
|
>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={16}>
|
||||||
|
<Form.Item
|
||||||
|
label={t('fwd.settings.listenAddresses')}
|
||||||
|
name="listen_addresses"
|
||||||
|
extra={t('fwd.settings.listenAddressesExtra')}
|
||||||
|
>
|
||||||
|
<Input placeholder="10.0.5.1, 10.0.20.1" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Item
|
||||||
|
label={t('fwd.settings.listenPort')}
|
||||||
|
name="listen_port"
|
||||||
|
extra={t('fwd.settings.listenPortExtra')}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider plain>{t('fwd.settings.cacheSection')}</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item label={t('fwd.settings.cacheMemMB')} name="cache_mem_mb" extra={t('fwd.settings.cacheMemMBExtra')}>
|
||||||
|
<InputNumber min={16} max={8192} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item label={t('fwd.settings.cacheDirMB')} name="cache_dir_mb" extra={t('fwd.settings.cacheDirMBExtra')}>
|
||||||
|
<InputNumber min={100} max={102400} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item label={t('fwd.settings.maxObjSizeMB')} name="max_obj_size_mb" extra={t('fwd.settings.maxObjSizeMBExtra')}>
|
||||||
|
<InputNumber min={1} max={1024} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider plain>{t('fwd.settings.timeoutSection')}</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item label={t('fwd.settings.connectTimeout')} name="connect_timeout" extra={t('fwd.settings.connectTimeoutExtra')}>
|
||||||
|
<InputNumber min={5} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item label={t('fwd.settings.readTimeout')} name="read_timeout" extra={t('fwd.settings.readTimeoutExtra')}>
|
||||||
|
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Form.Item label={t('fwd.settings.requestTimeout')} name="request_timeout" extra={t('fwd.settings.requestTimeoutExtra')}>
|
||||||
|
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={saveSettings.isPending}
|
||||||
|
disabled={isViewer}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
|||||||
@@ -193,26 +193,6 @@ export default function IPAddressesPage() {
|
|||||||
subtitle={t('ips.intro')}
|
subtitle={t('ips.intro')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card title={t('ips.systemDiscovered')} size="small" className="mb-12">
|
|
||||||
{(sysAddrs ?? []).length === 0
|
|
||||||
? <Typography.Text type="secondary">—</Typography.Text>
|
|
||||||
: (
|
|
||||||
<DataTable
|
|
||||||
size="small"
|
|
||||||
rowKey={(r) => `${r.ifname}-${r.address}`}
|
|
||||||
dataSource={sysAddrs ?? []}
|
|
||||||
|
|
||||||
columns={[
|
|
||||||
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
|
|
||||||
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
|
|
||||||
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
|
|
||||||
<DataTable
|
<DataTable
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -240,6 +220,24 @@ export default function IPAddressesPage() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card title={t('ips.systemDiscovered')} size="small" className="mt-12">
|
||||||
|
{(sysAddrs ?? []).length === 0
|
||||||
|
? <Typography.Text type="secondary">—</Typography.Text>
|
||||||
|
: (
|
||||||
|
<DataTable
|
||||||
|
size="small"
|
||||||
|
rowKey={(r) => `${r.ifname}-${r.address}`}
|
||||||
|
dataSource={sysAddrs ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
|
||||||
|
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
|
||||||
|
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
||||||
open={editing !== null || creating}
|
open={editing !== null || creating}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||||
|
import { KeyOutlined } from '@ant-design/icons'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -17,20 +19,24 @@ interface LoginValues {
|
|||||||
export default function LoginPage({ onLogin }: Props) {
|
export default function LoginPage({ onLogin }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [totpRequired, setTotpRequired] = useState(false)
|
||||||
|
const [totpCode, setTotpCode] = useState('')
|
||||||
|
const [verifying, setVerifying] = useState(false)
|
||||||
|
|
||||||
const onFinish = async (vals: LoginValues) => {
|
const onFinish = async (vals: LoginValues) => {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.post('/auth/login', vals)
|
const r = await apiClient.post('/auth/login', vals)
|
||||||
if (isEnvelope(r.data)) {
|
if (!isEnvelope(r.data)) return
|
||||||
const u = r.data.data as SessionUser
|
const d = r.data.data as { totp_required?: boolean } & SessionUser
|
||||||
onLogin(u)
|
if (d.totp_required) {
|
||||||
navigate('/dashboard', { replace: true })
|
setTotpRequired(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
onLogin(d)
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; status?: number }
|
const err = e as { message?: string; status?: number }
|
||||||
if (err.status === 503) {
|
if (err.status === 503) {
|
||||||
// setup-mode → drop to wizard
|
|
||||||
navigate('/setup', { replace: true })
|
navigate('/setup', { replace: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -38,36 +44,73 @@ export default function LoginPage({ onLogin }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onTOTPVerify = async () => {
|
||||||
|
if (!totpCode || totpCode.length < 6) return
|
||||||
|
setVerifying(true)
|
||||||
|
try {
|
||||||
|
const r = await apiClient.post('/auth/totp-verify', { code: totpCode })
|
||||||
|
if (isEnvelope(r.data)) {
|
||||||
|
onLogin(r.data.data as SessionUser)
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string }
|
||||||
|
message.error(err.message ?? t('auth.totp.invalidCode'))
|
||||||
|
setTotpCode('')
|
||||||
|
} finally {
|
||||||
|
setVerifying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
||||||
<Card style={{ width: 400 }}>
|
<Card style={{ width: 400 }}>
|
||||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
{t('app.title')}
|
{t('app.title')}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Form layout="vertical" onFinish={onFinish}>
|
|
||||||
<Form.Item
|
{!totpRequired ? (
|
||||||
label={t('auth.email')}
|
<Form layout="vertical" onFinish={onFinish}>
|
||||||
name="email"
|
<Form.Item label={t('auth.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||||
rules={[{ required: true, type: 'email' }]}
|
<Input autoComplete="email" autoFocus />
|
||||||
>
|
</Form.Item>
|
||||||
<Input autoComplete="email" autoFocus />
|
<Form.Item label={t('auth.password')} name="password" rules={[{ required: true }]}>
|
||||||
</Form.Item>
|
<Input.Password autoComplete="current-password" />
|
||||||
<Form.Item
|
</Form.Item>
|
||||||
label={t('auth.password')}
|
<Form.Item>
|
||||||
name="password"
|
<Button type="primary" htmlType="submit" block>{t('auth.login')}</Button>
|
||||||
rules={[{ required: true }]}
|
</Form.Item>
|
||||||
>
|
</Form>
|
||||||
<Input.Password autoComplete="current-password" />
|
) : (
|
||||||
</Form.Item>
|
<div>
|
||||||
<Form.Item>
|
<Typography.Paragraph style={{ textAlign: 'center' }}>
|
||||||
<Button type="primary" htmlType="submit" block>
|
<KeyOutlined style={{ fontSize: 32, color: '#1677ff', marginBottom: 8 }} /><br />
|
||||||
{t('auth.login')}
|
{t('auth.totp.prompt')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onPressEnter={onTOTPVerify}
|
||||||
|
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, marginBottom: 16 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="primary" block loading={verifying} onClick={onTOTPVerify}>
|
||||||
|
{t('auth.totp.verify')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
<Button type="link" block style={{ marginTop: 8 }} onClick={() => { setTotpRequired(false); setTotpCode('') }}>
|
||||||
</Form>
|
{t('common.back')}
|
||||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
</Button>
|
||||||
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{!totpRequired && (
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||||
|
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ function SettingsTab() {
|
|||||||
for (const i of sys ?? []) {
|
for (const i of sys ?? []) {
|
||||||
if (i.ifname === 'lo') continue
|
if (i.ifname === 'lo') continue
|
||||||
for (const a of i.addr_info ?? []) {
|
for (const a of i.addr_info ?? []) {
|
||||||
|
if (a.local.startsWith('fe80:')) continue
|
||||||
ipOptions.push({
|
ipOptions.push({
|
||||||
value: a.local,
|
value: a.local,
|
||||||
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ interface VIPSettingsValues {
|
|||||||
vip_interface?: string
|
vip_interface?: string
|
||||||
vip_auth_pass?: string
|
vip_auth_pass?: string
|
||||||
vrrp_router_id?: number
|
vrrp_router_id?: number
|
||||||
|
hb_interface?: string
|
||||||
|
hb_src_ip?: string
|
||||||
|
hb_peer_ip?: string
|
||||||
|
hb_router_id?: number
|
||||||
|
gw_check_ip?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -839,6 +844,31 @@ export default function SettingsPage() {
|
|||||||
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
|
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
|
||||||
<InputNumber min={1} max={255} disabled={isViewer} />
|
<InputNumber min={1} max={255} disabled={isViewer} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Typography.Text strong style={{ display: 'block', marginBottom: 12, marginTop: 8 }}>
|
||||||
|
{t('cluster.vipCard.splitBrainSection')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Form.Item label={t('cluster.vipCard.hbInterface')} name="hb_interface"
|
||||||
|
extra={t('cluster.vipCard.hbInterfaceHelp')}>
|
||||||
|
<Input placeholder="eth1" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('cluster.vipCard.hbSrcIp')} name="hb_src_ip"
|
||||||
|
extra={t('cluster.vipCard.hbSrcIpHelp')}>
|
||||||
|
<Input placeholder="192.168.1.1" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('cluster.vipCard.hbPeerIp')} name="hb_peer_ip"
|
||||||
|
extra={t('cluster.vipCard.hbPeerIpHelp')}>
|
||||||
|
<Input placeholder="192.168.1.2" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('cluster.vipCard.hbRouterId')} name="hb_router_id"
|
||||||
|
extra={t('cluster.vipCard.hbRouterIdHelp')}>
|
||||||
|
<InputNumber min={1} max={255} disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('cluster.vipCard.gwCheckIp')} name="gw_check_ip"
|
||||||
|
extra={t('cluster.vipCard.gwCheckIpHelp')}>
|
||||||
|
<Input placeholder="89.163.205.1" disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
{!isViewer && (
|
{!isViewer && (
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>
|
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Button, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
Button, Form, Input, Modal, QRCode, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { KeyOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'
|
import { KeyOutlined, LockOutlined, PlusOutlined, SafetyCertificateOutlined, TeamOutlined } from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ interface User {
|
|||||||
email: string
|
email: string
|
||||||
role: string
|
role: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
totp_enabled: boolean
|
||||||
last_login_at: string | null
|
last_login_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -45,9 +46,14 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
|
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
|
||||||
|
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [editing, setEditing] = useState<User | null>(null)
|
const [editing, setEditing] = useState<User | null>(null)
|
||||||
const [pwTarget, setPwTarget] = useState<User | null>(null)
|
const [pwTarget, setPwTarget] = useState<User | null>(null)
|
||||||
|
const [totpTarget, setTotpTarget] = useState<User | null>(null)
|
||||||
|
const [totpStep, setTotpStep] = useState(0)
|
||||||
|
const [totpSecret, setTotpSecret] = useState('')
|
||||||
|
const [totpUri, setTotpUri] = useState('')
|
||||||
|
const [totpCode, setTotpCode] = useState('')
|
||||||
const [createForm] = Form.useForm<CreateValues>()
|
const [createForm] = Form.useForm<CreateValues>()
|
||||||
const [editForm] = Form.useForm<EditValues>()
|
const [editForm] = Form.useForm<EditValues>()
|
||||||
const [pwForm] = Form.useForm<PwValues>()
|
const [pwForm] = Form.useForm<PwValues>()
|
||||||
@@ -80,6 +86,44 @@ export default function UsersPage() {
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
onError: (e: Error) => message.error(e.message),
|
onError: (e: Error) => message.error(e.message),
|
||||||
})
|
})
|
||||||
|
const disableTOTPMut = useMutation({
|
||||||
|
mutationFn: (id: number) => apiClient.delete(`/users/${id}/totp`),
|
||||||
|
onSuccess: () => { message.success(t('users.totp.disabled')); invalidate() },
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const openTOTPSetup = async (row: User) => {
|
||||||
|
setTotpTarget(row)
|
||||||
|
setTotpStep(0)
|
||||||
|
setTotpCode('')
|
||||||
|
if (row.email === me?.actor) {
|
||||||
|
// own account — generate secret via self-service endpoint
|
||||||
|
try {
|
||||||
|
const r = await apiClient.post('/auth/totp/setup')
|
||||||
|
if (isEnvelope(r.data)) {
|
||||||
|
const d = r.data.data as { secret: string; uri: string }
|
||||||
|
setTotpSecret(d.secret)
|
||||||
|
setTotpUri(d.uri)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error((e as Error).message)
|
||||||
|
setTotpTarget(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmTOTP = async () => {
|
||||||
|
if (!totpTarget) return
|
||||||
|
try {
|
||||||
|
await apiClient.post('/auth/totp/confirm', { secret: totpSecret, code: totpCode })
|
||||||
|
message.success(t('users.totp.enabled'))
|
||||||
|
setTotpTarget(null)
|
||||||
|
invalidate()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
message.error((e as Error).message ?? t('auth.totp.invalidCode'))
|
||||||
|
setTotpCode('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const roleOptions = [
|
const roleOptions = [
|
||||||
{ value: 'admin', label: t('users.roleAdmin') },
|
{ value: 'admin', label: t('users.roleAdmin') },
|
||||||
@@ -104,6 +148,12 @@ export default function UsersPage() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '2FA', key: 'totp', width: 70,
|
||||||
|
render: (_, row) => row.totp_enabled
|
||||||
|
? <Tag color="green" icon={<SafetyCertificateOutlined />}>{t('users.totp.on')}</Tag>
|
||||||
|
: <Tag color="default">{t('users.totp.off')}</Tag>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('users.active'), dataIndex: 'active', key: 'active', width: 80,
|
title: t('users.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||||
render: (v: boolean, row: User) => (
|
render: (v: boolean, row: User) => (
|
||||||
@@ -127,7 +177,7 @@ export default function UsersPage() {
|
|||||||
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
|
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('common.actions'), key: 'actions', width: 120,
|
title: t('common.actions'), key: 'actions', width: 160,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('users.setPassword')}>
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('users.setPassword')}>
|
||||||
@@ -135,6 +185,22 @@ export default function UsersPage() {
|
|||||||
disabled={isViewer}
|
disabled={isViewer}
|
||||||
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
|
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{/* 2FA button: setup for own account, disable for others */}
|
||||||
|
{row.email === me?.actor ? (
|
||||||
|
<Tooltip title={row.totp_enabled ? t('users.totp.manage') : t('users.totp.setup')}>
|
||||||
|
<Button type="text" size="small"
|
||||||
|
icon={<LockOutlined style={{ color: row.totp_enabled ? '#52c41a' : undefined }} />}
|
||||||
|
onClick={() => void openTOTPSetup(row)} />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
row.totp_enabled && !isViewer && (
|
||||||
|
<Tooltip title={t('users.totp.disableFor', { email: row.email })}>
|
||||||
|
<Button type="text" size="small" danger icon={<LockOutlined />}
|
||||||
|
loading={disableTOTPMut.isPending && disableTOTPMut.variables === row.id}
|
||||||
|
onClick={() => disableTOTPMut.mutate(row.id)} />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditing(row)
|
setEditing(row)
|
||||||
@@ -150,13 +216,11 @@ export default function UsersPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const isSelfTOTP = totpTarget?.email === me?.actor
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader icon={<TeamOutlined />} title={t('users.title')} subtitle={t('users.intro')} />
|
||||||
icon={<TeamOutlined />}
|
|
||||||
title={t('users.title')}
|
|
||||||
subtitle={t('users.intro')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -195,16 +259,10 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Create modal */}
|
{/* Create modal */}
|
||||||
<Modal
|
<Modal title={t('users.addUser')} open={creating}
|
||||||
title={t('users.addUser')}
|
|
||||||
open={creating}
|
|
||||||
onCancel={() => { setCreating(false); createForm.resetFields() }}
|
onCancel={() => { setCreating(false); createForm.resetFields() }}
|
||||||
onOk={() => void createForm.submit()}
|
onOk={() => void createForm.submit()} confirmLoading={createMut.isPending} destroyOnHidden>
|
||||||
confirmLoading={createMut.isPending}
|
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Form form={createForm} layout="vertical"
|
|
||||||
onFinish={(v) => createMut.mutate(v)}>
|
|
||||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||||
<Input autoFocus autoComplete="off" />
|
<Input autoFocus autoComplete="off" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -223,14 +281,9 @@ export default function UsersPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Edit modal */}
|
{/* Edit modal */}
|
||||||
<Modal
|
<Modal title={t('users.editUser')} open={editing !== null}
|
||||||
title={t('users.editUser')}
|
|
||||||
open={editing !== null}
|
|
||||||
onCancel={() => { setEditing(null); editForm.resetFields() }}
|
onCancel={() => { setEditing(null); editForm.resetFields() }}
|
||||||
onOk={() => void editForm.submit()}
|
onOk={() => void editForm.submit()} confirmLoading={updateMut.isPending} destroyOnHidden>
|
||||||
confirmLoading={updateMut.isPending}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Form form={editForm} layout="vertical"
|
<Form form={editForm} layout="vertical"
|
||||||
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
|
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
|
||||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||||
@@ -246,14 +299,10 @@ export default function UsersPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Set password modal */}
|
{/* Set password modal */}
|
||||||
<Modal
|
<Modal title={`${t('users.setPasswordTitle')} — ${pwTarget?.email ?? ''}`}
|
||||||
title={`${t('users.setPasswordTitle')} — ${pwTarget?.email ?? ''}`}
|
|
||||||
open={pwTarget !== null}
|
open={pwTarget !== null}
|
||||||
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
|
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
|
||||||
onOk={() => void pwForm.submit()}
|
onOk={() => void pwForm.submit()} confirmLoading={pwMut.isPending} destroyOnHidden>
|
||||||
confirmLoading={pwMut.isPending}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Form form={pwForm} layout="vertical"
|
<Form form={pwForm} layout="vertical"
|
||||||
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
|
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
|
||||||
<Form.Item label={t('users.newPassword')} name="password"
|
<Form.Item label={t('users.newPassword')} name="password"
|
||||||
@@ -263,6 +312,61 @@ export default function UsersPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* TOTP setup modal (own account only) */}
|
||||||
|
<Modal
|
||||||
|
title={isSelfTOTP ? t('users.totp.setupTitle') : t('users.totp.manageTitle')}
|
||||||
|
open={totpTarget !== null}
|
||||||
|
onCancel={() => setTotpTarget(null)}
|
||||||
|
footer={totpStep === 1
|
||||||
|
? [
|
||||||
|
<Button key="back" onClick={() => setTotpStep(0)}>{t('common.back')}</Button>,
|
||||||
|
<Button key="confirm" type="primary" onClick={() => void confirmTOTP()}>{t('users.totp.confirm')}</Button>,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
<Button key="cancel" onClick={() => setTotpTarget(null)}>{t('common.cancel')}</Button>,
|
||||||
|
totpTarget?.totp_enabled
|
||||||
|
? <Button key="disable" danger onClick={() => { if (totpTarget) { disableTOTPMut.mutate(totpTarget.id); setTotpTarget(null) } }}>{t('users.totp.disable')}</Button>
|
||||||
|
: <Button key="next" type="primary" onClick={() => setTotpStep(1)}>{t('common.next')}</Button>,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{totpStep === 0 && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{totpTarget?.totp_enabled ? (
|
||||||
|
<>
|
||||||
|
<SafetyCertificateOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
|
||||||
|
<Typography.Paragraph>{t('users.totp.alreadyEnabled')}</Typography.Paragraph>
|
||||||
|
<Typography.Paragraph type="secondary">{t('users.totp.disableHint')}</Typography.Paragraph>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography.Paragraph>{t('users.totp.scanHint')}</Typography.Paragraph>
|
||||||
|
{totpUri && <QRCode value={totpUri} size={200} style={{ margin: '0 auto 16px' }} />}
|
||||||
|
<Typography.Paragraph type="secondary" copyable={{ text: totpSecret }} style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||||
|
{totpSecret}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{totpStep === 1 && (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Typography.Paragraph>{t('users.totp.enterCode')}</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onPressEnter={() => void confirmTOTP()}
|
||||||
|
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, width: 200 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2850,6 +2850,17 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.fw-rule-row--disabled td:first-child {
|
.fw-rule-row--disabled td:first-child {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
/* Strikethrough on disabled rule name */
|
||||||
|
.fw-rule-row--disabled .fw-rule-name {
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zero-hit rule: amber left border (potentially unused/shadowed policy) */
|
||||||
|
.fw-rule-row--zero-hit td:first-child {
|
||||||
|
border-left: 3px solid #FAAD14 !important;
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Enabled status dot */
|
/* Enabled status dot */
|
||||||
.fw-rule-dot {
|
.fw-rule-dot {
|
||||||
@@ -2869,6 +2880,31 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action buttons: hidden by default, reveal on row hover */
|
||||||
|
.fw-row-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
.ant-table-row:hover .fw-row-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Natural-language rule description */
|
||||||
|
.fw-rule-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "any" address label */
|
||||||
|
.fw-addr-any {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94A3B8;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Filter bar */
|
/* Filter bar */
|
||||||
.fw-filter-bar {
|
.fw-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir
|
|||||||
PG Streaming Replication + provider Floating-IP for HTTP ingress).
|
PG Streaming Replication + provider Floating-IP for HTTP ingress).
|
||||||
.
|
.
|
||||||
This package ships the management API, scheduler and CLI.
|
This package ships the management API, scheduler and CLI.
|
||||||
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
|
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
|
||||||
Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban
|
Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban
|
||||||
Section: admin
|
Section: admin
|
||||||
Priority: optional
|
Priority: optional
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ case "$1" in
|
|||||||
if getent group haproxy >/dev/null; then
|
if getent group haproxy >/dev/null; then
|
||||||
usermod -a -G haproxy "$EG_USER" || true
|
usermod -a -G haproxy "$EG_USER" || true
|
||||||
fi
|
fi
|
||||||
|
# unbound-Gruppe: unbound-control-Socket ist root:unbound srw-rw----
|
||||||
|
# → edgeguard muss in der Gruppe sein um stats_noreset + flush aufrufen
|
||||||
|
# zu können (kein sudo nötig, Gruppe reicht).
|
||||||
|
if getent group unbound >/dev/null; then
|
||||||
|
usermod -a -G unbound "$EG_USER" || true
|
||||||
|
fi
|
||||||
# systemd-journal + adm: damit edgeguard-api `journalctl -u …`
|
# systemd-journal + adm: damit edgeguard-api `journalctl -u …`
|
||||||
# ohne sudo lesen kann — wird für /api/v1/logs gebraucht
|
# ohne sudo lesen kann — wird für /api/v1/logs gebraucht
|
||||||
# (zentrale Log-Übersicht über alle Services).
|
# (zentrale Log-Übersicht über alle Services).
|
||||||
@@ -90,9 +96,13 @@ edgeguard ALL=(root) NOPASSWD: /usr/sbin/nft list table inet edgeguard
|
|||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop wg-quick@*.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable wg-quick@*.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl start wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl start wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable wg-quick@*.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show *
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show *
|
||||||
# WireGuard symlink: /etc/wireguard/ ist root:root 700; edgeguard-api
|
# WireGuard symlink: /etc/wireguard/ ist root:root 700; edgeguard-api
|
||||||
@@ -139,6 +149,8 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-restore.ser
|
|||||||
# Keepalived reload: VIP-Settings-Änderung triggert keepalived-Reload.
|
# Keepalived reload: VIP-Settings-Änderung triggert keepalived-Reload.
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart keepalived.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart keepalived.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service
|
||||||
|
# VIP-Schwenk-Test: dediziertes Script mit interner Input-Validierung.
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/lib/edgeguard/vip-cmd.sh
|
||||||
SUDOERS
|
SUDOERS
|
||||||
|
|
||||||
# ── Distro-Conf-Includes für die per-Service Renderer ─────────
|
# ── Distro-Conf-Includes für die per-Service Renderer ─────────
|
||||||
@@ -422,6 +434,7 @@ create_iface() {
|
|||||||
local typ="$1" name="$2" parent="$3" vlan_id="$4" mtu="$5" members="$6"
|
local typ="$1" name="$2" parent="$3" vlan_id="$4" mtu="$5" members="$6"
|
||||||
case "$typ" in
|
case "$typ" in
|
||||||
vlan)
|
vlan)
|
||||||
|
ip link set "$parent" up 2>/dev/null || true
|
||||||
ip link add link "$parent" name "$name" type vlan id "$vlan_id" || return 1
|
ip link add link "$parent" name "$name" type vlan id "$vlan_id" || return 1
|
||||||
;;
|
;;
|
||||||
bridge)
|
bridge)
|
||||||
@@ -464,13 +477,31 @@ fi
|
|||||||
while IFS='|' read -r typ name parent vlan_id mtu members; do
|
while IFS='|' read -r typ name parent vlan_id mtu members; do
|
||||||
[ -z "$typ" ] && continue
|
[ -z "$typ" ] && continue
|
||||||
case "$typ" in '#'*) continue;; esac
|
case "$typ" in '#'*) continue;; esac
|
||||||
if ! ip link show "$name" >/dev/null 2>&1; then
|
if ip link show "$name" >/dev/null 2>&1; then
|
||||||
|
# Typ-Änderung: altes Interface löschen und neu erstellen
|
||||||
|
existing_type=$(ip -d link show "$name" 2>/dev/null | awk '/^[[:space:]]/{print $1; exit}')
|
||||||
|
case "$typ" in
|
||||||
|
vlan) expected="vlan" ;;
|
||||||
|
bridge) expected="bridge_slave" ;; # bridge selbst hat keinen eigenen type-String
|
||||||
|
bond) expected="bond_slave" ;;
|
||||||
|
*) expected="" ;;
|
||||||
|
esac
|
||||||
|
current_type=$(ip -d link show "$name" 2>/dev/null | grep -oE 'vlan |bridge |bond ' | head -1 | tr -d ' ')
|
||||||
|
if [ -n "$current_type" ] && [ "$current_type" != "$typ" ]; then
|
||||||
|
ip link set "$name" down 2>/dev/null || true
|
||||||
|
ip link del "$name" 2>/dev/null || true
|
||||||
|
if ! create_iface "$typ" "$name" "$parent" "$vlan_id" "$mtu" "$members"; then
|
||||||
|
echo "edgeguard-interfaces: failed to recreate $typ $name" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[ -n "$mtu" ] && ip link set "$name" mtu "$mtu" 2>/dev/null || true
|
||||||
|
[ "$typ" = "vlan" ] && [ -n "$parent" ] && ip link set "$parent" up 2>/dev/null || true
|
||||||
|
ip link set "$name" up 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
if ! create_iface "$typ" "$name" "$parent" "$vlan_id" "$mtu" "$members"; then
|
if ! create_iface "$typ" "$name" "$parent" "$vlan_id" "$mtu" "$members"; then
|
||||||
echo "edgeguard-interfaces: failed to create $typ $name" >&2
|
echo "edgeguard-interfaces: failed to create $typ $name" >&2
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
[ -n "$mtu" ] && ip link set "$name" mtu "$mtu" 2>/dev/null || true
|
|
||||||
ip link set "$name" up 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
done < "$CONF"
|
done < "$CONF"
|
||||||
|
|
||||||
@@ -574,12 +605,40 @@ IPADDRUNIT
|
|||||||
# werden von Keepalived als notify_master / notify_backup / check
|
# werden von Keepalived als notify_master / notify_backup / check
|
||||||
# aufgerufen. Kein Auto-Promote — keepalived-master.sh loggt nur.
|
# aufgerufen. Kein Auto-Promote — keepalived-master.sh loggt nur.
|
||||||
install -d -m 0755 /usr/lib/edgeguard
|
install -d -m 0755 /usr/lib/edgeguard
|
||||||
for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh; do
|
for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh keepalived-gw-check.sh; do
|
||||||
if [ -f "/usr/lib/edgeguard/${script}" ]; then
|
if [ -f "/usr/lib/edgeguard/${script}" ]; then
|
||||||
chmod 0755 "/usr/lib/edgeguard/${script}"
|
chmod 0755 "/usr/lib/edgeguard/${script}"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── VIP-Schwenk-Script ───────────────────────────────────────
|
||||||
|
# Führt `ip addr add/del` für VIP-Failover-Tests aus. Läuft via
|
||||||
|
# sudo (Whitelist in sudoers). Interne Input-Validierung verhindert
|
||||||
|
# Command-Injection trotz breiter sudoers-Regel.
|
||||||
|
cat > /usr/lib/edgeguard/vip-cmd.sh <<'VIPCMD'
|
||||||
|
#!/bin/bash
|
||||||
|
# vip-cmd.sh {add|del} {address/prefix} {device}
|
||||||
|
# Wird von edgeguard-api via sudo für VIP-Schwenk-Tests aufgerufen.
|
||||||
|
# Nach `ip addr add` werden squid/unbound/haproxy reloaded damit sie
|
||||||
|
# die neu aktive VIP sofort binden.
|
||||||
|
set -e
|
||||||
|
action="$1" addrpfx="$2" dev="$3"
|
||||||
|
case "$action" in
|
||||||
|
add|del) ;;
|
||||||
|
*) echo "vip-cmd: ungültige action '$action'" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
[[ -z "$addrpfx" || -z "$dev" ]] && { echo "vip-cmd: fehlende Parameter" >&2; exit 1; }
|
||||||
|
[[ "$addrpfx" =~ ^[0-9a-fA-F.:\/]+$ ]] || { echo "vip-cmd: ungültige Adresse '$addrpfx'" >&2; exit 1; }
|
||||||
|
[[ "$dev" =~ ^[a-zA-Z0-9._-]+$ ]] || { echo "vip-cmd: ungültiges Device '$dev'" >&2; exit 1; }
|
||||||
|
ip addr "$action" "$addrpfx" dev "$dev"
|
||||||
|
if [ "$action" = "add" ]; then
|
||||||
|
for svc in squid.service unbound.service haproxy.service; do
|
||||||
|
systemctl is-active --quiet "$svc" && systemctl reload "$svc" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
VIPCMD
|
||||||
|
chmod 0755 /usr/lib/edgeguard/vip-cmd.sh
|
||||||
|
|
||||||
# ── Self-signed default cert so HAProxy starts cleanly ───────
|
# ── Self-signed default cert so HAProxy starts cleanly ───────
|
||||||
# HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least
|
# HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least
|
||||||
# one PEM in the directory to come up. Operator runs certbot
|
# one PEM in the directory to come up. Operator runs certbot
|
||||||
@@ -625,6 +684,25 @@ IPADDRUNIT
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ALTER PUBLICATION erfordert den PG-Superuser (edgeguard ist nicht
|
||||||
|
# Owner der Publication). Idempotent — No-Op auf Secondary-Nodes
|
||||||
|
# (wo die Publication nicht existiert) und wenn die Tabellen schon
|
||||||
|
# drin sind.
|
||||||
|
sudo -u postgres psql edgeguard <<'EOSQL' 2>/dev/null || true
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared') THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'edgeguard_shared' AND tablename = 'network_interfaces'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION edgeguard_shared ADD TABLE network_interfaces, ip_addresses;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
EOSQL
|
||||||
|
|
||||||
# ── Render initial service configs ───────────────────────────
|
# ── Render initial service configs ───────────────────────────
|
||||||
# Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/
|
# Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/
|
||||||
# ruleset.nft from the (just-migrated, empty) PG state.
|
# ruleset.nft from the (just-migrated, empty) PG state.
|
||||||
@@ -641,6 +719,19 @@ IPADDRUNIT
|
|||||||
echo "postinst: edgeguard-ctl render-config (nftables) failed — aborting" >&2
|
echo "postinst: edgeguard-ctl render-config (nftables) failed — aborting" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# Keepalived-Config rendern (optional — nur wenn VIPs konfiguriert)
|
||||||
|
# /etc/keepalived/ wird vom keepalived-Paket als root angelegt;
|
||||||
|
# edgeguard braucht Schreibrecht damit der Config-Generator den
|
||||||
|
# atomic-write (tempfile → rename) durchführen kann.
|
||||||
|
install -d -m 0755 /etc/keepalived
|
||||||
|
chown "$EG_USER":"$EG_USER" /etc/keepalived
|
||||||
|
sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=keepalived || true
|
||||||
|
if [ -f /etc/keepalived/keepalived.conf ]; then
|
||||||
|
systemctl enable keepalived >/dev/null 2>&1 || true
|
||||||
|
systemctl is-active --quiet keepalived \
|
||||||
|
&& systemctl reload keepalived \
|
||||||
|
|| systemctl start keepalived || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ── HAProxy systemd drop-in: read EdgeGuard config ───────────
|
# ── HAProxy systemd drop-in: read EdgeGuard config ───────────
|
||||||
# Keeps the distro /etc/haproxy/haproxy.cfg untouched (it's a
|
# Keeps the distro /etc/haproxy/haproxy.cfg untouched (it's a
|
||||||
@@ -665,6 +756,15 @@ IPADDRUNIT
|
|||||||
# restart. Bei Erst-Install ist nichts running, dann ist das
|
# restart. Bei Erst-Install ist nichts running, dann ist das
|
||||||
# ein normaler Start.
|
# ein normaler Start.
|
||||||
systemctl restart edgeguard-api.service edgeguard-scheduler.service || true
|
systemctl restart edgeguard-api.service edgeguard-scheduler.service || true
|
||||||
|
|
||||||
|
# WireGuard-Sicherung: enabled-aber-inaktive wg-quick@-Interfaces
|
||||||
|
# nach dem API-Restart starten. Der API-Renderer kann wg-quick@
|
||||||
|
# kurz flippen; falls er dabei fehlschlägt, stellt dieser Block
|
||||||
|
# das Interface wieder her ohne manuellen Eingriff.
|
||||||
|
while IFS= read -r wg_unit; do
|
||||||
|
[ -n "$wg_unit" ] || continue
|
||||||
|
systemctl is-active --quiet "$wg_unit" || systemctl start "$wg_unit" 2>/dev/null || true
|
||||||
|
done < <(systemctl list-unit-files 'wg-quick@*.service' --state=enabled --no-legend 2>/dev/null | awk '{print $1}')
|
||||||
;;
|
;;
|
||||||
|
|
||||||
abort-upgrade|abort-remove|abort-deconfigure)
|
abort-upgrade|abort-remove|abort-deconfigure)
|
||||||
|
|||||||
8
packaging/scripts/keepalived-gw-check.sh
Normal file
8
packaging/scripts/keepalived-gw-check.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# keepalived-gw-check.sh <gateway-ip>
|
||||||
|
# Wird von vrrp_script chk_gateway aufgerufen.
|
||||||
|
# weight -110: 200 (MASTER) - 110 = 90 < 100 (BACKUP-Prio) → Failover ausgelöst.
|
||||||
|
# Exit 0 = GW erreichbar (OK), Exit 1 = GW nicht erreichbar (Gewicht abziehen).
|
||||||
|
GW="${1:-}"
|
||||||
|
if [ -z "$GW" ]; then exit 0; fi
|
||||||
|
ping -c 1 -W 2 "$GW" > /dev/null 2>&1
|
||||||
@@ -3,11 +3,22 @@
|
|||||||
#
|
#
|
||||||
# KEIN Auto-Promote — Split-Brain-Schutz durch manuelle Promotion.
|
# KEIN Auto-Promote — Split-Brain-Schutz durch manuelle Promotion.
|
||||||
# Admin muss "edgeguard-ctl promote" ausführen wenn PG-Failover gewünscht.
|
# Admin muss "edgeguard-ctl promote" ausführen wenn PG-Failover gewünscht.
|
||||||
#
|
|
||||||
# Was wir tun: Alert loggen + edgeguard-api benachrichtigen.
|
|
||||||
logger -t keepalived -p daemon.warning \
|
logger -t keepalived -p daemon.warning \
|
||||||
"MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote"
|
"MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote"
|
||||||
|
|
||||||
|
# Dienste reloaden/starten damit sie die neu aktiven VIPs binden.
|
||||||
|
# Squid + Unbound + HAProxy binden beim Start an spezifische IPs — war der Dienst
|
||||||
|
# während des BACKUP-Zustands gecrasht oder gestoppt, muss er gestartet werden.
|
||||||
|
for svc in squid.service unbound.service haproxy.service; do
|
||||||
|
if systemctl is-active --quiet "$svc"; then
|
||||||
|
systemctl reload "$svc" 2>/dev/null || systemctl restart "$svc" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
systemctl start "$svc" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
logger -t keepalived -p daemon.info "MASTER: squid/unbound/haproxy reload-or-start nach VIP-Übernahme"
|
||||||
|
|
||||||
# Alert an die API schicken (best-effort, ignoriert Fehler)
|
# Alert an die API schicken (best-effort, ignoriert Fehler)
|
||||||
curl -sf --max-time 3 -X POST \
|
curl -sf --max-time 3 -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ build_api() {
|
|||||||
# Keepalived notify-scripts → /usr/lib/edgeguard/
|
# Keepalived notify-scripts → /usr/lib/edgeguard/
|
||||||
# postinst setzt chmod 0755 nach der Installation.
|
# postinst setzt chmod 0755 nach der Installation.
|
||||||
mkdir -p "$build_dir/usr/lib/edgeguard"
|
mkdir -p "$build_dir/usr/lib/edgeguard"
|
||||||
for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh; do
|
for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh keepalived-gw-check.sh; do
|
||||||
install -m 0755 "$REPO_ROOT/packaging/scripts/$script" \
|
install -m 0755 "$REPO_ROOT/packaging/scripts/$script" \
|
||||||
"$build_dir/usr/lib/edgeguard/$script"
|
"$build_dir/usr/lib/edgeguard/$script"
|
||||||
done
|
done
|
||||||
|
|||||||
Reference in New Issue
Block a user