diff --git a/lambda-microvm-custom-domain-cdk/.gitignore b/lambda-microvm-custom-domain-cdk/.gitignore new file mode 100644 index 000000000..61eb6049e --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/.gitignore @@ -0,0 +1,26 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out + +# CDK context cache — can contain account-specific lookup data; don't commit +cdk.context.json + +# Build / deploy logs +*.log + +# Local env / creds — never commit +.env +*.local + +# IDE +.idea +.vscode +*.iml + +# OS +.DS_Store diff --git a/lambda-microvm-custom-domain-cdk/LICENSE b/lambda-microvm-custom-domain-cdk/LICENSE new file mode 100644 index 000000000..d30792a50 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/LICENSE @@ -0,0 +1,14 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lambda-microvm-custom-domain-cdk/README.md b/lambda-microvm-custom-domain-cdk/README.md new file mode 100644 index 000000000..441c30810 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/README.md @@ -0,0 +1,104 @@ +# Custom domains for AWS Lambda MicroVMs with Application Load Balancer + +This pattern gives each of your [AWS Lambda MicroVMs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-microvms-guide.html) a domain **you** own — e.g. `92cfc7f9-….microvms.example.com` — instead of exposing the service-generated `92cfc7f9-….lambda-microvm-….on.aws` endpoint directly. + +It is built entirely from load-balancing and networking primitives: **no CloudFront** and **no compute in the request path** — just an Application Load Balancer (ALB) that rewrites the `Host` header with an [ALB Host header rewrite](https://aws.amazon.com/blogs/networking-and-content-delivery/introducing-url-and-host-header-rewrite-with-aws-application-load-balancers/) and forwards to the MicroVM service over AWS PrivateLink. A wildcard ACM certificate and a Route 53 wildcard record cover every MicroVM id under a single base domain. + +The CDK app is split into two parts, deployed together as one stack: +- **`lib/microvm-custom-domains-stack.ts`** — the reusable core pattern (VPC, interface VPC endpoint, ACM cert, ALB, host-rewrite listener rule, Route 53 wildcard record). This is the only part in the request path. +- **`lib/microvm-demo-app.ts`** — an *optional* demo layer (a single-page app plus an unauthenticated provisioning API on one ALB-invoked Lambda) that makes the pattern easy to try. Remove the `new MicroVmDemoApp(...)` block from `bin/microvm-custom-domains.ts` to deploy the pure networking pattern on its own. + +Learn more about this pattern at [Serverless Land Patterns](https://serverlessland.com/patterns/lambda-microvm-custom-domain-cdk). + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +> ⚠️ **The optional demo layer is DEMO ONLY — NOT PRODUCTION-SAFE.** Its `POST /api/provision` endpoint is completely unauthenticated, it returns auth tokens to the browser, and it uses wide-open CORS (`Access-Control-Allow-Origin: *`). The **core pattern** (`MicroVmCustomDomainsStack`) is production-oriented networking, but before deploying the demo layer, put real authentication in front of the provisioning API, pin CORS to your own origin, and scope IAM and tokens to the minimum. Deploy the demo only in an isolated, non-production account. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured. +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). +* [Node.js 18+](https://nodejs.org/en/download/) and [AWS CDK v2](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) installed (`npx cdk` works; no global install required). +* An **existing Amazon Route 53 public hosted zone** for your domain (e.g. `example.com`). This stack imports it and adds a wildcard record. +* Access to [AWS Lambda MicroVMs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-microvms-guide.html) and its regional PrivateLink service (`com.amazonaws..lambda-microvm`) in your target Region. +* The **ALB Host/URL rewrite (Transforms) feature** available in your account/Region. + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +2. Change directory to the pattern directory: + ``` + cd serverless-patterns/lambda-microvm-custom-domain-cdk + ``` +3. Install dependencies: + ``` + npm install + ``` +4. Edit the configuration under the `microvm-custom-domains` key in [`cdk.json`](./cdk.json). At minimum set `account`, `region`, `hostedZoneId`, `hostedZoneName`, `customDomainBase`, `microvmEndpointBase`, and `microvmVpceServiceName`. (The `microvmImageArn` and `appDomain` keys are only needed if you keep the optional demo layer.) + + | Key | Meaning | + | --- | --- | + | `account` / `region` | Where to deploy. Region **must** match the MicroVM PrivateLink service region (PrivateLink is regional). | + | `hostedZoneId` / `hostedZoneName` | Your existing Route 53 public hosted zone. | + | `customDomainBase` | Wildcard base your clients use, e.g. `microvms.example.com`. | + | `microvmEndpointBase` | What `Host` is rewritten **to**, e.g. `lambda-microvm.us-east-2.on.aws`. Everything ahead of this suffix (the MicroVM id, and any future subdomains) is preserved per request. | + | `microvmVpceServiceName` | AWS-managed PrivateLink service, e.g. `com.amazonaws.us-east-2.lambda-microvm`. | + | `corsAllowOrigin` | Value for the `Access-Control-Allow-Origin` header the ALB inserts (`*` for the demo; pin to your origin otherwise). | + | `microvmImageArn` | **Demo only.** MicroVM image the provisioning Lambda launches when no running MicroVM is available. | + | `appDomain` | **Demo only.** Single host under `customDomainBase` that serves the demo page and its `/api/provision` endpoint. | + +5. From the command line, deploy the AWS resources for the pattern: + ``` + npx cdk deploy + ``` + Note the outputs — `WildcardDomain`, `AlbDnsName`, `CertificateArn`, and (with the demo layer) `DemoAppUrl`. + +## How it works + +A client sends an HTTPS request to `https://.microvms.example.com` (plus the MicroVM `X-aws-proxy-auth` / `X-aws-proxy-port` headers). Route 53 resolves the wildcard record to the ALB. The ALB terminates TLS with a wildcard ACM certificate, then a listener rule: + +1. **matches** the incoming host with a regex condition — `^(.+)\.microvms\.example\.com$` — capturing everything ahead of the base domain suffix; +2. **rewrites** the `Host` header with a `host-header-rewrite` Transform — `$1.lambda-microvm..on.aws` — swapping only the suffix and preserving everything before it; +3. **forwards** to an IP target group whose targets are the private ENI IPs of a MicroVM interface VPC endpoint (discovered at deploy time by an `AwsCustomResource`; this is the only Lambda in the package and it never runs in the request path). + +The request reaches the MicroVM service front-end over PrivateLink, which routes to the correct MicroVM using the rewritten `Host` header and enforces the JWE auth token the client supplied. Because a Transform (not a redirect) performs the rewrite, the customer's domain stays intact end to end — the browser address bar never shows the `.on.aws` URL. + +CORS is handled entirely at the ALB: `OPTIONS` preflights are answered with a fast `204`, and `Access-Control-Allow-*` headers are inserted on every response via ALB listener header-modification attributes — no change to the application inside the MicroVM. + +See the source files and code comments for full detail on health-check matchers (`200,403,404`), TLS re-origination, and the optional demo layer. + +## Testing + +The `WildcardDomain` output shows the base domain. Derive a MicroVM's custom domain by taking its service-generated endpoint and **swapping the suffix** — replace `.lambda-microvm..on.aws` with your `.microvms.example.com` base, keeping everything ahead of the suffix. For example: + +``` +78d01d43-96e8-b9f7-49cf-f152af2532af.lambda-microvm.us-east-2.on.aws + ─────────────── swap suffix ──────────────► +78d01d43-96e8-b9f7-49cf-f152af2532af.microvms.example.com +``` + +Then call the MicroVM through your custom domain, supplying the auth token and port from `create-microvm-auth-token`: + +``` +curl "https://78d01d43-96e8-b9f7-49cf-f152af2532af.microvms.example.com/" \ + -H "X-aws-proxy-auth: " \ + -H "X-aws-proxy-port: 8080" +``` + +A missing or expired token returns `403 Forbidden` — the ALB only rewrites `Host`; the client still supplies the token. If you deployed the optional demo layer, open the `DemoAppUrl` output in a browser to provision a MicroVM and call it end to end from a single page. + +## Cleanup + +1. Delete the stack: + ``` + npx cdk destroy + ``` + +---- +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/lambda-microvm-custom-domain-cdk/bin/microvm-custom-domains.ts b/lambda-microvm-custom-domain-cdk/bin/microvm-custom-domains.ts new file mode 100644 index 000000000..fef9f796c --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/bin/microvm-custom-domains.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import { MicroVmCustomDomainsStack, MicroVmCustomDomainsConfig } from '../lib/microvm-custom-domains-stack'; +import { MicroVmDemoApp, MicroVmDemoAppProps } from '../lib/microvm-demo-app'; + +const app = new cdk.App(); + +// All configuration lives under the "microvm-custom-domains" key in cdk.json. +// Any value can be overridden on the CLI, e.g.: +// npx cdk deploy -c microvm-custom-domains:hostedZoneId=Z123... +const cfg = (app.node.tryGetContext('microvm-custom-domains') ?? {}) as Partial & + Partial> & { + account?: string; + region?: string; + }; + +function required(value: T | undefined | '', name: string): T { + if (value === undefined || value === '' || value === 'REPLACE_WITH_HOSTED_ZONE_ID') { + throw new Error( + `Missing required config "${name}". Set it under the "microvm-custom-domains" key in cdk.json ` + + `or pass -c microvm-custom-domains:${name}=.`, + ); + } + return value; +} + +const customDomainBase = required(cfg.customDomainBase, 'customDomainBase'); +const microvmEndpointBase = required(cfg.microvmEndpointBase, 'microvmEndpointBase'); + +// The core, reusable networking pattern from the blog post: everything in the +// hot request path, and nothing else. +const stack = new MicroVmCustomDomainsStack(app, 'MicroVmCustomDomainsStack', { + env: { + account: cfg.account ?? process.env.CDK_DEFAULT_ACCOUNT, + region: cfg.region ?? process.env.CDK_DEFAULT_REGION ?? 'us-east-2', + }, + description: + 'Self-service custom domains for AWS Lambda MicroVMs via ALB Host-header rewrite over PrivateLink (no CloudFront, no request-path compute).', + config: { + hostedZoneId: required(cfg.hostedZoneId, 'hostedZoneId'), + hostedZoneName: required(cfg.hostedZoneName, 'hostedZoneName'), + customDomainBase, + microvmEndpointBase, + microvmVpceServiceName: required(cfg.microvmVpceServiceName, 'microvmVpceServiceName'), + corsAllowOrigin: cfg.corsAllowOrigin ?? '*', + }, +}); + +// The OPTIONAL demo layer (single-page app + provisioning API), deployed +// together with the core stack by attaching to its HTTPS listener. Remove this +// block to deploy the pure networking pattern on its own. +new MicroVmDemoApp(stack, 'DemoApp', { + listener: stack.httpsListener, + appDomain: required(cfg.appDomain, 'appDomain'), + microvmImageArn: required(cfg.microvmImageArn, 'microvmImageArn'), + customDomainBase, + microvmEndpointBase, +}); + +app.synth(); diff --git a/lambda-microvm-custom-domain-cdk/cdk.json b/lambda-microvm-custom-domain-cdk/cdk.json new file mode 100644 index 000000000..292a10131 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/cdk.json @@ -0,0 +1,61 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/microvm-custom-domains.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + + "microvm-custom-domains": { + "//": "------------------------------------------------------------------", + "//1": "EDIT THESE VALUES, then `npx cdk deploy`. All are overridable with -c.", + "//2": "------------------------------------------------------------------", + + "account": "111122223333", + "region": "us-east-2", + + "//zone": "Existing Route53 public hosted zone that will hold the wildcard record.", + "hostedZoneId": "Z0123456789ABCDEFGHIJ", + "hostedZoneName": "example.com", + + "//domain": "The wildcard base under which each custom domain is served.", + "customDomainBase": "microvms.example.com", + + "//origin": "The MicroVM GA endpoint domain requests are rewritten TO. The label before this base (the MicroVM id) is preserved per-request by the Host rewrite regex.", + "microvmEndpointBase": "lambda-microvm.us-east-2.on.aws", + + "//privatelink": "AWS-managed PrivateLink service for reaching MicroVMs privately (public com.amazonaws.. name; safe to commit).", + "microvmVpceServiceName": "com.amazonaws.us-east-2.lambda-microvm", + + "//cors": "Value for the Access-Control-Allow-Origin header the ALB inserts on every response. '*' for the demo.", + "corsAllowOrigin": "*", + + "//image": "ARN of the MicroVM image the provisioning Lambda launches when no running MicroVM is available.", + "microvmImageArn": "arn:aws:lambda:us-east-2:111122223333:microvm-image:my-microvm-image", + + "//app": "Single host (a label under customDomainBase) that serves the demo page AND its /api/provision endpoint from one ALB-invoked Lambda. Covered by the wildcard cert/record; matched ahead of the host-rewrite rule.", + "appDomain": "customdomain.microvms.example.com" + } + } +} diff --git a/lambda-microvm-custom-domain-cdk/example-pattern.json b/lambda-microvm-custom-domain-cdk/example-pattern.json new file mode 100644 index 000000000..8d0b8542f --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/example-pattern.json @@ -0,0 +1,65 @@ +{ + "title": "Custom domains for AWS Lambda MicroVMs with Application Load Balancer", + "description": "Serve each AWS Lambda MicroVM under a domain you own using an Application Load Balancer Host header rewrite over PrivateLink, with no CloudFront and no compute in the request path.", + "language": "TypeScript", + "level": "300", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern gives each AWS Lambda MicroVM a domain you own (e.g. .microvms.example.com) instead of exposing the service-generated .lambda-microvm..on.aws endpoint. It is built entirely from load-balancing and networking primitives: there is no CloudFront and no compute in the request path.", + "A client sends HTTPS to .microvms.example.com. Route 53 resolves a wildcard record to an Application Load Balancer, which terminates TLS with a wildcard ACM certificate. A listener rule matches the host with a regex condition, then a host-header-rewrite Transform swaps only the base-domain suffix (preserving everything ahead of it) to rewrite Host to .lambda-microvm..on.aws.", + "The ALB forwards to an IP target group whose targets are the private ENI IPs of a MicroVM interface VPC endpoint, reaching the MicroVM service over AWS PrivateLink. The service routes to the correct MicroVM using the rewritten Host header and enforces the JWE auth token the client supplied. Because a Transform (not a redirect) performs the rewrite, the customer's domain stays intact end to end.", + "CORS is handled entirely at the ALB (a 204 preflight rule plus inserted Access-Control-Allow-* response headers), so no change is required inside the MicroVM. The CDK app also includes an optional, demo-only single-page app and provisioning API that can be removed to deploy the pure networking pattern." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-microvm-custom-domain-cdk", + "templateURL": "serverless-patterns/lambda-microvm-custom-domain-cdk", + "projectFolder": "lambda-microvm-custom-domain-cdk", + "templateFile": "lib/microvm-custom-domains-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda MicroVMs", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/lambda-microvms-guide.html" + }, + { + "text": "Introducing URL and Host header rewrite with AWS Application Load Balancers", + "link": "https://aws.amazon.com/blogs/networking-and-content-delivery/introducing-url-and-host-header-rewrite-with-aws-application-load-balancers/" + }, + { + "text": "AWS PrivateLink interface VPC endpoints", + "link": "https://docs.aws.amazon.com/vpc/latest/privatelink/interface-endpoints.html" + } + ] + }, + "deploy": { + "text": [ + "npm install", + "npx cdk deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: npx cdk destroy." + ] + }, + "authors": [ + { + "name": "Frank Scarfo", + "image": "https://avatars.githubusercontent.com/u/8919289?v=4", + "bio": "Frank Scarfo is a Senior Software Development Engineer at Amazon Web Services.", + "linkedin": "frank-scarfo-7bba053a", + "twitter": "" + } + ] +} diff --git a/lambda-microvm-custom-domain-cdk/frontend/index.html b/lambda-microvm-custom-domain-cdk/frontend/index.html new file mode 100644 index 000000000..90cb4d9de --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/frontend/index.html @@ -0,0 +1,129 @@ + + + + + +Lambda MicroVMs — custom domain demo + + + +

