mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 05:53:12 +00:00
- Add VPC-connected poller Lambda that monitors lockdown status via Elements API every 15 seconds (4 polls per 1-min EventBridge schedule) - Handle Elements API rate limits (429) with retry-after support - Fix lockdown handler to use TextScreen XML instead of Execute XML (Execute shows globe icon on T58W, TextScreen renders properly) - Fix Elements API status parsing to be case-insensitive - Trust toggle action instead of re-checking status (eventual consistency) - Configure push_xml.server = any in T58W template for Push XML support - Clear action_url.setup_completed (poller replaces boot-time check) - Update README with lockdown architecture and known LED limitation Note: T58W line key LED color does not change to reflect lockdown status. Execute LED commands are transient on the T58W - the phone's XML Browser key type immediately overrides them.
257 lines
8.8 KiB
TypeScript
257 lines
8.8 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(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`,
|
|
});
|
|
}
|
|
}
|