From 7e5a7a83c0b0ef8fbfe3236cedac781159fe8c9a Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 29 May 2026 16:07:19 +0200 Subject: [PATCH] feat(setup): Cluster-Node-Modus im Setup-Wizard + Install-Script-Port-Fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup-Wizard zeigt jetzt beim ersten Aufruf eine Modusauswahl: - "Neuinstallation" → bisheriger Flow (Admin-Account + FQDN + ACME) - "Cluster-Knoten beitreten" → nur FQDN + ACME, kein Admin-Account; nach dem Submit werden die cluster-join-Befehle direkt angezeigt Backend: NodeRequest + Store.CompleteAsNode() + POST /setup/complete-node State: is_cluster_node Flag; login via PG-Replikation vom Primary Install-Script: Setup-URL zeigt jetzt korrekt :3443/setup statt /setup Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/setup.go | 19 + internal/services/setup/setup.go | 41 ++ management-ui/src/i18n/locales/de/common.json | 373 +++++++++++------- management-ui/src/i18n/locales/en/common.json | 373 +++++++++++------- management-ui/src/pages/Setup/index.tsx | 324 +++++++++++---- scripts/install.sh | 3 +- 10 files changed, 782 insertions(+), 359 deletions(-) diff --git a/VERSION b/VERSION index 2053fc9..683ec8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.147 +1.1.148 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 022cee6..2b5cc52 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.147" +var version = "1.1.148" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 55c9a86..cddb3e6 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.147" +var version = "1.1.148" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 44d0392..2d46279 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.147" +var version = "1.1.148" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 6c91434..18e7a07 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -34,6 +34,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/setup") g.GET("/status", h.Status) g.POST("/complete", h.Complete) + g.POST("/complete-node", h.CompleteAsNode) } // RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup @@ -111,3 +112,21 @@ func (h *SetupHandler) Complete(c *gin.Context) { "fqdn": st.FQDN, }) } + +func (h *SetupHandler) CompleteAsNode(c *gin.Context) { + var req setup.NodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + st, err := h.Store.CompleteAsNode(req) + if err != nil { + response.BadRequest(c, err) + return + } + response.OK(c, gin.H{ + "completed": st.Completed, + "is_cluster_node": st.IsClusterNode, + "fqdn": st.FQDN, + }) +} diff --git a/internal/services/setup/setup.go b/internal/services/setup/setup.go index 0e63e2e..59cbd96 100644 --- a/internal/services/setup/setup.go +++ b/internal/services/setup/setup.go @@ -38,6 +38,9 @@ type State struct { LicenseKey string `json:"license_key,omitempty"` Completed bool `json:"completed"` CompletedAt *time.Time `json:"completed_at,omitempty"` + // IsClusterNode marks this node as a secondary cluster member. + // Admin credentials are absent — they arrive via PG replication. + IsClusterNode bool `json:"is_cluster_node,omitempty"` // MaintenanceMode (whole-box): wenn true rendert HAProxy auf // :443 nur einen 503-Block für ALLE Customer-Domains. Mgmt-UI @@ -75,6 +78,13 @@ type Request struct { LicenseKey string `json:"license_key,omitempty"` } +// NodeRequest is the JSON body POST /api/v1/setup/complete-node accepts. +// No admin credentials — they are replicated from the primary via PG. +type NodeRequest struct { + FQDN string `json:"fqdn" binding:"required"` + ACMEEmail string `json:"acme_email" binding:"required,email"` +} + type Store struct { Dir string } @@ -155,6 +165,37 @@ func (s *Store) Complete(req Request) (*State, error) { return st, nil } +// CompleteAsNode configures this node as a secondary cluster member. +// No admin credentials are created; login becomes available after PG +// replication brings users from the primary. +func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) { + if !looksLikeFQDN(req.FQDN) { + return nil, fmt.Errorf("fqdn %q does not look like a fully-qualified hostname", req.FQDN) + } + if _, err := mail.ParseAddress(req.ACMEEmail); err != nil { + return nil, fmt.Errorf("invalid acme_email: %w", err) + } + prev, err := s.Load() + if err != nil { + return nil, err + } + if prev.Completed { + return nil, errors.New("setup already completed") + } + now := time.Now().UTC() + st := &State{ + FQDN: strings.TrimSpace(req.FQDN), + ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)), + IsClusterNode: true, + Completed: true, + CompletedAt: &now, + } + if err := s.Save(st); err != nil { + return nil, err + } + return st, nil +} + // VerifyAdminPassword does constant-time bcrypt comparison. func (st *State) VerifyAdminPassword(plaintext string) bool { return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index b97fefd..e2f0863 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -63,52 +63,87 @@ "emptyDesc": "Zonen gruppieren Interfaces (lan, wan, dmz). Vordefinierte Zonen sind bereits da; eigene anlegen wenn du z. B. eine separate dmz-Zone oder eine wg-Zone fürs VPN brauchst." }, "ao": { - "name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung", - "add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten", + "name": "Name", + "kind": "Typ", + "value": "Wert", + "description": "Beschreibung", + "add": "Adress-Objekt hinzufügen", + "edit": "Adress-Objekt bearbeiten", "deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?", "emptyTitle": "Noch keine Adress-Objekte.", "emptyDesc": "Wiederverwendbare benannte IPs/Netze/Ranges für Firewall- und NAT-Regeln (z. B. office-net = 10.0.0.0/24, mailbox-1 = 10.0.1.42)." }, "ag": { - "name": "Name", "members": "Mitglieder", "description": "Beschreibung", - "add": "Adress-Gruppe hinzufügen", "edit": "Adress-Gruppe bearbeiten", + "name": "Name", + "members": "Mitglieder", + "description": "Beschreibung", + "add": "Adress-Gruppe hinzufügen", + "edit": "Adress-Gruppe bearbeiten", "selectMembers": "Adress-Objekte wählen", "deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?", "emptyTitle": "Noch keine Adress-Gruppen.", "emptyDesc": "Bündele mehrere Adress-Objekte zu einer Gruppe (z. B. office-locations = [hq-net, branch-1-net, branch-2-net]) — Regeln müssen dann nur eine Gruppe referenzieren." }, "svc": { - "name": "Name", "proto": "Protokoll", "ports": "Ports", - "portStart": "Port (Start)", "portEnd": "Port (Ende)", - "description": "Beschreibung", "builtinHint": "Vordefiniert — nicht editierbar", - "add": "Service hinzufügen", "edit": "Service bearbeiten", + "name": "Name", + "proto": "Protokoll", + "ports": "Ports", + "portStart": "Port (Start)", + "portEnd": "Port (Ende)", + "description": "Beschreibung", + "builtinHint": "Vordefiniert — nicht editierbar", + "add": "Service hinzufügen", + "edit": "Service bearbeiten", "deleteConfirm": "Service {{name}} wirklich löschen?", "emptyTitle": "Noch keine eigenen Services.", "emptyDesc": "Vordefinierte (HTTP, HTTPS, SSH, …) sind bereits da. Eigene Services für app-spezifische Ports (z. B. mailcow-imaps tcp/993) erleichtern Regeln deutlich." }, "sg": { - "name": "Name", "members": "Mitglieder", "description": "Beschreibung", - "add": "Service-Gruppe hinzufügen", "edit": "Service-Gruppe bearbeiten", + "name": "Name", + "members": "Mitglieder", + "description": "Beschreibung", + "add": "Service-Gruppe hinzufügen", + "edit": "Service-Gruppe bearbeiten", "selectMembers": "Services wählen", "deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?", "emptyTitle": "Noch keine Service-Gruppen.", "emptyDesc": "Bündele mehrere Services zu einer Gruppe (z. B. web-stack = [HTTP, HTTPS, HTTP/3]) — eine Regel mit der Gruppe ersetzt drei Regeln pro Service." }, "rule": { - "name": "Name", "priority": "Priority", "enabled": "Aktiv", "log": "Logging", - "action": "Aktion", "src": "Quelle", "dst": "Ziel", "service": "Service", - "srcZone": "Quell-Zone", "dstZone": "Ziel-Zone", - "srcKind": "Quell-Typ", "dstKind": "Ziel-Typ", - "object": "Adress-Objekt", "group": "Adress-Gruppe", - "serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe", + "name": "Name", + "priority": "Priority", + "enabled": "Aktiv", + "log": "Logging", + "action": "Aktion", + "src": "Quelle", + "dst": "Ziel", + "service": "Service", + "srcZone": "Quell-Zone", + "dstZone": "Ziel-Zone", + "srcKind": "Quell-Typ", + "dstKind": "Ziel-Typ", + "object": "Adress-Objekt", + "group": "Adress-Gruppe", + "serviceKind": "Service-Typ", + "serviceGroup": "Service-Gruppe", "comment": "Kommentar", "hits": "Hits", - "add": "Regel hinzufügen", "edit": "Regel bearbeiten", + "add": "Regel hinzufügen", + "edit": "Regel bearbeiten", "deleteConfirm": "Diese Regel wirklich löschen?", "namePlaceholder": "z.B. SSH von Office erlauben", "cidr": "CIDR", - "actions": { "accept": "ACCEPT", "drop": "DROP", "reject": "REJECT" }, - "kinds": { "any": "Beliebig", "object": "Objekt", "group": "Gruppe", "cidr": "CIDR" }, + "actions": { + "accept": "ACCEPT", + "drop": "DROP", + "reject": "REJECT" + }, + "kinds": { + "any": "Beliebig", + "object": "Objekt", + "group": "Gruppe", + "cidr": "CIDR" + }, "duplicate": "Regel duplizieren", "duplicated": "Regel dupliziert (deaktiviert, Priorität +1).", "cidrInvalid": "Gültiges CIDR eingeben (z.B. 10.0.0.0/24)", @@ -139,16 +174,27 @@ "noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen." }, "nat": { - "name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv", - "match": "Match", "target": "Ziel", - "inZone": "Eingangs-Zone", "outZone": "Ausgangs-Zone", "proto": "Protokoll", - "matchSrcCidr": "Source-CIDR (Match)", "matchDstCidr": "Dest-CIDR (Match)", + "name": "Name", + "priority": "Priority", + "kind": "Typ", + "enabled": "Aktiv", + "match": "Match", + "target": "Ziel", + "inZone": "Eingangs-Zone", + "outZone": "Ausgangs-Zone", + "proto": "Protokoll", + "matchSrcCidr": "Source-CIDR (Match)", + "matchDstCidr": "Dest-CIDR (Match)", "matchDstCidrHint": "leer = jede dest-IP (z.B. öffentliche IP der Box)", - "dportStart": "Port (Start)", "dportEnd": "Port (Ende)", + "dportStart": "Port (Start)", + "dportEnd": "Port (Ende)", "portRangeError": "End-Port muss ≥ Start-Port sein", - "targetAddr": "Ziel-Adresse", "targetPortStart": "Ziel-Port (Start)", "targetPortEnd": "Ziel-Port (Ende)", + "targetAddr": "Ziel-Adresse", + "targetPortStart": "Ziel-Port (Start)", + "targetPortEnd": "Ziel-Port (Ende)", "comment": "Kommentar", - "add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten", + "add": "NAT-Regel hinzufügen", + "edit": "NAT-Regel bearbeiten", "deleteConfirm": "Diese NAT-Regel wirklich löschen?", "namePlaceholder": "z.B. HTTP zu Web-Backend weiterleiten", "emptyTitle": "Noch keine NAT-Regeln.", @@ -156,7 +202,10 @@ }, "sys": { "title": "System-Regeln (immer aktiv)", - "chain": "Chain", "match": "Match", "action": "Aktion", "note": "Hinweis", + "chain": "Chain", + "match": "Match", + "action": "Aktion", + "note": "Hinweis", "policy": "Default-Policy", "policyValue": "Eingang DROP — alles muss explizit erlaubt werden.", "order": "Auswertung", @@ -266,11 +315,116 @@ "licenseKeyHint": "Wenn vorhanden: 30-Tage-Trial wird übersprungen, Features unmittelbar freigeschaltet. Kann auch später unter License nachgereicht werden.", "submit": "Setup abschließen", "successTitle": "Setup abgeschlossen", - "successHint": "Du wirst zur Anmeldung weitergeleitet." + "successHint": "Du wirst zur Anmeldung weitergeleitet.", + "back": "Zurück", + "modeSelect": "Wie soll dieser Server eingerichtet werden?", + "modeStandalone": "Neuinstallation", + "modeStandaloneDesc": "Eigenständiger EdgeGuard oder erster Cluster-Knoten. Erstellt einen Admin-Account auf diesem Server.", + "modeNode": "Vorhandenem Cluster beitreten", + "modeNodeDesc": "Diesen Server als zweiten Cluster-Knoten hinzufügen. Admin-Zugangsdaten werden automatisch vom Primary repliziert.", + "nodeTitle": "Cluster-Knoten einrichten", + "nodeIntro": "Trage den FQDN dieses Knotens ein. Es wird kein Admin-Account erstellt — die Zugangsdaten kommen nach der Datenbankreplikation vom Primary.", + "nodePreflightTitle": "Vor dem Fortfahren", + "nodePreflightDesc": "Stelle sicher, dass EdgeGuard auf dem Primary bereits läuft und du dort einen Join-Token erstellen kannst (Cluster-Seite). Du benötigst ihn im nächsten Schritt.", + "nodeSubmit": "Als Cluster-Knoten einrichten", + "nodeSuccessTitle": "Knoten konfiguriert", + "nodeJoinTitle": "Nächster Schritt: Cluster beitreten", + "nodeJoinDesc": "Dieser Server ({{fqdn}}) ist konfiguriert. Führe jetzt den Join-Befehl aus, um ihn mit dem Primary zu verbinden.", + "nodeStep1Title": "Join-Token auf dem Primary generieren", + "nodeStep1Desc": "Öffne die Cluster-Seite auf deinem Primary-EdgeGuard → Knoten hinzufügen → Join-Token generieren. Token kopieren.", + "nodeStep2Title": "Join-Befehl auf diesem Server ausführen", + "nodeStep2Desc": "Ersetze mit dem FQDN deines Primarys und mit dem Token aus Schritt 1:", + "nodeStep3Title": "API neu starten", + "nodeStep3Desc": "Nach erfolgreichem Join-Befehl API neu starten, damit die replizierte Konfiguration übernommen wird:", + "nodeLoginNote": "Der Login erfolgt mit den Admin-Zugangsdaten des Primarys. Das funktioniert sobald die Datenbankreplikation aufgebaut wurde (der Join-Befehl richtet das ein)." }, "dashboard": { "title": "Dashboard", - "welcomeHint": "Übersicht aller laufenden EdgeGuard-Komponenten." + "welcomeHint": "EdgeGuard-Übersicht — Health, Counts, Live-Status der wichtigsten Dienste.", + "kpi": { + "domains": "Domains", + "backends": "Backends", + "ifaces": "Interfaces", + "fwRules": "FW-Regeln", + "natRules": "NAT-Regeln", + "wg": "WG-Verbindungen" + }, + "wgCard": { + "title": "WireGuard", + "empty": "Noch kein WG-Tunnel angelegt.", + "peersOnline": "{{online}} / {{total}} Peers verbunden" + }, + "firewallCard": { + "title": "Firewall", + "zones": "Zonen", + "activeRules": "{{rules}} aktive Regeln · {{nat}} NAT" + }, + "sslCard": { + "title": "SSL-Zertifikate", + "total": "Verwaltete Zertifikate", + "certExpired": "{{count}} Zertifikat(e) abgelaufen — sofort erneuern", + "expiringSoon": "{{count}} läuft bald ab (< 30 Tage)", + "allFresh": "Alle Zertifikate haben > 30 Tage Restlaufzeit." + }, + "clusterCard": { + "title": "Cluster", + "nodes": "Knoten", + "modeSingle": "Single-Node", + "modeCluster": "Cluster", + "drift": "Config-Drift erkannt", + "health": { + "ok": "OK", + "degraded": "degraded", + "split-brain": "split-brain" + } + }, + "routingCard": { + "title": "Routing", + "domains": "Domains", + "backends": "Backends", + "attached": "{{count}}/{{total}} Domains haben einen Primary-Backend" + }, + "systemCard": { + "title": "System", + "version": "Version", + "api": "API", + "ifaces": "Interfaces", + "wg": "WireGuard" + }, + "alertsCard": { + "title": "Aktuelle Alerts", + "viewAll": "Alle anzeigen" + }, + "downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP", + "maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus", + "onboardingTitle": "Willkommen bei EdgeGuard", + "onboardingIntro": "Frische Box — hier die nächsten Schritte um Customer-Traffic zu routen:", + "onboardingStep1": "Backend-Pool anlegen (App-Server hinter HAProxy)", + "onboardingStep2": "Domain hinzufügen (FQDN, primary Backend zuweisen)", + "onboardingStep3": "TLS-Zertifikat ausstellen (Let's Encrypt HTTP-01)", + "servicesCard": { + "title": "Service-Status (live, 10s)" + }, + "activityCard": { + "title": "Letzte Aktivität (Audit-Log)", + "empty": "Noch keine Aktivität — Mutationen werden hier protokolliert." + }, + "haproxyCard": { + "title": "HAProxy-Backends (live)", + "empty": "Keine Backend-Stats erreichbar (HAProxy down oder admin.sock-permission).", + "frontends": "Listener" + }, + "resCard": { + "load": "Load", + "memory": "Memory", + "disk": "Disk /", + "free": "frei", + "conntrack": "Conntrack", + "uptime": "Uptime" + }, + "licenseOk": "Lizenz OK", + "licenseTrial": "Trial", + "licenseTrialDays": "Trial · {{days}}d" }, "domains": { "title": "Domains", @@ -680,7 +834,10 @@ "wg": { "title": "WireGuard", "intro": "VPN-Tunnel über WireGuard. Server-Modus = wir lauschen für Peers; Client-Modus = wir verbinden zu einem festen Upstream. Privater Schlüssel liegt verschlüsselt in der DB.", - "tabs": { "servers": "Server-Tunnel", "clients": "Client-Tunnel" }, + "tabs": { + "servers": "Server-Tunnel", + "clients": "Client-Tunnel" + }, "serverIntro": "Server-Tunnel hosten ein Peer-Roster — z.B. Mitarbeiter-Geräte oder Niederlassungen. Pro Peer bekommt der Operator eine .conf zum Download (oder QR-Code für Mobile).", "clientIntro": "Client-Tunnel verbinden EdgeGuard zu einem fremden WireGuard-Server (z.B. HQ-Datacenter). Allowed-IPs steuert, welcher Traffic durch den Tunnel geroutet wird.", "iface": { @@ -767,98 +924,14 @@ "failed": "Import fehlgeschlagen" } }, - "dashboard": { - "title": "Dashboard", - "welcomeHint": "EdgeGuard-Übersicht — Health, Counts, Live-Status der wichtigsten Dienste.", - "kpi": { - "domains": "Domains", - "backends": "Backends", - "ifaces": "Interfaces", - "fwRules": "FW-Regeln", - "natRules": "NAT-Regeln", - "wg": "WG-Verbindungen" - }, - "wgCard": { - "title": "WireGuard", - "empty": "Noch kein WG-Tunnel angelegt.", - "peersOnline": "{{online}} / {{total}} Peers verbunden" - }, - "firewallCard": { - "title": "Firewall", - "zones": "Zonen", - "activeRules": "{{rules}} aktive Regeln · {{nat}} NAT" - }, - "sslCard": { - "title": "SSL-Zertifikate", - "total": "Verwaltete Zertifikate", - "certExpired": "{{count}} Zertifikat(e) abgelaufen — sofort erneuern", - "expiringSoon": "{{count}} läuft bald ab (< 30 Tage)", - "allFresh": "Alle Zertifikate haben > 30 Tage Restlaufzeit." - }, - "clusterCard": { - "title": "Cluster", - "nodes": "Knoten", - "modeSingle": "Single-Node", - "modeCluster": "Cluster", - "drift": "Config-Drift erkannt", - "health": { - "ok": "OK", - "degraded": "degraded", - "split-brain": "split-brain" - } - }, - "routingCard": { - "title": "Routing", - "domains": "Domains", - "backends": "Backends", - "attached": "{{count}}/{{total}} Domains haben einen Primary-Backend" - }, - "systemCard": { - "title": "System", - "version": "Version", - "api": "API", - "ifaces": "Interfaces", - "wg": "WireGuard" - }, - "alertsCard": { - "title": "Aktuelle Alerts", - "viewAll": "Alle anzeigen" - }, - "downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP", - "maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus", - "onboardingTitle": "Willkommen bei EdgeGuard", - "onboardingIntro": "Frische Box — hier die nächsten Schritte um Customer-Traffic zu routen:", - "onboardingStep1": "Backend-Pool anlegen (App-Server hinter HAProxy)", - "onboardingStep2": "Domain hinzufügen (FQDN, primary Backend zuweisen)", - "onboardingStep3": "TLS-Zertifikat ausstellen (Let's Encrypt HTTP-01)", - "servicesCard": { - "title": "Service-Status (live, 10s)" - }, - "activityCard": { - "title": "Letzte Aktivität (Audit-Log)", - "empty": "Noch keine Aktivität — Mutationen werden hier protokolliert." - }, - "haproxyCard": { - "title": "HAProxy-Backends (live)", - "empty": "Keine Backend-Stats erreichbar (HAProxy down oder admin.sock-permission).", - "frontends": "Listener" - }, - "resCard": { - "load": "Load", - "memory": "Memory", - "disk": "Disk /", - "free": "frei", - "conntrack": "Conntrack", - "uptime": "Uptime" - }, - "licenseOk": "Lizenz OK", - "licenseTrial": "Trial", - "licenseTrialDays": "Trial · {{days}}d" - }, "ntp": { "title": "Zeitserver (Chrony)", "intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.", - "tabs": { "pools": "Quellen", "settings": "Settings", "peers": "Peer-Status" }, + "tabs": { + "pools": "Quellen", + "settings": "Settings", + "peers": "Peer-Status" + }, "sourcesCard": { "title": "Live Peer-Status (chronyc sources)", "name": "Name / IP", @@ -934,7 +1007,11 @@ "dns": { "title": "DNS (Unbound)", "intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.", - "tabs": { "zones": "Zonen", "settings": "Resolver-Settings", "stats": "Resolver-Stats" }, + "tabs": { + "zones": "Zonen", + "settings": "Resolver-Settings", + "stats": "Resolver-Stats" + }, "statsCard": { "title": "Resolver-Statistiken (unbound-control stats_noreset)", "totalQueries": "Abfragen gesamt", @@ -1043,22 +1120,22 @@ }, "actions": { "allow": "allow — Zugriff erlauben", - "deny": "deny — Zugriff blockieren" + "deny": "deny — Zugriff blockieren" }, "aclTypes": { - "src": "src — Quell-IP/CIDR", - "dst": "dst — Ziel-IP/CIDR", - "dstdomain": "dstdomain — Ziel-Domain (exact)", - "srcdomain": "srcdomain — Quell-Domain (rDNS)", - "port": "port — Ziel-Port", - "proto": "proto — http/https/ftp/...", - "method": "method — GET/POST/CONNECT/...", - "time": "time — Wochentag/Zeit", - "url_regex": "url_regex — kompletter URL-Match", + "src": "src — Quell-IP/CIDR", + "dst": "dst — Ziel-IP/CIDR", + "dstdomain": "dstdomain — Ziel-Domain (exact)", + "srcdomain": "srcdomain — Quell-Domain (rDNS)", + "port": "port — Ziel-Port", + "proto": "proto — http/https/ftp/...", + "method": "method — GET/POST/CONNECT/...", + "time": "time — Wochentag/Zeit", + "url_regex": "url_regex — kompletter URL-Match", "urlpath_regex": "urlpath_regex — Path-Teil", - "dstdom_regex": "dstdom_regex — Domain-Regex", - "srcdom_regex": "srcdom_regex — Quell-Domain-Regex", - "browser": "browser — User-Agent-Regex" + "dstdom_regex": "dstdom_regex — Domain-Regex", + "srcdom_regex": "srcdom_regex — Quell-Domain-Regex", + "browser": "browser — User-Agent-Regex" } }, "common": { @@ -1173,7 +1250,10 @@ "intro": "Notification-Channels für kritische Events. Webhook (Slack/Discord/Teams/Generic-HTTP) oder Email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.", "scopeTitle": "Was triggert Alarme?", "scopeDesc": "cert.expiring — TLS-Zertifikat <14 Tage Restzeit (dedupe 12h). cert.renew_failed — ACME-Renewer hat Fails. backup.failed — Scheduled Backup konnte nicht erstellt werden. license.invalid — License-Server liefert valid=false. backend.down — alle Server eines Backend-Pools sind DOWN (2-Min-Check, dedupe 12h). disk.full — Root-Filesystem ≥80% Warnung, ≥90% Critical (stündlich, dedupe 12h). mem.high — RAM-Auslastung ≥85% Warnung, ≥95% Critical (5-Min-Check, dedupe 12h). conntrack.high — Conntrack-Tabelle ≥80% Warnung, ≥90% Critical; bei 100% werden alle neuen Verbindungen lautlos verworfen (2-Min-Check, dedupe 12h). ntp.unsync — chrony hat keine synchronisierte Zeitquelle; Drift führt zu TLS- und JWT-Fehlern (10-Min-Check, dedupe 12h). wg.tunnel.down — WireGuard-Client-Tunnel hat seit >5 Min keinen Handshake; Remote-Traffic wird lautlos verworfen (5-Min-Check, dedupe 12h).", - "tabs": { "channels": "Channels", "events": "History" }, + "tabs": { + "channels": "Channels", + "events": "History" + }, "add": "Channel hinzufügen", "addTitle": "Notification-Channel anlegen", "editTitle": "Channel bearbeiten", @@ -1264,11 +1344,21 @@ "run": "Ausführen", "runFromBoxTitle": "Tools laufen auf der EdgeGuard-Box", "runFromBoxDesc": "Diese Aufrufe nutzen die Outbound-Connectivity der EdgeGuard — nicht deinen lokalen Rechner. Wenn ping/curl auf der Box scheitert, ist das Box-Network-Layer das Problem (Firewall-Rules, Default-Route, DNS).", - "ping": { "intro": "ICMP echo-request × 4, 2s Timeout pro Paket. Misst Loss + RTT." }, - "trace": { "intro": "Hop-by-Hop-Pfad mit UDP-Probes (max 20 Hops, kein reverse-DNS)." }, - "dig": { "intro": "DNS-Lookup über unbound (Box-Resolver). Zeigt Antwort + AUTHORITY/ADDITIONAL Section." }, - "curl": { "intro": "HTTPS-Probe mit -IsSv: TLS-Handshake-Details + Header. Folgt KEINE Redirects." }, - "tcp": { "intro": "Pure TCP-Connect ohne I/O. Ideal um zu prüfen ob ein Backend-Port erreichbar ist." }, + "ping": { + "intro": "ICMP echo-request × 4, 2s Timeout pro Paket. Misst Loss + RTT." + }, + "trace": { + "intro": "Hop-by-Hop-Pfad mit UDP-Probes (max 20 Hops, kein reverse-DNS)." + }, + "dig": { + "intro": "DNS-Lookup über unbound (Box-Resolver). Zeigt Antwort + AUTHORITY/ADDITIONAL Section." + }, + "curl": { + "intro": "HTTPS-Probe mit -IsSv: TLS-Handshake-Details + Header. Folgt KEINE Redirects." + }, + "tcp": { + "intro": "Pure TCP-Connect ohne I/O. Ideal um zu prüfen ob ein Backend-Port erreichbar ist." + }, "securityTitle": "Sicherheits-Hinweis", "securityDesc": "Endpoints sind hinter Admin-Auth. Targets werden gegen eine strikte Allow-Liste validiert (keine Shell-Metazeichen). curl ist auf http(s) beschränkt — kein file:// / smb:// / data://.", "noOutput": "(kein Output)" @@ -1278,7 +1368,10 @@ "intro": "Sicherungen der PostgreSQL-Datenbank + /var/lib/edgeguard (Setup, License, JWT, ACME-Account). Täglicher Auto-Job + manueller Trigger.", "scopeTitle": "Was wird gesichert?", "scopeDesc": "DB-Dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Konfig-Dateien (haproxy.cfg, nft, …) sind aus der DB regenerierbar und werden NICHT mitgesichert.", - "tabs": { "history": "Sicherungen", "remotes": "Off-Site-Ziele" }, + "tabs": { + "history": "Sicherungen", + "remotes": "Off-Site-Ziele" + }, "statLastSuccess": "Letzte erfolgreiche Sicherung", "statTotal": "Erfolgreiche Sicherungen", "statSize": "Gesamtgröße", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 7ef7944..c199427 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -63,52 +63,87 @@ "emptyDesc": "Zones group interfaces (lan, wan, dmz). Built-in zones already exist; add custom ones for e.g. a separate dmz or a wg zone for VPN." }, "ao": { - "name": "Name", "kind": "Kind", "value": "Value", "description": "Description", - "add": "Add address object", "edit": "Edit address object", + "name": "Name", + "kind": "Kind", + "value": "Value", + "description": "Description", + "add": "Add address object", + "edit": "Edit address object", "deleteConfirm": "Really delete address object {{name}}?", "emptyTitle": "No address objects yet.", "emptyDesc": "Reusable named IPs/networks/ranges for firewall + NAT rules (e.g. office-net = 10.0.0.0/24, mailbox-1 = 10.0.1.42)." }, "ag": { - "name": "Name", "members": "Members", "description": "Description", - "add": "Add address group", "edit": "Edit address group", + "name": "Name", + "members": "Members", + "description": "Description", + "add": "Add address group", + "edit": "Edit address group", "selectMembers": "Select address objects", "deleteConfirm": "Really delete address group {{name}}?", "emptyTitle": "No address groups yet.", "emptyDesc": "Bundle multiple address objects into a group (e.g. office-locations = [hq-net, branch-1-net, branch-2-net]) — rules then reference one group." }, "svc": { - "name": "Name", "proto": "Protocol", "ports": "Ports", - "portStart": "Port (start)", "portEnd": "Port (end)", - "description": "Description", "builtinHint": "Built-in — not editable", - "add": "Add service", "edit": "Edit service", + "name": "Name", + "proto": "Protocol", + "ports": "Ports", + "portStart": "Port (start)", + "portEnd": "Port (end)", + "description": "Description", + "builtinHint": "Built-in — not editable", + "add": "Add service", + "edit": "Edit service", "deleteConfirm": "Really delete service {{name}}?", "emptyTitle": "No custom services yet.", "emptyDesc": "Built-ins (HTTP, HTTPS, SSH, …) already exist. Add app-specific ports (e.g. mailcow-imaps tcp/993) to make rules easier to read." }, "sg": { - "name": "Name", "members": "Members", "description": "Description", - "add": "Add service group", "edit": "Edit service group", + "name": "Name", + "members": "Members", + "description": "Description", + "add": "Add service group", + "edit": "Edit service group", "selectMembers": "Select services", "deleteConfirm": "Really delete service group {{name}}?", "emptyTitle": "No service groups yet.", "emptyDesc": "Bundle multiple services into a group (e.g. web-stack = [HTTP, HTTPS, HTTP/3]) — one rule with the group replaces three rules per service." }, "rule": { - "name": "Name", "priority": "Priority", "enabled": "Enabled", "log": "Log", - "action": "Action", "src": "Source", "dst": "Destination", "service": "Service", - "srcZone": "Source zone", "dstZone": "Dest. zone", - "srcKind": "Source kind", "dstKind": "Dest. kind", - "object": "Address object", "group": "Address group", - "serviceKind": "Service kind", "serviceGroup": "Service group", + "name": "Name", + "priority": "Priority", + "enabled": "Enabled", + "log": "Log", + "action": "Action", + "src": "Source", + "dst": "Destination", + "service": "Service", + "srcZone": "Source zone", + "dstZone": "Dest. zone", + "srcKind": "Source kind", + "dstKind": "Dest. kind", + "object": "Address object", + "group": "Address group", + "serviceKind": "Service kind", + "serviceGroup": "Service group", "comment": "Comment", "hits": "Hits", - "add": "Add rule", "edit": "Edit rule", + "add": "Add rule", + "edit": "Edit rule", "deleteConfirm": "Really delete this rule?", "namePlaceholder": "e.g. Allow SSH from office", "cidr": "CIDR", - "actions": { "accept": "ACCEPT", "drop": "DROP", "reject": "REJECT" }, - "kinds": { "any": "Any", "object": "Object", "group": "Group", "cidr": "CIDR" }, + "actions": { + "accept": "ACCEPT", + "drop": "DROP", + "reject": "REJECT" + }, + "kinds": { + "any": "Any", + "object": "Object", + "group": "Group", + "cidr": "CIDR" + }, "duplicate": "Duplicate rule", "duplicated": "Rule duplicated (disabled, priority +1).", "cidrInvalid": "Enter a valid CIDR (e.g. 10.0.0.0/24)", @@ -139,16 +174,27 @@ "noResultsHint": "Clear the filter to see all rules." }, "nat": { - "name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled", - "match": "Match", "target": "Target", - "inZone": "Ingress zone", "outZone": "Egress zone", "proto": "Protocol", - "matchSrcCidr": "Source CIDR (match)", "matchDstCidr": "Dest. CIDR (match)", + "name": "Name", + "priority": "Priority", + "kind": "Kind", + "enabled": "Enabled", + "match": "Match", + "target": "Target", + "inZone": "Ingress zone", + "outZone": "Egress zone", + "proto": "Protocol", + "matchSrcCidr": "Source CIDR (match)", + "matchDstCidr": "Dest. CIDR (match)", "matchDstCidrHint": "empty = any dest IP (e.g. box's public IP)", - "dportStart": "Port (start)", "dportEnd": "Port (end)", + "dportStart": "Port (start)", + "dportEnd": "Port (end)", "portRangeError": "End port must be ≥ start port", - "targetAddr": "Target address", "targetPortStart": "Target port (start)", "targetPortEnd": "Target port (end)", + "targetAddr": "Target address", + "targetPortStart": "Target port (start)", + "targetPortEnd": "Target port (end)", "comment": "Comment", - "add": "Add NAT rule", "edit": "Edit NAT rule", + "add": "Add NAT rule", + "edit": "Edit NAT rule", "deleteConfirm": "Really delete this NAT rule?", "namePlaceholder": "e.g. Forward HTTP to web backend", "emptyTitle": "No NAT rules yet.", @@ -156,7 +202,10 @@ }, "sys": { "title": "System rules (always active)", - "chain": "Chain", "match": "Match", "action": "Action", "note": "Note", + "chain": "Chain", + "match": "Match", + "action": "Action", + "note": "Note", "policy": "Default policy", "policyValue": "Input DROP — everything must be explicitly allowed.", "order": "Evaluation", @@ -266,11 +315,116 @@ "licenseKeyHint": "If present: 30-day trial is skipped, features unlock immediately. Can also be added later under License.", "submit": "Finish setup", "successTitle": "Setup complete", - "successHint": "Redirecting you to sign-in." + "successHint": "Redirecting you to sign-in.", + "back": "Back", + "modeSelect": "How would you like to set up this box?", + "modeStandalone": "Fresh installation", + "modeStandaloneDesc": "Set up as a standalone EdgeGuard or the first node of a new cluster. Creates an admin account on this box.", + "modeNode": "Join existing cluster", + "modeNodeDesc": "Add this box as a second cluster node. Admin credentials are replicated from the primary automatically — no separate account needed.", + "nodeTitle": "Cluster Node Setup", + "nodeIntro": "Enter the FQDN of this node. No admin account is created here — login credentials come from the primary via database replication after you run the join command.", + "nodePreflightTitle": "Before you continue", + "nodePreflightDesc": "Make sure EdgeGuard is already running on your primary node and you can generate a join token there (Cluster page). You will need it in the next step.", + "nodeSubmit": "Configure as cluster node", + "nodeSuccessTitle": "Node configured", + "nodeJoinTitle": "Next: join the cluster", + "nodeJoinDesc": "This box ({{fqdn}}) is configured. Now run the join command below to connect it to the primary.", + "nodeStep1Title": "Generate a join token on the primary", + "nodeStep1Desc": "Open the Cluster page on your primary EdgeGuard → Add node → Generate join token. Copy the token.", + "nodeStep2Title": "Run the join command on this box", + "nodeStep2Desc": "Replace with your primary's FQDN and with the token from step 1:", + "nodeStep3Title": "Restart the API", + "nodeStep3Desc": "After the join command completes, restart the API to pick up the replicated configuration:", + "nodeLoginNote": "Login uses the admin credentials from the primary. This will work once database replication is established (the join command sets this up)." }, "dashboard": { "title": "Dashboard", - "welcomeHint": "Overview of all running EdgeGuard components." + "welcomeHint": "EdgeGuard overview — health, counts, live status of the major services.", + "kpi": { + "domains": "Domains", + "backends": "Backends", + "ifaces": "Interfaces", + "fwRules": "FW rules", + "natRules": "NAT rules", + "wg": "WG connections" + }, + "wgCard": { + "title": "WireGuard", + "empty": "No WG tunnel configured yet.", + "peersOnline": "{{online}} / {{total}} peers online" + }, + "firewallCard": { + "title": "Firewall", + "zones": "Zones", + "activeRules": "{{rules}} active rules · {{nat}} NAT" + }, + "sslCard": { + "title": "SSL certificates", + "total": "Managed certificates", + "certExpired": "{{count}} certificate(s) expired — renew immediately", + "expiringSoon": "{{count}} expiring soon (< 30 days)", + "allFresh": "All certs have > 30 days remaining." + }, + "clusterCard": { + "title": "Cluster", + "nodes": "Nodes", + "modeSingle": "Single-Node", + "modeCluster": "Cluster", + "drift": "Config drift detected", + "health": { + "ok": "OK", + "degraded": "degraded", + "split-brain": "split-brain" + } + }, + "routingCard": { + "title": "Routing", + "domains": "Domains", + "backends": "Backends", + "attached": "{{count}}/{{total}} domains have a primary backend" + }, + "systemCard": { + "title": "System", + "version": "Version", + "api": "API", + "ifaces": "Interfaces", + "wg": "WireGuard" + }, + "alertsCard": { + "title": "Recent alerts", + "viewAll": "View all" + }, + "downBackendsAlert": "{{count}} backend(s) completely down — no server UP", + "maintenanceAlert": "{{count}} domain(s) in maintenance mode", + "onboardingTitle": "Welcome to EdgeGuard", + "onboardingIntro": "Fresh box — here are the next steps to route customer traffic:", + "onboardingStep1": "Create a backend pool (app servers behind HAProxy)", + "onboardingStep2": "Add a domain (FQDN, assign primary backend)", + "onboardingStep3": "Issue TLS certificate (Let's Encrypt HTTP-01)", + "servicesCard": { + "title": "Service status (live, 10s)" + }, + "activityCard": { + "title": "Recent activity (audit log)", + "empty": "No activity yet — mutations are logged here." + }, + "haproxyCard": { + "title": "HAProxy backends (live)", + "empty": "No backend stats reachable (HAProxy down or admin.sock permission).", + "frontends": "Listeners" + }, + "resCard": { + "load": "Load", + "memory": "Memory", + "disk": "Disk /", + "free": "free", + "conntrack": "Conntrack", + "uptime": "Uptime" + }, + "licenseOk": "License OK", + "licenseTrial": "Trial", + "licenseTrialDays": "Trial · {{days}}d" }, "domains": { "title": "Domains", @@ -680,7 +834,10 @@ "wg": { "title": "WireGuard", "intro": "WireGuard VPN tunnels. Server mode = we listen for peers; client mode = we dial out to a fixed upstream. Private keys are encrypted at rest.", - "tabs": { "servers": "Server tunnels", "clients": "Client tunnels" }, + "tabs": { + "servers": "Server tunnels", + "clients": "Client tunnels" + }, "serverIntro": "Server tunnels host a peer roster — typically employee devices or branch sites. Each peer can be downloaded as a wg-quick.conf or scanned as a QR code.", "clientIntro": "Client tunnels connect EdgeGuard to a remote WireGuard server (e.g. HQ datacenter). Allowed IPs control which traffic is routed through the tunnel.", "iface": { @@ -767,98 +924,14 @@ "failed": "Import failed" } }, - "dashboard": { - "title": "Dashboard", - "welcomeHint": "EdgeGuard overview — health, counts, live status of the major services.", - "kpi": { - "domains": "Domains", - "backends": "Backends", - "ifaces": "Interfaces", - "fwRules": "FW rules", - "natRules": "NAT rules", - "wg": "WG connections" - }, - "wgCard": { - "title": "WireGuard", - "empty": "No WG tunnel configured yet.", - "peersOnline": "{{online}} / {{total}} peers online" - }, - "firewallCard": { - "title": "Firewall", - "zones": "Zones", - "activeRules": "{{rules}} active rules · {{nat}} NAT" - }, - "sslCard": { - "title": "SSL certificates", - "total": "Managed certificates", - "certExpired": "{{count}} certificate(s) expired — renew immediately", - "expiringSoon": "{{count}} expiring soon (< 30 days)", - "allFresh": "All certs have > 30 days remaining." - }, - "clusterCard": { - "title": "Cluster", - "nodes": "Nodes", - "modeSingle": "Single-Node", - "modeCluster": "Cluster", - "drift": "Config drift detected", - "health": { - "ok": "OK", - "degraded": "degraded", - "split-brain": "split-brain" - } - }, - "routingCard": { - "title": "Routing", - "domains": "Domains", - "backends": "Backends", - "attached": "{{count}}/{{total}} domains have a primary backend" - }, - "systemCard": { - "title": "System", - "version": "Version", - "api": "API", - "ifaces": "Interfaces", - "wg": "WireGuard" - }, - "alertsCard": { - "title": "Recent alerts", - "viewAll": "View all" - }, - "downBackendsAlert": "{{count}} backend(s) completely down — no server UP", - "maintenanceAlert": "{{count}} domain(s) in maintenance mode", - "onboardingTitle": "Welcome to EdgeGuard", - "onboardingIntro": "Fresh box — here are the next steps to route customer traffic:", - "onboardingStep1": "Create a backend pool (app servers behind HAProxy)", - "onboardingStep2": "Add a domain (FQDN, assign primary backend)", - "onboardingStep3": "Issue TLS certificate (Let's Encrypt HTTP-01)", - "servicesCard": { - "title": "Service status (live, 10s)" - }, - "activityCard": { - "title": "Recent activity (audit log)", - "empty": "No activity yet — mutations are logged here." - }, - "haproxyCard": { - "title": "HAProxy backends (live)", - "empty": "No backend stats reachable (HAProxy down or admin.sock permission).", - "frontends": "Listeners" - }, - "resCard": { - "load": "Load", - "memory": "Memory", - "disk": "Disk /", - "free": "free", - "conntrack": "Conntrack", - "uptime": "Uptime" - }, - "licenseOk": "License OK", - "licenseTrial": "Trial", - "licenseTrialDays": "Trial · {{days}}d" - }, "ntp": { "title": "Time server (Chrony)", "intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.", - "tabs": { "pools": "Sources", "settings": "Settings", "peers": "Peer status" }, + "tabs": { + "pools": "Sources", + "settings": "Settings", + "peers": "Peer status" + }, "sourcesCard": { "title": "Live peer status (chronyc sources)", "name": "Name / IP", @@ -934,7 +1007,11 @@ "dns": { "title": "DNS (Unbound)", "intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.", - "tabs": { "zones": "Zones", "settings": "Resolver settings", "stats": "Resolver stats" }, + "tabs": { + "zones": "Zones", + "settings": "Resolver settings", + "stats": "Resolver stats" + }, "statsCard": { "title": "Resolver statistics (unbound-control stats_noreset)", "totalQueries": "Total queries", @@ -1043,22 +1120,22 @@ }, "actions": { "allow": "allow — permit access", - "deny": "deny — block access" + "deny": "deny — block access" }, "aclTypes": { - "src": "src — source IP/CIDR", - "dst": "dst — destination IP/CIDR", - "dstdomain": "dstdomain — destination domain (exact)", - "srcdomain": "srcdomain — source domain (rDNS)", - "port": "port — destination port", - "proto": "proto — http/https/ftp/...", - "method": "method — GET/POST/CONNECT/...", - "time": "time — weekday/time range", - "url_regex": "url_regex — full URL regex", + "src": "src — source IP/CIDR", + "dst": "dst — destination IP/CIDR", + "dstdomain": "dstdomain — destination domain (exact)", + "srcdomain": "srcdomain — source domain (rDNS)", + "port": "port — destination port", + "proto": "proto — http/https/ftp/...", + "method": "method — GET/POST/CONNECT/...", + "time": "time — weekday/time range", + "url_regex": "url_regex — full URL regex", "urlpath_regex": "urlpath_regex — URL path regex", - "dstdom_regex": "dstdom_regex — destination domain regex", - "srcdom_regex": "srcdom_regex — source domain regex", - "browser": "browser — User-Agent regex" + "dstdom_regex": "dstdom_regex — destination domain regex", + "srcdom_regex": "srcdom_regex — source domain regex", + "browser": "browser — User-Agent regex" } }, "common": { @@ -1173,7 +1250,10 @@ "intro": "Notification channels for critical events. Webhook (Slack/Discord/Teams/generic-HTTP) or email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.", "scopeTitle": "What triggers alerts?", "scopeDesc": "cert.expiring — TLS cert <14 days remaining (12 h dedupe). cert.renew_failed — ACME renewer cycle had failures. backup.failed — scheduled backup couldn't run. license.invalid — License server returns valid=false. backend.down — all servers in a backend pool are DOWN (2 min check, 12 h dedupe). disk.full — root filesystem ≥80% warning, ≥90% critical (hourly check, 12 h dedupe). mem.high — RAM usage ≥85% warning, ≥95% critical (5 min check, 12 h dedupe). conntrack.high — conntrack table ≥80% warning, ≥90% critical; at 100% all new connections are silently dropped (2 min check, 12 h dedupe). ntp.unsync — chrony has no synchronized time source; clock drift causes TLS and JWT failures (10 min check, 12 h dedupe). wg.tunnel.down — WireGuard client tunnel has no handshake for >5 min; remote traffic is silently dropped (5 min check, 12 h dedupe).", - "tabs": { "channels": "Channels", "events": "History" }, + "tabs": { + "channels": "Channels", + "events": "History" + }, "add": "Add channel", "addTitle": "Add notification channel", "editTitle": "Edit channel", @@ -1264,11 +1344,21 @@ "run": "Run", "runFromBoxTitle": "Tools run on the EdgeGuard box", "runFromBoxDesc": "These calls use the EdgeGuard's outbound connectivity — not your laptop's. If ping/curl fails here, the box's network layer is the issue (firewall rules, default route, DNS).", - "ping": { "intro": "ICMP echo-request × 4, 2 s timeout per packet. Measures loss + RTT." }, - "trace": { "intro": "Hop-by-hop path with UDP probes (max 20 hops, no reverse-DNS)." }, - "dig": { "intro": "DNS lookup via unbound (box resolver). Shows answer + AUTHORITY/ADDITIONAL section." }, - "curl": { "intro": "HTTPS probe with -IsSv: TLS handshake details + headers. Does NOT follow redirects." }, - "tcp": { "intro": "Pure TCP connect (no I/O). Ideal to verify a backend port is reachable." }, + "ping": { + "intro": "ICMP echo-request × 4, 2 s timeout per packet. Measures loss + RTT." + }, + "trace": { + "intro": "Hop-by-hop path with UDP probes (max 20 hops, no reverse-DNS)." + }, + "dig": { + "intro": "DNS lookup via unbound (box resolver). Shows answer + AUTHORITY/ADDITIONAL section." + }, + "curl": { + "intro": "HTTPS probe with -IsSv: TLS handshake details + headers. Does NOT follow redirects." + }, + "tcp": { + "intro": "Pure TCP connect (no I/O). Ideal to verify a backend port is reachable." + }, "securityTitle": "Security note", "securityDesc": "Endpoints are behind admin auth. Targets are validated against a strict allow-list (no shell metachars). curl is restricted to http(s) — no file:// / smb:// / data://.", "noOutput": "(no output)" @@ -1278,7 +1368,10 @@ "intro": "Snapshots of the PostgreSQL database + /var/lib/edgeguard (setup, license, JWT, ACME account). Daily auto job + manual trigger.", "scopeTitle": "What is backed up?", "scopeDesc": "DB dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Generated configs (haproxy.cfg, nft, …) are reproducible from the DB and are NOT included.", - "tabs": { "history": "Backups", "remotes": "Off-site targets" }, + "tabs": { + "history": "Backups", + "remotes": "Off-site targets" + }, "statLastSuccess": "Last successful backup", "statTotal": "Successful backups", "statSize": "Total size", diff --git a/management-ui/src/pages/Setup/index.tsx b/management-ui/src/pages/Setup/index.tsx index 26e1a49..9b5dc2c 100644 --- a/management-ui/src/pages/Setup/index.tsx +++ b/management-ui/src/pages/Setup/index.tsx @@ -1,4 +1,6 @@ -import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd' +import { useState } from 'react' +import { Alert, Button, Card, Form, Input, Space, Tag, Typography, message } from 'antd' +import { ArrowLeftOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -17,20 +19,36 @@ interface SetupValues { license_key?: string } -// FQDN-Regex: erlaubt RFC-1123-Labels (a-z 0-9 -) durch Punkte getrennt, -// 1+ Labels, keine führenden/abschließenden Bindestriche, kein TLD-Zwang -// (wir verifizieren live nicht die DNS-Existenz, nur die Form-Plausibilität). +interface NodeValues { + fqdn: string + acme_email: string +} + const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ +type Mode = 'standalone' | 'node' + +function CopyCode({ value }: { value: string }) { + return ( + (e.target as HTMLTextAreaElement).select()} + /> + ) +} + export default function SetupPage({ onComplete: _onComplete }: Props) { const { t } = useTranslation() const navigate = useNavigate() + const [mode, setMode] = useState(null) + const [nodeDone, setNodeDone] = useState(false) + const [nodeFqdn, setNodeFqdn] = useState('') const onFinish = async (vals: SetupValues) => { try { - // FQDN immer lower-casen — der Server erwartet das auch im - // EqualFold-Vergleich beim Login, also vereinheitlichen wir hier - // damit FQDN + ACME-Cert-Subject identisch werden. const normalised: SetupValues = { ...vals, admin_email: vals.admin_email.trim().toLowerCase(), @@ -39,7 +57,6 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { } await apiClient.post('/setup/complete', normalised) message.success(t('setup.successTitle')) - // Setup doesn't issue a session — the operator must log in. navigate('/login', { replace: true }) } catch (e: unknown) { const err = e as { message?: string } @@ -47,81 +64,240 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { } } + const onFinishNode = async (vals: NodeValues) => { + try { + const normalised: NodeValues = { + fqdn: vals.fqdn.trim().toLowerCase(), + acme_email: vals.acme_email.trim().toLowerCase(), + } + await apiClient.post('/setup/complete-node', normalised) + setNodeFqdn(normalised.fqdn) + setNodeDone(true) + } catch (e: unknown) { + const err = e as { message?: string } + message.error(err.message ?? t('common.error')) + } + } + + const joinCmd = `sudo edgeguard-ctl cluster-join --token ` + const restartCmd = `sudo systemctl restart edgeguard-api` + return (
- - - {t('setup.title')} - - {t('setup.intro')} - - + - + {/* ── Mode selection ── */} + {mode === null && ( + + EdgeGuard Setup + + {t('setup.modeSelect')} + -
- - - + setMode('standalone')} + > + + +
+ {t('setup.modeStandalone')} +
+ {t('setup.modeStandaloneDesc')} +
+
+
- - - + setMode('node')} + > + + +
+ {t('setup.modeNode')} +
+ {t('setup.modeNodeDesc')} +
+
+
+ + )} - - - + {/* ── Standalone form ── */} + {mode === 'standalone' && ( + <> + + + {t('setup.title')} + + {t('setup.intro')} + + - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + {/* ── Cluster node form ── */} + {mode === 'node' && !nodeDone && ( + <> + + + {t('setup.nodeTitle')} + + {t('setup.nodeIntro')} + + + + + +
+ + + + + + + + + + + +
+ + )} + + {/* ── Cluster node success ── */} + {mode === 'node' && nodeDone && ( + +
+ {t('setup.nodeSuccessTitle')} + {t('setup.nodeJoinTitle')} + + {t('setup.nodeJoinDesc', { fqdn: nodeFqdn })} + +
+ +
+ 1. {t('setup.nodeStep1Title')} + + {t('setup.nodeStep1Desc')} + +
+ +
+ 2. {t('setup.nodeStep2Title')} + + {t('setup.nodeStep2Desc')} + + +
+ +
+ 3. {t('setup.nodeStep3Title')} + + {t('setup.nodeStep3Desc')} + + +
+ + +
+ )} - - - -
) diff --git a/scripts/install.sh b/scripts/install.sh index 08904e4..aa818d5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -183,9 +183,10 @@ echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━ echo "" echo -e " ${GRN}${BLD}Installation complete!${NC}" echo "" -print_url "Setup wizard" "/setup" +print_url "Setup wizard" ":3443/setup" echo "" echo -e " ${DIM}Open the URL in your browser to configure EdgeGuard.${NC}" +echo -e " ${DIM}(Port 3443 is the management UI — separate from the public :443 frontend.)${NC}" echo -e " ${DIM}(SSL is self-signed on first boot — confirm the browser warning;${NC}" echo -e " ${DIM} a real Let's Encrypt cert is issued after the FQDN is set.)${NC}" echo ""