seahaven-door-unlock-api/lambda/lockdown/lockdown-handler.ts

168 lines
5.6 KiB
TypeScript
Raw Normal View History

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}`));
}
}