diff --git a/cmd/mailgosend/main.go b/cmd/mailgosend/main.go index f37ca66..8e03fdd 100644 --- a/cmd/mailgosend/main.go +++ b/cmd/mailgosend/main.go @@ -176,10 +176,9 @@ func run() error { // httpServers collects all HTTP servers for graceful shutdown. var httpServers []*http.Server - // Shared rate limiters: admin login is very strict (10 req/min, burst 3), - // webmail general is more relaxed (60 req/min, burst 10). - adminRL := middleware.NewRateLimiter(10, 3) - clientRL := middleware.NewRateLimiter(60, 10) + // Rate limiter for webmail (per IP). Admin panel is localhost-only so no + // IP rate limit there — brute guard handles login protection instead. + clientRL := middleware.NewRateLimiter(300, 30) // ---- Web admin panel ---- if cfg.WebAdminPort > 0 { @@ -192,7 +191,8 @@ func run() error { FS: assets.AdminFS(), } adminSrv := webadmin.New(adminDeps) - adminHandler := middleware.SecureHeaders(adminRL.Middleware(adminSrv.Handler())) + // No rate limiter on admin — it binds 127.0.0.1 by default; brute guard is the defence. + adminHandler := middleware.SecureHeaders(adminSrv.Handler()) adminHTTP := &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.WebAdminIface, cfg.WebAdminPort), Handler: adminHandler, diff --git a/internal/db/users.go b/internal/db/users.go index d7c44ea..76e7649 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -63,6 +63,14 @@ func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, erro return d.GetUserByID(ctx, userID) } +// HasAdminUser returns true if at least one enabled admin user exists. +func (d *DB) HasAdminUser(ctx context.Context) (bool, error) { + var n int + err := d.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM users WHERE admin=1 AND enabled=1").Scan(&n) + return n > 0, err +} + // CreateUser inserts a new user. Returns the new ID. func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) { res, err := d.db.ExecContext(ctx, ` @@ -78,6 +86,21 @@ func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, pa return res.LastInsertId() } +// CreateAdminUser inserts the first superadmin (admin=1). Used only during initial setup. +func (d *DB) CreateAdminUser(ctx context.Context, domainID int64, email, passwordHash string) (int64, error) { + username := email + res, err := d.db.ExecContext(ctx, ` + INSERT INTO users + (domain_id, username, email, password_hash, display_name, quota_bytes, + enabled, admin, domain_admin, created_at) + VALUES (?, ?, ?, ?, ?, 0, 1, 1, 1, ?)`, + domainID, username, email, passwordHash, email, time.Now().UTC()) + if err != nil { + return 0, fmt.Errorf("create admin user: %w", err) + } + return res.LastInsertId() +} + // UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store). func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error { _, err := d.db.ExecContext(ctx, diff --git a/internal/webadmin/auth.go b/internal/webadmin/auth.go index dda6f59..9a72df6 100644 --- a/internal/webadmin/auth.go +++ b/internal/webadmin/auth.go @@ -21,12 +21,22 @@ const adminPreAuthCookieName = "mailgo_admin_preauth" const adminPreAuthMaxAge = 300 // 5 minutes // loginGet renders the admin login form. +// Redirects to /admin/setup if no admin user has been created yet. func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) { // Already logged in → dashboard. if s.currentAdmin(r) != nil { http.Redirect(w, r, "/admin/", http.StatusSeeOther) return } + + // First-run: no admin user exists yet. + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + if has, err := s.deps.DB.HasAdminUser(ctx); err == nil && !has { + http.Redirect(w, r, "/admin/setup", http.StatusSeeOther) + return + } + flash, errMsg := flashFrom(r) s.render(w, "login", struct { basePage diff --git a/internal/webadmin/handlers.go b/internal/webadmin/handlers.go index 50b391f..b789999 100644 --- a/internal/webadmin/handlers.go +++ b/internal/webadmin/handlers.go @@ -710,3 +710,119 @@ func (s *Server) generateDKIM(ctx context.Context, domainID int64, algo, selecto func createDefaultMailboxes(ctx context.Context, database *db.DB, userID int64) error { return database.CreateDefaultMailboxes(ctx, userID) } + +// ---- First-run setup ---- + +type setupPage struct { + basePage +} + +// setupGet renders the initial setup form (only accessible when no admin user exists). +func (s *Server) setupGet(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + has, err := s.deps.DB.HasAdminUser(ctx) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if has { + // Setup already done — redirect to login. + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + flash, errMsg := flashFrom(r) + s.render(w, "setup", setupPage{basePage: newBaseNoUser(flash, errMsg)}) +} + +// setupPost creates the first admin user + its primary domain. +// Only callable when no admin user exists; subsequent calls redirect to login. +func (s *Server) setupPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + has, err := s.deps.DB.HasAdminUser(ctx) + if err != nil { + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if has { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) + password := r.FormValue("password") + confirm := r.FormValue("confirm_password") + domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain"))) + + // Validate. + if email == "" || len(email) > 254 || !strings.Contains(email, "@") { + redirect(w, r, "/admin/setup", "", "Invalid email address.") + return + } + if domain == "" || len(domain) > 253 || strings.Contains(domain, " ") { + redirect(w, r, "/admin/setup", "", "Invalid domain name.") + return + } + if len(password) < 8 || len(password) > 1024 { + redirect(w, r, "/admin/setup", "", "Password must be at least 8 characters.") + return + } + if password != confirm { + redirect(w, r, "/admin/setup", "", "Passwords do not match.") + return + } + + hash, err := appCrypto.HashPassword(password) + if err != nil { + log.Printf("[admin] setup hash: %v", err) + redirect(w, r, "/admin/setup", "", "Internal error.") + return + } + + // Create domain first (user references it). + selector := s.deps.Cfg.DKIMSelector + if selector == "" { + selector = "mail" + } + algo := s.deps.Cfg.DKIMAlgo + if algo == "" { + algo = "rsa2048" + } + domainID, err := s.deps.DB.CreateDomain(ctx, domain, selector, algo) + if err != nil { + log.Printf("[admin] setup create domain: %v", err) + redirect(w, r, "/admin/setup", "", "Failed to create domain.") + return + } + + // Create admin user. + userID, err := s.deps.DB.CreateAdminUser(ctx, domainID, email, hash) + if err != nil { + log.Printf("[admin] setup create admin: %v", err) + redirect(w, r, "/admin/setup", "", "Failed to create admin user.") + return + } + + // Default mailboxes + calendar + address book. + if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil { + log.Printf("[admin] setup mailboxes: %v", err) + } + if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil { + log.Printf("[admin] setup calendar: %v", err) + } + if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil { + log.Printf("[admin] setup addressbook: %v", err) + } + + log.Printf("[admin] setup complete: admin=%s domain=%s", email, domain) + redirect(w, r, "/admin/login", "Setup complete. Sign in with your new credentials.", "") +} diff --git a/internal/webadmin/server.go b/internal/webadmin/server.go index 29ca56c..9606de0 100644 --- a/internal/webadmin/server.go +++ b/internal/webadmin/server.go @@ -54,7 +54,18 @@ func (s *Server) Handler() http.Handler { func (s *Server) setupRoutes() { m := s.mux + // Root redirect — catches http://host:8081/ and http://host:8081 (no /admin/ prefix). + m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "" { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + http.NotFound(w, r) + }) + // Public + m.HandleFunc("GET /admin/setup", s.setupGet) + m.HandleFunc("POST /admin/setup", s.setupPost) m.HandleFunc("GET /admin/login", s.loginGet) m.HandleFunc("POST /admin/login", s.loginPost) m.HandleFunc("GET /admin/login/mfa", s.mfaGet) diff --git a/mailgosend b/mailgosend new file mode 100755 index 0000000..35e1f7f Binary files /dev/null and b/mailgosend differ diff --git a/web/admin/templates/setup.html b/web/admin/templates/setup.html new file mode 100644 index 0000000..5570285 --- /dev/null +++ b/web/admin/templates/setup.html @@ -0,0 +1,50 @@ +{{define "title"}}Initial Setup{{end}} +{{define "content"}} +
+
+
+
mailgosend
+
First-run setup
+
+
+ {{if .Error}}
{{.Error}}
{{end}} + {{if .Flash}}
{{.Flash}}
{{end}} + +

+ No admin account exists yet. Create one below to get started. + This page disappears after the first admin account is created. +

+ +
+
+ + +
Domain this server receives mail for.
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+{{end}}