diff --git a/constants/ports.ts b/constants/ports.ts new file mode 100644 index 00000000..b1288985 --- /dev/null +++ b/constants/ports.ts @@ -0,0 +1,4 @@ +export const MIN_PORT_NUMBER = 1024; +export const MAX_PORT_NUMBER = 65535; + +export const PORT_MANAGER_SERVER_PORT = 8080; diff --git a/lang/en.json b/lang/en.json index f35f4856..d0e60bdc 100644 --- a/lang/en.json +++ b/lang/en.json @@ -321,6 +321,24 @@ "modules": { "throwInvalidPathInput": "Expected Path Input" } + }, + "detectPort": { + "errors": { + "invalidPort": "Port must be between {{ minPort }} and {{ maxPort }}" + } + }, + "PortManagerServer": { + "started": "PortManagerServer running on port {{ port }}", + "setPort": "Server with instanceId {{ instanceId }} assigned to port {{ port }}", + "deletedPort": "Server with instanceId {{ instanceId }} unassigned from port {{ port }}", + "close": "PortManagerServer shutting down.", + "errors": { + "portInUse": "Failed to start PortManagerServer. Port {{ port }} is already in use.", + "duplicateInstance": "Failed to start PortManagerServer. An instance of PortManagerServer is already running.", + "404": "Could not find a server with instanceId {{ instanceId }}", + "409": "Failed to assign port. Server with instanceId {{ instanceId }} is already running on port {{ port }}", + "400": "Invalid port requested. Port must be between {{ minPort }} and {{ maxPort }}." + } } }, "http": { diff --git a/lib/portManager.ts b/lib/portManager.ts new file mode 100644 index 00000000..7d9eed46 --- /dev/null +++ b/lib/portManager.ts @@ -0,0 +1,62 @@ +import axios from 'axios'; + +import PortManagerServer from '../utils/PortManagerServer'; +import { detectPort } from '../utils/detectPort'; +import { PORT_MANAGER_SERVER_PORT } from '../constants/ports'; + +const BASE_URL = `http://localhost:${PORT_MANAGER_SERVER_PORT}`; + +async function isPortManagerServerRunning(): Promise { + const port = await detectPort(PORT_MANAGER_SERVER_PORT); + return port !== PORT_MANAGER_SERVER_PORT; +} + +export async function startPortManagerServer(): Promise { + const isRunning = await isPortManagerServerRunning(); + + if (!isRunning) { + await PortManagerServer.init(); + } +} + +export async function stopPortManagerServer(): Promise { + const isRunning = await isPortManagerServerRunning(); + + if (isRunning) { + await axios.post(`${BASE_URL}/close`); + } +} + +type RequestPortsData = { + instanceId: string; + port?: number; +}; + +export async function requestPorts( + portData: Array +): Promise<{ [instanceId: string]: number }> { + const { data } = await axios.post(`${BASE_URL}/servers`, { + portData: portData, + }); + + return data.ports; +} + +export async function deleteServerInstance( + serverInstanceId: string +): Promise { + await axios.post(`${BASE_URL}/servers/${serverInstanceId}`); +} + +export async function portManagerHasActiveServers() { + const { data } = await axios.get(`${BASE_URL}/servers`); + return data.count > 0; +} + +function toId(str: string) { + return str.replace(/\s+/g, '-').toLowerCase(); +} + +export function getServerInstanceId(serverId: string, resourceId: string) { + return `${toId(serverId)}__${toId(resourceId)}`; +} diff --git a/package.json b/package.json index 0cd81741..27c60297 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "license": "Apache-2.0", "devDependencies": { "@types/content-disposition": "^0.5.5", + "@types/cors": "^2.8.15", "@types/debounce": "^1.2.1", + "@types/express": "^4.17.18", "@types/findup-sync": "^4.0.2", "@types/fs-extra": "^11.0.1", "@types/jest": "^29.5.0", @@ -56,9 +58,12 @@ "./logger": "./lib/logging/logger.js" }, "dependencies": { + "address": "^2.0.1", "axios": "^1.3.5", "chokidar": "^3.5.3", "content-disposition": "^0.5.4", + "cors": "^2.8.5", + "express": "^4.18.2", "extract-zip": "^2.0.1", "findup-sync": "^5.0.0", "fs-extra": "^11.1.0", diff --git a/types/PortManager.ts b/types/PortManager.ts new file mode 100644 index 00000000..a319a031 --- /dev/null +++ b/types/PortManager.ts @@ -0,0 +1,4 @@ +export type RequestPortsData = { + instanceId: string; + port?: number; +}; diff --git a/utils/PortManagerServer.ts b/utils/PortManagerServer.ts new file mode 100644 index 00000000..53f60bc5 --- /dev/null +++ b/utils/PortManagerServer.ts @@ -0,0 +1,191 @@ +import express, { Express, Request, Response } from 'express'; +import { Server } from 'http'; +import cors from 'cors'; + +import { detectPort } from './detectPort'; +import { + MIN_PORT_NUMBER, + MAX_PORT_NUMBER, + PORT_MANAGER_SERVER_PORT, +} from '../constants/ports'; +import { throwErrorWithMessage } from '../errors/standardErrors'; +import { debug } from './logger'; +import { i18n } from './lang'; +import { BaseError } from '../types/Error'; +import { RequestPortsData } from '../types/PortManager'; + +type ServerPortMap = { + [instanceId: string]: number; +}; + +const i18nKey = 'utils.PortManagerServer'; + +class PortManagerServer { + app?: Express; + server?: Server; + serverPortMap: ServerPortMap; + + constructor() { + this.serverPortMap = {}; + } + + async init(): Promise { + if (this.app) { + throwErrorWithMessage(`${i18nKey}.errors.duplicateInstance`); + } + this.app = express(); + this.app.use(express.json()); + this.app.use(cors()); + this.setupRoutes(); + + try { + this.server = await this.listen(); + } catch (e) { + const error = e as BaseError; + if (error.code === 'EADDRINUSE') { + throwErrorWithMessage( + `${i18nKey}.errors.portInUse`, + { + port: PORT_MANAGER_SERVER_PORT, + }, + error + ); + } + throw error; + } + } + + listen(): Promise { + return new Promise((resolve, reject) => { + const server = this.app!.listen(PORT_MANAGER_SERVER_PORT, () => { + debug(`${i18nKey}.started`, { + port: PORT_MANAGER_SERVER_PORT, + }); + resolve(server); + }).on('error', (err: BaseError) => { + reject(err); + }); + }); + } + + setupRoutes(): void { + if (!this.app) { + return; + } + + this.app.get('/servers', this.getServers); + this.app.get('/servers/:instanceId', this.getServerPortByInstanceId); + this.app.post('/servers', this.assignPortsToServers); + this.app.delete('/servers/:instanceId', this.deleteServerInstance); + this.app.post('/close', this.closeServer); + } + + setPort(instanceId: string, port: number) { + debug(`${i18nKey}.setPort`, { instanceId, port }); + this.serverPortMap[instanceId] = port; + } + + deletePort(instanceId: string) { + debug(`${i18nKey}.deletedPort`, { + instanceId, + port: this.serverPortMap[instanceId], + }); + delete this.serverPortMap[instanceId]; + } + + send404(res: Response, instanceId: string) { + res + .status(404) + .send(i18n(`${i18nKey}.errors.404`, { instanceId: instanceId })); + } + + getServers = async (req: Request, res: Response): Promise => { + res.send({ + servers: this.serverPortMap, + count: Object.keys(this.serverPortMap).length, + }); + }; + + getServerPortByInstanceId = (req: Request, res: Response): void => { + const { instanceId } = req.params; + const port = this.serverPortMap[instanceId]; + + if (port) { + res.send({ port }); + } else { + this.send404(res, instanceId); + } + }; + + assignPortsToServers = async ( + req: Request }>, + res: Response + ): Promise => { + const { portData } = req.body; + + const portPromises: Array> = []; + + portData.forEach(data => { + const { port, instanceId } = data; + if (this.serverPortMap[instanceId]) { + res.status(409).send( + i18n(`${i18nKey}.errors.409`, { + instanceId, + port: this.serverPortMap[instanceId], + }) + ); + return; + } else if (port && (port < MIN_PORT_NUMBER || port > MAX_PORT_NUMBER)) { + res.status(400).send( + i18n(`${i18nKey}.errors.400`, { + minPort: MIN_PORT_NUMBER, + maxPort: MAX_PORT_NUMBER, + }) + ); + return; + } else { + const promise = new Promise<{ [instanceId: string]: number }>( + resolve => { + detectPort(port).then(resolvedPort => { + resolve({ + [instanceId]: resolvedPort, + }); + }); + } + ); + portPromises.push(promise); + } + }); + + const portList = await Promise.all(portPromises); + const ports = portList.reduce((a, c) => Object.assign(a, c)); + + for (const instanceId in ports) { + this.setPort(instanceId, ports[instanceId]); + } + + res.send({ ports }); + }; + + deleteServerInstance = (req: Request, res: Response): void => { + const { instanceId } = req.params; + const port = this.serverPortMap[instanceId]; + + if (port) { + this.deletePort(instanceId); + res.send(200); + } else { + this.send404(res, instanceId); + } + }; + + closeServer = (req: Request, res: Response): void => { + if (this.server) { + debug(`${i18nKey}.close`); + res.send(200); + this.server.close(); + } + }; +} + +export default new PortManagerServer(); diff --git a/utils/detectPort.ts b/utils/detectPort.ts new file mode 100644 index 00000000..24a94822 --- /dev/null +++ b/utils/detectPort.ts @@ -0,0 +1,118 @@ +/* +From https://github.com/node-modules/detect-port/tree/master + +The MIT License (MIT) + +Copyright (c) 2014 - present node-modules and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import net, { AddressInfo } from 'net'; +import { ip } from 'address'; +import { throwErrorWithMessage } from '../errors/standardErrors'; + +import { MIN_PORT_NUMBER, MAX_PORT_NUMBER } from '../constants/ports'; + +type NetError = Error & { + code: string; +}; + +type ListenCallback = (error: NetError | null, port: number) => void; + +const i18nKey = 'utils.detectPort'; + +export function detectPort(port?: number | null): Promise { + if (port && (port < MIN_PORT_NUMBER || port > MAX_PORT_NUMBER)) { + throwErrorWithMessage(`${i18nKey}.errors.invalidPort`, { + minPort: MIN_PORT_NUMBER, + maxPort: MAX_PORT_NUMBER, + }); + } + + const portToUse = port || 0; + const maxPort = Math.min(portToUse + 10, MAX_PORT_NUMBER); + + return new Promise(resolve => { + tryListen(portToUse, maxPort, (_, resolvedPort) => { + resolve(resolvedPort); + }); + }); +} + +function tryListen(port: number, maxPort: number, callback: ListenCallback) { + const shouldGiveUp = port >= maxPort; + const nextPort = shouldGiveUp ? 0 : port + 1; + const nextMaxPort = shouldGiveUp ? 0 : maxPort; + + listen(port, undefined, (err, realPort) => { + // ignore random listening + if (port === 0) { + return callback(err, realPort); + } + + if (err) { + return tryListen(nextPort, nextMaxPort, callback); + } + + // 2. check 0.0.0.0 + listen(port, '0.0.0.0', err => { + if (err) { + return tryListen(nextPort, nextMaxPort, callback); + } + + // 3. check localhost + listen(port, 'localhost', err => { + if (err && err.code !== 'EADDRNOTAVAIL') { + return tryListen(nextPort, nextMaxPort, callback); + } + + // 4. check current ip + listen(port, ip(), (err, realPort) => { + if (err) { + return tryListen(nextPort, nextMaxPort, callback); + } + + callback(null, realPort); + }); + }); + }); + }); +} + +function listen( + port: number, + hostname: string | undefined, + callback: ListenCallback +): void { + const server = new net.Server(); + + server.on('error', (err: NetError) => { + server.close(); + if (err.code === 'ENOTFOUND') { + return callback(null, port); + } + return callback(err, 0); + }); + + server.listen(port, hostname, () => { + const addressInfo = server.address() as AddressInfo; + server.close(); + return callback(null, addressInfo.port); + }); +}