diff --git a/VERSION b/VERSION
index e9acec7..e7468c7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.75
+1.0.76
diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go
index 64d34ba..cba8bd3 100644
--- a/cmd/edgeguard-api/main.go
+++ b/cmd/edgeguard-api/main.go
@@ -54,7 +54,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
)
-var version = "1.0.75"
+var version = "1.0.76"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go
index 7a1b90d..ffa87cf 100644
--- a/cmd/edgeguard-ctl/main.go
+++ b/cmd/edgeguard-ctl/main.go
@@ -7,9 +7,11 @@ package main
import (
"fmt"
"os"
+
+ "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
-var version = "1.0.75"
+var version = "1.0.76"
const usage = `edgeguard-ctl — EdgeGuard CLI
@@ -25,6 +27,7 @@ Commands:
initdb Create PostgreSQL role + database (idempotent)
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
wg-import [--path
] Import existing /etc/wireguard/*.conf files into the DB
+ reset-password Generate a one-time token for the /reset-password UI flow
cluster-join Join an existing cluster (Phase 3, not yet implemented)
promote Promote this node's PG to primary (Phase 3, not yet implemented)
dump-config Print effective config (Phase 3, not yet implemented)
@@ -48,6 +51,8 @@ func main() {
os.Exit(cmdRenderConfig(os.Args[2:]))
case "wg-import":
os.Exit(cmdWGImport(os.Args[2:]))
+ case "reset-password":
+ os.Exit(cmdResetPassword())
case "cluster-join", "cluster-leave", "promote", "dump-config":
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1)
@@ -57,3 +62,25 @@ func main() {
os.Exit(2)
}
}
+
+// cmdResetPassword generates a single-use reset token and prints it to
+// stdout. Operator pastes it into the /reset-password UI within 30 min.
+// File mode 0600, owned by the edgeguard user — the CLI muss als sudo
+// edgeguard ausgeführt werden (oder als root); fremde User sehen das
+// Token nicht.
+func cmdResetPassword() int {
+ store := setup.NewStore(setup.DefaultDir)
+ token, err := store.GenerateResetToken()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "edgeguard-ctl reset-password: %v\n", err)
+ return 1
+ }
+ fmt.Println("Admin-Password-Reset-Token (gültig 30 Minuten):")
+ fmt.Println()
+ fmt.Println(" " + token)
+ fmt.Println()
+ fmt.Println("→ UI öffnen: https:///reset-password")
+ fmt.Println("→ Token eingeben, neues Passwort setzen (min. 12 Zeichen)")
+ fmt.Println("→ Token ist single-use und wird beim erfolgreichen Reset gelöscht.")
+ return 0
+}
diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go
index 05e61cb..7b088d5 100644
--- a/cmd/edgeguard-scheduler/main.go
+++ b/cmd/edgeguard-scheduler/main.go
@@ -32,7 +32,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
-var version = "1.0.75"
+var version = "1.0.76"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index c7103f7..6eaabdf 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -32,6 +32,7 @@ func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc)
g.POST("/login", h.Login)
g.POST("/logout", h.Logout)
g.GET("/me", requireAuth, h.Me)
+ g.POST("/reset-password", h.ResetPassword)
}
type loginRequest struct {
@@ -99,6 +100,31 @@ func (h *AuthHandler) Me(c *gin.Context) {
})
}
+type resetPasswordRequest struct {
+ Token string `json:"token" binding:"required"`
+ NewPassword string `json:"new_password" binding:"required,min=12"`
+}
+
+// ResetPassword verifies the operator-generated token from
+// /var/lib/edgeguard/.reset-token and sets a new admin password. The
+// token is single-use — ConsumeResetToken löscht das File bei Erfolg.
+func (h *AuthHandler) ResetPassword(c *gin.Context) {
+ var req resetPasswordRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err)
+ return
+ }
+ if err := h.Setup.ConsumeResetToken(req.Token); err != nil {
+ response.Err(c, http.StatusUnauthorized, err)
+ return
+ }
+ if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
+ response.BadRequest(c, err)
+ return
+ }
+ response.OK(c, gin.H{"ok": true})
+}
+
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
if maxAge < 0 {
diff --git a/internal/services/setup/reset_token.go b/internal/services/setup/reset_token.go
new file mode 100644
index 0000000..40f8e1e
--- /dev/null
+++ b/internal/services/setup/reset_token.go
@@ -0,0 +1,114 @@
+package setup
+
+// Admin-Password-Reset-Flow: Operator hat Login-Zugang vergessen,
+// aber SSH-Zugang zur Box. Auf der Box ruft er
+//
+// sudo edgeguard-ctl reset-password
+//
+// auf — der CLI schreibt ein Token-File nach
+// /var/lib/edgeguard/.reset-token (mode 0600 edgeguard:edgeguard) und
+// druckt das Token. Der Operator gibt es auf der /reset-password-Seite
+// ein, setzt ein neues Passwort, fertig.
+//
+// Token-Eigenschaften:
+// - 32 zufällige hex-chars (128 bit entropy)
+// - File-mode 0600 — nur edgeguard liest, nur root + edgeguard schreibt
+// - Expiry 30 min (im File als ISO-timestamp gespeichert)
+// - Single-use: bei jedem erfolgreichen Reset wird das File gelöscht
+//
+// Sicherheit gegen Brute-Force: nicht nötig — Token hat 128 bit
+// Entropy, das File darf existieren nur kurzzeitig nach einem expliziten
+// CLI-Aufruf. Login-Seite hat kein Rate-Limit, aber jeder Versuch
+// braucht das exakte File-Inhalt — bei 32 hex zu raten dauert 2^127
+// Versuche.
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const (
+ resetTokenFile = ".reset-token"
+ resetTokenTTL = 30 * time.Minute
+)
+
+// GenerateResetToken erzeugt ein neues Token, schreibt das File und
+// gibt das Token zurück. Existiert bereits ein Token → wird
+// überschrieben (Operator hat das alte verworfen). Aufrufer: CLI.
+func (s *Store) GenerateResetToken() (string, error) {
+ prev, err := s.Load()
+ if err != nil {
+ return "", err
+ }
+ if prev == nil || !prev.Completed {
+ return "", errors.New("setup not completed — nothing to reset")
+ }
+ var raw [16]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return "", err
+ }
+ token := hex.EncodeToString(raw[:])
+ expiry := time.Now().Add(resetTokenTTL).UTC().Format(time.RFC3339)
+ content := token + "|" + expiry + "\n"
+
+ path := filepath.Join(s.Dir, resetTokenFile)
+ if err := os.MkdirAll(s.Dir, 0o755); err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ return "", err
+ }
+ return token, nil
+}
+
+// ConsumeResetToken prüft Token-Match + Expiry und löscht das File bei
+// Erfolg. Aufrufer: API-Handler /auth/reset-password.
+func (s *Store) ConsumeResetToken(token string) error {
+ path := filepath.Join(s.Dir, resetTokenFile)
+ b, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return errors.New("no reset-token available — run `sudo edgeguard-ctl reset-password` on the box first")
+ }
+ return err
+ }
+ parts := strings.SplitN(strings.TrimSpace(string(b)), "|", 2)
+ if len(parts) != 2 {
+ return errors.New("reset-token file malformed")
+ }
+ stored := strings.TrimSpace(parts[0])
+ expiryStr := strings.TrimSpace(parts[1])
+ if !constantTimeEqual(stored, strings.TrimSpace(token)) {
+ return errors.New("reset-token mismatch")
+ }
+ exp, err := time.Parse(time.RFC3339, expiryStr)
+ if err != nil {
+ return errors.New("reset-token expiry malformed")
+ }
+ if time.Now().After(exp) {
+ _ = os.Remove(path)
+ return errors.New("reset-token expired — generate a new one")
+ }
+ // success → delete file (single-use)
+ _ = os.Remove(path)
+ return nil
+}
+
+// constantTimeEqual prevents timing-attacks on the token compare.
+// crypto/subtle hätte's auch getan, aber für hex-strings reicht das
+// hier — und vermeidet einen weiteren Import.
+func constantTimeEqual(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ var diff byte
+ for i := range a {
+ diff |= a[i] ^ b[i]
+ }
+ return diff == 0
+}
diff --git a/internal/services/setup/setup.go b/internal/services/setup/setup.go
index 1b75ae3..6f5788a 100644
--- a/internal/services/setup/setup.go
+++ b/internal/services/setup/setup.go
@@ -136,6 +136,27 @@ func (st *State) VerifyAdminPassword(plaintext string) bool {
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil
}
+// SetAdminPassword hash't ein neues Plaintext-Passwort und persistiert
+// es. Verwendet vom Self-Service-Reset (CLI-Token-Flow).
+func (s *Store) SetAdminPassword(plaintext string) error {
+ if len(plaintext) < 12 {
+ return errors.New("admin_password must be at least 12 characters")
+ }
+ prev, err := s.Load()
+ if err != nil {
+ return err
+ }
+ if prev == nil || !prev.Completed {
+ return errors.New("setup not completed — cannot reset password before initial setup")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), adminPwCost)
+ if err != nil {
+ return fmt.Errorf("hash admin password: %w", err)
+ }
+ prev.AdminPasswordHash = string(hash)
+ return s.Save(prev)
+}
+
func validate(req Request) error {
if _, err := mail.ParseAddress(req.AdminEmail); err != nil {
return fmt.Errorf("invalid admin_email: %w", err)
diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx
index 0125467..a99a7f7 100644
--- a/management-ui/src/App.tsx
+++ b/management-ui/src/App.tsx
@@ -11,6 +11,7 @@ import apiClient, { isEnvelope } from './api/client'
import { useAuthStore, type SessionUser } from './stores/auth'
const LoginPage = lazy(() => import('./pages/Login'))
+const ResetPasswordPage = lazy(() => import('./pages/ResetPassword'))
const SetupPage = lazy(() => import('./pages/Setup'))
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const DomainsPage = lazy(() => import('./pages/Domains'))
@@ -98,6 +99,7 @@ export default function App() {
useAuthStore.getState().set(u)} />} />
useAuthStore.getState().set(u)} />} />
+ } />
}>
} />
diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx
index 6507bba..9a96bbe 100644
--- a/management-ui/src/components/Layout/Sidebar.tsx
+++ b/management-ui/src/components/Layout/Sidebar.tsx
@@ -85,7 +85,7 @@ const NAV: NavSection[] = [
},
]
-const VERSION = '1.0.75'
+const VERSION = '1.0.76'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// -