package main import ( "context" "encoding/json" "flag" "fmt" "log/slog" "net/http" "net/url" "os" "os/signal" "path/filepath" "syscall" "git.lohmar.co.uk/lexton-it/NextWks/core/admin" "git.lohmar.co.uk/lexton-it/NextWks/core/auth" "git.lohmar.co.uk/lexton-it/NextWks/core/config" "git.lohmar.co.uk/lexton-it/NextWks/core/proxy" "git.lohmar.co.uk/lexton-it/NextWks/core/db" "git.lohmar.co.uk/lexton-it/NextWks/core/email" "git.lohmar.co.uk/lexton-it/NextWks/core/ui" "git.lohmar.co.uk/lexton-it/NextWks/core/version" "github.com/caddyserver/certmagic" ) func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) // Config path: default to ./config.yaml for dev, override with -config for production configPath := flag.String("config", "./config.yaml", "path to configuration file") flag.Parse() logger.Info("starting Next Workspace (NextWks)", "version", version.Version, "config", *configPath) // Load configuration cfg, err := config.Load(*configPath) if err != nil { logger.Error("failed to load config", "error", err) os.Exit(1) } // Initialize database database, err := db.Initialize(cfg.Database.Path) if err != nil { logger.Error("failed to initialize database", "error", err) os.Exit(1) } defer database.Close() // Run schema migrations if err := database.Migrate(); err != nil { logger.Error("failed to run migrations", "error", err) os.Exit(1) } logger.Info("database initialized and migrated", "path", cfg.Database.Path) // Initialize admin components userStore := admin.NewUserStore(database.DB) syncWriter := admin.NewSyncWriter(cfg.Authelia.UsersDBPath, userStore) // Bootstrap: import existing Authelia users if this is a fresh start imported, err := syncWriter.Bootstrap() if err != nil { logger.Warn("bootstrap authelia users", "error", err) } else if imported > 0 { logger.Info("bootstrapped authelia users", "count", imported) } // Fix existing user roles based on groups if fixed, err := syncWriter.FixRoles(); err != nil { logger.Warn("fix roles", "error", err) } else if fixed > 0 { logger.Info("fixed user roles", "count", fixed) } // Create email sender emailSender := email.NewSender(cfg.SMTP.Host, cfg.SMTP.Port, cfg.SMTP.Username, cfg.SMTP.Password, cfg.SMTP.From, logger) // Create admin handler adminHandler := admin.NewHandler(userStore, admin.NewGroupStore(database.DB), syncWriter, emailSender, logger, *configPath) // Initialize session store and OIDC auth sessionStore := auth.NewSessionStore(database.DB) roleChecker := auth.NewRoleChecker(database.DB) // OIDC issuer: public-facing URL (via Zoraxy) for browser redirects // Falls back to authelia.host if not configured issuerURL := cfg.OIDC.IssuerURL if issuerURL == "" { issuerURL = cfg.Authelia.Host } oidcCfg := auth.OIDCConfig{ IssuerURL: issuerURL, ClientID: cfg.OIDC.ClientID, ClientSecret: cfg.OIDC.ClientSecret, RedirectURL: cfg.OIDC.RedirectURL, } oidcHandler := auth.NewOIDCHandler(oidcCfg, sessionStore) // Initialize launcher UI handler // appDir is the directory containing config.yaml (and static/ subdir) appDir := filepath.Dir(*configPath) if appDir == "." { appDir = "./" } uiHandler := ui.NewHandler(appDir, cfg.Locale.Language) // Setup HTTP router mux := http.NewServeMux() // Initialize reverse proxy prx, err := proxy.New([]proxy.Route{ {Path: "/auth/", Target: fmt.Sprintf("http://127.0.0.1:%d", 9091), StripPrefix: false}, }) if err != nil { logger.Error("failed to initialize proxy", "error", err) os.Exit(1) } // Friendly greeting for unmatched proxy routes prx.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) w.Write([]byte("\n\n\n \n \n NextWks\n \n\n\n
\n

Next Workspace

\n

This application isn't available yet. It may still be provisioning or the route hasn't been configured.

