mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 03:43:11 +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.
This commit is contained in:
parent
dbdb87ca4b
commit
b0976f94eb
5 changed files with 245 additions and 30 deletions
167
lambda/lockdown/lockdown-handler.ts
Normal file
167
lambda/lockdown/lockdown-handler.ts
Normal file
|
|
@ -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<string, { id: string; name: string; linekey: number }> = {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<string, unknown>;
|
||||
return data.status === "active" || data.isActive === true;
|
||||
}
|
||||
|
||||
async function setLockdown(lockdownId: string, apiKey: string, start: boolean): Promise<void> {
|
||||
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 => ` <ExecuteItem URI="${uri}"/>`).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<YealinkIPPhoneExecute>\n${body}\n</YealinkIPPhoneExecute>`;
|
||||
}
|
||||
|
||||
function xmlResponse(statusCode: number, body: string) {
|
||||
return {
|
||||
statusCode,
|
||||
headers: { "Content-Type": "application/xml" },
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function errorXml(message: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<YealinkIPPhoneTextScreen>\n <Title>Error</Title>\n <Text>${message}</Text>\n</YealinkIPPhoneTextScreen>`;
|
||||
}
|
||||
|
||||
export async function handler(event: {
|
||||
queryStringParameters?: Record<string, string>;
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
|
|
@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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%%
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue