import * as cdk from "aws-cdk-lib"; import * as lambda from "aws-cdk-lib/aws-lambda"; import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2"; import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations"; import * as ssm from "aws-cdk-lib/aws-ssm"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as events from "aws-cdk-lib/aws-events"; import * as targets from "aws-cdk-lib/aws-events-targets"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as route53Targets from "aws-cdk-lib/aws-route53-targets"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as logs from "aws-cdk-lib/aws-logs"; import { Construct } from "constructs"; import * as path from "path"; export class DoorUnlockStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const elementsApiKeyParam = ssm.StringParameter.fromSecureStringParameterAttributes( this, "ElementsApiKey", { parameterName: "/seahaven/door-unlock/elements-api-key" } ); const authTokenParam = ssm.StringParameter.fromSecureStringParameterAttributes( this, "AuthToken", { parameterName: "/seahaven/door-unlock/auth-token" } ); const doorIdParam = ssm.StringParameter.fromStringParameterName( this, "DoorId", "/seahaven/door-unlock/door-id" ); const unlockHandler = new lambda.Function(this, "UnlockHandler", { functionName: "door-unlock-api-unlock", runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, handler: "unlock-handler.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/unlock"), { bundling: { image: lambda.Runtime.NODEJS_22_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/unlock/unlock-handler.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "unlock-handler.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key", AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token", DOOR_ID_PARAM: "/seahaven/door-unlock/door-id", }, timeout: cdk.Duration.seconds(10), memorySize: 128, logRetention: logs.RetentionDays.TWO_MONTHS, }); const lockdownHandler = new lambda.Function(this, "LockdownHandler", { functionName: "door-unlock-api-lockdown", runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, handler: "lockdown-handler.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/lockdown"), { bundling: { image: lambda.Runtime.NODEJS_22_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/lockdown/lockdown-handler.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "lockdown-handler.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key", AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token", }, timeout: cdk.Duration.seconds(15), memorySize: 128, logRetention: logs.RetentionDays.TWO_MONTHS, }); elementsApiKeyParam.grantRead(unlockHandler); authTokenParam.grantRead(unlockHandler); doorIdParam.grantRead(unlockHandler); elementsApiKeyParam.grantRead(lockdownHandler); authTokenParam.grantRead(lockdownHandler); const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", { vpcId: "vpc-0d3d4b67bd0cf8a68", }); const privateSubnet1 = ec2.Subnet.fromSubnetId( this, "PrivateSubnet1", "subnet-04e38c507e96f1926" ); const privateSubnet2 = ec2.Subnet.fromSubnetId( this, "PrivateSubnet2", "subnet-0a0b4fc6f296dfba5" ); const pollerSg = new ec2.SecurityGroup(this, "PollerSecurityGroup", { vpc, securityGroupName: "door-unlock-api-poller", description: "Lockdown poller - outbound to Elements API and phone LAN", allowAllOutbound: false, }); pollerSg.addEgressRule( ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS to Elements API and SSM via NAT" ); pollerSg.addEgressRule( ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(443), "HTTPS to phone LAN via VPN" ); const phoneIpsParam = ssm.StringParameter.fromStringParameterName( this, "PhoneIps", "/seahaven/door-unlock/phone-ips" ); const phonePasswordSecret = secretsmanager.Secret.fromSecretNameV2( this, "PhonePassword", "door-unlock-api/phone-password" ); const pollerHandler = new lambda.Function(this, "LockdownPoller", { functionName: "door-unlock-api-lockdown-poller", runtime: lambda.Runtime.NODEJS_22_X, architecture: lambda.Architecture.ARM_64, handler: "lockdown-poller.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/poller"), { bundling: { image: lambda.Runtime.NODEJS_22_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/poller/lockdown-poller.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "lockdown-poller.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key", PHONE_IPS_PARAM: "/seahaven/door-unlock/phone-ips", PHONE_PASSWORD_SECRET: "door-unlock-api/phone-password", }, vpc, vpcSubnets: { subnets: [privateSubnet1, privateSubnet2] }, securityGroups: [pollerSg], timeout: cdk.Duration.seconds(75), memorySize: 128, logRetention: logs.RetentionDays.TWO_MONTHS, }); elementsApiKeyParam.grantRead(pollerHandler); phoneIpsParam.grantRead(pollerHandler); phonePasswordSecret.grantRead(pollerHandler); new events.Rule(this, "LockdownPollerSchedule", { ruleName: "door-unlock-api-lockdown-poller-schedule", schedule: events.Schedule.rate(cdk.Duration.minutes(1)), targets: [new targets.LambdaFunction(pollerHandler)], }); const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", { apiName: "door-unlock-api", }); const defaultStage = httpApi.defaultStage!.node.defaultChild as apigwv2.CfnStage; defaultStage.addPropertyOverride("DefaultRouteSettings", { ThrottlingBurstLimit: 5, ThrottlingRateLimit: 2, }); httpApi.addRoutes({ path: "/unlock", methods: [apigwv2.HttpMethod.GET], integration: new integrations.HttpLambdaIntegration( "UnlockIntegration", unlockHandler ), }); const lockdownIntegration = new integrations.HttpLambdaIntegration( "LockdownIntegration", lockdownHandler ); httpApi.addRoutes({ path: "/lockdown", methods: [apigwv2.HttpMethod.GET], integration: lockdownIntegration, }); httpApi.addRoutes({ path: "/lockdown/status", methods: [apigwv2.HttpMethod.GET], integration: lockdownIntegration, }); const hostedZone = route53.HostedZone.fromHostedZoneAttributes( this, "SeaHavenZone", { hostedZoneId: "Z06652411XKH89KTZD3XA", zoneName: "seahaven.com", } ); const certificate = acm.Certificate.fromCertificateArn( this, "WildcardCert", "arn:aws:acm:us-east-1:328440206208:certificate/a66c0994-90d4-410a-a1d9-5595c2a3fae3" ); const domainName = new apigwv2.DomainName(this, "DoorUnlockDomain", { domainName: "doorunlock.seahaven.com", certificate, }); new apigwv2.ApiMapping(this, "DoorUnlockMapping", { api: httpApi, domainName, }); new route53.ARecord(this, "DoorUnlockARecord", { zone: hostedZone, recordName: "doorunlock", target: route53.RecordTarget.fromAlias( new route53Targets.ApiGatewayv2DomainProperties( domainName.regionalDomainName, domainName.regionalHostedZoneId ) ), }); new cdk.CfnOutput(this, "ApiUrl", { value: `https://doorunlock.seahaven.com/unlock`, }); new cdk.CfnOutput(this, "LockdownApiUrl", { value: `https://doorunlock.seahaven.com/lockdown`, }); } }