diff --git a/AGENTS.md b/AGENTS.md
index b52a4dc..3e6b2ab 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,11 +65,11 @@
- 当前业务范围与验收以 [docs/plan01.md](docs/plan01.md) 为准:竞品分析、账号与大小号响应、环境与代理、评论线索和私信;先完成抖音完整流程,再完成小红书。
- 与旧探索规划或现有功能冲突时,以上述需求及使用者最新确认为准。自动响应按策略和 UID 冷却执行;人工发送逐次确认,两者不得混淆。自有账号互动与私信采用事件监听,不用轮询或 Mock 冒充实际能力。
-- 上述内容是目标范围,不代表已经实现。平台能力缺口、新的业务歧义必须向使用者确认;禁止自行删减需求、隐藏失败或以未要求的通用框架扩大实现范围。技术栈保持不变。
+- 上述内容是目标范围,不代表已经实现。平台能力缺口、新的业务歧义必须向使用者确认;禁止自行删减需求、隐藏失败或以未要求的通用框架扩大实现范围。控制面继续使用 Go;经使用者确认,Docker/浏览器 gateway 改用 Python。
## 已批准的技术栈
-### Go 后端
+### Go 控制面
- 使用 Go 1.26、Fiber v3(HTTP 路由与服务生命周期)、Viper(配置)、Logrus(应用日志)、Cobra(可执行入口)。依赖版本由 `go.mod` 和 `go.sum` 精确锁定。
- 保留成熟的标准库集成,如反向代理和 Docker HTTP 客户端,不重复造轮子。`net/http` handler 跨越 Fiber 边界时,使用 Fiber 官方适配器。
@@ -78,6 +78,12 @@
- 每个服务只保留一个最小化的 Cobra 根命令。仅当存在真实的运维工作流需求时,才添加子命令、持久化 flag、代码生成器或补全。
- 未经评审的需求批准,不添加 ORM、Redis、任务框架或另一套 HTTP/配置/日志/CLI 技术栈。
+### Python Docker/浏览器 gateway
+
+- Docker/浏览器 gateway 使用 Python 3.12+;优先使用标准库 HTTP、Docker Engine Unix socket、socket/ssl/asyncio 与显式输入校验。只有真实浏览器会话需要时才使用已锁定的 Patchright/WebSocket 依赖。
+- gateway 继续是唯一挂载 Docker socket 的服务,只提供领域路由;Docker 生命周期、网络代际、代理、浏览器 CDP 和抖音页面内动作由 Python 实现,账号身份必须在每次写操作前核对。
+- Python 依赖必须写入锁定文件;不允许自动登录、任意 CDP、Cookie/验证码/密码回显或把不确定写结果转换为成功。
+
### React 前端
- 使用 React 19、Vite 8、Refine 与 shadcn/ui、Tailwind CSS。在 `web/package-lock.json` 中锁定精确的已安装版本。
@@ -93,6 +99,6 @@
- 开始任务前先定义完成标准。交付前依此验证,发现问题就修好再测,不把未完成的工作交回给使用者。只有确认完成,或遇到真正需要使用者介入的障碍时,才回报。
- 每个非平凡行为变更附带最小的回归测试,且该测试在无此变更时会失败。在信任与集成边界覆盖成功、校验、失败和兼容路径;单元测试覆盖率保证 65% 以上。
-- 后端变更必须通过 `go test ./...`、`go vet ./...`,并完成 `./cmd/control-plane` 和 `./cmd/docker-gateway` 双端构建;涉及并发、生命周期或共享状态的变更须运行 `go test -race ./...`。Docker 或 Compose 变更还须通过 `docker compose config --quiet`。
+- 控制面变更必须通过 `go test ./...`、`go vet ./...`,并构建 `./cmd/control-plane`;涉及并发、生命周期或共享状态的变更须运行 `go test -race ./...`。Python gateway 必须通过其非交互式单元测试与覆盖率检查,并完成 Compose 构建/健康检查。Docker 或 Compose 变更还须通过 `docker compose config --quiet`。
- 前端变更必须从 lockfile 安装、通过仓库的非交互式测试命令,并通过 `npm --prefix web run build`。主题、Layout、导航、资源动作或 data provider 的变更需要聚焦的交互覆盖,包括适用的错误与禁用状态。
- 除非 issue 明确批准契约变更,保持既有 API 与 Docker 生命周期行为不变。在 PR 中文档化任何状态码、载荷、配置、迁移、安全或重试方面的影响。
diff --git a/Dockerfile b/Dockerfile
index c540fef..feeccc3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,14 +11,18 @@ WORKDIR /src
COPY go.mod go.sum ./
COPY cmd/ ./cmd/
COPY internal/ ./internal/
-RUN CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags='-s -w' -o /out/control-plane ./cmd/control-plane \
- && CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags='-s -w' -o /out/docker-gateway ./cmd/docker-gateway
+RUN CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags='-s -w' -o /out/control-plane ./cmd/control-plane
-FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
-RUN addgroup -g 65532 app && adduser -D -u 65532 -G app app \
- && install -d -o app -g app -m 0700 /var/lib/creatorhub/credentials
+FROM python:3.13-alpine@sha256:7415fbc3c9e4979cc717d92377ab2bc7b2b4a2af1ac03cc52b5f3f88efedaf3a
+COPY requirements-gateway.lock /tmp/requirements-gateway.lock
+RUN apk add --no-cache ffmpeg \
+ && python -m pip install --no-cache-dir -r /tmp/requirements-gateway.lock \
+ && addgroup -g 65532 app && adduser -D -u 65532 -G app app \
+ && install -d -o app -g app -m 0700 /var/lib/creatorhub/credentials /var/lib/creatorhub/materials
WORKDIR /app
-COPY --from=go /out/control-plane /out/docker-gateway /app/
+COPY --from=go /out/control-plane /app/
+COPY cmd/__init__.py /app/cmd/__init__.py
+COPY cmd/docker_gateway /app/cmd/docker_gateway
COPY --from=web /src/web/dist /app/web
USER 65532:65532
ENV WEB_DIR=/app/web
diff --git a/cmd/__init__.py b/cmd/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/cmd/control-plane/creator.go b/cmd/control-plane/creator.go
index d38a7f0..a924704 100644
--- a/cmd/control-plane/creator.go
+++ b/cmd/control-plane/creator.go
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strconv"
+ "strings"
"time"
"git.ipao.vip/rogee/creator-hub/internal/creator"
@@ -21,7 +22,11 @@ import (
)
func registerCreator(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) {
- registerCreatorWithServices(app, store, phaseAStore, hubStore, credentials, nil, nil, nil)
+ var executor creator.ActionExecutor
+ if store != nil && phaseAStore != nil && hubStore != nil {
+ executor = creatorGatewayActionExecutor{store: store, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials}
+ }
+ registerCreatorWithServices(app, store, phaseAStore, hubStore, credentials, executor, nil, nil)
}
func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, executor creator.ActionExecutor, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) {
@@ -289,17 +294,8 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
}
return c.Status(status).JSON(item)
})
- app.Post("/api/creator/works/:id/material/step", func(c fiber.Ctx) error {
- var input struct {
- Step string `json:"step"`
- Status string `json:"status"`
- Reference string `json:"reference"`
- Reason string `json:"reason"`
- }
- if err := decodeCreator(c, &input); err != nil {
- return creatorError(c, err)
- }
- item, err := store.SetMaterialStep(c.Context(), c.Params("id"), input.Step, input.Status, input.Reference, input.Reason)
+ app.Post("/api/creator/works/:id/material/process", func(c fiber.Ctx) error {
+ item, err := processCreatorMaterial(c.Context(), store, c.Params("id"))
if err != nil {
return creatorError(c, err)
}
@@ -632,6 +628,160 @@ type creatorGatewayBrowser struct {
environment hub.EnvironmentContext
}
+type creatorGatewayActionExecutor struct {
+ store *creator.Store
+ phaseAStore *phasea.Store
+ hubStore *hub.Store
+ credentials phasea.CredentialBridge
+}
+
+func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, request creator.ActionRequest) (creator.ActionResult, error) {
+ if request.Platform != creator.PlatformDouyin || executor.store == nil || executor.phaseAStore == nil || executor.hubStore == nil {
+ return creator.ActionResult{}, creator.ErrUnavailable
+ }
+ profile, err := executor.store.GetAccountProfile(ctx, request.AccountID)
+ if err != nil {
+ return creator.ActionResult{}, err
+ }
+ account, err := executor.phaseAStore.GetAccount(ctx, request.AccountID)
+ if err != nil {
+ return creator.ActionResult{}, err
+ }
+ if profile.Platform != creator.PlatformDouyin || account.Platform != creator.PlatformDouyin || profile.PlatformAccountKey == "" || profile.PlatformAccountKey != account.PlatformAccountKey {
+ return creator.ActionResult{}, creator.ErrConflict
+ }
+ environment, err := executor.hubStore.GetEnvironmentContextForAccount(ctx, request.AccountID)
+ if err != nil {
+ return creator.ActionResult{}, err
+ }
+ gateway, err := executor.hubStore.GetGateway(ctx, environment.Gateway)
+ if err != nil {
+ return creator.ActionResult{}, err
+ }
+ browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
+ uid, identityErr := browser.Identity(ctx, profile.PlatformAccountKey)
+ if identityErr != nil {
+ resolver, ok := executor.credentials.(phasea.CredentialResolver)
+ if !ok {
+ return creator.ActionResult{}, creator.ErrUnavailable
+ }
+ rawCredential, resolveErr := executor.phaseAStore.ResolveAccountCredential(ctx, request.AccountID, resolver)
+ if resolveErr != nil {
+ return creator.ActionResult{}, resolveErr
+ }
+ cookies, parseErr := douyin.ParseCredential(rawCredential)
+ if parseErr != nil {
+ return creator.ActionResult{}, fmt.Errorf("parse account credential: %w", parseErr)
+ }
+ if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
+ return creator.ActionResult{}, setErr
+ }
+ uid, identityErr = browser.Identity(ctx, profile.PlatformAccountKey)
+ if identityErr != nil {
+ return creator.ActionResult{}, fmt.Errorf("verify account identity after credential injection: %w", identityErr)
+ }
+ }
+ if _, verifyErr := executor.store.RecordVerifiedLoginResult(ctx, request.AccountID, uid); verifyErr != nil {
+ return creator.ActionResult{}, fmt.Errorf("persist verified account identity: %w", verifyErr)
+ }
+ payload := gatewayGenerationPayload(environment)
+ payload["expected_uid"] = uid
+ payload["action"] = request.Action
+ payload["target_uid"] = request.TargetUID
+ // UI and persistence use opaque internal IDs; the platform gateway receives only
+ // the verified platform keys and the comment's owning work key.
+ if request.TargetCommentID != "" {
+ comment, targetErr := executor.store.GetComment(ctx, request.TargetCommentID)
+ if targetErr != nil {
+ return creator.ActionResult{}, targetErr
+ }
+ payload["target_comment_id"] = comment.CommentKey
+ if request.TargetWorkID == "" {
+ request.TargetWorkID = comment.WorkID
+ }
+ }
+ if request.TargetWorkID != "" {
+ work, targetErr := executor.store.GetWork(ctx, request.TargetWorkID)
+ if targetErr != nil {
+ return creator.ActionResult{}, targetErr
+ }
+ payload["target_work_id"] = work.WorkKey
+ }
+ payload["text"] = request.Text
+ payload["confirm"] = true
+ status, body, err := gatewayCall(ctx, gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(environment.Alias)+"/douyin/action", payload, 30*time.Second)
+ if err != nil {
+ return creator.ActionResult{}, err
+ }
+ if status != http.StatusOK {
+ return creator.ActionResult{State: "uncertain", Reason: fmt.Sprintf("gateway returned HTTP %d", status)}, nil
+ }
+ var response struct {
+ Status string `json:"status"`
+ Code string `json:"code"`
+ Action string `json:"action"`
+ Evidence any `json:"evidence"`
+ }
+ if err := json.Unmarshal(body, &response); err != nil || (response.Status != "succeeded" && response.Status != "failed" && response.Status != "unknown") {
+ return creator.ActionResult{}, errors.New("gateway returned an invalid action result")
+ }
+ evidence := map[string]string{"gateway_status": response.Status}
+ if response.Action != "" {
+ evidence["action"] = response.Action
+ }
+ if response.Code != "" {
+ evidence["code"] = response.Code
+ }
+ evidenceCount := flattenActionEvidence(evidence, "evidence", response.Evidence)
+ state := response.Status
+ reason := response.Code
+ if state == "unknown" || state == "succeeded" && evidenceCount == 0 || state == "failed" && strings.TrimSpace(response.Code) == "" {
+ state = "uncertain"
+ if reason == "" {
+ reason = "写后确认证据不足"
+ }
+ }
+ return creator.ActionResult{State: state, Evidence: evidence, Reason: reason}, nil
+}
+
+func flattenActionEvidence(destination map[string]string, prefix string, value any) int {
+ switch typed := value.(type) {
+ case string:
+ if typed == "" {
+ return 0
+ }
+ destination[prefix] = typed
+ return 1
+ case map[string]any:
+ count := 0
+ for key, item := range typed {
+ count += flattenActionEvidence(destination, prefix+"."+key, item)
+ }
+ return count
+ default:
+ return 0
+ }
+}
+
+func (browser creatorGatewayBrowser) Identity(ctx context.Context, expectedKey string) (string, error) {
+ payload := gatewayGenerationPayload(browser.environment)
+ payload["expected_account_key"] = expectedKey
+ status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/identity", payload, 30*time.Second)
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", fmt.Errorf("douyin identity verification rejected with HTTP %d: %s", status, string(body))
+ }
+ var identity struct {
+ UID string `json:"uid"`
+ }
+ if err := json.Unmarshal(body, &identity); err != nil || identity.UID == "" {
+ return "", errors.New("douyin identity response omitted uid")
+ }
+ return identity.UID, nil
+}
+
func (browser creatorGatewayBrowser) SetCookies(ctx context.Context, cookies []douyin.Cookie) error {
payload := gatewayGenerationPayload(browser.environment)
payload["cookies"] = cookies
@@ -675,7 +825,7 @@ func syncCreatorCompetitorDue(ctx context.Context, store *creator.Store, phaseAS
}
func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, competitorID, accountID string, force bool) (creator.CollectionReport, error) {
- if store == nil || phaseAStore == nil || hubStore == nil || credentials == nil || accountID == "" {
+ if store == nil || phaseAStore == nil || hubStore == nil || accountID == "" {
return creator.CollectionReport{}, creator.ErrUnavailable
}
competitor, err := store.GetCompetitor(ctx, competitorID)
@@ -687,7 +837,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
return creator.CollectionReport{}, err
}
now := time.Now().UTC()
- claimed, err := store.ClaimCompetitorSync(ctx, competitorID, force, now)
+ leaseToken, claimed, err := store.ClaimCompetitorSync(ctx, competitorID, force, now)
if err != nil {
return creator.CollectionReport{}, err
}
@@ -695,7 +845,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
return creator.CollectionReport{}, creator.ErrConflict
}
blocked := func(blockErr error) (creator.CollectionReport, error) {
- markErr := store.MarkCompetitorSync(ctx, competitorID, "blocked", "", blockErr.Error(), nil)
+ markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "blocked", "", blockErr.Error(), nil)
return creator.CollectionReport{}, errors.Join(blockErr, markErr)
}
if competitor.Platform != creator.PlatformDouyin {
@@ -708,23 +858,12 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" {
return blocked(creator.ErrConflict)
}
- if _, err := store.GetAccountProfile(ctx, accountID); err != nil {
+ profile, err := store.GetAccountProfile(ctx, accountID)
+ if err != nil {
return blocked(err)
}
- resolver, ok := credentials.(phasea.CredentialResolver)
- if !ok {
- return blocked(creator.ErrUnavailable)
- }
- rawCredential, err := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
- if err != nil {
- return blocked(fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, err))
- }
- cookies, err := douyin.ParseCookieHeader(rawCredential)
- if err != nil {
- cookies, err = douyin.ParseCookieBundle(rawCredential)
- }
- if err != nil {
- return blocked(fmt.Errorf("%w: invalid account cookie bundle", creator.ErrConflict))
+ if profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
+ return blocked(creator.ErrConflict)
}
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
if err != nil {
@@ -738,16 +877,44 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
return blocked(fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err))
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
- if err := browser.SetCookies(ctx, cookies); err != nil {
- return blocked(fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, err))
+ if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
+ resolver, ok := credentials.(phasea.CredentialResolver)
+ if !ok {
+ return blocked(creator.ErrUnavailable)
+ }
+ rawCredential, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
+ if resolveErr != nil {
+ return blocked(fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr))
+ }
+ cookies, parseErr := douyin.ParseCredential(rawCredential)
+ if parseErr != nil {
+ return blocked(fmt.Errorf("%w: invalid account credential", creator.ErrConflict))
+ }
+ if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
+ return blocked(fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr))
+ }
+ if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil {
+ return blocked(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr))
+ }
}
collector := douyin.CreatorCollector{Browser: browser, AccountKey: competitor.PlatformAccountKey, SourceType: creator.SourceCompetitor, SourceID: competitor.ID}
- if err := collector.VerifyIdentity(ctx, account.PlatformAccountKey); err != nil {
+ canonicalSecUID, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey)
+ if err != nil {
return blocked(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err))
}
- report, collectErr := store.CollectSource(ctx, competitor.Platform, creator.SourceCompetitor, competitor.ID, collector, now)
+ collector.AccountKey = canonicalSecUID
+ collectionNow := now
+ if competitor.NextSyncAt != nil && !competitor.NextSyncAt.After(now) {
+ collectionNow = competitor.NextSyncAt.UTC()
+ }
+ report, collectErr := store.CollectSource(ctx, competitor.Platform, creator.SourceCompetitor, competitor.ID, collector, collectionNow)
if collectErr != nil {
- status, next := "failed", now.Add(time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
+ nextBase := now
+ if competitor.NextSyncAt != nil {
+ nextBase = competitor.NextSyncAt.UTC()
+ }
+ next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
+ status := "failed"
if errors.Is(collectErr, creator.ErrUnavailable) || errors.Is(collectErr, creator.ErrConflict) {
status, next = "blocked", time.Time{}
}
@@ -755,11 +922,15 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
if !next.IsZero() {
nextAt = &next
}
- markErr := store.MarkCompetitorSync(ctx, competitorID, status, "", collectErr.Error(), nextAt)
+ markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, status, "", collectErr.Error(), nextAt)
return report, errors.Join(collectErr, markErr)
}
- next := time.Now().UTC().Add(time.Duration(settings.NewWorkIntervalSeconds) * time.Second)
- if err := store.MarkCompetitorSync(ctx, competitorID, "idle", "", "", &next); err != nil {
+ nextBase := now
+ if competitor.NextSyncAt != nil {
+ nextBase = competitor.NextSyncAt.UTC()
+ }
+ next := creator.NextFixedRun(nextBase, time.Now().UTC(), time.Duration(settings.NewWorkIntervalSeconds)*time.Second)
+ if err := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "idle", "", "", &next); err != nil {
return report, err
}
return report, nil
@@ -781,7 +952,16 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto
for _, competitor := range competitors {
accountID, err := creatorCollectionAccount(ctx, store, phaseAStore, hubStore, competitor.Platform)
if err != nil {
- _ = store.MarkCompetitorSync(ctx, competitor.ID, "blocked", "", err.Error(), nil)
+ leaseToken, claimed, claimErr := store.ClaimCompetitorSync(ctx, competitor.ID, false, now)
+ if claimErr != nil {
+ logrus.WithError(claimErr).WithField("competitor_id", competitor.ID).Warn("creator competitor sync claim failed")
+ continue
+ }
+ if claimed {
+ if markErr := store.MarkCompetitorSync(ctx, competitor.ID, leaseToken, "blocked", "", err.Error(), nil); markErr != nil {
+ logrus.WithError(markErr).WithField("competitor_id", competitor.ID).Warn("creator competitor sync block update failed")
+ }
+ }
logrus.WithError(err).WithField("competitor_id", competitor.ID).Warn("creator competitor sync blocked")
continue
}
@@ -798,11 +978,113 @@ func runCreatorScheduleOnce(ctx context.Context, store *creator.Store, phaseASto
logrus.WithError(err).WithField("account_id", accountID).Warn("creator owned scheduled sync failed")
}
}
+ if err := runCreatorMetricScheduleOnce(ctx, store, phaseAStore, hubStore, credentials, now); err != nil {
+ return err
+ }
return nil
}
+func runCreatorMetricScheduleOnce(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, now time.Time) error {
+ settings, err := store.GetSettings(ctx)
+ if err != nil {
+ return err
+ }
+ works, err := store.ListDueMetricWorks(ctx, now)
+ if err != nil {
+ return err
+ }
+ for _, work := range works {
+ accountID := work.SourceID
+ if work.SourceType == creator.SourceCompetitor {
+ accountID, err = creatorCollectionAccount(ctx, store, phaseAStore, hubStore, work.Platform)
+ if err != nil {
+ logrus.WithError(err).WithField("work_id", work.ID).Warn("creator metric refresh account unavailable")
+ continue
+ }
+ }
+ if err := refreshCreatorMetricWork(ctx, store, phaseAStore, hubStore, credentials, work, accountID, settings, now); err != nil {
+ logrus.WithError(err).WithField("work_id", work.ID).Warn("creator metric refresh failed")
+ }
+ }
+ return nil
+}
+
+func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, work creator.Work, accountID string, settings creator.Settings, now time.Time) error {
+ account, err := phaseAStore.GetAccount(ctx, accountID)
+ if err != nil {
+ return err
+ }
+ profile, err := store.GetAccountProfile(ctx, accountID)
+ if err != nil {
+ return err
+ }
+ if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
+ return creator.ErrConflict
+ }
+ environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err)
+ }
+ gateway, err := hubStore.GetGateway(ctx, environment.Gateway)
+ if err != nil {
+ return fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err)
+ }
+ browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
+ if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
+ resolver, ok := credentials.(phasea.CredentialResolver)
+ if !ok {
+ return creator.ErrUnavailable
+ }
+ raw, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
+ if resolveErr != nil {
+ return fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr)
+ }
+ cookies, parseErr := douyin.ParseCredential(raw)
+ if parseErr != nil {
+ return fmt.Errorf("%w: invalid account credential", creator.ErrConflict)
+ }
+ if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
+ return fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr)
+ }
+ if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil {
+ return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr)
+ }
+ }
+ collector := douyin.CreatorCollector{Browser: browser, AccountKey: account.PlatformAccountKey, SourceType: work.SourceType, SourceID: work.SourceID}
+ canonical, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey)
+ if err != nil {
+ return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err)
+ }
+ collector.AccountKey = canonical
+ cursor := ""
+ for page := 0; page < 100; page++ {
+ result, pageErr := collector.ListWorks(ctx, canonical, cursor)
+ if pageErr != nil {
+ return pageErr
+ }
+ for _, item := range result.Items {
+ if item.WorkKey != work.WorkKey {
+ continue
+ }
+ if item.Likes == nil && item.CommentsCount == nil && item.Shares == nil {
+ return creator.ErrUnavailable
+ }
+ _, metricErr := store.RecordMetric(ctx, creator.MetricInput{WorkID: work.ID, CollectedAt: now, Likes: item.Likes, CommentsCount: item.CommentsCount, Shares: item.Shares}, settings, now)
+ return metricErr
+ }
+ if !result.HasMore {
+ return creator.ErrNotFound
+ }
+ if result.NextCursor == "" || result.NextCursor == cursor {
+ return creator.ErrInvalid
+ }
+ cursor = result.NextCursor
+ }
+ return creator.ErrInvalid
+}
+
func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, accountID string, now time.Time) error {
- if store == nil || phaseAStore == nil || hubStore == nil || credentials == nil || accountID == "" {
+ if store == nil || phaseAStore == nil || hubStore == nil || accountID == "" {
return creator.ErrUnavailable
}
account, err := phaseAStore.GetAccount(ctx, accountID)
@@ -812,23 +1094,19 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ph
if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" {
return creator.ErrConflict
}
- if _, err := store.GetAccountProfile(ctx, accountID); err != nil {
+ profile, err := store.GetAccountProfile(ctx, accountID)
+ if err != nil {
return err
}
- resolver, ok := credentials.(phasea.CredentialResolver)
- if !ok {
- return creator.ErrUnavailable
+ if profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
+ return creator.ErrConflict
}
- rawCredential, err := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
+ settings, err := store.GetSettings(ctx)
if err != nil {
- return fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, err)
+ return err
}
- cookies, err := douyin.ParseCookieHeader(rawCredential)
- if err != nil {
- cookies, err = douyin.ParseCookieBundle(rawCredential)
- }
- if err != nil {
- return fmt.Errorf("%w: invalid account cookie bundle", creator.ErrConflict)
+ blockOwned := func(blockErr error) error {
+ return errors.Join(blockErr, store.MarkCollectionBlocked(ctx, creator.SourceOwned, accountID, blockErr.Error(), now, settings.LookbackDays))
}
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
if err != nil {
@@ -842,14 +1120,38 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ph
return fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err)
}
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
- if err := browser.SetCookies(ctx, cookies); err != nil {
- return fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, err)
+ if _, identityErr := browser.Identity(ctx, account.PlatformAccountKey); identityErr != nil {
+ resolver, ok := credentials.(phasea.CredentialResolver)
+ if !ok {
+ return blockOwned(creator.ErrUnavailable)
+ }
+ rawCredential, resolveErr := phaseAStore.ResolveAccountCredential(ctx, accountID, resolver)
+ if resolveErr != nil {
+ return blockOwned(fmt.Errorf("%w: resolve account credential: %v", creator.ErrUnavailable, resolveErr))
+ }
+ cookies, parseErr := douyin.ParseCredential(rawCredential)
+ if parseErr != nil {
+ return blockOwned(fmt.Errorf("%w: invalid account credential", creator.ErrConflict))
+ }
+ if setErr := browser.SetCookies(ctx, cookies); setErr != nil {
+ return blockOwned(fmt.Errorf("%w: set account cookies: %v", creator.ErrUnavailable, setErr))
+ }
+ if _, verifyErr := browser.Identity(ctx, account.PlatformAccountKey); verifyErr != nil {
+ return blockOwned(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, verifyErr))
+ }
}
collector := douyin.CreatorCollector{Browser: browser, AccountKey: account.PlatformAccountKey, SourceType: creator.SourceOwned, SourceID: account.ID}
- if err := collector.VerifyIdentity(ctx, account.PlatformAccountKey); err != nil {
- return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err)
+ canonicalSecUID, err := collector.CanonicalSecUID(ctx, account.PlatformAccountKey)
+ if err != nil {
+ blockErr := store.MarkCollectionBlocked(ctx, creator.SourceOwned, account.ID, err.Error(), now, settings.LookbackDays)
+ return errors.Join(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err), blockErr)
}
- _, err = store.CollectSource(ctx, account.Platform, creator.SourceOwned, account.ID, collector, now)
+ collector.AccountKey = canonicalSecUID
+ _, collectionNow, windowErr := store.NextCollectionWindow(ctx, creator.SourceOwned, account.ID, now, time.Duration(settings.NewWorkIntervalSeconds)*time.Second, settings.LookbackDays)
+ if windowErr != nil {
+ return windowErr
+ }
+ _, err = store.CollectSource(ctx, account.Platform, creator.SourceOwned, account.ID, collector, collectionNow)
return err
}
diff --git a/cmd/control-plane/creator_events.go b/cmd/control-plane/creator_events.go
new file mode 100644
index 0000000..664de75
--- /dev/null
+++ b/cmd/control-plane/creator_events.go
@@ -0,0 +1,464 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "git.ipao.vip/rogee/creator-hub/internal/creator"
+ "git.ipao.vip/rogee/creator-hub/internal/hub"
+ "git.ipao.vip/rogee/creator-hub/internal/phasea"
+ "github.com/sirupsen/logrus"
+)
+
+const creatorEventReconcileInterval = 10 * time.Second
+
+type creatorGatewayEvent struct {
+ DeliveryID string `json:"delivery_id,omitempty"`
+ Kind string `json:"kind"`
+ Reason string `json:"reason,omitempty"`
+ Continuity string `json:"continuity,omitempty"`
+ BoundaryAt string `json:"boundary_at,omitempty"`
+ Baseline bool `json:"baseline,omitempty"`
+ Notice *creatorGatewayEventNotice `json:"notice,omitempty"`
+}
+
+type creatorGatewayEventNotice struct {
+ EventKey string `json:"event_key"`
+ EventType string `json:"event_type"`
+ InteractorUID string `json:"interactor_uid"`
+ CommentID string `json:"comment_id"`
+ WorkID string `json:"work_id"`
+ MessageText string `json:"message_text,omitempty"`
+ PlatformEventAt string `json:"platform_event_at,omitempty"`
+ GatewayReceivedAt string `json:"gateway_received_at,omitempty"`
+}
+
+type creatorEventBinding struct {
+ accountID string
+ uid string
+ env hub.EnvironmentContext
+ gateway hub.Gateway
+}
+
+func (binding creatorEventBinding) key() string {
+ return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s\x00%s\x00%s\x00%s", binding.gateway.Name, binding.gateway.Endpoint, binding.gateway.Token, binding.env.BindingVersion, binding.env.RuntimeID, binding.env.RuntimeNetworkID, binding.env.Exit.ID, binding.uid)
+}
+
+type creatorEventListenerHandle struct {
+ cancel context.CancelFunc
+ done chan struct{}
+ key string
+}
+
+type creatorEventListenerManager struct {
+ mu sync.Mutex
+ items map[string]creatorEventListenerHandle
+}
+
+func runCreatorEventListeners(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator) {
+ manager := &creatorEventListenerManager{items: map[string]creatorEventListenerHandle{}}
+ ticker := time.NewTicker(creatorEventReconcileInterval)
+ defer ticker.Stop()
+ defer manager.close()
+ for {
+ if err := manager.reconcile(ctx, store, phaseAStore, hubStore, executor, generator); err != nil && ctx.Err() == nil {
+ logrus.WithField("service", "control-plane").WithError(err).Warn("creator event listener reconciliation failed")
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+}
+
+func (manager *creatorEventListenerManager) reconcile(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator) error {
+ if store == nil || phaseAStore == nil || hubStore == nil {
+ return creator.ErrUnavailable
+ }
+ if recovered, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()); err != nil {
+ return err
+ } else if recovered > 0 {
+ logrus.WithField("count", recovered).Warn("recovered stale creator operations as uncertain")
+ }
+ accounts, err := phaseAStore.ListAccounts(ctx)
+ if err != nil {
+ return err
+ }
+ desired := make(map[string]creatorEventBinding)
+ for _, account := range accounts {
+ if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" {
+ continue
+ }
+ profile, profileErr := store.GetAccountProfile(ctx, account.ID)
+ if profileErr != nil {
+ logrus.WithError(profileErr).WithField("account_id", account.ID).Warn("creator event listener account profile unavailable")
+ continue
+ }
+ if profile.LoginStatus != "logged_in" {
+ continue
+ }
+ if !creatorEventUID(profile.PlatformAccountKey) {
+ logrus.WithField("account_id", account.ID).Warn("creator event listener account UID is unavailable")
+ continue
+ }
+ environment, environmentErr := hubStore.GetEnvironmentContextForAccount(ctx, account.ID)
+ if environmentErr != nil {
+ logrus.WithError(environmentErr).WithField("account_id", account.ID).Warn("creator event listener environment unavailable")
+ continue
+ }
+ if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 {
+ continue
+ }
+ gateway, gatewayErr := hubStore.GetGateway(ctx, environment.Gateway)
+ if gatewayErr != nil {
+ logrus.WithError(gatewayErr).WithField("account_id", account.ID).Warn("creator event listener gateway unavailable")
+ continue
+ }
+ desired[account.ID] = creatorEventBinding{accountID: account.ID, uid: profile.PlatformAccountKey, env: environment, gateway: gateway}
+ }
+
+ var stopping []creatorEventListenerHandle
+ manager.mu.Lock()
+ for accountID, current := range manager.items {
+ binding, ok := desired[accountID]
+ if ok && current.key == binding.key() {
+ continue
+ }
+ current.cancel()
+ delete(manager.items, accountID)
+ stopping = append(stopping, current)
+ }
+ manager.mu.Unlock()
+ for _, current := range stopping {
+ <-current.done
+ }
+
+ manager.mu.Lock()
+ defer manager.mu.Unlock()
+ for accountID, binding := range desired {
+ if _, ok := manager.items[accountID]; ok {
+ continue
+ }
+ listenerContext, cancel := context.WithCancel(ctx)
+ done := make(chan struct{})
+ listenerBinding := binding
+ manager.items[accountID] = creatorEventListenerHandle{cancel: cancel, done: done, key: binding.key()}
+ go func() {
+ defer close(done)
+ runCreatorEventListener(listenerContext, store, listenerBinding, executor, generator)
+ }()
+ }
+ return nil
+}
+
+func (manager *creatorEventListenerManager) close() {
+ manager.mu.Lock()
+ handles := make([]creatorEventListenerHandle, 0, len(manager.items))
+ for accountID, current := range manager.items {
+ current.cancel()
+ handles = append(handles, current)
+ delete(manager.items, accountID)
+ }
+ manager.mu.Unlock()
+ for _, current := range handles {
+ <-current.done
+ }
+}
+
+func runCreatorEventListener(ctx context.Context, store *creator.Store, binding creatorEventBinding, executor creator.ActionExecutor, generator creator.TextGenerator) {
+ path := "/v1/browsers/" + url.PathEscape(binding.env.Alias) + "/douyin/events"
+ generation := gatewayGenerationPayload(binding.env)
+ startPayload := make(map[string]any, len(generation)+1)
+ for key, value := range generation {
+ startPayload[key] = value
+ }
+ startPayload["expected_uid"] = binding.uid
+ defer stopCreatorEventListener(binding.accountID, binding.gateway, path, generation)
+
+ backoff := time.Second
+ for ctx.Err() == nil {
+ status, _, err := gatewayCall(ctx, binding.gateway, http.MethodPost, path, startPayload, 30*time.Second)
+ if err != nil || status != http.StatusOK {
+ if err == nil {
+ err = fmt.Errorf("gateway returned HTTP %d", status)
+ }
+ logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener start failed")
+ if !waitCreatorEventBackoff(ctx, backoff) {
+ return
+ }
+ backoff *= 2
+ if backoff > 30*time.Second {
+ backoff = 30 * time.Second
+ }
+ continue
+ }
+ backoff = time.Second
+ // Every successful start creates a new boundary. Events observed before
+ // its marker remain historical even when the previous poll loop was ready.
+ ready := false
+ var boundaryAt time.Time
+ for ctx.Err() == nil {
+ status, body, err := gatewayCall(ctx, binding.gateway, http.MethodGet, path+"?limit=100&wait=25", generation, 35*time.Second)
+ if err != nil || status != http.StatusOK {
+ if err == nil {
+ err = fmt.Errorf("gateway returned HTTP %d", status)
+ }
+ logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener poll failed")
+ break
+ }
+ var events []creatorGatewayEvent
+ if err := json.Unmarshal(body, &events); err != nil {
+ logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener response is invalid")
+ break
+ }
+ for _, event := range events {
+ if event.Kind == "baseline" {
+ if event.BoundaryAt != "" {
+ parsedBoundary, boundaryErr := time.Parse(time.RFC3339Nano, event.BoundaryAt)
+ if boundaryErr != nil {
+ logrus.WithError(boundaryErr).WithField("account_id", binding.accountID).Warn("creator event baseline timestamp is invalid")
+ ready = false
+ boundaryAt = time.Time{}
+ } else {
+ boundaryAt = parsedBoundary.UTC()
+ ready = true
+ }
+ } else {
+ // A marker without a platform-verified boundary cannot prove
+ // continuity. Keep all timestamped notices non-actionable.
+ ready = false
+ boundaryAt = time.Time{}
+ }
+ } else if event.Kind == "open" || event.Kind == "error" || event.Kind == "close" || event.Kind == "reconnected" {
+ // A transport event never proves continuity. Only the explicit
+ // boundary marker permits automatic writes again.
+ ready = false
+ }
+ if event.Kind == "notice" {
+ if needsBaseline, reason := creatorGatewayEventNeedsBaseline(event, ready); needsBaseline {
+ event.Baseline = true
+ event.Reason = reason
+ } else if creatorEventBeforeBoundary(event, boundaryAt) {
+ event.Baseline = true
+ event.Reason = "平台事件早于监听边界"
+ }
+ }
+ handleCreatorGatewayEvent(ctx, store, binding, event, executor, generator)
+ }
+ }
+ if !waitCreatorEventBackoff(ctx, backoff) {
+ return
+ }
+ backoff *= 2
+ if backoff > 30*time.Second {
+ backoff = 30 * time.Second
+ }
+ }
+}
+
+func stopCreatorEventListener(accountID string, gateway hub.Gateway, path string, generation map[string]any) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if status, _, err := gatewayCall(ctx, gateway, http.MethodDelete, path, generation, 5*time.Second); err != nil || status != http.StatusNoContent {
+ if err == nil {
+ err = fmt.Errorf("gateway returned HTTP %d", status)
+ }
+ logrus.WithError(err).WithField("account_id", accountID).Warn("creator event listener stop failed")
+ }
+}
+
+func waitCreatorEventBackoff(ctx context.Context, delay time.Duration) bool {
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return false
+ case <-timer.C:
+ return true
+ }
+}
+
+func pathForCreatorEvent(environment hub.EnvironmentContext) string {
+ return "/v1/browsers/" + url.PathEscape(environment.Alias) + "/douyin/events"
+}
+
+func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, binding creatorEventBinding, event creatorGatewayEvent, executor creator.ActionExecutor, generator creator.TextGenerator) {
+ ack := func() {
+ if event.DeliveryID == "" {
+ return
+ }
+ // Acknowledgement is deliberately detached from the listener poll loop:
+ // receipt and event classification must not be serialized behind a slow
+ // gateway request. The gateway keeps the delivery until this succeeds.
+ go func(deliveryID string) {
+ ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer cancel()
+ ackPath := pathForCreatorEvent(binding.env)
+ status, _, ackErr := gatewayCall(ackCtx, binding.gateway, http.MethodGet, ackPath+"?ack="+url.QueryEscape(deliveryID)+"&limit=1&wait=0", gatewayGenerationPayload(binding.env), 5*time.Second)
+ if ackErr != nil || status != http.StatusOK {
+ if ackErr == nil {
+ ackErr = fmt.Errorf("gateway returned HTTP %d", status)
+ }
+ logrus.WithError(ackErr).WithField("account_id", binding.accountID).Warn("creator event acknowledgement failed")
+ }
+ }(event.DeliveryID)
+ }
+ switch event.Kind {
+ case "error":
+ logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "reason": event.Reason, "continuity": event.Continuity}).Warn("creator event listener reported an error")
+ ack()
+ return
+ case "reconnected":
+ logrus.WithField("account_id", binding.accountID).Warn("creator event listener reconnected")
+ ack()
+ return
+ case "open", "baseline":
+ ack()
+ return
+ case "close":
+ logrus.WithField("account_id", binding.accountID).Warn("creator event listener connection closed")
+ ack()
+ return
+ case "notice":
+ default:
+ logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "kind": event.Kind}).Warn("creator event listener returned an unknown event")
+ ack()
+ return
+ }
+ if event.Notice == nil {
+ logrus.WithField("account_id", binding.accountID).Warn("creator event listener notice is missing")
+ ack()
+ return
+ }
+ input, err := creatorEventFromGatewayNotice(binding.accountID, *event.Notice)
+ if err != nil {
+ logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener notice was rejected")
+ ack()
+ return
+ }
+ input.Baseline = event.Baseline
+ input.BaselineReason = event.Reason
+ if input.PlatformEventAt == nil {
+ input.Baseline = true
+ input.BaselineReason = "缺少平台事件时间"
+ }
+ if store == nil {
+ return
+ }
+ // Receipt is durable before the potentially slow action. This prevents an
+ // executor outage from erasing the platform notification and lets polling
+ // continue while a prior action is still in flight.
+ received, err := store.RecordEvent(ctx, input)
+ if err != nil {
+ logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event receipt failed")
+ return
+ }
+ if input.EventType == "dm" && input.InteractorUID != "" {
+ messageAt := input.PlatformEventAt
+ if messageAt == nil {
+ receivedAt := input.ReceivedAt
+ if receivedAt.IsZero() {
+ receivedAt = time.Now().UTC()
+ }
+ messageAt = &receivedAt
+ }
+ if _, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: "event:" + input.EventKey, Direction: "inbound", MessageType: "text", Text: input.MessageText, SentState: "received", MessageAt: messageAt}); messageErr != nil {
+ logrus.WithError(messageErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator direct message persistence failed")
+ return
+ }
+ }
+ // The event is now durable. An action may be slow or unavailable, but that
+ // must not hold ingestion or cause the same receipt to be fetched forever.
+ ack()
+ if input.Baseline || received.Event.State != "received" {
+ return
+ }
+ go func() {
+ result, processErr := store.ProcessAutomaticEvent(ctx, input, executor, generator)
+ if processErr != nil {
+ logrus.WithError(processErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event processing failed")
+ return
+ }
+ logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey, "event_type": input.EventType, "state": result.Event.State}).Info("creator event processed")
+ }()
+}
+
+func creatorEventBeforeBoundary(event creatorGatewayEvent, boundaryAt time.Time) bool {
+ if boundaryAt.IsZero() || event.Notice == nil {
+ return false
+ }
+ platformAt, err := time.Parse(time.RFC3339Nano, event.Notice.PlatformEventAt)
+ return err != nil || !platformAt.After(boundaryAt)
+}
+
+func creatorGatewayEventNeedsBaseline(event creatorGatewayEvent, ready bool) (bool, string) {
+ if event.Baseline {
+ if event.Reason != "" {
+ return true, event.Reason
+ }
+ return true, "监听基线"
+ }
+ if !ready {
+ return true, "监听边界未确认"
+ }
+ if event.Notice == nil || strings.TrimSpace(event.Notice.PlatformEventAt) == "" {
+ return true, "缺少平台事件时间"
+ }
+ return false, ""
+}
+
+func creatorEventFromGatewayNotice(accountID string, notice creatorGatewayEventNotice) (creator.InteractionEvent, error) {
+ if accountID == "" || !creatorEventID(notice.EventKey) || !creator.ValidEventType(notice.EventType) {
+ return creator.InteractionEvent{}, creator.ErrInvalid
+ }
+ if notice.InteractorUID != "" && !creatorEventUID(notice.InteractorUID) {
+ return creator.InteractionEvent{}, creator.ErrInvalid
+ }
+ if (notice.CommentID != "" && !creatorEventID(notice.CommentID)) || (notice.WorkID != "" && !creatorEventID(notice.WorkID)) {
+ return creator.InteractionEvent{}, creator.ErrInvalid
+ }
+ result := creator.InteractionEvent{Platform: creator.PlatformDouyin, ReceivingAccountID: accountID, EventKey: notice.EventKey, EventType: notice.EventType, InteractorUID: notice.InteractorUID, CommentID: notice.CommentID, WorkID: notice.WorkID, MessageText: strings.TrimSpace(notice.MessageText)}
+ if strings.TrimSpace(notice.PlatformEventAt) != "" {
+ at, err := time.Parse(time.RFC3339Nano, notice.PlatformEventAt)
+ if err != nil {
+ return creator.InteractionEvent{}, fmt.Errorf("invalid platform event time: %w", err)
+ }
+ at = at.UTC()
+ result.PlatformEventAt = &at
+ }
+ if strings.TrimSpace(notice.GatewayReceivedAt) != "" {
+ receivedAt, err := time.Parse(time.RFC3339Nano, notice.GatewayReceivedAt)
+ if err != nil {
+ return creator.InteractionEvent{}, fmt.Errorf("invalid gateway receipt time: %w", err)
+ }
+ result.ReceivedAt = receivedAt.UTC()
+ }
+ return result, nil
+}
+
+func creatorEventUID(value string) bool {
+ return creatorEventDigits(value, 20)
+}
+
+func creatorEventID(value string) bool {
+ return creatorEventDigits(value, 64)
+}
+
+func creatorEventDigits(value string, max int) bool {
+ if value == "" || len(value) > max || value[0] < '1' || value[0] > '9' {
+ return false
+ }
+ for _, char := range value[1:] {
+ if char < '0' || char > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/cmd/control-plane/creator_events_test.go b/cmd/control-plane/creator_events_test.go
new file mode 100644
index 0000000..9a9116a
--- /dev/null
+++ b/cmd/control-plane/creator_events_test.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "testing"
+ "time"
+
+ "git.ipao.vip/rogee/creator-hub/internal/hub"
+)
+
+func TestGatewayGenerationPayloadIncludesCurrentProxyExit(t *testing.T) {
+ payload := gatewayGenerationPayload(hub.EnvironmentContext{
+ BindingVersion: 3,
+ RuntimeID: "runtime",
+ RuntimeNetworkID: "network",
+ Exit: hub.NetworkExit{ID: "exit-current"},
+ })
+ if payload["binding_version"] != int64(3) || payload["runtime_id"] != "runtime" || payload["network_id"] != "network" || payload["network_exit_id"] != "exit-current" {
+ t.Fatalf("unexpected generation payload: %#v", payload)
+ }
+}
+
+func TestCreatorGatewayEventNeedsBaseline(t *testing.T) {
+ if ok, reason := creatorGatewayEventNeedsBaseline(creatorGatewayEvent{Kind: "notice"}, true); !ok || reason != "缺少平台事件时间" {
+ t.Fatalf("missing platform time must be held at baseline: %v %q", ok, reason)
+ }
+ if ok, reason := creatorGatewayEventNeedsBaseline(creatorGatewayEvent{Kind: "notice", Notice: &creatorGatewayEventNotice{PlatformEventAt: "2024-01-01T00:00:00Z"}}, false); !ok || reason != "监听边界未确认" {
+ t.Fatalf("unconfirmed listener boundary must be held: %v %q", ok, reason)
+ }
+ if ok, reason := creatorGatewayEventNeedsBaseline(creatorGatewayEvent{Kind: "notice", Notice: &creatorGatewayEventNotice{PlatformEventAt: "2024-01-01T00:00:00Z"}}, true); ok || reason != "" {
+ t.Fatalf("confirmed event boundary should be actionable: %v %q", ok, reason)
+ }
+}
+
+func TestCreatorEventBeforeBoundary(t *testing.T) {
+ boundary := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ before := creatorGatewayEvent{Notice: &creatorGatewayEventNotice{PlatformEventAt: "2023-12-31T23:59:59Z"}}
+ after := creatorGatewayEvent{Notice: &creatorGatewayEventNotice{PlatformEventAt: "2024-01-01T00:00:01Z"}}
+ invalid := creatorGatewayEvent{Notice: &creatorGatewayEventNotice{PlatformEventAt: "not-a-time"}}
+ if !creatorEventBeforeBoundary(before, boundary) || !creatorEventBeforeBoundary(invalid, boundary) || creatorEventBeforeBoundary(after, boundary) {
+ t.Fatalf("unexpected boundary classification")
+ }
+}
+
+func TestCreatorEventFromGatewayNotice(t *testing.T) {
+ event, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{
+ EventKey: "9007199254740993",
+ EventType: "comment",
+ InteractorUID: "7654321",
+ CommentID: "987654",
+ WorkID: "123456",
+ PlatformEventAt: "2023-11-14T22:13:20+00:00",
+ GatewayReceivedAt: "2023-11-14T22:13:21+00:00",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if event.Platform != "douyin" || event.ReceivingAccountID != "account-1" || event.EventKey != "9007199254740993" || event.InteractorUID != "7654321" || event.CommentID != "987654" || event.WorkID != "123456" {
+ t.Fatalf("unexpected event: %+v", event)
+ }
+ if event.PlatformEventAt == nil || event.PlatformEventAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:20Z" {
+ t.Fatalf("unexpected event time: %+v", event.PlatformEventAt)
+ }
+ if event.ReceivedAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:21Z" {
+ t.Fatalf("unexpected gateway receipt time: %v", event.ReceivedAt)
+ }
+}
+
+func TestCreatorEventFromGatewayNoticeRejectsInvalidIdentity(t *testing.T) {
+ _, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{
+ EventKey: "1",
+ EventType: "follow",
+ InteractorUID: "not-a-uid",
+ })
+ if err == nil {
+ t.Fatal("expected invalid interactor UID")
+ }
+}
+
+func TestCreatorEventFromGatewayNoticeRejectsInvalidGatewayReceiptTime(t *testing.T) {
+ _, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{
+ EventKey: "1",
+ EventType: "like",
+ GatewayReceivedAt: "not-a-time",
+ })
+ if err == nil {
+ t.Fatal("expected invalid gateway receipt time")
+ }
+}
+
+func TestCreatorEventFromGatewayNoticeRejectsInvalidTime(t *testing.T) {
+ _, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{
+ EventKey: "1",
+ EventType: "like",
+ PlatformEventAt: "not-a-time",
+ })
+ if err == nil {
+ t.Fatal("expected invalid platform event time")
+ }
+}
diff --git a/cmd/control-plane/creator_material.go b/cmd/control-plane/creator_material.go
new file mode 100644
index 0000000..9b4df92
--- /dev/null
+++ b/cmd/control-plane/creator_material.go
@@ -0,0 +1,210 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "git.ipao.vip/rogee/creator-hub/internal/creator"
+)
+
+const maxCreatorMaterialBytes int64 = 512 << 20
+
+func processCreatorMaterial(ctx context.Context, store *creator.Store, workID string) (creator.MaterialJob, error) {
+ if store == nil || workID == "" || filepath.Base(workID) != workID {
+ return creator.MaterialJob{}, creator.ErrInvalid
+ }
+ work, err := store.GetWork(ctx, workID)
+ if err != nil {
+ return creator.MaterialJob{}, err
+ }
+ job, err := store.GetMaterial(ctx, workID)
+ if err != nil {
+ return creator.MaterialJob{}, err
+ }
+ if !job.Selected {
+ return creator.MaterialJob{}, creator.ErrConflict
+ }
+ root := os.Getenv("CREATOR_MEDIA_DIR")
+ if root == "" {
+ root = "/var/lib/creatorhub/materials"
+ }
+ root = filepath.Clean(root)
+ dir := filepath.Join(root, workID)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return creator.MaterialJob{}, fmt.Errorf("create material directory: %w", err)
+ }
+ videoPath := filepath.Join(dir, "source")
+ videoReference := filepath.Join(workID, "source")
+ if job.DownloadStatus != "succeeded" || !fileExists(videoPath) {
+ if _, err := store.SetMaterialStep(ctx, workID, "download", "running", "", ""); err != nil {
+ return creator.MaterialJob{}, err
+ }
+ if err := downloadCreatorMaterial(ctx, work.OriginalURL, videoPath); err != nil {
+ return setMaterialFailure(ctx, store, workID, "download", err)
+ }
+ job, err = store.SetMaterialStep(ctx, workID, "download", "succeeded", videoReference, "")
+ if err != nil {
+ return creator.MaterialJob{}, err
+ }
+ }
+
+ audioPath := filepath.Join(dir, "audio.wav")
+ audioReference := filepath.Join(workID, "audio.wav")
+ if job.AudioStatus != "succeeded" && job.AudioStatus != "no_audio" {
+ if _, err := store.SetMaterialStep(ctx, workID, "audio", "running", "", ""); err != nil {
+ return creator.MaterialJob{}, err
+ }
+ hasAudio, err := creatorMaterialHasAudio(ctx, videoPath)
+ if err != nil {
+ return setMaterialFailure(ctx, store, workID, "audio", err)
+ }
+ if !hasAudio {
+ job, err = store.SetMaterialStep(ctx, workID, "audio", "no_audio", "", "视频没有音轨")
+ if err != nil {
+ return creator.MaterialJob{}, err
+ }
+ } else if err := extractCreatorAudio(ctx, videoPath, audioPath); err != nil {
+ return setMaterialFailure(ctx, store, workID, "audio", err)
+ } else {
+ job, err = store.SetMaterialStep(ctx, workID, "audio", "succeeded", audioReference, "")
+ if err != nil {
+ return creator.MaterialJob{}, err
+ }
+ }
+ }
+
+ if job.TranscriptionStatus != "succeeded" && job.TranscriptionStatus != "no_speech" {
+ if job.AudioStatus == "no_audio" {
+ job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "没有可转写的音轨")
+ } else {
+ if _, err = store.SetMaterialStep(ctx, workID, "transcription", "running", "", ""); err == nil {
+ transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath)
+ if transcribeErr != nil {
+ job, err = setMaterialFailure(ctx, store, workID, "transcription", transcribeErr)
+ } else if strings.TrimSpace(transcript) == "" {
+ job, err = store.SetMaterialStep(ctx, workID, "transcription", "no_speech", "", "转写未检测到语音")
+ } else {
+ transcriptPath := filepath.Join(dir, "transcript.txt")
+ if writeErr := os.WriteFile(transcriptPath, []byte(transcript), 0o600); writeErr != nil {
+ job, err = setMaterialFailure(ctx, store, workID, "transcription", writeErr)
+ } else {
+ job, err = store.SetMaterialStep(ctx, workID, "transcription", "succeeded", filepath.Join(workID, "transcript.txt"), "")
+ }
+ }
+ }
+ }
+ if err != nil {
+ return creator.MaterialJob{}, err
+ }
+ }
+ return job, nil
+}
+
+func downloadCreatorMaterial(ctx context.Context, rawURL, destination string) error {
+ parsed, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil || (parsed.URL.Scheme != "http" && parsed.URL.Scheme != "https") || parsed.URL.Host == "" {
+ return creator.ErrInvalid
+ }
+ client := &http.Client{Timeout: 2 * time.Minute}
+ response, err := client.Do(parsed)
+ if err != nil {
+ return fmt.Errorf("download material: %w", err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return fmt.Errorf("download material: HTTP %s", response.Status)
+ }
+ if response.ContentLength > maxCreatorMaterialBytes {
+ return fmt.Errorf("download material exceeds size limit")
+ }
+ temporary := destination + ".tmp"
+ defer os.Remove(temporary)
+ file, err := os.OpenFile(temporary, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+ if err != nil {
+ return fmt.Errorf("create material file: %w", err)
+ }
+ written, copyErr := io.Copy(file, io.LimitReader(response.Body, maxCreatorMaterialBytes+1))
+ closeErr := file.Close()
+ if copyErr != nil {
+ return fmt.Errorf("write material: %w", copyErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("close material file: %w", closeErr)
+ }
+ if written > maxCreatorMaterialBytes {
+ return fmt.Errorf("download material exceeds size limit")
+ }
+ if err := os.Rename(temporary, destination); err != nil {
+ return fmt.Errorf("publish material: %w", err)
+ }
+ return nil
+}
+
+func creatorMaterialHasAudio(ctx context.Context, videoPath string) (bool, error) {
+ if _, err := exec.LookPath("ffprobe"); err != nil {
+ return false, fmt.Errorf("ffprobe is unavailable: %w", err)
+ }
+ command := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=index", "-of", "csv=p=0", videoPath)
+ output, err := command.Output()
+ if err != nil {
+ return false, fmt.Errorf("inspect audio stream: %w", err)
+ }
+ return strings.TrimSpace(string(output)) != "", nil
+}
+
+func extractCreatorAudio(ctx context.Context, videoPath, audioPath string) error {
+ if _, err := exec.LookPath("ffmpeg"); err != nil {
+ return fmt.Errorf("ffmpeg is unavailable: %w", err)
+ }
+ temporary := audioPath + ".tmp"
+ defer os.Remove(temporary)
+ command := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-v", "error", "-y", "-i", videoPath, "-vn", "-ac", "1", "-ar", "16000", temporary)
+ if output, err := command.CombinedOutput(); err != nil {
+ return fmt.Errorf("extract audio: %w: %s", err, strings.TrimSpace(string(output)))
+ }
+ if !fileExists(temporary) {
+ return fmt.Errorf("extract audio produced no file")
+ }
+ if err := os.Rename(temporary, audioPath); err != nil {
+ return fmt.Errorf("publish audio: %w", err)
+ }
+ return nil
+}
+
+func transcribeCreatorAudio(ctx context.Context, audioPath string) (string, error) {
+ binary := os.Getenv("CREATOR_TRANSCRIPTION_BIN")
+ if binary == "" {
+ return "", fmt.Errorf("transcription provider is not configured")
+ }
+ if _, err := exec.LookPath(binary); err != nil {
+ return "", fmt.Errorf("transcription provider is unavailable: %w", err)
+ }
+ output, err := exec.CommandContext(ctx, binary, audioPath).Output()
+ if err != nil {
+ return "", fmt.Errorf("transcribe audio: %w", err)
+ }
+ if len(output) > 1<<20 {
+ return "", fmt.Errorf("transcript exceeds size limit")
+ }
+ return string(output), nil
+}
+
+func setMaterialFailure(ctx context.Context, store *creator.Store, workID, step string, cause error) (creator.MaterialJob, error) {
+ job, err := store.SetMaterialStep(ctx, workID, step, "failed", "", cause.Error())
+ if err != nil {
+ return creator.MaterialJob{}, fmt.Errorf("record %s failure: %w", step, err)
+ }
+ return job, nil
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.Mode().IsRegular() && info.Size() > 0
+}
diff --git a/cmd/control-plane/creator_material_test.go b/cmd/control-plane/creator_material_test.go
new file mode 100644
index 0000000..512a6aa
--- /dev/null
+++ b/cmd/control-plane/creator_material_test.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDownloadCreatorMaterialPublishesOnlyCompleteFiles(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/source" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ _, _ = w.Write([]byte("media"))
+ }))
+ defer server.Close()
+
+ destination := filepath.Join(t.TempDir(), "source")
+ if err := downloadCreatorMaterial(context.Background(), server.URL+"/source", destination); err != nil {
+ t.Fatalf("download material: %v", err)
+ }
+ content, err := os.ReadFile(destination)
+ if err != nil {
+ t.Fatalf("read material: %v", err)
+ }
+ if string(content) != "media" {
+ t.Fatalf("unexpected material: %q", content)
+ }
+ if _, err := os.Stat(destination + ".tmp"); !os.IsNotExist(err) {
+ t.Fatalf("temporary file remains: %v", err)
+ }
+}
+
+func TestDownloadCreatorMaterialRejectsInvalidResponses(t *testing.T) {
+ server := httptest.NewServer(http.NotFoundHandler())
+ defer server.Close()
+ if err := downloadCreatorMaterial(context.Background(), server.URL, filepath.Join(t.TempDir(), "source")); err == nil {
+ t.Fatal("expected HTTP failure")
+ }
+ if err := downloadCreatorMaterial(context.Background(), "file:///tmp/source", filepath.Join(t.TempDir(), "source")); err == nil {
+ t.Fatal("expected non-HTTP URL failure")
+ }
+}
+
+func TestTranscribeCreatorAudioRequiresExplicitProvider(t *testing.T) {
+ t.Setenv("CREATOR_TRANSCRIPTION_BIN", "")
+ if _, err := transcribeCreatorAudio(context.Background(), "/tmp/audio.wav"); err == nil {
+ t.Fatal("expected missing provider error")
+ }
+}
diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go
index da18118..ed78e42 100644
--- a/cmd/control-plane/hub.go
+++ b/cmd/control-plane/hub.go
@@ -138,11 +138,15 @@ func gatewayProxyPayload(environment hub.EnvironmentContext, runtimeID, networkI
}
func gatewayGenerationPayload(environment hub.EnvironmentContext) map[string]any {
- if environment.RuntimeCleanupBindingVersion > 0 {
- return map[string]any{"binding_version": environment.RuntimeCleanupBindingVersion, "runtime_id": environment.RuntimeCleanupRuntimeID,
- "network_id": environment.RuntimeCleanupNetworkID}
+ return map[string]any{"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID, "network_id": environment.RuntimeNetworkID, "network_exit_id": environment.Exit.ID}
+}
+
+func gatewayCleanupGenerationPayload(environment hub.EnvironmentContext) map[string]any {
+ bindingVersion, runtimeID, networkID := environment.RuntimeCleanupBindingVersion, environment.RuntimeCleanupRuntimeID, environment.RuntimeCleanupNetworkID
+ if bindingVersion == 0 {
+ bindingVersion, runtimeID, networkID = environment.BindingVersion, environment.RuntimeID, environment.RuntimeNetworkID
}
- return map[string]any{"binding_version": environment.BindingVersion, "runtime_id": environment.RuntimeID, "network_id": environment.RuntimeNetworkID}
+ return map[string]any{"binding_version": bindingVersion, "runtime_id": runtimeID, "network_id": networkID}
}
func runtimeCleanupGeneration(environment hub.EnvironmentContext, bindingVersion int64, runtimeID string, networkIDs ...string) hub.EnvironmentContext {
@@ -1404,7 +1408,7 @@ func stopEnvironmentRuntime(ctx context.Context, store runtimeStopStore, environ
return err
}
status, body, callErr := gatewayCall(ctx, gateway, http.MethodPost, "/v1/browsers/"+environment.Alias+"/stop",
- gatewayGenerationPayload(environment), 30*time.Second)
+ gatewayCleanupGenerationPayload(environment), 30*time.Second)
if callErr != nil || status >= http.StatusInternalServerError {
container, found, reconcileErr := reconcileGatewayContainer(ctx, gateway, environment.Alias)
if reconcileErr != nil {
@@ -1787,7 +1791,7 @@ func removeGatewayRuntime(ctx context.Context, store runtimeCleanupStore, target
}
for attempt := 0; attempt < 2; attempt++ {
status, body, callErr := gatewayCall(ctx, target, http.MethodDelete, "/v1/browsers/"+environment.Alias,
- gatewayGenerationPayload(environment), 30*time.Second)
+ gatewayCleanupGenerationPayload(environment), 30*time.Second)
if callErr == nil && (status == http.StatusNoContent || status == http.StatusNotFound) {
return true, store.SetRuntimeCleanupPending(ctx, environment, false)
}
diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go
index 135fb57..be42ce7 100644
--- a/cmd/control-plane/main.go
+++ b/cmd/control-plane/main.go
@@ -30,6 +30,7 @@ import (
type config struct {
listenAddr, webDir, databaseURL, credentialStoreDir string
username, password string
+ aiAPIKey, aiBaseURL string
credentialMasterKey []byte
logLevel logrus.Level
}
@@ -97,13 +98,23 @@ func newCommand() *cobra.Command {
defer close(creatorScheduleDone)
runCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore, credentials)
}()
- listenErr := newHandlerWithCreator(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore, credentials, creatorStore).Listen(cfg.listenAddr, fiber.ListenConfig{
+ creatorEventContext, stopCreatorEvents := context.WithCancel(command.Context())
+ creatorEventDone := make(chan struct{})
+ creatorEventExecutor := creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials}
+ creatorAI := &creator.ConfiguredBailian{Store: creatorStore, APIKey: cfg.aiAPIKey, BaseURL: cfg.aiBaseURL}
+ go func() {
+ defer close(creatorEventDone)
+ 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
},
@@ -163,6 +174,8 @@ func loadConfig() (config, error) {
_ = 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 {
@@ -175,6 +188,8 @@ func loadConfig() (config, error) {
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 == "" {
@@ -204,6 +219,12 @@ func loadConfig() (config, error) {
(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
}
@@ -250,6 +271,10 @@ func newHandlerWithCredentialBridge(webDirectory, username, password string, pha
}
func newHandlerWithCreator(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, creatorStore *creator.Store) *fiber.App {
+ return newHandlerWithCreatorAndAI(webDirectory, username, password, phaseAStore, hubStore, credentials, creatorStore, nil, nil)
+}
+
+func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, creatorStore *creator.Store, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) *fiber.App {
app := fiber.New(fiber.Config{
AppName: "CreatorHub control plane",
BodyLimit: 1 << 20,
@@ -269,7 +294,15 @@ func newHandlerWithCreator(webDirectory, username, password string, phaseAStore
registerPhaseA(app, phaseAStore, hubStore, credentials)
}
if creatorStore != nil {
- registerCreator(app, creatorStore, phaseAStore, hubStore, credentials)
+ if generator == nil && analyzer == nil {
+ registerCreator(app, creatorStore, phaseAStore, hubStore, credentials)
+ } else {
+ var executor creator.ActionExecutor
+ if phaseAStore != nil && hubStore != nil {
+ executor = creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore, credentials: credentials}
+ }
+ registerCreatorWithServices(app, creatorStore, phaseAStore, hubStore, credentials, executor, generator, analyzer)
+ }
}
app.Get("/*", spaHandler(webDirectory))
return app
diff --git a/cmd/docker-gateway/douyin.go b/cmd/docker-gateway/douyin.go
deleted file mode 100644
index 78e6a69..0000000
--- a/cmd/docker-gateway/douyin.go
+++ /dev/null
@@ -1,509 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "io"
- "net"
- "net/http"
- "net/url"
- "regexp"
- "strconv"
- "strings"
- "time"
-
- "git.ipao.vip/rogee/creator-hub/internal/douyin"
- "github.com/gofiber/fiber/v3"
- "github.com/sirupsen/logrus"
- "golang.org/x/net/websocket"
-)
-
-const (
- douyinOrigin = "https://www.douyin.com"
- douyinOriginURL = "https://www.douyin.com/"
- douyinIdentityPath = "/aweme/v1/web/user/profile/self/"
- douyinWorksPath = "/aweme/v1/web/aweme/post/"
- douyinCommentsPath = "/aweme/v1/web/comment/list/"
- douyinResponseLimit = 1 << 20
- browserControlTimeout = 15 * time.Second
-)
-
-var douyinAccountKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$`)
-
-type restrictedBrowserResponse struct {
- Status int
- Body string
- Challenge douyin.Challenge
-}
-
-type restrictedBrowser interface {
- SetCookies(context.Context, string, []douyin.Cookie) error
- Get(context.Context, string, string) (restrictedBrowserResponse, error)
-}
-
-type douyinGenerationRequest struct {
- BindingVersion int64 `json:"binding_version"`
- RuntimeID string `json:"runtime_id"`
- NetworkID string `json:"network_id"`
- NetworkExitID string `json:"network_exit_id"`
-}
-
-type douyinCookieRequest struct {
- douyinGenerationRequest
- Cookies []douyin.Cookie `json:"cookies"`
-}
-
-type douyinGetRequest struct {
- douyinGenerationRequest
- URL string `json:"url"`
-}
-
-func (api gateway) setDouyinCookies(c fiber.Ctx) error {
- var input douyinCookieRequest
- if !runtimeIDPattern.MatchString(c.Params("id")) || decodeRestrictedBrowserRequest(c.Body(), &input) != nil || !validDouyinGeneration(input.douyinGenerationRequest) ||
- !validDouyinCookies(input.Cookies) {
- return writeError(c, http.StatusBadRequest, errors.New("invalid restricted browser request"))
- }
- _, release, err := api.locks.acquire(c.Params("id"))
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- defer release()
- if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
- return writeError(c, statusFor(err), err)
- }
- if api.browser == nil {
- return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
- }
- if err := api.browser.SetCookies(c.Context(), c.Params("id"), input.Cookies); err != nil {
- logrus.WithError(err).WithField("browser_id", c.Params("id")).Error("restricted browser cookie operation failed")
- return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
- }
- if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
- return writeError(c, statusFor(err), err)
- }
- return c.SendStatus(http.StatusNoContent)
-}
-
-func (api gateway) getDouyin(c fiber.Ctx) error {
- var input douyinGetRequest
- if !runtimeIDPattern.MatchString(c.Params("id")) || decodeRestrictedBrowserRequest(c.Body(), &input) != nil || !validDouyinGeneration(input.douyinGenerationRequest) ||
- !validDouyinURL(input.URL) {
- return writeError(c, http.StatusBadRequest, errors.New("invalid restricted browser request"))
- }
- _, release, err := api.locks.acquire(c.Params("id"))
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- defer release()
- if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
- return writeError(c, statusFor(err), err)
- }
- if api.browser == nil {
- return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
- }
- response, err := api.browser.Get(c.Context(), c.Params("id"), input.URL)
- if err != nil {
- logrus.WithError(err).WithField("browser_id", c.Params("id")).Error("restricted browser fetch operation failed")
- return writeError(c, http.StatusBadGateway, errors.New("restricted browser operation failed"))
- }
- if err := api.requireDouyinGeneration(c.Params("id"), input.douyinGenerationRequest); err != nil {
- return writeError(c, statusFor(err), err)
- }
- return writeJSON(c, http.StatusOK, map[string]any{
- "status": response.Status, "body": response.Body, "challenge": response.Challenge,
- })
-}
-
-func decodeRestrictedBrowserRequest(body []byte, target any) error {
- decoder := json.NewDecoder(bytes.NewReader(body))
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(target); err != nil {
- return err
- }
- var trailing json.RawMessage
- if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
- return errors.New("request must contain one JSON value")
- }
- return nil
-}
-
-func validDouyinGeneration(input douyinGenerationRequest) bool {
- return input.BindingVersion > 0 && exitIDPattern.MatchString(input.RuntimeID) &&
- exitIDPattern.MatchString(input.NetworkID) && (input.NetworkExitID == "" || exitIDPattern.MatchString(input.NetworkExitID))
-}
-
-func (api gateway) requireDouyinGeneration(alias string, input douyinGenerationRequest) error {
- runtimeID, labels, networks, err := api.managedContainerState(alias)
- if err != nil {
- return err
- }
- version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64)
- if runtimeID != input.RuntimeID || version != input.BindingVersion || labels[networkIDLabel] != input.NetworkID ||
- labels[networkExitLabel] != input.NetworkExitID {
- return errGenerationConflict
- }
- generation, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, input.BindingVersion,
- input.RuntimeID, api.self, input.NetworkID, false)
- if err != nil {
- return err
- }
- if !exists || !generation.RuntimeAttached || generation.SelfMember == "" || len(generation.GatewayMembers) == 0 ||
- len(networks) != 1 || networks[generation.Name] != generation.ID {
- return errGenerationConflict
- }
- return nil
-}
-
-func validDouyinCookies(cookies []douyin.Cookie) bool {
- if len(cookies) == 0 || len(cookies) > 64 {
- return false
- }
- for _, cookie := range cookies {
- domain := strings.ToLower(strings.TrimSpace(cookie.Domain))
- if cookie.Name == "" || len(cookie.Name) > 256 || len(cookie.Value) > 4096 || len(cookie.Domain) > 256 || len(cookie.Path) > 256 ||
- domain != cookie.Domain || cookie.Expires < 0 || strings.ContainsAny(cookie.Name, ";\r\n\x00") || strings.ContainsAny(cookie.Value, ";\r\n\x00") ||
- (domain != "douyin.com" && !strings.HasSuffix(domain, ".douyin.com")) || !strings.HasPrefix(cookie.Path, "/") ||
- strings.ContainsAny(cookie.Path, ";\r\n\x00") || (cookie.SameSite != "" && cookie.SameSite != "Lax" &&
- cookie.SameSite != "Strict" && cookie.SameSite != "None") {
- return false
- }
- }
- return true
-}
-
-func validDouyinURL(raw string) bool {
- parsed, err := url.Parse(raw)
- if err != nil || parsed.Scheme != "https" || parsed.Host != "www.douyin.com" || parsed.User != nil || parsed.Fragment != "" {
- return false
- }
- query := parsed.Query()
- switch parsed.Path {
- case douyinIdentityPath:
- return len(query) == 2 && len(query["aid"]) == 1 && query.Get("aid") == "6383" &&
- len(query["device_platform"]) == 1 && query.Get("device_platform") == "webapp"
- case douyinWorksPath:
- if len(query) != 3 || len(query["sec_user_id"]) != 1 || !douyinAccountKeyPattern.MatchString(query.Get("sec_user_id")) ||
- len(query["count"]) != 1 || query.Get("count") != "20" || len(query["max_cursor"]) != 1 {
- return false
- }
- cursor, err := strconv.ParseInt(query.Get("max_cursor"), 10, 64)
- return err == nil && cursor >= 0
- case douyinCommentsPath:
- if len(query) != 3 || len(query["aweme_id"]) != 1 || !douyinAccountKeyPattern.MatchString(query.Get("aweme_id")) ||
- len(query["count"]) != 1 || query.Get("count") != "20" || len(query["cursor"]) != 1 {
- return false
- }
- cursor, err := strconv.ParseInt(query.Get("cursor"), 10, 64)
- return err == nil && cursor >= 0
- default:
- return false
- }
-}
-
-type cdpBrowser struct {
- endpoint func(string) string
- client *http.Client
-}
-
-func (browser cdpBrowser) SetCookies(ctx context.Context, alias string, cookies []douyin.Cookie) error {
- connection, err := browser.connect(ctx, alias)
- if err != nil {
- return err
- }
- defer connection.Close()
- commandID := 0
- cdpCookies := make([]map[string]any, 0, len(cookies))
- for _, cookie := range cookies {
- value := map[string]any{
- "name": cookie.Name, "value": cookie.Value, "url": douyinOriginURL, "path": cookie.Path,
- "secure": cookie.Secure, "httpOnly": cookie.HTTPOnly,
- }
- if cookie.SameSite != "" {
- value["sameSite"] = cookie.SameSite
- }
- if cookie.Expires != 0 {
- value["expires"] = cookie.Expires
- }
- cdpCookies = append(cdpCookies, value)
- }
- if err := cdpCommand(connection, &commandID, "Network.enable", map[string]any{}, nil, nil); err != nil {
- return err
- }
- if err := cdpCommand(connection, &commandID, "Network.clearBrowserCookies", map[string]any{}, nil, nil); err != nil {
- return err
- }
- if err := cdpCommand(connection, &commandID, "Page.enable", map[string]any{}, nil, nil); err != nil {
- return err
- }
- if err := cdpCommand(connection, &commandID, "Page.setLifecycleEventsEnabled", map[string]bool{"enabled": true}, nil, nil); err != nil {
- return err
- }
- var events []cdpMessage
- var navigation struct {
- FrameID string `json:"frameId"`
- LoaderID string `json:"loaderId"`
- ErrorText string `json:"errorText"`
- }
- navigationErr := cdpCommand(connection, &commandID, "Page.navigate", map[string]string{"url": douyinOriginURL}, &navigation, &events)
- logrus.WithFields(logrus.Fields{"frame_id": navigation.FrameID, "loader_id": navigation.LoaderID,
- "error_text": navigation.ErrorText, "event_count": len(events), "command_error": navigationErr != nil}).Debug("restricted browser navigation response")
- if navigationErr != nil || navigation.ErrorText != "" || navigation.FrameID == "" || navigation.LoaderID == "" {
- logrus.WithFields(logrus.Fields{"frame_id": navigation.FrameID, "loader_id": navigation.LoaderID,
- "error_text": navigation.ErrorText, "event_count": len(events)}).Error("restricted browser navigation response invalid")
- return errors.New("restricted browser navigation failed")
- }
- if err := waitForDouyinPage(ctx, connection, &commandID, navigation.FrameID, events); err != nil {
- return err
- }
- return cdpCommand(connection, &commandID, "Network.setCookies", map[string]any{"cookies": cdpCookies}, nil, nil)
-}
-
-func (browser cdpBrowser) Get(ctx context.Context, alias, target string) (restrictedBrowserResponse, error) {
- connection, err := browser.connect(ctx, alias)
- if err != nil {
- return restrictedBrowserResponse{}, err
- }
- defer connection.Close()
- commandID := 0
- var currentOrigin struct {
- Result struct {
- Value string `json:"value"`
- } `json:"result"`
- }
- if err := cdpCommand(connection, &commandID, "Runtime.evaluate", map[string]any{
- "expression": "location.origin", "returnByValue": true,
- }, ¤tOrigin, nil); err != nil || currentOrigin.Result.Value != douyinOrigin {
- return restrictedBrowserResponse{}, errors.New("restricted browser origin changed")
- }
- encodedURL, _ := json.Marshal(target)
- expression := `(async()=>{const r=await fetch(` + string(encodedURL) + `,{credentials:"include",redirect:"error"});` +
- `if(!r.body)return {status:r.status,body:"",too_large:false};const q=r.body.getReader(),d=new TextDecoder();let n=0,b="";` +
- `for(;;){const x=await q.read();if(x.done)break;if(n+x.value.byteLength>=` + strconv.Itoa(douyinResponseLimit) +
- `){await q.cancel();return {too_large:true};}n+=x.value.byteLength;b+=d.decode(x.value,{stream:true});}` +
- `b+=d.decode();return {status:r.status,body:b,too_large:false};})()`
- var evaluated struct {
- Result struct {
- Value struct {
- Status int `json:"status"`
- Body string `json:"body"`
- TooLarge bool `json:"too_large"`
- } `json:"value"`
- } `json:"result"`
- ExceptionDetails json.RawMessage `json:"exceptionDetails"`
- }
- if err := cdpCommand(connection, &commandID, "Runtime.evaluate", map[string]any{
- "expression": expression, "awaitPromise": true, "returnByValue": true,
- }, &evaluated, nil); err != nil || len(evaluated.ExceptionDetails) != 0 || evaluated.Result.Value.TooLarge ||
- evaluated.Result.Value.Status < 200 || evaluated.Result.Value.Status > 599 ||
- (evaluated.Result.Value.Status >= 300 && evaluated.Result.Value.Status < 400) {
- return restrictedBrowserResponse{}, errors.New("restricted browser fetch failed")
- }
- body := evaluated.Result.Value.Body
- return restrictedBrowserResponse{Status: evaluated.Result.Value.Status, Body: body, Challenge: detectDouyinChallenge(evaluated.Result.Value.Status, body)}, nil
-}
-
-func (browser cdpBrowser) connect(ctx context.Context, alias string) (*websocket.Conn, error) {
- base := "http://" + namePrefix + alias + ":9222"
- if browser.endpoint != nil {
- base = browser.endpoint(alias)
- }
- baseURL, err := url.Parse(base)
- if err != nil || baseURL.Scheme != "http" || baseURL.Hostname() == "" || baseURL.Port() == "" {
- return nil, errors.New("restricted browser unavailable")
- }
- if net.ParseIP(baseURL.Hostname()) == nil {
- addresses, lookupErr := net.LookupIP(baseURL.Hostname())
- if lookupErr != nil || len(addresses) == 0 {
- return nil, errors.New("restricted browser unavailable")
- }
- baseURL.Host = net.JoinHostPort(addresses[0].String(), baseURL.Port())
- }
- base = strings.TrimRight(baseURL.String(), "/")
- request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil)
- if err != nil {
- return nil, errors.New("restricted browser unavailable")
- }
- client := http.Client{Timeout: browserControlTimeout}
- if browser.client != nil {
- client = *browser.client
- }
- if client.Timeout <= 0 {
- client.Timeout = browserControlTimeout
- }
- client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
- response, err := client.Do(request)
- if err != nil {
- logrus.WithError(err).WithField("browser_id", alias).Error("restricted browser discovery failed")
- return nil, errors.New("restricted browser unavailable")
- }
- defer response.Body.Close()
- var targets []struct {
- Type string `json:"type"`
- WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
- }
- if response.StatusCode != http.StatusOK {
- return nil, errors.New("restricted browser unavailable")
- }
- body, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
- if err != nil || len(body) > 64<<10 {
- return nil, errors.New("restricted browser unavailable")
- }
- decoder := json.NewDecoder(bytes.NewReader(body))
- if decoder.Decode(&targets) != nil || len(targets) > 32 {
- return nil, errors.New("restricted browser unavailable")
- }
- var trailing json.RawMessage
- if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
- return nil, errors.New("restricted browser unavailable")
- }
- pageTarget := ""
- for _, target := range targets {
- if target.Type != "page" {
- continue
- }
- if pageTarget != "" {
- return nil, errors.New("restricted browser unavailable")
- }
- websocketURL, parseErr := url.Parse(target.WebSocketDebuggerURL)
- if parseErr != nil || websocketURL.Scheme != "ws" || !strings.HasPrefix(websocketURL.Path, "/devtools/page/") {
- return nil, errors.New("restricted browser unavailable")
- }
- if websocketURL.Hostname() == "localhost" || websocketURL.Hostname() == "127.0.0.1" || websocketURL.Hostname() == "::1" {
- websocketURL.Host = baseURL.Host
- }
- if websocketURL.Host != baseURL.Host {
- return nil, errors.New("restricted browser unavailable")
- }
- pageTarget = websocketURL.String()
- }
- if pageTarget == "" {
- return nil, errors.New("restricted browser unavailable")
- }
- config, err := websocket.NewConfig(pageTarget, "devtools://devtools")
- if err != nil {
- return nil, errors.New("restricted browser unavailable")
- }
- connection, err := config.DialContext(ctx)
- if err != nil {
- logrus.WithError(err).WithField("browser_id", alias).Error("restricted browser websocket failed")
- return nil, errors.New("restricted browser unavailable")
- }
- deadline := time.Now().Add(browserControlTimeout)
- if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
- deadline = contextDeadline
- }
- _ = connection.SetDeadline(deadline)
- logrus.WithFields(logrus.Fields{"browser_id": alias, "cdp_endpoint": pageTarget}).Debug("restricted browser websocket connected")
- return connection, nil
-}
-
-type cdpMessage struct {
- ID int `json:"id"`
- Method string `json:"method"`
- Params json.RawMessage `json:"params"`
- Result json.RawMessage `json:"result"`
- Error json.RawMessage `json:"error"`
-}
-
-func cdpCommand(connection *websocket.Conn, commandID *int, method string, parameters any, output any, events *[]cdpMessage) error {
- *commandID = *commandID + 1
- if err := websocket.JSON.Send(connection, map[string]any{"id": *commandID, "method": method, "params": parameters}); err != nil {
- logrus.WithError(err).WithField("cdp_method", method).Error("restricted browser command send failed")
- return errors.New("restricted browser command failed")
- }
- for range 128 {
- var reply cdpMessage
- if err := websocket.JSON.Receive(connection, &reply); err != nil {
- logrus.WithError(err).WithField("cdp_method", method).Error("restricted browser command receive failed")
- return errors.New("restricted browser command failed")
- }
- if reply.ID != *commandID {
- if events != nil && reply.ID == 0 && reply.Method != "" {
- *events = append(*events, reply)
- }
- continue
- }
- if len(reply.Error) != 0 || len(reply.Result) == 0 || bytes.Equal(bytes.TrimSpace(reply.Result), []byte("null")) {
- logrus.WithFields(logrus.Fields{"cdp_method": method, "cdp_error": string(reply.Error), "has_result": len(reply.Result) != 0}).Error("restricted browser command returned failure")
- return errors.New("restricted browser command failed")
- }
- if output != nil && json.Unmarshal(reply.Result, output) != nil {
- return errors.New("restricted browser command failed")
- }
- return nil
- }
- return errors.New("restricted browser command failed")
-}
-
-func waitForDouyinPage(ctx context.Context, connection *websocket.Conn, commandID *int, frameID string, events []cdpMessage) error {
- deadline := time.Now().Add(10 * time.Second)
- if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
- deadline = contextDeadline
- }
- _ = connection.SetReadDeadline(deadline)
- bufferedLifecycle := map[string]bool{}
- for _, event := range events {
- if event.Method != "Page.lifecycleEvent" {
- continue
- }
- var lifecycle struct {
- FrameID string `json:"frameId"`
- LoaderID string `json:"loaderId"`
- Name string `json:"name"`
- }
- if json.Unmarshal(event.Params, &lifecycle) == nil && lifecycle.FrameID == frameID && (lifecycle.Name == "DOMContentLoaded" || lifecycle.Name == "load") {
- bufferedLifecycle[lifecycle.LoaderID+":"+lifecycle.Name] = true
- }
- }
- for {
- var event cdpMessage
- if len(events) != 0 {
- event, events = events[0], events[1:]
- } else if err := websocket.JSON.Receive(connection, &event); err != nil {
- logrus.WithError(err).Error("restricted browser lifecycle receive failed")
- return errors.New("restricted browser navigation failed")
- }
- if event.Method != "Page.lifecycleEvent" {
- continue
- }
- var lifecycle struct {
- FrameID string `json:"frameId"`
- LoaderID string `json:"loaderId"`
- Name string `json:"name"`
- }
- if err := json.Unmarshal(event.Params, &lifecycle); err != nil {
- logrus.WithError(err).Debug("restricted browser lifecycle decode failed")
- continue
- }
- if lifecycle.FrameID != frameID || lifecycle.Name != "DOMContentLoaded" && lifecycle.Name != "load" || bufferedLifecycle[lifecycle.LoaderID+":"+lifecycle.Name] {
- continue
- }
- var evaluated struct {
- Result struct {
- Value string `json:"value"`
- } `json:"result"`
- }
- if err := cdpCommand(connection, commandID, "Runtime.evaluate", map[string]any{
- "expression": "location.origin", "returnByValue": true,
- }, &evaluated, nil); err != nil || evaluated.Result.Value != douyinOrigin {
- logrus.WithFields(logrus.Fields{"frame_id": lifecycle.FrameID, "loader_id": lifecycle.LoaderID,
- "origin": evaluated.Result.Value}).Error("restricted browser navigation origin failed")
- return errors.New("restricted browser navigation failed")
- }
- return nil
- }
-}
-
-func detectDouyinChallenge(status int, body string) douyin.Challenge {
- lower := strings.ToLower(body)
- if status == http.StatusPreconditionFailed || strings.Contains(lower, "captcha") || strings.Contains(lower, "verify_center_decision_conf") {
- return douyin.ChallengeCaptcha
- }
- if strings.Contains(lower, "device_challenge") || strings.Contains(lower, "device verification") {
- return douyin.ChallengeDevice
- }
- return douyin.ChallengeNone
-}
diff --git a/cmd/docker-gateway/douyin_cdp_integration_test.go b/cmd/docker-gateway/douyin_cdp_integration_test.go
deleted file mode 100644
index 83f3c3f..0000000
--- a/cmd/docker-gateway/douyin_cdp_integration_test.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package main
-
-import (
- "context"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
- "testing"
- "time"
-
- "git.ipao.vip/rogee/creator-hub/internal/douyin"
-)
-
-func TestCDPBrowserRealNavigationIsolation(t *testing.T) {
- if os.Getenv("CREATORHUB_REAL_CDP_TEST") != "1" {
- t.Skip("set CREATORHUB_REAL_CDP_TEST=1 to run against local Chrome")
- }
- chrome, err := exec.LookPath("google-chrome")
- if err != nil {
- t.Skip("google-chrome is unavailable")
- }
-
- var mu sync.Mutex
- phase := "redirect"
- release := make(chan struct{})
- mainRequests := make(chan string, 4)
- loginRequests := make(chan string, 1)
- subdomainRequests := make(chan string, 1)
- server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- host := request.Host
- if name, _, splitErr := net.SplitHostPort(host); splitErr == nil {
- host = name
- }
- switch host {
- case "login.douyin.com":
- loginRequests <- request.Header.Get("Cookie")
- _, _ = response.Write([]byte("redirected"))
- case "api.douyin.com":
- subdomainRequests <- request.Header.Get("Cookie")
- _, _ = response.Write([]byte("ok"))
- case "www.douyin.com":
- if request.URL.Path == "/" {
- mainRequests <- request.Header.Get("Cookie")
- }
- mu.Lock()
- currentPhase, currentRelease := phase, release
- mu.Unlock()
- if currentPhase == "redirect" {
- http.Redirect(response, request, "https://login.douyin.com/landing", http.StatusFound)
- return
- }
- if currentPhase == "blocked" {
- <-currentRelease
- }
- response.Header().Set("Content-Type", "text/html")
- _, _ = response.Write([]byte(``))
- default:
- response.WriteHeader(http.StatusNotFound)
- }
- }))
- t.Cleanup(server.Close)
- allowedHosts := map[string]bool{"www.douyin.com": true, "login.douyin.com": true, "api.douyin.com": true}
- proxy := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- host := request.Host
- if name, _, splitErr := net.SplitHostPort(host); splitErr == nil {
- host = name
- }
- if request.Method != http.MethodConnect || !allowedHosts[host] {
- response.WriteHeader(http.StatusForbidden)
- return
- }
- upstream, dialErr := net.Dial("tcp", server.Listener.Addr().String())
- if dialErr != nil {
- response.WriteHeader(http.StatusBadGateway)
- return
- }
- client, _, hijackErr := response.(http.Hijacker).Hijack()
- if hijackErr != nil {
- upstream.Close()
- return
- }
- _, _ = client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
- go func() {
- _, _ = io.Copy(upstream, client)
- _ = upstream.Close()
- }()
- _, _ = io.Copy(client, upstream)
- _ = client.Close()
- }))
- t.Cleanup(proxy.Close)
-
- profile := t.TempDir()
- command := exec.Command(chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-background-networking", "--disable-quic",
- "--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check", "--password-store=basic", "--use-mock-keychain",
- "--ignore-certificate-errors", "--proxy-server="+proxy.URL,
- "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0", "--remote-allow-origins=*",
- "--user-data-dir="+profile, "about:blank")
- command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- if err := command.Start(); err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
- _ = command.Wait()
- })
-
- var debugPort string
- for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(25 * time.Millisecond) {
- content, readErr := os.ReadFile(filepath.Join(profile, "DevToolsActivePort"))
- if readErr == nil {
- debugPort = strings.SplitN(string(content), "\n", 2)[0]
- break
- }
- }
- if debugPort == "" {
- t.Fatal("Chrome did not expose a DevTools port")
- }
- time.Sleep(250 * time.Millisecond)
- browser := cdpBrowser{endpoint: func(string) string { return "http://127.0.0.1:" + debugPort }}
- cookie := []douyin.Cookie{{Name: "sessionid", Value: "fake-secret", Domain: ".douyin.com", Path: "/", Secure: true}}
-
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- redirectErr := browser.SetCookies(ctx, "account-a", cookie)
- if redirectErr == nil {
- cancel()
- t.Fatal("accepted a redirected navigation")
- }
- cancel()
- select {
- case got := <-loginRequests:
- if strings.Contains(got, "fake-secret") {
- t.Fatal("cookie leaked during redirected navigation")
- }
- case <-time.After(3 * time.Second):
- t.Fatalf("redirect target was not reached: %v", redirectErr)
- }
-
- mu.Lock()
- phase = "success"
- mu.Unlock()
- ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second)
- if err := browser.SetCookies(ctx, "account-a", cookie); err != nil {
- cancel()
- t.Fatal(err)
- }
- cancel()
- select {
- case <-mainRequests:
- case <-time.After(3 * time.Second):
- t.Fatal("successful navigation was not observed")
- }
-
- mu.Lock()
- phase = "blocked"
- release = make(chan struct{})
- currentRelease := release
- mu.Unlock()
- defer func() {
- select {
- case <-currentRelease:
- default:
- close(currentRelease)
- }
- }()
- done := make(chan error, 1)
- ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second)
- go func() { done <- browser.SetCookies(ctx, "account-a", cookie) }()
- select {
- case got := <-mainRequests:
- if strings.Contains(got, "fake-secret") {
- cancel()
- t.Fatal("cookie leaked during navigation")
- }
- case <-time.After(3 * time.Second):
- cancel()
- t.Fatal("blocked navigation was not observed")
- }
- select {
- case err := <-done:
- cancel()
- t.Fatalf("navigation completed before its loader: %v", err)
- case <-time.After(100 * time.Millisecond):
- }
- close(currentRelease)
- if err := <-done; err != nil {
- cancel()
- t.Fatal(err)
- }
- cancel()
- select {
- case got := <-subdomainRequests:
- if strings.Contains(got, "fake-secret") {
- t.Fatal("host-only cookie leaked to a subdomain")
- }
- case <-time.After(3 * time.Second):
- t.Fatal("subdomain probe did not run")
- }
-}
diff --git a/cmd/docker-gateway/douyin_test.go b/cmd/docker-gateway/douyin_test.go
deleted file mode 100644
index f72cd6f..0000000
--- a/cmd/docker-gateway/douyin_test.go
+++ /dev/null
@@ -1,410 +0,0 @@
-package main
-
-import (
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "strings"
- "sync"
- "testing"
- "time"
-
- "git.ipao.vip/rogee/creator-hub/internal/douyin"
- "golang.org/x/net/websocket"
-)
-
-const douyinIdentityURL = "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"
-
-type fakeRestrictedBrowser struct {
- cookies []douyin.Cookie
- urls []string
- response restrictedBrowserResponse
- after func()
-}
-
-func (browser *fakeRestrictedBrowser) SetCookies(_ context.Context, _ string, cookies []douyin.Cookie) error {
- browser.cookies = cookies
- if browser.after != nil {
- browser.after()
- }
- return nil
-}
-
-func (browser *fakeRestrictedBrowser) Get(_ context.Context, _ string, target string) (restrictedBrowserResponse, error) {
- browser.urls = append(browser.urls, target)
- if browser.after != nil {
- browser.after()
- }
- return browser.response, nil
-}
-
-func TestGatewayRestrictedDouyinContract(t *testing.T) {
- labels := map[string]string{
- managedLabel: "true", idLabel: "account-a", bindingVersionLabel: "2", networkIDLabel: "network-a", networkExitLabel: "exit-a",
- }
- self, _ := os.Hostname()
- var stateMu sync.Mutex
- containerNetworks := map[string]string{"creatorhub_browser-account-a": "network-a"}
- runtimeAttached := true
- server := httptest.NewServer(withAliasReservations(self, func(response http.ResponseWriter, request *http.Request) {
- switch request.URL.Path {
- case "/containers/" + namePrefix + "account-a/json":
- stateMu.Lock()
- labelCopy, networkCopy := map[string]string{}, map[string]any{}
- for key, value := range labels {
- labelCopy[key] = value
- }
- for name, id := range containerNetworks {
- networkCopy[name] = map[string]string{"NetworkID": id}
- }
- stateMu.Unlock()
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": "runtime-a", "Config": map[string]any{"Labels": labelCopy},
- "NetworkSettings": map[string]any{"Networks": networkCopy}})
- case "/networks/network-a":
- stateMu.Lock()
- members := map[string]any{self: map[string]string{"Name": self, "IPv4Address": "127.0.0.1/8"}}
- if runtimeAttached {
- members["runtime-a"] = map[string]string{"Name": namePrefix + "account-a", "IPv4Address": "127.0.0.2/8"}
- }
- stateMu.Unlock()
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-a", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "2"},
- "Containers": members,
- })
- default:
- response.WriteHeader(http.StatusNotFound)
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- browser := &fakeRestrictedBrowser{response: restrictedBrowserResponse{Status: 200, Body: `{"status_code":0}`, Challenge: douyin.ChallengeNone}}
- app := newGatewayWithBrowser(docker, "creatorhub_browser", testToken, self, browser)
- generation := `"binding_version":2,"runtime_id":"runtime-a","network_id":"network-a","network_exit_id":"exit-a"`
-
- cookieBody := `{` + generation + `,"cookies":[{"name":"sessionid","value":"private-session","domain":".douyin.com","path":"/"}]}`
- response, err := app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/cookies", strings.NewReader(cookieBody)))
- if err != nil || response.StatusCode != http.StatusNoContent || len(browser.cookies) != 1 || browser.cookies[0].Value != "private-session" {
- t.Fatalf("set cookies failed: status=%d cookies=%#v err=%v", response.StatusCode, browser.cookies, err)
- }
- response.Body.Close()
-
- identityURL := douyinIdentityURL
- getBody := `{` + generation + `,"url":"` + identityURL + `"}`
- response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody)))
- body, _ := io.ReadAll(response.Body)
- response.Body.Close()
- if err != nil || response.StatusCode != http.StatusOK || len(browser.urls) != 1 || browser.urls[0] != identityURL ||
- !strings.Contains(string(body), `\"status_code\":0`) || strings.Contains(string(body), "private-session") {
- t.Fatalf("get failed or leaked cookies: status=%d urls=%#v body=%s err=%v", response.StatusCode, browser.urls, body, err)
- }
-
- for name, request := range map[string]*http.Request{
- "unauthenticated": httptest.NewRequest(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody)),
- "invalid account": authed(http.MethodPost, "/v1/browsers/AccountA/douyin/get", strings.NewReader(getBody)),
- "generic URL": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(`{`+generation+`,"url":"https://example.com/"}`)),
- "generic CDP": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(`{`+generation+`,"url":"`+identityURL+`","method":"Runtime.evaluate"}`)),
- "trailing null": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody+`null`)),
- "stale generation": authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(`{"binding_version":1,"runtime_id":"runtime-a","network_id":"network-a","network_exit_id":"exit-a","url":"`+identityURL+`"}`)),
- } {
- t.Run(name, func(t *testing.T) {
- before := len(browser.urls)
- response, err := app.Test(request)
- if err != nil {
- t.Fatal(err)
- }
- response.Body.Close()
- want := http.StatusBadRequest
- if name == "unauthenticated" {
- want = http.StatusUnauthorized
- } else if name == "stale generation" {
- want = http.StatusConflict
- }
- if response.StatusCode != want || len(browser.urls) != before {
- t.Fatalf("status=%d want=%d calls=%d want=%d", response.StatusCode, want, len(browser.urls), before)
- }
- })
- }
-
- stateMu.Lock()
- runtimeAttached = false
- stateMu.Unlock()
- before := len(browser.urls)
- response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/get", strings.NewReader(getBody)))
- if err != nil || response.StatusCode != http.StatusConflict || len(browser.urls) != before {
- t.Fatalf("wrong network membership reached browser: status=%d calls=%d want=%d err=%v", response.StatusCode, len(browser.urls), before, err)
- }
- response.Body.Close()
- stateMu.Lock()
- runtimeAttached = true
- stateMu.Unlock()
-
- browser.after = func() {
- stateMu.Lock()
- containerNetworks["other-tenant"] = "network-b"
- stateMu.Unlock()
- }
- response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/cookies", strings.NewReader(cookieBody)))
- if err != nil || response.StatusCode != http.StatusConflict {
- t.Fatalf("post-operation cross-network attachment was accepted: status=%d err=%v", response.StatusCode, err)
- }
- response.Body.Close()
- stateMu.Lock()
- delete(containerNetworks, "other-tenant")
- stateMu.Unlock()
-
- browser.after = func() {
- stateMu.Lock()
- labels[networkIDLabel] = "network-replaced"
- stateMu.Unlock()
- }
- response, err = app.Test(authed(http.MethodPost, "/v1/browsers/account-a/douyin/cookies", strings.NewReader(cookieBody)))
- if err != nil || response.StatusCode != http.StatusConflict {
- t.Fatalf("post-operation generation replacement was accepted: status=%d err=%v", response.StatusCode, err)
- }
- response.Body.Close()
-}
-
-func TestCDPBrowserUsesOnlyNarrowCommands(t *testing.T) {
- var server *httptest.Server
- var mu sync.Mutex
- methods := []string{}
- secretSeen := false
- cookieNames := []string{}
- cookiesHostOnly := true
- setCookieCalls := 0
- pageOrigin := douyinOrigin
- onlyOldLoader := false
- fetchMode := "ok"
- fetchExpression := ""
- mux := http.NewServeMux()
- mux.HandleFunc("/json/list", func(response http.ResponseWriter, request *http.Request) {
- wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/devtools/page/one"
- _ = json.NewEncoder(response).Encode([]map[string]string{{"type": "page", "webSocketDebuggerUrl": wsURL}})
- })
- mux.Handle("/devtools/page/one", websocket.Server{
- Handshake: func(*websocket.Config, *http.Request) error { return nil },
- Handler: func(connection *websocket.Conn) {
- for {
- var command struct {
- ID int `json:"id"`
- Method string `json:"method"`
- Params json.RawMessage `json:"params"`
- }
- if websocket.JSON.Receive(connection, &command) != nil {
- return
- }
- mu.Lock()
- methods = append(methods, command.Method)
- secretSeen = secretSeen || strings.Contains(string(command.Params), "private-session")
- result := any(map[string]any{})
- var beforeReply, afterReply []map[string]any
- switch command.Method {
- case "Network.clearBrowserCookies":
- cookieNames = nil
- case "Network.setCookies":
- var params struct {
- Cookies []map[string]any `json:"cookies"`
- }
- _ = json.Unmarshal(command.Params, ¶ms)
- setCookieCalls++
- cookieNames = cookieNames[:0]
- for _, cookie := range params.Cookies {
- cookieNames = append(cookieNames, cookie["name"].(string))
- _, hasDomain := cookie["domain"]
- cookiesHostOnly = cookiesHostOnly && !hasDomain && cookie["url"] == douyinOriginURL
- }
- case "Page.navigate":
- result = map[string]any{"frameId": "frame-new", "loaderId": "loader-new"}
- beforeReply = append(beforeReply, map[string]any{"method": "Page.lifecycleEvent", "params": map[string]any{
- "frameId": "frame-old", "loaderId": "loader-old", "name": "load",
- }})
- if !onlyOldLoader {
- afterReply = append(afterReply, map[string]any{"method": "Page.lifecycleEvent", "params": map[string]any{
- "frameId": "frame-new", "loaderId": "loader-final", "name": "load",
- }})
- }
- case "Runtime.evaluate":
- var params struct {
- Expression string `json:"expression"`
- }
- _ = json.Unmarshal(command.Params, ¶ms)
- if params.Expression == "location.origin" {
- result = map[string]any{"result": map[string]any{"value": pageOrigin}}
- } else {
- fetchExpression = params.Expression
- value := map[string]any{"status": 412, "body": `{"captcha":true}`, "too_large": false}
- if fetchMode == "redirect" {
- value = map[string]any{"status": 302, "body": "", "too_large": false}
- } else if fetchMode == "too_large" {
- value = map[string]any{"too_large": true}
- }
- result = map[string]any{"result": map[string]any{"value": map[string]any{
- "status": value["status"], "body": value["body"], "too_large": value["too_large"],
- }}}
- }
- }
- mu.Unlock()
- for _, event := range beforeReply {
- _ = websocket.JSON.Send(connection, event)
- }
- _ = websocket.JSON.Send(connection, map[string]any{"id": command.ID, "result": result})
- for _, event := range afterReply {
- _ = websocket.JSON.Send(connection, event)
- }
- }
- },
- })
- server = httptest.NewServer(mux)
- defer server.Close()
- browser := cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}
- if err := browser.SetCookies(context.Background(), "account-a", []douyin.Cookie{{
- Name: "old_auth", Value: "old-session", Domain: ".douyin.com", Path: "/",
- }}); err != nil {
- t.Fatal(err)
- }
- if err := browser.SetCookies(context.Background(), "account-a", []douyin.Cookie{{
- Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/",
- }}); err != nil {
- t.Fatal(err)
- }
- result, err := browser.Get(context.Background(), "account-a", douyinIdentityURL)
- if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha {
- t.Fatalf("unexpected CDP response: %#v err=%v", result, err)
- }
- mu.Lock()
- if !secretSeen || !cookiesHostOnly || strings.Join(cookieNames, ",") != "sessionid" ||
- strings.Join(methods, ",") != "Network.enable,Network.clearBrowserCookies,Page.enable,Page.setLifecycleEventsEnabled,Page.navigate,Runtime.evaluate,Network.setCookies,"+
- "Network.enable,Network.clearBrowserCookies,Page.enable,Page.setLifecycleEventsEnabled,Page.navigate,Runtime.evaluate,Network.setCookies,Runtime.evaluate,Runtime.evaluate" ||
- !strings.Contains(fetchExpression, `redirect:"error"`) || !strings.Contains(fetchExpression, "getReader()") ||
- !strings.Contains(fetchExpression, "q.cancel()") || !strings.Contains(fetchExpression, ">=1048576") || strings.Contains(fetchExpression, "r.text()") {
- mu.Unlock()
- t.Fatalf("unexpected CDP contract: methods=%#v cookies=%#v secret_seen=%v expression=%s", methods, cookieNames, secretSeen, fetchExpression)
- }
- pageOrigin = "https://login.douyin.com"
- setCookiesBeforeRedirect := setCookieCalls
- mu.Unlock()
- if err := browser.SetCookies(context.Background(), "account-a", []douyin.Cookie{{
- Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/",
- }}); err == nil {
- t.Fatal("accepted navigation redirected to a Douyin subdomain")
- }
- mu.Lock()
- if setCookieCalls != setCookiesBeforeRedirect {
- mu.Unlock()
- t.Fatal("set cookies before rejecting redirected navigation")
- }
- pageOrigin, fetchMode = douyinOrigin, "redirect"
- mu.Unlock()
- if _, err := browser.Get(context.Background(), "account-a", douyinIdentityURL); err == nil {
- t.Fatal("accepted a redirected fetch")
- }
- mu.Lock()
- fetchMode = "too_large"
- mu.Unlock()
- if _, err := browser.Get(context.Background(), "account-a", douyinIdentityURL); err == nil {
- t.Fatal("accepted a response at the 1 MiB limit")
- }
- mu.Lock()
- onlyOldLoader = true
- setCookiesBeforeOldLoader := setCookieCalls
- mu.Unlock()
- ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cancel()
- if err := browser.SetCookies(ctx, "account-a", []douyin.Cookie{{
- Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/",
- }}); err == nil {
- t.Fatal("accepted an old page load event for the new navigation")
- }
- mu.Lock()
- defer mu.Unlock()
- if setCookieCalls != setCookiesBeforeOldLoader {
- t.Fatal("set cookies before the new loader completed")
- }
-}
-
-func TestCDPDiscoveryDoesNotFollowRedirects(t *testing.T) {
- redirected := 0
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- if request.URL.Path == "/json/list" {
- http.Redirect(response, request, "/redirected", http.StatusFound)
- return
- }
- redirected++
- response.WriteHeader(http.StatusInternalServerError)
- }))
- defer server.Close()
- browser := cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}
- if _, err := browser.connect(context.Background(), "account-a"); err == nil || redirected != 0 {
- t.Fatalf("discovery redirect was followed: redirected=%d err=%v", redirected, err)
- }
-}
-
-func TestCDPDiscoveryRequiresOnePage(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- _ = json.NewEncoder(response).Encode([]map[string]string{
- {"type": "page", "webSocketDebuggerUrl": "ws://localhost/devtools/page/one"},
- {"type": "page", "webSocketDebuggerUrl": "ws://localhost/devtools/page/two"},
- })
- }))
- defer server.Close()
- browser := cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}
- if _, err := browser.connect(context.Background(), "account-a"); err == nil {
- t.Fatal("accepted a profile with multiple page targets")
- }
-}
-
-func TestCDPDiscoveryRequiresOneJSONValue(t *testing.T) {
- for name, suffix := range map[string]string{
- "null": "null", "other value": `{}`, "garbage": "garbage", "oversized": strings.Repeat(" ", 64<<10), "whitespace": " \n\t",
- } {
- t.Run(name, func(t *testing.T) {
- websocketAttempts := 0
- var server *httptest.Server
- mux := http.NewServeMux()
- mux.HandleFunc("/json/list", func(response http.ResponseWriter, request *http.Request) {
- wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/devtools/page/one"
- _, _ = response.Write([]byte(`[{"type":"page","webSocketDebuggerUrl":"` + wsURL + `"}]` + suffix))
- })
- mux.Handle("/devtools/page/one", websocket.Server{
- Handshake: func(*websocket.Config, *http.Request) error {
- websocketAttempts++
- return nil
- },
- Handler: func(connection *websocket.Conn) {},
- })
- server = httptest.NewServer(mux)
- defer server.Close()
-
- connection, err := (cdpBrowser{endpoint: func(string) string { return server.URL }, client: server.Client()}).connect(context.Background(), "account-a")
- if connection != nil {
- connection.Close()
- }
- valid := name == "whitespace"
- wantAttempts := 0
- if valid {
- wantAttempts = 1
- }
- if (err == nil) != valid || websocketAttempts != wantAttempts {
- t.Fatalf("err=%v websocket attempts=%d", err, websocketAttempts)
- }
- })
- }
-}
-
-func TestDouyinURLContract(t *testing.T) {
- for target, want := range map[string]bool{
- douyinIdentityURL: true,
- "https://www.douyin.com" + douyinWorksPath + "?sec_user_id=sec-a&count=20&max_cursor=0": true,
- "https://www.douyin.com" + douyinWorksPath + "?sec_user_id=sec-a&count=20&max_cursor=1": true,
- "https://www.douyin.com" + douyinWorksPath + "?sec_user_id=sec-a&count=20&max_cursor=0&method=publish": false,
- "https://www.douyin.com/aweme/v1/web/commit/item/": false,
- } {
- if got := validDouyinURL(target); got != want {
- t.Fatalf("validDouyinURL(%q)=%v want=%v", target, got, want)
- }
- }
-}
diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go
deleted file mode 100644
index ebb8ca2..0000000
--- a/cmd/docker-gateway/main.go
+++ /dev/null
@@ -1,1547 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "crypto/subtle"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/url"
- "os"
- "os/signal"
- "regexp"
- "strconv"
- "strings"
- "syscall"
- "time"
- "unicode/utf8"
-
- "github.com/gofiber/fiber/v3"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/viper"
-)
-
-const (
- browserUser = "1000:1000"
- browserEntrypoint = "/usr/local/bin/docker-entrypoint.sh"
- managedLabel = "io.creatorhub.managed"
- idLabel = "io.creatorhub.runtime-id"
- nameLabel = "io.creatorhub.display-name"
- bindingVersionLabel = "io.creatorhub.binding-version"
- networkExitLabel = "io.creatorhub.network-exit-id"
- proxyPortLabel = "io.creatorhub.proxy-port"
- networkIDLabel = "io.creatorhub.network-id"
- networkRoleLabel = "io.creatorhub.network-role"
- gatewayMemberLabel = "io.creatorhub.gateway-member"
- browserNetworkRole = "browser"
- controlNetworkName = "creatorhub_control"
- namePrefix = "creatorhub-browser-"
- reservationPrefix = "creatorhub-reservation-"
- reservationLabel = "io.creatorhub.alias-reservation"
- reservationGenLabel = "io.creatorhub.reservation-generation"
- pullTimeout = 10 * time.Minute
-)
-
-var (
- runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
- networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
- imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`)
- volumePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
- exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
-)
-
-var (
- errInvalidRuntimeID = errors.New("invalid runtime id")
- errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub")
- errGenerationConflict = errors.New("container generation does not match request")
- errUnauthorized = errors.New("gateway token rejected")
-)
-
-type serviceConfig struct {
- listenAddr string
- dockerSock string
- network string
- token string
- logLevel logrus.Level
-}
-
-type dockerClient struct {
- baseURL string
- client *http.Client // 常规 Docker 调用
- slow *http.Client // 镜像拉取等长操作,不设整体超时
-}
-
-type dockerAliasReservations struct {
- docker dockerClient
- self string
-}
-
-type tenantNetworkGeneration struct {
- ID string
- Name string
- Created bool
- ConnectedSelf bool
- ConnectedRuntime bool
- GatewayMembers []string
- SelfMember string
- RuntimeAttached bool
-}
-
-type dockerTenantNetwork struct {
- ID string `json:"Id"`
- Name string `json:"Name"`
- Driver string `json:"Driver"`
- Internal bool `json:"Internal"`
- Attachable bool `json:"Attachable"`
- Ingress bool `json:"Ingress"`
- Labels map[string]string `json:"Labels"`
- Containers map[string]struct {
- Name string `json:"Name"`
- IPv4Address string `json:"IPv4Address"`
- } `json:"Containers"`
-}
-
-type gateway struct {
- docker dockerClient
- network string
- self string
- token string
- browser restrictedBrowser
- proxies *memoryProxyRegistry
- locks *dockerAliasReservations
-}
-
-// createRequest 全量字段由平台下发;网关不做业务决策,只做输入合法性校验。
-type createRequest struct {
- Alias string `json:"alias"`
- Name string `json:"name"`
- Image string `json:"image"`
- Cmd []string `json:"cmd"`
- Volume string `json:"volume"`
- BindingVersion int64 `json:"binding_version"`
- NetworkExitID string `json:"network_exit_id"`
- NetworkExit gatewayProxyExit `json:"network_exit"`
- Stopped bool `json:"stopped,omitempty"`
-}
-
-type gatewayProxyExit struct {
- Protocol string `json:"protocol"`
- Host string `json:"host"`
- Port int `json:"port"`
- Username string `json:"username"`
- Password string `json:"password"`
-}
-
-type generationRequest struct {
- BindingVersion int64 `json:"binding_version"`
- RuntimeID string `json:"runtime_id"`
- NetworkID string `json:"network_id"`
-}
-
-type proxyRestoreRequest struct {
- BindingVersion int64 `json:"binding_version"`
- RuntimeID string `json:"runtime_id"`
- NetworkID string `json:"network_id"`
- NetworkExitID string `json:"network_exit_id"`
- NetworkExit gatewayProxyExit `json:"network_exit"`
-}
-
-type browser struct {
- ID string `json:"id"`
- Alias string `json:"alias"`
- Name string `json:"name"`
- State string `json:"state"`
- Status string `json:"status"`
- Endpoint string `json:"endpoint"`
- BindingVersion int64 `json:"binding_version"`
- NetworkExitID string `json:"network_exit_id"`
- NetworkID string `json:"network_id"`
- ProxyReady bool `json:"proxy_ready"`
-}
-
-func main() {
- logrus.SetFormatter(&logrus.JSONFormatter{})
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
- if err := newCommand().ExecuteContext(ctx); err != nil {
- logrus.WithField("service", "docker-gateway").WithError(err).Error("service stopped")
- os.Exit(1)
- }
-}
-
-func newCommand() *cobra.Command {
- command := &cobra.Command{
- Use: "docker-gateway",
- Short: "Run the restricted CreatorHub Docker gateway",
- Args: cobra.NoArgs,
- SilenceErrors: true,
- SilenceUsage: true,
- RunE: func(command *cobra.Command, _ []string) error {
- cfg, err := loadConfig()
- if err != nil {
- return err
- }
- logrus.SetLevel(cfg.logLevel)
- return run(command, cfg)
- },
- }
- return command
-}
-
-func loadConfig() (serviceConfig, error) {
- v := viper.New()
- v.SetDefault("listen_addr", ":8081")
- v.SetDefault("docker_socket", "/var/run/docker.sock")
- v.SetDefault("browser_network", "creatorhub_browser")
- v.SetDefault("log_level", "info")
- _ = v.BindEnv("listen_addr", "LISTEN_ADDR")
- _ = v.BindEnv("docker_socket", "DOCKER_SOCKET")
- _ = v.BindEnv("browser_network", "BROWSER_NETWORK")
- _ = v.BindEnv("gateway_token", "GATEWAY_TOKEN")
- _ = v.BindEnv("log_level", "LOG_LEVEL")
-
- level, err := logrus.ParseLevel(v.GetString("log_level"))
- if err != nil {
- return serviceConfig{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace")
- }
- cfg := serviceConfig{
- listenAddr: strings.TrimSpace(v.GetString("listen_addr")),
- dockerSock: strings.TrimSpace(v.GetString("docker_socket")),
- network: strings.TrimSpace(v.GetString("browser_network")),
- token: strings.TrimSpace(v.GetString("gateway_token")),
- logLevel: level,
- }
- if cfg.listenAddr == "" {
- return serviceConfig{}, errors.New("LISTEN_ADDR must not be empty")
- }
- if err := validateListenAddr(cfg.listenAddr); err != nil {
- return serviceConfig{}, err
- }
- if cfg.dockerSock == "" {
- return serviceConfig{}, errors.New("DOCKER_SOCKET must not be empty")
- }
- if len(cfg.token) < 16 {
- return serviceConfig{}, errors.New("GATEWAY_TOKEN must be at least 16 characters")
- }
- if err := validateBrowserNetwork(cfg.network); err != nil {
- return serviceConfig{}, err
- }
- 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 run(command *cobra.Command, cfg serviceConfig) error {
- transport := &http.Transport{
- DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
- return (&net.Dialer{}).DialContext(ctx, "unix", cfg.dockerSock)
- },
- }
- defer transport.CloseIdleConnections()
- docker := dockerClient{
- baseURL: "http://docker/v1.43",
- client: &http.Client{Transport: transport, Timeout: 30 * time.Second},
- slow: &http.Client{Transport: transport},
- }
- logrus.WithFields(logrus.Fields{
- "service": "docker-gateway",
- "listen_addr": cfg.listenAddr,
- "network": cfg.network,
- }).Info("service starting")
- return newGateway(docker, cfg.network, cfg.token).Listen(cfg.listenAddr, fiber.ListenConfig{
- GracefulContext: command.Context(),
- DisableStartupMessage: true,
- })
-}
-
-func newGateway(client dockerClient, network, token string) *fiber.App {
- self, _ := os.Hostname()
- return newGatewayWithSelf(client, network, token, self)
-}
-
-func newGatewayWithSelf(client dockerClient, network, token, self string) *fiber.App {
- return newGatewayWithBrowser(client, network, token, self, cdpBrowser{})
-}
-
-func newGatewayWithBrowser(client dockerClient, network, token, self string, browser restrictedBrowser) *fiber.App {
- api := gateway{docker: client, network: network, self: self, token: token, proxies: newMemoryProxyRegistry(),
- locks: &dockerAliasReservations{docker: client, self: self}, browser: browser}
- app := fiber.New(fiber.Config{
- AppName: "CreatorHub Docker gateway",
- BodyLimit: 1 << 20,
- ReadTimeout: 5 * time.Second,
- IdleTimeout: 60 * time.Second,
- ErrorHandler: func(c fiber.Ctx, err error) error {
- status := http.StatusInternalServerError
- var fiberError *fiber.Error
- if errors.As(err, &fiberError) && fiberError != nil {
- status = fiberError.Code
- }
- return writeError(c, status, err)
- },
- })
- app.Get("/healthz", func(c fiber.Ctx) error {
- c.Status(fiber.StatusNoContent)
- return nil
- })
- app.Use("/v1", api.authorize)
- app.Get("/v1/browsers", api.list)
- app.Post("/v1/browsers", api.create)
- app.Post("/v1/browsers/:id/proxy", api.restoreProxy)
- app.Post("/v1/browsers/:id/douyin/cookies", api.setDouyinCookies)
- app.Post("/v1/browsers/:id/douyin/get", api.getDouyin)
- app.Post("/v1/browsers/:id/:action", api.changeState)
- app.Delete("/v1/browsers/:id", api.remove)
- return app
-}
-
-func (api gateway) authorize(c fiber.Ctx) error {
- expected := "Bearer " + api.token
- if subtle.ConstantTimeCompare([]byte(c.Get(fiber.HeaderAuthorization)), []byte(expected)) != 1 {
- return writeError(c, http.StatusUnauthorized, errUnauthorized)
- }
- return c.Next()
-}
-
-func (api gateway) list(c fiber.Ctx) error {
- filters, _ := json.Marshal(map[string][]string{"label": {managedLabel + "=true"}})
- result, err := api.docker.request(http.MethodGet, "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil)
- if err != nil {
- return writeError(c, http.StatusBadGateway, err)
- }
- defer result.Body.Close()
- if result.StatusCode != http.StatusOK {
- return forwardDockerError(c, result)
- }
-
- var containers []struct {
- ID string `json:"Id"`
- State string `json:"State"`
- Status string `json:"Status"`
- Labels map[string]string `json:"Labels"`
- }
- if err := json.NewDecoder(result.Body).Decode(&containers); err != nil {
- return writeError(c, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err))
- }
-
- browsers := make([]browser, 0, len(containers))
- for _, container := range containers {
- alias := container.Labels[idLabel]
- if !runtimeIDPattern.MatchString(alias) {
- continue
- }
- name := container.Labels[nameLabel]
- if name == "" {
- name = alias
- }
- bindingVersion, _ := strconv.ParseInt(container.Labels[bindingVersionLabel], 10, 64)
- proxyPort, _ := strconv.Atoi(container.Labels[proxyPortLabel])
- direct := container.Labels[networkExitLabel] == ""
- browsers = append(browsers, browser{
- ID: container.ID,
- Alias: alias,
- Name: name,
- State: container.State,
- Status: container.Status,
- Endpoint: "http://" + namePrefix + alias + ":9222",
- BindingVersion: bindingVersion,
- NetworkExitID: container.Labels[networkExitLabel],
- NetworkID: container.Labels[networkIDLabel],
- ProxyReady: direct || api.proxies.ready(alias, proxyPort, container.ID, container.Labels[networkIDLabel]),
- })
- }
- return writeJSON(c, http.StatusOK, browsers)
-}
-
-func (api gateway) create(c fiber.Ctx) error {
- var input createRequest
- decoder := json.NewDecoder(bytes.NewReader(c.Body()))
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(&input); err != nil {
- return writeError(c, http.StatusBadRequest, errors.New("body must contain only alias, name, image, cmd, volume, binding_version, network_exit_id, network_exit and stopped"))
- }
- if err := validateCreate(input); err != nil {
- return writeError(c, http.StatusBadRequest, err)
- }
- if err := api.docker.pullIfMissing(c.Context(), input.Image); err != nil {
- return writeError(c, http.StatusBadGateway, err)
- }
- if _, _, err := api.managedContainer(input.Alias); err == nil {
- return writeError(c, http.StatusConflict, errors.New("browser alias is already in use"))
- } else if !errors.Is(err, os.ErrNotExist) {
- return writeError(c, statusFor(err), err)
- }
- _, release, err := api.locks.acquire(input.Alias)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- defer release()
- if _, _, err := api.managedContainer(input.Alias); err == nil {
- return writeError(c, http.StatusConflict, errors.New("browser alias is already in use"))
- } else if !errors.Is(err, os.ErrNotExist) {
- return writeError(c, statusFor(err), err)
- }
- direct := input.NetworkExitID == ""
- network, proxyServer, undoProxy := "none", "", func() {}
- var networkGeneration tenantNetworkGeneration
- keepNetwork := input.Stopped
- if !input.Stopped {
- var err error
- var bindHost string
- networkGeneration, bindHost, err = api.docker.ensureTenantNetwork(api.network, input.Alias, api.self, input.BindingVersion, "", "", false)
- if networkGeneration.ID != "" {
- defer func() {
- if keepNetwork {
- return
- }
- var cleanupErr error
- if networkGeneration.Created {
- cleanupErr = api.removeTenantNetwork(input.Alias, input.BindingVersion, "", networkGeneration, nil, "", false)
- } else if networkGeneration.ConnectedSelf {
- _, cleanupErr = api.disconnectTenantNetworkMember(input.Alias, input.BindingVersion, "", networkGeneration,
- networkGeneration.SelfMember, nil, "", false)
- }
- if cleanupErr != nil {
- logrus.WithError(cleanupErr).WithField("alias", input.Alias).Error("rollback isolated browser network")
- }
- }()
- }
- if err != nil {
- return writeNetworkError(c, http.StatusBadGateway, errors.New("configure isolated browser network"), networkGeneration.ID)
- }
- network = networkGeneration.ID
- if !direct {
- proxyServer, undoProxy, err = api.proxies.configure(input.Alias, input.BindingVersion, bindHost, 0, input.NetworkExit, networkGeneration.ID)
- if err != nil {
- return writeNetworkError(c, statusFor(err), errors.Join(errors.New("configure in-memory proxy"), err), networkGeneration.ID)
- }
- }
- }
- keepProxy := false
- defer func() {
- if !keepProxy {
- undoProxy()
- }
- }()
-
- pidsLimit := int64(512)
- cmd := append([]string{}, input.Cmd...)
- if !input.Stopped && !direct {
- cmd = append(cmd[:len(cmd)-1], "--proxy-server="+proxyServer, "--disable-non-proxied-udp", cmd[len(cmd)-1])
- }
- payload := map[string]any{
- "Image": input.Image,
- "User": browserUser,
- "Entrypoint": []string{browserEntrypoint},
- "Cmd": cmd,
- "Env": []string{"REMOTE_DEBUGGING_PORT=9222"},
- "Labels": map[string]string{
- managedLabel: "true",
- idLabel: input.Alias,
- nameLabel: input.Name,
- bindingVersionLabel: strconv.FormatInt(input.BindingVersion, 10),
- networkExitLabel: input.NetworkExitID,
- networkIDLabel: networkGeneration.ID,
- proxyPortLabel: strconv.Itoa(proxyPort(proxyServer)),
- },
- "ExposedPorts": map[string]any{"9222/tcp": map[string]any{}},
- "HostConfig": map[string]any{
- "NetworkMode": network,
- "ReadonlyRootfs": true,
- "CapDrop": []string{"ALL"},
- "SecurityOpt": []string{"no-new-privileges"},
- "PidsLimit": &pidsLimit,
- "Memory": int64(1 << 30),
- "NanoCpus": int64(2_000_000_000),
- "Tmpfs": map[string]string{
- "/tmp": "rw,nosuid,nodev,noexec,mode=1777,size=256m",
- "/tmp/.X11-unix": "rw,nosuid,nodev,noexec,mode=1777,size=1m",
- "/dev/shm": "rw,nosuid,nodev,noexec,size=256m",
- "/home/ubuntu": "rw,nosuid,nodev,noexec,uid=1000,gid=1000,mode=700,size=64m",
- },
- "Mounts": []map[string]any{{
- "Type": "volume",
- "Source": input.Volume,
- "Target": "/data",
- }},
- },
- }
- result, err := api.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(namePrefix+input.Alias), payload)
- var created struct {
- ID string `json:"Id"`
- }
- status, createErr := http.StatusBadGateway, err
- if result != nil {
- if result.StatusCode == http.StatusConflict {
- status = http.StatusConflict
- }
- if result.StatusCode == http.StatusCreated {
- createErr = json.NewDecoder(result.Body).Decode(&created)
- } else {
- createErr = errors.New("Docker container creation failed")
- }
- result.Body.Close()
- }
- if createErr != nil || created.ID == "" {
- containerID, labels, inspectErr := api.managedContainer(input.Alias)
- if inspectErr == nil && labels[bindingVersionLabel] == strconv.FormatInt(input.BindingVersion, 10) &&
- labels[networkIDLabel] == networkGeneration.ID {
- created.ID = containerID
- } else {
- if createErr == nil {
- createErr = errors.New("Docker returned an invalid container id")
- }
- return writeNetworkError(c, status, createErr, networkGeneration.ID)
- }
- }
- if !input.Stopped {
- if !direct {
- if !api.proxies.bind(input.Alias, input.BindingVersion, proxyServer, created.ID, networkGeneration.ID) {
- cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
- if cleanupErr != nil {
- return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("proxy generation changed and container cleanup failed: %w", cleanupErr), networkGeneration.ID)
- }
- return writeNetworkError(c, http.StatusConflict, errGenerationConflict, networkGeneration.ID)
- }
- undoProxy = func() { api.proxies.remove(input.Alias, input.BindingVersion, created.ID) }
- }
- if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
- cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent)
- if cleanupErr != nil {
- return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr), networkGeneration.ID)
- }
- return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err), networkGeneration.ID)
- }
- }
- keepProxy, keepNetwork = !input.Stopped && !direct, true
- return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "alias": input.Alias, "network_id": networkGeneration.ID})
-}
-
-func validateCreate(input createRequest) error {
- if !runtimeIDPattern.MatchString(input.Alias) {
- return errors.New("alias must match [a-z0-9][a-z0-9-]{0,31}")
- }
- if input.Name == "" || utf8.RuneCountInString(input.Name) > 64 || hasControlRunes(input.Name) {
- return errors.New("name must be 1..64 visible characters")
- }
- if !imageRefPattern.MatchString(input.Image) {
- return errors.New("image must be a valid image reference")
- }
- if !volumePattern.MatchString(input.Volume) {
- return errors.New("volume must be a valid volume name")
- }
- direct := input.NetworkExitID == "" && input.NetworkExit == (gatewayProxyExit{})
- if input.BindingVersion < 1 || (input.Stopped && !direct) ||
- (!input.Stopped && !direct && !exitIDPattern.MatchString(input.NetworkExitID)) ||
- (input.NetworkExitID == "") != (input.NetworkExit == (gatewayProxyExit{})) {
- return errors.New("binding_version and network_exit_id must identify the current binding")
- }
- if len(input.Cmd) == 0 || len(input.Cmd) > 64 || input.Cmd[len(input.Cmd)-1] != "about:blank" {
- return errors.New("cmd must contain 1..64 arguments")
- }
- total := 0
- for _, arg := range input.Cmd {
- if arg == "" || hasControlRunes(arg) {
- return errors.New("cmd arguments must be non-empty visible strings")
- }
- if strings.HasPrefix(arg, "--proxy-server") || arg == "--disable-non-proxied-udp" {
- return errors.New("proxy arguments are platform-controlled")
- }
- total += len(arg)
- }
- if total > 4096 {
- return errors.New("cmd arguments exceed 4096 characters")
- }
- if input.Stopped || direct {
- return nil
- }
- proxy := input.NetworkExit
- if (proxy.Protocol != "http" && proxy.Protocol != "https" && proxy.Protocol != "socks4" && proxy.Protocol != "socks5") ||
- proxy.Host == "" || len(proxy.Host) > 253 || strings.ContainsAny(proxy.Host, "@/[]?# \t\r\n") ||
- proxy.Port < 1 || proxy.Port > 65535 || (proxy.Username == "" && proxy.Password != "") ||
- len(proxy.Username) > 255 || len(proxy.Password) > 255 ||
- hasControlRunes(proxy.Username) || hasControlRunes(proxy.Password) {
- return errors.New("network_exit must contain a valid proxy endpoint and optional credentials")
- }
- return nil
-}
-
-func proxyPort(proxyServer string) int {
- parsed, _ := url.Parse(proxyServer)
- port, _ := strconv.Atoi(parsed.Port())
- return port
-}
-
-func hasControlRunes(value string) bool {
- for _, r := range value {
- if r < 0x20 || r == 0x7f {
- return true
- }
- }
- return false
-}
-
-func (api gateway) changeState(c fiber.Ctx) error {
- id := c.Params("id")
- action := c.Params("action")
- var input generationRequest
- switch action {
- case "start":
- var err error
- input, err = decodeGeneration(c)
- if err != nil {
- return writeError(c, http.StatusBadRequest, err)
- }
- if input.NetworkID == "" {
- return writeError(c, http.StatusBadRequest, errors.New("network_id must identify the expected network generation"))
- }
- _, exists, err := api.requireGeneration(id, input)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- if !exists {
- return writeError(c, http.StatusNotFound, os.ErrNotExist)
- }
- case "stop":
- var err error
- input, err = decodeGeneration(c)
- if err != nil {
- return writeError(c, http.StatusBadRequest, err)
- }
- _, exists, err := api.requireGeneration(id, input)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- if !exists {
- return writeError(c, http.StatusNotFound, os.ErrNotExist)
- }
- default:
- return writeError(c, http.StatusNotFound, errors.New("unknown action"))
- }
- _, release, err := api.locks.acquire(id)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- defer release()
- var path string
- if action == "start" {
- containerID, exists, err := api.requireGeneration(id, input)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- if !exists {
- return writeError(c, http.StatusNotFound, os.ErrNotExist)
- }
- path = "/containers/" + url.PathEscape(containerID) + "/start"
- } else {
- containerID, exists, err := api.requireGeneration(id, input)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- if !exists {
- return writeError(c, http.StatusNotFound, os.ErrNotExist)
- }
- path = "/containers/" + url.PathEscape(containerID) + "/stop?t=10"
- }
- if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil {
- return writeError(c, http.StatusBadGateway, err)
- }
- c.Status(http.StatusNoContent)
- return nil
-}
-
-func (api gateway) remove(c fiber.Ctx) error {
- id := c.Params("id")
- input, err := decodeGeneration(c)
- if err != nil {
- return writeError(c, http.StatusBadRequest, err)
- }
- containerID, exists, err := api.requireGeneration(id, input)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- _, release, err := api.locks.acquire(id)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- defer release()
- currentID, currentExists, err := api.requireGeneration(id, input)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- if currentExists != exists || currentID != containerID {
- return writeError(c, http.StatusConflict, errGenerationConflict)
- }
- expectedNetworkID := input.NetworkID
- if currentExists {
- _, labels, inspectErr := api.managedContainer(id)
- if inspectErr != nil {
- return writeError(c, statusFor(inspectErr), inspectErr)
- }
- containerNetworkID := labels[networkIDLabel]
- if containerNetworkID != input.NetworkID {
- return writeError(c, http.StatusConflict, errGenerationConflict)
- }
- } else if expectedNetworkID == "" {
- return writeError(c, http.StatusConflict, errGenerationConflict)
- }
- var networkGeneration tenantNetworkGeneration
- networkExists := false
- if expectedNetworkID != "" {
- networkGeneration, _, networkExists, err = api.docker.inspectTenantNetwork(api.network, id, input.BindingVersion,
- input.RuntimeID, api.self, expectedNetworkID, false)
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- }
- if networkExists {
- err = api.removeTenantNetwork(id, input.BindingVersion, input.RuntimeID, networkGeneration, &input, containerID, exists)
- } else if expectedNetworkID != "" {
- if exists {
- // 容器仍在但登记的隔离网络已消失:代状态异常,fail-closed 交由人工对账。
- return writeError(c, http.StatusConflict, errGenerationConflict)
- }
- // 容器与登记网络均已不存在:请求指向的代在 Docker 侧已无残留,清理视为完成。
- // 否则被外部清理过的旧代会永远 409,控制面的 runtime_cleanup_pending 无法收敛。
- if !api.proxies.remove(id, input.BindingVersion, input.RuntimeID, input.NetworkID) {
- return writeError(c, http.StatusConflict, errGenerationConflict)
- }
- return c.SendStatus(http.StatusNoContent)
- }
- if err != nil {
- if errors.Is(err, errGenerationConflict) {
- return writeError(c, http.StatusConflict, err)
- }
- return c.Status(http.StatusAccepted).JSON(map[string]string{
- "status": "runtime_cleanup_pending",
- })
- }
- if !api.proxies.remove(id, input.BindingVersion, input.RuntimeID, input.NetworkID) {
- return writeError(c, http.StatusConflict, errGenerationConflict)
- }
- if exists {
- path := "/containers/" + url.PathEscape(containerID) + "?force=1&v=0"
- if err := api.requireRuntimeState(id, input, containerID, true); err != nil {
- return writeError(c, http.StatusConflict, err)
- }
- if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil {
- currentID, currentExists, generationErr := api.requireGeneration(id, input)
- if errors.Is(err, os.ErrNotExist) && generationErr == nil && !currentExists {
- return c.SendStatus(http.StatusNoContent)
- }
- if generationErr != nil || currentID != containerID {
- err = errGenerationConflict
- }
- if errors.Is(err, errGenerationConflict) {
- return writeError(c, http.StatusConflict, err)
- }
- return writeError(c, http.StatusBadGateway, err)
- }
- }
- c.Status(http.StatusNoContent)
- return nil
-}
-
-func (api gateway) restoreProxy(c fiber.Ctx) error {
- input := proxyRestoreRequest{}
- decoder := json.NewDecoder(bytes.NewReader(c.Body()))
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(&input); err != nil || input.BindingVersion < 1 || !exitIDPattern.MatchString(input.RuntimeID) ||
- !exitIDPattern.MatchString(input.NetworkID) ||
- !exitIDPattern.MatchString(input.NetworkExitID) ||
- validateCreate(createRequest{Alias: c.Params("id"), Name: "x", Image: "x", Cmd: []string{"about:blank"}, Volume: "x",
- BindingVersion: input.BindingVersion, NetworkExitID: input.NetworkExitID, NetworkExit: input.NetworkExit}) != nil {
- return writeError(c, http.StatusBadRequest, errors.New("invalid proxy recovery request"))
- }
- removeStaleProxy := func() { api.proxies.remove(c.Params("id"), input.BindingVersion, input.RuntimeID, input.NetworkID) }
- runtimeID, labels, err := api.requireProxyGeneration(c.Params("id"), input)
- if err != nil {
- removeStaleProxy()
- return writeError(c, statusFor(err), err)
- }
- port, _ := strconv.Atoi(labels[proxyPortLabel])
- if port < 1 {
- removeStaleProxy()
- return writeError(c, http.StatusConflict, errors.New("container binding does not match recovery request"))
- }
- _, release, err := api.locks.acquire(c.Params("id"))
- if err != nil {
- return writeError(c, statusFor(err), err)
- }
- defer release()
- if runtimeID, labels, err = api.requireProxyGeneration(c.Params("id"), input); err != nil {
- removeStaleProxy()
- return writeError(c, statusFor(err), err)
- }
- port, _ = strconv.Atoi(labels[proxyPortLabel])
- if port < 1 {
- removeStaleProxy()
- return writeError(c, http.StatusConflict, errors.New("container binding does not match recovery request"))
- }
- networkGeneration, bindHost, err := api.docker.ensureTenantNetwork(api.network, c.Params("id"), api.self, input.BindingVersion,
- input.RuntimeID, labels[networkIDLabel], true)
- keepNetwork := false
- if networkGeneration.ID != "" {
- defer func() {
- if keepNetwork {
- return
- }
- var cleanupErr error
- if networkGeneration.Created {
- cleanupErr = api.removeTenantNetwork(c.Params("id"), input.BindingVersion, input.RuntimeID, networkGeneration, nil, "", false)
- } else {
- if networkGeneration.ConnectedRuntime {
- _, cleanupErr = api.disconnectTenantNetworkMember(c.Params("id"), input.BindingVersion, input.RuntimeID,
- networkGeneration, input.RuntimeID, nil, "", false)
- }
- if cleanupErr == nil && networkGeneration.ConnectedSelf {
- _, cleanupErr = api.disconnectTenantNetworkMember(c.Params("id"), input.BindingVersion, input.RuntimeID,
- networkGeneration, networkGeneration.SelfMember, nil, "", false)
- }
- }
- if cleanupErr != nil {
- logrus.WithError(cleanupErr).WithField("alias", c.Params("id")).Error("rollback restored browser network")
- }
- }()
- }
- if err != nil {
- removeStaleProxy()
- return writeError(c, http.StatusBadGateway, errors.New("restore isolated browser network"))
- }
- if err = api.requireProxyNetworkGeneration(c.Params("id"), input, networkGeneration, bindHost); err != nil {
- removeStaleProxy()
- return writeError(c, statusFor(err), err)
- }
- proxyServer, undoProxy, err := api.proxies.configure(c.Params("id"), input.BindingVersion, bindHost, port, input.NetworkExit, input.NetworkID)
- if err != nil {
- removeStaleProxy()
- return writeError(c, statusFor(err), errors.Join(errors.New("restore in-memory proxy"), err))
- }
- if err = api.requireProxyNetworkGeneration(c.Params("id"), input, networkGeneration, bindHost); err != nil {
- undoProxy()
- return writeError(c, statusFor(err), err)
- }
- if !api.proxies.bind(c.Params("id"), input.BindingVersion, proxyServer, runtimeID, input.NetworkID) {
- undoProxy()
- return writeError(c, http.StatusConflict, errGenerationConflict)
- }
- if err = api.requireProxyNetworkGeneration(c.Params("id"), input, networkGeneration, bindHost); err != nil {
- undoProxy()
- return writeError(c, statusFor(err), err)
- }
- keepNetwork = true
- return c.SendStatus(http.StatusNoContent)
-}
-
-func (api gateway) requireProxyNetworkGeneration(alias string, input proxyRestoreRequest, expected tenantNetworkGeneration, bindHost string) error {
- _, labels, err := api.requireProxyGeneration(alias, input)
- if err != nil {
- return err
- }
- if networkID := labels[networkIDLabel]; networkID != "" && networkID != expected.ID {
- return errGenerationConflict
- }
- current, addresses, exists, err := api.docker.inspectTenantNetwork(api.network, alias, input.BindingVersion,
- input.RuntimeID, api.self, expected.ID, false)
- host, _, _ := net.ParseCIDR(addresses[current.SelfMember])
- if err != nil || !exists || !sameTenantNetworkMembers(current, expected) || host == nil || host.String() != bindHost {
- return errGenerationConflict
- }
- return nil
-}
-
-func sameTenantNetworkMembers(current, expected tenantNetworkGeneration) bool {
- if current.ID != expected.ID || current.Name != expected.Name || current.RuntimeAttached != expected.RuntimeAttached ||
- current.SelfMember != expected.SelfMember || len(current.GatewayMembers) != len(expected.GatewayMembers) {
- return false
- }
- members := make(map[string]struct{}, len(current.GatewayMembers))
- for _, member := range current.GatewayMembers {
- members[member] = struct{}{}
- }
- for _, member := range expected.GatewayMembers {
- if _, ok := members[member]; !ok {
- return false
- }
- }
- return true
-}
-
-func (api gateway) requireProxyGeneration(alias string, input proxyRestoreRequest) (string, map[string]string, error) {
- runtimeID, labels, err := api.managedContainer(alias)
- if err != nil {
- return "", nil, err
- }
- version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64)
- if runtimeID != input.RuntimeID || version != input.BindingVersion || labels[networkExitLabel] != input.NetworkExitID ||
- labels[networkIDLabel] != input.NetworkID {
- return "", nil, errGenerationConflict
- }
- return runtimeID, labels, nil
-}
-
-func decodeGeneration(c fiber.Ctx) (generationRequest, error) {
- var input generationRequest
- decoder := json.NewDecoder(bytes.NewReader(c.Body()))
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(&input); err != nil || input.BindingVersion < 1 ||
- (input.RuntimeID != "" && !exitIDPattern.MatchString(input.RuntimeID)) ||
- (input.NetworkID != "" && !exitIDPattern.MatchString(input.NetworkID)) {
- return generationRequest{}, errors.New("binding_version, runtime_id and network_id must identify the expected generation")
- }
- return input, nil
-}
-
-func (api gateway) requireGeneration(id string, input generationRequest) (string, bool, error) {
- runtimeID, labels, err := api.managedContainer(id)
- if errors.Is(err, os.ErrNotExist) {
- return "", false, nil
- }
- if err != nil {
- return "", false, err
- }
- version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64)
- if input.RuntimeID == "" || runtimeID != input.RuntimeID || version != input.BindingVersion || labels[networkIDLabel] != input.NetworkID {
- logrus.WithFields(logrus.Fields{
- "browser_id": id, "input_runtime_id": input.RuntimeID, "actual_runtime_id": runtimeID,
- "input_binding_version": input.BindingVersion, "actual_binding_version": version,
- "input_network_id": input.NetworkID, "actual_network_id": labels[networkIDLabel],
- }).Warn("browser generation mismatch")
- return "", false, errGenerationConflict
- }
- return runtimeID, true, nil
-}
-
-func (api gateway) managedContainer(id string) (string, map[string]string, error) {
- runtimeID, labels, _, err := api.managedContainerState(id)
- return runtimeID, labels, err
-}
-
-func (api gateway) managedContainerState(id string) (string, map[string]string, map[string]string, error) {
- if !runtimeIDPattern.MatchString(id) {
- return "", nil, nil, errInvalidRuntimeID
- }
- result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil)
- if err != nil {
- return "", nil, nil, err
- }
- defer result.Body.Close()
- if result.StatusCode == http.StatusNotFound {
- return "", nil, nil, os.ErrNotExist
- }
- if result.StatusCode != http.StatusOK {
- return "", nil, nil, fmt.Errorf("Docker inspect returned %s", result.Status)
- }
- var inspected struct {
- ID string `json:"Id"`
- Config struct {
- Labels map[string]string `json:"Labels"`
- } `json:"Config"`
- NetworkSettings struct {
- Networks map[string]struct {
- NetworkID string `json:"NetworkID"`
- } `json:"Networks"`
- } `json:"NetworkSettings"`
- }
- if err := json.NewDecoder(result.Body).Decode(&inspected); err != nil {
- return "", nil, nil, fmt.Errorf("decode Docker inspect: %w", err)
- }
- if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id {
- return "", nil, nil, errUnmanagedContainer
- }
- networks := make(map[string]string, len(inspected.NetworkSettings.Networks))
- for name, network := range inspected.NetworkSettings.Networks {
- networks[name] = network.NetworkID
- }
- return inspected.ID, inspected.Config.Labels, networks, nil
-}
-
-// pullIfMissing 在镜像不在本地时从远端仓库拉取;镜像缺失属于可恢复错误,调用方可直接重试。
-func (docker dockerClient) pullIfMissing(ctx context.Context, ref string) error {
- inspect, err := docker.request(http.MethodGet, "/images/"+url.PathEscape(ref)+"/json", nil)
- if err != nil {
- return fmt.Errorf("inspect image %s: %w", ref, err)
- }
- _, _ = io.Copy(io.Discard, inspect.Body)
- _ = inspect.Body.Close()
- switch inspect.StatusCode {
- case http.StatusOK:
- return nil
- case http.StatusNotFound:
- // 本地无此镜像,继续拉取
- default:
- return fmt.Errorf("inspect image %s returned %s", ref, inspect.Status)
- }
-
- pullCtx, cancel := context.WithTimeout(ctx, pullTimeout)
- defer cancel()
- query := url.Values{"fromImage": {ref}}
- if !strings.Contains(ref, "@") {
- if repository, tag := splitImageRef(ref); tag != "" {
- query = url.Values{"fromImage": {repository}, "tag": {tag}}
- }
- }
- request, err := http.NewRequestWithContext(pullCtx, http.MethodPost, docker.baseURL+"/images/create?"+query.Encode(), nil)
- if err != nil {
- return fmt.Errorf("build image pull request: %w", err)
- }
- response, err := docker.slow.Do(request)
- if err != nil {
- return fmt.Errorf("pull image %s: %w", ref, err)
- }
- defer response.Body.Close()
- if response.StatusCode != http.StatusOK {
- message, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
- return fmt.Errorf("pull image %s returned %s: %s", ref, response.Status, strings.TrimSpace(string(message)))
- }
- _, _ = io.Copy(io.Discard, response.Body)
- return nil
-}
-
-func splitImageRef(ref string) (repository, tag string) {
- if at := strings.Index(ref, "@"); at >= 0 {
- return ref[:at], ref[at+1:]
- }
- if colon := strings.LastIndex(ref, ":"); colon > strings.LastIndex(ref, "/") {
- return ref[:colon], ref[colon+1:]
- }
- return ref, ""
-}
-
-func (docker dockerClient) request(method, path string, payload any) (*http.Response, error) {
- var body io.Reader
- if payload != nil {
- encoded, err := json.Marshal(payload)
- if err != nil {
- return nil, err
- }
- body = bytes.NewReader(encoded)
- }
- request, err := http.NewRequest(method, docker.baseURL+path, body)
- if err != nil {
- return nil, err
- }
- if payload != nil {
- request.Header.Set("Content-Type", "application/json")
- }
- return docker.client.Do(request)
-}
-
-func (docker dockerClient) expect(method, path string, payload any, allowed ...int) error {
- response, err := docker.request(method, path, payload)
- if err != nil {
- return err
- }
- defer response.Body.Close()
- for _, status := range allowed {
- if response.StatusCode == status {
- return nil
- }
- }
- if response.StatusCode == http.StatusNotFound {
- return os.ErrNotExist
- }
- message, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
- return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message)))
-}
-
-func (locks *dockerAliasReservations) acquire(alias string) (string, func(), error) {
- inspected, err := locks.docker.request(http.MethodGet, "/containers/"+url.PathEscape(locks.self)+"/json", nil)
- if err != nil {
- return "", nil, err
- }
- var gatewayContainer struct {
- Image string `json:"Image"`
- Config struct {
- Labels map[string]string `json:"Labels"`
- } `json:"Config"`
- }
- if inspected.StatusCode != http.StatusOK || json.NewDecoder(inspected.Body).Decode(&gatewayContainer) != nil || gatewayContainer.Image == "" ||
- gatewayContainer.Config.Labels[gatewayMemberLabel] != "true" {
- inspected.Body.Close()
- return "", nil, errors.New("inspect trusted gateway for alias reservation")
- }
- inspected.Body.Close()
- generationBytes := make([]byte, 16)
- if _, err := rand.Read(generationBytes); err != nil {
- return "", nil, errors.New("create alias reservation generation")
- }
- generation := hex.EncodeToString(generationBytes)
-
- response, err := locks.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(reservationPrefix+alias), map[string]any{
- "Image": gatewayContainer.Image,
- "Labels": map[string]string{reservationLabel: "true", idLabel: alias, reservationGenLabel: generation},
- "HostConfig": map[string]any{"NetworkMode": "none"},
- })
- if err != nil {
- return "", nil, locks.reconcile(alias, generation, "", fmt.Errorf("create alias reservation result unknown: %w", err))
- }
- defer response.Body.Close()
- if response.StatusCode == http.StatusConflict {
- return "", nil, errGenerationConflict
- }
- if response.StatusCode != http.StatusCreated {
- return "", nil, locks.reconcile(alias, generation, "", fmt.Errorf("create alias reservation returned %s", response.Status))
- }
- var created struct {
- ID string `json:"Id"`
- }
- if json.NewDecoder(response.Body).Decode(&created) != nil || created.ID == "" {
- return "", nil, locks.reconcile(alias, generation, "", errors.New("Docker returned an invalid alias reservation id"))
- }
- actualID, inspectErr := locks.inspect(alias, generation)
- if inspectErr != nil {
- return "", nil, fmt.Errorf("Docker returned an unverified alias reservation id; generation %s requires manual reconcile: %w", generation, inspectErr)
- }
- if actualID != created.ID {
- return "", nil, fmt.Errorf("Docker returned an alias reservation id that conflicts with generation %s; manual reconcile required", generation)
- }
- return created.ID, func() {
- if err := locks.remove(alias, generation, created.ID); err != nil {
- logrus.WithError(err).WithFields(logrus.Fields{"alias": alias, "reservation_id": created.ID,
- "reservation_generation": generation}).Error("alias reservation cleanup pending; manual reconcile required")
- }
- }, nil
-}
-
-func (locks *dockerAliasReservations) reconcile(alias, generation, expectedID string, cause error) error {
- actualID, inspectErr := locks.inspect(alias, generation)
- if inspectErr != nil {
- return fmt.Errorf("%w; reservation generation %s requires manual reconcile: %v", cause, generation, inspectErr)
- }
- if expectedID != "" && actualID != expectedID {
- return fmt.Errorf("%w; reservation generation %s conflicts with immutable id", cause, generation)
- }
- if cleanupErr := locks.remove(alias, generation, actualID); cleanupErr != nil {
- return fmt.Errorf("%w; reservation %s generation %s cleanup pending: %v", cause, actualID, generation, cleanupErr)
- }
- return fmt.Errorf("%w; reservation %s was removed", cause, actualID)
-}
-
-func (locks *dockerAliasReservations) remove(alias, generation, expectedID string) error {
- var deleteErr error
- for attempt := 0; attempt < 2; attempt++ {
- actualID, inspectErr := locks.inspect(alias, generation)
- if errors.Is(inspectErr, os.ErrNotExist) {
- return nil
- }
- if inspectErr != nil {
- return inspectErr
- }
- if actualID != expectedID {
- return errors.New("reservation generation conflicts with immutable id")
- }
- deleteErr = locks.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(expectedID)+"?force=1&v=0", nil,
- http.StatusNoContent)
- if deleteErr == nil {
- return nil
- }
- if errors.Is(deleteErr, os.ErrNotExist) {
- _, confirmErr := locks.inspect(alias, generation)
- if errors.Is(confirmErr, os.ErrNotExist) {
- return nil
- }
- return errors.Join(deleteErr, confirmErr)
- }
- }
- return deleteErr
-}
-
-func (locks *dockerAliasReservations) inspect(alias, generation string) (string, error) {
- response, err := locks.docker.request(http.MethodGet, "/containers/"+url.PathEscape(reservationPrefix+alias)+"/json", nil)
- if err != nil {
- return "", err
- }
- defer response.Body.Close()
- if response.StatusCode == http.StatusNotFound {
- return "", os.ErrNotExist
- }
- var reservation struct {
- ID string `json:"Id"`
- Config struct {
- Labels map[string]string `json:"Labels"`
- } `json:"Config"`
- }
- if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&reservation) != nil || reservation.ID == "" ||
- reservation.Config.Labels[reservationLabel] != "true" || reservation.Config.Labels[idLabel] != alias ||
- reservation.Config.Labels[reservationGenLabel] != generation {
- return "", errors.New("reservation name does not identify the expected alias generation")
- }
- return reservation.ID, nil
-}
-
-func tenantNetworkName(base, alias string) (string, error) {
- name := base + "-" + alias
- if !networkNamePattern.MatchString(name) {
- return "", errors.New("isolated browser network name is invalid")
- }
- return name, nil
-}
-
-func sameContainerReference(id, name, reference string) bool {
- if reference == "" {
- return false
- }
- return id == reference || name == reference || strings.HasPrefix(id, reference) || strings.HasPrefix(reference, id)
-}
-
-func (docker dockerClient) trustedGatewayMember(id string) bool {
- response, err := docker.request(http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil)
- if err != nil {
- return false
- }
- defer response.Body.Close()
- var container struct {
- ID string `json:"Id"`
- Config struct {
- Labels map[string]string `json:"Labels"`
- } `json:"Config"`
- }
- return response.StatusCode == http.StatusOK && json.NewDecoder(response.Body).Decode(&container) == nil &&
- container.ID != "" && container.Config.Labels[gatewayMemberLabel] == "true"
-}
-
-func (docker dockerClient) inspectTenantNetwork(base, alias string, bindingVersion int64, runtimeID, self, expectedID string,
- allowUnversioned bool) (tenantNetworkGeneration, map[string]string, bool, error) {
- name, err := tenantNetworkName(base, alias)
- if err != nil {
- return tenantNetworkGeneration{}, nil, false, err
- }
- generation := tenantNetworkGeneration{ID: expectedID, Name: name}
- reference := name
- if expectedID != "" {
- reference = expectedID
- }
- response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(reference), nil)
- if err != nil {
- return generation, nil, false, err
- }
- defer response.Body.Close()
- if response.StatusCode == http.StatusNotFound {
- return generation, nil, false, nil
- }
- var network dockerTenantNetwork
- if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&network) != nil || network.ID == "" ||
- network.Name != name || network.Driver != "bridge" || network.Internal || network.Attachable || network.Ingress ||
- network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole || network.Labels[idLabel] != alias {
- return generation, nil, false, errors.New("refusing to operate on an unowned browser network")
- }
- if expectedID != "" && network.ID != expectedID {
- return generation, nil, false, errGenerationConflict
- }
- networkVersion := network.Labels[bindingVersionLabel]
- if networkVersion != strconv.FormatInt(bindingVersion, 10) && !(allowUnversioned && networkVersion == "") {
- return generation, nil, false, errGenerationConflict
- }
- generation.ID = network.ID
- addresses := make(map[string]string, len(network.Containers))
- for id, member := range network.Containers {
- addresses[id] = member.IPv4Address
- if id == runtimeID {
- generation.RuntimeAttached = true
- continue
- }
- if !docker.trustedGatewayMember(id) {
- return generation, nil, false, errGenerationConflict
- }
- generation.GatewayMembers = append(generation.GatewayMembers, id)
- if sameContainerReference(id, member.Name, self) {
- generation.SelfMember = id
- }
- }
- return generation, addresses, true, nil
-}
-
-func (docker dockerClient) ensureTenantNetwork(base, alias, self string, bindingVersion int64, runtimeID, expectedID string,
- allowUnversioned bool) (tenantNetworkGeneration, string, error) {
- if self == "" {
- return tenantNetworkGeneration{}, "", errors.New("isolated browser network identity is invalid")
- }
- generation, addresses, exists, err := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, expectedID, allowUnversioned)
- if err != nil {
- return generation, "", err
- }
- if !exists {
- if expectedID != "" {
- return generation, "", errGenerationConflict
- }
- response, createErr := docker.request(http.MethodPost, "/networks/create", map[string]any{
- "Name": generation.Name, "CheckDuplicate": true, "Driver": "bridge",
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias,
- bindingVersionLabel: strconv.FormatInt(bindingVersion, 10)},
- })
- if createErr != nil {
- return generation, "", fmt.Errorf("create isolated browser network result unknown; immutable generation requires manual reconcile: %w", createErr)
- }
- var created struct {
- ID string `json:"Id"`
- }
- if response.StatusCode != http.StatusCreated || json.NewDecoder(response.Body).Decode(&created) != nil || created.ID == "" {
- response.Body.Close()
- return generation, "", errors.New("create isolated browser network result has no immutable id; manual reconcile required")
- }
- response.Body.Close()
- generation.ID, generation.Created = created.ID, true
- observed, currentAddresses, observedExists, inspectErr := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, false)
- generation = preserveTenantNetworkGeneration(generation, observed)
- addresses, err = currentAddresses, inspectErr
- if err != nil || !observedExists {
- if err == nil {
- err = errGenerationConflict
- }
- return generation, "", err
- }
- }
- if runtimeID != "" && !generation.RuntimeAttached {
- generation.ConnectedRuntime = true
- if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(generation.ID)+"/connect", map[string]any{
- "Container": runtimeID,
- }, http.StatusOK); err != nil {
- observed, _, _, _ := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, allowUnversioned)
- generation = preserveTenantNetworkGeneration(generation, observed)
- return generation, "", err
- }
- generation.RuntimeAttached = true
- }
- if generation.SelfMember == "" {
- generation.ConnectedSelf = true
- if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(generation.ID)+"/connect", map[string]any{
- "Container": self, "EndpointConfig": map[string]any{"Aliases": []string{browserProxyHost}},
- }, http.StatusOK); err != nil {
- observed, _, _, _ := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, allowUnversioned)
- generation = preserveTenantNetworkGeneration(generation, observed)
- return generation, "", err
- }
- }
- observed, currentAddresses, observedExists, inspectErr := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, allowUnversioned)
- generation = preserveTenantNetworkGeneration(generation, observed)
- addresses, err = currentAddresses, inspectErr
- if err != nil || !observedExists || generation.SelfMember == "" {
- if err == nil && !observedExists {
- err = errGenerationConflict
- }
- return generation, "", errors.Join(err, errors.New("Docker did not connect the gateway to the isolated network"))
- }
- host, _, _ := net.ParseCIDR(addresses[generation.SelfMember])
- if host == nil {
- return generation, "", errors.New("Docker did not assign the gateway an isolated network address")
- }
- return generation, host.String(), nil
-}
-
-func preserveTenantNetworkGeneration(known, observed tenantNetworkGeneration) tenantNetworkGeneration {
- if observed.ID == "" {
- observed.ID = known.ID
- }
- if observed.Name == "" {
- observed.Name = known.Name
- }
- observed.Created = observed.Created || known.Created
- observed.RuntimeAttached = observed.RuntimeAttached || known.RuntimeAttached
- observed.ConnectedRuntime = observed.ConnectedRuntime || known.ConnectedRuntime
- observed.ConnectedSelf = observed.ConnectedSelf || known.ConnectedSelf
- if observed.SelfMember == "" {
- observed.SelfMember = known.SelfMember
- }
- for _, member := range known.GatewayMembers {
- if !memberPresent(observed, member, "") {
- observed.GatewayMembers = append(observed.GatewayMembers, member)
- }
- }
- return observed
-}
-
-func (api gateway) requireRuntimeState(alias string, input generationRequest, expectedID string, expectedExists bool) error {
- runtimeID, exists, err := api.requireGeneration(alias, input)
- if err != nil || exists != expectedExists || runtimeID != expectedID {
- return errGenerationConflict
- }
- return nil
-}
-
-func memberPresent(generation tenantNetworkGeneration, id, runtimeID string) bool {
- if generation.RuntimeAttached && runtimeID != "" && sameContainerReference(id, "", runtimeID) {
- return true
- }
- for _, member := range generation.GatewayMembers {
- if sameContainerReference(member, "", id) {
- return true
- }
- }
- return false
-}
-
-func generationWithoutMember(generation tenantNetworkGeneration, member, runtimeID string) tenantNetworkGeneration {
- if generation.RuntimeAttached && runtimeID != "" && sameContainerReference(member, "", runtimeID) {
- generation.RuntimeAttached = false
- }
- if sameContainerReference(member, "", generation.SelfMember) {
- generation.SelfMember = ""
- }
- members := generation.GatewayMembers[:0:0]
- for _, current := range generation.GatewayMembers {
- if !sameContainerReference(current, "", member) {
- members = append(members, current)
- }
- }
- generation.GatewayMembers = members
- return generation
-}
-
-func (api gateway) disconnectTenantNetworkMember(alias string, bindingVersion int64, runtimeID string,
- generation tenantNetworkGeneration, member string, input *generationRequest, expectedRuntime string, expectedExists bool) (tenantNetworkGeneration, error) {
- if input != nil {
- if err := api.requireRuntimeState(alias, *input, expectedRuntime, expectedExists); err != nil {
- return generation, err
- }
- }
- current, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false)
- if err != nil || !exists {
- return current, err
- }
- if !sameTenantNetworkMembers(current, generation) {
- return current, errGenerationConflict
- }
- if !memberPresent(current, member, runtimeID) {
- return current, nil
- }
- err = api.docker.expect(http.MethodPost, "/networks/"+url.PathEscape(generation.ID)+"/disconnect", map[string]any{
- "Container": member, "Force": true,
- }, http.StatusOK)
- current, _, exists, fenceErr := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false)
- if fenceErr != nil || !exists {
- return current, errGenerationConflict
- }
- if sameTenantNetworkMembers(current, generationWithoutMember(generation, member, runtimeID)) {
- return current, nil
- }
- if err != nil {
- if sameTenantNetworkMembers(current, generation) {
- return current, err
- }
- return current, errGenerationConflict
- }
- if !sameTenantNetworkMembers(current, generation) {
- return current, errGenerationConflict
- }
- return current, errors.New("Docker retained an isolated network member after disconnect")
-}
-
-func (api gateway) deleteTenantNetwork(alias string, bindingVersion int64, runtimeID string,
- generation tenantNetworkGeneration, input *generationRequest, expectedRuntime string, expectedExists bool) error {
- if input != nil {
- if err := api.requireRuntimeState(alias, *input, expectedRuntime, expectedExists); err != nil {
- return err
- }
- }
- current, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false)
- if err != nil {
- return err
- }
- if !exists {
- return nil
- }
- if current.RuntimeAttached || len(current.GatewayMembers) != 0 {
- return errGenerationConflict
- }
- err = api.docker.expect(http.MethodDelete, "/networks/"+url.PathEscape(generation.ID), nil, http.StatusNoContent)
- _, _, exists, fenceErr := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false)
- if fenceErr != nil {
- return errGenerationConflict
- }
- if !exists {
- return nil
- }
- if err != nil {
- return err
- }
- return errors.New("Docker retained the isolated browser network after delete")
-}
-
-func (api gateway) removeTenantNetwork(alias string, bindingVersion int64, runtimeID string,
- generation tenantNetworkGeneration, input *generationRequest, expectedRuntime string, expectedExists bool) error {
- if generation.RuntimeAttached || generation.ConnectedRuntime {
- var err error
- generation, err = api.disconnectTenantNetworkMember(alias, bindingVersion, runtimeID, generation, runtimeID,
- input, expectedRuntime, expectedExists)
- if err != nil {
- return fmt.Errorf("disconnect browser from isolated network: %w", err)
- }
- }
- for len(generation.GatewayMembers) > 0 {
- gatewayID := generation.GatewayMembers[0]
- var err error
- generation, err = api.disconnectTenantNetworkMember(alias, bindingVersion, runtimeID, generation, gatewayID,
- input, expectedRuntime, expectedExists)
- if err != nil {
- return fmt.Errorf("disconnect trusted gateway from isolated network: %w", err)
- }
- }
- if err := api.deleteTenantNetwork(alias, bindingVersion, runtimeID, generation, input, expectedRuntime, expectedExists); err != nil {
- return fmt.Errorf("remove isolated browser network: %w", err)
- }
- return nil
-}
-
-func validateBrowserNetwork(name string) error {
- if !networkNamePattern.MatchString(name) || len(name) > 31 {
- return errors.New("BROWSER_NETWORK must be a valid network prefix of at most 31 characters")
- }
- if name == controlNetworkName {
- return errors.New("BROWSER_NETWORK must not reuse the control network")
- }
- return nil
-}
-
-func statusFor(err error) int {
- switch {
- case errors.Is(err, errInvalidRuntimeID):
- return http.StatusBadRequest
- case errors.Is(err, os.ErrNotExist):
- return http.StatusNotFound
- case errors.Is(err, errUnmanagedContainer):
- return http.StatusForbidden
- case errors.Is(err, errGenerationConflict):
- return http.StatusConflict
- default:
- return http.StatusBadGateway
- }
-}
-
-func forwardDockerError(c fiber.Ctx, result *http.Response) error {
- message, _ := io.ReadAll(io.LimitReader(result.Body, 4096))
- status := http.StatusBadGateway
- if result.StatusCode == http.StatusConflict {
- status = http.StatusConflict
- }
- return writeError(c, status, fmt.Errorf("Docker returned %s: %s", result.Status, strings.TrimSpace(string(message))))
-}
-
-func writeError(c fiber.Ctx, status int, err error) error {
- return writeJSON(c, status, map[string]string{"error": err.Error()})
-}
-
-func writeNetworkError(c fiber.Ctx, status int, err error, networkID string) error {
- return writeJSON(c, status, map[string]string{"error": err.Error(), "network_id": networkID})
-}
-
-func writeJSON(c fiber.Ctx, status int, value any) error {
- return c.Status(status).JSON(value)
-}
diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go
deleted file mode 100644
index 308b52f..0000000
--- a/cmd/docker-gateway/main_test.go
+++ /dev/null
@@ -1,2459 +0,0 @@
-package main
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "os"
- "strconv"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/gofiber/fiber/v3"
- "github.com/gofiber/fiber/v3/middleware/adaptor"
-)
-
-const testToken = "unit-test-gateway-token"
-
-func authed(method, target string, body io.Reader) *http.Request {
- request := httptest.NewRequest(method, target, body)
- request.Header.Set("Authorization", "Bearer "+testToken)
- return request
-}
-
-func testDocker(handler http.HandlerFunc) (dockerClient, *httptest.Server) {
- self, _ := os.Hostname()
- var networkMu sync.Mutex
- networkMembers := map[string]map[string]bool{}
- networkDeleted := map[string]bool{}
- server := httptest.NewServer(withAliasReservations(self, func(response http.ResponseWriter, request *http.Request) {
- if strings.HasPrefix(request.URL.Path, "/networks/network-") {
- alias := strings.TrimPrefix(request.URL.Path, "/networks/network-")
- alias = strings.TrimSuffix(strings.TrimSuffix(alias, "/disconnect"), "/connect")
- networkMu.Lock()
- if request.Method == http.MethodGet {
- if networkDeleted[alias] {
- networkMu.Unlock()
- response.WriteHeader(http.StatusNotFound)
- return
- }
- members := map[string]any{}
- for id := range networkMembers[alias] {
- members[id] = map[string]string{"Name": id, "IPv4Address": "127.0.0.1/8"}
- }
- networkMu.Unlock()
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-" + alias, "Name": "creatorhub_browser-" + alias, "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias, bindingVersionLabel: "1"},
- "Containers": members,
- })
- return
- }
- if strings.HasSuffix(request.URL.Path, "/disconnect") {
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- delete(networkMembers[alias], body.Container)
- } else if strings.HasSuffix(request.URL.Path, "/connect") {
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- if networkMembers[alias] == nil {
- networkMembers[alias] = map[string]bool{}
- }
- networkMembers[alias][body.Container] = true
- } else if request.Method == http.MethodDelete {
- networkDeleted[alias] = true
- }
- networkMu.Unlock()
- if request.Method == http.MethodDelete {
- response.WriteHeader(http.StatusNoContent)
- } else {
- response.WriteHeader(http.StatusOK)
- }
- return
- }
- if strings.HasPrefix(request.URL.Path, "/networks/creatorhub_browser-") {
- if request.Method != http.MethodGet {
- if request.Method == http.MethodDelete {
- response.WriteHeader(http.StatusNoContent)
- } else {
- response.WriteHeader(http.StatusOK)
- }
- return
- }
- alias := strings.TrimPrefix(request.URL.Path, "/networks/creatorhub_browser-")
- networkMu.Lock()
- if networkDeleted[alias] {
- networkMu.Unlock()
- response.WriteHeader(http.StatusNotFound)
- return
- }
- if networkMembers[alias] == nil {
- networkMembers[alias] = map[string]bool{self: true}
- }
- members := map[string]any{}
- for id := range networkMembers[alias] {
- members[id] = map[string]string{"Name": id, "IPv4Address": "127.0.0.1/8"}
- }
- networkMu.Unlock()
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-" + alias, "Name": "creatorhub_browser-" + alias, "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias,
- bindingVersionLabel: "1"},
- "Containers": members,
- })
- return
- }
- handler(response, request)
- }))
- return dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}, server
-}
-
-func withAliasReservations(self string, next http.HandlerFunc) http.HandlerFunc {
- var mu sync.Mutex
- type reservation struct {
- id, generation string
- }
- locks := map[string]reservation{}
- return func(response http.ResponseWriter, request *http.Request) {
- if request.Method == http.MethodGet && request.URL.Path == "/containers/"+self+"/json" {
- _, _ = response.Write([]byte(`{"Id":"` + self + `","Image":"gateway-image-id","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- return
- }
- name := request.URL.Query().Get("name")
- if request.Method == http.MethodPost && request.URL.Path == "/containers/create" && strings.HasPrefix(name, reservationPrefix) {
- mu.Lock()
- defer mu.Unlock()
- if locks[name].id != "" {
- response.WriteHeader(http.StatusConflict)
- return
- }
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- id := "reservation-" + strings.TrimPrefix(name, reservationPrefix)
- locks[name] = reservation{id: id, generation: payload.Labels[reservationGenLabel]}
- response.WriteHeader(http.StatusCreated)
- _ = json.NewEncoder(response).Encode(map[string]string{"Id": id})
- return
- }
- if request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+reservationPrefix) && strings.HasSuffix(request.URL.Path, "/json") {
- name := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/containers/"), "/json")
- mu.Lock()
- current, found := locks[name]
- mu.Unlock()
- if !found {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": current.id, "Config": map[string]any{"Labels": map[string]string{
- reservationLabel: "true", idLabel: strings.TrimPrefix(name, reservationPrefix), reservationGenLabel: current.generation,
- }}})
- return
- }
- if request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/reservation-") {
- id := strings.TrimPrefix(request.URL.Path, "/containers/")
- mu.Lock()
- for name, current := range locks {
- if current.id == id {
- delete(locks, name)
- break
- }
- }
- mu.Unlock()
- response.WriteHeader(http.StatusNoContent)
- return
- }
- next(response, request)
- }
-}
-
-func decodeJSONBody(t *testing.T, response *http.Response) map[string]any {
- t.Helper()
- var body map[string]any
- if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
- t.Fatalf("decode JSON body: %v", err)
- }
- return body
-}
-
-const testCreateBody = `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` +
- `"cmd":["--fingerprint=1000","--lang=zh-CN","about:blank"],"volume":"creatorhub-profile-account-a",` +
- `"binding_version":1,"network_exit_id":"exit-1",` +
- `"network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
-
-const testGenerationBody = `{"binding_version":1,"runtime_id":"container-id"}`
-
-func TestAliasReservationNameDoesNotOverlapRuntimeNamespace(t *testing.T) {
- if strings.HasPrefix(reservationPrefix, namePrefix) || strings.HasPrefix(namePrefix, reservationPrefix) {
- t.Fatalf("reservation and runtime prefixes overlap: reservation=%q runtime=%q", reservationPrefix, namePrefix)
- }
- generation := ""
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- if request.URL.Query().Get("name") == namePrefix+"lock-account-a" {
- response.WriteHeader(http.StatusConflict)
- return
- }
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- generation = payload.Labels[reservationGenLabel]
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"reservation-id"}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"reservation-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"` + generation + `"}}}`))
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/reservation-id":
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- _, release, err := (&dockerAliasReservations{docker: docker, self: "gateway-self"}).acquire("account-a")
- if err != nil {
- t.Fatalf("runtime alias lock-account-a occupied account-a reservation: %v", err)
- }
- release()
-}
-
-func TestAliasReservationRecoversInvalidCreateAndReleaseResponses(t *testing.T) {
- t.Run("create disconnect after apply", func(t *testing.T) {
- generation := ""
- removed := false
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- generation = payload.Labels[reservationGenLabel]
- connection, _, _ := response.(http.Hijacker).Hijack()
- _ = connection.Close()
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"reservation-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"` + generation + `"}}}`))
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/reservation-id":
- removed = true
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- if _, _, err := (&dockerAliasReservations{docker: docker, self: "gateway-self"}).acquire("account-a"); err == nil || !removed {
- t.Fatalf("disconnected reservation create was not reconciled by immutable generation: removed=%v err=%v", removed, err)
- }
- })
-
- t.Run("invalid create body", func(t *testing.T) {
- reservationExists, removed := false, false
- generation := ""
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- generation = payload.Labels[reservationGenLabel]
- reservationExists = true
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- if !reservationExists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _, _ = response.Write([]byte(`{"Id":"reservation-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"` + generation + `"}}}`))
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/reservation-id":
- reservationExists, removed = false, true
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- if _, _, err := (&dockerAliasReservations{docker: docker, self: "gateway-self"}).acquire("account-a"); err == nil || !removed {
- t.Fatalf("invalid reservation response was not reconciled: removed=%v err=%v", removed, err)
- }
- })
-
- t.Run("nonempty foreign create id", func(t *testing.T) {
- generation := ""
- deletes := 0
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- generation = payload.Labels[reservationGenLabel]
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"foreign-id"}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"reservation-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"` + generation + `"}}}`))
- case request.Method == http.MethodDelete:
- deletes++
- response.WriteHeader(http.StatusNotFound)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- if _, _, err := (&dockerAliasReservations{docker: docker, self: "gateway-self"}).acquire("account-a"); err == nil || deletes != 0 {
- t.Fatalf("foreign 201 id was accepted or deleted: deletes=%d err=%v", deletes, err)
- }
- })
-
- t.Run("201 inspect disconnect", func(t *testing.T) {
- deletes := 0
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"reservation-id"}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- connection, _, _ := response.(http.Hijacker).Hijack()
- _ = connection.Close()
- case request.Method == http.MethodDelete:
- deletes++
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- if _, _, err := (&dockerAliasReservations{docker: docker, self: "gateway-self"}).acquire("account-a"); err == nil || deletes != 0 {
- t.Fatalf("unverified 201 reservation was deleted: deletes=%d err=%v", deletes, err)
- }
- })
-
- t.Run("delete 404 requires generation absence", func(t *testing.T) {
- generation := ""
- deletes := 0
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- generation = payload.Labels[reservationGenLabel]
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"reservation-id"}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"reservation-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"` + generation + `"}}}`))
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/reservation-id":
- deletes++
- response.WriteHeader(http.StatusNotFound)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- locks := &dockerAliasReservations{docker: docker, self: "gateway-self"}
- id, _, err := locks.acquire("account-a")
- if err != nil {
- t.Fatal(err)
- }
- if err := locks.remove("account-a", generation, id); err == nil || deletes != 1 {
- t.Fatalf("404 was accepted while the reservation generation remained: deletes=%d err=%v", deletes, err)
- }
- })
-
- t.Run("release retry", func(t *testing.T) {
- deletes := 0
- generation := ""
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- generation = payload.Labels[reservationGenLabel]
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"reservation-id"}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"reservation-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"` + generation + `"}}}`))
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/reservation-id":
- deletes++
- if deletes == 1 {
- response.WriteHeader(http.StatusInternalServerError)
- return
- }
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- _, release, err := (&dockerAliasReservations{docker: docker, self: "gateway-self"}).acquire("account-a")
- if err != nil {
- t.Fatal(err)
- }
- release()
- if deletes != 2 {
- t.Fatalf("reservation release did not retry the immutable id: deletes=%d", deletes)
- }
- })
-
- t.Run("release does not delete successor", func(t *testing.T) {
- generation := ""
- deletes := 0
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Image":"gateway-image","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- generation = payload.Labels[reservationGenLabel]
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"reservation-id"}`))
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/reservation-id":
- deletes++
- response.WriteHeader(http.StatusInternalServerError)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+reservationPrefix+"account-a/json":
- if deletes == 0 {
- _, _ = response.Write([]byte(`{"Id":"reservation-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"` + generation + `"}}}`))
- return
- }
- _, _ = response.Write([]byte(`{"Id":"successor-id","Config":{"Labels":{"` + reservationLabel + `":"true","` + idLabel + `":"account-a","` + reservationGenLabel + `":"successor"}}}`))
- default:
- t.Fatalf("unexpected Docker request %s %s generation=%s", request.Method, request.URL.String(), generation)
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- _, release, err := (&dockerAliasReservations{docker: docker, self: "gateway-self"}).acquire("account-a")
- if err != nil {
- t.Fatal(err)
- }
- release()
- if deletes != 1 {
- t.Fatalf("reservation release deleted a successor generation: deletes=%d", deletes)
- }
- })
-}
-
-func TestGatewayCreatesNetworkDisabledStoppedRecoveryContainer(t *testing.T) {
- created := false
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- _, _ = response.Write([]byte(`{}`))
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
- var payload map[string]any
- _ = json.NewDecoder(request.Body).Decode(&payload)
- host := payload["HostConfig"].(map[string]any)
- labels := payload["Labels"].(map[string]any)
- encoded, _ := json.Marshal(payload["Cmd"])
- if host["NetworkMode"] != "none" || labels[networkExitLabel] != "" || strings.Contains(string(encoded), "proxy") {
- t.Fatalf("unsafe stopped recovery payload: %#v", payload)
- }
- created = true
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"stopped-container"}`))
- default:
- t.Fatalf("stopped recovery unexpectedly called Docker %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
- handler := newGateway(docker, "creatorhub_browser", testToken)
- body := `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` +
- `"cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` +
- `"binding_version":1,"network_exit_id":"","network_exit":{},"stopped":true}`
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
- if response.Code != http.StatusCreated || !created {
- t.Fatalf("stopped recovery create failed: status=%d body=%s", response.Code, response.Body.String())
- }
-}
-
-func TestGatewayCreatesConstrainedBrowserWithPlatformSpec(t *testing.T) {
- var created map[string]any
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- _, _ = response.Write([]byte(`{}`))
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
- if got := request.URL.Query().Get("name"); got != namePrefix+"account-a" {
- t.Fatalf("unexpected container name %q", got)
- }
- if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
- t.Fatal(err)
- }
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"container-id"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"):
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)))
-
- if response.Code != http.StatusCreated {
- t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
- }
- if created["Image"] != "registry.example/browser:1.2.3" {
- t.Fatalf("gateway must run the platform-specified image: %#v", created["Image"])
- }
- if created["User"] != browserUser || created["Entrypoint"].([]any)[0] != browserEntrypoint {
- t.Fatalf("runtime identity is not fixed: user=%#v entrypoint=%#v", created["User"], created["Entrypoint"])
- }
- cmd := created["Cmd"].([]any)
- if len(cmd) != 5 || cmd[0] != "--fingerprint=1000" || !strings.HasPrefix(cmd[2].(string), "--proxy-server=http://docker-gateway:") ||
- cmd[3] != "--disable-non-proxied-udp" || cmd[4] != "about:blank" {
- t.Fatalf("cmd must be passed through verbatim: %#v", created["Cmd"])
- }
- host := created["HostConfig"].(map[string]any)
- if host["NetworkMode"] != "network-account-a" || host["ReadonlyRootfs"] != true {
- t.Fatalf("missing container isolation: %#v", host)
- }
- tmpfs := host["Tmpfs"].(map[string]any)
- if tmpfs["/tmp/.X11-unix"] == nil || tmpfs["/home/ubuntu"] == nil {
- t.Fatalf("missing writable runtime paths: %#v", tmpfs)
- }
- mount := host["Mounts"].([]any)[0].(map[string]any)
- if mount["Source"] != "creatorhub-profile-account-a" || mount["Target"] != "/data" {
- t.Fatalf("profile volume must come from the request: %#v", mount)
- }
- labels := created["Labels"].(map[string]any)
- if labels[managedLabel] != "true" || labels[idLabel] != "account-a" || labels[nameLabel] != "账号甲" {
- t.Fatalf("missing ownership labels: %#v", labels)
- }
-}
-
-func TestGatewayCreatesDirectBrowserWithoutProxyArguments(t *testing.T) {
- var created map[string]any
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- _, _ = response.Write([]byte(`{}`))
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
- if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
- t.Fatal(err)
- }
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"container-id"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"):
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- body := `{"alias":"account-a","name":"账号甲","image":"registry.example/browser:1.2.3",` +
- `"cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` +
- `"binding_version":1,"network_exit_id":"","network_exit":{}}`
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
-
- if response.Code != http.StatusCreated {
- t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
- }
- cmd := created["Cmd"].([]any)
- encoded, _ := json.Marshal(cmd)
- if len(cmd) != 2 || strings.Contains(string(encoded), "proxy") {
- t.Fatalf("direct runtime received proxy arguments: %#v", cmd)
- }
- host := created["HostConfig"].(map[string]any)
- labels := created["Labels"].(map[string]any)
- if host["NetworkMode"] != "network-account-a" || labels[networkExitLabel] != "" || labels[proxyPortLabel] != "0" {
- t.Fatalf("direct runtime metadata is invalid: host=%#v labels=%#v", host, labels)
- }
-}
-
-func TestGatewayCreateUsesCapturedNetworkIDAcrossNameReplacement(t *testing.T) {
- networkID := ""
- members := map[string]string{}
- usedNetworkID, touchedReplacement := "", false
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
- reference := strings.TrimPrefix(request.URL.Path, "/networks/")
- if reference == "network-n2" {
- touchedReplacement = true
- }
- if networkID == "" || (reference != "creatorhub_browser-account-a" && reference != networkID) {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- containers := map[string]any{}
- for id, name := range members {
- containers[id] = map[string]string{"Name": name, "IPv4Address": "127.0.0.1/8"}
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": networkID, "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"}, "Containers": containers,
- })
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- networkID = "network-n1"
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"network-n1"}`))
- case request.Method == http.MethodPost && request.URL.Path == "/networks/network-n1/connect":
- members["gateway-self"] = "gateway-self"
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- HostConfig map[string]any `json:"HostConfig"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- usedNetworkID, _ = payload.HostConfig["NetworkMode"].(string)
- networkID, members = "network-n2", map[string]string{"replacement": "replacement"}
- response.WriteHeader(http.StatusNotFound)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)))
- if response.Code != http.StatusBadGateway || usedNetworkID != "network-n1" || touchedReplacement || networkID != "network-n2" || members["replacement"] == "" {
- t.Fatalf("stale create crossed network generation: status=%d mode=%q touchedN2=%v network=%q members=%v body=%s",
- response.Code, usedNetworkID, touchedReplacement, networkID, members, response.Body.String())
- }
-}
-
-func TestGatewayDockerInspectContainsNoProxyCredentials(t *testing.T) {
- var created map[string]any
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
- if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
- t.Fatal(err)
- }
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"container-id"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/container-id/start"):
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- body := strings.Replace(testCreateBody, `"protocol":"socks5","host":"proxy.example","port":1080`,
- `"protocol":"socks5","host":"proxy.example","port":1080,"username":"operator","password":"ephemeral"`, 1)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
- if response.Code != http.StatusCreated {
- t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
- }
- inspect, _ := json.Marshal(created)
- for _, secret := range []string{"operator", "ephemeral", "operator:ephemeral@", "proxy.example"} {
- if bytes.Contains(inspect, []byte(secret)) {
- t.Fatalf("Docker inspect leaked proxy credential %q: %s", secret, inspect)
- }
- }
- if !bytes.Contains(inspect, []byte("--proxy-server=http://docker-gateway:")) {
- t.Fatalf("Docker inspect is missing the secret-free proxy configuration: %s", inspect)
- }
-}
-
-func TestGatewayPullsMissingImageOnCreate(t *testing.T) {
- tests := []struct {
- name string
- ref string
- fromImage string
- tag string
- }{{
- name: "tagged ref splits repository and tag",
- ref: "registry.example/browser:2.0.0",
- fromImage: "registry.example/browser",
- tag: "2.0.0",
- }, {
- name: "digest ref is pulled as a whole",
- ref: "registry.example/browser@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7",
- fromImage: "registry.example/browser@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7",
- tag: "",
- }}
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- pulled := false
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/images/create"):
- pulled = true
- if request.URL.Query().Get("fromImage") != test.fromImage || request.URL.Query().Get("tag") != test.tag {
- t.Fatalf("unexpected pull query %s", request.URL.RawQuery)
- }
- _, _ = response.Write([]byte(`{"status":"Download complete"}`))
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"container-id"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"):
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- body := `{"alias":"account-a","name":"账号甲","image":"` + test.ref +
- `","cmd":["--fingerprint=1000","about:blank"],"volume":"creatorhub-profile-account-a",` +
- `"binding_version":1,"network_exit_id":"exit-1",` +
- `"network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
-
- if response.Code != http.StatusCreated || !pulled {
- t.Fatalf("expected pull-then-create, status=%d pulled=%v body=%s", response.Code, pulled, response.Body.String())
- }
- })
- }
-}
-
-func TestGatewayRejectsCreateWithoutValidToken(t *testing.T) {
- docker, server := testDocker(func(http.ResponseWriter, *http.Request) {
- t.Fatal("no Docker request is expected for an unauthorized call")
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- for name, header := range map[string]string{
- "missing": "",
- "malformed": testToken,
- "wrong": "Bearer not-the-token",
- } {
- request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody))
- if header != "" {
- request.Header.Set("Authorization", header)
- }
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, request)
- if response.Code != http.StatusUnauthorized {
- t.Fatalf("%s token: expected 401, got %d: %s", name, response.Code, response.Body.String())
- }
- }
-
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
- if response.Code != http.StatusNoContent {
- t.Fatalf("healthz must stay unauthenticated, got %d", response.Code)
- }
-}
-
-func TestGatewayRejectsInvalidCreateRequest(t *testing.T) {
- tests := map[string]string{
- "unknown field": `{"alias":"account-a","seed":1}`,
- "invalid alias": `{"alias":"AccountA","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1"],"volume":"creatorhub-profile-account-a"}`,
- "invalid image": `{"alias":"account-a","name":"甲","image":"","cmd":["--fingerprint=1"],"volume":"creatorhub-profile-account-a"}`,
- "empty cmd": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":[],"volume":"creatorhub-profile-account-a"}`,
- "invalid volume": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1"],"volume":"bad volume!"}`,
- "proxy override": `{"alias":"account-a","name":"甲","image":"reg/img:1","cmd":["--fingerprint=1","--proxy-server=http://direct:8080","about:blank"],"volume":"creatorhub-profile-account-a","network_exit":{"protocol":"socks5","host":"proxy","port":1080}}`,
- }
- for name, body := range tests {
- t.Run(name, func(t *testing.T) {
- handler := newGateway(dockerClient{}, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
- if response.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d: %s", response.Code, response.Body.String())
- }
- })
- }
-}
-
-func TestGatewayRejectsOversizedCreateRequest(t *testing.T) {
- handler := newGateway(dockerClient{}, "creatorhub_browser", testToken)
- request := authed(http.MethodPost, "/v1/browsers", strings.NewReader(strings.Repeat("x", (1<<20)+1)))
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, request)
- if response.Code != http.StatusRequestEntityTooLarge {
- t.Fatalf("expected 413 for oversized body, status=%d body=%s", response.Code, response.Body.String())
- }
-
- handler.Post("/request-limit", func(fiber.Ctx) error { return fiber.ErrRequestEntityTooLarge })
- jsonResponse, err := handler.Test(httptest.NewRequest(http.MethodPost, "/request-limit", nil))
- if err != nil {
- t.Fatal(err)
- }
- defer jsonResponse.Body.Close()
- var body map[string]string
- decodeErr := json.NewDecoder(jsonResponse.Body).Decode(&body)
- contentType := jsonResponse.Header.Get("Content-Type")
- if jsonResponse.StatusCode != http.StatusRequestEntityTooLarge || decodeErr != nil || body["error"] == "" || !strings.HasPrefix(contentType, "application/json") {
- t.Fatalf("expected JSON 413 envelope, status=%d body=%v decode=%v content-type=%q", jsonResponse.StatusCode, body, decodeErr, contentType)
- }
-}
-
-func TestGatewayReconcilesContainerWhenCreateResponseHasNoID(t *testing.T) {
- created, started := false, false
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- _, _ = response.Write([]byte(`{}`))
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
- if !created {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _, _ = response.Write([]byte(`{"Id":"actual-container-id","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkIDLabel + `":"network-account-a"}}}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
- created = true
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":""}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/actual-container-id/start":
- started = true
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)))
-
- if response.Code != http.StatusCreated || !started {
- t.Fatalf("expected invalid create response reconciliation, status=%d started=%v body=%s", response.Code, started, response.Body.String())
- }
-}
-
-func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) {
- removed := false
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- _, _ = response.Write([]byte(`{}`))
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create"):
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"failed-id"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/failed-id/start"):
- http.Error(response, "start failed", http.StatusInternalServerError)
- case request.Method == http.MethodDelete && strings.Contains(request.URL.Path, "/containers/failed-id"):
- removed = request.URL.Query().Get("force") == "1" && request.URL.Query().Get("v") == "0"
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)))
-
- if response.Code != http.StatusBadGateway || !removed {
- t.Fatalf("expected failed container cleanup with preserved volume, status=%d removed=%v body=%s", response.Code, removed, response.Body.String())
- }
-}
-
-func TestGatewayDoesNotEchoProxyCredentialsFromDockerErrors(t *testing.T) {
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- if request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/") {
- response.WriteHeader(http.StatusOK)
- return
- }
- if request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"+namePrefix) {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/containers/create") {
- response.WriteHeader(http.StatusInternalServerError)
- _, _ = response.Write([]byte(`invalid cmd --proxy-server=http://operator:ephemeral@proxy.example:8080`))
- return
- }
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.Path)
- })
- defer server.Close()
- handler := newGateway(docker, "creatorhub_browser", testToken)
- body := strings.Replace(testCreateBody, `"protocol":"socks5","host":"proxy.example","port":1080`,
- `"protocol":"http","host":"proxy.example","port":8080,"username":"operator","password":"ephemeral"`, 1)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(body)))
- if response.Code != http.StatusBadGateway || strings.Contains(response.Body.String(), "operator") ||
- strings.Contains(response.Body.String(), "ephemeral") || strings.Contains(response.Body.String(), "proxy.example") {
- t.Fatalf("gateway leaked proxy material: status=%d body=%s", response.Code, response.Body.String())
- }
-}
-
-func TestGatewayListsBrowsers(t *testing.T) {
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- if request.Method != http.MethodGet || request.URL.Path != "/containers/json" {
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- _, _ = response.Write([]byte(`[{"Id":"container-id","State":"running","Status":"Up","Labels":{` +
- `"` + idLabel + `":"account-a","` + nameLabel + `":"账号甲"}}]`))
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodGet, "/v1/browsers", nil))
-
- var browsers []browser
- if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil ||
- len(browsers) != 1 || browsers[0].Alias != "account-a" || browsers[0].Name != "账号甲" ||
- browsers[0].Endpoint != "http://creatorhub-browser-account-a:9222" {
- t.Fatalf("unexpected list response status=%d body=%s", response.Code, response.Body.String())
- }
-}
-
-func TestGatewayRestartRestoresExistingProxyListener(t *testing.T) {
- reserved, err := net.Listen("tcp4", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- port := reserved.Addr().(*net.TCPAddr).Port
- _ = reserved.Close()
- labels := map[string]string{
- managedLabel: "true", idLabel: "account-a", nameLabel: "账号甲",
- bindingVersionLabel: "1", networkExitLabel: "exit-1", proxyPortLabel: strconv.Itoa(port),
- networkIDLabel: "network-account-a",
- }
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/containers/creatorhub-browser-account-a/json"):
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": "container-id", "Config": map[string]any{"Labels": labels}})
- case request.Method == http.MethodGet && request.URL.Path == "/containers/json":
- _ = json.NewEncoder(response).Encode([]map[string]any{{"Id": "container-id", "State": "running", "Status": "Up", "Labels": labels}})
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- })
- defer server.Close()
- handler := newGateway(docker, "creatorhub_browser", testToken)
- recovery := `{"binding_version":1,"runtime_id":"container-id","network_id":"network-account-a","network_exit_id":"exit-1","network_exit":{"protocol":"http","host":"127.0.0.1","port":1}}`
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers/account-a/proxy", strings.NewReader(recovery)))
- if response.Code != http.StatusNoContent {
- t.Fatalf("proxy recovery failed: %d %s", response.Code, response.Body.String())
- }
- response = httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodGet, "/v1/browsers", nil))
- var browsers []browser
- if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil || len(browsers) != 1 || !browsers[0].ProxyReady {
- t.Fatalf("restarted gateway did not report restored proxy: %d %s", response.Code, response.Body.String())
- }
-}
-
-func TestGatewayRestoreFinalFenceRemovesStaleProxy(t *testing.T) {
- reserved, err := net.Listen("tcp4", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- port := reserved.Addr().(*net.TCPAddr).Port
- _ = reserved.Close()
- registry := newMemoryProxyRegistry()
- networkID := "network-n1"
- replaced := false
- containerReads := 0
- exit := gatewayProxyExit{Protocol: "socks5", Host: "proxy.example", Port: 1080}
- proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", port, exit, "network-n1")
- if err != nil || !registry.bind("account-a", 1, proxyURL, "container-c1", "network-n1") {
- t.Fatalf("seed existing proxy generation: %v", err)
- }
- defer cleanup()
- labels := map[string]string{
- managedLabel: "true", idLabel: "account-a", bindingVersionLabel: "1", networkExitLabel: "exit-1",
- proxyPortLabel: strconv.Itoa(port), networkIDLabel: "network-n1",
- }
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- containerReads++
- registry.mu.Lock()
- proxy := registry.proxies["account-a"]
- bound := proxy != nil && proxy.runtimeID == "container-c1"
- registry.mu.Unlock()
- if bound && containerReads == 4 && !replaced {
- networkID, replaced = "network-n2", true
- }
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": "container-c1", "Config": map[string]any{"Labels": labels}})
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
- reference := strings.TrimPrefix(request.URL.Path, "/networks/")
- if reference != networkID && reference != "creatorhub_browser-account-a" {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": networkID, "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": map[string]any{
- "container-c1": map[string]string{"Name": namePrefix + "account-a", "IPv4Address": "127.0.0.3/8"},
- "gateway-self": map[string]string{"Name": "gateway-self", "IPv4Address": "127.0.0.1/8"},
- },
- })
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- api := gateway{docker: docker, network: "creatorhub_browser", self: "gateway-self", token: testToken, proxies: registry,
- locks: &dockerAliasReservations{docker: docker, self: "gateway-self"}}
- app := fiber.New()
- app.Use("/v1", api.authorize)
- app.Post("/v1/browsers/:id/proxy", api.restoreProxy)
- body := `{"binding_version":1,"runtime_id":"container-c1","network_id":"network-n1","network_exit_id":"exit-1","network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
- response := httptest.NewRecorder()
- adaptor.FiberApp(app).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers/account-a/proxy", strings.NewReader(body)))
- registry.mu.Lock()
- proxy := registry.proxies["account-a"]
- registry.mu.Unlock()
- if response.Code != http.StatusConflict || !replaced || networkID != "network-n2" || proxy != nil {
- t.Fatalf("restore final fence accepted a replaced network: status=%d replaced=%v network=%q proxy=%v body=%s",
- response.Code, replaced, networkID, proxy, response.Body.String())
- }
-}
-
-func TestGatewayProxyFenceRejectsGatewayAddressChange(t *testing.T) {
- labels := map[string]string{managedLabel: "true", idLabel: "account-a", bindingVersionLabel: "1",
- networkExitLabel: "exit-1", networkIDLabel: "network-n1"}
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch request.URL.Path {
- case "/containers/" + namePrefix + "account-a/json":
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": "container-c1", "Config": map[string]any{"Labels": labels}})
- case "/containers/gateway-self/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-self","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case "/networks/network-n1":
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-n1", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": map[string]any{
- "container-c1": map[string]string{"Name": namePrefix + "account-a", "IPv4Address": "127.0.0.2/8"},
- "gateway-self": map[string]string{"Name": "gateway-self", "IPv4Address": "127.0.0.4/8"},
- },
- })
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.Path)
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- api := gateway{docker: docker, network: "creatorhub_browser", self: "gateway-self"}
- input := proxyRestoreRequest{BindingVersion: 1, RuntimeID: "container-c1", NetworkID: "network-n1", NetworkExitID: "exit-1"}
- expected := tenantNetworkGeneration{ID: "network-n1", Name: "creatorhub_browser-account-a", RuntimeAttached: true,
- GatewayMembers: []string{"gateway-self"}, SelfMember: "gateway-self"}
- if err := api.requireProxyNetworkGeneration("account-a", input, expected, "127.0.0.3"); err != errGenerationConflict {
- t.Fatalf("gateway address change crossed proxy fence: %v", err)
- }
-}
-
-func TestGatewayLifecycleUsesInspectedImmutableContainerID(t *testing.T) {
- tests := []struct {
- method string
- path string
- dockerPath string
- }{
- {http.MethodPost, "/v1/browsers/account-a/start", "/containers/container-id/start"},
- {http.MethodPost, "/v1/browsers/account-a/stop", "/containers/container-id/stop"},
- {http.MethodDelete, "/v1/browsers/account-a", "/containers/container-id"},
- }
- for _, test := range tests {
- t.Run(test.method+" "+test.path, func(t *testing.T) {
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- if request.Method == http.MethodGet {
- networkLabel := ""
- if test.path == "/v1/browsers/account-a/start" {
- networkLabel = `,"` + networkIDLabel + `":"network-id"`
- }
- _, _ = response.Write([]byte(`{"Id":"container-id","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1"` + networkLabel + `}}}`))
- return
- }
- if request.URL.Path != test.dockerPath {
- t.Fatalf("unexpected Docker path %s", request.URL.String())
- }
- response.WriteHeader(http.StatusNoContent)
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- var body io.Reader
- if test.path == "/v1/browsers/account-a/start" {
- body = strings.NewReader(`{"binding_version":1,"runtime_id":"container-id","network_id":"network-id"}`)
- } else {
- body = strings.NewReader(testGenerationBody)
- }
- adaptor.FiberApp(handler).ServeHTTP(response, authed(test.method, test.path, body))
- if response.Code != http.StatusNoContent {
- t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String())
- }
- })
- }
-}
-
-func TestGatewayStartRejectsEmptyNetworkGeneration(t *testing.T) {
- mutations := 0
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- if request.Method != http.MethodGet {
- mutations++
- }
- _, _ = response.Write([]byte(`{"Id":"container-id","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1"}}}`))
- })
- defer server.Close()
- response := httptest.NewRecorder()
- adaptor.FiberApp(newGateway(docker, "creatorhub_browser", testToken)).ServeHTTP(response,
- authed(http.MethodPost, "/v1/browsers/account-a/start", strings.NewReader(`{"binding_version":1,"runtime_id":"container-id"}`)))
- if response.Code != http.StatusBadRequest || mutations != 0 {
- t.Fatalf("empty network generation reached Docker: status=%d mutations=%d body=%s", response.Code, mutations, response.Body.String())
- }
-}
-
-func TestGatewayDeleteUsesImmutableNetworkIDAcrossCleanupRetry(t *testing.T) {
- containerExists, cleanupFails, containerDeletes := true, true, 0
- networkExists := true
- networkMembers := map[string]any{
- "container-id": map[string]string{"Name": namePrefix + "account-a"},
- "gateway-self": map[string]string{"Name": "gateway-self"},
- }
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"):
- if !containerExists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _, _ = response.Write([]byte(`{"Id":"container-id","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkIDLabel + `":"network-id"}}}`))
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"):
- containerExists = false
- containerDeletes++
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
- if !networkExists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-id", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": networkMembers,
- })
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
- if request.URL.Path != "/networks/network-id/disconnect" {
- t.Fatalf("network cleanup did not use immutable id: %s", request.URL.Path)
- }
- if cleanupFails {
- response.WriteHeader(http.StatusInternalServerError)
- return
- }
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- delete(networkMembers, body.Container)
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/networks/"):
- if request.URL.Path != "/networks/network-id" {
- t.Fatalf("network delete did not use immutable id: %s", request.URL.Path)
- }
- networkExists = false
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
-
- response := httptest.NewRecorder()
- deleteBody := `{"binding_version":1,"runtime_id":"container-id","network_id":"network-id"}`
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(deleteBody)))
- if response.Code != http.StatusAccepted || !containerExists || containerDeletes != 0 {
- t.Fatalf("expected alias reservation with pending cleanup, status=%d exists=%v deletes=%d body=%s",
- response.Code, containerExists, containerDeletes, response.Body.String())
- }
- cleanupFails = false
- response = httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(deleteBody)))
- if response.Code != http.StatusNoContent || containerExists || containerDeletes != 1 {
- t.Fatalf("idempotent cleanup retry failed: status=%d deletes=%d body=%s", response.Code, containerDeletes, response.Body.String())
- }
-}
-
-func TestGatewayDeleteWithoutContainerOrNetworkGenerationFailsClosed(t *testing.T) {
- networkRequests := 0
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- response.WriteHeader(http.StatusNotFound)
- case strings.HasPrefix(request.URL.Path, "/networks/"):
- networkRequests++
- response.WriteHeader(http.StatusInternalServerError)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- body := `{"binding_version":1,"runtime_id":"runtime-not-found"}`
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(body)))
- if response.Code != http.StatusConflict || networkRequests != 0 {
- t.Fatalf("legacy cleanup discovered a replacement network: status=%d networkRequests=%d body=%s",
- response.Code, networkRequests, response.Body.String())
- }
-}
-
-func TestGatewayDeleteConvergesWhenGenerationAlreadyGone(t *testing.T) {
- networkRequests := 0
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodGet && request.URL.Path == "/networks/network-old":
- networkRequests++
- response.WriteHeader(http.StatusNotFound)
- case strings.HasPrefix(request.URL.Path, "/networks/"):
- t.Fatalf("network lookup leaked beyond the requested generation: %s %s", request.Method, request.URL.Path)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- body := `{"binding_version":1,"runtime_id":"container-old","network_id":"network-old"}`
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(body)))
- if response.Code != http.StatusNoContent || networkRequests != 1 {
- t.Fatalf("already-gone generation did not converge: status=%d networkRequests=%d body=%s",
- response.Code, networkRequests, response.Body.String())
- }
-}
-
-func TestGatewayDeleteFailsClosedWhenContainerOutlivesNetwork(t *testing.T) {
- containerDeleted := false
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"container-old","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkIDLabel + `":"network-old"}}}`))
- case request.Method == http.MethodGet && request.URL.Path == "/networks/network-old":
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"):
- containerDeleted = true
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- body := `{"binding_version":1,"runtime_id":"container-old","network_id":"network-old"}`
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(body)))
- if response.Code != http.StatusConflict || containerDeleted {
- t.Fatalf("container outliving its network must fail closed: status=%d containerDeleted=%v body=%s",
- response.Code, containerDeleted, response.Body.String())
- }
-}
-
-func TestGatewayRejectsStaleProxyRestoreAfterReplacementGeneration(t *testing.T) {
- type dockerState struct {
- sync.Mutex
- containerID string
- containerLabels map[string]string
- networkID string
- networkMembers map[string]string
- containerReads int
- cleanupMutations []string
- }
- state := &dockerState{
- containerID: "container-c1",
- containerLabels: map[string]string{managedLabel: "true", idLabel: "account-a", bindingVersionLabel: "1", networkExitLabel: "exit-1", proxyPortLabel: "12345", networkIDLabel: "network-n1"},
- networkID: "network-n1",
- networkMembers: map[string]string{"container-c1": namePrefix + "account-a", "gateway-self": "gateway-self"},
- }
- r1Captured := make(chan struct{})
- resumeR1 := make(chan struct{})
- var releaseOnce sync.Once
- release := func() { releaseOnce.Do(func() { close(resumeR1) }) }
- defer release()
- dockerServer := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- state.Lock()
- containerID := state.containerID
- labels := state.containerLabels
- state.containerReads++
- first := state.containerReads == 1
- state.Unlock()
- if first {
- close(r1Captured)
- <-resumeR1
- }
- if containerID == "" {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": containerID, "Config": map[string]any{"Labels": labels}})
- case request.Method == http.MethodGet && (request.URL.Path == "/networks/creatorhub_browser-account-a" ||
- request.URL.Path == "/networks/network-n1" || request.URL.Path == "/networks/network-n2"):
- state.Lock()
- networkID := state.networkID
- members := make(map[string]map[string]string, len(state.networkMembers))
- for id, name := range state.networkMembers {
- members[id] = map[string]string{"Name": name, "IPv4Address": "127.0.0.1/8"}
- }
- state.Unlock()
- if networkID == "" {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": networkID, "Name": "creatorhub_browser-account-a", "Driver": "bridge",
- "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": members,
- })
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- requestedNetwork := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/networks/"), "/disconnect")
- state.Lock()
- if requestedNetwork != state.networkID {
- state.Unlock()
- response.WriteHeader(http.StatusNotFound)
- return
- }
- state.cleanupMutations = append(state.cleanupMutations, "disconnect:"+requestedNetwork+":"+body.Container)
- delete(state.networkMembers, body.Container)
- state.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/networks/"):
- requestedNetwork := strings.TrimPrefix(request.URL.Path, "/networks/")
- state.Lock()
- if requestedNetwork != state.networkID {
- state.Unlock()
- response.WriteHeader(http.StatusNotFound)
- return
- }
- state.cleanupMutations = append(state.cleanupMutations, "delete-network:"+requestedNetwork)
- state.networkID = ""
- state.networkMembers = nil
- state.Unlock()
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"):
- requestedContainer := strings.TrimPrefix(request.URL.Path, "/containers/")
- state.Lock()
- if requestedContainer != state.containerID {
- state.Unlock()
- response.WriteHeader(http.StatusNotFound)
- return
- }
- state.cleanupMutations = append(state.cleanupMutations, "delete-container:"+requestedContainer)
- state.containerID = ""
- state.containerLabels = nil
- state.Unlock()
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- state.Lock()
- state.networkID = "network-n2"
- state.networkMembers = map[string]string{}
- state.Unlock()
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"network-n2"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect"):
- state.Lock()
- state.networkMembers["gateway-self"] = "gateway-self"
- state.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- state.Lock()
- state.containerID = "container-c2"
- state.containerLabels = payload.Labels
- state.networkMembers["container-c2"] = namePrefix + "account-a"
- state.Unlock()
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"container-c2"}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/container-c2/start":
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Errorf("unexpected Docker request %s %s", request.Method, request.URL.String())
- response.WriteHeader(http.StatusInternalServerError)
- }
- }))
- defer dockerServer.Close()
-
- registry := newMemoryProxyRegistry()
- proxyURL, _, err := registry.configure("account-a", 1, "127.0.0.1", 0,
- gatewayProxyExit{Protocol: "socks5", Host: "proxy.example", Port: 1080})
- if err != nil || !registry.bind("account-a", 1, proxyURL, "container-c1", "network-n1") {
- t.Fatalf("seed C1 proxy: %v", err)
- }
- defer func() {
- registry.remove("account-a", 1, "container-c2")
- registry.remove("account-a", 1, "container-c1")
- }()
- api := gateway{
- docker: dockerClient{baseURL: dockerServer.URL, client: dockerServer.Client(), slow: dockerServer.Client()},
- network: "creatorhub_browser", self: "gateway-self", token: testToken, proxies: registry,
- }
- api.locks = &dockerAliasReservations{docker: api.docker, self: api.self}
- app := fiber.New()
- app.Use("/v1", api.authorize)
- app.Post("/v1/browsers", api.create)
- app.Post("/v1/browsers/:id/proxy", api.restoreProxy)
- app.Delete("/v1/browsers/:id", api.remove)
- gatewayServer := httptest.NewServer(adaptor.FiberApp(app))
- defer gatewayServer.Close()
- defer release()
-
- type result struct {
- status int
- body string
- }
- call := func(method, path, body string) result {
- request, _ := http.NewRequest(method, gatewayServer.URL+path, strings.NewReader(body))
- request.Header.Set("Authorization", "Bearer "+testToken)
- response, err := gatewayServer.Client().Do(request)
- if err != nil {
- return result{body: err.Error()}
- }
- defer response.Body.Close()
- responseBody, _ := io.ReadAll(response.Body)
- return result{status: response.StatusCode, body: string(responseBody)}
- }
- r1Result := make(chan result, 1)
- go func() {
- r1Result <- call(http.MethodPost, "/v1/browsers/account-a/proxy",
- `{"binding_version":1,"runtime_id":"container-c1","network_id":"network-n1","network_exit_id":"exit-1","network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`)
- }()
- select {
- case <-r1Captured:
- case <-time.After(5 * time.Second):
- t.Fatal("R1 did not capture C1/N1")
- }
-
- r2Delete := call(http.MethodDelete, "/v1/browsers/account-a", `{"binding_version":1,"runtime_id":"container-c1","network_id":"network-n1"}`)
- if r2Delete.status != http.StatusNoContent {
- t.Fatalf("R2 cleanup failed: status=%d body=%s", r2Delete.status, r2Delete.body)
- }
- r2Create := call(http.MethodPost, "/v1/browsers", testCreateBody)
- if r2Create.status != http.StatusCreated {
- t.Fatalf("R2 replacement create failed: status=%d body=%s", r2Create.status, r2Create.body)
- }
- release()
- select {
- case stale := <-r1Result:
- if stale.status != http.StatusConflict {
- t.Fatalf("R1 stale restore was not fenced: status=%d body=%s", stale.status, stale.body)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("R1 did not finish")
- }
-
- state.Lock()
- defer state.Unlock()
- proxy := registry.proxies["account-a"]
- wantCleanup := []string{
- "disconnect:network-n1:container-c1",
- "disconnect:network-n1:gateway-self",
- "delete-network:network-n1",
- "delete-container:container-c1",
- }
- if state.containerID != "container-c2" || state.networkID != "network-n2" || state.networkMembers["container-c2"] == "" ||
- proxy == nil || proxy.runtimeID != "container-c2" || strings.Join(state.cleanupMutations, ",") != strings.Join(wantCleanup, ",") {
- t.Fatalf("stale R1 affected replacement generation: container=%q network=%q members=%v proxy=%v mutations=%v",
- state.containerID, state.networkID, state.networkMembers, proxy, state.cleanupMutations)
- }
-}
-
-func TestGatewayRejectsStaleCreateBeforeNetworkOrProxyMutation(t *testing.T) {
- type dockerState struct {
- sync.Mutex
- containerID string
- containerLabels map[string]string
- containerReads int
- networkExists bool
- networkCreates int
- networkConnects int
- gatewayConnected bool
- containerCreates int
- }
- state := &dockerState{}
- r1Inspected := make(chan struct{})
- resumeR1 := make(chan struct{})
- var releaseOnce sync.Once
- release := func() { releaseOnce.Do(func() { close(resumeR1) }) }
- defer release()
- dockerServer := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- state.Lock()
- containerID, labels := state.containerID, state.containerLabels
- state.containerReads++
- first := state.containerReads == 1
- state.Unlock()
- if first {
- close(r1Inspected)
- <-resumeR1
- }
- if containerID == "" {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": containerID, "Config": map[string]any{"Labels": labels}})
- case request.Method == http.MethodGet && (request.URL.Path == "/networks/creatorhub_browser-account-a" || request.URL.Path == "/networks/network-n1"):
- state.Lock()
- exists, connected := state.networkExists, state.gatewayConnected
- state.Unlock()
- if !exists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- members := map[string]any{}
- if connected {
- members["gateway-self"] = map[string]string{"Name": "gateway-self", "IPv4Address": "127.0.0.1/8"}
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-n1", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": members,
- })
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- state.Lock()
- state.networkExists = true
- state.networkCreates++
- state.Unlock()
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"network-n1"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect"):
- state.Lock()
- state.networkConnects++
- state.gatewayConnected = true
- state.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- state.Lock()
- state.containerID = "container-c1"
- state.containerLabels = payload.Labels
- state.containerCreates++
- state.Unlock()
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"container-c1"}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/container-c1/start":
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Errorf("unexpected Docker request %s %s", request.Method, request.URL.String())
- response.WriteHeader(http.StatusInternalServerError)
- }
- }))
- defer dockerServer.Close()
- docker := dockerClient{baseURL: dockerServer.URL, client: dockerServer.Client(), slow: dockerServer.Client()}
- registry := newMemoryProxyRegistry()
- defer registry.remove("account-a", 1, "container-c1")
- api := gateway{docker: docker, network: "creatorhub_browser", self: "gateway-self", token: testToken, proxies: registry,
- locks: &dockerAliasReservations{docker: docker, self: "gateway-self"}}
- app := fiber.New()
- app.Use("/v1", api.authorize)
- app.Post("/v1/browsers", api.create)
- gatewayServer := httptest.NewServer(adaptor.FiberApp(app))
- defer gatewayServer.Close()
-
- call := func() (int, string) {
- request, _ := http.NewRequest(http.MethodPost, gatewayServer.URL+"/v1/browsers", strings.NewReader(testCreateBody))
- request.Header.Set("Authorization", "Bearer "+testToken)
- response, err := gatewayServer.Client().Do(request)
- if err != nil {
- return 0, err.Error()
- }
- defer response.Body.Close()
- body, _ := io.ReadAll(response.Body)
- return response.StatusCode, string(body)
- }
- r1Result := make(chan struct {
- status int
- body string
- }, 1)
- go func() {
- status, body := call()
- r1Result <- struct {
- status int
- body string
- }{status, body}
- }()
- select {
- case <-r1Inspected:
- case <-time.After(5 * time.Second):
- t.Fatal("R1 did not inspect the empty alias")
- }
- status, body := call()
- if status != http.StatusCreated {
- release()
- t.Fatalf("R2 create failed: status=%d body=%s", status, body)
- }
- release()
- select {
- case stale := <-r1Result:
- if stale.status != http.StatusConflict {
- t.Fatalf("R1 stale create was not fenced: status=%d body=%s", stale.status, stale.body)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("R1 did not finish")
- }
-
- state.Lock()
- defer state.Unlock()
- proxy := registry.proxies["account-a"]
- if state.containerID != "container-c1" || state.networkCreates != 1 || state.networkConnects != 1 || state.containerCreates != 1 ||
- proxy == nil || proxy.runtimeID != "container-c1" {
- t.Fatalf("stale create left side effects: container=%q networkCreates=%d connects=%d containerCreates=%d proxy=%v",
- state.containerID, state.networkCreates, state.networkConnects, state.containerCreates, proxy)
- }
-}
-
-func TestGatewayTwoReplicaCreateRestoreRemoveProxyContract(t *testing.T) {
- type member struct{ name, ip string }
- type dockerState struct {
- sync.Mutex
- containerID string
- containerLabels map[string]string
- networkID string
- networkMembers map[string]member
- sequence int
- }
- state := &dockerState{}
- dockerServer := httptest.NewServer(withAliasReservations("gateway-g1", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-g2/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-g2","Image":"gateway-image-id","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- state.Lock()
- id, labels := state.containerID, state.containerLabels
- state.Unlock()
- if id == "" {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{"Id": id, "Config": map[string]any{"Labels": labels}})
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
- state.Lock()
- id := state.networkID
- members := map[string]any{}
- for memberID, current := range state.networkMembers {
- members[memberID] = map[string]string{"Name": current.name, "IPv4Address": current.ip}
- }
- state.Unlock()
- if id == "" {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- reference := strings.TrimPrefix(request.URL.Path, "/networks/")
- if reference != "creatorhub_browser-account-a" && reference != id {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": id, "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": members,
- })
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- state.Lock()
- state.sequence++
- state.networkID = fmt.Sprintf("network-n%d", state.sequence)
- state.networkMembers = map[string]member{}
- id := state.networkID
- state.Unlock()
- response.WriteHeader(http.StatusCreated)
- _ = json.NewEncoder(response).Encode(map[string]string{"Id": id})
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- state.Lock()
- ip := "127.0.0.3/8"
- if body.Container == "gateway-g1" {
- ip = "127.0.0.1/8"
- } else if body.Container == "gateway-g2" {
- ip = "127.0.0.2/8"
- }
- state.networkMembers[body.Container] = member{name: body.Container, ip: ip}
- state.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- var payload struct {
- Labels map[string]string `json:"Labels"`
- }
- _ = json.NewDecoder(request.Body).Decode(&payload)
- state.Lock()
- id := fmt.Sprintf("container-c%d", state.sequence)
- state.containerID, state.containerLabels = id, payload.Labels
- state.networkMembers[id] = member{name: namePrefix + "account-a", ip: "127.0.0.3/8"}
- state.Unlock()
- response.WriteHeader(http.StatusCreated)
- _ = json.NewEncoder(response).Encode(map[string]string{"Id": id})
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/start"):
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- state.Lock()
- delete(state.networkMembers, body.Container)
- state.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/networks/"):
- state.Lock()
- if len(state.networkMembers) != 0 {
- state.Unlock()
- response.WriteHeader(http.StatusConflict)
- return
- }
- state.networkID = ""
- state.Unlock()
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"):
- id := strings.TrimPrefix(request.URL.Path, "/containers/")
- state.Lock()
- if id != state.containerID {
- state.Unlock()
- response.WriteHeader(http.StatusNotFound)
- return
- }
- delete(state.networkMembers, id)
- state.containerID, state.containerLabels = "", nil
- state.Unlock()
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer dockerServer.Close()
- docker := dockerClient{baseURL: dockerServer.URL, client: dockerServer.Client(), slow: dockerServer.Client()}
- newReplica := func(self string) (*fiber.App, *memoryProxyRegistry) {
- registry := newMemoryProxyRegistry()
- api := gateway{docker: docker, network: "creatorhub_browser", self: self, token: testToken, proxies: registry,
- locks: &dockerAliasReservations{docker: docker, self: self}}
- app := fiber.New()
- app.Use("/v1", api.authorize)
- app.Post("/v1/browsers", api.create)
- app.Post("/v1/browsers/:id/proxy", api.restoreProxy)
- app.Delete("/v1/browsers/:id", api.remove)
- return app, registry
- }
- g1, proxiesG1 := newReplica("gateway-g1")
- g2, proxiesG2 := newReplica("gateway-g2")
- call := func(app *fiber.App, method, path, body string) *httptest.ResponseRecorder {
- response := httptest.NewRecorder()
- adaptor.FiberApp(app).ServeHTTP(response, authed(method, path, strings.NewReader(body)))
- return response
- }
- created := call(g1, http.MethodPost, "/v1/browsers", testCreateBody)
- if created.Code != http.StatusCreated {
- t.Fatalf("G1 create failed: %d %s", created.Code, created.Body.String())
- }
- state.Lock()
- c1, n1, port := state.containerID, state.networkID, state.containerLabels[proxyPortLabel]
- state.Unlock()
- restoreBody := `{"binding_version":1,"runtime_id":"` + c1 + `","network_id":"network-n1","network_exit_id":"exit-1","network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
- if restored := call(g2, http.MethodPost, "/v1/browsers/account-a/proxy", restoreBody); restored.Code != http.StatusNoContent {
- t.Fatalf("G2 restore failed: %d %s", restored.Code, restored.Body.String())
- }
- if proxiesG1.proxies["account-a"].runtimeID != c1 || proxiesG2.proxies["account-a"].runtimeID != c1 || port == "" {
- t.Fatal("both replicas did not bind the same runtime generation")
- }
- removeBody := `{"binding_version":1,"runtime_id":"` + c1 + `","network_id":"` + n1 + `"}`
- if removed := call(g2, http.MethodDelete, "/v1/browsers/account-a", removeBody); removed.Code != http.StatusNoContent {
- t.Fatalf("G2 remove failed: %d %s", removed.Code, removed.Body.String())
- }
- if replacement := call(g2, http.MethodPost, "/v1/browsers", testCreateBody); replacement.Code != http.StatusCreated {
- t.Fatalf("G2 replacement create failed: %d %s", replacement.Code, replacement.Body.String())
- }
- state.Lock()
- c2 := state.containerID
- _, g1Attached := state.networkMembers["gateway-g1"]
- _, g2Attached := state.networkMembers["gateway-g2"]
- _, c2Attached := state.networkMembers[c2]
- state.Unlock()
- if c2 == c1 || g1Attached || !g2Attached || !c2Attached || proxiesG1.proxies["account-a"].runtimeID != c1 ||
- proxiesG2.proxies["account-a"].runtimeID != c2 {
- t.Fatalf("cross-process proxy release contract failed: c1=%q c2=%q g1=%v g2=%v c2Attached=%v", c1, c2, g1Attached, g2Attached, c2Attached)
- }
- state.Lock()
- n2 := state.networkID
- port = state.containerLabels[proxyPortLabel]
- state.Unlock()
- restoreBody = `{"binding_version":1,"runtime_id":"` + c2 + `","network_id":"` + n2 + `","network_exit_id":"exit-1","network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
- if restored := call(g1, http.MethodPost, "/v1/browsers/account-a/proxy", restoreBody); restored.Code != http.StatusNoContent {
- t.Fatalf("G1 successor restore failed: %d %s", restored.Code, restored.Body.String())
- }
- state.Lock()
- _, g1Attached = state.networkMembers["gateway-g1"]
- state.Unlock()
- if !g1Attached || proxiesG1.proxies["account-a"].runtimeID != c2 || proxiesG2.proxies["account-a"].runtimeID != c2 {
- t.Fatalf("stale replica did not replace its proxy generation: c1=%q c2=%q g1=%v g1Proxy=%v g2Proxy=%v",
- c1, c2, g1Attached, proxiesG1.proxies["account-a"], proxiesG2.proxies["account-a"])
- }
- proxiesG1.remove("account-a", 1, c1)
- proxiesG2.remove("account-a", 1, c2)
-}
-
-func TestGatewayRemoveFencesNetworkReplacementAndMemberChanges(t *testing.T) {
- for _, test := range []struct {
- name string
- replaceOnDisconnect bool
- addMemberOnDisconnect bool
- wantNetworkID string
- }{
- {name: "N1 replaced by N2 after inspect", replaceOnDisconnect: true, wantNetworkID: "network-n2"},
- {name: "trusted member joins before delete", addMemberOnDisconnect: true, wantNetworkID: "network-n1"},
- } {
- t.Run(test.name, func(t *testing.T) {
- var mu sync.Mutex
- networkID := "network-n1"
- members := map[string]string{"container-c1": namePrefix + "account-a", "gateway-g1": "gateway-g1"}
- containerDeleted, networkDeletes := false, 0
- server := httptest.NewServer(withAliasReservations("gateway-g1", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"container-c1","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkIDLabel + `":"network-n1"}}}`))
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-g2/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-g2","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodGet && (request.URL.Path == "/networks/creatorhub_browser-account-a" ||
- request.URL.Path == "/networks/network-n1" || request.URL.Path == "/networks/network-n2"):
- mu.Lock()
- id := networkID
- current := map[string]any{}
- for memberID, name := range members {
- current[memberID] = map[string]string{"Name": name}
- }
- mu.Unlock()
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": id, "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": current,
- })
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- mu.Lock()
- if test.replaceOnDisconnect {
- networkID = "network-n2"
- mu.Unlock()
- response.WriteHeader(http.StatusNotFound)
- return
- }
- delete(members, body.Container)
- if test.addMemberOnDisconnect && body.Container == "gateway-g1" {
- members["gateway-g2"] = "gateway-g2"
- }
- mu.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/networks/"):
- mu.Lock()
- networkDeletes++
- mu.Unlock()
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/container-c1":
- mu.Lock()
- containerDeleted = true
- mu.Unlock()
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- registry := newMemoryProxyRegistry()
- proxyURL, _, err := registry.configure("account-a", 1, "127.0.0.1", 0, gatewayProxyExit{Protocol: "socks5", Host: "proxy.example", Port: 1080})
- if err != nil || !registry.bind("account-a", 1, proxyURL, "container-c1") {
- t.Fatal("seed proxy generation")
- }
- defer registry.remove("account-a", 1, "container-c1")
- api := gateway{docker: docker, network: "creatorhub_browser", self: "gateway-g1", token: testToken, proxies: registry,
- locks: &dockerAliasReservations{docker: docker, self: "gateway-g1"}}
- app := fiber.New()
- app.Use("/v1", api.authorize)
- app.Delete("/v1/browsers/:id", api.remove)
- response := httptest.NewRecorder()
- adaptor.FiberApp(app).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a",
- strings.NewReader(`{"binding_version":1,"runtime_id":"container-c1","network_id":"network-n1"}`)))
- mu.Lock()
- defer mu.Unlock()
- if response.Code != http.StatusConflict || networkID != test.wantNetworkID || containerDeleted || networkDeletes != 0 ||
- registry.proxies["account-a"] == nil {
- t.Fatalf("remove crossed network fence: status=%d network=%q containerDeleted=%v networkDeletes=%d proxy=%v body=%s",
- response.Code, networkID, containerDeleted, networkDeletes, registry.proxies["account-a"], response.Body.String())
- }
- })
- }
-}
-
-func TestGatewayCreateFailureRemovesCreatedNetworkGeneration(t *testing.T) {
- for _, failure := range []string{"network-create-id", "configure", "container-create", "start"} {
- t.Run(failure, func(t *testing.T) {
- var mu sync.Mutex
- networkExists, containerExists := false, false
- members := map[string]string{}
- networkDeletes := 0
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- mu.Lock()
- exists := containerExists
- mu.Unlock()
- if !exists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _, _ = response.Write([]byte(`{"Id":"container-c1","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkIDLabel + `":"network-n1"}}}`))
- case request.Method == http.MethodGet && (request.URL.Path == "/networks/creatorhub_browser-account-a" || request.URL.Path == "/networks/network-n1"):
- mu.Lock()
- exists := networkExists
- current := map[string]any{}
- for id, name := range members {
- ip := "127.0.0.1/8"
- if failure == "configure" && id == "gateway-self" {
- ip = "192.0.2.1/24"
- }
- current[id] = map[string]string{"Name": name, "IPv4Address": ip}
- }
- mu.Unlock()
- if !exists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-n1", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": current,
- })
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- mu.Lock()
- networkExists = true
- mu.Unlock()
- response.WriteHeader(http.StatusCreated)
- if failure == "network-create-id" {
- _, _ = response.Write([]byte(`{"Id":""}`))
- } else {
- _, _ = response.Write([]byte(`{"Id":"network-n1"}`))
- }
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect") &&
- !strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- mu.Lock()
- members[body.Container] = body.Container
- mu.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodPost && request.URL.Path == "/containers/create":
- if failure == "container-create" {
- response.WriteHeader(http.StatusInternalServerError)
- return
- }
- mu.Lock()
- containerExists = true
- members["container-c1"] = namePrefix + "account-a"
- mu.Unlock()
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"container-c1"}`))
- case request.Method == http.MethodPost && request.URL.Path == "/containers/container-c1/start":
- if failure == "start" {
- response.WriteHeader(http.StatusInternalServerError)
- return
- }
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodDelete && request.URL.Path == "/containers/container-c1":
- mu.Lock()
- containerExists = false
- delete(members, "container-c1")
- mu.Unlock()
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- mu.Lock()
- delete(members, body.Container)
- mu.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodDelete && request.URL.Path == "/networks/network-n1":
- mu.Lock()
- networkExists = false
- networkDeletes++
- mu.Unlock()
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- handler := newGatewayWithSelf(docker, "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)))
- mu.Lock()
- defer mu.Unlock()
- wantNetwork, wantDeletes := false, 1
- if failure == "network-create-id" {
- wantNetwork, wantDeletes = true, 0
- }
- if response.Code != http.StatusBadGateway || networkExists != wantNetwork || networkDeletes != wantDeletes || containerExists {
- t.Fatalf("%s failure left managed resources: status=%d network=%v deletes=%d container=%v body=%s",
- failure, response.Code, networkExists, networkDeletes, containerExists, response.Body.String())
- }
- })
- }
-}
-
-func TestGatewayNetworkCreateDisconnectDoesNotDiscoverReplacementByName(t *testing.T) {
- networkReads, networkDeletes := 0, 0
- networkCreated := false
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
- networkReads++
- if networkCreated {
- _, _ = response.Write([]byte(`{"Id":"network-n2","Name":"creatorhub_browser-account-a","Driver":"bridge","Labels":{"` + managedLabel + `":"true","` + networkRoleLabel + `":"` + browserNetworkRole + `","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1"},"Containers":{}}`))
- return
- }
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- networkCreated = true
- connection, _, _ := response.(http.Hijacker).Hijack()
- _ = connection.Close()
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/networks/"):
- networkDeletes++
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers", strings.NewReader(testCreateBody)))
- if response.Code != http.StatusBadGateway || !networkCreated || networkReads != 1 || networkDeletes != 0 {
- t.Fatalf("unknown N1 was discovered or cleaned as N2: status=%d created=%v reads=%d deletes=%d body=%s",
- response.Code, networkCreated, networkReads, networkDeletes, response.Body.String())
- }
-}
-
-func TestGatewayCompensatesConnectThatAppliedBeforeError(t *testing.T) {
- for _, restore := range []bool{false, true} {
- name := "create self"
- failedMember := "gateway-self"
- if restore {
- name, failedMember = "restore runtime", "container-c1"
- }
- t.Run(name, func(t *testing.T) {
- networkExists := false
- members := map[string]string{}
- disconnected := []string{}
- networkDeletes := 0
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/images/"):
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- if !restore {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _, _ = response.Write([]byte(`{"Id":"container-c1","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkExitLabel + `":"exit-1","` + networkIDLabel + `":"network-n1","` + proxyPortLabel + `":"12345"}}}`))
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
- if !networkExists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- containers := map[string]any{}
- for id, memberName := range members {
- containers[id] = map[string]string{"Name": memberName, "IPv4Address": "127.0.0.1/8"}
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-n1", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"}, "Containers": containers,
- })
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- networkExists = true
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"network-n1"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect") &&
- !strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- members[body.Container] = body.Container
- if body.Container == failedMember {
- response.WriteHeader(http.StatusInternalServerError)
- return
- }
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- delete(members, body.Container)
- disconnected = append(disconnected, body.Container)
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodDelete && request.URL.Path == "/networks/network-n1":
- networkExists = false
- networkDeletes++
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
- method, path, body := http.MethodPost, "/v1/browsers", testCreateBody
- if restore {
- path = "/v1/browsers/account-a/proxy"
- body = `{"binding_version":1,"runtime_id":"container-c1","network_id":"network-n1","network_exit_id":"exit-1","network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
- }
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(method, path, strings.NewReader(body)))
- wantDeletes, wantDisconnects := 1, 1
- if restore {
- wantDeletes, wantDisconnects = 0, 0
- }
- if response.Code != http.StatusBadGateway || networkExists || networkDeletes != wantDeletes ||
- len(disconnected) != wantDisconnects || (wantDisconnects == 1 && disconnected[0] != failedMember) {
- t.Fatalf("applied connect was not compensated: status=%d network=%v deletes=%d disconnected=%v body=%s",
- response.Code, networkExists, networkDeletes, disconnected, response.Body.String())
- }
- })
- }
-}
-
-func TestGatewayRestoreFailureRemovesCreatedNetworkGeneration(t *testing.T) {
- var mu sync.Mutex
- networkExists := false
- members := map[string]string{}
- networkDeletes, containerDeletes := 0, 0
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && request.URL.Path == "/containers/"+namePrefix+"account-a/json":
- _, _ = response.Write([]byte(`{"Id":"container-c1","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"1","` + networkExitLabel + `":"exit-1","` + networkIDLabel + `":"network-n1","` + proxyPortLabel + `":"12345"}}}`))
- case request.Method == http.MethodGet && (request.URL.Path == "/networks/creatorhub_browser-account-a" || request.URL.Path == "/networks/network-n1"):
- mu.Lock()
- exists := networkExists
- current := map[string]any{}
- for id, name := range members {
- ip := "127.0.0.3/8"
- if id == "gateway-self" {
- ip = "192.0.2.1/24"
- }
- current[id] = map[string]string{"Name": name, "IPv4Address": ip}
- }
- mu.Unlock()
- if !exists {
- response.WriteHeader(http.StatusNotFound)
- return
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-n1", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a", bindingVersionLabel: "1"},
- "Containers": current,
- })
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- mu.Lock()
- networkExists = true
- mu.Unlock()
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"network-n1"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- mu.Lock()
- members[body.Container] = body.Container
- mu.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/disconnect"):
- var body struct {
- Container string `json:"Container"`
- }
- _ = json.NewDecoder(request.Body).Decode(&body)
- mu.Lock()
- delete(members, body.Container)
- mu.Unlock()
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodDelete && request.URL.Path == "/networks/network-n1":
- mu.Lock()
- networkExists = false
- networkDeletes++
- mu.Unlock()
- response.WriteHeader(http.StatusNoContent)
- case request.Method == http.MethodDelete && strings.HasPrefix(request.URL.Path, "/containers/"):
- mu.Lock()
- containerDeletes++
- mu.Unlock()
- response.WriteHeader(http.StatusNoContent)
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- handler := newGatewayWithSelf(docker, "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- body := `{"binding_version":1,"runtime_id":"container-c1","network_id":"network-n1","network_exit_id":"exit-1","network_exit":{"protocol":"socks5","host":"proxy.example","port":1080}}`
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodPost, "/v1/browsers/account-a/proxy", strings.NewReader(body)))
- mu.Lock()
- defer mu.Unlock()
- if response.Code != http.StatusBadGateway || networkExists || networkDeletes != 0 || containerDeletes != 0 {
- t.Fatalf("restore failure left managed network: status=%d network=%v networkDeletes=%d containerDeletes=%d body=%s",
- response.Code, networkExists, networkDeletes, containerDeletes, response.Body.String())
- }
-}
-
-func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) {
- docker, server := testDocker(func(response http.ResponseWriter, _ *http.Request) {
- http.Error(response, "daemon unavailable", http.StatusInternalServerError)
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(testGenerationBody)))
- if response.Code != http.StatusBadGateway {
- t.Fatalf("expected 502 for Docker failure, got %d: %s", response.Code, response.Body.String())
- }
-}
-
-func TestGatewayRefusesUnmanagedContainer(t *testing.T) {
- deleted := false
- docker, server := testDocker(func(response http.ResponseWriter, request *http.Request) {
- switch request.Method {
- case http.MethodGet:
- _, _ = response.Write([]byte(`{"Config":{"Labels":{}}}`))
- case http.MethodDelete:
- deleted = true
- response.WriteHeader(http.StatusNoContent)
- }
- })
- defer server.Close()
-
- handler := newGateway(docker, "creatorhub_browser", testToken)
- request := authed(http.MethodDelete, "/v1/browsers/foreign", strings.NewReader(testGenerationBody))
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, request)
-
- if response.Code != http.StatusForbidden || deleted {
- t.Fatalf("expected unmanaged container to be rejected, status=%d deleted=%v", response.Code, deleted)
- }
-}
-
-func TestGatewayRejectsStaleGenerationBeforeDockerMutation(t *testing.T) {
- for _, request := range []struct {
- method, path string
- }{
- {http.MethodPost, "/v1/browsers/account-a/stop"},
- {http.MethodPost, "/v1/browsers/account-a/start"},
- {http.MethodDelete, "/v1/browsers/account-a"},
- } {
- t.Run(request.method, func(t *testing.T) {
- mutations := 0
- docker, server := testDocker(func(response http.ResponseWriter, dockerRequest *http.Request) {
- if dockerRequest.Method != http.MethodGet {
- mutations++
- }
- _, _ = response.Write([]byte(`{"Id":"new-container","Config":{"Labels":{"` + managedLabel + `":"true","` + idLabel + `":"account-a","` + bindingVersionLabel + `":"2"}}}`))
- })
- defer server.Close()
- handler := newGateway(docker, "creatorhub_browser", testToken)
- response := httptest.NewRecorder()
- body := testGenerationBody
- if strings.HasSuffix(request.path, "/start") {
- body = `{"binding_version":1,"runtime_id":"container-id","network_id":"network-id"}`
- }
- adaptor.FiberApp(handler).ServeHTTP(response, authed(request.method, request.path, strings.NewReader(body)))
- if response.Code != http.StatusConflict || mutations != 0 {
- t.Fatalf("stale generation reached Docker mutation: status=%d mutations=%d body=%s", response.Code, mutations, response.Body.String())
- }
- })
- }
-}
-
-func TestGatewayRejectsStaleDeleteDuringNewNetworkCreation(t *testing.T) {
- mutations := 0
- server := httptest.NewServer(withAliasReservations("gateway-self", func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/containers/"):
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/networks/"):
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-new", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a",
- bindingVersionLabel: "2"},
- })
- default:
- mutations++
- response.WriteHeader(http.StatusNoContent)
- }
- }))
- defer server.Close()
- handler := newGatewayWithSelf(dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()},
- "creatorhub_browser", testToken, "gateway-self")
- response := httptest.NewRecorder()
- adaptor.FiberApp(handler).ServeHTTP(response, authed(http.MethodDelete, "/v1/browsers/account-a", strings.NewReader(testGenerationBody)))
- if response.Code != http.StatusConflict || mutations != 0 {
- t.Fatalf("stale delete crossed the new network generation: status=%d mutations=%d body=%s", response.Code, mutations, response.Body.String())
- }
-}
-
-func TestEnsureTenantNetworkConnectsGatewayOnlyToRuntimeNetwork(t *testing.T) {
- created, connected := false, false
- server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- switch {
- case request.Method == http.MethodGet && !created:
- response.WriteHeader(http.StatusNotFound)
- case request.Method == http.MethodPost && request.URL.Path == "/networks/create":
- var body map[string]any
- _ = json.NewDecoder(request.Body).Decode(&body)
- labels := body["Labels"].(map[string]any)
- if body["Name"] != "creatorhub_browser-account-a" || labels[idLabel] != "account-a" {
- t.Fatalf("unexpected isolated network create: %#v", body)
- }
- created = true
- response.WriteHeader(http.StatusCreated)
- _, _ = response.Write([]byte(`{"Id":"network-id"}`))
- case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/connect"):
- connected = true
- response.WriteHeader(http.StatusOK)
- case request.Method == http.MethodGet && request.URL.Path == "/containers/gateway-id/json":
- _, _ = response.Write([]byte(`{"Id":"gateway-id","Config":{"Labels":{"` + gatewayMemberLabel + `":"true"}}}`))
- case request.Method == http.MethodGet:
- members := map[string]any{}
- if connected {
- members["gateway-id"] = map[string]string{"Name": "gateway-id", "IPv4Address": "127.0.0.3/8"}
- }
- _ = json.NewEncoder(response).Encode(map[string]any{
- "Id": "network-id", "Name": "creatorhub_browser-account-a", "Driver": "bridge", "Internal": false, "Attachable": false, "Ingress": false,
- "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: "account-a",
- bindingVersionLabel: "1"},
- "Containers": members,
- })
- default:
- t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.Path)
- }
- }))
- defer server.Close()
- docker := dockerClient{baseURL: server.URL, client: server.Client(), slow: server.Client()}
- generation, bindHost, err := docker.ensureTenantNetwork("creatorhub_browser", "account-a", "gateway-id", 1, "", "", false)
- if err != nil || !created || !connected || generation.Name != "creatorhub_browser-account-a" || bindHost != "127.0.0.3" {
- t.Fatalf("isolated network was not created and connected: generation=%#v host=%q created=%v connected=%v err=%v", generation, bindHost, created, connected, err)
- }
-}
-
-func TestLoadConfigRequiresGatewayToken(t *testing.T) {
- t.Setenv("GATEWAY_TOKEN", "short")
- if _, err := loadConfig(); err == nil {
- t.Fatal("expected a short gateway token to be rejected")
- }
-}
-
-func TestLoadConfigRejectsControlNetwork(t *testing.T) {
- t.Setenv("GATEWAY_TOKEN", testToken)
- t.Setenv("BROWSER_NETWORK", controlNetworkName)
- if _, err := loadConfig(); err == nil {
- t.Fatal("expected control network configuration to be rejected")
- }
-}
diff --git a/cmd/docker-gateway/proxy.go b/cmd/docker-gateway/proxy.go
deleted file mode 100644
index ae92734..0000000
--- a/cmd/docker-gateway/proxy.go
+++ /dev/null
@@ -1,435 +0,0 @@
-package main
-
-import (
- "bufio"
- "context"
- "crypto/tls"
- "encoding/base64"
- "encoding/binary"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/url"
- "strconv"
- "sync"
- "time"
-)
-
-const browserProxyHost = "docker-gateway"
-
-type memoryProxyRegistry struct {
- mu sync.Mutex
- proxies map[string]*memoryProxy
-}
-
-type memoryProxy struct {
- mu sync.RWMutex
- exit gatewayProxyExit
- bindingVersion int64
- runtimeID string
- networkID string
- bindHost string
- listener net.Listener
- server *http.Server
- tunnels map[net.Conn]net.Conn
- url string
-}
-
-func newMemoryProxyRegistry() *memoryProxyRegistry {
- return &memoryProxyRegistry{proxies: map[string]*memoryProxy{}}
-}
-
-func (registry *memoryProxyRegistry) configure(alias string, bindingVersion int64, bindHost string, port int, exit gatewayProxyExit,
- networkIDs ...string) (string, func(), error) {
- registry.mu.Lock()
- defer registry.mu.Unlock()
- networkID := ""
- if len(networkIDs) == 1 {
- networkID = networkIDs[0]
- }
- if proxy := registry.proxies[alias]; proxy != nil {
- if proxy.bindingVersion == bindingVersion && proxy.bindHost == bindHost &&
- (port == 0 || proxy.listener.Addr().(*net.TCPAddr).Port == port) && proxy.exit == exit && proxy.networkID == networkID {
- return proxy.url, func() { registry.removeObject(alias, proxy) }, nil
- }
- delete(registry.proxies, alias)
- // A gateway replica may still hold the previous runtime generation. Close
- // it before rebinding a restored listener, while removeObject's identity
- // check keeps old cleanup callbacks from deleting the replacement.
- closeMemoryProxy(proxy)
- }
- listener, err := net.Listen("tcp4", net.JoinHostPort(bindHost, strconv.Itoa(port)))
- if err != nil {
- return "", nil, err
- }
- actualPort := listener.Addr().(*net.TCPAddr).Port
- proxy := &memoryProxy{exit: exit, bindingVersion: bindingVersion, networkID: networkID, bindHost: bindHost, listener: listener,
- tunnels: make(map[net.Conn]net.Conn), url: "http://" + net.JoinHostPort(browserProxyHost, strconv.Itoa(actualPort))}
- proxy.server = &http.Server{Handler: proxy, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
- registry.proxies[alias] = proxy
- go func() { _ = proxy.server.Serve(listener) }()
- undo := func() {
- registry.removeObject(alias, proxy)
- }
- return proxy.url, undo, nil
-}
-
-func (registry *memoryProxyRegistry) removeObject(alias string, proxy *memoryProxy) {
- registry.mu.Lock()
- if registry.proxies[alias] == proxy {
- delete(registry.proxies, alias)
- } else {
- proxy = nil
- }
- registry.mu.Unlock()
- if proxy != nil {
- closeMemoryProxy(proxy)
- }
-}
-
-func closeMemoryProxy(proxy *memoryProxy) {
- proxy.mu.Lock()
- tunnels := proxy.tunnels
- proxy.tunnels = nil
- proxy.mu.Unlock()
- _ = proxy.listener.Close()
- _ = proxy.server.Close()
- for client, upstream := range tunnels {
- _ = client.Close()
- _ = upstream.Close()
- }
-}
-
-func (registry *memoryProxyRegistry) ready(alias string, port int, runtimeID string, networkIDs ...string) bool {
- registry.mu.Lock()
- defer registry.mu.Unlock()
- proxy := registry.proxies[alias]
- return proxy != nil && runtimeID != "" && proxy.runtimeID == runtimeID && proxy.listener.Addr().(*net.TCPAddr).Port == port &&
- (len(networkIDs) == 0 || proxy.networkID == networkIDs[0])
-}
-
-func (registry *memoryProxyRegistry) bind(alias string, bindingVersion int64, proxyURL, runtimeID string, networkIDs ...string) bool {
- registry.mu.Lock()
- defer registry.mu.Unlock()
- proxy := registry.proxies[alias]
- if proxy == nil || runtimeID == "" || proxy.bindingVersion != bindingVersion || proxy.url != proxyURL {
- return false
- }
- proxy.runtimeID = runtimeID
- if len(networkIDs) == 1 {
- proxy.networkID = networkIDs[0]
- }
- return true
-}
-
-func (registry *memoryProxyRegistry) remove(alias string, bindingVersion int64, runtimeID string, networkIDs ...string) bool {
- registry.mu.Lock()
- proxy := registry.proxies[alias]
- if proxy != nil && runtimeID != "" && proxy.bindingVersion == bindingVersion && proxy.runtimeID == runtimeID &&
- (len(networkIDs) == 0 || proxy.networkID == networkIDs[0]) {
- delete(registry.proxies, alias)
- } else if proxy != nil {
- registry.mu.Unlock()
- return false
- } else {
- proxy = nil
- }
- registry.mu.Unlock()
- if proxy != nil {
- closeMemoryProxy(proxy)
- }
- return true
-}
-
-func (proxy *memoryProxy) ServeHTTP(response http.ResponseWriter, request *http.Request) {
- if request.Method == http.MethodConnect {
- proxy.tunnel(response, request)
- return
- }
- proxy.mu.RLock()
- exit := proxy.exit
- proxy.mu.RUnlock()
- transport := &http.Transport{DisableKeepAlives: true}
- if exit.Protocol == "http" || exit.Protocol == "https" {
- upstream := &url.URL{Scheme: exit.Protocol, Host: net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port))}
- if exit.Username != "" {
- upstream.User = url.UserPassword(exit.Username, exit.Password)
- }
- transport.Proxy = http.ProxyURL(upstream)
- } else {
- transport.DialContext = proxy.dialContext
- }
- defer transport.CloseIdleConnections()
- outbound := request.Clone(request.Context())
- outbound.RequestURI = ""
- outbound.Header.Del("Proxy-Authorization")
- result, err := transport.RoundTrip(outbound)
- if err != nil {
- http.Error(response, "proxy connection failed", http.StatusBadGateway)
- return
- }
- defer result.Body.Close()
- for key, values := range result.Header {
- for _, value := range values {
- response.Header().Add(key, value)
- }
- }
- response.WriteHeader(result.StatusCode)
- _, _ = io.Copy(response, result.Body)
-}
-
-func (proxy *memoryProxy) tunnel(response http.ResponseWriter, request *http.Request) {
- upstream, err := proxy.dialContext(request.Context(), "tcp", request.Host)
- if err != nil {
- http.Error(response, "proxy connection failed", http.StatusBadGateway)
- return
- }
- client, buffered, err := http.NewResponseController(response).Hijack()
- if err != nil {
- _ = upstream.Close()
- http.Error(response, "proxy tunnel unavailable", http.StatusInternalServerError)
- return
- }
- if _, err := buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil || buffered.Flush() != nil {
- _ = client.Close()
- _ = upstream.Close()
- return
- }
- proxy.mu.Lock()
- if proxy.tunnels == nil {
- proxy.mu.Unlock()
- _ = client.Close()
- _ = upstream.Close()
- return
- }
- proxy.tunnels[client] = upstream
- proxy.mu.Unlock()
- defer func() {
- proxy.mu.Lock()
- delete(proxy.tunnels, client)
- proxy.mu.Unlock()
- _ = client.Close()
- _ = upstream.Close()
- }()
- done := make(chan struct{}, 2)
- go func() { _, _ = io.Copy(upstream, client); done <- struct{}{} }()
- go func() { _, _ = io.Copy(client, upstream); done <- struct{}{} }()
- <-done
-}
-
-func (proxy *memoryProxy) dialContext(ctx context.Context, _, target string) (net.Conn, error) {
- ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
- defer cancel()
- proxy.mu.RLock()
- exit := proxy.exit
- proxy.mu.RUnlock()
- switch exit.Protocol {
- case "http", "https":
- return dialHTTPProxy(ctx, exit, target)
- case "socks4":
- return dialSOCKS4Proxy(ctx, exit, target)
- case "socks5":
- return dialSOCKS5Proxy(ctx, exit, target)
- default:
- return nil, errors.New("unsupported proxy protocol")
- }
-}
-
-func dialHTTPProxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
- address := net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port))
- connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
- if err != nil {
- return nil, err
- }
- if exit.Protocol == "https" {
- tlsConnection := tls.Client(connection, &tls.Config{ServerName: exit.Host, MinVersion: tls.VersionTLS12})
- if err := tlsConnection.HandshakeContext(ctx); err != nil {
- _ = connection.Close()
- return nil, err
- }
- connection = tlsConnection
- }
- request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)}
- if exit.Username != "" {
- request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(exit.Username+":"+exit.Password)))
- }
- if deadline, ok := ctx.Deadline(); ok {
- _ = connection.SetDeadline(deadline)
- }
- if err := request.Write(connection); err != nil {
- _ = connection.Close()
- return nil, err
- }
- result, err := http.ReadResponse(bufio.NewReader(connection), request)
- if err != nil {
- _ = connection.Close()
- return nil, err
- }
- if result.StatusCode != http.StatusOK {
- _ = result.Body.Close()
- _ = connection.Close()
- return nil, fmt.Errorf("upstream proxy returned %s", result.Status)
- }
- _ = connection.SetDeadline(time.Time{})
- return connection, nil
-}
-
-func dialSOCKS4Proxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
- connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port)))
- if err != nil {
- return nil, err
- }
- host, portText, err := net.SplitHostPort(target)
- if err != nil {
- _ = connection.Close()
- return nil, err
- }
- port, err := strconv.Atoi(portText)
- if err != nil || port < 1 || port > 65535 {
- _ = connection.Close()
- return nil, errors.New("invalid SOCKS4 target")
- }
- payload := []byte{4, 1, byte(port >> 8), byte(port), 0, 0, 0, 1}
- if ip := net.ParseIP(host).To4(); ip != nil {
- copy(payload[4:8], ip)
- }
- payload = append(payload, exit.Username...)
- payload = append(payload, 0)
- if net.ParseIP(host).To4() == nil {
- payload = append(payload, host...)
- payload = append(payload, 0)
- }
- if err := exchangeSOCKS(ctx, connection, payload, 8); err != nil {
- return nil, err
- }
- return connection, nil
-}
-
-func dialSOCKS5Proxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
- connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port)))
- if err != nil {
- return nil, err
- }
- methods := []byte{5, 1, 0}
- if exit.Username != "" {
- methods = []byte{5, 1, 2}
- }
- if deadline, ok := ctx.Deadline(); ok {
- _ = connection.SetDeadline(deadline)
- }
- if _, err := connection.Write(methods); err != nil {
- _ = connection.Close()
- return nil, err
- }
- selection := make([]byte, 2)
- if _, err := io.ReadFull(connection, selection); err != nil || selection[0] != 5 || selection[1] == 0xff {
- _ = connection.Close()
- return nil, errors.New("SOCKS5 authentication method rejected")
- }
- if selection[1] == 2 {
- if len(exit.Username) > 255 || len(exit.Password) > 255 {
- _ = connection.Close()
- return nil, errors.New("SOCKS5 credentials too long")
- }
- auth := append([]byte{1, byte(len(exit.Username))}, exit.Username...)
- auth = append(auth, byte(len(exit.Password)))
- auth = append(auth, exit.Password...)
- if _, err := connection.Write(auth); err != nil {
- _ = connection.Close()
- return nil, err
- }
- result := make([]byte, 2)
- if _, err := io.ReadFull(connection, result); err != nil || result[1] != 0 {
- _ = connection.Close()
- return nil, errors.New("SOCKS5 authentication rejected")
- }
- } else if exit.Username != "" {
- _ = connection.Close()
- return nil, errors.New("SOCKS5 proxy skipped required authentication")
- }
- host, portText, err := net.SplitHostPort(target)
- if err != nil {
- _ = connection.Close()
- return nil, err
- }
- port, err := strconv.Atoi(portText)
- if err != nil || port < 1 || port > 65535 {
- _ = connection.Close()
- return nil, errors.New("invalid SOCKS5 target")
- }
- request := []byte{5, 1, 0}
- if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
- request = append(request, 1)
- request = append(request, ip.To4()...)
- } else if ip != nil {
- request = append(request, 4)
- request = append(request, ip.To16()...)
- } else {
- if len(host) > 255 {
- _ = connection.Close()
- return nil, errors.New("SOCKS5 target too long")
- }
- request = append(request, 3, byte(len(host)))
- request = append(request, host...)
- }
- portBytes := make([]byte, 2)
- binary.BigEndian.PutUint16(portBytes, uint16(port))
- request = append(request, portBytes...)
- if _, err := connection.Write(request); err != nil {
- _ = connection.Close()
- return nil, err
- }
- header := make([]byte, 4)
- if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 0 {
- _ = connection.Close()
- return nil, errors.New("SOCKS5 proxy rejected connection")
- }
- addressLength := 0
- switch header[3] {
- case 1:
- addressLength = 4
- case 4:
- addressLength = 16
- case 3:
- var length [1]byte
- if _, err := io.ReadFull(connection, length[:]); err != nil {
- _ = connection.Close()
- return nil, err
- }
- addressLength = int(length[0])
- default:
- _ = connection.Close()
- return nil, errors.New("invalid SOCKS5 response")
- }
- if _, err := io.CopyN(io.Discard, connection, int64(addressLength+2)); err != nil {
- _ = connection.Close()
- return nil, err
- }
- _ = connection.SetDeadline(time.Time{})
- return connection, nil
-}
-
-func exchangeSOCKS(ctx context.Context, connection net.Conn, request []byte, responseBytes int) error {
- if deadline, ok := ctx.Deadline(); ok {
- _ = connection.SetDeadline(deadline)
- }
- if _, err := connection.Write(request); err != nil {
- _ = connection.Close()
- return err
- }
- if responseBytes > 0 {
- response := make([]byte, responseBytes)
- if _, err := io.ReadFull(connection, response); err != nil {
- _ = connection.Close()
- return err
- }
- if responseBytes == 8 && response[1] != 90 {
- _ = connection.Close()
- return errors.New("SOCKS4 proxy rejected connection")
- }
- }
- _ = connection.SetDeadline(time.Time{})
- return nil
-}
diff --git a/cmd/docker-gateway/proxy_test.go b/cmd/docker-gateway/proxy_test.go
deleted file mode 100644
index 5467e07..0000000
--- a/cmd/docker-gateway/proxy_test.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package main
-
-import (
- "bufio"
- "encoding/binary"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strconv"
- "strings"
- "testing"
- "time"
-)
-
-func TestMemoryProxyUsesSOCKS5Credentials(t *testing.T) {
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- defer listener.Close()
- done := make(chan error, 1)
- go func() {
- connection, err := listener.Accept()
- if err != nil {
- done <- err
- return
- }
- defer connection.Close()
- greeting := make([]byte, 3)
- if _, err := io.ReadFull(connection, greeting); err != nil {
- done <- err
- return
- }
- _, _ = connection.Write([]byte{5, 2})
- authHeader := make([]byte, 2)
- _, _ = io.ReadFull(connection, authHeader)
- username := make([]byte, int(authHeader[1]))
- _, _ = io.ReadFull(connection, username)
- var passwordLength [1]byte
- _, _ = io.ReadFull(connection, passwordLength[:])
- password := make([]byte, int(passwordLength[0]))
- _, _ = io.ReadFull(connection, password)
- if string(username) != "operator" || string(password) != "ephemeral" {
- done <- io.ErrUnexpectedEOF
- return
- }
- _, _ = connection.Write([]byte{1, 0})
- requestHeader := make([]byte, 5)
- _, _ = io.ReadFull(connection, requestHeader)
- host := make([]byte, int(requestHeader[4]))
- _, _ = io.ReadFull(connection, host)
- port := make([]byte, 2)
- _, _ = io.ReadFull(connection, port)
- if string(host) != "example.com" || binary.BigEndian.Uint16(port) != 443 {
- done <- io.ErrUnexpectedEOF
- return
- }
- if _, err = connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0}); err != nil {
- done <- err
- return
- }
- var tunneled [1]byte
- _, err = io.ReadFull(connection, tunneled[:])
- if err == nil && tunneled[0] != 'x' {
- err = io.ErrUnexpectedEOF
- }
- done <- err
- }()
-
- host, portText, _ := net.SplitHostPort(listener.Addr().String())
- port, _ := net.LookupPort("tcp", portText)
- registry := newMemoryProxyRegistry()
- proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0, gatewayProxyExit{
- Protocol: "socks5", Host: host, Port: port, Username: "operator", Password: "ephemeral",
- })
- if err != nil {
- t.Fatal(err)
- }
- defer cleanup()
- parsed, _ := url.Parse(proxyURL)
- connection, err := net.Dial("tcp", strings.Replace(parsed.Host, browserProxyHost, "127.0.0.1", 1))
- if err != nil {
- t.Fatal(err)
- }
- if _, err := fmt.Fprint(connection, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); err != nil {
- t.Fatal(err)
- }
- response, err := http.ReadResponse(bufio.NewReader(connection), &http.Request{Method: http.MethodConnect})
- if err != nil || response.StatusCode != http.StatusOK {
- t.Fatalf("memory proxy CONNECT failed: response=%v err=%v", response, err)
- }
- if _, err := connection.Write([]byte{'x'}); err != nil {
- t.Fatal(err)
- }
- _ = connection.Close()
- if err := <-done; err != nil {
- t.Fatal(err)
- }
-}
-
-func TestMemoryProxyUsesAbsoluteFormForHTTPUpstream(t *testing.T) {
- upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
- if request.Method == http.MethodConnect {
- http.Error(response, "CONNECT forbidden", http.StatusMethodNotAllowed)
- return
- }
- if !request.URL.IsAbs() || request.URL.String() != "http://example.com/plain" {
- t.Fatalf("expected absolute-form request, got %q", request.URL.String())
- }
- if request.Header.Get("Proxy-Authorization") == "" {
- t.Fatal("upstream proxy credentials were not applied")
- }
- _, _ = response.Write([]byte("forwarded"))
- }))
- defer upstream.Close()
- address, _ := url.Parse(upstream.URL)
- port, _ := strconv.Atoi(address.Port())
- registry := newMemoryProxyRegistry()
- proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0, gatewayProxyExit{
- Protocol: "http", Host: address.Hostname(), Port: port, Username: "operator", Password: "ephemeral",
- })
- if err != nil {
- t.Fatal(err)
- }
- defer cleanup()
- proxyAddress := strings.Replace(strings.TrimPrefix(proxyURL, "http://"), browserProxyHost, "127.0.0.1", 1)
- client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: proxyAddress})}}
- response, err := client.Get("http://example.com/plain")
- if err != nil {
- t.Fatal(err)
- }
- defer response.Body.Close()
- body, _ := io.ReadAll(response.Body)
- if response.StatusCode != http.StatusOK || string(body) != "forwarded" {
- t.Fatalf("plain HTTP was not forwarded: status=%d body=%s", response.StatusCode, body)
- }
-}
-
-func TestMemoryProxyCleanupClosesHijackedTunnel(t *testing.T) {
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- defer listener.Close()
- upstreamClosed := make(chan error, 1)
- go func() {
- connection, err := listener.Accept()
- if err != nil {
- upstreamClosed <- err
- return
- }
- defer connection.Close()
- request, err := http.ReadRequest(bufio.NewReader(connection))
- if err == nil && request.Method == http.MethodConnect {
- _, err = fmt.Fprint(connection, "HTTP/1.1 200 Connection Established\r\n\r\n")
- }
- if err == nil {
- var data [1]byte
- _, err = connection.Read(data[:])
- }
- upstreamClosed <- err
- }()
- host, portText, _ := net.SplitHostPort(listener.Addr().String())
- port, _ := strconv.Atoi(portText)
- registry := newMemoryProxyRegistry()
- proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0,
- gatewayProxyExit{Protocol: "http", Host: host, Port: port})
- if err != nil {
- t.Fatal(err)
- }
- defer cleanup()
- proxyAddress := strings.Replace(strings.TrimPrefix(proxyURL, "http://"), browserProxyHost, "127.0.0.1", 1)
- client, err := net.Dial("tcp", proxyAddress)
- if err != nil {
- t.Fatal(err)
- }
- defer client.Close()
- if _, err = fmt.Fprint(client, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); err != nil {
- t.Fatal(err)
- }
- if response, err := http.ReadResponse(bufio.NewReader(client), &http.Request{Method: http.MethodConnect}); err != nil || response.StatusCode != http.StatusOK {
- t.Fatalf("open CONNECT tunnel: response=%v err=%v", response, err)
- }
- cleanup()
- _ = client.SetReadDeadline(time.Now().Add(time.Second))
- if _, err = client.Read(make([]byte, 1)); err == nil {
- t.Fatal("proxy cleanup left the client tunnel open")
- }
- select {
- case err = <-upstreamClosed:
- if err == nil {
- t.Fatal("proxy cleanup left the upstream tunnel open")
- }
- case <-time.After(time.Second):
- t.Fatal("proxy cleanup did not close the upstream tunnel")
- }
-}
-
-func TestMemoryProxyRejectsCrossAliasAddress(t *testing.T) {
- registry := newMemoryProxyRegistry()
- proxyURL, cleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0, gatewayProxyExit{Protocol: "http", Host: "127.0.0.1", Port: 1})
- if err != nil {
- t.Fatal(err)
- }
- defer cleanup()
- parsed, _ := url.Parse(proxyURL)
- if connection, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.2", parsed.Port()), 100*time.Millisecond); err == nil {
- _ = connection.Close()
- t.Fatal("another tenant address could reach account-a proxy")
- }
-}
-
-func TestMemoryProxyRemoveRequiresMatchingGeneration(t *testing.T) {
- registry := newMemoryProxyRegistry()
- proxyURL, cleanup, err := registry.configure("account-a", 2, "127.0.0.1", 0, gatewayProxyExit{Protocol: "http", Host: "127.0.0.1", Port: 1})
- if err != nil {
- t.Fatal(err)
- }
- defer cleanup()
- if !registry.bind("account-a", 2, proxyURL, "container-c2") {
- t.Fatal("bind proxy generation")
- }
- proxy := registry.proxies["account-a"]
- if registry.remove("account-a", 2, "") || registry.remove("account-a", 2, "container-c1") || registry.proxies["account-a"] != proxy {
- t.Fatal("stale generation removed the current proxy")
- }
-}
-
-func TestMemoryProxyReplacesStaleGenerationWithoutOldCleanup(t *testing.T) {
- registry := newMemoryProxyRegistry()
- exit := gatewayProxyExit{Protocol: "http", Host: "127.0.0.1", Port: 1}
- oldURL, oldCleanup, err := registry.configure("account-a", 1, "127.0.0.1", 0, exit, "network-n1")
- if err != nil {
- t.Fatal(err)
- }
- oldPort := registry.proxies["account-a"].listener.Addr().(*net.TCPAddr).Port
- newURL, newCleanup, err := registry.configure("account-a", 2, "127.0.0.1", oldPort, exit, "network-n2")
- if err != nil {
- t.Fatal(err)
- }
- defer newCleanup()
- if newURL != oldURL || registry.proxies["account-a"].bindingVersion != 2 {
- t.Fatalf("stale proxy was not replaced: old=%q new=%q proxy=%#v", oldURL, newURL, registry.proxies["account-a"])
- }
- oldCleanup()
- if registry.proxies["account-a"].bindingVersion != 2 {
- t.Fatal("old cleanup removed the replacement proxy")
- }
-}
diff --git a/cmd/docker_gateway/__init__.py b/cmd/docker_gateway/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/cmd/docker_gateway/docker_client.py b/cmd/docker_gateway/docker_client.py
new file mode 100644
index 0000000..1f82893
--- /dev/null
+++ b/cmd/docker_gateway/docker_client.py
@@ -0,0 +1,975 @@
+"""Minimal Docker Engine client and generation-fenced network helpers."""
+
+from __future__ import annotations
+
+import http.client
+import json
+import logging
+import os
+import re
+import socket
+import threading
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from ipaddress import ip_interface
+from urllib.parse import quote, urlencode
+
+MANAGED_LABEL = "io.creatorhub.managed"
+RUNTIME_ID_LABEL = "io.creatorhub.runtime-id"
+DISPLAY_NAME_LABEL = "io.creatorhub.display-name"
+BINDING_VERSION_LABEL = "io.creatorhub.binding-version"
+NETWORK_EXIT_LABEL = "io.creatorhub.network-exit-id"
+PROXY_PORT_LABEL = "io.creatorhub.proxy-port"
+NETWORK_ID_LABEL = "io.creatorhub.network-id"
+NETWORK_ROLE_LABEL = "io.creatorhub.network-role"
+GATEWAY_MEMBER_LABEL = "io.creatorhub.gateway-member"
+BROWSER_NETWORK_ROLE = "browser"
+BROWSER_PROXY_HOST = "docker-gateway"
+NAME_PREFIX = "creatorhub-browser-"
+RESERVATION_PREFIX = "creatorhub-reservation-"
+RESERVATION_LABEL = "io.creatorhub.alias-reservation"
+RESERVATION_GENERATION_LABEL = "io.creatorhub.reservation-generation"
+RESERVATION_OWNER_LABEL = "io.creatorhub.reservation-owner"
+
+RUNTIME_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
+NETWORK_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
+LOG = logging.getLogger("creatorhub.docker")
+
+
+class DockerError(RuntimeError):
+ def __init__(self, message: str, status: int | None = None) -> None:
+ super().__init__(message)
+ self.status = status
+
+
+class GenerationConflict(DockerError):
+ pass
+
+
+class NetworkSetupError(DockerError):
+ def __init__(self, message: str, generation: TenantNetworkGeneration) -> None:
+ super().__init__(message)
+ self.generation = generation
+
+
+class UnmanagedContainer(DockerError):
+ pass
+
+
+@dataclass(frozen=True)
+class DockerResponse:
+ status: int
+ reason: str
+ body: bytes
+
+
+class UnixHTTPConnection(http.client.HTTPConnection):
+ def __init__(self, socket_path: str, timeout: float) -> None:
+ super().__init__("docker", timeout=timeout)
+ self.socket_path = socket_path
+
+ def connect(self) -> None:
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.settimeout(self.timeout)
+ self.sock.connect(self.socket_path)
+
+
+@dataclass
+class TenantNetworkGeneration:
+ id: str = ""
+ name: str = ""
+ created: bool = False
+ connected_self: bool = False
+ connected_runtime: bool = False
+ gateway_members: list[str] = field(default_factory=list)
+ self_member: str = ""
+ runtime_attached: bool = False
+
+
+class DockerClient:
+ def __init__(self, socket_path: str, api_version: str = "v1.43") -> None:
+ self.socket_path = socket_path
+ self.base_path = "/" + api_version.strip("/")
+
+ def request(
+ self,
+ method: str,
+ path: str,
+ payload: object | None = None,
+ timeout: float = 30.0,
+ body_limit: int = 16 * 1024 * 1024,
+ ) -> DockerResponse:
+ encoded = (
+ None
+ if payload is None
+ else json.dumps(payload, separators=(",", ":")).encode()
+ )
+ connection: UnixHTTPConnection | None = None
+ try:
+ connection = UnixHTTPConnection(self.socket_path, timeout)
+ headers = {"Accept": "application/json"}
+ if encoded is not None:
+ headers["Content-Type"] = "application/json"
+ connection.request(method, self.base_path + path, encoded, headers)
+ response = connection.getresponse()
+ body = response.read(body_limit + 1)
+ if len(body) > body_limit:
+ raise DockerError(
+ "Docker response exceeded the configured limit", response.status
+ )
+ return DockerResponse(response.status, response.reason, body)
+ except (OSError, http.client.HTTPException) as exc:
+ raise DockerError("Docker API request failed") from exc
+ finally:
+ if connection is not None:
+ connection.close()
+
+ def expect(
+ self,
+ method: str,
+ path: str,
+ payload: object | None = None,
+ allowed: tuple[int, ...] = (204,),
+ ) -> None:
+ response = self.request(method, path, payload)
+ if response.status in allowed:
+ return
+ if response.status == 404:
+ raise FileNotFoundError(path)
+ raise DockerError(
+ f"Docker returned HTTP {response.status}: {response.body[:4096].decode('utf-8', 'replace').strip()}",
+ response.status,
+ )
+
+ def pull_if_missing(self, image: str) -> None:
+ encoded = quote(image, safe="")
+ response = self.request("GET", f"/images/{encoded}/json")
+ if response.status == 200:
+ return
+ if response.status != 404:
+ raise DockerError(
+ f"inspect image returned HTTP {response.status}", response.status
+ )
+ repository, tag = split_image_ref(image)
+ query = {"fromImage": image if "@" in image else repository}
+ if "@" not in image and tag:
+ query["tag"] = tag
+ response = self.request(
+ "POST",
+ "/images/create?" + urlencode(query),
+ timeout=600.0,
+ body_limit=64 * 1024 * 1024,
+ )
+ if response.status != 200:
+ raise DockerError(
+ f"pull image returned HTTP {response.status}: {response.body[:4096].decode('utf-8', 'replace').strip()}",
+ response.status,
+ )
+
+ def managed_container_state(
+ self, alias: str
+ ) -> tuple[str, dict[str, str], dict[str, str]]:
+ if not RUNTIME_ID_RE.fullmatch(alias):
+ raise ValueError("invalid runtime id")
+ response = self.request(
+ "GET", f"/containers/{quote(NAME_PREFIX + alias, safe='')}/json"
+ )
+ if response.status == 404:
+ raise FileNotFoundError(alias)
+ if response.status != 200:
+ raise DockerError(
+ f"Docker inspect returned HTTP {response.status}", response.status
+ )
+ try:
+ inspected = json.loads(response.body)
+ labels = inspected["Config"]["Labels"] or {}
+ container_id = inspected["Id"]
+ networks = inspected.get("NetworkSettings", {}).get("Networks", {}) or {}
+ if (
+ not isinstance(labels, dict)
+ or not isinstance(networks, dict)
+ or not isinstance(container_id, str)
+ ):
+ raise TypeError("Docker inspect container metadata is invalid")
+ if not all(
+ isinstance(key, str) and isinstance(value, str)
+ for key, value in labels.items()
+ ):
+ raise TypeError("Docker inspect labels are invalid")
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise DockerError(
+ "Docker inspect response is invalid", response.status
+ ) from exc
+ if labels.get(MANAGED_LABEL) != "true" or labels.get(RUNTIME_ID_LABEL) != alias:
+ raise UnmanagedContainer("refusing to operate on an unowned container")
+ network_ids: dict[str, str] = {}
+ for name, value in networks.items():
+ if (
+ not isinstance(name, str)
+ or not isinstance(value, dict)
+ or not isinstance(value.get("NetworkID", ""), str)
+ ):
+ raise DockerError("Docker inspect network metadata is invalid")
+ network_ids[name] = value.get("NetworkID", "")
+ return container_id, labels, network_ids
+
+ def managed_container(self, alias: str) -> tuple[str, dict[str, str]]:
+ container_id, labels, _ = self.managed_container_state(alias)
+ return container_id, labels
+
+ def trusted_gateway_member(self, container_id: str) -> bool:
+ response = self.request(
+ "GET", f"/containers/{quote(container_id, safe='')}/json"
+ )
+ if response.status != 200:
+ return False
+ try:
+ inspected = json.loads(response.body)
+ if not isinstance(inspected, dict):
+ return False
+ config = inspected.get("Config", {})
+ labels = config.get("Labels", {}) if isinstance(config, dict) else {}
+ return bool(
+ isinstance(inspected.get("Id"), str)
+ and isinstance(labels, dict)
+ and labels.get(GATEWAY_MEMBER_LABEL) == "true"
+ )
+ except (TypeError, ValueError, json.JSONDecodeError):
+ return False
+
+ def container_network_address(self, container_id: str, network_id: str) -> str:
+ response = self.request(
+ "GET", f"/containers/{quote(container_id, safe='')}/json"
+ )
+ if response.status == 404:
+ raise FileNotFoundError("Docker container is missing")
+ if response.status != 200:
+ raise DockerError("inspect browser container failed", response.status)
+ try:
+ inspected = json.loads(response.body)
+ settings = inspected["NetworkSettings"]
+ networks = settings["Networks"]
+ if not isinstance(networks, dict):
+ raise TypeError("Docker container networks are invalid")
+ for value in networks.values():
+ if isinstance(value, dict) and value.get("NetworkID") == network_id:
+ address = value.get("IPAddress")
+ if isinstance(address, str) and address:
+ return address
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise DockerError("Docker container network metadata is invalid") from exc
+ raise GenerationConflict("browser container is not attached to its network")
+
+ def inspect_tenant_network(
+ self,
+ base: str,
+ alias: str,
+ binding_version: int,
+ runtime_id: str,
+ self_name: str,
+ expected_id: str = "",
+ allow_unversioned: bool = False,
+ ) -> tuple[TenantNetworkGeneration, dict[str, str], bool]:
+ name = tenant_network_name(base, alias)
+ generation = TenantNetworkGeneration(id=expected_id, name=name)
+ reference = expected_id or name
+ response = self.request("GET", f"/networks/{quote(reference, safe='')}")
+ if response.status == 404:
+ return generation, {}, False
+ if response.status != 200:
+ raise DockerError(
+ f"Docker network inspect returned HTTP {response.status}",
+ response.status,
+ )
+ try:
+ network = json.loads(response.body)
+ labels = network["Labels"] or {}
+ containers = network.get("Containers") or {}
+ network_id = network["Id"]
+ network_name = network["Name"]
+ if (
+ not isinstance(labels, dict)
+ or not isinstance(containers, dict)
+ or not isinstance(network_id, str)
+ or not isinstance(network_name, str)
+ ):
+ raise TypeError("Docker network metadata is invalid")
+ if not all(
+ isinstance(key, str) and isinstance(value, str)
+ for key, value in labels.items()
+ ):
+ raise TypeError("Docker network labels are invalid")
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise DockerError("Docker network inspect response is invalid") from exc
+ if (
+ not network_id
+ or network_name != name
+ or network.get("Driver") != "bridge"
+ or network.get("Internal")
+ or network.get("Attachable")
+ or network.get("Ingress")
+ or labels.get(MANAGED_LABEL) != "true"
+ or labels.get(NETWORK_ROLE_LABEL) != BROWSER_NETWORK_ROLE
+ or labels.get(RUNTIME_ID_LABEL) != alias
+ ):
+ raise UnmanagedContainer(
+ "refusing to operate on an unowned browser network"
+ )
+ if expected_id and network_id != expected_id:
+ raise GenerationConflict("network generation does not match request")
+ network_version = labels.get(BINDING_VERSION_LABEL, "")
+ if network_version != str(binding_version) and not (
+ allow_unversioned and not network_version
+ ):
+ raise GenerationConflict("network generation does not match request")
+ generation.id = network_id
+ addresses: dict[str, str] = {}
+ for member_id, member in containers.items():
+ if not isinstance(member, dict):
+ raise DockerError("Docker network member is invalid")
+ addresses[member_id] = str(member.get("IPv4Address", ""))
+ if member_id == runtime_id:
+ generation.runtime_attached = True
+ continue
+ if not self.trusted_gateway_member(member_id):
+ raise GenerationConflict(
+ "isolated network contains an untrusted member"
+ )
+ generation.gateway_members.append(member_id)
+ if same_container_reference(
+ member_id, str(member.get("Name", "")), self_name
+ ):
+ generation.self_member = member_id
+ return generation, addresses, True
+
+ def _finish_network_create(
+ self,
+ base: str,
+ alias: str,
+ self_name: str,
+ binding_version: int,
+ runtime_id: str,
+ response: DockerResponse,
+ generation: TenantNetworkGeneration,
+ ) -> TenantNetworkGeneration:
+ try:
+ created_id = json.loads(response.body)["Id"]
+ except (KeyError, TypeError, json.JSONDecodeError) as exc:
+ try:
+ observed, _, observed_exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ):
+ observed_exists = False
+ observed = TenantNetworkGeneration(name=generation.name)
+ if observed_exists:
+ generation = preserve_generation(generation, observed)
+ raise NetworkSetupError(
+ "create isolated browser network returned no id", generation
+ ) from exc
+ if not isinstance(created_id, str) or not created_id:
+ try:
+ observed, _, observed_exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ) as exc:
+ raise NetworkSetupError(
+ "create isolated browser network returned no id", generation
+ ) from exc
+ if observed_exists:
+ generation = preserve_generation(generation, observed)
+ raise NetworkSetupError(
+ "create isolated browser network returned no id", generation
+ )
+ generation.id = created_id
+ generation.created = True
+ try:
+ observed, _, observed_exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name, created_id
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ) as exc:
+ raise NetworkSetupError(
+ "created browser network could not be verified", generation
+ ) from exc
+ generation = preserve_generation(generation, observed)
+ if not observed_exists:
+ raise NetworkSetupError("created browser network disappeared", generation)
+ return generation
+
+ def ensure_tenant_network(
+ self,
+ base: str,
+ alias: str,
+ self_name: str,
+ binding_version: int,
+ runtime_id: str = "",
+ expected_id: str = "",
+ allow_unversioned: bool = False,
+ ) -> tuple[TenantNetworkGeneration, str]:
+ if not self_name:
+ raise DockerError("gateway identity is invalid")
+ generation, addresses, exists = self.inspect_tenant_network(
+ base,
+ alias,
+ binding_version,
+ runtime_id,
+ self_name,
+ expected_id,
+ allow_unversioned,
+ )
+ if not exists:
+ if expected_id:
+ raise GenerationConflict("network generation does not match request")
+ response = self.request(
+ "POST",
+ "/networks/create",
+ {
+ "Name": generation.name,
+ "CheckDuplicate": True,
+ "Driver": "bridge",
+ "Labels": {
+ MANAGED_LABEL: "true",
+ NETWORK_ROLE_LABEL: BROWSER_NETWORK_ROLE,
+ RUNTIME_ID_LABEL: alias,
+ BINDING_VERSION_LABEL: str(binding_version),
+ },
+ },
+ )
+ if response.status != 201:
+ try:
+ observed, _, observed_exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ):
+ observed_exists = False
+ observed = TenantNetworkGeneration(name=generation.name)
+ if not observed_exists:
+ raise DockerError(
+ "create isolated browser network failed", response.status
+ )
+ generation = preserve_generation(generation, observed)
+ else:
+ generation = self._finish_network_create(
+ base,
+ alias,
+ self_name,
+ binding_version,
+ runtime_id,
+ response,
+ generation,
+ )
+ if runtime_id and not generation.runtime_attached:
+ generation.connected_runtime = True
+ try:
+ self.expect(
+ "POST",
+ f"/networks/{quote(generation.id, safe='')}/connect",
+ {"Container": runtime_id},
+ (200,),
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ) as exc:
+ raise self._network_setup_error(
+ base,
+ alias,
+ binding_version,
+ runtime_id,
+ self_name,
+ generation,
+ "connecting the browser to its isolated network failed",
+ ) from exc
+ generation.runtime_attached = True
+ if not generation.self_member:
+ generation.connected_self = True
+ try:
+ self.expect(
+ "POST",
+ f"/networks/{quote(generation.id, safe='')}/connect",
+ {
+ "Container": self_name,
+ "EndpointConfig": {"Aliases": [BROWSER_PROXY_HOST]},
+ },
+ (200,),
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ) as exc:
+ raise self._network_setup_error(
+ base,
+ alias,
+ binding_version,
+ runtime_id,
+ self_name,
+ generation,
+ "connecting the gateway to its isolated network failed",
+ ) from exc
+ try:
+ observed, addresses, observed_exists = self.inspect_tenant_network(
+ base,
+ alias,
+ binding_version,
+ runtime_id,
+ self_name,
+ generation.id,
+ allow_unversioned,
+ )
+ generation = preserve_generation(generation, observed)
+ if not observed_exists or not generation.self_member:
+ raise NetworkSetupError(
+ "Docker did not connect gateway to the isolated network", generation
+ )
+ bind_host = str(ip_interface(addresses[generation.self_member]).ip)
+ except NetworkSetupError:
+ raise
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ) as exc:
+ raise NetworkSetupError(
+ "Docker could not verify the isolated network", generation
+ ) from exc
+ return generation, bind_host
+
+ def _network_setup_error(
+ self,
+ base: str,
+ alias: str,
+ binding_version: int,
+ runtime_id: str,
+ self_name: str,
+ generation: TenantNetworkGeneration,
+ message: str,
+ ) -> NetworkSetupError:
+ try:
+ observed, _, exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name, generation.id
+ )
+ generation = (
+ preserve_generation(generation, observed) if exists else generation
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ) as verification_error:
+ return NetworkSetupError(
+ f"{message}; network state verification failed: {verification_error}",
+ generation,
+ )
+ return NetworkSetupError(message, generation)
+
+ def disconnect_member(
+ self,
+ base: str,
+ alias: str,
+ binding_version: int,
+ runtime_id: str,
+ generation: TenantNetworkGeneration,
+ member: str,
+ self_name: str,
+ missing_ok: bool = False,
+ ) -> TenantNetworkGeneration:
+ current, _, exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name, generation.id
+ )
+ if not exists:
+ if missing_ok:
+ return current
+ raise GenerationConflict("isolated network generation is missing")
+ if not same_network_members(current, generation):
+ raise GenerationConflict("isolated network generation changed")
+ if not member_present(current, member, runtime_id):
+ return current
+ self.expect(
+ "POST",
+ f"/networks/{quote(generation.id, safe='')}/disconnect",
+ {"Container": member, "Force": True},
+ (200,),
+ )
+ observed, _, observed_exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name, generation.id
+ )
+ if not observed_exists or not same_network_members(
+ observed, generation_without_member(generation, member, runtime_id)
+ ):
+ raise GenerationConflict(
+ "Docker retained an isolated network member after disconnect"
+ )
+ return observed
+
+ def delete_tenant_network(
+ self,
+ base: str,
+ alias: str,
+ binding_version: int,
+ runtime_id: str,
+ generation: TenantNetworkGeneration,
+ self_name: str,
+ missing_ok: bool = False,
+ ) -> None:
+ current, _, exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name, generation.id
+ )
+ if not exists:
+ if missing_ok:
+ return
+ raise GenerationConflict("isolated network generation is missing")
+ if current.runtime_attached or current.gateway_members:
+ raise GenerationConflict("isolated network still has members")
+ self.expect(
+ "DELETE", f"/networks/{quote(generation.id, safe='')}", allowed=(204,)
+ )
+ _, _, exists = self.inspect_tenant_network(
+ base, alias, binding_version, runtime_id, self_name, generation.id
+ )
+ if exists:
+ raise DockerError("Docker retained isolated browser network")
+
+
+def split_image_ref(ref: str) -> tuple[str, str]:
+ if "@" in ref:
+ return ref.split("@", 1)[0], ref.split("@", 1)[1]
+ colon = ref.rfind(":")
+ slash = ref.rfind("/")
+ return (ref[:colon], ref[colon + 1 :]) if colon > slash else (ref, "")
+
+
+def tenant_network_name(base: str, alias: str) -> str:
+ name = f"{base}-{alias}"
+ if not NETWORK_NAME_RE.fullmatch(name):
+ raise ValueError("isolated browser network name is invalid")
+ return name
+
+
+def same_container_reference(container_id: str, name: str, reference: str) -> bool:
+ return bool(
+ reference
+ and (
+ container_id == reference
+ or name == reference
+ or container_id.startswith(reference)
+ or reference.startswith(container_id)
+ )
+ )
+
+
+def preserve_generation(
+ known: TenantNetworkGeneration, observed: TenantNetworkGeneration
+) -> TenantNetworkGeneration:
+ if not observed.id:
+ observed.id = known.id
+ if not observed.name:
+ observed.name = known.name
+ observed.created |= known.created
+ observed.runtime_attached |= known.runtime_attached
+ observed.connected_runtime |= known.connected_runtime
+ observed.connected_self |= known.connected_self
+ if not observed.self_member:
+ observed.self_member = known.self_member
+ for member in known.gateway_members:
+ if not member_present(observed, member, ""):
+ observed.gateway_members.append(member)
+ return observed
+
+
+def same_network_members(
+ current: TenantNetworkGeneration, expected: TenantNetworkGeneration
+) -> bool:
+ return (
+ current.id == expected.id
+ and current.name == expected.name
+ and current.runtime_attached == expected.runtime_attached
+ and current.self_member == expected.self_member
+ and set(current.gateway_members) == set(expected.gateway_members)
+ )
+
+
+def member_present(
+ generation: TenantNetworkGeneration, member: str, runtime_id: str
+) -> bool:
+ if (
+ generation.runtime_attached
+ and runtime_id
+ and same_container_reference(member, "", runtime_id)
+ ):
+ return True
+ return any(
+ same_container_reference(existing, "", member)
+ for existing in generation.gateway_members
+ ) or same_container_reference(member, "", generation.self_member)
+
+
+def generation_without_member(
+ generation: TenantNetworkGeneration, member: str, runtime_id: str
+) -> TenantNetworkGeneration:
+ result = TenantNetworkGeneration(
+ id=generation.id,
+ name=generation.name,
+ created=generation.created,
+ connected_self=generation.connected_self,
+ connected_runtime=generation.connected_runtime,
+ gateway_members=list(generation.gateway_members),
+ self_member=generation.self_member,
+ runtime_attached=generation.runtime_attached,
+ )
+ if runtime_id and same_container_reference(member, "", runtime_id):
+ result.runtime_attached = False
+ if same_container_reference(member, "", result.self_member):
+ result.self_member = ""
+ result.gateway_members = [
+ x for x in result.gateway_members if not same_container_reference(x, "", member)
+ ]
+ return result
+
+
+def random_reservation_generation() -> str:
+ return os.urandom(16).hex()
+
+
+class AliasReservationManager:
+ def __init__(self, docker: DockerClient, self_name: str) -> None:
+ self.docker = docker
+ self.self_name = self_name
+ self._locks: dict[str, threading.Lock] = {}
+ self._locks_guard = threading.Lock()
+
+ def acquire(self, alias: str) -> Callable[[], None]:
+ with self._locks_guard:
+ lock = self._locks.setdefault(alias, threading.Lock())
+ lock.acquire()
+ generation = random_reservation_generation()
+ created_id = ""
+ try:
+ response = self.docker.request(
+ "GET", f"/containers/{quote(self.self_name, safe='')}/json"
+ )
+ if response.status != 200:
+ raise DockerError(
+ "inspect trusted gateway for alias reservation", response.status
+ )
+ inspected = json.loads(response.body)
+ config = inspected.get("Config", {})
+ labels = config.get("Labels", {}) if isinstance(config, dict) else {}
+ image = inspected.get("Image", "")
+ if (
+ not isinstance(labels, dict)
+ or not isinstance(image, str)
+ or not image
+ or labels.get(GATEWAY_MEMBER_LABEL) != "true"
+ ):
+ raise DockerError("inspect trusted gateway for alias reservation")
+ reservation_payload = {
+ "Image": image,
+ "Labels": {
+ RESERVATION_LABEL: "true",
+ RUNTIME_ID_LABEL: alias,
+ RESERVATION_GENERATION_LABEL: generation,
+ RESERVATION_OWNER_LABEL: self.self_name,
+ },
+ "HostConfig": {"NetworkMode": "none"},
+ }
+ response = self.docker.request(
+ "POST",
+ "/containers/create?" + urlencode({"name": RESERVATION_PREFIX + alias}),
+ reservation_payload,
+ )
+ if response.status == 409 and self._reclaim_stale(alias):
+ response = self.docker.request(
+ "POST",
+ "/containers/create?"
+ + urlencode({"name": RESERVATION_PREFIX + alias}),
+ reservation_payload,
+ )
+ if response.status == 409:
+ raise GenerationConflict("browser alias is already in use")
+ if response.status != 201:
+ raise DockerError("create alias reservation failed", response.status)
+ created_id = json.loads(response.body).get("Id", "")
+ if not isinstance(created_id, str) or not created_id:
+ raise DockerError("Docker returned an invalid alias reservation id")
+ check = self.docker.request(
+ "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json"
+ )
+ if check.status != 200:
+ raise DockerError(
+ "alias reservation could not be verified", check.status
+ )
+ observed = json.loads(check.body)
+ observed_config = observed.get("Config", {})
+ observed_labels = (
+ observed_config.get("Labels", {})
+ if isinstance(observed_config, dict)
+ else {}
+ )
+ if (
+ not isinstance(observed_labels, dict)
+ or observed.get("Id") != created_id
+ or observed_labels.get(RESERVATION_LABEL) != "true"
+ or observed_labels.get(RESERVATION_GENERATION_LABEL) != generation
+ ):
+ raise GenerationConflict(
+ "alias reservation generation is not immutable"
+ )
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ):
+ try:
+ self._reconcile(alias, generation, created_id)
+ except (
+ DockerError,
+ FileNotFoundError,
+ OSError,
+ TypeError,
+ ValueError,
+ KeyError,
+ ):
+ LOG.exception(
+ "failed to reconcile alias reservation",
+ extra={"alias": alias, "generation": generation},
+ )
+ lock.release()
+ raise
+
+ def release() -> None:
+ try:
+ self._reconcile(alias, generation, created_id)
+ finally:
+ lock.release()
+
+ return release
+
+ def _reclaim_stale(self, alias: str) -> bool:
+ response = self.docker.request(
+ "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json"
+ )
+ if response.status == 404:
+ return True
+ if response.status != 200:
+ raise DockerError("inspect alias reservation failed", response.status)
+ try:
+ observed = json.loads(response.body)
+ config = observed.get("Config", {})
+ labels = config.get("Labels", {}) if isinstance(config, dict) else {}
+ observed_id = observed.get("Id")
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise DockerError("alias reservation inspect response is invalid") from exc
+ if (
+ not isinstance(labels, dict)
+ or labels.get(RESERVATION_LABEL) != "true"
+ or labels.get(RUNTIME_ID_LABEL) != alias
+ or not isinstance(observed_id, str)
+ or not observed_id
+ ):
+ raise GenerationConflict("alias reservation generation changed")
+ owner = labels.get(RESERVATION_OWNER_LABEL)
+ if not isinstance(owner, str) or not owner:
+ return False
+ if owner != self.self_name:
+ owner_response = self.docker.request(
+ "GET", f"/containers/{quote(owner, safe='')}/json"
+ )
+ if owner_response.status == 200:
+ return False
+ if owner_response.status != 404:
+ raise DockerError(
+ "inspect alias reservation owner failed", owner_response.status
+ )
+ self.docker.expect(
+ "DELETE",
+ f"/containers/{quote(observed_id, safe='')}?force=1&v=0",
+ allowed=(204, 404),
+ )
+ return (
+ self.docker.request(
+ "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json"
+ ).status
+ == 404
+ )
+
+ def _reconcile(self, alias: str, generation: str, created_id: str) -> None:
+ response = self.docker.request(
+ "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json"
+ )
+ if response.status == 404:
+ return
+ if response.status != 200:
+ raise DockerError("inspect alias reservation failed", response.status)
+ try:
+ observed = json.loads(response.body)
+ config = observed.get("Config", {})
+ labels = config.get("Labels", {}) if isinstance(config, dict) else {}
+ observed_id = observed.get("Id")
+ except (TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise DockerError("alias reservation inspect response is invalid") from exc
+ if (
+ not isinstance(labels, dict)
+ or not isinstance(observed_id, str)
+ or labels.get(RESERVATION_LABEL) != "true"
+ or labels.get(RUNTIME_ID_LABEL) != alias
+ or labels.get(RESERVATION_GENERATION_LABEL) != generation
+ or (created_id and observed_id != created_id)
+ ):
+ raise GenerationConflict("alias reservation generation changed")
+ self.docker.expect(
+ "DELETE",
+ f"/containers/{quote(observed_id, safe='')}?force=1&v=0",
+ allowed=(204,),
+ )
+ if (
+ self.docker.request(
+ "GET", f"/containers/{quote(RESERVATION_PREFIX + alias, safe='')}/json"
+ ).status
+ != 404
+ ):
+ raise DockerError("Docker retained alias reservation")
diff --git a/cmd/docker_gateway/douyin.py b/cmd/docker_gateway/douyin.py
new file mode 100644
index 0000000..fdbf7e9
--- /dev/null
+++ b/cmd/docker_gateway/douyin.py
@@ -0,0 +1,1333 @@
+"""Douyin browser control built on a narrowly scoped CDP contract."""
+
+from __future__ import annotations
+
+import http.client
+import json
+import logging
+import math
+import re
+import threading
+import time
+import uuid
+from collections import deque
+from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager, suppress
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from urllib.parse import urlsplit
+
+import websocket
+
+from .docker_client import RUNTIME_ID_RE
+
+LOG = logging.getLogger("creatorhub.douyin")
+ORIGIN = "https://www.douyin.com"
+ORIGIN_URL = ORIGIN + "/"
+IDENTITY_URL = (
+ ORIGIN + "/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"
+)
+WORKS_PATH = "/aweme/v1/web/aweme/post/"
+COMMENTS_PATH = "/aweme/v1/web/comment/list/"
+RESPONSE_LIMIT = 1 << 20
+CONTROL_TIMEOUT = 15.0
+UID_RE = re.compile(r"^[1-9][0-9]{0,19}$")
+ID_RE = re.compile(r"^[1-9][0-9]{0,63}$")
+ACCOUNT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$")
+ACTIONS = frozenset(
+ {"follow", "dm", "reply_comment", "like_comment", "like_work", "repost"}
+)
+
+
+class DouyinError(RuntimeError):
+ def __init__(self, message: str):
+ super().__init__(message)
+ self.uncertain = False
+
+
+LISTENER_ERRORS = (
+ DouyinError,
+ OSError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+)
+
+
+@dataclass(frozen=True)
+class BrowserResponse:
+ status: int
+ body: str
+ challenge: str = ""
+
+
+class CDPConnection:
+ def __init__(self, socket: websocket.WebSocket) -> None:
+ self.socket = socket
+ self._lock = threading.RLock()
+ self._next_id = 0
+ self._pending: deque[dict] = deque()
+
+ def command(self, method: str, params: dict | None = None) -> dict:
+ with self._lock:
+ self._next_id += 1
+ command_id = self._next_id
+ self.socket.send(
+ json.dumps({"id": command_id, "method": method, "params": params or {}})
+ )
+ deadline = time.monotonic() + CONTROL_TIMEOUT
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ self._terminate_evaluation(method)
+ raise DouyinError(f"CDP command timed out: {method}")
+ message = self._take_pending(command_id)
+ if message is None:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ self._terminate_evaluation(method)
+ raise DouyinError(f"CDP command timed out: {method}")
+ try:
+ message = self._receive(remaining, method)
+ except DouyinError:
+ self._terminate_evaluation(method)
+ raise
+ if not isinstance(message, dict):
+ raise DouyinError(f"CDP command returned invalid message: {method}")
+ if message.get("id") != command_id:
+ self._pending.append(message)
+ continue
+ if message.get("error"):
+ raise DouyinError(f"CDP command rejected: {method}")
+ result = message.get("result")
+ if not isinstance(result, dict):
+ raise DouyinError(f"CDP command returned invalid result: {method}")
+ return result
+
+ def wait_event(
+ self,
+ method: str,
+ predicate: Callable[[dict], bool],
+ timeout: float = CONTROL_TIMEOUT,
+ ) -> dict:
+ with self._lock:
+ deadline = time.monotonic() + timeout
+ while True:
+ for index, message in enumerate(self._pending):
+ params = message.get("params", {})
+ if (
+ message.get("method") == method
+ and isinstance(params, dict)
+ and predicate(params)
+ ):
+ del self._pending[index]
+ return message
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise DouyinError(f"CDP event timed out: {method}")
+ message = self._receive(remaining, method)
+ if isinstance(message, dict):
+ params = message.get("params", {})
+ if (
+ message.get("method") == method
+ and isinstance(params, dict)
+ and predicate(params)
+ ):
+ return message
+ self._pending.append(message)
+
+ def _terminate_evaluation(self, method: str) -> None:
+ if method != "Runtime.evaluate":
+ return
+ try:
+ self._next_id += 1
+ command_id = self._next_id
+ self.socket.settimeout(1.0)
+ self.socket.send(
+ json.dumps(
+ {
+ "id": command_id,
+ "method": "Runtime.terminateExecution",
+ "params": {},
+ }
+ )
+ )
+ deadline = time.monotonic() + 1.0
+ while time.monotonic() < deadline:
+ message = json.loads(self.socket.recv())
+ if isinstance(message, dict) and message.get("id") == command_id:
+ return
+ if isinstance(message, dict):
+ self._pending.append(message)
+ except (
+ OSError,
+ TypeError,
+ websocket.WebSocketException,
+ json.JSONDecodeError,
+ ) as exc:
+ LOG.debug("failed to terminate timed-out CDP evaluation", exc_info=exc)
+
+ def _take_pending(self, command_id: int) -> dict | None:
+ for index, message in enumerate(self._pending):
+ if message.get("id") == command_id:
+ del self._pending[index]
+ return message
+ return None
+
+ def _receive(self, timeout: float, operation: str) -> object:
+ self.socket.settimeout(timeout)
+ try:
+ return json.loads(self.socket.recv())
+ except (TimeoutError, websocket.WebSocketTimeoutException) as exc:
+ raise DouyinError(f"CDP command timed out during {operation}") from exc
+ except (
+ OSError,
+ TypeError,
+ websocket.WebSocketException,
+ json.JSONDecodeError,
+ ) as exc:
+ raise DouyinError(f"CDP message failed during {operation}") from exc
+
+ def evaluate(self, expression: str) -> object:
+ result = self.command(
+ "Runtime.evaluate",
+ {
+ "expression": expression,
+ "awaitPromise": True,
+ "returnByValue": True,
+ "userGesture": False,
+ },
+ )
+ if result.get("exceptionDetails"):
+ raise DouyinError("page evaluation failed")
+ value = result.get("result", {}).get("value")
+ if "value" not in result.get("result", {}):
+ raise DouyinError("page evaluation returned no value")
+ return value
+
+ def close(self) -> None:
+ with suppress(OSError, websocket.WebSocketException):
+ self.socket.close()
+
+
+class DouyinBrowser:
+ def __init__(self, endpoint: Callable[[str], str] | None = None) -> None:
+ self.endpoint = endpoint or (
+ lambda alias: f"http://creatorhub-browser-{alias}:9222"
+ )
+
+ @contextmanager
+ def connection(self, alias: str):
+ connection = self._connect(alias)
+ try:
+ yield connection
+ finally:
+ connection.close()
+
+ def _connect(self, alias: str) -> CDPConnection:
+ if not RUNTIME_ID_RE.fullmatch(alias):
+ raise DouyinError("browser alias is invalid")
+ base = self.endpoint(alias).rstrip("/")
+ try:
+ parsed = urlsplit(base)
+ port = parsed.port
+ except (TypeError, ValueError) as exc:
+ raise DouyinError("restricted browser endpoint is invalid") from exc
+ if (
+ parsed.scheme != "http"
+ or not parsed.hostname
+ or not port
+ or parsed.username
+ or parsed.password
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise DouyinError("restricted browser endpoint is invalid")
+ http_connection = http.client.HTTPConnection(
+ parsed.hostname, port, timeout=CONTROL_TIMEOUT
+ )
+ try:
+ http_connection.request(
+ "GET", "/json/list", headers={"Accept": "application/json"}
+ )
+ response = http_connection.getresponse()
+ if response.status != 200:
+ raise DouyinError("browser target discovery failed")
+ payload = response.read(64 * 1024 + 1)
+ except (OSError, http.client.HTTPException) as exc:
+ raise DouyinError("restricted browser unavailable") from exc
+ finally:
+ http_connection.close()
+ if len(payload) > 64 * 1024:
+ raise DouyinError("browser target discovery response is too large")
+ try:
+ targets = json.loads(payload)
+ except json.JSONDecodeError as exc:
+ raise DouyinError("browser target discovery response is invalid") from exc
+ if not isinstance(targets, list) or len(targets) > 32:
+ raise DouyinError("browser target discovery response is invalid")
+ page_targets = [
+ target
+ for target in targets
+ if isinstance(target, dict) and target.get("type") == "page"
+ ]
+ douyin_targets = [
+ target for target in page_targets if is_douyin_url(target.get("url", ""))
+ ]
+ if len(douyin_targets) > 1 or (not douyin_targets and len(page_targets) > 1):
+ raise DouyinError("browser has more than one page target")
+ target = (
+ douyin_targets[0]
+ if douyin_targets
+ else page_targets[0]
+ if page_targets
+ else None
+ )
+ if target is None:
+ raise DouyinError("browser page target is unavailable")
+ websocket_url = target.get("webSocketDebuggerUrl")
+ try:
+ parsed_ws = urlsplit(
+ websocket_url if isinstance(websocket_url, str) else ""
+ )
+ websocket_port = parsed_ws.port or port
+ except (TypeError, ValueError) as exc:
+ raise DouyinError("browser target websocket is invalid") from exc
+ if (
+ parsed_ws.scheme != "ws"
+ or not parsed_ws.hostname
+ or not websocket_port
+ or not parsed_ws.path.startswith("/devtools/page/")
+ ):
+ raise DouyinError("browser target websocket is invalid")
+ if parsed_ws.hostname in ("localhost", "127.0.0.1", "::1"):
+ parsed_ws = parsed_ws._replace(netloc=f"{parsed.hostname}:{websocket_port}")
+ if parsed_ws.hostname != parsed.hostname or (
+ parsed_ws.port is not None and parsed_ws.port != port
+ ):
+ raise DouyinError("browser target websocket host is invalid")
+ page_target = parsed_ws.geturl()
+ try:
+ socket = websocket.create_connection(
+ page_target,
+ timeout=CONTROL_TIMEOUT,
+ origin="devtools://devtools",
+ enable_multithread=True,
+ )
+ except (OSError, websocket.WebSocketException) as exc:
+ raise DouyinError("browser CDP connection failed") from exc
+ return CDPConnection(socket)
+
+ def set_cookies(self, alias: str, cookies: list[dict]) -> None:
+ with self.connection(alias) as cdp:
+ cdp.command("Network.enable")
+ cdp.command("Network.clearBrowserCookies")
+ cdp.command("Page.enable")
+ navigation = cdp.command("Page.navigate", {"url": ORIGIN_URL})
+ frame_id = navigation.get("frameId")
+ if not isinstance(frame_id, str) or navigation.get("errorText"):
+ raise DouyinError("browser navigation failed")
+ cdp.wait_event(
+ "Page.frameNavigated",
+ lambda params: (
+ params.get("frame", {}).get("id") == frame_id
+ and is_douyin_url(params.get("frame", {}).get("url"))
+ ),
+ )
+ deadline = time.monotonic() + CONTROL_TIMEOUT
+ last_error: DouyinError | None = None
+ while time.monotonic() < deadline:
+ try:
+ state = cdp.evaluate(
+ "({origin:location.origin,state:document.readyState})"
+ )
+ if (
+ isinstance(state, dict)
+ and state.get("origin") == ORIGIN
+ and state.get("state") in {"interactive", "complete"}
+ ):
+ break
+ except DouyinError as exc:
+ last_error = exc
+ time.sleep(0.1)
+ else:
+ if last_error:
+ raise DouyinError("browser did not reach Douyin") from last_error
+ raise DouyinError("browser did not reach Douyin")
+ values = []
+ for cookie in cookies:
+ value = {
+ "name": cookie["name"],
+ "value": cookie["value"],
+ "url": ORIGIN_URL,
+ "domain": cookie["domain"],
+ "path": cookie.get("path") or "/",
+ "secure": bool(cookie.get("secure")),
+ "httpOnly": bool(cookie.get("http_only")),
+ }
+ if cookie.get("same_site"):
+ value["sameSite"] = cookie["same_site"]
+ if cookie.get("expires"):
+ value["expires"] = cookie["expires"]
+ values.append(value)
+ cdp.command("Network.setCookies", {"cookies": values})
+
+ def get(self, alias: str, target: str) -> BrowserResponse:
+ with self.connection(alias) as cdp:
+ if cdp.evaluate("location.origin") != ORIGIN:
+ raise DouyinError("restricted browser origin changed")
+ expression = f"""(async()=>{{
+ const r=await fetch({json.dumps(target)},{{credentials:'include',redirect:'error'}});
+ if(!r.body)return {{status:r.status,body:'',too_large:false}};
+ const reader=r.body.getReader(), decoder=new TextDecoder(); let size=0, body='';
+ for(;;){{const item=await reader.read();if(item.done)break;
+ if(size+item.value.byteLength>={RESPONSE_LIMIT}){{await reader.cancel();return {{too_large:true}};}}
+ size+=item.value.byteLength;body+=decoder.decode(item.value,{{stream:true}});
+ }}
+ body+=decoder.decode();return {{status:r.status,body,too_large:false}};
+ }})()"""
+ result = cdp.evaluate(expression)
+ if (
+ not isinstance(result, dict)
+ or result.get("too_large")
+ or not isinstance(result.get("status"), int)
+ ):
+ raise DouyinError("restricted browser fetch failed")
+ status = result["status"]
+ if 300 <= status < 400:
+ raise DouyinError("restricted browser fetch redirected")
+ body = result.get("body")
+ if not isinstance(body, str):
+ raise DouyinError("restricted browser fetch returned invalid body")
+ return BrowserResponse(status, body, detect_challenge(status, body))
+
+ def identity(self, alias: str, expected_uid: str | None = None) -> dict:
+ response = self.get(alias, IDENTITY_URL)
+ try:
+ payload = json.loads(response.body)
+ except json.JSONDecodeError as exc:
+ raise DouyinError("Douyin identity response is invalid") from exc
+ user = payload.get("user") if isinstance(payload, dict) else None
+ uid = str(user.get("uid", "")) if isinstance(user, dict) else ""
+ if (
+ response.status != 200
+ or not isinstance(payload, dict)
+ or payload.get("status_code") != 0
+ or not UID_RE.fullmatch(uid)
+ ):
+ raise DouyinError("Douyin login is not valid")
+ sec_uid = str(user.get("sec_uid", "")) if isinstance(user, dict) else ""
+ unique_id = str(user.get("unique_id", "")) if isinstance(user, dict) else ""
+ if not ACCOUNT_KEY_RE.fullmatch(sec_uid) or (
+ unique_id and not ACCOUNT_KEY_RE.fullmatch(unique_id)
+ ):
+ raise DouyinError("Douyin identity response is invalid")
+ if expected_uid and uid != expected_uid:
+ raise DouyinError("Douyin identity does not match the expected account")
+ return {
+ "uid": uid,
+ "sec_uid": sec_uid,
+ "unique_id": unique_id,
+ "nickname": user.get("nickname", "") if isinstance(user, dict) else "",
+ "short_id": user.get("short_id", "") if isinstance(user, dict) else "",
+ }
+
+ def action(
+ self,
+ alias: str,
+ expected_uid: str,
+ action: str,
+ target_uid: str = "",
+ comment_id: str = "",
+ work_id: str = "",
+ text: str = "",
+ confirm: bool = False,
+ ) -> dict:
+ if not UID_RE.fullmatch(expected_uid):
+ raise DouyinError("expected account UID is invalid")
+ if target_uid and not UID_RE.fullmatch(target_uid):
+ raise DouyinError("target UID is invalid")
+ if work_id and not ID_RE.fullmatch(work_id):
+ raise DouyinError("work ID is invalid")
+ if comment_id and not ID_RE.fullmatch(comment_id):
+ raise DouyinError("comment ID is invalid")
+ if action not in ACTIONS:
+ raise DouyinError("Douyin action is invalid")
+ if action in {"follow", "dm"} and not target_uid:
+ raise DouyinError("target UID is required")
+ if action in {"like_work", "repost"} and not work_id:
+ raise DouyinError("work ID is required")
+ if action in {"reply_comment", "like_comment"} and (
+ not work_id or not comment_id or not target_uid
+ ):
+ raise DouyinError("comment target is incomplete")
+ if action in {"dm", "reply_comment", "repost"} and (
+ not text.strip() or len(text) > 1000
+ ):
+ raise DouyinError("action text is invalid")
+ identity = self.identity(alias, expected_uid)
+ if action == "follow":
+ params = {
+ "expected": expected_uid,
+ "target": target_uid,
+ "check": not confirm,
+ }
+ value = (
+ self._confirmed_evaluate(alias, follow_expression(params))
+ if confirm
+ else self._evaluate(alias, follow_expression(params))
+ )
+ elif action == "dm":
+ if not confirm:
+ return {
+ "action": "preview",
+ "sender_uid": identity["uid"],
+ "uid": target_uid,
+ "text": text,
+ }
+ params = {
+ "uid": target_uid,
+ "text": text,
+ "confirm": True,
+ "limit": 20,
+ "cursor": "9223372036854775807",
+ "action": "send",
+ }
+ value = self._confirmed_evaluate(alias, im_expression(params, expected_uid))
+ else:
+ params = {
+ "action": action,
+ "expected": expected_uid,
+ "target": target_uid,
+ "work": work_id,
+ "comment": comment_id,
+ "text": text,
+ "confirm": confirm,
+ }
+ if not confirm:
+ return {
+ "action": "preview",
+ "sender_uid": identity["uid"],
+ "target_uid": target_uid,
+ "target_work_id": work_id,
+ "target_comment_id": comment_id,
+ "text": text,
+ }
+ value = self._confirmed_evaluate(alias, action_expression(params))
+ if not isinstance(value, dict):
+ raise DouyinError("Douyin action response is invalid")
+ return value
+
+ def _evaluate(self, alias: str, expression: str) -> object:
+ with self.connection(alias) as cdp:
+ if cdp.evaluate("location.origin") != ORIGIN:
+ raise DouyinError("restricted browser origin changed")
+ return cdp.evaluate(expression)
+
+ def _confirmed_evaluate(self, alias: str, expression: str) -> object:
+ try:
+ return self._evaluate(alias, expression)
+ except DouyinError as exc:
+ exc.uncertain = True
+ raise
+
+
+class DouyinSubscription:
+ def __init__(
+ self,
+ browser: DouyinBrowser,
+ alias: str,
+ uid: str,
+ pending: list[dict] | None = None,
+ ) -> None:
+ self.browser = browser
+ self.alias = alias
+ self.uid = uid
+ # A stable key lets a fresh wrapper dispose a stale listener left on the
+ # same page. Recovery disposes the old state before installing a new one.
+ self.key = "__creatorhub_notice_sub_" + alias
+ self._connection_lock = threading.RLock()
+ self.queue: deque[dict] = deque(pending or [])
+ self.condition = threading.Condition()
+ self.stopped = threading.Event()
+ self._epoch = 0
+ self._initial_boundary_pending = True
+ self._browser_inflight: set[str] = set()
+ self._browser_inflight_lock = threading.Lock()
+ self._detail_pool = ThreadPoolExecutor(
+ max_workers=4, thread_name_prefix=f"creatorhub-notice-details-{alias}"
+ )
+ try:
+ self.connection = self._open_listener()
+ except Exception:
+ self._detail_pool.shutdown(wait=False, cancel_futures=True)
+ raise
+ # Events observed before the consumer establishes its durable boundary
+ # are explicitly classified as baseline and must never trigger writes.
+ self._put(
+ {
+ "kind": "baseline",
+ "reason": "listener_start",
+ "uid": self.uid,
+ "boundary_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ self.thread = threading.Thread(
+ target=self._run, name=f"creatorhub-notices-{alias}", daemon=True
+ )
+ self.thread.start()
+
+ def _open_listener(self) -> CDPConnection:
+ connection = self.browser._connect(self.alias)
+ try:
+ result = connection.evaluate(install_expression(self.key, self.uid))
+ if not isinstance(result, str):
+ raise DouyinError("notification listener returned invalid state")
+ state = json.loads(result)
+ if not isinstance(state, dict) or not state.get("connected"):
+ raise DouyinError("Douyin notification connection is not ready")
+ return connection
+ except (
+ DouyinError,
+ OSError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+ ):
+ connection.close()
+ raise
+
+ def _get_connection(self) -> CDPConnection:
+ with self._connection_lock:
+ return self.connection
+
+ def _run(self) -> None:
+ wait = wait_expression(self.key)
+ while not self.stopped.is_set():
+ try:
+ raw = self._get_connection().evaluate(wait)
+ events = json.loads(raw) if isinstance(raw, str) else raw
+ if not isinstance(events, list):
+ raise DouyinError("notification listener returned invalid events")
+ initial_boundary = self._initial_boundary_pending
+ self._initial_boundary_pending = False
+ for event in events:
+ if (
+ initial_boundary
+ and isinstance(event, dict)
+ and event.get("kind") == "push"
+ ):
+ event = dict(event)
+ event["baseline"] = True
+ self._handle(event)
+ except (
+ DouyinError,
+ OSError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+ ) as exc:
+ if self.stopped.is_set():
+ break
+ self._put({"kind": "error", "reason": str(exc)})
+ self._recover()
+ self._dispose_current()
+
+ def _recover(self) -> None:
+ # Dispose the old same-page handlers before installing a new state with
+ # the stable key; disposing afterward would remove the fresh handlers.
+ with self._connection_lock:
+ old = self.connection
+ try:
+ old.evaluate(dispose_expression(self.key))
+ except DouyinError:
+ LOG.debug(
+ "old notification listener disposal was not available",
+ exc_info=True,
+ )
+ finally:
+ old.close()
+ self._initial_boundary_pending = True
+ delay = 0.5
+ while not self.stopped.is_set():
+ if self.stopped.wait(delay):
+ return
+ try:
+ connection = self._open_listener()
+ except (
+ DouyinError,
+ OSError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+ ) as exc:
+ self._put({"kind": "error", "reason": str(exc)})
+ delay = min(delay * 2, 30.0)
+ continue
+ with self._connection_lock:
+ self.connection = connection
+ self._epoch += 1
+ self._put({"kind": "reconnected", "uid": self.uid})
+ # The boundary is a separate event so the control plane never treats
+ # a transport reconnect itself as proof that new notices are safe.
+ self._put(
+ {
+ "kind": "baseline",
+ "reason": "listener_reconnected",
+ "uid": self.uid,
+ "boundary_at": datetime.now(timezone.utc).isoformat(),
+ }
+ )
+ return
+
+ def _dispose_current(self) -> None:
+ connection = self._get_connection()
+ try:
+ connection.evaluate(dispose_expression(self.key))
+ except DouyinError:
+ LOG.debug("notification listener disposal was not available", exc_info=True)
+ finally:
+ connection.close()
+
+ def _handle(self, event: object) -> None:
+ if not isinstance(event, dict):
+ raise DouyinError("notification event is invalid")
+ kind = event.get("kind")
+ delivery_id = event.get("delivery_id")
+ if kind in {"open", "close"}:
+ self._put({"kind": kind})
+ if delivery_id and hasattr(self, "_connection_lock"):
+ self._ack_browser_event(delivery_id, self._get_connection())
+ if kind == "close":
+ raise DouyinError("Douyin notification connection closed")
+ return
+ if kind == "error":
+ self._put(
+ {
+ "kind": "error",
+ "reason": str(event.get("reason", "notification continuity gap")),
+ "continuity": "gap",
+ }
+ )
+ if delivery_id and hasattr(self, "_connection_lock"):
+ self._ack_browser_event(delivery_id, self._get_connection())
+ return
+ if kind != "push":
+ raise DouyinError("notification event kind is invalid")
+ if not hasattr(self, "_browser_inflight_lock"):
+ self._browser_inflight_lock = threading.Lock()
+ if not hasattr(self, "_browser_inflight"):
+ self._browser_inflight = set()
+ if isinstance(delivery_id, str) and delivery_id:
+ with self._browser_inflight_lock:
+ if delivery_id in self._browser_inflight:
+ return
+ self._browser_inflight.add(delivery_id)
+ epoch = getattr(self, "_epoch", 0)
+ baseline = bool(event.get("baseline"))
+ if hasattr(self, "_detail_pool"):
+ self._detail_pool.submit(self._process_push_async, event, epoch, baseline)
+ else:
+ success = self._process_push(event, self._get_connection(), baseline)
+ if delivery_id:
+ if success:
+ self._ack_browser_event(delivery_id, self._get_connection())
+ else:
+ self._retry_browser_event(delivery_id, self._get_connection())
+ if delivery_id:
+ with self._browser_inflight_lock:
+ self._browser_inflight.discard(delivery_id)
+
+ def _handle_push_error(
+ self,
+ error: Exception,
+ delivery_id: object,
+ connection: CDPConnection | None,
+ ) -> None:
+ self._put({"kind": "error", "reason": str(error), "continuity": "gap"})
+ if isinstance(delivery_id, str) and connection is not None:
+ self._retry_browser_event(delivery_id, connection)
+
+ def _process_push(
+ self, event: dict, connection: CDPConnection, baseline: bool
+ ) -> bool:
+ try:
+ ids = notice_ids(event)
+ except (
+ DouyinError,
+ KeyError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+ ) as exc:
+ self._put({"kind": "error", "reason": str(exc), "continuity": "gap"})
+ return False
+ # Detail lookup is isolated per platform notification. A malformed or
+ # temporarily unavailable detail must not discard its siblings.
+ complete = True
+ for notice_id in ids:
+ try:
+ for notice in self._details([notice_id], connection):
+ normalized = normalize_notice(notice)
+ if normalized is not None:
+ self._put(
+ {
+ "kind": "notice",
+ "notice": normalized,
+ "baseline": baseline,
+ }
+ )
+ except (
+ DouyinError,
+ OSError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+ ) as exc:
+ self._put(
+ {
+ "kind": "error",
+ "reason": str(exc),
+ "event_key": notice_id,
+ "continuity": "gap",
+ }
+ )
+ complete = False
+ return complete
+
+ def _process_push_async(self, event: dict, epoch: int, baseline: bool) -> None:
+ connection: CDPConnection | None = None
+ success = False
+ delivery_id = event.get("delivery_id")
+ try:
+ connection = self.browser._connect(self.alias)
+ success = self._process_push(
+ event,
+ connection,
+ baseline or epoch != getattr(self, "_epoch", 0),
+ )
+ if isinstance(delivery_id, str):
+ if success:
+ self._ack_browser_event(delivery_id, connection)
+ else:
+ self._retry_browser_event(delivery_id, connection)
+ except LISTENER_ERRORS as exc:
+ self._handle_push_error(exc, delivery_id, connection)
+ finally:
+ if connection is not None:
+ connection.close()
+ if isinstance(delivery_id, str):
+ with self._browser_inflight_lock:
+ self._browser_inflight.discard(delivery_id)
+
+ def _ack_browser_event(
+ self, delivery_id: object, connection: CDPConnection
+ ) -> None:
+ if not isinstance(delivery_id, str) or not delivery_id:
+ return
+ try:
+ result = connection.evaluate(ack_expression(self.key, [delivery_id]))
+ if result not in (True, "true"):
+ raise DouyinError("notification acknowledgement failed")
+ except (
+ DouyinError,
+ OSError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+ ):
+ LOG.warning("notification acknowledgement failed", exc_info=True)
+
+ def _retry_browser_event(
+ self, delivery_id: object, connection: CDPConnection
+ ) -> None:
+ if not isinstance(delivery_id, str) or not delivery_id:
+ return
+ try:
+ result = connection.evaluate(retry_expression(self.key, [delivery_id]))
+ if result not in (True, "true"):
+ raise DouyinError("notification retry acknowledgement failed")
+ except (
+ DouyinError,
+ OSError,
+ TypeError,
+ ValueError,
+ websocket.WebSocketException,
+ ):
+ LOG.warning("notification retry marker failed", exc_info=True)
+
+ def pending(self) -> list[dict]:
+ with self.condition:
+ return list(self.queue)
+
+ def _details(
+ self, ids: list[str], connection: CDPConnection | None = None
+ ) -> list[dict]:
+ expected = set(ids)
+ connection = connection or self._get_connection()
+ for attempt in range(2):
+ raw = connection.evaluate(details_expression(ids))
+ if not isinstance(raw, str):
+ raise DouyinError("notification details returned invalid data")
+ try:
+ response = json.loads(raw)
+ body = json.loads(response["body"])
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise DouyinError("notification details could not be parsed") from exc
+ if (
+ not isinstance(response, dict)
+ or not isinstance(body, dict)
+ or response.get("status") != 200
+ or body.get("status_code") != 0
+ ):
+ raise DouyinError("notification details request failed")
+ notices = body.get("notice_list_v2")
+ if not isinstance(notices, list):
+ raise DouyinError("notification details list is invalid")
+ normalized: list[dict] = []
+ found: set[str] = set()
+ for notice in notices:
+ if (
+ not isinstance(notice, dict)
+ or str(notice.get("user_id")) != self.uid
+ ):
+ raise DouyinError("notification identity changed")
+ nid = str(notice.get("nid_str") or notice.get("nid") or "")
+ if not nid.isascii() or not nid.isdecimal():
+ raise DouyinError("notification ID is invalid")
+ found.add(nid)
+ normalized.append(notice)
+ if found == expected:
+ return normalized
+ if attempt == 0:
+ time.sleep(0.2)
+ continue
+ raise DouyinError("notification details are incomplete")
+ raise DouyinError("notification details are incomplete")
+
+ def _put(self, item: dict) -> None:
+ item = dict(item)
+ item.setdefault("delivery_id", uuid.uuid4().hex)
+ if item.get("kind") == "notice":
+ notice = item.get("notice")
+ if isinstance(notice, dict):
+ notice.setdefault(
+ "gateway_received_at", datetime.now(timezone.utc).isoformat()
+ )
+ with self.condition:
+ if len(self.queue) >= 1000:
+ # Keep the backlog visible; replace only one oldest item with a
+ # gap marker instead of silently dropping the entire queue.
+ dropped = getattr(self, "_overflow_count", 0) + 1
+ self._overflow_count = dropped
+ self.queue[0] = {
+ "kind": "error",
+ "reason": "notification queue overflow",
+ "continuity": "gap",
+ "dropped": dropped,
+ "delivery_id": uuid.uuid4().hex,
+ }
+ else:
+ self.queue.append(item)
+ self.condition.notify_all()
+
+ def poll(self, limit: int, wait_seconds: float) -> list[dict]:
+ deadline = time.monotonic() + wait_seconds
+ with self.condition:
+ while not self.queue and not self.stopped.is_set():
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ self.condition.wait(remaining)
+ return list(self.queue)[:limit]
+
+ def ack(self, delivery_ids: list[str]) -> None:
+ ids = {value for value in delivery_ids if isinstance(value, str) and value}
+ if not ids:
+ return
+ with self.condition:
+ self.queue = deque(
+ item for item in self.queue if item.get("delivery_id") not in ids
+ )
+
+ def stop(self) -> None:
+ self.stopped.set()
+ with self._connection_lock:
+ connection = self.connection
+ connection.close()
+ with self.condition:
+ self.condition.notify_all()
+ self.thread.join(timeout=2.0)
+ if self.thread.is_alive():
+ LOG.error(
+ "notification listener did not stop within timeout",
+ extra={"alias": self.alias},
+ )
+ pool = getattr(self, "_detail_pool", None)
+ if pool is not None:
+ pool.shutdown(wait=True, cancel_futures=True)
+
+
+class SubscriptionManager:
+ def __init__(self, browser: DouyinBrowser) -> None:
+ self.browser = browser
+ self._lock = threading.RLock()
+ self._items: dict[str, DouyinSubscription] = {}
+
+ def start(self, alias: str, uid: str) -> dict:
+ with self._lock:
+ previous = self._items.pop(alias, None)
+ pending = previous.pending() if previous else []
+ if previous:
+ previous.stop()
+ item = DouyinSubscription(self.browser, alias, uid, pending=pending)
+ self._items[alias] = item
+ return {"connected": True, "alias": alias, "uid": uid}
+
+ def poll(self, alias: str, limit: int, wait_seconds: float) -> list[dict]:
+ with self._lock:
+ item = self._items.get(alias)
+ if not item:
+ raise DouyinError("notification listener is not running")
+ return item.poll(limit, wait_seconds)
+
+ def ack(self, alias: str, delivery_ids: list[str]) -> None:
+ with self._lock:
+ item = self._items.get(alias)
+ if not item:
+ raise DouyinError("notification listener is not running")
+ item.ack(delivery_ids)
+
+ def stop(self, alias: str) -> None:
+ with self._lock:
+ item = self._items.pop(alias, None)
+ if item:
+ item.stop()
+
+ def close(self) -> None:
+ with self._lock:
+ aliases = list(self._items)
+ for alias in aliases:
+ self.stop(alias)
+
+
+def _notice_id(value: object) -> str:
+ if type(value) is int and value > 0:
+ return str(value)
+ if isinstance(value, str) and ID_RE.fullmatch(value):
+ return value
+ return ""
+
+
+def normalize_notice(notice: object) -> dict | None:
+ if not isinstance(notice, dict):
+ raise DouyinError("notification detail is invalid")
+ if notice.get("comment"):
+ kind, event_type = "comment", "comment"
+ elif notice.get("follow"):
+ kind, event_type = "follow", "follow"
+ elif notice.get("digg"):
+ kind, event_type = "digg", "like"
+ elif notice.get("share"):
+ kind, event_type = "share", "repost"
+ elif (
+ notice.get("dm")
+ or notice.get("message")
+ or notice.get("im")
+ or notice.get("chat")
+ ):
+ kind, event_type = next(
+ (candidate, "dm")
+ for candidate in ("dm", "message", "im", "chat")
+ if notice.get(candidate)
+ )
+ else:
+ return None
+ detail = notice.get(kind)
+ if not isinstance(detail, dict):
+ raise DouyinError("notification detail payload is invalid")
+ if event_type == "dm":
+ sender = (
+ detail.get("from_user") or detail.get("sender") or detail.get("user") or {}
+ )
+ if isinstance(sender, list):
+ sender = sender[0] if sender else {}
+ if not isinstance(sender, dict):
+ raise DouyinError("direct-message sender is invalid")
+ message = (
+ detail.get("text")
+ or detail.get("content")
+ or detail.get("message")
+ or notice.get("text")
+ or ""
+ )
+ if isinstance(message, dict):
+ message = message.get("text") or message.get("content") or ""
+ if not isinstance(message, str) or len(message) > 100000:
+ raise DouyinError("direct-message text is invalid")
+ event_key = _notice_id(
+ notice.get("message_id")
+ or notice.get("msg_id")
+ or detail.get("message_id")
+ or detail.get("msg_id")
+ or notice.get("nid_str")
+ or notice.get("nid")
+ )
+ if not event_key:
+ raise DouyinError("direct-message ID is invalid")
+ result = {
+ "event_key": event_key,
+ "event_type": "dm",
+ "interactor_uid": _notice_id(sender.get("uid") or sender.get("user_id")),
+ "comment_id": "",
+ "work_id": "",
+ "message_text": message,
+ }
+ create_time = notice.get("create_time")
+ if isinstance(create_time, (int, float)) and not isinstance(create_time, bool):
+ try:
+ timestamp = float(create_time)
+ if math.isfinite(timestamp) and timestamp > 0:
+ result["platform_event_at"] = datetime.fromtimestamp(
+ timestamp, timezone.utc
+ ).isoformat()
+ except (OverflowError, OSError, ValueError) as exc:
+ raise DouyinError("notification timestamp is invalid") from exc
+ return result
+ users = detail.get("from_user") or []
+ if isinstance(users, dict):
+ users = [users]
+ if not isinstance(users, list):
+ raise DouyinError("notification users are invalid")
+ comment = detail.get("comment") or {}
+ if not isinstance(comment, dict):
+ raise DouyinError("notification comment is invalid")
+ if not users and comment.get("user"):
+ users = [comment["user"]]
+ uids = {
+ uid
+ for user in users
+ if isinstance(user, dict)
+ for uid in [_notice_id(user.get("uid") or user.get("user_id"))]
+ if uid
+ }
+ event_key = _notice_id(notice.get("nid_str") or notice.get("nid"))
+ if not event_key:
+ raise DouyinError("notification ID is invalid")
+ work = detail.get("aweme") or notice.get("aweme") or {}
+ if not isinstance(work, dict):
+ work = {}
+ comment_id = next(
+ (
+ value
+ for value in (
+ notice.get("comment_id"),
+ detail.get("comment_id"),
+ comment.get("cid_str"),
+ comment.get("cid"),
+ )
+ if _notice_id(value)
+ ),
+ "",
+ )
+ work_id = next(
+ (
+ value
+ for value in (
+ notice.get("aweme_id"),
+ detail.get("aweme_id"),
+ work.get("aweme_id"),
+ work.get("id"),
+ )
+ if _notice_id(value)
+ ),
+ "",
+ )
+ result = {
+ "event_key": event_key,
+ "event_type": event_type,
+ "interactor_uid": next(iter(uids), "") if len(uids) == 1 else "",
+ "comment_id": _notice_id(comment_id),
+ "work_id": _notice_id(work_id),
+ }
+ create_time = notice.get("create_time")
+ if isinstance(create_time, (int, float)) and not isinstance(create_time, bool):
+ try:
+ timestamp = float(create_time)
+ if math.isfinite(timestamp) and timestamp > 0:
+ result["platform_event_at"] = datetime.fromtimestamp(
+ timestamp, timezone.utc
+ ).isoformat()
+ except (OverflowError, OSError, ValueError) as exc:
+ raise DouyinError("notification timestamp is invalid") from exc
+ return result
+
+
+def is_douyin_url(value: object) -> bool:
+ if not isinstance(value, str):
+ return False
+ try:
+ parsed = urlsplit(value)
+ return (
+ parsed.scheme == "https"
+ and parsed.hostname == "www.douyin.com"
+ and parsed.port is None
+ and parsed.username is None
+ and parsed.password is None
+ )
+ except (TypeError, ValueError):
+ return False
+
+
+def detect_challenge(status: int, body: str) -> str:
+ if status == 429:
+ return ""
+ if status == 412:
+ return "captcha"
+ lowered = body.lower()
+ if any(token in lowered for token in ("captcha", "verify_code", "验证码")):
+ return "captcha"
+ if any(token in lowered for token in ("device_verify", "device_check", "设备验证")):
+ return "device"
+ return ""
+
+
+def notice_ids(event: dict) -> list[str]:
+ try:
+ payload = json.loads(event["payload"])
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
+ raise DouyinError("notification push payload is invalid") from exc
+ if not isinstance(payload, dict):
+ raise DouyinError("notification push payload is invalid")
+ if event.get("service") == 20313:
+ notices = payload.get("notices", [])
+ if not isinstance(notices, list):
+ raise DouyinError("notification push list is invalid")
+ selected = [
+ notice
+ for notice in notices
+ if isinstance(notice, dict)
+ and {str(group) for group in notice.get("effect_groups", [])}
+ & {"960", "961"}
+ ]
+ elif event.get("service") == 20003 and payload.get("notice_type") in {
+ 45,
+ 31,
+ 9009,
+ 9002,
+ 514,
+ 9067,
+ }:
+ selected = [payload]
+ else:
+ return []
+ ids: list[str] = []
+ for notice in selected:
+ value = notice.get("notice_id_str")
+ if not isinstance(value, str) or not value.isascii() or not value.isdecimal():
+ raise DouyinError("notification ID is invalid")
+ ids.append(value)
+ return list(dict.fromkeys(ids))
+
+
+def install_expression(key: str, uid: str) -> str:
+ return INSTALL_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace(
+ "UID_VALUE", json.dumps(uid)
+ )
+
+
+def wait_expression(key: str) -> str:
+ return WAIT_SCRIPT.replace("KEY_VALUE", json.dumps(key))
+
+
+def dispose_expression(key: str) -> str:
+ return f"(() => {{ const key={json.dumps(key)}; window[key]?.dispose(); delete window[key]; return true; }})()"
+
+
+def ack_expression(key: str, ids: list[str]) -> str:
+ if (
+ not isinstance(key, str)
+ or not key
+ or not ids
+ or any(not isinstance(value, str) or not value for value in ids)
+ ):
+ raise ValueError("notification delivery IDs are invalid")
+ return ACK_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace(
+ "IDS_VALUE", json.dumps(ids)
+ )
+
+
+def retry_expression(key: str, ids: list[str]) -> str:
+ if (
+ not isinstance(key, str)
+ or not key
+ or not ids
+ or any(not isinstance(value, str) or not value for value in ids)
+ ):
+ raise ValueError("notification delivery IDs are invalid")
+ return RETRY_SCRIPT.replace("KEY_VALUE", json.dumps(key)).replace(
+ "IDS_VALUE", json.dumps(ids)
+ )
+
+
+def details_expression(ids: list[str]) -> str:
+ if not ids or any(
+ not isinstance(value, str) or not value.isascii() or not value.isdecimal()
+ for value in ids
+ ):
+ raise ValueError("notification IDs are invalid")
+ return DETAILS_SCRIPT.replace("IDS_VALUE", json.dumps(ids))
+
+
+def follow_expression(params: dict) -> str:
+ return FOLLOW_SCRIPT.replace("PARAMS_VALUE", json.dumps(params, ensure_ascii=True))
+
+
+def im_expression(params: dict, expected_uid: str) -> str:
+ return IM_SCRIPT.replace("EXPECTED_UID_VALUE", json.dumps(expected_uid)).replace(
+ "PARAMS_VALUE", json.dumps(params, ensure_ascii=True)
+ )
+
+
+def action_expression(params: dict) -> str:
+ return ACTION_SCRIPT.replace("PARAMS_VALUE", json.dumps(params, ensure_ascii=True))
+
+
+INSTALL_SCRIPT = r"""(async()=>{try{
+ if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN");
+ const key=KEY_VALUE,uid=UID_VALUE,chunks=window.webpackChunkdouyin_web;if(!chunks)throw Error("RUNTIME_MISSING");const old=window[key],carry=Array.isArray(old?.queue)?old.queue.filter(e=>e&&e.delivery_id):[];old?.dispose?.();delete window[key];let req;
+ chunks.push([["creatorhub-notice-"+Date.now()],{},r=>{req=r;}]);chunks.pop();
+ const entries=Object.entries(req.m||{});const entry=entries.find(([,f])=>String(f).includes("NOTICE_PUSH_EVENT_NAMES:function"));
+ const codec=entries.find(([,f])=>{const s=String(f);return s.includes(".decodedFrame=")&&s.includes(".encodeFrame=");});
+ if(!entry||!codec)throw Error("SDK_CHANGED");const C=req(entry[0]).NoticeFrontier,decode=req(codec[0]).decodedFrame,f=C.frontierInstance;
+ if(!f||String(f._options.deviceID)!==uid)throw Error("SOCKET_NOT_READY");
+ const state={queue:carry,wake:null,C,f,uid,dropped:0,nextDelivery:0};const emit=e=>{const item={...e,delivered:false,delivery_id:"browser-"+(++state.nextDelivery)};if(state.queue.length>=1000){state.queue.shift();state.dropped++;state.queue.push({kind:"error",reason:"QUEUE_OVERFLOW",continuity:"gap",dropped:state.dropped,delivered:false,delivery_id:"browser-"+(++state.nextDelivery)});}else state.queue.push(item);if(state.wake)state.wake();};
+ const message=e=>{try{const frame=decode(new Uint8Array(e.data));if(frame.service===20313||frame.service===20003)emit({kind:"push",service:frame.service,payload:new TextDecoder().decode(frame.payload)});}catch(_){emit({kind:"error",reason:"FRAME_DECODE_FAILED",continuity:"gap"});}};
+ const open=()=>emit({kind:"open"}),close=()=>emit({kind:"close"});f.addEventListener("message",message);f.addEventListener("open",open);f.addEventListener("close",close);
+ state.dispose=()=>{f.removeEventListener("message",message);f.removeEventListener("open",open);f.removeEventListener("close",close);if(state.wake)state.wake();};window[key]=state;
+ return JSON.stringify({connected:f.readyState===f.OPEN});
+}catch(e){return JSON.stringify({bridge_error:String(e.message||e)});}})()"""
+
+
+WAIT_SCRIPT = r"""(async()=>{const s=window[KEY_VALUE];if(!s||s.C.frontierInstance!==s.f||String(s.f._options.deviceID)!==s.uid)throw Error("LISTENER_INVALID");
+ const ready=()=>s.queue.filter(e=>!e.delivered);if(!ready().length)await new Promise(resolve=>{const timer=setTimeout(done,2000);function done(){clearTimeout(timer);s.wake=null;resolve();}s.wake=done;});
+ const result=ready();result.forEach(e=>{e.delivered=true;});return JSON.stringify(result);})()"""
+
+
+ACK_SCRIPT = r"""((ids)=>{const s=window[KEY_VALUE];if(!s||!Array.isArray(ids))throw Error("LISTENER_INVALID");const wanted=new Set(ids);s.queue=s.queue.filter(e=>!wanted.has(e?.delivery_id));return true;})(IDS_VALUE)"""
+
+
+RETRY_SCRIPT = r"""((ids)=>{const s=window[KEY_VALUE];if(!s||!Array.isArray(ids))throw Error("LISTENER_INVALID");const wanted=new Set(ids);s.queue.forEach(e=>{if(wanted.has(e?.delivery_id))e.delivered=false;});if(s.wake)s.wake();return true;})(IDS_VALUE)"""
+
+
+DETAILS_SCRIPT = r"""(async ids=>{if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN");const chunks=window.webpackChunkdouyin_web;if(!chunks)throw Error("RUNTIME_MISSING");let req;chunks.push([["creatorhub-detail-"+Date.now()],{},r=>{req=r;}]);chunks.pop();const entry=Object.entries(req.m||{}).find(([,f])=>/getNoticeDetail\s*:/.test(String(f)));const sdk=entry&&req(entry[0]),client=window.axiosInstance;if(typeof sdk?.getNoticeDetail!=="function"||!client?.interceptors?.response)throw Error("SDK_NOT_READY");const params={id_list:JSON.stringify(ids.map(n=>({notice_id_str:n,type:0}))),is_mark_read:0};let raw,timer;const observer=client.interceptors.response.use(response=>{const config=response.config||{},url=new URL(config.url||"",location.origin),xhr=response.request;if(config.params?.id_list===params.id_list&&config.params.is_mark_read===0&&url.origin===location.origin&&url.pathname==="/aweme/v1/web/notice/detail/"&&xhr&&(!xhr.responseType||xhr.responseType==="text")&&typeof xhr.responseText==="string")raw={status:response.status,body:xhr.responseText};return response;});try{await Promise.race([sdk.getNoticeDetail(params),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error("TIMEOUT")),20000);})]);}finally{clearTimeout(timer);client.interceptors.response.eject(observer);}if(!raw)throw Error("RAW_RESPONSE_MISSING");return JSON.stringify(raw);})(IDS_VALUE)"""
+
+
+FOLLOW_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;try{if(location.origin!=="https://www.douyin.com")throw Error("WRONG_ORIGIN");const get=async path=>{const r=await fetch(path,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(15000)}),v=await r.json();if(!r.ok||v.status_code!==0)throw Error("READ_FAILED");return v;};const prefix="?device_platform=webapp&aid=6383&channel=channel_pc_web";const self=await get("/aweme/v1/web/user/profile/self/"+prefix);if(String(self.user?.uid)!==p.expected)throw Error("IDENTITY_MISMATCH");if(p.target===p.expected)throw Error("SELF_TARGET");const path="/aweme/v1/web/user/profile/other/"+prefix+"&user_id="+encodeURIComponent(p.target),profile=(await get(path)).user;if(String(profile?.uid)!==p.target)throw Error("TARGET_MISMATCH");if(![0,1,2].includes(profile.follow_status))throw Error("UNKNOWN_FOLLOW_STATE");if(profile.follow_status!==0||p.check)return {status:"succeeded",action:p.check?"checked":"already_following",follow_status:profile.follow_status,evidence:{target_uid:p.target,follow_status:String(profile.follow_status)}};sent=true;let result;try{const r=await fetch("/aweme/v1/web/commit/follow/user/"+prefix,{method:"POST",credentials:"include",redirect:"error",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:new URLSearchParams({user_id:p.target,type:"1"}),signal:AbortSignal.timeout(10000)});result=await r.json();if(!r.ok)throw Error("POST_UNCERTAIN");}catch(e){if(e.message==="POST_UNCERTAIN")throw e;throw Error("POST_UNCERTAIN");}if(result.status_code!==0){const e=Error("BUSINESS_REJECTED");e.definitive=true;throw e;}if(![1,2].includes(result.follow_status))throw Error("POST_UNCONFIRMED");const verify=(await get(path)).user;return {status:String(verify?.uid)===p.target&&[1,2].includes(verify.follow_status)?"succeeded":"unknown",action:"followed",evidence:{target_uid:p.target,follow_status:String(verify?.follow_status??"")}};}catch(e){const code=["WRONG_ORIGIN","IDENTITY_MISMATCH","SELF_TARGET","TARGET_MISMATCH","UNKNOWN_FOLLOW_STATE"].includes(e.message)?e.message:(e.message==="BUSINESS_REJECTED"?e.message:"REQUEST_FAILED");return {status:sent?(e.definitive?"failed":"unknown"):"failed",code};}})()"""
+
+
+ACTION_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const qs="device_platform=webapp&aid=6383&channel=channel_pc_web";const get=async path=>{const r=await fetch(path,{credentials:"include",redirect:"error",signal:AbortSignal.timeout(15000)});let v;try{v=await r.json();}catch(_){fail("INVALID_RESPONSE");}if(!r.ok||v.status_code!==0)fail("READ_FAILED");return v;};const post=async(path,data)=>{const r=await fetch(path,{method:"POST",credentials:"include",redirect:"error",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:new URLSearchParams(data),signal:AbortSignal.timeout(10000)});let v;try{v=await r.json();}catch(_){fail(r.ok?"INVALID_RESPONSE":"POST_UNCERTAIN");}if(!r.ok)fail("POST_UNCERTAIN");if(v.status_code===undefined)fail("POST_UNCONFIRMED");if(v.status_code!==0)fail("BUSINESS_REJECTED",true);return v;};const self=await get("/aweme/v1/web/user/profile/self/?"+qs);if(String(self.user?.uid)!==p.expected)fail("IDENTITY_MISMATCH");if((p.action==="follow"||p.action==="dm")&&p.target===p.expected)fail("SELF_TARGET");const detail=async()=>get("/aweme/v1/web/aweme/detail/?"+qs+"&aweme_id="+encodeURIComponent(p.work));const findComment=async()=>{let cursor=0;for(let page=0;page<100;page++){const response=await get("/aweme/v1/web/comment/list/?"+qs+"&aweme_id="+encodeURIComponent(p.work)+"&cursor="+cursor+"&count=50");if(!Array.isArray(response.comments)||typeof response.has_more!=="boolean")fail("READ_FAILED");const list=response.comments;const comment=list.find(c=>String(c?.cid||c?.comment_id||"")===p.comment);if(comment){if(String(comment.aweme_id||comment.item_id||p.work)!==p.work||String(comment.user?.uid||comment.user_id||"")!==p.target)fail("TARGET_MISMATCH");return comment;}if(!response.has_more)break;const next=Number(response.cursor);if(!Number.isSafeInteger(next)||next<=cursor)fail("PAGINATION_INVALID");cursor=next;}fail("TARGET_NOT_FOUND");};if(p.action==="like_work"){const before=(await detail()).aweme_detail;if(String(before?.aweme_id)!==p.work)fail("TARGET_MISMATCH");if(Number(before.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:{work_id:p.work,user_digged:"1"}};sent=true;const result=await post("/aweme/v1/web/commit/item/digg/?"+qs,{aweme_id:p.work,type:"1",item_type:"0"});if(Number(result.is_digg)!==1)fail("POST_UNCONFIRMED");const after=(await detail()).aweme_detail;return {status:Number(after?.user_digged)===1?"succeeded":"unknown",action:"liked",evidence:{work_id:p.work,user_digged:String(after?.user_digged??"")}};}if(p.action==="like_comment"){const comment=await findComment();if(Number(comment.user_digged)===1)return {status:"succeeded",action:"already_liked",evidence:"comment.user_digged"};sent=true;await post("/aweme/v1/web/comment/digg?"+qs,{cid:p.comment,aweme_id:p.work,digg_type:"1",channel_id:"0",app_name:"aweme",item_type:"0",level:"1"});const after=await findComment();return {status:Number(after.user_digged)===1?"succeeded":"unknown",action:"liked_comment",evidence:{comment_id:p.comment,work_id:p.work,user_digged:String(after.user_digged??"")}};}if(p.action==="reply_comment"){await findComment();sent=true;const result=await post("/aweme/v1/web/comment/publish?"+qs,{app_name:"aweme",enter_from:"pc_web",previous_page:"video",reply_id:p.comment,reply_to_reply_id:"0",aweme_id:p.work,text:p.text,text_extra:"[]",comment_send_celltime:"0",comment_video_celltime:"0"});const posted=result.comment||result.comment_info||result.data?.comment;const postedID=String(posted?.cid||posted?.comment_id||"");const postedWork=String(posted?.aweme_id||posted?.item_id||"");const postedAuthor=String(posted?.user?.uid||posted?.user_id||"");const postedText=String(posted?.text??posted?.content??"");if(!posted||!postedID||postedWork!==p.work||postedAuthor!==p.expected||postedText!==p.text)fail("UNCONFIRMED");return {status:"succeeded",action:"replied",evidence:{comment_id:postedID,work_id:postedWork,author_uid:postedAuthor,text:postedText}};}if(p.action==="repost"){if(!p.text)fail("TEXT_REQUIRED");fail("REPOST_TEXT_UNSUPPORTED");}fail("ACTION_NOT_IMPLEMENTED");}catch(e){const code=String(e.message||"REQUEST_FAILED");return {status:sent?(e.definitive?"failed":"unknown"):"failed",code};}})()"""
+
+
+IM_SCRIPT = r"""(async()=>{const p=PARAMS_VALUE;let sent=false;const fail=(code,definitive=false)=>{const e=Error(code);e.definitive=definitive;throw e;};try{if(location.origin!=="https://www.douyin.com")fail("WRONG_ORIGIN");const response=await fetch("/aweme/v1/web/user/profile/self/?device_platform=webapp&aid=6383",{credentials:"include",signal:AbortSignal.timeout(15000)});if(!response.ok)fail("LOGIN_CHECK_FAILED");const profile=await response.json();if(profile.status_code!==0||String(profile.user?.uid)!==EXPECTED_UID_VALUE)fail("IDENTITY_MISMATCH");if(String(profile.user.uid)===p.uid)fail("SELF_TARGET");let service;for(const key of Object.keys(window).filter(k=>k.startsWith("@pc-im/im:"))){const chunks=window[key];if(!Array.isArray(chunks))continue;let req;chunks.push([["creatorhub-im-"+Date.now()],{},r=>{req=r;}]);for(const [id,module] of Object.entries(req?.c||{})){if(!String(req.m[id]).includes("getOrCreatePrivateConversationByUid"))continue;for(const exported of Object.values(module.exports||{}))if(exported?.instance?.imSdkService)service=exported.instance.imSdkService;}}if(!service)fail("IM_SDK_NOT_READY");const sdk=service.imSdkManager.getImSdkInstance();if(!sdk)fail("IM_SDK_NOT_READY");const meta=c=>({id:String(c.id),short_id:String(c.shortId),uid:String(c.toParticipantUserId),type:c.type});const pack=m=>({server_id:String(m.serverId||""),client_id:m.clientId||null,sender:String(m.sender),type:m.type,content:m.content,created_at:m.createdAt,server_status:m.serverStatus});let conversation=sdk.getConversationList().find(c=>c.type===1&&String(c.toParticipantUserId)===p.uid);if(!conversation&&p.action==="send")conversation=await service.conversationManager.getOrCreatePrivateConversationByUid(p.uid);if(!conversation)fail("CONVERSATION_NOT_FOUND");if(conversation.type!==1||String(conversation.toParticipantUserId)!==p.uid)fail("TARGET_MISMATCH");if(p.action!=="send"||!p.confirm)return {status:"preview",action:"preview",sender_uid:String(profile.user.uid),uid:p.uid,text:p.text,conversation:meta(conversation)};const message=await sdk.createMessage({conversation,type:7,content:JSON.stringify({aweType:700,type:0,richTextInfos:[],text:p.text})});if(!message||typeof message.sendFunc!=="function")fail("MESSAGE_BUILD_FAILED");sent=true;const result=await Promise.race([sdk.sendMessage({message}),new Promise((_,reject)=>setTimeout(()=>reject(Error("MESSAGE_UNCONFIRMED")),10000))]);if(result?.success===false)fail("MESSAGE_REJECTED",true);if(result?.success!==true)fail("MESSAGE_UNCONFIRMED");const packed=pack(message);if(!packed.server_id&&!packed.client_id)fail("MESSAGE_UNCONFIRMED");return {status:"succeeded",action:"send",success:true,status_code:result?.statusCode??null,check_code:String(result?.checkCode??""),conversation:meta(conversation),message:packed,evidence:{conversation_id:String(conversation.id),message_server_id:packed.server_id,message_client_id:String(packed.client_id??"")}};}catch(e){const known=["WRONG_ORIGIN","LOGIN_CHECK_FAILED","IDENTITY_MISMATCH","SELF_TARGET","IM_SDK_NOT_READY","CONVERSATION_NOT_FOUND","TARGET_MISMATCH","MESSAGE_BUILD_FAILED","MESSAGE_REJECTED","MESSAGE_UNCONFIRMED"];return {status:sent?(e.definitive?"failed":"unknown"):"failed",code:known.includes(e.message)?e.message:"SDK_REQUEST_FAILED"};}})()"""
diff --git a/cmd/docker_gateway/gateway.py b/cmd/docker_gateway/gateway.py
new file mode 100644
index 0000000..8a20577
--- /dev/null
+++ b/cmd/docker_gateway/gateway.py
@@ -0,0 +1,1608 @@
+"""CreatorHub Python Docker/browser gateway."""
+
+from __future__ import annotations
+
+import hmac
+import json
+import logging
+import math
+import os
+import re
+import signal
+import socket
+import threading
+import time
+from collections.abc import Mapping
+from contextlib import suppress
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from typing import cast
+from urllib.parse import parse_qs, quote, urlsplit
+
+from .docker_client import (
+ BINDING_VERSION_LABEL,
+ DISPLAY_NAME_LABEL,
+ MANAGED_LABEL,
+ NAME_PREFIX,
+ NETWORK_EXIT_LABEL,
+ NETWORK_ID_LABEL,
+ PROXY_PORT_LABEL,
+ RUNTIME_ID_LABEL,
+ RUNTIME_ID_RE,
+ AliasReservationManager,
+ DockerClient,
+ DockerError,
+ GenerationConflict,
+ NetworkSetupError,
+ TenantNetworkGeneration,
+ UnmanagedContainer,
+)
+from .douyin import (
+ ACCOUNT_KEY_RE,
+ ACTIONS,
+ COMMENTS_PATH,
+ IDENTITY_URL,
+ UID_RE,
+ WORKS_PATH,
+ DouyinBrowser,
+ DouyinError,
+ SubscriptionManager,
+)
+from .proxy import ProxyExit, ProxyRegistry
+
+LOG = logging.getLogger("creatorhub.gateway")
+CONTROL_NETWORK = "creatorhub_control"
+BROWSER_ENTRYPOINT = "/usr/local/bin/docker-entrypoint.sh"
+BROWSER_USER = "1000:1000"
+IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$")
+VOLUME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
+CONTAINER_ID_RE = re.compile(r"^[a-f0-9]{64}$")
+RUNTIME_CLEANUP_SENTINEL = "runtime-not-found"
+EXIT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
+DOUYIN_ACCOUNT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
+DOUYIN_ORIGIN = "https://www.douyin.com"
+DOUYIN_IDENTITY_PATH = "/aweme/v1/web/user/profile/self/"
+DOUYIN_IDENTITY_URL = IDENTITY_URL
+DOUYIN_WORKS_PATH = WORKS_PATH
+DOUYIN_COMMENTS_PATH = COMMENTS_PATH
+
+
+def _noop() -> None:
+ return None
+
+
+class RequestError(RuntimeError):
+ def __init__(self, message: str, status: int = 502, network_id: str = "") -> None:
+ super().__init__(message)
+ self.status = status
+ self.network_id = network_id
+
+
+class Gateway:
+ def __init__(
+ self,
+ docker: DockerClient,
+ network: str,
+ token: str,
+ self_name: str,
+ browser: DouyinBrowser | None = None,
+ ) -> None:
+ self.docker = docker
+ self.network = network
+ self.token = token
+ self.self_name = self_name
+ self.browser = browser or DouyinBrowser(self._browser_endpoint)
+ self.proxies = ProxyRegistry()
+ self.reservations = AliasReservationManager(docker, self_name)
+ self.subscriptions = SubscriptionManager(self.browser)
+ self._action_ownership_lock = threading.Lock()
+ self._uncertain_actions: dict[str, float] = {}
+
+ def _browser_endpoint(self, alias: str) -> str:
+ container_id, labels = self.docker.managed_container(alias)
+ network_id = labels.get(NETWORK_ID_LABEL, "")
+ if not isinstance(network_id, str) or not network_id:
+ raise GenerationConflict("browser container has no isolated network")
+ address = self.docker.container_network_address(container_id, network_id)
+ return f"http://{address}:9222"
+
+ def list_browsers(self) -> list[dict]:
+ filters = quote(
+ json.dumps({"label": [f"{MANAGED_LABEL}=true"]}, separators=(",", ":")),
+ safe="",
+ )
+ response = self.docker.request(
+ "GET", f"/containers/json?all=1&filters={filters}"
+ )
+ if response.status != 200:
+ raise RequestError(
+ f"Docker returned HTTP {response.status}", response.status
+ )
+ try:
+ containers = json.loads(response.body)
+ except json.JSONDecodeError as exc:
+ raise RequestError("Docker container list is invalid") from exc
+ if not isinstance(containers, list):
+ raise RequestError("Docker container list is invalid")
+ result = []
+ for container in containers:
+ if not isinstance(container, dict):
+ raise RequestError("Docker container list is invalid")
+ labels = container.get("Labels") or {}
+ if not isinstance(labels, dict):
+ raise RequestError("Docker container labels are invalid")
+ alias = labels.get(RUNTIME_ID_LABEL, "")
+ if not isinstance(alias, str) or not RUNTIME_ID_RE.fullmatch(alias):
+ continue
+ try:
+ binding = int(labels.get(BINDING_VERSION_LABEL, "0"))
+ proxy_port = int(labels.get(PROXY_PORT_LABEL, "0"))
+ except (TypeError, ValueError):
+ binding = proxy_port = 0
+ network_exit_id = labels.get(NETWORK_EXIT_LABEL, "")
+ network_id = labels.get(NETWORK_ID_LABEL, "")
+ container_id = container.get("Id", "")
+ if (
+ not isinstance(network_exit_id, str)
+ or not isinstance(network_id, str)
+ or not isinstance(container_id, str)
+ ):
+ raise RequestError("Docker container metadata is invalid")
+ direct = not network_exit_id
+ endpoint = f"http://{NAME_PREFIX}{alias}:9222"
+ network_error = ""
+ if network_id and container.get("State") == "running":
+ try:
+ address = self.docker.container_network_address(
+ container_id, network_id
+ )
+ except (DockerError, FileNotFoundError, GenerationConflict) as exc:
+ network_error = str(exc)
+ endpoint = ""
+ LOG.warning(
+ "browser network address unavailable",
+ extra={
+ "alias": alias,
+ "network_id": network_id,
+ "error": network_error,
+ },
+ )
+ else:
+ endpoint = f"http://{address}:9222"
+ result.append(
+ {
+ "id": container.get("Id", ""),
+ "alias": alias,
+ "name": labels.get(DISPLAY_NAME_LABEL) or alias,
+ "state": container.get("State", ""),
+ "status": container.get("Status", ""),
+ "endpoint": endpoint,
+ "binding_version": binding,
+ "network_exit_id": network_exit_id,
+ "network_id": network_id,
+ "proxy_ready": (not network_error)
+ and (
+ direct
+ or self.proxies.ready(
+ alias, proxy_port, container.get("Id", ""), network_id
+ )
+ ),
+ **({"error": network_error} if network_error else {}),
+ }
+ )
+ return result
+
+ def create(self, input: dict) -> dict:
+ validate_create(input)
+ self.docker.pull_if_missing(input["image"])
+ alias = input["alias"]
+ release = self.reservations.acquire(alias)
+ try:
+ # The reservation is the cross-process alias lock; checking before it
+ # was acquired leaves a create/create race window.
+ try:
+ self.docker.managed_container(alias)
+ except FileNotFoundError:
+ pass
+ else:
+ raise RequestError("browser alias is already in use", 409)
+ except Exception:
+ release()
+ raise
+ network_generation = TenantNetworkGeneration()
+ undo_proxy = _noop
+ keep_network = bool(input.get("stopped"))
+ keep_proxy = False
+ keep_container = False
+ container_attempted = False
+ created_id = ""
+ try:
+ direct = not input["network_exit_id"]
+ network = "none"
+ proxy_url = ""
+ if not input.get("stopped"):
+ network_generation, bind_host = self.docker.ensure_tenant_network(
+ self.network, alias, self.self_name, input["binding_version"]
+ )
+ network = network_generation.id
+ if not direct:
+ proxy_url, undo_proxy = self.proxies.configure(
+ alias,
+ input["binding_version"],
+ bind_host,
+ 0,
+ input["network_exit"],
+ network_generation.id,
+ )
+ command = list(input["cmd"])
+ if not input.get("stopped") and not direct:
+ command = command[:-1] + [
+ f"--proxy-server={proxy_url}",
+ "--disable-non-proxied-udp",
+ command[-1],
+ ]
+ pids_limit = 512
+ payload = {
+ "Image": input["image"],
+ "User": BROWSER_USER,
+ "Entrypoint": [BROWSER_ENTRYPOINT],
+ "Cmd": command,
+ "Env": ["REMOTE_DEBUGGING_PORT=9222"],
+ "Labels": {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: alias,
+ DISPLAY_NAME_LABEL: input["name"],
+ BINDING_VERSION_LABEL: str(input["binding_version"]),
+ NETWORK_EXIT_LABEL: input["network_exit_id"],
+ NETWORK_ID_LABEL: network_generation.id,
+ PROXY_PORT_LABEL: str(proxy_port(proxy_url)),
+ },
+ "ExposedPorts": {"9222/tcp": {}},
+ "HostConfig": {
+ "NetworkMode": network,
+ "ReadonlyRootfs": True,
+ "CapDrop": ["ALL"],
+ "SecurityOpt": ["no-new-privileges"],
+ "PidsLimit": pids_limit,
+ "Memory": 1 << 30,
+ "NanoCpus": 2_000_000_000,
+ "Tmpfs": browser_tmpfs(),
+ "Mounts": [
+ {"Type": "volume", "Source": input["volume"], "Target": "/data"}
+ ],
+ },
+ }
+ container_attempted = True
+ response = self.docker.request(
+ "POST",
+ "/containers/create?" + "name=" + quote(NAME_PREFIX + alias, safe=""),
+ payload,
+ )
+ if response.status == 409:
+ raise RequestError(
+ "browser alias is already in use", 409, network_generation.id
+ )
+ if response.status != 201:
+ raise RequestError(
+ f"Docker container creation failed with HTTP {response.status}",
+ response.status,
+ network_generation.id,
+ )
+ try:
+ created_id = json.loads(response.body)["Id"]
+ except (KeyError, TypeError, json.JSONDecodeError) as exc:
+ raise RequestError(
+ "Docker returned an invalid container id",
+ 502,
+ network_generation.id,
+ ) from exc
+ if not isinstance(created_id, str) or not created_id:
+ raise RequestError(
+ "Docker returned an invalid container id",
+ 502,
+ network_generation.id,
+ )
+ if not input.get("stopped"):
+ if not direct and not self.proxies.bind(
+ alias,
+ input["binding_version"],
+ proxy_url,
+ created_id,
+ network_generation.id,
+ ):
+ self.docker.expect(
+ "DELETE",
+ f"/containers/{quote(created_id, safe='')}?force=1&v=0",
+ )
+ raise RequestError(
+ "proxy generation changed", 409, network_generation.id
+ )
+ try:
+ self.docker.expect(
+ "POST",
+ f"/containers/{quote(created_id, safe='')}/start",
+ allowed=(204, 304),
+ )
+ except Exception as exc:
+ try:
+ self.docker.expect(
+ "DELETE",
+ f"/containers/{quote(created_id, safe='')}?force=1&v=0",
+ )
+ except Exception:
+ LOG.exception(
+ "failed to remove container after start failure",
+ extra={"container_id": created_id},
+ )
+ raise RequestError(
+ "container did not start and was removed",
+ 502,
+ network_generation.id,
+ ) from exc
+ keep_proxy = not direct
+ keep_network = True
+ keep_container = True
+ self._release_action_ownership(alias)
+ return {
+ "id": created_id,
+ "alias": alias,
+ "network_id": network_generation.id,
+ }
+ except NetworkSetupError as exc:
+ network_generation = exc.generation
+ raise RequestError(str(exc), 502, network_generation.id) from exc
+ finally:
+ if not keep_proxy:
+ undo_proxy()
+ if container_attempted and not keep_container:
+ self._reconcile_created_container(
+ alias,
+ created_id,
+ input["binding_version"],
+ network_generation.id,
+ )
+ if not keep_network and network_generation.id:
+ self._cleanup_network(
+ alias, input["binding_version"], "", network_generation
+ )
+ try:
+ release()
+ except Exception:
+ LOG.exception(
+ "failed to release browser alias reservation",
+ extra={"alias": alias},
+ )
+
+ def _reconcile_created_container(
+ self, alias: str, created_id: str, binding_version: int, network_id: str
+ ) -> None:
+ # A response without a container ID is not attributable to this
+ # request. Never delete an alias-matching container created by another
+ # request; an unknown outcome is logged and reconciled by the control
+ # plane's generation-aware cleanup instead.
+ if not created_id:
+ LOG.error(
+ "container creation outcome has no attributable container id",
+ extra={"alias": alias, "binding_version": binding_version},
+ )
+ return
+ try:
+ observed_id, labels = self.docker.managed_container(alias)
+ if created_id and observed_id != created_id:
+ LOG.error(
+ "container creation outcome has a replacement generation",
+ extra={
+ "alias": alias,
+ "created_id": created_id,
+ "observed_id": observed_id,
+ },
+ )
+ return
+ if (
+ labels.get(RUNTIME_ID_LABEL) != alias
+ or labels.get(BINDING_VERSION_LABEL) != str(binding_version)
+ or labels.get(NETWORK_ID_LABEL, "") != network_id
+ ):
+ LOG.error(
+ "container creation outcome is not safely attributable",
+ extra={"alias": alias, "observed_id": observed_id},
+ )
+ return
+ self.docker.expect(
+ "DELETE",
+ f"/containers/{quote(observed_id, safe='')}?force=1&v=0",
+ )
+ except FileNotFoundError:
+ return
+ except (DockerError, OSError, TypeError, ValueError, KeyError):
+ LOG.exception(
+ "failed to reconcile container creation outcome",
+ extra={"alias": alias, "created_id": created_id},
+ )
+
+ def change_state(self, alias: str, action: str, input: dict) -> None:
+ generation = decode_generation(
+ input, require_runtime=True, require_network=action == "start"
+ )
+ with self._alias_lock(alias):
+ container_id, exists = self._require_generation(alias, generation)
+ if not exists:
+ raise RequestError("browser not found", 404)
+ path = f"/containers/{quote(container_id, safe='')}/{'start' if action == 'start' else 'stop?t=10'}"
+ try:
+ self.docker.expect("POST", path, allowed=(204, 304))
+ except FileNotFoundError as exc:
+ raise RequestError("browser not found", 404) from exc
+ except Exception as exc:
+ raise RequestError("Docker state change failed") from exc
+
+ def remove(self, alias: str, input: dict) -> None:
+ generation = decode_generation(
+ input, require_runtime=False, require_network=False
+ )
+ with self._alias_lock(alias):
+ try:
+ container_id, labels = self.docker.managed_container(alias)
+ exists = True
+ except FileNotFoundError:
+ container_id, labels, exists = "", {}, False
+ if exists and (
+ not generation["runtime_id"]
+ or container_id != generation["runtime_id"]
+ or labels.get(BINDING_VERSION_LABEL)
+ != str(generation["binding_version"])
+ or labels.get(RUNTIME_ID_LABEL) != alias
+ or labels.get(NETWORK_ID_LABEL) != generation["network_id"]
+ ):
+ raise RequestError("container generation does not match request", 409)
+ try:
+ network_generation, _, network_exists = (
+ self.docker.inspect_tenant_network(
+ self.network,
+ alias,
+ generation["binding_version"],
+ generation["runtime_id"],
+ self.self_name,
+ generation["network_id"],
+ )
+ )
+ if (
+ network_exists
+ and generation["runtime_id"] == RUNTIME_CLEANUP_SENTINEL
+ and network_generation.runtime_attached
+ ):
+ raise RequestError(
+ "container generation is required while the network is attached",
+ 409,
+ )
+ if network_exists:
+ self._remove_network(
+ alias,
+ generation["binding_version"],
+ generation["runtime_id"],
+ network_generation,
+ )
+ elif exists and generation["network_id"]:
+ raise RequestError("container network generation is missing", 409)
+ except (GenerationConflict, RequestError):
+ raise
+ except (DockerError, OSError, TypeError, ValueError, KeyError) as exc:
+ raise RequestError(
+ "runtime_cleanup_pending", 202, generation["network_id"]
+ ) from exc
+ if not self.proxies.remove(
+ alias,
+ generation["binding_version"],
+ generation["runtime_id"],
+ generation["network_id"],
+ ):
+ raise RequestError("proxy generation does not match request", 409)
+ if exists:
+ try:
+ self.docker.expect(
+ "DELETE",
+ f"/containers/{quote(container_id, safe='')}?force=1&v=0",
+ )
+ except FileNotFoundError:
+ pass
+ except Exception as exc:
+ raise RequestError("Docker container removal failed") from exc
+ self._release_action_ownership(alias)
+
+ def restore_proxy(self, alias: str, input: dict) -> None:
+ validate_proxy_restore(input, alias)
+ with self._alias_lock(alias):
+ container_id, labels = self.docker.managed_container(alias)
+ expected = {
+ "binding_version": input["binding_version"],
+ "runtime_id": input["runtime_id"],
+ "network_id": input["network_id"],
+ }
+ if (
+ labels.get(RUNTIME_ID_LABEL) != alias
+ or labels.get(BINDING_VERSION_LABEL) != str(expected["binding_version"])
+ or labels.get(NETWORK_ID_LABEL) != expected["network_id"]
+ or labels.get(NETWORK_EXIT_LABEL) != input["network_exit_id"]
+ ):
+ raise RequestError(
+ "container binding does not match recovery request", 409
+ )
+ try:
+ port = int(labels.get(PROXY_PORT_LABEL, "0"))
+ except (TypeError, ValueError) as exc:
+ raise RequestError(
+ "container binding has an invalid proxy port", 409
+ ) from exc
+ if port < 1 and input["network_exit_id"]:
+ raise RequestError("container binding has no proxy port", 409)
+ generation = TenantNetworkGeneration(id=input["network_id"])
+ configured = False
+ undo = _noop
+ try:
+ generation, bind_host = self.docker.ensure_tenant_network(
+ self.network,
+ alias,
+ self.self_name,
+ input["binding_version"],
+ input["runtime_id"],
+ input["network_id"],
+ True,
+ )
+ self._require_proxy_network_generation(
+ alias, input, generation, bind_host
+ )
+ if not input["network_exit_id"]:
+ return
+ proxy_url, undo = self.proxies.configure(
+ alias,
+ input["binding_version"],
+ bind_host,
+ port,
+ input["network_exit"],
+ input["network_id"],
+ )
+ configured = True
+ self._require_proxy_network_generation(
+ alias, input, generation, bind_host
+ )
+ if not self.proxies.bind(
+ alias,
+ input["binding_version"],
+ proxy_url,
+ container_id,
+ input["network_id"],
+ ):
+ raise RequestError("proxy generation changed", 409)
+ self._require_proxy_network_generation(
+ alias, input, generation, bind_host
+ )
+ except Exception:
+ if configured:
+ undo()
+ else:
+ self.proxies.remove(
+ alias,
+ input["binding_version"],
+ input["runtime_id"],
+ input["network_id"],
+ )
+ self._cleanup_network(
+ alias, input["binding_version"], input["runtime_id"], generation
+ )
+ raise
+
+ def set_douyin_cookies(self, alias: str, input: dict) -> None:
+ cookies_value = input.get("cookies")
+ if not valid_douyin_generation(input) or not valid_cookies(cookies_value):
+ raise RequestError("invalid restricted browser request", 400)
+ cookies = cast(list[dict], cookies_value)
+ with self._alias_lock(alias):
+ self._require_douyin_generation(alias, input)
+ try:
+ self.browser.set_cookies(alias, cookies)
+ self._require_douyin_generation(alias, input)
+ except DouyinError as exc:
+ raise RequestError("restricted browser operation failed") from exc
+
+ def get_douyin(self, alias: str, input: dict) -> dict:
+ if not valid_douyin_generation(input) or not valid_douyin_url(
+ input.get("url", "")
+ ):
+ raise RequestError("invalid restricted browser request", 400)
+ with self._alias_lock(alias):
+ self._require_douyin_generation(alias, input)
+ try:
+ response = self.browser.get(alias, input["url"])
+ self._require_douyin_generation(alias, input)
+ except DouyinError as exc:
+ LOG.warning(
+ "Douyin GET failed alias=%s reason=%s",
+ alias,
+ str(exc),
+ )
+ raise RequestError("restricted browser operation failed") from exc
+ return {
+ "status": response.status,
+ "body": response.body,
+ "challenge": response.challenge,
+ }
+
+ def douyin_identity(self, alias: str, input: dict) -> dict:
+ expected_account_key = input.get("expected_account_key", "")
+ if (
+ not valid_douyin_generation(input)
+ or not isinstance(expected_account_key, str)
+ or not ACCOUNT_KEY_RE.fullmatch(expected_account_key)
+ ):
+ raise RequestError("invalid Douyin identity request", 400)
+ with self._alias_lock(alias):
+ self._require_douyin_generation(alias, input)
+ try:
+ identity = self.browser.identity(alias)
+ except DouyinError as exc:
+ LOG.warning(
+ "Douyin identity verification failed alias=%s reason=%s",
+ alias,
+ str(exc),
+ )
+ raise RequestError(
+ "Douyin login identity could not be verified"
+ ) from exc
+ if expected_account_key not in {
+ identity["uid"],
+ identity["sec_uid"],
+ identity["unique_id"],
+ }:
+ raise RequestError(
+ "Douyin identity does not match the expected account", 409
+ )
+ return identity
+
+ def douyin_action(self, alias: str, input: dict) -> dict:
+ expected_uid = input.get("expected_uid", "")
+ action = input.get("action", "")
+ target_uid = input.get("target_uid", "")
+ target_comment_id = input.get("target_comment_id", "")
+ target_work_id = input.get("target_work_id", "")
+ text = input.get("text", "")
+ confirm = input.get("confirm", False)
+ if (
+ not valid_douyin_generation(input)
+ or not isinstance(expected_uid, str)
+ or not isinstance(action, str)
+ or not isinstance(target_uid, str)
+ or not isinstance(target_comment_id, str)
+ or not isinstance(target_work_id, str)
+ or not isinstance(text, str)
+ or type(confirm) is not bool
+ or not UID_RE.fullmatch(expected_uid)
+ or action not in ACTIONS
+ ):
+ raise RequestError("invalid Douyin action request", 400)
+ with self._alias_lock(alias):
+ self._require_douyin_generation(alias, input)
+ self._claim_action(alias)
+ try:
+ result = self.browser.action(
+ alias,
+ expected_uid,
+ action,
+ target_uid,
+ target_comment_id,
+ target_work_id,
+ text,
+ confirm,
+ )
+ self._require_douyin_generation(alias, input)
+ except DouyinError as exc:
+ self._handle_douyin_action_error(alias, action, exc)
+ raise RequestError("Douyin action failed") from exc
+ except Exception:
+ self._release_action_ownership(alias)
+ raise
+ else:
+ self._release_action_ownership(alias)
+ return result
+
+ def _handle_douyin_action_error(
+ self, alias: str, action: str, error: DouyinError
+ ) -> None:
+ if getattr(error, "uncertain", False) or "timed out" in str(error).lower():
+ self._retain_action_ownership(alias)
+ else:
+ self._release_action_ownership(alias)
+ LOG.warning(
+ "Douyin action failed alias=%s action=%s reason=%s",
+ alias,
+ action,
+ str(error),
+ )
+
+ def _claim_action(self, alias: str) -> None:
+ now = time.monotonic()
+ with self._action_ownership_lock:
+ until = self._uncertain_actions.get(alias, 0.0)
+ if until > now:
+ raise RequestError("previous Douyin action outcome is uncertain", 409)
+ self._uncertain_actions.pop(alias, None)
+ self._uncertain_actions[alias] = 0.0
+
+ def _release_action_ownership(self, alias: str) -> None:
+ with self._action_ownership_lock:
+ self._uncertain_actions.pop(alias, None)
+
+ def _retain_action_ownership(self, alias: str) -> None:
+ with self._action_ownership_lock:
+ # A timed-out page script may still finish its network write. Keep the
+ # alias blocked until the browser generation is removed or replaced.
+ self._uncertain_actions[alias] = math.inf
+
+ def start_douyin_events(self, alias: str, input: dict) -> dict:
+ expected_uid = input.get("expected_uid", "")
+ if (
+ not valid_douyin_generation(input)
+ or not isinstance(expected_uid, str)
+ or not UID_RE.fullmatch(expected_uid)
+ ):
+ raise RequestError("invalid Douyin event request", 400)
+ with self._alias_lock(alias):
+ self._require_douyin_generation(alias, input)
+ try:
+ self.browser.identity(alias, expected_uid)
+ return self.subscriptions.start(alias, expected_uid)
+ except DouyinError as exc:
+ raise RequestError("Douyin event listener could not start") from exc
+
+ def poll_douyin_events(self, alias: str, input: dict, query: dict) -> list[dict]:
+ if not valid_douyin_generation(input):
+ raise RequestError("invalid Douyin event request", 400)
+ try:
+ limit = int(query.get("limit", ["50"])[0])
+ wait = float(query.get("wait", ["25"])[0])
+ except (IndexError, TypeError, ValueError) as exc:
+ raise RequestError("invalid event poll options", 400) from exc
+ if not 1 <= limit <= 100 or not 0 <= wait <= 30:
+ raise RequestError("invalid event poll options", 400)
+ with self._alias_lock(alias):
+ self._require_douyin_generation(alias, input)
+ try:
+ acknowledgements = query.get("ack", [])
+ delivery_ids = [
+ value for raw in acknowledgements for value in raw.split(",")
+ ]
+ self.subscriptions.ack(alias, delivery_ids)
+ return self.subscriptions.poll(alias, limit, wait)
+ except DouyinError as exc:
+ raise RequestError("Douyin event listener is unavailable") from exc
+
+ def stop_douyin_events(self, alias: str, input: dict) -> None:
+ if not valid_douyin_generation(input):
+ raise RequestError("invalid Douyin event request", 400)
+ with self._alias_lock(alias):
+ self._require_douyin_generation(alias, input)
+ self.subscriptions.stop(alias)
+
+ def _require_generation(self, alias: str, generation: dict) -> tuple[str, bool]:
+ try:
+ container_id, labels = self.docker.managed_container(alias)
+ except FileNotFoundError:
+ return "", False
+ if (
+ container_id != generation["runtime_id"]
+ or labels.get(RUNTIME_ID_LABEL) != alias
+ or labels.get(BINDING_VERSION_LABEL) != str(generation["binding_version"])
+ or labels.get(NETWORK_ID_LABEL) != generation["network_id"]
+ ):
+ raise RequestError("container generation does not match request", 409)
+ return container_id, True
+
+ def _require_douyin_generation(self, alias: str, input: dict) -> None:
+ container_id, labels = self.docker.managed_container(alias)
+ if (
+ container_id != input["runtime_id"]
+ or labels.get(RUNTIME_ID_LABEL) != alias
+ or labels.get(BINDING_VERSION_LABEL) != str(input["binding_version"])
+ or labels.get(NETWORK_ID_LABEL) != input["network_id"]
+ or labels.get(NETWORK_EXIT_LABEL, "") != input.get("network_exit_id", "")
+ ):
+ raise RequestError("container generation does not match request", 409)
+ generation, _, exists = self.docker.inspect_tenant_network(
+ self.network,
+ alias,
+ input["binding_version"],
+ input["runtime_id"],
+ self.self_name,
+ input["network_id"],
+ )
+ if (
+ not exists
+ or not generation.runtime_attached
+ or not generation.self_member
+ or not generation.gateway_members
+ ):
+ raise RequestError(
+ "container network generation does not match request", 409
+ )
+ _, _, networks = self.docker.managed_container_state(alias)
+ if networks != {generation.name: generation.id}:
+ raise RequestError(
+ "container network generation does not match request", 409
+ )
+
+ def _require_proxy_network_generation(
+ self, alias: str, input: dict, expected: TenantNetworkGeneration, bind_host: str
+ ) -> None:
+ container_id, labels = self.docker.managed_container(alias)
+ if (
+ container_id != input["runtime_id"]
+ or labels.get(NETWORK_ID_LABEL) != expected.id
+ ):
+ raise RequestError("network generation changed", 409)
+ current, addresses, exists = self.docker.inspect_tenant_network(
+ self.network,
+ alias,
+ input["binding_version"],
+ input["runtime_id"],
+ self.self_name,
+ expected.id,
+ )
+ if (
+ not exists
+ or not same_network_members(current, expected)
+ or addresses.get(current.self_member, "").split("/", 1)[0] != bind_host
+ ):
+ raise RequestError("network generation changed", 409)
+
+ def _cleanup_network(
+ self,
+ alias: str,
+ binding_version: int,
+ runtime_id: str,
+ generation: TenantNetworkGeneration,
+ ) -> None:
+ try:
+ current, _, exists = self.docker.inspect_tenant_network(
+ self.network,
+ alias,
+ binding_version,
+ runtime_id,
+ self.self_name,
+ generation.id,
+ )
+ if not exists:
+ return
+ if generation.created:
+ self._remove_network(alias, binding_version, runtime_id, current, True)
+ else:
+ if generation.connected_runtime and runtime_id:
+ self.docker.disconnect_member(
+ self.network,
+ alias,
+ binding_version,
+ runtime_id,
+ current,
+ runtime_id,
+ self.self_name,
+ missing_ok=True,
+ )
+ if generation.connected_self:
+ member = current.self_member or generation.self_member
+ if member:
+ self.docker.disconnect_member(
+ self.network,
+ alias,
+ binding_version,
+ runtime_id,
+ current,
+ member,
+ self.self_name,
+ missing_ok=True,
+ )
+ except (DockerError, OSError, TypeError, ValueError, KeyError):
+ LOG.exception(
+ "failed to clean up isolated browser network",
+ extra={"alias": alias, "network_id": generation.id},
+ )
+
+ def _remove_network(
+ self,
+ alias: str,
+ binding_version: int,
+ runtime_id: str,
+ generation: TenantNetworkGeneration,
+ missing_ok: bool = False,
+ ) -> None:
+ current = generation
+ if current.runtime_attached or current.connected_runtime:
+ current = self.docker.disconnect_member(
+ self.network,
+ alias,
+ binding_version,
+ runtime_id,
+ current,
+ runtime_id,
+ self.self_name,
+ missing_ok=missing_ok,
+ )
+ for member in list(current.gateway_members):
+ current = self.docker.disconnect_member(
+ self.network,
+ alias,
+ binding_version,
+ runtime_id,
+ current,
+ member,
+ self.self_name,
+ missing_ok=missing_ok,
+ )
+ if current.self_member:
+ current = self.docker.disconnect_member(
+ self.network,
+ alias,
+ binding_version,
+ runtime_id,
+ current,
+ current.self_member,
+ self.self_name,
+ missing_ok=missing_ok,
+ )
+ self.docker.delete_tenant_network(
+ self.network,
+ alias,
+ binding_version,
+ runtime_id,
+ current,
+ self.self_name,
+ missing_ok=missing_ok,
+ )
+
+ def _alias_lock(self, alias: str):
+ return _AliasLock(self.reservations, alias)
+
+
+class _AliasLock:
+ def __init__(self, reservations: AliasReservationManager, alias: str) -> None:
+ self.reservations = reservations
+ self.alias = alias
+ self.release = None
+
+ def __enter__(self):
+ self.release = self.reservations.acquire(self.alias)
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
+ if self.release:
+ self.release()
+
+
+class GatewayHTTPServer(ThreadingHTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+ gateway: Gateway
+
+ def __init__(self, address, gateway: Gateway):
+ super().__init__(address, GatewayHandler)
+ self.gateway = gateway
+ self._connections: set[socket.socket] = set()
+ self._connections_lock = threading.Lock()
+ self._connections_changed = threading.Condition(self._connections_lock)
+
+ def process_request(self, request, client_address):
+ with self._connections_changed:
+ self._connections.add(cast(socket.socket, request))
+ try:
+ super().process_request(request, client_address)
+ except Exception:
+ with self._connections_changed:
+ self._connections.discard(cast(socket.socket, request))
+ self._connections_changed.notify_all()
+ raise
+
+ def process_request_thread(self, request, client_address):
+ try:
+ super().process_request_thread(request, client_address)
+ finally:
+ with self._connections_changed:
+ self._connections.discard(cast(socket.socket, request))
+ self._connections_changed.notify_all()
+
+ def wait_for_requests(self, timeout: float) -> None:
+ deadline = time.monotonic() + timeout
+ with self._connections_changed:
+ while self._connections:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ self._connections_changed.wait(remaining)
+ if self._connections:
+ connections = list(self._connections)
+ else:
+ connections = []
+ for connection in connections:
+ with suppress(OSError):
+ connection.shutdown(socket.SHUT_RDWR)
+ connection.close()
+
+
+class GatewayHandler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def setup(self) -> None:
+ super().setup()
+ self.request.settimeout(5.0)
+
+ def do_GET(self) -> None:
+ self._dispatch("GET")
+
+ def do_POST(self) -> None:
+ self._dispatch("POST")
+
+ def do_DELETE(self) -> None:
+ self._dispatch("DELETE")
+
+ def log_message(self, format: str, *args) -> None:
+ LOG.info("http_request", extra={"request": format % args})
+
+ def _dispatch(self, method: str) -> None:
+ parsed = urlsplit(self.path)
+ if parsed.path == "/healthz":
+ self._respond(204, b"")
+ return
+ if not parsed.path.startswith("/v1/"):
+ self._respond(404, json_bytes({"error": "not found"}))
+ return
+ if not self._authorized():
+ self._respond(401, json_bytes({"error": "gateway token rejected"}))
+ return
+ try:
+ needs_body = method in {"POST", "DELETE"} or (
+ method == "GET" and parsed.path != "/v1/browsers"
+ )
+ body = self._body() if needs_body else {}
+ result = self._route(method, parsed.path, parse_qs(parsed.query), body)
+ if result is None:
+ self._respond(204, b"")
+ elif isinstance(result, tuple):
+ status, value = result
+ if type(status) is not int:
+ raise RuntimeError("gateway route returned an invalid status")
+ self._respond(status, json_bytes(value))
+ else:
+ self._respond(200, json_bytes(result))
+ except (RuntimeError, OSError, ValueError, TypeError, KeyError) as exc:
+ self._handle_exception(parsed.path, exc)
+
+ def _handle_exception(self, path: str, exc: Exception) -> None:
+ if isinstance(exc, RequestError):
+ payload = {"error": str(exc)}
+ if exc.network_id:
+ payload["network_id"] = exc.network_id
+ self._respond(exc.status, json_bytes(payload))
+ elif isinstance(exc, FileNotFoundError):
+ self._respond(404, json_bytes({"error": str(exc)}))
+ elif isinstance(exc, (GenerationConflict, UnmanagedContainer)):
+ self._respond(409, json_bytes({"error": str(exc)}))
+ elif isinstance(exc, DockerError):
+ status = (
+ exc.status
+ if exc.status is not None and 400 <= exc.status < 500
+ else 502
+ )
+ self._respond(status, json_bytes({"error": str(exc)}))
+ elif isinstance(exc, ValueError):
+ self._respond(400, json_bytes({"error": str(exc)}))
+ else:
+ LOG.exception("gateway request failed", extra={"path": path})
+ self._respond(500, json_bytes({"error": "gateway operation failed"}))
+
+ def _route(self, method: str, path: str, query: dict, body: dict):
+ gateway = self.server_as_gateway().gateway
+ if method == "GET" and path == "/v1/browsers":
+ return gateway.list_browsers()
+ if method == "POST" and path == "/v1/browsers":
+ return 201, gateway.create(body)
+ match = re.fullmatch(r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})", path)
+ if match and method == "DELETE":
+ gateway.remove(
+ match.group(1),
+ decode_generation(body, require_runtime=False, require_network=False),
+ )
+ return None
+ match = re.fullmatch(
+ r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/(start|stop|proxy)", path
+ )
+ if match:
+ alias, action = match.groups()
+ if method == "POST" and action in {"start", "stop"}:
+ gateway.change_state(alias, action, body)
+ return None
+ if method == "POST" and action == "proxy":
+ gateway.restore_proxy(alias, body)
+ return None
+ match = re.fullmatch(
+ r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(cookies|get|identity|action|events)",
+ path,
+ )
+ if match:
+ alias, action = match.groups()
+ if action == "cookies" and method == "POST":
+ gateway.set_douyin_cookies(alias, body)
+ return None
+ if action == "get" and method == "POST":
+ return gateway.get_douyin(alias, body)
+ if action == "identity" and method == "POST":
+ return gateway.douyin_identity(alias, body)
+ if action == "action" and method == "POST":
+ return gateway.douyin_action(alias, body)
+ if action == "events":
+ if method == "POST":
+ return gateway.start_douyin_events(alias, body)
+ if method == "GET":
+ return gateway.poll_douyin_events(alias, body, query)
+ if method == "DELETE":
+ gateway.stop_douyin_events(alias, body)
+ return None
+ raise RequestError("not found", 404)
+
+ def server_as_gateway(self) -> GatewayHTTPServer:
+ if not isinstance(self.server, GatewayHTTPServer):
+ raise TypeError("gateway HTTP server type is invalid")
+ return self.server
+
+ def _authorized(self) -> bool:
+ supplied = self.headers.get("Authorization", "")
+ return hmac.compare_digest(
+ supplied, "Bearer " + self.server_as_gateway().gateway.token
+ )
+
+ def _body(self) -> dict:
+ length_text = self.headers.get("Content-Length")
+ if length_text is None:
+ raise RequestError("request body is required", 400)
+ try:
+ length = int(length_text)
+ except ValueError as exc:
+ raise RequestError("invalid request body length", 400) from exc
+ if length < 0 or length > 1 << 20:
+ raise RequestError("request body is too large", 400)
+ raw = self.rfile.read(length)
+ try:
+ value = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise RequestError("request body must be one JSON object", 400) from exc
+ if not isinstance(value, dict):
+ raise RequestError("request body must be one JSON object", 400)
+ return value
+
+ def _respond(self, status: int, body: bytes) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ if body:
+ self.wfile.write(body)
+
+
+def json_bytes(value: object) -> bytes:
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
+
+
+def validate_create(input: dict) -> None:
+ allowed = {
+ "alias",
+ "name",
+ "image",
+ "cmd",
+ "volume",
+ "binding_version",
+ "network_exit_id",
+ "network_exit",
+ "stopped",
+ }
+ if set(input) - allowed:
+ raise RequestError("body contains unknown fields", 400)
+ alias = input.get("alias", "")
+ name = input.get("name", "")
+ image = input.get("image", "")
+ volume = input.get("volume", "")
+ command = input.get("cmd")
+ binding = input.get("binding_version")
+ exit_id = input.get("network_exit_id", "")
+ stopped = input.get("stopped", False)
+ if not isinstance(alias, str) or not RUNTIME_ID_RE.fullmatch(alias):
+ raise RequestError("alias must match [a-z0-9][a-z0-9-]{0,31}", 400)
+ if not isinstance(name, str) or not 1 <= len(name) <= 64 or has_control(name):
+ raise RequestError("name must be 1..64 visible characters", 400)
+ if not isinstance(image, str) or not IMAGE_RE.fullmatch(image):
+ raise RequestError("image must be a valid image reference", 400)
+ if not isinstance(volume, str) or not VOLUME_RE.fullmatch(volume):
+ raise RequestError("volume must be a valid volume name", 400)
+ if type(binding) is not int or binding < 1:
+ raise RequestError(
+ "binding_version and network_exit_id must identify the current binding", 400
+ )
+ if type(stopped) is not bool:
+ raise RequestError("stopped must be boolean", 400)
+ if not isinstance(exit_id, str):
+ raise RequestError("network_exit_id must be a string", 400)
+ input["network_exit_id"] = exit_id
+ input.setdefault("network_exit", {})
+ exit_value = parse_proxy_exit(input.get("network_exit", {}))
+ direct = not exit_id and exit_value == ProxyExit("", "", 0)
+ if stopped and not direct:
+ raise RequestError("stopped browsers must use direct networking", 400)
+ if bool(exit_id) != (exit_value != ProxyExit("", "", 0)):
+ raise RequestError(
+ "binding_version and network_exit_id must identify the current binding", 400
+ )
+ if exit_id and not EXIT_ID_RE.fullmatch(exit_id):
+ raise RequestError("network_exit_id is invalid", 400)
+ if (
+ not isinstance(command, list)
+ or not 1 <= len(command) <= 64
+ or command[-1] != "about:blank"
+ ):
+ raise RequestError("cmd must contain 1..64 arguments", 400)
+ total = 0
+ for item in command:
+ if (
+ not isinstance(item, str)
+ or not item
+ or has_control(item)
+ or item.startswith("--proxy-server")
+ or item == "--disable-non-proxied-udp"
+ ):
+ raise RequestError("cmd arguments are invalid", 400)
+ total += len(item)
+ if total > 4096:
+ raise RequestError("cmd arguments exceed 4096 characters", 400)
+ if not stopped and not direct:
+ validate_proxy_exit(exit_value)
+ input["network_exit"] = exit_value
+
+
+def validate_proxy_exit(exit: ProxyExit) -> None:
+ if (
+ exit.protocol not in {"http", "https", "socks4", "socks5"}
+ or not exit.host
+ or len(exit.host) > 253
+ or any(char in exit.host for char in "@/[]?# \t\r\n")
+ or not 1 <= exit.port <= 65535
+ or (not exit.username and exit.credential)
+ or len(exit.username) > 255
+ or len(exit.credential) > 255
+ or has_control(exit.username)
+ or has_control(exit.credential)
+ ):
+ raise RequestError("network_exit must contain a valid proxy endpoint", 400)
+
+
+def parse_proxy_exit(value: object) -> ProxyExit:
+ if not isinstance(value, dict):
+ raise RequestError("network_exit must be an object", 400)
+ allowed = {"protocol", "host", "port", "username", "password"}
+ if set(value) - allowed:
+ raise RequestError("network_exit contains unknown fields", 400)
+ try:
+ exit = ProxyExit(
+ value.get("protocol", ""),
+ value.get("host", ""),
+ value.get("port", 0),
+ value.get("username", ""),
+ value.get("password", ""),
+ )
+ except (TypeError, ValueError) as exc:
+ raise RequestError("network_exit is invalid", 400) from exc
+ if (
+ not all(
+ isinstance(item, str)
+ for item in (exit.protocol, exit.host, exit.username, exit.credential)
+ )
+ or type(exit.port) is not int
+ ):
+ raise RequestError("network_exit is invalid", 400)
+ return exit
+
+
+def browser_tmpfs() -> dict[str, str]:
+ # These are in-container tmpfs mounts; no host path or bind mount is exposed.
+ tmp = os.path.join(os.sep, "tmp")
+ return {
+ tmp: "rw,nosuid,nodev,noexec,mode=1777,size=256m",
+ os.path.join(tmp, ".X11-unix"): "rw,nosuid,nodev,noexec,mode=1777,size=1m",
+ os.path.join(os.sep, "dev", "shm"): "rw,nosuid,nodev,noexec,size=256m",
+ os.path.join(
+ os.sep, "home", "ubuntu"
+ ): "rw,nosuid,nodev,noexec,uid=1000,gid=1000,mode=700,size=64m",
+ }
+
+
+def proxy_port(proxy_url: str) -> int:
+ return urlsplit(proxy_url).port or 0
+
+
+def has_control(value: str) -> bool:
+ return any(ord(char) < 0x20 or ord(char) == 0x7F for char in value)
+
+
+def decode_generation(
+ value: dict, require_runtime: bool, require_network: bool
+) -> dict:
+ allowed = {"binding_version", "runtime_id", "network_id"}
+ if not isinstance(value, dict) or set(value) - allowed:
+ raise RequestError(
+ "binding_version, runtime_id and network_id must identify the expected generation",
+ 400,
+ )
+ binding = value.get("binding_version")
+ runtime = value.get("runtime_id", "")
+ network = value.get("network_id", "")
+ if (
+ type(binding) is not int
+ or binding < 1
+ or not isinstance(runtime, str)
+ or not isinstance(network, str)
+ or (
+ runtime
+ and runtime != RUNTIME_CLEANUP_SENTINEL
+ and not CONTAINER_ID_RE.fullmatch(runtime)
+ )
+ or (network and not EXIT_ID_RE.fullmatch(network))
+ or (require_runtime and not runtime)
+ or (require_network and not network)
+ ):
+ raise RequestError(
+ "binding_version, runtime_id and network_id must identify the expected generation",
+ 400,
+ )
+ return {"binding_version": binding, "runtime_id": runtime, "network_id": network}
+
+
+def validate_proxy_restore(value: dict, alias: str) -> None:
+ allowed = {
+ "binding_version",
+ "runtime_id",
+ "network_id",
+ "network_exit_id",
+ "network_exit",
+ }
+ if set(value) - allowed:
+ raise RequestError("invalid proxy recovery request", 400)
+ generation = decode_generation(
+ {
+ key: value.get(key)
+ for key in ("binding_version", "runtime_id", "network_id")
+ },
+ True,
+ True,
+ )
+ exit_id = value.get("network_exit_id", "")
+ if not isinstance(exit_id, str) or (exit_id and not EXIT_ID_RE.fullmatch(exit_id)):
+ raise RequestError("invalid proxy recovery request", 400)
+ exit = parse_proxy_exit(value.get("network_exit", {}))
+ direct = not exit_id and exit == ProxyExit("", "", 0)
+ if bool(exit_id) != (not direct):
+ raise RequestError("invalid proxy recovery request", 400)
+ if not direct:
+ validate_proxy_exit(exit)
+ value["network_exit"] = exit
+ value.update(generation)
+ value["network_exit_id"] = exit_id
+
+
+def valid_douyin_generation(value: dict) -> bool:
+ if not isinstance(value, dict):
+ return False
+ binding = value.get("binding_version")
+ runtime = value.get("runtime_id", "")
+ network = value.get("network_id", "")
+ exit_id = value.get("network_exit_id", "")
+ return (
+ type(binding) is int
+ and binding > 0
+ and isinstance(runtime, str)
+ and isinstance(network, str)
+ and isinstance(exit_id, str)
+ and bool(CONTAINER_ID_RE.fullmatch(runtime))
+ and bool(EXIT_ID_RE.fullmatch(network))
+ and (not exit_id or bool(EXIT_ID_RE.fullmatch(exit_id)))
+ )
+
+
+def valid_cookies(cookies: object) -> bool:
+ if not isinstance(cookies, list) or not 1 <= len(cookies) <= 64:
+ return False
+ for cookie in cookies:
+ if not isinstance(cookie, dict):
+ return False
+ allowed = {
+ "name",
+ "value",
+ "domain",
+ "path",
+ "secure",
+ "http_only",
+ "same_site",
+ "expires",
+ }
+ if set(cookie) - allowed:
+ return False
+ name = cookie.get("name")
+ value = cookie.get("value")
+ domain = cookie.get("domain")
+ path_value = cookie.get("path")
+ path = "/" if path_value is None else path_value
+ if (
+ not isinstance(name, str)
+ or not isinstance(value, str)
+ or not isinstance(domain, str)
+ or not isinstance(path, str)
+ ):
+ return False
+ if (
+ not name
+ or len(name) > 256
+ or len(value) > 4096
+ or len(domain) > 256
+ or len(path) > 256
+ or domain != domain.lower().strip()
+ or (domain != "douyin.com" and not domain.endswith(".douyin.com"))
+ or not path.startswith("/")
+ or has_control(name)
+ or has_control(value)
+ or has_control(path)
+ or (cookie.get("same_site", "") not in {"", "Lax", "Strict", "None"})
+ ):
+ return False
+ expires = cookie.get("expires", 0)
+ if type(expires) not in (int, float) or expires < 0:
+ return False
+ return True
+
+
+def valid_douyin_url(raw: object) -> bool:
+ if not isinstance(raw, str):
+ return False
+ try:
+ parsed = urlsplit(raw)
+ except ValueError:
+ return False
+ if (
+ parsed.scheme != "https"
+ or parsed.netloc != "www.douyin.com"
+ or parsed.username
+ or parsed.fragment
+ ):
+ return False
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ if parsed.path == DOUYIN_IDENTITY_PATH:
+ return query == {"aid": ["6383"], "device_platform": ["webapp"]}
+ if parsed.path == DOUYIN_WORKS_PATH:
+ return (
+ len(query) == 3
+ and valid_account_key_query(query, "sec_user_id")
+ and query.get("count") == ["20"]
+ and numeric_cursor(query.get("max_cursor"))
+ )
+ if parsed.path == DOUYIN_COMMENTS_PATH:
+ return (
+ len(query) == 3
+ and valid_account_key_query(query, "aweme_id")
+ and query.get("count") == ["20"]
+ and numeric_cursor(query.get("cursor"))
+ )
+ return False
+
+
+def valid_account_key_query(query: dict[str, list[str]], key: str) -> bool:
+ return len(query.get(key, [])) == 1 and bool(
+ DOUYIN_ACCOUNT_KEY_RE.fullmatch(query[key][0])
+ )
+
+
+def numeric_cursor(values: list[str] | None) -> bool:
+ if not values or len(values) != 1 or not values[0].isdigit():
+ return False
+ try:
+ return int(values[0]) >= 0
+ except ValueError:
+ return False
+
+
+def same_network_members(
+ current: TenantNetworkGeneration, expected: TenantNetworkGeneration
+) -> bool:
+ return (
+ current.id == expected.id
+ and current.name == expected.name
+ and current.runtime_attached == expected.runtime_attached
+ and current.self_member == expected.self_member
+ and set(current.gateway_members) == set(expected.gateway_members)
+ )
+
+
+def load_config(env: Mapping[str, str] | None = None) -> dict:
+ env = os.environ if env is None else env
+ listen = env.get("LISTEN_ADDR", ":8081").strip()
+ socket_path = env.get("DOCKER_SOCKET", "/var/run/docker.sock").strip()
+ network = env.get("BROWSER_NETWORK", "creatorhub_browser").strip()
+ token = env.get("GATEWAY_TOKEN", "").strip()
+ host, port = split_listen_address(listen)
+ if (
+ not socket_path
+ or len(token) < 16
+ or not re.fullmatch(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$", network)
+ or network == CONTROL_NETWORK
+ ):
+ raise ValueError("invalid gateway configuration")
+ if not 1 <= port <= 65535:
+ raise ValueError("LISTEN_ADDR port must be 1..65535")
+ return {
+ "listen": (host, port),
+ "docker_socket": socket_path,
+ "network": network,
+ "token": token,
+ }
+
+
+def split_listen_address(value: str) -> tuple[str, int]:
+ if value.startswith(":"):
+ host, port_text = "", value[1:]
+ elif value.startswith("["):
+ closing = value.find("]:")
+ if closing <= 1:
+ raise ValueError("LISTEN_ADDR must be host:port")
+ host, port_text = value[1:closing], value[closing + 2 :]
+ else:
+ if ":" not in value:
+ raise ValueError("LISTEN_ADDR must be host:port")
+ host, port_text = value.rsplit(":", 1)
+ try:
+ port = int(port_text)
+ except ValueError as exc:
+ raise ValueError("LISTEN_ADDR port must be an integer") from exc
+ return host, port
+
+
+def run() -> None:
+ config = load_config()
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
+ docker = DockerClient(config["docker_socket"])
+ gateway = Gateway(docker, config["network"], config["token"], socket.gethostname())
+ server = GatewayHTTPServer(config["listen"], gateway)
+ LOG.info(
+ json.dumps(
+ {
+ "service": "docker-gateway",
+ "listen_addr": f"{config['listen'][0]}:{config['listen'][1]}",
+ "network": config["network"],
+ }
+ )
+ )
+ shutdown_requested = threading.Event()
+
+ def request_shutdown(_signum, _frame) -> None:
+ if shutdown_requested.is_set():
+ return
+ shutdown_requested.set()
+ threading.Thread(
+ target=server.shutdown,
+ name="gateway-shutdown",
+ daemon=True,
+ ).start()
+
+ signal.signal(signal.SIGINT, request_shutdown)
+ signal.signal(signal.SIGTERM, request_shutdown)
+ try:
+ server.serve_forever()
+ finally:
+ # Stop accepting first, then let in-flight work finish before closing
+ # the browser and proxy dependencies it may still own.
+ server.wait_for_requests(30.0)
+ gateway.subscriptions.close()
+ gateway.proxies.close()
+ server.server_close()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/cmd/docker_gateway/proxy.py b/cmd/docker_gateway/proxy.py
new file mode 100644
index 0000000..12ea92c
--- /dev/null
+++ b/cmd/docker_gateway/proxy.py
@@ -0,0 +1,656 @@
+"""Small in-memory HTTP/SOCKS proxy used by one browser generation."""
+
+from __future__ import annotations
+
+import base64
+import select
+import socket
+import socketserver
+import ssl
+import threading
+from collections.abc import Callable
+from contextlib import suppress
+from dataclasses import dataclass
+from typing import cast
+from urllib.parse import urlsplit
+
+
+@dataclass(frozen=True)
+class ProxyExit:
+ protocol: str
+ host: str
+ port: int
+ username: str = ""
+ credential: str = ""
+
+
+class _ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
+ allow_reuse_address = True
+ daemon_threads = True
+
+
+class ProxyRegistry:
+ def __init__(self) -> None:
+ self._lock = threading.RLock()
+ self._proxies: dict[str, MemoryProxy] = {}
+
+ def configure(
+ self,
+ alias: str,
+ binding_version: int,
+ bind_host: str,
+ port: int,
+ exit: ProxyExit,
+ network_id: str = "",
+ ) -> tuple[str, Callable[[], None]]:
+ with self._lock:
+ current = self._proxies.get(alias)
+ if current and current.matches(
+ binding_version, bind_host, port, exit, network_id
+ ):
+ return current.url, lambda: self._remove_object(alias, current)
+ if current:
+ self._proxies.pop(alias, None)
+ current.close()
+ proxy = MemoryProxy(
+ alias, binding_version, bind_host, port, exit, network_id
+ )
+ self._proxies[alias] = proxy
+ return proxy.url, lambda: self._remove_object(alias, proxy)
+
+ def bind(
+ self,
+ alias: str,
+ binding_version: int,
+ proxy_url: str,
+ runtime_id: str,
+ network_id: str = "",
+ ) -> bool:
+ with self._lock:
+ proxy = self._proxies.get(alias)
+ return bool(
+ proxy and proxy.bind(binding_version, proxy_url, runtime_id, network_id)
+ )
+
+ def ready(
+ self, alias: str, port: int, runtime_id: str, network_id: str = ""
+ ) -> bool:
+ with self._lock:
+ proxy = self._proxies.get(alias)
+ return bool(proxy and proxy.ready(port, runtime_id, network_id))
+
+ def remove(
+ self, alias: str, binding_version: int, runtime_id: str, network_id: str = ""
+ ) -> bool:
+ with self._lock:
+ proxy = self._proxies.get(alias)
+ if not proxy:
+ return True
+ if not proxy.identity_matches(binding_version, runtime_id, network_id):
+ return False
+ self._proxies.pop(alias, None)
+ proxy.close()
+ return True
+
+ def _remove_object(self, alias: str, proxy: MemoryProxy) -> None:
+ with self._lock:
+ if self._proxies.get(alias) is not proxy:
+ return
+ self._proxies.pop(alias, None)
+ proxy.close()
+
+ def close(self) -> None:
+ with self._lock:
+ proxies = list(self._proxies.values())
+ self._proxies.clear()
+ for proxy in proxies:
+ proxy.close()
+
+
+class MemoryProxy:
+ def __init__(
+ self,
+ alias: str,
+ binding_version: int,
+ bind_host: str,
+ port: int,
+ exit: ProxyExit,
+ network_id: str,
+ ) -> None:
+ self.alias = alias
+ self.binding_version = binding_version
+ self.bind_host = bind_host
+ self.exit = exit
+ self.network_id = network_id
+ self.runtime_id = ""
+ self._lock = threading.RLock()
+ self._tunnels: set[socket.socket] = set()
+ handler_type = type(
+ "ProxyHandler",
+ (_ProxyHandler,),
+ {"proxy": self},
+ )
+ self.server = _ThreadingTCPServer((bind_host, port), handler_type)
+ self.listener = self.server.socket
+ actual_port = self.listener.getsockname()[1]
+ self.url = f"http://docker-gateway:{actual_port}"
+ self._thread = threading.Thread(
+ target=self.server.serve_forever,
+ name=f"creatorhub-proxy-{alias}",
+ daemon=True,
+ )
+ self._thread.start()
+
+ def matches(
+ self,
+ binding_version: int,
+ bind_host: str,
+ port: int,
+ exit: ProxyExit,
+ network_id: str,
+ ) -> bool:
+ actual_port = self.listener.getsockname()[1]
+ return (
+ self.binding_version == binding_version
+ and self.bind_host == bind_host
+ and (port == 0 or port == actual_port)
+ and self.exit == exit
+ and self.network_id == network_id
+ )
+
+ def bind(
+ self, binding_version: int, proxy_url: str, runtime_id: str, network_id: str
+ ) -> bool:
+ with self._lock:
+ if (
+ binding_version != self.binding_version
+ or proxy_url != self.url
+ or not runtime_id
+ ):
+ return False
+ self.runtime_id = runtime_id
+ if network_id:
+ self.network_id = network_id
+ return True
+
+ def ready(self, port: int, runtime_id: str, network_id: str) -> bool:
+ with self._lock:
+ return bool(
+ runtime_id
+ and self.runtime_id == runtime_id
+ and self.listener.getsockname()[1] == port
+ and (not network_id or self.network_id == network_id)
+ )
+
+ def identity_matches(
+ self, binding_version: int, runtime_id: str, network_id: str
+ ) -> bool:
+ with self._lock:
+ return (
+ self.binding_version == binding_version
+ and bool(runtime_id)
+ and self.runtime_id == runtime_id
+ and (not network_id or self.network_id == network_id)
+ )
+
+ def close(self) -> None:
+ self.server.shutdown()
+ self.server.server_close()
+ with self._lock:
+ tunnels = list(self._tunnels)
+ self._tunnels.clear()
+ for connection in tunnels:
+ with suppress(OSError):
+ connection.close()
+
+ def add_tunnel(self, connection: socket.socket) -> bool:
+ with self._lock:
+ if self.server.socket.fileno() < 0:
+ return False
+ self._tunnels.add(connection)
+ return True
+
+ def remove_tunnel(self, connection: socket.socket) -> None:
+ with self._lock:
+ self._tunnels.discard(connection)
+
+ def dial(self, target: str, timeout: float = 20.0) -> socket.socket:
+ exit = self.exit
+ if exit.protocol in ("http", "https"):
+ return _dial_http_proxy(exit, target, timeout)
+ if exit.protocol == "socks4":
+ return _dial_socks4(exit, target, timeout)
+ if exit.protocol == "socks5":
+ return _dial_socks5(exit, target, timeout)
+ raise OSError("unsupported proxy protocol")
+
+ def forward_http(
+ self,
+ client: socket.socket,
+ method: str,
+ target: str,
+ headers: list[tuple[str, str]],
+ body: bytes,
+ ) -> None:
+ parsed = urlsplit(target)
+ if not parsed.hostname:
+ raise OSError("proxy request target is invalid")
+ target_host = parsed.hostname
+ target_port = parsed.port or (443 if parsed.scheme == "https" else 80)
+ origin_target = parsed.path or "/"
+ if parsed.query:
+ origin_target += "?" + parsed.query
+ if self.exit.protocol in ("http", "https"):
+ upstream = _open_host(
+ self.exit.host, self.exit.port, 20.0, self.exit.protocol == "https"
+ )
+ request_target = target
+ request_headers = _forward_headers(headers, len(body))
+ if self.exit.username:
+ token = base64.b64encode(
+ f"{self.exit.username}:{self.exit.credential}".encode()
+ ).decode()
+ request_headers.append(("Proxy-Authorization", f"Basic {token}"))
+ else:
+ upstream = self.dial(f"{target_host}:{target_port}")
+ request_target = origin_target
+ request_headers = _forward_headers(headers, len(body))
+ try:
+ lines = [f"{method} {request_target} HTTP/1.1"]
+ lines.extend(f"{k}: {v}" for k, v in request_headers)
+ lines.append("Connection: close")
+ upstream.sendall(("\r\n".join(lines) + "\r\n\r\n").encode() + body)
+ _copy_until_close(upstream, client)
+ finally:
+ upstream.close()
+
+ def tunnel(self, client: socket.socket, target: str) -> None:
+ upstream = self.dial(target)
+ try:
+ client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
+ if not self.add_tunnel(client):
+ return
+ _relay(client, upstream)
+ finally:
+ self.remove_tunnel(client)
+ with suppress(OSError):
+ upstream.close()
+
+
+class _ProxyHandler(socketserver.BaseRequestHandler):
+ proxy: MemoryProxy
+
+ def handle(self) -> None:
+ client = self.request
+ client.settimeout(60.0)
+ try:
+ head, body = _read_request(client)
+ method, target, headers = _parse_request(head)
+ if method.upper() == "CONNECT":
+ self.proxy.tunnel(client, target)
+ else:
+ self.proxy.forward_http(client, method, target, headers, body)
+ except (OSError, ValueError):
+ with suppress(OSError):
+ client.sendall(
+ b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
+ )
+
+
+def _read_request(connection: socket.socket) -> tuple[bytes, bytes]:
+ data = bytearray()
+ while b"\r\n\r\n" not in data:
+ chunk = connection.recv(65536)
+ if not chunk:
+ raise OSError("proxy request closed")
+ data.extend(chunk)
+ if len(data) > 256 * 1024:
+ raise OSError("proxy headers too large")
+ split = data.index(b"\r\n\r\n") + 4
+ head, initial_body = bytes(data[:split]), bytearray(data[split:])
+ fields = _header_fields(head)
+ lengths = fields.get("content-length", [])
+ transfer_encoding = fields.get("transfer-encoding", [])
+ if lengths and transfer_encoding:
+ raise OSError("proxy request has both content length and transfer encoding")
+ if transfer_encoding:
+ if (
+ len(transfer_encoding) != 1
+ or transfer_encoding[0].lower().strip() != "chunked"
+ ):
+ raise OSError("unsupported proxy transfer encoding")
+ return head, _read_chunked_body(connection, initial_body)
+ content_length = 0
+ if lengths:
+ if len(lengths) != 1:
+ raise OSError("proxy content length is ambiguous")
+ try:
+ content_length = int(lengths[0])
+ except ValueError as exc:
+ raise OSError("invalid proxy content length") from exc
+ if content_length < 0 or content_length > 16 * 1024 * 1024:
+ raise OSError("proxy body too large")
+ while len(initial_body) < content_length:
+ chunk = connection.recv(min(65536, content_length - len(initial_body)))
+ if not chunk:
+ raise OSError("proxy body closed")
+ initial_body.extend(chunk)
+ return head, bytes(initial_body[:content_length])
+
+
+def _header_fields(head: bytes) -> dict[str, list[str]]:
+ fields: dict[str, list[str]] = {}
+ for line in head.decode("iso-8859-1").split("\r\n")[1:]:
+ if not line:
+ continue
+ if ":" not in line:
+ raise OSError("invalid proxy header")
+ key, value = line.split(":", 1)
+ fields.setdefault(key.strip().lower(), []).append(value.strip())
+ return fields
+
+
+def _read_line(connection: socket.socket, buffer: bytearray, limit: int) -> bytes:
+ while b"\r\n" not in buffer:
+ chunk = connection.recv(65536)
+ if not chunk:
+ raise OSError("proxy chunked body closed")
+ buffer.extend(chunk)
+ if len(buffer) > limit:
+ raise OSError("proxy chunk line too large")
+ end = buffer.index(b"\r\n")
+ line = bytes(buffer[:end])
+ del buffer[: end + 2]
+ return line
+
+
+def _read_chunked_body(connection: socket.socket, buffer: bytearray) -> bytes:
+ body = bytearray()
+ while True:
+ line = _read_line(connection, buffer, 8192)
+ size_text = line.split(b";", 1)[0].strip()
+ try:
+ size = int(size_text, 16)
+ except ValueError as exc:
+ raise OSError("invalid proxy chunk size") from exc
+ if size < 0:
+ raise OSError("invalid proxy chunk size")
+ if size == 0:
+ while True:
+ trailer = _read_line(connection, buffer, 256 * 1024)
+ if not trailer:
+ return bytes(body)
+ if b":" not in trailer:
+ raise OSError("invalid proxy trailer")
+ if len(body) + size > 16 * 1024 * 1024:
+ raise OSError("proxy body too large")
+ while len(buffer) < size + 2:
+ chunk = connection.recv(65536)
+ if not chunk:
+ raise OSError("proxy chunked body closed")
+ buffer.extend(chunk)
+ body.extend(buffer[:size])
+ if buffer[size : size + 2] != b"\r\n":
+ raise OSError("invalid proxy chunk terminator")
+ del buffer[: size + 2]
+
+
+def _forward_headers(
+ headers: list[tuple[str, str]], body_length: int
+) -> list[tuple[str, str]]:
+ return [
+ (k, v)
+ for k, v in headers
+ if k.lower()
+ not in (
+ "proxy-authorization",
+ "connection",
+ "transfer-encoding",
+ "content-length",
+ )
+ ] + [("Content-Length", str(body_length))]
+
+
+def _parse_request(head: bytes) -> tuple[str, str, list[tuple[str, str]]]:
+ lines = head.decode("iso-8859-1").split("\r\n")
+ method, target, version = lines[0].split(" ", 2)
+ if version not in ("HTTP/1.0", "HTTP/1.1") or not target:
+ raise ValueError("invalid proxy request")
+ headers: list[tuple[str, str]] = []
+ for line in lines[1:]:
+ if not line:
+ continue
+ if ":" not in line:
+ raise ValueError("invalid proxy header")
+ key, value = line.split(":", 1)
+ headers.append((key.strip(), value.strip()))
+ if method.upper() == "CONNECT":
+ if ":" not in target:
+ raise ValueError("CONNECT target missing port")
+ return method, target, headers
+ return method, target, headers
+
+
+def _open_host(
+ host: str, port: int, timeout: float, tls: bool = False
+) -> socket.socket:
+ connection = socket.create_connection((host, port), timeout=timeout)
+ if tls:
+ context = ssl.create_default_context()
+ connection = context.wrap_socket(connection, server_hostname=host)
+ return connection
+
+
+def _dial_http_proxy(exit: ProxyExit, target: str, timeout: float) -> socket.socket:
+ connection = _open_host(exit.host, exit.port, timeout, exit.protocol == "https")
+ try:
+ lines = [f"CONNECT {target} HTTP/1.1", f"Host: {target}"]
+ if exit.username:
+ token = base64.b64encode(
+ f"{exit.username}:{exit.credential}".encode()
+ ).decode()
+ lines.append(f"Proxy-Authorization: Basic {token}")
+ connection.sendall(("\r\n".join(lines) + "\r\n\r\n").encode())
+ status, buffered = _read_connect_response(connection)
+ if status != 200:
+ raise OSError(f"upstream proxy returned {status}")
+ result = cast(
+ socket.socket,
+ _BufferedSocket(connection, buffered) if buffered else connection,
+ )
+ result.settimeout(None)
+ return result
+ except Exception:
+ connection.close()
+ raise
+
+
+def _dial_socks4(exit: ProxyExit, target: str, timeout: float) -> socket.socket:
+ try:
+ host, port_text = target.rsplit(":", 1)
+ port = int(port_text)
+ except (ValueError, IndexError) as exc:
+ raise OSError("invalid SOCKS4 target") from exc
+ if not 1 <= port <= 65535:
+ raise OSError("invalid SOCKS4 target port")
+ connection = _open_host(exit.host, exit.port, timeout)
+ try:
+ ip = socket.inet_aton(host) if _is_ipv4(host) else b"\x00\x00\x00\x01"
+ payload = (
+ b"\x04\x01"
+ + port.to_bytes(2, "big")
+ + ip
+ + exit.username.encode()
+ + b"\x00"
+ )
+ if ip == b"\x00\x00\x00\x01":
+ payload += host.encode() + b"\x00"
+ connection.sendall(payload)
+ response = _recv_exact(connection, 8)
+ if response[1] != 90:
+ raise OSError("SOCKS4 proxy rejected connection")
+ connection.settimeout(None)
+ return connection
+ except Exception:
+ connection.close()
+ raise
+
+
+def _dial_socks5(exit: ProxyExit, target: str, timeout: float) -> socket.socket:
+ try:
+ host, port_text = target.rsplit(":", 1)
+ port = int(port_text)
+ except (ValueError, IndexError) as exc:
+ raise OSError("invalid SOCKS5 target") from exc
+ if not 1 <= port <= 65535:
+ raise OSError("invalid SOCKS5 target port")
+ connection = _open_host(exit.host, exit.port, timeout)
+ try:
+ methods = b"\x05\x01\x02" if exit.username else b"\x05\x01\x00"
+ connection.sendall(methods)
+ version, selected = _recv_exact(connection, 2)
+ if version != 5 or selected == 255:
+ raise OSError("SOCKS5 authentication method rejected")
+ if selected == 2:
+ user, credential = exit.username.encode(), exit.credential.encode()
+ if len(user) > 255 or len(credential) > 255:
+ raise OSError("SOCKS5 credentials too long")
+ connection.sendall(
+ b"\x01"
+ + bytes([len(user)])
+ + user
+ + bytes([len(credential)])
+ + credential
+ )
+ if _recv_exact(connection, 2)[1] != 0:
+ raise OSError("SOCKS5 authentication rejected")
+ elif exit.username:
+ raise OSError("SOCKS5 proxy skipped required authentication")
+ if _is_ipv4(host):
+ address = b"\x01" + socket.inet_aton(host)
+ else:
+ try:
+ address = b"\x04" + socket.inet_pton(socket.AF_INET6, host)
+ except OSError:
+ encoded = host.encode()
+ if len(encoded) > 255:
+ raise OSError("SOCKS5 target too long") from None
+ address = b"\x03" + bytes([len(encoded)]) + encoded
+ connection.sendall(b"\x05\x01\x00" + address + port.to_bytes(2, "big"))
+ header = _recv_exact(connection, 4)
+ if header[0] != 5 or header[1] != 0:
+ raise OSError("SOCKS5 proxy rejected connection")
+ if header[3] == 1:
+ length = 4
+ elif header[3] == 3:
+ length = _recv_exact(connection, 1)[0]
+ elif header[3] == 4:
+ length = 16
+ else:
+ raise OSError("invalid SOCKS5 response")
+ _recv_exact(connection, length + 2)
+ connection.settimeout(None)
+ return connection
+ except Exception:
+ connection.close()
+ raise
+
+
+def _read_status(connection: socket.socket) -> int:
+ return _read_connect_response(connection)[0]
+
+
+def _read_connect_response(connection: socket.socket) -> tuple[int, bytes]:
+ # Consume the complete response header. A proxy may return the first
+ # tunnel bytes in the same read, so retain bytes after the header block.
+ data = bytearray()
+ while b"\r\n\r\n" not in data:
+ chunk = connection.recv(65536)
+ if not chunk:
+ raise OSError("proxy response closed before headers")
+ data.extend(chunk)
+ if len(data) > 256 * 1024:
+ raise OSError("proxy response headers too large")
+ split = data.index(b"\r\n\r\n") + 4
+ header = bytes(data[:split])
+ lines = header.decode("iso-8859-1").split("\r\n")
+ parts = lines[0].split(" ", 2)
+ if len(parts) < 2 or not parts[0].startswith("HTTP/"):
+ raise OSError("invalid proxy response status")
+ try:
+ status = int(parts[1])
+ except ValueError as exc:
+ raise OSError("invalid proxy response status") from exc
+ for line in lines[1:-2]:
+ if line and ":" not in line:
+ raise OSError("invalid proxy response header")
+ return status, bytes(data[split:])
+
+
+class _BufferedSocket:
+ def __init__(self, connection: socket.socket, buffered: bytes) -> None:
+ self.connection = connection
+ self.buffered = bytearray(buffered)
+
+ def recv(self, size: int, flags: int = 0) -> bytes:
+ if self.buffered:
+ result = bytes(self.buffered[:size])
+ del self.buffered[:size]
+ return result
+ return (
+ self.connection.recv(size, flags) if flags else self.connection.recv(size)
+ )
+
+ def sendall(self, data: bytes) -> None:
+ self.connection.sendall(data)
+
+ def send(self, data: bytes, flags: int = 0) -> int:
+ return (
+ self.connection.send(data, flags) if flags else self.connection.send(data)
+ )
+
+ def settimeout(self, value: float | None) -> None:
+ self.connection.settimeout(value)
+
+ def close(self) -> None:
+ self.connection.close()
+
+
+def _recv_exact(connection: socket.socket, size: int) -> bytes:
+ result = bytearray()
+ while len(result) < size:
+ chunk = connection.recv(size - len(result))
+ if not chunk:
+ raise OSError("proxy connection closed")
+ result.extend(chunk)
+ return bytes(result)
+
+
+def _copy_until_close(source: socket.socket, target: socket.socket) -> None:
+ while True:
+ data = source.recv(65536)
+ if not data:
+ return
+ target.sendall(data)
+
+
+def _relay(left: socket.socket, right: socket.socket) -> None:
+ sockets = [left, right]
+ while sockets:
+ readable, _, _ = select.select(sockets, [], [], 60.0)
+ if not readable:
+ return
+ for source in readable:
+ destination = right if source is left else left
+ data = source.recv(65536)
+ if not data:
+ return
+ destination.sendall(data)
+
+
+def _is_ipv4(host: str) -> bool:
+ try:
+ socket.inet_aton(host)
+ return True
+ except OSError:
+ return False
diff --git a/cmd/docker_gateway/test_gateway.py b/cmd/docker_gateway/test_gateway.py
new file mode 100644
index 0000000..d342cb9
--- /dev/null
+++ b/cmd/docker_gateway/test_gateway.py
@@ -0,0 +1,2076 @@
+from __future__ import annotations
+
+import io
+import json
+import socket
+import threading
+import unittest
+from collections import deque
+from contextlib import contextmanager
+from importlib import import_module
+from typing import Any, cast
+from unittest.mock import Mock, patch
+
+import websocket
+
+from .docker_client import (
+ BINDING_VERSION_LABEL,
+ BROWSER_NETWORK_ROLE,
+ GATEWAY_MEMBER_LABEL,
+ MANAGED_LABEL,
+ NETWORK_EXIT_LABEL,
+ NETWORK_ID_LABEL,
+ NETWORK_ROLE_LABEL,
+ RESERVATION_LABEL,
+ RESERVATION_OWNER_LABEL,
+ RUNTIME_ID_LABEL,
+ AliasReservationManager,
+ DockerClient,
+ DockerError,
+ DockerResponse,
+ GenerationConflict,
+ NetworkSetupError,
+ TenantNetworkGeneration,
+ UnmanagedContainer,
+ split_image_ref,
+ tenant_network_name,
+)
+from .douyin import (
+ BrowserResponse,
+ CDPConnection,
+ DouyinBrowser,
+ DouyinError,
+ DouyinSubscription,
+ SubscriptionManager,
+ ack_expression,
+ action_expression,
+ detect_challenge,
+ im_expression,
+ install_expression,
+ is_douyin_url,
+ normalize_notice,
+ notice_ids,
+ wait_expression,
+)
+from .proxy import (
+ MemoryProxy,
+ ProxyExit,
+ ProxyRegistry,
+ _copy_until_close,
+ _dial_http_proxy,
+ _dial_socks4,
+ _dial_socks5,
+ _is_ipv4,
+ _parse_request,
+ _read_request,
+ _read_status,
+ _recv_exact,
+)
+
+gateway_module = import_module(f"{__package__}.gateway")
+douyin_module = import_module(f"{__package__}.douyin")
+proxy_module = import_module(f"{__package__}.proxy")
+docker_client_module = import_module(f"{__package__}.docker_client")
+Gateway = gateway_module.Gateway
+RequestError = gateway_module.RequestError
+browser_tmpfs = gateway_module.browser_tmpfs
+decode_generation = gateway_module.decode_generation
+json_bytes = gateway_module.json_bytes
+load_config = gateway_module.load_config
+split_listen_address = gateway_module.split_listen_address
+valid_douyin_url = gateway_module.valid_douyin_url
+validate_create = gateway_module.validate_create
+parse_proxy_exit = gateway_module.parse_proxy_exit
+validate_proxy_exit = gateway_module.validate_proxy_exit
+validate_proxy_restore = gateway_module.validate_proxy_restore
+valid_cookies = gateway_module.valid_cookies
+valid_douyin_generation = gateway_module.valid_douyin_generation
+valid_account_key_query = gateway_module.valid_account_key_query
+numeric_cursor = gateway_module.numeric_cursor
+proxy_port = gateway_module.proxy_port
+has_control = gateway_module.has_control
+
+
+class FakeSocket:
+ def __init__(self, messages: list[object]) -> None:
+ self.messages = list(messages)
+ self.sent: list[bytes] = []
+ self.timeout = 0.0
+
+ def send(self, data: bytes) -> None:
+ self.sent.append(data)
+
+ def recv(self) -> str:
+ if not self.messages:
+ raise TimeoutError("no more messages")
+ return json.dumps(self.messages.pop(0))
+
+ def settimeout(self, value: float) -> None:
+ self.timeout = value
+
+ def close(self) -> None:
+ return None
+
+
+class FakeConnection:
+ def __init__(self, values: list[object]) -> None:
+ self.values = list(values)
+
+ def evaluate(self, expression: str) -> object:
+ del expression
+ if not self.values:
+ raise DouyinError("no fake response")
+ return self.values.pop(0)
+
+
+class FakeDocker:
+ def __init__(self, containers: list[dict] | None = None) -> None:
+ self.containers = containers or []
+
+ def container_network_address(self, container_id: str, network_id: str) -> str:
+ del container_id, network_id
+ return "192.0.2.10"
+
+ def request(
+ self, method: str, path: str, body: object | None = None
+ ) -> DockerResponse:
+ del body
+ if method == "GET" and path.startswith("/containers/json"):
+ return DockerResponse(200, "OK", json.dumps(self.containers).encode())
+ return DockerResponse(404, "Not Found", b"")
+
+
+class GatewayValidationTests(unittest.TestCase):
+ def test_create_and_generation_validation(self) -> None:
+ value = {
+ "alias": "safe-account",
+ "name": "Safe account",
+ "image": "creatorhub/browser:latest",
+ "cmd": ["about:blank"],
+ "volume": "creatorhub-safe-account",
+ "binding_version": 1,
+ "stopped": True,
+ }
+ validate_create(value)
+ self.assertEqual(value["network_exit"], ProxyExit("", "", 0))
+ self.assertEqual(value["network_exit_id"], "")
+ self.assertEqual(
+ decode_generation(
+ {
+ "binding_version": 1,
+ "runtime_id": "runtime-not-found",
+ "network_id": "network-id",
+ },
+ False,
+ False,
+ )["runtime_id"],
+ "runtime-not-found",
+ )
+ with self.assertRaises(RequestError):
+ validate_create(
+ {**value, "cmd": ["--proxy-server=http://x", "about:blank"]}
+ )
+ with self.assertRaises(RequestError):
+ decode_generation(
+ {"binding_version": 1, "runtime_id": 4, "network_id": "n"}, True, True
+ )
+
+ def test_config_and_urls(self) -> None:
+ self.assertEqual(split_listen_address(":8081"), ("", 8081))
+ self.assertEqual(split_listen_address("[::1]:8081"), ("::1", 8081))
+ with self.assertRaises(ValueError):
+ split_listen_address("missing-port")
+ config = load_config(
+ {
+ "LISTEN_ADDR": ":8081",
+ "DOCKER_SOCKET": "/var/run/docker.sock",
+ "BROWSER_NETWORK": "creatorhub_browser",
+ "GATEWAY_TOKEN": "0123456789abcdef",
+ }
+ )
+ self.assertEqual(config["listen"], ("", 8081))
+ tmp_mount = next(path for path in browser_tmpfs() if path.endswith("tmp"))
+ self.assertTrue(browser_tmpfs()[tmp_mount].startswith("rw,"))
+ self.assertTrue(
+ valid_douyin_url(
+ "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383&device_platform=webapp"
+ )
+ )
+ self.assertFalse(valid_douyin_url("https://www.douyin.com.evil/"))
+ self.assertTrue(is_douyin_url("https://www.douyin.com/video/123"))
+ self.assertFalse(is_douyin_url("https://www.douyin.com.evil/video/123"))
+
+ def test_http_routes_and_body_validation(self) -> None:
+ handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
+ gateway = Mock()
+ gateway.list_browsers.return_value = []
+ gateway.douyin_identity.return_value = {"uid": "123"}
+ gateway.douyin_action.return_value = {"status": "succeeded"}
+ gateway.poll_douyin_events.return_value = []
+ server = Mock()
+ server.gateway = gateway
+ cast(Any, handler).server = server
+ cast(Any, handler).server_as_gateway = lambda: server
+ self.assertEqual(handler._route("GET", "/v1/browsers", {}, {}), [])
+ self.assertEqual(
+ handler._route("POST", "/v1/browsers", {}, {}),
+ (201, gateway.create.return_value),
+ )
+ handler._route(
+ "DELETE",
+ "/v1/browsers/safe",
+ {},
+ {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""},
+ )
+ gateway.remove.assert_called_once_with(
+ "safe", {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""}
+ )
+ handler._route("POST", "/v1/browsers/safe/start", {}, {})
+ handler._route("POST", "/v1/browsers/safe/stop", {}, {})
+ handler._route("POST", "/v1/browsers/safe/proxy", {}, {})
+ handler._route("POST", "/v1/browsers/safe/douyin/cookies", {}, {})
+ handler._route("POST", "/v1/browsers/safe/douyin/get", {}, {})
+ handler._route("POST", "/v1/browsers/safe/douyin/identity", {}, {})
+ handler._route("POST", "/v1/browsers/safe/douyin/action", {}, {})
+ self.assertEqual(
+ handler._route("GET", "/v1/browsers/safe/douyin/events", {}, {}), []
+ )
+ handler._route("POST", "/v1/browsers/safe/douyin/events", {}, {})
+ handler._route("DELETE", "/v1/browsers/safe/douyin/events", {}, {})
+ with self.assertRaises(RequestError):
+ handler._route("GET", "/v1/unknown", {}, {})
+ cast(Any, handler).headers = {"Content-Length": "7"}
+ cast(Any, handler).rfile = io.BytesIO(b'{"x":1}')
+ self.assertEqual(handler._body(), {"x": 1})
+ cast(Any, handler).headers = {}
+ with self.assertRaises(RequestError):
+ handler._body()
+ test_token = "x" * 16
+ cast(Any, handler).headers = {"Authorization": f"Bearer {test_token}"}
+ server.gateway.token = test_token
+ self.assertTrue(handler._authorized())
+ handler._respond = Mock()
+ handler._handle_exception("/v1", RequestError("bad", 400))
+ handler._handle_exception("/v1", ValueError("bad"))
+ self.assertEqual(handler._respond.call_count, 2)
+
+ def test_get_event_route_reads_generation_body(self) -> None:
+ handler = gateway_module.GatewayHandler.__new__(gateway_module.GatewayHandler)
+ cast(Any, handler).path = "/v1/browsers/safe/douyin/events?wait=1"
+ cast(Any, handler).headers = {"Content-Length": "67"}
+ cast(Any, handler).rfile = io.BytesIO(
+ b'{"binding_version":1,"runtime_id":"runtime","network_id":"network"}'
+ )
+ cast(Any, handler)._authorized = lambda: True
+ cast(Any, handler)._route = Mock(return_value=[])
+ cast(Any, handler)._respond = Mock()
+ handler._dispatch("GET")
+ cast(Any, handler)._route.assert_called_once_with(
+ "GET",
+ "/v1/browsers/safe/douyin/events",
+ {"wait": ["1"]},
+ {"binding_version": 1, "runtime_id": "runtime", "network_id": "network"},
+ )
+
+ def test_validation_boundaries(self) -> None:
+ self.assertEqual(proxy_port("http://docker-gateway:1234"), 1234)
+ self.assertTrue(has_control("bad\nvalue"))
+ exit_value = parse_proxy_exit(
+ {"protocol": "http", "host": "proxy", "port": 8080}
+ )
+ validate_proxy_exit(exit_value)
+ with self.assertRaises(RequestError):
+ validate_proxy_exit(
+ parse_proxy_exit({"protocol": "ftp", "host": "proxy", "port": 21})
+ )
+ with self.assertRaises(RequestError):
+ validate_proxy_exit(ProxyExit("http", "proxy", 0))
+ cookie = {"name": "sessionid", "value": "v", "domain": ".douyin.com"}
+ self.assertTrue(valid_cookies([cookie]))
+ self.assertFalse(valid_cookies([{**cookie, "domain": "evil.example"}]))
+ self.assertFalse(valid_cookies([{**cookie, "same_site": "bad"}]))
+ generation = {
+ "binding_version": 1,
+ "runtime_id": "a" * 64,
+ "network_id": "b" * 64,
+ "network_exit_id": "exit",
+ }
+ self.assertTrue(valid_douyin_generation(generation))
+ self.assertFalse(
+ valid_douyin_generation({**generation, "binding_version": True})
+ )
+ self.assertTrue(valid_account_key_query({"account": ["account"]}, "account"))
+ self.assertFalse(valid_account_key_query({"account": ["bad key"]}, "account"))
+ self.assertTrue(numeric_cursor(["0"]))
+ self.assertFalse(numeric_cursor(["-1"]))
+ restore = {
+ "binding_version": 1,
+ "runtime_id": "a" * 64,
+ "network_id": "b" * 64,
+ "network_exit_id": "exit",
+ "network_exit": {"protocol": "http", "host": "proxy", "port": 8080},
+ }
+ validate_proxy_restore(restore, "safe")
+ with self.assertRaises(RequestError):
+ validate_proxy_restore({**restore, "network_exit_id": ""}, "safe")
+ with self.assertRaises(RequestError):
+ validate_proxy_restore({"network_exit_id": ""}, "safe")
+ with self.assertRaises(RequestError):
+ validate_create({"alias": "safe", "unknown": True})
+ with self.assertRaises(RequestError):
+ validate_create(
+ {
+ "alias": "safe",
+ "name": "Safe",
+ "image": "bad image",
+ "volume": "safe",
+ "binding_version": 1,
+ "cmd": ["about:blank"],
+ }
+ )
+ with self.assertRaises(ValueError):
+ load_config({"GATEWAY_TOKEN": "short"})
+ self.assertFalse(valid_douyin_url("http://www.douyin.com/video/1"))
+ self.assertFalse(valid_douyin_url("https://www.douyin.com/unknown"))
+ self.assertFalse(valid_cookies("not-a-list"))
+ self.assertFalse(
+ valid_cookies(
+ [
+ {
+ "name": "x",
+ "value": "v",
+ "domain": ".douyin.com",
+ "path": "relative",
+ }
+ ]
+ )
+ )
+ with self.assertRaises(RequestError):
+ decode_generation(
+ {"binding_version": 1, "runtime_id": "a" * 64}, True, True
+ )
+
+ def test_list_keeps_other_environments_when_one_network_is_missing(self) -> None:
+ class PartialDocker(FakeDocker):
+ def container_network_address(
+ self, container_id: str, network_id: str
+ ) -> str:
+ if container_id == "broken":
+ raise GenerationConflict("network attachment disappeared")
+ return "192.0.2.10"
+
+ containers = [
+ {
+ "Id": "broken",
+ "Labels": {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: "broken-account",
+ BINDING_VERSION_LABEL: "1",
+ NETWORK_EXIT_LABEL: "",
+ NETWORK_ID_LABEL: "b" * 64,
+ },
+ "State": "running",
+ "Status": "Up",
+ },
+ {
+ "Id": "healthy",
+ "Labels": {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: "healthy-account",
+ BINDING_VERSION_LABEL: "1",
+ NETWORK_EXIT_LABEL: "",
+ NETWORK_ID_LABEL: "c" * 64,
+ },
+ "State": "running",
+ "Status": "Up",
+ },
+ ]
+ result = Gateway(
+ cast(DockerClient, PartialDocker(containers)),
+ "creatorhub_browser",
+ "0123456789abcdef",
+ "gateway",
+ ).list_browsers()
+ self.assertEqual(
+ [item["alias"] for item in result], ["broken-account", "healthy-account"]
+ )
+ self.assertEqual(result[0]["endpoint"], "")
+ self.assertIn("error", result[0])
+ self.assertEqual(result[1]["endpoint"], "http://192.0.2.10:9222")
+
+ def test_container_network_address(self) -> None:
+ docker = DockerClient("/var/run/docker.sock")
+ cast(Any, docker).request = lambda method, path: DockerResponse(
+ 200,
+ "OK",
+ json.dumps(
+ {
+ "NetworkSettings": {
+ "Networks": {
+ "tenant": {
+ "NetworkID": "network",
+ "IPAddress": "192.0.2.20",
+ }
+ }
+ }
+ }
+ ).encode(),
+ )
+ self.assertEqual(
+ docker.container_network_address("container", "network"), "192.0.2.20"
+ )
+ cast(Any, docker).request = lambda method, path: DockerResponse(
+ 200, "OK", b'{"NetworkSettings":{"Networks":{}}}'
+ )
+ with self.assertRaises(GenerationConflict):
+ docker.container_network_address("container", "network")
+
+ def test_list_browsers_and_json(self) -> None:
+ docker = FakeDocker(
+ [
+ {
+ "Id": "container-id",
+ "Labels": {
+ "io.creatorhub.managed": "true",
+ "io.creatorhub.runtime-id": "safe-account",
+ "io.creatorhub.binding-version": "2",
+ "io.creatorhub.network-exit-id": "",
+ "io.creatorhub.network-id": "b" * 64,
+ },
+ "State": "running",
+ "Status": "Up 1 second",
+ }
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker),
+ "creatorhub_browser",
+ "0123456789abcdef",
+ "gateway",
+ )
+ self.assertEqual(gateway.list_browsers()[0]["id"], "container-id")
+ self.assertEqual(
+ gateway.list_browsers()[0]["endpoint"], "http://192.0.2.10:9222"
+ )
+ self.assertEqual(
+ json_bytes({"text": "中文"}), b'{"text":"\xe4\xb8\xad\xe6\x96\x87"}'
+ )
+
+
+class CDPTests(unittest.TestCase):
+ def test_connect_response_headers_are_drained(self) -> None:
+ connection = ChunkSocket(
+ [b"HTTP/1.1 200 Connection Established\r\n", b"X-Proxy: value\r\n\r\nTLS"]
+ )
+ self.assertEqual(_read_status(cast(socket.socket, connection)), 200)
+
+ def test_command_queues_events_for_wait_event(self) -> None:
+ socket_ = FakeSocket(
+ [
+ {
+ "method": "Page.frameNavigated",
+ "params": {"frame": {"id": "frame-1"}},
+ },
+ {"id": 1, "result": {}},
+ ]
+ )
+ connection = CDPConnection(cast(websocket.WebSocket, socket_))
+ self.assertEqual(connection.command("Page.enable"), {})
+ event = connection.wait_event(
+ "Page.frameNavigated", lambda params: params["frame"]["id"] == "frame-1"
+ )
+ self.assertEqual(event["method"], "Page.frameNavigated")
+ self.assertTrue(socket_.sent)
+
+ def test_eof_status_is_a_failure(self) -> None:
+ class Closed:
+ def recv(self, size: int) -> bytes:
+ del size
+ return b""
+
+ with self.assertRaises(OSError):
+ _read_status(cast(socket.socket, Closed()))
+
+ def test_expression_markers_and_challenge(self) -> None:
+ expression = im_expression({"text": "hello EXPECTED_UID_VALUE"}, "123")
+ self.assertIn("hello EXPECTED_UID_VALUE", expression)
+ self.assertIn(
+ "https://www.douyin.com", action_expression({"action": "like_work"})
+ )
+ self.assertEqual(detect_challenge(429, "captcha"), "")
+ self.assertEqual(detect_challenge(412, ""), "captcha")
+
+ def test_notification_details_normalize_safe_targets(self) -> None:
+ notice = {
+ "nid_str": "9007199254740993",
+ "user_id": "99491952055",
+ "create_time": 1700000000,
+ "aweme_id": "123456",
+ "comment": {
+ "from_user": [{"uid": "7654321"}],
+ "comment": {"cid_str": "987654", "user": {"uid": "7654321"}},
+ },
+ }
+ self.assertEqual(
+ normalize_notice(notice),
+ {
+ "event_key": "9007199254740993",
+ "event_type": "comment",
+ "interactor_uid": "7654321",
+ "comment_id": "987654",
+ "work_id": "123456",
+ "platform_event_at": "2023-11-14T22:13:20+00:00",
+ },
+ )
+ favorite = {"nid_str": "7", "favorite": {"from_user": [{"uid": "1"}]}}
+ self.assertIsNone(normalize_notice(favorite))
+
+ def test_notification_detail_retries_partial_response(self) -> None:
+ details = [
+ {"nid_str": "1", "user_id": "123"},
+ {"nid_str": "2", "user_id": "123"},
+ ]
+ payload = json.dumps({"status_code": 0, "notice_list_v2": details})
+ subscription = DouyinSubscription.__new__(DouyinSubscription)
+ subscription.uid = "123"
+ subscription.connection = cast(
+ CDPConnection,
+ FakeConnection(
+ [
+ json.dumps(
+ {
+ "status": 200,
+ "body": json.dumps(
+ {"status_code": 0, "notice_list_v2": details[:1]}
+ ),
+ }
+ ),
+ json.dumps({"status": 200, "body": payload}),
+ ]
+ ),
+ )
+ subscription._get_connection = lambda: subscription.connection
+ with patch.object(douyin_module.time, "sleep"):
+ self.assertEqual(len(subscription._details(["1", "2"])), 2)
+
+ def test_notification_detail_rejects_unexpected_id(self) -> None:
+ subscription = DouyinSubscription.__new__(DouyinSubscription)
+ subscription.uid = "123"
+ subscription.connection = cast(
+ CDPConnection,
+ FakeConnection(
+ [
+ json.dumps(
+ {
+ "status": 200,
+ "body": json.dumps(
+ {
+ "status_code": 0,
+ "notice_list_v2": [
+ {"nid_str": "9", "user_id": "123"}
+ ],
+ }
+ ),
+ }
+ )
+ ]
+ ),
+ )
+ subscription._get_connection = lambda: subscription.connection
+ with self.assertRaises(DouyinError):
+ subscription._details(["1"])
+
+
+class BrowserCDP:
+ def __init__(self, values: list[object]) -> None:
+ self.values = list(values)
+ self.commands: list[tuple[str, dict | None]] = []
+ self.events: list[str] = []
+ self.closed = False
+
+ def command(self, method: str, params: dict | None = None) -> dict:
+ self.commands.append((method, params))
+ if method == "Page.navigate":
+ return {"frameId": "frame-1"}
+ return {}
+
+ def wait_event(self, method: str, predicate: object, timeout: float = 15.0) -> dict:
+ del predicate, timeout
+ self.events.append(method)
+ return {"method": method}
+
+ def evaluate(self, expression: str) -> object:
+ del expression
+ if not self.values:
+ raise DouyinError("fake CDP value exhausted")
+ return self.values.pop(0)
+
+ def close(self) -> None:
+ self.closed = True
+
+
+class FakeHTTPResponse:
+ def __init__(self, status: int, body: bytes) -> None:
+ self.status = status
+ self.body = body
+
+ def read(self, limit: int = -1) -> bytes:
+ del limit
+ return self.body
+
+
+class FakeHTTPConnection:
+ def __init__(self, response: FakeHTTPResponse) -> None:
+ self.response = response
+ self.requested: list[tuple[str, str]] = []
+ self.closed = False
+
+ def request(
+ self,
+ method: str,
+ path: str,
+ body: bytes | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ del body, headers
+ self.requested.append((method, path))
+
+ def getresponse(self) -> FakeHTTPResponse:
+ return self.response
+
+ def close(self) -> None:
+ self.closed = True
+
+
+class BrowserTests(unittest.TestCase):
+ def _with_connection(self, browser: DouyinBrowser, connection: BrowserCDP) -> None:
+ @contextmanager
+ def bound(alias: str):
+ del alias
+ yield connection
+
+ cast(Any, browser).connection = bound
+
+ def test_cookie_navigation_and_fetch(self) -> None:
+ cdp = BrowserCDP(
+ [{"origin": "https://www.douyin.com", "state": "complete"}, "{}"]
+ )
+ browser = DouyinBrowser()
+ self._with_connection(browser, cdp)
+ browser.set_cookies(
+ "safe", [{"name": "sessionid", "value": "v", "domain": ".douyin.com"}]
+ )
+ self.assertIn("Page.navigate", [method for method, _ in cdp.commands])
+ set_command = next(
+ params for method, params in cdp.commands if method == "Network.setCookies"
+ )
+ assert set_command is not None
+ self.assertEqual(set_command["cookies"][0]["domain"], ".douyin.com")
+
+ cdp = BrowserCDP(
+ [
+ "https://www.douyin.com",
+ {"status": 200, "body": "{}", "too_large": False},
+ ]
+ )
+ self._with_connection(browser, cdp)
+ response = browser.get(
+ "safe", "https://www.douyin.com/aweme/v1/web/user/profile/self/?aid=6383"
+ )
+ self.assertEqual(response.status, 200)
+
+ def test_connect_identity_and_actions(self) -> None:
+ target = [
+ {
+ "type": "page",
+ "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1",
+ }
+ ]
+ http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
+ with (
+ patch.object(
+ douyin_module.http.client, "HTTPConnection", return_value=http
+ ),
+ patch.object(
+ douyin_module.websocket,
+ "create_connection",
+ return_value=FakeSocket([]),
+ ),
+ ):
+ connection = DouyinBrowser(lambda alias: "http://127.0.0.1:9222")._connect(
+ "safe"
+ )
+ self.assertIsInstance(connection, CDPConnection)
+ self.assertEqual(http.requested[0], ("GET", "/json/list"))
+ expression = install_expression("safe", "123")
+ self.assertIn("safe", expression)
+ self.assertIn("old?.dispose?.()", expression)
+ self.assertEqual(
+ notice_ids(
+ {
+ "service": 20313,
+ "payload": json.dumps(
+ {"notices": [{"notice_id_str": "1", "effect_groups": [960]}]}
+ ),
+ }
+ ),
+ ["1"],
+ )
+ self.assertEqual(
+ notice_ids(
+ {
+ "service": 20003,
+ "payload": json.dumps({"notice_type": 45, "notice_id_str": "2"}),
+ }
+ ),
+ ["2"],
+ )
+
+ browser = DouyinBrowser()
+ cast(Any, browser).identity = lambda alias, expected: {"uid": expected}
+ cast(Any, browser)._evaluate = lambda *args: {
+ "status": "succeeded",
+ "action": "followed",
+ }
+ preview = browser.action(
+ "safe", "123", "dm", "456", text="hello", confirm=False
+ )
+ self.assertEqual(preview["action"], "preview")
+ self.assertEqual(
+ browser.action("safe", "123", "like_work", work_id="789", confirm=False)[
+ "action"
+ ],
+ "preview",
+ )
+ with self.assertRaises(DouyinError):
+ browser.action("safe", "123", "dm", "456", text=" ", confirm=False)
+ with self.assertRaises(DouyinError):
+ browser.action("safe", "123", "follow", "bad", confirm=False)
+
+ def test_browser_queue_is_retained_until_ack(self) -> None:
+ wait = wait_expression("alpha")
+ self.assertIn("delivered", wait)
+ self.assertNotIn("splice(0)", wait)
+ ack = ack_expression("__creatorhub_notice_sub_alpha", ["browser-1"])
+ self.assertIn("browser-1", ack)
+ self.assertIn("__creatorhub_notice_sub_alpha", ack)
+
+ def test_subscription_receipts_are_replayed_until_ack(self) -> None:
+ subscription = DouyinSubscription.__new__(DouyinSubscription)
+ subscription.uid = "123"
+ subscription.queue = deque()
+ subscription.condition = threading.Condition()
+ subscription.stopped = threading.Event()
+ subscription._put({"kind": "notice", "notice": {"event_key": "1"}})
+ first = subscription.poll(10, 0)
+ second = subscription.poll(10, 0)
+ self.assertEqual(first, second)
+ self.assertTrue(first[0]["notice"]["gateway_received_at"].endswith("+00:00"))
+ subscription.ack([first[0]["delivery_id"]])
+ self.assertEqual(subscription.poll(10, 0), [])
+
+ def test_subscription_detail_failure_does_not_discard_siblings(self) -> None:
+ bad = json.dumps(
+ {
+ "status": 200,
+ "body": json.dumps(
+ {
+ "status_code": 0,
+ "notice_list_v2": [{"nid_str": "9", "user_id": "123"}],
+ }
+ ),
+ }
+ )
+ good = json.dumps(
+ {
+ "status": 200,
+ "body": json.dumps(
+ {
+ "status_code": 0,
+ "notice_list_v2": [
+ {
+ "nid_str": "2",
+ "user_id": "123",
+ "follow": {"from_user": [{"uid": "7"}]},
+ }
+ ],
+ }
+ ),
+ }
+ )
+ subscription = DouyinSubscription.__new__(DouyinSubscription)
+ subscription.uid = "123"
+ subscription.queue = deque()
+ subscription.condition = threading.Condition()
+ subscription.stopped = threading.Event()
+ subscription.connection = cast(CDPConnection, FakeConnection([bad, bad, good]))
+ subscription._get_connection = lambda: subscription.connection
+ subscription._handle(
+ {
+ "kind": "push",
+ "service": 20313,
+ "payload": json.dumps(
+ {
+ "notices": [
+ {"notice_id_str": "1", "effect_groups": [960]},
+ {"notice_id_str": "2", "effect_groups": [960]},
+ ]
+ }
+ ),
+ }
+ )
+ events = subscription.poll(10, 0)
+ self.assertEqual(events[0]["kind"], "error")
+ self.assertEqual(events[0]["event_key"], "1")
+ self.assertEqual(events[1]["notice"]["event_key"], "2")
+
+ def test_subscription_manager_and_queue(self) -> None:
+ event = {"kind": "open"}
+ subscription = DouyinSubscription.__new__(DouyinSubscription)
+ subscription.uid = "123"
+ subscription.queue = deque()
+ subscription.condition = __import__("threading").Condition()
+ subscription.stopped = __import__("threading").Event()
+ subscription._handle(event)
+ first = subscription.poll(10, 0)
+ self.assertEqual(first[0]["kind"], "open")
+ self.assertTrue(first[0]["delivery_id"])
+ subscription.queue = deque([{} for _ in range(1000)])
+ subscription._put({"kind": "new"})
+ self.assertEqual(subscription.queue[0]["kind"], "error")
+ subscription.stopped.set()
+ overflow = subscription.poll(10, 0)
+ self.assertEqual(overflow[0]["kind"], "error")
+ self.assertEqual(overflow[0]["reason"], "notification queue overflow")
+ self.assertTrue(overflow[0]["delivery_id"])
+ self.assertEqual(len(overflow), 10)
+ self.assertEqual(len(subscription.queue), 1000)
+ subscription.ack([overflow[0]["delivery_id"]])
+ self.assertEqual(len(subscription.queue), 999)
+ self.assertEqual(subscription.queue[0], {})
+
+ browser = DouyinBrowser()
+ fake = Mock()
+ fake.poll.return_value = [{"kind": "notice"}]
+ with patch.object(douyin_module, "DouyinSubscription", return_value=fake):
+ manager = SubscriptionManager(browser)
+ self.assertTrue(manager.start("safe", "123")["connected"])
+ self.assertEqual(manager.poll("safe", 1, 0), [{"kind": "notice"}])
+ manager.stop("safe")
+ manager.close()
+ with self.assertRaises(DouyinError):
+ manager.poll("safe", 1, 0)
+
+
+class ProxyTests(unittest.TestCase):
+ def test_chunked_request_body_is_decoded_and_forwarded_with_length(self) -> None:
+ request = ChunkSocket(
+ [
+ (
+ b"POST http://example.test/a HTTP/1.1\r\nHost: example.test\r\n"
+ b"Transfer-Encoding: chunked\r\n\r\n2\r\nab\r\n3;part=x\r\ncde\r\n0\r\nX-Trailer: yes\r\n\r\n"
+ )
+ ]
+ )
+ head, body = _read_request(cast(socket.socket, request))
+ self.assertEqual(body, b"abcde")
+ proxy = MemoryProxy.__new__(MemoryProxy)
+ proxy.exit = ProxyExit("socks5", "proxy", 1080)
+ upstream = ChunkSocket([b""])
+ proxy.dial = lambda target, timeout=20.0: cast(socket.socket, upstream)
+ with patch.object(proxy_module, "_copy_until_close"):
+ proxy.forward_http(
+ cast(socket.socket, ChunkSocket([])),
+ "POST",
+ "http://example.test/a",
+ _parse_request(head)[2],
+ body,
+ )
+ sent = upstream.sent[0].decode("iso-8859-1")
+ self.assertIn("Content-Length: 5", sent)
+ self.assertNotIn("Transfer-Encoding:", sent)
+
+ def test_registry_generation_and_shutdown(self) -> None:
+ registry = ProxyRegistry()
+ url, undo = registry.configure(
+ "safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network-1"
+ )
+ port = int(url.rsplit(":", 1)[1])
+ self.assertFalse(registry.ready("safe", port, "container-1", "network-1"))
+ self.assertTrue(registry.bind("safe", 1, url, "container-1", "network-1"))
+ self.assertTrue(registry.ready("safe", port, "container-1", "network-1"))
+ self.assertFalse(registry.remove("safe", 1, "container-2", "network-1"))
+ undo()
+ registry.close()
+
+ def test_memory_proxy_can_close(self) -> None:
+ proxy = MemoryProxy(
+ "safe", 1, "127.0.0.1", 0, ProxyExit("http", "127.0.0.1", 8080), "network"
+ )
+ self.assertGreater(proxy.listener.getsockname()[1], 0)
+ proxy.close()
+
+ def test_proxy_request_parser_and_copy_helpers(self) -> None:
+ request = ChunkSocket(
+ [
+ b"POST http://example.test/a HTTP/1.1\r\nContent-Length: 3\r\nHost: example.test\r\n\r\nabc"
+ ]
+ )
+ head, body = _read_request(cast(socket.socket, request))
+ self.assertEqual(body, b"abc")
+ method, target, headers = _parse_request(head)
+ self.assertEqual((method, target), ("POST", "http://example.test/a"))
+ self.assertEqual(headers[0], ("Content-Length", "3"))
+ source = ChunkSocket([b"one", b"", b"ignored"])
+ destination = ChunkSocket([])
+ _copy_until_close(cast(socket.socket, source), cast(socket.socket, destination))
+ self.assertEqual(b"".join(destination.sent), b"one")
+ self.assertEqual(
+ _recv_exact(cast(socket.socket, ChunkSocket([b"ab", b"cd"])), 4), b"abcd"
+ )
+ self.assertTrue(_is_ipv4("127.0.0.1"))
+ self.assertFalse(_is_ipv4("host.example"))
+ with self.assertRaises(ValueError):
+ _parse_request(b"BROKEN\r\n\r\n")
+
+ def test_connect_payload_in_same_read_is_preserved(self) -> None:
+ class GreedySocket(ChunkSocket):
+ def recv(self, size: int) -> bytes:
+ del size
+ return self.chunks.pop(0) if self.chunks else b""
+
+ upstream = GreedySocket([b"HTTP/1.1 200 OK\r\nX-Test: yes\r\n\r\nTLS"])
+ with patch.object(proxy_module, "_open_host", return_value=upstream):
+ result = _dial_http_proxy(
+ ProxyExit("http", "proxy", 8080), "target:443", 1.0
+ )
+ self.assertEqual(_recv_exact(cast(socket.socket, result), 3), b"TLS")
+ result.close()
+
+ def test_http_and_socks_handshakes(self) -> None:
+ http_socket = ChunkSocket([b"HTTP/1.1 200 Connection Established\r\n\r\n"])
+ with patch.object(proxy_module, "_open_host", return_value=http_socket):
+ result = _dial_http_proxy(
+ ProxyExit("http", "proxy", 8080, "u", "c"), "target:443", 1.0
+ )
+ self.assertIs(result, http_socket)
+ self.assertIn(b"Proxy-Authorization: Basic dTpj", http_socket.sent[0])
+
+ socks4_socket = ChunkSocket([b"\x00\x5a\x00\x00\x00\x00\x00\x00"])
+ with patch.object(proxy_module, "_open_host", return_value=socks4_socket):
+ self.assertIs(
+ _dial_socks4(ProxyExit("socks4", "proxy", 1080), "127.0.0.1:80", 1.0),
+ socks4_socket,
+ )
+ socks5_socket = ChunkSocket(
+ [b"\x05\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
+ )
+ with patch.object(proxy_module, "_open_host", return_value=socks5_socket):
+ self.assertIs(
+ _dial_socks5(ProxyExit("socks5", "proxy", 1080), "127.0.0.1:80", 1.0),
+ socks5_socket,
+ )
+ with self.assertRaises(OSError):
+ _dial_socks5(ProxyExit("socks5", "proxy", 1080), "host:0", 1.0)
+
+ def test_proxy_forward_and_tunnel_paths(self) -> None:
+ proxy = MemoryProxy.__new__(MemoryProxy)
+ proxy.exit = ProxyExit("socks5", "proxy", 1080)
+ upstream = ChunkSocket([b"reply", b""])
+ client = ChunkSocket([])
+ cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
+ socket.socket, upstream
+ )
+ with patch.object(proxy_module, "_copy_until_close") as copy:
+ proxy.forward_http(
+ cast(socket.socket, client),
+ "GET",
+ "http://example.test/path?q=1",
+ [("Host", "example.test")],
+ b"",
+ )
+ copy.assert_called_once()
+ self.assertIn(b"GET /path?q=1 HTTP/1.1", upstream.sent[0])
+ proxy.exit = ProxyExit("http", "proxy", 8080)
+ upstream = ChunkSocket([b""])
+ cast(Any, proxy).dial = lambda target, timeout=20.0: cast(
+ socket.socket, upstream
+ )
+ with (
+ patch.object(proxy_module, "_open_host", return_value=upstream),
+ patch.object(proxy_module, "_copy_until_close"),
+ ):
+ proxy.forward_http(
+ cast(socket.socket, client), "GET", "http://example.test/", [], b""
+ )
+ self.assertIn(b"GET http://example.test/ HTTP/1.1", upstream.sent[0])
+
+
+class ChunkSocket:
+ def __init__(self, chunks: list[bytes]) -> None:
+ self.chunks = list(chunks)
+ self.sent: list[bytes] = []
+
+ def recv(self, size: int) -> bytes:
+ if not self.chunks:
+ return b""
+ chunk = self.chunks.pop(0)
+ if len(chunk) <= size:
+ return chunk
+ self.chunks.insert(0, chunk[size:])
+ return chunk[:size]
+
+ def sendall(self, data: bytes) -> None:
+ self.sent.append(data)
+
+ def send(self, data: bytes) -> None:
+ self.sent.append(data)
+
+ def settimeout(self, value: float | None) -> None:
+ del value
+
+ def close(self) -> None:
+ return None
+
+
+class DockerClientTests(unittest.TestCase):
+ def test_digest_pull_preserves_digest_in_docker_query(self) -> None:
+ client = ScriptedDocker(
+ [
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(200, "OK", b""),
+ ]
+ )
+ client.pull_if_missing("registry.example/repo@sha256:" + "a" * 64)
+ self.assertIn(
+ "fromImage=registry.example%2Frepo%40sha256%3A" + "a" * 64,
+ client.calls[1][1],
+ )
+
+ def test_image_ref_and_expected_statuses(self) -> None:
+ self.assertEqual(
+ split_image_ref("registry.example/repo:tag"),
+ ("registry.example/repo", "tag"),
+ )
+ self.assertEqual(split_image_ref("repo@sha256:abc"), ("repo", "sha256:abc"))
+ client = ScriptedDocker(
+ [
+ DockerResponse(204, "No Content", b""),
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(500, "Error", b"failure"),
+ ]
+ )
+ client.expect("POST", "/ok")
+ with self.assertRaises(FileNotFoundError):
+ client.expect("POST", "/missing")
+ with self.assertRaises(DockerError):
+ client.expect("POST", "/error")
+ self.assertEqual(tenant_network_name("creatorhub", "safe"), "creatorhub-safe")
+
+ def test_pull_and_managed_container_response_validation(self) -> None:
+ client = ScriptedDocker(
+ [
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(200, "OK", b"{}"),
+ ]
+ )
+ client.pull_if_missing("repo:tag")
+ self.assertIn("/images/repo%3Atag/json", client.calls[0][1])
+ managed = {
+ "Id": "container-id",
+ "Config": {"Labels": {MANAGED_LABEL: "true", RUNTIME_ID_LABEL: "safe"}},
+ "NetworkSettings": {"Networks": {"tenant": {"NetworkID": "network-id"}}},
+ }
+ client = ScriptedDocker(
+ [DockerResponse(200, "OK", json.dumps(managed).encode())]
+ )
+ self.assertEqual(client.managed_container_state("safe")[0], "container-id")
+ unmanaged = {
+ **managed,
+ "Config": {"Labels": {MANAGED_LABEL: "false", RUNTIME_ID_LABEL: "safe"}},
+ }
+ client = ScriptedDocker(
+ [DockerResponse(200, "OK", json.dumps(unmanaged).encode())]
+ )
+ with self.assertRaises(UnmanagedContainer):
+ client.managed_container("safe")
+
+ def test_existing_network_generation_and_disconnect(self) -> None:
+ network = {
+ "Id": "network-id",
+ "Name": "creatorhub-safe",
+ "Driver": "bridge",
+ "Internal": False,
+ "Attachable": False,
+ "Ingress": False,
+ "Labels": {
+ MANAGED_LABEL: "true",
+ NETWORK_ROLE_LABEL: BROWSER_NETWORK_ROLE,
+ RUNTIME_ID_LABEL: "safe",
+ BINDING_VERSION_LABEL: "1",
+ },
+ "Containers": {
+ "gateway-id": {"Name": "gateway", "IPv4Address": "10.0.0.2/24"},
+ "runtime-id": {"Name": "runtime", "IPv4Address": "10.0.0.3/24"},
+ },
+ }
+ gateway = {
+ "Id": "gateway-id",
+ "Config": {"Labels": {GATEWAY_MEMBER_LABEL: "true"}},
+ }
+ client = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", json.dumps(network).encode()),
+ DockerResponse(200, "OK", json.dumps(gateway).encode()),
+ ]
+ )
+ generation, addresses, exists = client.inspect_tenant_network(
+ "creatorhub", "safe", 1, "runtime-id", "gateway", "network-id"
+ )
+ self.assertTrue(exists)
+ self.assertTrue(generation.runtime_attached)
+ self.assertEqual(addresses["gateway-id"], "10.0.0.2/24")
+ self.assertEqual(generation.self_member, "gateway-id")
+
+ after = {
+ **network,
+ "Containers": {"gateway-id": network["Containers"]["gateway-id"]},
+ }
+ client = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", json.dumps(network).encode()),
+ DockerResponse(200, "OK", json.dumps(gateway).encode()),
+ DockerResponse(200, "OK", json.dumps(network).encode()),
+ DockerResponse(200, "OK", json.dumps(gateway).encode()),
+ DockerResponse(200, "OK", b""),
+ DockerResponse(200, "OK", json.dumps(after).encode()),
+ DockerResponse(200, "OK", json.dumps(gateway).encode()),
+ ]
+ )
+ current, _, _ = client.inspect_tenant_network(
+ "creatorhub", "safe", 1, "runtime-id", "gateway", "network-id"
+ )
+ updated = client.disconnect_member(
+ "creatorhub", "safe", 1, "runtime-id", current, "runtime-id", "gateway"
+ )
+ self.assertFalse(updated.runtime_attached)
+
+ def test_new_network_is_generation_fenced(self) -> None:
+ empty = {
+ "Id": "network-id",
+ "Name": "creatorhub-safe",
+ "Driver": "bridge",
+ "Internal": False,
+ "Attachable": False,
+ "Ingress": False,
+ "Labels": {
+ MANAGED_LABEL: "true",
+ NETWORK_ROLE_LABEL: BROWSER_NETWORK_ROLE,
+ RUNTIME_ID_LABEL: "safe",
+ BINDING_VERSION_LABEL: "1",
+ },
+ "Containers": {},
+ }
+ connected = {
+ **empty,
+ "Containers": {
+ "runtime-id": {"Name": "runtime", "IPv4Address": "10.0.0.3/24"},
+ "gateway-id": {"Name": "gateway", "IPv4Address": "10.0.0.2/24"},
+ },
+ }
+ gateway = {
+ "Id": "gateway-id",
+ "Config": {"Labels": {GATEWAY_MEMBER_LABEL: "true"}},
+ }
+ client = ScriptedDocker(
+ [
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(201, "Created", b'{"Id":"network-id"}'),
+ DockerResponse(200, "OK", json.dumps(empty).encode()),
+ DockerResponse(200, "OK", b""),
+ DockerResponse(200, "OK", b""),
+ DockerResponse(200, "OK", json.dumps(connected).encode()),
+ DockerResponse(200, "OK", json.dumps(gateway).encode()),
+ ]
+ )
+ generation, bind_host = client.ensure_tenant_network(
+ "creatorhub", "safe", "gateway", 1, "runtime-id"
+ )
+ self.assertTrue(generation.created)
+ self.assertEqual(bind_host, "10.0.0.2")
+
+ def test_alias_reservation_releases_only_its_generation(self) -> None:
+ gateway = {
+ "Id": "gateway-id",
+ "Image": "creatorhub/gateway:latest",
+ "Config": {"Labels": {MANAGED_LABEL: "true", GATEWAY_MEMBER_LABEL: "true"}},
+ }
+ reservation = {
+ "Id": "reservation-id",
+ "Config": {
+ "Labels": {
+ "io.creatorhub.alias-reservation": "true",
+ "io.creatorhub.reservation-generation": "reservation-generation",
+ RUNTIME_ID_LABEL: "safe",
+ }
+ },
+ }
+ client = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", json.dumps(gateway).encode()),
+ DockerResponse(201, "Created", b'{"Id":"reservation-id"}'),
+ DockerResponse(200, "OK", json.dumps(reservation).encode()),
+ DockerResponse(200, "OK", json.dumps(reservation).encode()),
+ DockerResponse(204, "No Content", b""),
+ DockerResponse(404, "Not Found", b""),
+ ]
+ )
+ with patch.object(
+ docker_client_module,
+ "random_reservation_generation",
+ return_value="reservation-generation",
+ ):
+ release = AliasReservationManager(client, "gateway").acquire("safe")
+ release()
+ self.assertTrue(any(call[0] == "DELETE" for call in client.calls))
+
+ def test_stale_alias_reservation_is_reclaimed(self) -> None:
+ gateway = {
+ "Id": "gateway-id",
+ "Image": "creatorhub/gateway:latest",
+ "Config": {"Labels": {MANAGED_LABEL: "true", GATEWAY_MEMBER_LABEL: "true"}},
+ }
+ stale = {
+ "Id": "stale-reservation-id",
+ "Config": {
+ "Labels": {
+ RESERVATION_LABEL: "true",
+ RUNTIME_ID_LABEL: "safe",
+ RESERVATION_OWNER_LABEL: "old-gateway",
+ }
+ },
+ }
+ current = {
+ "Id": "current-reservation-id",
+ "Config": {
+ "Labels": {
+ RESERVATION_LABEL: "true",
+ RUNTIME_ID_LABEL: "safe",
+ RESERVATION_OWNER_LABEL: "gateway",
+ "io.creatorhub.reservation-generation": "current-generation",
+ }
+ },
+ }
+ client = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", json.dumps(gateway).encode()),
+ DockerResponse(409, "Conflict", b""),
+ DockerResponse(200, "OK", json.dumps(stale).encode()),
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(204, "No Content", b""),
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(201, "Created", b'{"Id":"current-reservation-id"}'),
+ DockerResponse(200, "OK", json.dumps(current).encode()),
+ DockerResponse(200, "OK", json.dumps(current).encode()),
+ DockerResponse(204, "No Content", b""),
+ DockerResponse(404, "Not Found", b""),
+ ]
+ )
+ with patch.object(
+ docker_client_module,
+ "random_reservation_generation",
+ return_value="current-generation",
+ ):
+ release = AliasReservationManager(client, "gateway").acquire("safe")
+ release()
+ self.assertEqual(
+ client.calls[4][1],
+ "/containers/stale-reservation-id?force=1&v=0",
+ )
+ self.assertEqual(client.calls[6][0], "POST")
+
+
+class ScriptedDocker(DockerClient):
+ def __init__(self, responses: list[DockerResponse]) -> None:
+ super().__init__("/dev/null")
+ self.responses = list(responses)
+ self.calls: list[tuple[str, str, object | None]] = []
+
+ def request(
+ self,
+ method: str,
+ path: str,
+ payload: object | None = None,
+ timeout: float = 30.0,
+ body_limit: int = 16 * 1024 * 1024,
+ ) -> DockerResponse:
+ del timeout, body_limit
+ self.calls.append((method, path, payload))
+ if not self.responses:
+ raise AssertionError(f"unexpected Docker call: {method} {path}")
+ return self.responses.pop(0)
+
+
+class ReservationStub:
+ def __init__(self) -> None:
+ self.released = 0
+
+ def acquire(self, alias: str):
+ del alias
+
+ def release() -> None:
+ self.released += 1
+
+ return release
+
+
+class LockingReservationStub(ReservationStub):
+ def __init__(self) -> None:
+ super().__init__()
+ self.lock = threading.Lock()
+
+ def acquire(self, alias: str):
+ del alias
+ self.lock.acquire()
+
+ def release() -> None:
+ self.released += 1
+ self.lock.release()
+
+ return release
+
+
+class LifecycleDocker(ScriptedDocker):
+ def ensure_tenant_network(
+ self, *args: object, **kwargs: object
+ ) -> tuple[TenantNetworkGeneration, str]:
+ del args, kwargs
+ return TenantNetworkGeneration(
+ id="network-id", self_member="gateway-id"
+ ), "10.0.0.2"
+
+ def inspect_tenant_network(
+ self, *args: object, **kwargs: object
+ ) -> tuple[TenantNetworkGeneration, dict[str, str], bool]:
+ del args, kwargs
+ return (
+ TenantNetworkGeneration(
+ id="network-id",
+ name="creatorhub-safe",
+ self_member="gateway-id",
+ gateway_members=["gateway-id"],
+ runtime_attached=True,
+ ),
+ {"gateway-id": "10.0.0.2/24"},
+ True,
+ )
+
+
+class GatewayLifecycleTests(unittest.TestCase):
+ def test_server_tracks_daemon_request_threads_and_timeout(self) -> None:
+ server = gateway_module.GatewayHTTPServer(("127.0.0.1", 0), Mock())
+ try:
+ self.assertTrue(server.daemon_threads)
+ self.assertEqual(gateway_module.GatewayHandler.protocol_version, "HTTP/1.1")
+ finally:
+ server.server_close()
+
+ def test_network_setup_error_keeps_created_generation(self) -> None:
+ client = ScriptedDocker(
+ [
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(201, "Created", b'{"Id":"network-id"}'),
+ DockerResponse(500, "Error", b"inspect failed"),
+ ]
+ )
+ with self.assertRaises(docker_client_module.NetworkSetupError) as caught:
+ client.ensure_tenant_network("creatorhub", "safe", "gateway", 1, "runtime")
+ self.assertEqual(caught.exception.generation.id, "network-id")
+ self.assertTrue(caught.exception.generation.created)
+
+ def test_network_cleanup_failure_returns_pending_contract(self) -> None:
+ docker = ScriptedDocker(
+ [
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(500, "Error", b"temporary"),
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, ReservationStub())
+ with self.assertRaises(RequestError) as caught:
+ gateway.remove(
+ "safe",
+ {
+ "binding_version": 1,
+ "runtime_id": "runtime-not-found",
+ "network_id": "network-id",
+ },
+ )
+ self.assertEqual(caught.exception.status, 202)
+ self.assertEqual(caught.exception.network_id, "network-id")
+ self.assertEqual(str(caught.exception), "runtime_cleanup_pending")
+
+ def test_timed_out_action_retains_alias_ownership(self) -> None:
+ gateway = Gateway(
+ cast(DockerClient, Mock()), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway._claim_action("safe")
+ gateway._retain_action_ownership("safe")
+ with self.assertRaises(RequestError):
+ gateway._claim_action("safe")
+
+ def _input(self, stopped: bool = True) -> dict:
+ return {
+ "alias": "safe",
+ "name": "Safe",
+ "image": "creatorhub/browser:latest",
+ "cmd": ["about:blank"],
+ "volume": "creatorhub-safe",
+ "binding_version": 1,
+ "network_exit_id": "",
+ "stopped": stopped,
+ }
+
+ def test_concurrent_create_cannot_delete_the_winner(self) -> None:
+ class ConcurrentDocker(DockerClient):
+ def __init__(self) -> None:
+ super().__init__("/dev/null")
+ self.created = False
+ self.calls: list[tuple[str, str]] = []
+
+ def managed_container(self, alias: str) -> tuple[str, dict[str, str]]:
+ if not self.created:
+ raise FileNotFoundError(alias)
+ return "a" * 64, {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: alias,
+ BINDING_VERSION_LABEL: "1",
+ NETWORK_ID_LABEL: "",
+ }
+
+ def request(
+ self,
+ method: str,
+ path: str,
+ payload: object | None = None,
+ timeout: float = 30.0,
+ body_limit: int = 16 * 1024 * 1024,
+ ) -> DockerResponse:
+ del payload, timeout, body_limit
+ self.calls.append((method, path))
+ if method == "GET" and path.startswith("/images/"):
+ return DockerResponse(200, "OK", b"{}")
+ if method == "POST" and path.startswith("/containers/create"):
+ if self.created:
+ return DockerResponse(409, "Conflict", b"")
+ self.created = True
+ return DockerResponse(
+ 201, "Created", b'{"Id":"' + b"b" * 64 + b'"}'
+ )
+ raise AssertionError(f"unexpected Docker call: {method} {path}")
+
+ docker = ConcurrentDocker()
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, LockingReservationStub())
+ results: list[object] = []
+
+ def create() -> None:
+ try:
+ results.append(gateway.create(self._input()))
+ except (
+ AssertionError,
+ DockerError,
+ OSError,
+ RequestError,
+ ValueError,
+ ) as exc:
+ results.append(exc)
+
+ threads = [threading.Thread(target=create) for _ in range(2)]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join()
+ self.assertEqual(sum(isinstance(item, dict) for item in results), 1)
+ self.assertEqual(sum(isinstance(item, RequestError) for item in results), 1)
+ self.assertFalse(any(method == "DELETE" for method, _ in docker.calls))
+
+ def test_create_stopped_pulls_image_and_keeps_container(self) -> None:
+ docker = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", b"{}"),
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(201, "Created", b'{"Id":"container-id"}'),
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ reservations = ReservationStub()
+ gateway.reservations = cast(AliasReservationManager, reservations)
+ result = gateway.create(self._input())
+ self.assertEqual(result["id"], "container-id")
+ self.assertEqual(reservations.released, 1)
+ self.assertEqual(docker.calls[2][0], "POST")
+
+ def test_running_create_starts_container_and_network(self) -> None:
+ docker = LifecycleDocker(
+ [
+ DockerResponse(200, "OK", b"{}"),
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(201, "Created", b'{"Id":"container-id"}'),
+ DockerResponse(204, "No Content", b""),
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, ReservationStub())
+ result = gateway.create(self._input(False))
+ self.assertEqual(result["network_id"], "network-id")
+ self.assertEqual(docker.calls[-1][0], "POST")
+
+ def test_change_state_and_remove_are_generation_fenced(self) -> None:
+ labels = {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: "safe",
+ BINDING_VERSION_LABEL: "1",
+ NETWORK_ID_LABEL: "network-id",
+ }
+ inspected = {
+ "Id": "a" * 64,
+ "Config": {"Labels": labels},
+ "NetworkSettings": {"Networks": {}},
+ }
+ docker = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", json.dumps(inspected).encode()),
+ DockerResponse(204, "No Content", b""),
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, ReservationStub())
+ gateway.change_state(
+ "safe",
+ "stop",
+ {
+ "binding_version": 1,
+ "runtime_id": "a" * 64,
+ "network_id": "network-id",
+ },
+ )
+ self.assertEqual(docker.calls[-1][0], "POST")
+
+ docker = LifecycleDocker(
+ [
+ DockerResponse(200, "OK", json.dumps(inspected).encode()),
+ DockerResponse(204, "No Content", b""),
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, ReservationStub())
+ gateway._remove_network = lambda *args, **kwargs: None
+ gateway.remove(
+ "safe",
+ {
+ "binding_version": 1,
+ "runtime_id": "a" * 64,
+ "network_id": "network-id",
+ },
+ )
+ self.assertEqual(docker.calls[-1][0], "DELETE")
+
+ def test_remove_stopped_direct_container_without_network(self) -> None:
+ labels = {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: "safe",
+ BINDING_VERSION_LABEL: "1",
+ NETWORK_ID_LABEL: "",
+ }
+ inspected = {
+ "Id": "a" * 64,
+ "Config": {"Labels": labels},
+ "NetworkSettings": {"Networks": {}},
+ }
+ docker = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", json.dumps(inspected).encode()),
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(204, "No Content", b""),
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, ReservationStub())
+ gateway.remove(
+ "safe",
+ {"binding_version": 1, "runtime_id": "a" * 64, "network_id": ""},
+ )
+ self.assertEqual(docker.calls[-1][0], "DELETE")
+
+ def test_remove_rejects_runtime_replacement(self) -> None:
+ labels = {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: "safe",
+ BINDING_VERSION_LABEL: "1",
+ NETWORK_ID_LABEL: "",
+ }
+ inspected = {
+ "Id": "a" * 64,
+ "Config": {"Labels": labels},
+ "NetworkSettings": {"Networks": {}},
+ }
+ docker = ScriptedDocker(
+ [DockerResponse(200, "OK", json.dumps(inspected).encode())]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, ReservationStub())
+ with self.assertRaises(RequestError):
+ gateway.remove(
+ "safe",
+ {"binding_version": 1, "runtime_id": "b" * 64, "network_id": ""},
+ )
+
+ def test_create_failure_reconciles_unknown_container(self) -> None:
+ docker = ScriptedDocker(
+ [
+ DockerResponse(200, "OK", b"{}"),
+ DockerResponse(404, "Not Found", b""),
+ DockerResponse(500, "Error", b"failed"),
+ DockerResponse(404, "Not Found", b""),
+ ]
+ )
+ gateway = Gateway(
+ cast(DockerClient, docker), "creatorhub", "0123456789abcdef", "gateway"
+ )
+ gateway.reservations = cast(AliasReservationManager, ReservationStub())
+ with self.assertRaises(RequestError):
+ gateway.create(self._input())
+
+
+class AdditionalGatewayCoverageTests(unittest.TestCase):
+ def test_douyin_connect_rejects_bad_discovery(self) -> None:
+ browser = DouyinBrowser(lambda _: "https://browser:9222")
+ with self.assertRaises(DouyinError):
+ browser._connect("safe")
+ cases = [
+ (500, b"{}"),
+ (200, b"{}"),
+ (200, json.dumps([{"type": "service"}]).encode()),
+ (
+ 200,
+ json.dumps(
+ [{"type": "page", "url": "https://www.douyin.com/1"}]
+ ).encode(),
+ ),
+ (
+ 200,
+ json.dumps(
+ [
+ {
+ "type": "page",
+ "url": "https://www.douyin.com/1",
+ "webSocketDebuggerUrl": "http://browser/devtools/page/1",
+ }
+ ]
+ ).encode(),
+ ),
+ ]
+ for status, body in cases:
+ with self.subTest(status=status, body=body):
+ http = FakeHTTPConnection(FakeHTTPResponse(status, body))
+ with (
+ patch.object(
+ douyin_module.http.client, "HTTPConnection", return_value=http
+ ),
+ self.assertRaises(DouyinError),
+ ):
+ browser._connect("safe")
+ targets = [
+ {
+ "type": "page",
+ "url": "https://www.douyin.com/1",
+ "webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
+ },
+ {
+ "type": "page",
+ "url": "https://www.douyin.com/2",
+ "webSocketDebuggerUrl": "ws://browser:9222/devtools/page/2",
+ },
+ ]
+ http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(targets).encode()))
+ with (
+ patch.object(
+ douyin_module.http.client, "HTTPConnection", return_value=http
+ ),
+ self.assertRaises(DouyinError),
+ ):
+ browser._connect("safe")
+
+ def test_douyin_fetch_identity_and_confirmed_actions(self) -> None:
+ browser = DouyinBrowser()
+
+ def bind(connection: BrowserCDP) -> None:
+ BrowserTests()._with_connection(browser, connection)
+
+ for result in (
+ {"too_large": True},
+ {"status": 302, "body": ""},
+ {"status": 200, "body": 1},
+ ):
+ bind(BrowserCDP(["https://www.douyin.com", result]))
+ with self.assertRaises(DouyinError):
+ browser.get("safe", "https://www.douyin.com/a")
+ cast(Any, browser).get = lambda alias, target: BrowserResponse(200, "not-json")
+ with self.assertRaises(DouyinError):
+ browser.identity("safe")
+ cast(Any, browser).get = lambda alias, target: BrowserResponse(
+ 200, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
+ )
+ self.assertEqual(browser.identity("safe")["uid"], "1")
+ cast(Any, browser).get = lambda alias, target: BrowserResponse(
+ 403, json.dumps({"status_code": 0, "user": {"uid": "1", "sec_uid": "sec"}})
+ )
+ with self.assertRaises(DouyinError):
+ browser.identity("safe")
+ cast(Any, browser).identity = lambda alias, expected_uid=None: {
+ "uid": expected_uid or "1"
+ }
+ cast(Any, browser)._evaluate = lambda alias, expression: {
+ "status": 200,
+ "action": "sent",
+ }
+ bind(BrowserCDP(["https://www.douyin.com", {}]))
+ self.assertEqual(
+ browser.action("safe", "1", "follow", "2", confirm=True)["action"], "sent"
+ )
+ bind(BrowserCDP(["https://www.douyin.com", {}]))
+ self.assertEqual(
+ browser.action("safe", "1", "dm", "2", text="hello", confirm=True)[
+ "status"
+ ],
+ 200,
+ )
+ cast(Any, browser)._evaluate = lambda alias, expression: "bad"
+ bind(BrowserCDP(["https://www.douyin.com", "bad"]))
+ with self.assertRaises(DouyinError):
+ browser.action("safe", "1", "follow", "2", confirm=True)
+
+ def test_gateway_and_proxy_validation_edges(self) -> None:
+ for value in (
+ None,
+ [],
+ [{"name": "bad"}],
+ [{"name": "sessionid", "value": "x", "domain": "evil.test"}],
+ ):
+ self.assertFalse(valid_cookies(value))
+ self.assertEqual(
+ parse_proxy_exit({"protocol": "http", "host": "", "port": 80}).host, ""
+ )
+ with self.assertRaises(RequestError):
+ validate_proxy_exit(
+ parse_proxy_exit({"protocol": "http", "host": "", "port": 80})
+ )
+ with self.assertRaises(RequestError):
+ validate_proxy_exit(
+ parse_proxy_exit({"protocol": "http", "host": "proxy", "port": 0})
+ )
+ with self.assertRaises(RequestError):
+ validate_proxy_restore(
+ {
+ "binding_version": 1,
+ "runtime_id": "a" * 64,
+ "network_id": "n",
+ "network_exit_id": "x",
+ },
+ "bad alias",
+ )
+ self.assertFalse(valid_account_key_query({"key": ["bad key"]}, "key"))
+
+ def test_proxy_rejected_handshakes_and_docker_errors(self) -> None:
+ bad_http = ChunkSocket([b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"])
+ with (
+ patch.object(proxy_module, "_open_host", return_value=bad_http),
+ self.assertRaises(OSError),
+ ):
+ _dial_http_proxy(ProxyExit("http", "proxy", 8080), "target:443", 1.0)
+ bad_socks5 = ChunkSocket([b"\x05\x02"])
+ with (
+ patch.object(proxy_module, "_open_host", return_value=bad_socks5),
+ self.assertRaises(OSError),
+ ):
+ _dial_socks5(
+ ProxyExit("socks5", "proxy", 1080, "u", "p"), "127.0.0.1:80", 1.0
+ )
+ client = DockerClient("/not/a/socket")
+ with (
+ patch.object(
+ docker_client_module, "UnixHTTPConnection", side_effect=OSError("down")
+ ),
+ self.assertRaises(DockerError),
+ ):
+ client.request("GET", "/version", timeout=0.01)
+ with (
+ patch.object(
+ client, "request", return_value=DockerResponse(500, "bad", b"x")
+ ),
+ self.assertRaises(DockerError),
+ ):
+ client.expect("POST", "/x")
+ with (
+ patch.object(
+ client, "request", return_value=DockerResponse(404, "missing", b"")
+ ),
+ self.assertRaises(FileNotFoundError),
+ ):
+ client.expect("DELETE", "/gone")
+
+ def test_docker_client_transport_and_metadata_edges(self) -> None:
+ client = DockerClient("/unused")
+ response = FakeHTTPResponse(200, b"{}")
+ cast(Any, response).reason = "OK"
+ connection = FakeHTTPConnection(response)
+ with patch.object(
+ docker_client_module, "UnixHTTPConnection", return_value=connection
+ ):
+ result = client.request("POST", "/version", {"ok": True})
+ self.assertEqual(result.status, 200)
+ self.assertEqual(connection.requested, [("POST", "/v1.43/version")])
+ huge = FakeHTTPResponse(200, b"12345")
+ cast(Any, huge).reason = "OK"
+ with (
+ patch.object(
+ docker_client_module,
+ "UnixHTTPConnection",
+ return_value=FakeHTTPConnection(huge),
+ ),
+ self.assertRaises(DockerError),
+ ):
+ client.request("GET", "/version", body_limit=4)
+ labels = {
+ MANAGED_LABEL: "true",
+ RUNTIME_ID_LABEL: "safe",
+ GATEWAY_MEMBER_LABEL: "true",
+ }
+ inspected = {
+ "Id": "a" * 64,
+ "Config": {"Labels": labels},
+ "NetworkSettings": {
+ "Networks": {"n": {"NetworkID": "network", "IPAddress": "198.51.100.5"}}
+ },
+ }
+ with patch.object(
+ client,
+ "request",
+ return_value=DockerResponse(200, "OK", json.dumps(inspected).encode()),
+ ):
+ self.assertTrue(client.trusted_gateway_member("gateway"))
+ self.assertEqual(
+ client.container_network_address("a" * 64, "network"), "198.51.100.5"
+ )
+ bad = DockerResponse(200, "OK", b"[]")
+ with patch.object(client, "request", return_value=bad):
+ self.assertFalse(client.trusted_gateway_member("gateway"))
+ with self.assertRaises(DockerError):
+ client.managed_container_state("safe")
+
+ def test_client_connection_pull_and_inspect_edges(self) -> None:
+ class ConnectedSocket:
+ def __init__(self) -> None:
+ self.timeout = None
+ self.path = ""
+
+ def settimeout(self, value: float) -> None:
+ self.timeout = value
+
+ def connect(self, path: str) -> None:
+ self.path = path
+
+ connected = ConnectedSocket()
+ with patch.object(
+ docker_client_module.socket, "socket", return_value=connected
+ ):
+ connection = docker_client_module.UnixHTTPConnection(
+ "/run/docker.sock", 1.0
+ )
+ connection.connect()
+ self.assertEqual(connected.path, "/run/docker.sock")
+ client = DockerClient("/unused")
+ with patch.object(
+ client,
+ "request",
+ side_effect=[
+ DockerResponse(404, "missing", b""),
+ DockerResponse(200, "OK", b"{}"),
+ ],
+ ) as request:
+ client.pull_if_missing("registry.example/repo:tag")
+ self.assertIn("tag=tag", request.call_args_list[1].args[1])
+ with (
+ patch.object(
+ client, "request", return_value=DockerResponse(500, "bad", b"no")
+ ),
+ self.assertRaises(DockerError),
+ ):
+ client.pull_if_missing("registry.example/repo:tag")
+ invalid = DockerResponse(
+ 200,
+ "OK",
+ json.dumps(
+ {
+ "Id": "a" * 64,
+ "Config": {"Labels": []},
+ "NetworkSettings": {"Networks": {}},
+ }
+ ).encode(),
+ )
+ with (
+ patch.object(client, "request", return_value=invalid),
+ self.assertRaises(DockerError),
+ ):
+ client.managed_container_state("safe")
+
+ def test_cdp_error_and_proxy_auth_paths(self) -> None:
+ socket_ = FakeSocket([{"id": 1, "result": {"result": {"value": {"ok": True}}}}])
+ connection = CDPConnection(cast(websocket.WebSocket, socket_))
+ self.assertEqual(connection.evaluate("1"), {"ok": True})
+ socket_ = FakeSocket([{"id": 1, "result": {"result": {}}}])
+ with self.assertRaises(DouyinError):
+ CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
+ with self.assertRaises(DouyinError):
+ CDPConnection(
+ cast(websocket.WebSocket, FakeSocket([{"id": 1, "error": {}}]))
+ ).command("Page.enable")
+ with self.assertRaises(DouyinError):
+ CDPConnection(
+ cast(websocket.WebSocket, FakeSocket([{"id": 1, "result": []}]))
+ ).command("Page.enable")
+ with self.assertRaises(DouyinError):
+ CDPConnection(cast(websocket.WebSocket, FakeSocket([]))).wait_event(
+ "Page.loadEventFired", lambda _: True, timeout=0.01
+ )
+ socket_ = FakeSocket([{"id": 1, "result": {"exceptionDetails": {}}}])
+ with self.assertRaises(DouyinError):
+ CDPConnection(cast(websocket.WebSocket, socket_)).evaluate("1")
+ target = [
+ {
+ "type": "page",
+ "url": "https://www.douyin.com/1",
+ "webSocketDebuggerUrl": "ws://browser:9222/devtools/page/1",
+ }
+ ]
+ http = FakeHTTPConnection(FakeHTTPResponse(200, json.dumps(target).encode()))
+ with (
+ patch.object(
+ douyin_module.http.client, "HTTPConnection", return_value=http
+ ),
+ patch.object(
+ douyin_module.websocket,
+ "create_connection",
+ side_effect=OSError("down"),
+ ),
+ self.assertRaises(DouyinError),
+ ):
+ DouyinBrowser(lambda _: "http://browser:9222")._connect("safe")
+ socks5 = ChunkSocket(
+ [b"\x05\x02", b"\x01\x00", b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x50"]
+ )
+ with patch.object(proxy_module, "_open_host", return_value=socks5):
+ self.assertIs(
+ _dial_socks5(
+ ProxyExit("socks5", "proxy", 1080, "u", "p"), "127.0.0.1:80", 1.0
+ ),
+ socks5,
+ )
+
+ def test_direct_message_notice_and_uncertain_post_contract(self) -> None:
+ notice = normalize_notice(
+ {
+ "dm": {
+ "message_id": "123456",
+ "from_user": {"uid": "456789"},
+ "text": "hello",
+ },
+ "create_time": 1700000000,
+ }
+ )
+ self.assertIsNotNone(notice)
+ assert notice is not None
+ self.assertEqual(notice["event_type"], "dm")
+ self.assertEqual(notice["interactor_uid"], "456789")
+ self.assertEqual(notice["message_text"], "hello")
+ script = action_expression({"alias": "safe", "action": "follow", "target": "2"})
+ self.assertIn("POST_UNCERTAIN", script)
+ self.assertIn("BUSINESS_REJECTED", script)
+
+ def test_cdp_timeout_terminates_page_evaluation(self) -> None:
+ class TimeoutSocket(FakeSocket):
+ def __init__(self) -> None:
+ super().__init__([])
+ self.receives = 0
+
+ def recv(self) -> str:
+ self.receives += 1
+ if self.receives == 1:
+ raise TimeoutError("deadline")
+ return json.dumps({"id": 2, "result": {}})
+
+ socket_ = TimeoutSocket()
+ connection = CDPConnection(cast(websocket.WebSocket, socket_))
+ with (
+ patch.object(douyin_module, "CONTROL_TIMEOUT", 0.001),
+ self.assertRaises(DouyinError),
+ ):
+ connection.command("Runtime.evaluate")
+ sent = [json.loads(item) for item in socket_.sent]
+ self.assertEqual(sent[1]["method"], "Runtime.terminateExecution")
+
+ def test_network_create_reconciliation_preserves_observed_generation(self) -> None:
+ client = DockerClient("/unused")
+ observed = TenantNetworkGeneration(name="creatorhub-safe", id="observed")
+ generation = TenantNetworkGeneration(name="creatorhub-safe")
+ with (
+ patch.object(
+ client, "inspect_tenant_network", return_value=(observed, [], True)
+ ),
+ self.assertRaises(NetworkSetupError) as caught,
+ ):
+ client._finish_network_create(
+ "creatorhub",
+ "safe",
+ "gateway",
+ 1,
+ "runtime",
+ DockerResponse(201, "Created", b"{}"),
+ generation,
+ )
+ self.assertEqual(caught.exception.generation.id, "observed")
+
+ with patch.object(
+ client, "inspect_tenant_network", return_value=(observed, [], True)
+ ):
+ result = client._finish_network_create(
+ "creatorhub",
+ "safe",
+ "gateway",
+ 1,
+ "runtime",
+ DockerResponse(201, "Created", b'{"Id":"created"}'),
+ TenantNetworkGeneration(name="creatorhub-safe"),
+ )
+ self.assertEqual(result.id, "observed")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/compose.yaml b/compose.yaml
index b632e2d..2c6ba3d 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -5,12 +5,18 @@ services:
DATABASE_URL: postgres://creatorhub@postgres/creatorhub?sslmode=disable
CONTROL_PLANE_USERNAME: ${CONTROL_PLANE_USERNAME:?required}
CONTROL_PLANE_PASSWORD: ${CONTROL_PLANE_PASSWORD:?required}
- CREATORHUB_CREDENTIAL_MASTER_KEY: ${CREATORHUB_CREDENTIAL_MASTER_KEY:?required}
+ CREATORHUB_CREDENTIAL_MASTER_KEY: >-
+ ${CREATORHUB_CREDENTIAL_MASTER_KEY:?required}
+ BAILIAN_API_KEY: ${BAILIAN_API_KEY:-}
+ BAILIAN_BASE_URL: ${BAILIAN_BASE_URL:-}
+ CREATOR_MEDIA_DIR: /var/lib/creatorhub/materials
+ CREATOR_TRANSCRIPTION_BIN: ${CREATOR_TRANSCRIPTION_BIN:-}
ports:
- "${CREATORHUB_PORT:-8080}:8080"
read_only: true
volumes:
- creatorhub_credentials:/var/lib/creatorhub/credentials
+ - creatorhub_materials:/var/lib/creatorhub/materials
tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev
cap_drop: [ALL]
@@ -24,7 +30,8 @@ services:
restart: unless-stopped
postgres:
- image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
+ image: >-
+ postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
environment:
POSTGRES_DB: creatorhub
POSTGRES_USER: creatorhub
@@ -48,7 +55,8 @@ services:
docker-gateway:
build: .
- command: ["/app/docker-gateway"]
+ stop_grace_period: 45s
+ command: ["python3", "-m", "cmd.docker_gateway.gateway"]
labels:
io.creatorhub.gateway-member: "true"
environment:
@@ -57,7 +65,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- - "${DOCKER_GID:-999}"
+ - "${DOCKER_GID:?required}"
healthcheck:
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8081/healthz]
interval: 2s
@@ -77,3 +85,4 @@ networks:
volumes:
creatorhub_postgres:
creatorhub_credentials:
+ creatorhub_materials:
diff --git a/docker/browser-wrapper/Dockerfile b/docker/browser-wrapper/Dockerfile
new file mode 100644
index 0000000..37c46c3
--- /dev/null
+++ b/docker/browser-wrapper/Dockerfile
@@ -0,0 +1,5 @@
+ARG BROWSER_BASE_IMAGE
+FROM ${BROWSER_BASE_IMAGE}
+
+COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/creatorhub-browser-entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/creatorhub-browser-entrypoint.sh"]
diff --git a/docker/browser-wrapper/README.md b/docker/browser-wrapper/README.md
new file mode 100644
index 0000000..9f281a4
--- /dev/null
+++ b/docker/browser-wrapper/README.md
@@ -0,0 +1,12 @@
+# CreatorHub browser wrapper
+
+这是浏览器运行时的受管理入口,不包含 Chromium 本体。构建时必须传入已登记的 immutable base image digest,禁止使用 tag 或 `latest`:
+
+```bash
+docker build \
+ --build-arg BROWSER_BASE_IMAGE=git.ipao.vip/rogee/fingerprint-chromium@sha256:
+ 凭据: + {profile.password_configured ? "已配置" : "未配置"} + {profile.login_reason + ? ` · ${profile.login_reason}` + : ""}
@@ -333,6 +442,22 @@ export function CreatorAccountsPage() { {platformLabel[selected.platform] || selected.platform} ·{" "} {selected.platform_account_key} ++ 登录核验: + {selected.login_status === "logged_in" + ? "已登录" + : "需人工确认"} + {selected.login_checked_at + ? ` · ${dateTime(selected.login_checked_at)}` + : " · 尚未核验"} + {selected.login_reason + ? ` · ${selected.login_reason}` + : ""} +
++ 密码凭据: + {selected.password_configured ? "已配置" : "未配置"} +