Files
creator-hub/cmd/docker_gateway/gateway.py
T

2106 lines
80 KiB
Python

"""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 nullcontext, 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,
XiaohongshuBrowser,
is_xiaohongshu_share_url,
)
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_PROFILE_OTHER_PATH = "/aweme/v1/web/user/profile/other/"
DOUYIN_IDENTITY_URL = IDENTITY_URL
DOUYIN_WORKS_PATH = WORKS_PATH
DOUYIN_COMMENTS_PATH = COMMENTS_PATH
XHS_ACCOUNT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$")
XHS_IDENTITY_PATH = "/api/sns/web/v2/user/me"
XHS_USER_POSTED_PATH = "/api/sns/web/v1/user_posted"
XHS_COMMENTS_PATH = "/api/sns/web/v2/comment/page"
XHS_SEARCH_PATH = "/api/sns/web/v2/search/notes"
XHS_FEED_PATH = "/api/sns/web/v1/feed"
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,
xiaohongshu_browser: XiaohongshuBrowser | None = None,
external_cdp: Mapping[str, object] | None = None,
) -> None:
self.docker = docker
self.network = network
self.token = token
self.self_name = self_name
self.external_cdp = dict(external_cdp or {})
self.browser = browser or DouyinBrowser(self._browser_endpoint)
self.xiaohongshu_browser = xiaohongshu_browser or XiaohongshuBrowser(
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:
if self.external_cdp:
if alias != self.external_cdp["alias"]:
raise GenerationConflict("external browser alias does not match")
return cast(str, self.external_cdp["url"])
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]:
if self.external_cdp:
return [
{
"id": self.external_cdp["runtime_id"],
"alias": self.external_cdp["alias"],
"name": self.external_cdp["alias"],
"state": "external",
"status": "external CDP",
"endpoint": self.external_cdp["url"],
"binding_version": self.external_cdp["binding_version"],
"network_exit_id": "",
"network_id": self.external_cdp["network_id"],
"proxy_ready": True,
}
]
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 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 get_douyin_media(self, alias: str, input: dict) -> dict:
target = input.get("url", "")
if not valid_douyin_generation(input) or not isinstance(target, str):
raise RequestError("invalid restricted browser request", 400)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
response = self.browser.get_media(alias, target)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
"Douyin media download failed alias=%s reason=%s",
alias,
str(exc),
)
raise RequestError("restricted browser media download failed") from exc
return {
"status": response.status,
"content_type": response.content_type,
"body_base64": response.body_base64,
}
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_login_qr(self, alias: str, input: dict) -> dict:
if not valid_douyin_generation(input):
raise RequestError("invalid Douyin login QR request", 400)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
screen = self.browser.login_qr(alias)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
"Douyin login screen capture failed alias=%s reason=%s",
alias,
str(exc),
)
raise RequestError("Douyin login screen could not be captured") from exc
return {
"content_type": screen.content_type,
"body_base64": screen.body_base64,
"qr_detected": screen.qr_detected,
}
def get_xiaohongshu(self, alias: str, input: dict) -> dict:
target = input.get("url", "")
if not valid_xiaohongshu_generation(input) or not valid_xiaohongshu_url(target):
raise RequestError("invalid restricted Xiaohongshu request", 400)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
response = self.xiaohongshu_browser.get(alias, target)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
"Xiaohongshu GET failed alias=%s reason=%s", alias, str(exc)
)
raise RequestError("restricted Xiaohongshu operation failed") from exc
return {
"status": response.status,
"body": response.body,
"challenge": response.challenge,
}
def post_xiaohongshu(self, alias: str, input: dict) -> dict:
target = input.get("url", "")
body = input.get("body")
if (
not valid_xiaohongshu_generation(input)
or not valid_xhs_post_url(target)
or not isinstance(body, dict)
):
raise RequestError("invalid restricted Xiaohongshu POST request", 400)
try:
encoded = json.dumps(
body, ensure_ascii=False, separators=(",", ":")
).encode()
except (TypeError, ValueError) as exc:
raise RequestError("invalid restricted Xiaohongshu POST body", 400) from exc
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
response = self.xiaohongshu_browser.post(alias, target, encoded)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
"Xiaohongshu POST failed alias=%s reason=%s", alias, str(exc)
)
raise RequestError("restricted Xiaohongshu operation failed") from exc
return {
"status": response.status,
"body": response.body,
"challenge": response.challenge,
}
def resolve_xiaohongshu(self, alias: str, input: dict) -> dict:
target = input.get("url", "")
if not valid_xiaohongshu_generation(input) or not valid_xiaohongshu_source_url(
target
):
raise RequestError("invalid restricted Xiaohongshu source URL", 400)
if not is_xiaohongshu_share_url(target):
return {"url": target}
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
resolved = self.xiaohongshu_browser.resolve(alias, target)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
"Xiaohongshu share resolution failed alias=%s reason=%s",
alias,
str(exc),
)
raise RequestError(
"restricted Xiaohongshu share resolution failed"
) from exc
if not valid_xiaohongshu_page_url(resolved):
raise RequestError("Xiaohongshu share resolved to an unsupported URL", 502)
return {"url": resolved}
def get_xiaohongshu_media(self, alias: str, input: dict) -> dict:
target = input.get("url", "")
if not valid_xiaohongshu_generation(input) or not valid_xiaohongshu_media_url(
target
):
raise RequestError("invalid restricted Xiaohongshu media request", 400)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
response = self.xiaohongshu_browser.get_media(alias, target)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
"Xiaohongshu media download failed alias=%s reason=%s",
alias,
str(exc),
)
raise RequestError(
"restricted Xiaohongshu media download failed"
) from exc
return {
"status": response.status,
"content_type": response.content_type,
"body_base64": response.body_base64,
}
def xiaohongshu_identity(self, alias: str, input: dict) -> dict:
expected_account_key = input.get("expected_account_key", "")
if (
not valid_xiaohongshu_generation(input)
or not isinstance(expected_account_key, str)
or not XHS_ACCOUNT_KEY_RE.fullmatch(expected_account_key)
):
raise RequestError("invalid Xiaohongshu identity request", 400)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
identity = self.xiaohongshu_browser.identity(alias)
except DouyinError as exc:
LOG.warning(
"Xiaohongshu identity verification failed alias=%s reason=%s",
alias,
str(exc),
)
raise RequestError(
"Xiaohongshu login identity could not be verified"
) from exc
if identity.get("uid") != expected_account_key:
raise RequestError(
"Xiaohongshu identity does not match the expected account", 409
)
return identity
def douyin_message_history(self, alias: str, input: dict) -> dict:
expected_uid = input.get("expected_uid", "")
target_uid = input.get("target_uid", "")
limit = input.get("limit", 100)
cursor = input.get("cursor", "")
if (
not valid_douyin_generation(input)
or not isinstance(expected_uid, str)
or not isinstance(target_uid, str)
or not isinstance(cursor, str)
or len(cursor) > 500
or type(limit) is not int
or not UID_RE.fullmatch(expected_uid)
or not UID_RE.fullmatch(target_uid)
or expected_uid == target_uid
or not 1 <= limit <= 200
):
raise RequestError("invalid Douyin message history request", 400)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
try:
if cursor:
result = self.browser.message_history(
alias, expected_uid, target_uid, limit, cursor=cursor
)
else:
result = self.browser.message_history(
alias, expected_uid, target_uid, limit
)
self._require_douyin_generation(alias, input)
except DouyinError as exc:
LOG.warning(
"Douyin message history failed alias=%s reason=%s",
alias,
str(exc),
)
raise RequestError("Douyin message history failed") from exc
return result
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)
operation_id = input.get("operation_id", "")
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 not isinstance(operation_id, str)
or len(operation_id) > 200
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)
if action == "repost":
raise RequestError("ACTION_UNAVAILABLE", 409)
with self._alias_lock(alias):
self._require_douyin_generation(alias, input)
self._claim_action(
alias,
str(input.get("runtime_id", "")),
operation_id,
)
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, operation_id)
raise RequestError("Douyin action failed") from exc
except Exception:
self._release_action_ownership(alias, operation_id)
raise
else:
self._release_action_ownership(alias, operation_id)
return result
def _handle_douyin_action_error(
self, alias: str, action: str, error: DouyinError, operation_id: str = ""
) -> None:
if getattr(error, "uncertain", False) or "timed out" in str(error).lower():
self._retain_action_ownership(alias, operation_id)
else:
self._release_action_ownership(alias, operation_id)
LOG.warning(
"Douyin action failed alias=%s action=%s reason=%s",
alias,
action,
str(error),
)
def _claim_action(
self, alias: str, runtime_id: str = "", operation_id: 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)
if runtime_id:
marker = self.browser.action_ownership(alias)
if marker is not None:
raise RequestError("previous Douyin action outcome is uncertain", 409)
self.browser.set_action_ownership(
alias,
{
"runtime_id": runtime_id,
"operation_id": operation_id,
"started_at": time.time(),
},
)
with self._action_ownership_lock:
self._uncertain_actions.pop(alias, None)
self._uncertain_actions[alias] = 0.0
def _release_action_ownership(self, alias: str, operation_id: str = "") -> None:
if operation_id:
self.browser.clear_action_ownership(alias, operation_id)
with self._action_ownership_lock:
self._uncertain_actions.pop(alias, None)
def _retain_action_ownership(self, alias: str, operation_id: 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:
LOG.warning(
"Douyin event listener start failed alias=%s uid=%s reason=%s",
alias,
expected_uid,
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:
LOG.warning(
"Douyin event listener poll failed alias=%s reason=%s",
alias,
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:
if self.external_cdp:
if (
alias != self.external_cdp["alias"]
or any(
input.get(key) != self.external_cdp[key]
for key in ("binding_version", "runtime_id", "network_id")
)
or input.get("network_exit_id", "")
):
raise RequestError(
"external browser generation does not match request", 409
)
return
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):
if self.external_cdp:
if alias != self.external_cdp["alias"]:
raise GenerationConflict("external browser alias does not match")
return nullcontext()
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})/xiaohongshu/(get|post|media|identity|resolve)",
path,
)
if match:
alias, action = match.groups()
if action == "get" and method == "POST":
return gateway.get_xiaohongshu(alias, body)
if action == "post" and method == "POST":
return gateway.post_xiaohongshu(alias, body)
if action == "media" and method == "POST":
return gateway.get_xiaohongshu_media(alias, body)
if action == "resolve" and method == "POST":
return gateway.resolve_xiaohongshu(alias, body)
if action == "identity" and method == "POST":
return gateway.xiaohongshu_identity(alias, body)
match = re.fullmatch(
r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/douyin/(get|media|identity|login-qr|action|messages|events)",
path,
)
if match:
alias, action = match.groups()
if action == "get" and method == "POST":
return gateway.get_douyin(alias, body)
if action == "media" and method == "POST":
return gateway.get_douyin_media(alias, body)
if action == "identity" and method == "POST":
return gateway.douyin_identity(alias, body)
if action == "login-qr" and method == "POST":
return gateway.douyin_login_qr(alias, body)
if action == "action" and method == "POST":
return gateway.douyin_action(alias, body)
if action == "messages" and method == "POST":
return gateway.douyin_message_history(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_xiaohongshu_generation(value: dict) -> bool:
return valid_douyin_generation(value)
def valid_xhs_query(
query: object, allowed: set[str], required: set[str] | None = None
) -> bool:
if not isinstance(query, dict) or not isinstance(allowed, set):
return False
required = required or set()
if not required.issubset(query) or not set(query).issubset(allowed):
return False
for key, values in query.items():
if not isinstance(key, str) or not isinstance(values, list) or len(values) != 1:
return False
if (
not isinstance(values[0], str)
or len(values[0]) > 2048
or "\r" in values[0]
or "\n" in values[0]
):
return False
return True
def _valid_xhs_host(parsed: object, host: str) -> bool:
return (
getattr(parsed, "scheme", "") == "https"
and getattr(parsed, "hostname", None) == host
and getattr(parsed, "port", None) is None
and getattr(parsed, "username", None) is None
and getattr(parsed, "password", None) is None
and getattr(parsed, "fragment", "") == ""
)
def valid_xhs_url(raw: object) -> bool:
if not isinstance(raw, str):
return False
try:
parsed = urlsplit(raw)
query = parse_qs(parsed.query, keep_blank_values=True)
except ValueError:
return False
if (
_valid_xhs_host(parsed, "edith.xiaohongshu.com")
and parsed.path == XHS_IDENTITY_PATH
):
return not query
if (
_valid_xhs_host(parsed, "edith.xiaohongshu.com")
and parsed.path == XHS_USER_POSTED_PATH
):
return (
valid_xhs_query(
query,
{
"user_id",
"cursor",
"num",
"image_formats",
"xsec_source",
"xsec_token",
},
{"user_id", "num"},
)
and bool(XHS_ACCOUNT_KEY_RE.fullmatch(query["user_id"][0]))
and query["num"] == ["30"]
)
if (
_valid_xhs_host(parsed, "edith.xiaohongshu.com")
and parsed.path == XHS_COMMENTS_PATH
):
return valid_xhs_query(
query,
{
"note_id",
"cursor",
"top_comment_id",
"image_formats",
"xsec_source",
"xsec_token",
},
{"note_id", "cursor", "top_comment_id"},
) and bool(XHS_ACCOUNT_KEY_RE.fullmatch(query["note_id"][0]))
return False
def valid_xiaohongshu_url(raw: object) -> bool:
return valid_xhs_url(raw)
def valid_xiaohongshu_page_url(raw: object) -> bool:
if not isinstance(raw, str):
return False
try:
parsed = urlsplit(raw)
query = parse_qs(parsed.query, keep_blank_values=True)
except ValueError:
return False
if not _valid_xhs_host(parsed, "www.xiaohongshu.com") or not valid_xhs_query(
query, {"xsec_source", "xsec_token"}
):
return False
parts = parsed.path.strip("/").split("/")
return (
len(parts) == 2
and parts[0] == "explore"
and bool(XHS_ACCOUNT_KEY_RE.fullmatch(parts[1]))
) or (
len(parts) == 3
and parts[:2] == ["user", "profile"]
and bool(XHS_ACCOUNT_KEY_RE.fullmatch(parts[2]))
)
def valid_xiaohongshu_source_url(raw: object) -> bool:
return valid_xiaohongshu_page_url(raw) or is_xiaohongshu_share_url(raw)
def valid_xhs_post_url(raw: object) -> bool:
if not isinstance(raw, str):
return False
try:
parsed = urlsplit(raw)
query = parse_qs(parsed.query, keep_blank_values=True)
except ValueError:
return False
return (
_valid_xhs_host(parsed, "so.xiaohongshu.com")
and parsed.path == XHS_SEARCH_PATH
and not query
) or (
_valid_xhs_host(parsed, "edith.xiaohongshu.com")
and parsed.path == XHS_FEED_PATH
and not query
)
def valid_xiaohongshu_media_url(raw: object) -> bool:
if not isinstance(raw, str):
return False
try:
parsed = urlsplit(raw)
query = parse_qs(parsed.query, keep_blank_values=True)
except ValueError:
return False
if not _valid_xhs_host(parsed, "www.xiaohongshu.com"):
return False
parts = parsed.path.strip("/").split("/")
return (
len(parts) == 2
and parts[0] == "explore"
and bool(XHS_ACCOUNT_KEY_RE.fullmatch(parts[1]))
and valid_xhs_query(query, {"xsec_source", "xsec_token"})
)
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_PROFILE_OTHER_PATH:
return valid_douyin_profile_query(query)
if parsed.path == DOUYIN_WORKS_PATH:
return valid_douyin_api_query(query, "sec_user_id", "max_cursor")
if parsed.path == DOUYIN_COMMENTS_PATH:
return valid_douyin_api_query(query, "aweme_id", "cursor")
return False
def valid_douyin_profile_query(query: dict[str, list[str]]) -> bool:
account_fields = [field for field in ("user_id", "sec_user_id") if field in query]
return (
len(query) == 3
and query.get("aid") == ["6383"]
and query.get("device_platform") == ["webapp"]
and len(account_fields) == 1
and valid_account_key_query(query, account_fields[0])
)
def valid_douyin_api_query(
query: dict[str, list[str]], account_field: str, cursor_field: str
) -> bool:
return (
len(query) == 5
and query.get("aid") == ["6383"]
and query.get("device_platform") == ["webapp"]
and valid_account_key_query(query, account_field)
and query.get("count") == ["20"]
and numeric_cursor(query.get(cursor_field))
)
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()
cdp_url = env.get("BROWSER_CDP_URL", "").strip()
cdp_target_id = env.get("BROWSER_CDP_TARGET_ID", "").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")
external_cdp = None
if cdp_url:
if not valid_cdp_url(cdp_url) or (
cdp_target_id and not re.fullmatch(r"^[A-Za-z0-9_-]{1,128}$", cdp_target_id)
):
raise ValueError("BROWSER_CDP_URL or BROWSER_CDP_TARGET_ID is invalid")
try:
binding_version = int(env.get("BROWSER_CDP_BINDING_VERSION", "1"))
except (TypeError, ValueError) as exc:
raise ValueError("BROWSER_CDP_BINDING_VERSION must be an integer") from exc
external_cdp = {
"url": cdp_url,
"target_id": cdp_target_id,
"alias": env.get("BROWSER_CDP_ALIAS", "local-cdp").strip(),
"runtime_id": env.get("BROWSER_CDP_RUNTIME_ID", "0" * 64).strip(),
"network_id": env.get("BROWSER_CDP_NETWORK_ID", "local-cdp").strip(),
"binding_version": binding_version,
}
if (
not RUNTIME_ID_RE.fullmatch(external_cdp["alias"])
or not CONTAINER_ID_RE.fullmatch(external_cdp["runtime_id"])
or not EXIT_ID_RE.fullmatch(external_cdp["network_id"])
or external_cdp["binding_version"] < 1
):
raise ValueError("BROWSER_CDP generation is invalid")
return {
"listen": (host, port),
"docker_socket": socket_path,
"network": network,
"token": token,
"external_cdp": external_cdp,
}
def valid_cdp_url(raw: str) -> bool:
try:
parsed = urlsplit(raw)
port = parsed.port
except ValueError:
return False
return (
parsed.scheme == "http"
and bool(parsed.hostname)
and port is not None
and parsed.path in ("", "/")
and not parsed.username
and not parsed.password
and not parsed.query
and not parsed.fragment
)
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"])
external_cdp = config["external_cdp"]
browser = None
xiaohongshu_browser = None
if external_cdp:
def endpoint(_alias: str) -> str:
return cast(str, external_cdp["url"])
browser = DouyinBrowser(
endpoint, target_id=cast(str, external_cdp["target_id"])
)
xiaohongshu_browser = XiaohongshuBrowser(
endpoint, target_id=cast(str, external_cdp["target_id"])
)
gateway = Gateway(
docker,
config["network"],
config["token"],
socket.gethostname(),
browser=browser,
xiaohongshu_browser=xiaohongshu_browser,
external_cdp=external_cdp,
)
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()