diff --git a/.env.example b/.env.example index 17ba03e..b8b75bd 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ # Server PORT=4208 +# HTTPS server port (defaults to 8443, set to 0 or false to disable) +HTTPS_PORT=8443 +# Optional custom TLS cert/key paths (defaults to auto-generated certs/key.pem and certs/cert.pem) +# HTTPS_KEY_PATH=./certs/key.pem +# HTTPS_CERT_PATH=./certs/cert.pem BODY_LIMIT=100mb # CORS — comma-separated allowed origins diff --git a/.gitignore b/.gitignore index 4ea6047..bc61670 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ .env data/ logs/ -print_spool/ \ No newline at end of file +print_spool/ +certs/ \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md index e921696..e69a22b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -2,6 +2,7 @@ ``` http://localhost:4208/api +https://localhost:8443/api ``` ## CORS & cookies diff --git a/package-lock.json b/package-lock.json index 4d817ff..daaaf97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "multer": "^2.4.0", "react": "^19.3.0", "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor", + "selfsigned": "^5.5.0", "sharp": "^0.35.4" }, "devDependencies": { @@ -708,6 +709,205 @@ "node": ">=18" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.10.0.tgz", + "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.10.0.tgz", + "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.10.0.tgz", + "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.10.0.tgz", + "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-rsa": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.10.0.tgz", + "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.10.0.tgz", + "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pfx": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.10.0.tgz", + "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.10.0.tgz", + "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.10.0.tgz", + "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -821,6 +1021,20 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -1009,6 +1223,15 @@ "node": ">= 0.8" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2918,6 +3141,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pkijs": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", + "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], + "dependencies": { + "@noble/hashes": "1.8.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2987,6 +3230,24 @@ "once": "^1.3.1" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -3142,6 +3403,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3231,6 +3498,19 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -3709,8 +3989,25 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", diff --git a/package.json b/package.json index 4a4408e..41873da 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "multer": "^2.4.0", "react": "^19.3.0", "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor", + "selfsigned": "^5.5.0", "sharp": "^0.35.4" }, "devDependencies": { diff --git a/src/api/index.js b/src/api/index.js index d87d2a3..08b77bf 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,3 +1,5 @@ +import http from 'http'; +import https from 'https'; import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; @@ -9,6 +11,7 @@ import env from '../config/env.js'; import { authMiddleware } from './middleware/auth.js'; import { ensureConnection } from '../db/store.js'; import { closeAll as closeAllSse } from './sse.js'; +import { getOrCreateCert } from '../util/cert.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -36,6 +39,7 @@ async function loadRoutes(app, basePath = '') { } let server; +let httpsServer; let starting; let startedAt; @@ -95,8 +99,34 @@ async function createApi() { return app; } +function listenServer(instance, port, protocol) { + return new Promise((resolve, reject) => { + instance.once('listening', () => { + resolve(); + }); + + instance.once('error', (err) => { + if (err.code === 'EADDRINUSE') { + let detail = ''; + try { + const pids = execSync(`lsof -ti :${port}`, { encoding: 'utf8' }).trim(); + if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`; + } catch { + // lsof unavailable or no match + } + console.error(`Port ${port} (${protocol}) is already in use${detail}. Check with: lsof -i :${port}`); + } else { + console.error(`${protocol} server error:`, err.message); + } + reject(err); + }); + + instance.listen(port); + }); +} + export async function startApi() { - if (server) { + if (server || httpsServer) { return server; } if (starting) { @@ -109,31 +139,22 @@ export async function startApi() { startedAt = new Date().toISOString(); const app = await createApi(); - await new Promise((resolve, reject) => { - const instance = app.listen(env.port); + const httpInstance = http.createServer(app); + await listenServer(httpInstance, env.port, 'HTTP'); + server = httpInstance; + console.log(`API listening on port ${env.port} (HTTP)`); - instance.once('listening', () => { - server = instance; - console.log(`API listening on port ${env.port}`); - resolve(); - }); - - instance.once('error', (err) => { - if (err.code === 'EADDRINUSE') { - let detail = ''; - try { - const pids = execSync(`lsof -ti :${env.port}`, { encoding: 'utf8' }).trim(); - if (pids) detail = ` (PID ${pids.replace(/\n/g, ', ')})`; - } catch { - // lsof unavailable or no match - } - console.error(`Port ${env.port} is already in use${detail}. Check with: lsof -i :${env.port}`); - } else { - console.error('Server error:', err.message); - } - reject(err); - }); - }); + if (env.httpsPort) { + try { + const tls = await getOrCreateCert(); + const httpsInstance = https.createServer(tls, app); + await listenServer(httpsInstance, env.httpsPort, 'HTTPS'); + httpsServer = httpsInstance; + console.log(`API listening on port ${env.httpsPort} (HTTPS self-signed)`); + } catch (err) { + console.error('Failed to start HTTPS server:', err.message); + } + } return server; })(); @@ -146,13 +167,26 @@ export async function startApi() { } export async function stopApi() { - if (!server) return; + if (!server && !httpsServer) return; // Long-lived SSE (and keep-alive) sockets otherwise block server.close() // forever — node --watch hangs on "Waiting for graceful termination...". closeAllSse(); - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - server.closeAllConnections(); - }); - server = undefined; + const closers = []; + if (server) { + const s = server; + server = undefined; + closers.push(new Promise((resolve, reject) => { + s.close((err) => (err ? reject(err) : resolve())); + s.closeAllConnections(); + })); + } + if (httpsServer) { + const s = httpsServer; + httpsServer = undefined; + closers.push(new Promise((resolve, reject) => { + s.close((err) => (err ? reject(err) : resolve())); + s.closeAllConnections(); + })); + } + await Promise.all(closers); } diff --git a/src/config/env.js b/src/config/env.js index 0efc2f6..f676e06 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -5,8 +5,18 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: resolve(__dirname, '../../.env'), quiet: true }); +function parseHttpsPort(val) { + if (val === undefined || val === '') return 8443; + if (val === 'false' || val === '0' || val === 'none' || val === 'disabled') return null; + const n = Number(val); + return Number.isInteger(n) && n > 0 ? n : 8443; +} + export default { port: Number(process.env.PORT) || 4208, + httpsPort: parseHttpsPort(process.env.HTTPS_PORT), + httpsKeyPath: process.env.HTTPS_KEY_PATH || resolve(__dirname, '../../certs/key.pem'), + httpsCertPath: process.env.HTTPS_CERT_PATH || resolve(__dirname, '../../certs/cert.pem'), bodyLimit: process.env.BODY_LIMIT || '100mb', corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173') .split(',') diff --git a/src/util/cert.js b/src/util/cert.js new file mode 100644 index 0000000..ad51140 --- /dev/null +++ b/src/util/cert.js @@ -0,0 +1,71 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import selfsigned from 'selfsigned'; +import env from '../config/env.js'; + +function getLocalIPv4() { + const addresses = []; + for (const iface of Object.values(os.networkInterfaces())) { + if (!iface) continue; + for (const addr of iface) { + if (addr.family === 'IPv4' && !addr.internal) { + addresses.push(addr.address); + } + } + } + return addresses; +} + +/** + * Loads existing TLS credentials or generates a self-signed certificate. + * @returns {Promise<{ key: Buffer | string, cert: Buffer | string }>} + */ +export async function getOrCreateCert() { + const keyPath = env.httpsKeyPath; + const certPath = env.httpsCertPath; + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + return { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }; + } + + const keyDir = path.dirname(keyPath); + if (!fs.existsSync(keyDir)) { + fs.mkdirSync(keyDir, { recursive: true }); + } + + const certDir = path.dirname(certPath); + if (!fs.existsSync(certDir)) { + fs.mkdirSync(certDir, { recursive: true }); + } + + const altNames = [ + { type: 2, value: 'localhost' }, + { type: 7, ip: '127.0.0.1' }, + ]; + for (const ip of getLocalIPv4()) { + altNames.push({ type: 7, ip }); + } + + const pems = await selfsigned.generate( + [{ name: 'commonName', value: 'localhost' }], + { + days: 365, + algorithm: 'sha256', + extensions: [{ name: 'subjectAltName', altNames }], + } + ); + + fs.writeFileSync(keyPath, pems.private, { mode: 0o600 }); + fs.writeFileSync(certPath, pems.cert); + + console.log(`[tls] Generated self-signed certificate at ${certPath}`); + + return { + key: pems.private, + cert: pems.cert, + }; +}