diff --git a/deploy.sh b/deploy.sh index a2d2d96..dc8e2d8 100755 --- a/deploy.sh +++ b/deploy.sh @@ -42,10 +42,31 @@ cd "$REPO_DIR" echo "[1/6] Pulling latest code..." git pull -echo "[2/6] Building binary..." +echo "[2/6] Building binary and helper tool..." export PATH=$PATH:/usr/local/go/bin go build -o "$BINARY_NAME" . +# Build helper tool for cert management +TOOL_DIR="$REPO_DIR/tools/nextwks-tool" +TOOL_BIN="/tmp/nextwks-tool" +if [ -d "$TOOL_DIR" ]; then + cd "$TOOL_DIR" + go build -o "$TOOL_BIN" . 2>/dev/null && echo "[OK] Helper tool built" || echo "[WARN] Helper tool build failed" + cd "$REPO_DIR" +fi + +# --- Certificate management via helper tool --- +if [ -f "$TOOL_BIN" ]; then + echo "[*] Managing LE certificates..." + mkdir -p /opt/backup/certs + + "$TOOL_BIN" cert \ + --domains "app.${DOMAIN},dns.${DOMAIN},www.${DOMAIN}" \ + --email "${TLS_EMAIL:-admin@${DOMAIN}}" \ + --backup-dir /opt/backup/certs \ + --deploy-dir "${TARGET_DIR}/config/zoraxy/conf/certs" 2>&1 || true +fi + # --- Greenfield path --- if [ "$GREENFIELD" = true ]; then echo "[3/6] Removing old deployment..." @@ -224,6 +245,23 @@ ZORAXY_WWW # Copy www landing page cp -r config/www/* "$TARGET_DIR/config/zoraxy/www/html/" + # Generate auth subdomain config (handled by Zoraxy Auth on port 5489) + echo "[*] Generating auth subdomain config..." + cat > "$TARGET_DIR/config/zoraxy/conf/proxy/auth.$DOMAIN.config" </dev/null || true @@ -369,6 +407,16 @@ EOF echo " Enabling auto-renew..." zoraxy_post "/api/acme/autoRenew/enable" "enable=true" > /dev/null echo " [OK] LE auto-renew enabled" + + # Configure Zoraxy Auth gateway in BoltDB + echo " Configuring Zoraxy Auth gateway..." + SYS_DB="$TARGET_DIR/data/zoraxy/sys.db" + if [ -f "$SYS_DB" ] && [ -f "$TOOL_BIN" ]; then + "$TOOL_BIN" db \ + --db "$SYS_DB" \ + --set "zorxauth:options:{\"enable_auth_gateway\":true,\"sso_redirect_url\":\"https://app.${DOMAIN}/\"}" 2>&1 || true + echo " [OK] Zoraxy Auth gateway configured" + fi else echo " [WARN] Login failed: $LOGIN_RESULT" fi diff --git a/go.mod b/go.mod index e3555cf..3feb2e4 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,14 @@ module nextworkspace go 1.25.0 -require gopkg.in/yaml.v3 v3.0.1 +require ( + go.etcd.io/bbolt v1.5.0 + golang.org/x/crypto v0.53.0 + gopkg.in/yaml.v3 v3.0.1 +) require ( - go.etcd.io/bbolt v1.5.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 75f9ac3..24c9f07 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,21 @@ +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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= 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 4119ffe..a337c08 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,6 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "fmt" "html/template" "log" @@ -13,7 +10,6 @@ import ( "os" "path/filepath" "strings" - "time" "gopkg.in/yaml.v3" ) @@ -47,46 +43,6 @@ 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) { @@ -118,21 +74,16 @@ func loadApps(configDir string) ([]AppEntry, error) { return appsFile.Apps, nil } -// --- Auth middleware --- +// --- Auth middleware (trusts X-Forwarded-User from Zoraxy Auth) --- 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) + user := r.Header.Get("X-Forwarded-User") + if user == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - username, valid := validateSessionToken(cookie.Value) - if !valid { - http.Redirect(w, r, "/login", http.StatusFound) - return - } - r.Header.Set("X-Forwarded-User", username) + r.Header.Set("X-Auth-User", user) next(w, r) } } @@ -144,55 +95,6 @@ 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)) @@ -228,73 +130,6 @@ func proxyToUpstream(upstream string) http.HandlerFunc { // --- Templates --- -const loginHTML = ` - - - - - Login — NextWorkspace - - - -
-

NextWorkspace

-

