From e575b4eeb0d7760c4f218d7f289cfee75444d144 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Mon, 6 Jul 2026 08:59:12 +0100 Subject: [PATCH] feat: self-signed TLS fallback on :443 + proxy as central router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CertFile/KeyFile fields to TLSConfig (config.go) - File-based TLS fallback: when cert_file+key_file set, ListenAndServeTLS on :443 while keeping HTTP on configured port (certmagic ACME is non-fallback path) - deploy.sh: enable TLS by default, generate self-signed cert during deploy - deploy.sh: change default port 8080 → 80 (reverse proxy standard) - deploy.sh: add cert_file/key_file to config template - proxy as central router fix (handler.go: ServeHTTP + StaticHandler methods) --- deploy.sh | 17 ++- src/core/config/config.go | 13 +++ src/core/ui/handler.go | 16 +++ src/main.go | 239 ++++++++++++++++++++++++++------------ 4 files changed, 207 insertions(+), 78 deletions(-) diff --git a/deploy.sh b/deploy.sh index 2d7bf32..fa9857a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -133,11 +133,13 @@ session: expiry_minutes: 60 tls: - enabled: false + enabled: true domain: "" email: "" storage_path: "${CERTS_DIR}" staging: false + cert_file: "${CERTS_DIR}/cert.pem" + key_file: "${CERTS_DIR}/key.pem" CONFIGEOF chmod 600 "$CONFIG_PATH" @@ -148,6 +150,19 @@ else ok "Config exists (not overwritten): $CONFIG_PATH" fi +# Generate self-signed TLS certificate if needed +if [ ! -f "${CERTS_DIR}/cert.pem" ] || [ ! -f "${CERTS_DIR}/key.pem" ]; then + DOMAIN="${TLS_DOMAIN:-test.nextwks.eu}" + info "Generating self-signed TLS certificate for ${DOMAIN}..." + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "${CERTS_DIR}/key.pem" \ + -out "${CERTS_DIR}/cert.pem" \ + -subj "/CN=${DOMAIN}" 2>/dev/null + chmod 600 "${CERTS_DIR}/key.pem" + chown -R "$SVC_USER:$SVC_USER" "$CERTS_DIR" + ok "Self-signed certificate generated for ${DOMAIN}" +fi + if [ -d "$SRC_DIR/app/static" ]; then cp -r "$SRC_DIR/app/static/"* "$STATIC_DIR/" 2>/dev/null || true chown -R "$SVC_USER:$SVC_USER" "$STATIC_DIR" diff --git a/src/core/config/config.go b/src/core/config/config.go index ad35069..6673231 100644 --- a/src/core/config/config.go +++ b/src/core/config/config.go @@ -10,6 +10,7 @@ import ( // Config represents the full NextWks configuration. type Config struct { Server ServerConfig `yaml:"server"` + TLS TLSConfig `yaml:"tls"` Admin AdminConfig `yaml:"admin"` Database DatabaseConfig `yaml:"database"` Authelia AutheliaConfig `yaml:"authelia"` @@ -25,6 +26,18 @@ type ServerConfig struct { Port int `yaml:"port"` } +// TLSConfig holds automatic HTTPS configuration via certmagic/Let's Encrypt, +// or file-based TLS using a self-signed or custom certificate. +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + Domain string `yaml:"domain"` + Email string `yaml:"email"` + StoragePath string `yaml:"storage_path"` + Staging bool `yaml:"staging"` + CertFile string `yaml:"cert_file"` // File-based TLS cert (optional — overrides certmagic) + KeyFile string `yaml:"key_file"` // File-based TLS key (optional — overrides certmagic) +} + type AdminConfig struct { SecretToken string `yaml:"secret_token"` } diff --git a/src/core/ui/handler.go b/src/core/ui/handler.go index abca47c..2ee2c78 100644 --- a/src/core/ui/handler.go +++ b/src/core/ui/handler.go @@ -85,3 +85,19 @@ func (h *Handler) getContext(r *http.Request) PageCtx { Locale: i18n.Get(lang), } } + +// ServeHTTP serves the launcher dashboard for GET /. +// This replaces the mux-based registration for proxy integration. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + h.launcherPage(w, r) +} + +// StaticHandler returns an http.Handler for static file serving. +func (h *Handler) StaticHandler() http.Handler { + staticDir := filepath.Join(h.appDir, "static") + return http.StripPrefix("/static", http.FileServer(http.Dir(staticDir))) +} diff --git a/src/main.go b/src/main.go index ca18bf2..11426ab 100644 --- a/src/main.go +++ b/src/main.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "net/http" - "net/http/httputil" "net/url" "os" "os/signal" @@ -17,10 +16,13 @@ import ( "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() { @@ -108,62 +110,26 @@ func main() { // Setup HTTP router mux := http.NewServeMux() - // Reverse proxy: /auth/* → Authelia (except /access) - autheliaProxy := httputil.NewSingleHostReverseProxy(&url.URL{ - Scheme: "http", - Host: fmt.Sprintf("%s:%d", "127.0.0.1", 9091), + // Initialize reverse proxy + prx, err := proxy.New([]proxy.Route{ + {Path: "/auth/", Target: fmt.Sprintf("http://127.0.0.1:%d", 9091), StripPrefix: false}, }) - mux.HandleFunc("GET /auth/", func(w http.ResponseWriter, r *http.Request) { - // Skip /access — handled by NextWks - if r.URL.Path == "/access" || r.URL.Path == "/auth/login" || r.URL.Path == "/auth/logout" { - http.NotFound(w, r) - return - } - autheliaProxy.ServeHTTP(w, r) - }) - mux.HandleFunc("POST /auth/", func(w http.ResponseWriter, r *http.Request) { - autheliaProxy.ServeHTTP(w, r) + 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")) }) - // --- Public endpoints --- - 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 (public) --- - 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) - }) - - // --- Workspace launcher (public, but OIDC-protected) --- - // Chain: SessionMiddleware (reads cookie → sets context) → AuthGate (checks context → redirects if needed) + // --- Auth middleware --- combinedAuth := func(next http.Handler) http.Handler { return sessionStore.SessionMiddleware(oidcHandler.AuthGateMiddleware(next)) } - uiHandler.RegisterRoutes(mux, combinedAuth) - - // --- Admin auth: session (with admin role) OR bearer token --- bearerAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken) adminAuth := func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -192,48 +158,167 @@ func main() { }) } - adminHandler.RegisterRoutes(mux, adminAuth) - adminHandler.RegisterUIRoutes(mux, adminAuth) - adminHandler.RegisterHTMXRoutes(mux, adminAuth) + // --- 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 config page — shows Authelia status --- + // --- 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) }) - - // --- PWA Guide modal (HTMX fragment) --- 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-gated) + launcherHandler := combinedAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + // Not the root path — let proxy's NotFoundHandler handle it + prx.NotFoundHandler.ServeHTTP(w, r) + return + } + uiHandler.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 - addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - server := &http.Server{ - Addr: addr, - Handler: handler, - } + 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, + } - // 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() - }() + // 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, + } - 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) + // 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) + } } }