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

@@ -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 <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": {
"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",