package hub import ( "context" "crypto/rand" "database/sql" _ "embed" "encoding/hex" "encoding/json" "errors" "fmt" "net/url" "regexp" "sort" "strings" "time" "unicode/utf8" "git.ipao.vip/rogee/creator-hub/internal/taskstate" "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" ) //go:embed migrations/002_hub.sql var migration002 string //go:embed migrations/003_unified_accounts.sql var migration003 string //go:embed migrations/004_environment_actions.sql var migration004 string //go:embed migrations/005_sanitize_legacy_proxy.sql var migration005 string //go:embed migrations/006_runtime_cleanup.sql var migration006 string //go:embed migrations/007_runtime_binding_version.sql var migration007 string //go:embed migrations/008_runtime_cleanup_generation.sql var migration008 string //go:embed migrations/009_runtime_cleanup_compatibility.sql var migration009 string //go:embed migrations/010_runtime_network_generation.sql var migration010 string //go:embed migrations/011_task_recovery.sql var migration011 string //go:embed migrations/012_task_recovery_compatibility.sql var migration012 string //go:embed migrations/013_account_creation.sql var migration013 string //go:embed migrations/014_account_creation_compatibility.sql var migration014 string //go:embed migrations/015_gateway_rename_cascade.sql var migration015 string //go:embed migrations/016_network_exit_plain_credentials.sql var migration016 string var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid hub input") ErrNotFound = errors.New("resource not found") ErrReconcileRequired = errors.New("runtime cleanup generation is unknown; manual reconciliation required") ) func ValidImageVersion(version string) bool { return imageVersionPattern.MatchString(version) } func ValidNetworkExitID(id string) bool { return exitIDPattern.MatchString(id) } var ( aliasPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) gatewayNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) tokenPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$`) imageVersionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9.+~-]{0,63}$`) imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`) ) type Store struct { db *sql.DB lockAdmission chan struct{} notify taskstate.Notifier } // Gateway 是平台注册的 docker-gateway 实例;Token 由平台生成,明文存储供页面复制(开发阶段约定)。 type Gateway struct { Name string `json:"name"` Endpoint string `json:"endpoint"` Token string `json:"token"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Image 是页面维护的可用浏览器镜像版本。 type Image struct { Version string `json:"version"` ImageRef string `json:"image_ref"` Note string `json:"note"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Env 是一个浏览器环境;Fingerprint 为全量启动参数,创建后随请求整体下发网关。 type Env struct { Alias string `json:"alias"` Name string `json:"name"` Gateway string `json:"gateway"` ImageVersion string `json:"image_version"` Fingerprint Fingerprint `json:"fingerprint"` CreatedAt time.Time `json:"created_at"` } func Open(ctx context.Context, databaseURL string) (*Store, error) { db, err := sql.Open("pgx", databaseURL) if err != nil { return nil, errors.New("open hub database") } db.SetMaxOpenConns(10) db.SetMaxIdleConns(2) db.SetConnMaxIdleTime(5 * time.Minute) if err := db.PingContext(ctx); err != nil { db.Close() return nil, errors.New("connect to hub database") } // LockResources keeps one connection until the lifecycle operation finishes. // Admit at most half the pool so those operations can still open nested DB calls. store := &Store{db: db, lockAdmission: make(chan struct{}, 5)} if err := store.migrate(ctx); err != nil { db.Close() return nil, err } return store, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) SetTaskNotifier(notify taskstate.Notifier) { s.notify = notify } func (s *Store) notifyTransitions(transitions []taskstate.Transition) { if s.notify == nil { return } for _, transition := range transitions { s.notify(transition) } } // LockResources serializes lifecycle state across control-plane replicas. The // transaction carries no data changes; rolling it back only releases the locks. func (s *Store) LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error) { for _, alias := range aliases { if !aliasPattern.MatchString(alias) { return nil, ErrInvalid } } for _, id := range exitIDs { if !exitIDPattern.MatchString(id) { return nil, ErrInvalid } } for _, version := range imageVersions { if !imageVersionPattern.MatchString(version) { return nil, ErrInvalid } } if len(aliases)+len(exitIDs)+len(imageVersions) == 0 { return func() {}, nil } select { case s.lockAdmission <- struct{}{}: case <-ctx.Done(): return nil, ctx.Err() } tx, err := s.db.BeginTx(ctx, nil) if err != nil { <-s.lockAdmission return nil, errors.New("begin resource lock") } resources := []struct { namespace int keys []string }{{1542738013, aliases}, {1542738015, exitIDs}, {1542738014, imageVersions}} for _, resource := range resources { keys := append([]string(nil), resource.keys...) sort.Strings(keys) for _, key := range keys { if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1, hashtext($2))`, resource.namespace, key); err != nil { _ = tx.Rollback() <-s.lockAdmission return nil, errors.New("lock resource") } } } return func() { _ = tx.Rollback() <-s.lockAdmission }, nil } func (s *Store) migrate(ctx context.Context) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return errors.New("begin hub schema migration") } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1542738012)`); err != nil { return errors.New("lock hub schema migration") } if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { return errors.New("create schema migration table") } for _, migration := range []struct { version int sql string }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { return errors.New("read hub schema migration state") } if applied { continue } if _, err := tx.ExecContext(ctx, migration.sql); err != nil { return fmt.Errorf("apply hub schema migration %d: %w", migration.version, err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migration (version) VALUES ($1)`, migration.version); err != nil { return fmt.Errorf("record hub schema migration %d", migration.version) } } if err := tx.Commit(); err != nil { return errors.New("commit hub schema migration") } return nil } func (s *Store) CreateGateway(ctx context.Context, name, endpoint, token string) (Gateway, error) { name, endpoint, token = strings.TrimSpace(name), strings.TrimSpace(endpoint), strings.TrimSpace(token) if !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) { return Gateway{}, ErrInvalid } // 未显式提供 token 时由平台生成;提供则与网关进程的 GATEWAY_TOKEN 保持一致。 if token != "" { if !tokenPattern.MatchString(token) { return Gateway{}, ErrInvalid } } else { token = newToken() } gateway := Gateway{Name: name, Endpoint: endpoint, Token: token} err := s.db.QueryRowContext(ctx, ` INSERT INTO gateway (name, endpoint, token) VALUES ($1, $2, $3) RETURNING created_at, updated_at`, name, endpoint, gateway.Token). Scan(&gateway.CreatedAt, &gateway.UpdatedAt) if err != nil { return Gateway{}, publicDatabaseError(err) } return gateway, nil } // UpdateGateway 修改网关名称、Endpoint 和令牌。名称变更由数据库外键 ON UPDATE CASCADE // 原子同步 browser_env 引用;空令牌表示保留当前令牌,避免只改地址时意外轮换凭证。 func (s *Store) UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (Gateway, error) { currentName, name = strings.TrimSpace(currentName), strings.TrimSpace(name) endpoint, token = strings.TrimSpace(endpoint), strings.TrimSpace(token) if !gatewayNamePattern.MatchString(currentName) || !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) || (token != "" && !tokenPattern.MatchString(token)) { return Gateway{}, ErrInvalid } var gateway Gateway err := s.db.QueryRowContext(ctx, ` UPDATE gateway SET name = $1, endpoint = $2, token = CASE WHEN $3 = '' THEN token ELSE $3 END, updated_at = now() WHERE name = $4 RETURNING name, endpoint, token, created_at, updated_at`, name, endpoint, token, currentName). Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt) if err != nil { return Gateway{}, rowError(err) } return gateway, nil } func (s *Store) ListGateways(ctx context.Context) ([]Gateway, error) { rows, err := s.db.QueryContext(ctx, ` SELECT name, endpoint, token, created_at, updated_at FROM gateway ORDER BY created_at, name`) if err != nil { return nil, errors.New("read gateways") } defer rows.Close() gateways := []Gateway{} for rows.Next() { var gateway Gateway if err := rows.Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt); err != nil { return nil, errors.New("decode gateway") } gateways = append(gateways, gateway) } return gateways, rows.Err() } func (s *Store) GetGateway(ctx context.Context, name string) (Gateway, error) { var gateway Gateway if !gatewayNamePattern.MatchString(name) { return gateway, ErrInvalid } err := s.db.QueryRowContext(ctx, ` SELECT name, endpoint, token, created_at, updated_at FROM gateway WHERE name = $1`, name). Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt) if err != nil { return Gateway{}, rowError(err) } return gateway, nil } func (s *Store) DeleteGateway(ctx context.Context, name string) error { if !gatewayNamePattern.MatchString(name) { return ErrInvalid } var deleted string if err := s.db.QueryRowContext(ctx, `DELETE FROM gateway WHERE name = $1 RETURNING name`, name).Scan(&deleted); err != nil { return rowError(err) } return nil } func (s *Store) CreateImage(ctx context.Context, image Image) error { image.Version = strings.TrimSpace(image.Version) image.ImageRef = strings.TrimSpace(image.ImageRef) image.Note = strings.TrimSpace(image.Note) if !imageVersionPattern.MatchString(image.Version) || !imageRefPattern.MatchString(image.ImageRef) || utf8.RuneCountInString(image.Note) > 200 { return ErrInvalid } _, err := s.db.ExecContext(ctx, ` INSERT INTO browser_image (version, image_ref, note, enabled) VALUES ($1, $2, $3, $4)`, image.Version, image.ImageRef, image.Note, image.Enabled) return publicDatabaseError(err) } func (s *Store) UpdateImage(ctx context.Context, image Image) error { image.ImageRef = strings.TrimSpace(image.ImageRef) image.Note = strings.TrimSpace(image.Note) if !imageVersionPattern.MatchString(image.Version) || !imageRefPattern.MatchString(image.ImageRef) || utf8.RuneCountInString(image.Note) > 200 { return ErrInvalid } var updated string err := s.db.QueryRowContext(ctx, ` UPDATE browser_image SET image_ref = $2, note = $3, enabled = $4, updated_at = now() WHERE version = $1 RETURNING version`, image.Version, image.ImageRef, image.Note, image.Enabled). Scan(&updated) return rowError(err) } func (s *Store) ListImages(ctx context.Context, enabledOnly bool) ([]Image, error) { query := `SELECT version, image_ref, note, enabled, created_at, updated_at FROM browser_image` if enabledOnly { query += ` WHERE enabled` } query += ` ORDER BY created_at DESC, version` rows, err := s.db.QueryContext(ctx, query) if err != nil { return nil, errors.New("read browser images") } defer rows.Close() images := []Image{} for rows.Next() { var image Image if err := rows.Scan(&image.Version, &image.ImageRef, &image.Note, &image.Enabled, &image.CreatedAt, &image.UpdatedAt); err != nil { return nil, errors.New("decode browser image") } images = append(images, image) } return images, rows.Err() } func (s *Store) DeleteImage(ctx context.Context, version string) error { if !imageVersionPattern.MatchString(version) { return ErrInvalid } var deleted string if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_image WHERE version = $1 RETURNING version`, version).Scan(&deleted); err != nil { return rowError(err) } return nil } // ImageRef 返回可用(存在且启用)版本的镜像引用;缺失或禁用均视为不可用。 func (s *Store) ImageRef(ctx context.Context, version string) (string, error) { if !imageVersionPattern.MatchString(version) { return "", ErrInvalid } var ref string if err := s.db.QueryRowContext(ctx, `SELECT image_ref FROM browser_image WHERE version = $1 AND enabled`, version).Scan(&ref); err != nil { return "", rowError(err) } return ref, nil } func (s *Store) CreateEnv(ctx context.Context, env Env) error { env.Alias = strings.TrimSpace(env.Alias) env.Name = strings.TrimSpace(env.Name) if !aliasPattern.MatchString(env.Alias) || !validDisplayName(env.Name) || !gatewayNamePattern.MatchString(env.Gateway) || !imageVersionPattern.MatchString(env.ImageVersion) || env.Fingerprint.ProxyServer != "" { return ErrInvalid } if err := env.Fingerprint.Validate(); err != nil { return fmt.Errorf("%w: %s", ErrInvalid, err) } encoded, err := json.Marshal(env.Fingerprint) if err != nil { return ErrInvalid } var created string err = s.db.QueryRowContext(ctx, ` INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) SELECT $1, $2, $3, version, $5 FROM browser_image WHERE version = $4 AND enabled RETURNING alias`, env.Alias, env.Name, env.Gateway, env.ImageVersion, encoded).Scan(&created) return rowError(err) } func (s *Store) ListEnvs(ctx context.Context) ([]Env, error) { rows, err := s.db.QueryContext(ctx, ` SELECT alias, name, gateway_name, image_version, fingerprint, created_at FROM browser_env ORDER BY created_at, alias`) if err != nil { return nil, errors.New("read browser envs") } defer rows.Close() envs := []Env{} for rows.Next() { env, err := scanEnv(rows) if err != nil { return nil, err } envs = append(envs, env) } return envs, rows.Err() } func (s *Store) GetEnv(ctx context.Context, alias string) (Env, error) { if !aliasPattern.MatchString(alias) { return Env{}, ErrInvalid } rows, err := s.db.QueryContext(ctx, ` SELECT alias, name, gateway_name, image_version, fingerprint, created_at FROM browser_env WHERE alias = $1`, alias) if err != nil { return Env{}, errors.New("read browser env") } defer rows.Close() if !rows.Next() { return Env{}, ErrNotFound } env, err := scanEnv(rows) if err != nil { return Env{}, err } return env, rows.Err() } // UpgradeEnv 将环境切换到指定可用镜像版本;参数与卷不变,容器重建由控制面编排网关完成。 func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error { if !aliasPattern.MatchString(alias) || !imageVersionPattern.MatchString(version) { return ErrInvalid } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return errors.New("begin environment upgrade") } defer tx.Rollback() var cleanupPending bool if err := tx.QueryRowContext(ctx, ` SELECT runtime_cleanup_pending FROM environment_binding WHERE browser_env_alias = $1 FOR UPDATE`, alias).Scan(&cleanupPending); err != nil && !errors.Is(err, sql.ErrNoRows) { return publicDatabaseError(err) } if cleanupPending { return ErrConflict } var updated string if err := tx.QueryRowContext(ctx, ` UPDATE browser_env SET image_version = $2, version = version + 1 WHERE alias = $1 AND EXISTS (SELECT 1 FROM browser_image WHERE version = $2 AND enabled) RETURNING alias`, alias, version).Scan(&updated); err != nil { return rowError(err) } if _, err := tx.ExecContext(ctx, ` UPDATE environment_binding SET version = version + 1, updated_at = now() WHERE browser_env_alias = $1`, alias); err != nil { return errors.New("version upgraded environment binding") } rows, err := tx.QueryContext(ctx, ` WITH changed AS ( UPDATE social_account account SET status = 'paused', paused_at = now(), version = account.version + 1, updated_at = now() FROM environment_binding binding WHERE binding.browser_env_alias = $1 AND binding.account_id = account.id RETURNING account.id ), held AS ( UPDATE operation_task task SET state = CASE task.state WHEN 'executing' THEN 'needs_confirmation' ELSE 'policy_hold' END, hold_reason = CASE task.state WHEN 'executing' THEN 'task_result_uncertain' ELSE 'binding_version_changed' END, verification_result = NULL, verified_at = NULL, verified_by = NULL, lease_owner = NULL, lease_until = NULL, updated_at = now() FROM changed WHERE task.account_id = changed.id AND task.state IN ('queued', 'executing') RETURNING task.current_attempt_id, task.state, task.hold_reason, task.account_id, task.id ), finished AS ( UPDATE execution_attempt attempt SET finished_at = now(), outcome = 'uncertain' FROM held WHERE held.state = 'needs_confirmation' AND attempt.id = held.current_attempt_id AND attempt.finished_at IS NULL RETURNING attempt.id ) SELECT state, hold_reason, account_id, id FROM held`, alias) if err != nil { return errors.New("invalidate upgraded environment account") } transitions, err := scanTransitions(rows, "read upgraded environment tasks") if err != nil { return err } return s.commitTransitions(tx, transitions) } func (s *Store) DeleteEnv(ctx context.Context, alias string) error { if !aliasPattern.MatchString(alias) { return ErrInvalid } var deleted string if err := s.db.QueryRowContext(ctx, `DELETE FROM browser_env WHERE alias = $1 RETURNING alias`, alias).Scan(&deleted); err != nil { return rowError(err) } return nil } func scanEnv(rows *sql.Rows) (Env, error) { var env Env var encoded []byte if err := rows.Scan(&env.Alias, &env.Name, &env.Gateway, &env.ImageVersion, &encoded, &env.CreatedAt); err != nil { return Env{}, errors.New("decode browser env") } if len(encoded) > 0 { if err := json.Unmarshal(encoded, &env.Fingerprint); err != nil { return Env{}, errors.New("decode env fingerprint") } } env.Fingerprint.ProxyServer = "" env.Fingerprint.DisableNonProxiedUDP = false return env, nil } func validDisplayName(name string) bool { if name == "" || utf8.RuneCountInString(name) > 64 { return false } for _, r := range name { if r < 0x20 || r == 0x7f { return false } } return true } func validHTTPURL(raw string) bool { parsed, err := url.Parse(raw) return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https") } func newToken() string { var value [24]byte _, _ = rand.Read(value[:]) return hex.EncodeToString(value[:]) } func commitHub(tx *sql.Tx) error { if err := tx.Commit(); err != nil { return errors.New("commit hub transaction") } return nil } func (s *Store) commitTransitions(tx *sql.Tx, transitions []taskstate.Transition) error { if err := commitHub(tx); err != nil { return err } s.notifyTransitions(transitions) return nil } func scanTransitions(rows *sql.Rows, failure string) ([]taskstate.Transition, error) { defer rows.Close() var transitions []taskstate.Transition for rows.Next() { var transition taskstate.Transition if err := rows.Scan(&transition.State, &transition.ReasonCode, &transition.AccountID, &transition.TaskID); err != nil { return nil, errors.New(failure) } transitions = append(transitions, transition) } if err := rows.Err(); err != nil { return nil, errors.New(failure) } return transitions, nil } func rowError(err error) error { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } return publicDatabaseError(err) } func publicDatabaseError(err error) error { if err == nil { return nil } var postgresError *pgconn.PgError if errors.As(err, &postgresError) && (postgresError.Code == "23505" || postgresError.Code == "23503" || postgresError.Code == "23514") { return ErrConflict } return errors.New("hub persistence operation failed") }