mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 05:53:12 +00:00
Some checks failed
Deploy / deploy (push) Has been cancelled
* feat(3cx): sync office department BLFs via XAPI Keep unlock and lockdown keys in the templates and write colleague plus shared-parking BLFs per extension so phones skip their own line. * fix(3cx): preserve parking BLF IDs and fail the job on PATCH errors
465 lines
18 KiB
TypeScript
465 lines
18 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 authorizers from "aws-cdk-lib/aws-apigatewayv2-authorizers";
|
|
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 * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
|
import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions";
|
|
import * as sns from "aws-cdk-lib/aws-sns";
|
|
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" }
|
|
);
|
|
|
|
// forceDynamicReference: the stack only needs the parameter for grantRead
|
|
// (ARN, built from the name); without it, fromStringParameterName injects
|
|
// an unreferenced AWS::SSM::Parameter::Value CloudFormation parameter.
|
|
const doorIdParam = ssm.StringParameter.fromStringParameterAttributes(
|
|
this,
|
|
"DoorId",
|
|
{
|
|
parameterName: "/seahaven/door-unlock/door-id",
|
|
forceDynamicReference: true,
|
|
}
|
|
);
|
|
|
|
const unlockHandler = new lambda.Function(this, "UnlockHandler", {
|
|
functionName: "door-unlock-api-unlock",
|
|
runtime: lambda.Runtime.NODEJS_24_X,
|
|
architecture: lambda.Architecture.ARM_64,
|
|
handler: "unlock-handler.handler",
|
|
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/unlock"), {
|
|
bundling: {
|
|
image: lambda.Runtime.NODEJS_24_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=node24 --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_24_X,
|
|
architecture: lambda.Architecture.ARM_64,
|
|
handler: "lockdown-handler.handler",
|
|
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/lockdown"), {
|
|
bundling: {
|
|
image: lambda.Runtime.NODEJS_24_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=node24 --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);
|
|
|
|
// Gateway authorizer (INFRA-99): validates the same `?token=` query-string
|
|
// value the Yealink XML Browser keys already send, so it is transparent to
|
|
// the phones while rejecting unauthenticated callers at the gateway.
|
|
const authorizerHandler = new lambda.Function(this, "AuthorizerHandler", {
|
|
functionName: "door-unlock-api-authorizer",
|
|
runtime: lambda.Runtime.NODEJS_24_X,
|
|
architecture: lambda.Architecture.ARM_64,
|
|
handler: "authorizer-handler.handler",
|
|
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/authorizer"), {
|
|
bundling: {
|
|
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
|
local: {
|
|
tryBundle(outputDir: string) {
|
|
const { execSync } = require("child_process");
|
|
execSync(
|
|
`esbuild ${path.join(__dirname, "../lambda/authorizer/authorizer-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "authorizer-handler.js")} --external:@aws-sdk/*`
|
|
);
|
|
return true;
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
environment: {
|
|
AUTH_TOKEN_PARAM: authTokenParam.parameterName,
|
|
},
|
|
// APIGW HTTP API authorizers have a hard 10s limit; keep margin so the
|
|
// gateway returns its own 500 rather than racing the Lambda timeout. The
|
|
// token is cached in module scope, so warm invocations never hit SSM.
|
|
timeout: cdk.Duration.seconds(8),
|
|
memorySize: 128,
|
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
|
});
|
|
|
|
authTokenParam.grantRead(authorizerHandler);
|
|
|
|
const tokenAuthorizer = new authorizers.HttpLambdaAuthorizer(
|
|
"DoorUnlockTokenAuthorizer",
|
|
authorizerHandler,
|
|
{
|
|
authorizerName: "door-unlock-api-token-authorizer",
|
|
// The Yealink phones send the token in the query string; scope the
|
|
// identity source there. A request with no `token` query param is
|
|
// rejected by the gateway before the authorizer Lambda is invoked.
|
|
identitySource: ["$request.querystring.token"],
|
|
responseTypes: [authorizers.HttpLambdaResponseType.SIMPLE],
|
|
// Authorizer result caching keyed on the identity source (the token).
|
|
// 5 min keeps repeated phone presses fast without a long stale window.
|
|
resultsCacheTtl: cdk.Duration.minutes(5),
|
|
}
|
|
);
|
|
|
|
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"
|
|
);
|
|
|
|
// forceDynamicReference for the same reason as DoorId above.
|
|
const phoneIpsParam = ssm.StringParameter.fromStringParameterAttributes(
|
|
this, "PhoneIps",
|
|
{ parameterName: "/seahaven/door-unlock/phone-ips", forceDynamicReference: true }
|
|
);
|
|
|
|
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_24_X,
|
|
architecture: lambda.Architecture.ARM_64,
|
|
handler: "lockdown-poller.handler",
|
|
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/poller"), {
|
|
bundling: {
|
|
image: lambda.Runtime.NODEJS_24_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=node24 --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 threeCxDomainSecret = secretsmanager.Secret.fromSecretNameV2(
|
|
this, "ThreeCxDomain", "afterhours-shift-manager/3cx-domain"
|
|
);
|
|
const threeCxClientIdSecret = secretsmanager.Secret.fromSecretNameV2(
|
|
this, "ThreeCxClientId", "afterhours-shift-manager/3cx-client-id"
|
|
);
|
|
const threeCxClientSecret = secretsmanager.Secret.fromSecretNameV2(
|
|
this, "ThreeCxClientSecret", "afterhours-shift-manager/3cx-client-secret"
|
|
);
|
|
|
|
const blfSyncHandler = new lambda.Function(this, "BlfSyncHandler", {
|
|
functionName: "door-unlock-api-blf-sync",
|
|
runtime: lambda.Runtime.NODEJS_24_X,
|
|
architecture: lambda.Architecture.ARM_64,
|
|
handler: "blf-sync-handler.handler",
|
|
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/blf-sync"), {
|
|
bundling: {
|
|
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
|
local: {
|
|
tryBundle(outputDir: string) {
|
|
const { execSync } = require("child_process");
|
|
execSync(
|
|
`esbuild ${path.join(__dirname, "../lambda/blf-sync/blf-sync-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "blf-sync-handler.js")} --external:@aws-sdk/*`
|
|
);
|
|
return true;
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
environment: {
|
|
THREE_CX_DOMAIN_SECRET: "afterhours-shift-manager/3cx-domain",
|
|
THREE_CX_CLIENT_ID_SECRET: "afterhours-shift-manager/3cx-client-id",
|
|
THREE_CX_CLIENT_SECRET_SECRET: "afterhours-shift-manager/3cx-client-secret",
|
|
DRY_RUN: "false",
|
|
},
|
|
timeout: cdk.Duration.seconds(60),
|
|
memorySize: 256,
|
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
|
});
|
|
|
|
threeCxDomainSecret.grantRead(blfSyncHandler);
|
|
threeCxClientIdSecret.grantRead(blfSyncHandler);
|
|
threeCxClientSecret.grantRead(blfSyncHandler);
|
|
|
|
// 05:00 ET during EDT (09:00 UTC).
|
|
new events.Rule(this, "BlfSyncSchedule", {
|
|
ruleName: "door-unlock-api-blf-sync-schedule",
|
|
schedule: events.Schedule.cron({ minute: "0", hour: "9" }),
|
|
enabled: true,
|
|
targets: [new targets.LambdaFunction(blfSyncHandler)],
|
|
});
|
|
|
|
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
|
|
),
|
|
authorizer: tokenAuthorizer,
|
|
});
|
|
|
|
const lockdownIntegration = new integrations.HttpLambdaIntegration(
|
|
"LockdownIntegration",
|
|
lockdownHandler
|
|
);
|
|
|
|
httpApi.addRoutes({
|
|
path: "/lockdown",
|
|
methods: [apigwv2.HttpMethod.GET],
|
|
integration: lockdownIntegration,
|
|
authorizer: tokenAuthorizer,
|
|
});
|
|
|
|
httpApi.addRoutes({
|
|
path: "/lockdown/status",
|
|
methods: [apigwv2.HttpMethod.GET],
|
|
integration: lockdownIntegration,
|
|
authorizer: tokenAuthorizer,
|
|
});
|
|
|
|
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`,
|
|
});
|
|
|
|
// ── CloudWatch error alarms (INFRA-101) ──────────────────────────────────
|
|
// Import the cross-stack site-alerts SNS topic. This topic is managed by
|
|
// seahaven-account-baseline and encrypted with alias/seahaven-alarm-topics
|
|
// (CMK). Never use alias/aws/sns here — CloudWatch cannot publish to topics
|
|
// using the AWS-managed SNS key (silent publish failure).
|
|
// ALARM state only; no OK/recovery actions per Sea Haven preference.
|
|
const siteAlerts = sns.Topic.fromTopicArn(
|
|
this,
|
|
"SiteAlerts",
|
|
"arn:aws:sns:us-east-1:328440206208:site-alerts"
|
|
);
|
|
|
|
interface AlarmSpec {
|
|
readonly id: string;
|
|
readonly fn: lambda.Function;
|
|
readonly alarmName: string;
|
|
readonly description: string;
|
|
}
|
|
|
|
const alarmSpecs: AlarmSpec[] = [
|
|
{
|
|
id: "UnlockErrors",
|
|
fn: unlockHandler,
|
|
alarmName: "door-unlock-api-unlock-errors",
|
|
description: "door-unlock-api-unlock Lambda is erroring — physical door unlock calls may be failing",
|
|
},
|
|
{
|
|
id: "LockdownErrors",
|
|
fn: lockdownHandler,
|
|
alarmName: "door-unlock-api-lockdown-errors",
|
|
description: "door-unlock-api-lockdown Lambda is erroring — lockdown commands may not be executing",
|
|
},
|
|
{
|
|
id: "AuthorizerErrors",
|
|
fn: authorizerHandler,
|
|
alarmName: "door-unlock-api-authorizer-errors",
|
|
description: "door-unlock-api-authorizer Lambda is erroring — all API requests will be rejected",
|
|
},
|
|
{
|
|
id: "PollerErrors",
|
|
fn: pollerHandler,
|
|
alarmName: "door-unlock-api-lockdown-poller-errors",
|
|
description: "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken",
|
|
},
|
|
{
|
|
id: "BlfSyncErrors",
|
|
fn: blfSyncHandler,
|
|
alarmName: "door-unlock-api-blf-sync-errors",
|
|
description: "door-unlock-api-blf-sync Lambda is erroring — department BLF updates may not be applying",
|
|
},
|
|
];
|
|
|
|
for (const spec of alarmSpecs) {
|
|
const alarm = new cloudwatch.Alarm(this, spec.id, {
|
|
alarmName: spec.alarmName,
|
|
alarmDescription: spec.description,
|
|
metric: spec.fn.metricErrors({
|
|
period: cdk.Duration.minutes(5),
|
|
statistic: "Sum",
|
|
}),
|
|
threshold: 0,
|
|
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
evaluationPeriods: 1,
|
|
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
});
|
|
// ALARM-only: addAlarmAction only (no addOkAction / addInsufficientDataAction)
|
|
alarm.addAlarmAction(new cwactions.SnsAction(siteAlerts));
|
|
}
|
|
}
|
|
}
|