HH-868: add fail-closed Douyin read-only connector core (#31)

This commit is contained in:
2026-09-01 12:02:24 +08:00
parent 2c2291998f
commit ddd92eea89
9 changed files with 1802 additions and 9 deletions
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"time"
"git.ipao.vip/rogee/creator-hub/internal/douyin"
"git.ipao.vip/rogee/creator-hub/internal/hub"
)
type douyinGatewayBrowser struct {
gateway hub.Gateway
environment hub.EnvironmentContext
}
type douyinGatewayRequest struct {
BindingVersion int64 `json:"binding_version"`
RuntimeID string `json:"runtime_id"`
NetworkID string `json:"network_id"`
NetworkExitID string `json:"network_exit_id"`
Cookies []douyin.Cookie `json:"cookies,omitempty"`
URL string `json:"url,omitempty"`
}
func (browser douyinGatewayBrowser) SetCookies(ctx context.Context, cookies []douyin.Cookie) error {
request, err := browser.request()
if err != nil {
return err
}
request.Cookies = cookies
status, _, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/cookies", request, 30*time.Second)
if err != nil || status != http.StatusNoContent {
return errors.New("restricted browser operation failed")
}
return nil
}
func (browser douyinGatewayBrowser) Get(ctx context.Context, target string) (douyin.Response, error) {
request, err := browser.request()
if err != nil {
return douyin.Response{}, err
}
request.URL = target
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/douyin/get", request, 30*time.Second)
if err != nil || status != http.StatusOK {
return douyin.Response{}, errors.New("restricted browser operation failed")
}
var response struct {
Status int `json:"status"`
Body string `json:"body"`
Challenge douyin.Challenge `json:"challenge"`
}
if json.Unmarshal(body, &response) != nil || response.Status < 100 || response.Status > 599 ||
(response.Challenge != douyin.ChallengeNone && response.Challenge != douyin.ChallengeCaptcha &&
response.Challenge != douyin.ChallengeDevice) {
return douyin.Response{}, errors.New("restricted browser returned an invalid response")
}
return douyin.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil
}
func (browser douyinGatewayBrowser) request() (douyinGatewayRequest, error) {
environment := browser.environment
if browser.gateway.Endpoint == "" || browser.gateway.Token == "" || !accountRunnable(environment) ||
!gatewayGenerationIDPattern.MatchString(environment.AccountID) || !gatewayGenerationIDPattern.MatchString(environment.Alias) ||
!gatewayGenerationIDPattern.MatchString(environment.BindingID) ||
!gatewayGenerationIDPattern.MatchString(environment.Exit.ID) || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending ||
!gatewayGenerationIDPattern.MatchString(environment.RuntimeInstanceID) || !gatewayGenerationIDPattern.MatchString(environment.RuntimeID) ||
!gatewayGenerationIDPattern.MatchString(environment.RuntimeNetworkID) ||
environment.BindingVersion < 1 {
return douyinGatewayRequest{}, errors.New("restricted browser is not ready")
}
return douyinGatewayRequest{BindingVersion: environment.BindingVersion, RuntimeID: environment.RuntimeID,
NetworkID: environment.RuntimeNetworkID, NetworkExitID: environment.Exit.ID}, nil
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.ipao.vip/rogee/creator-hub/internal/douyin"
"git.ipao.vip/rogee/creator-hub/internal/hub"
)
const testDouyinIdentityURL = "https://www.douyin.com/aweme/v1/web/user/profile/self/"
func TestDouyinGatewayBrowserFencesAccountGeneration(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
requests++
if request.Header.Get("Authorization") != "Bearer gateway-token-1" {
t.Fatalf("missing gateway authorization")
}
var body map[string]any
if json.NewDecoder(request.Body).Decode(&body) != nil || body["binding_version"] != float64(2) ||
body["runtime_id"] != "runtime-a" || body["network_id"] != "network-a" || body["network_exit_id"] != "exit-a" {
t.Fatalf("generation fence missing: %#v", body)
}
switch request.URL.Path {
case "/v1/browsers/account-a/douyin/cookies":
cookies, ok := body["cookies"].([]any)
if !ok || len(cookies) != 1 {
t.Fatalf("cookies missing: %#v", body)
}
response.WriteHeader(http.StatusNoContent)
case "/v1/browsers/account-a/douyin/get":
if body["url"] != testDouyinIdentityURL {
t.Fatalf("unexpected URL: %#v", body)
}
_ = json.NewEncoder(response).Encode(map[string]any{"status": 412, "body": `{"captcha":true}`, "challenge": "captcha"})
default:
response.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
if err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}}); err != nil {
t.Fatal(err)
}
result, err := browser.Get(context.Background(), testDouyinIdentityURL)
if err != nil || result.Status != 412 || result.Challenge != douyin.ChallengeCaptcha || requests != 2 {
t.Fatalf("unexpected result: %#v requests=%d err=%v", result, requests, err)
}
}
func TestDouyinGatewayBrowserFailsClosedWithoutReadyBinding(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
defer server.Close()
environment := readyDouyinEnvironment()
environment.Exit.HealthStatus = "unhealthy"
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: environment}
if _, err := browser.Get(context.Background(), testDouyinIdentityURL); err == nil || requests != 0 {
t.Fatalf("unready binding reached gateway: requests=%d err=%v", requests, err)
}
}
func TestDouyinGatewayBrowserDoesNotEchoCredentialOnFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.WriteHeader(http.StatusBadGateway)
_, _ = response.Write([]byte(`{"error":"private-session"}`))
}))
defer server.Close()
browser := douyinGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
err := browser.SetCookies(context.Background(), []douyin.Cookie{{Name: "sessionid", Value: "private-session", Domain: ".douyin.com", Path: "/"}})
if err == nil || strings.Contains(err.Error(), "private-session") {
t.Fatalf("gateway failure leaked credential: %v", err)
}
}
func readyDouyinEnvironment() hub.EnvironmentContext {
return hub.EnvironmentContext{Env: hub.Env{Alias: "account-a", Gateway: "gateway-a"}, AccountID: "account-a",
AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "binding-a", BindingVersion: 2,
Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "healthy"}, RuntimeInstanceID: "instance-a",
RuntimeID: "runtime-a", RuntimeNetworkID: "network-a"}
}