diff --git a/AGENTS.md b/AGENTS.md index b6e11c3..f656f42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ which owns availability and compatibility checks. The bundled deployment also advertises Kata Containers and Firecracker on KVM-capable nodes. The native Linux runc payload is build-time optional and must be explicitly included and enabled by an operator. -Creation-time network policies support unrestricted networking, blocking new -flows except the YuanRong control and published sandbox-port routes, or denying -exact and leading-wildcard DNS names. +Creation-time network policies and atomic runtime replacement support +unrestricted networking, blocking new flows except the YuanRong control and +published sandbox-port routes, or denying exact and leading-wildcard DNS names. Experimental whole-device NVIDIA GPU requests require runsc. Configurable writable-storage requests are supported by runsc and Firecracker. @@ -124,9 +124,10 @@ node components and produces the AKernel all-in-one image using the selected runtime image and its matching service configuration. The control-plane and RRT release version is independent of the optional -actor-based `openyuanrong_sdk` installed in the Python runtime profile. Keep -the latter on its explicitly pinned legacy version unless that backend is -being upgraded and tested as a separate compatibility change. +actor-based `openyuanrong_sdk` installed in the Python runtime profile. This +actor backend is deprecated and retained only for compatibility with existing +applications. Keep it on its explicitly pinned legacy version; do not advance +it with the default `openyuanrong-sandbox` backend or use it for new features. Initialize submodules with `git submodule update --init --recursive` before building. The all-in-one image builds the sandboxd binaries, including @@ -219,14 +220,19 @@ usable `/dev/kvm` device. The bundled sandboxd configuration enables per-sandbox network ACLs. Pooled TAP networking requires the host `tun` module and a usable `/dev/net/tun`. The -default iptables backend additionally requires `br_netfilter`, conntrack, -connmark/CONNMARK, and bridge netfilter. The optional bpfnat backend instead -requires eBPF `SCHED_CLS`, TC `clsact`, writable bpffs, and permission to load -BPF programs and manage TC filters. Both require free TCP/UDP port 53 on the -sandbox bridge. Drain existing sandboxes before enabling ACLs or upgrading a +default iptables backend additionally requires `iptables`, `ip6tables`, +`ipset`, IPv4/IPv6 filter tables, `br_netfilter`, `xt_physdev`, conntrack, +conntrack-netlink, connmark/CONNMARK, timeout-capable `hash:ip` sets, and +IPv4/IPv6 bridge netfilter. The optional bpfnat backend +instead requires Linux 5.17 or newer for `bpf_loop`, eBPF `SCHED_CLS`, TC +`clsact`, writable bpffs, and permission to load BPF programs and manage TC +filters. Both require free TCP/UDP port 53 on the sandbox bridge. Drain +existing sandboxes before enabling ACLs or upgrading a node from a pre-ACL configuration; sandboxd refuses to initialize ACLs when old sandbox records remain. A sandbox without a network policy stays -unrestricted. See `deploy/README.md` for deployment requirements and +unrestricted. Schema v2 supports independent ingress and egress defaults, +allow and deny rules over IPv4 CIDRs, domains, protocols, and ports, plus an +independent DNS policy. See `deploy/README.md` for deployment requirements and `sdk/python/README.md` for API limits. Dragonfly distribution is optional and disabled by default. Enable it during @@ -352,6 +358,18 @@ with Sandbox(network_policy=NetworkPolicy.block()) as sb: print(sb.commands.run("echo control-plane-access").stdout) ``` +Configure a generic egress allowlist: + +```python +from akernel_sdk import NetworkPolicy, NetworkRule + +policy = NetworkPolicy.allowlist( + [NetworkRule(domain="*.example.com", protocol="tcp", port_range=443)] +) +with Sandbox(network_policy=policy) as sb: + print(sb.commands.run("curl https://api.example.com").stdout) +``` + Required environment: ```bash @@ -428,14 +446,16 @@ make sdk-check ``` The Python SDK installs `openyuanrong-sandbox` as its default execution -backend. The actor-based `openyuanrong-sdk` backend is available through the -`openyuanrong-sdk` extra. Installing that extra leaves both distributions -present, so `openyuanrong-sandbox` remains the automatic default unless -`AKERNEL_BACKEND=openyuanrong-sdk` is set before import. Backend selection -happens once during import and backend modules are loaded lazily on first use. -Keep public `Sandbox`, `Commands`, `Filesystem`, and value types independent -of both native packages; all native conversions belong under -`akernel_sdk._backends`. +backend. The actor-based `openyuanrong-sdk` backend is deprecated and retained +only for compatibility with existing applications through the +`openyuanrong-sdk` extra. Do not update its pinned legacy version alongside +the default backend or extend it with new capabilities. Installing that extra +leaves both distributions present, so `openyuanrong-sandbox` remains the +automatic default unless `AKERNEL_BACKEND=openyuanrong-sdk` is set before +import. Backend selection happens once during import and backend modules are +loaded lazily on first use. Keep public `Sandbox`, `Commands`, `Filesystem`, +and value types independent of both native packages; all native conversions +belong under `akernel_sdk._backends`. Dockerfile direct launch is a supported AKernel SDK capability through `DockerContext` and diff --git a/README.md b/README.md index b5754c3..2f54892 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,15 @@ python -m pip install akernel-sdk # Source python -m pip install ./sdk/python -# Also install the actor backend +# Also install the deprecated actor compatibility backend python -m pip install "akernel-sdk[openyuanrong-sdk]" ``` -When the actor extra is installed, both backend packages are present and -`openyuanrong-sandbox` remains the automatic default. Set +The actor-based `openyuanrong-sdk` backend is deprecated and retained only for +compatibility with existing applications. New applications should use the +default `openyuanrong-sandbox` backend. When the actor extra is installed, +both backend packages are present and `openyuanrong-sandbox` remains the +automatic default. Set `AKERNEL_BACKEND=openyuanrong-sdk` before importing `akernel_sdk` to select the actor backend: @@ -206,7 +209,7 @@ See the complete [basic usage example](./sdk/python/examples/basic_usage.py), th - [x] Kata Containers runtime on KVM-capable nodes - [x] Firecracker microVM runtime on KVM-capable nodes - [x] Optional native Linux runc runtime -- [x] Sandbox network ACL +- [x] Stateful sandbox network ACLs for CIDRs, domains, protocols, and ports - [ ] Fork-based sandbox launch based on gVisor - [x] Same-node checkpoint recovery for runsc and Firecracker - [ ] Support for GKE and AWS diff --git a/builder/node.Dockerfile b/builder/node.Dockerfile index fd6f4f9..82171c0 100644 --- a/builder/node.Dockerfile +++ b/builder/node.Dockerfile @@ -10,12 +10,12 @@ ARG AKERNEL_ENABLE_RUNC=false ARG AKERNEL_ENABLE_FIRECRACKER=true ARG SANDBOXD_BUILD_IMAGE=golang:1.25.5-bookworm ARG DISTILL_FS_BUILD_IMAGE=rust:1.85.0-bookworm -ARG OPEN_YR_VERSION=0.10.1rc3 +ARG OPEN_YR_VERSION=0.10.1rc4 ARG OPEN_YR_CORE_WHEEL_URL= ARG OPEN_YR_CORE_WHEEL_SHA256= ARG OPEN_YR_RELEASE_BASE_URL=https://openyuanrong.obs.cn-southwest-2.myhuaweicloud.com/release -ARG OPEN_YR_CORE_AMD64_SHA256=d303a25587919ce64bae8a7e193cab4b0f33ef12213968fa96a1ba563df629d7 -ARG OPEN_YR_CORE_ARM64_SHA256=c611e4e2e4c08e696b60bf8ad394261b2a2bca5b18100b8e84351c2cbc289f1c +ARG OPEN_YR_CORE_AMD64_SHA256=65c1f27e7e700a253a2e907dea0273e85f1c76610e48c93544caa6bcc07ac3af +ARG OPEN_YR_CORE_ARM64_SHA256=29d25c3388c2913346035ee8df9b8159e702de7a8e6f783e3e218ab773896333 ARG GVISOR_DOWNLOAD_IMAGE=ubuntu:24.04 ARG GVISOR_RELEASE ARG GVISOR_AMD64_URL @@ -243,6 +243,7 @@ RUN apt-get update && \ fuse3 \ gnupg \ iproute2 \ + ipset \ iptables \ jq \ kmod \ diff --git a/builder/runtime.Dockerfile b/builder/runtime.Dockerfile index 6fde3bc..d5edf8e 100644 --- a/builder/runtime.Dockerfile +++ b/builder/runtime.Dockerfile @@ -9,14 +9,14 @@ ARG PYTHON_311_VERSION=3.11.13 ARG PYTHON_312_VERSION=3.12.11 ARG PYTHON_313_VERSION=3.13.5 ARG PYTHON_314_VERSION=3.14.6 -ARG OPEN_YR_VERSION=0.10.1rc3 +ARG OPEN_YR_VERSION=0.10.1rc4 ARG OPEN_YR_LEGACY_SDK_VERSION=0.9.9 FROM ${AKERNEL_RUNTIME_BASE_IMAGE} AS rrt-download ARG OPEN_YR_VERSION ARG RRT_RUNTIME_URL=https://openyuanrong.obs.cn-southwest-2.myhuaweicloud.com/release/${OPEN_YR_VERSION}/linux/amd64/rrt-runtime-amd64 -ARG RRT_RUNTIME_SHA256=c22d3c95b38845763e9f27553a065a959458743bbf42358c609bc2f1f9fce789 +ARG RRT_RUNTIME_SHA256=7c2064531e91fba8b9bbe96ac9e706cd496a198551657ee5461a078a2f7ea9ea RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates curl && \ diff --git a/builder/scripts/sandboxd_network_prepare.sh b/builder/scripts/sandboxd_network_prepare.sh index 5bbe7f8..2e30cb0 100755 --- a/builder/scripts/sandboxd_network_prepare.sh +++ b/builder/scripts/sandboxd_network_prepare.sh @@ -57,11 +57,13 @@ fi # netfilter hooks. Host provisioning must load br_netfilter; this hook only # configures the node container's network namespace. if [[ "${enable_network_acl,,}" == "true" && "${nat_backend}" == "iptables" ]]; then - if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables ]]; then + if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables || + ! -e /proc/sys/net/bridge/bridge-nf-call-ip6tables ]]; then echo "br_netfilter is unavailable; load it on the host before starting the AKernel node" >&2 exit 1 fi "${SYSCTL_BIN}" -w net.bridge.bridge-nf-call-iptables=1 + "${SYSCTL_BIN}" -w net.bridge.bridge-nf-call-ip6tables=1 fi # bpfnat validates this setting when its local-DNAT path is enabled. Apply it diff --git a/deploy/README.md b/deploy/README.md index e5dde05..9ba6a48 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -57,17 +57,21 @@ The bundled standalone, Helm, and Terraform sandboxd configurations enable per-sandbox network ACLs. Runsc, Kata, and Firecracker require the host `tun` module and a usable `/dev/net/tun` for their pooled TAP endpoints. A sandbox created without a policy remains on the unrestricted fast path. The default -`iptables` backend additionally requires `ip_tables`, `br_netfilter`, -conntrack, connmark/CONNMARK support, and -`net.bridge.bridge-nf-call-iptables=1`. The optional `bpfnat` backend instead -requires Linux eBPF `SCHED_CLS`, TC `clsact`, supported hash and array maps, a -writable bpffs at `/sys/fs/bpf` (or permission to mount one), and permission -to load BPF programs and manage TC filters. Both backends require TCP and UDP -port 53 on the sandbox bridge to be free and at least one usable upstream -nameserver. AKernel's privileged node container prepares the selected -backend's namespace-local settings. Host provisioning must load the required -kernel modules before the node pod starts; the Terraform node bootstrap does -this automatically. +`iptables` backend additionally requires the `iptables`, `ip6tables`, and +`ipset` userspace commands; IPv4/IPv6 filter-table, `br_netfilter`, +`xt_physdev`, conntrack and conntrack-netlink, connmark/CONNMARK, and +timeout-capable `hash:ip` ipset support; and both bridge netfilter sysctls for +iptables and ip6tables set to `1`. Sandboxd probes the IPv6 physdev rule and +the required ipset type when it initializes this backend. The optional `bpfnat` +backend instead requires Linux 5.17 or newer for `bpf_loop`, eBPF +`SCHED_CLS`, TC `clsact`, +supported hash and array maps, a writable bpffs at `/sys/fs/bpf` (or +permission to mount one), and permission to load BPF programs and manage TC +filters. Both backends require TCP and UDP port 53 on the sandbox bridge to be +free and at least one usable upstream nameserver. AKernel's privileged node +container prepares the selected backend's namespace-local settings. Host +provisioning must load the required kernel modules before the node pod starts; +the Terraform node bootstrap does this automatically. Drain all sandboxes from a node before enabling ACLs or upgrading an existing deployment to a release that enables them. Sandboxd deliberately refuses to @@ -78,6 +82,8 @@ sandboxd is healthy. Sandboxd selects the ACL implementation matching the configured `iptables` or `bpfnat` NAT backend. DNS policies manage each sandbox's `/etc/resolv.conf`; a caller mount that owns that path is rejected while ACL support is enabled. +Schema v2 domain traffic rules also use the managed DNS proxy to install +TTL-bound address grants, even when no separate DNS policy is supplied. `make config` is interactive by default. It writes: diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md index 60f3af9..b24912b 100644 --- a/deploy/standalone/README.md +++ b/deploy/standalone/README.md @@ -92,9 +92,10 @@ later creation of `sandbox0` cannot change the advertised node address. Set override. The standalone configuration enables per-sandbox network ACLs. With the -default iptables backend, `start.sh` loads `br_netfilter` on the host before -the node starts; the node then enables bridge netfilter in its own network -namespace. The host also requires conntrack plus connmark/CONNMARK support. +default iptables backend, `start.sh` loads IPv6 filter-table, `br_netfilter`, +`xt_physdev`, conntrack/connmark, and timeout-capable ipset modules on the host +before the node starts; the node then enables IPv4 and IPv6 bridge netfilter in +its own network namespace. The optional bpfnat backend instead requires TC eBPF support and a writable bpffs. TCP and UDP port 53 on the sandbox bridge must remain free for sandboxd's managed DNS proxy. Before diff --git a/deploy/standalone/start.sh b/deploy/standalone/start.sh index 101fa32..1fff5d2 100755 --- a/deploy/standalone/start.sh +++ b/deploy/standalone/start.sh @@ -308,19 +308,44 @@ prepare_host_network_modules() { fi if [[ "$(id -u)" -eq 0 ]]; then + "${modprobe_bin}" ip_tables + "${modprobe_bin}" iptable_filter + "${modprobe_bin}" ip6_tables + "${modprobe_bin}" ip6table_filter "${modprobe_bin}" br_netfilter - elif sudo -n "${modprobe_bin}" br_netfilter; then + "${modprobe_bin}" xt_physdev + "${modprobe_bin}" nf_conntrack + "${modprobe_bin}" nf_conntrack_netlink + "${modprobe_bin}" xt_conntrack + "${modprobe_bin}" xt_connmark + "${modprobe_bin}" ip_set + "${modprobe_bin}" ip_set_hash_ip + "${modprobe_bin}" xt_set + elif sudo -n "${modprobe_bin}" ip_tables && + sudo -n "${modprobe_bin}" iptable_filter && + sudo -n "${modprobe_bin}" ip6_tables && + sudo -n "${modprobe_bin}" ip6table_filter && + sudo -n "${modprobe_bin}" br_netfilter && + sudo -n "${modprobe_bin}" xt_physdev && + sudo -n "${modprobe_bin}" nf_conntrack && + sudo -n "${modprobe_bin}" nf_conntrack_netlink && + sudo -n "${modprobe_bin}" xt_conntrack && + sudo -n "${modprobe_bin}" xt_connmark && + sudo -n "${modprobe_bin}" ip_set && + sudo -n "${modprobe_bin}" ip_set_hash_ip && + sudo -n "${modprobe_bin}" xt_set; then : else - log_error "Unable to load br_netfilter; run this script as root or allow passwordless sudo for modprobe" + log_error "Unable to load required iptables ACL modules; run this script as root or allow passwordless sudo for modprobe" exit 1 fi - if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables ]]; then + if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables || + ! -e /proc/sys/net/bridge/bridge-nf-call-ip6tables ]]; then log_error "br_netfilter loaded but bridge netfilter sysctls are unavailable" exit 1 fi - log_info "Loaded host br_netfilter module for the iptables ACL backend" + log_info "Loaded host filter, bridge, conntrack, and ipset modules for the iptables ACL backend" } # Start the AKernel all-in-one container. Traefik runs separately so traffic diff --git a/deploy/terraform/aliyun/variables.tf b/deploy/terraform/aliyun/variables.tf index 40ba488..dbf7eea 100644 --- a/deploy/terraform/aliyun/variables.tf +++ b/deploy/terraform/aliyun/variables.tf @@ -321,7 +321,7 @@ variable "extra_node_pools" { variable "sandboxd_nat_backend" { type = string - description = "Sandboxd NAT backend. When set to 'iptables', ip_tables kernel module will be loaded at boot." + description = "Sandboxd NAT backend. The iptables mode loads IPv4/IPv6 bridge-netfilter modules at boot." default = "iptables" } diff --git a/deploy/terraform/huaweicloud/variables.tf b/deploy/terraform/huaweicloud/variables.tf index 6ba608c..5c668bb 100644 --- a/deploy/terraform/huaweicloud/variables.tf +++ b/deploy/terraform/huaweicloud/variables.tf @@ -365,7 +365,7 @@ variable "extra_node_pools" { variable "sandboxd_nat_backend" { type = string - description = "Sandboxd NAT backend. When set to 'iptables', ip_tables kernel module will be loaded at boot." + description = "Sandboxd NAT backend. The iptables mode loads IPv4/IPv6 bridge-netfilter modules at boot." default = "iptables" } diff --git a/deploy/terraform/shared/node-bootstrap.sh.tftpl b/deploy/terraform/shared/node-bootstrap.sh.tftpl index 940d12c..f5cb907 100644 --- a/deploy/terraform/shared/node-bootstrap.sh.tftpl +++ b/deploy/terraform/shared/node-bootstrap.sh.tftpl @@ -27,7 +27,8 @@ systemctl enable akernel-tun-module.service systemctl start akernel-tun-module.service %{ if sandboxd_nat_backend == "iptables" ~} -# ip_tables provides NAT and br_netfilter provides bridged ACL enforcement. +# ip_tables provides NAT. IPv6 bridge filtering prevents traffic from bypassing +# the IPv4-only ACL policy surface. cat >/etc/systemd/system/akernel-network-modules.service <<'EOF' [Unit] Description=Load AKernel iptables networking modules on boot @@ -35,7 +36,7 @@ After=network-pre.target [Service] Type=oneshot -ExecStart=/bin/sh -c 'modprobe ip_tables && modprobe br_netfilter' +ExecStart=/bin/sh -c 'modprobe ip_tables && modprobe iptable_filter && modprobe ip6_tables && modprobe ip6table_filter && modprobe br_netfilter && modprobe xt_physdev && modprobe nf_conntrack && modprobe nf_conntrack_netlink && modprobe xt_conntrack && modprobe xt_connmark && modprobe ip_set && modprobe ip_set_hash_ip && modprobe xt_set' RemainAfterExit=yes [Install] diff --git a/sdk/python/README.md b/sdk/python/README.md index d687fe1..a828280 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -7,7 +7,8 @@ PTYs, port forwarding, and reverse tunnels. It supports two backends: - `openyuanrong-sandbox` (default), using a RESTful API and Rust runtime. -- `openyuanrong-sdk` (legacy), using YuanRong actors and a Python runtime. +- `openyuanrong-sdk` (deprecated compatibility backend), using YuanRong actors + and a Python runtime. ## Navigation @@ -62,8 +63,10 @@ Address behavior is deterministic: scheme uses HTTP/WS. Exec and file transfer continue to use `AKERNEL_SERVER_ADDRESS`. -The legacy actor backend is optional. Install and select it before importing -`akernel_sdk`: +The actor-based `openyuanrong-sdk` backend is deprecated and retained only for +compatibility with existing applications. New applications should use +`openyuanrong-sandbox`. If compatibility requires the actor backend, install +and select it before importing `akernel_sdk`: ```bash pip install "akernel-sdk[openyuanrong-sdk]" @@ -153,7 +156,7 @@ with Sandbox() as unrestricted: ``` Block new sandbox flows except the YuanRong control proxy and published -sandbox-port routes: +sandbox-port routes with the compatibility helper: ```python with Sandbox(network_policy=NetworkPolicy.block()) as sandbox: @@ -181,24 +184,97 @@ with Sandbox(network_policy=policy) as sandbox: An exact pattern matches only that name. For example, `github.com` does not match `api.github.com`, while `*.github.com` matches descendants but not the apex. Supply both when both should be denied. Patterns are normalized to -lower case without a trailing dot; international names must use ASCII -punycode. Each pattern may be an exact ASCII name or begin with one leading +lower-case ASCII without a trailing dot; international names are converted to +punycode. Each pattern may be an exact name or begin with one leading `*.`. The remaining name is at most 253 characters; each dot-separated label is 1-63 letters, digits, underscores, or hyphens, and a hyphen cannot start or end a label. Other wildcard placements and `?` are rejected. -Network policies are fixed when a sandbox is created. `block_network` and -`dns_blacklist` cannot be combined in the current SDK. DNS blacklists cover -ordinary UDP and TCP DNS and return a refused response for blocked queries; -DNS-over-HTTPS and connections to a known IP are outside their scope. The -block-network packet ACL is currently stateful IPv4. DNS-only policies do not -install a general packet allowlist. - -See [`examples/network_policy.py`](./examples/network_policy.py) for all -three modes. Deployment nodes must have network ACL support enabled; the -bundled standalone, Helm, and Terraform configurations enable it. Drain -existing sandboxes before upgrading a node to an ACL-enabled sandboxd -configuration, as described in the +Create an egress allowlist with the high-level helper. Rules may select an +IPv4 address or CIDR, an exact or leading-wildcard DNS name, TCP or UDP peer +ports, and a priority: + +```python +from akernel_sdk import NetworkRule, PortRange + +policy = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="*.example.com", + protocol="tcp", + port_range=PortRange(443), + priority=200, + ), + NetworkRule( + cidr="192.0.2.10", + protocol="tcp", + port_range=PortRange(8000, 8010), + ), + ] +) +with Sandbox(network_policy=policy) as sandbox: + print(sandbox.commands.run("curl https://api.example.com").stdout) +``` +Replace the complete policy of a running sandbox atomically with +`update_network_policy`. Passing `None` or an empty `NetworkPolicy()` +clears the policy and restores unrestricted networking: + +```python +with Sandbox() as sandbox: + sandbox.update_network_policy(NetworkPolicy.block()) + sandbox.update_network_policy( + NetworkPolicy.deny_dns("github.com", "*.github.com") + ) + sandbox.update_network_policy(None) +``` + +The desired policy survives sandboxd restarts, explicit reloads, and same-node +failover. Dynamic replacement is supported by the default +`openyuanrong-sandbox` backend; the actor-based backend rejects it explicitly. + +For independent ingress and egress defaults, deny rules, sandbox-side port +ranges, DNS allowlists, or stateless matching, construct the schema v2 model +directly with `TrafficPolicy`, `NetworkRule`, `DNSPolicy`, and `DNSRule`. +Traffic rules are evaluated by highest priority first; an equal-priority deny +wins. Stateful mode is the default and permits reply traffic. Priority +`4294967295` is reserved for control-plane and published-port rules, so user +priorities are limited to `1..4294967294`. +In stateless mode, a protected published-port rule covers ingress only; add an +explicit egress rule for the matching sandbox source port when the application +must send a reply. This avoids turning a published port into an unrestricted +egress escape hatch. + +Domain traffic rules authorize IPv4 addresses learned from an allowed +original DNS query, following its complete CNAME chain. The authorization +uses the answer TTL, clamped to 1..3600 seconds, and is replaced when the name +is resolved again with an IPv4 A or ANY query. Existing connections that +depended on an expired or replaced authorization are removed. Parallel AAAA +queries do not revoke IPv4 grants. DNS names not covered by a domain traffic +rule can still resolve when the DNS policy allows the query, but their answers +do not grant packet access. While a DNS policy or domain traffic rule is +active, ordinary TCP and UDP DNS is accepted only through sandboxd's managed +resolver; a traffic rule for another port-53 resolver does not bypass it. +The resulting enforcement is at IPv4 and transport layers. Another virtual +host sharing an authorized address and port is not distinguishable; use an +application proxy when hostname-level isolation is required. + +Network policy replacement uses whole-policy semantics. The legacy +`block_network` and `dns_blacklist` fields cannot be combined with schema v2 +sections. DNS policies cover ordinary UDP and TCP DNS and return a refused +response for denied queries; DNS-over-HTTPS and connections to a known IP are +outside DNS filtering. Packet rules are IPv4. sandboxd accepts 256 combined +traffic rules; the backend reserves entries from that limit for the Function +Proxy and each distinct published sandbox port. IPv6 traffic is dropped +whenever a traffic or DNS policy is active, preventing an alternate resolver +from bypassing the IPv4 policy. Arbitrary non-IP Ethernet protocols are +outside the portable ACL contract. Domain and DNS patterns are normalized +through IDNA. + +See [`examples/network_policy.py`](./examples/network_policy.py) for the +compatibility modes and generic allowlist. Deployment nodes must have network +ACL support enabled; the bundled standalone, Helm, and Terraform +configurations enable it. Drain existing sandboxes before upgrading a node to +an ACL-enabled sandboxd configuration, as described in the [deployment guide](../../deploy/README.md#network-acls). ## Sandbox runtimes @@ -595,7 +671,12 @@ not part of the default test suite. | `S3Config` | `endpoint`, `bucket`, `object`, optional credentials | | `Mount` | `target`, one source, and `type` | | `HttpReverseTunnel` | `target`, `reverse_port`, `listen_port`, `connect_timeout` | -| `NetworkPolicy` | `block_network`, `dns_blacklist` | +| `PortRange` | `first`, `last` | +| `NetworkRule` | action, direction, protocol, peer and sandbox port selectors, priority | +| `TrafficPolicy` | independent ingress/egress defaults, rules, mode | +| `DNSRule` | `pattern`, `action` | +| `DNSPolicy` | `default_action`, `rules` | +| `NetworkPolicy` | legacy fields or schema v2 `traffic` and `dns` sections | | `DockerfileLaunch` | `context`, `auto_start_cmd`, `run_timeout` | | `DockerContext` | Abstract Dockerfile and build-context source | | `DockerContextEntry` | `path`, `kind`, `mode` | diff --git a/sdk/python/akernel_sdk/__init__.py b/sdk/python/akernel_sdk/__init__.py index 5e77dd9..752aef1 100644 --- a/sdk/python/akernel_sdk/__init__.py +++ b/sdk/python/akernel_sdk/__init__.py @@ -26,13 +26,18 @@ from .types import ( CommandInfo, CommandResult, + DNSPolicy, + DNSRule, EntryInfo, HttpReverseTunnel, Mount, NetworkPolicy, + NetworkRule, NodeInfo, + PortRange, S3Config, SandboxInfo, + TrafficPolicy, ) __all__ = [ @@ -40,6 +45,11 @@ "S3Config", "Mount", "NetworkPolicy", + "NetworkRule", + "PortRange", + "TrafficPolicy", + "DNSPolicy", + "DNSRule", "HttpReverseTunnel", "CommandResult", "CommandInfo", diff --git a/sdk/python/akernel_sdk/_backends/base.py b/sdk/python/akernel_sdk/_backends/base.py index 815aa9e..5715300 100644 --- a/sdk/python/akernel_sdk/_backends/base.py +++ b/sdk/python/akernel_sdk/_backends/base.py @@ -151,6 +151,7 @@ def is_running(self) -> bool: ... def get_info(self) -> SandboxInfo: ... def reload(self) -> bool: ... + def update_network_policy(self, policy: NetworkPolicy | None) -> None: ... def terminate(self) -> None: ... diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py index 32c1a27..8f577d8 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py @@ -26,8 +26,14 @@ from ..types import ( CommandInfo, CommandResult, + DNSPolicy, + DNSRule, EntryInfo, + NetworkPolicy, + NetworkRule, + PortRange, SandboxInfo, + TrafficPolicy, ) from .base import ( Backend, @@ -42,6 +48,59 @@ _DEFAULT_LISTEN_PORT = 8766 +def _native_port_range(value: PortRange | int | None) -> Any: + if value is None: + return None + assert isinstance(value, PortRange) + return yr_sandbox.PortRange(first=value.first, last=value.last) + + +def _native_network_rule(rule: NetworkRule) -> Any: + return yr_sandbox.NetworkRule( + action=rule.action, + direction=rule.direction, + protocol=rule.protocol, + cidr=rule.cidr, + domain=rule.domain, + port_range=_native_port_range(rule.port_range), + sandbox_port_range=_native_port_range(rule.sandbox_port_range), + priority=rule.priority, + ) + + +def _native_traffic_policy(policy: TrafficPolicy | None) -> Any: + if policy is None: + return None + return yr_sandbox.TrafficPolicy( + ingress_default_action=policy.ingress_default_action, + egress_default_action=policy.egress_default_action, + rules=tuple(_native_network_rule(rule) for rule in policy.rules), + mode=policy.mode, + ) + + +def _native_dns_rule(rule: DNSRule) -> Any: + return yr_sandbox.DNSRule(pattern=rule.pattern, action=rule.action) + + +def _native_dns_policy(policy: DNSPolicy | None) -> Any: + if policy is None: + return None + return yr_sandbox.DNSPolicy( + default_action=policy.default_action, + rules=tuple(_native_dns_rule(rule) for rule in policy.rules), + ) + + +def _native_network_policy(policy: NetworkPolicy) -> Any: + return yr_sandbox.NetworkPolicy( + block_network=policy.block_network, + dns_blacklist=policy.dns_blacklist, + traffic=_native_traffic_policy(policy.traffic), + dns=_native_dns_policy(policy.dns), + ) + + def _convert_error(operation: str, error: Exception) -> BackendOperationError: return BackendOperationError(f"{operation} failed: {error}") @@ -283,6 +342,23 @@ def reload(self) -> bool: except Exception: return False + def update_network_policy(self, policy: NetworkPolicy | None) -> None: + if self._terminated or self._closed: + raise BackendOperationError( + "update network policy failed: sandbox is closed" + ) + update = getattr(self._sandbox, "update_network_policy", None) + if not callable(update): + raise UnsupportedBackendFeatureError( + "The installed openyuanrong-sandbox backend does not support " + "dynamic network policy updates. Upgrade the backend package." + ) + native_policy = None if policy is None else _native_network_policy(policy) + try: + update(native_policy) + except Exception as error: + raise _convert_error("update network policy", error) from error + def terminate(self) -> None: if self._terminated: return @@ -324,9 +400,7 @@ def __init__(self, config: BackendConfig) -> None: os.environ["YR_SERVER_ADDRESS"] = config.api_endpoint.authority() os.environ["YR_TLS"] = "1" if config.api_endpoint.use_tls else "0" os.environ["YR_GATEWAY_ADDRESS"] = config.gateway_endpoint.authority() - os.environ["YR_GATEWAY_TLS"] = ( - "1" if config.gateway_endpoint.use_tls else "0" - ) + os.environ["YR_GATEWAY_TLS"] = "1" if config.gateway_endpoint.use_tls else "0" os.environ["YR_TOKEN"] = config.token def _validate(self, spec: SandboxSpec) -> None: @@ -357,10 +431,7 @@ def create(self, spec: SandboxSpec) -> BackendSession: ) network = None if spec.network_policy is not None: - network = yr_sandbox.NetworkPolicy( - block_network=spec.network_policy.block_network, - dns_blacklist=spec.network_policy.dns_blacklist, - ) + network = _native_network_policy(spec.network_policy) mounts = [ yr_sandbox.Mount( target=mount.target, diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py index 28c8703..ce512bf 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py @@ -24,6 +24,7 @@ CommandInfo, CommandResult, EntryInfo, + NetworkPolicy, SandboxInfo, ) from . import openyuanrong_sdk_impl as _impl @@ -249,6 +250,12 @@ def reload(self) -> bool: "Use the default 'openyuanrong-sandbox' backend." ) + def update_network_policy(self, policy: NetworkPolicy | None) -> None: + raise UnsupportedBackendFeatureError( + "Backend 'openyuanrong-sdk' does not support dynamic network policy " + "updates. Use the default 'openyuanrong-sandbox' backend." + ) + def terminate(self) -> None: if self._terminated: return diff --git a/sdk/python/akernel_sdk/sandbox.py b/sdk/python/akernel_sdk/sandbox.py index ba552c2..9c58ffd 100644 --- a/sdk/python/akernel_sdk/sandbox.py +++ b/sdk/python/akernel_sdk/sandbox.py @@ -272,9 +272,7 @@ def __init__( raise ValueError("runtime must be a non-empty string") normalized_xpu = normalize_xpu(xpu) validate_storage_mb(storage_mb) - if network_policy is not None and not isinstance( - network_policy, NetworkPolicy - ): + if network_policy is not None and not isinstance(network_policy, NetworkPolicy): raise TypeError("network_policy must be a NetworkPolicy or None") _validate_integer("cpu", cpu, minimum=1) _validate_integer("memory", memory, minimum=1) @@ -469,6 +467,30 @@ def reload(self) -> bool: return False return self._session.reload() + def update_network_policy(self, policy: NetworkPolicy | None) -> None: + """Atomically replace the complete network policy of this sandbox. + + Passing None or an empty NetworkPolicy clears the existing policy and + restores unrestricted networking. The desired policy is retained + across sandboxd restarts, explicit reloads, and same-node failover. + + Args: + policy: The replacement policy, or None to clear it. + + Raises: + TypeError: If policy is not a NetworkPolicy or None. + RuntimeError: If the sandbox is already closed. + UnsupportedBackendFeatureError: If the selected backend cannot + update policies dynamically. + """ + + if policy is not None and not isinstance(policy, NetworkPolicy): + raise TypeError("policy must be a NetworkPolicy or None") + if self._closed or self._session is None: + raise RuntimeError("sandbox is closed") + normalized = None if policy is None or policy.is_empty else policy + self._session.update_network_policy(normalized) + def get_port_url(self, port: int, *, internal: bool = False) -> str: """Return the gateway URL for a declared sandbox port. diff --git a/sdk/python/akernel_sdk/types.py b/sdk/python/akernel_sdk/types.py index 67a43cd..6f50aa7 100644 --- a/sdk/python/akernel_sdk/types.py +++ b/sdk/python/akernel_sdk/types.py @@ -16,11 +16,15 @@ from __future__ import annotations +import ipaddress import re +from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any from urllib.parse import urlparse +import idna + # Default yr.get() timeout in seconds. # Ref: yr.common.constants.DEFAULT_GET_TIMEOUT YR_GET_DEFAULT_TIMEOUT = 300 @@ -29,20 +33,37 @@ YR_GET_TIMEOUT_BUFFER = 30 -# DNS blacklist patterns use exact ASCII names or a single leading ``*.``. -# Each label is 1-63 characters from this set; a hyphen cannot be an endpoint. _DNS_LABEL_PATTERN = re.compile(r"^[a-z0-9_-]+$") +_NETWORK_ACTIONS = frozenset({"allow", "deny"}) +_NETWORK_DIRECTIONS = frozenset({"ingress", "egress", "both"}) +_NETWORK_PROTOCOLS = frozenset({"any", "tcp", "udp", "icmp"}) +_TRAFFIC_POLICY_MODES = frozenset({"stateless", "stateful"}) +_MAX_TRAFFIC_RULES = 256 +# UINT32_MAX is reserved for FunctionSystem's control-plane and published-port +# rules, which must remain effective even when user traffic is default-deny. +_MAX_USER_RULE_PRIORITY = (1 << 32) - 2 -def _normalize_dns_pattern(pattern: str) -> str: +def _normalize_domain_pattern(pattern: str, description: str) -> str: if not isinstance(pattern, str): - raise TypeError("dns blacklist patterns must be strings") - value = pattern.strip().lower().rstrip(".") + raise TypeError(f"{description} patterns must be strings") + value = pattern.strip().lower() + if value.endswith("."): + value = value[:-1] wildcard = value.startswith("*.") if wildcard: value = value[2:] - if not value or "*" in value or "?" in value or len(value) > 253: - raise ValueError(f"invalid DNS blacklist pattern: {pattern!r}") + if not value or "*" in value or "?" in value: + raise ValueError(f"invalid {description} pattern: {pattern!r}") + try: + value = idna.encode(value, uts46=True).decode("ascii") + except idna.IDNAError: + # Preserve DNS-SD-compatible underscores in ASCII owner names while + # normalizing ordinary international names to punycode. + if any(ord(char) > 127 for char in value): + raise ValueError(f"invalid {description} pattern: {pattern!r}") from None + if len(value) > 253: + raise ValueError(f"invalid {description} pattern: {pattern!r}") for label in value.split("."): if ( not label @@ -51,10 +72,247 @@ def _normalize_dns_pattern(pattern: str) -> str: or label.endswith("-") or _DNS_LABEL_PATTERN.fullmatch(label) is None ): - raise ValueError(f"invalid DNS blacklist pattern: {pattern!r}") + raise ValueError(f"invalid {description} pattern: {pattern!r}") return f"*.{value}" if wildcard else value +def _normalize_dns_pattern(pattern: str) -> str: + return _normalize_domain_pattern(pattern, "DNS blacklist") + + +def _normalize_choice(value: str, name: str, allowed: frozenset[str]) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + normalized = value.strip().lower() + if normalized not in allowed: + choices = ", ".join(sorted(allowed)) + raise ValueError(f"{name} must be one of: {choices}") + return normalized + + +def _normalize_cidr(value: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("cidr must be a non-empty IPv4 address or CIDR") + try: + network = ipaddress.ip_network(value.strip(), strict=False) + except ValueError as error: + raise ValueError(f"invalid IPv4 address or CIDR: {value!r}") from error + if network.version != 4: + raise ValueError(f"invalid IPv4 address or CIDR: {value!r}") + return str(network) + + +@dataclass(frozen=True) +class PortRange: + """Inclusive TCP or UDP port interval. + + Omit ``last`` to select one port. Both endpoints must be in 1..65535. + """ + + first: int + last: int | None = None + + def __post_init__(self) -> None: + last = self.first if self.last is None else self.last + for name, value in (("first", self.first), ("last", last)): + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"port range {name} must be an integer") + if value < 1 or value > 65535: + raise ValueError(f"port range {name} must be in 1..65535") + if self.first > last: + raise ValueError("port range first must not exceed last") + object.__setattr__(self, "last", last) + + def to_dict(self) -> dict[str, int]: + """Return the JSON-compatible inclusive interval.""" + + assert self.last is not None + return {"first": self.first, "last": self.last} + + +def _normalize_port_range(value: object | None, name: str) -> PortRange | None: + if value is None or isinstance(value, PortRange): + return value + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer, PortRange, or None") + return PortRange(value) + + +@dataclass(frozen=True) +class NetworkRule: + """One IPv4 traffic rule expressed from the sandbox's point of view. + + ``cidr`` and ``domain`` are mutually exclusive. Omitting both matches any + peer address. A domain is valid only for egress and may be exact or start + with ``*.``. ``port_range`` selects the peer port and + ``sandbox_port_range`` selects the local sandbox port. + """ + + action: str = "allow" + direction: str = "egress" + protocol: str = "any" + cidr: str | None = None + domain: str | None = None + port_range: PortRange | int | None = None + sandbox_port_range: PortRange | int | None = None + priority: int = 100 + + def __post_init__(self) -> None: + action = _normalize_choice(self.action, "action", _NETWORK_ACTIONS) + direction = _normalize_choice(self.direction, "direction", _NETWORK_DIRECTIONS) + protocol = _normalize_choice(self.protocol, "protocol", _NETWORK_PROTOCOLS) + if self.cidr is not None and self.domain is not None: + raise ValueError("cidr and domain cannot be combined in one rule") + cidr = _normalize_cidr(self.cidr) if self.cidr is not None else None + domain = ( + _normalize_domain_pattern(self.domain, "domain") + if self.domain is not None + else None + ) + if domain is not None and direction != "egress": + raise ValueError("domain rules are valid only for egress") + port_range = _normalize_port_range(self.port_range, "port_range") + sandbox_port_range = _normalize_port_range( + self.sandbox_port_range, "sandbox_port_range" + ) + if (port_range is not None or sandbox_port_range is not None) and ( + protocol not in ("tcp", "udp") + ): + raise ValueError("port ranges require protocol='tcp' or 'udp'") + if isinstance(self.priority, bool) or not isinstance(self.priority, int): + raise TypeError("priority must be an integer") + if self.priority < 1 or self.priority > _MAX_USER_RULE_PRIORITY: + raise ValueError(f"priority must be in 1..{_MAX_USER_RULE_PRIORITY}") + object.__setattr__(self, "action", action) + object.__setattr__(self, "direction", direction) + object.__setattr__(self, "protocol", protocol) + object.__setattr__(self, "cidr", cidr) + object.__setattr__(self, "domain", domain) + object.__setattr__(self, "port_range", port_range) + object.__setattr__(self, "sandbox_port_range", sandbox_port_range) + + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible schema v2 rule.""" + + value: dict[str, Any] = { + "action": self.action, + "direction": self.direction, + "protocol": self.protocol, + "priority": self.priority, + } + peer: dict[str, Any] = {} + if self.cidr is not None: + peer["cidr"] = self.cidr + if self.domain is not None: + peer["domain"] = self.domain + if self.port_range is not None: + assert isinstance(self.port_range, PortRange) + peer["portRange"] = self.port_range.to_dict() + if peer: + value["peer"] = peer + if self.sandbox_port_range is not None: + assert isinstance(self.sandbox_port_range, PortRange) + value["sandboxPortRange"] = self.sandbox_port_range.to_dict() + return value + + +@dataclass(frozen=True) +class TrafficPolicy: + """Generic IPv4 policy with independent direction defaults.""" + + ingress_default_action: str = "allow" + egress_default_action: str = "allow" + rules: Sequence[NetworkRule] = () + mode: str = "stateful" + + def __post_init__(self) -> None: + ingress = _normalize_choice( + self.ingress_default_action, + "ingress_default_action", + _NETWORK_ACTIONS, + ) + egress = _normalize_choice( + self.egress_default_action, + "egress_default_action", + _NETWORK_ACTIONS, + ) + mode = _normalize_choice(self.mode, "mode", _TRAFFIC_POLICY_MODES) + if isinstance(self.rules, (str, bytes)): + raise TypeError("rules must be a sequence of NetworkRule values") + rules = tuple(self.rules) + if len(rules) > _MAX_TRAFFIC_RULES: + raise ValueError( + f"traffic policies support at most {_MAX_TRAFFIC_RULES} rules" + ) + if any(not isinstance(rule, NetworkRule) for rule in rules): + raise TypeError("rules must contain only NetworkRule values") + object.__setattr__(self, "ingress_default_action", ingress) + object.__setattr__(self, "egress_default_action", egress) + object.__setattr__(self, "rules", rules) + object.__setattr__(self, "mode", mode) + + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible schema v2 traffic policy.""" + + return { + "ingressDefaultAction": self.ingress_default_action, + "egressDefaultAction": self.egress_default_action, + "mode": self.mode, + "rules": [rule.to_dict() for rule in self.rules], + } + + +@dataclass(frozen=True) +class DNSRule: + """One exact or leading-wildcard DNS query rule.""" + + pattern: str + action: str = "deny" + + def __post_init__(self) -> None: + object.__setattr__( + self, + "action", + _normalize_choice(self.action, "action", _NETWORK_ACTIONS), + ) + object.__setattr__( + self, "pattern", _normalize_domain_pattern(self.pattern, "DNS") + ) + + def to_dict(self) -> dict[str, str]: + """Return the JSON-compatible DNS rule.""" + + return {"action": self.action, "pattern": self.pattern} + + +@dataclass(frozen=True) +class DNSPolicy: + """DNS query policy evaluated by sandboxd's managed DNS proxy.""" + + default_action: str = "allow" + rules: Sequence[DNSRule] = () + + def __post_init__(self) -> None: + default = _normalize_choice( + self.default_action, "default_action", _NETWORK_ACTIONS + ) + if isinstance(self.rules, (str, bytes)): + raise TypeError("rules must be a sequence of DNSRule values") + rules = tuple(self.rules) + if any(not isinstance(rule, DNSRule) for rule in rules): + raise TypeError("rules must contain only DNSRule values") + object.__setattr__(self, "default_action", default) + object.__setattr__(self, "rules", rules) + + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible DNS policy.""" + + return { + "defaultAction": self.default_action, + "rules": [rule.to_dict() for rule in self.rules], + } + + @dataclass(frozen=True) class NetworkPolicy: """Creation-time network policy for an AKernel sandbox. @@ -64,15 +322,15 @@ class NetworkPolicy: and explicit port forwarding. Use :meth:`deny_dns` to reject conventional DNS queries matching exact names or leading ``*.`` suffix patterns. - DNS patterns are ASCII names up to 253 characters, split into labels of - 1-63 lowercase letters, digits, underscores, or hyphens. Hyphens cannot - start or end a label. A wildcard is accepted only as the leading ``*.``; - use ASCII punycode for internationalized names. Input is normalized to - lowercase and trailing dots are removed. + ``traffic`` and ``dns`` expose the generic schema v2 model. Legacy fields + and schema v2 sections cannot be combined. Domains are normalized to + lowercase IDNA ASCII without a trailing dot. """ block_network: bool = False dns_blacklist: tuple[str, ...] = () + traffic: TrafficPolicy | None = None + dns: DNSPolicy | None = None def __post_init__(self) -> None: if not isinstance(self.block_network, bool): @@ -84,6 +342,14 @@ def __post_init__(self) -> None: ) if self.block_network and normalized: raise ValueError("block_network and dns_blacklist cannot be combined") + if (self.block_network or normalized) and ( + self.traffic is not None or self.dns is not None + ): + raise ValueError("legacy and schema v2 network policies cannot be combined") + if self.traffic is not None and not isinstance(self.traffic, TrafficPolicy): + raise TypeError("traffic must be a TrafficPolicy or None") + if self.dns is not None and not isinstance(self.dns, DNSPolicy): + raise TypeError("dns must be a DNSPolicy or None") object.__setattr__(self, "dns_blacklist", normalized) @classmethod @@ -100,11 +366,39 @@ def deny_dns(cls, *patterns: str) -> NetworkPolicy: raise ValueError("deny_dns requires at least one domain pattern") return cls(dns_blacklist=patterns) + @classmethod + def allowlist( + cls, + rules: Sequence[NetworkRule], + *, + default_action: str = "deny", + ingress_default_action: str = "allow", + mode: str = "stateful", + ) -> NetworkPolicy: + """Allow selected egress rules and apply a default action to the rest.""" + + normalized = tuple(rules) + if not normalized: + raise ValueError("allowlist requires at least one NetworkRule") + return cls( + traffic=TrafficPolicy( + ingress_default_action=ingress_default_action, + egress_default_action=default_action, + rules=normalized, + mode=mode, + ) + ) + @property def is_empty(self) -> bool: """Whether this policy has no effect and should be omitted.""" - return not self.block_network and not self.dns_blacklist + return ( + not self.block_network + and not self.dns_blacklist + and self.traffic is None + and self.dns is None + ) def to_dict(self) -> dict[str, Any]: """Return the JSON-compatible public API representation.""" @@ -114,6 +408,12 @@ def to_dict(self) -> dict[str, Any]: value["blockNetwork"] = True if self.dns_blacklist: value["dnsBlacklist"] = list(self.dns_blacklist) + if self.traffic is not None or self.dns is not None: + value["schemaVersion"] = 2 + if self.traffic is not None: + value["traffic"] = self.traffic.to_dict() + if self.dns is not None: + value["dns"] = self.dns.to_dict() return value diff --git a/sdk/python/examples/network_policy.py b/sdk/python/examples/network_policy.py index 76c6c73..dfa713f 100644 --- a/sdk/python/examples/network_policy.py +++ b/sdk/python/examples/network_policy.py @@ -12,11 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Exercise unrestricted, fully blocked, and DNS-denylisted networking.""" +"""Exercise unrestricted, blocked, DNS-filtered, and allowlisted networking.""" import shlex -from akernel_sdk import NetworkPolicy, Sandbox +from akernel_sdk import NetworkPolicy, NetworkRule, PortRange, Sandbox def tcp_connection(host: str, port: int) -> str: @@ -61,6 +61,50 @@ def main() -> None: assert allowed.exit_code == 0, allowed.stderr print("Allowed DNS and connection succeeded.") + allowlist = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="*.github.com", + protocol="tcp", + port_range=PortRange(443), + priority=200, + ), + NetworkRule( + cidr="192.0.2.10", + protocol="tcp", + port_range=PortRange(8443), + ), + ] + ) + with Sandbox(network_policy=allowlist) as restricted: + allowed = restricted.commands.run( + tcp_connection("api.github.com", 443), timeout=30 + ) + assert allowed.exit_code == 0, allowed.stderr + + denied = restricted.commands.run(tcp_connection("example.com", 443), timeout=10) + assert denied.exit_code != 0 + print("Generic egress allowlist enforced domain and port rules.") + with Sandbox() as dynamic: + dynamic.update_network_policy(NetworkPolicy.block()) + denied = dynamic.commands.run(direct_connection(), timeout=10) + assert denied.exit_code != 0 + + dynamic.update_network_policy( + NetworkPolicy.deny_dns("github.com", "*.github.com") + ) + allowed = dynamic.commands.run( + tcp_connection("example.com", 443), timeout=30 + ) + assert allowed.exit_code == 0, allowed.stderr + + dynamic.update_network_policy(None) + cleared = dynamic.commands.run( + tcp_connection("github.com", 443), timeout=30 + ) + assert cleared.exit_code == 0, cleared.stderr + print("Dynamic policy replacement and clearing succeeded.") + if __name__ == "__main__": main() diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 055e135..8f859a6 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -28,7 +28,8 @@ classifiers = [ "Topic :: System :: Distributed Computing", ] dependencies = [ - "openyuanrong-sandbox==0.10.1rc3", + "idna>=3,<4", + "openyuanrong-sandbox==0.10.1rc4", "websockets>=10.0", "dockerfile-parse>=2.0.1", ] @@ -81,7 +82,7 @@ warn_unused_ignores = true exclude = ["akernel_sdk/_instance.py"] [[tool.mypy.overrides]] -module = ["yr", "yr.*"] +module = ["yr", "yr.*", "yr_sandbox", "yr_sandbox.*"] ignore_missing_imports = true [[tool.mypy.overrides]] diff --git a/sdk/python/tests/unit/test_backends.py b/sdk/python/tests/unit/test_backends.py index d00bb42..425a55d 100644 --- a/sdk/python/tests/unit/test_backends.py +++ b/sdk/python/tests/unit/test_backends.py @@ -37,6 +37,8 @@ HttpReverseTunnel, Mount, NetworkPolicy, + NetworkRule, + PortRange, S3Config, ) @@ -362,6 +364,40 @@ def test_create_converts_network_policy_to_native_sdk_type(self): self.assertEqual(network.dns_blacklist, ("github.com", "*.github.com")) session.close() + def test_create_converts_acl_v2_to_native_sdk_types(self): + native = MagicMock() + native.id = "default-worker" + native.commands = MagicMock() + native.files = MagicMock() + policy = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="api.github.com", + protocol="tcp", + port_range=PortRange(80, 443), + priority=110, + ) + ] + ) + with patch.object( + openyuanrong_sandbox.yr_sandbox, + "Sandbox", + return_value=native, + ) as sandbox_type: + session = self.backend.create(_spec(network_policy=policy)) + + network = sandbox_type.call_args.kwargs["network"] + self.assertEqual(network.to_dict(), policy.to_dict()) + self.assertIsInstance( + network.traffic.rules[0], + openyuanrong_sandbox.yr_sandbox.NetworkRule, + ) + self.assertIsInstance( + network.traffic.rules[0].port_range, + openyuanrong_sandbox.yr_sandbox.PortRange, + ) + session.close() + def test_terminate_forces_deletion_of_detached_native_sandbox(self): native = MagicMock() native.id = "default-worker" @@ -415,9 +451,7 @@ def test_detached_delete_failure_still_allows_local_cleanup(self): None, ] session = self.backend.create(_spec(detached=True)) - with self.assertRaisesRegex( - BackendOperationError, "remote delete failed" - ): + with self.assertRaisesRegex(BackendOperationError, "remote delete failed"): try: session.terminate() finally: @@ -557,6 +591,31 @@ def test_reload_requires_capable_native_sdk(self): ): session.reload() + def test_update_network_policy_converts_native_type_and_clears(self): + native = MagicMock() + native.id = "default-source" + native.commands = MagicMock() + native.files = MagicMock() + with patch.object( + openyuanrong_sandbox.yr_sandbox, + "Sandbox", + return_value=native, + ): + session = self.backend.create(_spec()) + + policy = NetworkPolicy.deny_dns("github.com") + session.update_network_policy(policy) + session.update_network_policy(None) + + native_policy = native.update_network_policy.call_args_list[0].args[0] + self.assertIsInstance( + native_policy, + openyuanrong_sandbox.yr_sandbox.NetworkPolicy, + ) + self.assertEqual(native_policy.to_dict(), policy.to_dict()) + self.assertIsNone(native.update_network_policy.call_args_list[1].args[0]) + + class OpenYuanRongSdkBackendTest(unittest.TestCase): def setUp(self): self.config = BackendConfig( @@ -702,5 +761,15 @@ def test_close_finalizes_actor_sdk(self): finalize.assert_called_once_with() + def test_dynamic_network_policy_is_explicitly_unsupported(self): + session = openyuanrong_sdk._Session(MagicMock(), "physical-id", _spec(), None) + + with self.assertRaisesRegex( + UnsupportedBackendFeatureError, + "does not support dynamic network policy", + ): + session.update_network_policy(NetworkPolicy.block()) + + if __name__ == "__main__": unittest.main() diff --git a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py index a766a68..013b219 100644 --- a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py +++ b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py @@ -16,7 +16,14 @@ import unittest from unittest.mock import MagicMock, patch -from akernel_sdk import HttpReverseTunnel, Mount, NetworkPolicy, S3Config +from akernel_sdk import ( + HttpReverseTunnel, + Mount, + NetworkPolicy, + NetworkRule, + PortRange, + S3Config, +) from akernel_sdk._backends import openyuanrong_sdk_impl as _impl @@ -108,8 +115,9 @@ def test_resource_limit_validation(self): with self.assertRaisesRegex(ValueError, "mem_limit"): self.build_options(memory=4096, mem_limit=2048) for value in (0, -1): - with self.subTest(schedule_timeout=value), self.assertRaisesRegex( - ValueError, "schedule_timeout" + with ( + self.subTest(schedule_timeout=value), + self.assertRaisesRegex(ValueError, "schedule_timeout"), ): self.build_options(schedule_timeout=value) @@ -139,6 +147,24 @@ def test_network_policy_uses_custom_extension_wire_format(self): {"dnsBlacklist": ["github.com", "*.github.com"]}, ) + def test_acl_v2_uses_custom_extension_wire_format(self): + policy = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="api.github.com", + protocol="tcp", + port_range=PortRange(80, 443), + ) + ] + ) + + options = self.build_options(network_policy=policy) + + self.assertEqual( + json.loads(options.custom_extensions["network_policy"]), + policy.to_dict(), + ) + def test_extra_config_uses_custom_extension_wire_format(self): options = self.build_options( runtime="custom-runtime", diff --git a/sdk/python/tests/unit/test_sandbox.py b/sdk/python/tests/unit/test_sandbox.py index 6115869..5ba7508 100644 --- a/sdk/python/tests/unit/test_sandbox.py +++ b/sdk/python/tests/unit/test_sandbox.py @@ -18,11 +18,16 @@ from unittest.mock import MagicMock, patch from akernel_sdk import ( + DNSPolicy, + DNSRule, DockerfileLaunch, HttpReverseTunnel, NetworkPolicy, + NetworkRule, + PortRange, S3Config, Sandbox, + TrafficPolicy, ) from akernel_sdk import sandbox as sandbox_module from akernel_sdk._dockercontext import LocalDockerContext @@ -224,13 +229,17 @@ def test_runtime_identifier_is_normalized_and_passed_to_backend(self): def test_runtime_identifier_validation(self): for value in (None, 1): - with self.subTest(value=value), self.assertRaisesRegex( - TypeError, "runtime must be a string" + with ( + self.subTest(value=value), + self.assertRaisesRegex(TypeError, "runtime must be a string"), ): Sandbox(runtime=value) for value in ("", " "): - with self.subTest(value=value), self.assertRaisesRegex( - ValueError, "runtime must be a non-empty string" + with ( + self.subTest(value=value), + self.assertRaisesRegex( + ValueError, "runtime must be a non-empty string" + ), ): Sandbox(runtime=value) self.backend.create.assert_not_called() @@ -294,6 +303,106 @@ def test_dns_blacklist_is_normalized_and_passed_to_backend(self): ) sandbox.kill() + def test_acl_v2_allowlist_is_normalized_and_passed_to_backend(self): + policy = NetworkPolicy.allowlist( + [ + NetworkRule( + cidr="10.20.30.40", + protocol="tcp", + port_range=22773, + priority=110, + ), + NetworkRule( + domain="BÜCHER.example.", + protocol="tcp", + port_range=PortRange(80, 443), + ), + NetworkRule(protocol="udp", port_range=53), + ] + ) + + sandbox = Sandbox(network_policy=policy) + + spec = self.backend.create.call_args.args[0] + self.assertIs(spec.network_policy, policy) + self.assertEqual( + policy.to_dict(), + { + "schemaVersion": 2, + "traffic": { + "ingressDefaultAction": "allow", + "egressDefaultAction": "deny", + "mode": "stateful", + "rules": [ + { + "action": "allow", + "direction": "egress", + "protocol": "tcp", + "priority": 110, + "peer": { + "cidr": "10.20.30.40/32", + "portRange": {"first": 22773, "last": 22773}, + }, + }, + { + "action": "allow", + "direction": "egress", + "protocol": "tcp", + "priority": 100, + "peer": { + "domain": "xn--bcher-kva.example", + "portRange": {"first": 80, "last": 443}, + }, + }, + { + "action": "allow", + "direction": "egress", + "protocol": "udp", + "priority": 100, + "peer": {"portRange": {"first": 53, "last": 53}}, + }, + ], + }, + }, + ) + sandbox.kill() + + def test_acl_v2_supports_low_level_traffic_and_dns_policies(self): + policy = NetworkPolicy( + traffic=TrafficPolicy( + ingress_default_action="deny", + egress_default_action="allow", + mode="stateless", + rules=( + NetworkRule( + action="deny", + direction="ingress", + protocol="tcp", + cidr="192.0.2.129/24", + sandbox_port_range=PortRange(8000, 8010), + priority=200, + ), + ), + ), + dns=DNSPolicy( + default_action="deny", + rules=(DNSRule("*.example.com", action="allow"),), + ), + ) + + self.assertEqual(policy.to_dict()["schemaVersion"], 2) + self.assertEqual( + policy.to_dict()["traffic"]["rules"][0]["peer"]["cidr"], + "192.0.2.0/24", + ) + self.assertEqual( + policy.to_dict()["dns"], + { + "defaultAction": "deny", + "rules": [{"action": "allow", "pattern": "*.example.com"}], + }, + ) + def test_empty_network_policy_is_treated_as_unrestricted(self): sandbox = Sandbox(network_policy=NetworkPolicy()) @@ -307,7 +416,21 @@ def test_invalid_network_policy_is_rejected_before_backend(self): lambda: NetworkPolicy(dns_blacklist="github.com"), lambda: NetworkPolicy.deny_dns(), lambda: NetworkPolicy.deny_dns("github.*"), + lambda: NetworkPolicy.deny_dns("github.com.."), lambda: NetworkPolicy(block_network=True, dns_blacklist=("github.com",)), + lambda: PortRange(0), + lambda: PortRange(100, 99), + lambda: NetworkRule(cidr="2001:db8::/32"), + lambda: NetworkRule(cidr="10.0.0.0/8", domain="example.com"), + lambda: NetworkRule(domain="*.example.com", direction="both"), + lambda: NetworkRule(protocol="any", port_range=443), + lambda: NetworkRule(priority=(1 << 32) - 1), + lambda: TrafficPolicy(rules=(NetworkRule(),) * 257), + lambda: NetworkPolicy( + block_network=True, + traffic=TrafficPolicy(), + ), + lambda: NetworkPolicy.allowlist(()), ) for factory in invalid_factories: with ( @@ -327,8 +450,9 @@ def test_common_resource_validation_happens_before_backend(self): with self.assertRaisesRegex(ValueError, "cpu_limit"): Sandbox(cpu=2000, cpu_limit=1000) for value in (0, -1, -2): - with self.subTest(schedule_timeout=value), self.assertRaisesRegex( - ValueError, "schedule_timeout" + with ( + self.subTest(schedule_timeout=value), + self.assertRaisesRegex(ValueError, "schedule_timeout"), ): Sandbox(schedule_timeout=value) self.backend.create.assert_not_called() @@ -574,6 +698,28 @@ def test_get_port_url(self): sandbox.get_port_url(9090) sandbox.kill() + def test_update_network_policy_normalizes_and_delegates(self): + sandbox = Sandbox() + policy = NetworkPolicy.deny_dns("github.com") + + sandbox.update_network_policy(policy) + sandbox.update_network_policy(NetworkPolicy()) + sandbox.update_network_policy(None) + + self.assertEqual( + self.session.update_network_policy.call_args_list, + [ + unittest.mock.call(policy), + unittest.mock.call(None), + unittest.mock.call(None), + ], + ) + with self.assertRaises(TypeError): + sandbox.update_network_policy({"blockNetwork": True}) + sandbox.kill() + with self.assertRaises(RuntimeError): + sandbox.update_network_policy(None) + if __name__ == "__main__": unittest.main() diff --git a/sdk/python/tests/unit/test_types.py b/sdk/python/tests/unit/test_types.py index 06e9374..21c92da 100644 --- a/sdk/python/tests/unit/test_types.py +++ b/sdk/python/tests/unit/test_types.py @@ -77,6 +77,11 @@ def test_public_exports_are_minimal(self): "S3Config", "Mount", "NetworkPolicy", + "NetworkRule", + "PortRange", + "TrafficPolicy", + "DNSPolicy", + "DNSRule", "HttpReverseTunnel", "CommandResult", "CommandInfo", diff --git a/src/sandboxd b/src/sandboxd index 499e6d4..49fa7b1 160000 --- a/src/sandboxd +++ b/src/sandboxd @@ -1 +1 @@ -Subproject commit 499e6d499f0c5e705848179ccc42bcc6e193f6e2 +Subproject commit 49fa7b10996e28c18c5588e2fa1ee1e7d463e6a0 diff --git a/src/yuanrong b/src/yuanrong index 6004958..353e955 160000 --- a/src/yuanrong +++ b/src/yuanrong @@ -1 +1 @@ -Subproject commit 6004958250e06578385c554d165062704e9d646e +Subproject commit 353e955ff6aea6c175a453e1b38c895269758f62