- PG Logical Replication: edgeguard_shared PUBLICATION auf Primary, edgeguard_sub SUBSCRIPTION auf Secondary. Nur geteilte Config-Tabellen werden repliziert; node-eigene Daten (network_interfaces, ip_addresses, static_routes, cluster_settings, dns_settings, ntp_settings) bleiben lokal — OPNsense-Muster. - cluster-init-replication: Erstellt PUBLICATION, Rolle + pg_hba-Einträge (logical + replication), WAL-Level auf logical. - cluster-setup-standby: Erstellt SUBSCRIPTION (copy_data=true), pollt pg_subscription_rel bis alle Tabellen sync = 'r', rendert dann Configs. - promote: manueller Failover via pg_promote() + touch recovery.signal. - VIP/Keepalived: cluster_settings-Tabelle (vip_address, vip_interface, vrrp_router_id), /cluster/vip-settings API, Keepalived-Config-Generator mit VRRP + check_script + notify-Skripten in /usr/lib/edgeguard/scripts/. - config_hash sync: Secondary pusht alle 5 Min seinen Hash via mTLS an Primary (PushSelfToPrimary). Heartbeat schreibt nur LOCAL, daher ohne aktiven Push wäre Primary-Sicht des Secondary-Hash stale gewesen. - runSecondaryConfigRender: Goroutine auf Secondary rendert HAProxy+nftables neu wenn config_hash sich ändert (Logical-Replication-Nachzügler). - confighash: node-spezifische Tabellen aus hashSpec entfernt. - postinst: Keepalived-Skripte installieren, sudoers für keepalived. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
4.1 KiB
Go
155 lines
4.1 KiB
Go
// Package keepalived rendert /etc/keepalived/keepalived.conf aus
|
|
// cluster_settings (VIP/VRRP-Config) und ha_nodes (local vs. peer).
|
|
//
|
|
// Split-Brain-Strategie: kein Auto-Promote. notify_master loggt nur
|
|
// und sendet einen internen Alert. Promotion ist immer manuell via
|
|
// "edgeguard-ctl promote" — das ist die einzig sichere Option ohne
|
|
// externes Quorum in einem 2-Node-Cluster.
|
|
package keepalived
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"text/template"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
const ConfPath = "/etc/keepalived/keepalived.conf"
|
|
|
|
//go:embed keepalived.conf.tpl
|
|
var cfgTpl string
|
|
|
|
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
|
|
|
|
// 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
|
|
}
|
|
|
|
type generator struct {
|
|
pool *pgxpool.Pool
|
|
localID string
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool, localID string) configgen.Generator {
|
|
return &generator{pool: pool, localID: localID}
|
|
}
|
|
|
|
func (g *generator) Name() string { return "keepalived" }
|
|
|
|
func (g *generator) Render(ctx context.Context) error {
|
|
cs, 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.
|
|
return nil
|
|
}
|
|
v := g.buildView(cs, local, peer)
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, v); err != nil {
|
|
return fmt.Errorf("keepalived: template: %w", err)
|
|
}
|
|
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o640); err != nil {
|
|
return fmt.Errorf("keepalived: write: %w", err)
|
|
}
|
|
if err := reloadKeepalived(); err != nil {
|
|
return fmt.Errorf("keepalived: reload: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *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)
|
|
}
|
|
|
|
rows, 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, fmt.Errorf("ha_nodes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var local, peer *models.HANode
|
|
for rows.Next() {
|
|
n := &models.HANode{}
|
|
if err := rows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
|
|
continue
|
|
}
|
|
if n.ID == g.localID {
|
|
local = n
|
|
} else {
|
|
peer = n
|
|
}
|
|
}
|
|
if local == nil {
|
|
return nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
|
|
}
|
|
return &cs, local, peer, nil
|
|
}
|
|
|
|
func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HANode) View {
|
|
v := View{
|
|
RouterID: cs.VRRPRouterID,
|
|
VIP: deref(cs.VIPAddress),
|
|
Interface: deref(cs.VIPInterface),
|
|
AuthPass: deref(cs.VIPAuthPass),
|
|
}
|
|
if v.Interface == "" {
|
|
v.Interface = "eth0"
|
|
}
|
|
if v.AuthPass == "" {
|
|
v.AuthPass = "edgeguard"
|
|
}
|
|
|
|
// Primary-Node bekommt höhere Priorität und startet als MASTER.
|
|
if local.PGRole == "primary" || local.Role == "primary" {
|
|
v.State = "MASTER"
|
|
v.Priority = 200
|
|
} else {
|
|
v.State = "BACKUP"
|
|
v.Priority = 100
|
|
}
|
|
|
|
if local.PublicIP != nil {
|
|
v.SrcIP = *local.PublicIP
|
|
}
|
|
if peer != nil && peer.PublicIP != nil {
|
|
v.PeerIP = *peer.PublicIP
|
|
}
|
|
return v
|
|
}
|
|
|
|
func reloadKeepalived() error {
|
|
if _, err := os.Stat("/run/keepalived.pid"); os.IsNotExist(err) {
|
|
// keepalived läuft noch nicht — erster Render beim Start.
|
|
return nil
|
|
}
|
|
return exec.Command("systemctl", "reload-or-restart", "keepalived").Run()
|
|
}
|
|
|
|
func deref(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|