From 4c4ebaa725ccc7b94352b9354d25658ba7e776e1 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Tue, 27 Oct 2026 19:04:45 +0000 Subject: [PATCH] Limit the model provider exemption to its port on public addresses --- contracts/agents-api/environments.md | 4 +- contracts/agents-api/index.md | 2 +- contracts/agents-api/sandbox-deployment.md | 2 +- contracts/agents-api/zh/environments.md | 6 +- contracts/agents-api/zh/index.md | 4 +- contracts/agents-api/zh/sandbox-deployment.md | 4 +- docs/getting-started/nodes.md | 4 +- docs/getting-started/troubleshooting.md | 2 +- docs/sandbox-provider.md | 6 +- docs/zh/getting-started/nodes.md | 6 +- docs/zh/getting-started/troubleshooting.md | 4 +- docs/zh/sandbox-provider.md | 8 +- .../core/internal/execution/network_egress.go | 29 ++++--- .../internal/execution/network_egress_test.go | 44 ++++++++--- services/core/internal/sandbox/network.go | 20 ++++- .../core/internal/sandbox/sandbox_provider.go | 7 +- services/core/tools/e2b-provider/provider.py | 28 ++++++- .../core/tools/e2b-provider/provider_test.py | 7 +- .../tools/microsandbox-provider/network.go | 76 ++++++++++++------- .../microsandbox-provider/network_test.go | 35 +++------ 20 files changed, 185 insertions(+), 113 deletions(-) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index d722c969f..706f930ce 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -306,8 +306,8 @@ Core may start a hosted Environment from the result of an earlier preparation in A hosted Session's effective `network` narrows the deployment's [network cap](./sandbox-deployment.md#network), and the policy holds for the sandbox's whole life, including package installation and setup commands: - `enabled`: the sandbox reaches what the cap allows. -- `disabled`: the sandbox reaches only Core and the Session's model provider. Package installation and setup commands that need the network fail as ordinary setup errors. -- `restricted`: the sandbox reaches the listed domains, Core and the model provider. A domain is allowed on public addresses when the cap allows the public internet, and on its resolved addresses when it is one of the cap's extra domains. Any other domain is rejected at Session creation with `sandbox_network_outside_cap`, whose `param` is that `allowed_domains` entry. If the cap shrinks later, the sandbox attempt fails with the same error. +- `disabled`: the sandbox reaches only Core and the Session's model provider. The model provider stays reachable on its `base_url` port at public addresses; a private model provider host is reachable only when the cap lists its exact name in the extra domains. Package installation and setup commands that need the network fail as ordinary setup errors. +- `restricted`: the sandbox reaches the listed domains, Core and the model provider (on the same terms as `disabled`). A domain is allowed on public addresses when the cap allows the public internet, and on its resolved addresses when it is one of the cap's extra domains. Any other domain is rejected at Session creation with `sandbox_network_outside_cap`, whose `param` is that `allowed_domains` entry. If the cap shrinks later, the sandbox attempt fails with the same error. `disabled` and `restricted` return `sandbox_network_unsupported` when the deployment's sandbox Provider cannot restrict egress. A `self_hosted` Environment accepts only `enabled`, because Core cannot enforce a policy on a machine it does not run. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index e8071b74d..a77a417f7 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -160,7 +160,7 @@ Each item is Core's deliberate or native behavior where the official service beh - Hosted workspace admission is limited by the [tenant capacity policy](../../docs/configuration.md#tenant-capacity) and the [input reservation deadline](./environments.md#reservations). - MiniMax Code does not declare `retained_native_history`, so it cannot run on microsandbox, which keeps every Environment on workspace storage; Core rejects the Session ([Harness capabilities](./harness-capabilities.md)). -- The model provider stays reachable from the sandbox because the Harness runs there; MCP connections from the Environment follow the deployment's [network cap](./sandbox-deployment.md#network). E2B sandboxes cannot reach private networks, and `169.254.169.254` answers inside an E2B sandbox under every cap. +- The model provider's port stays reachable at public addresses from the sandbox because the Harness runs there, and a private model provider host only when the network cap lists its name; MCP connections from the Environment follow the deployment's [network cap](./sandbox-deployment.md#network). E2B sandboxes cannot reach private networks, and `169.254.169.254` answers inside an E2B sandbox under every cap. - `disabled` and `restricted` networking run on hosted Environments whose sandbox Provider can restrict egress, within the deployment's network cap; `self_hosted` Environments accept only `enabled` ([network policy](./environments.md#restricted-network)). The official API accepts an `allowed_domains` entry with a leading dot, such as `.example.com`, with unspecified meaning; Core rejects it. - `packages.system` other than null or an empty list is rejected; system packages must be preinstalled. - `Environment.files` lists the files recorded at Session creation; files created later through the Environment files API do not appear. diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index a93a3a99f..129c4c439 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -63,7 +63,7 @@ Select the filesystem configuration before the microsandbox deployment. Selectin ### Network -`network` caps what every sandbox of the deployment may reach when its Environment's `network.access` is `enabled` or omitted. The connection to Core and the host of the Session's model provider are always reachable, because the Harness runs inside the sandbox. MCP connections from the Environment follow the cap like any other traffic. +`network` caps what every sandbox of the deployment may reach when its Environment's `network.access` is `enabled` or omitted. The connection to Core and the Session's model provider port on public addresses are always reachable, because the Harness runs inside the sandbox. To reach a model provider at a private address, list its exact host name in `domains`. MCP connections from the Environment follow the cap like any other traffic. | Field | Accepted value | | --- | --- | diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 742130413..d42142a54 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: b921c3cde636588da6aebd95d902016fbd4e1a4b4bbbcc8c374540181d5d4ceb +source_hash: 41335469992a2f5262e73ea7e61d4fb0fd101cab48b844f458e4313d50c50647 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -308,8 +308,8 @@ Core 可以用同一 Project 中较早一次准备的结果启动托管 Environm 托管 Session 的有效 `network` 在部署的[网络上限](sandbox-deployment.md#network)内收窄,并且该策略在沙箱的整个生命周期内生效,包括包安装和设置命令: - `enabled`:沙箱可访问上限允许的范围。 -- `disabled`:沙箱只能访问 Core 和 Session 的模型提供商。需要网络的包安装和设置命令按普通设置错误失败。 -- `restricted`:沙箱可访问所列域名、Core 和模型提供商。上限允许公网时,域名在公开地址上被允许;域名属于上限的额外域名时,在其解析出的地址上被允许。其他域名在创建 Session 时被拒绝,错误码为 `sandbox_network_outside_cap`,`param` 指向该 `allowed_domains` 条目。上限之后缩小时,沙箱尝试以同一错误失败。 +- `disabled`:沙箱只能访问 Core 和 Session 的模型提供商。模型提供商仅在公开地址上的 `base_url` 端口可达;私有的模型提供商主机只有在上限的额外域名列出其精确名称时才可达。需要网络的包安装和设置命令按普通设置错误失败。 +- `restricted`:沙箱可访问所列域名、Core 和模型提供商(条件同 `disabled`)。上限允许公网时,域名在公开地址上被允许;域名属于上限的额外域名时,在其解析出的地址上被允许。其他域名在创建 Session 时被拒绝,错误码为 `sandbox_network_outside_cap`,`param` 指向该 `allowed_domains` 条目。上限之后缩小时,沙箱尝试以同一错误失败。 部署的沙箱 Provider 无法限制出站时,`disabled` 和 `restricted` 返回 `sandbox_network_unsupported`。`self_hosted` Environment 只接受 `enabled`,因为 Core 无法在不由它运行的机器上强制策略。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index d0363ff32..b9443c477 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 41a9d5d03e05f56929d5112f29f0ed2441658e9c9f269f8692c200d4fcf2b91b +source_hash: cf0ef0f7d2bf076ed3b402b0c8c98f55d53c584c14b2f45714c17cb34cd14877 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -163,7 +163,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - Hosted 工作区准入受[租户容量策略](../../../docs/zh/configuration.md#tenant-capacity)及既有[输入预留截止时间](environments.md#reservations)限制。 - MiniMax Code 未声明 `retained_native_history`,因此无法在 microsandbox 上运行(其每个 Environment 都使用工作区存储);Core 会拒绝该 Session([Harness 能力](./harness-capabilities.md))。 -- 模型提供商从沙箱内始终可达,因为 Harness 运行在那里;Environment 中的 MCP 连接遵循部署的[网络上限](sandbox-deployment.md#network)。E2B 沙箱无法访问私有网络,且在任何上限下 `169.254.169.254` 都会在 E2B 沙箱内应答。 +- 模型提供商在公开地址上的端口从沙箱内始终可达,因为 Harness 运行在那里,私有的模型提供商主机只有在网络上限列出其名称时才可达;Environment 中的 MCP 连接遵循部署的[网络上限](sandbox-deployment.md#network)。E2B 沙箱无法访问私有网络,且在任何上限下 `169.254.169.254` 都会在 E2B 沙箱内应答。 - `disabled` 和 `restricted` 网络在沙箱 Provider 能限制出站的托管 Environment 上运行,并受部署网络上限约束;`self_hosted` Environment 只接受 `enabled`。官方 API 接受带前导点的 `allowed_domains` 条目(如 `.example.com`),含义未规定,Core 拒绝它([restricted network policy](environments.md#restricted-network))。 - 除 null 或空列表以外的 `packages.system` 都会被拒绝;系统软件包必须预先安装。 - `Environment.files` 列出 Session 创建时记录的文件;之后通过 Environment files API 创建的文件不会出现。 diff --git a/contracts/agents-api/zh/sandbox-deployment.md b/contracts/agents-api/zh/sandbox-deployment.md index a41907a4d..e8cfdad2d 100644 --- a/contracts/agents-api/zh/sandbox-deployment.md +++ b/contracts/agents-api/zh/sandbox-deployment.md @@ -1,7 +1,7 @@ --- title: "沙箱部署" source: contracts/agents-api/sandbox-deployment.md -source_hash: ad85ff2713327c6b1e171088b84829308a50d6b0bca93cae531252745e3c6b4e +source_hash: 5afb4db75820cfbadd390409074c94730a559ba86594f8bc40de4796cd174b94 --- 沙箱部署为 Core 管理的 `openai_hosted` 执行选择沙箱提供商和每个沙箱的资源。PostgreSQL 为每个安装维护一个当前有效选择;Web 和 Core API 写入同一配置。节点文件保存其已安装副本和特定于主机的路径,且不能覆盖其资源。Runtime 不属于该选择:每个节点运行其所属安装验证过的 Runtime。该选择独立于 Harness。部署可以保持未配置状态,没有节点;此时它拒绝托管准入。 @@ -66,7 +66,7 @@ microsandbox 会配置 CPU、内存和托管根磁盘,并在挂载于 `/enviro ### 网络 {#network} -当 Environment 的 `network.access` 为 `enabled` 或省略时,`network` 限定该部署的每个沙箱最多能访问什么。到 Core 的连接和 Session 的模型提供商主机始终可达,因为 Harness 运行在沙箱内。Environment 中的 MCP 连接与其他流量一样遵循该上限。 +当 Environment 的 `network.access` 为 `enabled` 或省略时,`network` 限定该部署的每个沙箱最多能访问什么。到 Core 的连接和 Session 的模型提供商在公开地址上的端口始终可达,因为 Harness 运行在沙箱内。要访问私有地址上的模型提供商,请把其精确主机名列入 `domains`。Environment 中的 MCP 连接与其他流量一样遵循该上限。 | 字段 | 可接受的值 | | --- | --- | diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index ff1ba194d..b39353238 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -66,12 +66,12 @@ The node service keeps the proxy settings of the latest run, so it reaches Core | Node state | `/var/lib/oac-node/.oac/nodes//`: identity, configuration, node files. microsandbox keeps its images and sandboxes under `/var/lib/oac-node/.oac/m/` | | Records | `/etc/oac-node/`: what the installer created or changed, used by reruns and uninstall, and the service's proxy settings | | Runtime image | Imported once into the microsandbox store | -| microsandbox network policy | Core applies the deployment's [network cap](../../contracts/agents-api/sandbox-deployment.md#network) to every sandbox: no inbound connections, and private networks only for the hosts the cap names | +| microsandbox network policy | Core applies the deployment's [network cap](../../contracts/agents-api/sandbox-deployment.md#network) to every sandbox: no inbound connections, and private networks only for the names the cap lists | Root only prepares the account, the group and the unit; everything else runs as `oac-node`, in its own session without a terminal. The installer never installs KVM or packages, never changes device permissions, sudoers, firewall or SELinux settings, and never touches other accounts. The node connects out to Core; Core needs no SSH access to the host. > [!WARNING] -> microsandbox sandboxes reach a private address only through a name in the network cap's `domains`; see [Sandboxes cannot reach a private endpoint](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint). +> microsandbox sandboxes reach a private address only through a name in the network cap's `domains`; see [Sandboxes cannot reach a private endpoint](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint). Until the upstream DNS fix ships, a node's Core URL host must be an IP address or a name with public addresses: a name that resolves to a private address is dropped by DNS rebind protection. **One Core per host.** Nodes share the `oac-node` account, so a host serves one Core; a command from a second Core is refused. diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md index 59c8a6a36..01b958370 100644 --- a/docs/getting-started/troubleshooting.md +++ b/docs/getting-started/troubleshooting.md @@ -40,7 +40,7 @@ Add node shows no command: see [Before you add a node](./nodes.md#before-you-add ### Sandboxes cannot reach a private endpoint -A microsandbox sandbox reaches a private address only for the model provider's host and for the names in the deployment's [network cap](../../contracts/agents-api/sandbox-deployment.md#network) `domains`. Any other name that resolves to a private address does not resolve in the sandbox, because DNS rebind protection discards the answer, and a Turn that depends on it fails once the Harness stops retrying. This includes an MCP server at a private address. +A microsandbox sandbox reaches a private address only for the names in the deployment's [network cap](../../contracts/agents-api/sandbox-deployment.md#network) `domains`. Any other name that resolves to a private address does not resolve in the sandbox, because DNS rebind protection discards the answer, and a Turn that depends on it fails once the Harness stops retrying. This includes an MCP server at a private address. Add the server's exact host name to the domains in **Settings** → **Sandbox and scheduling** → **Network**. The node resolves those names with the nameservers in the host's `/etc/resolv.conf` when a sandbox starts and when it resumes. A name that does not resolve there gets no rule, and the node logs `sandbox network names without an address` with the names. diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 673bf4fd9..f1dd6351d 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -200,11 +200,11 @@ Core sends the same `Egress` in `Bootstrap` and in every `ResumeRequest`, derive - `Public`: public addresses are allowed. - `PrivateDomains`: exact host names whose resolved addresses, private ones included, are allowed. - `Domains`: exact host names allowed on public addresses only; a private answer stays blocked. They carry a `restricted` Environment's `allowed_domains` when the cap allows the public internet. -- `Endpoints`: the host of the model provider's `base_url`, omitted when it is loopback. The Harness runs in the sandbox and calls it directly. +- `Endpoints`: `host:port` entries (`net.JoinHostPort` form) for the model provider's `base_url`, with the port from the URL or 443 for https and 80 for http, omitted when the host is loopback. The Harness runs in the sandbox and calls it directly. An entry allows that TCP port on public addresses only, never a private address. When the cap lists the model host's name in its extra domains, Core also adds that name to `PrivateDomains`, so a private model host keeps working under every policy. -The Provider enforces exactly this on top of the always-allowed connection to Core, and blocks loopback, link-local, cloud-metadata and every private address that `Egress` does not name. It resolves names with its host's DNS when it creates and when it resumes, and cannot follow later changes of an answer. A name that does not resolve gets no rule. MCP traffic has no entry: it follows `Public`, `PrivateDomains` and `Domains`. +The Provider enforces exactly this on top of the always-allowed connection to Core, and blocks loopback, link-local, cloud-metadata and every private address that `Egress` does not name, except that E2B cannot block the metadata address 169.254.169.254. It resolves names with its host's DNS when it creates and when it resumes, and cannot follow later changes of an answer. A name that does not resolve gets no rule. MCP traffic has no entry: it follows `Public`, `PrivateDomains` and `Domains`. -microsandbox applies the rules with first-match order, default deny, and host DNS reachable; it allows the resolved private addresses without a port so that DNS rebind protection passes them. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all traffic except the Core host, `Endpoints` and `Domains`. +microsandbox applies the rules with first-match order, default deny, and host DNS reachable; it allows the resolved private addresses without a port so that DNS rebind protection passes them. E2B declares `Restrict` only: `Public` adds a deny list for loopback, private and link-local ranges, and not `Public` denies all traffic except the Core host, `Endpoints` and `Domains`. E2B matches a host name only on ports 80 and 443, so the adapter also allows the public IPv4 addresses of the Core host and of each Endpoint on any other port. ## Checkpoint lifecycle diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index 41724019f..80dd4f247 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,7 +1,7 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: 247ca9c1eff00c067eed15c09a9bd1baaab8f8d7a47d5446644bafe19865b6e3 +source_hash: 1395a1edece41b37e8c10fc11c2e3658c796972fd83d14e2bb20fc7a11edc84d --- 节点是一台 Linux 主机,为 Core 托管 Session 运行沙箱。本指南面向在沙箱提供商(参阅[选择沙箱提供商](./sandbox-backends.md))为 microsandbox 时添加、修改和移除节点的运维人员;E2B 不需要节点。 @@ -68,13 +68,13 @@ Core 主机与其他主机一样加入:要在它上面运行沙箱,将它添 | 节点状态 | `/var/lib/oac-node/.oac/nodes//`:身份、配置和节点文件。microsandbox 在 `/var/lib/oac-node/.oac/m/` 保存镜像和沙箱 | | 记录 | `/etc/oac-node/`:安装程序创建或修改的内容,用于重新运行和卸载,以及服务的代理设置 | | Runtime 镜像 | 一次性导入 microsandbox 存储 | -| microsandbox 网络策略 | Core 对每个沙箱应用部署的[网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network):无入站连接,只有上限列出的主机可访问私有网络 | +| microsandbox 网络策略 | Core 对每个沙箱应用部署的[网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network):无入站连接,只有上限列出的名称可访问私有网络 | root 只准备账号、组和服务单元;其他操作都以 `oac-node` 在没有终端的独立会话中执行。安装程序不安装 KVM 或软件包,不修改设备权限、sudoers、防火墙或 SELinux 设置,也不修改其他账号。节点向外连接 Core;Core 不需要通过 SSH 访问主机。 > [!CAUTION] > [!WARNING] -> microsandbox 沙箱只能通过网络上限 `domains` 中的名称访问私有地址;参见[沙箱无法访问私有端点](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint)。 +> microsandbox 沙箱只能通过网络上限 `domains` 中的名称访问私有地址;参见[沙箱无法访问私有端点](./troubleshooting.md#sandboxes-cannot-reach-a-private-endpoint)。在上游 DNS 修复发布之前,节点的 Core URL 主机必须是 IP 地址,或解析为公开地址的名称:解析为私有地址的名称会被 DNS rebind 保护丢弃。 **每台主机只服务一个 Core。** 节点共享 `oac-node` 账号,因此主机只服务一个 Core;第二个 Core 的命令会被拒绝。 diff --git a/docs/zh/getting-started/troubleshooting.md b/docs/zh/getting-started/troubleshooting.md index d473a29b9..2645ab75e 100644 --- a/docs/zh/getting-started/troubleshooting.md +++ b/docs/zh/getting-started/troubleshooting.md @@ -1,7 +1,7 @@ --- title: "故障排查" source: docs/getting-started/troubleshooting.md -source_hash: c77f0eb6c07c2f262183d690928e03bf51442e59fb04b0acca955dd0177d3d31 +source_hash: f1d90aac721b527b49e1f214d54997918f3bb437aa0af1d5dc33304584819813 --- 按症状查找安装、访问、节点和 Session 问题的原因与解决方法。本页面向 Core 安装的运维人员。 @@ -42,7 +42,7 @@ source_hash: c77f0eb6c07c2f262183d690928e03bf51442e59fb04b0acca955dd0177d3d31 ### 沙箱无法访问私有端点 {#sandboxes-cannot-reach-a-private-endpoint} -microsandbox 沙箱只能访问模型提供商的主机,以及部署[网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network) `domains` 中的名称所对应的私有地址。任何其他解析为私有地址的名称在沙箱内都无法解析,因为 DNS rebind 保护会丢弃该应答,依赖它的 Turn 会在 Harness 停止重试后失败,私有地址上的 MCP 服务器也是如此。 +microsandbox 沙箱只能访问部署[网络上限](../../../contracts/agents-api/zh/sandbox-deployment.md#network) `domains` 中的名称所对应的私有地址。任何其他解析为私有地址的名称在沙箱内都无法解析,因为 DNS rebind 保护会丢弃该应答,依赖它的 Turn 会在 Harness 停止重试后失败,私有地址上的 MCP 服务器也是如此。 在 **设置** → **沙箱与调度** → **网络** 中把该服务器的精确主机名加入域名列表。节点在沙箱启动和恢复时,用主机 `/etc/resolv.conf` 中的域名服务器解析这些名称。无法解析的名称没有规则,节点会记录 `sandbox network names without an address` 并列出这些名称。 diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 1d4bcaf3e..5c50cc862 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: 45f9a9c4f99c0e764390d832c547ab1303cacb090fc7159d3fb0e5b5c3b25db8 +source_hash: 45dd97e9627942748787865fc257bb2f25ee72e874d2c14a639d46f631b7028e --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox 和 E2B 实现了该协议。 @@ -202,11 +202,11 @@ Core 在 `Bootstrap` 和每个 `ResumeRequest` 中发送同一个 `Egress`,它 - `Public`:允许公开地址。 - `PrivateDomains`:精确主机名,其解析出的地址(包括私有地址)都被允许。 - `Domains`:只在公开地址上被允许的精确主机名,私有应答仍被阻止。上限允许公网时,它们承载 `restricted` Environment 的 `allowed_domains`。 -- `Endpoints`:模型提供商 `base_url` 的主机,回环主机时省略。Harness 运行在沙箱内,直接调用它。 +- `Endpoints`:模型提供商 `base_url` 的 `host:port` 条目(`net.JoinHostPort` 形式),端口取自 URL,https 默认 443,http 默认 80,主机为回环时省略。Harness 运行在沙箱内,直接调用它。条目只允许该 TCP 端口在公开地址上的访问,绝不包括私有地址。上限的额外域名列出了模型主机名时,Core 还会把该名称加入 `PrivateDomains`,使私有模型主机在任何策略下都能工作。 -Provider 在始终允许到 Core 的连接之上严格执行这些内容,并阻止回环、链路本地、云元数据以及 `Egress` 未列出的所有私有地址。它在创建和恢复时用主机 DNS 解析名称,无法跟随之后应答的变化。无法解析的名称没有规则。MCP 流量没有条目:它遵循 `Public`、`PrivateDomains` 和 `Domains`。 +Provider 在始终允许到 Core 的连接之上严格执行这些内容,并阻止回环、链路本地、云元数据以及 `Egress` 未列出的所有私有地址,但 E2B 无法阻止元数据地址 169.254.169.254。它在创建和恢复时用主机 DNS 解析名称,无法跟随之后应答的变化。无法解析的名称没有规则。MCP 流量没有条目:它遵循 `Public`、`PrivateDomains` 和 `Domains`。 -microsandbox 按首条匹配的顺序、默认拒绝并保持主机 DNS 可达来应用规则;它不带端口地允许解析出的私有地址,使 DNS rebind 保护放行它们。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部流量。 +microsandbox 按首条匹配的顺序、默认拒绝并保持主机 DNS 可达来应用规则;它不带端口地允许解析出的私有地址,使 DNS rebind 保护放行它们。E2B 只声明 `Restrict`:`Public` 时加入回环、私有和链路本地网段的拒绝列表,非 `Public` 时拒绝除 Core 主机、`Endpoints` 和 `Domains` 之外的全部流量。E2B 只在 80 和 443 端口匹配主机名,因此 adapter 还会为 Core 主机和每个 Endpoint 在其他端口上允许其公开 IPv4 地址。 ## 检查点生命周期 {#checkpoint-lifecycle} diff --git a/services/core/internal/execution/network_egress.go b/services/core/internal/execution/network_egress.go index 196e00ef0..79d77d92c 100644 --- a/services/core/internal/execution/network_egress.go +++ b/services/core/internal/execution/network_egress.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/netip" "net/url" "slices" @@ -23,9 +24,6 @@ import ( // longer covers fails the attempt with the admission error. func (r *runtimeLifecycle) allocationEgress(ctx context.Context, owner deployment.Allocation, network sandbox.Network) (sandbox.Egress, error) { session, err := r.sessions.GetSession(ctx, owner.TenantID, owner.SessionID) - if errors.Is(err, sessions.ErrNotFound) { - return egressFor(network, nil, "") - } if err != nil { return sandbox.Egress{}, err } @@ -57,8 +55,10 @@ func admitNetwork(support sandbox.NetworkSupport, network sandbox.Network, polic } // egressFor applies the cap and the Environment policy. The Harness runs inside -// the sandbox, so the host of the Session's model provider is always reachable; -// a loopback host is the sandbox itself and needs no entry. A nil policy equals +// the sandbox, so the port of the Session's model provider is always reachable +// on public addresses; a loopback host is the sandbox itself and needs no +// entry. A model host the cap lists by name also joins PrivateDomains, so a +// private model host keeps working under every policy. A nil policy equals // enabled. func egressFor(network sandbox.Network, policy *v1.EnvironmentNetworkInput, modelBaseURL string) (sandbox.Egress, error) { egress := sandbox.Egress{Public: network.Public, PrivateDomains: append([]string{}, network.Domains...), Domains: []string{}, Endpoints: []string{}} @@ -76,16 +76,25 @@ func egressFor(network sandbox.Network, policy *v1.EnvironmentNetworkInput, mode Message: fmt.Sprintf("%s is outside the network this deployment allows.", domain)} } } - slices.Sort(egress.PrivateDomains) - egress.PrivateDomains = slices.Compact(egress.PrivateDomains) - slices.Sort(egress.Domains) - egress.Domains = slices.Compact(egress.Domains) } if u, err := url.Parse(modelBaseURL); err == nil { if host := strings.ToLower(u.Hostname()); host != "" && !loopbackHost(host) { - egress.Endpoints = append(egress.Endpoints, host) + port := u.Port() + if port == "" { + port = map[string]string{"http": "80", "https": "443"}[u.Scheme] + } + if port != "" { + egress.Endpoints = append(egress.Endpoints, net.JoinHostPort(host, port)) + } + if slices.Contains(network.Domains, host) { + egress.PrivateDomains = append(egress.PrivateDomains, host) + } } } + slices.Sort(egress.PrivateDomains) + egress.PrivateDomains = slices.Compact(egress.PrivateDomains) + slices.Sort(egress.Domains) + egress.Domains = slices.Compact(egress.Domains) return egress, nil } diff --git a/services/core/internal/execution/network_egress_test.go b/services/core/internal/execution/network_egress_test.go index 54c2bc43d..5980d7e20 100644 --- a/services/core/internal/execution/network_egress_test.go +++ b/services/core/internal/execution/network_egress_test.go @@ -1,36 +1,56 @@ package execution import ( + "context" "errors" "reflect" "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -func TestEgressFollowsTheCapAndTheModelProviderHost(t *testing.T) { - cap := sandbox.Network{Public: false, Domains: []string{"gitlab.example.com"}} +func TestEgressLimitsTheModelProviderToItsPort(t *testing.T) { + closed := sandbox.Network{Public: false, Domains: []string{"gitlab.example.com", "llm.corp.example"}} + disabled := &v1.EnvironmentNetworkInput{Access: "disabled"} for _, tc := range []struct { + name string + network sandbox.Network + policy *v1.EnvironmentNetworkInput baseURL string want sandbox.Egress }{ - {"https://API.Model.example:8443/v1", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Domains: []string{}, Endpoints: []string{"api.model.example"}}}, - {"http://10.1.2.3:8000/v1", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Domains: []string{}, Endpoints: []string{"10.1.2.3"}}}, - {"http://127.0.0.1:8000/v1", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Domains: []string{}, Endpoints: []string{}}}, - {"http://localhost:8000", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Domains: []string{}, Endpoints: []string{}}}, - {"", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com"}, Domains: []string{}, Endpoints: []string{}}}, + {"public host, default port", closed, nil, "https://API.Model.example/v1", sandbox.Egress{PrivateDomains: []string{"gitlab.example.com", "llm.corp.example"}, Domains: []string{}, Endpoints: []string{"api.model.example:443"}}}, + {"public host, http and own port", sandbox.DefaultNetwork(), disabled, "http://api.model.example:8000", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{"api.model.example:8000"}}}, + {"private host listed by the cap stays reachable when disabled", closed, disabled, "http://llm.corp.example:8000/v1", sandbox.Egress{PrivateDomains: []string{"llm.corp.example"}, Domains: []string{}, Endpoints: []string{"llm.corp.example:8000"}}}, + {"private host the cap does not list", sandbox.DefaultNetwork(), disabled, "http://llm.corp.example:8000/v1", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{"llm.corp.example:8000"}}}, + {"private IP literal", closed, disabled, "http://10.1.2.3:8000/v1", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{"10.1.2.3:8000"}}}, + {"loopback", closed, disabled, "http://127.0.0.1:8000/v1", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{}}}, + {"localhost", closed, disabled, "http://localhost:8000", sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{}}}, } { - got, err := egressFor(cap, nil, tc.baseURL) + got, err := egressFor(tc.network, tc.policy, tc.baseURL) if err != nil || !reflect.DeepEqual(got, tc.want) { - t.Fatalf("%q: %+v %v, want %+v", tc.baseURL, got, err, tc.want) + t.Fatalf("%s: %+v %v, want %+v", tc.name, got, err, tc.want) } } - if got, _ := egressFor(sandbox.DefaultNetwork(), &v1.EnvironmentNetworkInput{Access: "enabled"}, ""); !got.Public || len(got.PrivateDomains) != 0 { - t.Fatalf("default cap: %+v", got) +} + +// A Session whose record is missing fails the attempt instead of falling back to the full cap. +func TestAllocationEgressFailsClosedWithoutSession(t *testing.T) { + r := &runtimeLifecycle{sessions: missingSession{}} + if _, err := r.allocationEgress(context.Background(), deployment.Allocation{}, sandbox.DefaultNetwork()); !errors.Is(err, sessions.ErrNotFound) { + t.Fatalf("err %v", err) } } +type missingSession struct{ sessions.Reader } + +func (missingSession) GetSession(context.Context, string, string) (sessions.Session, error) { + return sessions.Session{}, sessions.ErrNotFound +} + func TestEnvironmentPolicyNarrowsTheCap(t *testing.T) { open := sandbox.Network{Public: true, Domains: []string{"gitlab.example.com"}} closed := sandbox.Network{Public: false, Domains: []string{"gitlab.example.com"}} @@ -38,7 +58,7 @@ func TestEnvironmentPolicyNarrowsTheCap(t *testing.T) { restricted := &v1.EnvironmentNetworkInput{Access: "restricted", AllowedDomains: []string{"GitLab.example.com", "pypi.org", "pypi.org"}} got, err := egressFor(open, disabled, "https://model.example/v1") - if want := (sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{"model.example"}}); err != nil || !reflect.DeepEqual(got, want) { + if want := (sandbox.Egress{PrivateDomains: []string{}, Domains: []string{}, Endpoints: []string{"model.example:443"}}); err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("disabled: %+v %v", got, err) } got, err = egressFor(open, restricted, "") diff --git a/services/core/internal/sandbox/network.go b/services/core/internal/sandbox/network.go index 2c8263b18..f57d530c4 100644 --- a/services/core/internal/sandbox/network.go +++ b/services/core/internal/sandbox/network.go @@ -2,8 +2,10 @@ package sandbox import ( "fmt" + "net" "regexp" "slices" + "strconv" "strings" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/agentnetwork" @@ -73,7 +75,7 @@ func (n Network) ValidatePolicy(provider string, support NetworkSupport) error { } // Validate checks an Egress a Provider receives: exact host names for -// PrivateDomains and Domains and non-empty host names or IP addresses for Endpoints. +// PrivateDomains and Domains and host:port entries for Endpoints. func (e Egress) Validate() error { for _, names := range [][]string{e.PrivateDomains, e.Domains} { if len(names) > maxNetworkDomains { @@ -85,14 +87,24 @@ func (e Egress) Validate() error { } } } - for _, h := range e.Endpoints { - if h == "" || len(h) > maxDomainLength || strings.ContainsAny(h, "/ \t\r\n") { - return ErrInvalid + for _, endpoint := range e.Endpoints { + if _, _, err := SplitEndpoint(endpoint); err != nil { + return err } } return nil } +// SplitEndpoint splits an Egress.Endpoints entry into its host and TCP port. +func SplitEndpoint(endpoint string) (host string, port int, err error) { + host, p, splitErr := net.SplitHostPort(endpoint) + port, convErr := strconv.Atoi(p) + if splitErr != nil || convErr != nil || host == "" || len(host) > maxDomainLength || port < 1 || port > 65535 || strings.ContainsAny(host, "/ \t\r\n") { + return "", 0, ErrInvalid + } + return host, port, nil +} + // ValidDomain reports whether name is an exact host name under the deployment // network rule. func ValidDomain(name string) bool { diff --git a/services/core/internal/sandbox/sandbox_provider.go b/services/core/internal/sandbox/sandbox_provider.go index 73e86437f..a77c91cbc 100644 --- a/services/core/internal/sandbox/sandbox_provider.go +++ b/services/core/internal/sandbox/sandbox_provider.go @@ -83,9 +83,10 @@ type Egress struct { // Domains are exact host names the Provider allows on public addresses // only; a private answer for them stays blocked. Domains []string `json:"domains"` - // Endpoints are the host names or IP addresses of the Session's model - // provider. The Harness runs inside the sandbox and calls them directly, so - // every address they resolve to is allowed, private ones included. + // Endpoints are host:port entries (net.JoinHostPort form) of the Session's + // model provider. The Harness runs inside the sandbox and calls them + // directly. Each entry allows that TCP port on public addresses only, never + // a private one. Endpoints []string `json:"endpoints"` } diff --git a/services/core/tools/e2b-provider/provider.py b/services/core/tools/e2b-provider/provider.py index 5aea4df8d..f2017c48f 100644 --- a/services/core/tools/e2b-provider/provider.py +++ b/services/core/tools/e2b-provider/provider.py @@ -1,8 +1,10 @@ """Bounded SDK operations for an already authorized Core allocation, plus read-only deployment validation and observation.""" from concurrent.futures import ThreadPoolExecutor +import ipaddress import json import math +import socket import time from datetime import datetime, timezone from urllib.parse import urlsplit @@ -87,13 +89,33 @@ def observed(cloud, point): ALL_TRAFFIC = '0.0.0.0/0' +def public_ipv4(host): + """The public IPv4 addresses of a host name or literal; none when it does not resolve.""" + try: + found = {info[4][0] for info in socket.getaddrinfo(host, None, socket.AF_INET)} + except OSError: + return [] + return sorted(a for a in found if ipaddress.ip_address(a).is_global) + + def network_options(bootstrap): - """The SDK network option for Core's egress: public access, or only Core, the model provider and the named domains.""" + """The SDK network option for Core's egress: public access, or only Core, the model provider and the named domains. + + E2B matches a host name in allow_out only on ports 80 and 443, so a host on + any other port is also allowed by its public addresses.""" egress = bootstrap['Egress'] if egress['public']: return {'deny_out': list(BLOCKED_RANGES)} - allowed = [urlsplit(bootstrap['CoreURL']).hostname, *egress['endpoints'], *egress['domains']] - return {'deny_out': [ALL_TRAFFIC], 'allow_out': sorted(set(allowed))} + core = urlsplit(bootstrap['CoreURL']) + targets = [(core.hostname, core.port or (443 if core.scheme == 'https' else 80))] + for endpoint in egress['endpoints']: + target = urlsplit('//' + endpoint) + targets.append((target.hostname, target.port)) + allowed = {host for host, _ in targets} | set(egress['domains']) + for host, port in targets: + if port not in (80, 443): + allowed.update(f'{a}/32' for a in public_ipv4(host)) + return {'deny_out': [ALL_TRAFFIC], 'allow_out': sorted(allowed)} class Provider: diff --git a/services/core/tools/e2b-provider/provider_test.py b/services/core/tools/e2b-provider/provider_test.py index 6129b7067..561fc8a63 100644 --- a/services/core/tools/e2b-provider/provider_test.py +++ b/services/core/tools/e2b-provider/provider_test.py @@ -94,9 +94,10 @@ def test_create_maps_egress_to_network_options(self): provider = __import__('provider') public = provider.network_options(self.request['Bootstrap']) self.assertEqual(public, {'deny_out': list(provider.BLOCKED_RANGES)}) - closed = dict(self.request['Bootstrap'], Egress={'public': False, 'private_domains': [], 'domains': ['pypi.org'], 'endpoints': ['api.model.example']}) - self.assertEqual(provider.network_options(closed), - {'deny_out': ['0.0.0.0/0'], 'allow_out': ['api.model.example', 'core.example', 'pypi.org']}) + closed = dict(self.request['Bootstrap'], Egress={'public': False, 'private_domains': [], 'domains': ['pypi.org'], 'endpoints': ['api.model.example:443', 'models.example:8443']}) + with patch.object(provider, 'public_ipv4', side_effect=lambda host: {'models.example': ['203.0.113.9']}.get(host, [])): + self.assertEqual(provider.network_options(closed), + {'deny_out': ['0.0.0.0/0'], 'allow_out': ['203.0.113.9/32', 'api.model.example', 'core.example', 'models.example', 'pypi.org']}) self.assertEqual(self.call('create')['ErrorCode'], '') self.assertNotIn('Egress', json.loads(self.cloud.files.write.call_args.args[1])) self.assertEqual(self.api.create.call_args.kwargs['network'], public) diff --git a/services/core/tools/microsandbox-provider/network.go b/services/core/tools/microsandbox-provider/network.go index 9ee9967e7..5c8c05e63 100644 --- a/services/core/tools/microsandbox-provider/network.go +++ b/services/core/tools/microsandbox-provider/network.go @@ -9,6 +9,7 @@ import ( "net/url" "slices" "strconv" + "sync" "time" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -19,8 +20,6 @@ import ( type resolver func(ctx context.Context, host string) ([]netip.Addr, error) func hostResolver(ctx context.Context, host string) ([]netip.Addr, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() return net.DefaultResolver.LookupNetIP(ctx, "ip", host) } @@ -28,21 +27,28 @@ var carrierGradeNAT = netip.MustParsePrefix("100.64.0.0/10") func isPrivate(a netip.Addr) bool { return a.IsPrivate() || carrierGradeNAT.Contains(a) } +// resolveTimeout bounds all lookups of one egressNetwork call together. +const resolveTimeout = 5 * time.Second + // egressNetwork builds one allocation's network policy; Create and Resume call // it with the same Egress. Rules are first match with default deny: // 1. host DNS // 2. deny loopback, link-local and metadata // 3. each address of the Core host, on its port -// 4. each private address that an Endpoint or PrivateDomains name resolves to, -// without port, because only a port-less address allow lets the guest's DNS -// answer pass rebind protection, which stays at its default +// 4. each private address that a PrivateDomains name resolves to, without +// port, because only a port-less address allow lets the guest's DNS answer +// pass rebind protection, which stays at its default; Endpoints never +// yield private addresses // 5. deny every other private address -// 6. unless Public, a domain allow for each Endpoint, PrivateDomains and -// Domains name; only public answers reach it, since rule 5 and rebind -// protection already refuse private ones +// 6. unless Public, a tcp allow on its port for each Endpoint (domain, or a +// public IP literal), and a port-less domain allow for each PrivateDomains +// and Domains name; only public answers reach a domain allow, since rule 5 +// and rebind protection already refuse private ones // 7. public addresses, when Public // -// A name that does not resolve gets no address rule and is reported to report. +// Core and PrivateDomains names are resolved together under one deadline. A +// PrivateDomains name that does not resolve gets no address rule and is +// reported to report. func egressNetwork(ctx context.Context, coreURL string, egress sandbox.Egress, resolve resolver, report io.Writer) (*sdk.NetworkConfig, error) { core, err := url.Parse(coreURL) if err != nil || core.Hostname() == "" { @@ -55,26 +61,32 @@ func egressNetwork(ctx context.Context, coreURL string, egress sandbox.Egress, r if _, err := strconv.ParseUint(port, 10, 16); err != nil { return nil, sandbox.ErrInvalid } - coreAddresses, err := addresses(ctx, core.Hostname(), resolve) - if err != nil { - return nil, err - } - names := slices.Concat(egress.Endpoints, egress.PrivateDomains) + names := slices.Concat(egress.PrivateDomains, egress.Domains) slices.Sort(names) names = slices.Compact(names) - // Domains resolve to public addresses only, so they need no address rule. - allowed := slices.Concat(names, egress.Domains) - slices.Sort(allowed) - allowed = slices.Compact(allowed) + ctx, cancel := context.WithTimeout(ctx, resolveTimeout) + defer cancel() + var wg sync.WaitGroup + var coreAddresses []netip.Addr + var coreErr error + wg.Go(func() { coreAddresses, coreErr = addresses(ctx, core.Hostname(), resolve) }) + found := make([][]netip.Addr, len(egress.PrivateDomains)) + failed := make([]error, len(egress.PrivateDomains)) + for i, name := range egress.PrivateDomains { + wg.Go(func() { found[i], failed[i] = addresses(ctx, name, resolve) }) + } + wg.Wait() + if coreErr != nil { + return nil, coreErr + } var private []string - for _, name := range names { - found, err := addresses(ctx, name, resolve) - if err != nil { + for i, name := range egress.PrivateDomains { + if failed[i] != nil { fmt.Fprintf(report, "unresolved %s\n", name) continue } - for _, a := range found { + for _, a := range found[i] { if isPrivate(a) { private = append(private, a.String()) } @@ -103,15 +115,23 @@ func egressNetwork(ctx context.Context, coreURL string, egress sandbox.Egress, r } deny("private") if !egress.Public { - for _, name := range allowed { - if a, err := netip.ParseAddr(name); err == nil { - allow(a.Unmap().String(), "", "") - } else if sandbox.ValidDomain(name) { - allow(name, "", "") + for _, endpoint := range egress.Endpoints { + host, p, err := sandbox.SplitEndpoint(endpoint) + if err != nil { + fmt.Fprintf(report, "unsupported %s\n", endpoint) + } else if a, err := netip.ParseAddr(host); err == nil { + if a = a.Unmap(); !isPrivate(a) { + allow(a.String(), "tcp", strconv.Itoa(p)) + } + } else if sandbox.ValidDomain(host) { + allow(host, "tcp", strconv.Itoa(p)) } else { - fmt.Fprintf(report, "unsupported %s\n", name) + fmt.Fprintf(report, "unsupported %s\n", endpoint) } } + for _, name := range names { + allow(name, "", "") + } } if egress.Public { allow("public", "", "") diff --git a/services/core/tools/microsandbox-provider/network_test.go b/services/core/tools/microsandbox-provider/network_test.go index 912ee9b86..16984ce24 100644 --- a/services/core/tools/microsandbox-provider/network_test.go +++ b/services/core/tools/microsandbox-provider/network_test.go @@ -44,8 +44,8 @@ func rules(t *testing.T, egress sandbox.Egress, dns map[string][]string) (string } func TestDefaultPolicyRules(t *testing.T) { - got, _ := rules(t, sandbox.Egress{Public: true, Endpoints: []string{"api.openai.com"}}, map[string][]string{ - "core.example.com": {"172.29.144.1"}, "api.openai.com": {"104.18.1.1"}}) + got, _ := rules(t, sandbox.Egress{Public: true, Endpoints: []string{"api.openai.com:443"}}, map[string][]string{ + "core.example.com": {"172.29.144.1"}}) want := `allow host udp 53 allow host tcp 53 deny loopback @@ -59,9 +59,12 @@ allow public ` } } -func TestPrivateDomainRules(t *testing.T) { - got, report := rules(t, sandbox.Egress{PrivateDomains: []string{"gitlab.example.com", "gone.example.com"}, Endpoints: []string{"models.example.com"}}, map[string][]string{ - "core.example.com": {"172.29.144.1"}, "gitlab.example.com": {"10.74.2.81", "8.8.8.8"}, "models.example.com": {"203.0.113.9"}}) +// Only PrivateDomains names yield port-less private allows; an Endpoint is +// limited to its port and sits after "deny private". +func TestEndpointIsPortLimitedAndNeverPrivate(t *testing.T) { + got, report := rules(t, sandbox.Egress{PrivateDomains: []string{"gitlab.example.com", "gone.example.com"}, Domains: []string{"pypi.org"}, + Endpoints: []string{"models.example.com:8443", "203.0.113.9:8000", "10.1.2.3:8000"}}, map[string][]string{ + "core.example.com": {"172.29.144.1"}, "gitlab.example.com": {"10.74.2.81", "8.8.8.8"}, "models.example.com": {"10.9.9.9"}}) want := `allow host udp 53 allow host tcp 53 deny loopback @@ -70,9 +73,11 @@ deny metadata allow 172.29.144.1 tcp 24443 allow 10.74.2.81 deny private +allow models.example.com tcp 8443 +allow 203.0.113.9 tcp 8000 allow gitlab.example.com allow gone.example.com -allow models.example.com ` +allow pypi.org ` if got != want { t.Fatalf("rules:\n%s", got) } @@ -80,21 +85,3 @@ allow models.example.com ` t.Fatalf("report %q", report) } } - -// Domains admit public answers only: they sit after "deny private" and add no address rule. -func TestDomainRulesFollowDenyPrivate(t *testing.T) { - got, _ := rules(t, sandbox.Egress{Domains: []string{"pypi.org"}, Endpoints: []string{"models.example.com"}}, map[string][]string{ - "core.example.com": {"172.29.144.1"}, "models.example.com": {"203.0.113.9"}, "pypi.org": {"151.101.0.223"}}) - want := `allow host udp 53 -allow host tcp 53 -deny loopback -deny link-local -deny metadata -allow 172.29.144.1 tcp 24443 -deny private -allow models.example.com -allow pypi.org ` - if got != want { - t.Fatalf("rules:\n%s", got) - } -}