Go-Quality-Baseline-Rollout ABGESCHLOSSEN. Code-Quality-Backlog (55 → 0): - errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf `_, _ =` (Zero-Value degradiert sauber). - unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline, acme.Service.user, strFold + ungenutzter Import). - noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx. - staticcheck: QF1001/S1009/ST1005/SA9003. - contextcheck: detached-by-design-Stellen mit begründetem //nolint. Zwei echte Bugs beim Aufräumen gefunden+gefixt: - backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt → unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet. - haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts → echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain). Bewusste Config-Entscheidungen (.golangci.yml): - noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/ wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client- Disconnect darf keinen laufenden Reload mitten in der Ausführung killen. net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst. - rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs). Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check` grün: vet, golangci-lint, govulncheck, build, test -race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
5.2 KiB
Go
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 func() { _ = 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 ""
|
|
}
|