From b0976f94eb7ac88ae49d27b55347b30b81c20b29 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:05:38 -0400 Subject: [PATCH] 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. --- lambda/lockdown/lockdown-handler.ts | 167 ++++++++++++++++++++++++++ lambda/{ => unlock}/unlock-handler.ts | 0 lib/door-unlock-stack.ts | 64 +++++++++- package-lock.json | 4 +- yealinkT58W-door-unlock.ph.xml | 40 +++--- 5 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 lambda/lockdown/lockdown-handler.ts rename lambda/{ => unlock}/unlock-handler.ts (100%) diff --git a/lambda/lockdown/lockdown-handler.ts b/lambda/lockdown/lockdown-handler.ts new file mode 100644 index 0000000..44cd3ff --- /dev/null +++ b/lambda/lockdown/lockdown-handler.ts @@ -0,0 +1,167 @@ +import { + SSMClient, + GetParameterCommand, +} from "@aws-sdk/client-ssm"; + +const ssm = new SSMClient({}); + +let cachedAuthToken: string | undefined; +let cachedApiKey: string | undefined; + +const LOCKDOWN_PROFILES: Record = { + bohemia: { id: "4b4a3e6b-c903-4cce-8cd6-288612bf0542", name: "Bohemia - Whole Building", linekey: 3 }, + ronkonkoma: { id: "ff9876bc-c54f-472e-aef9-d2bffd4b7cf7", name: "Ronkonkoma - Whole Building", linekey: 4 }, +}; + +const ELEMENTS_BASE_URL = "https://api.elementssecure.com/v1"; + +async function getParameter(name: string, decrypt: boolean): Promise { + const res = await ssm.send( + new GetParameterCommand({ Name: name, WithDecryption: decrypt }) + ); + return res.Parameter!.Value!; +} + +async function loadSecrets() { + const [authToken, apiKey] = await Promise.all([ + cachedAuthToken ?? getParameter(process.env.AUTH_TOKEN_PARAM!, true), + cachedApiKey ?? getParameter(process.env.ELEMENTS_API_KEY_PARAM!, true), + ]); + cachedAuthToken = authToken; + cachedApiKey = apiKey; + return { authToken, apiKey }; +} + +async function getLockdownStatus(lockdownId: string, apiKey: string): Promise { + const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}`, { + headers: { "api-key": apiKey }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Elements status check failed: ${response.status} - ${body}`); + } + + const data = await response.json() as Record; + return data.status === "active" || data.isActive === true; +} + +async function setLockdown(lockdownId: string, apiKey: string, start: boolean): Promise { + const action = start ? "start" : "stop"; + const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}/${action}`, { + method: "POST", + headers: { + "api-key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Elements ${action} failed: ${response.status} - ${body}`); + } +} + +// LED values: 0=off, 1=green, 2=green fast blink, 3=green slow blink, 4=red, 5=red fast blink, 6=red slow blink +function ledColor(active: boolean): number { + return active ? 4 : 1; +} + +function yealinkExecuteXml(items: string[]): string { + const body = items.map(uri => ` `).join("\n"); + return `\n\n${body}\n`; +} + +function xmlResponse(statusCode: number, body: string) { + return { + statusCode, + headers: { "Content-Type": "application/xml" }, + body, + }; +} + +function errorXml(message: string): string { + return `\n\n Error\n ${message}\n`; +} + +export async function handler(event: { + queryStringParameters?: Record; + rawPath?: string; + requestContext?: { http?: { sourceIp?: string } }; +}) { + const sourceIp = event.requestContext?.http?.sourceIp ?? "unknown"; + const token = event.queryStringParameters?.token; + const profile = event.queryStringParameters?.profile; + const path = event.rawPath ?? ""; + + if (!token) { + console.log(JSON.stringify({ action: "lockdown", status: "rejected", reason: "missing_token", sourceIp })); + return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) }; + } + + let secrets; + try { + secrets = await loadSecrets(); + } catch (err) { + console.error(JSON.stringify({ action: "lockdown", status: "error", reason: "ssm_failure", sourceIp, error: String(err) })); + return xmlResponse(500, errorXml("Internal error")); + } + + if (token !== secrets.authToken) { + console.log(JSON.stringify({ action: "lockdown", status: "rejected", reason: "invalid_token", sourceIp })); + return { statusCode: 403, body: JSON.stringify({ error: "Forbidden" }) }; + } + + if (path === "/lockdown/status") { + return handleStatus(secrets.apiKey, sourceIp); + } + + return handleToggle(profile, secrets.apiKey, sourceIp); +} + +async function handleStatus(apiKey: string, sourceIp: string) { + try { + const ledItems: string[] = []; + + for (const [key, config] of Object.entries(LOCKDOWN_PROFILES)) { + const active = await getLockdownStatus(config.id, apiKey); + ledItems.push(`Led:LINEKEY${config.linekey}=${ledColor(active)}`); + console.log(JSON.stringify({ action: "lockdown_status", profile: key, active, sourceIp })); + } + + return xmlResponse(200, yealinkExecuteXml(ledItems)); + } catch (err) { + console.error(JSON.stringify({ action: "lockdown_status", status: "error", sourceIp, error: String(err) })); + return xmlResponse(502, errorXml("Unable to check lockdown status")); + } +} + +async function handleToggle(profile: string | undefined, apiKey: string, sourceIp: string) { + if (!profile || !LOCKDOWN_PROFILES[profile]) { + return xmlResponse(400, errorXml("Invalid profile")); + } + + const config = LOCKDOWN_PROFILES[profile]; + + try { + const wasActive = await getLockdownStatus(config.id, apiKey); + await setLockdown(config.id, apiKey, !wasActive); + const isActive = await getLockdownStatus(config.id, apiKey); + + console.log(JSON.stringify({ + action: "lockdown_toggle", + profile, + wasActive, + isActive, + sourceIp, + })); + + return xmlResponse(200, yealinkExecuteXml([ + `Led:LINEKEY${config.linekey}=${ledColor(isActive)}`, + ])); + } catch (err) { + console.error(JSON.stringify({ action: "lockdown_toggle", status: "error", profile, sourceIp, error: String(err) })); + return xmlResponse(502, errorXml(`Lockdown error: ${config.name}`)); + } +} diff --git a/lambda/unlock-handler.ts b/lambda/unlock/unlock-handler.ts similarity index 100% rename from lambda/unlock-handler.ts rename to lambda/unlock/unlock-handler.ts diff --git a/lib/door-unlock-stack.ts b/lib/door-unlock-stack.ts index 2976860..9244648 100644 --- a/lib/door-unlock-stack.ts +++ b/lib/door-unlock-stack.ts @@ -33,16 +33,18 @@ export class DoorUnlockStack extends cdk.Stack { ); const unlockHandler = new lambda.Function(this, "UnlockHandler", { - runtime: lambda.Runtime.NODEJS_20_X, + 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"), { + code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/unlock"), { bundling: { - image: lambda.Runtime.NODEJS_20_X.bundlingImage, + image: lambda.Runtime.NODEJS_22_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( - `esbuild ${path.join(__dirname, "../lambda/unlock-handler.ts")} --bundle --platform=node --target=node20 --outfile=${path.join(outputDir, "unlock-handler.js")} --external:@aws-sdk/*` + `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; }, @@ -56,13 +58,44 @@ export class DoorUnlockStack extends cdk.Stack { }, timeout: cdk.Duration.seconds(10), memorySize: 128, - logRetention: logs.RetentionDays.THREE_MONTHS, + 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 httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", { apiName: "door-unlock-api", }); @@ -82,6 +115,23 @@ export class DoorUnlockStack extends cdk.Stack { ), }); + 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", @@ -121,5 +171,9 @@ export class DoorUnlockStack extends cdk.Stack { new cdk.CfnOutput(this, "ApiUrl", { value: `https://doorunlock.seahaven.com/unlock`, }); + + new cdk.CfnOutput(this, "LockdownApiUrl", { + value: `https://doorunlock.seahaven.com/lockdown`, + }); } } diff --git a/package-lock.json b/package-lock.json index ce3d8f2..6580c16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "semver" ], "license": "Apache-2.0", + "peer": true, "dependencies": { "jsonschema": "~1.4.1", "semver": "^7.7.4" @@ -914,7 +915,8 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz", "integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/esbuild": { "version": "0.25.0", diff --git a/yealinkT58W-door-unlock.ph.xml b/yealinkT58W-door-unlock.ph.xml index 6476b97..f71b776 100644 --- a/yealinkT58W-door-unlock.ph.xml +++ b/yealinkT58W-door-unlock.ph.xml @@ -897,7 +897,7 @@ voice.headset_send = #$call_id--The caller ID when in the incoming state, the outgoing state or during conversation. #For example, action_url.log_on = http://192.168.1.20/help.xml?mac=$mac -action_url.setup_completed = +action_url.setup_completed = https://doorunlock.seahaven.com/lockdown/status?token=__DOOR_UNLOCK_TOKEN__ action_url.registered = action_url.unregistered = action_url.register_failed = @@ -2183,7 +2183,7 @@ features.voice_mail_tone_enable = 1 features.alert_info_tone = features.barge_in_via_username.enable = -features.flash_url_dsskey_led.enable = +features.flash_url_dsskey_led.enable = 1 features.default_account = #The following parameter only applicable to V80 @@ -3100,28 +3100,20 @@ linekey.2.pickup_value = %NULL% linekey.2.type = 17 linekey.2.label = Unlock Door linekey.2.extension = %NULL% -#Configure Line Key3 -{IF blf3} -linekey.3.line = %%Line%% -linekey.3.value = %%type%% -linekey.3.pickup_value = %%PickupValue%% -linekey.3.type = %%DKtype%% -linekey.3.label = %%label%% -linekey.3.extension = %%PickupValue%% -{ELSE} -linekey.3.type = 0 -{ENDIF} -#Configure Line Key4 -{IF blf4} -linekey.4.line = %%Line%% -linekey.4.value = %%type%% -linekey.4.pickup_value = %%PickupValue%% -linekey.4.type = %%DKtype%% -linekey.4.label = %%label%% -linekey.4.extension = %%PickupValue%% -{ELSE} -linekey.4.type = 0 -{ENDIF} +#Configure Line Key3 - Lockdown: Bohemia (hardcoded) +linekey.3.line = 1 +linekey.3.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=bohemia +linekey.3.pickup_value = %NULL% +linekey.3.type = 17 +linekey.3.label = Lockdown BOH +linekey.3.extension = %NULL% +#Configure Line Key4 - Lockdown: Ronkonkoma (hardcoded) +linekey.4.line = 1 +linekey.4.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=ronkonkoma +linekey.4.pickup_value = %NULL% +linekey.4.type = 17 +linekey.4.label = Lockdown RNK +linekey.4.extension = %NULL% #Configure Line Key5 {IF blf5} linekey.5.line = %%Line%%