Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7611572062 | ||
|
|
3b5bf578a0 | ||
|
|
bf211ca273 | ||
|
|
becd068637 | ||
|
|
b3dda81b49 | ||
|
|
b20ace8763 | ||
|
|
053b38e46c | ||
|
|
df31bfa720 | ||
|
|
5f92851a96 |
41
CLAUDE.md
41
CLAUDE.md
@@ -13,7 +13,9 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
|
||||
|
||||
# EdgeGuard Native (`eg`)
|
||||
|
||||
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker, kein WAF in v1. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
|
||||
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker — alle Dienste nativ unter systemd. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
|
||||
>
|
||||
> **Hinweis:** Mehrere ursprüngliche v1-Nicht-Ziele (WAF, IDS/IPS, DHCP, RADIUS) sind inzwischen umgesetzt — siehe „Feature-Stand" weiter unten.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,26 +69,47 @@ ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCH
|
||||
| **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) |
|
||||
| **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 |
|
||||
| **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` |
|
||||
| **State/HA** | KeyDB Active-Active (Redis-kompatibel) |
|
||||
| **State/HA** | PostgreSQL Logical Replication + Cluster-Agent-Heartbeat (mTLS); KeyDB Active-Active nur optional (`Recommends`, für Lizenz-Leader-Election/Shared-Cache) |
|
||||
| **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB |
|
||||
| **WAF** | Coraza v3 + OWASP CRS, via HAProxy SPOE (`edgeguard-waf`-Agent) |
|
||||
| **IDS/IPS** | CrowdSec + `crowdsec-firewall-bouncer` (nftables) — managed-wenn-installiert (kein Depends) |
|
||||
| **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
|
||||
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
|
||||
| **DHCP** | Kea (`kea-dhcp4-server`) — managed, default-off |
|
||||
| **AAA/RADIUS** | FreeRADIUS (PAP/CHAP, files-based) — managed, default-off |
|
||||
| **NTP** | chrony (Distro) |
|
||||
| **VIP/HA** | keepalived (VRRP) |
|
||||
| **FW** | nftables (Distro) |
|
||||
| **Forward-Proxy** | Squid (Distro) |
|
||||
| **Auth/SSO** | JWT (lokal) + 2FA/TOTP + OIDC/OAuth2 (Keycloak u. a.) |
|
||||
| **TLS** | certbot + webroot-Plugin |
|
||||
| **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) |
|
||||
| **Plattform** | Debian 13 Trixie · amd64 + arm64 |
|
||||
|
||||
---
|
||||
|
||||
## Nicht-Ziele (v1)
|
||||
## Feature-Stand (Stand 2026-06)
|
||||
|
||||
Über den ursprünglichen v1-Scope hinaus inzwischen **umgesetzt** (waren mal Nicht-Ziele):
|
||||
|
||||
- **WAF** — Coraza v3 + OWASP CRS via HAProxy SPOE (`edgeguard-waf`)
|
||||
- **IDS/IPS** — CrowdSec + `crowdsec-firewall-bouncer` (nftables-Bouncer); managed-wenn-installiert, kein hartes Depends
|
||||
- **DHCP** — Kea `kea-dhcp4-server` (managed, default-off)
|
||||
- **RADIUS** — FreeRADIUS PAP/CHAP, files-based (managed, default-off)
|
||||
- **SSO** — OIDC/OAuth2 (additiv, Rolle aus DB, lokaler Login bleibt)
|
||||
- **2FA** — TOTP
|
||||
- **IPv6** — Firewall-Regeln + NAT familienbewusst
|
||||
- **HA** — VIP via keepalived (VRRP), PG-Logical-Replication, bidirektionaler Cluster-Heartbeat
|
||||
|
||||
Damit ist die ursprüngliche v2-Roadmap (WAF, 2FA, IPv6-FW, OIDC, DHCP, RADIUS) abgearbeitet.
|
||||
|
||||
## Nicht-Ziele (weiterhin)
|
||||
|
||||
- **Kein Docker** — alle Dienste nativ unter systemd
|
||||
- **Kein WAF** (kein Coraza, kein ModSecurity)
|
||||
- **Kein IDS/IPS** (kein Suricata, kein CrowdSec)
|
||||
- **Kein DHCP-Server** (kein Kea)
|
||||
- **Kein RADIUS** (kein FreeRADIUS)
|
||||
- **Kein Network-IDS Suricata** — Intrusion-Detection läuft über CrowdSec, nicht über Suricata-Paket-Inspektion
|
||||
- **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway)
|
||||
- **Keine Multi-Tenant-GuardZones**
|
||||
- **Keine ISO-Builds** (kein EdgeGuardOS-Klon — nur APT)
|
||||
- **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL
|
||||
|
||||
---
|
||||
@@ -154,7 +177,6 @@ cd management-ui && bun run dev
|
||||
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
|
||||
│ ├── firewall/ # nftables-Generator
|
||||
│ ├── cluster/ # Join/Promote/Peer-Discovery
|
||||
│ ├── proxy/ # Write-Proxy → Cluster-Primary
|
||||
│ ├── aggregator/ # Cluster-View APIs
|
||||
│ └── license/ # Lizenz-Validierung
|
||||
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
|
||||
@@ -185,7 +207,8 @@ cd management-ui && bun run dev
|
||||
- **ORM:** GORM für Queries, nicht für Schema-Verwaltung
|
||||
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
|
||||
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
|
||||
- **Cluster-Writes:** immer über `internal/proxy` → Primary-URL aus KeyDB `cluster:pg-primary-url`
|
||||
- **Cluster-Primary-Ermittlung:** zuverlässig über `pg_publication` (`edgeguard_shared`); Primary-URL aus `setup.json` `PrimaryFQDN` via `clusterjoin.NormalizePrimaryURL`. **Kein Write-Proxy** — Schreibzugriffe auf geteilte Tabellen erfolgen am Primary.
|
||||
- **Failover:** `edgeguard-ctl promote` ist Logical-Replication-aware (Subscription lösen → `setupReplicationPrimary` → Publisher werden, inkl. PG-Restart für `wal_level=logical`); erholte Nodes danach via `cluster-setup-standby <neuer-primary>` zurückhängen.
|
||||
|
||||
### Packaging
|
||||
- `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
||||
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||
@@ -61,6 +62,7 @@ import (
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
)
|
||||
@@ -192,6 +194,12 @@ func main() {
|
||||
}
|
||||
// runSecondaryConfigRender wird weiter unten gestartet sobald
|
||||
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
|
||||
} else if nodeID != "" && st != nil && st.Completed && st.FQDN != "" {
|
||||
// Primary/Founder (kein joined Secondary): self (role=primary) an
|
||||
// alle Peers pushen, damit deren lokale ha_nodes den Primary frisch
|
||||
// hält — sonst zeigt die vom Secondary ausgelieferte UI den Primary
|
||||
// als offline. No-op solange keine Peers existieren (Single-Node).
|
||||
go runPeerPush(context.Background(), pool, clusterStore, nodeID, st.FQDN, version)
|
||||
}
|
||||
|
||||
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
||||
@@ -453,15 +461,22 @@ func main() {
|
||||
}
|
||||
handlers.NewDHCPHandler(dhcpsvc.New(pool), auditRepo, nodeID, withFW(keaReloader)).Register(authed)
|
||||
|
||||
// RADIUS (FreeRADIUS) — re-render clients.conf + authorize + service lifecycle.
|
||||
radiusReloader := func(ctx context.Context) error {
|
||||
return radiusrender.New(pool, secretsBox).Render(ctx)
|
||||
}
|
||||
handlers.NewRADIUSHandler(radiussvc.New(pool, secretsBox), auditRepo, nodeID, withFW(radiusReloader)).Register(authed)
|
||||
|
||||
// Wire all service reloaders into systemHdl so RenderConfigs
|
||||
// re-renders every service from DB state in one shot.
|
||||
systemHdl.WithAllReloaders(map[string]func(context.Context) error{
|
||||
"nftables": fwReloader,
|
||||
"wireguard": wgReloader,
|
||||
"squid": squidReloader,
|
||||
"unbound": unboundReloader,
|
||||
"chrony": chronyReloader,
|
||||
"kea": keaReloader,
|
||||
"nftables": fwReloader,
|
||||
"wireguard": wgReloader,
|
||||
"squid": squidReloader,
|
||||
"unbound": unboundReloader,
|
||||
"chrony": chronyReloader,
|
||||
"kea": keaReloader,
|
||||
"freeradius": radiusReloader,
|
||||
})
|
||||
|
||||
// License — node-local key store + DB-mirror of last verify
|
||||
@@ -817,12 +832,20 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secre
|
||||
}
|
||||
|
||||
// runPrimaryPush periodically pushes this secondary node's config_hash to the
|
||||
// primary via mTLS. The primary's ha_nodes view only gets config_hash written
|
||||
// during join-time autoRegister — after that the primary never hears about
|
||||
// hash changes unless we push. Without this, the drift banner shows stale
|
||||
// hashes from join-time forever.
|
||||
// primary via mTLS. The primary's ha_nodes view only gets config_hash + last_seen
|
||||
// written during join-time autoRegister — after that the primary never hears about
|
||||
// the secondary unless we push. Without this, the drift banner shows stale hashes
|
||||
// from join-time forever AND the secondary's last_seen freezes → SweepStaleNodes
|
||||
// marks it offline.
|
||||
//
|
||||
// WICHTIG: tick MUSS deutlich unter dem Stale-Threshold (4× 30s = 2 min, siehe
|
||||
// scheduler.staleThreshold / cluster.SweepStaleNodes) liegen. Sonst flippt der
|
||||
// Secondary zwischen den Pushes zwangsläufig auf "offline" (bei 5-min-Tick:
|
||||
// 2 min online, 3 min offline). 30s = 4 Pushes pro Stale-Fenster → ein
|
||||
// verpasster Push (Netz-Glitch) ist unkritisch. Der Receiver (AgentRegisterPeer)
|
||||
// lädt nftables nur bei IP-Änderung neu → kein Reload-Sturm durch häufige Pushes.
|
||||
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
|
||||
const tick = 5 * time.Minute
|
||||
const tick = 30 * time.Second
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
push := func() {
|
||||
@@ -846,6 +869,51 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
|
||||
}
|
||||
}
|
||||
|
||||
// runPeerPush läuft auf dem Primary/Founder und pusht alle 30s die eigene
|
||||
// Identität (role=primary) an jeden Peer via mTLS — das Gegenstück zu
|
||||
// runPrimaryPush (Secondary→Primary). Zusammen ergibt das einen
|
||||
// bidirektionalen Cross-Node-Heartbeat: beide Nodes sehen sich gegenseitig
|
||||
// als online, egal von welchem Node die UI ausgeliefert wird. Tick wie
|
||||
// runPrimaryPush deutlich unter dem 2-min-Stale-Threshold. No-op solange
|
||||
// keine Peers existieren (Single-Node) bzw. wenn ein Peer down ist (Debug-Log).
|
||||
func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, nodeID, fqdn, version string) {
|
||||
const tick = 30 * time.Second
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
push := func() {
|
||||
pCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
peers, err := store.List(pCtx)
|
||||
if err != nil {
|
||||
slog.Warn("cluster: peer-push list failed", "error", err)
|
||||
return
|
||||
}
|
||||
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
|
||||
for i := range peers {
|
||||
p := peers[i]
|
||||
if p.ID == nodeID {
|
||||
continue // nicht an sich selbst pushen
|
||||
}
|
||||
target := p.APIURL
|
||||
if target == "" {
|
||||
target = "https://" + p.FQDN
|
||||
}
|
||||
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil {
|
||||
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
push() // immediate push on API startup
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
push()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomEphemeralSecret() []byte {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -80,6 +81,7 @@ var localOnlyTables = []string{
|
||||
"dns_settings", // listen_addresses ist node-spezifisch
|
||||
"ntp_settings", // listen_addresses ist node-spezifisch
|
||||
"dhcp_settings", // ob DIESE Node DHCP betreibt (Dual-DHCP vermeiden)
|
||||
"radius_settings", // ob DIESE Node RADIUS betreibt + Listen-Adressen
|
||||
"system_settings", // Hostname, Maintenance-Mode etc.
|
||||
"join_tokens_used", // Token-Tracking nur auf Primary relevant
|
||||
"audit_log", // Lokales Audit-Protokoll
|
||||
@@ -113,11 +115,29 @@ func cmdClusterInitReplication(args []string) int {
|
||||
}
|
||||
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
|
||||
|
||||
if err := setupReplicationPrimary(pg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Nächste Schritte:")
|
||||
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
|
||||
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
|
||||
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
|
||||
return 0
|
||||
}
|
||||
|
||||
// setupReplicationPrimary konfiguriert die lokale PG-Instanz als Logical-
|
||||
// Replication-Primary: Replikations-Rolle + Secret, conf.d (wal_level=logical),
|
||||
// pg_hba, SELECT-Grants, PUBLICATION. Stellt sicher dass wal_level=logical
|
||||
// AKTIV ist (Restart nur falls nötig — für wal_level reicht reload nicht).
|
||||
// Idempotent. Gemeinsam genutzt von cluster-init-replication und promote.
|
||||
func setupReplicationPrimary(pg pgConfig) error {
|
||||
// 1. Passwort generieren
|
||||
pass, err := generatePassword(32)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err)
|
||||
return 1
|
||||
return fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
|
||||
// 2. edgeguard_replicator-Rolle anlegen/updaten
|
||||
@@ -131,21 +151,17 @@ BEGIN
|
||||
END
|
||||
$$`, egReplUser, egReplUser, pass, egReplUser, pass)
|
||||
if err := psqlExec(roleSQL); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err)
|
||||
return 1
|
||||
return fmt.Errorf("create replication role: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
|
||||
|
||||
// 3. Passwort speichern
|
||||
// 3. Passwort speichern (Ownership an edgeguard-User, damit die API liest)
|
||||
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err)
|
||||
return 1
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err)
|
||||
return 1
|
||||
return fmt.Errorf("write secret: %w", err)
|
||||
}
|
||||
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
|
||||
if u, err := user.Lookup("edgeguard"); err == nil {
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
@@ -154,71 +170,88 @@ $$`, egReplUser, egReplUser, pass, egReplUser, pass)
|
||||
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
|
||||
|
||||
// 4. conf.d/edgeguard-replication.conf schreiben
|
||||
// wal_level=logical ist eine Obermenge von replica — unterstützt
|
||||
// sowohl Logical Replication als auch ggfs. physisches WAL-Archiving.
|
||||
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err)
|
||||
return 1
|
||||
return fmt.Errorf("conf.d mkdir: %w", err)
|
||||
}
|
||||
replConf := `# EdgeGuard Logical Replication — automatisch generiert
|
||||
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet.
|
||||
# Nicht manuell bearbeiten; wird von edgeguard-ctl verwaltet.
|
||||
wal_level = logical
|
||||
max_wal_senders = 10
|
||||
max_replication_slots = 20
|
||||
max_logical_replication_workers = 4
|
||||
wal_keep_size = 512MB
|
||||
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers
|
||||
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
|
||||
# bekannte Replikations-User beschränkt.
|
||||
# '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt.
|
||||
listen_addresses = '*'
|
||||
`
|
||||
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
|
||||
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err)
|
||||
return 1
|
||||
return fmt.Errorf("write postgresql conf: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
|
||||
|
||||
// 5. pg_hba.conf aktualisieren
|
||||
if err := ensureHBAReplication(pg.HBAPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err)
|
||||
return 1
|
||||
return fmt.Errorf("pg_hba.conf: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
|
||||
|
||||
// 6. PG reload (damit wal_level + pg_hba aktiv werden)
|
||||
// 6. PG reload (pg_hba aktiv). wal_level/max_wal_senders sind aber
|
||||
// postmaster-Parameter → nur per RESTART aktiv. Nur restarten wenn nötig.
|
||||
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out)
|
||||
return 1
|
||||
return fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
|
||||
if cur, _ := psqlRun([]string{"-tA", "-c", "SHOW wal_level;"}); strings.TrimSpace(string(cur)) != "logical" {
|
||||
fmt.Println("→ wal_level wechselt auf 'logical' — PostgreSQL-Restart nötig...")
|
||||
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "restart").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("pg restart: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
ready := false
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := psqlRun([]string{"-tA", "-c", "SELECT 1;"}); err == nil {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if !ready {
|
||||
return fmt.Errorf("PostgreSQL kam nach Restart binnen 60s nicht zurück — prüfe PG-Logs")
|
||||
}
|
||||
fmt.Println("✓ PostgreSQL neu gestartet (wal_level=logical aktiv)")
|
||||
}
|
||||
|
||||
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden
|
||||
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
|
||||
// 7. SELECT-Grants (DEFAULT PRIVILEGES sichert zukünftige Tabellen)
|
||||
grantSQL := fmt.Sprintf(`
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
|
||||
`, egReplUser, egReplUser)
|
||||
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err)
|
||||
return 1
|
||||
return fmt.Errorf("grant SELECT: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
|
||||
|
||||
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables.
|
||||
// Idempotent: DROP IF EXISTS + CREATE.
|
||||
// 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE)
|
||||
if err := createPublication(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err)
|
||||
return 1
|
||||
return fmt.Errorf("create publication: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Nächste Schritte:")
|
||||
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
|
||||
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
|
||||
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
|
||||
return 0
|
||||
// dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription
|
||||
// idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den
|
||||
// Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen.
|
||||
func dropSubscriptionIfExists() error {
|
||||
dropSQL := fmt.Sprintf(`
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
|
||||
ALTER SUBSCRIPTION %s DISABLE;
|
||||
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
|
||||
DROP SUBSCRIPTION %s;
|
||||
END IF;
|
||||
END $$;`, egSubName, egSubName, egSubName, egSubName)
|
||||
return psqlDBExec("edgeguard", dropSQL)
|
||||
}
|
||||
|
||||
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen
|
||||
@@ -332,15 +365,7 @@ func cmdClusterSetupStandby(args []string) int {
|
||||
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
|
||||
|
||||
// 2. Bestehende Subscription löschen (idempotent)
|
||||
dropSQL := fmt.Sprintf(`
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
|
||||
ALTER SUBSCRIPTION %s DISABLE;
|
||||
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
|
||||
DROP SUBSCRIPTION %s;
|
||||
END IF;
|
||||
END $$;`, egSubName, egSubName, egSubName, egSubName)
|
||||
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
|
||||
if err := dropSubscriptionIfExists(); err != nil {
|
||||
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
|
||||
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
|
||||
} else {
|
||||
@@ -358,7 +383,9 @@ END $$;`, egSubName, egSubName, egSubName, egSubName)
|
||||
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
|
||||
egSubName, connStr, egPubName,
|
||||
)
|
||||
if err := psqlDBExec("edgeguard", createSQL); err != nil {
|
||||
// Via stdin (nicht -c), damit das Replikations-Passwort nicht in der
|
||||
// Prozess-Argv (ps/proc) oder in PG-log_statement landet.
|
||||
if err := psqlDBExecStdin("edgeguard", createSQL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
@@ -469,7 +496,7 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
|
||||
},
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s:%d/agent/cluster/pg-replication-info", host, agentPort)
|
||||
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s: %w", url, err)
|
||||
@@ -515,7 +542,7 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
|
||||
},
|
||||
},
|
||||
}
|
||||
url := fmt.Sprintf("https://%s:%d/agent/cluster/master-key", host, agentPort)
|
||||
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
@@ -567,6 +594,17 @@ func psqlDBExec(db, sql string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// psqlDBExecStdin führt SQL über stdin (`-f -`) aus statt `-c`, damit
|
||||
// Secrets im SQL nicht in der Prozess-Argv / PG-Statement-Logs erscheinen.
|
||||
func psqlDBExecStdin(db, sql string) error {
|
||||
cmd := buildPsqlCmd([]string{"-d", db, "-v", "ON_ERROR_STOP=1", "-f", "-"})
|
||||
cmd.Stdin = strings.NewReader(sql)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus.
|
||||
func psqlDBRun(db string, args []string) ([]byte, error) {
|
||||
baseArgs := []string{"-d", db}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,18 +12,22 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
||||
)
|
||||
|
||||
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby
|
||||
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain
|
||||
// in 2-Node-Clustern ohne externen Quorum zu verhindern.
|
||||
// cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles
|
||||
// Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern
|
||||
// ohne externes Quorum zu verhindern.
|
||||
//
|
||||
// Hintergrund: Die Replikation ist LOGICAL (Publication/Subscription), nicht
|
||||
// physisch. Ein Subscriber ist eine normale beschreibbare PG-Instanz (nie „in
|
||||
// recovery", kein standby.signal). „Promote" heißt darum: Subscription zum
|
||||
// (toten/alten) Primary lösen und selbst Publisher werden.
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby)
|
||||
// 2. pg_ctlcluster promote → PG wird Primary
|
||||
// 3. Warten bis pg_is_in_recovery() = false
|
||||
// 4. ha_nodes.pg_role auf 'primary' setzen
|
||||
// 5. KeyDB cluster:pg-primary-url auf lokal setzen
|
||||
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200)
|
||||
// 7. keepalived reload
|
||||
// 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig
|
||||
// 2. Subscription lösen (DISABLE + slot_name=NONE + DROP)
|
||||
// 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication
|
||||
// + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig)
|
||||
// 4. ha_nodes.pg_role/role = 'primary'
|
||||
// 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP)
|
||||
func cmdPromote(args []string) int {
|
||||
pg, err := detectPGConfig()
|
||||
if err != nil {
|
||||
@@ -33,51 +35,43 @@ func cmdPromote(args []string) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// 1. Standby-Signal prüfen
|
||||
signalPath := filepath.Join(pg.DataDir, "standby.signal")
|
||||
if _, err := os.Stat(signalPath); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n",
|
||||
signalPath)
|
||||
return 1
|
||||
// 1. Idempotenz: bereits Publisher (Primary) ohne Subscription?
|
||||
pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)})
|
||||
subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)})
|
||||
hasPub := strings.TrimSpace(string(pubOut)) == "1"
|
||||
hasSub := strings.TrimSpace(string(subOut)) == "1"
|
||||
if hasPub && !hasSub {
|
||||
fmt.Println("✓ Diese Node ist bereits Logical-Replication-Primary (Publication vorhanden, keine Subscription). Nichts zu tun.")
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster)
|
||||
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
|
||||
CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("✓ pg_ctlcluster promote gesendet")
|
||||
fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
|
||||
|
||||
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false)
|
||||
fmt.Print("→ Warte auf PG Primary-Mode")
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
||||
if err == nil && strings.TrimSpace(string(out)) == "f" {
|
||||
break
|
||||
// 2. Subscription zum alten/toten Primary lösen
|
||||
if hasSub {
|
||||
if err := dropSubscriptionIfExists(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Print(".")
|
||||
time.Sleep(2 * time.Second)
|
||||
fmt.Println("✓ Subscription zum alten Primary entfernt")
|
||||
}
|
||||
fmt.Println()
|
||||
// Nochmal prüfen
|
||||
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
||||
if err != nil || strings.TrimSpace(string(out)) != "f" {
|
||||
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
|
||||
|
||||
// 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart)
|
||||
if err := setupReplicationPrimary(pg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("✓ PostgreSQL ist jetzt Primary")
|
||||
|
||||
// 3. ha_nodes.pg_role + role aktualisieren
|
||||
// 4. ha_nodes-Rolle aktualisieren
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := database.Open(ctx, database.ConnStringFromEnv())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
|
||||
fmt.Println(" → ha_nodes manuell updaten: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
|
||||
fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
|
||||
} else {
|
||||
defer pool.Close()
|
||||
localID, err := loadLocalID()
|
||||
@@ -93,15 +87,7 @@ func cmdPromote(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. KeyDB cluster:pg-primary-url updaten
|
||||
if err := updateKeyDBPrimaryURL(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
|
||||
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
|
||||
} else {
|
||||
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
|
||||
}
|
||||
|
||||
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
|
||||
// 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
|
||||
if pool != nil {
|
||||
localID, _ := loadLocalID()
|
||||
kg := keepalived.New(pool, localID)
|
||||
@@ -109,9 +95,9 @@ func cmdPromote(args []string) int {
|
||||
defer renderCancel()
|
||||
if err := kg.Render(renderCtx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
|
||||
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived")
|
||||
fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
|
||||
} else {
|
||||
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)")
|
||||
fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +105,8 @@ func cmdPromote(args []string) int {
|
||||
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
|
||||
fmt.Println()
|
||||
fmt.Println("Empfohlene Nachschritte:")
|
||||
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)")
|
||||
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:")
|
||||
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
|
||||
fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:")
|
||||
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
|
||||
return 0
|
||||
}
|
||||
@@ -136,25 +122,3 @@ func loadLocalID() (string, error) {
|
||||
}
|
||||
return c.NodeID, nil
|
||||
}
|
||||
|
||||
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
|
||||
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
|
||||
func updateKeyDBPrimaryURL() error {
|
||||
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
|
||||
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
|
||||
out, err := exec.Command("redis-cli",
|
||||
"-s", "/var/run/keydb/keydb.sock",
|
||||
"SET", "cluster:pg-primary-url", localDSN,
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
// Fallback: Standard-Port
|
||||
out2, err2 := exec.Command("redis-cli",
|
||||
"-p", "6379",
|
||||
"SET", "cluster:pg-primary-url", localDSN,
|
||||
).CombinedOutput()
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
@@ -63,10 +64,16 @@ func cmdRenderConfig(args []string) int {
|
||||
ub := unbound.New(pool)
|
||||
cn := chrony.New(pool)
|
||||
ke := kea.New(pool)
|
||||
fr := freeradius.New(pool, secrets.New(""))
|
||||
if skipReload {
|
||||
hap.SkipReload = true
|
||||
fw.SkipReload = true
|
||||
sq.SkipReload = true
|
||||
wg.SkipReload = true
|
||||
ub.SkipReload = true
|
||||
cn.SkipReload = true
|
||||
ke.SkipReload = true
|
||||
fr.SkipReload = true
|
||||
}
|
||||
|
||||
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
||||
@@ -75,7 +82,7 @@ func cmdRenderConfig(args []string) int {
|
||||
ka = keepalived.New(pool, lc.NodeID)
|
||||
}
|
||||
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke}
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr}
|
||||
if ka != nil {
|
||||
gens = append(gens, ka)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ func main() {
|
||||
slog.Error("waf: SPOE agent stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Graceful shutdown (ctx cancelled): gepufferte Alerts flushen.
|
||||
alertWriter.Close()
|
||||
}
|
||||
|
||||
// reload fetches all domain+waf_config pairs from DB and rebuilds engines.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# EdgeGuard — Architektur
|
||||
|
||||
> Status: **v0.1 (Entwurf)** · Stand: 2026-05-08 · Ziel-Plattformen: Debian 13 (Trixie) + Ubuntu 24.04 LTS (Noble Numbat), Architekturen amd64 + arm64.
|
||||
> Status: **in Produktion** (v1.2.x) · Entwurf: 2026-05-08 · **Cluster/HA-Abschnitte an Ist-Stand angeglichen: 2026-06-06** · Plattform: Debian 13 (Trixie), amd64 + arm64.
|
||||
>
|
||||
> ⚠️ **Lesehinweis:** Dieses Dokument war ursprünglich ein Entwurf. Mehrere Cluster/HA-Annahmen wurden anders umgesetzt — maßgeblich sind die mit „**Ist-Stand**" markierten Stellen (§0, §6–§9). Kurzfassung: **Logical Replication** statt Streaming, **keepalived/VRRP** statt Hoster-Floating-IP, **KeyDB optional/ungenutzt** (Cluster-State in PostgreSQL), **kein Write-Proxy**.
|
||||
|
||||
EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`).
|
||||
|
||||
@@ -8,14 +10,14 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
## 0. Leitplanken (nicht verhandelbar)
|
||||
|
||||
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, KeyDB, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
|
||||
- **Plattform-Matrix:** Debian 13 (Trixie) **und** Ubuntu 24.04 LTS (Noble Numbat), je amd64 + arm64. Alle vier Targets gleichberechtigt.
|
||||
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, keepalived, chrony, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
|
||||
- **Plattform:** **Debian 13 (Trixie), amd64 + arm64.** Nur Trixie — die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`. _(Eine frühere Ubuntu-24.04-Matrix war geplant, ist aber nicht implementiert.)_
|
||||
- **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`.
|
||||
- **HA nativ als Cluster:** N symmetrische Peers, **KeyDB Active-Active** für Shared State + **PostgreSQL Streaming Replication** (single writer, transparenter API-Write-Proxy) + **Floating-IP des Hosters** für HTTP/HTTPS-Ingress (nicht VRRP, nicht DNS-RR).
|
||||
- **Kein WAF, kein IDS, kein DHCP, kein RADIUS, keine Mail-Funktion in v1.** Mail-Gateway ist eigenes Produkt (`nmg`); WAF/CrowdSec/Suricata kommen ggf. in v2.
|
||||
- **HA nativ als Cluster (Ist-Stand 2026-06):** N symmetrische Peers, **PostgreSQL Logical Replication** (ein Publisher/Primary `edgeguard_shared` → N Subscriber; node-lokale Tabellen ausgenommen) + **keepalived/VRRP** für den VIP-Failover (HTTP/HTTPS-Ingress) + **mTLS-Cluster-Agent** (:8443) für Heartbeat/Cert-Sync/Aggregation. **KeyDB ist optional** (`Recommends`) und im Code praktisch ungenutzt; ein Write-Proxy existiert nicht (Writes erfolgen am Primary). _(Der ursprüngliche Entwurf — KeyDB Active-Active, PG-Streaming-Replication mit transparentem Write-Proxy, Floating-IP-statt-VRRP — wurde so nicht umgesetzt; Details in §6–§9.)_
|
||||
- **WAF, IDS/IPS, DHCP, RADIUS inzwischen umgesetzt** (Stand 2026-06): WAF via Coraza+SPOE, IDS/IPS via CrowdSec, DHCP via Kea, RADIUS via FreeRADIUS. Mail-Funktion bleibt ausgeschlossen — Mail-Gateway ist eigenes Produkt (`nmg`).
|
||||
- **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate.
|
||||
|
||||
**Nicht-Ziele (ausdrücklich):** kein WAF, kein Network-IDS (Suricata), kein IPS (CrowdSec), kein DHCP-Server (Kea), kein RADIUS, keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones in v1, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT).
|
||||
**Nicht-Ziele (weiterhin):** kein Network-IDS via Suricata (IDS/IPS läuft über CrowdSec), keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). _(Historisch waren auch WAF/DHCP/RADIUS/IPS Nicht-Ziele — siehe oben, inzwischen umgesetzt.)_
|
||||
|
||||
---
|
||||
|
||||
@@ -33,12 +35,13 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
| Komponente | Rolle |
|
||||
|---|---|
|
||||
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads aus lokaler PG, Writes an Cluster-Primary |
|
||||
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, License-Heartbeat) |
|
||||
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `dump-config`) |
|
||||
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads/Writes auf lokaler PG. Geteilte Tabellen werden vom Primary per Logical Replication an Subscriber verteilt; Writes sollen am Primary erfolgen (keine Write-Proxy-Umleitung im Code). |
|
||||
| `edgeguard-waf` | Coraza-WAF-Agent (HAProxy SPOE) — Binary im `edgeguard-api`-Paket, eigene systemd-Unit |
|
||||
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, Stale-Node-Sweep, License-Heartbeat) |
|
||||
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `cluster-init-replication`, `cluster-setup-standby`, `dump-config`) |
|
||||
| `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) |
|
||||
| **PostgreSQL 16** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, etc. |
|
||||
| **KeyDB** (Redis-kompatibel) | Active-Active-Replication, Cluster-State, Locks, Rate-Counter, Pub/Sub für Config-Reload |
|
||||
| **PostgreSQL 16/17** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, Cluster-State (`ha_nodes`), Lizenz etc. |
|
||||
| **KeyDB** (optional) | `Recommends`, im Code praktisch ungenutzt — kein Redis-Client in `go.mod`. Cluster-State/Heartbeat/Locks liegen in PostgreSQL, nicht in KeyDB. |
|
||||
|
||||
---
|
||||
|
||||
@@ -61,10 +64,15 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
|
||||
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS)
|
||||
│ ├── firewall/ # nftables-Ruleset-Generator
|
||||
│ ├── cluster/ # Join/Promote/Peer-Discovery, KeyDB-Replication-Setup, pg_basebackup
|
||||
│ ├── proxy/ # API-Write-Proxy-Middleware (Replica → Primary), mTLS-Calls
|
||||
│ ├── aggregator/ # Cluster-View-APIs (alle Backends, alle Peers, alle Health-States)
|
||||
│ └── license/ # License-Validation, License-Leader-Election (KeyDB-Lock)
|
||||
│ ├── cluster/ # Join/Promote/Peer-Discovery, Heartbeat, Logical-Replication-Setup, confighash
|
||||
│ ├── keepalived/ # keepalived/VRRP-Config-Generator (VIP-Failover)
|
||||
│ ├── chrony/ # chrony-Config-Generator (NTP)
|
||||
│ ├── kea/ # Kea-DHCP4-Config-Generator
|
||||
│ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS)
|
||||
│ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert)
|
||||
│ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik
|
||||
│ ├── aggregator/ # Cluster-View-APIs via mTLS (read-only Fan-Out + Trigger-Actions)
|
||||
│ └── license/ # License-Validation (jeder Node verifiziert eigenständig — KEINE KeyDB-Leader-Election)
|
||||
├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/)
|
||||
├── packaging/
|
||||
│ └── debian/
|
||||
@@ -94,17 +102,17 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
## 3. Debian-Pakete
|
||||
|
||||
Drei Pakete + Meta — analog nmg, kein WAF-Paket weil kein WAF in v1.
|
||||
Drei Pakete + Meta — analog nmg. Der WAF-Agent `edgeguard-waf` ist **kein eigenes Paket**, sondern liegt als zusätzliches Binary im `edgeguard-api`-Paket (eigene systemd-Unit).
|
||||
|
||||
| Paket | Arch | Inhalt | Depends |
|
||||
|---|---|---|---|
|
||||
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl}`, Unit-Files, Migrations, Default-Configs | `postgresql-16`, `keydb-server`, `haproxy`, `squid`, `wireguard-tools`, `unbound`, `nftables`, `certbot`, `openssl` |
|
||||
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl,waf}`, Unit-Files, Migrations, Default-Configs | `postgresql-16 \| postgresql-17`, `haproxy (>=2.8)`, `squid`, `wireguard-tools`, `unbound`, `chrony`, `kea-dhcp4-server`, `freeradius`, `nftables`, `keepalived`, `certbot`, `openssl`, `sudo`, `adduser`, `systemd`, `ca-certificates`, `ulogd2`, `ulogd2-json` u. a. · _Recommends:_ `edgeguard-keydb`, `apparmor`, `fail2ban` · _CrowdSec: managed-wenn-installiert (kein Depends)_ |
|
||||
| `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` |
|
||||
| `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` |
|
||||
|
||||
Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnostische × 2 Dists = 4 `.deb` → **8 Artefakte je Release**.
|
||||
Pro Release: 1 arch-spezifisches Paket (`edgeguard-api`) × **1 Dist (trixie)** × 2 Arches = 2 `.deb` + 2 arch-agnostische (`edgeguard-ui`, `edgeguard-meta`) = **4 Artefakte je Release**. (Build/Publish-Pipeline zielt nur auf `trixie`.)
|
||||
|
||||
**KeyDB-Herkunft:** KeyDB ist weder in `trixie` noch `noble` in den offiziellen Repos. Wir bauen es aus Source (amd64 + arm64), veröffentlichen es parallel im eigenen APT-Repo. `edgeguard-api` `Depends: keydb-server` löst aus unserem Repo aus.
|
||||
**KeyDB-Herkunft:** KeyDB ist optional (`Recommends: edgeguard-keydb`), nicht in den offiziellen trixie-Repos. Falls genutzt, aus Source gebaut + im eigenen APT-Repo veröffentlicht. Im aktuellen Code wird KeyDB nicht benötigt — siehe §7.
|
||||
|
||||
**Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow.
|
||||
|
||||
@@ -162,7 +170,8 @@ Entspricht FHS — keine Überraschungen für Admins, Lintian-clean.
|
||||
|
||||
| Unit | Typ | Depends-on | User | Restart |
|
||||
|---|---|---|---|---|
|
||||
| `edgeguard-api.service` | `simple` | `postgresql.service`, `keydb-server.service` | `edgeguard` | `on-failure`, `RestartSec=5` |
|
||||
| `edgeguard-api.service` | `simple` | `Requires=postgresql.service`; `After=`/`Wants=keydb-server.service` (KeyDB nur weich/optional) | `edgeguard` | `on-failure`, `RestartSec=5` |
|
||||
| `edgeguard-waf.service` | `simple` | `edgeguard-api.service` (Coraza SPOE-Agent) | `edgeguard` | `on-failure` |
|
||||
| `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` |
|
||||
| `edgeguard-cert-deploy.path` | `path` | — | — | — |
|
||||
| `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — |
|
||||
@@ -183,7 +192,7 @@ SystemCallFilter=@system-service
|
||||
ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard
|
||||
```
|
||||
|
||||
Drittsoftware (HAProxy, Squid, WireGuard via `wg-quick@.service`, Unbound, nftables) läuft als **Distro-Units**. EdgeGuard generiert deren Config + signalisiert Reload, übernimmt aber die Service-Verwaltung **nicht**.
|
||||
Drittsoftware läuft als **Distro-Units** — EdgeGuard generiert deren Config + signalisiert Reload/Restart, übernimmt aber die Service-Verwaltung weitgehend nicht. Renderer existieren für: **HAProxy, Squid, WireGuard (`wg-quick@.service`), Unbound, nftables, keepalived, chrony, Kea (`kea-dhcp4-server`), FreeRADIUS** (letzte beide default-off). **CrowdSec** (`crowdsec` + `crowdsec-firewall-bouncer`) wird gemanagt, wenn installiert (kein Depends).
|
||||
|
||||
API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends.
|
||||
|
||||
@@ -191,29 +200,28 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf
|
||||
|
||||
## 6. Datenbank-Setup
|
||||
|
||||
- **PostgreSQL 16**, Distro-Paket `postgresql-16`.
|
||||
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 mit TLS-Client-Cert nur zwischen Cluster-Peers für Streaming Replication.
|
||||
- **Topologie:** **ein logischer Primary** zu jedem Zeitpunkt, N Read-Replicas. Lokale API liest immer aus lokaler PG; Writes routet die API-Write-Proxy-Middleware transparent an den aktuellen Primary (KeyDB-Key `cluster:pg-primary-url`).
|
||||
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary gepackt). **Nicht** GORM AutoMigrate.
|
||||
- **PostgreSQL 16/17**, Distro-Paket `postgresql-16 | postgresql-17`.
|
||||
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 (Rolle `edgeguard_replicator`) nur zwischen Cluster-Peers für die Logical-Replication-Verbindung.
|
||||
- **Topologie (Ist-Stand):** **Logical Replication** — ein Primary publiziert `edgeguard_shared` (alle Tabellen außer `localOnlyTables`), N Subscriber (`edgeguard_sub`, `wal_level=logical`, Initialkopie via `copy_data=true`). Jeder Node hat eine **eigene beschreibbare** PG-Instanz; geteilte Config fließt vom Primary zu den Subscribern. **Es gibt keinen Write-Proxy** — Schreibzugriffe auf geteilte Tabellen müssen am Primary erfolgen; ein Subscriber-Write auf eine replizierte Tabelle würde nicht propagieren (Drift-Banner erkennt das via `config_hash`). Primary-Erkennung zuverlässig über `pg_publication`; der Standby-Bootstrap läuft per Logical Subscription (kein `pg_basebackup` im aktiven Pfad).
|
||||
- **node-lokale Tabellen** (nicht repliziert): `ha_nodes`, `network_interfaces`, `ip_addresses`, `static_routes`, `cluster_settings`, `dns_settings`, `ntp_settings`, `dhcp_settings`, `radius_settings`, `system_settings`, `join_tokens_used`, `audit_log`, `alert_events`, `backups`, `goose_db_version` (Liste: `cmd/edgeguard-ctl/cluster_replication.go` `localOnlyTables`).
|
||||
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed`). **Nicht** GORM AutoMigrate.
|
||||
|
||||
GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`.
|
||||
GORM bleibt als ORM für Query-Komfort; Schema-Management läuft über `goose`.
|
||||
|
||||
---
|
||||
|
||||
## 7. KeyDB Active-Active
|
||||
## 7. Cluster-State & KeyDB (Ist-Stand: PostgreSQL-zentrisch)
|
||||
|
||||
KeyDB ersetzt Redis. **Active-Active Replication** (Multi-Master, operation-basiert, split-brain-tolerant).
|
||||
> **Hinweis:** Der ursprüngliche Entwurf sah KeyDB Active-Active als Cluster-State-Layer vor. **Im Code ist das nicht umgesetzt** — es gibt **keinen Redis/KeyDB-Client** (`go.mod` enthält nur `pgx`). KeyDB ist optional (`Recommends`) und wird vom laufenden System nicht benötigt.
|
||||
|
||||
**Verwendung:**
|
||||
- `cluster:pg-primary-url` — wer ist aktueller PG-Primary?
|
||||
- `cluster:license-leader` — Lock für License-Heartbeat (`SET … NX EX 60`)
|
||||
- `cluster:license-status` — Cache des Lizenz-Validate-Ergebnisses (TTL 24 h)
|
||||
- `cluster:nodes:<node-id>` — Heartbeat-Marker (TTL 2 min)
|
||||
- `ratelimit:<scope>:<key>` — Rate-Counter (HINCRBY-Ops mergen korrekt)
|
||||
- `acme:lock:<domain>` — verhindert Parallel-Issue auf zwei Nodes
|
||||
- Pub/Sub: `edgeguard:config-changed` — alle Nodes regenerieren Config
|
||||
**Wie Cluster-State tatsächlich gehalten wird:**
|
||||
- **PG-Primary** — über `pg_publication` (`edgeguard_shared`) ermittelt; die Peer-Adresse für Pushes stammt aus `setup.json` `PrimaryFQDN`.
|
||||
- **Node-Heartbeat/-Status** — Spalten `last_seen`/`status` in PG `ha_nodes`. Jeder Node bumpt seine Row alle 30s (`runClusterHeartbeat`); Secondary→Primary (`runPrimaryPush`) und Primary→Secondary (`runPeerPush`) pushen sich gegenseitig per mTLS (30s, bidirektional). `SweepStaleNodes` (Scheduler) flippt Peers nach 2 min ohne Heartbeat auf `offline`.
|
||||
- **Lizenz** — jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (kein Leader-Lock); Ergebnis in PG `licenses`.
|
||||
- **ACME** — kein verteilter Issue-Lock implementiert (Single-Node-Default; bei Cluster Issue am aktiven/Primary-Node).
|
||||
- `cluster:pg-primary-url` in KeyDB wird von `edgeguard-ctl promote` **geschrieben, falls KeyDB läuft**, aber von der API **nie gelesen** (advisory/Altlast).
|
||||
|
||||
KeyDB hört nur auf `127.0.0.1:6379` für lokale Clients und `<node-ip>:16379` (TLS) für Peer-Replication.
|
||||
_Falls KeyDB künftig wieder eingeführt wird (Rate-Limiting-Counter, Pub/Sub-Config-Reload): hört auf `127.0.0.1:6379` lokal und `<node-ip>:16379` (TLS) für Peer-Replication. Derzeit ungenutzt._
|
||||
|
||||
---
|
||||
|
||||
@@ -234,7 +242,7 @@ Unbound erfüllt zwei Rollen, beide aus PG generiert:
|
||||
- **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`).
|
||||
- Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`.
|
||||
- Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`).
|
||||
- Cluster-interner Traffic (PG-Replication, KeyDB-Replication, mTLS-API-Calls, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
|
||||
- Cluster-interner Traffic (PG-Logical-Replication, mTLS-Agent-Calls auf :8443, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
|
||||
- `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers).
|
||||
|
||||
### Config-Schichten
|
||||
@@ -253,54 +261,56 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
|
||||
|
||||
## 8. Cluster-Topologie & HA pro Service
|
||||
|
||||
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Keine VRRP, keine Master/Backup-Rollen für Daten-Services. Public-IP: **Floating-IP des Hosters** (siehe §9).
|
||||
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Public-IP-Failover via **VIP/VRRP (keepalived)** — siehe §9 (der ursprünglich geplante „Floating-IP statt VRRP"-Ansatz wurde **nicht** umgesetzt).
|
||||
|
||||
| Service | HA-Strategie |
|
||||
|---|---|
|
||||
| **HAProxy** | stateless, pro Node identisch. Floating-IP zeigt zum aktuellen aktiven Node; bei Node-Ausfall API-Call zum Hoster (oder manueller Switch) reicht. ACME-Issue nur auf License-Leader (KeyDB-Lock); Zerts werden via PG/mTLS an alle verteilt. |
|
||||
| **Squid** | stateless (Cache lokal, kein Sync nötig). Pro Node identische ACL-Config. |
|
||||
| **VIP/keepalived** | VRRP (`vrrp_instance`), MASTER/BACKUP per `pg_role` (primary→prio 200/MASTER, standby→100/BACKUP). VIPs aus `ip_addresses` (`is_vip=true`). Trägt den HTTP/HTTPS-Ingress. |
|
||||
| **HAProxy** | stateless, pro Node identisch. Hört auf der VIP des aktiven Node. ACME-Issue ohne verteilten Lock (Single-/Primary-Node); Zerts werden via mTLS (`/agent/cluster/tls-certs`) an alle verteilt. |
|
||||
| **Squid** | stateless (Cache lokal). Pro Node identische ACL-Config. |
|
||||
| **WireGuard** | siehe §8.1 |
|
||||
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + identische Cluster-internen Local-Zones (siehe §7.5). |
|
||||
| **nftables** | pro Node identisch, Ruleset aus PG generiert. `crowdsec_blocklist`/`threat_intel_blocklist`-Sets entfallen in v1 (kein CrowdSec). |
|
||||
| **edgeguard-api** | pro Node, Reads lokal, Writes via Proxy zu Primary. |
|
||||
| **edgeguard-ui** | statisch, pro Node identisch. |
|
||||
| **PostgreSQL** | Streaming Replication, manueller Promote (siehe nmg §6.2). |
|
||||
| **KeyDB** | Active-Active. |
|
||||
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + Cluster-Local-Zones (§7.5). |
|
||||
| **nftables** | pro Node, Ruleset aus PG generiert. CrowdSec-Blocklist via `crowdsec-firewall-bouncer` (eigene Sets), wenn CrowdSec installiert. |
|
||||
| **edgeguard-api** | pro Node, Reads lokal. Writes auf geteilte Tabellen am Primary (kein Write-Proxy). |
|
||||
| **edgeguard-ui / edgeguard-waf** | statisch bzw. pro Node identisch. |
|
||||
| **PostgreSQL** | **Logical Replication** (Publisher→Subscriber), manueller Promote (§8.2). |
|
||||
| **KeyDB** | optional/ungenutzt (§7). |
|
||||
|
||||
### 8.1 WireGuard im Cluster
|
||||
|
||||
Drei Optionen, für v1 wählen wir **Option A**:
|
||||
|
||||
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Floating-IP routet UDP zum aktiven Node. Bei Failover: Floating-IP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~1–2s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
|
||||
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Die **VIP (keepalived)** trägt das WireGuard-UDP zum aktiven Node. Bei Failover: VIP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~1–2s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
|
||||
- B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil.
|
||||
- C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität.
|
||||
|
||||
**Begründung A:** Privatkey ist in PG (verschlüsselt mit `edgeguard.key`), wird beim Cluster-Join an neue Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Operation-Tools (Peer hinzufügen/entfernen) wirken auf alle Nodes via `edgeguard:config-changed` Pub/Sub + lokales `wg syncconf`.
|
||||
**Begründung A:** Privatkey liegt verschlüsselt in PG, wird per Logical Replication an die Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Peer-Änderungen propagieren über die Logical Replication; Secondaries erkennen die Änderung am `config_hash` (`runSecondaryConfigRender`, 5-min-Tick) und re-rendern lokal → `wg syncconf`.
|
||||
|
||||
### 8.2 Manual Promote (PG-Primary-Failover)
|
||||
|
||||
1:1 nmg-Pattern (siehe `mail-gateway/docs/architecture.md` §6.2). Bei Ausfall des Primary antworten Config-Writes mit `503 + actionable Error`. Admin promotet via UI/CLI. Datenebene (HAProxy/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat.
|
||||
Bei Ausfall des Primary läuft die Datenebene (HAProxy/Squid/WireGuard/Unbound) weiter, weil jeder Node eine lokale, lesbare PG-Instanz (Logical-Subscriber) hat. Schreibzugriffe auf geteilte Config müssen am Primary erfolgen — fällt der Primary aus, promotet der Admin manuell via **`edgeguard-ctl promote`**. Das ist Logical-Replication-aware: es löst die Subscription zum toten Primary (`DISABLE` + `slot_name=NONE` + `DROP`, hängt also nicht am toten Publisher), richtet die Node via `setupReplicationPrimary` als Publisher ein (Rolle/Secret/`wal_level=logical` inkl. **PG-Restart** falls nötig/Publication), setzt `ha_nodes.pg_role='primary'` und rendert keepalived (→ MASTER, übernimmt die VIP). Erholte Nodes danach mit `edgeguard-ctl cluster-setup-standby <neuer-primary>` zurückhängen. **Achtung:** echtes Cross-Node-Failover ist nur im Drill testbar — die Bausteine (Drop-Subscription, Publication, Restart) sind dieselben wie in `cluster-init-replication`/`cluster-setup-standby`.
|
||||
|
||||
### 8.3 License-Leader-Election
|
||||
### 8.3 License-Verifikation
|
||||
|
||||
Ein einziger Node kontaktiert `license.netcell-it.com` (KeyDB-Lock, 60-s-TTL). Ergebnis cluster-weit in `cluster:license-status` (TTL 24 h). `active_servers`-Verbrauchswert = Count der Peers mit Heartbeat < 2 min.
|
||||
**Kein Leader-Election** (anders als ursprünglich geplant). Jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (Scheduler-Tick), Ergebnis in PG `licenses`. `active_servers` = Anzahl Peers mit Heartbeat < 2 min (aus `ha_nodes`). Ein KeyDB-Lock existiert nicht.
|
||||
|
||||
---
|
||||
|
||||
## 9. Public-Ingress — Floating-IP statt VRRP
|
||||
## 9. Public-Ingress — VIP via keepalived/VRRP
|
||||
|
||||
**Problem:** HTTP-Clients machen kein automatisches Failover bei DNS-RR (anders als MTAs). Ein toter A-Record = 50% Fehler bis DNS-TTL.
|
||||
> **Ist-Stand:** Umgesetzt ist **VIP-Failover über keepalived (VRRP)** — nicht der ursprünglich angedachte „Floating-IP des Hosters"-Ansatz. Es gibt **keinen** Hoster-API-Code und **keinen** `POST /cluster/promote-this-node`-Endpoint.
|
||||
|
||||
**Entscheidung:** **Floating-IP des Hosters**. Der Hoster bietet eine API zum Umroute der IP zwischen Servern (z. B. via REST oder DNS-Update bei dynamischer Anycast-Lösung). Failover dauert Sekunden, kein VRRP-Drama, kein "VIP verschwindet"-Problem aus dem alten Setup.
|
||||
**Mechanik (`internal/keepalived`):**
|
||||
- Renderer erzeugt `/etc/keepalived/keepalived.conf` mit `vrrp_instance` (unicast peer, `virtual_router_id`, `authentication`).
|
||||
- **State/Priorität aus `pg_role`:** Primary → `state MASTER`, `priority 200`; Standby → `state BACKUP`, `priority 100`.
|
||||
- **VIPs** kommen aus `ip_addresses` (`is_vip=true`, `active=true`), inkl. Interface; managed via `systemctl reload-or-restart keepalived`.
|
||||
- Bei Node-/PG-Ausfall übernimmt VRRP die VIP auf den verbleibenden Node (Sekundenbereich).
|
||||
|
||||
Optionen pro Hoster:
|
||||
1. **Provider-Floating-IP** (gewünscht): API-Call schaltet IP um. EdgeGuard exponiert `POST /api/v1/cluster/promote-this-node`, das die Hoster-API aufruft.
|
||||
2. **DNS-RR mit kurzer TTL (60s)** als Notlösung wenn keine Floating-IP verfügbar.
|
||||
3. **Anycast/BGP** als Premium-Variante (für Enterprise).
|
||||
**Tooling:** `GET/PUT /cluster/vip-settings`, `GET /cluster/vip-status`, `POST /cluster/vip-test` (Letzteres bewegt eine VIP testweise per `ip addr add/del` zwischen Nodes — kein Hoster-Call).
|
||||
|
||||
**v1-Default:** Single-Node mit fest zugewiesener Floating-IP. Cluster-Erweiterung kommt mit Phase 2.
|
||||
**v1-Default:** Single-Node. Im Cluster trägt der MASTER (Primary) die VIP.
|
||||
|
||||
⚑ **OFFEN:** Welcher Hoster ist Standard? API-Spec dokumentieren sobald geklärt.
|
||||
⚑ **OFFEN (Altlast-Bereinigung):** Doku-Abschnitte/Code, die noch „Floating-IP des Hosters" implizieren, sind historisch — der reale Pfad ist keepalived/VRRP.
|
||||
|
||||
---
|
||||
|
||||
@@ -312,7 +322,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
|
||||
|
||||
Schritte (idempotent, analog `netcell-webpanel/install.sh`):
|
||||
|
||||
1. **OS-Detection** (`/etc/os-release`): nur Trixie *oder* Noble, sonst Abbruch.
|
||||
1. **OS-Detection** (`/etc/os-release`): nur Debian 13 (Trixie), sonst Abbruch.
|
||||
2. **Arch-Detection**: nur amd64 *oder* arm64.
|
||||
3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`.
|
||||
4. **APT-Keyrings:**
|
||||
@@ -329,7 +339,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
|
||||
--token <cluster-join-token>
|
||||
```
|
||||
|
||||
`edgeguard-ctl cluster-join` führt aus: PG-Basebackup vom Primary, KeyDB-Replication-Setup, Node-Registrierung in `ha_nodes`, TLS-Cert-Pull via mTLS, Config-Regeneration, Service-Start.
|
||||
`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert), Node-Registrierung in `ha_nodes` (`autoRegister`), Setup als **Logical-Replication-Subscriber** (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen), Config-Regeneration, Service-Start. _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
|
||||
|
||||
---
|
||||
|
||||
@@ -337,7 +347,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
|
||||
|
||||
- **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`).
|
||||
- **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea).
|
||||
- **Suiten:** `stable` · `testing` · `security` — pro Codename (`trixie`, `noble`).
|
||||
- **Suiten:** `stable` · `testing` · `security` — Codename `trixie`.
|
||||
- **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`.
|
||||
- **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`.
|
||||
- **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`).
|
||||
@@ -355,13 +365,13 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
|
||||
- **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key).
|
||||
- **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`.
|
||||
- **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`.
|
||||
- **Caching:** Live → KeyDB `cluster:license-status` (TTL 24h) → `/var/lib/edgeguard/trial.json` (30 Tage) → `expired`.
|
||||
- **Leader-Election** wie nmg §6.3.
|
||||
- **Caching:** Live-Verify → Ergebnis in PG `licenses` → `/var/lib/edgeguard/trial.json` (30-Tage-Trial-Fallback) → `expired`.
|
||||
- **Keine Leader-Election** — jeder Node verifiziert eigenständig (§8.3).
|
||||
|
||||
### 12.2 ACME
|
||||
|
||||
- **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert.
|
||||
- **Lock vor Issue:** `acme:lock:<domain>` in KeyDB verhindert Parallel-Issue auf zwei Nodes.
|
||||
- **Cluster-Locking:** derzeit **kein** verteilter Issue-Lock implementiert (Single-Node-Default; im Cluster sollte ACME am Primary/aktiven Node laufen). _(Der ursprünglich geplante KeyDB-`acme:lock:<domain>` existiert nicht.)_
|
||||
- **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu.
|
||||
- **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`.
|
||||
|
||||
@@ -384,14 +394,12 @@ Komponentenbibliothek, Theme, Layouts, Navigations-Struktur, Form-Patterns, i18n
|
||||
|
||||
## 14. Plattform-Matrix
|
||||
|
||||
| Distribution | Codename | Arch | Status v1 |
|
||||
| Distribution | Codename | Arch | Status |
|
||||
|---|---|---|---|
|
||||
| Debian 13 | trixie | amd64 | Tier 1 |
|
||||
| Debian 13 | trixie | arm64 | Tier 1 |
|
||||
| Ubuntu 24.04 LTS | noble | amd64 | Tier 1 |
|
||||
| Ubuntu 24.04 LTS | noble | arm64 | Tier 1 |
|
||||
|
||||
Andere Distributionen (Debian 12, Ubuntu 22.04, RHEL/Rocky) sind **nicht unterstützt**. Installer bricht hart ab.
|
||||
**Nur Debian 13 (Trixie).** Die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`; der Installer bricht auf anderem OS hart ab. _(Eine ursprünglich geplante Ubuntu-24.04-„noble"-Matrix ist nicht implementiert.)_ Andere Distributionen (Debian 12, Ubuntu, RHEL/Rocky) sind **nicht unterstützt**.
|
||||
|
||||
---
|
||||
|
||||
@@ -402,7 +410,7 @@ EdgeGuard-Native ist eigenes Repo (`git.netcell-it.de/projekte/edgeguard-native`
|
||||
1. **Frische Installation** auf Test-VM via `install.sh`.
|
||||
2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format.
|
||||
3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen).
|
||||
4. **Cutover** via Floating-IP-Switch.
|
||||
4. **Cutover** via VIP-Umzug (keepalived) bzw. DNS-Umstellung.
|
||||
|
||||
Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features.
|
||||
|
||||
@@ -410,6 +418,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- **Hoster + Floating-IP-API** (§9): Spec dokumentieren.
|
||||
- **WireGuard-State-Replication** in der Praxis testen (Handshake-Latenz nach Floating-IP-Switch messen).
|
||||
- **Failover-Drill:** `edgeguard-ctl promote` (Logical-aware) + anschließendes `cluster-setup-standby` in einem echten 2-Node-Failover durchspielen (inkl. VIP-Umzug, WireGuard-Handshake-Latenz). _(Code-Altlasten `internal/proxy`-Stub und `promote.go`-`standby.signal` wurden 2026-06 bereinigt.)_
|
||||
- **Optional KeyDB** (Rate-Limit-Counter, Pub/Sub-Config-Reload) — falls je benötigt; aktuell ungenutzt.
|
||||
- **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen.
|
||||
|
||||
@@ -76,6 +76,11 @@ var hashSpec = []hashTable{
|
||||
{Name: "dhcp_subnets"},
|
||||
{Name: "dhcp_reservations"},
|
||||
|
||||
// RADIUS: Clients + Users sind geteilte Config (repliziert).
|
||||
// radius_settings ist node-lokal → NICHT hier.
|
||||
{Name: "radius_clients"},
|
||||
{Name: "radius_users"},
|
||||
|
||||
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
|
||||
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
|
||||
// repliziert und sind per Design node-spezifisch (jede Node hat eigene
|
||||
|
||||
51
internal/database/migrations/0042_radius.sql
Normal file
51
internal/database/migrations/0042_radius.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- RADIUS (FreeRADIUS) — node-lokale Singleton-Settings (ob DIESE Node
|
||||
-- RADIUS betreibt + Listen). Clients/Users sind geteilte Config (repliziert).
|
||||
CREATE TABLE IF NOT EXISTS radius_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
listen_addresses TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT radius_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
INSERT INTO radius_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- NAS-Clients (Geräte, die RADIUS-Requests senden): IP/CIDR + Shared Secret
|
||||
-- (verschlüsselt via secrets.Box).
|
||||
CREATE TABLE IF NOT EXISTS radius_clients (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ipaddr TEXT NOT NULL,
|
||||
secret_enc BYTEA,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT radius_clients_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
-- Benutzer (PAP/CHAP): Name + Passwort (verschlüsselt; Cleartext nur zur
|
||||
-- Render-Zeit in die freeradius-lesbare authorize-Datei).
|
||||
CREATE TABLE IF NOT EXISTS radius_users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_enc BYTEA,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT radius_users_username_unique UNIQUE (username)
|
||||
);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS radius_users;
|
||||
DROP TABLE IF EXISTS radius_clients;
|
||||
DROP TABLE IF EXISTS radius_settings;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -150,6 +150,7 @@ type AutoFWRule struct {
|
||||
Proto string
|
||||
Port int
|
||||
DstIP string
|
||||
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostisch
|
||||
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
|
||||
Comment string
|
||||
}
|
||||
@@ -434,7 +435,44 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
// RADIUS (FreeRADIUS): wenn aktiviert → udp 1812 (auth) + 1813 (acct).
|
||||
// Pro listen-IP, sonst global. FreeRADIUS verwirft unbekannte Clients selbst.
|
||||
var radiusEnabled bool
|
||||
var radiusListen string
|
||||
if err := g.Pool.QueryRow(ctx, `SELECT enabled, listen_addresses FROM radius_settings WHERE id=1`).Scan(&radiusEnabled, &radiusListen); err == nil && radiusEnabled {
|
||||
ips := splitCSV(radiusListen)
|
||||
emit := func(ip string) {
|
||||
out = append(out,
|
||||
AutoFWRule{Proto: "udp", Port: 1812, DstIP: ip, Comment: "RADIUS-Auth (FreeRADIUS)"},
|
||||
AutoFWRule{Proto: "udp", Port: 1813, DstIP: ip, Comment: "RADIUS-Acct (FreeRADIUS)"},
|
||||
)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
emit("")
|
||||
} else {
|
||||
for _, ip := range ips {
|
||||
if !isLoopback(ip) && ip != "0.0.0.0" && ip != "::" {
|
||||
emit(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Familien-Tag (ip/ip6) für DstIP-basierte Auto-Rules setzen; eine
|
||||
// IPv6-Listen-Adresse muss `ip6 daddr` ergeben (sonst lehnt nft das
|
||||
// gesamte Ruleset ab). Unparsebare DstIPs werden verworfen.
|
||||
tagged := out[:0]
|
||||
for _, r := range out {
|
||||
if r.DstIP != "" {
|
||||
fam := addrFamily(r.DstIP)
|
||||
if fam == "" {
|
||||
continue
|
||||
}
|
||||
r.L3 = fam
|
||||
}
|
||||
tagged = append(tagged, r)
|
||||
}
|
||||
return tagged
|
||||
}
|
||||
|
||||
// splitCSV — wie in den Service-renderern.
|
||||
|
||||
@@ -15,7 +15,8 @@ func TestTemplate_autoRuleIface(t *testing.T) {
|
||||
view := &View{
|
||||
AutoRules: []AutoFWRule{
|
||||
{Proto: "udp", Port: 67, Iface: "eth1", Comment: "DHCP (Kea) auf eth1"},
|
||||
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", Comment: "DNS"},
|
||||
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", L3: "ip", Comment: "DNS"},
|
||||
{Proto: "udp", Port: 53, DstIP: "2001:db8::1", L3: "ip6", Comment: "DNS v6"},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
@@ -27,9 +28,13 @@ func TestTemplate_autoRuleIface(t *testing.T) {
|
||||
if !strings.Contains(out, `iifname "eth1" udp dport 67 accept comment "auto: DHCP (Kea) auf eth1"`) {
|
||||
t.Errorf("missing iface-scoped DHCP auto-rule\n----\n%s", out)
|
||||
}
|
||||
// Regression: DstIP-Auto-Rule ohne Iface bleibt unverändert.
|
||||
// v4-DstIP-Auto-Rule: ip daddr.
|
||||
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
|
||||
t.Errorf("DstIP auto-rule changed\n----\n%s", out)
|
||||
t.Errorf("v4 DstIP auto-rule wrong\n----\n%s", out)
|
||||
}
|
||||
// Fix #5: v6-DstIP muss `ip6 daddr` ergeben (sonst bricht nft das Ruleset).
|
||||
if !strings.Contains(out, `ip6 daddr 2001:db8::1 udp dport 53 accept`) {
|
||||
t.Errorf("v6 DstIP auto-rule must use ip6 daddr\n----\n%s", out)
|
||||
}
|
||||
|
||||
// Echte nft-Syntaxvalidierung (braucht root → via sudo, sonst skip).
|
||||
|
||||
@@ -61,7 +61,7 @@ table inet edgeguard {
|
||||
# editiert diese nicht. Wenn der Service entfernt/disabled
|
||||
# wird, ist die Rule beim nächsten Render weg.
|
||||
{{range .AutoRules}}
|
||||
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
||||
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}{{.L3}} daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
||||
{{end}}
|
||||
|
||||
# ── Operator-defined rules ──
|
||||
|
||||
157
internal/freeradius/freeradius.go
Normal file
157
internal/freeradius/freeradius.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Package freeradius renders the FreeRADIUS client + user files from the
|
||||
// radius_* tables and manages the freeradius service lifecycle.
|
||||
//
|
||||
// Two files are rendered (mirrors the multi-file WireGuard renderer):
|
||||
// - clients.conf — NAS clients (ipaddr + shared secret)
|
||||
// - authorize — users file ("name" Cleartext-Password := "pw")
|
||||
// Both managed under /etc/edgeguard/freeradius/ and symlinked from the
|
||||
// distro paths by postinst. Shared secrets / passwords are decrypted via
|
||||
// secrets.Box at render time. Service runs ONLY when radius_settings.enabled
|
||||
// is true on this node (default off).
|
||||
package freeradius
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfDir = configgen.EtcEdgeguard + "/freeradius"
|
||||
ClientsPath = ConfDir + "/clients.conf"
|
||||
AuthorizePath = ConfDir + "/authorize"
|
||||
serviceName = "freeradius"
|
||||
)
|
||||
|
||||
type Generator struct {
|
||||
Pool *pgxpool.Pool
|
||||
Repo *radiussvc.Repo
|
||||
Box *secrets.Box
|
||||
SkipReload bool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
||||
return &Generator{Pool: pool, Repo: radiussvc.New(pool, box), Box: box}
|
||||
}
|
||||
|
||||
func (g *Generator) Name() string { return "freeradius" }
|
||||
|
||||
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote)
|
||||
// und strippt Steuerzeichen (CR/LF) als Defense-in-Depth gegen Zeilen-
|
||||
// Injection — die Werte werden zwar schon im Handler validiert.
|
||||
func confEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
return s
|
||||
}
|
||||
|
||||
// buildClients rendert clients.conf. mask=true ersetzt Secrets durch *** (Preview).
|
||||
func (g *Generator) buildClients(ctx context.Context, mask bool) (string, error) {
|
||||
clients, err := g.Repo.ListClients(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
|
||||
for _, c := range clients {
|
||||
if !c.Active {
|
||||
continue
|
||||
}
|
||||
secret := "***"
|
||||
if !mask {
|
||||
pt, err := g.Box.Open(c.SecretEnc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open secret for client %s: %w", c.Name, err)
|
||||
}
|
||||
secret = string(pt)
|
||||
}
|
||||
fmt.Fprintf(&b, "client %s {\n ipaddr = %s\n secret = \"%s\"\n shortname = %s\n}\n\n",
|
||||
c.Name, c.IPAddr, confEscape(secret), c.Name)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// buildAuthorize rendert die Users-Datei. mask=true ersetzt Passwörter durch ***.
|
||||
func (g *Generator) buildAuthorize(ctx context.Context, mask bool) (string, error) {
|
||||
users, err := g.Repo.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
|
||||
for _, u := range users {
|
||||
if !u.Active {
|
||||
continue
|
||||
}
|
||||
pw := "***"
|
||||
if !mask {
|
||||
pt, err := g.Box.Open(u.PasswordEnc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open password for user %s: %w", u.Username, err)
|
||||
}
|
||||
pw = string(pt)
|
||||
}
|
||||
fmt.Fprintf(&b, "\"%s\" Cleartext-Password := \"%s\"\n", confEscape(u.Username), confEscape(pw))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// RenderToString liefert beide Dateien (Secrets maskiert) für die Preview.
|
||||
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||
clients, err := g.buildClients(ctx, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorize, err := g.buildAuthorize(ctx, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "# ── clients.conf ──\n" + clients + "\n# ── authorize ──\n" + authorize, nil
|
||||
}
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
settings, err := g.Repo.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get radius settings: %w", err)
|
||||
}
|
||||
|
||||
if !settings.Enabled {
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
_ = configgen.DisableService(serviceName)
|
||||
_ = configgen.StopService(serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
clients, err := g.buildClients(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authorize, err := g.buildAuthorize(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := configgen.AtomicWrite(ClientsPath, []byte(clients), 0o640); err != nil {
|
||||
return fmt.Errorf("write clients.conf: %w", err)
|
||||
}
|
||||
if err := configgen.AtomicWrite(AuthorizePath, []byte(authorize), 0o640); err != nil {
|
||||
return fmt.Errorf("write authorize: %w", err)
|
||||
}
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
if err := configgen.EnableService(serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
return configgen.RestartService(serviceName)
|
||||
}
|
||||
83
internal/freeradius/freeradius_test.go
Normal file
83
internal/freeradius/freeradius_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package freeradius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
|
||||
func TestRender_ClientsAndUsers(t *testing.T) {
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the freeradius renderer test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
var mErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if mErr = database.Migrate(ctx, dsn); mErr == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if mErr != nil {
|
||||
t.Fatalf("migrate: %v", mErr)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
for _, q := range []string{`DELETE FROM radius_clients`, `DELETE FROM radius_users`} {
|
||||
if _, err := pool.Exec(ctx, q); err != nil {
|
||||
t.Fatalf("clean: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
box := secrets.New(t.TempDir() + "/master_key")
|
||||
repo := radiussvc.New(pool, box)
|
||||
if _, err := repo.CreateClient(ctx, "testnas", "10.0.0.0/24", `s3c"ret\x`, true, "lab"); err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
if _, err := repo.CreateUser(ctx, "alice", "alicepw", true); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
g := New(pool, box)
|
||||
|
||||
clients, err := g.buildClients(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("buildClients: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"client testnas {",
|
||||
"ipaddr = 10.0.0.0/24",
|
||||
`secret = "s3c\"ret\\x"`, // " und \ escaped → Secret-Roundtrip + Escaping
|
||||
"shortname = testnas",
|
||||
} {
|
||||
if !strings.Contains(clients, want) {
|
||||
t.Errorf("clients.conf missing %q\n----\n%s", want, clients)
|
||||
}
|
||||
}
|
||||
|
||||
authorize, err := g.buildAuthorize(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAuthorize: %v", err)
|
||||
}
|
||||
if !strings.Contains(authorize, `"alice" Cleartext-Password := "alicepw"`) {
|
||||
t.Errorf("authorize missing alice entry\n----\n%s", authorize)
|
||||
}
|
||||
|
||||
// Maskierte Preview enthält keine echten Secrets.
|
||||
masked, _ := g.buildClients(ctx, true)
|
||||
if strings.Contains(masked, "s3c") {
|
||||
t.Errorf("masked preview leaked secret:\n%s", masked)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
actor, role := "", "admin"
|
||||
remote := c.ClientIP()
|
||||
var totpEnabled bool
|
||||
var viaDB bool // true wenn Rolle/TOTP bereits aus der DB-Row stammen
|
||||
|
||||
// 1. Try DB users table first.
|
||||
if h.Users != nil {
|
||||
@@ -134,6 +135,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
actor = ai.Email
|
||||
role = ai.Role
|
||||
totpEnabled = ai.TOTPEnabled
|
||||
viaDB = true
|
||||
h.Users.RecordLogin(c.Request.Context(), ai.ID)
|
||||
}
|
||||
}
|
||||
@@ -168,6 +170,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bei Fallback (Setup-Store) / Federation (Primary) stammen role/TOTP
|
||||
// NICHT aus der DB. Rolle + TOTP-Status autoritativ aus der lokalen
|
||||
// (replizierten) users-Row ableiten — damit 2FA greift und die Rolle
|
||||
// nie aus einer Remote-Payload kommt. Ist der User lokal (noch) nicht
|
||||
// vorhanden (Replikations-Lag/DB aus), bleibt es beim Fallback-Wert.
|
||||
if actor != "" && !viaDB && h.Users != nil {
|
||||
if ai, err := h.Users.FindForAuth(c.Request.Context(), actor); err == nil {
|
||||
role = ai.Role
|
||||
totpEnabled = ai.TOTPEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// TOTP gate: password OK but 2FA required → issue a short-lived pending
|
||||
// cookie and tell the UI to show the TOTP input.
|
||||
if totpEnabled {
|
||||
|
||||
@@ -866,6 +866,7 @@ type registerPeerRequest struct {
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
||||
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
|
||||
}
|
||||
|
||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||
@@ -904,12 +905,21 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
|
||||
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
|
||||
// wir brauchen.)
|
||||
// Rolle aus dem Request (default "peer"). Ein joining-Peer sendet keine
|
||||
// Rolle → "peer". Der Primary-Push sendet "primary", damit die vom
|
||||
// Secondary ausgelieferte UI den Primary korrekt als primary zeigt.
|
||||
// Cert-CN authentifiziert die FQDN; role ist node-lokal/Anzeige (echte
|
||||
// Rollenerkennung läuft über pg_publication).
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "peer"
|
||||
}
|
||||
n := models.HANode{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
FQDN: req.FQDN,
|
||||
APIURL: req.APIURL,
|
||||
Role: "peer",
|
||||
Role: role,
|
||||
Status: "online", // peer IS online — it just connected via mTLS
|
||||
}
|
||||
if req.PublicIP != "" {
|
||||
@@ -965,7 +975,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("cluster: peer registered via mTLS",
|
||||
// Bei neuem Peer / IP-Wechsel als Info loggen (relevantes Ereignis),
|
||||
// sonst Debug — die periodischen 30s-Pushes (runPrimaryPush/runPeerPush)
|
||||
// würden sonst das Log fluten.
|
||||
logFn := slog.Debug
|
||||
if ipChanged {
|
||||
logFn = slog.Info
|
||||
}
|
||||
logFn("cluster: peer registered via mTLS",
|
||||
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
|
||||
"client_cn", cn, "remote", c.ClientIP())
|
||||
response.OK(c, out)
|
||||
|
||||
@@ -81,7 +81,14 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.nodeHasPublication(ctx) {
|
||||
isPrimary, err := h.nodeHasPublication(ctx)
|
||||
if err != nil {
|
||||
// Primary/Subscriber-Status nicht ermittelbar → NICHT raten
|
||||
// (sonst Resync auf dem falschen Node). Abbrechen.
|
||||
response.Internal(c, fmt.Errorf("primary-status nicht ermittelbar: %w", err))
|
||||
return
|
||||
}
|
||||
if isPrimary {
|
||||
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
|
||||
if h.Aggregator == nil {
|
||||
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
|
||||
@@ -162,7 +169,12 @@ func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) er
|
||||
}
|
||||
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
|
||||
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
|
||||
if h.nodeHasPublication(ctx) {
|
||||
// Bei Statusfehler fail-closed (NICHT resyncen).
|
||||
isPrimary, err := h.nodeHasPublication(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publication-status nicht ermittelbar: %w", err)
|
||||
}
|
||||
if isPrimary {
|
||||
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
|
||||
}
|
||||
if st := repairUnitState(); st == "activating" || st == "active" {
|
||||
@@ -201,9 +213,9 @@ rm -f %[2]s
|
||||
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
|
||||
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
|
||||
// DB-User lesbar (anders als pg_subscription).
|
||||
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
|
||||
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) (bool, error) {
|
||||
if h.Store == nil || h.Store.Pool == nil {
|
||||
return false
|
||||
return false, errors.New("no db pool")
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -211,9 +223,9 @@ func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
|
||||
if err := h.Store.Pool.QueryRow(cctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
|
||||
).Scan(&exists); err != nil {
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
return exists
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
|
||||
@@ -231,7 +243,9 @@ type repairStatusResponse struct {
|
||||
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
|
||||
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
if h.Store != nil && h.nodeHasPublication(ctx) && h.Aggregator != nil {
|
||||
// Status-Poll: bei Fehler kein 500 — einfach lokalen Status liefern.
|
||||
isPrimary, _ := h.nodeHasPublication(ctx)
|
||||
if h.Store != nil && isPrimary && h.Aggregator != nil {
|
||||
if all, err := h.Store.List(ctx); err == nil {
|
||||
if peer := findOtherPeer(all, h.LocalID); peer != nil {
|
||||
results := h.Aggregator.FanOut(ctx,
|
||||
|
||||
@@ -7,14 +7,21 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
)
|
||||
|
||||
// ruStateMu serialisiert Lesen/Schreiben der Rolling-Update-State-Datei
|
||||
// (HTTP-Handler + Hintergrund-Goroutine greifen gleichzeitig zu).
|
||||
var ruStateMu sync.Mutex
|
||||
|
||||
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
|
||||
|
||||
const (
|
||||
@@ -26,17 +33,24 @@ const (
|
||||
phaseFailed = "failed"
|
||||
)
|
||||
|
||||
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen. Wenn die
|
||||
// State-Datei "updating-primary" enthält, bedeutet das dass der Primary
|
||||
// gerade erfolgreich neugestartet ist → Update abgeschlossen → "done" schreiben.
|
||||
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen.
|
||||
// - "updating-primary": der Primary ist gerade erfolgreich neugestartet →
|
||||
// Update abgeschlossen → "done".
|
||||
// - "updating-secondary"/"waiting-secondary": die orchestrierende Goroutine
|
||||
// lief in DIESEM (jetzt neu gestarteten) Prozess und ist mit ihm gestorben.
|
||||
// Die Phase kann nicht weiterlaufen → auf "idle" zurücksetzen, sonst zeigt
|
||||
// die UI ewig "Rolling Update läuft". (Vorher blieb so ein Stand hängen.)
|
||||
func FinishRollingUpdateIfPending() {
|
||||
st := readRollingUpdateState()
|
||||
if st.Phase == phaseUpdatingPrimary {
|
||||
switch st.Phase {
|
||||
case phaseUpdatingPrimary:
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseDone,
|
||||
SecondaryID: st.SecondaryID,
|
||||
SecondaryFQDN: st.SecondaryFQDN,
|
||||
})
|
||||
case phaseUpdatingSecondary, phaseWaitingSecondary:
|
||||
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +67,8 @@ type RollingUpdateState struct {
|
||||
}
|
||||
|
||||
func readRollingUpdateState() RollingUpdateState {
|
||||
ruStateMu.Lock()
|
||||
defer ruStateMu.Unlock()
|
||||
data, err := os.ReadFile(rollingUpdateStateFile)
|
||||
if err != nil {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
@@ -61,6 +77,13 @@ func readRollingUpdateState() RollingUpdateState {
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
}
|
||||
// Terminale Zustände altern aus (statt Mutation-on-GET): nach 10 min
|
||||
// gilt done/failed als idle — so verliert kein paralleler Poller das
|
||||
// Ergebnis und ein alter Stand bleibt nicht hängen.
|
||||
if (s.Phase == phaseDone || s.Phase == phaseFailed) && !s.UpdatedAt.IsZero() &&
|
||||
time.Since(s.UpdatedAt) > 10*time.Minute {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -71,7 +94,10 @@ func writeRollingUpdateState(s RollingUpdateState) {
|
||||
slog.Warn("rolling-update: failed to marshal state", "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(rollingUpdateStateFile, data, 0o600); err != nil {
|
||||
ruStateMu.Lock()
|
||||
defer ruStateMu.Unlock()
|
||||
// AtomicWrite (temp+rename) → Leser sehen nie einen partiellen Stand.
|
||||
if err := configgen.AtomicWrite(rollingUpdateStateFile, data, 0o600); err != nil {
|
||||
slog.Warn("rolling-update: failed to write state file", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -127,76 +153,108 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
|
||||
// Bei phase == "done" wird nach Auslieferung sofort auf idle zurückgesetzt
|
||||
// damit der nächste Pageload keinen Stale-done vorfindet.
|
||||
// Read-only — terminale Zustände altern in readRollingUpdateState aus
|
||||
// (kein Reset-on-GET mehr, das parallelen Pollern das "done" wegnahm).
|
||||
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
|
||||
st := readRollingUpdateState()
|
||||
response.OK(c, st)
|
||||
if st.Phase == phaseDone {
|
||||
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
|
||||
}
|
||||
response.OK(c, readRollingUpdateState())
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Secondary triggern
|
||||
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
|
||||
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
|
||||
if !result.OK {
|
||||
// Zielversion = das verfügbare apt-Candidate (worauf wir hochziehen) und
|
||||
// die aktuelle Secondary-Version als Baseline. Beides steuert, ob der
|
||||
// Secondary überhaupt etwas zu tun hat.
|
||||
candidate := rollingCandidateVersion(ctx)
|
||||
baseline := secondaryVersion(ctx, h, secondary)
|
||||
|
||||
// Ist der Secondary bereits auf der Zielversion, gibt es nichts
|
||||
// hochzuziehen — KEIN Trigger, KEIN Warten. Sonst würde auf einen
|
||||
// Version-Flip gewartet, der nie kommt → 10-min-Timeout (der frühere Bug,
|
||||
// wenn beide Nodes schon aktuell waren).
|
||||
secondaryUpToDate := candidate != "" && baseline != "" && baseline == candidate
|
||||
if secondaryUpToDate {
|
||||
slog.Info("rolling-update: secondary already at target — skipping secondary step",
|
||||
"version", candidate)
|
||||
} else {
|
||||
// 1. Secondary triggern
|
||||
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
|
||||
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
|
||||
if !result.OK {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "trigger-update failed: " + result.Err,
|
||||
})
|
||||
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Secondary-Version pollen — der Secondary restartet nach dem
|
||||
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
Phase: phaseWaitingSecondary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "trigger-update failed: " + result.Err,
|
||||
})
|
||||
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
|
||||
return
|
||||
}
|
||||
slog.Info("rolling-update: waiting for secondary version flip",
|
||||
"baseline", baseline, "candidate", candidate)
|
||||
|
||||
// 2. Secondary-Version pollen — der Secondary restartet nach dem
|
||||
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseWaitingSecondary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
})
|
||||
slog.Info("rolling-update: waiting for secondary version flip")
|
||||
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
|
||||
time.Sleep(20 * time.Second)
|
||||
|
||||
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
|
||||
time.Sleep(20 * time.Second)
|
||||
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
versionFlipped := false
|
||||
for time.Now().Before(deadline) {
|
||||
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var ver struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
|
||||
slog.Info("rolling-update: secondary version", "version", ver.Version, "primary", h.Version)
|
||||
if ver.Version != h.Version {
|
||||
versionFlipped = true
|
||||
break
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
versionFlipped := false
|
||||
for time.Now().Before(deadline) {
|
||||
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var ver struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
|
||||
slog.Info("rolling-update: secondary version", "version", ver.Version,
|
||||
"baseline", baseline, "candidate", candidate)
|
||||
// Erfolg = Secondary hat die Zielversion erreicht (candidate)
|
||||
// ODER hat sich gegenüber der Baseline überhaupt bewegt
|
||||
// (Fallback, wenn candidate nicht ermittelbar war).
|
||||
if ver.Version != "" &&
|
||||
((candidate != "" && ver.Version == candidate) || ver.Version != baseline) {
|
||||
versionFlipped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
|
||||
if !versionFlipped {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "timeout (10 min) waiting for secondary version flip",
|
||||
})
|
||||
slog.Warn("rolling-update: secondary version flip timeout")
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
|
||||
if !versionFlipped {
|
||||
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade.
|
||||
// Ist der Primary bereits auf der Zielversion (z. B. beide Nodes schon
|
||||
// aktuell), gibt es nichts zu tun → direkt "done". Sonst liefe ein
|
||||
// apt-Lauf ohne Paket-Wechsel → kein Restart → Phase hinge ewig in
|
||||
// "updating-primary".
|
||||
if candidate != "" && h.Version == candidate {
|
||||
slog.Info("rolling-update: primary already at target — nothing to upgrade", "version", candidate)
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
Phase: phaseDone,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "timeout (10 min) waiting for secondary version flip",
|
||||
})
|
||||
slog.Warn("rolling-update: secondary version flip timeout")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseUpdatingPrimary,
|
||||
SecondaryID: secondary.ID,
|
||||
@@ -256,3 +314,26 @@ rm -f /var/lib/edgeguard/upgrade.sh
|
||||
// UI erkennt Version-Flip via /system/health und schließt den Flow.
|
||||
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
|
||||
}
|
||||
|
||||
// rollingCandidateVersion liefert best-effort die verfügbare apt-Candidate-
|
||||
// Version des Meta-Pakets "edgeguard" — also die Version, auf die das Rolling-
|
||||
// Update hochzieht. Leerer String, wenn apt sie nicht ermitteln kann (dann
|
||||
// fällt runRollingUpdate auf reine Baseline-Flip-Erkennung zurück).
|
||||
func rollingCandidateVersion(ctx context.Context) string {
|
||||
vers := aptsvc.PackageVersions(ctx, false)
|
||||
return vers["edgeguard_available"]
|
||||
}
|
||||
|
||||
// secondaryVersion holt best-effort die laufende Version des Peers via mTLS.
|
||||
func secondaryVersion(ctx context.Context, h *ClusterHandler, secondary *models.HANode) string {
|
||||
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var ver struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.Unmarshal(results[0].Data, &ver) == nil {
|
||||
return ver.Version
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
359
internal/handlers/radius.go
Normal file
359
internal/handlers/radius.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
)
|
||||
|
||||
// RADIUSHandler exposes /api/v1/radius/{settings,clients,users} for the
|
||||
// FreeRADIUS server (files-based PAP/CHAP).
|
||||
type RADIUSHandler struct {
|
||||
Repo *radiussvc.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewRADIUSHandler(repo *radiussvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *RADIUSHandler {
|
||||
return &RADIUSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) reload(ctx context.Context, op string) {
|
||||
if h.Reloader == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Reloader(ctx); err != nil {
|
||||
slog.Warn("freeradius: reload after mutation failed", "op", op, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/radius")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
|
||||
c := g.Group("/clients")
|
||||
c.GET("", h.ListClients)
|
||||
c.POST("", h.CreateClient)
|
||||
c.GET("/:id", h.GetClient)
|
||||
c.PUT("/:id", h.UpdateClient)
|
||||
c.DELETE("/:id", h.DeleteClient)
|
||||
|
||||
u := g.Group("/users")
|
||||
u.GET("", h.ListUsers)
|
||||
u.POST("", h.CreateUser)
|
||||
u.GET("/:id", h.GetUser)
|
||||
u.PUT("/:id", h.UpdateUser)
|
||||
u.DELETE("/:id", h.DeleteUser)
|
||||
}
|
||||
|
||||
var validClientName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
func (h *RADIUSHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) UpdateSettings(c *gin.Context) {
|
||||
var req models.RADIUSSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateIPList(req.ListenAddresses); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.settings.update", "",
|
||||
gin.H{"enabled": out.Enabled}, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
// ── Clients ──────────────────────────────────────────────────────────
|
||||
|
||||
type clientView struct {
|
||||
models.RADIUSClient
|
||||
SecretConfigured bool `json:"secret_configured"`
|
||||
}
|
||||
|
||||
func clientToView(c models.RADIUSClient) clientView {
|
||||
return clientView{RADIUSClient: c, SecretConfigured: len(c.SecretEnc) > 0}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) ListClients(c *gin.Context) {
|
||||
list, err := h.Repo.ListClients(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]clientView, 0, len(list))
|
||||
for _, cl := range list {
|
||||
out = append(out, clientToView(cl))
|
||||
}
|
||||
response.OK(c, gin.H{"clients": out})
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) GetClient(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cl, err := h.Repo.GetClient(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.clientErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, clientToView(*cl))
|
||||
}
|
||||
|
||||
type clientBody struct {
|
||||
Name string `json:"name"`
|
||||
IPAddr string `json:"ipaddr"`
|
||||
Secret *string `json:"secret"` // create: required; update: nil=unchanged
|
||||
Active bool `json:"active"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (b *clientBody) validate(creating bool) error {
|
||||
b.Name = strings.TrimSpace(b.Name)
|
||||
b.IPAddr = strings.TrimSpace(b.IPAddr)
|
||||
if !validClientName.MatchString(b.Name) {
|
||||
return errors.New("name darf nur Buchstaben/Ziffern/._- enthalten")
|
||||
}
|
||||
if net.ParseIP(b.IPAddr) == nil {
|
||||
if _, _, err := net.ParseCIDR(b.IPAddr); err != nil {
|
||||
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
|
||||
}
|
||||
}
|
||||
if creating && b.Secret == nil {
|
||||
return errors.New("secret ist erforderlich")
|
||||
}
|
||||
if b.Secret != nil {
|
||||
if len(*b.Secret) < 6 {
|
||||
return errors.New("secret muss mind. 6 Zeichen haben (leer löscht es nicht)")
|
||||
}
|
||||
if strings.ContainsAny(*b.Secret, "\r\n") {
|
||||
return errors.New("secret darf keine Zeilenumbrüche enthalten")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) CreateClient(c *gin.Context) {
|
||||
var b clientBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(true); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateClient(c.Request.Context(), b.Name, b.IPAddr, *b.Secret, b.Active, b.Description)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.create", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
|
||||
response.Created(c, clientToView(*out))
|
||||
h.reload(c.Request.Context(), "client.create")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) UpdateClient(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var b clientBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(false); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateClient(c.Request.Context(), id, b.Name, b.IPAddr, b.Secret, b.Active, b.Description)
|
||||
if err != nil {
|
||||
h.clientErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.update", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
|
||||
response.OK(c, clientToView(*out))
|
||||
h.reload(c.Request.Context(), "client.update")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) DeleteClient(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteClient(c.Request.Context(), id); err != nil {
|
||||
h.clientErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "client.delete")
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────
|
||||
|
||||
type userView struct {
|
||||
models.RADIUSUser
|
||||
PasswordConfigured bool `json:"password_configured"`
|
||||
}
|
||||
|
||||
func userToView(u models.RADIUSUser) userView {
|
||||
return userView{RADIUSUser: u, PasswordConfigured: len(u.PasswordEnc) > 0}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) ListUsers(c *gin.Context) {
|
||||
list, err := h.Repo.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]userView, 0, len(list))
|
||||
for _, u := range list {
|
||||
out = append(out, userToView(u))
|
||||
}
|
||||
response.OK(c, gin.H{"users": out})
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) GetUser(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u, err := h.Repo.GetUser(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.userErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, userToView(*u))
|
||||
}
|
||||
|
||||
type userBody struct {
|
||||
Username string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (b *userBody) validate(creating bool) error {
|
||||
b.Username = strings.TrimSpace(b.Username)
|
||||
if b.Username == "" || strings.ContainsAny(b.Username, "\r\n") {
|
||||
return errors.New("username ist erforderlich (ohne Zeilenumbrüche)")
|
||||
}
|
||||
if creating && (b.Password == nil || *b.Password == "") {
|
||||
return errors.New("password ist erforderlich")
|
||||
}
|
||||
if b.Password != nil {
|
||||
if *b.Password == "" {
|
||||
return errors.New("password darf nicht leer sein (löscht es nicht)")
|
||||
}
|
||||
if strings.ContainsAny(*b.Password, "\r\n") {
|
||||
return errors.New("password darf keine Zeilenumbrüche enthalten")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) CreateUser(c *gin.Context) {
|
||||
var b userBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(true); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateUser(c.Request.Context(), b.Username, *b.Password, b.Active)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.create", out.Username, nil, h.NodeID)
|
||||
response.Created(c, userToView(*out))
|
||||
h.reload(c.Request.Context(), "user.create")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) UpdateUser(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var b userBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(false); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateUser(c.Request.Context(), id, b.Username, b.Password, b.Active)
|
||||
if err != nil {
|
||||
h.userErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.update", out.Username, nil, h.NodeID)
|
||||
response.OK(c, userToView(*out))
|
||||
h.reload(c.Request.Context(), "user.update")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) DeleteUser(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteUser(c.Request.Context(), id); err != nil {
|
||||
h.userErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "user.delete")
|
||||
}
|
||||
|
||||
// ── error mapping ────────────────────────────────────────────────────
|
||||
|
||||
func (h *RADIUSHandler) clientErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, radiussvc.ErrClientNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) userErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, radiussvc.ErrUserNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
@@ -194,6 +194,8 @@ var servicesToCheck = []struct{ Label, Unit string }{
|
||||
{"unbound", "unbound"},
|
||||
{"chrony", "chrony"},
|
||||
{"squid", "squid"},
|
||||
{"kea-dhcp4", "kea-dhcp4-server"},
|
||||
{"freeradius", "freeradius"},
|
||||
{"postgresql", "postgresql"},
|
||||
{"crowdsec", "crowdsec"},
|
||||
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -15,6 +18,10 @@ import (
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
)
|
||||
|
||||
// wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" /
|
||||
// "942100-942999") als Exclusion — verhindert SecLang-Direktiven-Injection.
|
||||
var wafRuleIDRe = regexp.MustCompile(`^[0-9]{1,9}(-[0-9]{1,9})?$`)
|
||||
|
||||
// WafHandler exposes the per-domain WAF configuration REST API:
|
||||
//
|
||||
// GET /waf/configs — list all configs (one per domain)
|
||||
@@ -110,6 +117,27 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
||||
if body.ExclusionNotes == nil {
|
||||
body.ExclusionNotes = map[string]string{}
|
||||
}
|
||||
// Exclusions müssen reine Rule-IDs/Ranges sein (sonst Direktiven-Injection
|
||||
// in die SecLang-Config via Newline).
|
||||
for _, ex := range body.RuleExclusions {
|
||||
if !wafRuleIDRe.MatchString(strings.TrimSpace(ex)) {
|
||||
response.BadRequest(c, errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): "+ex))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Trusted-Proxies müssen gültige IPs/CIDRs sein.
|
||||
for _, p := range body.TrustedProxies {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(p) == nil {
|
||||
if _, _, err := net.ParseCIDR(p); err != nil {
|
||||
response.BadRequest(c, errors.New("ungültiger Trusted-Proxy (IP/CIDR): "+p))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg := models.WafConfig{
|
||||
DomainID: domainID,
|
||||
Enabled: body.Enabled,
|
||||
|
||||
@@ -119,7 +119,7 @@ func (g *Generator) buildConfig(ctx context.Context) (*keaConfig, *bool, error)
|
||||
}
|
||||
|
||||
ifaceSet := map[string]bool{}
|
||||
var ifaces []string
|
||||
ifaces := []string{} // nie nil → JSON "[]" statt "null" (Kea lehnt null ab)
|
||||
var sn4 []subnet4
|
||||
|
||||
for _, s := range subnets {
|
||||
|
||||
43
internal/models/radius.go
Normal file
43
internal/models/radius.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// RADIUSSettings ist die node-lokale Singleton-Konfiguration des
|
||||
// FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen).
|
||||
type RADIUSSettings struct {
|
||||
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||
Enabled bool `gorm:"column:enabled" json:"enabled"`
|
||||
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (RADIUSSettings) TableName() string { return "radius_settings" }
|
||||
|
||||
// RADIUSClient ist ein NAS-Client (IP/CIDR + Shared Secret). SecretEnc
|
||||
// wird via secrets.Box verschlüsselt und nie serialisiert.
|
||||
type RADIUSClient struct {
|
||||
ID int64 `gorm:"column:id;primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
IPAddr string `gorm:"column:ipaddr" json:"ipaddr"`
|
||||
SecretEnc []byte `gorm:"column:secret_enc" json:"-"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
Description string `gorm:"column:description" json:"description"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (RADIUSClient) TableName() string { return "radius_clients" }
|
||||
|
||||
// RADIUSUser ist ein PAP/CHAP-Benutzer. PasswordEnc wird via secrets.Box
|
||||
// verschlüsselt und nie serialisiert.
|
||||
type RADIUSUser struct {
|
||||
ID int64 `gorm:"column:id;primaryKey" json:"id"`
|
||||
Username string `gorm:"column:username" json:"username"`
|
||||
PasswordEnc []byte `gorm:"column:password_enc" json:"-"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (RADIUSUser) TableName() string { return "radius_users" }
|
||||
@@ -128,7 +128,7 @@ func Join(req Request) error {
|
||||
// synchronous on the primary side.
|
||||
var autoRegErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil {
|
||||
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, "", "peer"); err == nil {
|
||||
autoRegErr = nil
|
||||
break
|
||||
} else {
|
||||
@@ -222,13 +222,21 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
|
||||
// goroutine so the primary's ha_nodes always reflects the secondary's actual
|
||||
// config_hash (not the stale join-time value).
|
||||
func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error {
|
||||
return PushSelfToPeer(primaryURL, tlsDir, nodeID, fqdn, version, configHash, "peer")
|
||||
}
|
||||
|
||||
// PushSelfToPeer sendet die eigene Identität an einen beliebigen Peer (mTLS,
|
||||
// /agent/cluster/peers). role bestimmt, mit welcher Rolle sich dieser Node
|
||||
// beim Empfänger einträgt: ein Secondary pusht "peer" an den Primary, der
|
||||
// Primary pusht "primary" an jeden Secondary (bidirektionaler Heartbeat).
|
||||
func PushSelfToPeer(peerURL, tlsDir, nodeID, fqdn, version, configHash, role string) error {
|
||||
if tlsDir == "" {
|
||||
tlsDir = clustertls.DefaultDir
|
||||
}
|
||||
return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash)
|
||||
return autoRegister(peerURL, tlsDir, fqdn, version, nodeID, configHash, role)
|
||||
}
|
||||
|
||||
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error {
|
||||
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role string) error {
|
||||
u, err := url.Parse(primary)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -241,6 +249,9 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
|
||||
nodeID = strings.TrimSpace(string(raw))
|
||||
}
|
||||
hostname, _ := os.Hostname()
|
||||
if role == "" {
|
||||
role = "peer"
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"id": nodeID,
|
||||
"name": hostname,
|
||||
@@ -248,6 +259,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
|
||||
"api_url": "https://" + commonName + ":3443",
|
||||
"version": version,
|
||||
"config_hash": configHash,
|
||||
"role": role,
|
||||
})
|
||||
|
||||
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
||||
|
||||
@@ -44,6 +44,7 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
|
||||
whitelist[n] = true
|
||||
}
|
||||
out := make([]Result, 0, len(gens))
|
||||
var errs []error
|
||||
for _, g := range gens {
|
||||
if len(whitelist) > 0 && !whitelist[g.Name()] {
|
||||
out = append(out, Result{Name: g.Name(), Skipped: true})
|
||||
@@ -52,11 +53,14 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
|
||||
err := g.Render(ctx)
|
||||
out = append(out, Result{Name: g.Name(), Err: err})
|
||||
if err != nil && !errors.Is(err, configgen.ErrNotImplemented) {
|
||||
// hard failure — surface it but return what's done so far
|
||||
return out, fmt.Errorf("%s: %w", g.Name(), err)
|
||||
// Weitermachen: die Generatoren sind unabhängig und reloaden
|
||||
// inline (nft/Service-Reload sind atomar). Abbrechen würde die
|
||||
// restlichen Dienste auf altem Stand lassen → halb angewandt.
|
||||
// Stattdessen alle versuchen und Fehler gesammelt zurückgeben.
|
||||
errs = append(errs, fmt.Errorf("%s: %w", g.Name(), err))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return out, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Summarise turns the result slice into a human-readable multiline
|
||||
|
||||
239
internal/services/radius/radius.go
Normal file
239
internal/services/radius/radius.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Package radius provides CRUD against radius_settings (singleton),
|
||||
// radius_clients and radius_users. Shared secrets / user passwords are
|
||||
// sealed at rest via secrets.Box. The FreeRADIUS renderer in
|
||||
// internal/freeradius consumes these.
|
||||
package radius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClientNotFound = errors.New("radius client not found")
|
||||
ErrUserNotFound = errors.New("radius user not found")
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
Pool *pgxpool.Pool
|
||||
Box *secrets.Box
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo { return &Repo{Pool: pool, Box: box} }
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
func (r *Repo) GetSettings(ctx context.Context) (*models.RADIUSSettings, error) {
|
||||
var s models.RADIUSSettings
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, enabled, listen_addresses, created_at, updated_at FROM radius_settings WHERE id=1`).Scan(
|
||||
&s.ID, &s.Enabled, &s.ListenAddresses, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateSettings(ctx context.Context, s models.RADIUSSettings) (*models.RADIUSSettings, error) {
|
||||
var out models.RADIUSSettings
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
UPDATE radius_settings SET enabled=$1, listen_addresses=$2, updated_at=NOW() WHERE id=1
|
||||
RETURNING id, enabled, listen_addresses, created_at, updated_at`,
|
||||
s.Enabled, s.ListenAddresses).Scan(
|
||||
&out.ID, &out.Enabled, &out.ListenAddresses, &out.CreatedAt, &out.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ── Clients ──────────────────────────────────────────────────────────
|
||||
|
||||
const clientCols = `id, name, ipaddr, secret_enc, active, description, created_at, updated_at`
|
||||
|
||||
func scanClient(row pgx.Row) (*models.RADIUSClient, error) {
|
||||
var c models.RADIUSClient
|
||||
if err := row.Scan(&c.ID, &c.Name, &c.IPAddr, &c.SecretEnc, &c.Active, &c.Description,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListClients(ctx context.Context) ([]models.RADIUSClient, error) {
|
||||
rows, err := r.Pool.Query(ctx, `SELECT `+clientCols+` FROM radius_clients ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.RADIUSClient, 0, 8)
|
||||
for rows.Next() {
|
||||
c, err := scanClient(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) GetClient(ctx context.Context, id int64) (*models.RADIUSClient, error) {
|
||||
c, err := scanClient(r.Pool.QueryRow(ctx, `SELECT `+clientCols+` FROM radius_clients WHERE id=$1`, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrClientNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *Repo) CreateClient(ctx context.Context, name, ipaddr, secret string, active bool, desc string) (*models.RADIUSClient, error) {
|
||||
enc, err := r.Box.Seal([]byte(secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanClient(r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO radius_clients (name, ipaddr, secret_enc, active, description)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING `+clientCols,
|
||||
name, ipaddr, enc, active, desc))
|
||||
}
|
||||
|
||||
// UpdateClient: secret==nil → unverändert, ""→löschen, sonst neu sealen.
|
||||
func (r *Repo) UpdateClient(ctx context.Context, id int64, name, ipaddr string, secret *string, active bool, desc string) (*models.RADIUSClient, error) {
|
||||
if secret == nil {
|
||||
out, err := scanClient(r.Pool.QueryRow(ctx, `
|
||||
UPDATE radius_clients SET name=$1, ipaddr=$2, active=$3, description=$4, updated_at=NOW()
|
||||
WHERE id=$5 RETURNING `+clientCols, name, ipaddr, active, desc, id))
|
||||
return mapClientErr(out, err)
|
||||
}
|
||||
var enc []byte
|
||||
if *secret != "" {
|
||||
sealed, err := r.Box.Seal([]byte(*secret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc = sealed
|
||||
}
|
||||
out, err := scanClient(r.Pool.QueryRow(ctx, `
|
||||
UPDATE radius_clients SET name=$1, ipaddr=$2, secret_enc=$3, active=$4, description=$5, updated_at=NOW()
|
||||
WHERE id=$6 RETURNING `+clientCols, name, ipaddr, enc, active, desc, id))
|
||||
return mapClientErr(out, err)
|
||||
}
|
||||
|
||||
func mapClientErr(c *models.RADIUSClient, err error) (*models.RADIUSClient, error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrClientNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteClient(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_clients WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrClientNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────
|
||||
|
||||
const userCols = `id, username, password_enc, active, created_at, updated_at`
|
||||
|
||||
func scanUser(row pgx.Row) (*models.RADIUSUser, error) {
|
||||
var u models.RADIUSUser
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordEnc, &u.Active, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListUsers(ctx context.Context) ([]models.RADIUSUser, error) {
|
||||
rows, err := r.Pool.Query(ctx, `SELECT `+userCols+` FROM radius_users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.RADIUSUser, 0, 8)
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) GetUser(ctx context.Context, id int64) (*models.RADIUSUser, error) {
|
||||
u, err := scanUser(r.Pool.QueryRow(ctx, `SELECT `+userCols+` FROM radius_users WHERE id=$1`, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *Repo) CreateUser(ctx context.Context, username, password string, active bool) (*models.RADIUSUser, error) {
|
||||
enc, err := r.Box.Seal([]byte(password))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanUser(r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO radius_users (username, password_enc, active) VALUES ($1,$2,$3) RETURNING `+userCols,
|
||||
username, enc, active))
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateUser(ctx context.Context, id int64, username string, password *string, active bool) (*models.RADIUSUser, error) {
|
||||
if password == nil {
|
||||
u, err := scanUser(r.Pool.QueryRow(ctx, `
|
||||
UPDATE radius_users SET username=$1, active=$2, updated_at=NOW() WHERE id=$3 RETURNING `+userCols,
|
||||
username, active, id))
|
||||
return mapUserErr(u, err)
|
||||
}
|
||||
var enc []byte
|
||||
if *password != "" {
|
||||
sealed, err := r.Box.Seal([]byte(*password))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc = sealed
|
||||
}
|
||||
u, err := scanUser(r.Pool.QueryRow(ctx, `
|
||||
UPDATE radius_users SET username=$1, password_enc=$2, active=$3, updated_at=NOW() WHERE id=$4 RETURNING `+userCols,
|
||||
username, enc, active, id))
|
||||
return mapUserErr(u, err)
|
||||
}
|
||||
|
||||
func mapUserErr(u *models.RADIUSUser, err error) (*models.RADIUSUser, error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteUser(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_users WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -96,14 +96,15 @@ func loadOrCreateSecret(path string) ([]byte, error) {
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// IssueWithRole returns a signed token for the given actor + role.
|
||||
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
|
||||
// issue builds + signs a token with an explicit TTL. No shared-state
|
||||
// mutation — safe for concurrent use of the shared Signer singleton.
|
||||
func (s *Signer) issue(actor, role string, ttl time.Duration) (string, *Token, error) {
|
||||
now := s.Now()
|
||||
t := Token{
|
||||
Actor: actor,
|
||||
Role: role,
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(s.TTL).Unix(),
|
||||
Exp: now.Add(ttl).Unix(),
|
||||
}
|
||||
data, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
@@ -117,18 +118,20 @@ func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
|
||||
return encoded, &t, nil
|
||||
}
|
||||
|
||||
// Issue is IssueWithRole with empty role.
|
||||
func (s *Signer) Issue(actor string) (string, *Token, error) {
|
||||
return s.IssueWithRole(actor, "")
|
||||
// IssueWithRole returns a signed token for the given actor + role.
|
||||
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
|
||||
return s.issue(actor, role, s.TTL)
|
||||
}
|
||||
|
||||
// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call).
|
||||
// Issue is IssueWithRole with empty role.
|
||||
func (s *Signer) Issue(actor string) (string, *Token, error) {
|
||||
return s.issue(actor, "", s.TTL)
|
||||
}
|
||||
|
||||
// IssueWithRoleTTL issues a token with a custom TTL — no longer mutates
|
||||
// the shared Signer (previously a data race under concurrent logins).
|
||||
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
|
||||
orig := s.TTL
|
||||
s.TTL = ttl
|
||||
raw, tok, err := s.IssueWithRole(actor, role)
|
||||
s.TTL = orig
|
||||
return raw, tok, err
|
||||
return s.issue(actor, role, ttl)
|
||||
}
|
||||
|
||||
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
|
||||
|
||||
47
internal/services/session/ttl_test.go
Normal file
47
internal/services/session/ttl_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSigner_TTLNotShared beweist Fix #1: IssueWithRoleTTL darf das geteilte
|
||||
// s.TTL nicht mehr mutieren. Unter `go test -race` schlägt die alte Version
|
||||
// als Data-Race an; zusätzlich prüfen wir, dass parallele normale Logins nie
|
||||
// die kurze TOTP-TTL erben.
|
||||
func TestSigner_TTLNotShared(t *testing.T) {
|
||||
s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, time.Hour)
|
||||
var wg sync.WaitGroup
|
||||
var bad int32
|
||||
for i := 0; i < 200; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _, _ = s.IssueWithRoleTTL("a", "totp_pending", 2*time.Minute)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, tok, err := s.IssueWithRole("b", "admin")
|
||||
if err != nil {
|
||||
atomic.AddInt32(&bad, 1)
|
||||
return
|
||||
}
|
||||
// Normale Session muss ~1h gelten, nie die 2-Min-TOTP-TTL.
|
||||
if tok.Exp-tok.Iat < int64((30 * time.Minute).Seconds()) {
|
||||
atomic.AddInt32(&bad, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if bad > 0 {
|
||||
t.Fatalf("%d normale Tokens bekamen eine zu kurze TTL → geteilter Zustand", bad)
|
||||
}
|
||||
|
||||
// TTL-Override wirkt weiterhin korrekt für den TOTP-Token.
|
||||
_, ptok, _ := s.IssueWithRoleTTL("x", "totp_pending", 2*time.Minute)
|
||||
if d := ptok.Exp - ptok.Iat; d > int64((3 * time.Minute).Seconds()) {
|
||||
t.Fatalf("totp-pending TTL = %ds, want ~120s", d)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package waf
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -26,8 +28,12 @@ type Alert struct {
|
||||
// AlertWriter accepts Alert values via a buffered channel and writes
|
||||
// them to PostgreSQL asynchronously so SPOE handling stays low-latency.
|
||||
type AlertWriter struct {
|
||||
pool *pgxpool.Pool
|
||||
ch chan Alert
|
||||
pool *pgxpool.Pool
|
||||
ch chan Alert
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
|
||||
@@ -36,14 +42,19 @@ func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter {
|
||||
aw := &AlertWriter{
|
||||
pool: pool,
|
||||
ch: make(chan Alert, bufSize),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go aw.run()
|
||||
return aw
|
||||
}
|
||||
|
||||
// Send enqueues an alert. Drops silently if the channel is full to
|
||||
// avoid slowing down SPOE request handling.
|
||||
// Send enqueues an alert. Drops silently if the channel is full (or the
|
||||
// writer is closing) to avoid slowing down / panicking SPOE handling.
|
||||
func (aw *AlertWriter) Send(a Alert) {
|
||||
if aw.closed.Load() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case aw.ch <- a:
|
||||
default:
|
||||
@@ -51,9 +62,34 @@ func (aw *AlertWriter) Send(a Alert) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the writer and flushes buffered alerts (best-effort).
|
||||
// Safe to call multiple times. The channel is never closed → Send never
|
||||
// panics even if it races with Close.
|
||||
func (aw *AlertWriter) Close() {
|
||||
aw.closeOnce.Do(func() {
|
||||
aw.closed.Store(true)
|
||||
close(aw.stop)
|
||||
})
|
||||
<-aw.done
|
||||
}
|
||||
|
||||
func (aw *AlertWriter) run() {
|
||||
for a := range aw.ch {
|
||||
aw.write(a)
|
||||
defer close(aw.done)
|
||||
for {
|
||||
select {
|
||||
case a := <-aw.ch:
|
||||
aw.write(a)
|
||||
case <-aw.stop:
|
||||
// Restliche gepufferte Alerts noch wegschreiben, dann Ende.
|
||||
for {
|
||||
select {
|
||||
case a := <-aw.ch:
|
||||
aw.write(a)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
61
internal/waf/alerts_test.go
Normal file
61
internal/waf/alerts_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
)
|
||||
|
||||
// Beweist Fix #15: AlertWriter.Close() flusht, ist idempotent, und Send/Close
|
||||
// racen ohne Panic (Kanal wird nie geschlossen). Guarded per EG_FWTEST_DSN.
|
||||
func TestAlertWriter_CloseFlush(t *testing.T) {
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the alert-writer test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
var mErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if mErr = database.Migrate(ctx, dsn); mErr == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if mErr != nil {
|
||||
t.Fatalf("migrate: %v", mErr)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
aw := NewAlertWriter(pool, 64)
|
||||
for i := 0; i < 20; i++ {
|
||||
aw.Send(Alert{Hostname: "t.local", ClientIP: "203.0.113.1", Method: "GET", URI: "/", Action: "detected"})
|
||||
}
|
||||
|
||||
// Send parallel zu Close → darf nicht paniken.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); aw.Send(Alert{Hostname: "t.local", Action: "detected"}) }()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { aw.Close(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Close() did not return (flush hung)")
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Idempotent + Send nach Close ist No-op (kein Panic).
|
||||
aw.Close()
|
||||
aw.Send(Alert{Hostname: "after.local", Action: "detected"})
|
||||
}
|
||||
@@ -66,14 +66,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs.
|
||||
for _, ip := range cfg.TrustedProxies {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n"))
|
||||
_ = ip // used in custom rules below if needed
|
||||
}
|
||||
}
|
||||
// Trusted proxies are NOT a SecLang directive — they are applied in the
|
||||
// SPOE agent (spoe.go): when the connection source is a trusted proxy,
|
||||
// the real client IP is taken from X-Forwarded-For before Coraza sees
|
||||
// it. (Previously this loop emitted a bogus, unrelated directive.)
|
||||
|
||||
// Custom rules (appended last so they can override CRS).
|
||||
if strings.TrimSpace(cfg.CustomRules) != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package waf
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
@@ -12,8 +13,9 @@ import (
|
||||
|
||||
// DomainEngine bundles a Coraza WAF with its operating mode.
|
||||
type DomainEngine struct {
|
||||
WAF coraza.WAF
|
||||
Mode string // "detection" | "blocking"
|
||||
WAF coraza.WAF
|
||||
Mode string // "detection" | "blocking"
|
||||
TrustedProxies []string // wenn src ∈ diese → echte Client-IP aus X-Forwarded-For
|
||||
}
|
||||
|
||||
// Manager holds per-domain Coraza engine instances. Engines are
|
||||
@@ -90,7 +92,7 @@ func (m *Manager) Reload(domains []DomainConfig) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
|
||||
}
|
||||
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode}
|
||||
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode, TrustedProxies: dc.Config.TrustedProxies}
|
||||
slog.Info("waf: engine (re)loaded",
|
||||
"host", dc.Hostname,
|
||||
"mode", dc.Config.Mode,
|
||||
@@ -110,8 +112,9 @@ func (m *Manager) Reload(domains []DomainConfig) error {
|
||||
// (nil, false) when the domain has no WAF or WAF is disabled.
|
||||
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
||||
// Strip port if present (e.g. "example.com:443" → "example.com").
|
||||
if i := lastColon(host); i >= 0 {
|
||||
host = host[:i]
|
||||
// SplitHostPort errors for a bare host or bare IPv6 literal → keep as-is.
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
m.mu.RLock()
|
||||
de, ok := m.engines[host]
|
||||
@@ -122,36 +125,3 @@ func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
||||
return de, true
|
||||
}
|
||||
|
||||
// lastColon returns the index of the last ':' in s that looks like a
|
||||
// port separator (after the final ']' for IPv6), or -1.
|
||||
func lastColon(s string) int {
|
||||
// IPv6 addresses in brackets: "[::1]:443"
|
||||
if len(s) > 0 && s[0] == '[' {
|
||||
if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' {
|
||||
return rb + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
// Plain host — only strip port if there's exactly one colon.
|
||||
count := 0
|
||||
idx := -1
|
||||
for i, c := range s {
|
||||
if c == ':' {
|
||||
count++
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func lastByte(s string, b byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package waf
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -75,6 +76,15 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
|
||||
return // WAF not configured or disabled for this domain
|
||||
}
|
||||
|
||||
// Trusted-Proxy-Handling: stammt die Verbindung von einem konfigurierten
|
||||
// Trusted-Proxy, ist die echte Client-IP das letzte X-Forwarded-For-Glied
|
||||
// (das der Proxy angehängt hat), nicht die Proxy-IP selbst.
|
||||
if clientIP != "" && len(de.TrustedProxies) > 0 && ipMatchesAny(clientIP, de.TrustedProxies) {
|
||||
if real := rightmostXFF(rawHdrs); real != "" {
|
||||
clientIP = real
|
||||
}
|
||||
}
|
||||
|
||||
tx := de.WAF.NewTransaction()
|
||||
defer func() {
|
||||
tx.ProcessLogging()
|
||||
@@ -165,6 +175,53 @@ func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.Match
|
||||
})
|
||||
}
|
||||
|
||||
// rightmostXFF gibt den letzten (vom nächstgelegenen Proxy angehängten)
|
||||
// X-Forwarded-For-Eintrag zurück, sofern es eine gültige IP ist.
|
||||
func rightmostXFF(rawHdrs string) string {
|
||||
var val string
|
||||
for _, line := range strings.Split(rawHdrs, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
idx := strings.IndexByte(line, ':')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(line[:idx]), "x-forwarded-for") {
|
||||
val = strings.TrimSpace(line[idx+1:]) // letzter XFF-Header gewinnt
|
||||
}
|
||||
}
|
||||
if val == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(val, ",")
|
||||
cand := strings.TrimSpace(parts[len(parts)-1])
|
||||
if net.ParseIP(cand) == nil {
|
||||
return ""
|
||||
}
|
||||
return cand
|
||||
}
|
||||
|
||||
// ipMatchesAny prüft, ob ip exakt einer IP oder einem CIDR aus list entspricht.
|
||||
func ipMatchesAny(ip string, list []string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range list {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(e, "/") {
|
||||
if _, n, err := net.ParseCIDR(e); err == nil && n.Contains(parsed) {
|
||||
return true
|
||||
}
|
||||
} else if pe := net.ParseIP(e); pe != nil && pe.Equal(parsed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
|
||||
// calls fn for each valid header line.
|
||||
func parseHeaders(raw string, fn func(name, val string)) {
|
||||
|
||||
37
internal/waf/spoe_test.go
Normal file
37
internal/waf/spoe_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package waf
|
||||
|
||||
import "testing"
|
||||
|
||||
// Beweist Fix #2: Trusted-Proxy-XFF-Auflösung.
|
||||
func TestRightmostXFF(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"X-Forwarded-For: 203.0.113.7": "203.0.113.7",
|
||||
"X-Forwarded-For: 203.0.113.7, 10.0.0.1": "10.0.0.1", // rightmost
|
||||
"x-forwarded-for: 1.2.3.4 , 5.6.7.8": "5.6.7.8",
|
||||
"Host: x\r\nX-Forwarded-For: 2001:db8::1": "2001:db8::1",
|
||||
"X-Forwarded-For: not-an-ip": "",
|
||||
"User-Agent: foo": "",
|
||||
"": "",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := rightmostXFF(raw); got != want {
|
||||
t.Errorf("rightmostXFF(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPMatchesAny(t *testing.T) {
|
||||
list := []string{"10.0.0.5", "192.168.0.0/16", "2001:db8::/32"}
|
||||
yes := []string{"10.0.0.5", "192.168.4.7", "2001:db8::abcd"}
|
||||
no := []string{"10.0.0.6", "172.16.0.1", "2002::1", "garbage"}
|
||||
for _, ip := range yes {
|
||||
if !ipMatchesAny(ip, list) {
|
||||
t.Errorf("ipMatchesAny(%q) = false, want true", ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range no {
|
||||
if ipMatchesAny(ip, list) {
|
||||
t.Errorf("ipMatchesAny(%q) = true, want false", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
@@ -27,10 +28,11 @@ import (
|
||||
const ConfDir = "/etc/edgeguard/wireguard"
|
||||
|
||||
type Generator struct {
|
||||
Pool *pgxpool.Pool
|
||||
Box *secrets.Box
|
||||
Ifaces *wgsvc.InterfacesRepo
|
||||
Peers *wgsvc.PeersRepo
|
||||
Pool *pgxpool.Pool
|
||||
Box *secrets.Box
|
||||
Ifaces *wgsvc.InterfacesRepo
|
||||
Peers *wgsvc.PeersRepo
|
||||
SkipReload bool // nur Configs schreiben, keine wg-quick@-Service-Aktionen
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
||||
@@ -151,8 +153,10 @@ func (g *Generator) Render(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
|
||||
_ = stopWGQuick(ifaceName)
|
||||
_ = disableWGQuick(ifaceName)
|
||||
if !g.SkipReload {
|
||||
_ = stopWGQuick(ifaceName)
|
||||
_ = disableWGQuick(ifaceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -228,21 +232,31 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
|
||||
}
|
||||
|
||||
path := filepath.Join(ConfDir, ifc.Name+".conf")
|
||||
// Config (enthält den Private Key) ZUERST atomar schreiben — vorher
|
||||
// keinen Symlink/Service auf eine evtl. fehlende/abgeschnittene Datei
|
||||
// zeigen lassen. AtomicWrite = temp+fsync+rename, 0600.
|
||||
changed := true
|
||||
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
||||
changed = false
|
||||
}
|
||||
if changed {
|
||||
if err := configgen.AtomicWrite(path, body.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
|
||||
// Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in
|
||||
// /etc/edgeguard/wireguard/ und symlinken via sudo — /etc/wireguard/
|
||||
// ist root:root 700, daher braucht es sudo /bin/ln. Das sudoers-Entry
|
||||
// wird von postinst angelegt.
|
||||
// Default), nicht unseren ConfDir. Symlink via sudo (/etc/wireguard/
|
||||
// ist root:root 700). Das sudoers-Entry wird von postinst angelegt.
|
||||
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
|
||||
return fmt.Errorf("symlink: %w", err)
|
||||
}
|
||||
_ = enableWGQuick(ifc.Name)
|
||||
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
||||
if !changed {
|
||||
return startWGQuick(ifc.Name)
|
||||
}
|
||||
if err := os.WriteFile(path, body.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return restartWGQuick(ifc.Name)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const SSLPage = lazy(() => import('./pages/SSL'))
|
||||
const FirewallPage = lazy(() => import('./pages/Firewall'))
|
||||
const WireguardPage = lazy(() => import('./pages/Wireguard'))
|
||||
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||
const RADIUSPage = lazy(() => import('./pages/RADIUS'))
|
||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const DHCPPage = lazy(() => import('./pages/DHCP'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
@@ -161,6 +162,7 @@ export default function App() {
|
||||
<Route path="/firewall/live" element={<FirewallLivePage />} />
|
||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||
<Route path="/radius" element={<RADIUSPage />} />
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/dhcp" element={<DHCPPage />} />
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
|
||||
@@ -23,6 +23,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/ntp': 'nav.ntp',
|
||||
'/vpn/wireguard': 'nav.wireguard',
|
||||
'/forward-proxy': 'nav.forwardProxy',
|
||||
'/radius': 'nav.radius',
|
||||
'/firewall/live': 'nav.firewallLive',
|
||||
'/firewall': 'nav.firewall',
|
||||
'/cluster': 'nav.cluster',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
IdcardOutlined,
|
||||
NodeIndexOutlined,
|
||||
RadarChartOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
@@ -80,6 +81,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
||||
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
|
||||
{ path: '/radius', labelKey: 'nav.radius', icon: <IdcardOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"security": "Sicherheit",
|
||||
"system": "System"
|
||||
},
|
||||
"waf": "WAF"
|
||||
"waf": "WAF",
|
||||
"radius": "RADIUS"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall",
|
||||
@@ -1371,6 +1372,7 @@
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"or": "oder",
|
||||
"status": "Status",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"loading": "Lädt …",
|
||||
@@ -1855,6 +1857,39 @@
|
||||
"confirmRemove": "Collection {{name}} wirklich entfernen?"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"title": "RADIUS (FreeRADIUS)",
|
||||
"intro": "RADIUS-Authentifizierung (PAP/CHAP): NAS-Clients + Benutzer für VPN-/Switch-/AP-Login.",
|
||||
"csvHint": "Komma-getrennt; leer = alle Adressen.",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"deleted": "Gelöscht",
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
||||
"secretUnset": "Noch nichts gespeichert.",
|
||||
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
|
||||
"settings": {
|
||||
"enabled": "RADIUS auf dieser Node aktiv",
|
||||
"listen": "Listen-Adressen"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"ipaddr": "IP / CIDR",
|
||||
"secret": "Shared Secret",
|
||||
"description": "Beschreibung",
|
||||
"add": "Client hinzufügen",
|
||||
"edit": "Client bearbeiten",
|
||||
"deleteConfirm": "Client \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"user": {
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"papHint": "Benutzer-Passwörter werden für PAP/CHAP als Cleartext in der FreeRADIUS-Datei hinterlegt (verschlüsselt at-rest in der DB, Datei nur für root/freeradius lesbar).",
|
||||
"add": "Benutzer hinzufügen",
|
||||
"edit": "Benutzer bearbeiten",
|
||||
"deleteConfirm": "Benutzer \"{{name}}\" wirklich löschen?"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
"title": "Web Application Firewall",
|
||||
"intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"security": "Security",
|
||||
"system": "System"
|
||||
},
|
||||
"waf": "WAF"
|
||||
"waf": "WAF",
|
||||
"radius": "RADIUS"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall",
|
||||
@@ -1371,6 +1372,7 @@
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"or": "or",
|
||||
"status": "Status",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading …",
|
||||
@@ -1855,6 +1857,39 @@
|
||||
"confirmRemove": "Really remove collection {{name}}?"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"title": "RADIUS (FreeRADIUS)",
|
||||
"intro": "RADIUS authentication (PAP/CHAP): NAS clients + users for VPN/switch/AP login.",
|
||||
"csvHint": "Comma-separated; empty = all addresses.",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Save failed",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Delete failed",
|
||||
"secretSet": "Stored — leave empty to keep unchanged.",
|
||||
"secretUnset": "Nothing stored yet.",
|
||||
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
|
||||
"settings": {
|
||||
"enabled": "RADIUS active on this node",
|
||||
"listen": "Listen addresses"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"ipaddr": "IP / CIDR",
|
||||
"secret": "Shared secret",
|
||||
"description": "Description",
|
||||
"add": "Add client",
|
||||
"edit": "Edit client",
|
||||
"deleteConfirm": "Really delete client \"{{name}}\"?"
|
||||
},
|
||||
"user": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"papHint": "User passwords are stored as cleartext in the FreeRADIUS file for PAP/CHAP (encrypted at-rest in the DB; the file is readable only by root/freeradius).",
|
||||
"add": "Add user",
|
||||
"edit": "Edit user",
|
||||
"deleteConfirm": "Really delete user \"{{name}}\"?"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
"title": "Web Application Firewall",
|
||||
"intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.",
|
||||
|
||||
226
management-ui/src/pages/RADIUS/index.tsx
Normal file
226
management-ui/src/pages/RADIUS/index.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Form, Input, Modal, Popconfirm, Space, Switch,
|
||||
Table, Tabs, Tag, Tooltip, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { IdcardOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
interface RADIUSSettings { id: number; enabled: boolean; listen_addresses: string }
|
||||
interface RADIUSClient {
|
||||
id: number; name: string; ipaddr: string; active: boolean; description: string; secret_configured: boolean
|
||||
}
|
||||
interface RADIUSUser { id: number; username: string; active: boolean; password_configured: boolean }
|
||||
|
||||
export default function RADIUSPage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<PageHeader icon={<IdcardOutlined />} title={t('radius.title')} subtitle={t('radius.intro')} />
|
||||
<Tabs
|
||||
defaultActiveKey="settings"
|
||||
items={[
|
||||
{ key: 'settings', label: t('radius.tabs.settings'), children: <SettingsTab /> },
|
||||
{ key: 'clients', label: t('radius.tabs.clients'), children: <ClientsTab /> },
|
||||
{ key: 'users', label: t('radius.tabs.users'), children: <UsersTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<RADIUSSettings>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/settings')
|
||||
return isEnvelope(r.data) ? (r.data.data as RADIUSSettings) : null
|
||||
},
|
||||
})
|
||||
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: RADIUSSettings) => apiClient.put('/radius/settings', v),
|
||||
onSuccess: () => { msg.success(t('radius.saved')); void qc.invalidateQueries({ queryKey: ['radius', 'settings'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Form<RADIUSSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.settings.enabled')} name="enabled" valuePropName="checked">
|
||||
<Switch disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('radius.settings.listen')} name="listen_addresses" extra={t('radius.csvHint')}>
|
||||
<Input placeholder="0.0.0.0" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>{t('common.save')}</Button>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface ClientForm { name: string; ipaddr: string; secret?: string; active: boolean; description: string }
|
||||
|
||||
function ClientsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<ClientForm>()
|
||||
const [editing, setEditing] = useState<RADIUSClient | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'clients'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/clients')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { clients?: RADIUSClient[] }).clients ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: ClientForm) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
if (!v.secret) delete body.secret // leer = unverändert
|
||||
return editing ? apiClient.put(`/radius/clients/${editing.id}`, body) : apiClient.post('/radius/clients', body)
|
||||
},
|
||||
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/radius/clients/${id}`),
|
||||
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<RADIUSClient> = [
|
||||
{ title: t('radius.client.name'), dataIndex: 'name' },
|
||||
{ title: t('radius.client.ipaddr'), dataIndex: 'ipaddr' },
|
||||
{ title: t('radius.client.secret'), dataIndex: 'secret_configured', render: (v: boolean) => (v ? <Tag color="green">✓</Tag> : <Tag>—</Tag>) },
|
||||
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 200,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ name: r.name, ipaddr: r.ipaddr, active: r.active, description: r.description, secret: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
|
||||
{!isViewer && <Popconfirm title={t('radius.client.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
|
||||
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<ClientForm>); setOpen(true) }}>
|
||||
{t('radius.client.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<RADIUSClient> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
|
||||
<Modal title={editing ? t('radius.client.edit') : t('radius.client.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
|
||||
<Form<ClientForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.client.name')} name="name" rules={[{ required: true }]}><Input placeholder="switch-core" /></Form.Item>
|
||||
<Form.Item label={t('radius.client.ipaddr')} name="ipaddr" rules={[{ required: true }]}><Input placeholder="10.0.0.0/24" /></Form.Item>
|
||||
<Form.Item label={t('radius.client.secret')} name="secret" extra={editing ? (editing.secret_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder={editing?.secret_configured ? '••••••••' : ''} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('radius.client.description')} name="description"><Input /></Form.Item>
|
||||
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface UserForm { username: string; password?: string; active: boolean }
|
||||
|
||||
function UsersTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<UserForm>()
|
||||
const [editing, setEditing] = useState<RADIUSUser | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'users'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/users')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { users?: RADIUSUser[] }).users ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: UserForm) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
if (!v.password) delete body.password
|
||||
return editing ? apiClient.put(`/radius/users/${editing.id}`, body) : apiClient.post('/radius/users', body)
|
||||
},
|
||||
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/radius/users/${id}`),
|
||||
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<RADIUSUser> = [
|
||||
{ title: t('radius.user.username'), dataIndex: 'username' },
|
||||
{ title: t('radius.user.password'), dataIndex: 'password_configured', render: (v: boolean) => (v ? <Tag color="green">✓</Tag> : <Tag>—</Tag>) },
|
||||
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 200,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ username: r.username, active: r.active, password: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
|
||||
{!isViewer && <Popconfirm title={t('radius.user.deleteConfirm', { name: r.username })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Alert type="info" showIcon className="mb-16" message={t('radius.user.papHint')} />
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
|
||||
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<UserForm>); setOpen(true) }}>
|
||||
{t('radius.user.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<RADIUSUser> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
|
||||
<Modal title={editing ? t('radius.user.edit') : t('radius.user.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
|
||||
<Form<UserForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.user.username')} name="username" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item label={t('radius.user.password')} name="password" extra={editing ? (editing.password_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder={editing?.password_configured ? '••••••••' : ''} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir
|
||||
PG Streaming Replication + provider Floating-IP for HTTP ingress).
|
||||
.
|
||||
This package ships the management API, scheduler and CLI.
|
||||
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, kea-dhcp4-server, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
|
||||
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, kea-dhcp4-server, freeradius, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
|
||||
Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban
|
||||
Section: admin
|
||||
Priority: optional
|
||||
|
||||
@@ -31,6 +31,11 @@ case "$1" in
|
||||
if getent group unbound >/dev/null; then
|
||||
usermod -a -G unbound "$EG_USER" || true
|
||||
fi
|
||||
# freeradius: edgeguard muss die gerenderten clients.conf/authorize
|
||||
# ins freeradius-lesbare Verzeichnis schreiben (setgid-Dir unten).
|
||||
if getent group freeradius >/dev/null; then
|
||||
usermod -a -G freeradius "$EG_USER" || true
|
||||
fi
|
||||
# systemd-journal + adm: damit edgeguard-api `journalctl -u …`
|
||||
# ohne sudo lesen kann — wird für /api/v1/logs gebraucht
|
||||
# (zentrale Log-Übersicht über alle Services).
|
||||
@@ -128,6 +133,15 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable kea-dhcp4-server.servic
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable kea-dhcp4-server.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable kea-dhcp4-server.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable kea-dhcp4-server.service
|
||||
# RADIUS (FreeRADIUS): Service-Lifecycle an radius_settings.enabled gekoppelt.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
# Auto-Update-Toggle (Settings → Automatische Updates) schreibt/entfernt
|
||||
@@ -251,6 +265,28 @@ SUDOERS
|
||||
ln -sfn /etc/edgeguard/kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf
|
||||
systemctl disable --now kea-dhcp4-server.service 2>/dev/null || true
|
||||
|
||||
# FreeRADIUS: managed clients.conf + authorize unter /etc/edgeguard/
|
||||
# freeradius (setgid → Dateien erben Gruppe freeradius, 0640 → der
|
||||
# freeradius-Dienst kann lesen). Symlinks von den Distro-Pfaden.
|
||||
# Dienst default AUS (kein RADIUS bis Operator aktiviert).
|
||||
if getent group freeradius >/dev/null; then
|
||||
install -d -m 2750 -o "$EG_USER" -g freeradius /etc/edgeguard/freeradius
|
||||
else
|
||||
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" /etc/edgeguard/freeradius
|
||||
fi
|
||||
if [ -d /etc/freeradius/3.0 ]; then
|
||||
if [ -e /etc/freeradius/3.0/clients.conf ] && [ ! -L /etc/freeradius/3.0/clients.conf ]; then
|
||||
mv /etc/freeradius/3.0/clients.conf /etc/freeradius/3.0/clients.conf.distro-bak
|
||||
fi
|
||||
ln -sfn /etc/edgeguard/freeradius/clients.conf /etc/freeradius/3.0/clients.conf
|
||||
if [ -e /etc/freeradius/3.0/mods-config/files/authorize ] && [ ! -L /etc/freeradius/3.0/mods-config/files/authorize ]; then
|
||||
mv /etc/freeradius/3.0/mods-config/files/authorize /etc/freeradius/3.0/mods-config/files/authorize.distro-bak
|
||||
fi
|
||||
install -d /etc/freeradius/3.0/mods-config/files
|
||||
ln -sfn /etc/edgeguard/freeradius/authorize /etc/freeradius/3.0/mods-config/files/authorize
|
||||
fi
|
||||
systemctl disable --now freeradius.service 2>/dev/null || true
|
||||
|
||||
chmod 0440 /etc/sudoers.d/edgeguard
|
||||
|
||||
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──
|
||||
|
||||
Reference in New Issue
Block a user