Compare commits
4 Commits
v1.3.31
...
4be9f7f280
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4be9f7f280 | ||
|
|
a34457f13f | ||
|
|
1b0320a5da | ||
|
|
9137c07c95 |
@@ -167,8 +167,10 @@ func main() {
|
||||
st, _ := setupStore.Load()
|
||||
|
||||
var renewer *certrenewer.Service
|
||||
var acmeIssuer *acme.Service
|
||||
if st != nil && st.ACMEEmail != "" {
|
||||
issuer := acme.New(st.ACMEEmail)
|
||||
acmeIssuer = issuer
|
||||
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
|
||||
slog.Info("scheduler: ACME renewer enabled",
|
||||
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
|
||||
@@ -195,6 +197,11 @@ func main() {
|
||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||
}
|
||||
// Das EIGENE Management-Zertifikat dagegen auf jedem Node — dessen FQDN
|
||||
// zeigt auf die eigene IP, nicht auf die VIP (siehe mgmtcert.go).
|
||||
if acmeIssuer != nil {
|
||||
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
|
||||
}
|
||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||
|
||||
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
|
||||
@@ -268,6 +275,10 @@ func main() {
|
||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||
}
|
||||
// Eigenes Management-Cert: unabhaengig von der VIP, siehe oben.
|
||||
if acmeIssuer != nil {
|
||||
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
|
||||
}
|
||||
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
|
||||
case <-licTick.C:
|
||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||
|
||||
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal file
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
// Node-lokale Erneuerung des eigenen Management-Zertifikats.
|
||||
//
|
||||
// Befund 2026-09-11: Auf utm-2 war das Zertifikat fuer die Management-UI
|
||||
// seit zwei Wochen abgelaufen und haette sich nie erneuert. Zwei Gruende
|
||||
// trafen zusammen:
|
||||
//
|
||||
// 1. Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
|
||||
// tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
|
||||
// von niemandem mehr angefasst. certrenewer arbeitet ausschliesslich
|
||||
// die Tabelle ab und sieht es deshalb nie.
|
||||
// 2. Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
|
||||
// (v1.3.20). Das ist fuer geteilte Domains richtig — die zeigen per DNS
|
||||
// auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
|
||||
// eigene Management-FQDN stimmt es NICHT: das zeigt auf die eigene IP
|
||||
// des Nodes, der die HTTP-01-Challenge also selbst beantworten kann.
|
||||
//
|
||||
// Deshalb laeuft diese Pruefung auf JEDEM Node, unabhaengig von der VIP —
|
||||
// aber ausschliesslich fuer das eigene FQDN aus setup.json.
|
||||
//
|
||||
// Bewusst NICHT ueber die tls_certs-Tabelle: die ist eine replizierte
|
||||
// Shared-Table, und cluster-reconcile-replication TRUNCATEt solche Tabellen
|
||||
// beim Refresh. Eine lokal auf dem Subscriber eingefuegte Zeile waere beim
|
||||
// naechsten Paket-Upgrade wieder weg. Das Management-Zertifikat ist
|
||||
// node-lokale Infrastruktur (wie die cluster-tls-Certs) und wird auch so
|
||||
// behandelt: reine Datei unter certDir.
|
||||
//
|
||||
// Existiert dagegen eine tls_certs-Zeile fuer das eigene FQDN (so ist es
|
||||
// auf dem Primary, dessen FQDN beim Setup regulaer als Domain angelegt
|
||||
// wurde), bleibt alles beim Alten — dann macht certrenewer weiter seine
|
||||
// Arbeit und wir fassen nichts an. Sonst haetten wir zwei Mechanismen auf
|
||||
// derselben Datei.
|
||||
|
||||
// mgmtCertRenewThreshold: ab wann erneuert wird. Gleicher Wert wie der
|
||||
// certrenewer fuer die Domain-Certs.
|
||||
const mgmtCertRenewThreshold = 30 * 24 * time.Hour
|
||||
|
||||
// runManagementCertRenew prueft das eigene Management-Zertifikat und
|
||||
// erneuert es bei Bedarf. Best-effort: Fehler werden geloggt/gemeldet,
|
||||
// der Tick laeuft beim naechsten Zyklus erneut.
|
||||
func runManagementCertRenew(
|
||||
ctx context.Context,
|
||||
setupStore *setup.Store,
|
||||
tlsRepo *tlscerts.Repo,
|
||||
issuer interface {
|
||||
Issue(domain string) (string, string, string, error)
|
||||
},
|
||||
a *alerts.Service, d *dedupe,
|
||||
) {
|
||||
if setupStore == nil || issuer == nil {
|
||||
return
|
||||
}
|
||||
st, err := setupStore.Load()
|
||||
if err != nil || st == nil || st.FQDN == "" {
|
||||
return
|
||||
}
|
||||
fqdn := strings.ToLower(strings.TrimSpace(st.FQDN))
|
||||
|
||||
// Wird das FQDN bereits als regulaere Domain verwaltet, ist der
|
||||
// certrenewer zustaendig — nicht zusaetzlich hier anfassen.
|
||||
if tlsRepo != nil {
|
||||
if managed, err := mgmtCertIsManaged(ctx, tlsRepo, fqdn); err == nil && managed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(certDir, fqdn+".pem")
|
||||
remaining, err := certRemainingValidity(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
slog.Info("scheduler: management cert missing/unreadable — issuing",
|
||||
"fqdn", fqdn, "path", path, "error", err)
|
||||
case remaining > mgmtCertRenewThreshold:
|
||||
return // noch lange gueltig
|
||||
default:
|
||||
slog.Info("scheduler: management cert expiring — renewing",
|
||||
"fqdn", fqdn, "remaining", remaining.Round(time.Hour).String())
|
||||
}
|
||||
|
||||
certPEM, chainPEM, keyPEM, err := issuer.Issue(fqdn)
|
||||
if err != nil {
|
||||
slog.Error("scheduler: management cert issue failed", "fqdn", fqdn, "error", err)
|
||||
if a != nil && d != nil && d.shouldFire("cert.mgmt_renew_failed:"+fqdn) {
|
||||
_, _ = a.Fire(ctx, "cert.mgmt_renew_failed", alerts.SeverityError,
|
||||
"Management-Zertifikat konnte nicht erneuert werden: "+fqdn,
|
||||
"Die HTTP-01-Challenge fuer das eigene Management-FQDN ist fehlgeschlagen. "+
|
||||
"Pruefe, ob "+fqdn+" auf die oeffentliche IP DIESES Nodes zeigt und Port 80 "+
|
||||
"von aussen erreichbar ist. Fehler: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := certstore.WriteCombined(certDir, fqdn, certPEM, chainPEM, keyPEM); err != nil {
|
||||
slog.Error("scheduler: management cert write failed", "fqdn", fqdn, "error", err)
|
||||
return
|
||||
}
|
||||
if err := reloadHAProxyForMgmtCert(); err != nil {
|
||||
slog.Warn("scheduler: haproxy reload after management cert renewal failed", "error", err)
|
||||
}
|
||||
slog.Info("scheduler: management cert renewed", "fqdn", fqdn)
|
||||
}
|
||||
|
||||
// mgmtCertIsManaged sagt, ob fuer das FQDN bereits eine tls_certs-Zeile
|
||||
// existiert (dann gehoert es dem certrenewer).
|
||||
func mgmtCertIsManaged(ctx context.Context, repo *tlscerts.Repo, fqdn string) (bool, error) {
|
||||
rows, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if strings.EqualFold(strings.TrimSpace(r.Domain), fqdn) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// certRemainingValidity liest die Restlaufzeit des ersten Zertifikats in
|
||||
// einer kombinierten PEM-Datei. Fehler (Datei fehlt, unlesbar, kein
|
||||
// Zertifikat drin) bedeuten "muss ausgestellt werden".
|
||||
func certRemainingValidity(path string) (time.Duration, error) {
|
||||
raw, err := os.ReadFile(path) //nolint:gosec // fester Pfad aus certDir + eigenem FQDN
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rest := raw
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
return 0, os.ErrNotExist
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
crt, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return time.Until(crt.NotAfter), nil
|
||||
}
|
||||
}
|
||||
|
||||
// reloadHAProxyForMgmtCert: "haproxy.service" ausgeschrieben, weil die
|
||||
// sudoers-Regel im postinst exakt darauf gepinnt ist — ohne Suffix wuerde
|
||||
// sudo den Aufruf ablehnen. Gleicher Aufruf wie in certrenewer.
|
||||
func reloadHAProxyForMgmtCert() error {
|
||||
//nolint:noctx // System-Reload darf nicht am Tick-Context haengen
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
|
||||
if err != nil {
|
||||
return &exitErr{msg: strings.TrimSpace(string(out)), err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type exitErr struct {
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *exitErr) Error() string { return e.err.Error() + ": " + e.msg }
|
||||
func (e *exitErr) Unwrap() error { return e.err }
|
||||
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal file
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// certRemainingValidity entscheidet, ob ueberhaupt erneuert wird — ein
|
||||
// falsches Ergebnis heisst entweder "Zertifikat laeuft unbemerkt ab"
|
||||
// (genau der Befund auf utm-2) oder "wir erneuern bei jedem Tick".
|
||||
func writeTestPEM(t *testing.T, dir, name string, notAfter time.Time, withKey bool) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("cert: %v", err)
|
||||
}
|
||||
var buf []byte
|
||||
// Reihenfolge wie certstore.WriteCombined: erst Cert(-Kette), dann Key.
|
||||
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
|
||||
if withKey {
|
||||
kd, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})...)
|
||||
}
|
||||
p := filepath.Join(dir, name+".pem")
|
||||
if err := os.WriteFile(p, buf, 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestCertRemainingValidity_LongLived(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(60*24*time.Hour), true)
|
||||
got, err := certRemainingValidity(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if got <= mgmtCertRenewThreshold {
|
||||
t.Errorf("60d-Cert muss ueber dem 30d-Schwellwert liegen, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRemainingValidity_ExpiringSoon(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(5*24*time.Hour), true)
|
||||
got, err := certRemainingValidity(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if got > mgmtCertRenewThreshold {
|
||||
t.Errorf("5d-Cert muss unter dem Schwellwert liegen, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Der utm-2-Fall: bereits abgelaufen → negative Restlaufzeit, also
|
||||
// eindeutig unter dem Schwellwert und damit erneuerungspflichtig.
|
||||
func TestCertRemainingValidity_AlreadyExpired(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(-14*24*time.Hour), true)
|
||||
got, err := certRemainingValidity(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if got >= 0 {
|
||||
t.Errorf("abgelaufenes Cert muss negative Restlaufzeit liefern, got %v", got)
|
||||
}
|
||||
if got > mgmtCertRenewThreshold {
|
||||
t.Errorf("abgelaufenes Cert muss erneuert werden, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRemainingValidity_MissingFile(t *testing.T) {
|
||||
if _, err := certRemainingValidity(filepath.Join(t.TempDir(), "nope.pem")); err == nil {
|
||||
t.Error("fehlende Datei muss einen Fehler liefern (→ ausstellen)")
|
||||
}
|
||||
}
|
||||
|
||||
// Nur-Key-Datei: darf nicht als gueltiges Zertifikat durchgehen, sonst
|
||||
// wuerde ein kaputter Zustand nie repariert.
|
||||
func TestCertRemainingValidity_NoCertificateBlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "keyonly.pem")
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
kd, _ := x509.MarshalECPrivateKey(key)
|
||||
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := certRemainingValidity(p); err == nil {
|
||||
t.Error("PEM ohne CERTIFICATE-Block muss einen Fehler liefern")
|
||||
}
|
||||
}
|
||||
@@ -424,8 +424,9 @@
|
||||
"split-brain": "split-brain"
|
||||
},
|
||||
"roleDbPrimary": "DB-Primary",
|
||||
"rolePeer": "Peer",
|
||||
"roleHint": "DB-/Cluster-Rolle: bestimmt, wohin Schreibzugriffe gehen. Sie wandert NICHT mit der VIP — ein Node kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“."
|
||||
"roleHint": "Datenbank-Rolle aus der Replikation: DB-Primary nimmt Schreibzugriffe entgegen, DB-Standby repliziert von dort. Sie wandert NICHT mit der VIP — ein Knoten kann DB-Primary sein und trotzdem gerade keepalived-BACKUP (siehe VIP/VRRP-Karte). Sie aendert sich nur durch „edgeguard-ctl promote“.",
|
||||
"roleDbStandby": "DB-Standby",
|
||||
"roleDbUnknown": "—"
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
|
||||
@@ -424,8 +424,9 @@
|
||||
"split-brain": "split-brain"
|
||||
},
|
||||
"roleDbPrimary": "DB primary",
|
||||
"rolePeer": "Peer",
|
||||
"roleHint": "Database/cluster role: decides where writes go. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\"."
|
||||
"roleHint": "Database role from replication: the DB primary accepts writes, the DB standby replicates from it. It does NOT follow the VIP — a node can be DB primary while currently being keepalived BACKUP (see the VIP/VRRP card). It only changes via \"edgeguard-ctl promote\".",
|
||||
"roleDbStandby": "DB standby",
|
||||
"roleDbUnknown": "—"
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
|
||||
@@ -68,7 +68,7 @@ interface FwRule { id: number; enabled: boolean; action: string }
|
||||
interface FwNAT { id: number; enabled: boolean; kind: string }
|
||||
interface FwZone { id: number; name: string; builtin: boolean }
|
||||
interface TLSCert { id: number; common_name: string; not_after?: string }
|
||||
interface ClusterNode { id: string; fqdn: string; role: string }
|
||||
interface ClusterNode { id: string; fqdn: string; role: string; pg_role?: string }
|
||||
interface WGIface { id: number; name: string; mode: string; active: boolean }
|
||||
interface WGStatusRow {
|
||||
interface: string
|
||||
@@ -644,6 +644,24 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
|
||||
|
||||
// ── Cluster card ──────────────────────────────────────────────
|
||||
|
||||
// dbRoleLabel/-Color bilden pg_role ab. 'standalone' bzw. leer heisst:
|
||||
// keine Replikation eingerichtet — dann gibt es schlicht keine DB-Rolle
|
||||
// zu zeigen, statt eine zu erfinden.
|
||||
function dbRoleLabel(pgRole: string | undefined, t: (k: string) => string): string {
|
||||
switch (pgRole) {
|
||||
case 'primary': return t('dashboard.clusterCard.roleDbPrimary')
|
||||
case 'standby': return t('dashboard.clusterCard.roleDbStandby')
|
||||
default: return t('dashboard.clusterCard.roleDbUnknown')
|
||||
}
|
||||
}
|
||||
function dbRoleColor(pgRole: string | undefined): string {
|
||||
switch (pgRole) {
|
||||
case 'primary': return 'green'
|
||||
case 'standby': return 'blue'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
interface ClusterStatusCardProps {
|
||||
nodes: ClusterNode[]
|
||||
status: { mode: string; health: string; drift_found: boolean } | null
|
||||
@@ -677,16 +695,19 @@ function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) {
|
||||
padding: '4px 0', borderBottom: '1px solid #F1F5F9', fontSize: 12,
|
||||
}}>
|
||||
<code style={{ color: '#334155' }}>{n.fqdn}</code>
|
||||
{/* ha_nodes.role ist die DB-/Cluster-Rolle (wohin Schreibzugriffe
|
||||
gehen) und wandert bewusst NICHT mit der VIP — sie aendert
|
||||
sich nur durch `edgeguard-ctl promote`. Ein nacktes "primary"
|
||||
hier las sich neben der VIP-Karte ("BACKUP") wie ein
|
||||
Widerspruch, deshalb explizit als DB-Rolle beschriftet. */}
|
||||
{/* Bewusst pg_role, NICHT role: ha_nodes ist node-lokal (nicht
|
||||
repliziert), und jeder Node traegt sich in `role` selbst ein —
|
||||
ein per Join dazugekommener Node behaelt dort den Default
|
||||
"primary" und behauptete deshalb in seiner eigenen Ansicht,
|
||||
beide Knoten seien DB-Primary. `pg_role` folgt der
|
||||
tatsaechlichen Replikationsrolle und stimmt auf beiden Seiten
|
||||
ueberein. Siehe auch cluster_repair.go: role/pg_role sind
|
||||
node-lokal, verlaesslich ist letztlich die PUBLICATION.
|
||||
Die DB-Rolle wandert NICHT mit der VIP — sie aendert sich nur
|
||||
durch `edgeguard-ctl promote`. */}
|
||||
<Tooltip title={t('dashboard.clusterCard.roleHint')}>
|
||||
<Tag color={n.role === 'primary' ? 'green' : 'default'} style={{ margin: 0 }}>
|
||||
{n.role === 'primary'
|
||||
? t('dashboard.clusterCard.roleDbPrimary')
|
||||
: t('dashboard.clusterCard.rolePeer')}
|
||||
<Tag color={dbRoleColor(n.pg_role)} style={{ margin: 0 }}>
|
||||
{dbRoleLabel(n.pg_role, t)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user