feat: self-signed TLS fallback on :443 + proxy as central router
- 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)
This commit is contained in:
parent
b069cab1e3
commit
e575b4eeb0
4 changed files with 207 additions and 78 deletions
17
deploy.sh
17
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"
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
197
src/main.go
197
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
|
||||
if err != nil {
|
||||
logger.Error("failed to initialize proxy", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
autheliaProxy.ServeHTTP(w, r)
|
||||
})
|
||||
mux.HandleFunc("POST /auth/", func(w http.ResponseWriter, r *http.Request) {
|
||||
autheliaProxy.ServeHTTP(w, r)
|
||||
|
||||
// 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>"))
|
||||
})
|
||||
|
||||
// --- 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,26 +158,144 @@ 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
|
||||
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,
|
||||
|
|
@ -235,6 +319,7 @@ func main() {
|
|||
logger.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers for frontend access.
|
||||
|
|
|
|||
Loading…
Reference in a new issue