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

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