AWS Lambda MicroVMs — custom domain demo

+

+ This page and its provisioning API are both served from the same custom domain by the + ALB (the page is static HTML, the API a Lambda target). Step 1 provisions a MicroVM + (reuses a running one, else launches one) and mints a short-lived auth token. Step 2 + calls the MicroVM through its custom domain — the ALB rewrites the + Host header and forwards over PrivateLink. +

+ +
+ 1 · Provision +

+ Calls /api/provision on this same domain — a Lambda served by the + ALB. No endpoint to paste; the page and the provisioning API share one origin. +

+ +
+
+ +
+ 2 · Call the MicroVM via the custom domain + + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+ + + + diff --git a/lambda-microvm-custom-domain-cdk/jest.config.js b/lambda-microvm-custom-domain-cdk/jest.config.js new file mode 100644 index 000000000..44ead8540 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, +}; diff --git a/lambda-microvm-custom-domain-cdk/lambda/provision.ts b/lambda-microvm-custom-domain-cdk/lambda/provision.ts new file mode 100644 index 000000000..14ca889e2 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/lambda/provision.ts @@ -0,0 +1,181 @@ +import { + LambdaMicrovmsClient, + RunMicrovmCommand, + ListMicrovmsCommand, + GetMicrovmCommand, + CreateMicrovmAuthTokenCommand, + type MicrovmState, +} from '@aws-sdk/client-lambda-microvms'; +// The demo page is bundled into this function as a string via esbuild's text +// loader (configured on the NodejsFunction in the stack). We serve it from the +// same custom domain as the API so the page and its /api/provision call are +// same-origin — no CORS, no separate public endpoint. +// @ts-ignore -- resolved to a string at bundle time by the '.html' text loader. +import INDEX_HTML from '../frontend/index.html'; + +/** + * Demo app + provisioning API for MicroVM custom domains, served from the ALB + * as a Lambda target under the demo custom domain: + * + * GET / -> the single-file demo page (HTML) + * POST /api/provision -> reuse a RUNNING MicroVM from the configured image if + * one exists (else RunMicrovm a fresh one), mint a + * short-lived auth token, and return everything the + * page needs to call the MicroVM through its custom + * domain. + * + * This Lambda is a CONTROL-PLANE target only — it provisions and serves static + * HTML. It is never in the request path to the MicroVM itself; the browser + * calls the MicroVM's own custom domain directly afterwards. + */ + +const IMAGE_ARN = requireEnv('MICROVM_IMAGE_ARN'); +const CUSTOM_DOMAIN_BASE = requireEnv('CUSTOM_DOMAIN_BASE'); // e.g. microvms.example.com +const MICROVM_ENDPOINT_BASE = requireEnv('MICROVM_ENDPOINT_BASE'); // e.g. lambda-microvm.us-east-2.on.aws +const TOKEN_TTL_MINUTES = Number(process.env.TOKEN_TTL_MINUTES ?? '30'); +const DEFAULT_PORT = Number(process.env.DEFAULT_PORT ?? '8080'); + +const client = new LambdaMicrovmsClient({}); + +// The page and the /api/provision call are same-origin (both under the demo +// custom domain), so no CORS is needed between them. We still send permissive +// CORS on the JSON response in case the page is opened from elsewhere. Tokens +// are short-lived and per-MicroVM. +const JSON_HEADERS: Record = { + 'content-type': 'application/json', + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET,POST,OPTIONS', + 'access-control-allow-headers': 'content-type', +}; + +export async function handler(event: any): Promise { + // ALB target invocation shape: { httpMethod, path, ... }. + const method = + event?.httpMethod ?? event?.requestContext?.http?.method ?? 'GET'; + const rawPath = + event?.path ?? event?.requestContext?.http?.path ?? '/'; + const routePath = rawPath.split('?')[0].replace(/\/+$/, '') || '/'; + + if (method === 'OPTIONS') { + return { statusCode: 204, headers: JSON_HEADERS, body: '' }; + } + + // Serve the demo page for the app root; anything that isn't the API path is + // treated as the page too, so a stray refresh on a sub-path still works. + if (routePath !== '/api/provision') { + return { + statusCode: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + body: INDEX_HTML, + }; + } + + try { + const microvm = await getOrRunMicrovm(); + const endpoint = await waitForEndpoint(microvm.microvmId!, microvm.endpoint); + const port = DEFAULT_PORT; + + const tokenResp = await client.send( + new CreateMicrovmAuthTokenCommand({ + microvmIdentifier: microvm.microvmId!, + expirationInMinutes: TOKEN_TTL_MINUTES, + allowedPorts: [{ port }], + }), + ); + const authToken = tokenResp.authToken?.['X-aws-proxy-auth']; + if (!authToken) { + throw new Error('CreateMicrovmAuthToken returned no X-aws-proxy-auth token'); + } + + // The service endpoint is ".lambda-microvm..on.aws"; the + // custom domain swaps the base for the wildcard base this stack owns. + const uuid = endpoint.split('.')[0]; + const customDomain = `${uuid}.${CUSTOM_DOMAIN_BASE}`; + + return json(200, { + microvmId: microvm.microvmId, + state: microvm.state, + reused: microvm.reused, + serviceEndpoint: endpoint, + customDomain, + authToken, + port, + tokenTtlMinutes: TOKEN_TTL_MINUTES, + defaultPath: '/ping', + }); + } catch (err: any) { + console.error('provision failed', err); + return json(500, { error: err?.name ?? 'Error', message: String(err?.message ?? err) }); + } +} + +/** Reuse the first RUNNING MicroVM from our image, else launch a new one. */ +async function getOrRunMicrovm(): Promise<{ + microvmId?: string; + state?: MicrovmState; + endpoint?: string; + reused: boolean; +}> { + // ListMicrovms summaries don't include the endpoint, so we filter by state + // here and resolve the endpoint via GetMicrovm / RunMicrovm below. + let nextToken: string | undefined; + do { + const page = await client.send( + new ListMicrovmsCommand(nextToken ? { nextToken } : {}), + ); + const running = (page.microvms ?? []).find( + (m) => m.state === 'RUNNING' && m.imageArn === IMAGE_ARN, + ); + if (running?.microvmId) { + return { microvmId: running.microvmId, state: running.state, reused: true }; + } + nextToken = page.nextToken; + } while (nextToken); + + const run = await client.send( + new RunMicrovmCommand({ + imageIdentifier: IMAGE_ARN, + idlePolicy: { + autoResumeEnabled: true, + maxIdleDurationSeconds: 900, + suspendedDurationSeconds: 1800, + }, + }), + ); + return { + microvmId: run.microvmId, + state: run.state, + endpoint: run.endpoint, + reused: false, + }; +} + +/** + * Resolve the MicroVM endpoint, polling GetMicrovm until it appears. RunMicrovm + * returns the endpoint directly; a reused MicroVM needs one GetMicrovm call. + */ +async function waitForEndpoint(microvmId: string, known?: string): Promise { + if (known) return known; + for (let attempt = 0; attempt < 10; attempt++) { + const got = await client.send( + new GetMicrovmCommand({ microvmIdentifier: microvmId }), + ); + if (got.endpoint) return got.endpoint; + await sleep(1000); + } + throw new Error(`MicroVM ${microvmId} has no endpoint yet`); +} + +function json(statusCode: number, body: unknown) { + return { statusCode, headers: JSON_HEADERS, body: JSON.stringify(body) }; +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing required environment variable ${name}`); + return v; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/lambda-microvm-custom-domain-cdk/lib/microvm-custom-domains-stack.ts b/lambda-microvm-custom-domain-cdk/lib/microvm-custom-domains-stack.ts new file mode 100644 index 000000000..2128ea022 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/lib/microvm-custom-domains-stack.ts @@ -0,0 +1,327 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +import * as elbv2t from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets'; +import * as acm from 'aws-cdk-lib/aws-certificatemanager'; +import * as route53 from 'aws-cdk-lib/aws-route53'; +import * as targets from 'aws-cdk-lib/aws-route53-targets'; +import * as cr from 'aws-cdk-lib/custom-resources'; +import * as iam from 'aws-cdk-lib/aws-iam'; + +export interface MicroVmCustomDomainsConfig { + /** ID of an existing Route53 public hosted zone that will hold the wildcard record. */ + readonly hostedZoneId: string; + /** Name of that hosted zone, e.g. "example.com". */ + readonly hostedZoneName: string; + /** + * The wildcard base under which each custom domain is served, e.g. + * "microvms.example.com". Customers reach + * ".microvms.example.com". + */ + readonly customDomainBase: string; + /** + * The MicroVM endpoint base the Host header is rewritten TO, e.g. + * "lambda-microvm.us-east-2.on.aws". The per-request prefix is preserved, + * producing ".lambda-microvm.us-east-2.on.aws". + */ + readonly microvmEndpointBase: string; + /** + * AWS-managed PrivateLink service used to reach MicroVMs privately, e.g. + * "com.amazonaws.us-east-2.lambda-microvm". + */ + readonly microvmVpceServiceName: string; + /** Origin allowed on the CORS response headers the ALB inserts. */ + readonly corsAllowOrigin: string; +} + +export interface MicroVmCustomDomainsStackProps extends cdk.StackProps { + readonly config: MicroVmCustomDomainsConfig; +} + +/** + * Fronts AWS Lambda MicroVMs with customer-owned wildcard custom domains. + * + * This is the core, reusable networking pattern described in the blog post: + * everything in the hot request path and nothing else. The optional demo layer + * (single-page app + provisioning API) lives in a separate construct, + * {@link MicroVmDemoApp}, and attaches to the {@link httpsListener} exposed here. + * + * Request flow: + * .microvms.example.com + * -> Route53 A/AAAA alias (wildcard) -> ALB (TLS, *.customDomainBase cert) + * -> HTTPS listener rule: regex host match + Host-header rewrite Transform + * .customDomainBase => .microvmEndpointBase + * -> IP target group (private ENI IPs of the MicroVM interface endpoint), HTTPS:443 + * MicroVM front-end routes on the (rewritten) Host header and enforces the JWE + * auth token the client still supplies in X-aws-proxy-auth. + * + * No CloudFront, and no compute in the request path. The only Lambda is a + * deploy-time custom resource that discovers the endpoint ENI IPs. + */ +export class MicroVmCustomDomainsStack extends cdk.Stack { + /** + * The public HTTPS:443 listener on the ALB. Exposed so the optional demo + * construct can attach its own (higher-priority) host rule without the core + * stack needing to know anything about the demo. + */ + public readonly httpsListener: elbv2.ApplicationListener; + + constructor(scope: Construct, id: string, props: MicroVmCustomDomainsStackProps) { + super(scope, id, props); + const cfg = props.config; + + const wildcardDomain = `*.${cfg.customDomainBase}`; + + // --- Networking ------------------------------------------------------- + // A small VPC to host the ALB and the MicroVM interface endpoint. 2 AZs is + // the minimum for an internet-facing ALB. NAT gateways are unnecessary + // (nothing here needs egress), so we omit them to stay lean and cheap. + const vpc = new ec2.Vpc(this, 'Vpc', { + maxAzs: 2, + natGateways: 0, + subnetConfiguration: [ + { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 }, + { name: 'private', subnetType: ec2.SubnetType.PRIVATE_ISOLATED, cidrMask: 24 }, + ], + }); + + // Security group for the interface endpoint: allow HTTPS from within the VPC + // (the ALB nodes) only. + const endpointSg = new ec2.SecurityGroup(this, 'EndpointSg', { + vpc, + description: 'Ingress to MicroVM interface endpoint from ALB', + allowAllOutbound: true, + }); + endpointSg.addIngressRule( + ec2.Peer.ipv4(vpc.vpcCidrBlock), + ec2.Port.tcp(443), + 'HTTPS from ALB within VPC', + ); + + // Interface (PrivateLink) endpoint to the AWS-managed MicroVM service. + const endpoint = new ec2.InterfaceVpcEndpoint(this, 'MicroVmEndpoint', { + vpc, + service: new ec2.InterfaceVpcEndpointService(cfg.microvmVpceServiceName, 443), + subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED }, + securityGroups: [endpointSg], + // We rewrite Host ourselves at the ALB; keep the service's own private DNS + // off so the ALB reaches the endpoint by its ENI IPs, not by the MicroVM name. + privateDnsEnabled: false, + open: false, + }); + + // --- Resolve the endpoint's private ENI IPs -------------------------- + // CloudFormation does not surface interface-endpoint ENI IPs as attributes, + // so discover them at deploy time with a custom resource (deploy-time only, + // never in the request path). + const targetIps = this.discoverEndpointIps(endpoint, vpc.availabilityZones.length); + + // --- TLS certificate for the wildcard custom domain ------------------- + const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'Zone', { + hostedZoneId: cfg.hostedZoneId, + zoneName: cfg.hostedZoneName, + }); + + const certificate = new acm.Certificate(this, 'WildcardCert', { + domainName: wildcardDomain, + validation: acm.CertificateValidation.fromDns(hostedZone), + }); + + // --- Application Load Balancer --------------------------------------- + const albSg = new ec2.SecurityGroup(this, 'AlbSg', { + vpc, + description: 'Public HTTPS ingress to MicroVM custom-domain ALB', + allowAllOutbound: true, + }); + albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'HTTPS from internet'); + + const alb = new elbv2.ApplicationLoadBalancer(this, 'Alb', { + vpc, + internetFacing: true, + securityGroup: albSg, + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, + }); + + // Target group of the endpoint ENI IPs, spoken to over HTTPS. The MicroVM + // service presents its own certificate; ALB does not validate target certs, + // so re-origination succeeds regardless of the name mismatch. Health checks + // use a permissive matcher because an unauthenticated probe returns 403. + const targetGroup = new elbv2.ApplicationTargetGroup(this, 'MicroVmTargets', { + vpc, + protocol: elbv2.ApplicationProtocol.HTTPS, + port: 443, + targetType: elbv2.TargetType.IP, + targets: targetIps.map((ip) => new elbv2t.IpTarget(ip, 443)), + healthCheck: { + protocol: elbv2.Protocol.HTTPS, + path: '/', + healthyHttpCodes: '200,403,404', + }, + }); + + const listener = alb.addListener('Https', { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [certificate], + // Default action for anything that doesn't match our host regex. + defaultAction: elbv2.ListenerAction.fixedResponse(404, { + contentType: 'text/plain', + messageBody: 'Unknown custom domain', + }), + }); + this.httpsListener = listener; + + // --- CORS response headers, inserted by the ALB on EVERY response -------- + // The ALB header-modification feature adds these Access-Control-* headers to + // all responses on this listener -- both our OPTIONS preflight 204 AND the + // forwarded MicroVM response. That satisfies the browser without any change + // to the MicroVM application. (These are listener attributes; the L2 does + // not model them yet, so we set them on the underlying CfnListener.) + const cfnListener = listener.node.defaultChild as elbv2.CfnListener; + cfnListener.addPropertyOverride('ListenerAttributes', [ + { Key: 'routing.http.response.access_control_allow_origin.header_value', Value: cfg.corsAllowOrigin }, + { Key: 'routing.http.response.access_control_allow_methods.header_value', Value: 'GET,POST,PUT,DELETE,OPTIONS,PATCH,HEAD' }, + { Key: 'routing.http.response.access_control_allow_headers.header_value', Value: 'x-aws-proxy-auth,x-aws-proxy-port,content-type,authorization' }, + { Key: 'routing.http.response.access_control_expose_headers.header_value', Value: 'content-type,content-length' }, + { Key: 'routing.http.response.access_control_max_age.header_value', Value: '86400' }, + ]); + + // --- CORS preflight: answer OPTIONS at the edge -------------------------- + // Priority ahead of the forwarding rule so preflights never reach the origin + // (they would 403 there without a JWE token anyway). See the README "CORS" + // section for the important limitation on Access-Control-* response headers. + new elbv2.ApplicationListenerRule(this, 'CorsPreflightRule', { + listener, + priority: 10, + conditions: [elbv2.ListenerCondition.httpRequestMethods(['OPTIONS'])], + action: elbv2.ListenerAction.fixedResponse(204, { + contentType: 'text/plain', + messageBody: '', + }), + }); + + // --- Host-header rewrite forwarding rule ------------------------------ + // Match ".customDomainBase" with a regex condition, forward to the + // target group, and rewrite the Host header to ".microvmEndpointBase" + // using the new ALB Transforms feature (L1 escape hatch; not yet in the L2). + const forwardingRule = new elbv2.ApplicationListenerRule(this, 'HostRewriteRule', { + listener, + priority: 20, + // The L2 requires at least one condition/action; we replace both on the L1 + // below to express the regex match + transform that the L2 can't model yet. + conditions: [elbv2.ListenerCondition.hostHeaders([`*.${cfg.customDomainBase}`])], + action: elbv2.ListenerAction.forward([targetGroup]), + }); + + this.applyHostRewrite(forwardingRule, cfg.customDomainBase, cfg.microvmEndpointBase); + + // --- Route53: wildcard alias to the ALB ------------------------------- + // customDomainBase is always within the hosted zone, so we use the fully + // qualified wildcard name directly (CDK accepts absolute names that end in + // the zone name). This is robust to any number of subdomain labels between + // the base and the zone apex. + const albAlias = route53.RecordTarget.fromAlias(new targets.LoadBalancerTarget(alb)); + new route53.ARecord(this, 'WildcardAlias', { + zone: hostedZone, + recordName: wildcardDomain, + target: albAlias, + }); + new route53.AaaaRecord(this, 'WildcardAliasV6', { + zone: hostedZone, + recordName: wildcardDomain, + target: albAlias, + }); + + // --- Outputs ---------------------------------------------------------- + new cdk.CfnOutput(this, 'AlbDnsName', { value: alb.loadBalancerDnsName }); + new cdk.CfnOutput(this, 'WildcardDomain', { value: wildcardDomain }); + new cdk.CfnOutput(this, 'CertificateArn', { value: certificate.certificateArn }); + new cdk.CfnOutput(this, 'ResolvedTargetIps', { value: cdk.Fn.join(',', targetIps) }); + new cdk.CfnOutput(this, 'CorsAllowOrigin', { + value: cfg.corsAllowOrigin, + description: 'Origin the OPTIONS preflight advertises. See README CORS caveat.', + }); + } + + /** + * Discovers the private IPv4 addresses of the interface endpoint's ENIs at + * deploy time. This is the ONLY compute in the package, it runs only during + * `cdk deploy`, and it is never in the request path. + */ + private discoverEndpointIps(endpoint: ec2.InterfaceVpcEndpoint, count: number): string[] { + // Query the endpoint's OWN ENIs by ID (exposed as a CloudFormation attribute), + // rather than by a filter. This avoids guessing filter keys and guarantees we + // only ever read IPs that belong to this endpoint — so ordering of the results + // is irrelevant (every returned private IP is a valid target). + const lookup = new cr.AwsCustomResource(this, 'EndpointIpLookup', { + onUpdate: { + service: 'EC2', + action: 'describeNetworkInterfaces', + parameters: { + NetworkInterfaceIds: endpoint.vpcEndpointNetworkInterfaceIds, + }, + physicalResourceId: cr.PhysicalResourceId.of(endpoint.vpcEndpointId), + }, + policy: cr.AwsCustomResourcePolicy.fromStatements([ + new iam.PolicyStatement({ + actions: ['ec2:DescribeNetworkInterfaces'], + resources: ['*'], // DescribeNetworkInterfaces does not support resource-level scoping. + }), + ]), + installLatestAwsSdk: false, + }); + lookup.node.addDependency(endpoint); + + // One ENI per AZ; pull the private IP out of each response entry. + const ips: string[] = []; + for (let i = 0; i < count; i++) { + ips.push(lookup.getResponseField(`NetworkInterfaces.${i}.PrivateIpAddress`)); + } + return ips; + } + + /** + * Injects the ALB Transforms `host-header-rewrite` onto a listener rule, and + * swaps the plain host-header condition for a regex condition that captures the + * prefix ahead of the base. The L2 ApplicationListenerRule can't model either + * yet, so we reach the underlying CfnListenerRule and override its properties + * directly. + * + * Verified against the CloudFormation reference for + * AWS::ElasticLoadBalancingV2::ListenerRule (Transforms / RewriteConfig). + */ + private applyHostRewrite( + rule: elbv2.ApplicationListenerRule, + customDomainBase: string, + microvmEndpointBase: string, + ): void { + const cfnRule = rule.node.defaultChild as elbv2.CfnListenerRule; + + // Escape regex metacharacters in the (dotted) domain literals. + const escapedBase = customDomainBase.replace(/[.]/g, '\\.'); + // Capture everything ahead of the base (the MicroVM prefix) and reuse it. + const matchRegex = `^(.+)\\.${escapedBase}$`; + const replaceWith = `$1.${microvmEndpointBase}`; + + // Regex host-header condition. ELBv2 requires EXACTLY ONE of Values / + // RegexValues / HostHeaderConfig on a host-header condition, so we set only + // RegexValues (the GA field for regex matching) — no HostHeaderConfig. + cfnRule.conditions = [ + { + field: 'host-header', + regexValues: [matchRegex], + }, + ]; + + // Host-header rewrite transform. + cfnRule.addPropertyOverride('Transforms', [ + { + Type: 'host-header-rewrite', + HostHeaderRewriteConfig: { + Rewrites: [{ Regex: matchRegex, Replace: replaceWith }], + }, + }, + ]); + } +} diff --git a/lambda-microvm-custom-domain-cdk/lib/microvm-demo-app.ts b/lambda-microvm-custom-domain-cdk/lib/microvm-demo-app.ts new file mode 100644 index 000000000..a520d65fd --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/lib/microvm-demo-app.ts @@ -0,0 +1,141 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +import * as elbv2t from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as path from 'path'; + +export interface MicroVmDemoAppProps { + /** + * The core stack's public HTTPS listener. The demo attaches a higher-priority + * host rule here; it does not create its own ALB, certificate, or DNS record + * (the demo host is a label under the wildcard base, so the core stack's + * wildcard cert and Route53 record already cover it). + */ + readonly listener: elbv2.IApplicationListener; + /** + * The single hostname (a label under the wildcard base) that serves the demo + * app: the static page AND its /api/provision endpoint, both from one + * ALB-invoked Lambda, e.g. "customdomain.microvms.example.com". + * A higher-priority listener rule matches this exact host BEFORE the core + * stack's wildcard host-rewrite rule, so it is never rewritten to a MicroVM + * origin. + */ + readonly appDomain: string; + /** + * ARN of the MicroVM image the provisioning Lambda launches when no running + * MicroVM is available, e.g. + * "arn:aws:lambda:us-east-2:111122223333:microvm-image:my-microvm-image". + */ + readonly microvmImageArn: string; + /** Wildcard base, passed to the Lambda so it can build custom-domain URLs. */ + readonly customDomainBase: string; + /** MicroVM endpoint base, passed to the Lambda so it can map endpoints. */ + readonly microvmEndpointBase: string; +} + +/** + * OPTIONAL demo layer for the MicroVM custom-domains pattern, deployed alongside + * {@link MicroVmCustomDomainsStack} and attached to its {@link + * MicroVmCustomDomainsStack.httpsListener}. NOTHING here is in the hot request + * path to a MicroVM — the browser calls the MicroVM's own custom domain + * directly. This construct only makes the pattern easy to try end to end. + * + * One control-plane Lambda, invoked directly by the ALB (no Function URL), + * serves BOTH the static demo page (GET /) and the provisioning endpoint + * (POST /api/provision) under a dedicated host in the same wildcard. It reuses a + * RUNNING MicroVM from the configured image (or runs a new one) and mints a + * short-lived auth token. + * + * Not production-safe as written: the provisioning path is intentionally open + * for demonstration. Put authentication and rate limiting in front of it, pin + * CORS to your origin, and scope IAM to the minimum before adapting it. + */ +export class MicroVmDemoApp extends Construct { + constructor(scope: Construct, id: string, props: MicroVmDemoAppProps) { + super(scope, id); + + // Bundled with esbuild because the @aws-sdk/client-lambda-microvms client is + // not in the Lambda runtime, and the demo HTML is inlined via a text loader. + const provisionFn = new NodejsFunction(this, 'ProvisionFn', { + runtime: lambda.Runtime.NODEJS_20_X, + entry: path.join(__dirname, '..', 'lambda', 'provision.ts'), + handler: 'handler', + timeout: cdk.Duration.seconds(60), + memorySize: 256, + environment: { + MICROVM_IMAGE_ARN: props.microvmImageArn, + CUSTOM_DOMAIN_BASE: props.customDomainBase, + MICROVM_ENDPOINT_BASE: props.microvmEndpointBase, + TOKEN_TTL_MINUTES: '30', + DEFAULT_PORT: '8080', + }, + bundling: { + // The lambda-microvms client is NOT in the Node runtime, so bundle all + // deps (empty externalModules overrides the default that treats the v3 + // SDK as external). + externalModules: [], + // Inline frontend/index.html as a string import in the handler. + loader: { '.html': 'text' }, + }, + }); + + // Least-privilege: only the MicroVM control-plane actions the handler calls. + // (IAM prefix is "lambda"; verified via the SDK's defaultSigningName.) + provisionFn.addToRolePolicy( + new iam.PolicyStatement({ + actions: [ + 'lambda:RunMicrovm', + 'lambda:ListMicrovms', + 'lambda:GetMicrovm', + 'lambda:CreateMicrovmAuthToken', + ], + resources: ['*'], // MicroVM ids are generated at run time; scope by account/region via the execution env. + }), + ); + + // RunMicrovm implicitly attaches the AWS-managed default ingress/egress + // network connectors, which requires lambda:PassNetworkConnector on those + // managed connector ARNs (partition-owned, "aws" account). + provisionFn.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['lambda:PassNetworkConnector'], + resources: [ + `arn:${cdk.Aws.PARTITION}:lambda:${cdk.Aws.REGION}:aws:network-connector:aws-network-connector:*`, + ], + }), + ); + + // Register the Lambda as an ALB target. The ALB invokes it directly for the + // demo host; there is no Function URL and no public Lambda endpoint. Health + // checks are disabled: a Lambda target group's health check would call the + // handler with an ELB probe, and we would rather not spin the provisioning + // logic on every probe -- the target is healthy as long as the function + // exists. + const appTargetGroup = new elbv2.ApplicationTargetGroup(this, 'AppTargets', { + targetType: elbv2.TargetType.LAMBDA, + targets: [new elbv2t.LambdaTarget(provisionFn)], + healthCheck: { enabled: false }, + }); + + // Highest-priority rule: the exact demo host, forwarded to the Lambda. It + // sits ABOVE the core stack's wildcard host-rewrite rule (priority 20), so + // requests to the demo host are served by the Lambda and never rewritten to + // a MicroVM origin. appDomain is a label under customDomainBase, so the + // wildcard cert and wildcard Route53 record already cover it. + new elbv2.ApplicationListenerRule(this, 'AppHostRule', { + listener: props.listener, + priority: 5, + conditions: [elbv2.ListenerCondition.hostHeaders([props.appDomain])], + action: elbv2.ListenerAction.forward([appTargetGroup]), + }); + + // --- Output ----------------------------------------------------------- + new cdk.CfnOutput(this, 'DemoAppUrl', { + value: `https://${props.appDomain}/`, + description: 'Open this in a browser: the demo page and its /api/provision endpoint, same origin.', + }); + } +} diff --git a/lambda-microvm-custom-domain-cdk/package.json b/lambda-microvm-custom-domain-cdk/package.json new file mode 100644 index 000000000..d7fb2d795 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/package.json @@ -0,0 +1,33 @@ +{ + "name": "lambda-microvm-custom-domain-cdk", + "version": "1.0.0", + "description": "Self-service custom domains for AWS Lambda MicroVMs using CloudFront-free, compute-free ALB Host header rewrite over PrivateLink.", + "license": "MIT-0", + "bin": { + "microvm-custom-domains": "bin/microvm-custom-domains.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "synth": "cdk synth", + "deploy": "cdk deploy", + "destroy": "cdk destroy" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^20.11.30", + "aws-cdk": "^2.1000.0", + "esbuild": "^0.21.0", + "jest": "^29.7.0", + "source-map-support": "^0.5.21", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "typescript": "~5.4.3" + }, + "dependencies": { + "@aws-sdk/client-lambda-microvms": "^3.0.0", + "aws-cdk-lib": "^2.150.0", + "constructs": "^10.3.0" + } +} diff --git a/lambda-microvm-custom-domain-cdk/test/microvm-custom-domains.test.ts b/lambda-microvm-custom-domain-cdk/test/microvm-custom-domains.test.ts new file mode 100644 index 000000000..b6074bc23 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/test/microvm-custom-domains.test.ts @@ -0,0 +1,212 @@ +import * as cdk from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import { MicroVmCustomDomainsStack, MicroVmCustomDomainsConfig } from '../lib/microvm-custom-domains-stack'; +import { MicroVmDemoApp } from '../lib/microvm-demo-app'; + +// The full config the app accepts: the core-stack fields plus the two +// demo-only fields (appDomain, microvmImageArn) consumed by MicroVmDemoApp. +type FullConfig = MicroVmCustomDomainsConfig & { + microvmImageArn: string; + appDomain: string; +}; + +const config: FullConfig = { + hostedZoneId: 'Z0123456789ABCDEFGHIJ', + hostedZoneName: 'example.com', + customDomainBase: 'microvms.example.com', + microvmEndpointBase: 'lambda-microvm.us-east-2.on.aws', + microvmVpceServiceName: 'com.amazonaws.us-east-2.lambda-microvm', + corsAllowOrigin: '*', + microvmImageArn: 'arn:aws:lambda:us-east-2:111122223333:microvm-image:my-microvm-image', + appDomain: 'customdomain.microvms.example.com', +}; + +// Synthesizes the core stack WITH the demo layer attached, exactly as bin/ wires +// them, so a single Template covers both files' resources. +function synth(overrides: Partial = {}): Template { + const c = { ...config, ...overrides }; + const app = new cdk.App(); + const stack = new MicroVmCustomDomainsStack(app, 'TestStack', { + env: { account: '111122223333', region: 'us-east-2' }, + config: { + hostedZoneId: c.hostedZoneId, + hostedZoneName: c.hostedZoneName, + customDomainBase: c.customDomainBase, + microvmEndpointBase: c.microvmEndpointBase, + microvmVpceServiceName: c.microvmVpceServiceName, + corsAllowOrigin: c.corsAllowOrigin, + }, + }); + new MicroVmDemoApp(stack, 'DemoApp', { + listener: stack.httpsListener, + appDomain: c.appDomain, + microvmImageArn: c.microvmImageArn, + customDomainBase: c.customDomainBase, + microvmEndpointBase: c.microvmEndpointBase, + }); + return Template.fromStack(stack); +} + +describe('MicroVmCustomDomainsStack', () => { + test('provisions an internet-facing ALB', () => { + const t = synth(); + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', { + Scheme: 'internet-facing', + Type: 'application', + }); + }); + + test('creates a wildcard ACM certificate for the custom domain base', () => { + const t = synth(); + t.hasResourceProperties('AWS::CertificateManager::Certificate', { + DomainName: '*.microvms.example.com', + }); + }); + + test('creates an interface endpoint to the MicroVM PrivateLink service', () => { + const t = synth(); + t.hasResourceProperties('AWS::EC2::VPCEndpoint', { + ServiceName: 'com.amazonaws.us-east-2.lambda-microvm', + VpcEndpointType: 'Interface', + PrivateDnsEnabled: false, + }); + }); + + test('target group speaks HTTPS to IP targets with a permissive health matcher', () => { + const t = synth(); + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::TargetGroup', { + Protocol: 'HTTPS', + Port: 443, + TargetType: 'ip', + Matcher: { HttpCode: '200,403,404' }, + }); + }); + + test('forwarding rule rewrites the Host header via a Transform with a capture group', () => { + const t = synth(); + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::ListenerRule', { + Transforms: [ + { + Type: 'host-header-rewrite', + HostHeaderRewriteConfig: { + Rewrites: [ + { + Regex: '^(.+)\\.microvms\\.example\\.com$', + Replace: '$1.lambda-microvm.us-east-2.on.aws', + }, + ], + }, + }, + ], + }); + }); + + test('forwarding rule matches the host with a regex condition', () => { + const t = synth(); + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::ListenerRule', { + Conditions: Match.arrayWith([ + Match.objectLike({ + Field: 'host-header', + RegexValues: ['^(.+)\\.microvms\\.example\\.com$'], + }), + ]), + }); + }); + + test('answers OPTIONS preflight with a fixed 204 ahead of forwarding', () => { + const t = synth(); + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 10, + Actions: Match.arrayWith([ + Match.objectLike({ + Type: 'fixed-response', + FixedResponseConfig: Match.objectLike({ StatusCode: '204' }), + }), + ]), + }); + }); + + test('creates a wildcard Route53 alias to the ALB', () => { + const t = synth(); + t.hasResourceProperties('AWS::Route53::RecordSet', { + Type: 'A', + Name: '*.microvms.example.com.', + }); + }); + + test('a deploy-time IP-discovery custom resource is created', () => { + const t = synth(); + // AwsCustomResource renders as a Custom::AWS resource. + t.resourceCountIs('Custom::AWS', 1); + t.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ Action: 'ec2:DescribeNetworkInterfaces' }), + ]), + }, + }); + }); + + test('inserts CORS response headers on the HTTPS listener', () => { + const t = synth(); + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::Listener', { + ListenerAttributes: Match.arrayWith([ + Match.objectLike({ + Key: 'routing.http.response.access_control_allow_origin.header_value', + Value: '*', + }), + Match.objectLike({ + Key: 'routing.http.response.access_control_allow_headers.header_value', + Value: Match.stringLikeRegexp('x-aws-proxy-auth'), + }), + ]), + }); + }); + + test('creates the provisioning Lambda with the image ARN and least-privilege MicroVM permissions', () => { + const t = synth(); + t.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MICROVM_IMAGE_ARN: 'arn:aws:lambda:us-east-2:111122223333:microvm-image:my-microvm-image', + }), + }, + }); + t.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith([ + 'lambda:RunMicrovm', + 'lambda:ListMicrovms', + 'lambda:GetMicrovm', + 'lambda:CreateMicrovmAuthToken', + ]), + }), + ]), + }, + }); + }); + + test('serves the demo app as an ALB Lambda target (no Function URL)', () => { + const t = synth(); + // The Lambda is registered as an ALB target group, not exposed via a URL. + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::TargetGroup', { + TargetType: 'lambda', + }); + t.resourceCountIs('AWS::Lambda::Url', 0); + }); + + test('routes the demo host to the app target ahead of the host-rewrite rule', () => { + const t = synth(); + t.hasResourceProperties('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 5, + Conditions: Match.arrayWith([ + Match.objectLike({ + Field: 'host-header', + HostHeaderConfig: { Values: ['customdomain.microvms.example.com'] }, + }), + ]), + }); + }); +}); diff --git a/lambda-microvm-custom-domain-cdk/tsconfig.json b/lambda-microvm-custom-domain-cdk/tsconfig.json new file mode 100644 index 000000000..ad6da90c8 --- /dev/null +++ b/lambda-microvm-custom-domain-cdk/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": ["./node_modules/@types"] + }, + "include": ["bin/**/*.ts", "lib/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "cdk.out"] +}