seahaven-door-unlock-api/lib/door-unlock-stack.ts

258 lines
8.8 KiB
TypeScript
Raw Normal View History

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`,
});
}
}