From 7fdf6692df6a9b079c03481d178f8b0d644f019c Mon Sep 17 00:00:00 2001 From: cclohmar Date: Mon, 6 Jul 2026 16:49:57 +0100 Subject: [PATCH] feat: Zoraxy proxy + launcher rewrite --- compose/zoraxy.yaml | 16 + .../zoraxy/proxy/(ST).app.nextwks.eu.config | 16 + config/zoraxy/proxy/root.config | 9 + configs/core/config.yaml | 9 - configs/core/proxies.yaml | 5 - deploy.sh | 49 ++- go.mod | 24 +- go.sum | 56 --- main.go | 366 +++++++++--------- 9 files changed, 263 insertions(+), 287 deletions(-) create mode 100644 compose/zoraxy.yaml create mode 100644 config/zoraxy/proxy/(ST).app.nextwks.eu.config create mode 100644 config/zoraxy/proxy/root.config delete mode 100644 configs/core/config.yaml delete mode 100644 configs/core/proxies.yaml diff --git a/compose/zoraxy.yaml b/compose/zoraxy.yaml new file mode 100644 index 0000000..0383616 --- /dev/null +++ b/compose/zoraxy.yaml @@ -0,0 +1,16 @@ +services: + zoraxy: + image: zoraxydocker/zoraxy:latest + container_name: zoraxy + restart: unless-stopped + network_mode: host + volumes: + - /opt/nextworkspace/config/zoraxy/:/opt/zoraxy/config/ + - /opt/nextworkspace/data/zoraxy/:/opt/zoraxy/data/ + - /opt/nextworkspace/logs/zoraxy/:/opt/zoraxy/log/ + - /var/run/docker.sock:/var/run/docker.sock + environment: + - PORT=8000 + - NOAUTH=false + - DOCKER=true + - TZ=UTC diff --git a/config/zoraxy/proxy/(ST).app.nextwks.eu.config b/config/zoraxy/proxy/(ST).app.nextwks.eu.config new file mode 100644 index 0000000..f5e1641 --- /dev/null +++ b/config/zoraxy/proxy/(ST).app.nextwks.eu.config @@ -0,0 +1,16 @@ +{ + "ProxyType": 1, + "RootOrMatchingDomain": "app.nextwks.eu", + "ActiveOrigins": [ + { + "OriginIpOrDomain": "host.docker.internal:9000", + "RequireTLS": false, + "Weight": 1, + "MaxConn": 0 + } + ], + "Disabled": false, + "AuthenticationProvider": { + "AuthMethod": 4 + } +} diff --git a/config/zoraxy/proxy/root.config b/config/zoraxy/proxy/root.config new file mode 100644 index 0000000..5f058f7 --- /dev/null +++ b/config/zoraxy/proxy/root.config @@ -0,0 +1,9 @@ +{ + "ProxyType": 1, + "RootOrMatchingDomain": "*", + "ActiveOrigins": [], + "Disabled": false, + "AuthenticationProvider": { + "AuthMethod": 0 + } +} diff --git a/configs/core/config.yaml b/configs/core/config.yaml deleted file mode 100644 index 5bc7d7e..0000000 --- a/configs/core/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -server: - port: 80 - host: "0.0.0.0" - -tls: - enabled: true # ← was false, now true - email: "den@2-4-h.net" # ← Let's Encrypt account email - -domain: "nextwks.eu" # Base domain for subdomain routing + certs diff --git a/configs/core/proxies.yaml b/configs/core/proxies.yaml deleted file mode 100644 index dd8a413..0000000 --- a/configs/core/proxies.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# Subdomain → registered app mapping -# app : app handler name (reserved) -# future: mail, drive, calendar, etc. -apps: - app: core diff --git a/deploy.sh b/deploy.sh index 668bfc1..dfdc9a9 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,7 +2,7 @@ set -euo pipefail REPO_DIR="/opt/NextWks" -TARGET_DIR="/opt/workspace" +TARGET_DIR="/opt/nextworkspace" SERVICE_NAME="nextworkspace" BINARY_NAME="nextworkspace" HEALTH_CHECK_RETRIES=10 @@ -35,23 +35,32 @@ if [ "$GREENFIELD" = true ]; then rm -rf "$TARGET_DIR" echo "[4/8] Creating target directories..." - mkdir -p "$TARGET_DIR/configs/core/certs" + mkdir -p "$TARGET_DIR/config/nextworkspace" + mkdir -p "$TARGET_DIR/config/zoraxy/proxy" + mkdir -p "$TARGET_DIR/data/zoraxy" + mkdir -p "$TARGET_DIR/compose" + mkdir -p "$TARGET_DIR/logs/zoraxy" echo "[5/8] Copying binary..." cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" echo "[6/8] Copying config files..." - cp configs/core/config.yaml "$TARGET_DIR/configs/core/config.yaml" - cp configs/core/proxies.yaml "$TARGET_DIR/configs/core/proxies.yaml" + cp -r config/nextworkspace/* "$TARGET_DIR/config/nextworkspace/" + cp -r config/zoraxy/* "$TARGET_DIR/config/zoraxy/" + cp compose/zoraxy.yaml "$TARGET_DIR/compose/zoraxy.yaml" - echo "[7/8] Writing systemd service..." + echo "[7/8] Deploying Zoraxy..." + podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || echo "[WARN] Zoraxy deploy had issues (see above)" + + echo "[8/8] Writing systemd service and starting launcher..." + mkdir -p "$TARGET_DIR" cat > /etc/systemd/system/$SERVICE_NAME.service </dev/null || true echo "[4/8] Swapping binary..." cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" echo "[5/8] Refreshing configs..." - cp configs/core/config.yaml "$TARGET_DIR/configs/core/config.yaml" - cp configs/core/proxies.yaml "$TARGET_DIR/configs/core/proxies.yaml" + if [ -f config/nextworkspace/apps.yaml ]; then + cp config/nextworkspace/apps.yaml "$TARGET_DIR/config/nextworkspace/apps.yaml" + fi + if [ -d config/zoraxy/proxy ]; then + cp config/zoraxy/proxy/* "$TARGET_DIR/config/zoraxy/proxy/" 2>/dev/null || true + fi - echo "[6/8] Restarting service..." + echo "[6/8] Restarting Zoraxy..." + podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" restart 2>&1 || true + + echo "[7/8] Starting launcher..." systemctl restart $SERVICE_NAME fi -# --- Health check (both modes) --- +# --- Health check --- echo "[*] Running health check..." for i in $(seq 1 $HEALTH_CHECK_RETRIES); do - if curl -sf http://localhost:80/ > /dev/null 2>&1; then - echo "[OK] NextWorkspace is serving on http://localhost:80/" + if curl -sf http://127.0.0.1:9000/health > /dev/null 2>&1; then + echo "[OK] NextWorkspace launcher is healthy on http://127.0.0.1:9000/" exit 0 fi echo " Attempt $i/$HEALTH_CHECK_RETRIES — not ready yet..." sleep $HEALTH_CHECK_INTERVAL done -echo "[FAIL] Health check failed — service did not respond on port 80" +echo "[FAIL] Health check failed — launcher did not respond on port 9000" exit 1 diff --git a/go.mod b/go.mod index e8e0c7d..382f199 100644 --- a/go.mod +++ b/go.mod @@ -2,26 +2,4 @@ module nextworkspace go 1.25.0 -require ( - github.com/caddyserver/certmagic v0.25.4 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/caddyserver/zerossl v0.1.5 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/libdns/libdns v1.1.1 // indirect - github.com/mholt/acmez/v3 v3.1.6 // indirect - github.com/miekg/dns v1.1.72 // indirect - github.com/zeebo/blake3 v0.2.4 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect - go.uber.org/zap/exp v0.3.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect -) +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 5891461..a62c313 100644 --- a/go.sum +++ b/go.sum @@ -1,59 +1,3 @@ -code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= -code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= -github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg= -github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= -github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= -github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= -github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= -github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= -github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= -github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= -github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= -github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= -github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= -github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= -github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= -github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= -github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= -go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 0be5c88..5274112 100644 --- a/main.go +++ b/main.go @@ -1,121 +1,105 @@ package main import ( - "context" "fmt" + "html/template" "log" "net/http" "os" "path/filepath" - "strings" - "github.com/caddyserver/certmagic" "gopkg.in/yaml.v3" ) // --- Config types --- -type Config struct { - Server ServerConfig `yaml:"server"` - TLS TLSConfig `yaml:"tls"` - Domain string `yaml:"domain"` -} - type ServerConfig struct { Port int `yaml:"port"` Host string `yaml:"host"` } -type TLSConfig struct { - Enabled bool `yaml:"enabled"` - Email string `yaml:"email"` +type AppConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` } -type Proxies struct { - Apps map[string]string `yaml:"apps"` +type Config struct { + Server ServerConfig `yaml:"server"` + App AppConfig `yaml:"app"` +} + +type AppEntry struct { + Name string `yaml:"name"` + Subtitle string `yaml:"subtitle"` + URL string `yaml:"url"` + Icon string `yaml:"icon"` +} + +type AppsFile struct { + Apps []AppEntry `yaml:"apps"` } // --- Config loading --- -func loadConfig(configDir string) (*Config, *Proxies, error) { - configPath := filepath.Join(configDir, "config.yaml") - proxiesPath := filepath.Join(configDir, "proxies.yaml") - - configData, err := os.ReadFile(configPath) +func loadConfig(configDir string) (*Config, error) { + path := filepath.Join(configDir, "config.yaml") + data, err := os.ReadFile(path) if err != nil { - return nil, nil, fmt.Errorf("reading config: %w", err) + return nil, fmt.Errorf("reading config: %w", err) } - proxiesData, err := os.ReadFile(proxiesPath) - if err != nil { - return nil, nil, fmt.Errorf("reading proxies: %w", err) - } - var cfg Config - if err := yaml.Unmarshal(configData, &cfg); err != nil { - return nil, nil, fmt.Errorf("parsing config: %w", err) + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) } - var proxies Proxies - if err := yaml.Unmarshal(proxiesData, &proxies); err != nil { - return nil, nil, fmt.Errorf("parsing proxies: %w", err) - } - - return &cfg, &proxies, nil + return &cfg, nil } -// --- Subdomain extraction --- - -func extractSubdomain(host, domain string) string { - host = strings.ToLower(host) - - // Strip port if present - if idx := strings.LastIndex(host, ":"); idx != -1 { - host = host[:idx] +func loadApps(configDir string) ([]AppEntry, error) { + path := filepath.Join(configDir, "apps.yaml") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return []AppEntry{}, nil + } + return nil, fmt.Errorf("reading apps: %w", err) } - - // Bare domain — no subdomain (e.g. "nextwks.eu") - domainWithDot := "." + domain - if host == domain { - return "" + var appsFile AppsFile + if err := yaml.Unmarshal(data, &appsFile); err != nil { + return nil, fmt.Errorf("parsing apps: %w", err) } - if !strings.HasSuffix(host, domainWithDot) { - return "" - } - - return strings.TrimSuffix(host, domainWithDot) + return appsFile.Apps, nil } // --- Handlers --- -func coreAppHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") +func healthHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - fmt.Fprint(w, ` - - - - NextWorkspace — App - - -

