diff --git a/.env.example b/.env.example index a66a947a..d47b3ac6 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,6 @@ PORT=1337 LOG_LEVEL=debug ALLOWED_ORIGINS= APP_VERSION=dev +# SSL_KEY=/path/to/server.key +# SSL_CERT=/path/to/server.crt +# HTTPS_PORT=1338 diff --git a/src/index.ts b/src/index.ts index 9e8827d2..0b86f7f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; import 'reflect-metadata'; -import cors from 'cors'; +import cors, { CorsOptions } from 'cors'; import contentTypeMiddleware from './middleware/contentTypeMiddleware.js'; import express from 'express'; import { Pimple } from '@timesplinter/pimple'; @@ -11,13 +11,12 @@ import FactoryServiceProvider from './serviceProvider/factoryServiceProvider.js' import DeviceServiceProvider from './serviceProvider/deviceServiceProvider.js'; import SettingsServiceProvider from './serviceProvider/settingsServiceProvider.js'; import SchemaValidationServiceProvider from './serviceProvider/schemaValidationServiceProvider.js'; -import http from 'http' import SocketServiceProvider from './serviceProvider/socketServiceProvider.js'; import { DeviceUpdateData } from './socket/types.js'; import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js'; import Device from './device/device.js'; import WebSocketEvent from './device/webSocketEvent.js'; -import ServerServiceProvider from './serviceProvider/serverServiceProvider.js'; +import ServerServiceProvider, { SslConfig } from './serviceProvider/serverServiceProvider.js'; import AutomationEventType from './automation/automationEventType.js'; import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js'; import DeviceDiscriminator from './serialization/discriminator/deviceDiscriminator.js'; @@ -30,21 +29,38 @@ import { logError } from './util/error.js'; import { setIntervalAsync } from './util/async.js'; import HealthServiceProvider from './serviceProvider/healthServiceProvider.js'; -const APP_PORT = process.env.PORT ?? '1337'; +const APP_HTTP_PORT = process.env.PORT ?? '1337'; +const APP_HTTPS_PORT = process.env.HTTPS_PORT ?? '1338'; const ALLOWED_ORIGINS = undefined !== process.env.ALLOWED_ORIGINS && null !== process.env.ALLOWED_ORIGINS.length ? process.env.ALLOWED_ORIGINS.split(',') .map(origin => origin.trim()) .filter(origin => origin.length > 0) : []; -const container = new Pimple(); +const SSL_KEY_FILE = process.env.SSL_KEY; +const SSL_CERT_FILE = process.env.SSL_CERT; + +const sslConfig: SslConfig | undefined = SSL_KEY_FILE !== undefined && SSL_CERT_FILE !== undefined + ? { keyFile: SSL_KEY_FILE, certFile: SSL_CERT_FILE } + : undefined; + +const corsOptions: CorsOptions = { + origin: (origin, callback) => { + if (undefined === origin || ALLOWED_ORIGINS.length === 0) { + return callback(null, true); + } + + return callback(null, ALLOWED_ORIGINS.includes(origin)); + }, +}; + const app = express(); -const httpServer = http.createServer(app); +const container = new Pimple(); container .register(new LoggerServiceProvider()) .register(new HealthServiceProvider()) - .register(new ServerServiceProvider(httpServer)) + .register(new ServerServiceProvider(app, corsOptions, sslConfig)) .register(new SettingsServiceProvider()) .register(new DeviceServiceProvider()) .register(new ControllerServiceProvider()) @@ -77,15 +93,7 @@ app next(); }) - .use(cors({ - origin: (origin, callback) => { - if (undefined === origin || ALLOWED_ORIGINS.length === 0) { - return callback(null, true); - } - - return callback(null, ALLOWED_ORIGINS.includes(origin)); - }, - })) + .use(cors(corsOptions)) .use(contentTypeMiddleware) .use(express.json()) .use(express.text()) @@ -164,11 +172,20 @@ setIntervalAsync(async () => { onError: (err) => logError(logger, 'Health metrics broadcast failed', err), }); -httpServer.listen(APP_PORT, () => { +const httpServer = container.get('server.http'); +const httpsServer = container.get('server.https'); + +httpServer.listen(APP_HTTP_PORT, () => { logger.info(`Node version: ${process.version}`); - logger.info(`SlvCtrl+ server listening on port ${APP_PORT}!`); + logger.info(`SlvCtrl+ server listening on http://localhost:${APP_HTTP_PORT}`); }); +if (httpsServer !== undefined) { + httpsServer.listen(APP_HTTPS_PORT, () => { + logger.info(`SlvCtrl+ server listening on https://localhost:${APP_HTTPS_PORT} (ssl)`); + }); +} + process.on('uncaughtException', (error: Error) => { logger.error('Asynchronous error caught', error); }); diff --git a/src/serviceMap.ts b/src/serviceMap.ts index 803c84d2..b1e38a2a 100644 --- a/src/serviceMap.ts +++ b/src/serviceMap.ts @@ -1,4 +1,6 @@ import { Ajv } from 'ajv'; +import type http from 'http'; +import type https from 'https'; import { Server } from 'socket.io'; import ClassToPlainSerializer from './serialization/classToPlainSerializer.js'; import PlainToClassSerializer from './serialization/plainToClassSerializer.js'; @@ -56,6 +58,8 @@ type ServiceMap = { 'logger.default': Logger, /* serverServiceProvider */ + 'server.http': http.Server, + 'server.https': https.Server | undefined, 'server.websocket': Server, /* deviceServiceProvider */ diff --git a/src/serviceProvider/serverServiceProvider.ts b/src/serviceProvider/serverServiceProvider.ts index 97061133..33275e29 100644 --- a/src/serviceProvider/serverServiceProvider.ts +++ b/src/serviceProvider/serverServiceProvider.ts @@ -1,24 +1,61 @@ +import BaseError from 'modern-errors'; import { Pimple, ServiceProvider } from '@timesplinter/pimple'; import http from 'http' +import https from 'https' +import fs from 'fs' import { Server } from 'socket.io'; import ServiceMap from '../serviceMap.js'; +import { CorsOptions } from 'cors'; +import express from 'express'; + +export type SslConfig = { keyFile: string, certFile: string }; export default class ServerServiceProvider implements ServiceProvider { - private readonly httpServer: http.Server; + private readonly app: express.Application; + private readonly corsOptions: CorsOptions; + private readonly sslConfig?: SslConfig; - public constructor(server: http.Server) { - this.httpServer = server; + public constructor(app: express.Application, corsOptions: CorsOptions, sslConfig?: SslConfig) { + this.app = app; + this.corsOptions = corsOptions; + this.sslConfig = sslConfig; } public register(container: Pimple): void { - container.set('server.websocket', () => { - return new Server(this.httpServer, { - cors: { - origin: '*', - methods: ['GET', 'POST', 'PATCH'] - } - }); + container.set('server.websocket', () => new Server(undefined, { + cors: this.corsOptions + })); + + container.set('server.http', () => { + const server = http.createServer(this.app); + + container.get('server.websocket').attach(server); + + return server; + }); + + container.set('server.https', () => { + if (this.sslConfig === undefined) { + return undefined; + } + + const logger = container.get('logger.default'); + + try { + const key = fs.readFileSync(this.sslConfig.keyFile); + const cert = fs.readFileSync(this.sslConfig.certFile); + const server = https.createServer({ key, cert }, this.app); + + container.get('server.websocket').attach(server); + + return server; + } catch (err) { + const baseError = BaseError.normalize(err); + logger.error(`Failed to load SSL certificates: ${baseError.message}`); + logger.warn('HTTPS server will not be started'); + return undefined; + } }); } } diff --git a/src/util/async.ts b/src/util/async.ts index 7d8b9449..030f80e5 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -1,5 +1,12 @@ export const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); +export class IntervalTimeoutError extends Error { + public constructor(timeoutMs: number) { + super(`Interval function timed out (>${timeoutMs}ms)`); + this.name = 'IntervalTimeoutError'; + } +} + export const setImmediateInterval = ( callback: (...args: TArgs) => void, delay?: number, @@ -41,10 +48,11 @@ export const setIntervalAsync = ( let timeoutHandle: ReturnType | undefined; if (undefined !== options.timeoutMs) { + const timeoutMs = options.timeoutMs; promises.push(new Promise((_, reject) => timeoutHandle = setTimeout(() => { - reject(new Error(`Interval function timed out (>${options.timeoutMs}ms)`)); - }, options.timeoutMs)) + reject(new IntervalTimeoutError(timeoutMs)); + }, timeoutMs)) ); } diff --git a/src/util/error.ts b/src/util/error.ts index c00bd27e..3867aadb 100644 --- a/src/util/error.ts +++ b/src/util/error.ts @@ -1,7 +1,13 @@ import BaseError from 'modern-errors'; import Logger from '../logging/Logger.js'; +import { IntervalTimeoutError } from './async.js'; export const logError = (logger: Logger, message: string, error: unknown): void => { + if (error instanceof IntervalTimeoutError) { + logger.warn(`${message}: ${error.message}`); + return; + } + const baseError = error instanceof Error ? error : BaseError.normalize(error); logger.error(`${message}: ${baseError.message}`, baseError); };