Skip to content

Commit 56ba428

Browse files
authored
Merge pull request opensandbox-group#733 from Pangjiping/fix/server/windows-networkpolicy
fix(server): fix sandbox port expose bug under windows and egress
2 parents 282df42 + d9335a1 commit 56ba428

3 files changed

Lines changed: 192 additions & 18 deletions

File tree

server/opensandbox_server/services/docker.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@
6363
)
6464
from opensandbox_server.config import AppConfig, get_config
6565
from opensandbox_server.services.docker_diagnostics import DockerDiagnosticsMixin
66-
from opensandbox_server.services.docker_port_allocator import allocate_port_bindings
66+
from opensandbox_server.services.docker_port_allocator import (
67+
allocate_port_bindings,
68+
normalize_container_port_spec,
69+
normalize_port_bindings,
70+
)
6771
from opensandbox_server.services.docker_windows_profile import (
6872
apply_windows_runtime_host_config_defaults,
6973
fetch_execd_install_bat,
@@ -1197,26 +1201,40 @@ def _provision_sandbox(
11971201
volume_binds = self._build_volume_binds(request.volumes, pvc_inspect_cache)
11981202

11991203
host_config_kwargs: Dict[str, Any]
1200-
exposed_ports: Optional[list[str]] = None
1204+
exposed_ports: Optional[list[str]] = ["44772", "8080"]
1205+
if requested_windows_profile:
1206+
# dockur/windows exposes RDP and noVNC/web UI on these ports.
1207+
# https://github.com/dockur/windows/blob/master/Dockerfile
1208+
exposed_ports.extend(["3389/tcp", "3389/udp", "8006/tcp"])
1209+
container_exposed_ports: Optional[list[str]] = exposed_ports
12011210

12021211
if request.network_policy:
12031212
egress_token = generate_egress_token()
12041213
labels[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] = egress_token
1205-
sidecar_port_bindings = allocate_port_bindings(["44772", "8080"])
1214+
sidecar_port_bindings = allocate_port_bindings(exposed_ports)
12061215
host_execd_port = sidecar_port_bindings["44772"][1]
12071216
host_http_port = sidecar_port_bindings["8080"][1]
1217+
extra_sidecar_port_bindings = {
1218+
port: binding
1219+
for port, binding in sidecar_port_bindings.items()
1220+
if port not in {"44772", "8080"}
1221+
}
12081222
sidecar_container = self._start_egress_sidecar(
12091223
sandbox_id=sandbox_id,
12101224
network_policy=request.network_policy,
12111225
egress_token=egress_token,
12121226
host_execd_port=host_execd_port,
12131227
host_http_port=host_http_port,
1228+
extra_port_bindings=extra_sidecar_port_bindings,
12141229
)
12151230
labels[SANDBOX_EMBEDDING_PROXY_PORT_LABEL] = str(host_execd_port)
12161231
labels[SANDBOX_HTTP_PORT_LABEL] = str(host_http_port)
12171232
host_config_kwargs = self._base_host_config_kwargs(
12181233
effective_mem_limit, effective_nano_cpus, f"container:{sidecar_container.id}"
12191234
)
1235+
# Container network namespace is shared with sidecar. Docker rejects
1236+
# exposing ports on the main container in "container:<id>" mode.
1237+
container_exposed_ports = None
12201238
# Drop NET_ADMIN for the main container; only the sidecar should keep it
12211239
cap_drop = set(host_config_kwargs.get("cap_drop") or [])
12221240
cap_drop.add("NET_ADMIN")
@@ -1227,17 +1245,14 @@ def _provision_sandbox(
12271245
effective_mem_limit, effective_nano_cpus, self.network_mode
12281246
)
12291247
if self.network_mode != HOST_NETWORK_MODE:
1230-
exposed_ports = ["44772", "8080"]
1231-
if requested_windows_profile:
1232-
# dockur/windows exposes RDP and noVNC/web UI on these ports.
1233-
# https://github.com/dockur/windows/blob/master/Dockerfile
1234-
exposed_ports.extend(["3389/tcp", "3389/udp", "8006/tcp"])
12351248
port_bindings = allocate_port_bindings(exposed_ports)
12361249
host_execd_port = port_bindings["44772"][1]
12371250
host_http_port = port_bindings["8080"][1]
1238-
host_config_kwargs["port_bindings"] = port_bindings
1251+
host_config_kwargs["port_bindings"] = normalize_port_bindings(port_bindings)
12391252
labels[SANDBOX_EMBEDDING_PROXY_PORT_LABEL] = str(host_execd_port)
12401253
labels[SANDBOX_HTTP_PORT_LABEL] = str(host_http_port)
1254+
else:
1255+
exposed_ports = None
12411256

12421257
# Inject volume bind mounts into Docker host config
12431258
if volume_binds:
@@ -1260,7 +1275,7 @@ def _provision_sandbox(
12601275
labels,
12611276
environment,
12621277
host_config_kwargs,
1263-
exposed_ports,
1278+
container_exposed_ports,
12641279
request.platform,
12651280
)
12661281
except Exception:
@@ -2336,6 +2351,7 @@ def _start_egress_sidecar(
23362351
egress_token: str,
23372352
host_execd_port: int,
23382353
host_http_port: int,
2354+
extra_port_bindings: Optional[dict[str, tuple[str, int]]] = None,
23392355
):
23402356
sidecar_name = f"sandbox-egress-{sandbox_id}"
23412357
sidecar_labels = {
@@ -2357,13 +2373,17 @@ def _start_egress_sidecar(
23572373
f"{OPENSANDBOX_EGRESS_TOKEN}={egress_token}",
23582374
]
23592375

2376+
sidecar_port_bindings: dict[str, tuple[str, int]] = {
2377+
"44772": ("0.0.0.0", host_execd_port),
2378+
"8080": ("0.0.0.0", host_http_port),
2379+
}
2380+
if extra_port_bindings:
2381+
sidecar_port_bindings.update(extra_port_bindings)
2382+
23602383
sidecar_host_config_kwargs: dict[str, Any] = {
23612384
"network_mode": BRIDGE_NETWORK_MODE,
23622385
"cap_add": ["NET_ADMIN"],
2363-
"port_bindings": {
2364-
"44772": ("0.0.0.0", host_execd_port),
2365-
"8080": ("0.0.0.0", host_http_port),
2366-
},
2386+
"port_bindings": normalize_port_bindings(sidecar_port_bindings),
23672387
}
23682388
if self.app_config.egress.disable_ipv6:
23692389
# Optional: disable IPv6 in the shared namespace when egress.disable_ipv6 is set.
@@ -2388,7 +2408,7 @@ def _start_egress_sidecar(
23882408
labels=sidecar_labels,
23892409
environment=sidecar_env,
23902410
# Expose the ports that have host bindings so Docker publishes them in bridge mode.
2391-
ports=["44772", "8080"],
2411+
ports=[normalize_container_port_spec(p) for p in sidecar_port_bindings.keys()],
23922412
)
23932413
sidecar_container_id = sidecar_resp.get("Id")
23942414
if not sidecar_container_id:
@@ -2460,7 +2480,11 @@ def _create_and_start_container(
24602480
container_kwargs = {
24612481
"image": image_uri,
24622482
"command": bootstrap_command,
2463-
"ports": exposed_ports,
2483+
"ports": (
2484+
[normalize_container_port_spec(p) for p in exposed_ports]
2485+
if exposed_ports
2486+
else None
2487+
),
24642488
"name": f"sandbox-{sandbox_id}",
24652489
"environment": environment,
24662490
"labels": labels,

server/opensandbox_server/services/docker_port_allocator.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@
2323
from opensandbox_server.services.constants import SandboxErrorCodes
2424

2525

26+
def normalize_container_port_spec(port_spec: str) -> str:
27+
token = str(port_spec).strip()
28+
if token.endswith("/tcp"):
29+
return token[:-4]
30+
return token
31+
32+
33+
def normalize_port_bindings(
34+
port_bindings: dict[str, tuple[str, int]],
35+
) -> dict[str, tuple[str, int]]:
36+
"""
37+
Normalize binding keys to docker-py canonical forms.
38+
39+
Docker port bindings accept "port" for tcp and "port/udp" for udp.
40+
"""
41+
normalized: dict[str, tuple[str, int]] = {}
42+
for container_port, binding in port_bindings.items():
43+
normalized_key = normalize_container_port_spec(container_port)
44+
normalized[normalized_key] = binding
45+
return normalized
46+
47+
2648
def allocate_host_port(
2749
min_port: int = 40000,
2850
max_port: int = 60000,

server/tests/test_docker_service.py

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,54 @@ def host_cfg_side_effect(**kwargs):
962962
hc2 = mock_client.api.create_host_config.call_args.kwargs
963963
assert hc2["sysctls"]["net.ipv6.conf.all.disable_ipv6"] == 1
964964

965+
966+
@patch("opensandbox_server.services.docker.docker")
967+
def test_egress_sidecar_normalizes_windows_port_bindings(mock_docker):
968+
mock_client = MagicMock()
969+
mock_client.containers.list.return_value = []
970+
971+
def host_cfg_side_effect(**kwargs):
972+
return kwargs
973+
974+
sidecar_container = MagicMock()
975+
mock_client.api.create_host_config.side_effect = host_cfg_side_effect
976+
mock_client.api.create_container.return_value = {"Id": "sidecar-id"}
977+
mock_client.containers.get.return_value = sidecar_container
978+
mock_docker.from_env.return_value = mock_client
979+
980+
cfg = _app_config()
981+
cfg.docker.network_mode = "bridge"
982+
cfg.egress = EgressConfig(image="egress:latest", disable_ipv6=False)
983+
service = DockerSandboxService(config=cfg)
984+
985+
with (
986+
patch.object(service, "_ensure_image_available"),
987+
patch.object(service, "_docker_operation") as mock_op,
988+
):
989+
mock_op.return_value.__enter__.return_value = None
990+
mock_op.return_value.__exit__.return_value = None
991+
service._start_egress_sidecar(
992+
"sandbox-id",
993+
NetworkPolicy(defaultAction="deny", egress=[]),
994+
egress_token="egress-token",
995+
host_execd_port=44772,
996+
host_http_port=8080,
997+
extra_port_bindings={
998+
"3389/tcp": ("0.0.0.0", 53389),
999+
"3389/udp": ("0.0.0.0", 53390),
1000+
"8006/tcp": ("0.0.0.0", 58006),
1001+
},
1002+
)
1003+
1004+
hc_kwargs = mock_client.api.create_host_config.call_args.kwargs
1005+
assert "3389" in hc_kwargs["port_bindings"]
1006+
assert "3389/udp" in hc_kwargs["port_bindings"]
1007+
assert "8006" in hc_kwargs["port_bindings"]
1008+
sidecar_kwargs = mock_client.api.create_container.call_args.kwargs
1009+
assert "3389" in sidecar_kwargs["ports"]
1010+
assert "3389/udp" in sidecar_kwargs["ports"]
1011+
assert "8006" in sidecar_kwargs["ports"]
1012+
9651013
def test_expire_cleans_sidecar():
9661014
service = DockerSandboxService(config=_app_config())
9671015
mock_container = MagicMock()
@@ -1327,9 +1375,9 @@ async def test_create_sandbox_windows_profile_injects_runtime_defaults(mock_dock
13271375
port_bindings = host_config_kwargs["port_bindings"]
13281376
assert "44772" in port_bindings
13291377
assert "8080" in port_bindings
1330-
assert "3389/tcp" in port_bindings
1378+
assert "3389" in port_bindings
13311379
assert "3389/udp" in port_bindings
1332-
assert "8006/tcp" in port_bindings
1380+
assert "8006" in port_bindings
13331381

13341382

13351383
@pytest.mark.asyncio
@@ -1496,6 +1544,86 @@ async def test_create_sandbox_windows_profile_accepts_dockur_demo_like_request(m
14961544
assert response.platform.os == "windows"
14971545
assert response.platform.arch == "amd64"
14981546

1547+
1548+
@pytest.mark.asyncio
1549+
@patch("opensandbox_server.services.docker.docker")
1550+
async def test_create_sandbox_windows_profile_with_network_policy_maps_windows_ports(mock_docker):
1551+
mock_client = MagicMock()
1552+
mock_client.containers.list.return_value = []
1553+
mock_docker.from_env.return_value = mock_client
1554+
1555+
cfg = _app_config()
1556+
cfg.runtime.execd_image = "ghcr.io/opensandbox/execd:v1.0.11"
1557+
cfg.docker.network_mode = "bridge"
1558+
cfg.egress = EgressConfig(image="opensandbox/egress:latest")
1559+
service = DockerSandboxService(config=cfg)
1560+
request = CreateSandboxRequest(
1561+
image=ImageSpec(uri="dockurr/windows:latest"),
1562+
resourceLimits=ResourceLimits(
1563+
root={
1564+
"cpu": "4",
1565+
"memory": "8G",
1566+
"disk": "64G",
1567+
}
1568+
),
1569+
env={"VERSION": "11"},
1570+
entrypoint=["cmd", "/c", "echo ready"],
1571+
platform=PlatformSpec(os="windows", arch="amd64"),
1572+
networkPolicy=NetworkPolicy(default_action="deny", egress=[]),
1573+
)
1574+
created_container = MagicMock()
1575+
created_container.image.attrs = {"Os": "windows", "Architecture": "amd64"}
1576+
sidecar = MagicMock()
1577+
sidecar.id = "sidecar-123"
1578+
1579+
with (
1580+
patch(
1581+
"opensandbox_server.services.docker.validate_windows_runtime_prerequisites",
1582+
return_value=None,
1583+
),
1584+
patch("opensandbox_server.services.docker.generate_egress_token", return_value="egress-token"),
1585+
patch(
1586+
"opensandbox_server.services.docker.allocate_port_bindings",
1587+
return_value={
1588+
"44772": ("0.0.0.0", 51664),
1589+
"8080": ("0.0.0.0", 48891),
1590+
"3389/tcp": ("0.0.0.0", 53389),
1591+
"3389/udp": ("0.0.0.0", 53390),
1592+
"8006/tcp": ("0.0.0.0", 58006),
1593+
},
1594+
),
1595+
patch.object(service, "_start_egress_sidecar", return_value=sidecar) as mock_start_sidecar,
1596+
patch.object(
1597+
service,
1598+
"_create_and_start_container",
1599+
return_value=created_container,
1600+
) as mock_create,
1601+
):
1602+
await service.create_sandbox(request)
1603+
1604+
_, start_kwargs = mock_start_sidecar.call_args
1605+
assert start_kwargs["host_execd_port"] == 51664
1606+
assert start_kwargs["host_http_port"] == 48891
1607+
assert start_kwargs["extra_port_bindings"] == {
1608+
"3389/tcp": ("0.0.0.0", 53389),
1609+
"3389/udp": ("0.0.0.0", 53390),
1610+
"8006/tcp": ("0.0.0.0", 58006),
1611+
}
1612+
1613+
forwarded_env = mock_create.call_args.args[4]
1614+
host_config_kwargs = mock_create.call_args.args[5]
1615+
forwarded_ports = mock_create.call_args.args[6]
1616+
labels = mock_create.call_args.args[3]
1617+
1618+
assert "USER_PORTS=44772,8080,3389,8006" in forwarded_env
1619+
assert host_config_kwargs["network_mode"] == "container:sidecar-123"
1620+
assert "NET_ADMIN" in set(host_config_kwargs.get("cap_add") or [])
1621+
assert "NET_ADMIN" not in set(host_config_kwargs.get("cap_drop") or [])
1622+
assert forwarded_ports is None
1623+
assert labels["opensandbox.io/embedding-proxy-port"] == "51664"
1624+
assert labels["opensandbox.io/http-port"] == "48891"
1625+
1626+
14991627
def test_restore_existing_sandboxes_ignores_manual_cleanup_without_warning():
15001628
service = DockerSandboxService(config=_app_config())
15011629
manual_container = MagicMock()

0 commit comments

Comments
 (0)