Sign in to your workspace

- {{if .Error}}
{{.Error}}
{{end}} -
- - - - - -
-
- -` - const landingPageHTML = ` @@ -397,7 +232,6 @@ 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; } @@ -454,7 +288,7 @@ const launcherHTML = `

{{.Description}}

- Welcome, {{.User}} · Logout + Welcome, {{.User}} · Logout
{{range .Apps}} @@ -480,12 +314,6 @@ func main() { 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) @@ -500,27 +328,23 @@ func main() { mux := http.NewServeMux() - // Unprotected paths + // Public 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) + mux.Handle(app.Path+"/", proxyHandler) + mux.Handle(app.Path, proxyHandler) } } - // Default: serve landing page for www subdomain, redirect to launcher otherwise + // Default: serve landing page for www, redirect to launcher otherwise domain := os.Getenv("DOMAIN") mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.Host, "www.") || (domain != "" && r.Host == "www."+domain) { diff --git a/tools/nextwks-tool/main.go b/tools/nextwks-tool/main.go new file mode 100644 index 0000000..36c1958 --- /dev/null +++ b/tools/nextwks-tool/main.go @@ -0,0 +1,315 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "fmt" + "log" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "go.etcd.io/bbolt" + "golang.org/x/crypto/acme/autocert" +) + +func main() { + if len(os.Args) < 2 { + log.Fatalf("Usage: %s [flags]", os.Args[0]) + } + + switch os.Args[1] { + case "cert": + runCert(os.Args[2:]) + case "db": + runDB(os.Args[2:]) + default: + log.Fatalf("Unknown command: %s (use cert or db)", os.Args[1]) + } +} + +// --- Cert command --- + +func runCert(args []string) { + fs := flag.NewFlagSet("cert", flag.ExitOnError) + domainsStr := fs.String("domains", "", "Comma-separated domain list") + email := fs.String("email", "", "ACME email") + backupDir := fs.String("backup-dir", "/opt/backup/certs", "Backup directory for certs") + deployDir := fs.String("deploy-dir", "", "Optional deploy directory to copy certs to") + fs.Parse(args) + + if *domainsStr == "" || *email == "" { + log.Fatal("--domains and --email are required") + } + + domains := strings.Split(*domainsStr, ",") + for i := range domains { + domains[i] = strings.TrimSpace(domains[i]) + } + + if err := os.MkdirAll(*backupDir, 0755); err != nil { + log.Fatalf("Failed to create backup dir: %v", err) + } + + // Check if all domains have valid certs in backup + needIssue := false + for _, domain := range domains { + certFile := filepath.Join(*backupDir, domain, "fullchain.pem") + keyFile := filepath.Join(*backupDir, domain, "privkey.pem") + if !fileExists(certFile) || !fileExists(keyFile) { + needIssue = true + break + } + // Check expiry + if isCertExpired(certFile, 7*24*time.Hour) { + log.Printf("[INFO] Cert for %s expires soon or is invalid, reissuing", domain) + needIssue = true + break + } + } + + if needIssue { + log.Printf("[INFO] Requesting LE certificates for %v...", domains) + if err := obtainCerts(domains, *email, *backupDir); err != nil { + log.Printf("[WARN] LE cert issuance failed: %v", err) + log.Printf("[INFO] Generating self-signed fallback certs") + if err := generateSelfSigned(domains, *backupDir); err != nil { + log.Printf("[WARN] Self-signed fallback also failed: %v", err) + } + } + } else { + log.Printf("[OK] All certificates found in backup, deploying") + for _, domain := range domains { + certFile := filepath.Join(*backupDir, domain, "fullchain.pem") + expiry := getCertExpiry(certFile) + log.Printf(" %s — expires %s", domain, expiry.Format(time.RFC3339)) + } + } + + // Copy to deploy-dir if specified + if *deployDir != "" { + if err := os.MkdirAll(*deployDir, 0755); err != nil { + log.Printf("[WARN] Failed to create deploy dir: %v", err) + return + } + for _, domain := range domains { + srcCert := filepath.Join(*backupDir, domain, "fullchain.pem") + srcKey := filepath.Join(*backupDir, domain, "privkey.pem") + dstCert := filepath.Join(*deployDir, domain+".crt") + dstKey := filepath.Join(*deployDir, domain+".key") + + if fileExists(srcCert) && fileExists(srcKey) { + copyFile(srcCert, dstCert) + copyFile(srcKey, dstKey) + log.Printf("[OK] Deployed cert for %s", domain) + } + } + } +} + +func obtainCerts(domains []string, email, backupDir string) error { + m := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Email: email, + Cache: autocert.DirCache(backupDir), + } + + // Try to obtain certs by starting a temporary HTTP server for challenge + ln, err := net.Listen("tcp", ":80") + if err != nil { + // Port 80 busy — try to use the autocert client directly without HTTP server + log.Printf("[WARN] Port 80 not available (%v), trying direct ACME...", err) + return obtainCertsDirect(domains, email, backupDir) + } + defer ln.Close() + + // Serve HTTP-01 challenge handler + srv := &http.Server{ + Handler: m.HTTPHandler(nil), + Addr: ":80", + } + go srv.Serve(ln) + + // Give LE a moment to validate + time.Sleep(2 * time.Second) + + for _, domain := range domains { + hello := &tls.ClientHelloInfo{ + ServerName: domain, + } + cert, err := m.GetCertificate(hello) + if err != nil { + log.Printf("[WARN] Failed to get cert for %s: %v", domain, err) + continue + } + // Store the cert to backup + domainDir := filepath.Join(backupDir, domain) + os.MkdirAll(domainDir, 0755) + + for _, c := range cert.Certificate { + block := &pem.Block{Type: "CERTIFICATE", Bytes: c} + f, err := os.OpenFile(filepath.Join(domainDir, "fullchain.pem"), + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("writing cert: %w", err) + } + pem.Encode(f, block) + f.Close() + } + + // Extract and save private key + if key, ok := cert.PrivateKey.(*rsa.PrivateKey); ok { + keyBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)} + os.WriteFile(filepath.Join(domainDir, "privkey.pem"), + pem.EncodeToMemory(keyBlock), 0600) + } + log.Printf("[OK] Certificate obtained for %s", domain) + } + + return nil +} + +func obtainCertsDirect(domains []string, email, backupDir string) error { + // Direct ACME without port 80 — will likely fail but try anyway + // This is a simplified fallback + return fmt.Errorf("port 80 required for HTTP-01 challenge") +} + +func generateSelfSigned(domains []string, backupDir string) error { + for _, domain := range domains { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: domain}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + if len(domains) > 1 { + tmpl.DNSNames = domains + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return err + } + + domainDir := filepath.Join(backupDir, domain) + os.MkdirAll(domainDir, 0755) + + certFile := filepath.Join(domainDir, "fullchain.pem") + keyFile := filepath.Join(domainDir, "privkey.pem") + + f, _ := os.Create(certFile) + pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + f.Close() + + f, _ = os.Create(keyFile) + pem.Encode(f, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + f.Close() + + log.Printf("[INFO] Self-signed cert generated for %s", domain) + } + return nil +} + +// --- DB command --- + +func runDB(args []string) { + fs := flag.NewFlagSet("db", flag.ExitOnError) + dbPath := fs.String("db", "", "Path to BoltDB file") + set := fs.String("set", "", "bucket:key:json-value") + fs.Parse(args) + + if *dbPath == "" || *set == "" { + log.Fatal("--db and --set are required") + } + + parts := strings.SplitN(*set, ":", 3) + if len(parts) != 3 { + log.Fatalf("Invalid --set format. Use bucket:key:json-value") + } + bucket := parts[0] + key := parts[1] + value := parts[2] + + db, err := bbolt.Open(*dbPath, 0600, &bbolt.Options{Timeout: 1 * time.Second}) + if err != nil { + log.Fatalf("Failed to open BoltDB: %v", err) + } + defer db.Close() + + if err := db.Update(func(tx *bbolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(bucket)) + if err != nil { + return err + } + return b.Put([]byte(key), []byte(value)) + }); err != nil { + log.Fatalf("Failed to write to BoltDB: %v", err) + } + + log.Printf("[OK] Wrote %s:%s to %s", bucket, key, *dbPath) +} + +// --- Helpers --- + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func isCertExpired(certFile string, threshold time.Duration) bool { + data, err := os.ReadFile(certFile) + if err != nil { + return true + } + block, _ := pem.Decode(data) + if block == nil { + return true + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return true + } + return time.Now().Add(threshold).After(cert.NotAfter) +} + +func getCertExpiry(certFile string) time.Time { + data, err := os.ReadFile(certFile) + if err != nil { + return time.Time{} + } + block, _ := pem.Decode(data) + if block == nil { + return time.Time{} + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return time.Time{} + } + return cert.NotAfter +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +}