feat(setup): Cluster-Node-Modus im Setup-Wizard + Install-Script-Port-Fix

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 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 16:07:19 +02:00
parent a0b3bccbc6
commit 7e5a7a83c0
10 changed files with 782 additions and 359 deletions

View File

@@ -1 +1 @@
1.1.147 1.1.148

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
) )
var version = "1.1.147" var version = "1.1.148"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "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 const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -41,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.1.147" var version = "1.1.148"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -34,6 +34,7 @@ func (h *SetupHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/setup") g := rg.Group("/setup")
g.GET("/status", h.Status) g.GET("/status", h.Status)
g.POST("/complete", h.Complete) g.POST("/complete", h.Complete)
g.POST("/complete-node", h.CompleteAsNode)
} }
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup // RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
@@ -111,3 +112,21 @@ func (h *SetupHandler) Complete(c *gin.Context) {
"fqdn": st.FQDN, "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,
})
}

View File

@@ -38,6 +38,9 @@ type State struct {
LicenseKey string `json:"license_key,omitempty"` LicenseKey string `json:"license_key,omitempty"`
Completed bool `json:"completed"` Completed bool `json:"completed"`
CompletedAt *time.Time `json:"completed_at,omitempty"` 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 // MaintenanceMode (whole-box): wenn true rendert HAProxy auf
// :443 nur einen 503-Block für ALLE Customer-Domains. Mgmt-UI // :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"` 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 { type Store struct {
Dir string Dir string
} }
@@ -155,6 +165,37 @@ func (s *Store) Complete(req Request) (*State, error) {
return st, nil 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. // VerifyAdminPassword does constant-time bcrypt comparison.
func (st *State) VerifyAdminPassword(plaintext string) bool { func (st *State) VerifyAdminPassword(plaintext string) bool {
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil

View File

@@ -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." "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": { "ao": {
"name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung", "name": "Name",
"add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten", "kind": "Typ",
"value": "Wert",
"description": "Beschreibung",
"add": "Adress-Objekt hinzufügen",
"edit": "Adress-Objekt bearbeiten",
"deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?", "deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?",
"emptyTitle": "Noch keine Adress-Objekte.", "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)." "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": { "ag": {
"name": "Name", "members": "Mitglieder", "description": "Beschreibung", "name": "Name",
"add": "Adress-Gruppe hinzufügen", "edit": "Adress-Gruppe bearbeiten", "members": "Mitglieder",
"description": "Beschreibung",
"add": "Adress-Gruppe hinzufügen",
"edit": "Adress-Gruppe bearbeiten",
"selectMembers": "Adress-Objekte wählen", "selectMembers": "Adress-Objekte wählen",
"deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?", "deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?",
"emptyTitle": "Noch keine Adress-Gruppen.", "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." "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": { "svc": {
"name": "Name", "proto": "Protokoll", "ports": "Ports", "name": "Name",
"portStart": "Port (Start)", "portEnd": "Port (Ende)", "proto": "Protokoll",
"description": "Beschreibung", "builtinHint": "Vordefiniert — nicht editierbar", "ports": "Ports",
"add": "Service hinzufügen", "edit": "Service bearbeiten", "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?", "deleteConfirm": "Service {{name}} wirklich löschen?",
"emptyTitle": "Noch keine eigenen Services.", "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." "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": { "sg": {
"name": "Name", "members": "Mitglieder", "description": "Beschreibung", "name": "Name",
"add": "Service-Gruppe hinzufügen", "edit": "Service-Gruppe bearbeiten", "members": "Mitglieder",
"description": "Beschreibung",
"add": "Service-Gruppe hinzufügen",
"edit": "Service-Gruppe bearbeiten",
"selectMembers": "Services wählen", "selectMembers": "Services wählen",
"deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?", "deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?",
"emptyTitle": "Noch keine Service-Gruppen.", "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." "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": { "rule": {
"name": "Name", "priority": "Priority", "enabled": "Aktiv", "log": "Logging", "name": "Name",
"action": "Aktion", "src": "Quelle", "dst": "Ziel", "service": "Service", "priority": "Priority",
"srcZone": "Quell-Zone", "dstZone": "Ziel-Zone", "enabled": "Aktiv",
"srcKind": "Quell-Typ", "dstKind": "Ziel-Typ", "log": "Logging",
"object": "Adress-Objekt", "group": "Adress-Gruppe", "action": "Aktion",
"serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe", "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", "comment": "Kommentar",
"hits": "Hits", "hits": "Hits",
"add": "Regel hinzufügen", "edit": "Regel bearbeiten", "add": "Regel hinzufügen",
"edit": "Regel bearbeiten",
"deleteConfirm": "Diese Regel wirklich löschen?", "deleteConfirm": "Diese Regel wirklich löschen?",
"namePlaceholder": "z.B. SSH von Office erlauben", "namePlaceholder": "z.B. SSH von Office erlauben",
"cidr": "CIDR", "cidr": "CIDR",
"actions": { "accept": "ACCEPT", "drop": "DROP", "reject": "REJECT" }, "actions": {
"kinds": { "any": "Beliebig", "object": "Objekt", "group": "Gruppe", "cidr": "CIDR" }, "accept": "ACCEPT",
"drop": "DROP",
"reject": "REJECT"
},
"kinds": {
"any": "Beliebig",
"object": "Objekt",
"group": "Gruppe",
"cidr": "CIDR"
},
"duplicate": "Regel duplizieren", "duplicate": "Regel duplizieren",
"duplicated": "Regel dupliziert (deaktiviert, Priorität +1).", "duplicated": "Regel dupliziert (deaktiviert, Priorität +1).",
"cidrInvalid": "Gültiges CIDR eingeben (z.B. 10.0.0.0/24)", "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." "noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen."
}, },
"nat": { "nat": {
"name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv", "name": "Name",
"match": "Match", "target": "Ziel", "priority": "Priority",
"inZone": "Eingangs-Zone", "outZone": "Ausgangs-Zone", "proto": "Protokoll", "kind": "Typ",
"matchSrcCidr": "Source-CIDR (Match)", "matchDstCidr": "Dest-CIDR (Match)", "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)", "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", "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", "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?", "deleteConfirm": "Diese NAT-Regel wirklich löschen?",
"namePlaceholder": "z.B. HTTP zu Web-Backend weiterleiten", "namePlaceholder": "z.B. HTTP zu Web-Backend weiterleiten",
"emptyTitle": "Noch keine NAT-Regeln.", "emptyTitle": "Noch keine NAT-Regeln.",
@@ -156,7 +202,10 @@
}, },
"sys": { "sys": {
"title": "System-Regeln (immer aktiv)", "title": "System-Regeln (immer aktiv)",
"chain": "Chain", "match": "Match", "action": "Aktion", "note": "Hinweis", "chain": "Chain",
"match": "Match",
"action": "Aktion",
"note": "Hinweis",
"policy": "Default-Policy", "policy": "Default-Policy",
"policyValue": "Eingang DROP — alles muss explizit erlaubt werden.", "policyValue": "Eingang DROP — alles muss explizit erlaubt werden.",
"order": "Auswertung", "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.", "licenseKeyHint": "Wenn vorhanden: 30-Tage-Trial wird übersprungen, Features unmittelbar freigeschaltet. Kann auch später unter License nachgereicht werden.",
"submit": "Setup abschließen", "submit": "Setup abschließen",
"successTitle": "Setup abgeschlossen", "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 <primary-fqdn> mit dem FQDN deines Primarys und <token> 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": { "dashboard": {
"title": "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": { "domains": {
"title": "Domains", "title": "Domains",
@@ -680,7 +834,10 @@
"wg": { "wg": {
"title": "WireGuard", "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.", "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).", "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.", "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": { "iface": {
@@ -767,98 +924,14 @@
"failed": "Import fehlgeschlagen" "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": { "ntp": {
"title": "Zeitserver (Chrony)", "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.", "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": { "sourcesCard": {
"title": "Live Peer-Status (chronyc sources)", "title": "Live Peer-Status (chronyc sources)",
"name": "Name / IP", "name": "Name / IP",
@@ -934,7 +1007,11 @@
"dns": { "dns": {
"title": "DNS (Unbound)", "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.", "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": { "statsCard": {
"title": "Resolver-Statistiken (unbound-control stats_noreset)", "title": "Resolver-Statistiken (unbound-control stats_noreset)",
"totalQueries": "Abfragen gesamt", "totalQueries": "Abfragen gesamt",
@@ -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.", "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?", "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).", "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", "add": "Channel hinzufügen",
"addTitle": "Notification-Channel anlegen", "addTitle": "Notification-Channel anlegen",
"editTitle": "Channel bearbeiten", "editTitle": "Channel bearbeiten",
@@ -1264,11 +1344,21 @@
"run": "Ausführen", "run": "Ausführen",
"runFromBoxTitle": "Tools laufen auf der EdgeGuard-Box", "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).", "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." }, "ping": {
"trace": { "intro": "Hop-by-Hop-Pfad mit UDP-Probes (max 20 Hops, kein reverse-DNS)." }, "intro": "ICMP echo-request × 4, 2s Timeout pro Paket. Misst Loss + RTT."
"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." }, "trace": {
"tcp": { "intro": "Pure TCP-Connect ohne I/O. Ideal um zu prüfen ob ein Backend-Port erreichbar ist." }, "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", "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://.", "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)" "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.", "intro": "Sicherungen der PostgreSQL-Datenbank + /var/lib/edgeguard (Setup, License, JWT, ACME-Account). Täglicher Auto-Job + manueller Trigger.",
"scopeTitle": "Was wird gesichert?", "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.", "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", "statLastSuccess": "Letzte erfolgreiche Sicherung",
"statTotal": "Erfolgreiche Sicherungen", "statTotal": "Erfolgreiche Sicherungen",
"statSize": "Gesamtgröße", "statSize": "Gesamtgröße",

View File

@@ -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." "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": { "ao": {
"name": "Name", "kind": "Kind", "value": "Value", "description": "Description", "name": "Name",
"add": "Add address object", "edit": "Edit address object", "kind": "Kind",
"value": "Value",
"description": "Description",
"add": "Add address object",
"edit": "Edit address object",
"deleteConfirm": "Really delete address object {{name}}?", "deleteConfirm": "Really delete address object {{name}}?",
"emptyTitle": "No address objects yet.", "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)." "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": { "ag": {
"name": "Name", "members": "Members", "description": "Description", "name": "Name",
"add": "Add address group", "edit": "Edit address group", "members": "Members",
"description": "Description",
"add": "Add address group",
"edit": "Edit address group",
"selectMembers": "Select address objects", "selectMembers": "Select address objects",
"deleteConfirm": "Really delete address group {{name}}?", "deleteConfirm": "Really delete address group {{name}}?",
"emptyTitle": "No address groups yet.", "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." "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": { "svc": {
"name": "Name", "proto": "Protocol", "ports": "Ports", "name": "Name",
"portStart": "Port (start)", "portEnd": "Port (end)", "proto": "Protocol",
"description": "Description", "builtinHint": "Built-in — not editable", "ports": "Ports",
"add": "Add service", "edit": "Edit service", "portStart": "Port (start)",
"portEnd": "Port (end)",
"description": "Description",
"builtinHint": "Built-in — not editable",
"add": "Add service",
"edit": "Edit service",
"deleteConfirm": "Really delete service {{name}}?", "deleteConfirm": "Really delete service {{name}}?",
"emptyTitle": "No custom services yet.", "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." "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": { "sg": {
"name": "Name", "members": "Members", "description": "Description", "name": "Name",
"add": "Add service group", "edit": "Edit service group", "members": "Members",
"description": "Description",
"add": "Add service group",
"edit": "Edit service group",
"selectMembers": "Select services", "selectMembers": "Select services",
"deleteConfirm": "Really delete service group {{name}}?", "deleteConfirm": "Really delete service group {{name}}?",
"emptyTitle": "No service groups yet.", "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." "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": { "rule": {
"name": "Name", "priority": "Priority", "enabled": "Enabled", "log": "Log", "name": "Name",
"action": "Action", "src": "Source", "dst": "Destination", "service": "Service", "priority": "Priority",
"srcZone": "Source zone", "dstZone": "Dest. zone", "enabled": "Enabled",
"srcKind": "Source kind", "dstKind": "Dest. kind", "log": "Log",
"object": "Address object", "group": "Address group", "action": "Action",
"serviceKind": "Service kind", "serviceGroup": "Service group", "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", "comment": "Comment",
"hits": "Hits", "hits": "Hits",
"add": "Add rule", "edit": "Edit rule", "add": "Add rule",
"edit": "Edit rule",
"deleteConfirm": "Really delete this rule?", "deleteConfirm": "Really delete this rule?",
"namePlaceholder": "e.g. Allow SSH from office", "namePlaceholder": "e.g. Allow SSH from office",
"cidr": "CIDR", "cidr": "CIDR",
"actions": { "accept": "ACCEPT", "drop": "DROP", "reject": "REJECT" }, "actions": {
"kinds": { "any": "Any", "object": "Object", "group": "Group", "cidr": "CIDR" }, "accept": "ACCEPT",
"drop": "DROP",
"reject": "REJECT"
},
"kinds": {
"any": "Any",
"object": "Object",
"group": "Group",
"cidr": "CIDR"
},
"duplicate": "Duplicate rule", "duplicate": "Duplicate rule",
"duplicated": "Rule duplicated (disabled, priority +1).", "duplicated": "Rule duplicated (disabled, priority +1).",
"cidrInvalid": "Enter a valid CIDR (e.g. 10.0.0.0/24)", "cidrInvalid": "Enter a valid CIDR (e.g. 10.0.0.0/24)",
@@ -139,16 +174,27 @@
"noResultsHint": "Clear the filter to see all rules." "noResultsHint": "Clear the filter to see all rules."
}, },
"nat": { "nat": {
"name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled", "name": "Name",
"match": "Match", "target": "Target", "priority": "Priority",
"inZone": "Ingress zone", "outZone": "Egress zone", "proto": "Protocol", "kind": "Kind",
"matchSrcCidr": "Source CIDR (match)", "matchDstCidr": "Dest. CIDR (match)", "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)", "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", "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", "comment": "Comment",
"add": "Add NAT rule", "edit": "Edit NAT rule", "add": "Add NAT rule",
"edit": "Edit NAT rule",
"deleteConfirm": "Really delete this NAT rule?", "deleteConfirm": "Really delete this NAT rule?",
"namePlaceholder": "e.g. Forward HTTP to web backend", "namePlaceholder": "e.g. Forward HTTP to web backend",
"emptyTitle": "No NAT rules yet.", "emptyTitle": "No NAT rules yet.",
@@ -156,7 +202,10 @@
}, },
"sys": { "sys": {
"title": "System rules (always active)", "title": "System rules (always active)",
"chain": "Chain", "match": "Match", "action": "Action", "note": "Note", "chain": "Chain",
"match": "Match",
"action": "Action",
"note": "Note",
"policy": "Default policy", "policy": "Default policy",
"policyValue": "Input DROP — everything must be explicitly allowed.", "policyValue": "Input DROP — everything must be explicitly allowed.",
"order": "Evaluation", "order": "Evaluation",
@@ -266,11 +315,116 @@
"licenseKeyHint": "If present: 30-day trial is skipped, features unlock immediately. Can also be added later under License.", "licenseKeyHint": "If present: 30-day trial is skipped, features unlock immediately. Can also be added later under License.",
"submit": "Finish setup", "submit": "Finish setup",
"successTitle": "Setup complete", "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 <primary-fqdn> with your primary's FQDN and <token> 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": { "dashboard": {
"title": "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": { "domains": {
"title": "Domains", "title": "Domains",
@@ -680,7 +834,10 @@
"wg": { "wg": {
"title": "WireGuard", "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.", "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.", "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.", "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": { "iface": {
@@ -767,98 +924,14 @@
"failed": "Import failed" "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": { "ntp": {
"title": "Time server (Chrony)", "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.", "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": { "sourcesCard": {
"title": "Live peer status (chronyc sources)", "title": "Live peer status (chronyc sources)",
"name": "Name / IP", "name": "Name / IP",
@@ -934,7 +1007,11 @@
"dns": { "dns": {
"title": "DNS (Unbound)", "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.", "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": { "statsCard": {
"title": "Resolver statistics (unbound-control stats_noreset)", "title": "Resolver statistics (unbound-control stats_noreset)",
"totalQueries": "Total queries", "totalQueries": "Total queries",
@@ -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.", "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?", "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).", "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", "add": "Add channel",
"addTitle": "Add notification channel", "addTitle": "Add notification channel",
"editTitle": "Edit channel", "editTitle": "Edit channel",
@@ -1264,11 +1344,21 @@
"run": "Run", "run": "Run",
"runFromBoxTitle": "Tools run on the EdgeGuard box", "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).", "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." }, "ping": {
"trace": { "intro": "Hop-by-hop path with UDP probes (max 20 hops, no reverse-DNS)." }, "intro": "ICMP echo-request × 4, 2 s timeout per packet. Measures loss + RTT."
"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." }, "trace": {
"tcp": { "intro": "Pure TCP connect (no I/O). Ideal to verify a backend port is reachable." }, "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", "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://.", "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)" "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.", "intro": "Snapshots of the PostgreSQL database + /var/lib/edgeguard (setup, license, JWT, ACME account). Daily auto job + manual trigger.",
"scopeTitle": "What is backed up?", "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.", "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", "statLastSuccess": "Last successful backup",
"statTotal": "Successful backups", "statTotal": "Successful backups",
"statSize": "Total size", "statSize": "Total size",

View File

@@ -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 { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -17,20 +19,36 @@ interface SetupValues {
license_key?: string license_key?: string
} }
// FQDN-Regex: erlaubt RFC-1123-Labels (a-z 0-9 -) durch Punkte getrennt, interface NodeValues {
// 1+ Labels, keine führenden/abschließenden Bindestriche, kein TLD-Zwang fqdn: string
// (wir verifizieren live nicht die DNS-Existenz, nur die Form-Plausibilität). acme_email: string
}
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ 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 (
<Input.TextArea
value={value}
readOnly
autoSize
style={{ fontFamily: 'monospace', fontSize: 12, background: '#f6f8fa', cursor: 'text' }}
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
)
}
export default function SetupPage({ onComplete: _onComplete }: Props) { export default function SetupPage({ onComplete: _onComplete }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [mode, setMode] = useState<Mode | null>(null)
const [nodeDone, setNodeDone] = useState(false)
const [nodeFqdn, setNodeFqdn] = useState('')
const onFinish = async (vals: SetupValues) => { const onFinish = async (vals: SetupValues) => {
try { 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 = { const normalised: SetupValues = {
...vals, ...vals,
admin_email: vals.admin_email.trim().toLowerCase(), admin_email: vals.admin_email.trim().toLowerCase(),
@@ -39,7 +57,6 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
} }
await apiClient.post('/setup/complete', normalised) await apiClient.post('/setup/complete', normalised)
message.success(t('setup.successTitle')) message.success(t('setup.successTitle'))
// Setup doesn't issue a session — the operator must log in.
navigate('/login', { replace: true }) navigate('/login', { replace: true })
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string } const err = e as { message?: string }
@@ -47,10 +64,75 @@ 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 <primary-fqdn> --token <token>`
const restartCmd = `sudo systemctl restart edgeguard-api`
return ( return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5', padding: 24 }}> <div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5', padding: 24 }}>
<Card style={{ width: 560 }}> <Card style={{ width: 580 }}>
{/* ── Mode selection ── */}
{mode === null && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Typography.Title level={3} style={{ marginBottom: 4 }}>EdgeGuard Setup</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
{t('setup.modeSelect')}
</Typography.Paragraph>
<Card
hoverable
style={{ cursor: 'pointer', borderColor: '#1677ff' }}
onClick={() => setMode('standalone')}
>
<Space align="start">
<DesktopOutlined style={{ fontSize: 28, color: '#1677ff', marginTop: 2 }} />
<div>
<Typography.Text strong style={{ fontSize: 15 }}>{t('setup.modeStandalone')}</Typography.Text>
<br />
<Typography.Text type="secondary">{t('setup.modeStandaloneDesc')}</Typography.Text>
</div>
</Space>
</Card>
<Card
hoverable
style={{ cursor: 'pointer' }}
onClick={() => setMode('node')}
>
<Space align="start">
<ClusterOutlined style={{ fontSize: 28, color: '#52c41a', marginTop: 2 }} />
<div>
<Typography.Text strong style={{ fontSize: 15 }}>{t('setup.modeNode')}</Typography.Text>
<br />
<Typography.Text type="secondary">{t('setup.modeNodeDesc')}</Typography.Text>
</div>
</Space>
</Card>
</Space>
)}
{/* ── Standalone form ── */}
{mode === 'standalone' && (
<>
<Space direction="vertical" size={4} style={{ width: '100%' }}> <Space direction="vertical" size={4} style={{ width: '100%' }}>
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
{t('setup.back')}
</Button>
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.title')}</Typography.Title> <Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.title')}</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{t('setup.intro')} {t('setup.intro')}
@@ -90,10 +172,7 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
extra={t('setup.fqdnHint')} extra={t('setup.fqdnHint')}
rules={[ rules={[
{ required: true }, { required: true },
{ { pattern: FQDN_RE, message: t('setup.fqdnInvalid') },
pattern: FQDN_RE,
message: t('setup.fqdnInvalid'),
},
]} ]}
> >
<Input placeholder="eg.example.com" /> <Input placeholder="eg.example.com" />
@@ -122,6 +201,103 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
</>
)}
{/* ── Cluster node form ── */}
{mode === 'node' && !nodeDone && (
<>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
{t('setup.back')}
</Button>
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.nodeTitle')}</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{t('setup.nodeIntro')}
</Typography.Paragraph>
</Space>
<Alert
type="warning"
showIcon
message={t('setup.nodePreflightTitle')}
description={t('setup.nodePreflightDesc')}
style={{ marginBottom: 16 }}
/>
<Form layout="vertical" onFinish={onFinishNode}>
<Form.Item
label={t('setup.fqdn')}
name="fqdn"
extra={t('setup.fqdnHint')}
rules={[
{ required: true },
{ pattern: FQDN_RE, message: t('setup.fqdnInvalid') },
]}
>
<Input autoFocus placeholder="eg2.example.com" />
</Form.Item>
<Form.Item
label={t('setup.acmeEmail')}
name="acme_email"
extra={t('setup.acmeEmailHint')}
rules={[{ required: true, type: 'email' }]}
>
<Input placeholder="ops@example.com" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" block>
{t('setup.nodeSubmit')}
</Button>
</Form.Item>
</Form>
</>
)}
{/* ── Cluster node success ── */}
{mode === 'node' && nodeDone && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Tag color="success" style={{ marginBottom: 8 }}>{t('setup.nodeSuccessTitle')}</Tag>
<Typography.Title level={4} style={{ marginTop: 0 }}>{t('setup.nodeJoinTitle')}</Typography.Title>
<Typography.Paragraph type="secondary">
{t('setup.nodeJoinDesc', { fqdn: nodeFqdn })}
</Typography.Paragraph>
</div>
<div>
<Typography.Text strong>1. {t('setup.nodeStep1Title')}</Typography.Text>
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0' }}>
{t('setup.nodeStep1Desc')}
</Typography.Paragraph>
</div>
<div>
<Typography.Text strong>2. {t('setup.nodeStep2Title')}</Typography.Text>
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 8px' }}>
{t('setup.nodeStep2Desc')}
</Typography.Paragraph>
<CopyCode value={joinCmd} />
</div>
<div>
<Typography.Text strong>3. {t('setup.nodeStep3Title')}</Typography.Text>
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 8px' }}>
{t('setup.nodeStep3Desc')}
</Typography.Paragraph>
<CopyCode value={restartCmd} />
</div>
<Alert
type="info"
showIcon
message={t('setup.nodeLoginNote')}
/>
</Space>
)}
</Card> </Card>
</div> </div>
) )

View File

@@ -183,9 +183,10 @@ echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━
echo "" echo ""
echo -e " ${GRN}${BLD}Installation complete!${NC}" echo -e " ${GRN}${BLD}Installation complete!${NC}"
echo "" echo ""
print_url "Setup wizard" "/setup" print_url "Setup wizard" ":3443/setup"
echo "" echo ""
echo -e " ${DIM}Open the URL in your browser to configure EdgeGuard.${NC}" 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}(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 -e " ${DIM} a real Let's Encrypt cert is issued after the FQDN is set.)${NC}"
echo "" echo ""