package app import ( "context" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "io" "net" "net/url" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" account "git.ipao.vip/rogee/creator-hub/internal/account" "git.ipao.vip/rogee/creator-hub/internal/controlplane/api" accountsapi "git.ipao.vip/rogee/creator-hub/internal/controlplane/api/accounts" creatorapi "git.ipao.vip/rogee/creator-hub/internal/controlplane/api/creator" environmentsapi "git.ipao.vip/rogee/creator-hub/internal/controlplane/api/environments" "git.ipao.vip/rogee/creator-hub/internal/controlplane/api/system" "git.ipao.vip/rogee/creator-hub/internal/controlplane/workers" "git.ipao.vip/rogee/creator-hub/internal/creator" "git.ipao.vip/rogee/creator-hub/internal/credentials" hub "git.ipao.vip/rogee/creator-hub/internal/environment" "git.ipao.vip/rogee/creator-hub/internal/taskstate" "github.com/gofiber/fiber/v3" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) type config struct { listenAddr, webDir, databaseURL, credentialStoreDir string username, password string aiAPIKey, aiBaseURL string credentialMasterKey []byte logLevel logrus.Level } func Execute() error { logrus.SetFormatter(&logrus.JSONFormatter{}) return execute(newCommand()) } func execute(command *cobra.Command) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return command.ExecuteContext(ctx) } func newCommand() *cobra.Command { command := &cobra.Command{ Use: "control-plane", Short: "Run the CreatorHub control plane", Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(command *cobra.Command, _ []string) error { cfg, err := loadConfig() if err != nil { return err } credentials, err := credentials.NewPersistentCredentialBridge(cfg.credentialStoreDir, cfg.credentialMasterKey) if err != nil { return err } logrus.SetLevel(cfg.logLevel) phaseAStore, err := account.Open(command.Context(), cfg.databaseURL) if err != nil { return err } defer phaseAStore.Close() hubStore, err := hub.Open(command.Context(), cfg.databaseURL) if err != nil { return err } defer hubStore.Close() creatorStore, err := creator.Open(command.Context(), cfg.databaseURL) if err != nil { return err } defer creatorStore.Close() creatorStore.SetSecretBridge(creatorSecretBridge{bridge: credentials}) notify := newAttentionNotifier(os.Stderr) phaseAStore.SetTaskNotifier(notify) hubStore.SetTaskNotifier(notify) logStartup(cfg) heartbeatContext, stopHeartbeat := context.WithCancel(command.Context()) heartbeatDone := make(chan struct{}) go func() { defer close(heartbeatDone) workers.RunRuntimeLeaseHeartbeat(heartbeatContext, hubStore) }() creatorScheduleContext, stopCreatorScheduler := context.WithCancel(command.Context()) creatorScheduleDone := make(chan struct{}) go func() { defer close(creatorScheduleDone) workers.RunCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore) }() creatorEventContext, stopCreatorEvents := context.WithCancel(command.Context()) creatorEventDone := make(chan struct{}) creatorEventExecutor := api.NewCreatorGatewayActionExecutor(creatorStore, phaseAStore, hubStore) creatorAI := &creator.ConfiguredBailian{Store: creatorStore, APIKey: cfg.aiAPIKey, BaseURL: cfg.aiBaseURL} go func() { defer close(creatorEventDone) workers.RunCreatorEventListeners(creatorEventContext, creatorStore, phaseAStore, hubStore, creatorEventExecutor, creatorAI) }() listenErr := newHandlerWithCreatorAndAI(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore, credentials, creatorStore, creatorAI, creatorAI).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) stopCreatorScheduler() stopCreatorEvents() stopHeartbeat() <-creatorScheduleDone <-creatorEventDone <-heartbeatDone return listenErr }, } return command } func newAttentionNotifier(output io.Writer) taskstate.Notifier { logger := logrus.New() logger.SetFormatter(&logrus.JSONFormatter{}) logger.SetOutput(output) return func(transition taskstate.Transition) { if transition.State != "policy_hold" && transition.State != "needs_confirmation" { return } logger.WithFields(logrus.Fields{ "service": "control-plane", "notification_channel": "structured_log", "event_type": transition.State, "reason_code": transition.ReasonCode, "account_id": transition.AccountID, "task_id": transition.TaskID, }).Warn("operator attention required") } } func runtimeLeaseHeartbeat(ctx context.Context, store *hub.Store) { workers.RunRuntimeLeaseHeartbeat(ctx, store) } func logStartup(cfg config) { logrus.WithFields(logrus.Fields{ "service": "control-plane", "listen_addr": cfg.listenAddr, }).Info("service starting") } func loadConfig() (config, error) { v := viper.New() v.SetDefault("listen_addr", ":8080") v.SetDefault("web_dir", "web/dist") v.SetDefault("database_url", "postgres://creatorhub@127.0.0.1:5432/creatorhub?sslmode=disable") v.SetDefault("credential_store_dir", "/var/lib/creatorhub/credentials") v.SetDefault("log_level", "info") _ = v.BindEnv("listen_addr", "LISTEN_ADDR") _ = v.BindEnv("web_dir", "WEB_DIR") _ = v.BindEnv("database_url", "DATABASE_URL") _ = v.BindEnv("credential_store_dir", "CREATORHUB_CREDENTIAL_STORE_DIR") _ = v.BindEnv("credential_master_key", "CREATORHUB_CREDENTIAL_MASTER_KEY") _ = v.BindEnv("log_level", "LOG_LEVEL") _ = v.BindEnv("username", "CONTROL_PLANE_USERNAME") _ = v.BindEnv("password", "CONTROL_PLANE_PASSWORD") _ = v.BindEnv("ai_api_key", "BAILIAN_API_KEY") _ = v.BindEnv("ai_base_url", "BAILIAN_BASE_URL") level, err := logrus.ParseLevel(v.GetString("log_level")) if err != nil { return config{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace") } cfg := config{ listenAddr: strings.TrimSpace(v.GetString("listen_addr")), webDir: strings.TrimSpace(v.GetString("web_dir")), databaseURL: strings.TrimSpace(v.GetString("database_url")), credentialStoreDir: strings.TrimSpace(v.GetString("credential_store_dir")), username: strings.TrimSpace(v.GetString("username")), password: v.GetString("password"), aiAPIKey: strings.TrimSpace(v.GetString("ai_api_key")), aiBaseURL: strings.TrimSpace(v.GetString("ai_base_url")), logLevel: level, } if cfg.listenAddr == "" { return config{}, errors.New("LISTEN_ADDR must not be empty") } if err := validateListenAddr(cfg.listenAddr); err != nil { return config{}, err } if cfg.webDir == "" { return config{}, errors.New("WEB_DIR must not be empty") } if cfg.username == "" || strings.Contains(cfg.username, ":") { return config{}, errors.New("CONTROL_PLANE_USERNAME must be non-empty and contain no colon") } if len(cfg.password) < 6 { return config{}, errors.New("CONTROL_PLANE_PASSWORD must contain at least 6 bytes") } if !filepath.IsAbs(cfg.credentialStoreDir) { return config{}, errors.New("CREATORHUB_CREDENTIAL_STORE_DIR must be absolute") } cfg.credentialMasterKey, err = base64.StdEncoding.DecodeString(strings.TrimSpace(v.GetString("credential_master_key"))) if err != nil || len(cfg.credentialMasterKey) != 32 { return config{}, errors.New("CREATORHUB_CREDENTIAL_MASTER_KEY must be base64 for exactly 32 bytes") } databaseURL, err := url.Parse(cfg.databaseURL) if err != nil || databaseURL.Host == "" || databaseURL.Path == "" || databaseURL.Path == "/" || (databaseURL.Scheme != "postgres" && databaseURL.Scheme != "postgresql") { return config{}, errors.New("DATABASE_URL must be a postgres URL with a host") } if cfg.aiBaseURL != "" { aiURL, parseErr := url.Parse(cfg.aiBaseURL) if parseErr != nil || aiURL.Host == "" || (aiURL.Scheme != "http" && aiURL.Scheme != "https") || aiURL.User != nil { return config{}, errors.New("BAILIAN_BASE_URL must be an HTTP(S) URL without credentials") } } return cfg, nil } func validateListenAddr(addr string) error { _, port, err := net.SplitHostPort(addr) if err != nil { return errors.New("LISTEN_ADDR must be a host:port address") } number, err := strconv.Atoi(port) if err != nil || number < 1 || number > 65535 { return errors.New("LISTEN_ADDR port must be 1..65535") } return nil } func newHandler(webDirectory, username, password string) *fiber.App { return newHandlerWithStores(webDirectory, username, password, nil, nil) } func newHandlerWithStores(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store) *fiber.App { return newHandlerWithCredentialBridge(webDirectory, username, password, phaseAStore, hubStore, nil) } type creatorSecretBridge struct { bridge account.CredentialBridge } func (b creatorSecretBridge) Store(ctx context.Context, reference creator.SecretReference, key, value string) error { if b.bridge == nil { return errors.New("credential bridge is unavailable") } return b.bridge.Store(ctx, account.CredentialReference{ID: reference.ID, Provider: reference.Provider}, key, value) } func (b creatorSecretBridge) Delete(ctx context.Context, reference creator.SecretReference, key string) error { if b.bridge == nil { return errors.New("credential bridge is unavailable") } return b.bridge.Delete(ctx, account.CredentialReference{ID: reference.ID, Provider: reference.Provider}, key) } func newHandlerWithCredentialBridge(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store, credentials account.CredentialBridge) *fiber.App { return newHandlerWithCreator(webDirectory, username, password, phaseAStore, hubStore, credentials, nil) } func newHandlerWithCreator(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store, credentials account.CredentialBridge, creatorStore *creator.Store) *fiber.App { return newHandlerWithCreatorAndAI(webDirectory, username, password, phaseAStore, hubStore, credentials, creatorStore, nil, nil) } func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store, credentials account.CredentialBridge, creatorStore *creator.Store, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) *fiber.App { app := fiber.New(fiber.Config{ AppName: "CreatorHub control plane", BodyLimit: 1 << 20, // 读超时只约束请求读取;创建/升级环境的处理器可等待 native gateway 就绪(最长 11 分钟)。 ReadTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, }) system.RegisterHealth(app, phaseAStore, hubStore, creatorStore) app.Use(system.Authenticate(username, password)) if hubStore != nil { environmentsapi.Register(app, hubStore) } if phaseAStore != nil { accountsapi.Register(app, phaseAStore, hubStore, credentials) } if creatorStore != nil { if generator == nil && analyzer == nil { creatorapi.Register(app, creatorStore, phaseAStore, hubStore) } else { creatorapi.RegisterWithAI(app, creatorStore, phaseAStore, hubStore, generator, analyzer) } if phaseAStore != nil && hubStore != nil { accountsapi.RegisterDeletion(app, phaseAStore, hubStore, creatorStore, credentials) } } app.Use(func(c fiber.Ctx) error { if system.IsAPIPath(c.Path()) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "not found"}) } return c.Next() }) app.Get("/*", system.SPAHandler(webDirectory)) return app } func isControlPlaneAPIPath(path string) bool { for _, prefix := range []string{"/api", "/phase-a", "/gateways", "/browsers", "/network-exits"} { if path == prefix || strings.HasPrefix(path, prefix+"/") { return true } } return false } func authenticate(username, password string) fiber.Handler { wantUser, wantPassword := sha256.Sum256([]byte(username)), sha256.Sum256([]byte(password)) return func(c fiber.Ctx) error { encoded, ok := strings.CutPrefix(c.Get(fiber.HeaderAuthorization), "Basic ") decoded, err := base64.StdEncoding.DecodeString(encoded) user, suppliedPassword, found := strings.Cut(string(decoded), ":") gotUser, gotPassword := sha256.Sum256([]byte(user)), sha256.Sum256([]byte(suppliedPassword)) userOK := subtle.ConstantTimeCompare(gotUser[:], wantUser[:]) passwordOK := subtle.ConstantTimeCompare(gotPassword[:], wantPassword[:]) if !ok || err != nil || !found || userOK != 1 || passwordOK != 1 { c.Set(fiber.HeaderWWWAuthenticate, `Basic realm="CreatorHub", charset="UTF-8"`) return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "authentication required"}) } return c.Next() } } func spaHandler(directory string) fiber.Handler { return func(c fiber.Ctx) error { path, err := url.PathUnescape(strings.TrimPrefix(c.Path(), "/")) path = filepath.Clean(path) if err == nil && filepath.IsLocal(path) { name := filepath.Join(directory, path) if info, err := os.Stat(name); err == nil && !info.IsDir() { return c.SendFile(name) } } return c.SendFile(filepath.Join(directory, "index.html")) } } func registerPhaseA(appInstance *fiber.App, store *account.Store, runtimeStore api.RuntimeStopStore, credentials account.CredentialBridge) { api.RegisterAccounts(appInstance, store, runtimeStore, credentials) } func registerHubWithNetwork(appInstance *fiber.App, store api.HubStore, probe api.NetworkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) { api.RegisterHubWithNetwork(appInstance, store, probe, resolve) } func phaseAError(c fiber.Ctx, err error) error { return api.PhaseAError(c, err) }