From 49ac348694072733a2bbd3418e67e461176c2bad Mon Sep 17 00:00:00 2001 From: cclohmar Date: Mon, 6 Jul 2026 20:58:33 +0100 Subject: [PATCH] feat: combined launcher + auth-proxy with path-based routing --- .../zoraxy/conf/proxy/app.nextwks.eu.config | 51 +-- .../zoraxy/conf/proxy/dns.nextwks.eu.config | 17 - config/zoraxy/conf/proxy/root.config | 39 -- deploy.sh | 205 ++++------- main.go | 332 ++++++++++++++---- tools/fix-zoraxy-sso/main.go | 68 ---- 6 files changed, 335 insertions(+), 377 deletions(-) delete mode 100644 config/zoraxy/conf/proxy/dns.nextwks.eu.config delete mode 100644 config/zoraxy/conf/proxy/root.config delete mode 100644 tools/fix-zoraxy-sso/main.go diff --git a/config/zoraxy/conf/proxy/app.nextwks.eu.config b/config/zoraxy/conf/proxy/app.nextwks.eu.config index 2c311e3..80f1fe4 100644 --- a/config/zoraxy/conf/proxy/app.nextwks.eu.config +++ b/config/zoraxy/conf/proxy/app.nextwks.eu.config @@ -1,49 +1,12 @@ { "ProxyType": 1, "RootOrMatchingDomain": "app.nextwks.eu", - "MatchingDomainAlias": [], - "ActiveOrigins": [ - { - "OriginIpOrDomain": "127.0.0.1:9000", - "RequireTLS": false, - "SkipCertValidations": false, - "SkipWebSocketOriginCheck": false, - "Weight": 1, - "MaxConn": 0, - "RespTimeout": 0 - } - ], - "InactiveOrigins": [], - "UseStickySession": false, - "UseActiveLoadBalance": false, + "ActiveOrigins": [{ + "OriginIpOrDomain": "127.0.0.1:9000", + "RequireTLS": false, + "Weight": 1, + "MaxConn": 0 + }], "Disabled": false, - "BypassGlobalTLS": false, - "VirtualDirectories": [], - "HeaderRewriteRules": { - "UserDefinedHeaders": null, - "RequestHostOverwrite": "", - "HSTSMaxAge": 0, - "EnablePermissionPolicyHeader": false, - "PermissionPolicy": null, - "DisableHopByHopHeaderRemoval": false - }, - "EnableWebsocketCustomHeaders": false, - "AuthenticationProvider": { - "AuthMethod": 4, - "BasicAuthCredentials": null, - "BasicAuthExceptionRules": null, - "BasicAuthGroupIDs": [], - "ForwardAuthURL": "", - "ForwardAuthResponseHeaders": [], - "ForwardAuthResponseClientHeaders": [], - "ForwardAuthRequestHeaders": [], - "ForwardAuthRequestExcludedCookies": [] - }, - "RequireRateLimit": false, - "RateLimit": 0, - "DisableUptimeMonitor": false, - "AccessFilterUUID": "", - "DefaultSiteOption": 0, - "DefaultSiteValue": "", - "Tags": [] + "AuthenticationProvider": {"AuthMethod": 0} } diff --git a/config/zoraxy/conf/proxy/dns.nextwks.eu.config b/config/zoraxy/conf/proxy/dns.nextwks.eu.config deleted file mode 100644 index 4421e8d..0000000 --- a/config/zoraxy/conf/proxy/dns.nextwks.eu.config +++ /dev/null @@ -1,17 +0,0 @@ -{ - "ProxyType": 1, - "RootOrMatchingDomain": "dns.nextwks.eu", - "ActiveOrigins": [ - { - "OriginIpOrDomain": "127.0.0.1:8000", - "RequireTLS": false, - "Weight": 1, - "MaxConn": 0 - } - ], - "Disabled": false, - "AuthenticationProvider": { - "AuthMethod": 0 - }, - "BypassGlobalTLS": false -} diff --git a/config/zoraxy/conf/proxy/root.config b/config/zoraxy/conf/proxy/root.config deleted file mode 100644 index 4cd6b22..0000000 --- a/config/zoraxy/conf/proxy/root.config +++ /dev/null @@ -1,39 +0,0 @@ -{ - "ProxyType": 1, - "RootOrMatchingDomain": "*", - "MatchingDomainAlias": [], - "ActiveOrigins": [], - "InactiveOrigins": [], - "UseStickySession": false, - "UseActiveLoadBalance": false, - "Disabled": false, - "BypassGlobalTLS": false, - "VirtualDirectories": [], - "HeaderRewriteRules": { - "UserDefinedHeaders": null, - "RequestHostOverwrite": "", - "HSTSMaxAge": 0, - "EnablePermissionPolicyHeader": false, - "PermissionPolicy": null, - "DisableHopByHopHeaderRemoval": false - }, - "EnableWebsocketCustomHeaders": false, - "AuthenticationProvider": { - "AuthMethod": 0, - "BasicAuthCredentials": null, - "BasicAuthExceptionRules": null, - "BasicAuthGroupIDs": [], - "ForwardAuthURL": "", - "ForwardAuthResponseHeaders": [], - "ForwardAuthResponseClientHeaders": [], - "ForwardAuthRequestHeaders": [], - "ForwardAuthRequestExcludedCookies": [] - }, - "RequireRateLimit": false, - "RateLimit": 0, - "DisableUptimeMonitor": false, - "AccessFilterUUID": "", - "DefaultSiteOption": 0, - "DefaultSiteValue": "", - "Tags": [] -} diff --git a/deploy.sh b/deploy.sh index efba771..2c0b1b0 100755 --- a/deploy.sh +++ b/deploy.sh @@ -33,35 +33,35 @@ fi # --- Common: pull + build --- cd "$REPO_DIR" -echo "[1/8] Pulling latest code..." +echo "[1/6] Pulling latest code..." git pull -echo "[2/8] Building binary..." +echo "[2/6] Building binary..." export PATH=$PATH:/usr/local/go/bin go build -o "$BINARY_NAME" . # --- Greenfield path --- if [ "$GREENFIELD" = true ]; then - echo "[3/8] Removing old deployment..." + echo "[3/6] Removing old deployment..." rm -rf "$TARGET_DIR" - echo "[4/8] Creating target directories..." + echo "[4/6] Creating target directories..." mkdir -p "$TARGET_DIR/config/nextworkspace" mkdir -p "$TARGET_DIR/config/zoraxy/conf/proxy" mkdir -p "$TARGET_DIR/data/zoraxy" mkdir -p "$TARGET_DIR/compose" mkdir -p "$TARGET_DIR/logs" - echo "[5/8] Copying binary..." + echo "[5/6] Copying binary..." cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" - echo "[6/8] Deploying Zoraxy..." + echo "[6/6] Deploying Zoraxy..." cp compose/zoraxy.yaml "$TARGET_DIR/compose/zoraxy.yaml" podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || echo "[WARN] Zoraxy deploy had issues (see above)" - # Generate Zoraxy proxy configs with actual domain - echo "[*] Generating Zoraxy proxy configs..." - cat > "$TARGET_DIR/config/zoraxy/conf/proxy/app.$DOMAIN.config" < "$TARGET_DIR/config/zoraxy/conf/proxy/app.$DOMAIN.config" < "$TARGET_DIR/config/zoraxy/conf/proxy/dns.$DOMAIN.config" < /dev/null 2>&1; then - break - fi - echo " Waiting for Zoraxy... ($i/15)" - sleep 2 - done - - # Fetch CSRF token AND session cookie from Zoraxy login page (follow redirect) - COOKIE_JAR="/tmp/zoraxy_cookies.txt" - rm -f "$COOKIE_JAR" - LOGIN_PAGE=$(curl -sL -c "$COOKIE_JAR" http://127.0.0.1:8000/) - CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP '(?<=&1) - else - echo "[OK] CSRF token acquired" - ADMIN_CREATE=$(curl -s -b "$COOKIE_JAR" -X POST "$ZORAXY_ADMIN_URL" \ - -H "X-CSRF-Token: $CSRF_TOKEN" \ - -d "username=${ADMIN_USERNAME:-master}" \ - -d "password=${ADMIN_PASSWORD:-9Aku7MfklZU9ldnZ}" 2>&1) - fi - - if echo "$ADMIN_CREATE" | grep -qi '"success"\|"ok"\|"User registered\|"registered'; then - echo "[OK] Zoraxy admin account created" - elif echo "$ADMIN_CREATE" | grep -qi '"error'; then - echo "[WARN] Admin account create issue: $ADMIN_CREATE" - else - echo "[INFO] Admin API response: $ADMIN_CREATE" - fi - - # --- Login to Zoraxy API for authenticated operations --- - echo "[*] Logging in to Zoraxy API..." - # Re-fetch CSRF token with fresh cookies for login - LOGIN_PAGE=$(curl -sL -c "$COOKIE_JAR" http://127.0.0.1:8000/) - CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP '(?<= /dev/null - - echo "[*] Obtaining certificates for app.${DOMAIN}..." - OBTAIN_RESULT=$(curl -s -b "$COOKIE_JAR" -X GET "http://127.0.0.1:8000/api/acme/obtainCert" \ - -G \ - -d "domains=app.${DOMAIN}" \ - -d "filename=app.${DOMAIN}" \ - -d "email=${TLS_EMAIL}" \ - -d "ca=Let's Encrypt" \ - -d "dns=false" 2>&1) - - if echo "$OBTAIN_RESULT" | grep -qi '"success"\|"ok"\|"installed'; then - echo "[OK] Let's Encrypt certificate obtained for app.${DOMAIN}" - else - echo "[INFO] Certificate result: $OBTAIN_RESULT" - echo "[INFO] This may fail in dev if DNS doesn't point here — auto-renew will retry." - fi - - echo "[*] Enabling auto-renew..." - curl -s -b "$COOKIE_JAR" -X POST "http://127.0.0.1:8000/api/acme/autoRenew/enable" \ - -d "enable=true" > /dev/null - - # --- Configure ZorxAuth SSO --- - echo "[*] Configuring ZorxAuth SSO..." - curl -s -b "$COOKIE_JAR" -X POST "http://127.0.0.1:8000/api/sso/zorxauth/provider" \ - -d "enable=true" \ - -d "name=NextWorkspace" \ - -d "admin=${ADMIN_USERNAME}" > /dev/null - - curl -s -b "$COOKIE_JAR" -X POST "http://127.0.0.1:8000/api/sso/zorxauth/gateway" \ - -d "redirect=https://app.${DOMAIN}/" > /dev/null - - echo "[OK] ZorxAuth SSO configured" - - # --- Fix SSO redirect URL in BoltDB (API may not persist it correctly) --- - echo "[*] Ensuring SSO redirect URL is set in BoltDB..." - go build -o /tmp/fix-zoraxy-sso ./tools/fix-zoraxy-sso/ - sudo /tmp/fix-zoraxy-sso /opt/nextworkspace/config/zoraxy/sys.db 2>&1 || echo "[WARN] SSO BoltDB fix failed (non-fatal)" - rm -f /tmp/fix-zoraxy-sso - - # --- Generate launcher apps.yaml with actual domain --- + # Generate launcher apps.yaml with path-based URLs + echo "[*] Generating apps.yaml..." cat > "$TARGET_DIR/config/nextworkspace/apps.yaml" <&1 || true sleep 2 - # Write systemd service and start launcher - echo "[7/8] Writing systemd service and starting launcher..." + # Write systemd service with EnvironmentFile for .env vars + echo "[*] Writing systemd service..." cat > /etc/systemd/system/$SERVICE_NAME.service </dev/null || true - echo "[4/8] Swapping binary..." + echo "[4/6] Swapping binary..." cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" - echo "[5/8] Refreshing configs..." + echo "[5/6] Refreshing configs..." if [ -f config/nextworkspace/apps.yaml ]; then cp config/nextworkspace/apps.yaml "$TARGET_DIR/config/nextworkspace/apps.yaml" fi @@ -248,10 +169,8 @@ else cp config/zoraxy/conf/proxy/* "$TARGET_DIR/config/zoraxy/conf/proxy/" 2>/dev/null || true fi - echo "[6/8] Restarting Zoraxy..." + echo "[6/6] Restarting Zoraxy and launcher..." podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" restart 2>&1 || true - - echo "[7/8] Starting launcher..." systemctl restart $SERVICE_NAME fi diff --git a/main.go b/main.go index 5274112..c4127d4 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,19 @@ package main import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "fmt" "html/template" "log" "net/http" + "net/http/httputil" + "net/url" "os" "path/filepath" + "strings" + "time" "gopkg.in/yaml.v3" ) @@ -31,7 +38,8 @@ type Config struct { type AppEntry struct { Name string `yaml:"name"` Subtitle string `yaml:"subtitle"` - URL string `yaml:"url"` + Path string `yaml:"path"` + Upstream string `yaml:"upstream"` Icon string `yaml:"icon"` } @@ -39,6 +47,46 @@ type AppsFile struct { Apps []AppEntry `yaml:"apps"` } +// --- Session key (derived from master password) --- + +var sessionKey []byte + +func initSessionKey(password string) { + hash := sha256.Sum256([]byte(password)) + sessionKey = hash[:] +} + +func createSessionToken(username string) string { + expires := time.Now().Add(24 * time.Hour).Unix() + data := fmt.Sprintf("%s:%d", username, expires) + mac := hmac.New(sha256.New, sessionKey) + mac.Write([]byte(data)) + sig := hex.EncodeToString(mac.Sum(nil)) + return hex.EncodeToString([]byte(data)) + "." + sig +} + +func validateSessionToken(token string) (string, bool) { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return "", false + } + data, err := hex.DecodeString(parts[0]) + if err != nil { + return "", false + } + mac := hmac.New(sha256.New, sessionKey) + mac.Write(data) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) { + return "", false + } + pieces := strings.SplitN(string(data), ":", 2) + if len(pieces) != 2 { + return "", false + } + return pieces[0], true +} + // --- Config loading --- func loadConfig(configDir string) (*Config, error) { @@ -70,6 +118,25 @@ func loadApps(configDir string) ([]AppEntry, error) { return appsFile.Apps, nil } +// --- Auth middleware --- + +func authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session") + if err != nil || cookie.Value == "" { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + username, valid := validateSessionToken(cookie.Value) + if !valid { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + r.Header.Set("X-Forwarded-User", username) + next(w, r) + } +} + // --- Handlers --- func healthHandler(w http.ResponseWriter, r *http.Request) { @@ -77,59 +144,157 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "OK") } +func loginFormHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.New("login").Parse(loginHTML)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl.Execute(w, nil) +} + +func loginAuthHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + adminUser := os.Getenv("ADMIN_USERNAME") + adminPass := os.Getenv("ADMIN_PASSWORD") + + if username == "" || password == "" || username != adminUser || password != adminPass { + tmpl := template.Must(template.New("login").Parse(loginHTML)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + tmpl.Execute(w, map[string]string{"Error": "Invalid username or password"}) + return + } + + token := createSessionToken(username) + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + }) + http.Redirect(w, r, "/home/", http.StatusFound) +} + +func logoutHandler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + http.Redirect(w, r, "/login", http.StatusFound) +} + 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 --- - -func main() { - configDir := os.Getenv("CONFIG_DIR") - if configDir == "" { - configDir = "/opt/nextworkspace/config/nextworkspace" - } - - cfg, err := loadConfig(configDir) +func proxyToUpstream(upstream string) http.HandlerFunc { + target, err := url.Parse(upstream) if err != nil { - log.Fatalf("Failed to load config: %v", err) + log.Fatalf("Invalid upstream URL %q: %v", upstream, err) } - - apps, err := loadApps(configDir) - if err != nil { - log.Fatalf("Failed to load apps: %v", err) + proxy := httputil.NewSingleHostReverseProxy(target) + return func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("X-Forwarded-User", r.Header.Get("X-Forwarded-User")) + proxy.ServeHTTP(w, r) } - - addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - - mux := http.NewServeMux() - mux.HandleFunc("/health", healthHandler) - mux.HandleFunc("/", launcherHandler(cfg, apps)) - - log.Printf("NextWorkspace launcher listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, mux)) } +// --- Templates --- + +const loginHTML = ` + + + + + Login — NextWorkspace + + + + + +` + const launcherHTML = ` @@ -149,6 +314,7 @@ const launcherHTML = ` color: #fff; padding: 2rem; text-align: center; + position: relative; } header h1 { font-size: 2rem; margin-bottom: 0.25rem; } header p { color: #a0aec0; font-size: 1rem; } @@ -197,21 +363,6 @@ const launcherHTML = ` } .card h3 { font-size: 1.1rem; margin-bottom: 0.25rem; } .card p { color: #718096; font-size: 0.85rem; } - .login-prompt { - text-align: center; - padding: 3rem 2rem; - } - .login-prompt h2 { margin-bottom: 1rem; } - .btn { - display: inline-block; - background: #1a1a2e; - color: #fff; - padding: 0.75rem 2rem; - border-radius: 8px; - text-decoration: none; - font-weight: 500; - } - .btn:hover { background: #2d3748; } @@ -219,29 +370,78 @@ const launcherHTML = `

