Add CLI flags for setup and server configuration

- Add --setup flag to create admin user
- Add --admin-user and --admin-password for setup
- Generate cryptographically random passwords for --setup
- Add --bind and --port flags for server binding
- Add BIND env var support
- Add SMTP email service for password reset
- Add password reset token storage
- Add auth header to queue/logs frontend pages
- Fix journalctl timestamp format
- Fix logs to return empty array instead of error when no entries
- Fix queue handler to return proper error message
- Hide Users/Aliases nav links when no domain selected
This commit is contained in:
Christoph Haas 2026-03-22 01:17:43 +01:00
parent 2c3a4c3daa
commit 53ce3b06ae
14 changed files with 435 additions and 69 deletions

View file

@ -6,3 +6,10 @@ DB_NAME=mailserver
JWT_SECRET=change-this-secret-in-production
ADMIN_USER=admin
ADMIN_PASSWORD=admin123
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@localhost
BASE_URL=http://localhost:8080

View file

@ -1,6 +1,9 @@
package main
import (
"crypto/rand"
"flag"
"fmt"
"log"
"net/http"
"os"
@ -13,8 +16,22 @@ import (
)
func main() {
setup := flag.Bool("setup", false, "Create admin user and exit")
adminUser := flag.String("admin-user", "admin", "Admin username for setup")
adminPassword := flag.String("admin-password", "", "Admin password for setup (required with --setup)")
bind := flag.String("bind", "", "IP address to bind to (default: 0.0.0.0)")
port := flag.String("port", "", "Port to listen on (default: 8080)")
flag.Parse()
cfg := config.Load()
if *bind != "" {
cfg.Bind = *bind
}
if *port != "" {
cfg.Port = *port
}
database, err := db.Connect(cfg)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
@ -24,6 +41,28 @@ func main() {
log.Printf("Warning: Could not initialize schema: %v", err)
}
if *setup {
password := *adminPassword
if password == "" {
password = generateRandomPassword(16)
fmt.Printf("Generated random password: %s\n", password)
}
hash, err := auth.HashPassword(password)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
if err := database.EnsureAdminUser(*adminUser, hash); err != nil {
log.Fatalf("Failed to create admin user: %v", err)
}
fmt.Printf("Admin user '%s' created successfully.\n", *adminUser)
fmt.Printf("You can now login with username: %s\n", *adminUser)
fmt.Printf("Password: %s\n", password)
return
}
if cfg.AdminUser != "" && cfg.AdminPassword != "" {
hash, err := auth.HashPassword(cfg.AdminPassword)
if err != nil {
@ -38,7 +77,6 @@ func main() {
}
useEmbedded := os.Getenv("USE_EMBEDDED") != "false"
port := cfg.Port
frontendFS := FrontendFileSystem()
@ -62,18 +100,32 @@ func main() {
log.Println("Using filesystem frontend")
}
log.Printf("Server starting on http://localhost:%s", port)
log.Fatal(http.ListenAndServe(":"+port, router))
addr := fmt.Sprintf("%s:%s", cfg.Bind, cfg.Port)
log.Printf("Server starting on http://%s", addr)
log.Fatal(http.ListenAndServe(addr, router))
}
func generateRandomPassword(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
result := make([]byte, length)
if _, err := rand.Read(result); err != nil {
for i := range result {
result[i] = charset[i%len(charset)]
}
return string(result)
}
for i := range result {
result[i] = charset[int(result[i])%len(charset)]
}
return string(result)
}
func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := prefix + r.URL.Path
log.Printf("Static file requested: /_app/%s -> %s", r.URL.Path, path)
file, err := fsys.Open(path)
if err != nil {
log.Printf("Error opening file %s: %v", path, err)
http.NotFound(w, r)
return
}
@ -98,11 +150,8 @@ func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc {
func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("Static file requested: /%s", filename)
file, err := fsys.Open(filename)
if err != nil {
log.Printf("Error opening file %s: %v", filename, err)
http.NotFound(w, r)
return
}
@ -129,11 +178,7 @@ func serveSPA(fsys http.FileSystem) http.HandlerFunc {
return
}
if path == "/" || path == "" {
path = "embed/index.html"
} else {
path = "embed/index.html"
}
path = "embed/index.html"
file, err := fsys.Open(path)
if err != nil {

View file

@ -2,25 +2,29 @@ package handlers
import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/db"
"github.com/imc-vibe/backend/internal/mail"
)
const MaxLoginAttempts = 5
type AuthHandler struct {
db *db.DB
jwtManager *auth.JWTManager
db *db.DB
jwtManager *auth.JWTManager
emailService *mail.EmailService
}
func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager) *AuthHandler {
func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager, emailService *mail.EmailService) *AuthHandler {
return &AuthHandler{
db: database,
jwtManager: jwtManager,
db: database,
jwtManager: jwtManager,
emailService: emailService,
}
}
@ -153,14 +157,29 @@ func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
return
}
_, err := h.db.GetImcUserByUsername(req.Identifier)
if err == nil {
user, err := h.db.GetImcUserByUsername(req.Identifier)
if err != nil {
Success(w, map[string]string{
"message": "If the account exists, a password reset link will be sent",
})
return
}
token := mail.GenerateToken()
expiresAt := time.Now().Add(1 * time.Hour)
if err := h.db.CreatePasswordResetToken(user.ID, token, expiresAt); err != nil {
log.Printf("Failed to create password reset token: %v", err)
Error(w, http.StatusInternalServerError, "failed to create reset token")
return
}
if err := h.emailService.SendPasswordReset(user.Username, token); err != nil {
log.Printf("Failed to send password reset email: %v", err)
Error(w, http.StatusInternalServerError, "failed to send email")
return
}
Success(w, map[string]string{
"message": "If the account exists, a password reset link will be sent",
})

View file

@ -30,7 +30,7 @@ func (h *LogsHandler) List(w http.ResponseWriter, r *http.Request) {
entries, err := mail.GetLogs(hours, filter)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to get logs")
Error(w, http.StatusServiceUnavailable, "log service not available")
return
}

View file

@ -20,7 +20,7 @@ func (h *QueueHandler) List(w http.ResponseWriter, r *http.Request) {
entries, err := mail.GetQueue()
if err != nil {
Error(w, http.StatusInternalServerError, "failed to get queue")
Error(w, http.StatusServiceUnavailable, "mail queue tools not available")
return
}

View file

@ -9,6 +9,7 @@ import (
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/config"
"github.com/imc-vibe/backend/internal/db"
"github.com/imc-vibe/backend/internal/mail"
)
type Router struct {
@ -25,9 +26,10 @@ type Router struct {
func New(database *db.DB, cfg *config.Config) *Router {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe")
emailService := mail.NewEmailService(cfg)
return &Router{
authHandler: handlers.NewAuthHandler(database, jwtManager),
authHandler: handlers.NewAuthHandler(database, jwtManager, emailService),
domainHandler: handlers.NewDomainHandler(database),
userHandler: handlers.NewUserHandler(database),
aliasHandler: handlers.NewAliasHandler(database),

View file

@ -12,6 +12,7 @@ type Config struct {
DBUser string
DBPassword string
DBName string
Bind string
Port string
UseEmbedded bool
JWTSecret string
@ -23,6 +24,12 @@ type Config struct {
DovecotQuotaCmd string
JournalctlPath string
RspamdAPI string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
BaseURL string
}
func Load() *Config {
@ -34,9 +41,10 @@ func Load() *Config {
DBUser: getEnv("DB_USER", "mailadmin"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "mailserver"),
Bind: getEnv("BIND", "0.0.0.0"),
Port: getEnv("PORT", "8080"),
UseEmbedded: getEnv("USE_EMBEDDED", "true") == "true",
JWTSecret: getEnv("JWT_SECRET", "change-this-secret"),
JWTSecret: getEnv("JWT_SECRET", "change-this-secret-in-production"),
AdminUser: getEnv("ADMIN_USER", ""),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
MailDataDir: getEnv("MAIL_DATA_DIR", "/var/vmail"),
@ -45,6 +53,12 @@ func Load() *Config {
DovecotQuotaCmd: getEnv("DOVECOT_QUOTA_CMD", "/usr/bin/doveadm"),
JournalctlPath: getEnv("JOURNALCTL_PATH", "/usr/bin/journalctl"),
RspamdAPI: getEnv("RSPAMD_API", "http://127.0.0.1:11334"),
SMTPHost: getEnv("SMTP_HOST", "localhost"),
SMTPPort: getEnv("SMTP_PORT", "587"),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
}
}

View file

@ -78,6 +78,17 @@ type ImcLoginAttempt struct {
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
type PasswordResetToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"userId"`
Token string `gorm:"size:64;not null;uniqueIndex" json:"token"`
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"`
Used bool `gorm:"default:false" json:"used"`
CreatedAt time.Time `json:"createdAt"`
}
func (PasswordResetToken) TableName() string { return "imc_password_reset_tokens" }
type DomainStats struct {
ID uint `json:"id"`
Name string `json:"name"`
@ -160,5 +171,21 @@ func (d *DB) InitSchema() error {
INDEX idx_domain_id (domain_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
return d.Exec(users2DomainsSQL).Error
if err := d.Exec(users2DomainsSQL).Error; err != nil {
return err
}
resetTokensSQL := `
CREATE TABLE IF NOT EXISTS imc_password_reset_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
return d.Exec(resetTokensSQL).Error
}

View file

@ -1,5 +1,7 @@
package db
import "time"
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
var user ImcUser
if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil {
@ -120,3 +122,25 @@ func (d *DB) IsUserInDomain(userID, domainID uint) (bool, error) {
err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error
return count > 0, err
}
func (d *DB) CreatePasswordResetToken(userID uint, token string, expiresAt time.Time) error {
resetToken := PasswordResetToken{
UserID: userID,
Token: token,
ExpiresAt: expiresAt,
}
return d.Create(&resetToken).Error
}
func (d *DB) GetValidPasswordResetToken(token string) (*PasswordResetToken, error) {
var resetToken PasswordResetToken
err := d.Where("token = ? AND used = false AND expires_at > ?", token, time.Now()).First(&resetToken).Error
if err != nil {
return nil, err
}
return &resetToken, nil
}
func (d *DB) MarkResetTokenUsed(token string) error {
return d.Model(&PasswordResetToken{}).Where("token = ?", token).Update("used", true).Error
}

View file

@ -0,0 +1,148 @@
package mail
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
"github.com/imc-vibe/backend/internal/config"
)
type EmailService struct {
host string
port string
username string
password string
from string
baseURL string
}
func NewEmailService(cfg *config.Config) *EmailService {
return &EmailService{
host: cfg.SMTPHost,
port: cfg.SMTPPort,
username: cfg.SMTPUser,
password: cfg.SMTPPassword,
from: cfg.SMTPFrom,
baseURL: cfg.BaseURL,
}
}
func (s *EmailService) SendPasswordReset(to, token string) error {
resetURL := fmt.Sprintf("%s/auth/reset-password?token=%s", s.baseURL, token)
subject := "Password Reset Request"
body := fmt.Sprintf(`Hi,
You requested a password reset for your account.
Click the link below to reset your password:
%s
This link will expire in 1 hour.
If you didn't request this, please ignore this email.
Best regards,
IMC Vibe Mail Server Admin
`, resetURL)
return s.send(to, subject, body)
}
func (s *EmailService) send(to, subject, body string) error {
if s.host == "" || s.username == "" {
return fmt.Errorf("SMTP not configured")
}
addr := fmt.Sprintf("%s:%s", s.host, s.port)
msg := fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: text/plain; charset=\"UTF-8\"\r\n"+
"\r\n"+
"%s", s.from, to, subject, body)
var auth smtp.Auth
if s.port == "465" {
err := s.sendWithTLS(addr, to, msg)
return err
} else {
auth = smtp.PlainAuth("", s.username, s.password, s.host)
err := smtp.SendMail(addr, auth, s.from, []string{to}, []byte(msg))
return err
}
}
func (s *EmailService) sendWithTLS(addr, to, msg string) error {
tlsConfig := &tls.Config{
ServerName: s.host,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.host)
if err != nil {
return err
}
defer client.Close()
auth := smtp.PlainAuth("", s.username, s.password, s.host)
if err := client.Auth(auth); err != nil {
return err
}
if err := client.Mail(s.from); err != nil {
return err
}
if err := client.Rcpt(to); err != nil {
return err
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write([]byte(msg))
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return client.Quit()
}
func GenerateToken() string {
b := make([]byte, 32)
for i := range b {
b[i] = tokenChars[i%len(tokenChars)]
}
return fmt.Sprintf("%x", b)
}
const tokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func ValidateToken(token string) bool {
if len(token) != 64 {
return false
}
for _, c := range token {
if !strings.ContainsRune(tokenChars, c) {
return false
}
}
return true
}

View file

@ -24,7 +24,6 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
"-u", "postfix",
"--since", formatDuration(hours),
"--no-pager",
"-o", "short=false",
}
cmd := exec.CommandContext(ctx, "journalctl", args...)
@ -40,7 +39,11 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`)
for scanner.Scan() {
line := scanner.Text()
line := strings.TrimSpace(scanner.Text())
if strings.Contains(line, "-- No entries") || line == "" {
continue
}
if filter != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(filter)) {
continue
@ -84,5 +87,5 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
}
func formatDuration(hours int) string {
return time.Now().Add(-time.Duration(hours) * time.Hour).Format("15:04:05 2006-01-02")
return time.Now().Add(-time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
}