seahaven-door-unlock-api/lambda/lockdown/lockdown-handler.ts
Adam Moussa ccbc98962b Add lockdown mode and CI/CD pipeline (#3)
* 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
2026-05-01 18:52:37 -04:00

163 lines
5.4 KiB
TypeScript

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>;
console.log(JSON.stringify({ action: "lockdown_raw_status", lockdownId, data }));
return String(data.status).toLowerCase() === "active";
}
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}`);
}
}
function textScreenXml(title: string, text: string): string {
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<YealinkIPPhoneTextScreen>`,
` <Title>${title}</Title>`,
` <Text>${text}</Text>`,
`</YealinkIPPhoneTextScreen>`,
].join("\n");
}
function xmlResponse(statusCode: number, body: string) {
return {
statusCode,
headers: { "Content-Type": "application/xml" },
body,
};
}
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, textScreenXml("Error", "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 lines: string[] = [];
for (const [key, config] of Object.entries(LOCKDOWN_PROFILES)) {
const active = await getLockdownStatus(config.id, apiKey);
lines.push(`${config.name}: ${active ? "LOCKED DOWN" : "Normal"}`);
console.log(JSON.stringify({ action: "lockdown_status", profile: key, active, sourceIp }));
}
return xmlResponse(200, textScreenXml("Lockdown Status", lines.join("\n")));
} catch (err) {
console.error(JSON.stringify({ action: "lockdown_status", status: "error", sourceIp, error: String(err) }));
return xmlResponse(502, textScreenXml("Error", "Unable to check lockdown status"));
}
}
async function handleToggle(profile: string | undefined, apiKey: string, sourceIp: string) {
if (!profile || !LOCKDOWN_PROFILES[profile]) {
return xmlResponse(400, textScreenXml("Error", "Invalid profile"));
}
const config = LOCKDOWN_PROFILES[profile];
try {
const wasActive = await getLockdownStatus(config.id, apiKey);
await setLockdown(config.id, apiKey, !wasActive);
const nowActive = !wasActive;
console.log(JSON.stringify({
action: "lockdown_toggle",
profile,
wasActive,
nowActive,
sourceIp,
}));
const statusText = nowActive ? "LOCKED DOWN" : "Normal";
return xmlResponse(200, textScreenXml(config.name, statusText));
} catch (err) {
console.error(JSON.stringify({ action: "lockdown_toggle", status: "error", profile, sourceIp, error: String(err) }));
return xmlResponse(502, textScreenXml("Error", `Lockdown error: ${config.name}`));
}
}