{{.AppName}}

{{.Description}}

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

{{.Name}}

-

{{.Subtitle}}

-
+
+ Welcome, {{.User}} · Logout +
+ {{end}} + {{end}} +
` + +// --- Main --- + +func main() { + configDir := os.Getenv("CONFIG_DIR") + if configDir == "" { + configDir = "/opt/nextworkspace/config/nextworkspace" + } + + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminPassword == "" { + log.Fatal("ADMIN_PASSWORD environment variable is required") + } + initSessionKey(adminPassword) + + cfg, err := loadConfig(configDir) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + apps, err := loadApps(configDir) + if err != nil { + log.Fatalf("Failed to load apps: %v", err) + } + + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + + mux := http.NewServeMux() + + // Unprotected paths + mux.HandleFunc("/health", healthHandler) + mux.HandleFunc("/login", loginFormHandler) + mux.HandleFunc("/login/auth", loginAuthHandler) + + // Protected: launcher + mux.Handle("/home/", authMiddleware(launcherHandler(cfg, apps))) + mux.Handle("/home", authMiddleware(launcherHandler(cfg, apps))) + mux.HandleFunc("/home/logout", logoutHandler) + + // Protected: upstream app proxies + for _, app := range apps { + if app.Path != "" && app.Upstream != "" { + appPath := app.Path + proxyHandler := authMiddleware(proxyToUpstream(app.Upstream)) + mux.Handle(appPath+"/", proxyHandler) + mux.Handle(appPath, proxyHandler) + } + } + + // Default: redirect to launcher + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/home/", http.StatusFound) + }) + + log.Printf("NextWorkspace listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} diff --git a/tools/fix-zoraxy-sso/main.go b/tools/fix-zoraxy-sso/main.go deleted file mode 100644 index 9c00df7..0000000 --- a/tools/fix-zoraxy-sso/main.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - - "go.etcd.io/bbolt" -) - -func main() { - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "Usage: fix-zoraxy-sso \n") - os.Exit(1) - } - - dbPath := os.Args[1] - bucket := "zorxauth" - key := "options" - - db, err := bbolt.Open(dbPath, 0600, nil) - if err != nil { - log.Fatalf("Failed to open DB %s: %v", dbPath, err) - } - defer db.Close() - - err = db.Update(func(tx *bbolt.Tx) error { - b := tx.Bucket([]byte(bucket)) - if b == nil { - return fmt.Errorf("bucket %q not found", bucket) - } - - val := b.Get([]byte(key)) - if val == nil { - return fmt.Errorf("key %q not found in bucket %q", key, bucket) - } - - var opts map[string]interface{} - if err := json.Unmarshal(val, &opts); err != nil { - return fmt.Errorf("failed to parse JSON: %v", err) - } - - // Update the critical SSO fields - opts["sso_redirect_url"] = fmt.Sprintf("https://app.%s/", getDomain()) - opts["enable_auth_gateway"] = true - - updated, err := json.Marshal(opts) - if err != nil { - return fmt.Errorf("failed to marshal JSON: %v", err) - } - - return b.Put([]byte(key), updated) - }) - - if err != nil { - log.Fatalf("Update failed: %v", err) - } - - fmt.Println("[OK] ZorxAuth SSO config updated in BoltDB") -} - -func getDomain() string { - if d := os.Getenv("DOMAIN"); d != "" { - return d - } - return "nextwks.eu" -}