move packages
Some checks failed
Build and push Doorman UI / API / docker (push) Failing after 17s

This commit is contained in:
Martin Dimitrov 2024-10-26 11:49:42 -07:00
parent bc08150533
commit 24ca5f8e0b
67 changed files with 28 additions and 518 deletions

View File

@ -1,20 +0,0 @@
FROM oven/bun
ADD packages packages
ADD bun.lockb bun.lockb
ADD package.json package.json
ADD tsconfig.json tsconfig.json
# install all deps
RUN bun install
# client build
WORKDIR /home/bun/app/packages/client
RUN bun run build
# move built client to server
RUN mv dist ../server/
WORKDIR /home/bun/app/packages/server
# start server
CMD bun run ./src/server.ts

View File

@ -14,13 +14,23 @@ bun run index.ts
This project was created using `bun init` in bun v1.0.3. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
## Deployments
They are configured to happen in gitea actions for CI/CD. If you need to deploy manually, it should be possible as long as you source the requisite envs first.
## to deploy Doorman API / UI
```bash
bun run deploy-serverless
```
## homeassistant integration
## to deploy Doorman Buzzer client
```bash
bun run deploy-buzzer-client
```
## homeassistant integration poller
in configuration.yaml

BIN
bun.lockb

Binary file not shown.

View File

@ -1,14 +0,0 @@
version: "3"
services:
doorman:
container_name: doorman
image: gitea.chromart.dedyn.io/martin/doorman:latest
environment:
- CHALLENGE_EXPIRE_MS=105000
- BASE_DOMAIN=gitea.chromart.dedyn.io
- REDIS_CONNECT_URL=redis://@redis:6379
depends_on:
- redis
redis:
image: redis:latest

View File

