Skip to content

Commit 4278b27

Browse files
committed
containers: Add experimental support for interceptOutboundHttps
`interceptOutboundHttps` is a way for users to intercept their own TLS traffic. The way this works is different from interceptOutboundHttp. In the first one, we can decide which IP and port combinations should intercept HTTP traffic, but HTTPS is more idiomatic to handle at the SNI level. The reasoning behind this is that customers that want to intercept TLS might want to only be triggering on certain SNIs being intercepted. We do support currently other ports than 443 but that might change in the future by extending the method to accept a port with the SNI. It's just the use-case is clear to be SNI based only. The glob format of the SNI that we accept is really simple, only '*' and the domain (to support cases like *google.com and all its subdomains). No plans on supporting regex here whatsoever. The way local dev works is we generate the certificates in the networking sidecar, we read it via exec, and write them to the container to a known path (/etc/cloudflare/certs/cloudflare-containers-ca.crt). We could try to append the certificate to known distro paths, but that might be a more controversial move, we can discuss in the MR if it's worth doing. The flow of the connection is: ``` [container] --> [proxy-everything] (tls) --> [workerd container-client.c++] (processes configured egress policies) -> [workerd subrequest channel] ``` The only way to make files being written consistently across distros is by using the Docker /archive API. It can only accept a tar right now, so we had to add a method that creates a simple tar file that contains a single file that we want to add to the container (the CA).
1 parent a68feff commit 4278b27

15 files changed

Lines changed: 53103 additions & 24 deletions

File tree

.opencode/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"dependencies": {
3-
"@opencode-ai/plugin": "^1"
3+
"@opencode-ai/plugin": "1.2.18"
44
}
5-
}
5+
}

images/container-client-test/app.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const { createServer } = require('http');
22

33
const webSocketEnabled = process.env.WS_ENABLED === 'true';
44
const wsProxyTarget = process.env.WS_PROXY_TARGET || null;
5+
const wsProxySecure = process.env.WS_PROXY_SECURE === 'true';
56

67
const server = createServer(function (req, res) {
78
if (req.url === '/ws') {
@@ -55,6 +56,24 @@ const server = createServer(function (req, res) {
5556
return;
5657
}
5758

59+
if (req.url === '/intercept-https') {
60+
const targetHost = req.headers['x-host'] || 'example.com';
61+
fetch(`https://${targetHost}`)
62+
.then((result) => result.text())
63+
.then((body) => {
64+
res.writeHead(200);
65+
res.write(body);
66+
res.end();
67+
})
68+
.catch((err) => {
69+
res.writeHead(500);
70+
res.write(`${targetHost} ${err.message}`);
71+
res.end();
72+
});
73+
74+
return;
75+
}
76+
5877
res.writeHead(200, { 'Content-Type': 'text/plain' });
5978
res.write('Hello World!');
6079
res.end();
@@ -66,7 +85,8 @@ if (webSocketEnabled) {
6685

6786
wss.on('connection', function (clientWs) {
6887
if (wsProxyTarget) {
69-
const targetWs = new WebSocket(`ws://${wsProxyTarget}/ws`);
88+
const protocol = wsProxySecure ? 'wss' : 'ws';
89+
const targetWs = new WebSocket(`${protocol}://${wsProxyTarget}/ws`);
7090
const ready = new Promise(function (resolve) {
7191
targetWs.on('open', resolve);
7292
});

src/workerd/api/container.c++

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,19 @@ jsg::Promise<void> Container::interceptAllOutboundHttp(jsg::Lock& js, jsg::Ref<F
106106
kj::joinPromisesFailFast(kj::arr(reqV4.sendIgnoringResult(), reqV6.sendIgnoringResult())));
107107
}
108108

109+
jsg::Promise<void> Container::interceptOutboundHttps(
110+
jsg::Lock& js, kj::String sniGlob, jsg::Ref<Fetcher> binding) {
111+
auto& ioctx = IoContext::current();
112+
auto channel = binding->getSubrequestChannel(ioctx);
113+
auto token = channel->getToken(IoChannelFactory::ChannelTokenUsage::RPC);
114+
115+
auto req = rpcClient->setEgressHttpsRequest();
116+
req.setSniGlob(sniGlob);
117+
req.setChannelToken(token);
118+
119+
return ioctx.awaitIo(js, req.sendIgnoringResult());
120+
}
121+
109122
jsg::Promise<void> Container::monitor(jsg::Lock& js) {
110123
JSG_REQUIRE(running, Error, "monitor() cannot be called on a container that is not running.");
111124

src/workerd/api/container.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ class Container: public jsg::Object {
6565
jsg::Promise<void> interceptOutboundHttp(
6666
jsg::Lock& js, kj::String addr, jsg::Ref<Fetcher> binding);
6767
jsg::Promise<void> interceptAllOutboundHttp(jsg::Lock& js, jsg::Ref<Fetcher> binding);
68+
jsg::Promise<void> interceptOutboundHttps(
69+
jsg::Lock& js, kj::String sniGlob, jsg::Ref<Fetcher> binding);
6870

6971
// TODO(containers): listenTcp()
7072

@@ -80,6 +82,7 @@ class Container: public jsg::Object {
8082
if (flags.getWorkerdExperimental()) {
8183
JSG_METHOD(interceptOutboundHttp);
8284
JSG_METHOD(interceptAllOutboundHttp);
85+
JSG_METHOD(interceptOutboundHttps);
8386
}
8487
}
8588

src/workerd/io/container.capnp

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,19 @@ interface Container @0x9aaceefc06523bca {
119119
# Configures egress HTTP routing for the container. When the container attempts to connect to the
120120
# specified host:port, the connection should be routed back to the Workers runtime using the channel token.
121121
# The format of hostPort can be '<ip|cidr>[':'<port>]'. If port is omitted, it's assumed to only cover port 80.
122-
# This method does not support HTTPs yet.
123122

123+
setEgressHttps @9 (sniGlob :Text, channelToken :Data);
124+
# Intercepts outbound TLS connections whose SNI matches `sniGlob`, routing decrypted
125+
# HTTP to the worker binding identified by `channelToken`. The runtime must ensure
126+
# the container trusts the interception CA.
127+
#
128+
# sniGlob: glob pattern for TLS SNI hostnames (e.g. "*.example.com", "*").
129+
# "*.example.com" matches any subdomain including nested ones like "a.b.example.com".
130+
# channelToken: opaque token identifying the worker binding to route requests to.
131+
#
132+
# This method does not support specifying ports as it's designed for traffic firewall
133+
# of internet access of the container through Workers. To hit a Worker in another
134+
# port that is not 443, use setEgressHttp.
124135

125136
# TODO: setEgressTcp
126137
}

0 commit comments

Comments
 (0)