underpost 2.8.884 → 2.8.886

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.env.production +3 -0
  2. package/.github/workflows/ghpkg.ci.yml +1 -1
  3. package/.github/workflows/npmpkg.ci.yml +1 -1
  4. package/.github/workflows/publish.ci.yml +5 -5
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  7. package/CHANGELOG.md +145 -1
  8. package/Dockerfile +1 -1
  9. package/README.md +5 -121
  10. package/bin/build.js +18 -9
  11. package/bin/deploy.js +102 -197
  12. package/bin/file.js +4 -6
  13. package/cli.md +16 -12
  14. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  15. package/manifests/deployment/dd-test-development/deployment.yaml +54 -54
  16. package/manifests/deployment/dd-test-development/proxy.yaml +4 -4
  17. package/manifests/lxd/underpost-setup.sh +5 -5
  18. package/package.json +3 -3
  19. package/scripts/ssl.sh +164 -0
  20. package/src/cli/baremetal.js +7 -7
  21. package/src/cli/cloud-init.js +1 -1
  22. package/src/cli/cluster.js +31 -3
  23. package/src/cli/cron.js +9 -1
  24. package/src/cli/db.js +64 -2
  25. package/src/cli/deploy.js +189 -4
  26. package/src/cli/env.js +43 -0
  27. package/src/cli/fs.js +96 -2
  28. package/src/cli/image.js +15 -0
  29. package/src/cli/index.js +17 -4
  30. package/src/cli/monitor.js +33 -2
  31. package/src/cli/repository.js +95 -2
  32. package/src/cli/run.js +315 -51
  33. package/src/cli/script.js +32 -0
  34. package/src/cli/secrets.js +34 -0
  35. package/src/cli/test.js +42 -1
  36. package/src/client/components/core/Css.js +16 -8
  37. package/src/client/components/core/Docs.js +5 -13
  38. package/src/client/components/core/Modal.js +48 -29
  39. package/src/client/components/core/Router.js +6 -3
  40. package/src/client/components/core/Worker.js +205 -118
  41. package/src/client/components/core/windowGetDimensions.js +229 -162
  42. package/src/client/components/default/MenuDefault.js +1 -0
  43. package/src/client.dev.js +6 -3
  44. package/src/db/DataBaseProvider.js +65 -12
  45. package/src/db/mariadb/MariaDB.js +39 -6
  46. package/src/db/mongo/MongooseDB.js +51 -133
  47. package/src/index.js +2 -2
  48. package/src/mailer/EmailRender.js +58 -9
  49. package/src/mailer/MailerProvider.js +99 -25
  50. package/src/runtime/express/Express.js +32 -38
  51. package/src/runtime/lampp/Dockerfile +1 -1
  52. package/src/server/auth.js +9 -28
  53. package/src/server/backup.js +20 -0
  54. package/src/server/client-build-live.js +23 -12
  55. package/src/server/client-build.js +136 -91
  56. package/src/server/client-dev-server.js +35 -8
  57. package/src/server/client-icons.js +19 -0
  58. package/src/server/conf.js +543 -80
  59. package/src/server/dns.js +184 -42
  60. package/src/server/downloader.js +65 -24
  61. package/src/server/object-layer.js +260 -162
  62. package/src/server/peer.js +3 -9
  63. package/src/server/proxy.js +93 -76
  64. package/src/server/runtime.js +15 -21
  65. package/src/server/ssr.js +4 -4
  66. package/src/server/start.js +39 -0
  67. package/src/server/tls.js +251 -0
  68. package/src/server/valkey.js +11 -10
  69. package/src/ws/IoInterface.js +133 -39
  70. package/src/ws/IoServer.js +80 -31
  71. package/src/ws/core/core.ws.connection.js +50 -16
  72. package/src/ws/core/core.ws.emit.js +47 -8
  73. package/src/ws/core/core.ws.server.js +62 -10
  74. package/manifests/maas/lxd-preseed.yaml +0 -32
  75. package/src/server/ssl.js +0 -108
  76. /package/{manifests/maas → scripts}/device-scan.sh +0 -0
  77. /package/{manifests/maas → scripts}/gpu-diag.sh +0 -0
  78. /package/{manifests/maas → scripts}/maas-setup.sh +0 -0
  79. /package/{manifests/maas → scripts}/nat-iptables.sh +0 -0
  80. /package/{manifests/maas → scripts}/nvim.sh +0 -0
  81. /package/{manifests/maas → scripts}/snap-clean.sh +0 -0
  82. /package/{manifests/maas → scripts}/ssh-cluster-info.sh +0 -0
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Manages the creation and configuration of the reverse proxy server,
3
+ * including handling HTTP/HTTPS listeners and routing based on host configuration.
4
+ * @module src/server/proxy.js
5
+ * @namespace ProxyService
6
+ */
1
7
  'use strict';
