mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 14:03:12 +00:00
* Add lockdown profile toggle endpoints with T58W linekey support Add a new Lambda handler that toggles Elements lockdown profiles (Bohemia and Ronkonkoma) via the Elements API, with status verification before and after each toggle. Returns Yealink XML to control linekey LEDs (green=inactive, red=locked down). Also brings both Lambda handlers into compliance with system standards: Node 22.x runtime, arm64 architecture, 60-day log retention, and kebab-case function names. * Add lockdown poller Lambda and fix lockdown handler responses - 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. * Add buildspec for CodePipeline CI/CD * Update README with CI/CD pipeline details
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`,
|
|
});
|
|
}
|
|
}
|