package cluster // /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen Cluster- // Peers replizierte Konfiguration. 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-Privilegien. 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 "" }