From 7faad0aef0f0d9d5c106e57c16e17340cb1ba303 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 27 May 2021 13:00:52 +0200 Subject: [PATCH] feat: Support creation of HTTPS server --- config/example-https-file.json | 53 ++++++++ config/http/README.md | 1 + config/http/server-factory/https-example.json | 22 ++++ config/http/server-factory/no-websockets.json | 4 +- src/server/BaseHttpServerFactory.ts | 53 ++++++-- test/assets/https/server.cert | 21 ++++ test/assets/https/server.key | 28 +++++ .../unit/server/BaseHttpServerFactory.test.ts | 116 +++++++++++------- test/util/Util.ts | 3 + 9 files changed, 246 insertions(+), 55 deletions(-) create mode 100644 config/example-https-file.json create mode 100644 config/http/server-factory/https-example.json create mode 100644 test/assets/https/server.cert create mode 100644 test/assets/https/server.key diff --git a/config/example-https-file.json b/config/example-https-file.json new file mode 100644 index 000000000..babd02609 --- /dev/null +++ b/config/example-https-file.json @@ -0,0 +1,53 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld", + "import": [ + "files-scs:config/http/handler/default.json", + "files-scs:config/http/middleware/websockets.json", + + "files-scs:config/http/static/default.json", + "files-scs:config/identity/handler/default.json", + "files-scs:config/identity/email/default.json", + "files-scs:config/init/handler/default.json", + "files-scs:config/ldp/authentication/dpop-bearer.json", + "files-scs:config/ldp/authorization/webacl.json", + "files-scs:config/ldp/handler/default.json", + "files-scs:config/ldp/metadata-parser/default.json", + "files-scs:config/ldp/metadata-writer/default.json", + "files-scs:config/ldp/permissions/acl.json", + "files-scs:config/pod/handler/static.json", + "files-scs:config/storage/key-value/memory.json", + "files-scs:config/storage/resource-store/file.json", + "files-scs:config/util/auxiliary/acl.json", + "files-scs:config/util/identifiers/suffix.json", + "files-scs:config/util/index/default.json", + "files-scs:config/util/logging/winston.json", + "files-scs:config/util/representation-conversion/default.json", + "files-scs:config/util/resource-locker/memory.json", + "files-scs:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": [ + "An example of what a config could look like if HTTPS is required.", + "The http/server-factory import above has been omitted since that feature is set below." + ] + }, + { + "comment": "The key/cert values should be replaces with paths to the correct files. The 'options' block can be removed if not needed.", + "@id": "urn:solid-server:default:ServerFactory", + "@type": "WebSocketServerFactory", + "baseServerFactory": { + "@id": "urn:solid-server:default:HttpServerFactory", + "@type": "BaseHttpServerFactory", + "handler": { "@id": "urn:solid-server:default:HttpHandler" }, + "options_https": true, + "options_key": "/path/to/server.key", + "options_cert": "/path/to/server.cert" + }, + "webSocketHandler": { + "@type": "UnsecureWebSocketsProtocol", + "source": { "@id": "urn:solid-server:default:ResourceStore" } + } + } + ] +} diff --git a/config/http/README.md b/config/http/README.md index 9c264fad5..f0a4253f4 100644 --- a/config/http/README.md +++ b/config/http/README.md @@ -16,6 +16,7 @@ and then pass the request along. The factory used to create the actual server object. * *no-websockets*: Only HTTP. * *websockets*: HTTP and websockets. +* *https-example*: An example configuration to use HTTPS directly at the server (instead of at a reverse proxy). ## Static Support for static files that should be found at a specific path. diff --git a/config/http/server-factory/https-example.json b/config/http/server-factory/https-example.json new file mode 100644 index 000000000..d3e069e2b --- /dev/null +++ b/config/http/server-factory/https-example.json @@ -0,0 +1,22 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^0.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "An example of how to set up a server with HTTPS", + "@id": "urn:solid-server:default:ServerFactory", + "@type": "WebSocketServerFactory", + "baseServerFactory": { + "@id": "urn:solid-server:default:HttpServerFactory", + "@type": "BaseHttpServerFactory", + "handler": { "@id": "urn:solid-server:default:HttpHandler" }, + "options_https": true, + "options_key": "/path/to/server.key", + "options_cert": "/path/to/server.cert" + }, + "webSocketHandler": { + "@type": "UnsecureWebSocketsProtocol", + "source": { "@id": "urn:solid-server:default:ResourceStore" } + } + } + ] +} diff --git a/config/http/server-factory/no-websockets.json b/config/http/server-factory/no-websockets.json index 8e39069b5..ca9a9296c 100644 --- a/config/http/server-factory/no-websockets.json +++ b/config/http/server-factory/no-websockets.json @@ -5,9 +5,7 @@ "comment": "Creates a server that supports HTTP requests.", "@id": "urn:solid-server:default:ServerFactory", "@type": "BaseHttpServerFactory", - "handler": { - "@id": "urn:solid-server:default:HttpHandler" - } + "handler": { "@id": "urn:solid-server:default:HttpHandler" } } ] } diff --git a/src/server/BaseHttpServerFactory.ts b/src/server/BaseHttpServerFactory.ts index ee45ae6bd..8c02f89b5 100644 --- a/src/server/BaseHttpServerFactory.ts +++ b/src/server/BaseHttpServerFactory.ts @@ -1,11 +1,33 @@ +import { readFileSync } from 'fs'; import type { Server, IncomingMessage, ServerResponse } from 'http'; -import { createServer } from 'http'; +import { createServer as createHttpServer } from 'http'; +import { createServer as createHttpsServer } from 'https'; +import { URL } from 'url'; import { getLoggerFor } from '../logging/LogUtil'; import { isNativeError } from '../util/errors/ErrorUtil'; import { guardStream } from '../util/GuardedStream'; import type { HttpHandler } from './HttpHandler'; import type { HttpServerFactory } from './HttpServerFactory'; +/** + * Options to be used when creating the server. + * Due to Components.js not supporting external types, this has been simplified (for now?). + * The common https keys here (key/cert/pfx) will be interpreted as file paths that need to be read + * before passing the options to the `createServer` function. + */ +export interface BaseHttpServerOptions { + /** + * If the server should start as an http or https server. + */ + https?: boolean; + + key?: string; + cert?: string; + + pfx?: string; + passphrase?: string; +} + /** * HttpServerFactory based on the native Node.js http module */ @@ -14,19 +36,26 @@ export class BaseHttpServerFactory implements HttpServerFactory { /** The main HttpHandler */ private readonly handler: HttpHandler; + private readonly options: BaseHttpServerOptions; - public constructor(handler: HttpHandler) { + public constructor(handler: HttpHandler, options: BaseHttpServerOptions = { https: false }) { this.handler = handler; + this.options = { ...options }; } /** - * Creates and starts an HTTP server + * Creates and starts an HTTP(S) server * @param port - Port on which the server listens */ public startServer(port: number): Server { - this.logger.info(`Starting server at http://localhost:${port}/`); + const protocol = this.options.https ? 'https' : 'http'; + const url = new URL(`${protocol}://localhost:${port}/`).href; + this.logger.info(`Starting server at ${url}`); - const server = createServer( + const createServer = this.options.https ? createHttpsServer : createHttpServer; + const options = this.createServerOptions(); + + const server = createServer(options, async(request: IncomingMessage, response: ServerResponse): Promise => { try { this.logger.info(`Received ${request.method} request for ${request.url}`); @@ -45,9 +74,19 @@ export class BaseHttpServerFactory implements HttpServerFactory { response.writeHead(404).end(); } } - }, - ); + }); return server.listen(port); } + + private createServerOptions(): BaseHttpServerOptions { + const options = { ...this.options }; + for (const id of [ 'key', 'cert', 'pfx' ] as const) { + const val = options[id]; + if (val) { + options[id] = readFileSync(val, 'utf8'); + } + } + return options; + } } diff --git a/test/assets/https/server.cert b/test/assets/https/server.cert new file mode 100644 index 000000000..253ec76ce --- /dev/null +++ b/test/assets/https/server.cert @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDbTCCAlWgAwIBAgIUFoZojFFFwF4/zB9XlZ3zXgZyvo0wDQYJKoZIhvcNAQEL +BQAwRjELMAkGA1UEBhMCQkUxEzARBgNVBAgMClNvbWUtU3RhdGUxDjAMBgNVBAoM +BVNvbGlkMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjEwNTI3MDkwMTQxWhcNMjEw +NjI2MDkwMTQxWjBGMQswCQYDVQQGEwJCRTETMBEGA1UECAwKU29tZS1TdGF0ZTEO +MAwGA1UECgwFU29saWQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAK5C/0PCj2If698eYOCvp4z87oY0YZSI5MZnsbv2 +NWNAA+9ODvycA0oStyqzmGUw4sajqFrJZuYjDZWELnhEgv7bpwGDncHh/KjwPkwl +w0RQkELSUfFPO6uAz/JwaF2XiI1XS11I2EvzSJNPlgrqKWNiAOZ59Vm5Seda+16m +nhnqFJCSQ+bImdGVdnGQUHzHjidwoyv5QFOW6MZd+1jwY+HZFoWFYy9QMAHMR2/I +pKnsfb3zeQYyEhSxwDwwrCiIFfPhUX+wezsVS3KrGZyUeg8Z8/Jb7p3Vk5fXItxP +t2IcOKrU/fnXDdcghRylcX93fs6DzCh9p4x/gyUsAazoDVECAwEAAaNTMFEwHQYD +VR0OBBYEFAS3dWyE56NTg8K+RabiIwSII6njMB8GA1UdIwQYMBaAFAS3dWyE56NT +g8K+RabiIwSII6njMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB +ADFPYohoV7scihIW+nW2KQuflNYc026CSUBbjIh3CtUQKI4uM8d9zY6+hDX7WtF2 +asR29a6f1/BMKDWlHCbBbP17x4FXZwpUCnLPqQS9XEVvC4B+s4hc3wgxMFewvClX +isAZPIpbrOiqpF1unRHmKzyuGsmQmul22YRfeqj3y6qVry/iSdTtjZQY7oy33prC +2MZWxs5C+YSRbpSYmYg6T7jDqpvcuWn4Ta5L04g7lsksE5/hSBML6cLGpCmNbqnx +l1ZJDs9o4DAzMoRux2N1p+ndY7Rly19bOazTBeYGZ7WwWh8LH/TBuM8o2w74dlgq +tUDcKM3uEHZQrIrscK27ef4= +-----END CERTIFICATE----- diff --git a/test/assets/https/server.key b/test/assets/https/server.key new file mode 100644 index 000000000..a7f760c3a --- /dev/null +++ b/test/assets/https/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCuQv9Dwo9iH+vf +HmDgr6eM/O6GNGGUiOTGZ7G79jVjQAPvTg78nANKErcqs5hlMOLGo6hayWbmIw2V +hC54RIL+26cBg53B4fyo8D5MJcNEUJBC0lHxTzurgM/ycGhdl4iNV0tdSNhL80iT +T5YK6iljYgDmefVZuUnnWvtepp4Z6hSQkkPmyJnRlXZxkFB8x44ncKMr+UBTlujG +XftY8GPh2RaFhWMvUDABzEdvyKSp7H2983kGMhIUscA8MKwoiBXz4VF/sHs7FUty +qxmclHoPGfPyW+6d1ZOX1yLcT7diHDiq1P351w3XIIUcpXF/d37Og8wofaeMf4Ml +LAGs6A1RAgMBAAECggEBAKlDSEDf9XfBO9Gf50e3No76ixDuRi4JffW9eOTytE6w +OmIyNtplC8jiPuoKQidgAZYiFwAACqPVPneRSbXmDjtQzXnqBszxHgJWQJykPXPY +sRdGxPMYHARs/Q8m4iiubKOlO/3jKL01FLSJpFr7sbHn2qoDoi5BjKhdNjZsrrrJ +ikoTCZwmHtxxOd98Rb1/VOt/pQsNaOTtfshMVu/gsw7Ruz7jWMMjSklNBXC2rcIh +GGJWO2f/tRpbA+R5efnuUgLFTCHgopsccHDr8c+QhEZkTuKW/jTItspGkylNL0An +n7rlkj7rDztwXMfS/ntNJQYki74s0+5NNYkfQtV+ZQUCgYEA1t0YzCZl4YnRn5tw +kubXhWhiiAeUjg5tYy9enXmbvybzB6qEPU3H3gyOC53B38bEm3fhcDfS74qPz1Pk +XiVDfBNRhMrrOjacTmrzxy+x6B+x0ZcdNhHYlfOvoVKpGs8mT1rlmg6BeFQ12E5K +ho+PekAGZx/Mt9MUcPQGVde66ecCgYEAz5/qlljDawUfnSHflXWl9ql9QGQY0WM4 +w8KFtbG2K6CAhYduIirJ9U6b20GGBW0NKaFip88zGTelYm8TLUBl/gWDuCKbrV3/ +CFdgo3MUuC8+GIYt+dh+nX1t59KHLERNtNQx2pwDEK9/S250PoYf+O3b0THafZ7I +bpwmQShyGAcCgYEAwFIU0R8JkHBQ/sEeaY9QmCwQDdxjHyhQxzfuQ5xHSTkuzczW +Ix1M6jdoqYMitw9uig4q7sw49YqcIKLhxVcraZLNI8SR+oBJNnPLEp5havl7q7PM +RMqCh+4gZZDcpo+Gpf8hhty3DKKrs5qYYIt9jJpkYMf48Q1xvYzfYtT/jD8CgYAm +5BmZF/9i6I7HbDTpViREU/M2QIm1jxRu9tz879Dj0yi/2mJy2/kAjjz7kQZ9tbOl +fKlyLYmwy4+bJJs++rUgJABMWY83pkfDVDqx4ziaV58WEOxDxJ3S+k/AANt5G0JD +AQxlmpuoYHdDtejoXU9X3ZYzVVdL+JYqwe0Yf27/uQKBgQDR1WFoSAppR3ocPzn8 +jP+xF9tNxDJ3Wpohcnw7JAoakbMzP4LQtjwPmRTf9ORgqDjKGanP5m+gTfiPOUos +7eWNVFNKg+7X7Ej8Rst9s6JNrEhsKgz0u0vAvjwsZDCsy6WwrlrdZZkvwb5S+3Ls +M5aHThIzFI2fJX8HGAcnudkKIw== +-----END PRIVATE KEY----- diff --git a/test/unit/server/BaseHttpServerFactory.test.ts b/test/unit/server/BaseHttpServerFactory.test.ts index acd726739..100b84301 100644 --- a/test/unit/server/BaseHttpServerFactory.test.ts +++ b/test/unit/server/BaseHttpServerFactory.test.ts @@ -1,8 +1,13 @@ import type { Server } from 'http'; import request from 'supertest'; +import type { BaseHttpServerOptions } from '../../../src/server/BaseHttpServerFactory'; 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'; + +const port = getPort('BaseHttpServerFactory'); const handler: jest.Mocked = { handleSafe: jest.fn(async(input: { response: HttpResponse }): Promise => { @@ -11,58 +16,79 @@ const handler: jest.Mocked = { }), } as any; -describe('BaseHttpServerFactory', (): void => { +describe('A BaseHttpServerFactory', (): void => { let server: Server; - beforeAll(async(): Promise => { - const factory = new BaseHttpServerFactory(handler); - server = factory.startServer(5555); - }); + const options: [string, BaseHttpServerOptions | undefined][] = [ + [ 'http', undefined ], + [ 'https', { + https: true, + key: joinFilePath(__dirname, '../../assets/https/server.key'), + cert: joinFilePath(__dirname, '../../assets/https/server.cert'), + }], + ]; - afterAll(async(): Promise => { - server.close(); - }); + describe.each(options)('with %s', (protocol, httpOptions): void => { + let rejectTls: string | undefined; + beforeAll(async(): Promise => { + // Allow self-signed certificate + rejectTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - it('sends incoming requests to the handler.', async(): Promise => { - await request(server).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('returns a 404 when the handler does not do anything.', async(): Promise => { - handler.handleSafe.mockResolvedValueOnce(undefined); - - await expect(request(server).get('/').expect(404)).resolves.toBeDefined(); - }); - - it('writes an error to the HTTP response.', async(): Promise => { - handler.handleSafe.mockRejectedValueOnce(new Error('dummyError')); - - const res = await request(server).get('/').expect(500); - expect(res.headers['content-type']).toBe('text/plain; charset=utf-8'); - expect(res.text).toContain('dummyError'); - }); - - it('does not write an error if the response had been started.', async(): Promise => { - handler.handleSafe.mockImplementationOnce(async(input: { response: HttpResponse }): Promise => { - input.response.write('content'); - throw new Error('dummyError'); + const factory = new BaseHttpServerFactory(handler, httpOptions); + server = factory.startServer(port); }); - const res = await request(server).get('/'); - expect(res.text).not.toContain('dummyError'); - }); + beforeEach(async(): Promise => { + jest.clearAllMocks(); + }); - it('throws unknown errors if its handler throw non-Error objects.', async(): Promise => { - handler.handleSafe.mockRejectedValueOnce('apple'); + afterAll(async(): Promise => { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = rejectTls; + server.close(); + }); - const res = await request(server).get('/').expect(500); - expect(res.text).toContain('Unknown error.'); + it('sends incoming requests to the handler.', async(): Promise => { + await request(server).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('returns a 404 when the handler does not do anything.', async(): Promise => { + handler.handleSafe.mockResolvedValueOnce(undefined); + + await expect(request(server).get('/').expect(404)).resolves.toBeDefined(); + }); + + it('writes an error to the HTTP response.', async(): Promise => { + handler.handleSafe.mockRejectedValueOnce(new Error('dummyError')); + + const res = await request(server).get('/').expect(500); + expect(res.headers['content-type']).toBe('text/plain; charset=utf-8'); + expect(res.text).toContain('dummyError'); + }); + + it('does not write an error if the response had been started.', async(): Promise => { + handler.handleSafe.mockImplementationOnce(async(input: { response: HttpResponse }): Promise => { + input.response.write('content'); + throw new Error('dummyError'); + }); + + const res = await request(server).get('/'); + expect(res.text).not.toContain('dummyError'); + }); + + it('throws unknown errors if its handler throw non-Error objects.', async(): Promise => { + handler.handleSafe.mockRejectedValueOnce('apple'); + + const res = await request(server).get('/').expect(500); + expect(res.text).toContain('Unknown error.'); + }); }); }); diff --git a/test/util/Util.ts b/test/util/Util.ts index e2f6b694f..0cb308ea7 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -6,6 +6,7 @@ import type { SystemError } from '../../src/util/errors/SystemError'; /* eslint-disable @typescript-eslint/naming-convention */ const portNames = [ + // Integration 'DynamicPods', 'Identity', 'LpdHandlerWithAuth', @@ -17,6 +18,8 @@ const portNames = [ 'SparqlStorage', 'Subdomains', 'WebSocketsProtocol', + // Unit + 'BaseHttpServerFactory', ] as const; export function getPort(name: typeof portNames[number]): number {