Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/workerd/server/container-client-test.c++
Original file line number Diff line number Diff line change
Expand Up @@ -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<docker_api::Docker::ContainerCreateRequest>();

capnp::MallocMessageBuilder message;
auto root = message.initRoot<docker_api::Docker::ContainerCreateRequest>();
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<docker_api::Docker::ContainerCreateRequest>(jsonText);
auto decodedRoot = decoded->getRoot<docker_api::Docker::ContainerCreateRequest>();
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<docker_api::Docker::ContainerCreateRequest>();

capnp::MallocMessageBuilder message;
auto root = message.initRoot<docker_api::Docker::ContainerCreateRequest>();
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<docker_api::Docker::ContainerCreateRequest>(jsonText);
auto decodedRoot = decoded->getRoot<docker_api::Docker::ContainerCreateRequest>();
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
33 changes: 32 additions & 1 deletion src/workerd/server/container-client.c++
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,33 @@ kj::Maybe<uint16_t> 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
Expand Down Expand Up @@ -939,7 +966,8 @@ ContainerClient::ContainerClient(capnp::ByteStreamFactory& byteStreamFactory,
kj::TaskSet& waitUntilTasks,
kj::Promise<void> pendingCleanup,
kj::Function<void(kj::Promise<void>)> cleanupCallback,
ChannelTokenHandler& channelTokenHandler)
ChannelTokenHandler& channelTokenHandler,
bool allowPrivileged)
: byteStreamFactory(byteStreamFactory),
timer(timer),
network(network),
Expand All @@ -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)),
Expand Down Expand Up @@ -1722,6 +1751,8 @@ kj::Promise<void> 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));

Expand Down
12 changes: 11 additions & 1 deletion src/workerd/server/container-client.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ kj::Own<capnp::MallocMessageBuilder> 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<ContainerClient>().
// This allows the Container JSG class to use Docker directly without knowing
Expand All @@ -68,7 +72,8 @@ class ContainerClient final: public rpc::Container::Server, public kj::Refcounte
kj::TaskSet& waitUntilTasks,
kj::Promise<void> pendingCleanup,
kj::Function<void(kj::Promise<void>)> cleanupCallback,
ChannelTokenHandler& channelTokenHandler);
ChannelTokenHandler& channelTokenHandler,
bool allowPrivileged);

~ContainerClient() noexcept(false);

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/workerd/server/docker-api.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Comment on lines 34 to 38

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docker's API uses PascalCase keys for these fields (PathOnHost / PathInContainer / CgroupPermissions). Without these $Json.name annotations, capnp's JSON codec emits the source field names in camelCase, which Docker silently ignores


struct DeviceRequest {
Expand Down
9 changes: 6 additions & 3 deletions src/workerd/server/server.c++
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActorIdFactory::ActorId>) {
Expand All @@ -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),
Expand Down Expand Up @@ -2992,7 +2994,7 @@ class Server::WorkerService final: public Service,
}

kj::Own<ContainerClient> 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();
}
Expand Down Expand Up @@ -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());
Expand Down
7 changes: 7 additions & 0 deletions src/workerd/server/workerd.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

Expand Down
Loading