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%%