From ce00b5f145b5f5818cd3d9222cc843ad738582b9 Mon Sep 17 00:00:00 2001 From: Ben Werner Date: Thu, 16 Apr 2026 12:49:51 -0700 Subject: [PATCH] Add ContainerOptions.allowPrivileged for FUSE in local-dev Docker containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers using FUSE work in production but break in `wrangler dev` because workerd doesn't grant CAP_SYS_ADMIN, mount /dev/fuse, or relax AppArmor on the container it creates. Without those three knobs the mount("fuse", ...) syscall inside the container fails. Add a new boolean to ContainerOptions in workerd.capnp, off by default: struct ContainerOptions { imageName @0 :Text; allowPrivileged @1 :Bool = false; # NEW } When set on a DurableObjectNamespace's container config, ContainerClient adds the minimum HostConfig fields needed for FUSE to the POST /containers/create payload: - CapAdd: ["SYS_ADMIN"] (for the mount() syscall) - Devices: [/dev/fuse] (so the kernel has something to open) - SecurityOpt: ["apparmor:unconfined"] (bypasses the default docker apparmor profile's mount block) No Privileged=true. Mirrors what Cloudflare's production container runtime effectively provides for FUSE workers, without granting all Linux capabilities or full /dev passthrough. server.c++ reads the flag from the per-DO ContainerOptions config and threads it through to the ContainerClient constructor. Off by default means existing configs are unaffected. Only the local Docker code path is touched; production is unaffected. Also adds missing $Json.name annotations to docker_api::DeviceMapping fields in docker-api.capnp so they serialize as PathOnHost / PathInContainer / CgroupPermissions to match the Docker API. This is load-bearing for the Devices bind above — without it, the entry serializes as camelCase and Docker silently ignores it. Before this PR no call site populated Devices, so the missing annotations were latent. Follows the existing pattern of createSidecarContainer() which already sets CapAdd=[NET_ADMIN] for its own need at container-client.c++:1958. The matching workers-sdk PR (https://github.com/cloudflare/workers-sdk/pull/13748) adds `dev.enable_containers_privileged_mode` and `--enable-containers-privileged-mode` so wrangler / vite-plugin users can opt in. End-to-end reproduction (libc mount("fuse", ...) syscall succeeding inside the container with the flag set, and failing without it) at https://github.com/Ben2W/workerd-fuse-local-repro Signed-off-by: Ben Werner --- src/workerd/server/container-client-test.c++ | 74 ++++++++++++++++++++ src/workerd/server/container-client.c++ | 33 ++++++++- src/workerd/server/container-client.h | 12 +++- src/workerd/server/docker-api.capnp | 6 +- src/workerd/server/server.c++ | 9 ++- src/workerd/server/workerd.capnp | 7 ++ 6 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/workerd/server/container-client-test.c++ b/src/workerd/server/container-client-test.c++ index 2e60addb3b7..ceae37ab04f 100644 --- a/src/workerd/server/container-client-test.c++ +++ b/src/workerd/server/container-client-test.c++ @@ -195,5 +195,79 @@ KJ_TEST("ContainerCreateRequest encodes HostConfig Dns") { KJ_EXPECT(decodedDns[1] == "8.8.8.8"); } +KJ_TEST("ContainerCreateRequest encodes privileged FUSE HostConfig") { + capnp::JsonCodec codec; + codec.handleByAnnotation(); + + capnp::MallocMessageBuilder message; + auto root = message.initRoot(); + root.setImage("test-image"); + + auto hostConfig = root.initHostConfig(); + configurePrivilegedContainerHostConfig(hostConfig, true); + + auto json = codec.encode(root); + auto jsonText = json.asPtr(); + + KJ_EXPECT(jsonText.contains("\"CapAdd\"")); + KJ_EXPECT(jsonText.contains("\"SYS_ADMIN\"")); + KJ_EXPECT(jsonText.contains("\"Devices\"")); + KJ_EXPECT(jsonText.contains("\"PathOnHost\":\"/dev/fuse\"")); + KJ_EXPECT(jsonText.contains("\"PathInContainer\":\"/dev/fuse\"")); + KJ_EXPECT(jsonText.contains("\"CgroupPermissions\":\"rwm\"")); + KJ_EXPECT(jsonText.contains("\"SecurityOpt\"")); + KJ_EXPECT(jsonText.contains("\"apparmor:unconfined\"")); + + auto decoded = decodeJsonResponse(jsonText); + auto decodedRoot = decoded->getRoot(); + auto decodedHostConfig = decodedRoot.getHostConfig(); + auto decodedCapAdd = decodedHostConfig.getCapAdd(); + auto decodedDevices = decodedHostConfig.getDevices(); + auto decodedSecurityOpt = decodedHostConfig.getSecurityOpt(); + + KJ_REQUIRE(decodedCapAdd.size() == 1); + KJ_EXPECT(decodedCapAdd[0] == "SYS_ADMIN"); + + KJ_REQUIRE(decodedDevices.size() == 1); + KJ_EXPECT(decodedDevices[0].getPathOnHost() == "/dev/fuse"); + KJ_EXPECT(decodedDevices[0].getPathInContainer() == "/dev/fuse"); + KJ_EXPECT(decodedDevices[0].getCgroupPermissions() == "rwm"); + + KJ_REQUIRE(decodedSecurityOpt.size() == 1); + KJ_EXPECT(decodedSecurityOpt[0] == "apparmor:unconfined"); +} + +KJ_TEST("ContainerCreateRequest does not encode privileged FUSE HostConfig when disabled") { + capnp::JsonCodec codec; + codec.handleByAnnotation(); + + capnp::MallocMessageBuilder message; + auto root = message.initRoot(); + root.setImage("test-image"); + + auto hostConfig = root.initHostConfig(); + configurePrivilegedContainerHostConfig(hostConfig, false); + + auto json = codec.encode(root); + auto jsonText = json.asPtr(); + + KJ_EXPECT(!jsonText.contains("\"CapAdd\"")); + KJ_EXPECT(!jsonText.contains("\"SYS_ADMIN\"")); + KJ_EXPECT(!jsonText.contains("\"Devices\"")); + KJ_EXPECT(!jsonText.contains("\"PathOnHost\"")); + KJ_EXPECT(!jsonText.contains("\"PathInContainer\"")); + KJ_EXPECT(!jsonText.contains("\"CgroupPermissions\"")); + KJ_EXPECT(!jsonText.contains("\"SecurityOpt\"")); + KJ_EXPECT(!jsonText.contains("\"apparmor:unconfined\"")); + + auto decoded = decodeJsonResponse(jsonText); + auto decodedRoot = decoded->getRoot(); + auto decodedHostConfig = decodedRoot.getHostConfig(); + + KJ_EXPECT(decodedHostConfig.getCapAdd().size() == 0); + KJ_EXPECT(decodedHostConfig.getDevices().size() == 0); + KJ_EXPECT(decodedHostConfig.getSecurityOpt().size() == 0); +} + } // namespace } // namespace workerd::server diff --git a/src/workerd/server/container-client.c++ b/src/workerd/server/container-client.c++ index 69f2f0c6201..12180f3939a 100644 --- a/src/workerd/server/container-client.c++ +++ b/src/workerd/server/container-client.c++ @@ -912,6 +912,33 @@ kj::Maybe tryParsePublishedHostPort(capnp::json::Value::Reader portMap } // namespace +void configurePrivilegedContainerHostConfig( + docker_api::Docker::ContainerCreateRequest::HostConfig::Builder hostConfig, + bool allowPrivileged) { + if (!allowPrivileged) { + return; + } + + // Docker doesn't grant FUSE access by default. When the user opts in via + // ContainerOptions.allowPrivileged, enable the minimum permissions for it. + { + auto capAdd = hostConfig.initCapAdd(1); + capAdd.set(0, "SYS_ADMIN"); + } + { + auto devices = hostConfig.initDevices(1); + auto fuseDev = devices[0]; + fuseDev.setPathOnHost("/dev/fuse"); + fuseDev.setPathInContainer("/dev/fuse"); + fuseDev.setCgroupPermissions("rwm"); + } + { + // Linux-only: no-op on hosts without AppArmor (e.g. macOS). + auto securityOpt = hostConfig.initSecurityOpt(1); + securityOpt.set(0, "apparmor:unconfined"); + } +} + // Represents a parsed egress mapping. IP/CIDR mappings match destination IPs, // while hostnameGlob mappings match either HTTP hostnames or TLS SNI depending on protocol. // Defined here (not in the header) to avoid pulling kj::OneOf, kj::CidrRange, and @@ -939,7 +966,8 @@ ContainerClient::ContainerClient(capnp::ByteStreamFactory& byteStreamFactory, kj::TaskSet& waitUntilTasks, kj::Promise pendingCleanup, kj::Function)> cleanupCallback, - ChannelTokenHandler& channelTokenHandler) + ChannelTokenHandler& channelTokenHandler, + bool allowPrivileged) : byteStreamFactory(byteStreamFactory), timer(timer), network(network), @@ -948,6 +976,7 @@ ContainerClient::ContainerClient(capnp::ByteStreamFactory& byteStreamFactory, sidecarContainerName(kj::encodeUriComponent(kj::str(containerName, "-proxy"))), imageName(kj::mv(imageName)), containerEgressInterceptorImage(kj::mv(containerEgressInterceptorImage)), + allowPrivileged(allowPrivileged), waitUntilTasks(waitUntilTasks), pendingCleanup(kj::mv(pendingCleanup).fork()), cleanupCallback(kj::mv(cleanupCallback)), @@ -1722,6 +1751,8 @@ kj::Promise ContainerClient::createContainer(kj::StringPtr effectiveImage, } } + configurePrivilegedContainerHostConfig(hostConfig, allowPrivileged); + auto response = co_await dockerApiRequest(network, kj::str(dockerPath), kj::HttpMethod::POST, kj::str("/containers/create?name=", containerName), codec.encode(jsonRoot)); diff --git a/src/workerd/server/container-client.h b/src/workerd/server/container-client.h index 1cc6c244bd2..a6f56dd61b4 100644 --- a/src/workerd/server/container-client.h +++ b/src/workerd/server/container-client.h @@ -48,6 +48,10 @@ kj::Own decodeJsonResponse(kj::StringPtr response) return message; } +void configurePrivilegedContainerHostConfig( + docker_api::Docker::ContainerCreateRequest::HostConfig::Builder hostConfig, + bool allowPrivileged); + // Docker-based implementation that implements the rpc::Container::Server interface // so it can be used as a rpc::Container::Client via kj::heap(). // This allows the Container JSG class to use Docker directly without knowing @@ -68,7 +72,8 @@ class ContainerClient final: public rpc::Container::Server, public kj::Refcounte kj::TaskSet& waitUntilTasks, kj::Promise pendingCleanup, kj::Function)> cleanupCallback, - ChannelTokenHandler& channelTokenHandler); + ChannelTokenHandler& channelTokenHandler, + bool allowPrivileged); ~ContainerClient() noexcept(false); @@ -104,6 +109,11 @@ class ContainerClient final: public rpc::Container::Server, public kj::Refcounte // Container egress interceptor image name (sidecar for egress proxy) kj::String containerEgressInterceptorImage; + // When true, createContainer() injects the HostConfig fields needed for FUSE + // (CAP_SYS_ADMIN, /dev/fuse, AppArmor unconfined). See ContainerOptions in + // workerd.capnp. + bool allowPrivileged; + kj::TaskSet& waitUntilTasks; // Forked promise representing pending cleanup from a previous ContainerClient for the same diff --git a/src/workerd/server/docker-api.capnp b/src/workerd/server/docker-api.capnp index 3b63abb0834..8464e03b992 100644 --- a/src/workerd/server/docker-api.capnp +++ b/src/workerd/server/docker-api.capnp @@ -32,9 +32,9 @@ struct Docker { } struct DeviceMapping { - pathOnHost @0 :Text; - pathInContainer @1 :Text; - cgroupPermissions @2 :Text; + pathOnHost @0 :Text $Json.name("PathOnHost"); + pathInContainer @1 :Text $Json.name("PathInContainer"); + cgroupPermissions @2 :Text $Json.name("CgroupPermissions"); } struct DeviceRequest { diff --git a/src/workerd/server/server.c++ b/src/workerd/server/server.c++ index a298e80cad4..6f9c1b97674 100644 --- a/src/workerd/server/server.c++ +++ b/src/workerd/server/server.c++ @@ -2936,6 +2936,7 @@ class Server::WorkerService final: public Service, KJ_IF_SOME(config, containerOptions) { KJ_ASSERT(config.hasImageName(), "Image name is required"); auto imageName = config.getImageName(); + bool allowPrivileged = config.getAllowPrivileged(); kj::String containerId; KJ_SWITCH_ONEOF(id) { KJ_CASE_ONEOF(globalId, kj::Own) { @@ -2947,7 +2948,8 @@ class Server::WorkerService final: public Service, } container = ns.getContainerClient( - kj::str("workerd-", KJ_ASSERT_NONNULL(uniqueKey), "-", containerId), imageName); + kj::str("workerd-", KJ_ASSERT_NONNULL(uniqueKey), "-", containerId), imageName, + allowPrivileged); } auto actor = actorClass->newActor(getTracker(), Worker::Actor::cloneId(id), @@ -2992,7 +2994,7 @@ class Server::WorkerService final: public Service, } kj::Own getContainerClient( - kj::StringPtr containerId, kj::StringPtr imageName) { + kj::StringPtr containerId, kj::StringPtr imageName, bool allowPrivileged) { KJ_IF_SOME(existingClient, containerClients.find(containerId)) { return existingClient->addRef(); } @@ -3050,7 +3052,8 @@ class Server::WorkerService final: public Service, kj::str(dockerPathRef), kj::str(containerId), kj::str(imageName), kj::str(KJ_ASSERT_NONNULL(containerEgressInterceptorImage, "containerEgressInterceptorImage must be configured for containers.")), - waitUntilTasks, kj::mv(previousCleanup), kj::mv(cleanupCallback), channelTokenHandler); + waitUntilTasks, kj::mv(previousCleanup), kj::mv(cleanupCallback), channelTokenHandler, + allowPrivileged); // Store raw pointer in map (does not own) containerClients.insert(kj::str(containerId), client.get()); diff --git a/src/workerd/server/workerd.capnp b/src/workerd/server/workerd.capnp index d433362ff99..bc1bf70616b 100644 --- a/src/workerd/server/workerd.capnp +++ b/src/workerd/server/workerd.capnp @@ -673,6 +673,13 @@ struct Worker { imageName @0 :Text; # Image name to be used to create the container using supported provider. # By default, we pull the "latest" tag of this image. + + allowPrivileged @1 :Bool = false; + # When true, workerd creates the container with the elevated permissions + # needed for FUSE in local dev: CAP_SYS_ADMIN, /dev/fuse passthrough, and + # AppArmor unconfined. Off by default — only set this from a dev-only + # config path. Has no effect on production Cloudflare Containers, which + # use a different runtime path. } }