From bf0e35be37e666769a083a95d52e78335f1d993e Mon Sep 17 00:00:00 2001 From: Koen Luyten <--global> Date: Thu, 17 Nov 2022 16:57:20 +0100 Subject: [PATCH] feat: allow server to bind to Unix Domain Sockets --- README.md | 3 +- config/app/init/initializers/server.json | 3 +- config/app/variables/cli/cli.json | 9 ++ config/app/variables/resolver/resolver.json | 8 ++ config/util/variables/default.json | 5 + src/init/ServerInitializer.ts | 15 ++- .../variables/extractors/BaseUrlExtractor.ts | 3 + src/server/BaseHttpServerFactory.ts | 20 +++- src/server/HttpServerFactory.ts | 5 +- src/server/WebSocketServerFactory.ts | 6 +- test/integration/Config.ts | 1 + test/unit/init/ServerInitializer.test.ts | 12 +++ .../extractors/BaseUrlExtractor.test.ts | 5 + .../unit/server/BaseHttpServerFactory.test.ts | 91 ++++++++++++++++++- test/util/Util.ts | 14 +++ 15 files changed, 186 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3027b684c..516df617b 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ to some commonly used settings: | parameter name | default value | description | |------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| | `--port, -p` | `3000` | The TCP port on which the server should listen. | -| `--baseUrl, -b` | `http://localhost:$PORT/` | The base URL used internally to generate URLs. Change this if your server does not run on `http://localhost:$PORT/`. | +| `--baseUrl, -b` | `http://localhost:$PORT/` | The base URL used internally to generate URLs. Change this if your server does not run on `http://localhost:$PORT/`. +| `--socket` | | The Unix Domain Socket on which the server should listen. `--baseUrl` must be set if this option is provided | | `--loggingLevel, -l` | `info` | The detail level of logging; useful for debugging problems. Use `debug` for full information. | | `--config, -c` | `@css:config/default.json` | The configuration(s) for the server. The default only stores data in memory; to persist to your filesystem, use `@css:config/file.json` | | `--rootFilePath, -f` | `./` | Root folder where the server stores data, when using a file-based configuration. | diff --git a/config/app/init/initializers/server.json b/config/app/init/initializers/server.json index 3becec2d9..c9dd96fc6 100644 --- a/config/app/init/initializers/server.json +++ b/config/app/init/initializers/server.json @@ -6,7 +6,8 @@ "@id": "urn:solid-server:default:ServerInitializer", "@type": "ServerInitializer", "serverFactory": { "@id": "urn:solid-server:default:ServerFactory" }, - "port": { "@id": "urn:solid-server:default:variable:port" } + "port": { "@id": "urn:solid-server:default:variable:port" }, + "socketPath": { "@id": "urn:solid-server:default:variable:socket" } } ] } diff --git a/config/app/variables/cli/cli.json b/config/app/variables/cli/cli.json index bbeb70111..43641dcfb 100644 --- a/config/app/variables/cli/cli.json +++ b/config/app/variables/cli/cli.json @@ -56,6 +56,15 @@ "describe": "The TCP port on which the server runs." } }, + { + "@type": "YargsParameter", + "name": "socket", + "options": { + "requiresArg": true, + "type": "string", + "describe": "The path to the Unix Domain Socket on which the server runs. This overrides the port argument." + } + }, { "@type": "YargsParameter", "name": "rootFilePath", diff --git a/config/app/variables/resolver/resolver.json b/config/app/variables/resolver/resolver.json index 2341d0f6b..213e6fdc7 100644 --- a/config/app/variables/resolver/resolver.json +++ b/config/app/variables/resolver/resolver.json @@ -28,6 +28,14 @@ "defaultValue": 3000 } }, + { + "CombinedShorthandResolver:_resolvers_key": "urn:solid-server:default:variable:socket", + "CombinedShorthandResolver:_resolvers_value": { + "@type": "KeyExtractor", + "key": "socket", + "defaultValue" : "" + } + }, { "CombinedShorthandResolver:_resolvers_key": "urn:solid-server:default:variable:rootFilePath", "CombinedShorthandResolver:_resolvers_value": { diff --git a/config/util/variables/default.json b/config/util/variables/default.json index ec3767640..9fb91ca3c 100644 --- a/config/util/variables/default.json +++ b/config/util/variables/default.json @@ -7,6 +7,11 @@ "@id": "urn:solid-server:default:variable:port", "@type": "Variable" }, + { + "comment": "Unix Domain Socket of the server.", + "@id": "urn:solid-server:default:variable:socket", + "@type": "Variable" + }, { "comment": "Needs to be set to the base URL of the server for authentication and authorization to function.", "@id": "urn:solid-server:default:variable:baseUrl", diff --git a/src/init/ServerInitializer.ts b/src/init/ServerInitializer.ts index 40438dad2..556714cc9 100644 --- a/src/init/ServerInitializer.ts +++ b/src/init/ServerInitializer.ts @@ -9,18 +9,27 @@ import { Initializer } from './Initializer'; */ export class ServerInitializer extends Initializer implements Finalizable { private readonly serverFactory: HttpServerFactory; - private readonly port: number; + private readonly port?: number; + private readonly socketPath?: string; private server?: Server; - public constructor(serverFactory: HttpServerFactory, port: number) { + public constructor(serverFactory: HttpServerFactory, port?: number, socketPath?: string) { super(); this.serverFactory = serverFactory; this.port = port; + this.socketPath = socketPath; + if (!port && !socketPath) { + throw new Error('Either Port or Socket arguments must be set'); + } } public async handle(): Promise { - this.server = this.serverFactory.startServer(this.port); + if (this.socketPath) { + this.server = this.serverFactory.startServer(this.socketPath); + } else if (this.port) { + this.server = this.serverFactory.startServer(this.port); + } } public async finalize(): Promise { diff --git a/src/init/variables/extractors/BaseUrlExtractor.ts b/src/init/variables/extractors/BaseUrlExtractor.ts index 6ac029210..939e0514b 100644 --- a/src/init/variables/extractors/BaseUrlExtractor.ts +++ b/src/init/variables/extractors/BaseUrlExtractor.ts @@ -18,6 +18,9 @@ export class BaseUrlExtractor extends ShorthandExtractor { if (typeof args.baseUrl === 'string') { return ensureTrailingSlash(args.baseUrl); } + if (typeof args.socket === 'string') { + throw new Error('BaseUrl argument should be provided when using Unix Domain Sockets.'); + } const port = args.port ?? this.defaultPort; return `http://localhost:${port}/`; } diff --git a/src/server/BaseHttpServerFactory.ts b/src/server/BaseHttpServerFactory.ts index 03824f4b5..611365814 100644 --- a/src/server/BaseHttpServerFactory.ts +++ b/src/server/BaseHttpServerFactory.ts @@ -50,12 +50,12 @@ export class BaseHttpServerFactory implements HttpServerFactory { /** * Creates and starts an HTTP(S) server - * @param port - Port on which the server listens + * @param portOrSocket - Port or Unix Domain Socket on which the server listens */ - public startServer(port: number): Server { + public startServer(port: number): Server; + public startServer(socket: string): Server; + public startServer(portOrSocket: number | string): Server { const protocol = this.options.https ? 'https' : 'http'; - const url = new URL(`${protocol}://localhost:${port}/`).href; - this.logger.info(`Listening to server at ${url}`); const createServer = this.options.https ? createHttpsServer : createHttpServer; const options = this.createServerOptions(); @@ -92,7 +92,17 @@ export class BaseHttpServerFactory implements HttpServerFactory { } }); - return server.listen(port); + if (typeof portOrSocket === 'string') { + if (process.platform === 'win32') { + throw new Error('Windows does not support Unix Domain Sockets'); + } + const result = server.listen(portOrSocket); + this.logger.info(`Listening to server at ${server.address()}`); + return result; + } + const url = new URL(`${protocol}://localhost:${portOrSocket}/`).href; + this.logger.info(`Listening to server at ${url}`); + return server.listen(portOrSocket); } private createServerOptions(): BaseHttpServerOptions { diff --git a/src/server/HttpServerFactory.ts b/src/server/HttpServerFactory.ts index a0bd50f19..20ad8ccd0 100644 --- a/src/server/HttpServerFactory.ts +++ b/src/server/HttpServerFactory.ts @@ -4,5 +4,8 @@ import type { Server } from 'http'; * A factory for HTTP servers */ export interface HttpServerFactory { - startServer: (port: number) => Server; + /* eslint-disable @typescript-eslint/method-signature-style */ + startServer(port: number): Server; + startServer(socket: string): Server; + startServer(portOrSocket: number | string): Server; } diff --git a/src/server/WebSocketServerFactory.ts b/src/server/WebSocketServerFactory.ts index 5826e1501..7aa97fe90 100644 --- a/src/server/WebSocketServerFactory.ts +++ b/src/server/WebSocketServerFactory.ts @@ -18,7 +18,9 @@ export class WebSocketServerFactory implements HttpServerFactory { this.webSocketHandler = webSocketHandler; } - public startServer(port: number): Server { + public startServer(port: number): Server; + public startServer(socket: string): Server; + public startServer(portOrSocket: number | string): Server { // Create WebSocket server const webSocketServer = new WebSocketServer({ noServer: true }); webSocketServer.on('connection', async(webSocket: WebSocket, upgradeRequest: HttpRequest): Promise => { @@ -26,7 +28,7 @@ export class WebSocketServerFactory implements HttpServerFactory { }); // Create base HTTP server - const httpServer = this.baseServerFactory.startServer(port); + const httpServer = this.baseServerFactory.startServer(portOrSocket); httpServer.on('upgrade', (upgradeRequest: HttpRequest, socket: Socket, head: Buffer): void => { webSocketServer.handleUpgrade(upgradeRequest, socket, head, (webSocket: WebSocket): void => { webSocketServer.emit('connection', webSocket, upgradeRequest); diff --git a/test/integration/Config.ts b/test/integration/Config.ts index 7556e9e3d..6542e0040 100644 --- a/test/integration/Config.ts +++ b/test/integration/Config.ts @@ -51,6 +51,7 @@ export function getDefaultVariables(port: number, baseUrl?: string): Record { expect(serverFactory.startServer).toHaveBeenCalledWith(3000); }); + it('starts a server on the specified Unix Domain Socket.', async(): Promise => { + initializer = new ServerInitializer(serverFactory, undefined, '/tmp/css.sock'); + await initializer.handle(); + expect(serverFactory.startServer).toHaveBeenCalledWith('/tmp/css.sock'); + }); + + it('throws when neither port or socket are set.', async(): Promise => { + expect((): void => { + initializer = new ServerInitializer(serverFactory, undefined, undefined); + }).toThrow('Either Port or Socket arguments must be set'); + }); + it('can stop the server.', async(): Promise => { await initializer.handle(); await expect(initializer.finalize()).resolves.toBeUndefined(); diff --git a/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts index 21b46e36a..55e4281fc 100644 --- a/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts +++ b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts @@ -16,6 +16,11 @@ describe('A BaseUrlExtractor', (): void => { await expect(computer.handle({ port: 3333 })).resolves.toBe('http://localhost:3333/'); }); + it('throws when a Unix Socket Path is provided without a baseUrl.', async(): Promise => { + await expect(computer.handle({ socket: '/tmp/css.sock' })).rejects + .toThrow('BaseUrl argument should be provided when using Unix Domain Sockets.'); + }); + it('defaults to port 3000.', async(): Promise => { await expect(computer.handle({})).resolves.toBe('http://localhost:3000/'); }); diff --git a/test/unit/server/BaseHttpServerFactory.test.ts b/test/unit/server/BaseHttpServerFactory.test.ts index e380431cf..a6cafa2a5 100644 --- a/test/unit/server/BaseHttpServerFactory.test.ts +++ b/test/unit/server/BaseHttpServerFactory.test.ts @@ -1,3 +1,4 @@ +import { mkdirSync } from 'fs'; import type { Server } from 'http'; import request from 'supertest'; import type { BaseHttpServerOptions } from '../../../src/server/BaseHttpServerFactory'; @@ -5,7 +6,8 @@ import { BaseHttpServerFactory } from '../../../src/server/BaseHttpServerFactory import type { HttpHandler } from '../../../src/server/HttpHandler'; import type { HttpResponse } from '../../../src/server/HttpResponse'; import { joinFilePath } from '../../../src/util/PathUtil'; -import { getPort } from '../../util/Util'; +import { getTestFolder, removeFolder } from '../../integration/Config'; +import { getPort, getSocket } from '../../util/Util'; const port = getPort('BaseHttpServerFactory'); @@ -125,4 +127,91 @@ describe('A BaseHttpServerFactory', (): void => { expect(res.text).toBe(`${error.stack}\n`); }); }); + + describe('A Base HttpServerFactory (With Unix Sockets)', (): void => { + const socketFolder = getTestFolder('sockets'); + const socket = joinFilePath(socketFolder, getSocket('BaseHttpServerFactory')); + const httpOptions = { + http: true, + showStackTrace: true, + }; + + beforeAll(async(): Promise => { + mkdirSync(socketFolder, { recursive: true }); + }); + + afterAll(async(): Promise => { + server.close(); + await removeFolder(socketFolder); + }); + + beforeEach(async(): Promise => { + jest.clearAllMocks(); + }); + describe('On linux', (): void => { + if (process.platform === 'win32') { + return; + } + it('sends incoming requests to the handler.', async(): Promise => { + const factory = new BaseHttpServerFactory(handler, httpOptions); + server = factory.startServer(socket); + await request(`http+unix://${socket.replace(/\//gui, '%2F')}`).get('/').set('Host', 'test.com').expect(200); + + expect(handler.handleSafe).toHaveBeenCalledTimes(1); + expect(handler.handleSafe).toHaveBeenLastCalledWith({ + request: expect.objectContaining({ + headers: expect.objectContaining({ host: 'test.com' }), + }), + response: expect.objectContaining({}), + }); + }); + + it('throws an error on windows.', async(): Promise => { + const prevPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + + const factory = new BaseHttpServerFactory(handler, httpOptions); + expect((): void => { + factory.startServer(socket); + }).toThrow(); + + Object.defineProperty(process, 'platform', { + value: prevPlatform, + }); + }); + }); + + describe('On Windows', (): void => { + if (process.platform !== 'win32') { + return; + } + it('throws an error when trying to start the server on windows.', async(): Promise => { + const factory = new BaseHttpServerFactory(handler, httpOptions); + expect((): void => { + factory.startServer(socket); + }).toThrow(); + }); + }); + + describe('On any platform', (): void => { + it('throws an error when trying to start with an invalid socket Path.', async(): Promise => { + const prevPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + + const factory = new BaseHttpServerFactory(handler, httpOptions); + factory.startServer('/fake/path') + .on('error', (error): void => { + expect(error).toHaveProperty('code', 'EACCES'); + }); + + Object.defineProperty(process, 'platform', { + value: prevPlatform, + }); + }); + }); + }); }); diff --git a/test/util/Util.ts b/test/util/Util.ts index 92ed731da..3ea110629 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -33,6 +33,11 @@ const portNames = [ 'BaseHttpServerFactory', ] as const; +const socketNames = [ + // Unit + 'BaseHttpServerFactory', +]; + export function getPort(name: typeof portNames[number]): number { const idx = portNames.indexOf(name); // Just in case something doesn't listen to the typings @@ -42,6 +47,15 @@ export function getPort(name: typeof portNames[number]): number { return 6000 + idx; } +export function getSocket(name: typeof socketNames[number]): string { + const idx = socketNames.indexOf(name); + // Just in case something doesn't listen to the typings + if (idx < 0) { + throw new Error(`Unknown socket name ${name}`); + } + return `css${idx}.sock`; +} + export function describeIf(envFlag: string): Describe { const flag = `TEST_${envFlag.toUpperCase()}`; const enabled = !/^(|0|false)$/iu.test(process.env[flag] ?? '');