Files
edgeguard-native/internal/cluster/local_config.go
Debian 02096c8ad8 chore(lint): golangci-lint --fix — misspell + staticcheck-Autofixes (Backlog 142→~98)
Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4
staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch).
build+test grün. Kein Runtime-Change → kein Deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:25:22 +02:00

204 lines
5.2 KiB
Go

package cluster
// /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen Cluster-
// Peers replizierte Configuration. Hält die Identitäts-Werte die jeden
// Node einzigartig machen:
//
// NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier
// gespiegelt für Operator-Sichtbarkeit)
// HOSTNAME `hostname -f`
// MGMT_IP Management-IP (Interface auf dem die API exposed wird;
// NIE VIP — wenn diese Box ein VIP übernimmt, bleibt die
// MGMT_IP unverändert auf der eigenen Static-IP)
// ROLE primary | secondary
// PEER_HOSTS comma-separated FQDNs der anderen Cluster-Peers
// (leer im Single-Node-Mode)
//
// Format ist INI-style ohne sections — eine `KEY=VALUE`-Zeile pro
// Eintrag. Kommentare mit `#`. Whitespace um `=` wird getrimmt.
//
// Postinst legt eine leere/auto-befüllte Datei an. Backup-System
// INCLUDIERT diese Datei (sie ist Teil des Node-State); im Cluster-
// Sync-Path bleibt sie aber explizit DRAUSSEN.
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
)
const DefaultLocalConfigPath = "/etc/edgeguard/node.conf"
type LocalConfig struct {
NodeID string
Hostname string
MgmtIP string // Management-IP des Nodes (IPv4/v6, ohne CIDR)
Role string // "primary" | "secondary"
PeerHosts []string // andere Cluster-Peers (FQDNs)
}
// LoadLocalConfig liest die Datei. Wenn sie nicht existiert: returns
// nil, nil — kein Fehler (single-node default).
func LoadLocalConfig(path string) (*LocalConfig, error) {
if path == "" {
path = DefaultLocalConfigPath
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer f.Close()
c := &LocalConfig{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
val := strings.TrimSpace(line[eq+1:])
val = strings.Trim(val, `"`)
switch strings.ToUpper(key) {
case "NODE_ID":
c.NodeID = val
case "HOSTNAME":
c.Hostname = val
case "MGMT_IP":
c.MgmtIP = val
case "ROLE":
c.Role = strings.ToLower(val)
case "PEER_HOSTS":
for _, h := range strings.Split(val, ",") {
h = strings.TrimSpace(h)
if h != "" {
c.PeerHosts = append(c.PeerHosts, h)
}
}
}
}
return c, sc.Err()
}
// SaveLocalConfig schreibt die Datei atomic + 0644 root:root.
// Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilege.
func SaveLocalConfig(path string, c *LocalConfig) error {
if path == "" {
path = DefaultLocalConfigPath
}
if c == nil {
return errors.New("nil config")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
var b strings.Builder
b.WriteString("# Managed by edgeguard — node-local cluster identity.\n")
b.WriteString("# NIEMALS zwischen Cluster-Peers replizieren!\n")
b.WriteString("# Backup-System sichert diese Datei (Teil des Node-State).\n\n")
fmt.Fprintf(&b, "NODE_ID=%s\n", c.NodeID)
fmt.Fprintf(&b, "HOSTNAME=%s\n", c.Hostname)
fmt.Fprintf(&b, "MGMT_IP=%s\n", c.MgmtIP)
if c.Role == "" {
c.Role = "primary"
}
fmt.Fprintf(&b, "ROLE=%s\n", c.Role)
fmt.Fprintf(&b, "PEER_HOSTS=%s\n", strings.Join(c.PeerHosts, ","))
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// EnsureLocalConfig liest die Datei; legt sie an wenn nicht vorhanden
// (autogeneriert NodeID via EnsureNodeID, Hostname via os.Hostname,
// MgmtIP via firstNonLoopbackIPv4). Schreibt nur dann zurück wenn
// vorher nichts da war ODER NodeID/Hostname noch leer waren.
func EnsureLocalConfig(path string) (*LocalConfig, error) {
if path == "" {
path = DefaultLocalConfigPath
}
c, err := LoadLocalConfig(path)
if err != nil {
return nil, err
}
if c == nil {
c = &LocalConfig{}
}
dirty := false
if c.NodeID == "" {
id, _ := EnsureNodeID("")
c.NodeID = id
dirty = true
}
if c.Hostname == "" {
h, _ := os.Hostname()
c.Hostname = h
dirty = true
}
if c.MgmtIP == "" {
c.MgmtIP = firstNonLoopbackIPv4()
dirty = true
}
if c.Role == "" {
c.Role = "primary"
dirty = true
}
if !dirty {
return c, nil
}
if err := SaveLocalConfig(path, c); err != nil {
// File-not-writable (z.B. dev box als nicht-root): nicht fatal.
// Caller bekommt trotzdem den in-memory config.
return c, nil
}
return c, nil
}
// firstNonLoopbackIPv4 sucht eine plausible MGMT_IP für den
// Default-Case. Operator überschreibt das in /etc/edgeguard/node.conf
// wenn die Box mehrere Interfaces hat und wir das falsche gepickt
// haben.
func firstNonLoopbackIPv4() string {
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip4 := ipnet.IP.To4()
if ip4 == nil {
continue
}
return ip4.String()
}
}
return ""
}