2
8
 
3
9
  import express from 'express';
@@ -5,7 +11,7 @@ import dotenv from 'dotenv';
5
11
 
6
12
  import { createProxyMiddleware } from 'http-proxy-middleware';
7
13
  import { loggerFactory, loggerMiddleware } from './logger.js';
8
- import { createSslServer, sslRedirectMiddleware } from './ssl.js';
14
+ import { TLS } from './tls.js';
9
15
  import { buildPortProxyRouter, buildProxyRouter } from './conf.js';
10
16
  import UnderpostStartUp from './start.js';
11
17
 
@@ -13,81 +19,92 @@ dotenv.config();
13
19
 
14
20
  const logger = loggerFactory(import.meta);
15
21
 
16
- const buildProxy = async () => {
17
- // default target
18
-
19
- express().listen(process.env.PORT);
20
-
21
- const proxyRouter = buildProxyRouter();
22
-
23
- for (let port of Object.keys(proxyRouter)) {
24
- port = parseInt(port);
25
- const hosts = proxyRouter[port];
26
- const proxyPath = '/';
27
- const proxyHost = 'localhost';
28
- const runningData = { host: proxyHost, path: proxyPath, client: null, runtime: 'nodejs', meta: import.meta };
29
- const app = express();
30
-
31
- // set logger
32
- app.use(loggerMiddleware(import.meta));
33
-
34
- // instance proxy options
35
- // https://github.com/chimurai/http-proxy-middleware/tree/v2.0.4#readme
36
-
37
- // proxy middleware options
38
- /** @type {import('http-proxy-middleware/dist/types').Options} */
39
- const options = {
40
- ws: true,
41
- // changeOrigin: true,
42
- // autoRewrite: false,
43
- target: `http://localhost:${process.env.PORT}`,
44
- router: {},
45
- xfwd: true, // adds x-forward headers
46
- // preserveHeaderKeyCase: true,
47
- // secure: true, warn validator
48
- onProxyReq: (proxyReq, req, res, options) => {
49
- // https://wtools.io/check-http-status-code
50
- // http://nexodev.org
51
- sslRedirectMiddleware(req, res, port, proxyRouter);
52
- },
53
- pathRewrite: {
54
- // only add path
55
- // '^/target-path': '/',
56
- },
57
- };
58
- options.router = buildPortProxyRouter(port, proxyRouter);
59
-
60
- const filter = false
61
- ? (pathname, req) => {
62
- // return pathname.match('^/api') && req.method === 'GET';
63
- return true;
64
- }
65
- : proxyPath;
66
- app.use(proxyPath, createProxyMiddleware(filter, options));
67
-
68
- switch (process.env.NODE_ENV) {
69
- case 'production':
70
- switch (port) {
71
- case 443:
72
- const { ServerSSL } = await createSslServer(app, hosts);
73
- await UnderpostStartUp.API.listenPortController(ServerSSL, port, runningData);
74
- break;
75
-
76
- default:
77
- await UnderpostStartUp.API.listenPortController(app, port, runningData);
78
-
79
- break;
80
- }
81
-
82
- break;
83
-
84
- default:
85
- await UnderpostStartUp.API.listenPortController(app, port, runningData);
86
-
87
- break;
22
+ /**
23
+ * Main class for building and running the proxy server.
24
+ * All utility methods are implemented as static to serve as a namespace container.
25
+ * @class Proxy
26
+ * @augments Proxy
27
+ * @memberof ProxyService
28
+ */
29
+ class Proxy {
30
+ /**
31
+ * Initializes and starts the reverse proxy server for all configured ports and hosts.
32
+ * @async
33
+ * @static
34
+ * @memberof ProxyService
35
+ * @returns {Promise<void>}
36
+ * @memberof ProxyService
37
+ */
38
+ static async buildProxy() {
39
+ // Start a default Express listener on process.env.PORT (potentially unused, but ensures Express is initialized)
40
+ express().listen(process.env.PORT);
41
+
42
+ const proxyRouter = buildProxyRouter();
43
+
44
+ for (let port of Object.keys(proxyRouter)) {
45
+ port = parseInt(port);
46
+ const hosts = proxyRouter[port];
47
+ const proxyPath = '/';
48
+ const proxyHost = 'localhost';
49
+ const runningData = { host: proxyHost, path: proxyPath, client: null, runtime: 'nodejs', meta: import.meta };
50
+ const app = express();
51
+
52
+ // Set logger middleware
53
+ app.use(loggerMiddleware(import.meta));
54
+
55
+ // Proxy middleware options
56
+ /** @type {import('http-proxy-middleware/dist/types').Options} */
57
+ const options = {
58
+ ws: true, // Enable websocket proxying
59
+ target: `http://localhost:${process.env.PORT}`, // Default target (should be overridden by router)
60
+ router: {},
61
+ xfwd: true, // Adds x-forward headers (Host, Proto, etc.)
62
+ onProxyReq: (proxyReq, req, res, options) => {
63
+ // Use the static method from the TLS class for redirection logic
64
+ TLS.sslRedirectMiddleware(req, res, port, proxyRouter);
65
+ },
66
+ pathRewrite: {
67
+ // Add path rewrite rules here if necessary
68
+ },
69
+ };
70
+
71
+ options.router = buildPortProxyRouter(port, proxyRouter, { orderByPathLength: true });
72
+
73
+ const filter = proxyPath; // Use '/' as the general filter
74
+ app.use(proxyPath, createProxyMiddleware(filter, options));
75
+
76
+ // Determine which server to start (HTTP or HTTPS) based on port and environment
77
+ switch (process.env.NODE_ENV) {
78
+ case 'production':
79
+ switch (port) {
80
+ case 443:
81
+ // For port 443 (HTTPS), create the SSL server
82
+ const { ServerSSL } = await TLS.createSslServer(app, hosts);
83
+ await UnderpostStartUp.API.listenPortController(ServerSSL, port, runningData);
84
+ break;
85
+
86
+ default:
87
+ // For other ports in production, use standard HTTP
88
+ await UnderpostStartUp.API.listenPortController(app, port, runningData);
89
+ break;
90
+ }
91
+ break;
92
+
93
+ default:
94
+ // In non-production, always use standard HTTP listener
95
+ await UnderpostStartUp.API.listenPortController(app, port, runningData);
96
+ break;
97
+ }
98
+ logger.info('Proxy running', { port, options });
88
99
  }
89
- logger.info('Proxy running', { port, options });
90
100
  }
91
- };
101
+ }
92
102
 
93
- export { buildProxy };
103
+ /**
104
+ * Backward compatibility export
105
+ * @type {function(): Promise<void>}
106
+ * @memberof ProxyService
107
+ */
108
+ const buildProxy = Proxy.buildProxy;
109
+
110
+ export { Proxy, buildProxy };
@@ -1,8 +1,9 @@
1
1
  /**
2
- * @namespace Runtime
3
- * @description The main runtime orchestrator responsible for reading configuration,
2
+ * The main runtime orchestrator responsible for reading configuration,
4
3
  * initializing services (Prometheus, Ports, DB, Mailer), and building the
5
4
  * specific server runtime for each host/path (e.g., nodejs, lampp).
5
+ * @module src/server/runtime.js
6
+ * @namespace Runtime
6
7
  */
7
8
 
8
9
  import fs from 'fs-extra';
@@ -27,11 +28,12 @@ const logger = loggerFactory(import.meta);
27
28
  *
28
29
  * @memberof Runtime
29
30
  * @returns {Promise<void>}
31
+ * @function buildRuntime
30
32
  */
31
33
  const buildRuntime = async () => {
32
34
  const deployId = process.env.DEPLOY_ID;
33
35
 
34
- // 1. Initialize Prometheus Metrics
36
+ // Initialize Prometheus Metrics
35
37
  const collectDefaultMetrics = promClient.collectDefaultMetrics;
36
38
  collectDefaultMetrics();
37
39
 
@@ -45,16 +47,13 @@ const buildRuntime = async () => {
45
47
  const initPort = parseInt(process.env.PORT) + 1;
46
48
  let currentPort = initPort;
47
49
 
48
- // 2. Load Configuration
50
+ // Load Configuration
49
51
  const confServer = JSON.parse(fs.readFileSync(`./conf/conf.server.json`, 'utf8'));
50
52
  const confSSR = JSON.parse(fs.readFileSync(`./conf/conf.ssr.json`, 'utf8'));
51
- const singleReplicaHosts = [];
52
53
 
53
- // 3. Iterate through hosts and paths
54
+ // Iterate through hosts and paths
54
55
  for (const host of Object.keys(confServer)) {
55
- if (singleReplicaHosts.length > 0)
56
- currentPort += singleReplicaHosts.reduce((accumulator, currentValue) => accumulator + currentValue.replicas, 0);
57
-
56
+ let singleReplicaOffsetPortCount = 0;
58
57
  const rootHostPath = `/public/${host}`;
59
58
  for (const path of Object.keys(confServer[host])) {
60
59
  confServer[host][path].port = newInstance(currentPort);
@@ -74,21 +73,19 @@ const buildRuntime = async () => {
74
73
  replicas,
75
74
  valkey,
76
75
  apiBaseHost,
76
+ useLocalSsl,
77
77
  } = confServer[host][path];
78
78
 
79
79
  // Calculate context data
80
- const { redirectTarget, singleReplicaHost } = await getInstanceContext({
80
+ const { redirectTarget, singleReplicaOffsetPortSum } = await getInstanceContext({
81
+ deployId,
81
82
  redirect,
82
- singleReplicaHosts,
83
83
  singleReplica,
84
84
  replicas,
85
85
  });
86
86
 
87
- if (singleReplicaHost) {
88
- singleReplicaHosts.push({
89
- host,
90
- replicas: replicas.length,
91
- });
87
+ if (singleReplicaOffsetPortSum > 0) {
88
+ singleReplicaOffsetPortCount += singleReplicaOffsetPortSum;
92
89
  continue;
93
90
  }
94
91
 
@@ -103,10 +100,6 @@ const buildRuntime = async () => {
103
100
 
104
101
  switch (runtime) {
105
102
  case 'nodejs':
106
- // The devApiPort is used for development CORS origin calculation
107
- // It needs to account for the current port and potential peer server increment
108
- const devApiPort = currentPort + (peer ? 2 : 1);
109
-
110
103
  logger.info('Build nodejs server runtime', `${host}${path}:${port}`);
111
104
 
112
105
  const { portsUsed } = await ExpressService.createApp({
@@ -117,6 +110,7 @@ const buildRuntime = async () => {
117
110
  apis,
118
111
  origins,
119
112
  directory,
113
+ useLocalSsl,
120
114
  ws,
121
115
  mailer,
122
116
  db,
@@ -124,7 +118,6 @@ const buildRuntime = async () => {
124
118
  peer,
125
119
  valkey,
126
120
  apiBaseHost,
127
- devApiPort, // Pass the dynamically calculated dev API port
128
121
  redirectTarget,
129
122
  rootHostPath,
130
123
  confSSR,
@@ -161,6 +154,7 @@ const buildRuntime = async () => {
161
154
  }
162
155
  currentPort++;
163
156
  }
157
+ currentPort += singleReplicaOffsetPortCount;
164
158
  }
165
159
 
166
160
  if (Lampp.enabled() && Lampp.router) Lampp.initService();
package/src/server/ssr.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Module for managing server side rendering
3
3
  * @module src/server/ssr.js
4
- * @namespace SSR
4
+ * @namespace ServerSideRendering
5
5
  */
6
6
 
7
7
  import fs from 'fs-extra';
@@ -23,7 +23,7 @@ const logger = loggerFactory(import.meta);
23
23
  * It reads the component file, formats it, and executes it in a sandboxed Node.js VM context to extract the component.
24
24
  * @param {string} [componentPath='./src/client/ssr/Render.js'] - The path to the SSR component file.
25
25
  * @returns {Promise<Function>} A promise that resolves to the SSR component function.
26
- * @memberof SSR
26
+ * @memberof ServerSideRendering
27
27
  */
28
28
  const ssrFactory = async (componentPath = `./src/client/ssr/Render.js`) => {
29
29
  const context = { SrrComponent: () => {}, npm_package_version: Underpost.version };
@@ -39,7 +39,7 @@ const ssrFactory = async (componentPath = `./src/client/ssr/Render.js`) => {
39
39
  * @param {object} req - The Express request object.
40
40
  * @param {string} html - The HTML string to sanitize.
41
41
  * @returns {string} The sanitized HTML string with nonces.
42
- * @memberof SSR
42
+ * @memberof ServerSideRendering
43
43
  */
44
44
  const sanitizeHtml = (res, req, html) => {
45
45
  const nonce = res.locals.nonce;
@@ -58,7 +58,7 @@ const sanitizeHtml = (res, req, html) => {
58
58
  * @param {string} options.rootHostPath - The root path for the host's public files.
59
59
  * @param {string} options.path - The base path for the instance.
60
60
  * @returns {Promise<{error500: Function, error400: Function}>} A promise that resolves to an object containing the 500 and 404 error handling middleware.
61
- * @memberof SSR
61
+ * @memberof ServerSideRendering
62
62
  */
63
63
  const ssrMiddlewareFactory = async ({ app, directory, rootHostPath, path }) => {
64
64
  const Render = await ssrFactory();
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Manages the startup and runtime configuration of Underpost applications.
3
+ * @module src/server/start.js
4
+ * @namespace UnderpostStartUp
5
+ */
6
+
1
7
  import UnderpostDeploy from '../cli/deploy.js';
2
8
  import fs from 'fs-extra';
3
9
  import { awaitDeployMonitor } from './conf.js';
@@ -7,8 +13,17 @@ import UnderpostRootEnv from '../cli/env.js';
7
13
 
8
14
  const logger = loggerFactory(import.meta);
9
15
 
16
+ /**
17
+ * @class UnderpostStartUp
18
+ * @description Manages the startup and runtime configuration of Underpost applications.
19
+ * @memberof UnderpostStartUp
20
+ */
10
21
  class UnderpostStartUp {
11
22
  static API = {
23
+ /**
24
+ * Logs the runtime network configuration.
25
+ * @memberof UnderpostStartUp
26
+ */
12
27
  logRuntimeRouter: () => {
13
28
  const displayLog = {};
14
29
 
@@ -18,6 +33,12 @@ class UnderpostStartUp {
18
33
 
19
34
  logger.info('Runtime network', displayLog);
20
35
  },
36
+ /**
37
+ * Creates a server factory.
38
+ * @memberof UnderpostStartUp
39
+ * @param {Function} logic - The logic to execute when the server is listening.
40
+ * @returns {Object} An object with a listen method.
41
+ */
21
42
  listenServerFactory: (logic = async () => {}) => {
22
43
  return {
23
44
  listen: async (...args) => {
@@ -36,6 +57,15 @@ class UnderpostStartUp {
36
57
  },
37
58
  };
38
59
  },
60
+
61
+ /**
62
+ * Controls the listening port for a server.
63
+ * @memberof UnderpostStartUp
64
+ * @param {Object} server - The server to listen on.
65
+ * @param {number|string} port - The port number or colon for all ports.
66
+ * @param {Object} metadata - Metadata for the server.
67
+ * @returns {Promise<boolean>} A promise that resolves to true if the server is listening, false otherwise.
68
+ */
39
69
  listenPortController: async (server, port, metadata) =>
40
70
  new Promise((resolve) => {
41
71
  try {
@@ -79,6 +109,15 @@ class UnderpostStartUp {
79
109
  }
80
110
  }),
81
111
 
112
+ /**
113
+ * Starts a deployment.
114
+ * @memberof UnderpostStartUp
115
+ * @param {string} deployId - The ID of the deployment.
116
+ * @param {string} env - The environment of the deployment.
117
+ * @param {Object} options - Options for the deployment.
118
+ * @param {boolean} options.build - Whether to build the deployment.
119
+ * @param {boolean} options.run - Whether to run the deployment.
120
+ */
82
121
  async callback(deployId = 'dd-default', env = 'development', options = { build: false, run: false }) {
83
122
  if (options.build === true) await UnderpostStartUp.API.build(deployId, env);
84
123
  if (options.run === true) await UnderpostStartUp.API.run(deployId, env);
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Provides utilities for managing, building, and serving SSL/TLS contexts,
3
+ * primarily using Certbot files and creating HTTPS servers.
4
+ * @module src/server/tls.js
5
+ * @namespace TransportLayerSecurity
6
+ */
7
+
8
+ import fs from 'fs-extra';
9
+ import https from 'https';
10
+ import path from 'path';
11
+ import dotenv from 'dotenv';
12
+ import { loggerFactory } from './logger.js';
13
+
14
+ dotenv.config();
15
+ const logger = loggerFactory(import.meta);
16
+
17
+ const DEFAULT_HOST = 'localhost';
18
+ const SSL_BASE = (host = DEFAULT_HOST) => path.resolve(`./engine-private/ssl/${host}`);
19
+
20
+ // Common filename candidates for certs/keys produced by various tools (mkcert, certbot, openssl scripts).
21
+ const CERT_CANDIDATES = [
22
+ 'fullchain.pem',
23
+ 'cert.pem',
24
+ 'ca_bundle.crt',
25
+ 'crt.crt',
26
+ `${DEFAULT_HOST}.pem`,
27
+ `${DEFAULT_HOST}.crt`,
28
+ `${DEFAULT_HOST}-fullchain.pem`,
29
+ ];
30
+ const KEY_CANDIDATES = [
31
+ 'privkey.pem',
32
+ 'key.key',
33
+ 'private.key',
34
+ 'key.pem',
35
+ `${DEFAULT_HOST}-key.pem`,
36
+ `${DEFAULT_HOST}.key`,
37
+ ];
38
+ const ROOT_CANDIDATES = ['rootCA.pem', 'ca.pem', 'ca.crt', 'root.pem'];
39
+
40
+ class TLS {
41
+ /**
42
+ * Look for existing SSL files under engine-private/ssl/<host> and return canonical paths.
43
+ * It attempts to be permissive: accepts cert-only, cert+ca, or fullchain.
44
+ * @param {string} host
45
+ * @returns {{key?:string, cert?:string, fullchain?:string, ca?:string, dir:string}}
46
+ * @memberof TransportLayerSecurity
47
+ */
48
+ static locateSslFiles(host = DEFAULT_HOST) {
49
+ const dir = SSL_BASE(host);
50
+ const result = { dir };
51
+
52
+ if (!fs.existsSync(dir)) {
53
+ logger.warn('SSL dir does not exist', { dir });
54
+ return result;
55
+ }
56
+
57
+ // find key
58
+ for (const name of KEY_CANDIDATES) {
59
+ const p = path.join(dir, name);
60
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) {
61
+ result.key = p;
62
+ break;
63
+ }
64
+ }
65
+
66
+ // find fullchain first
67
+ for (const name of CERT_CANDIDATES) {
68
+ const p = path.join(dir, name);
69
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) {
70
+ // treat fullchain.pem / ca_bundle.crt as fullchain if name indicates so
71
+ if (
72
+ ['fullchain.pem', 'ca_bundle.crt', `${host}-fullchain.pem`].includes(name) ||
73
+ name.endsWith('fullchain.pem')
74
+ ) {
75
+ result.fullchain = p;
76
+ result.cert = p; // fullchain will be used as cert when building context
77
+ break;
78
+ }
79
+ // otherwise candidate may be leaf cert
80
+ if (!result.cert) result.cert = p;
81
+ }
82
+ }
83
+
84
+ // find root/ca if not using fullchain
85
+ if (!result.fullchain) {
86
+ // check for direct ca bundle (cert + ca combined) names
87
+ const caCandidates = ROOT_CANDIDATES.concat(['ca_bundle.crt']);
88
+ for (const name of caCandidates) {
89
+ const p = path.join(dir, name);
90
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) {
91
+ result.ca = p;
92
+ break;
93
+ }
94
+ }
95
+ // if no dedicated ca found but cert looks like leaf and there is separate ca under other known names,
96
+ // try to detect cert + ca in a single file (not trivial) — we prefer explicit ca
97
+ }
98
+
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Validate that a secure context can be built for host (key + cert or fullchain present)
104
+ * @param {string} host
105
+ * @returns {boolean}
106
+ * @memberof TransportLayerSecurity
107
+ */
108
+ static validateSecureContext(host = DEFAULT_HOST) {
109
+ const files = TLS.locateSslFiles(host);
110
+ return Boolean((files.key && files.cert) || (files.key && files.fullchain));
111
+ }
112
+
113
+ /**
114
+ * Build a Node.js https.createServer options object (key, cert, ca) for the given host.
115
+ * If a fullchain is available it will be used for cert and ca will be omitted (fullchain already includes chain).
116
+ * If separate cert + ca are found, they will be used accordingly.
117
+ * @param {string} host
118
+ * @returns {{key:string, cert:string, ca?:string}} options
119
+ * @memberof TransportLayerSecurity
120
+ */
121
+ static buildSecureContext(host = DEFAULT_HOST) {
122
+ const files = TLS.locateSslFiles(host);
123
+ if (!files.key) throw new Error(`SSL key not found for host ${host} (looked in ${files.dir})`);
124
+ if (!files.cert) throw new Error(`SSL certificate not found for host ${host} (looked in ${files.dir})`);
125
+
126
+ const key = fs.readFileSync(files.key, 'utf8');
127
+ const cert = fs.readFileSync(files.cert, 'utf8');
128
+
129
+ // If we have a root CA file (explicit) and cert is leaf-only, include ca
130
+ if (files.ca && files.ca !== files.cert) {
131
+ const ca = fs.readFileSync(files.ca, 'utf8');
132
+ return { key, cert, ca };
133
+ }
134
+
135
+ // If cert is fullchain (already contains chain), just return key/cert
136
+ return { key, cert };
137
+ }
138
+
139
+ /**
140
+ * Convenience: ensure default host directory exists and copy any matching cert/key files into it using canonical names.
141
+ * This is useful if your generator produced nonstandard names and you want to normalize them.
142
+ * The function will copy existing discovered files to: key.key, crt.crt, ca_bundle.crt when possible.
143
+ * @param {string} host
144
+ * @returns {boolean} true if at least key+cert exist after operation
145
+ * @memberof TransportLayerSecurity
146
+ */
147
+ static async buildLocalSSL(host = DEFAULT_HOST) {
148
+ const dir = SSL_BASE(host);
149
+ await fs.ensureDir(dir);
150
+ const files = TLS.locateSslFiles(host);
151
+
152
+ // If key+cert already exist under canonical names, done
153
+ const canonicalKey = path.join(dir, 'key.key');
154
+ const canonicalCert = path.join(dir, 'crt.crt');
155
+ const canonicalCa = path.join(dir, 'ca_bundle.crt');
156
+
157
+ try {
158
+ if (files.key && files.key !== canonicalKey) await fs.copy(files.key, canonicalKey, { overwrite: true });
159
+ if (files.cert && files.cert !== canonicalCert) await fs.copy(files.cert, canonicalCert, { overwrite: true });
160
+ if (files.ca && files.ca !== canonicalCa) await fs.copy(files.ca, canonicalCa, { overwrite: true });
161
+
162
+ // If we had a fullchain but not a separate ca, write fullchain also to ca_bundle if missing
163
+ if (files.fullchain && !fs.existsSync(canonicalCa)) {
164
+ await fs.copy(files.fullchain, canonicalCa, { overwrite: false });
165
+ }
166
+ } catch (err) {
167
+ logger.warn('buildLocalSSL copy step failed', { err: err.message });
168
+ }
169
+
170
+ return TLS.validateSecureContext(host);
171
+ }
172
+
173
+ /**
174
+ * Create an HTTPS server (first host) and/or attach SNI contexts for additional hosts.
175
+ * hosts param is an object whose keys are hostnames (e.g. { 'localhost': {...} }).
176
+ * Returns the created https.Server instance (or undefined if none created).
177
+ * @param {import('express').Application} app
178
+ * @param {Object<string, any>} hosts
179
+ * @returns {{ServerSSL?: https.Server}}
180
+ * @memberof TransportLayerSecurity
181
+ */
182
+ static async createSslServer(app, hosts = { [DEFAULT_HOST]: {} }) {
183
+ let server;
184
+ for (const host of Object.keys(hosts)) {
185
+ // ensure canonical files exist (copies where possible)
186
+ await TLS.buildLocalSSL(host);
187
+ if (!TLS.validate_secure_context_check(host)) {
188
+ // backward compatibility: some callers expect validateSecureContext
189
+ if (!TLS.validateSecureContext(host)) {
190
+ logger.error('Invalid SSL context, skipping host', { host });
191
+ continue;
192
+ }
193
+ }
194
+
195
+ // build secure context options
196
+ try {
197
+ const ctx = TLS.buildSecureContext(host);
198
+ if (!server) {
199
+ server = https.createServer(ctx, app);
200
+ logger.info('Created HTTPS server for host', { host });
201
+ } else {
202
+ server.addContext(host, ctx);
203
+ logger.info('Added SNI context for host', { host });
204
+ }
205
+ } catch (err) {
206
+ logger.error('Failed to build secure context', { host, message: err.message });
207
+ }
208
+ }
209
+
210
+ return { ServerSSL: server };
211
+ }
212
+
213
+ /**
214
+ * Middleware that redirects HTTP -> HTTPS in production for recognized hosts.
215
+ * Skips ACME challenge paths.
216
+ * @param {import('express').Request} req
217
+ * @param {import('express').Response} res
218
+ * @param {number} port
219
+ * @param {Object<string, any>} proxyRouter
220
+ * @returns {import('express').RequestHandler}
221
+ * @memberof TransportLayerSecurity
222
+ */
223
+ static sslRedirectMiddleware(req, res, port = 80, proxyRouter = {}) {
224
+ const sslRedirectUrl = `https://${req.headers.host}${req.url}`;
225
+ if (
226
+ process.env.NODE_ENV === 'production' &&
227
+ port !== 443 &&
228
+ !req.secure &&
229
+ !req.url.startsWith('/.well-known/acme-challenge') &&
230
+ proxyRouter[443] &&
231
+ Object.keys(proxyRouter[443]).find((host) => {
232
+ const [hostSSL] = host.split('/');
233
+ return sslRedirectUrl.match(hostSSL) && TLS.validateSecureContext(hostSSL);
234
+ })
235
+ ) {
236
+ return res.status(302).redirect(sslRedirectUrl);
237
+ }
238
+ }
239
+ }
240
+
241
+ // small helper for internal backward compatibility check name typo in older code
242
+ TLS.validate_secure_context_check = TLS.validateSecureContext;
243
+
244
+ // Backward compatibility exports
245
+ const buildSSL = TLS.buildLocalSSL;
246
+ const buildSecureContext = TLS.buildSecureContext;
247
+ const validateSecureContext = TLS.validateSecureContext;
248
+ const createSslServer = TLS.createSslServer;
249
+ const sslRedirectMiddleware = TLS.sslRedirectMiddleware;
250
+
251
+ export { TLS, buildSSL, buildSecureContext, validateSecureContext, createSslServer, sslRedirectMiddleware };