Skip to content

Commit 4f98512

Browse files
committed
Add ContainerOptions.allowPrivileged for FUSE in local-dev Docker containers
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 (cloudflare/workers-sdk#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 <bewerner23@gmail.com>
1 parent 22c1ca0 commit 4f98512

5 files changed

Lines changed: 47 additions & 8 deletions

File tree

src/workerd/server/container-client.c++

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,8 @@ ContainerClient::ContainerClient(capnp::ByteStreamFactory& byteStreamFactory,
934934
kj::TaskSet& waitUntilTasks,
935935
kj::Promise<void> pendingCleanup,
936936
kj::Function<void(kj::Promise<void>)> cleanupCallback,
937-
ChannelTokenHandler& channelTokenHandler)
937+
ChannelTokenHandler& channelTokenHandler,
938+
bool allowPrivileged)
938939
: byteStreamFactory(byteStreamFactory),
939940
timer(timer),
940941
network(network),
@@ -943,6 +944,7 @@ ContainerClient::ContainerClient(capnp::ByteStreamFactory& byteStreamFactory,
943944
sidecarContainerName(kj::encodeUriComponent(kj::str(containerName, "-proxy"))),
944945
imageName(kj::mv(imageName)),
945946
containerEgressInterceptorImage(kj::mv(containerEgressInterceptorImage)),
947+
allowPrivileged(allowPrivileged),
946948
waitUntilTasks(waitUntilTasks),
947949
pendingCleanup(kj::mv(pendingCleanup).fork()),
948950
cleanupCallback(kj::mv(cleanupCallback)),
@@ -1698,6 +1700,27 @@ kj::Promise<void> ContainerClient::createContainer(kj::StringPtr effectiveImage,
16981700
}
16991701
}
17001702

1703+
// Docker doesn't grant FUSE access by default — when the user opts in via
1704+
// ContainerOptions.allowPrivileged, enable the minimum permissions for it.
1705+
if (allowPrivileged) {
1706+
{
1707+
auto capAdd = hostConfig.initCapAdd(1);
1708+
capAdd.set(0, "SYS_ADMIN");
1709+
}
1710+
{
1711+
auto devices = hostConfig.initDevices(1);
1712+
auto fuseDev = devices[0];
1713+
fuseDev.setPathOnHost("/dev/fuse");
1714+
fuseDev.setPathInContainer("/dev/fuse");
1715+
fuseDev.setCgroupPermissions("rwm");
1716+
}
1717+
{
1718+
// Linux-only: no-op on hosts without AppArmor (e.g. macOS).
1719+
auto securityOpt = hostConfig.initSecurityOpt(1);
1720+
securityOpt.set(0, "apparmor:unconfined");
1721+
}
1722+
}
1723+
17011724
auto response = co_await dockerApiRequest(network, kj::str(dockerPath), kj::HttpMethod::POST,
17021725
kj::str("/containers/create?name=", containerName), codec.encode(jsonRoot));
17031726

src/workerd/server/container-client.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ class ContainerClient final: public rpc::Container::Server, public kj::Refcounte
6868
kj::TaskSet& waitUntilTasks,
6969
kj::Promise<void> pendingCleanup,
7070
kj::Function<void(kj::Promise<void>)> cleanupCallback,
71-
ChannelTokenHandler& channelTokenHandler);
71+
ChannelTokenHandler& channelTokenHandler,
72+
bool allowPrivileged);
7273

7374
~ContainerClient() noexcept(false);
7475

@@ -103,6 +104,11 @@ class ContainerClient final: public rpc::Container::Server, public kj::Refcounte
103104
// Container egress interceptor image name (sidecar for egress proxy)
104105
kj::String containerEgressInterceptorImage;
105106

107+
// When true, createContainer() injects the HostConfig fields needed for FUSE
108+
// (CAP_SYS_ADMIN, /dev/fuse, AppArmor unconfined). See ContainerOptions in
109+
// workerd.capnp.
110+
bool allowPrivileged;
111+
106112
kj::TaskSet& waitUntilTasks;
107113

108114
// Forked promise representing pending cleanup from a previous ContainerClient for the same

src/workerd/server/docker-api.capnp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ struct Docker {
3232
}
3333

3434
struct DeviceMapping {
35-
pathOnHost @0 :Text;
36-
pathInContainer @1 :Text;
37-
cgroupPermissions @2 :Text;
35+
pathOnHost @0 :Text $Json.name("PathOnHost");
36+
pathInContainer @1 :Text $Json.name("PathInContainer");
37+
cgroupPermissions @2 :Text $Json.name("CgroupPermissions");
3838
}
3939

4040
struct DeviceRequest {

src/workerd/server/server.c++

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2867,6 +2867,7 @@ class Server::WorkerService final: public Service,
28672867
KJ_IF_SOME(config, containerOptions) {
28682868
KJ_ASSERT(config.hasImageName(), "Image name is required");
28692869
auto imageName = config.getImageName();
2870+
bool allowPrivileged = config.getAllowPrivileged();
28702871
kj::String containerId;
28712872
KJ_SWITCH_ONEOF(id) {
28722873
KJ_CASE_ONEOF(globalId, kj::Own<ActorIdFactory::ActorId>) {
@@ -2878,7 +2879,8 @@ class Server::WorkerService final: public Service,
28782879
}
28792880

28802881
container = ns.getContainerClient(
2881-
kj::str("workerd-", KJ_ASSERT_NONNULL(uniqueKey), "-", containerId), imageName);
2882+
kj::str("workerd-", KJ_ASSERT_NONNULL(uniqueKey), "-", containerId), imageName,
2883+
allowPrivileged);
28822884
}
28832885

28842886
auto actor = actorClass->newActor(getTracker(), Worker::Actor::cloneId(id),
@@ -2922,7 +2924,7 @@ class Server::WorkerService final: public Service,
29222924
}
29232925

29242926
kj::Own<ContainerClient> getContainerClient(
2925-
kj::StringPtr containerId, kj::StringPtr imageName) {
2927+
kj::StringPtr containerId, kj::StringPtr imageName, bool allowPrivileged) {
29262928
KJ_IF_SOME(existingClient, containerClients.find(containerId)) {
29272929
return existingClient->addRef();
29282930
}
@@ -2980,7 +2982,8 @@ class Server::WorkerService final: public Service,
29802982
kj::str(dockerPathRef), kj::str(containerId), kj::str(imageName),
29812983
kj::str(KJ_ASSERT_NONNULL(containerEgressInterceptorImage,
29822984
"containerEgressInterceptorImage must be configured for containers.")),
2983-
waitUntilTasks, kj::mv(previousCleanup), kj::mv(cleanupCallback), channelTokenHandler);
2985+
waitUntilTasks, kj::mv(previousCleanup), kj::mv(cleanupCallback), channelTokenHandler,
2986+
allowPrivileged);
29842987

29852988
// Store raw pointer in map (does not own)
29862989
containerClients.insert(kj::str(containerId), client.get());

src/workerd/server/workerd.capnp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,13 @@ struct Worker {
672672
imageName @0 :Text;
673673
# Image name to be used to create the container using supported provider.
674674
# By default, we pull the "latest" tag of this image.
675+
676+
allowPrivileged @1 :Bool = false;
677+
# When true, workerd creates the container with the elevated permissions
678+
# needed for FUSE in local dev: CAP_SYS_ADMIN, /dev/fuse passthrough, and
679+
# AppArmor unconfined. Off by default — only set this from a dev-only
680+
# config path. Has no effect on production Cloudflare Containers, which
681+
# use a different runtime path.
675682
}
676683
}
677684

0 commit comments

Comments
 (0)