260 lines
9 KiB
Go
260 lines
9 KiB
Go
// Package main is the entry point for the IMC mail server admin application.
|
|
// This single binary contains both the Go backend API and the embedded SvelteKit frontend.
|
|
package main
|
|
|
|
import (
|
|
"crypto/rand" // cryptographically secure random number generator
|
|
"flag" // standard library for parsing command-line flags
|
|
"fmt" // formatted I/O, used here for printing output
|
|
"log" // logging package
|
|
"net/http" // HTTP server and client
|
|
"os" // OS-level operations like reading command-line args
|
|
"strings" // string manipulation utilities
|
|
|
|
"git.workaround.org/chaas/imc/backend/internal/api" // HTTP API routing and handlers
|
|
"git.workaround.org/chaas/imc/backend/internal/auth" // password hashing
|
|
"git.workaround.org/chaas/imc/backend/internal/config" // configuration loading
|
|
"git.workaround.org/chaas/imc/backend/internal/db" // database connection and operations
|
|
"github.com/gin-gonic/gin" // web framework
|
|
)
|
|
|
|
// main is the entry point of the application.
|
|
// It handles command-line flags, initializes the database, sets up the HTTP router,
|
|
// and starts the web server.
|
|
func main() {
|
|
// Parse command-line flags.
|
|
// Flags are optional arguments passed after the program name.
|
|
// Example: ./imc --bind=0.0.0.0 --port=8080
|
|
|
|
// --bind: The IP address the server should listen on.
|
|
// 0.0.0.0 means listen on all network interfaces (accessible from other machines).
|
|
// 127.0.0.1 means listen only on localhost (accessible only from this machine).
|
|
bind := flag.String("bind", "", "IP address to bind to (default: 0.0.0.0)")
|
|
|
|
// --port: The TCP port the server should listen on.
|
|
// Common ports: 80 (HTTP), 443 (HTTPS), 8080 (HTTP alternate).
|
|
port := flag.String("port", "", "Port to listen on (default: 8080)")
|
|
|
|
// --reset-admin-password: Generate a random password for the admin user and exit.
|
|
// Useful for initial setup or recovering from a lost password.
|
|
resetAdminPassword := flag.Bool("reset-admin-password", false, "Reset admin password to a random value and exit")
|
|
|
|
// Customize the help output to show double-hyphen style (Unix convention).
|
|
// By default, Go's flag package uses single hyphens.
|
|
flag.Usage = func() {
|
|
fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options]\n\nOptions:\n", os.Args[0])
|
|
flag.PrintDefaults()
|
|
}
|
|
|
|
// Actually parse the flags from command line arguments.
|
|
flag.Parse()
|
|
|
|
// Load configuration from environment variables.
|
|
// Environment variables take precedence over code defaults.
|
|
// See config.Load() for all available environment variables.
|
|
cfg := config.Load()
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
log.Fatalf("Invalid configuration: %v", err)
|
|
}
|
|
|
|
// Override configuration with command-line flags if they were provided.
|
|
// Empty string means the flag was not set, so we keep the config value.
|
|
if *bind != "" {
|
|
cfg.Bind = *bind
|
|
}
|
|
if *port != "" {
|
|
cfg.Port = *port
|
|
}
|
|
|
|
// Connect to the database (MariaDB/MySQL).
|
|
// The database stores both ISPmail data (virtual_users, virtual_domains, etc.)
|
|
// and IMC's own data (imc_users, imc_users2domains, etc.).
|
|
database, err := db.Connect(cfg)
|
|
if err != nil {
|
|
log.Fatalf("Failed to connect to database: %v", err) // Fatal = print and exit
|
|
}
|
|
|
|
// Create database tables if they don't exist.
|
|
// This only creates IMC's own tables, not the ISPmail tables.
|
|
if err := database.InitSchema(); err != nil {
|
|
log.Printf("Warning: Could not initialize schema: %v", err) // Non-fatal = continue
|
|
}
|
|
|
|
// Handle --reset-admin-password flag.
|
|
// When this flag is set, we generate a random password, update the database, and exit.
|
|
if *resetAdminPassword {
|
|
password := generateRandomPassword(16)
|
|
hash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
log.Fatalf("Failed to hash password: %v", err)
|
|
}
|
|
|
|
// Get or create a default domain for the admin user.
|
|
domains, err := database.GetAllDomains()
|
|
if err != nil || len(domains) == 0 {
|
|
// Create a default domain if none exist.
|
|
domain, err := database.CreateDomain("localhost")
|
|
if err != nil {
|
|
log.Fatalf("Failed to create default domain: %v", err)
|
|
}
|
|
if err := database.UpsertAdminUser("admin", hash, domain.ID); err != nil {
|
|
log.Fatalf("Failed to reset admin password: %v", err)
|
|
}
|
|
fmt.Printf("Admin password reset successfully.\n")
|
|
fmt.Printf("Username: admin\n")
|
|
fmt.Printf("Password: %s\n", password)
|
|
fmt.Printf("Default domain created: localhost\n")
|
|
return
|
|
}
|
|
// Use the first domain found.
|
|
if err := database.UpsertAdminUser("admin", hash, domains[0].ID); err != nil {
|
|
log.Fatalf("Failed to reset admin password: %v", err)
|
|
}
|
|
fmt.Printf("Admin password reset successfully.\n")
|
|
fmt.Printf("Username: admin\n")
|
|
fmt.Printf("Password: %s\n", password)
|
|
return
|
|
}
|
|
|
|
// Start the web server.
|
|
// From here on, we set up the HTTP router and handlers.
|
|
|
|
// Get the embedded filesystem containing the frontend.
|
|
// The frontend is compiled into the binary during build time.
|
|
frontendFS := FrontendFileSystem()
|
|
|
|
// Set Gin mode based on environment.
|
|
// USE_EMBEDDED=false means development mode (e.g., via air hot-reloader).
|
|
// Release mode disables debug logging and other development features.
|
|
if os.Getenv("USE_EMBEDDED") == "false" {
|
|
gin.SetMode(gin.DebugMode)
|
|
} else {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
}
|
|
|
|
// Create a new Gin engine (router).
|
|
engine := gin.New()
|
|
engine.Use(gin.Recovery())
|
|
|
|
// Register API routes from the router.
|
|
apiRouter := api.New(database, cfg)
|
|
apiRouter.RegisterRoutes(engine)
|
|
|
|
// Set up SPA fallback for non-API routes.
|
|
// This must be the last route to catch all unmatched paths.
|
|
engine.NoRoute(func(c *gin.Context) {
|
|
path := c.Request.URL.Path
|
|
|
|
// If it's an API path, return 404.
|
|
// The API router should have handled /api/* routes.
|
|
if strings.HasPrefix(path, "/api/") {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
return
|
|
}
|
|
|
|
// Serve SvelteKit hashed assets at /_app/*
|
|
if strings.HasPrefix(path, "/_app/") {
|
|
assetPath := "embed/_app/" + strings.TrimPrefix(path, "/_app/")
|
|
serveGinStaticFile(c, frontendFS, assetPath)
|
|
return
|
|
}
|
|
|
|
// Serve favicon
|
|
if path == "/favicon.png" {
|
|
serveGinStaticFile(c, frontendFS, "embed/favicon.png")
|
|
return
|
|
}
|
|
|
|
// For all other paths, serve the SPA index.html.
|
|
// This allows client-side routing (e.g., /domains/example.org).
|
|
serveGinStaticFile(c, frontendFS, "embed/index.html")
|
|
})
|
|
|
|
// Build the address string for binding: "IP:PORT"
|
|
// Example: "0.0.0.0:8080" or "127.0.0.1:8080"
|
|
addr := fmt.Sprintf("%s:%s", cfg.Bind, cfg.Port)
|
|
|
|
// Log the startup message.
|
|
log.Printf("Server starting on http://%s", addr)
|
|
|
|
// Start the HTTP server.
|
|
// ListenAndServe blocks until the server is stopped.
|
|
// log.Fatal prints the error and exits if the server can't start.
|
|
log.Fatal(http.ListenAndServe(addr, engine))
|
|
}
|
|
|
|
// serveGinStaticFile serves a static file from the embedded filesystem.
|
|
func serveGinStaticFile(c *gin.Context, fs http.FileSystem, path string) {
|
|
file, err := fs.Open(path)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
fi, err := file.Stat()
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
return
|
|
}
|
|
|
|
if fi.IsDir() {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
|
return
|
|
}
|
|
|
|
contentType := mimeType(path)
|
|
c.Header("Content-Type", contentType)
|
|
http.ServeContent(c.Writer, c.Request, fi.Name(), fi.ModTime(), file)
|
|
}
|
|
|
|
// generateRandomPassword creates a cryptographically secure random password.
|
|
func generateRandomPassword(length int) string {
|
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
|
result := make([]byte, length)
|
|
if _, err := rand.Read(result); err != nil {
|
|
log.Fatalf("Failed to generate random password: %v", err)
|
|
}
|
|
for i := range result {
|
|
result[i] = charset[int(result[i])%len(charset)]
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
// mimeType returns the MIME type for a file based on its extension.
|
|
// MIME types tell the browser what kind of file it is.
|
|
// This is important for browsers to correctly interpret files.
|
|
func mimeType(path string) string {
|
|
// Use switch statement with multiple conditions (Go's patterns work like regex).
|
|
// Check file extension and return appropriate MIME type.
|
|
switch {
|
|
case strings.HasSuffix(path, ".html"):
|
|
return "text/html; charset=utf-8"
|
|
case strings.HasSuffix(path, ".css"):
|
|
return "text/css"
|
|
case strings.HasSuffix(path, ".js"):
|
|
return "application/javascript"
|
|
case strings.HasSuffix(path, ".json"):
|
|
return "application/json"
|
|
case strings.HasSuffix(path, ".png"):
|
|
return "image/png"
|
|
case strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg"):
|
|
return "image/jpeg"
|
|
case strings.HasSuffix(path, ".svg"):
|
|
return "image/svg+xml"
|
|
case strings.HasSuffix(path, ".ico"):
|
|
return "image/x-icon"
|
|
case strings.HasSuffix(path, ".woff"):
|
|
return "font/woff"
|
|
case strings.HasSuffix(path, ".woff2"):
|
|
return "font/woff2"
|
|
case strings.HasSuffix(path, ".ttf"):
|
|
return "font/ttf"
|
|
case strings.HasSuffix(path, ".eot"):
|
|
return "application/vnd.ms-fontobject"
|
|
default:
|
|
// Fallback for unknown types - treat as plain text.
|
|
return "text/plain"
|
|
}
|
|
}
|