From fb7460cf1293220a00e4734b5c0ac46be92beac9 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 02:56:02 +0100 Subject: [PATCH 01/15] Extracting the logSend function into a general utility class Going to be extracting out a lot of functionality to slim this down --- src/index.ts | 16 +--------------- src/utils.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 src/utils.ts diff --git a/src/index.ts b/src/index.ts index 2355192..c24b9b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,21 +140,7 @@ function x32Checkin() { x32Devices.forEach(({socket, ip, port}) => socket.send(transmit, port, ip, logSend(ip, port))); } -/** - * Returns a callback function handling errors as the first parameter. In the event error is non-null it will log - * the address it failed to forward to and error which raised the error - * @param address the address to which this send is being made - * @param port the port to which this send is being made - */ -function logSend(address: string, port: number) { - return (error: Error | null) => { - if (error) { - console.error(`Failed to forward to address ${address}:${port}`); - console.error(error); - return; - } - } -} + /** * Directly forwards the message parameter to all ip address and port combinations defined in {@link reflectorTargets}. diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..59f37e5 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,15 @@ +/** + * Returns a callback function handling errors as the first parameter. In the event error is non-null it will log + * the address it failed to forward to and error which raised the error + * @param address the address to which this send is being made + * @param port the port to which this send is being made + */ +export function logSend(address: string, port: number) { + return (error: Error | null) => { + if (error) { + console.error(`Failed to forward to address ${address}:${port}`); + console.error(error); + return; + } + } +} \ No newline at end of file From c6598825184fda054e0af61c58954f1a8a04398b Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 02:56:34 +0100 Subject: [PATCH 02/15] Extracting out configuration information to its own class --- src/config/configuration.ts | 92 +++++++++++++++++++++++++++++++++++++ src/index.ts | 85 +--------------------------------- 2 files changed, 94 insertions(+), 83 deletions(-) create mode 100644 src/config/configuration.ts diff --git a/src/config/configuration.ts b/src/config/configuration.ts new file mode 100644 index 0000000..dfb785c --- /dev/null +++ b/src/config/configuration.ts @@ -0,0 +1,92 @@ +import os from "os"; +import path from "path"; +import * as zod from "zod"; +import {Socket} from "dgram"; +import {promises as fsp} from "fs"; +import fs from "fs"; + +/** + * HTML main site template loaded from file. Content is cached so program will have to be restarted to pick up new + * changes in the file + */ +export const TEMPLATE = fs.readFileSync('res/index.html', {encoding: 'utf8'}); + +/** + * The valid locations for a configuration on the machine. The linux based path is removed if the platform is not + * identified as linux + */ +const CONFIG_PATHS: string[] = [ + os.platform() === 'linux' ? '/etc/ents/x32-reflector.json' : undefined, + os.platform() === 'linux' ? path.join('~', '.x32-reflector.config.json') : undefined, + path.join(__dirname, '..', '..', 'config', 'config.json'), +].filter((e) => e !== undefined) as string[]; + +const X32_INSTANCE_VALIDATOR = zod.object({ + name: zod.string(), + ip: zod.string(), + port: zod.number(), +}); +/** + * A unique instance of X32 connections + */ +export type X32Instance = zod.infer; +export type X32InstanceWithSocket = X32Instance & { socket: Socket }; +/** + * The validator for the configuration which contains udp and http bind and listen ports as well as timeouts for pairs + */ +const CONFIG_VALIDATOR = zod.object({ + udp: zod.object({ + bind: zod.string(), + }), + http: zod.object({ + bind: zod.string(), + port: zod.number(), + }), + x32: X32_INSTANCE_VALIDATOR.or(zod.array(X32_INSTANCE_VALIDATOR)), + timeout: zod.number(), + siteRoot: zod.string().regex(/\/$/, {message: 'Path must end in a /'}).default('/'), +}); +export type Configuration = zod.infer; + + +/** + * Attempts to load the configuration from disk and return it if one is found as a safely parsed object. If no config + * can be loaded it will throw an error + * @param paths custom set of locations to test for configuration files, defaults to {@link CONFIG_PATHS} + */ +export async function loadConfiguration(paths: string[] = CONFIG_PATHS): Promise { + for (const file of paths) { + console.log(`[config]: trying to load config from path ${file}`); + let content; + + // Try and read file from disk + try { + content = await fsp.readFile(file, {encoding: 'utf8'}); + } catch (e) { + console.warn(`[config]: could not load configuration file ${file} due to an error: ${e}`) + continue; + } + + // Parse it as JSON and fail it out if its not + try { + content = JSON.parse(content); + } catch (e) { + console.warn(`[config]: Failed to load the JSON data at path ${file} due to error: ${e}`); + continue; + } + + // Try and parse it as a config file and reject if the file was not valid with the zod errors7 + // Try and be as helpful with the output as possible + let safeParse = CONFIG_VALIDATOR.safeParse(content); + if (!safeParse.success) { + const reasons = safeParse.error.message + safeParse.error.errors.map((e) => `${e.message} (@ ${e.path.join('.')}`).join(', '); + console.warn(`[config]: content in ${file} is not valid: ${reasons}`); + continue; + } + + console.log(`[config]: config loaded from ${file}`); + return safeParse.data; + } + + throw new Error(`No valid configuration found, scanned: ${CONFIG_PATHS.join(', ')}`); +} diff --git a/src/index.ts b/src/index.ts index c24b9b0..6486024 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,55 +7,14 @@ import {constants} from "http2"; import path from "path"; import * as os from "os"; import * as zod from 'zod'; - -/** - * The valid locations for a configuration on the machine. The linux based path is removed if the platform is not - * identified as linux - */ -const CONFIG_PATHS: string[] = [ - os.platform() === 'linux' ? '/etc/ents/x32-reflector.json' : undefined, - os.platform() === 'linux' ? path.join('~', '.x32-reflector.config.json') : undefined, - path.join(__dirname, '..', 'config', 'config.json'), -].filter((e) => e !== undefined) as string[]; - -const X32_INSTANCE_VALIDATOR = zod.object({ - name: zod.string().optional(), - ip: zod.string(), - port: zod.number(), -}); -/** - * A unique instance of X32 connections - */ -type X32Instance = zod.infer; - -/** - * The validator for the configuration which contains udp and http bind and listen ports as well as timeouts for pairs - */ -const CONFIG_VALIDATOR = zod.object({ - udp: zod.object({ - bind: zod.string(), - }), - http: zod.object({ - bind: zod.string(), - port: zod.number(), - }), - x32: X32_INSTANCE_VALIDATOR.or(zod.array(X32_INSTANCE_VALIDATOR)), - timeout: zod.number(), - siteRoot: zod.string().regex(/\/$/, {message: 'Path must end in a /'}).default('/'), -}); -type Configuration = zod.infer; +import {Configuration, loadConfiguration, TEMPLATE, X32Instance} from "./config/configuration"; +import {logSend} from "./utils"; /** * The root of the site to be used for url parsing and url generation */ let siteRoot = '/'; -/** - * HTML main site template loaded from file. Content is cached so program will have to be restarted to pick up new - * changes in the file - */ -const TEMPLATE = fs.readFileSync('res/index.html', {encoding: 'utf8'}); - /** * The currently loaded configuration */ @@ -87,46 +46,6 @@ const reflectorKeyToHuman = (id: string) => { } const genericDeviceToHuman = (name: string|undefined, ip: string, port: string) => (name ?? '').length === 0 ? `${ip}:${port}` : `${name} (${ip}:${port})`; -/** - * Attempts to load the configuration from disk and return it if one is found as a safely parsed object. If no config - * can be loaded it will throw an error - */ -async function loadConfiguration(): Promise { - for (const file of CONFIG_PATHS) { - let content; - - // Try and read file from disk - try { - content = await fsp.readFile(file, {encoding: 'utf8'}); - } catch (e) { - console.warn(`Could not load configuration file ${file} due to an error: ${e}`) - continue; - } - - // Parse it as JSON and fail it out if its not - try { - content = JSON.parse(content); - } catch (e) { - console.warn(`Failed to load the JSON data at path ${file} due to error: ${e}`); - continue; - } - - // Try and parse it as a config file and reject if the file was not valid with the zod errors7 - // Try and be as helpful with the output as possible - let safeParse = CONFIG_VALIDATOR.safeParse(content); - if (!safeParse.success) { - const reasons = safeParse.error.message + safeParse.error.errors.map((e) => `${e.message} (@ ${e.path.join('.')}`).join(', '); - console.warn(`Content in ${file} is not valid: ${reasons}`); - continue; - } - - console.log(`Config loaded from ${file}`); - return safeParse.data; - } - - throw new Error(`No valid configuration found, scanned: ${CONFIG_PATHS.join(', ')}`); -} - /** * Transmits an osc '/xremote' packet to all x32 devices using the socket stored in {@link x32Devices}. This should * trigger x32 to begin sending all updates to the clients. From a9d7c7fd5168ef7fe6edf3fb78112ddde0652f82 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 02:57:39 +0100 Subject: [PATCH 03/15] Creating a basic HTTP router for GET/POST requests This is just to avoid a new dependency via express and handles some pre-processing for me by default. Not the prettiest but its also not the worst thing I've ever made --- src/micro-router.ts | 223 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/micro-router.ts diff --git a/src/micro-router.ts b/src/micro-router.ts new file mode 100644 index 0000000..df64f74 --- /dev/null +++ b/src/micro-router.ts @@ -0,0 +1,223 @@ +import {IncomingMessage, ServerResponse} from "http"; +import {constants} from "http2"; + +const DEBUG_LOG = true; +const d = (message: string) => DEBUG_LOG && console.log(`[router|DEBUG]: ${message}`); + +export type Method = 'GET' | 'POST'; + +type BaseRoute = { + path: string | RegExp; + fail?: (res: ServerResponse, code: number, error: string) => void; +} + +type KeyBasedValidator = { + name: string; + validator: (value: string) => boolean | PromiseLike | Promise; + error: string; + required: boolean; +} + +export type GetRoute = BaseRoute & ({ + handle: (path: string, query: URLSearchParams, res: ServerResponse, req: IncomingMessage) => void | Promise | PromiseLike; + parseQuery: true; + queryValidator: KeyBasedValidator[], +} | { + handle: (path: string, query: URLSearchParams, res: ServerResponse, req: IncomingMessage) => void | Promise | PromiseLike; + parseQuery: false; +}); + +type PostRoute = BaseRoute & { + handle: (path: string, query: URLSearchParams, body: string, res: ServerResponse, req: IncomingMessage) => void | Promise | PromiseLike; +} & ({ + parseBody: 'json'; + validator: KeyBasedValidator[], +} | { + parseBody: 'text'; + validator: (body: string) => (boolean | Error) | PromiseLike | Promise, +}) + +type Route = GetRoute | PostRoute; +type InternalRoute = Route & { method: Method }; + +export const fail = (res: ServerResponse, error: string) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { + Location: `/?error=${encodeURIComponent(error)}` +}).end(); +export const succeed = (res: ServerResponse) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { + Location: `/` +}).end(); + +export default class MicroRouter { + + private _methodsInUse: Set; + private _routes: InternalRoute[]; + + constructor() { + this._routes = []; + this._methodsInUse = new Set(); + } + + get(route: GetRoute): void { + // @ts-ignore - eeeeeh its right but complicated? TODO? + const r: InternalRoute = Object.assign(route, {method: 'GET'}); + this._routes.push(r); + this._methodsInUse.add("GET"); + + d(`added a new route for GET ${route.path}`); + } + + post(route: Omit): void { + // @ts-ignore - eeeeeh its right but complicated? TODO? + const r: InternalRoute = Object.assign(route, {method: 'POST'}); + this._routes.push(r); + this._methodsInUse.add("POST"); + + d(`added a new route for POST ${route.path}`); + } + + async incoming(req: IncomingMessage, res: ServerResponse, next: () => void) { + d(`got an incoming request for ${req.method} ${req.url}`); + // Ensure method is valid + if (req.method === undefined || !this._methodsInUse.has(req.method)) { + d(`method was not specified or the method was unknown: ${req.method}`); + next(); + return; + } + + if (req.url === undefined) { + d(`url was not specified ${req.url}`); + next(); + return; + } + + const urlInit = req.url; + + const {pathname, searchParams} = new URL(urlInit, `http://${req.headers.host}`); + + // Then locate matching record + const route = this._routes.find((route) => + route.method === req.method + && (typeof (route.path) === 'string' ? route.path === pathname : route.path.test(pathname)) + ); + + d(`identified route: ${route?.path}`) + + if (route === undefined) { + d('no route was identified'); + next(); + return; + } + + if (route.method === 'GET') { + await MicroRouter.incomingGet(pathname, route as GetRoute, searchParams, req, res); + } else if (route.method === 'POST') { + await MicroRouter.incomingPost(pathname, route as PostRoute, searchParams, req, res); + } + } + + private static close(res: ServerResponse, status: number, message: string) { + res.statusCode = status; + res.write(message); + res.end(); + } + + private static async incomingGet(pathname: string, route: GetRoute, query: URLSearchParams, req: IncomingMessage, res: ServerResponse) { + if (route.parseQuery) { + for (let keyBasedValidator of route.queryValidator) { + const entry = query.get(keyBasedValidator.name); + if (entry === undefined || entry === null) { + if (keyBasedValidator.required) { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + `query parameter ${keyBasedValidator.name} is required`, + ); + return; + } + } else { + if (!(await keyBasedValidator.validator(entry))) { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + keyBasedValidator.error, + ); + return; + } + } + } + } + + route.handle(pathname, query, res, req); + } + + private static async incomingPost(pathname: string, route: PostRoute, query: URLSearchParams, req: IncomingMessage, res: ServerResponse) { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (d) => body += d); + req.on('close', async () => { + if (route.parseBody === 'text') { + if (!await route.validator(body)) { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + 'invalid body', + ); + return; + } + } else if (route.parseBody === 'json') { + let data: any; + try { + data = JSON.parse(body); + } catch (e) { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + 'body json was invalid', + ); + return; + } + + if (typeof (data) !== 'object') { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + 'body json must be an object', + ); + return; + } + + for (let keyBasedValidator of data) { + const entry = data[keyBasedValidator.name]; + if (entry === undefined || entry === null) { + if (keyBasedValidator.required) { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + `body key ${keyBasedValidator.name} is required`, + ); + return; + } + } else { + if (!(await keyBasedValidator.validator(entry))) { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + keyBasedValidator.error, + ); + return; + } + } + } + + } + + route.handle( + pathname, + query, + body, + res, + req, + ); + }); + } +} \ No newline at end of file From c240c99462212f8f0c4709d1bc614c169bcbb6c2 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 02:58:53 +0100 Subject: [PATCH 04/15] Migrating a lot of the index functionality into a state manager This centralises all the interaction with x32 into a single class with methods to interact with them. Uses some hacky stuff for error handling which may not be necessary but should roughly handle everything it needs to do. Logging has been included throughout --- src/state/state.ts | 240 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 src/state/state.ts diff --git a/src/state/state.ts b/src/state/state.ts new file mode 100644 index 0000000..20cb8d3 --- /dev/null +++ b/src/state/state.ts @@ -0,0 +1,240 @@ +import {writePacket} from "osc"; +import {createSocket, Socket} from "dgram"; +import {Configuration, loadConfiguration, X32Instance, X32InstanceWithSocket} from "../config/configuration"; +import {logSend} from "../utils"; + +type CreateSocketFunction = typeof createSocket; + +/** + * A promisified version of {@link Socket#bind}. This will resolve with the socket or reject with an + * error if one is raised. + * @param socket the socket on which this should be called + * @param port the port on which this socket should bind or undefined if the OS should select it + * @param address the address on which the socket should bind + * @return a promise containing the socket + */ +function promisifyBind(socket: Socket, port?: number, address?: string) { + return new Promise((resolve, reject) => { + // Going to use a roundabout error handling method here to make a cancellable error handler + // Generate a random event name unique to this socket (not actually necessary but why not) + const event = `a${Math.round(Math.random() * 1000)}`; + + // Redirect all error events through to this custom event. This can keep running forever + // without impact to any other services + socket.on('error', (err) => socket.emit(event, err)); + + // Then listen for one error on this event. This should only be emitted by `bind` because + // that is all that is happening right now. We use null as a special object to mean 'clear' + // so it does nothing. If an error is emitted by bind, we reject and that will remove the listener + // as its just in a once call + socket.once(event, (err: Error | null) => { + if (err === null) return; + reject(err); + }); + + socket.bind(port, address, () => { + // If we reach this point the bind was successful so we can clear our error handler to + // make sure we don't receive an unrelated error in the future which will try and reject + // this promise that is already resolved. This will trigger the `once` handler above + // but the null is handled to be suppressed + socket.emit(event, null); + + // Once this is done, just resolve it and everything should be cleaned up + resolve(socket); + }); + }); +} + +export type Target = { + ip: string, + port: number, + checkin: number, +}; + +export class State { + + private _config: Configuration; + private _targets: Record; + private _interval: NodeJS.Timeout | undefined; + private _devices: X32InstanceWithSocket[]; + private readonly _create: CreateSocketFunction; + + public static async makeState(create: CreateSocketFunction = createSocket) { + return new State(await loadConfiguration(), create); + } + + constructor(config: Configuration, create: CreateSocketFunction = createSocket) { + this._create = create; + this._config = config; + this._targets = {}; + this._devices = []; + + this.checkin = this.checkin.bind(this); + this.receive = this.receive.bind(this); + this.cleanup = this.cleanup.bind(this); + + if (Array.isArray(this._config.x32)) { + this._config.x32.forEach((device) => this.register(device)); + } else { + void this.register(this._config.x32); + } + } + + /** + * Sends a check in message to all devices contained in {@link _devices}. This is an OSC packet to `/xremote` + * triggering the x32 device to start sending all updates to this server. + * @private + */ + private checkin(): void { + const array = writePacket({ + address: '/xremote', + }); + const transmit = Buffer.from(array); + + this._devices.forEach( + ({socket, ip, port}) => socket.send( + transmit, + port, + ip, + logSend(ip, port), + ) + ); + } + + /** + * Begins sending check in messages every 9 seconds to every device contained in {@link _devices}. This sends the + * messages using {@link checkin}. Will raise an error if checkins have already been started + */ + public launch() { + if (this._interval !== undefined) throw new Error('Interval has already been launched'); + console.log('[state]: launching'); + this._interval = setInterval(() => { + this.checkin(); + this.cleanup(); + }, 9000); + } + + /** + * Stops sending check in messages every 9 seconds to all devices. This will clear the interval allowing + * {@link launch} to be called again. + */ + public stop() { + if (this._interval === undefined) throw new Error('Interval has not been set, have you called launch()'); + console.log('[state]: stopping'); + clearInterval(this._interval); + this._interval = undefined; + } + + /** + * Registers a new device and attempts to form a new socket, binding the socket with a random port. This will + * insert it into the currently running devices which will immediately be included in broadcasts by {@link checkin}. + * @param device the device to register + */ + public async register(device: X32Instance): Promise { + const socket = this._create({type: 'udp4'}); + const entity = {...device, socket}; + + await promisifyBind(socket, undefined, this._config.udp.bind); + socket.on('message', (m) => this.receive(entity, m)) + + console.log(`[state]: registered x32 device at ${device.ip}:${device.port} under ${device.name}`) + + this._devices.push(entity); + this._targets[device.name] = []; + } + + /** + * Sets up a new redirection from an x32 device identified by the name `from` and send all data to the target + * ip address (`to`) and port (`port`). This should take effect immediately. This will raise an exception if the + * device does not exist + * @param from the source device name + * @param to the redirect target IP address + * @param port the redirect target port + */ + public redirect(from: string, to: string, port: number) { + if (this._targets[from] === undefined) throw new Error('Unknown device'); + this._targets[from].push({ + ip: to, + port, + checkin: Date.now(), + }); + + console.log(`[state]: changes from ${from} are being redirected to ${to}:${port}`); + } + + /** + * Removes a redirection from the system. Will raise an error if the device or or target is not present + * @param from the source device name + * @param to the redirect target IP address + * @param port the redirect target port + */ + public unredirect(from: string, to: string, port: number) { + if (this._targets[from] === undefined) throw new Error('Unknown device'); + const before = this._targets[from].length; + this._targets[from] = this._targets[from].filter((redirect) => !(redirect.ip === to && redirect.port === port)); + if (this._targets[from].length === before) throw new Error('Unknown redirect target'); + console.log(`[state]: changes from ${from} are no longer being redirected to ${to}:${port}`); + } + + /** + * Attempts to renew the given client to not be removed by the automatic cleanup + * @param from the source device name + * @param to the redirect target IP address + * @param port the redirect target port + */ + public renew(from: string, to: string, port: number) { + if (this._targets[from] === undefined) throw new Error('Unknown device'); + let changed = false; + this._targets[from].forEach((entity) => { + if (entity.ip === to && entity.port === port) { + entity.checkin = Date.now(); + changed = true; + } + }); + if (!changed) throw new Error('Unknown redirect target'); + console.log(`[state]: ${to}:${port} has renewed itself against ${from}`); + } + + /** + * On receive of a message the x32 device in the first argument with the message buffer. This will send it to all + * of the registered redirect clients. + * @param source the source from which the message was received, with socket for sending + * @param message the message received from the x32 device + * @private + */ + private receive(source: X32InstanceWithSocket, message: Buffer) { + if (this._targets[source.name] === undefined) return; + this._targets[source.name].forEach((entity) => { + source.socket.send(message, entity.port, entity.ip, logSend(entity.ip, entity.port)); + }); + } + + /** + * Removes all reflector targets that have not checked in within a reasonable amount of time + * @private + */ + private cleanup() { + for (const key of this.devices) { + this._targets[key.name] = this._targets[key.name].filter(({checkin}) => { + return Date.now() - checkin < this._config.timeout * 60000; + }); + } + } + + get configuration() { + return this._config; + } + + get devices(): X32Instance[] { + return this._devices.map((e) => ({ + name: e.name, + ip: e.ip, + port: e.port, + })); + } + + public clients(device: string): Target[] { + if (this._targets[device] === undefined) throw new Error('Unknown device'); + return this._targets[device]; + } +} \ No newline at end of file From d545134210639d99afb3bd1df78a71cbdf65ae96 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 02:59:37 +0100 Subject: [PATCH 05/15] Creating a series of basic aliasing routes These routes are just aliases around a simple state function call. Everything is routed and parameters are checked through the micro router --- src/routes/register.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ src/routes/remove.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ src/routes/renew.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/routes/register.ts create mode 100644 src/routes/remove.ts create mode 100644 src/routes/renew.ts diff --git a/src/routes/register.ts b/src/routes/register.ts new file mode 100644 index 0000000..59e8f8b --- /dev/null +++ b/src/routes/register.ts @@ -0,0 +1,49 @@ +import MicroRouter, {fail, succeed} from "../micro-router"; +import {IncomingMessage, ServerResponse} from "http"; +import {State} from "../state/state"; + +const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; + +function handle( + state: State, + req: IncomingMessage, + res: ServerResponse, + ip: string, + port: number, + device: string, +) { + try { + state.redirect( + device, + ip, + port, + ); + + succeed(res); + } catch (e: any) { + fail(res, e.message); + } +} + +export default function (state: State, router: MicroRouter) { + router.get({ + path: /^\/register$/i, + parseQuery: true, + queryValidator: [ + {name: 'ip', required: true, error: 'IP must be a valid IP address', validator: (t) => IP_REGEX.test(t)}, + {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, + {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, + ], + fail: (res, code, error) => fail(res, error), + handle: ((path, query, res, req) => { + handle( + state, + req, + res, + query.get('ip') as string, + Number(query.get('port')), + query.get('device') as string, + ); + }), + }); +} \ No newline at end of file diff --git a/src/routes/remove.ts b/src/routes/remove.ts new file mode 100644 index 0000000..6f691a5 --- /dev/null +++ b/src/routes/remove.ts @@ -0,0 +1,49 @@ +import MicroRouter, {fail, succeed} from "../micro-router"; +import {IncomingMessage, ServerResponse} from "http"; +import {State} from "../state/state"; + +const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; + +function handle( + state: State, + req: IncomingMessage, + res: ServerResponse, + ip: string, + port: number, + device: string, +) { + try { + state.unredirect( + device, + ip, + port, + ); + + succeed(res); + } catch (e: any) { + fail(res, e.message); + } +} + +export default function (state: State, router: MicroRouter) { + router.get({ + path: /^\/remove$/i, + parseQuery: true, + queryValidator: [ + {name: 'ip', required: true, error: 'IP must be a valid IP address', validator: (t) => IP_REGEX.test(t)}, + {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, + {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, + ], + fail: (res, code, error) => fail(res, error), + handle: ((path, query, res, req) => { + handle( + state, + req, + res, + query.get('ip') as string, + Number(query.get('port')), + query.get('device') as string, + ); + }), + }); +} \ No newline at end of file diff --git a/src/routes/renew.ts b/src/routes/renew.ts new file mode 100644 index 0000000..45dbc7b --- /dev/null +++ b/src/routes/renew.ts @@ -0,0 +1,49 @@ +import MicroRouter, {fail, succeed} from "../micro-router"; +import {IncomingMessage, ServerResponse} from "http"; +import {State} from "../state/state"; + +const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; + +function handle( + state: State, + req: IncomingMessage, + res: ServerResponse, + ip: string, + port: number, + device: string, +) { + try { + state.renew( + device, + ip, + port, + ); + + succeed(res); + } catch (e: any) { + fail(res, e.message); + } +} + +export default function (state: State, router: MicroRouter) { + router.get({ + path: /^\/renew$/i, + parseQuery: true, + queryValidator: [ + {name: 'ip', required: true, error: 'IP must be a valid IP address', validator: (t) => IP_REGEX.test(t)}, + {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, + {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, + ], + fail: (res, code, error) => fail(res, error), + handle: ((path, query, res, req) => { + handle( + state, + req, + res, + query.get('ip') as string, + Number(query.get('port')), + query.get('device') as string, + ); + }), + }); +} \ No newline at end of file From 395606293d2e534068d98766ff937c390f0f835c Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 02:59:59 +0100 Subject: [PATCH 06/15] Rewriting the index page - a lot cleaner imo Slimmed down and extracted out of the index --- src/routes/index.ts | 84 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/routes/index.ts diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..cdb9f7a --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,84 @@ +import MicroRouter, {fail, succeed} from "../micro-router"; +import {IncomingMessage, ServerResponse} from "http"; +import {State} from "../state/state"; +import {constants} from "http2"; +import {TEMPLATE, X32Instance} from "../config/configuration"; + +function makeTable(state: State, device: X32Instance) { + const clients = state.clients(device.name); + const timeout = state.configuration.timeout * 60000; + const tableRows = clients.map(({ip, port, checkin}) => ` + + ${ip} + ${port} + + Delete + + + ${(timeout - (Date.now() - checkin)) / 1000} + + + Renew + + `).join(''); + + return `

Instance ${device.name} (${device.ip}:${device.port})

+ + + + + + + + + ${tableRows} +
IP AddressPortTime Remaining (s)
`; +} + +function handle( + state: State, + req: IncomingMessage, + res: ServerResponse, + error?: string, +) { + console.log('handle called'); + + const devices = state.devices.map((device) => + `` + ).join(''); + + const tables = state.devices + .map((e) => makeTable(state, e)) + .join('
'); + + const template = TEMPLATE + .replace('{{ERROR_INSERT}}', error ? `

${error}

` : '') + .replace('{{DEVICES}}', devices) + .replace('{{TABLE_INSERT}}', tables); + + res.writeHead(constants.HTTP_STATUS_OK, { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }) + .write(template) + + res.end(); +} + + +export default function (state: State, router: MicroRouter) { + router.get({ + path: /^\/?$/i, + parseQuery: false, + fail: (res, code, error) => fail(res, error), + handle: ((path, query, res, req) => { + handle( + state, + req, + res, + query.get('error') ?? undefined, + ); + }), + }); +} \ No newline at end of file From 888f167f622e262ea56b31ed7fd2080723456c33 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 03:01:34 +0100 Subject: [PATCH 07/15] Cleaning out the index to build the state and produce a clean launch This is much nicer and should make a much more maintainable system. Will be going through and adding some more comments soon and that should conclude the initial cleanup --- src/index.ts | 534 +++++---------------------------------------------- 1 file changed, 50 insertions(+), 484 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6486024..cc3d62c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,491 +1,57 @@ -import {createSocket, Socket} from 'dgram'; -import {writePacket} from "osc"; -import {createServer, IncomingMessage, ServerResponse} from "http"; -import * as fs from "fs"; -import {promises as fsp} from "fs"; -import {constants} from "http2"; -import path from "path"; -import * as os from "os"; -import * as zod from 'zod'; -import {Configuration, loadConfiguration, TEMPLATE, X32Instance} from "./config/configuration"; -import {logSend} from "./utils"; - -/** - * The root of the site to be used for url parsing and url generation - */ -let siteRoot = '/'; - -/** - * The currently loaded configuration - */ -let configuration: Configuration | undefined; - -/** - * The target to which incoming OSC packets should be resent. A tuple of ip and port - */ -let reflectorTargets: Record = {}; -/** - * The timer for the x32 checkin function, if started. Can be used to cancel the loop in the event the server needs - * to disconnect - */ -let x32CheckinInterval: NodeJS.Timeout | undefined; - -let x32Devices: (X32Instance & { socket: Socket })[] = []; - -/** - * Converts a device to a fixed ID which is unique to this instance - * @param device - */ -const deviceToID = (device: X32Instance) => `${device.name ?? ''}#${device.ip}#${device.port}`; - -const deviceToHuman = (device: X32Instance) => genericDeviceToHuman(device.name, device.ip, String(device.port)); -const reflectorKeyToHuman = (id: string) => { - let strings = id.split('#'); - if (strings.length !== 3) throw new Error('invalid number of indexes'); - return genericDeviceToHuman(strings[0], strings[1], strings[2]); -} -const genericDeviceToHuman = (name: string|undefined, ip: string, port: string) => (name ?? '').length === 0 ? `${ip}:${port}` : `${name} (${ip}:${port})`; - -/** - * Transmits an osc '/xremote' packet to all x32 devices using the socket stored in {@link x32Devices}. This should - * trigger x32 to begin sending all updates to the clients. - */ -function x32Checkin() { - const array = writePacket({ - address: '/xremote', - }); - const transmit = Buffer.from(array); - - x32Devices.forEach(({socket, ip, port}) => socket.send(transmit, port, ip, logSend(ip, port))); -} - - - -/** - * Directly forwards the message parameter to all ip address and port combinations defined in {@link reflectorTargets}. - * There is no additional parsing or manipulation of the packets - * @param device the device, including socket, to which the messages should be sent - */ -function onReceiveFromX32(device: (typeof x32Devices)[number]) { - return (message: Buffer) => { - reflectorTargets[deviceToID(device)].forEach(([address, port]) => device.socket - .send(message, port, address, logSend(address, port))); - }; -} +import {State} from "./state/state"; +import MicroRouter from "./micro-router"; +import register from "./routes/register"; +import {createServer, Server} from "http"; +import remove from "./routes/remove"; +import renew from "./routes/renew"; +import index from "./routes/index"; +import {Configuration} from "./config/configuration"; + +const makeServer = (router: MicroRouter, config: Configuration, timeout: number = 5000): Promise => { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + void router.incoming(req, res, () => { + res.statusCode = 404; + res.end(); + }); + }); -/** - * Attempts to find the x32 instance on the given IP and port. If found it will return its key in - * {@link reflectorTargets}. Otherwise it will redirect to home page with an error and return null. - * @param x32IP the ip address of the x32 instance being interacted with - * @param x32Port the port of the x32 instance being interacted with - * @param res the response to which the error should be written - */ -function findKeyOrFail(x32IP: string, x32Port: number, res: ServerResponse): string | null { - const key = Object.keys(reflectorTargets).find((e) => { - const [,i, p] = e.split('#'); - console.log(i, '===', x32IP, '&&', p, '===', String(x32Port)); - return i === x32IP && p === String(x32Port); + // Force a timeout in case something gos wrong with the listen command + let resolved = false; + const timeoutInterval = setTimeout(() => { + if (resolved) return; + reject(); + }, timeout); + + // Pass along errors before its resolved which should stem from listen. + server.once('error', (err) => { + if (resolved) return; + clearTimeout(timeoutInterval); + reject(err); + }) + + // Then listen and resolve when done + server.listen(config.http.port, config.http.bind, () => { + resolved = true; + clearTimeout(timeoutInterval); + + resolve(server); + }); }); - - if (key === undefined) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent('Unknown X32 IP and Port combination')}`, - }).end(); - return null; - } - - return key; } -/** - * HTTP handler - * - * Registers a new reflector target. This will add the ip and port combination to {@link reflectorTargets} and then - * return a 301 response redirecting the user back to the home page - * @param x32IP the ip address of the x32 instance being interacted with - * @param x32Port the port of the x32 instance being interacted with - * @param ip the ip address which should be added - * @param port the port which should be added - * @param res the http response to which the redirect response should be written - */ -function register(x32IP: string, x32Port: number, ip: string, port: number, res: ServerResponse) { - console.log('Trying client', x32IP, x32Port, ip, port); - const key = findKeyOrFail(x32IP, x32Port, res); - if (key === null) return; - - let find = reflectorTargets[key].find(([tIp, tPort]) => tIp === ip && tPort === port); - if (find !== undefined) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent('Device is already registered on this device')}`, - }).end(); - return; - } - - console.log('Added client'); +State.makeState() + .then((state) => { + state.launch(); - reflectorTargets[key].push([ip, port, Date.now()]); + const router = new MicroRouter(); + register(state, router); + remove(state, router); + renew(state, router); + index(state, router); - // Redirect back to index - res.writeHead(301, { - Location: siteRoot, - }).end(); -} - -/** - * Attempts to remove the given ip address port combination from the {@link reflectorTargets}. If the ip address and - * port are not present in the array, it will return a temporary redirect to the homepage with an error string as a - * query parameter. If there are more than one of the same ip port combinations then only one will be removed. If the - * remove is successful a temporary redirect to / without any error components will take place - * @param x32IP the ip address of the x32 instance being interacted with - * @param x32Port the port of the x32 instance being interacted with - * @param ip the ip address which should be removed - * @param port the port address should be removed - * @param res the response on which the response should be send. - */ -function remove(x32IP: string, x32Port: number, ip: string, port: number, res: ServerResponse) { - const key = findKeyOrFail(x32IP, x32Port, res); - if (key === null) return; - - const originalLength = reflectorTargets[key].length; - reflectorTargets[key] = reflectorTargets[key].filter(([tIp, tPort]) => tIp !== ip && tPort !== port); - - if (originalLength === reflectorTargets[key].length) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent('Unknown IP and Port combination')}`, - }).end(); - return; - } - - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: siteRoot, - }).end(); -} - -/** - * Returns the formatted index page to the response with the table of values and error messages substituted in. This - * uses the pre-loaded {@link TEMPLATE} so new changes to file will not be visible. - * @param error the error message if one is provided in the query parameters - * @param res the response to which the html should be written - */ -function index(error: string | null | undefined, res: ServerResponse) { - if (configuration === undefined) { - res.writeHead(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR).write('Configuration is not loaded'); - res.end(); - return; - } - - // Record the time in milliseconds records are allowed to exist - const timeout = configuration.timeout * 60000; - - let tables = []; - for (const [key, value] of Object.entries(reflectorTargets)) { - const [, xIP, xPort] = key.split('#'); - const xQuery = `&x32Port=${decodeURIComponent(xPort)}&x32IP=${encodeURIComponent(xIP)}`; - const tableRows = value.map(([ip, port, created]) => ` - - ${ip} - ${port} - - Delete - - - ${(timeout - (Date.now() - created)) / 1000} - - - Renew - - `).join(''); - - tables.push(`

Instance ${reflectorKeyToHuman(key)}

- - - - - - - - - ${tableRows} -
IP AddressPortTime Remaining (s)
`); - } - - const devices = x32Devices.map((device) => - `` - ).join(''); - - const template = TEMPLATE - .replace('{{ERROR_INSERT}}', error ? `

${error}

` : '') - .replace('{{DEVICES}}', devices) - .replace('{{TABLE_INSERT}}', tables.join('
')); - - res.writeHead(constants.HTTP_STATUS_OK, { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' + makeServer(router, state.configuration) + .then(() => console.log(`[http]: server has launched successfully on http://${state.configuration.http.bind}:${state.configuration.http.port}/`)) + .catch((err) => console.error(`[http]: server failed to launch due to error`, err)); }) - .write(template) - res.end(); -} - -/** - * Attempts to renew the first entry which matches the given ip and port. This will only update one. In th event the - * pairing is not found it will redirect to / with an error query parameter. On success it will redirect to / without - * an error component. This updates the final entry in the tuple to Date.now() to reset the clock on the timeout - * @param x32IP the ip address of the x32 instance being interacted with - * @param x32Port the port of the x32 instance being interacted with - * @param ip the ip address to query - * @param port the port to query - * @param res the response to which the redirects should be written - */ -function renew(x32IP: string, x32Port: number, ip: string, port: number, res: ServerResponse) { - const key = findKeyOrFail(x32IP, x32Port, res); - if (key === null) return; - - const find = reflectorTargets[key].find(([i, p]) => i === ip && p === port); - if (!find) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent('Unknown IP and Port combination')}`, - }).end(); - return; - } - - find[2] = Date.now(); - - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: siteRoot, - }).end(); -} - -/** - * Tries to fetch the parameter with name from the query. If it is not present or null it will return an error - * directly to the response and return null. Otherwise it returns the value - * @param query the query parameters to be searched - * @param name the name of the entry to try and fetch - * @param res the response on which the error should be written if needed - * @return the value or null if not present - */ -const get = (query: URLSearchParams, name: string, res: ServerResponse) => { - let data = query.get(name); - if (!query.has(name) || data === null) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent(`${name} not specified`)}` - }).end(); - return null; - } - - return data; -} - -/** - * Attempts to convert the data from the query parameter with the given name to a port by convering to number and - * bounds checking it. If it fails it will write an error directly to the response - * @param data the value loaded from params - * @param name the id of the param - * @param res the server response - * @return the cast port or null if it failed and an error was returned - */ -const convertToPort = (data: string, name: string, res: ServerResponse) => { - // Verify that port is numeric - let port: number; - try { - port = Number(data); - } catch (e) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent(`Invalid ${name} - not a number`)}` - }).end(); - return null; - } - - // Verify port is within valid range - if (port < 1 || port > 65535) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent('Invalid ${name} - out of range')}` - }).end(); - return null; - } - - return port; -} - -/** - * Performs Regex validation against the data to match it to an IP. If it fails it will write an error to the response - * and return null - * @param data the ip to test - * @param name the name of the query it was pulled from - * @param res the response on which the error should be written if needed - * @return null if not an ip or the value of data - */ -const convertToIP = (data: string, name: string, res: ServerResponse) => { - const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; - // Verify that IP address is of correct format - if (!IP_REGEX.test(data)) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent(`Invalid IP address ${name} - did not match regex`)}` - }).end(); - return null; - } - - return data; -} - -/** - * Attempts to parse the ip address and port from the query parameters and validate them before dispatching them to - * one of {@link register} or {@link remove} depending on the path. This will ensure that ip and port are present, that - * port is a number between 1 and 65535, and that ip address matches a basic regex. In the event of an error it will use - * a 301 code with an error query parameter - * @param path the path on which this request was received, used to direct the function to call with parameters - * @param query the query arguments in the URL which should be queried for the ip and port - * @param res the response to which any error should be written, and which should be passed to other functions. - */ -function tryParseAttributes(path: '/remove' | '/renew', query: URLSearchParams, res: ServerResponse) { - - // Verify that IP address is present - const ip = get(query, 'ip', res); - if (ip === null) return; - - // Verify that port is present - let num = get(query, 'port', res); - if (num === null) return; - - // Convert to number - const port = convertToPort(num, 'port', res); - if (port === null) return; - - // Verify that IP address is present - const x32Ip = get(query, 'x32IP', res); - if (x32Ip === null) return; - - // Verify that port is present - let x32PortRaw = get(query, 'x32Port', res); - if (x32PortRaw === null) return; - - // Convert to number - const x32Port = convertToPort(x32PortRaw, 'x32Port', res); - if (x32Port === null) return; - - if (convertToIP(ip, 'ip', res) === null || convertToIP(x32Ip, 'x32IP', res) === null) return; - - // Dispatch - if (path === '/remove') remove(x32Ip, x32Port, ip, port, res); - // else if (path === '/register') register(x32Ip, x32Port, ip, port, res); - else if (path === '/renew') renew(x32Ip, x32Port, ip, port, res); -} - -function registerHTTP(query: URLSearchParams, res: ServerResponse) { -// Verify that IP address is present - const ip = get(query, 'ip', res); - if (ip === null) return; - - // Verify that port is present - let num = get(query, 'port', res); - if (num === null) return; - - // Verify device was present - let device = get(query, 'device', res); - if (device === null) return; - - // Convert to number - const port = convertToPort(num, 'port', res); - if (port === null) return; - - if (convertToIP(ip, 'ip', res) === null) return; - - if (!/.+#[0-9]+/.test(device)) { - res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `${siteRoot}?error=${encodeURIComponent(`Invalid device - did not match regex`)}` - }).end(); - return null; - } - - const [xi, xp] = device.split('#'); - const xPort = convertToPort(xp, 'port', res); - if (xPort === null || convertToIP(xi, 'ip', res) === null) return; - - register(xi, xPort, ip, port, res); - return; -} - -/** - * Handle an incoming HTTP request. This will parse the {@link IncomingMessage#url} using {@link URL} using - * the host header. From this the {@link URL#pathname} is used to direct execution. In the event that no path matches - * it will return a 404 error - * @param req the original request from the http server - * @param res the response to which the output should be written - */ -function handleHTTP(req: IncomingMessage, res: ServerResponse) { - if (req.url === undefined) { - res.writeHead(constants.HTTP_STATUS_BAD_REQUEST) - .end(); - return; - } - - // noinspection HttpUrlsUsage - const parsed = new URL(req.url, `http://${req.headers.host}`); - const lowerPath = parsed.pathname.toLowerCase(); - switch (lowerPath) { - case '/register': - registerHTTP(parsed.searchParams, res); - break; - case '/remove': - case '/renew': - tryParseAttributes(lowerPath, parsed.searchParams, res); - break; - case '/': - index(parsed.searchParams ? parsed.searchParams.get('error') : undefined, res); - break; - default: - res.writeHead(constants.HTTP_STATUS_NOT_FOUND).end(); - } -} - -function cleanup(config: Configuration) { - return () => { - for (const key of Object.keys(reflectorTargets)) { - reflectorTargets[key] = reflectorTargets[key].filter(([, , created]) => { - return Date.now() - created < config.timeout * 60000; - }); - } - } -} - -loadConfiguration().then(async (config) => { - // Load in the ip and port from file to overwrite the default - siteRoot = config.siteRoot; - configuration = config; - // Construct a HTTP server and make it listen on all interfaces on port 1325 - const httpServer = createServer(handleHTTP); - httpServer.listen(config.http.port, config.http.bind); - - // Create a UDP socket and bind it to 1324. This will be used to send and receive from X32 as it requires a bound port - // Then bind the receive function and start checking in every 9 seconds. - // X32 requires check in every 10 seconds but to make sure that the clocks run over time and we miss parameters, use - // 9 seconds so its slightly more frequent than required. - const promises = []; - for (const instance of Array.isArray(config.x32) ? config.x32 : [config.x32]) { - const socket = createSocket({ - type: 'udp4', - }); - socket.on('error', console.error); - promises.push(new Promise((res, rej) => { - socket.once('error', () => rej()); - socket.bind(undefined, config.udp.bind, () => { - const mapping = {socket, ...instance}; - x32Devices.push(mapping); - reflectorTargets[deviceToID(instance)] = []; - - socket.on('message', onReceiveFromX32(mapping)); - - console.log(`X32 ${instance.ip}:${instance.port} is bound to ${config.udp.bind}:${socket.address().port}`); - res(); - }); - })) - } - - await Promise.all(promises); - - x32CheckinInterval = setInterval(x32Checkin, 9000); - x32Checkin(); - - // Cleanup old addresses - setInterval(cleanup(config), 60000); -}) + .catch((err) => console.error(`[root]: failed to launch due to an error initialising`, err)); \ No newline at end of file From 2e2a0a4e8c3c186154064b1e40805960e40e9002 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 03:02:28 +0100 Subject: [PATCH 08/15] Removing some dud logging --- src/routes/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/routes/index.ts b/src/routes/index.ts index cdb9f7a..b9e4606 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -41,8 +41,6 @@ function handle( res: ServerResponse, error?: string, ) { - console.log('handle called'); - const devices = state.devices.map((device) => `` ).join(''); From 8db8c1c1462147daf1b10b17e01a8a6a944181e6 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 03:03:14 +0100 Subject: [PATCH 09/15] Linking the debugging output into the environment variables --- src/micro-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micro-router.ts b/src/micro-router.ts index df64f74..f94f760 100644 --- a/src/micro-router.ts +++ b/src/micro-router.ts @@ -1,7 +1,7 @@ import {IncomingMessage, ServerResponse} from "http"; import {constants} from "http2"; -const DEBUG_LOG = true; +const DEBUG_LOG = (process.env.NODE_ENV ?? 'production') === 'dev'; const d = (message: string) => DEBUG_LOG && console.log(`[router|DEBUG]: ${message}`); export type Method = 'GET' | 'POST'; From 0e3e02d6828ee40298355a7960a64868a7d39508 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 03:44:17 +0100 Subject: [PATCH 10/15] Adding comments to everything missing --- src/config/configuration.ts | 9 ++ src/micro-router.ts | 211 ++++++++++++++++++++++++++++++++++-- src/routes/index.ts | 37 ++++++- src/state/state.ts | 55 +++++++++- 4 files changed, 298 insertions(+), 14 deletions(-) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index dfb785c..8352492 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -21,6 +21,9 @@ const CONFIG_PATHS: string[] = [ path.join(__dirname, '..', '..', 'config', 'config.json'), ].filter((e) => e !== undefined) as string[]; +/** + * Validator against the x32 instance entries in the configuration file + */ const X32_INSTANCE_VALIDATOR = zod.object({ name: zod.string(), ip: zod.string(), @@ -30,6 +33,9 @@ const X32_INSTANCE_VALIDATOR = zod.object({ * A unique instance of X32 connections */ export type X32Instance = zod.infer; +/** + * An x32 connection item along with the socket which is bound to it and its responses + */ export type X32InstanceWithSocket = X32Instance & { socket: Socket }; /** * The validator for the configuration which contains udp and http bind and listen ports as well as timeouts for pairs @@ -46,6 +52,9 @@ const CONFIG_VALIDATOR = zod.object({ timeout: zod.number(), siteRoot: zod.string().regex(/\/$/, {message: 'Path must end in a /'}).default('/'), }); +/** + * The derived type of the configuration from the validator + */ export type Configuration = zod.infer; diff --git a/src/micro-router.ts b/src/micro-router.ts index f94f760..9b7f5c8 100644 --- a/src/micro-router.ts +++ b/src/micro-router.ts @@ -1,55 +1,173 @@ import {IncomingMessage, ServerResponse} from "http"; import {constants} from "http2"; +/** + * If additional debugging messages should be logged for interactions with the HTTP server + */ const DEBUG_LOG = (process.env.NODE_ENV ?? 'production') === 'dev'; +/** + * An alias for debug logging which will only print a message if {@link DEBUG_LOG} is true + * @param message the message to log + */ const d = (message: string) => DEBUG_LOG && console.log(`[router|DEBUG]: ${message}`); +/** + * The supported HTTP method types as a type + */ export type Method = 'GET' | 'POST'; +/** + * The supported HTTP methods as an array + */ +const SUPPORTED_METHODS: Method[] = ['GET', 'POST']; +/** + * The type for a basic route containing properties that all routes will need. In this case it is a path which can be a + * string or a regex which will be applied to the pathname region of a URL. If a fail function is specified then it will + * be used in the event of an error instead of sending directly so custom processing can take place + */ type BaseRoute = { path: string | RegExp; fail?: (res: ServerResponse, code: number, error: string) => void; } +/** + * A key based validator is a check run against a property of an object or query parameter. + */ type KeyBasedValidator = { + /** + * The name of the property to which the validator will need to be applied + */ name: string; + /** + * The validation function which should do some processing against the loaded value. This will return true or false + * to whether the value is acceptable or reject in the event of a promise. + * @param value the value which should be processed + */ validator: (value: string) => boolean | PromiseLike | Promise; + /** + * The error message to use if the validator fails + */ error: string; + /** + * If the value is required to exist in the object + */ required: boolean; } +/** + * A get route, optionally supporting query validation + */ export type GetRoute = BaseRoute & ({ + /** + * A handler function accepting the query parameters and the route in use + * @param path the path on which this handle function was activated + * @param query the query parameters included in this request + * @param res the response to which results should be written + * @param req the request from which additional properties can be parsed + */ handle: (path: string, query: URLSearchParams, res: ServerResponse, req: IncomingMessage) => void | Promise | PromiseLike; + /** + * If the properties of the query parameters should be parsed + */ parseQuery: true; + /** + * The set of query validators that need to be applied to the query parameters + */ queryValidator: KeyBasedValidator[], } | { + /** + * A handler function accepting the query parameters and the route in use + * @param path the path on which this handle function was activated + * @param query the query parameters included in this request + * @param res the response to which results should be written + * @param req the request from which additional properties can be parsed + */ handle: (path: string, query: URLSearchParams, res: ServerResponse, req: IncomingMessage) => void | Promise | PromiseLike; + /** + * If the properties of the query parameters should be parsed + */ parseQuery: false; }); + +/** + * A post route, optionally supporting validation of the body in JSON or text formats + */ type PostRoute = BaseRoute & { + /** + * A handler function accepting the query parameters and the route in use and the body + * @param path the path on which this handle function was activated + * @param query the query parameters included in this request + * @param body the body of the request as a string, ready for processing + * @param res the response to which results should be written + * @param req the request from which additional properties can be parsed + */ handle: (path: string, query: URLSearchParams, body: string, res: ServerResponse, req: IncomingMessage) => void | Promise | PromiseLike; } & ({ + /** + * Specifies the body should be parsed as JSON and have a key based validator applied to it. This requires the JSON + * body to be an object. If you require any other types you will need to process it yourself through the text type + */ parseBody: 'json'; + /** + * The set of validators which should be applied to the JSON body object + */ validator: KeyBasedValidator[], } | { + /** + * Specifies the body should be parsed as raw text + */ parseBody: 'text'; + /** + * The validator function which should be applied to the string body + * @param body the body text + * @return if the body is valid + */ validator: (body: string) => (boolean | Error) | PromiseLike | Promise, }) +/** + * Joining of the supported route types + */ type Route = GetRoute | PostRoute; +/** + * The internally usable type which also attaches the method to the route + */ type InternalRoute = Route & { method: Method }; +/** + * A utility function which fails by redirecting the request with the given error to / with the error provided as a + * query parameter + * @param res the response on which results should be written + * @param error the error which should be included in the request + */ export const fail = (res: ServerResponse, error: string) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { Location: `/?error=${encodeURIComponent(error)}` }).end(); + +/** + * A utility function which succeeds a request by redirecting it to / without an error parameter + * @param res the response on which the redirect should be written + */ export const succeed = (res: ServerResponse) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { Location: `/` }).end(); +/** + * A basic (likely bugging) HTTP router supporting basic validation of requests before routing + */ export default class MicroRouter { + /** + * The set of methods that currently have routes registered. This will be used to quickly reject unsupported + * requests before sorting through the routers + * @private + */ private _methodsInUse: Set; + /** + * The set of routes which are currently registered on this router + * @private + */ private _routes: InternalRoute[]; constructor() { @@ -57,6 +175,11 @@ export default class MicroRouter { this._methodsInUse = new Set(); } + /** + * Registers a new get route. This will be placed at the bottom of the list of routes meaning any route that matches + * before it will be used instead + * @param route the route which should be registered + */ get(route: GetRoute): void { // @ts-ignore - eeeeeh its right but complicated? TODO? const r: InternalRoute = Object.assign(route, {method: 'GET'}); @@ -66,6 +189,11 @@ export default class MicroRouter { d(`added a new route for GET ${route.path}`); } + /** + * Registers a new post route. This will be placed at the bottom of the list of routes meaning any route that + * matches before it will be used instead + * @param route the route which should be registered + */ post(route: Omit): void { // @ts-ignore - eeeeeh its right but complicated? TODO? const r: InternalRoute = Object.assign(route, {method: 'POST'}); @@ -75,6 +203,12 @@ export default class MicroRouter { d(`added a new route for POST ${route.path}`); } + /** + * Handles an incoming message form the HTTP server + * @param req the request which was received + * @param res the response to which results should be written + * @param next the function which should be called when an error occurs or a route is not found + */ async incoming(req: IncomingMessage, res: ServerResponse, next: () => void) { d(`got an incoming request for ${req.method} ${req.url}`); // Ensure method is valid @@ -84,14 +218,24 @@ export default class MicroRouter { return; } + // And the URL is defined, not sure when this is not true but fail if its not if (req.url === undefined) { d(`url was not specified ${req.url}`); next(); return; } + // Reject requests immediately with an unsupported method + if (!SUPPORTED_METHODS.includes(req.method as any) || this._methodsInUse.has(req.method)) { + res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED).end(); + return; + } + + // Caching this value now its type is refined to a string. This will ensure its usable inside arrow functions const urlInit = req.url; + // Extract the query parameters and pathname from the url. We sub in the host here because URL will not parse it + // otherwise const {pathname, searchParams} = new URL(urlInit, `http://${req.headers.host}`); // Then locate matching record @@ -102,12 +246,14 @@ export default class MicroRouter { d(`identified route: ${route?.path}`) + // If none are found push it on to the next one if (route === undefined) { d('no route was identified'); next(); return; } + // And finally route it with its correct record if (route.method === 'GET') { await MicroRouter.incomingGet(pathname, route as GetRoute, searchParams, req, res); } else if (route.method === 'POST') { @@ -115,16 +261,37 @@ export default class MicroRouter { } } + /** + * Utility function to close a request with the given status code and message in a single line + * @param res the response which should be closed + * @param status the status code is should be closed with + * @param message the message which should be written in response + * @private + */ private static close(res: ServerResponse, status: number, message: string) { res.statusCode = status; res.write(message); res.end(); } + /** + * Handles an incoming get request being routed to the provided route. This will run any validators if they are + * present + * @param pathname the pathname on which this request was received + * @param route the route which matched it + * @param query the query parameters extracted + * @param req the original request + * @param res the output response + * @private + */ private static async incomingGet(pathname: string, route: GetRoute, query: URLSearchParams, req: IncomingMessage, res: ServerResponse) { if (route.parseQuery) { + // If the route needs the query validating, go through each validator and extract the key for (let keyBasedValidator of route.queryValidator) { const entry = query.get(keyBasedValidator.name); + + // If its not specified but required, immediately reject, optionally using the routes fail method if + // specified if (entry === undefined || entry === null) { if (keyBasedValidator.required) { (route.fail ?? MicroRouter.close)( @@ -133,30 +300,50 @@ export default class MicroRouter { `query parameter ${keyBasedValidator.name} is required`, ); return; + } else { + // Otherwise, continue because there is no more processing that can be done on this record + continue; } - } else { - if (!(await keyBasedValidator.validator(entry))) { - (route.fail ?? MicroRouter.close)( - res, - constants.HTTP_STATUS_BAD_REQUEST, - keyBasedValidator.error, - ); - return; - } + } + + // If the validator returns false, or rejects, send the failure message, optionally using the provided + // fail method + if (!(await Promise.resolve(keyBasedValidator.validator(entry)).catch(() => false))) { + (route.fail ?? MicroRouter.close)( + res, + constants.HTTP_STATUS_BAD_REQUEST, + keyBasedValidator.error, + ); + return; } } } + // Otherwise if all the validators passed (none of them returned out of the function) then handle it route.handle(pathname, query, res, req); } + /** + * Handles an incoming post request being routed to the provided route. This will run any validators if they are + * present + * @param pathname the pathname on which this request was received + * @param route the route which matched it + * @param query the query parameters extracted + * @param req the original request + * @param res the output response + * @private + */ private static async incomingPost(pathname: string, route: PostRoute, query: URLSearchParams, req: IncomingMessage, res: ServerResponse) { + // Load the body let body = ''; req.setEncoding('utf8'); req.on('data', (d) => body += d); - req.on('close', async () => { + + // When the data is done being read, run the validators against it + req.on('end', async () => { if (route.parseBody === 'text') { - if (!await route.validator(body)) { + // If a text validator is provided, just run it against the string and fail if it doesn't pass + if (!await Promise.resolve(route.validator(body)).catch(() => false)) { (route.fail ?? MicroRouter.close)( res, constants.HTTP_STATUS_BAD_REQUEST, @@ -165,6 +352,8 @@ export default class MicroRouter { return; } } else if (route.parseBody === 'json') { + // If it is JSON, try and parse it and then run the validators against it in the same way as the get + // function. Read that if there are issues let data: any; try { data = JSON.parse(body); diff --git a/src/routes/index.ts b/src/routes/index.ts index b9e4606..b4134a0 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,37 @@ import {State} from "../state/state"; import {constants} from "http2"; import {TEMPLATE, X32Instance} from "../config/configuration"; +const SECONDS = 1000.0; +const MINUTES = 60 * SECONDS; +const HOURS = 60 * MINUTES; + +/** + * Converts a number of milliseconds into the form `n hours, m minutes, s seconds` where if any of the entries are zero + * it is omitted. This is returned as a simple string. Values are rounded so will always be whole numbers + * @param millis the number of milliseconds + * @return the number of milliseconds in a textual format + */ +const millisToTime = (millis: number): string => { + const hours = Math.floor(millis / HOURS); + const minutes = Math.floor((millis - (hours * HOURS)) / MINUTES); + const seconds = Math.floor((millis - ((hours * HOURS) + (minutes * MINUTES))) / SECONDS); + + let result = ''; + if (hours > 0) result += `, ${hours} hours`; + if (minutes > 0) result += `, ${minutes} minutes`; + if (seconds > 0) result += `, ${seconds} seconds`; + + if (result.length > 0) return result.substring(2); + else return 'now'; +} + +/** + * Produces a HTML table for the given device listing every forwarding device including its IP, port and remaining time. + * This also wraps it in a header for the instance. + * @param state the state from which information of routing can be loaded + * @param device the device which should be displayed in this table + * @return a html string containing a header and a table of all devices + */ function makeTable(state: State, device: X32Instance) { const clients = state.clients(device.name); const timeout = state.configuration.timeout * 60000; @@ -15,7 +46,7 @@ function makeTable(state: State, device: X32Instance) { Delete - ${(timeout - (Date.now() - checkin)) / 1000} + ${millisToTime((timeout - (Date.now() - checkin)))} Renew @@ -41,19 +72,23 @@ function handle( res: ServerResponse, error?: string, ) { + // Create the set of devices which will be included in the 'register this device' dropdown const devices = state.devices.map((device) => `` ).join(''); + // Build the tables and divide them up with a
const tables = state.devices .map((e) => makeTable(state, e)) .join('
'); + // Replace the regions in the template required which should produce a complete HTML output const template = TEMPLATE .replace('{{ERROR_INSERT}}', error ? `

${error}

` : '') .replace('{{DEVICES}}', devices) .replace('{{TABLE_INSERT}}', tables); + // And return it, forcing the page to not cache it if possible res.writeHead(constants.HTTP_STATUS_OK, { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', diff --git a/src/state/state.ts b/src/state/state.ts index 20cb8d3..b441e33 100644 --- a/src/state/state.ts +++ b/src/state/state.ts @@ -45,6 +45,10 @@ function promisifyBind(socket: Socket, port?: number, address?: string) { }); } +/** + * A target of a redirection containing the ip and port to which packets should be sent and the last time the device + * checked in which should be used to produce its cleanup time + */ export type Target = { ip: string, port: number, @@ -53,16 +57,49 @@ export type Target = { export class State { - private _config: Configuration; - private _targets: Record; + /** + * The configuration this state is currently running from + * @private + */ + private readonly _config: Configuration; + /** + * The set of redirection targets. This is a mapping of x32 device names to a list of targets to which packets + * should be sent + * @private + */ + private readonly _targets: Record; + /** + * The interval which is being used to manage the checkin protocols with X32 and device cleanup. If undefined it + * means that no interval is currently running. See {@link launch} and ${@link stop}. + * @private + */ private _interval: NodeJS.Timeout | undefined; + /** + * The set of devices that are currently running and 'connected' to + * @private + */ private _devices: X32InstanceWithSocket[]; + /** + * The function which should be used to create UDP sockets. This is designed to support test mocking + * @private + */ private readonly _create: CreateSocketFunction; + /** + * Creates a new state object, loading the configuration via {@link loadConfiguration}. If a create function is + * specified, that will be used instead of the default dgram {@link createSocket} function. + * @param create the optional function which should be used to create UDP sockets. Defaults to dgram default + */ public static async makeState(create: CreateSocketFunction = createSocket) { return new State(await loadConfiguration(), create); } + /** + * Creates a new state and registers all devices contained in the config. {@link launch} is not called which means + * this will not begin checkins + * @param config the configuration which should be used for this state + * @param create the optional function which should be used to create UDP sockets. Defaults to dgram default + */ constructor(config: Configuration, create: CreateSocketFunction = createSocket) { this._create = create; this._config = config; @@ -221,10 +258,18 @@ export class State { } } + /** + * Returns the configuration currently in use by this state + * @return the configuration in use + */ get configuration() { return this._config; } + /** + * Returns the set of devices currently being communicated with. + * @return the devices curently in use, this does not include their sockets + */ get devices(): X32Instance[] { return this._devices.map((e) => ({ name: e.name, @@ -233,6 +278,12 @@ export class State { })); } + /** + * Return this clients which are receiving redirects from the specified device. Raises an error if the device does + * not exist + * @param device the device to lookup + * @return a list of targets to which data is being redirected + */ public clients(device: string): Target[] { if (this._targets[device] === undefined) throw new Error('Unknown device'); return this._targets[device]; From 96c4e79602fd1fd92666f476f8c4ee24000aff17 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Fri, 29 Apr 2022 03:52:24 +0100 Subject: [PATCH 11/15] Cleaning up documentation and adding some restrictions to device names This should be the end of the changes required to fix up #6 --- README.md | 55 ++++++++++++++++++++++++++++++++++--- src/config/configuration.ts | 11 +++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 94a8cd9..b507dfe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # x32-reflector -A simple module that polls x32 to send all parameter changes and then redirects them to a set of configured clients. This allows you to go beyond the 4 client limit imposed by X32 and also allow you to use X32 with software like QLab that has restrictions on OSC data sources. +A simple module that polls x32 to send all parameter changes and then redirects them to a set of configured clients. +This allows you to go beyond the 4 client limit imposed by X32 and also allow you to use X32 with software like QLab +that has restrictions on OSC data sources. Built at the request of Andrew @@ -15,14 +17,59 @@ A summary of the config file is provided but it should be relatively self explan |`x32`|The ip address and port on the network for X32 - this will be used to direct `/xremote` packets| |`timeout`|How long, in minutes, a client should remain on the list before being removed automatically. This is suggested to be a long value (such as 1440 for 24 hours) so that clients are not removed part way through a show. | +Annotated details + +```typescript +({ + "udp": { + // The address on which the UDP sockets should bind. Port is not specified as an ephemeral one is chosen for + // each device + "bind": "0.0.0.0" + }, + "http": { + // The address on which the http server should be made available, as normal 0.0.0.0 is for all + "bind": "0.0.0.0", + // The port on which the HTTP server should be made accessible + "port": 1325 + }, + // The set of X32 instances which are accessible via this reflector. This can either be an array like below or an + // object. If it is an object, it should just be one entry like one in the array. All entries must have a name and + // they must be unique across all entries as it is used for unique identification. Valid names match the regex + // ^[A-Za-z0-9_-]+$ + "x32": [ + { + "ip": "10.1.10.20", + "port": 10023, + "name": "Primary" + }, + { + "ip": "10.1.10.21", + "port": 10023, + "name": "Secondary" + } + ], + // The timeout, in minutes, after which an un-renewed client should be removed. In this case it is 24 hours + "timeout": 1440 +}) +``` + ## Timeouts -Clients are configured to timeout at a certain point so that the system is not sending packets repeatedly to clients that do not exist. The timeout value should be set high enough to prevent clients from timing out during shows. Additionally it is recommended that clients use the 'Renew' button just before a show which will reset the countdown and make sure they don't expire mid-show. +Clients are configured to timeout at a certain point so that the system is not sending packets repeatedly to clients +that do not exist. The timeout value should be set high enough to prevent clients from timing out during shows. +Additionally it is recommended that clients use the 'Renew' button just before a show which will reset the countdown and +make sure they don't expire mid-show. ## Known Problems -This system does not verify that X32 is online so while running it will constantly send `/xremote` packets every 9 seconds. See issue #1 for more info +This system does not verify that X32 is online so while running it will constantly send `/xremote` packets every 9 +seconds. See issue #1 for more info ## Web Interface -The web interface has been designed to be as simple as possible. Simply enter the IP address of the client and the port on which you wish to receive packets and press the button. The page should refresh and your client will be listed and will begin receiving packets. To stop receiving packets, just press Delete or to stop your client timing out just press Renew. The page should refresh every 10 seconds to keep the countdown up to date and the client list accurate. There is a countdown to when each client will timeout which can be used to make sure important clients are not being removed at the wrong time \ No newline at end of file +The web interface has been designed to be as simple as possible. Simply enter the IP address of the client and the port +on which you wish to receive packets and press the button. The page should refresh and your client will be listed and +will begin receiving packets. To stop receiving packets, just press Delete or to stop your client timing out just press +Renew. The page should refresh every 10 seconds to keep the countdown up to date and the client list accurate. There is +a countdown to when each client will timeout which can be used to make sure important clients are not being removed at +the wrong time \ No newline at end of file diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 8352492..0876ac9 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -25,7 +25,7 @@ const CONFIG_PATHS: string[] = [ * Validator against the x32 instance entries in the configuration file */ const X32_INSTANCE_VALIDATOR = zod.object({ - name: zod.string(), + name: zod.string().regex(/^[A-Za-z0-9_-]+$/), ip: zod.string(), port: zod.number(), }); @@ -93,6 +93,15 @@ export async function loadConfiguration(paths: string[] = CONFIG_PATHS): Promise continue; } + // Last bit of validation about names + if (Array.isArray(safeParse.data.x32)) { + if (!safeParse.data.x32.map((e) => e.name).every((v, i, a) => a.indexOf(v) === i)) { + // If any name is not unique + console.warn(`[config]: config is invalid because multiple X32 instances with the same name were identified`); + continue; + } + } + console.log(`[config]: config loaded from ${file}`); return safeParse.data; } From 642327e6aa4702777133ef0010f67b0f78c1afdf Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Wed, 25 Jan 2023 18:46:30 +0000 Subject: [PATCH 12/15] Fixing prefix support https://github.com/UStAEnts/x32-reflector/issues/11 --- res/index.html | 2 +- src/config/configuration.ts | 1 + src/micro-router.ts | 9 +++++---- src/routes/index.ts | 9 +++++---- src/routes/register.ts | 6 +++--- src/routes/remove.ts | 6 +++--- src/routes/renew.ts | 6 +++--- 7 files changed, 21 insertions(+), 18 deletions(-) diff --git a/res/index.html b/res/index.html index 949fd30..57f5299 100644 --- a/res/index.html +++ b/res/index.html @@ -118,7 +118,7 @@

OSC Reflector

Register New Target

{{ERROR_INSERT}} -
+ `).join(''); @@ -86,7 +86,8 @@ function handle( const template = TEMPLATE .replace('{{ERROR_INSERT}}', error ? `

${error}

` : '') .replace('{{DEVICES}}', devices) - .replace('{{TABLE_INSERT}}', tables); + .replace('{{TABLE_INSERT}}', tables) + .replace('{{PREFIX}}', state.configuration.http.prefix ?? ''); // And return it, forcing the page to not cache it if possible res.writeHead(constants.HTTP_STATUS_OK, { @@ -104,7 +105,7 @@ export default function (state: State, router: MicroRouter) { router.get({ path: /^\/?$/i, parseQuery: false, - fail: (res, code, error) => fail(res, error), + fail: (res, code, error) => fail(res, error, state.configuration), handle: ((path, query, res, req) => { handle( state, diff --git a/src/routes/register.ts b/src/routes/register.ts index 59e8f8b..4d0fa7c 100644 --- a/src/routes/register.ts +++ b/src/routes/register.ts @@ -19,9 +19,9 @@ function handle( port, ); - succeed(res); + succeed(res, state.configuration); } catch (e: any) { - fail(res, e.message); + fail(res, e.message, state.configuration); } } @@ -34,7 +34,7 @@ export default function (state: State, router: MicroRouter) { {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, ], - fail: (res, code, error) => fail(res, error), + fail: (res, code, error) => fail(res, error, state.configuration), handle: ((path, query, res, req) => { handle( state, diff --git a/src/routes/remove.ts b/src/routes/remove.ts index 6f691a5..ecdd704 100644 --- a/src/routes/remove.ts +++ b/src/routes/remove.ts @@ -19,9 +19,9 @@ function handle( port, ); - succeed(res); + succeed(res, state.configuration); } catch (e: any) { - fail(res, e.message); + fail(res, e.message, state.configuration); } } @@ -34,7 +34,7 @@ export default function (state: State, router: MicroRouter) { {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, ], - fail: (res, code, error) => fail(res, error), + fail: (res, code, error) => fail(res, error, state.configuration), handle: ((path, query, res, req) => { handle( state, diff --git a/src/routes/renew.ts b/src/routes/renew.ts index 45dbc7b..b2b05df 100644 --- a/src/routes/renew.ts +++ b/src/routes/renew.ts @@ -19,9 +19,9 @@ function handle( port, ); - succeed(res); + succeed(res, state.configuration); } catch (e: any) { - fail(res, e.message); + fail(res, e.message, state.configuration); } } @@ -34,7 +34,7 @@ export default function (state: State, router: MicroRouter) { {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, ], - fail: (res, code, error) => fail(res, error), + fail: (res, code, error) => fail(res, error, state.configuration), handle: ((path, query, res, req) => { handle( state, From b6aa26995d3bd870c858e4c9953f34cc26aac07e Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Wed, 25 Jan 2023 18:49:07 +0000 Subject: [PATCH 13/15] Unifying device name regexes https://github.com/UStAEnts/x32-reflector/issues/13 --- src/config/configuration.ts | 4 +++- src/routes/register.ts | 3 ++- src/routes/remove.ts | 3 ++- src/routes/renew.ts | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index a67e7fd..75ed564 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -5,6 +5,8 @@ import {Socket} from "dgram"; import {promises as fsp} from "fs"; import fs from "fs"; +export const DEVICE_NAME_REGEX = /^[A-Za-z0-9_-]+$/; + /** * HTML main site template loaded from file. Content is cached so program will have to be restarted to pick up new * changes in the file @@ -25,7 +27,7 @@ const CONFIG_PATHS: string[] = [ * Validator against the x32 instance entries in the configuration file */ const X32_INSTANCE_VALIDATOR = zod.object({ - name: zod.string().regex(/^[A-Za-z0-9_-]+$/), + name: zod.string().regex(DEVICE_NAME_REGEX), ip: zod.string(), port: zod.number(), }); diff --git a/src/routes/register.ts b/src/routes/register.ts index 4d0fa7c..c797a25 100644 --- a/src/routes/register.ts +++ b/src/routes/register.ts @@ -1,6 +1,7 @@ import MicroRouter, {fail, succeed} from "../micro-router"; import {IncomingMessage, ServerResponse} from "http"; import {State} from "../state/state"; +import { DEVICE_NAME_REGEX } from "../config/configuration"; const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; @@ -32,7 +33,7 @@ export default function (state: State, router: MicroRouter) { queryValidator: [ {name: 'ip', required: true, error: 'IP must be a valid IP address', validator: (t) => IP_REGEX.test(t)}, {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, - {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, + {name: 'device', required: true, error: 'Device name require', validator: (t) => DEVICE_NAME_REGEX.test(t)}, ], fail: (res, code, error) => fail(res, error, state.configuration), handle: ((path, query, res, req) => { diff --git a/src/routes/remove.ts b/src/routes/remove.ts index ecdd704..1013192 100644 --- a/src/routes/remove.ts +++ b/src/routes/remove.ts @@ -1,6 +1,7 @@ import MicroRouter, {fail, succeed} from "../micro-router"; import {IncomingMessage, ServerResponse} from "http"; import {State} from "../state/state"; +import { DEVICE_NAME_REGEX } from "../config/configuration"; const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; @@ -32,7 +33,7 @@ export default function (state: State, router: MicroRouter) { queryValidator: [ {name: 'ip', required: true, error: 'IP must be a valid IP address', validator: (t) => IP_REGEX.test(t)}, {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, - {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, + {name: 'device', required: true, error: 'Device name require', validator: (t) => DEVICE_NAME_REGEX.test(t)}, ], fail: (res, code, error) => fail(res, error, state.configuration), handle: ((path, query, res, req) => { diff --git a/src/routes/renew.ts b/src/routes/renew.ts index b2b05df..b5d8c41 100644 --- a/src/routes/renew.ts +++ b/src/routes/renew.ts @@ -1,6 +1,7 @@ import MicroRouter, {fail, succeed} from "../micro-router"; import {IncomingMessage, ServerResponse} from "http"; import {State} from "../state/state"; +import { DEVICE_NAME_REGEX } from "../config/configuration"; const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/; @@ -32,7 +33,7 @@ export default function (state: State, router: MicroRouter) { queryValidator: [ {name: 'ip', required: true, error: 'IP must be a valid IP address', validator: (t) => IP_REGEX.test(t)}, {name: 'port', required: true, error: 'Port must be a number', validator: (t) => !isNaN(Number(t))}, - {name: 'device', required: true, error: 'Device name require', validator: (t) => /^[A-Za-z]+$/.test(t)}, + {name: 'device', required: true, error: 'Device name require', validator: (t) => DEVICE_NAME_REGEX.test(t)}, ], fail: (res, code, error) => fail(res, error, state.configuration), handle: ((path, query, res, req) => { From 1d396ec41a4ee31ad033faef8728b73a5bc5de21 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Wed, 25 Jan 2023 18:49:46 +0000 Subject: [PATCH 14/15] Fixing request handling https://github.com/UStAEnts/x32-reflector/issues/12 --- src/micro-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micro-router.ts b/src/micro-router.ts index fa4453e..b54276a 100644 --- a/src/micro-router.ts +++ b/src/micro-router.ts @@ -227,7 +227,7 @@ export default class MicroRouter { } // Reject requests immediately with an unsupported method - if (!SUPPORTED_METHODS.includes(req.method as any) || this._methodsInUse.has(req.method)) { + if (!SUPPORTED_METHODS.includes(req.method as any) || !this._methodsInUse.has(req.method)) { res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED).end(); return; } From 1f8a8ad6939af643dee816d09bd976db22517567 Mon Sep 17 00:00:00 2001 From: Ryan Delaney Date: Wed, 25 Jan 2023 18:55:25 +0000 Subject: [PATCH 15/15] Bumping version number in favour of the new release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8022e32..1b989de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "x32-reflector", - "version": "1.0.0", + "version": "1.1.1", "description": "", "main": "index.js", "scripts": {
diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 0876ac9..a67e7fd 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -47,6 +47,7 @@ const CONFIG_VALIDATOR = zod.object({ http: zod.object({ bind: zod.string(), port: zod.number(), + prefix: zod.string().optional(), }), x32: X32_INSTANCE_VALIDATOR.or(zod.array(X32_INSTANCE_VALIDATOR)), timeout: zod.number(), diff --git a/src/micro-router.ts b/src/micro-router.ts index 9b7f5c8..fa4453e 100644 --- a/src/micro-router.ts +++ b/src/micro-router.ts @@ -1,5 +1,6 @@ import {IncomingMessage, ServerResponse} from "http"; import {constants} from "http2"; +import { Configuration } from "./config/configuration"; /** * If additional debugging messages should be logged for interactions with the HTTP server @@ -141,16 +142,16 @@ type InternalRoute = Route & { method: Method }; * @param res the response on which results should be written * @param error the error which should be included in the request */ -export const fail = (res: ServerResponse, error: string) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `/?error=${encodeURIComponent(error)}` +export const fail = (res: ServerResponse, error: string, config: Configuration) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { + Location: `${config.http.prefix ?? ''}/?error=${encodeURIComponent(error)}` }).end(); /** * A utility function which succeeds a request by redirecting it to / without an error parameter * @param res the response on which the redirect should be written */ -export const succeed = (res: ServerResponse) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { - Location: `/` +export const succeed = (res: ServerResponse, config: Configuration) => res.writeHead(constants.HTTP_STATUS_TEMPORARY_REDIRECT, { + Location: `${config.http.prefix ?? ''}/` }).end(); /** diff --git a/src/routes/index.ts b/src/routes/index.ts index b4134a0..ceebf1d 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -43,13 +43,13 @@ function makeTable(state: State, device: X32Instance) { ${ip} ${port} - Delete + Delete ${millisToTime((timeout - (Date.now() - checkin)))} - Renew + Renew