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:
Debian
2026-05-31 18:18:31 +02:00
parent 49899e984c
commit 1d06b28064
55 changed files with 3132 additions and 672 deletions

View File

@@ -229,6 +229,43 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string)
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
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert

View File

@@ -41,6 +41,7 @@ type hashTable struct {
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…)
// → 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
@@ -70,9 +71,33 @@ var hashSpec = []hashTable{
{Name: "ntp_pools", MigrationDefault: true},
// network_interfaces, ip_addresses, static_routes, dns_settings, ntp_settings
// sind node-spezifisch (jeder Node hat eigene IPs/Routes/Listen-Adressen)
// und fließen NICHT in den Drift-Hash ein.
// network_interfaces + ip_addresses werden seit 0030 repliziert —
// VLAN/Bridge/Bond-Definitionen und Gateway-IPs müssen auf dem Secondary
// 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.
@@ -112,7 +137,11 @@ func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error)
hasUserConfig := false
for _, t := range hashSpec {
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.
s = ""
}

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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;

View 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;

View File

@@ -363,11 +363,24 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
// Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound
// (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter).
var aclCount int
if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 {
out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"})
// Squid Forward-Proxy: lese Port + Listen-Adressen aus
// forward_proxy_settings. Für jede nicht-loopback IP eine
// Auto-Rule; leere Liste = alle Interfaces (generische Regel).
var squidAddrs string
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.

View File

@@ -60,15 +60,22 @@ func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
return h
}
const totpPendingCookie = "edgeguard_totp_pending"
// Register mounts /auth/login + /logout (public) and /auth/me
// (gated by requireAuth, passed in as a per-route middleware).
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
g := rg.Group("/auth")
g.POST("/login", h.Login)
g.POST("/logout", h.Logout)
g.POST("/totp-verify", h.TOTPVerify)
g.GET("/me", requireAuth, h.Me)
g.POST("/reset-password", h.ResetPassword)
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 {
@@ -77,9 +84,10 @@ type loginRequest struct {
}
type loginResponse struct {
Actor string `json:"actor"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
Actor string `json:"actor"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
TOTPRequired bool `json:"totp_required,omitempty"`
}
func (h *AuthHandler) Login(c *gin.Context) {
@@ -101,12 +109,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
email := strings.TrimSpace(req.Email)
actor, role := "", "admin"
remote := c.ClientIP()
var totpEnabled bool
// 1. Try DB users table first.
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 !u.Active {
if !ai.Active {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
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"))
return
}
if !usersvc.VerifyPassword(hash, req.Password) {
if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) {
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
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"))
return
}
actor = u.Email
role = u.Role
h.Users.RecordLogin(c.Request.Context(), u.ID)
actor = ai.Email
role = ai.Role
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) {
actor = st.AdminEmail
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 {
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
}
}
}
// 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.
// 3. Auth federation: cluster nodes forward failed auth to the primary.
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 {
actor = a
@@ -161,6 +168,21 @@ func (h *AuthHandler) Login(c *gin.Context) {
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)
if err != nil {
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) {
clearSessionCookie(c)
response.OK(c, gin.H{"logged_out": true})

View File

@@ -75,6 +75,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
g.PUT("/vip-settings", h.UpdateVIPSettings)
g.POST("/rolling-update", h.RollingUpdate)
g.GET("/rolling-update/status", h.RollingUpdateStatus)
g.GET("/vip-status", h.VIPStatus)
g.POST("/vip-test", h.VIPTest)
if h.TLSStore != nil {
g.GET("/cert-status", h.CertStatus)
g.POST("/renew-self", h.RenewSelf)
@@ -130,9 +132,12 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
return
}
var cs vipSettingsRow
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`)
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil {
row := h.Store.Pool.QueryRow(c.Request.Context(), `
SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
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)
return
}
@@ -154,10 +159,14 @@ func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
}
_, err := h.Store.Pool.Exec(c.Request.Context(), `
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`,
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 {
response.Internal(c, err)
return
@@ -181,6 +190,11 @@ type vipSettingsRow struct {
VIPInterface *string `json:"vip_interface"`
VIPAuthPass *string `json:"vip_auth_pass"`
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 {
@@ -218,6 +232,9 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
g.GET("/master-key", h.AgentMasterKey)
g.GET("/version", h.AgentVersion)
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

View 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
}

View 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
}

View File

@@ -306,7 +306,7 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
// cached RRs from the resolver. Useful after DNS propagation or when
// stale records need to be evicted immediately.
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 {
slog.Error("dns flush-cache failed", "err", err, "out", string(out))
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
// wiederholte Aufrufe aus dem UI.
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 {
response.OK(c, gin.H{
"error": "unbound-control nicht verfügbar: " + err.Error(),

View File

@@ -39,6 +39,8 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
base := rg.Group("/forward-proxy")
base.GET("/stats", h.Stats)
base.GET("/settings", h.GetSettings)
base.PUT("/settings", h.UpdateSettings)
g := base.Group("/acls")
g.GET("", h.List)
@@ -48,6 +50,34 @@ func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
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) {
out, err := h.Repo.List(c.Request.Context())
if err != nil {

View File

@@ -122,6 +122,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g.GET("/ipv6", h.IPv6)
g.POST("/ipv6", h.SetIPv6)
g.GET("/config-preview", h.ConfigPreview)
g.GET("/vip-status", h.VIPStatus)
}
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
@@ -188,6 +189,7 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"edgeguard-scheduler", "edgeguard-scheduler"},
{"haproxy", "haproxy"},
{"nftables", "nftables"},
{"keepalived", "keepalived"},
{"unbound", "unbound"},
{"chrony", "chrony"},
{"squid", "squid"},
@@ -1077,6 +1079,87 @@ func classifyLinkType(ifc net.Interface) string {
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 {
var out []string
if f&net.FlagUp != 0 {

View File

@@ -35,6 +35,7 @@ func (h *UsersHandler) Register(rg *gin.RouterGroup) {
g.PUT("/:id", h.Update)
g.POST("/:id/password", h.SetPassword)
g.DELETE("/:id", h.Delete)
g.DELETE("/:id/totp", h.DisableTOTP)
}
func (h *UsersHandler) List(c *gin.Context) {
@@ -164,3 +165,22 @@ func (h *UsersHandler) Delete(c *gin.Context) {
c.Param("id"), nil, h.NodeID)
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})
}

View File

@@ -2,8 +2,6 @@ global_defs {
router_id {{ .RouterID }}
script_user root
enable_script_security
vrrp_garp_interval 0
vrrp_gna_interval 0
}
vrrp_script chk_edgeguard {
@@ -13,7 +11,23 @@ vrrp_script chk_edgeguard {
fall 3
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 {
state {{ .State }}
interface {{ .Interface }}
@@ -29,12 +43,30 @@ vrrp_instance VI_1 {
auth_pass {{ .AuthPass }}
}
virtual_ipaddress {
{{ .VIP }}
}
{{ range .VIPs }} {{ .Address }}/{{ .Prefix }} dev {{ .Device }}
{{ end }} }
track_script {
chk_edgeguard
}
{{ if .GWCheckIP }} chk_gateway
{{ end }} }
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
notify_backup "/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 }}

View File

@@ -14,6 +14,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
@@ -29,16 +30,30 @@ var cfgTpl string
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.
type View struct {
State string // MASTER | BACKUP
Interface string
RouterID int
Priority int // MASTER=200, BACKUP=100
SrcIP string // eigene Public-IP (für unicast_src_ip)
PeerIP string // Peer-Public-IP (für unicast_peer)
AuthPass string
VIP string
State string // MASTER | BACKUP
Interface string // Interface für VRRP-Advertisements (VI_1)
RouterID int
Priority int // MASTER=200, BACKUP=100
SrcIP string // eigene Public-IP (unicast_src_ip)
PeerIP string // Peer-Public-IP (unicast_peer)
AuthPass 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 {
@@ -53,15 +68,15 @@ func New(pool *pgxpool.Pool, localID string) configgen.Generator {
func (g *generator) Name() string { return "keepalived" }
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 {
return fmt.Errorf("keepalived: load: %w", err)
}
if cs.VIPAddress == nil || *cs.VIPAddress == "" {
// Kein VIP konfiguriert → keepalived.conf nicht schreiben.
if len(vips) == 0 {
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
return nil
}
v := g.buildView(cs, local, peer)
v := g.buildView(cs, vips, local, peer)
var buf bytes.Buffer
if err := tpl.Execute(&buf, v); err != nil {
return fmt.Errorf("keepalived: template: %w", err)
@@ -75,23 +90,47 @@ func (g *generator) Render(ctx context.Context) error {
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
row := g.pool.QueryRow(ctx, `SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`)
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil {
return nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
row := g.pool.QueryRow(ctx, `
SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
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 {
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
for rows.Next() {
for nodeRows.Next() {
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
}
if n.ID == g.localID {
@@ -101,17 +140,22 @@ func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *mod
}
}
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{
RouterID: cs.VRRPRouterID,
VIP: deref(cs.VIPAddress),
Interface: deref(cs.VIPInterface),
AuthPass: deref(cs.VIPAuthPass),
RouterID: cs.VRRPRouterID,
VIPs: vips,
Interface: deref(cs.VIPInterface),
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 == "" {
v.Interface = "eth0"
@@ -119,9 +163,17 @@ func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HA
if v.AuthPass == "" {
v.AuthPass = "edgeguard"
}
if v.HBRouterID == 0 {
v.HBRouterID = 52
}
// Primary-Node bekommt höhere Priorität und startet als MASTER.
if local.PGRole == "primary" || local.Role == "primary" {
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
// 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.Priority = 200
} else {
@@ -143,7 +195,11 @@ func reloadKeepalived() error {
// keepalived läuft noch nicht — erster Render beim Start.
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 {

View File

@@ -4,12 +4,19 @@ import "time"
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
// 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 {
ID int `gorm:"column:id;primaryKey" json:"id"`
VIPAddress *string `gorm:"column:vip_address" json:"vip_address,omitempty"`
VIPInterface *string `gorm:"column:vip_interface" json:"vip_interface,omitempty"`
VIPAuthPass *string `gorm:"column:vip_auth_pass" json:"vip_auth_pass,omitempty"`
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"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}

View File

@@ -40,16 +40,20 @@ func (DNSRecord) TableName() string { return "dns_records" }
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
// für die typische LAN-Resolver-Rolle).
type DNSSettings struct {
ID int64 `gorm:"primaryKey" json:"id"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
ID int64 `gorm:"primaryKey" json:"id"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
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" }

View 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" }

View File

@@ -204,12 +204,16 @@ func (r *Repo) DeleteRecord(ctx context.Context, id int64) error {
func (r *Repo) GetSettings(ctx context.Context) (*models.DNSSettings, error) {
row := r.Pool.QueryRow(ctx, `
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`)
var s models.DNSSettings
if err := row.Scan(&s.ID, &s.ListenAddresses, &s.ListenPort, &s.UpstreamForwards,
&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 &s, nil
@@ -220,16 +224,22 @@ func (r *Repo) UpdateSettings(ctx context.Context, s models.DNSSettings) (*model
UPDATE dns_settings SET
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,
prefetch=$9, serve_expired=$10, msg_cache_size_mb=$11, rrset_cache_size_mb=$12,
updated_at=NOW()
WHERE id=1
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.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
if err := row.Scan(&out.ID, &out.ListenAddresses, &out.ListenPort, &out.UpstreamForwards,
&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 &out, nil

View File

@@ -1,6 +1,6 @@
// Package forwardproxy provides CRUD against the forward_proxy_acls
// table. Renderer in internal/squid consumes the same rows to emit
// /etc/edgeguard/squid/squid.conf.
// table and settings in forward_proxy_settings. Renderer in internal/squid
// consumes both tables to emit /etc/edgeguard/squid/squid.conf.
package forwardproxy
import (
@@ -97,6 +97,52 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
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) {
var a models.ForwardProxyACL
if err := row.Scan(

View File

@@ -22,19 +22,37 @@ func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} }
// Render schreibt /etc/edgeguard/ip-addresses.conf (Format: dev|addr/prefix)
// und triggert das apply-Skript via sudo.
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 {
dev string
addr string
prefix int
}
rows, err := g.Repo.Pool.Query(ctx, `
q := `
SELECT ni.name, ia.address, ia.prefix
FROM ip_addresses ia
JOIN network_interfaces ni ON ni.id = ia.interface_id
WHERE ia.active = true
ORDER BY ni.name, ia.address`,
)
WHERE ia.active = true`
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 {
return fmt.Errorf("query: %w", err)
}

View File

@@ -122,6 +122,15 @@ func (s *Signer) Issue(actor string) (string, *Token, error) {
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.
func (s *Signer) Verify(raw string) (*Token, error) {
if raw == "" {

View File

@@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
@@ -27,22 +28,30 @@ type User struct {
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
TOTPEnabled bool `json:"totp_enabled"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_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 {
pool *pgxpool.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) {
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)
return u, err
}
@@ -71,7 +80,7 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err
var hash string
err := r.pool.QueryRow(ctx,
`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)
if errors.Is(err, pgx.ErrNoRows) {
return u, "", ErrNotFound
@@ -79,6 +88,70 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, 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) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)

View File

@@ -2,12 +2,14 @@
# Source: internal/squid/squid.go (template: squid.cfg.tpl).
# 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
# isn't a CDN — we keep cache modest to avoid disk pressure.
cache_dir ufs /var/spool/squid 100 16 256
cache_mem 64 MB
cache_dir ufs /var/spool/squid {{.CacheDirMB}} 16 256
cache_mem {{.CacheMemMB}} MB
maximum_object_size {{.MaxObjSizeMB}} MB
# Logging — combined access log, rotated by logrotate.
access_log /var/log/squid/access.log squid
@@ -56,7 +58,9 @@ http_access allow localhost
http_access allow localnet
http_access deny all
# Hostnames + visible name — operator can override via squid.conf
# drop-in if needed.
connect_timeout {{.ConnectTimeout}} seconds
read_timeout {{.ReadTimeout}} seconds
request_timeout {{.RequestTimeout}} seconds
visible_hostname edgeguard-proxy
forwarded_for on

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
@@ -21,8 +22,8 @@ import (
)
const (
confPath = "/etc/edgeguard/squid/squid.conf"
listenPort = 3128
confPath = "/etc/edgeguard/squid/squid.conf"
defaultListenPort = 3128
)
//go:embed squid.cfg.tpl
@@ -30,9 +31,20 @@ var cfgTpl string
var tpl = template.Must(template.New("squid").Parse(cfgTpl))
type ListenAddr struct {
Addr string // empty = all interfaces
Port int
}
type View struct {
ListenPort int
ACLs []models.ForwardProxyACL
ListenAddrs []ListenAddr
ACLs []models.ForwardProxyACL
CacheMemMB int
CacheDirMB int
MaxObjSizeMB int
ConnectTimeout int
ReadTimeout int
RequestTimeout int
}
type Generator struct {
@@ -52,7 +64,45 @@ func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) {
if err != nil {
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
if err := tpl.Execute(&body, view); err != nil {
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
}
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) {
buf, err := g.renderBuf(ctx)
if err != nil {

View File

@@ -31,8 +31,10 @@ server:
do-tcp: yes
cache-min-ttl: {{.Settings.CacheMinTTL}}
cache-max-ttl: {{.Settings.CacheMaxTTL}}
msg-cache-size: 64m
rrset-cache-size: 128m
msg-cache-size: {{.Settings.MsgCacheSizeMB}}m
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
# Hardening

View File

@@ -34,6 +34,21 @@ func stopWGQuick(iface string) error {
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
// /etc/wireguard/<iface>.conf → target via sudo. /etc/wireguard/ is
// owned root:root 700 so the edgeguard user cannot write to it directly;

View File

@@ -152,6 +152,7 @@ func (g *Generator) Render(ctx context.Context) error {
}
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
}
}
return nil
@@ -235,6 +236,7 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
return fmt.Errorf("symlink: %w", err)
}
_ = enableWGQuick(ifc.Name)
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
return startWGQuick(ifc.Name)
}