Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

634 lines
26 KiB
Python

# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Network management mixin for Docker sandboxes.
Provides network validation, endpoint resolution, egress sidecar lifecycle,
and bridge IP extraction. Mixed into DockerSandboxService.
"""
from __future__ import annotations
import json
import logging
import os
import socket
import time
import urllib.error
import urllib.request
from typing import Any, Dict, Optional
from docker.errors import DockerException, NotFound as DockerNotFound
from fastapi import HTTPException, status
from opensandbox_server.api.schema import Endpoint, NetworkPolicy
from opensandbox_server.services.constants import (
EGRESS_MODE_ENV,
EGRESS_RULES_ENV,
OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT,
OPENSANDBOX_EGRESS_TOKEN,
OPENSANDBOX_RUNTIME_MOUNT_PATH,
SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY,
SANDBOX_EMBEDDING_PROXY_PORT_LABEL,
SANDBOX_HTTP_PORT_LABEL,
SandboxErrorCodes,
)
from opensandbox_server.services.docker.port_allocator import (
normalize_container_port_spec,
normalize_port_bindings,
)
from opensandbox_server.services.endpoint_auth import (
build_egress_auth_headers,
merge_endpoint_headers,
)
from opensandbox_server.services.validators import (
ensure_credential_proxy_configured,
ensure_egress_configured,
ensure_egress_runtime_compatible,
)
logger = logging.getLogger(__name__)
def _running_inside_docker_container() -> bool:
"""Return True if the current process is running inside a Docker container."""
return os.path.exists("/.dockerenv")
def _docker_error_indicates_unsupported_ipv6_sysctls(exc: DockerException) -> bool:
"""Return True when Docker rejects IPv6-disable sysctls for the target daemon."""
message = str(exc).lower()
return (
"disable_ipv6" in message
and (
"/proc/sys/net/ipv6/" in message
or "no such file or directory" in message
or "sysctl" in message
)
)
HOST_NETWORK_MODE = "host"
BRIDGE_NETWORK_MODE = "bridge"
EGRESS_SIDECAR_LABEL = "opensandbox.io/egress-sidecar-for"
class DockerNetworkingMixin:
"""Mixin providing network validation, endpoint resolution, and egress sidecar."""
def _is_user_defined_network(self) -> bool:
"""Return True when network_mode is a named user-defined network (not host/bridge/none/container:*)."""
return (
self.network_mode not in {HOST_NETWORK_MODE, BRIDGE_NETWORK_MODE, "none"}
and not self.network_mode.startswith("container:")
)
def _validate_network_exists(self) -> None:
"""Verify the configured user-defined Docker network exists before creating a sandbox."""
if not self._is_user_defined_network():
return
try:
self.docker_client.networks.get(self.network_mode)
except DockerNotFound:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
f"Docker network '{self.network_mode}' does not exist. "
"Create it first with 'docker network create <name>'."
),
},
)
except DockerException as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.CONTAINER_START_FAILED,
"message": f"Failed to inspect Docker network '{self.network_mode}': {exc}",
},
) from exc
def _ensure_network_policy_support(self, request) -> None:
"""
Validate that network policy can be honored under the current runtime config.
This includes Docker-specific checks (network_mode) and common checks (egress.image).
"""
if not request.network_policy:
return
# Docker-specific validation: network_mode must be bridge
if self.network_mode == HOST_NETWORK_MODE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": "networkPolicy is not supported when docker network_mode=host.",
},
)
# User-defined networks cannot be combined with networkPolicy: the egress sidecar
# always runs on the default bridge, which would silently discard the configured network.
if self._is_user_defined_network():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
f"networkPolicy is not supported when docker network_mode='{self.network_mode}' "
"(user-defined network). Use network_mode='bridge' to enable network policy enforcement."
),
},
)
# Common validation: egress.image must be configured
ensure_egress_configured(request.network_policy, self.app_config.egress)
ensure_credential_proxy_configured(
request.credential_proxy, request.network_policy, self.app_config.egress
)
ensure_egress_runtime_compatible(request.network_policy, self.app_config.secure_runtime)
def _ensure_secure_access_support(self, request) -> None:
"""Validate that secure access can be honored under the current Docker runtime."""
if not request.secure_access:
return
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": (
"secureAccess is not supported when runtime.type='docker'. "
"Use the Kubernetes runtime to create secured sandboxes."
),
},
)
def get_endpoint(self, sandbox_id: str, port: int, resolve_internal: bool = False,
expires: Optional[int] = None) -> Endpoint:
"""
Get sandbox access endpoint.
Args:
sandbox_id: Unique sandbox identifier
port: Port number where the service is listening inside the sandbox
resolve_internal: If True, return the internal container IP (for proxy), ignoring router config.
expires: Not supported by Docker runtime.
Returns:
Endpoint: Public endpoint URL
Raises:
HTTPException: If sandbox not found, endpoint not available,
or expires is provided (Docker does not support signed routes).
"""
if expires is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.API_NOT_SUPPORTED,
"message": (
"Signed routes (expires parameter) are not supported when "
"runtime.type='docker'. Use the Kubernetes runtime for signed routes."
),
},
)
try:
self.validate_port(port)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PORT,
"message": str(exc),
},
) from exc
if resolve_internal:
container = self._get_container_by_sandbox_id(sandbox_id)
labels = container.attrs.get("Config", {}).get("Labels") or {}
# Sandboxes created with egress sidecar share the sidecar network namespace, so the
# main container's private IP is not a stable proxy target. In that case, treat the
# server-proxy target as the server-local host-mapped endpoint instead of a container IP.
if labels.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY):
return self._resolve_host_mapped_endpoint(
self._resolve_proxy_host(),
labels,
port,
include_egress_auth_headers=False,
)
return self._resolve_internal_endpoint(container, port)
public_host = self._resolve_public_host()
if self.network_mode == HOST_NETWORK_MODE:
endpoint = Endpoint(endpoint=f"{public_host}:{port}")
container = self._get_container_by_sandbox_id(sandbox_id)
labels = container.attrs.get("Config", {}).get("Labels") or {}
self._attach_egress_auth_headers(endpoint, labels, port)
return endpoint
# non-host mode (bridge / user-defined network)
container = self._get_container_by_sandbox_id(sandbox_id)
labels = container.attrs.get("Config", {}).get("Labels") or {}
return self._resolve_host_mapped_endpoint(public_host, labels, port)
def _resolve_host_mapped_endpoint(
self,
public_host: str,
labels: dict[str, str],
port: int,
*,
include_egress_auth_headers: bool = True,
) -> Endpoint:
execd_host_port = self._parse_host_port_label(
labels.get(SANDBOX_EMBEDDING_PROXY_PORT_LABEL),
SANDBOX_EMBEDDING_PROXY_PORT_LABEL,
)
http_host_port = self._parse_host_port_label(
labels.get(SANDBOX_HTTP_PORT_LABEL),
SANDBOX_HTTP_PORT_LABEL,
)
if port == 8080:
if http_host_port is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.NETWORK_MODE_ENDPOINT_UNAVAILABLE,
"message": "Missing host port mapping for container port 8080.",
},
)
endpoint = Endpoint(endpoint=f"{public_host}:{http_host_port}")
if include_egress_auth_headers:
self._attach_egress_auth_headers(endpoint, labels, port)
return endpoint
if execd_host_port is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.NETWORK_MODE_ENDPOINT_UNAVAILABLE,
"message": "Missing host port mapping for execd proxy port 44772.",
},
)
endpoint = Endpoint(endpoint=f"{public_host}:{execd_host_port}/proxy/{port}")
if include_egress_auth_headers:
self._attach_egress_auth_headers(endpoint, labels, port)
return endpoint
def _attach_egress_auth_headers(
self,
endpoint: Endpoint,
labels: dict[str, str],
port: int,
) -> None:
if port != 18080:
return
token = labels.get(SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY)
if not token:
return
endpoint.headers = merge_endpoint_headers(
endpoint.headers,
build_egress_auth_headers(token),
)
def _get_docker_host_ip(self) -> Optional[str]:
"""When running inside a container, return [docker].host_ip for endpoint URLs (if set)."""
ip = (self.app_config.docker.host_ip or "").strip()
return ip or None
def _resolve_public_host(self) -> str:
"""Resolve the host used in endpoint URLs. If [server].eip is set, use it directly without resolving host."""
eip_cfg = (self.app_config.server.eip or "").strip()
if eip_cfg:
return eip_cfg
host_cfg = (self.app_config.server.host or "").strip()
host_key = host_cfg.lower()
if host_key in {"", "0.0.0.0", "::"}:
if _running_inside_docker_container():
host_ip = self._get_docker_host_ip()
if host_ip:
return host_ip
return self._resolve_bind_ip(socket.AF_INET)
return host_cfg
def _resolve_proxy_host(self) -> str:
"""Resolve the server-local host used for proxying to host-mapped Docker endpoints.
This intentionally does not use ``server.eip`` because the proxy target must be reachable
from the server process itself, even in deployments without hairpin access to the public EIP.
"""
host_cfg = (self.app_config.server.host or "").strip()
host_key = host_cfg.lower()
if host_key in {"", "0.0.0.0", "::"}:
if _running_inside_docker_container():
host_ip = self._get_docker_host_ip()
if host_ip:
return host_ip
return "127.0.0.1"
return host_cfg
def _resolve_internal_endpoint(self, container, port: int) -> Endpoint:
"""Return the internal endpoint used when bypassing host mapping."""
if self.network_mode == HOST_NETWORK_MODE:
return Endpoint(endpoint=f"127.0.0.1:{port}")
ip_address = self._extract_bridge_ip(container)
return Endpoint(endpoint=f"{ip_address}:{port}")
# ---------------------------
# Common helpers for creation
# ---------------------------
def _cleanup_egress_sidecar(self, sandbox_id: str) -> None:
"""
Remove egress sidecar associated with sandbox_id (best effort).
"""
try:
containers = self.docker_client.containers.list(
all=True, filters={"label": f"{EGRESS_SIDECAR_LABEL}={sandbox_id}"}
)
except DockerException as exc:
logger.warning("sandbox=%s | failed to list egress sidecar: %s", sandbox_id, exc)
return
for container in containers:
try:
with self._docker_operation("cleanup egress sidecar", sandbox_id):
container.remove(force=True)
except DockerException as exc:
logger.warning(
"sandbox=%s | failed to remove egress sidecar %s: %s",
sandbox_id,
container.id,
exc,
)
def _start_egress_sidecar(
self,
sandbox_id: str,
network_policy: NetworkPolicy,
egress_token: str,
host_execd_port: int,
host_http_port: int,
extra_port_bindings: Optional[dict[str, tuple[str, int]]] = None,
egress_api_host_port: Optional[int] = None,
runtime_volume_name: Optional[str] = None,
credential_proxy_enabled: bool = False,
extra_env: Optional[Dict[str, Optional[str]]] = None,
):
sidecar_name = f"sandbox-egress-{sandbox_id}"
sidecar_labels = {
EGRESS_SIDECAR_LABEL: sandbox_id,
}
# Ensure sidecar image is available before create/start.
egress_image = self.app_config.egress.image if self.app_config.egress else None
if not egress_image:
raise ValueError("egress.image must be configured when networkPolicy is provided.")
self._ensure_image_available(egress_image, None, sandbox_id)
policy_payload = json.dumps(network_policy.model_dump(by_alias=True, exclude_none=True))
assert self.app_config.egress is not None # validated by ensure_egress_configured with networkPolicy
egress_mode = self.app_config.egress.mode
sidecar_env = [
f"{EGRESS_RULES_ENV}={policy_payload}",
f"{EGRESS_MODE_ENV}={egress_mode}",
f"{OPENSANDBOX_EGRESS_TOKEN}={egress_token}",
]
if credential_proxy_enabled:
sidecar_env.append(f"{OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT}=true")
if extra_env:
skip_keys = {OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT} if credential_proxy_enabled else set()
for key, value in extra_env.items():
if key not in skip_keys and value is not None:
sidecar_env.append(f"{key}={value}")
sidecar_port_bindings: dict[str, tuple[str, int]] = {
"44772": ("0.0.0.0", host_execd_port),
"8080": ("0.0.0.0", host_http_port),
}
if extra_port_bindings:
sidecar_port_bindings.update(extra_port_bindings)
base_sidecar_host_config_kwargs: dict[str, Any] = {
"network_mode": BRIDGE_NETWORK_MODE,
"cap_add": ["NET_ADMIN"],
"port_bindings": normalize_port_bindings(sidecar_port_bindings),
}
if runtime_volume_name:
base_sidecar_host_config_kwargs["binds"] = [
f"{runtime_volume_name}:{OPENSANDBOX_RUNTIME_MOUNT_PATH}:rw"
]
def build_sidecar_host_config(*, include_ipv6_sysctls: bool) -> Any:
sidecar_host_config_kwargs = dict(base_sidecar_host_config_kwargs)
if include_ipv6_sysctls:
# Optional: disable IPv6 in the shared namespace when egress.disable_ipv6 is set.
sidecar_host_config_kwargs["sysctls"] = {
"net.ipv6.conf.all.disable_ipv6": 1,
"net.ipv6.conf.default.disable_ipv6": 1,
"net.ipv6.conf.lo.disable_ipv6": 1,
}
return self.docker_client.api.create_host_config(**sidecar_host_config_kwargs)
include_ipv6_sysctls = self.app_config.egress.disable_ipv6
sidecar_host_config = build_sidecar_host_config(
include_ipv6_sysctls=include_ipv6_sysctls
)
sidecar_container = None
sidecar_container_id: Optional[str] = None
try:
try:
with self._docker_operation("create egress sidecar", sandbox_id):
sidecar_resp = self.docker_client.api.create_container(
image=egress_image,
name=sidecar_name,
host_config=sidecar_host_config,
labels=sidecar_labels,
environment=sidecar_env,
# Expose the ports that have host bindings so Docker publishes them in bridge mode.
ports=[normalize_container_port_spec(p) for p in sidecar_port_bindings.keys()],
)
except DockerException as exc:
if not (
include_ipv6_sysctls
and _docker_error_indicates_unsupported_ipv6_sysctls(exc)
):
raise
logger.warning(
"sandbox=%s | retry egress sidecar without IPv6 sysctls after daemon rejection: %s",
sandbox_id,
exc,
)
sidecar_host_config = build_sidecar_host_config(
include_ipv6_sysctls=False
)
with self._docker_operation("create egress sidecar", sandbox_id):
sidecar_resp = self.docker_client.api.create_container(
image=egress_image,
name=sidecar_name,
host_config=sidecar_host_config,
labels=sidecar_labels,
environment=sidecar_env,
# Expose the ports that have host bindings so Docker publishes them in bridge mode.
ports=[normalize_container_port_spec(p) for p in sidecar_port_bindings.keys()],
)
sidecar_container_id = sidecar_resp.get("Id")
if not sidecar_container_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.CONTAINER_START_FAILED,
"message": "Docker did not return an egress sidecar container ID.",
},
)
sidecar_container = self.docker_client.containers.get(sidecar_container_id)
with self._docker_operation("start egress sidecar", sandbox_id):
sidecar_container.start()
if egress_api_host_port is not None:
self._wait_for_egress_sidecar_ready(
sandbox_id,
egress_api_host_port,
egress_token,
)
return sidecar_container
except Exception as exc:
if sidecar_container is not None:
try:
with self._docker_operation("cleanup egress sidecar", sandbox_id):
sidecar_container.remove(force=True)
except DockerException as cleanup_exc:
logger.warning(
"Failed to cleanup egress sidecar for sandbox %s: %s",
sandbox_id,
cleanup_exc,
)
elif sidecar_container_id:
try:
with self._docker_operation("cleanup egress sidecar (API)", sandbox_id):
self.docker_client.api.remove_container(sidecar_container_id, force=True)
except DockerException as cleanup_exc:
logger.warning(
"Failed to cleanup egress sidecar for sandbox %s: %s",
sandbox_id,
cleanup_exc,
)
if isinstance(exc, HTTPException):
raise exc
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.CONTAINER_START_FAILED,
"message": "Egress sidecar container failed to start.",
},
) from exc
def _wait_for_egress_sidecar_ready(
self,
sandbox_id: str,
host_port: int,
egress_token: str,
timeout_seconds: float = 30.0,
) -> None:
deadline = time.monotonic() + timeout_seconds
url = f"http://{self._resolve_proxy_host()}:{host_port}/healthz"
last_error: Exception | None = None
while time.monotonic() < deadline:
request = urllib.request.Request(
url,
headers=build_egress_auth_headers(egress_token),
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=1.0) as response: # nosec B310 - local Docker endpoint
if 200 <= response.status < 300:
return
except urllib.error.HTTPError as exc:
last_error = exc
if exc.code >= 500:
time.sleep(0.2)
continue
except Exception as exc: # noqa: BLE001
last_error = exc
time.sleep(0.2)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.CONTAINER_START_FAILED,
"message": (
f"Egress sidecar did not become ready within {timeout_seconds:.0f}s "
f"for sandbox {sandbox_id}: {last_error}"
),
},
)
@staticmethod
def _parse_host_port_label(value: Optional[str], label_name: str) -> Optional[int]:
if not value:
return None
try:
port = int(value)
if port <= 0 or port > 65535:
raise ValueError
return port
except ValueError:
logger.warning("Invalid port label %s=%s", label_name, value)
return None
def _extract_bridge_ip(self, container) -> str:
"""Extract the IP address assigned to a container on a bridge or user-defined network.
For user-defined networks, the top-level ``NetworkSettings.IPAddress`` is empty;
the IP lives under ``NetworkSettings.Networks[<network-name>].IPAddress``.
This method prefers the configured ``network_mode`` entry when it is a user-defined
network, then falls back to any non-empty entry for robustness.
"""
network_settings = container.attrs.get("NetworkSettings", {}) or {}
networks = network_settings.get("Networks", {}) or {}
ip_address: Optional[str] = None
if self._is_user_defined_network():
# Prefer the explicit network entry for the configured named network.
net_conf = networks.get(self.network_mode) or {}
ip_address = net_conf.get("IPAddress") or None
if not ip_address:
# Default bridge path (or fallback): check the top-level IPAddress first.
ip_address = network_settings.get("IPAddress") or None
if not ip_address:
# Last resort: iterate all network entries and take the first populated IP.
for net_conf in networks.values():
if net_conf and net_conf.get("IPAddress"):
ip_address = net_conf.get("IPAddress")
break
if not ip_address:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.NETWORK_MODE_ENDPOINT_UNAVAILABLE,
"message": "Container is running but has no assigned IP address.",
},
)
return ip_address