diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index a0089735..0e9497ae 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -1,4 +1,5 @@ import http from 'http'; +import https from 'https'; import gzipStatic, {RequestHandler} from 'connect-gzip-static'; import finalhandler from 'finalhandler'; import logger from '../util/logger'; @@ -6,6 +7,7 @@ import frontend from 'zigbee2mqtt-frontend'; import WebSocket from 'ws'; import net from 'net'; import url from 'url'; +import fs from 'fs'; import * as settings from '../util/settings'; import utils from '../util/utils'; import stringify from 'json-stable-stringify-without-jsonify'; @@ -19,6 +21,8 @@ export default class Frontend extends Extension { private mqttBaseTopic = settings.get().mqtt.base_topic; private host = settings.get().frontend.host; private port = settings.get().frontend.port; + private sslCert = settings.get().frontend.ssl_cert; + private sslKey = settings.get().frontend.ssl_key; private authToken = settings.get().frontend.auth_token; private retainedMessages = new Map(); private server: http.Server; @@ -33,8 +37,28 @@ export default class Frontend extends Extension { this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage); } + private isHttpsConfigured():boolean { + if (this.sslCert && this.sslKey) { + if (!fs.existsSync(this.sslCert) || !fs.existsSync(this.sslKey)) { + logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`); /* eslint-disable-line max-len */ + return false; + } + return true; + } + return false; + } + + override async start(): Promise { - this.server = http.createServer(this.onRequest); + if (this.isHttpsConfigured()) { + const serverOptions = { + key: fs.readFileSync(this.sslKey), + cert: fs.readFileSync(this.sslCert)}; + this.server = https.createServer(serverOptions, this.onRequest); + } else { + this.server = http.createServer(this.onRequest); + } + this.server.on('upgrade', this.onUpgrade); /* istanbul ignore next */ diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 859d7388..8ce79669 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -233,6 +233,8 @@ declare global { host?: string, port?: number, url?: string, + ssl_cert?: string, + ssl_key?: string, }, devices?: {[s: string]: DeviceOptions}, groups?: {[s: string]: GroupOptions}, diff --git a/lib/util/settings.schema.json b/lib/util/settings.schema.json index eb661f41..8bed6896 100644 --- a/lib/util/settings.schema.json +++ b/lib/util/settings.schema.json @@ -388,6 +388,18 @@ "title": "URL", "description": "URL on which the frontend can be reached, currently only used for the Home Assistant device configuration page", "requiresRestart": true + }, + "ssl_cert" : { + "type": ["string", "null"], + "title": "Certificate file path", + "description": "SSL Certificate file path for exposing HTTPS. The sibling property 'ssl_key' must be set for HTTPS to be activated.", + "requiresRestart": true + }, + "ssl_key" : { + "type": ["string", "null"], + "title": "key file path", + "description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.", + "requiresRestart": true } } } diff --git a/test/assets/certs/dummy.crt b/test/assets/certs/dummy.crt new file mode 100644 index 00000000..628c6cdb --- /dev/null +++ b/test/assets/certs/dummy.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIUU70Q1tbGP071yeQ8EcHd/0rxtnwwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjEyMDMwMDA1MTBaFw0yMzAx +MDIwMDA1MTBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDR6bYQPgkfWlGQq19U2nMsAo0RZR7wSUuFS0MFpbQS +2Rr5x/4FwwKhTMOlfr6H2Xo/5pQvGOlhFE69j1HxQRooWDAswnscMv/9pb+BUqoE +7J29SEndIpmCaI0c3ejZsu4Nq8DxGod/RsQhJdbDU+hkw8qARJ0JjnTb+aN1KH8g +J3KeSMTOL3guBVZQujO0Unbjv47yBIFwi0IihR18aoRaxinb94Aqf92HcjZN++WU +gq7/2Qlm3tF5uJwuNtG/DmzReSblRcUgzPri7qIhb9z51gFNdNX6cTH3yNoF/KtT +XwEArvvsAyMCYuC6w8KI7WNmPI6yxSksgHdcNPlUVZYquLD+z2kYAdIByiyHmIpm +OH3jVN7lRJ6xITOGmyNktfRuSUi2GzECAOWIGpDD/5/gK1pacbOXpJWqVrTjUIAN +BrQf496CbabVgwaQEvq1QJJbuMbpma21/IlS6NpZS7vm/q1ojNsbEB6PYkSsBTzP +iZiiaomjhV2w5yIp5Zn8xyMFVKLb+SEDQBtZZ6CErmbgqp4B3U2b4k5LippLmZZK +pSk7BOuMpsZK8FIGS4KlANFfGVs79y6N+Jxue2IO9pRcpcpbqOXty4662BsPKInw +dKxKTr6TcTe0gEq1fpf1npYwcZ9t36LMJ7L51TbaaJ5sEsXe5QNOOOUgSwPqWmrE +lwIDAQABo1MwUTAdBgNVHQ4EFgQUYD/x9cCuGJgLUPkYLIVa8uvseBkwHwYDVR0j +BBgwFoAUYD/x9cCuGJgLUPkYLIVa8uvseBkwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAI+gp+8qqO4anj8NHgjJvXhrETLPJIeLuBp/cjF9HA2IL +oAd5rXWPnQ6qgh3q/HazILgIHwI0wSrhVtRlprgGIxNa59cD3RcZCtqFSWIqhAqZ +JnAye3bnGU/KahazQX3oP+y564b/QZvwpJDKC1EoMv1wdpXND2NVn4HwaBc7Tu/d +SX/Tr6k+e32K8mwE+00/yf2rLrmWmNjWtP0f7zRmH3j1+ovktk7UoVhjKXfhqMUE +pWdhPZIXQJ65JNMGKdeFI2ULQY9IbXHeWQUgBCyhTciMvm2XZzopk7rxrh9NZopS +pAlCrWBkOZrs6hfZrus9FOzwSkeIEY7NnhNk05kslERJxjkoFytp7mDd4mTKCVW0 +dKc7G1hZWYfO3oe0Zjxi0ragyReNKtyL1BqMbDKJmxHl91iPbdueWix1xxhNFakH +l3eOzfa/DEixvzxy3hzCcvagNb5RsXFVMQXQTgnIW/5GcG5VVB2/o7KidPL3XtqR +Hc/0ebQvloGXgqJbyTXG9EInkwB35wXDQuHgwt1PhwzP1yfp2BCvDsQWrR3IxnBT +DO1o2RpsurgCgUpd0vcW17DePLNfprrhhU2uTPn2bNcWdT/rZmEImkNaA29Rku+3 +SZQJZWIx4s2FMdVdAv6ZauROStTY/WayudfcFfP+hYvJ5/ccQ9Gkb7vrGUssFRw= +-----END CERTIFICATE----- diff --git a/test/assets/certs/dummy.key b/test/assets/certs/dummy.key new file mode 100644 index 00000000..f7ba7110 --- /dev/null +++ b/test/assets/certs/dummy.key @@ -0,0 +1,54 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +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 +yvytkM9LPan2/GJM4zLFeA== +-----END ENCRYPTED PRIVATE KEY----- diff --git a/test/frontend.test.js b/test/frontend.test.js index 3e618650..df088867 100644 --- a/test/frontend.test.js +++ b/test/frontend.test.js @@ -7,6 +7,7 @@ const Controller = require('../lib/controller'); const stringify = require('json-stable-stringify-without-jsonify'); const flushPromises = require('./lib/flushPromises'); const zigbeeHerdsman = require('./stub/zigbeeHerdsman'); +const path = require("path"); jest.spyOn(process, 'exit').mockImplementation(() => {}); const mockHTTP = { @@ -19,6 +20,16 @@ const mockHTTP = { events: {}, }; +const mockHTTPS = { + implementation: { + listen: jest.fn(), + on: (event, handler) => {mockHTTPS.events[event] = handler}, + close: jest.fn().mockImplementation((cb) => cb()), + }, + variables: {}, + events: {}, +}; + const mockWSocket = { close: jest.fn(), }; @@ -50,6 +61,13 @@ jest.mock('http', () => ({ }), })); +jest.mock('https', () => ({ + createServer: jest.fn().mockImplementation((onRequest) => { + mockHTTPS.variables.onRequest = onRequest; + return mockHTTPS.implementation; + }), +})); + jest.mock("connect-gzip-static", () => jest.fn().mockImplementation((path) => { mockNodeStatic.variables.path = path @@ -98,7 +116,6 @@ describe('Frontend', () => { await controller.start(); expect(mockNodeStatic.variables.path).toBe("my/dummy/path"); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); - const mockWSClient = { implementation: { terminate: jest.fn(), @@ -111,6 +128,46 @@ describe('Frontend', () => { expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1); expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1); expect(mockWS.implementation.close).toHaveBeenCalledTimes(1); + mockHTTP.implementation.listen.mockClear(); + mockHTTPS.implementation.listen.mockClear(); + }); + + + it('Start/stop HTTPS valid', async () => { + settings.set(['frontend','ssl_cert'], path.join(__dirname,'assets','certs','dummy.crt')); + settings.set(['frontend','ssl_key'], path.join(__dirname,'assets','certs','dummy.key')); + controller = new Controller(jest.fn(), jest.fn()); + await controller.start(); + expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); + expect(mockHTTPS.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); + await controller.stop(); + mockHTTP.implementation.listen.mockClear(); + mockHTTPS.implementation.listen.mockClear(); + }); + + it('Start/stop HTTPS invalid : missing config', async () => { + settings.set(['frontend','ssl_cert'], path.join(__dirname,'assets','certs','dummy.crt')); + controller = new Controller(jest.fn(), jest.fn()); + await controller.start(); + expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); + expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); + await controller.stop(); + mockHTTP.implementation.listen.mockClear(); + mockHTTPS.implementation.listen.mockClear(); + + }); + + it('Start/stop HTTPS invalid : missing file', async () => { + settings.set(['frontend','ssl_cert'], 'filesNotExists.crt'); + settings.set(['frontend','ssl_key'], path.join(__dirname,'assets','certs','dummy.key')); + controller = new Controller(jest.fn(), jest.fn()); + await controller.start(); + expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); + expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, "127.0.0.1"); + await controller.stop(); + mockHTTP.implementation.listen.mockClear(); + mockHTTPS.implementation.listen.mockClear(); + }); it('Websocket interaction', async () => { @@ -140,7 +197,7 @@ describe('Frontend', () => { expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bulb_color', - stringify({state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: "unknown"}}), + stringify({state: 'ON', power_on_behavior:null, linkquality: null, update_available: null, update: {state: null, installed_version: "unknown"}}), { retain: false, qos: 0 }, expect.any(Function) );