mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 07:03:12 +00:00
The door-unlock-api-unlock function hit its 10,000ms timeout on 2026-06-05 at 18:00 UTC during a cold start combined with a slow LenelS2 Elements API call (REPORT: Duration 10000.00 ms, Status: timeout). A concurrent attempt succeeded in 3639ms, confirming the call path is healthy but lacks margin under cold-start + slow-API conditions. Raise the UnlockHandler timeout from 10s to 20s for additional headroom. LockdownHandler (15s) and the poller (75s) are unchanged.
278 lines
9.7 KiB
TypeScript
278 lines
9.7 KiB
TypeScript
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(20),
|
|
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,
|
|
});
|
|
|
|
// Access logging (audit M-18). Throttling above was already present.
|
|
const apiAccessLogGroup = new logs.LogGroup(this, "ApiAccessLogGroup", {
|
|
logGroupName: "/aws/apigateway/door-unlock-api",
|
|
retention: logs.RetentionDays.THREE_MONTHS,
|
|
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
});
|
|
defaultStage.addPropertyOverride("AccessLogSettings", {
|
|
DestinationArn: apiAccessLogGroup.logGroupArn,
|
|
Format: JSON.stringify({
|
|
requestId: "$context.requestId",
|
|
ip: "$context.identity.sourceIp",
|
|
requestTime: "$context.requestTime",
|
|
method: "$context.httpMethod",
|
|
routeKey: "$context.routeKey",
|
|
status: "$context.status",
|
|
protocol: "$context.protocol",
|
|
responseLength: "$context.responseLength",
|
|
integrationError: "$context.integrationErrorMessage",
|
|
}),
|
|
});
|
|
|
|
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`,
|
|
});
|
|
}
|
|
}
|