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. } }