package handlers import ( "bytes" "context" "encoding/base64" "encoding/json" "log/slog" "os" "os/exec" "path/filepath" "strings" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/aggregator" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/models" ) const tlsCertDir = "/etc/edgeguard/tls" // AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als // Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate // des Primary zu spiegeln. func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) { entries, err := os.ReadDir(tlsCertDir) if err != nil { response.Internal(c, err) return } certs := make(map[string]string, len(entries)) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") { continue } data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name())) if err != nil { continue } certs[e.Name()] = base64.StdEncoding.EncodeToString(data) } response.OK(c, gin.H{"certs": certs}) } // SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS // und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy // wenn mindestens ein Zertifikat aktualisiert wurde. // // Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick — // nicht hash-gated, da certbot-Renewals den config_hash nicht ändern. func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error { if agg == nil { return nil } // Primary-Peer aus ha_nodes ermitteln rows, err := pool.Query(ctx, `SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID) if err != nil { return err } defer rows.Close() var primary *models.HANode for rows.Next() { n := &models.HANode{} if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil { continue } primary = n } if primary == nil { return nil // kein Peer → Single-Node } results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID) if len(results) == 0 || !results[0].OK { return nil // Primary nicht erreichbar — nächster Tick } var payload struct { Certs map[string]string `json:"certs"` } if err := json.Unmarshal(results[0].Data, &payload); err != nil { return err } if err := os.MkdirAll(tlsCertDir, 0o750); err != nil { return err } changed := false for name, b64 := range payload.Certs { data, err := base64.StdEncoding.DecodeString(b64) if err != nil { slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err) continue } path := filepath.Join(tlsCertDir, name) existing, readErr := os.ReadFile(path) if readErr == nil && bytes.Equal(existing, data) { continue // unverändert } if err := os.WriteFile(path, data, 0o640); err != nil { slog.Warn("cert-sync: write failed", "file", name, "error", err) continue } changed = true slog.Info("cert-sync: updated", "file", name) } // Prune: lokale .pem entfernen, die der Primary NICHT (mehr) hat. // Ohne diesen Schritt bleiben Zertifikate gelöschter Domains auf dem // Secondary als Waisen liegen — der Sync oben ist write-only, „nicht // mitgeschickt" ≠ „gelöscht". Geschützt bleiben: // _default.pem — Self-Signed-Fallback // .pem — eigener Node-Cert (steht NICHT im Primary-Payload) // Nur prunen wenn der Payload nicht leer ist — Schutz gegen ein // versehentliches Leerräumen bei unvollständiger Primary-Antwort. if len(payload.Certs) > 0 { protected := map[string]bool{"_default.pem": true} var localFQDN string if err := pool.QueryRow(ctx, `SELECT fqdn FROM ha_nodes WHERE id = $1`, localID).Scan(&localFQDN); err == nil && localFQDN != "" { protected[localFQDN+".pem"] = true } if entries, err := os.ReadDir(tlsCertDir); err == nil { for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".pem") || protected[name] { continue } if _, ok := payload.Certs[name]; ok { continue // vom Primary gepflegt — behalten } if err := os.Remove(filepath.Join(tlsCertDir, name)); err != nil { slog.Warn("cert-sync: prune failed", "file", name, "error", err) continue } changed = true slog.Info("cert-sync: pruned orphan", "file", name) } } } if changed { if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil { slog.Warn("cert-sync: haproxy reload failed", "error", err) } } return nil }