Custom domain (experimental)¶
Since 0.7.0, experimental (What's new).
What it does¶
Without this feature, a sandbox port is only reachable by sending the
x-aws-proxy-auth/x-aws-proxy-port headers that get_host() returns:
a browser, a webhook or an <iframe> cannot. With a custom domain, each
port has a normal HTTPS URL under your domain:
which opens from a browser without the proxy header. Underneath there is a
CloudFront distribution in your account with a CloudFront Function that,
on each request, looks up the hostname's route in a KeyValueStore, checks the
traffic token and forwards to the sandbox, setting the AWS proxy headers
itself.
Experimental: not verified end to end on real AWS
CustomDomain (deploying and deleting the stack, and route
register/unregister/refresh) is implemented in Python and TypeScript,
with unit tests, cfn-lint on the template and tests of the routing
Function. It is published as experimental so you can try it: the API
may change in a minor.
Verified on real AWS (AWS_API_NOTES.md):
- The CloudFront Function compiles and routes in the real
cloudfront-js-2.0runtime (TestFunction, Q121): correct token → continues; no token or wrong token → 403; expired or nonexistent route → 404. CustomDomain.deploy()creates the template'sKeyValueStoreand Function as is (Q140, after adjusting theCommentfields to CloudFront's 128-character limit), and the rollback anddestroy()leave nothing behind.
Not verified on real AWS: creating the distribution and serving traffic
through it (HTTP/1.1, WebSocket, KeyValueStore propagation
latency, JWE renewal). The project's test account
denies cloudfront:CreateDistribution with an organization SCP
(Q141), so nobody has yet opened a custom domain URL
deployed with this template. The repository's e2e
(tests/e2e/test_m15_custom_domain.py, custom-domain.e2e.test.ts)
automates that test for an account that does allow it.
Sandbox.create(domain=...)/get_host() also do not return that URL
on their own yet: domain= still raises UnimplementedError
(ADR-024 of ARCHITECTURE.md). Today you register the route yourself and
route.host is exactly the hostname above (see the example).
If you try it, tell us how it went in a
GitHub issue
with [custom-domain] in the title: SDK and version, region, which step failed
(deploy, register, the HTTP request), the status code and the
reason from the stack events if there was one. Never paste your
certificate ARN, your real domain, tokens or JWE: use example.com and
123456789012.
Cost and activation
- Status: experimental. The cost figures are list prices and have not been measured with a real distribution (see the warning above).
- Default: off. Without instantiating
CustomDomainthe SDK does not build anycloudformationorcloudfront-keyvaluestoreclient. - Turns on:
CustomDomain(public_domain="sbx.example.com").deploy(certificate_arn=...)orrayito domain deploy. - Resources and AWS calls: one CloudFront distribution, its routing
CloudFront Function and a KeyValueStore
(
infra/custom-domain.yaml). In use,register()/unregister()/refresh()callDescribeKeyValueStore/PutKey/DeleteKey. - Approximate cost (us-east-1,
CloudFront pricing;
list figures for CloudFront Functions/KeyValueStore since their
launch, to be reconfirmed in AWS acceptance): $0 at rest;
~$0.085/GB + $0.0075/10,000 outbound HTTPS requests; the CloudFront
Function, ~$0.10 per 1,000,000 invocations (one per request); the
KeyValueStore, ~$0.50 per 1,000,000 reads (the Function's) and
~$5 per 1,000,000 management calls (
PutKey/DeleteKey). - IAM (credentials of whoever calls the SDK):
cloudformation:*Stack*fordeploy/status/destroy, and the CloudFront permissions to create and delete the distribution, the Function and the KeyValueStore (the stack runs with those same credentials);cloudfront-keyvaluestore:DescribeKeyValueStore/PutKey/DeleteKeyon the stack's KVS. - How to turn it off:
destroy()orrayito domain destroydeletes the distribution (it takes ~15 min to disable first), the Function and the KeyValueStore — no route survives, they are ephemeral. Also delete theCNAMEyou created in your DNS.
When to use it¶
- You need a sandbox port to answer on a normal HTTPS URL, without
the client having to add headers: a browser, a webhook, an
<iframe>. - When not to: a programmatic client that already knows how to send
x-aws-proxy-auth/x-aws-proxy-port—get_host()already solves that without deploying anything; for local development,rayito sandbox proxy.
Installation¶
Registering routes signs with SigV4A (required by the data plane of
cloudfront-keyvaluestore), which does not come in the default install:
Without the extra, register()/refresh()/unregister() raise
CustomDomainException with this same command. deploy()/status()/
destroy() do not need it.
Turn it on¶
You need three things:
- A domain under which the URLs will live, e.g.
sbx.example.com. - An ACM certificate in
us-east-1(a CloudFront requirement, regardless of which region your sandboxes run in) covering*.sbx.example.com. - A DNS record: after deploying, a
CNAME(or alias, in Route 53) from*.sbx.example.comto theDistributionDomainNamethe deployment prints (dxxxxxxxxxxxx.cloudfront.net).
import { CustomDomain } from "rayito";
const domain = new CustomDomain({ publicDomain: "sbx.example.com" }); // does not call AWS
const status = await domain.deploy({
certificateArn: "arn:aws:acm:us-east-1:<account>:certificate/<id>",
});
console.log(status.outputs.DistributionDomainName); // CNAME target
Without the SDK, the same template with CloudFormation:
curl -fsSLO https://raw.githubusercontent.com/alejandro-cedeno-10/rayito/main/infra/custom-domain.yaml
aws cloudformation deploy --stack-name rayito-custom-domain \
--template-file custom-domain.yaml \
--parameter-overrides PublicDomain=sbx.example.com CertificateArn=arn:aws:acm:us-east-1:<account>:certificate/<id>
Expose a port¶
import secrets
from rayito import CustomDomain, Sandbox
domain = CustomDomain(public_domain="sbx.example.com")
domain.status() # reads the KvsArn of the already deployed stack
with Sandbox.create("rayito-base", allowed_ports=[8000]) as sbx:
jwe = sbx.get_host(8000).headers["x-aws-proxy-auth"] # (1)!
traffic_token = secrets.token_urlsafe(32) # (2)!
route = domain.register(
sbx.sandbox_id, 8000, endpoint=sbx.endpoint, jwe=jwe,
traffic_token=traffic_token, ttl_seconds=2400,
)
print(f"https://{route.host}") # (3)!
# ... before it expires, with a freshly minted JWE (4):
fresh_jwe = sbx.get_host(8000).headers["x-aws-proxy-auth"]
domain.refresh(route, jwe=fresh_jwe, ttl_seconds=2400)
domain.unregister(sbx.sandbox_id, 8000)
- The JWE that
get_host()already issues; it is the same one the AWS Lambda MicroVMs proxy validates. The Function adds it for you on every request. - Whoever opens the URL sends it in the
e2b-traffic-access-tokenheader or in therayito_ttcookie (the practical one in a browser: your application sets it for.sbx.example.com). Without a token,register()rejects the call — a route is only public with an explicitpublic=True, never by default. https://8000-<sandbox_id>.sbx.example.com.refresh()needs a new JWE:get_host()mints it again on demand. Passing the original again only extends the route's TTL, not the token the AWS proxy checks.
import asyncio
import secrets
from rayito import AsyncCustomDomain, AsyncSandbox
async def main() -> None:
domain = AsyncCustomDomain(public_domain="sbx.example.com")
await domain.status()
async with await AsyncSandbox.create("rayito-base", allowed_ports=[8000]) as sbx:
jwe = (await sbx.get_host(8000)).headers["x-aws-proxy-auth"]
route = await domain.register(
sbx.sandbox_id, 8000, endpoint=sbx.endpoint, jwe=jwe,
traffic_token=secrets.token_urlsafe(32), ttl_seconds=2400,
)
print(f"https://{route.host}")
await domain.unregister(sbx.sandbox_id, 8000)
asyncio.run(main())
import { randomBytes } from "node:crypto";
import { CustomDomain, Sandbox } from "rayito";
const domain = new CustomDomain({ publicDomain: "sbx.example.com" });
await domain.status(); // reads the KvsArn of the already deployed stack
const sbx = await Sandbox.create({ template: "rayito-base", allowedPorts: [8000] });
try {
const jwe = (await sbx.getHost(8000)).headers["x-aws-proxy-auth"] ?? "";
const trafficToken = randomBytes(32).toString("base64url");
const route = await domain.register(sbx.sandboxId, 8000, {
endpoint: sbx.endpoint,
jwe,
trafficToken,
ttlSeconds: 2400,
});
console.log(`https://${route.host}`);
await domain.unregister(sbx.sandboxId, 8000);
} finally {
await sbx.kill();
}
Remove it¶
Then delete the CNAME from your DNS. The ACM certificate is yours and is not touched.
Explicit names instead of the wildcard¶
By default the distribution carries the alias *.<your domain>. CloudFront does not
allow the same alias on two distributions, so if another distribution
already has that wildcard, the deployment fails. You can pass a list of
exact hostnames (each <label>.<your domain>, normally
host_for(alias, port)): on an overlap CloudFront picks the most
specific name, so they coexist with the other party's wildcard. Only routes whose
hostnames are on the list reach this distribution; the cost does not change.
One exception: if your DNS already has a wildcard record that points to the other
distribution, CloudFront rejects the more specific name until you create
a record of its own for it that points to this one.
Test without touching DNS¶
Before creating the CNAME you can check the distribution by connecting
directly to its *.cloudfront.net and sending your hostname as SNI and as
Host (CloudFront picks the distribution by the Host):
curl --connect-to 8000-<sandbox_id>.sbx.example.com:443:dxxxxxxxxxxxx.cloudfront.net:443 \
-H "e2b-traffic-access-token: <token>" https://8000-<sandbox_id>.sbx.example.com/
That is also how the repository's e2e works
(tests/e2e/test_m15_custom_domain.py, custom-domain.e2e.test.ts): it takes
the domain and the certificate only from RAYITO_E2E_DOMAIN (a domain whose
wildcard the certificate covers) and RAYITO_E2E_CERT_ARN, deploys its own
stack per run (rayito-cd-e2e-<id>) with random explicit names
so as not to clash with any existing alias, creates no DNS record and
deletes everything when it finishes. Without those variables it is skipped.
How it works¶
- The distribution has a single "placeholder" origin in the template — it is never actually contacted.
- Each request goes first through the CloudFront Function
(
infra/functions/custom_domain_router.js), which deletes anyx-aws-proxy-*header the viewer sent, reads the route from theKeyValueStoreby the hostname's first label ({port}-{alias}), checks the traffic token if the route is not public, and callscf.updateRequestOrigin()with the sandbox's realendpointand the proxy headers that are needed. - A route with no entry in the
KeyValueStore, or whosettl_secondshas already passed, gets a 404 straight from the Function: it never reaches the placeholder origin, and from outside it is indistinguishable from a route that never existed.
Known limitations¶
- Without
Sandbox.create(domain=)orget_host()wired up, you have to register the route by hand (as in the example). - The traffic token is only stored as its sha256 hash: if you lose it, generate a new one and register the route again.
- A route is not deleted by itself when the sandbox dies: call
unregister()yourself. Without that, the route is bounded by two independent sides:ttl_secondsofregister()/refresh()(which the Function itself checks — 404) and the JWE's own expiry, on the AWS proxy side (401/403), whichever comes first. - There is no automatic refresher (DOM-14): call
refresh()before the JWE expires if the route must live longer. - Ports
8080and9000(hooks port and reserved) cannot be exposed, as in the rest of the SDK. - If your account is in an AWS organization, an SCP can deny
cloudfront:CreateDistributioneven if your role is an administrator. Thendeploy()fails withStackException(the stack ends up inROLLBACK_COMPLETE; the reason,AccessDenied ... explicit deny in a service control policy, shows up in the stack events,aws cloudformation describe-stack-events). Nothing is left created: the rollback deletes the Function and the KeyValueStore, anddestroy()removes the failed stack. Ask the organization's administrator to allow that action (Q141 ofAWS_API_NOTES.md).