AgentRegisterPeer löschte bisher nur den neuen prenode-{fqdn}-Placeholder.
Alte pre-{timestamp}-Rows (aus Versionen vor 1.1.158) blieben stehen und
zeigten dauerhaft status=joining.
Fix: DeletePlaceholdersByFQDN löscht ALLE ha_nodes-Rows mit gleicher FQDN
außer der echten Node-ID — unabhängig vom ID-Format.
Auch preRegisterByFQDN nutzt jetzt das stabile prenode-{fqdn}-Format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. preRegisterJoiner now runs SYNCHRONOUSLY before IssueCert responds,
so nftables @peer_ipv4 is updated before the joiner calls autoRegister.
Previously it was a goroutine → race → autoRegister failed → "joining"
forever.
2. Stable node ID for pre-registered placeholder (prenode-{fqdn}) instead
of time-based ID — re-joins are now idempotent.
3. AgentRegisterPeer sets status="online" immediately (peer proved it is
online by connecting via mTLS) and deletes the prenode-{fqdn} placeholder.
4. autoRegister retries 3× with 2s delay in case of transient nftables lag.
5. Auth federation: cluster nodes forward failed logins to the primary via
mTLS /agent/auth/check so users can log in on any node with primary
credentials (no PG replication needed).
- SystemHandler.AgentAuthCheck: new endpoint on :8443
- AuthHandler.checkWithPrimary: mTLS call to primary when local auth fails
- AuthHandler.WithClusterTLS: inject cluster TLS store
- startAgentListener now uses the wired systemHdl with Users repo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
callPeer speicherte den rohen HTTP-Response-Body ({"data": {...}, "error":
null}) in PeerResult.Data. Der lokale Pfad serialisiert dagegen die Struct
direkt ohne Envelope. Im UI führte das auf Peer-Nodes zu
`r.data.load_avg_1 = undefined` → TypeError: Cannot read properties of
undefined (reading 'toFixed') → Cluster-Seite nicht ladbar.
Fix: Envelope-Feld `data` extrahieren; fallback auf rohen Body wenn kein
gültiger Envelope.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On API restart, cluster nodes now re-register their primary in the local
ha_nodes and reload nftables so @peer_ipv4 is correct after a package
update or reboot without requiring a re-join.
Also fixes duplicate ha_nodes rows: preRegisterPrimary previously used
time.Now().UnixNano() as node ID, creating a fresh row each call.
Now uses a deterministic ID derived from the FQDN so repeated upserts
are idempotent.
PrimaryFQDN is now persisted in setup.json during CompleteAsNode so the
startup sync knows which primary to contact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nach erfolgreichem cluster-join trägt der Joining-Node (utm-2) den Primary
per DNS-Lookup in seine lokale ha_nodes ein und lädt nftables neu.
Damit ist @peer_ipv4 auf utm-2 sofort mit utm-1's IP befüllt und Port 8443
ist bidirektional offen — der Aggregator auf utm-1 kann utm-2 auf :8443
abklappern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cluster-Seite: Schritt 2 fragt jetzt den FQDN des neuen Knotens bevor
der Token generiert wird. Der Knoten wird sofort in ha_nodes (status=pending)
eingetragen. Nach Token-Generierung wird direkt die Setup-Wizard-URL des
neuen Knotens angezeigt (https://<fqdn>:3443/setup).
Backend: POST /cluster/join-tokens nimmt jetzt node_fqdn entgegen,
pre-registriert via preRegisterByFQDN(). Die IP wird beim issue-cert
nachgetragen (preRegisterJoiner).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IssueCert registriert den beitretenden Node jetzt direkt in ha_nodes
(public_ip = ClientIP, status='joining') und triggert den Firewall-Reload,
bevor die Response zurückgeht. Damit ist Port 8443 schon offen wenn der
Node im nächsten Schritt auto-register via mTLS versucht.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wenn setup bereits abgeschlossen war (z.B. zuvor als Standalone eingerichtet),
wird der Node jetzt ohne Fehler auf is_cluster_node=true umgestellt statt
mit "setup already completed" zu fehlschlagen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sicherheit des Joins kommt vom HMAC-Token, nicht von TLS-Cert-Trust.
Insecure=true wird jetzt immer im Handler gesetzt — GUI-Nutzer müssen
das nicht kennen oder konfigurieren.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Der API-Boot erstellt für Single-Node-Betrieb automatisch ein selbst-signiertes
peer-Cert. Beim Join über die GUI wurde das als Fehler gemeldet.
clusterjoin.Request.Force=true im Setup-Handler überspringt die HasPeer-Prüfung;
CLI (edgeguard-ctl cluster-join) bleibt vorsichtig (Force=false, manuelles rm nötig).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /setup/join-cluster macht alles was bisher edgeguard-ctl cluster-join
tat: CSR generieren, Certs vom Primary holen, schreiben, auto-registrieren,
Setup als Cluster-Node markieren.
Setup-Wizard Node-Modus fragt jetzt direkt Primary-FQDN + Join-Token ab.
Nach Submit: Erfolgsmeldung + einziger verbleibender Schritt (systemctl restart).
Neue interne Bibliothek: internal/services/clusterjoin — wird von Handler
und CLI (edgeguard-ctl cluster-join) gleichermaßen genutzt, keine Duplizierung.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
pgx kann INET-Typen (OID 869) nicht direkt in *string scannen.
Migration 0028 konvertiert public_ip, internal_ip, mgmt_ip auf TEXT
(USING ip::TEXT erhält bestehende Werte). Basis-Migrationen 0002 + 0020
auf TEXT umgestellt damit frische Installs keine INET anlegen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- certrenewer.Result.FailedDomains []string — jeder fehlgeschlagene Domain-Name
wird erfasst (Issue/Parse/Write-Fehler)
- runRenewer: pro Domain eigener Alert + eigener Dedupe-Key statt einem
shared "cert.renew_failed"-Key; Alert-Message nennt jetzt den Domain-Namen
und gibt Hinweis auf ACME/DNS-Debugging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upgrade war die einzige system-Mutation ohne Audit-Eintrag. Alle
anderen (maintenance, service-restart, haproxy-reload, IPv6, render-
configs, auto-update) loggen bereits. Jetzt wird system.upgrade mit
actor + unit-Name geloggt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Login handler hatte h.Audit injiziert (mit Kommentar "login-success/fail
ins audit_log fließen") aber nie aufgerufen. Jetzt werden geloggt:
- auth.login.failed (reason: invalid_credentials | account_disabled)
- auth.login.success (mit role + remote IP)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Trial-Result hatte Product="NetCell MailGuard" und Feature-Keys
(reporting, eu_portal, digest, whitelabel, rest_api_write) die aus
dem mail-gateway copy-pastet wurden und für EdgeGuard bedeutungslos
sind. Bereinigt: Product="NetCell EdgeGuard", Features={} (leer bis
EdgeGuard-eigene Gates definiert werden), Limits-Struktur bleibt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DeleteZone, DeleteRecord und NTPPool.DeletePool hatten keine Audit-
Einträge. Alle anderen Mutations derselben Handler loggen korrekt —
nur Delete war vergessen worden. Jetzt konsistent:
dns.zone.delete, dns.record.delete, ntp.pool.delete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Vorher hat ChangePassword das Passwort ausschließlich gegen den
setup-store-Admin geprüft und dort gespeichert. User die via
User-Management angelegt wurden, bekamen immer 401.
Fix: DB-Lookup via Users.FindByEmail, Verify + SetPassword im DB-Store.
Setup-Store-Admin bleibt synchron wenn die E-Mail übereinstimmt.
Fallback auf setup-store bleibt erhalten für Legacy-Installs (pre-DB).
Bonus: %m-Formatverb-Fehler im Scheduler-Alert-Text behoben.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api_backend (management API) and rl_* (rate-limit stick-tables) were
appearing in the dashboard's backend health card alongside customer
backends. Skip these internal HAProxy proxy entries in the stats parser.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two root causes for Dashboard backends not showing:
1. haproxy.cfg.tpl had no stats socket directive → /run/haproxy/admin.sock
never created → handler got "connection refused" → empty array returned.
Fixed: added stats socket /run/haproxy/admin.sock mode 0660 level admin.
2. safeAt() used i<=0 instead of i<0 → pxname (column 0) always returned ""
→ all backend names were empty in the API response.
After upgrade: HAProxy reload (triggered by any domain/backend save, or
edgeguard-ctl render-config) will create the socket. v1.1.113.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GET /ntp/sources endpoint (runs chronyc sources, parses tabular
output) and a new "Peer status" tab in the NTP page showing all
configured peers with mode, state badge, stratum, poll interval,
reach register, last-rx and offset/error sample.
v1.1.101
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Sidebar: Hostname unter der Versionsnummer (aus /system/health);
10px, gedimmt — beim Verwalten mehrerer Boxen sofort erkennbar
- /system/resources: neues Feld num_cpus (via runtime.NumCPU)
- Dashboard Resources-Card: Load-Average-Label zeigt "(N CPU)" —
1.80 auf 1 CPU ist saturiert, auf 4 CPUs ist es harmlos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /system/health gibt zusätzlich hostname, os (PRETTY_NAME aus
/etc/os-release) und kernel (/proc/version, ohne Build-Details) zurück
- Settings-Seite: SystemHealth-Interface erweitert, System-Info-Card
zeigt Hostname, Betriebssystem und Kernel-Version an (conditional,
nur wenn vorhanden)
- Nützlich beim Verwalten mehrerer Boxen — ohne SSH sofort sehen
welches System gerade geöffnet ist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- chrony.RenderToString(): rendert /etc/chrony/conf.d/edgeguard.conf
ohne Datei-Write oder Service-Reload
- wireguard.RenderToString(): kombiniert alle aktiven Interface-Configs;
PrivateKey + PresharedKey werden als <redacted> ausgegeben (sicher für
UI-Anzeige)
- main.go: beide in WithConfigPreviewers eingetragen
- Settings UI: chrony + wireguard im Generator-Dropdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SystemHandler.ExtraReloaders: neues Feld für nftables, wireguard,
squid, unbound, chrony Reloader
- WithAllReloaders(): Wire-Methode; main.go bindet alle Reloader nach
Initialisierung ein
- RenderConfigs: läuft alle Reloader durch, sammelt Fehler, antwortet
mit {ok, rendered[], errors{}} — partiell OK wird als warning gemeldet
- UI: Settings-Button-Label "Re-render all configs"; partial-error
Toast zeigt welche Dienste fehlschlugen
- Timeout: 30s statt 10s (alle Dienste brauchen mehr Zeit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GET /wireguard/importable listet alle .conf-Dateien in /etc/wireguard/
mit already_in-Flag (bereits in DB)
- POST /wireguard/import nimmt optionale Names-Liste; ohne Namen → alles
- edgeguard-ctl wg-import [iface…] importiert nur die genannten Interfaces
- UI: Checkbox-Modal mit "Alle neuen auswählen" + Einzelauswahl;
bereits importierte Interfaces disabled + grüner Tag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend: POST /dns/flush-cache (unbound-control flush_zone .)
POST /ntp/force-sync (chronyc makestep)
Beide werden im Audit-Log festgehalten.
UI: Schaltflächen in DNS-Settings und NTP-Settings neben Save,
mit Tooltip-Beschreibung + i18n (de+en).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
wireguard.go: UpdatePeer zerstörte den gespeicherten encPriv wenn der Client
denselben public_key im Update-Body mitschickte (encPriv = nil auf Zeile 532).
Fix: encPriv wird nur geleert wenn der public_key sich tatsächlich ÄNDERT.
Andernfalls (gleicher Public Key = Benutzer ändert z.B. nur Allowed-IPs)
bleibt das verschlüsselte Private Key erhalten und QR/conf-Download
bleiben verfügbar.
domains.go + domainheaders.go: UpdateHeader() ignorierte die domainID aus der
URL. Fix: Update-SQL prüft jetzt WHERE id=$4 AND domain_id=$5 — falsch
adressierte Requests kriegen 404 statt blind zu updaten.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
haproxy: http_to_https=false Domains bekommen jetzt eigene use_backend-Regeln
im public_http-Frontend statt dem globalen HTTPS-Redirect. Das Feld war bisher
in DB + UI vorhanden aber vom Config-Generator komplett ignoriert.
unbound: Forward-Zones ohne forward-addr (ForwardTo=nil oder leer) werden jetzt
übersprungen. Unbound lehnt solche Blöcke beim Start ab.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- haproxy_stats.go: FRONTEND-Rows aus show-stat CSV auslesen und als
frontends[] zurückgeben (Name, Sessions, MaxSess, Bytes, ReqTot/Rate)
- Dashboard: Listener-Sektion über Backend-Liste; query jetzt unified
haproxyStats mit backends/frontends alias für Abwärtskompatibilität
- common.relTime.*-Keys (en+de) eingeführt; relativeTime/relativeFromIso
in Dashboard + relTime in WireGuard Servers/Clients auf t-Parameter
umgestellt statt hardcodierter Strings
- CertExpiry in Cluster-Seite nutzt jetzt certDaysRemaining/certExpiredDaysAgo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- internal/services/users: Repo mit CRUD, bcrypt (cost 12), Upsert für
setup-store-Admin-Migration, RecordLogin
- internal/handlers/users: GET/POST/PUT /users, POST /users/:id/password,
DELETE /users/:id; Schutz gegen Selbst-Löschung
- auth.go Login: DB-Nutzer first, Fallback auf setup-store-Admin;
bei erfolgreichem Fallback wird der Admin per Upsert in die DB
migriert (kein manueller Eingriff nötig)
- management-ui: /users-Seite mit Tabelle, Anlegen-, Bearbeiten-,
Passwort-setzen- und Löschen-Modals; "You"-Badge für eigenen Account
- Sidebar + Route + i18n (de/en) ergänzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend: force_http1 (bool, default false) — zwingt HAProxy auf der
Backend-Verbindung zu HTTP/1.1 statt h2,http/1.1 zu verhandeln.
Nötig für Legacy-Apps die kein h2 sprechen.
Domain: disable_h3 (bool, default false) — unterdrückt Alt-Svc-
Response-Header für diese Domain. Browser erhalten keinen h3/QUIC-
Hinweis und bleiben auf h2/http1.1.
Migration 0027, Model+Service+HAProxy-Template+UI+i18n.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WireGuard-Peer-Änderungen landeten nicht im laufenden Interface:
/etc/wireguard/ ist root:root 700, os.Readlink schlug fehl →
ensureWGQuickSymlink fiel immer in den Error-Pfad. Fix: Symlink
via sudo /bin/ln -sf (sudoers-Entry in postinst ergänzt).
- Site-to-Site-Masquerade: Roadwarrior-Clients (z. B. 192.168.99.3)
konnten LANs hinter anderen Peers nicht erreichen, weil das remote
Gateway die VPN-Client-IP nicht als Tunnel-Route kannte. Fix: auto
masquerade in nftables postrouting_nat pro WireGuard-Server-Interface
(oifname "wg7" ip saddr 192.168.99.0/24 masquerade).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Neues Feld 'client_routes' auf wireguard_interfaces: der Operator
trägt dort kommagetrennte Netzwerke ein (z. B. 10.0.10.0/24 für ein
LAN hinter der Box). Der Peer-Config-Download fügt diese automatisch
als zusätzliche AllowedIPs in den [Peer]-Block der Client-Config ein.
Bisher wurde nur das Server-Tunnel-Subnetz (ifc.address_cidr) als
AllowedIPs exportiert — Peers konnten so keine anderen Netze über
den Tunnel erreichen ohne die Config manuell anzupassen.
Migration: 0026_wg_client_routes.sql
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Die forward-chain hatte policy=drop ohne Ausnahme für WireGuard-
Peer-to-Peer-Traffic. Pakete von Peer A nach Peer B (beide am
selben wg-Interface) müssen durch die Box forwarded werden — das
war bisher stiller Drop.
Lösung: loadView liest alle aktiven server-mode WG-Interfaces und
trägt sie in View.WGServerIfaces ein. Das Template emittiert pro
Interface iifname "<wg>" accept in der forward-chain. Return-Pakete
gehen bereits via ct state established durch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ruleset.nft.tpl: operator-rules bekommen `counter <action> comment "egid:<id>"`
→ nft zählt Packets + Bytes per Regel ab dem letzten Ruleset-Apply
- handlers/firewall_counters.go: GET /firewall/counters parst
`sudo nft list table inet edgeguard` per Regex, liefert [{rule_id,packets,bytes}]
- handlers/firewall_counters_test.go: unit-tests für parseNFTCounters
- Firewall/Rules.tsx: neue "Hits"-Spalte (Packet-Count, Tooltip mit Bytes),
10s-Polling via TanStack Query ['fw','counters']
- i18n de+en: fw.rule.hits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GET /haproxy/stats liefert jetzt req_tot (kumulierte Requests seit
HAProxy-Start) und req_rate (Requests/s im letzten Messfenster).
Backend-Detail zeigt in der Live-Spalte 'N sess · X/s' wenn Traffic
fließt; Dashboard-HAProxy-Card zeigt req/s ebenfalls an.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET /system/interfaces liefert jetzt rx_bytes/tx_bytes/rx_packets/
tx_packets/rx_drop/tx_drop aus /proc/net/dev. Die System-Interfaces-
Card in Netzwerk → Interfaces zeigt die Werte als Mini-Tabelle mit
10s-Refetch; Hover auf den Bytes-Werten zeigt Paketzähler + Drops.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-Frage: „Werden via haproxy die echten IPs durchgereicht?". Antwort:
X-Forwarded-For ja (option forwardfor), aber Apps wie WordPress/Mailcow
brauchen zusätzlich X-Forwarded-Proto=https um Redirect-Loops zu
vermeiden, und X-Real-IP ist die bequeme single-value-Variante die viele
Tools out-of-the-box lesen (ohne die XFF-Chain parsen zu müssen).
Beide Frontends (public_https + mgmt_https) emittieren jetzt:
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
Was Backends sehen:
X-Forwarded-For: <client-ip> (defaults: option forwardfor)
X-Forwarded-Proto: https (NEW)
X-Real-IP: <client-ip> (NEW, single value)
PROXY-Protocol-Toggle pro Backend kommt nicht in diesem Release — der
Operator hat „nur Header-Variante" gewählt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Peers als „offline"
Bug-Report: Unify-Home-Tunnel ist aktiv (handshake live, 2.8 GB rx),
UI zeigte „Letzter Handshake: nie / Traffic: — / Offline".
Root cause: handlers/wireguard.go Status() parsed `wg show all dump`
mit `PeerPublicKey: fields[2]` — fields[2] ist aber der pre-shared
key (PSK), NICHT der peer_pubkey. Format mit "all"-prefix ist:
iface lines (5 cols): iface priv_key pubkey listen_port fwmark
peer lines (9 cols): iface peer_pubkey psk endpoint allowed_ips
last_hs rx tx keepalive
Damit matched die UI gegen den PSK → DB-Peer-Row (gespeichert per
pubkey) traf nie zu, Status blieb „nie/—" auch bei aktivem Tunnel.
Fix: fields[1] statt fields[2]. Endpoint/AllowedIPs/Handshake/RX/TX
waren bereits korrekt verschoben.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>