- Move combinedAuth inside the launcher handler path check - Unmatched proxy paths (not exactly /) now correctly serve NotFoundHandler greeting - Root / remains auth-gated as intended
350 lines
13 KiB
Go
350 lines
13 KiB
Go
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("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>NextWks</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n display: flex; justify-content: center; align-items: center;\n min-height: 100vh; background: #0f172a; color: #e2e8f0; }\n .card { background: #1e293b; padding: 3rem 4rem; border-radius: 16px;\n box-shadow: 0 4px 24px rgba(0,0,0,0.3); text-align: center;\n max-width: 520px; border: 1px solid #334155; }\n h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.75rem; color: #f8fafc; }\n p { color: #94a3b8; line-height: 1.6; margin-bottom: 1.5rem; }\n a { color: #38bdf8; text-decoration: none; font-weight: 600;\n padding: 0.5rem 1.5rem; border: 1px solid #38bdf8;\n border-radius: 8px; display: inline-block; transition: all 0.2s; }\n a:hover { background: #38bdf8; color: #0f172a; }\n </style>\n</head>\n<body>\n <div class=\"card\">\n <h1>Next Workspace</h1>\n <p>This application isn't available yet. It may still be provisioning or the route hasn't been configured.</p>\n <a href=\"/\">Return to Dashboard</a>\n </div>\n</body>\n</html>"))
|
|
})
|
|
|
|
// --- 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()
|
|
}
|