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)
+ }
}
}