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" ) // --- Config types --- type ServerConfig struct { Port int `yaml:"port"` Host string `yaml:"host"` } type AppConfig struct { Name string `yaml:"name"` Description string `yaml:"description"` } type Config struct { Server ServerConfig `yaml:"server"` App AppConfig `yaml:"app"` } type AppEntry struct { Name string `yaml:"name"` Subtitle string `yaml:"subtitle"` Path string `yaml:"path"` Upstream string `yaml:"upstream"` Icon string `yaml:"icon"` } 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) { path := filepath.Join(configDir, "config.yaml") data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } return &cfg, nil } 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) } var appsFile AppsFile if err := yaml.Unmarshal(data, &appsFile); err != nil { return nil, fmt.Errorf("parsing apps: %w", err) } 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) { w.WriteHeader(http.StatusOK) 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 Apps []AppEntry }{ AppName: cfg.App.Name, Description: cfg.App.Description, User: user, Apps: apps, } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, data) } } func proxyToUpstream(upstream string) http.HandlerFunc { target, err := url.Parse(upstream) if err != nil { log.Fatalf("Invalid upstream URL %q: %v", upstream, 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) } } // --- Templates --- const loginHTML = ` Login — NextWorkspace

NextWorkspace

Sign in to your workspace

{{if .Error}}
{{.Error}}
{{end}}
` const launcherHTML = ` {{.AppName}}

{{.AppName}}

{{.Description}}

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

{{.Name}}

{{.Subtitle}}

{{if .Upstream}}
{{else}}
{{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)) }