package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/http" "os" "os/signal" "syscall" "time" "forgejo.digital-droplets.de/philschlo/proxui/platform/config" "forgejo.digital-droplets.de/philschlo/proxui/platform/logging" _ "github.com/jackc/pgx/v5/stdlib" "proxui/backend/internal/auth" "proxui/backend/internal/authorization" "proxui/backend/internal/cluster" "proxui/backend/internal/clusteradmin" "proxui/backend/internal/encryption" "proxui/backend/internal/membership" "proxui/backend/internal/operator" "proxui/backend/internal/profile" "proxui/backend/internal/rbac" ) func main() { cfg, err := config.Load() if err != nil { logger := logging.New("backend", "unknown", 0) logger.Error("failed to load config", "error", err) os.Exit(1) } logger := logging.New("backend", cfg.AppEnv, cfg.LogLevel) jwtValidator, err := auth.NewValidator( cfg.SupabaseIssuer, cfg.SupabaseJWKSURL, auth.WithHMACSecret(cfg.SupabaseJWTSecret), ) if err != nil { logger.Error("failed to initialize jwt validator", "error", err) os.Exit(1) } authMiddleware := auth.NewMiddleware(jwtValidator) tokenCipher, err := encryption.NewFromBase64(cfg.MasterKeyBase64) if err != nil { logger.Error("failed to initialize cluster token encryption", "error", err) os.Exit(1) } _ = tokenCipher db, err := openDatabase(cfg.DatabaseURL) if err != nil { logger.Error("failed to connect database", "error", err) os.Exit(1) } defer db.Close() profileMiddleware := profile.NewMiddleware(profile.NewRepository(db), logger) membershipMiddleware := membership.NewMiddleware(membership.NewRepository(db), logger) authorizationMiddleware := authorization.NewMiddleware() clusterRepository := cluster.NewRepository(db, tokenCipher) clusterAdminHandler := clusteradmin.NewHandler(clusterRepository) operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok","service":"backend"}`)) }) meHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, _ := auth.PrincipalFromRequest(r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(principal) }) mux.Handle("GET /me", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(meHandler))) tenantMembershipHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tenantMembership, _ := membership.FromRequest(r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(tenantMembership) }) mux.Handle( "GET /tenants/{tenantID}/membership", authMiddleware.RequireAuth( profileMiddleware.EnsureProfile( membershipMiddleware.RequireTenantMembership( pathTenantID, authorizationMiddleware.Require(rbac.ActionProjectRead, tenantMembershipHandler), ), ), ), ) clusterManageChain := func(handler http.HandlerFunc) http.Handler { return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler)) } mux.Handle("POST /internal/clusters", clusterManageChain(clusterAdminHandler.CreateCluster)) mux.Handle("PUT /internal/clusters/{clusterID}", clusterManageChain(clusterAdminHandler.UpdateCluster)) mux.Handle("PATCH /internal/clusters/{clusterID}/status", clusterManageChain(clusterAdminHandler.SetClusterStatus)) server := &http.Server{ Addr: cfg.BackendAddr, Handler: withCORS(cfg.AppSiteURL, mux), ReadHeaderTimeout: 5 * time.Second, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { logger.Info("backend listening", "addr", cfg.BackendAddr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("backend failed", "error", err) os.Exit(1) } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { logger.Error("backend shutdown failed", "error", err) os.Exit(1) } } func pathTenantID(r *http.Request) (string, bool) { tenantID := r.PathValue("tenantID") return tenantID, tenantID != "" } func openDatabase(databaseURL string) (*sql.DB, error) { if databaseURL == "" { return nil, fmt.Errorf("DATABASE_URL is required") } db, err := sql.Open("pgx", databaseURL) if err != nil { return nil, err } if err := db.Ping(); err != nil { _ = db.Close() return nil, err } return db, nil } func withCORS(allowedOrigin string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" && origin == allowedOrigin { w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Vary", "Origin") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) }