mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 04:53:10 +00:00
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
This commit is contained in:
parent
dbdb87ca4b
commit
ccbc98962b
9 changed files with 517 additions and 45 deletions
67
README.md
67
README.md
|
|
@ -1,18 +1,34 @@
|
|||
# Sea Haven Door Unlock API
|
||||
|
||||
AWS Lambda middleware that allows a Yealink T54W desk phone to unlock the front door controlled by LenelS2 Elements. Press a DSS key on the phone, and the door unlocks.
|
||||
AWS Lambda middleware that allows Yealink desk phones to unlock the front door and manage lockdown profiles via LenelS2 Elements.
|
||||
|
||||
```
|
||||
Yealink T54W → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API → Door Unlocks
|
||||
Yealink T54W/T58W → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **API Gateway (HTTP API)** — single `GET /unlock` endpoint with throttling (5 burst / 2 sustained req/sec)
|
||||
- **Lambda (Node.js 20.x)** — validates a shared auth token, calls the Elements `TemporaryUnlock` command
|
||||
- **SSM Parameter Store** — stores the Elements API key, auth token, and door device ID
|
||||
- **API Gateway (HTTP API)** — `GET /unlock`, `GET /lockdown`, `GET /lockdown/status` with throttling (5 burst / 2 sustained req/sec)
|
||||
- **Unlock Lambda** — validates a shared auth token, calls the Elements `TemporaryUnlock` command
|
||||
- **Lockdown Lambda** — toggles lockdown profiles (start/stop) and checks status, returns Yealink XML TextScreen responses
|
||||
- **Lockdown Poller Lambda** — VPC-connected, polls Elements API every 15 seconds for lockdown status (runs 4x per 1-minute EventBridge schedule)
|
||||
- **SSM Parameter Store** — stores the Elements API key, auth token, door ID, and phone IPs
|
||||
- **Secrets Manager** — stores the Yealink phone admin password
|
||||
- **Custom Domain** — `doorunlock.seahaven.com` via Route 53 + ACM wildcard cert
|
||||
|
||||
## Lockdown Profiles
|
||||
|
||||
Two lockdown profiles are configured:
|
||||
|
||||
| Profile | Elements ID | Line Key |
|
||||
|---------|-------------|----------|
|
||||
| Bohemia - Whole Building | `4b4a3e6b-c903-4cce-8cd6-288612bf0542` | 3 |
|
||||
| Ronkonkoma - Whole Building | `ff9876bc-c54f-472e-aef9-d2bffd4b7cf7` | 4 |
|
||||
|
||||
Pressing the line key toggles the lockdown on/off and displays the current status on the phone screen.
|
||||
|
||||
**Known limitation:** Line key LED color does not currently change to reflect lockdown status. The T58W's XML Browser key type (17) does not support persistent LED color changes via Push XML or Execute commands — LED commands are transient and immediately overridden by the phone's key type management.
|
||||
|
||||
## SSM Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|
|
@ -20,8 +36,27 @@ Yealink T54W → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API
|
|||
| `/seahaven/door-unlock/elements-api-key` | SecureString | LenelS2 Elements API key |
|
||||
| `/seahaven/door-unlock/auth-token` | SecureString | Shared secret embedded in the Yealink DSS key URL |
|
||||
| `/seahaven/door-unlock/door-id` | String | Elements device ID for the front door reader |
|
||||
| `/seahaven/door-unlock/phone-ips` | String | Comma-separated phone IPs for lockdown poller |
|
||||
|
||||
## Deployment
|
||||
## Secrets Manager
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `door-unlock-api/phone-password` | Yealink phone admin password for Push XML |
|
||||
|
||||
## CI/CD
|
||||
|
||||
Pushes to `main` trigger an AWS CodePipeline (V2) that runs `cdk deploy` via CodeBuild.
|
||||
|
||||
| Resource | Name |
|
||||
|----------|------|
|
||||
| Pipeline | `seahaven-door-unlock-api-pipeline` |
|
||||
| CodeBuild project | `seahaven-door-unlock-api-build` |
|
||||
| Artifact bucket | `seahaven-door-unlock-api-pipeline-artifacts` |
|
||||
|
||||
The CodeBuild role assumes CDK bootstrap roles for deployment — no separate CloudFormation stage.
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
```bash
|
||||
npm install
|
||||
|
|
@ -30,18 +65,22 @@ npx cdk deploy
|
|||
|
||||
## Phone Configuration
|
||||
|
||||
Configure a DSS key on the Yealink T54W (via phone web UI or 3CX):
|
||||
Configure DSS keys on the Yealink T54W/T58W (via phone web UI or 3CX):
|
||||
|
||||
- **Type:** URL
|
||||
- **Label:** Unlock Door
|
||||
- **Value:** `https://doorunlock.seahaven.com/unlock?token=<auth-token-value>`
|
||||
- **Key 2 — Unlock Door**
|
||||
- Type: URL
|
||||
- Value: `https://doorunlock.seahaven.com/unlock?token=<auth-token>`
|
||||
|
||||
- **Keys 3-4 — Lockdown Toggle**
|
||||
- Type: XML Browser (17)
|
||||
- Value: `https://doorunlock.seahaven.com/lockdown?token=<auth-token>&profile=bohemia|ronkonkoma`
|
||||
|
||||
## 3CX Provisioning Templates
|
||||
|
||||
Custom 3CX templates are included with the door unlock URL hardcoded on line key 2.
|
||||
Custom 3CX templates are included with door unlock and lockdown URLs hardcoded.
|
||||
|
||||
| Template | Model | Line Key 2 | Keys 3+ | Display |
|
||||
|----------|-------|-----------|---------|---------|
|
||||
| Template | Model | Key 2 | Keys 3-4 | Display |
|
||||
|----------|-------|-------|----------|---------|
|
||||
| `yealinkT54W-door-unlock.ph.xml` | T54W | Unlock Door | Managed by 3CX BLF | Dim after 5 min, never sleep |
|
||||
| `yealinkT54W-door-unlock-with-sp.ph.xml` | T54W | Unlock Door | Shared Parking SP1-3 | Dim after 5 min, never sleep |
|
||||
| `yealinkT58W-door-unlock.ph.xml` | T58W | Unlock Door | Managed by 3CX BLF | Default T58W display settings |
|
||||
| `yealinkT58W-door-unlock.ph.xml` | T58W | Unlock Door | Lockdown Toggle (Bohemia/Ronkonkoma) | Default T58W display settings |
|
||||
|
|
|
|||
11
buildspec.yml
Normal file
11
buildspec.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
version: 0.2
|
||||
|
||||
phases:
|
||||
install:
|
||||
runtime-versions:
|
||||
nodejs: 22
|
||||
commands:
|
||||
- npm ci
|
||||
build:
|
||||
commands:
|
||||
- npx cdk deploy --require-approval never
|
||||
47
cdk.context.json
Normal file
47
cdk.context.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"vpc-provider:account=328440206208:filter.vpc-id=vpc-0d3d4b67bd0cf8a68:region=us-east-1:returnAsymmetricSubnets=true": {
|
||||
"vpcId": "vpc-0d3d4b67bd0cf8a68",
|
||||
"vpcCidrBlock": "10.20.0.0/16",
|
||||
"ownerAccountId": "328440206208",
|
||||
"availabilityZones": [],
|
||||
"vpnGatewayId": "vgw-073737d44762dffc2",
|
||||
"subnetGroups": [
|
||||
{
|
||||
"name": "Private",
|
||||
"type": "Private",
|
||||
"subnets": [
|
||||
{
|
||||
"subnetId": "subnet-04e38c507e96f1926",
|
||||
"cidr": "10.20.30.0/24",
|
||||
"availabilityZone": "us-east-1a",
|
||||
"routeTableId": "rtb-06a2f56f492b9b4de"
|
||||
},
|
||||
{
|
||||
"subnetId": "subnet-0a0b4fc6f296dfba5",
|
||||
"cidr": "10.20.40.0/24",
|
||||
"availabilityZone": "us-east-1b",
|
||||
"routeTableId": "rtb-01e152fe5cabca7d6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Public",
|
||||
"type": "Public",
|
||||
"subnets": [
|
||||
{
|
||||
"subnetId": "subnet-0eea820effe1b3ae5",
|
||||
"cidr": "10.20.10.0/24",
|
||||
"availabilityZone": "us-east-1a",
|
||||
"routeTableId": "rtb-0f2232493a5c43fe8"
|
||||
},
|
||||
{
|
||||
"subnetId": "subnet-0012f5895182c1580",
|
||||
"cidr": "10.20.20.0/24",
|
||||
"availabilityZone": "us-east-1b",
|
||||
"routeTableId": "rtb-0f2232493a5c43fe8"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
163
lambda/lockdown/lockdown-handler.ts
Normal file
163
lambda/lockdown/lockdown-handler.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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}`));
|
||||
}
|
||||
}
|
||||
86
lambda/poller/lockdown-poller.ts
Normal file
86
lambda/poller/lockdown-poller.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
SSMClient,
|
||||
GetParameterCommand,
|
||||
} from "@aws-sdk/client-ssm";
|
||||
|
||||
const ssm = new SSMClient({});
|
||||
|
||||
let cachedApiKey: string | undefined;
|
||||
|
||||
const LOCKDOWN_PROFILES: { key: string; id: string }[] = [
|
||||
{ key: "bohemia", id: "4b4a3e6b-c903-4cce-8cd6-288612bf0542" },
|
||||
{ key: "ronkonkoma", id: "ff9876bc-c54f-472e-aef9-d2bffd4b7cf7" },
|
||||
];
|
||||
|
||||
const ELEMENTS_BASE_URL = "https://api.elementssecure.com/v1";
|
||||
const POLL_COUNT = 4;
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
async function getSsmParam(name: string, decrypt: boolean): Promise<string> {
|
||||
const res = await ssm.send(
|
||||
new GetParameterCommand({ Name: name, WithDecryption: decrypt })
|
||||
);
|
||||
return res.Parameter!.Value!;
|
||||
}
|
||||
|
||||
async function loadApiKey(): Promise<string> {
|
||||
if (!cachedApiKey) {
|
||||
cachedApiKey = await getSsmParam(process.env.ELEMENTS_API_KEY_PARAM!, true);
|
||||
}
|
||||
return cachedApiKey;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getLockdownStatus(lockdownId: string, apiKey: string): Promise<boolean> {
|
||||
const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}`, {
|
||||
headers: { "api-key": apiKey },
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = parseInt(response.headers.get("retry-after") ?? "2", 10);
|
||||
await sleep(retryAfter * 1000);
|
||||
return getLockdownStatus(lockdownId, apiKey);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Elements status check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
return String(data.status).toLowerCase() === "active";
|
||||
}
|
||||
|
||||
export async function handler() {
|
||||
let apiKey: string;
|
||||
try {
|
||||
apiKey = await loadApiKey();
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ action: "poller_config_error", error: String(err) }));
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < POLL_COUNT; i++) {
|
||||
try {
|
||||
const statuses = [];
|
||||
for (const profile of LOCKDOWN_PROFILES) {
|
||||
const active = await getLockdownStatus(profile.id, apiKey);
|
||||
statuses.push({ profile: profile.key, active });
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
action: "poller_cycle",
|
||||
iteration: i + 1,
|
||||
statuses,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ action: "poller_poll_error", iteration: i + 1, error: String(err) }));
|
||||
}
|
||||
|
||||
if (i < POLL_COUNT - 1) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@ import * as lambda from "aws-cdk-lib/aws-lambda";
|
|||
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
|
||||
import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations";
|
||||
import * as ssm from "aws-cdk-lib/aws-ssm";
|
||||
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
|
||||
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
||||
import * as events from "aws-cdk-lib/aws-events";
|
||||
import * as targets from "aws-cdk-lib/aws-events-targets";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
|
||||
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
||||
|
|
@ -33,16 +37,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 +62,118 @@ 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 vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", {
|
||||
vpcId: "vpc-0d3d4b67bd0cf8a68",
|
||||
});
|
||||
|
||||
const privateSubnet1 = ec2.Subnet.fromSubnetId(
|
||||
this, "PrivateSubnet1", "subnet-04e38c507e96f1926"
|
||||
);
|
||||
const privateSubnet2 = ec2.Subnet.fromSubnetId(
|
||||
this, "PrivateSubnet2", "subnet-0a0b4fc6f296dfba5"
|
||||
);
|
||||
|
||||
const pollerSg = new ec2.SecurityGroup(this, "PollerSecurityGroup", {
|
||||
vpc,
|
||||
securityGroupName: "door-unlock-api-poller",
|
||||
description: "Lockdown poller - outbound to Elements API and phone LAN",
|
||||
allowAllOutbound: false,
|
||||
});
|
||||
pollerSg.addEgressRule(
|
||||
ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS to Elements API and SSM via NAT"
|
||||
);
|
||||
pollerSg.addEgressRule(
|
||||
ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(443), "HTTPS to phone LAN via VPN"
|
||||
);
|
||||
|
||||
const phoneIpsParam = ssm.StringParameter.fromStringParameterName(
|
||||
this, "PhoneIps", "/seahaven/door-unlock/phone-ips"
|
||||
);
|
||||
|
||||
const phonePasswordSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||
this, "PhonePassword", "door-unlock-api/phone-password"
|
||||
);
|
||||
|
||||
const pollerHandler = new lambda.Function(this, "LockdownPoller", {
|
||||
functionName: "door-unlock-api-lockdown-poller",
|
||||
runtime: lambda.Runtime.NODEJS_22_X,
|
||||
architecture: lambda.Architecture.ARM_64,
|
||||
handler: "lockdown-poller.handler",
|
||||
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/poller"), {
|
||||
bundling: {
|
||||
image: lambda.Runtime.NODEJS_22_X.bundlingImage,
|
||||
local: {
|
||||
tryBundle(outputDir: string) {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`esbuild ${path.join(__dirname, "../lambda/poller/lockdown-poller.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "lockdown-poller.js")} --external:@aws-sdk/*`
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
environment: {
|
||||
ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key",
|
||||
PHONE_IPS_PARAM: "/seahaven/door-unlock/phone-ips",
|
||||
PHONE_PASSWORD_SECRET: "door-unlock-api/phone-password",
|
||||
},
|
||||
vpc,
|
||||
vpcSubnets: { subnets: [privateSubnet1, privateSubnet2] },
|
||||
securityGroups: [pollerSg],
|
||||
timeout: cdk.Duration.seconds(75),
|
||||
memorySize: 128,
|
||||
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||
});
|
||||
|
||||
elementsApiKeyParam.grantRead(pollerHandler);
|
||||
phoneIpsParam.grantRead(pollerHandler);
|
||||
phonePasswordSecret.grantRead(pollerHandler);
|
||||
|
||||
new events.Rule(this, "LockdownPollerSchedule", {
|
||||
ruleName: "door-unlock-api-lockdown-poller-schedule",
|
||||
schedule: events.Schedule.rate(cdk.Duration.minutes(1)),
|
||||
targets: [new targets.LambdaFunction(pollerHandler)],
|
||||
});
|
||||
|
||||
const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", {
|
||||
apiName: "door-unlock-api",
|
||||
});
|
||||
|
|
@ -82,6 +193,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 +249,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",
|
||||
|
|
|
|||
|
|
@ -979,7 +979,7 @@ zero_touch.network_fail_delay_times =
|
|||
## Push XML ##
|
||||
#######################################################################################
|
||||
|
||||
push_xml.server =
|
||||
push_xml.server = any
|
||||
|
||||
#Enable or disable the phone to display the push XML interface when receiving an incoming call; 0-Disabled (default), 1-Enabled;
|
||||
push_xml.block_in_calling = 0
|
||||
|
|
@ -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