feat: allow server to bind to Unix Domain Sockets

This commit is contained in:
Koen Luyten 2022-11-17 16:57:20 +01:00 committed by Joachim Van Herwegen
parent 0eb50891ec
commit bf0e35be37
15 changed files with 186 additions and 14 deletions

View File

@ -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. |

View File

@ -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" }
}
]
}

View File

@ -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",

View File

@ -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": {

View File

@ -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",

View File

@ -9,19 +9,28 @@ 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<void> {
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<void> {
if (this.server) {

View File

@ -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}/`;
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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<void> => {
@ -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);

View File

@ -51,6 +51,7 @@ export function getDefaultVariables(port: number, baseUrl?: string): Record<stri
return {
'urn:solid-server:default:variable:baseUrl': baseUrl ?? `http://localhost:${port}/`,
'urn:solid-server:default:variable:port': port,
'urn:solid-server:default:variable:socket': null,
'urn:solid-server:default:variable:loggingLevel': 'off',
'urn:solid-server:default:variable:showStackTrace': true,
'urn:solid-server:default:variable:seededPodConfigJson': null,

View File

@ -22,6 +22,18 @@ describe('ServerInitializer', (): void => {
expect(serverFactory.startServer).toHaveBeenCalledWith(3000);
});
it('starts a server on the specified Unix Domain Socket.', async(): Promise<void> => {
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<void> => {
expect((): void => {
initializer = new ServerInitializer(serverFactory, undefined, undefined);
}).toThrow('Either Port or Socket arguments must be set');
});
it('can stop the server.', async(): Promise<void> => {
await initializer.handle();
await expect(initializer.finalize()).resolves.toBeUndefined();

View File

@ -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<void> => {
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<void> => {
await expect(computer.handle({})).resolves.toBe('http://localhost:3000/');
});

View File

@ -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<void> => {
mkdirSync(socketFolder, { recursive: true });
});
afterAll(async(): Promise<void> => {
server.close();
await removeFolder(socketFolder);
});
beforeEach(async(): Promise<void> => {
jest.clearAllMocks();
});
describe('On linux', (): void => {
if (process.platform === 'win32') {
return;
}
it('sends incoming requests to the handler.', async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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,
});
});
});
});
});

View File

@ -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] ?? '');