NextWorkspace — Your Workspace, Your Server

-

Coming soon.

- -`) + fmt.Fprint(w, "OK") } -func unknownSubdomainHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusNotFound) - fmt.Fprint(w, ` - - - - Not Found - - -

Not Found

-

No application registered for this subdomain.

- -`) +func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc { + tmpl := template.Must(template.New("launcher").Parse(launcherHTML)) + + return func(w http.ResponseWriter, r *http.Request) { + user := r.Header.Get("X-Forwarded-User") + + data := struct { + AppName string + Description string + User string + LoggedIn bool + Apps []AppEntry + }{ + AppName: cfg.App.Name, + Description: cfg.App.Description, + User: user, + LoggedIn: user != "", + Apps: apps, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl.Execute(w, data) + } } // --- Main --- @@ -123,113 +107,141 @@ func unknownSubdomainHandler(w http.ResponseWriter, r *http.Request) { func main() { configDir := os.Getenv("CONFIG_DIR") if configDir == "" { - configDir = "/opt/workspace/configs/core" + configDir = "/opt/nextworkspace/config/nextworkspace" } - cfg, proxies, err := loadConfig(configDir) + cfg, err := loadConfig(configDir) if err != nil { log.Fatalf("Failed to load config: %v", err) } - // Build subdomain -> handler map - handlers := make(map[string]http.HandlerFunc) - for subdomain, appName := range proxies.Apps { - switch appName { - case "core": - handlers[subdomain] = coreAppHandler - default: - log.Printf("Warning: unknown app %q for subdomain %q", appName, subdomain) - handlers[subdomain] = unknownSubdomainHandler - } + apps, err := loadApps(configDir) + if err != nil { + log.Fatalf("Failed to load apps: %v", err) } - // Main router (for HTTPS or non-TLS mode) - router := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - subdomain := extractSubdomain(r.Host, cfg.Domain) - if handler, ok := handlers[subdomain]; ok { - handler(w, r) - return - } - unknownSubdomainHandler(w, r) - }) + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - httpAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + mux := http.NewServeMux() + mux.HandleFunc("/health", healthHandler) + mux.HandleFunc("/", launcherHandler(cfg, apps)) - if cfg.TLS.Enabled { - log.Println("NextWorkspace starting with TLS (certmagic)...") - - certmagic.DefaultACME.Agreed = true - certmagic.DefaultACME.Email = cfg.TLS.Email - certmagic.Default.Storage = &certmagic.FileStorage{ - Path: filepath.Join(configDir, "certs"), - } - - // Collect domains from proxies.yaml only (no bare domain, no wildcard) - // Each subdomain gets its own certificate independently. - var domains []string - for subdomain := range proxies.Apps { - domains = append(domains, subdomain+"."+cfg.Domain) - } - - // Obtain and manage certificates - magic := certmagic.NewDefault() - if err := magic.ManageSync(context.Background(), domains); err != nil { - log.Fatalf("Failed to manage certificates: %v", err) - } - - // Extract ACME issuer for http-01 challenge handling - var acmeIssuer *certmagic.ACMEIssuer - if len(magic.Issuers) > 0 { - acmeIssuer, _ = magic.Issuers[0].(*certmagic.ACMEIssuer) - } - - // HTTP handler on :80 - // - Known subdomains → redirect to HTTPS - // - Unknown subdomains → error directly on HTTP (no redirect to broken HTTPS) - // - ACME challenge paths → handled by certmagic before reaching us - var httpHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - subdomain := extractSubdomain(r.Host, cfg.Domain) - if _, ok := handlers[subdomain]; ok { - target := "https://" + r.Host + r.URL.RequestURI() - http.Redirect(w, r, target, http.StatusMovedPermanently) - return - } - unknownSubdomainHandler(w, r) - }) - - // Wrap with ACME challenge handler for Let's Encrypt http-01 validation - if acmeIssuer != nil { - httpHandler = acmeIssuer.HTTPChallengeHandler(httpHandler) - log.Println("ACME challenge handler enabled on :80") - } - - httpServer := &http.Server{ - Addr: httpAddr, - Handler: httpHandler, - } - - // HTTPS server on :443 with TLS - tlsConfig := magic.TLSConfig() - tlsConfig.NextProtos = append(tlsConfig.NextProtos, "h2") // HTTP/2 support - - httpsServer := &http.Server{ - Addr: ":443", - Handler: router, - TLSConfig: tlsConfig, - } - - // Start HTTP server - go func() { - log.Printf("HTTP listening on %s", httpAddr) - if err := httpServer.ListenAndServe(); err != nil { - log.Fatalf("HTTP server error: %v", err) - } - }() - - log.Println("HTTPS listening on :443") - log.Fatal(httpsServer.ListenAndServeTLS("", "")) // certmagic provides certs via TLSConfig - } else { - log.Printf("NextWorkspace listening on %s (no TLS)", httpAddr) - log.Fatal(http.ListenAndServe(httpAddr, router)) - } + log.Printf("NextWorkspace launcher listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) } + +const launcherHTML = ` + + + + + {{.AppName}} + + + +
+

{{.AppName}}

+

{{.Description}}

+
+ + {{if .LoggedIn}} +
+ Welcome, {{.User}} · Logout +
+ {{else}} + + {{end}} + + {{if .LoggedIn}} +
+ {{range .Apps}} + +
{{.Icon}}
+

{{.Name}}

+

{{.Subtitle}}

+
+ {{end}} +
+ {{end}} + +`