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: // -