@ -1,16 +1,20 @@
{
"folders": [
{
"path": "packages/client"
"name": "doorman-ui",
"path": "packages/doorman-ui"
},
{
"path": "packages/server"
"name": "doorman-api",
"path": "packages/doorman-api"
},
{
"name": "doorman-client",
"path": "packages/doorman-client"
},
{
"name": "doorman",
"path": "."
},
{
"path": "packages/serverless"
}
],
"settings": {

View File

@ -7,9 +7,9 @@
"bun-types": "latest"
},
"scripts": {
"prepare-client-serverless": "bun --filter 'doorman-client' build && rm -rf packages/serverless/assets/* && mkdir -p packages/serverless/assets/assets && cp -fr packages/client/dist/* packages/serverless/assets/ && cp -f packages/serverless/assets/index.html packages/serverless/assets/assets/index.html",
"deploy-serverless": "bun run prepare-client-serverless && bun --filter 'serverless' deploy",
"deploy-buzzer-client": "bun --filter 'buzzer-client' deploy"
"prepare-ui": "bun --filter 'doorman-ui' build && rm -rf packages/doorman-api/assets/* && mkdir -p packages/doorman-api/assets/assets && cp -fr packages/doorman-ui/dist/* packages/doorman-api/assets/ && cp -f packages/doorman-api/assets/index.html packages/doorman-api/assets/assets/index.html",
"deploy-serverless": "bun run prepare-ui && bun --filter 'doorman-api' deploy",
"deploy-buzzer-client": "bun --filter 'doorman-client' deploy"
},
"peerDependencies": {
"typescript": "^5.0.0"

View File

@ -2,8 +2,8 @@
this project deploys the UI and API to twilio functions https://doorman-6741-prod.twil.io
It uses a cloud redis cache
It uses DDB for the backend
After the twilio functions I have setup a cloudflare worker at https://doorman.chromart.workers.dev
After the twilio functions I have setup a cloudflare worker at https://doorman.chromart.cc to proxy the requests to the twilio lambda
The cloudflare worker just proxies requests so the endpoint is a bit nicer

View File

@ -1,5 +1,5 @@
{
"name": "serverless",
"name": "doorman-api",
"version": "0.0.0",
"private": true,
"scripts": {

View File

@ -1,5 +1,5 @@
{
"name": "buzzer-client",
"name": "doorman-client",
"version": "0.0.0",
"private": true,
"scripts": {

View File

@ -1,5 +1,5 @@
{
"name": "doorman-client",
"name": "doorman-ui",
"version": "0.1.0",
"private": true,
"dependencies": {

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -1,28 +0,0 @@
{
"name": "doorman-server",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "bun --hot run src/server.ts",
"build": "bun tsc"
},
"dependencies": {
"express": "^4.18.2",
"express-fileupload": "^1.4.0",
"express-rate-limit": "^6.10.0",
"lnurl": "^0.25.1",
"qrcode": "^1.5.3",
"redis": "^4.6.8",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.2.2"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/express-fileupload": "^1.4.1",
"@types/express-rate-limit": "^6.0.0",
"@types/node": "^20.6.4",
"@types/qrcode": "^1.5.2"
}
}

View File

@ -1,31 +0,0 @@
import type { IAccessControl } from "../../types/IAccessControl";
export abstract class AbstractDbClient {
/**
* Checks if the given challenge already exists in the db
* @param challenge - challenge to check against db
* @returns true if challenge exists in DB
*/
public abstract doesChallengeExist(challenge: string): Promise<boolean>;
// access control methods
/**
* Set an entry in the DB to mark that a particular challenge was completed by a certain key.
* A future operation which presents this challenge will be authenticated by the key associated in this operation
* @param challenge - the challenge which was completed
* @param key - the key which was part of the authentication test
*/
public abstract markChallengeSuccess(challenge: string, key: string): Promise<void>;
/**
* Calls into the DB to see if a particular challege has been completed. If it has, return the relevant access control metadata.
* This method will also decrement the access count, and remove it if it falls below the number of remaining access tokens on this challenge
* @param challenge - 32 byte hex challenge
*/
public abstract accessFromChallenge(challenge: string): Promise<IAccessControl | null>;
public abstract connect(): Promise<any>;
public abstract put(key: string, value: string): Promise<any>;
public abstract exists(key: string): Promise<any>;
}

View File

@ -1,69 +0,0 @@
import type { RedisClientOptions, RedisClientType, RedisFunctions, RedisModules, RedisScripts } from "redis";
import { createClient } from "redis";
import type { IAccessControl } from "../../types/IAccessControl";
import { AbstractDbClient } from "./AbstractDbClient";
import { RedisKeys } from "../../types/RedisKeys";
export class RedisDbClient<A extends RedisModules, B extends RedisFunctions, C extends RedisScripts> extends AbstractDbClient {
private client: RedisClientType<A, B, C>;
private timers: { [challenge: string]: NodeJS.Timeout } = {};
constructor(onError: (err: any) => void, options?: RedisClientOptions<A, B, C>) {
super();
this.client = createClient(options);
this.client.on("error", onError);
}
public async connect(): Promise<RedisClientType<A, B, C>> {
return this.client.connect();
}
public doesChallengeExist(challenge: string): Promise<boolean> {
return this.client.sIsMember(RedisKeys.CHALLENGES, challenge);
}
public async removeChallenge(challenge: string): Promise<boolean> {
let res: number = await this.client.sRem(RedisKeys.CHALLENGES, challenge);
clearTimeout(this.timers[challenge]);
delete this.timers[challenge];
return res > 0;
}
public async markChallengeSuccess(challenge: string, key: string): Promise<void> {
await this.client.set(challenge, key);
}
public async accessFromChallenge(challenge: string): Promise<IAccessControl | null> {
let key: string | null = await this.client.getDel(challenge);
if (key == null) {
return null;
}
return {
key,
remainingAccess: 0,
}
}
public async put(key: string, value: string): Promise<any> {
return this.client.set(key, value);
}
public async exists(key: string): Promise<number> {
return this.client.exists(key);
}
public async get(key: string): Promise<string | null> {
return this.client.get(key);
}
public async remove(key: string): Promise<string | null> {
return this.client.getDel(key);
}
public getClient() {
return this.client;
}
}

View File

@ -1,15 +0,0 @@
import { getEnv } from "../../util/EnvConfigUtil";
import { RedisDbClient } from "./RedisDbClient";
let client: RedisDbClient<any, any, any>;
export async function getRedisClient(): Promise<RedisDbClient<any, any, any>> {
if (!client) {
client = new RedisDbClient((err) => console.error(err), { url: getEnv("REDIS_CONNECT_URL") });
await client.connect()
}
return client;
}

View File

@ -1,66 +0,0 @@
import type { Request, RequestHandler } from "express";
import { getRedisClient } from "../clients/db/RedisDbProvider";
import { getAllDoorNames, getAuthModes, getDoorSettingString, getEnv } from "../util/EnvConfigUtil";
import { IAuthMode } from "../types/IAuthMode";
import { IDoorConfig } from "../types/IDoorConfig";
import { doorRotatingKey } from "../types/RedisKeys";
import crypto from "crypto";
import fetch from "node-fetch";
const client = await getRedisClient();
export const HandleAuthMode: RequestHandler = async (req, res, next) => {
const authModes = getAuthModes(req.params.id);
if (authModes.length === 0) {
res.status(404).json({ msg: `Unknown door ${req.params.id}` });
return;
}
const checkAuth = async (mode: IAuthMode): Promise<boolean> => {
switch(mode) {
case IAuthMode.FIXED_PIN: return handleFixedPinAuth(req)
case IAuthMode.RANDOM_ROTATING_KEY: return await handleRandomRotatingKeyAuth(req)
default: return false;
}
}
const isAuthorized = (await Promise.all(authModes.map((mode) => checkAuth(mode)))).some(b => b);
if (!isAuthorized) {
res.status(401).json({ msg: 'Unauthorized' });
return;
}
next();
}
const handleFixedPinAuth = (req: Request): boolean => {
const fixedPin = getDoorSettingString(req.params.id, IDoorConfig.FIXED_PIN);
return fixedPin !== undefined && req.query['key'] === fixedPin;
}
const handleRandomRotatingKeyAuth = async (req: Request): Promise<boolean> => {
const currentPin = await client.get(doorRotatingKey(req.params.id));
if (currentPin === req.query['rotatingKey']) {
await replaceDoorRandomKey(req.params.id);
return true;
}
return false;
}
export const initializeRandomDoorPins = () => {
const doors = getAllDoorNames();
doors.forEach(replaceDoorRandomKey);
}
export const replaceDoorRandomKey = async (door: string) => {
const newKey = crypto.randomBytes(20).toString('hex');
await client.put(doorRotatingKey(door), newKey);
const message = `New key for door ${door}! Unlock link: ${getEnv("BASE_DOMAIN")}/door/${door}?rotatingKey=${newKey}`;
console.log(message);
await fetch(getEnv("ROTATING_KEY_NTFY"), { method: "POST", body: message });
}

View File

@ -1,17 +0,0 @@
import type { RequestHandler } from "express";
import { getDoorSettingTimeLock } from "../util/EnvConfigUtil";
import { IDoorStatus } from "../types/IDoorStatus";
export const TimeLockVerify: RequestHandler = async (req, res, next) => {
const timeLock = getDoorSettingTimeLock(req.params.id);
const timeHr = (new Date()).getHours();
if (timeHr >= timeLock[0] || timeHr <= timeLock[1]) {
res.status(410).json({ status: IDoorStatus.TIME_LOCK, msg: 'Sorry! This door is locked at this hour, try again later' });
return;
}
next();
}

View File

@ -1,65 +0,0 @@
import express from "express";
import { getRedisClient } from "../clients/db/RedisDbProvider";
import { doorStatusKey } from "../types/RedisKeys";
import { HandleAuthMode } from "../middlewares/DoorAuthModes";
import { getAllDoorNames, getAuthModes, getDoorSettingNumber, getDoorSettingString, getEnv } from "../util/EnvConfigUtil";
import { IDoorConfig } from "../types/IDoorConfig";
import { TimeLockVerify } from "../middlewares/TimeLockMiddleware";
import { IDoorStatus } from "../types/IDoorStatus";
const router = express.Router();
const client = await getRedisClient();
router.get('/', async (req, res) => {
res.redirect(`/api/door/${getEnv("DEFAULT_DOOR") || getAllDoorNames()[0]}`);
});
router.get('/:id', async(req, res) => {
const doorId = req.params.id;
const authModes = getAuthModes(doorId);
const timeout = getDoorSettingNumber(doorId, IDoorConfig.OPEN_TIMEOUT)|| 60;
if (authModes.length === 0) {
res.status(404).json({ msg: `Door ${doorId} not found` });
return;
}
const status = await client.get(doorStatusKey(doorId)) ? IDoorStatus.OPEN: IDoorStatus.CLOSED;
res.status(200).json({ id: doorId, authModes, timeout, status });
});
router.get('/:id/status', TimeLockVerify, async(req, res) => {
const isOpen = await client.get(doorStatusKey(req.params.id));
if (isOpen) {
const fingerprint = JSON.parse(isOpen);
res.status(200).json({ status: IDoorStatus.OPEN, fingerprint });
if (getDoorSettingString(req.params.id, IDoorConfig.CLOSE_AFTER_POLL)) {
await client.remove(doorStatusKey(req.params.id));
}
return;
}
res.status(401).json({ status: IDoorStatus.CLOSED });
});
router.delete('/:id/status', async(req, res) => {
await client.remove(doorStatusKey(req.params.id));
res.status(200).json({ msg: `Closed the door ${req.params.id}` });
});
router.all('/:id/auth', HandleAuthMode, async(req, res) => {
const statusKey = doorStatusKey(req.params.id);
const fingerprint = (req as any).fingerprint;
const timeout = getDoorSettingNumber(req.params.id, IDoorConfig.OPEN_TIMEOUT) || 60;
await client.put(statusKey, JSON.stringify(fingerprint));
await client.getClient().expire(statusKey, timeout);
res.status(200).json({ msg: `Opened the door "${req.params.id}" for ${timeout}s` });
});
export default router;

View File

@ -1,34 +0,0 @@
import express from "express";
import DoorRouter from "./routers/DoorRouter";
import { initializeRandomDoorPins } from "./middlewares/DoorAuthModes";
import path from "path";
const Fingerprint = require('express-fingerprint');
const app = express();
app.set('trust proxy', 1);
app.use(Fingerprint({
parameters: [
Fingerprint.useragent,
Fingerprint.geoip
]
}));
app.use((req, res, next) => {
(req as any).fingerprint.ip = req.ip;
next();
});
app.use(express.json());
app.use(express.static("dist"));
app.use('/api/door', DoorRouter);
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "..", "dist", "index.html"));
});
app.listen(5000, async () => {
console.log("listening on port 5000");
initializeRandomDoorPins();
});

View File

@ -1,4 +0,0 @@
export interface IAccessControl {
key: string;
remainingAccess?: number;
}

View File

@ -1,7 +0,0 @@
export default interface IAuthCallbackQuery {
action: string;
tag: string;
k1: string;
sig: string;
key: string
}

View File

@ -1,4 +0,0 @@
export enum IAuthMode {
FIXED_PIN = "FIXED_PIN",
RANDOM_ROTATING_KEY = "RANDOM_ROTATING_KEY",
}

View File

@ -1,7 +0,0 @@
export enum IDoorConfig {
AUTH_MODES = "AUTH_MODES",
OPEN_TIMEOUT = "OPEN_TIMEOUT",
FIXED_PIN = "FIXED_PIN",
CLOSE_AFTER_POLL="CLOSE_AFTER_POLL",
ALWAYS_LOCKED_TIME="ALWAYS_LOCKED_TIME",
}

View File

@ -1,5 +0,0 @@
export interface IDoorResponse {
id: string,
timeout: number;
buzzerCode: string;
};

View File

@ -1,5 +0,0 @@
export enum IDoorStatus {
OPEN="OPEN",
CLOSED="CLOSED",
TIME_LOCK="TIME_LOCK"
}

View File

@ -1,16 +0,0 @@
export enum RedisKeys {
CHALLENGES = "challenges",
DOORS = "doors"
}
export function doorStatusKey(id: string) {
return concatKeys(RedisKeys.DOORS, id, 'open');
}
export function doorRotatingKey(id: string) {
return concatKeys(RedisKeys.DOORS, id, 'rotatingKey');
}
export function concatKeys(...keys: String[]) {
return keys.join(':');
}

View File

@ -1,55 +0,0 @@
import { IAuthMode } from "../types/IAuthMode";
import { IDoorConfig } from "../types/IDoorConfig";
export const getEnv = (key: string): string => {
return process.env[key] || "";
}
const doorToEnv = (door: string): string => {
return door.toUpperCase().replaceAll(' ', '_').replaceAll('-', '_');
};
export const getAuthModes = (door: string): IAuthMode[] => {
const config = getDoorSettingString(door, IDoorConfig.AUTH_MODES);
if (config) {
return JSON.parse(config);
}
return [];
};
export const getDoorSettingString = (door: string, setting: IDoorConfig): string | undefined => {
return getEnv(`${setting}_${doorToEnv(door)}`);
};
export const getDoorSettingNumber = (door: string, setting: IDoorConfig): number => {
return parseInt(getDoorSettingString(door, setting) || "0");
};
export const getDoorSettingTimeLock = (door: string): number[] => {
const config = getDoorSettingString(door, IDoorConfig.ALWAYS_LOCKED_TIME);
if (config) {
try {
return config.split(',').map(n => parseInt(n));
} catch (e) {
console.warn(`Config ${IDoorConfig.ALWAYS_LOCKED_TIME} for door ${door} is invalid`);
}
}
// never locked (always -1 < hr < 25)
return [25, -1];
}
export const getAllDoorNames = (): string[] => {
const names: string[] = [];
Object.keys(process.env).forEach(key => {
if (key.startsWith(IDoorConfig.AUTH_MODES)) {
names.push(key.replace(IDoorConfig.AUTH_MODES + "_", "").toLowerCase());
}
});
return names;
}

View File

@ -1,14 +0,0 @@
import rateLimit from "express-rate-limit";
export const uploadDownloadLimiter = rateLimit({
windowMs: 5 * 60 * 1000,
max: 5,
skipFailedRequests: true,
});
export const challengeLimiter = rateLimit({
windowMs: 5 * 60 * 1000,
max: 10,
skipFailedRequests: true,
});

View File

@ -1,28 +0,0 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"outDir": "build",
// Bundler mode
"moduleResolution": "bundler",
// "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": false,
// Best practices
"strict": false,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// // Some stricter flags
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noPropertyAccessFromIndexSignature": true
}
}