\n Return to Dashboard\n
\n\n")) }) // --- Auth middleware --- combinedAuth := func(next http.Handler) http.Handler { return sessionStore.SessionMiddleware(oidcHandler.AuthGateMiddleware(next)) } bearerAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken) adminAuth := func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if token := r.Header.Get("Authorization"); token != "" { bearerAuth(next).ServeHTTP(w, r) return } cookie, err := r.Cookie("nextwks_session") if err != nil || cookie == nil { oidcHandler.LoginRedirect(w, r) return } session, err := sessionStore.ValidateSession(cookie.Value) if err != nil || session == nil { oidcHandler.LoginRedirect(w, r) return } isAdmin, _ := roleChecker.IsAdmin(session.UserID) if isAdmin { ctx := context.WithValue(r.Context(), auth.ContextUserID, session.UserID) ctx = context.WithValue(ctx, auth.ContextRole, "admin") next.ServeHTTP(w, r.WithContext(ctx)) return } http.Error(w, "{\"error\":\"admin access required\"}", http.StatusForbidden) }) } // --- Public endpoints (on mux, before catch-all) --- mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) }) mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(version.Info()) }) // --- OIDC auth routes (explicit mux patterns — take precedence over catch-all) --- mux.HandleFunc("GET /auth/login", oidcHandler.LoginRedirect) mux.HandleFunc("GET /access", oidcHandler.Callback) mux.HandleFunc("POST /access", oidcHandler.Callback) mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) { // Check if this is the return from Authelia logout (no NextWks cookie) if _, err := r.Cookie("nextwks_session"); err != nil { // Second visit: show logout confirmation page component := ui.LogoutPage("", cfg.OIDC.RedirectURL) component.Render(r.Context(), w) return } // First visit: clear cookie and redirect to Authelia logout http.SetCookie(w, &http.Cookie{ Name: "nextwks_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) logoutURL := fmt.Sprintf("%s/logout?rd=%s/auth/logout", cfg.OIDC.IssuerURL, cfg.OIDC.RedirectURL) http.Redirect(w, r, logoutURL, http.StatusFound) }) mux.HandleFunc("GET /auth/status", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"provider":"Authelia","issuer":"%s","status":"configured"}`, cfg.Authelia.Host) }) mux.HandleFunc("GET /pwa-guide", func(w http.ResponseWriter, r *http.Request) { component := ui.PWAGuideModal() component.Render(r.Context(), w) }) // --- Proxy routes (all traffic through proxy as catch-all) --- // Launcher dashboard: auth only for exact "/", unmatched paths get NotFoundHandler launcherHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { // Not the root path — let proxy's NotFoundHandler handle it (no auth needed) prx.NotFoundHandler.ServeHTTP(w, r) return } // Only apply auth for the actual dashboard root combinedAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { uiHandler.ServeHTTP(w, r) })).ServeHTTP(w, r) }) // Admin routes use a sub-mux with no-op auth (auth is applied at proxy route level) adminMux := http.NewServeMux() adminHandler.RegisterRoutes(adminMux, func(next http.Handler) http.Handler { return next }) adminHandler.RegisterUIRoutes(adminMux, func(next http.Handler) http.Handler { return next }) adminHandler.RegisterHTMXRoutes(adminMux, func(next http.Handler) http.Handler { return next }) // Add routes to proxy (longest prefix wins — order matters) prx.AddRoute(proxy.Route{Path: "/auth/", Target: fmt.Sprintf("http://127.0.0.1:%d", 9091)}) prx.AddRoute(proxy.Route{Path: "/static/", Handler: uiHandler.StaticHandler()}) prx.AddRoute(proxy.Route{Path: "/admin/", Handler: adminAuth(adminMux)}) prx.AddRoute(proxy.Route{Path: "/", Handler: launcherHandler}) // Catch-all: everything else goes through proxy mux.Handle("/", prx.Handler()) // CORS middleware handler := corsMiddleware(mux) // Start server if cfg.TLS.Enabled && cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" { // --- File-based TLS (self-signed or custom cert) --- // TLS listener on :443 tlsAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, 443) tlsServer := &http.Server{ Addr: tlsAddr, Handler: handler, } // HTTP listener on configured port (for redirect or mixed mode) httpAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) httpServer := &http.Server{ Addr: httpAddr, Handler: handler, } // Graceful shutdown go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan logger.Info("shutting down server...") tlsServer.Close() httpServer.Close() }() go func() { logger.Info("server listening (TLS)", "address", tlsAddr) logger.Info("workspace launcher", "url", "https://"+cfg.Server.Host+":443/") logger.Info("admin panel", "url", "https://"+cfg.Server.Host+":443/admin") if err := tlsServer.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile); err != http.ErrServerClosed { logger.Error("tls server error", "error", err) os.Exit(1) } }() logger.Info("server listening (HTTP)", "address", httpAddr) if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { logger.Error("http server error", "error", err) os.Exit(1) } } else if cfg.TLS.Enabled { // --- TLS mode: certmagic on :443, HTTP→HTTPS redirect on :80 --- certmagic.DefaultACME.Agreed = true certmagic.DefaultACME.Email = cfg.TLS.Email certmagic.Default.Storage = &certmagic.FileStorage{Path: cfg.TLS.StoragePath} if cfg.TLS.Staging { certmagic.DefaultACME.CA = certmagic.LetsEncryptStagingCA } // certmagic.HTTPS handles ACME challenges, TLS, and HTTP→HTTPS redirect go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan logger.Info("shutting down server...") os.Exit(0) }() logger.Info("server listening (TLS)", "domain", cfg.TLS.Domain) logger.Info("workspace launcher", "url", "https://"+cfg.TLS.Domain+"/") logger.Info("admin panel", "url", "https://"+cfg.TLS.Domain+"/admin") logger.Info("auth status", "url", "https://"+cfg.TLS.Domain+"/auth/status") if err := certmagic.HTTPS([]string{cfg.TLS.Domain}, handler); err != nil { logger.Error("certmagic server error", "error", err) os.Exit(1) } } else { // --- Plain HTTP mode --- addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) server := &http.Server{ Addr: addr, Handler: handler, } // Graceful shutdown go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan logger.Info("shutting down server...") server.Close() }() logger.Info("server listening", "address", addr) logger.Info("workspace launcher", "url", fmt.Sprintf("http://%s/", addr)) logger.Info("admin panel", "url", fmt.Sprintf("http://%s/admin", addr)) logger.Info("auth status", "url", fmt.Sprintf("http://%s/auth/status", addr)) if err := server.ListenAndServe(); err != http.ErrServerClosed { logger.Error("server error", "error", err) os.Exit(1) } } } // corsMiddleware adds CORS headers for frontend access. func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } func domainFromURL(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return "" } return u.Hostname() }