underpost 3.2.80 → 3.3.0

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 (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -25,7 +25,6 @@ import shell from 'shelljs';
25
25
  import { loggerFactory } from './logger.js';
26
26
  import clipboard from 'clipboardy';
27
27
  import Underpost from '../index.js';
28
- import { getNpmRootPath } from './conf.js';
29
28
  const logger = loggerFactory(import.meta);
30
29
  /**
31
30
  * Gets the current working directory, replacing backslashes with forward slashes for consistency.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * SELinux policy, labeling, and enforcement command helpers.
3
+ *
4
+ * @module src/server/selinux.js
5
+ * @namespace SELinuxService
6
+ */
7
+ 'use strict';
8
+
9
+ /**
10
+ * Main SELinux utility.
11
+ * @class SELinuxService
12
+ * @memberof SELinuxService
13
+ */
14
+ class SELinuxService {
15
+ /** Shared container label every unprivileged container domain can read and write. */
16
+ static SHARED_CONTAINER_TYPE = 'container_file_t';
17
+
18
+ /**
19
+ * Quotes one shell argument used by generated SELinux commands.
20
+ * @param {*} value - Value to quote.
21
+ * @returns {string}
22
+ */
23
+ static shellArgumentFactory(value) {
24
+ return `'${`${value ?? ''}`.replaceAll("'", `'"'"'`)}'`;
25
+ }
26
+
27
+ /**
28
+ * Builds the Rocky/RHEL SELinux userspace installation command.
29
+ * @param {{sudo?: boolean}} [options]
30
+ * @returns {string}
31
+ */
32
+ static selinuxPackagesCommandFactory({ sudo = true } = {}) {
33
+ return `${sudo ? 'sudo ' : ''}dnf install -y policycoreutils policycoreutils-python-utils selinux-policy-targeted audit`;
34
+ }
35
+
36
+ /**
37
+ * Builds commands that make Enforcing mode persistent and active.
38
+ * @param {{sudo?: boolean, restorePaths?: string[]}} [options]
39
+ * @returns {string[]}
40
+ */
41
+ static selinuxEnforcingCommandsFactory({ sudo = true, restorePaths = [] } = {}) {
42
+ const prefix = sudo ? 'sudo ' : '';
43
+ return [
44
+ `if [ -f /etc/selinux/config ]; then ${prefix}sed -i -E 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config; fi`,
45
+ // A host running with SELinux Disabled has an unlabeled filesystem, so the
46
+ // config flip alone would boot it into Enforcing with nothing labeled.
47
+ // `setenforce` cannot activate the mode from Disabled either: the switch
48
+ // completes on the next boot, and only after this relabel pass.
49
+ `if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" = "Disabled" ]; then ${prefix}touch /.autorelabel; fi`,
50
+ ...(restorePaths.length > 0
51
+ ? [SELinuxService.selinuxRestoreconCommandFactory(restorePaths, { sudo })]
52
+ : []),
53
+ `if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then ${prefix}setenforce 1; fi`,
54
+ ];
55
+ }
56
+
57
+ /**
58
+ * Builds a command that restores policy-defined file contexts.
59
+ * @param {string|string[]} paths - Files or directories to label.
60
+ * @param {{recursive?: boolean, sudo?: boolean}} [options]
61
+ * @returns {string}
62
+ */
63
+ static selinuxRestoreconCommandFactory(paths, { recursive = true, sudo = true } = {}) {
64
+ const values = (Array.isArray(paths) ? paths : [paths]).filter(Boolean);
65
+ if (values.length === 0) throw new TypeError('selinuxRestoreconCommandFactory requires at least one path');
66
+ const operations = values
67
+ .map(SELinuxService.shellArgumentFactory)
68
+ .map(
69
+ (path) =>
70
+ `{ [ ! -e ${path} ] || ${sudo ? 'sudo ' : ''}restorecon ${recursive ? '-RF ' : ''}${path}; }`,
71
+ )
72
+ .join(' && ');
73
+ return `if command -v restorecon >/dev/null 2>&1; then ${operations}; fi`;
74
+ }
75
+
76
+ /**
77
+ * Builds an idempotent persistent file context mapping.
78
+ * @param {string} path - Directory or file prefix to map.
79
+ * @param {{type: string, sudo?: boolean}} options
80
+ * @returns {string}
81
+ */
82
+ static selinuxFileContextCommandFactory(path, { type, sudo = true } = {}) {
83
+ if (!path) throw new TypeError('selinuxFileContextCommandFactory requires a path');
84
+ if (!type) throw new TypeError('selinuxFileContextCommandFactory requires a type');
85
+ const prefix = sudo ? 'sudo ' : '';
86
+ const expression = SELinuxService.shellArgumentFactory(`${path}(/.*)?`);
87
+ return `if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then command -v semanage >/dev/null 2>&1 || { echo 'semanage is required for persistent file contexts' >&2; exit 1; }; ${prefix}semanage fcontext -a -t ${type} ${expression} 2>/dev/null || ${prefix}semanage fcontext -m -t ${type} ${expression}; fi`;
88
+ }
89
+
90
+ /**
91
+ * Builds persistent labeling commands for host paths bind-mounted into
92
+ * unprivileged containers. `container_t` cannot read the policy defaults of
93
+ * those trees (`kubernetes_file_t`, `var_lib_t`), and the mapping is
94
+ * registered before the files exist so entries created later inherit the
95
+ * shared label instead of requiring another relabel pass.
96
+ * @param {string|string[]} paths - Files or directories to share.
97
+ * @param {{sudo?: boolean}} [options]
98
+ * @returns {string[]}
99
+ */
100
+ static selinuxContainerSharedContextCommandsFactory(paths, { sudo = true } = {}) {
101
+ const values = (Array.isArray(paths) ? paths : [paths]).filter(Boolean);
102
+ if (values.length === 0)
103
+ throw new TypeError('selinuxContainerSharedContextCommandsFactory requires at least one path');
104
+ return [
105
+ ...values.map((path) =>
106
+ SELinuxService.selinuxFileContextCommandFactory(path, { type: SELinuxService.SHARED_CONTAINER_TYPE, sudo }),
107
+ ),
108
+ SELinuxService.selinuxRestoreconCommandFactory(values, { sudo }),
109
+ ];
110
+ }
111
+
112
+ /**
113
+ * Builds persistent labeling commands for an SSH directory.
114
+ * Standard /root and /home locations already have policy mappings; custom
115
+ * home locations receive an explicit ssh_home_t mapping.
116
+ * @param {{sshDirectory: string, sudo?: boolean}} options
117
+ * @returns {string[]}
118
+ */
119
+ static selinuxSshContextCommandsFactory({ sshDirectory, sudo = true } = {}) {
120
+ if (!sshDirectory) throw new TypeError('selinuxSshContextCommandsFactory requires sshDirectory');
121
+ const prefix = sudo ? 'sudo ' : '';
122
+ const standard = sshDirectory === '/root/.ssh' || /^\/home\/[^/]+\/\.ssh$/.test(sshDirectory);
123
+ const commands = [];
124
+ if (!standard) {
125
+ const expression = SELinuxService.shellArgumentFactory(`${sshDirectory}(/.*)?`);
126
+ commands.push(
127
+ `if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then command -v semanage >/dev/null 2>&1 || { echo 'semanage is required for a custom SSH home' >&2; exit 1; }; ${prefix}semanage fcontext -a -t ssh_home_t ${expression} 2>/dev/null || ${prefix}semanage fcontext -m -t ssh_home_t ${expression}; fi`,
128
+ );
129
+ }
130
+ commands.push(SELinuxService.selinuxRestoreconCommandFactory(sshDirectory, { sudo }));
131
+ return commands;
132
+ }
133
+
134
+ /**
135
+ * Builds an idempotent ssh_port_t assignment for a custom SSH port.
136
+ * @param {{port?: number|string, sudo?: boolean}} [options]
137
+ * @returns {string[]}
138
+ */
139
+ static selinuxSshPortCommandsFactory({ port = 22, sudo = true } = {}) {
140
+ const value = Number(port);
141
+ if (!Number.isInteger(value) || value < 1 || value > 65535) throw new RangeError('SSH port must be 1-65535');
142
+ if (value === 22) return [];
143
+ const prefix = sudo ? 'sudo ' : '';
144
+ return [
145
+ `if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then command -v semanage >/dev/null 2>&1 || { echo 'semanage is required for a custom SSH port' >&2; exit 1; }; ${prefix}semanage port -a -t ssh_port_t -p tcp ${value} 2>/dev/null || ${prefix}semanage port -m -t ssh_port_t -p tcp ${value}; fi`,
146
+ ];
147
+ }
148
+
149
+ /**
150
+ * Executes a generated command list.
151
+ * @param {string[]} [commands]
152
+ * @param {{execute: Function}} options
153
+ * @returns {*[]}
154
+ */
155
+ static runSELinuxCommands(commands = [], { execute } = {}) {
156
+ if (typeof execute !== 'function') throw new TypeError('runSELinuxCommands requires an executor');
157
+ return commands.map((command) => execute(command));
158
+ }
159
+ }
160
+
161
+ const {
162
+ runSELinuxCommands,
163
+ selinuxContainerSharedContextCommandsFactory,
164
+ selinuxEnforcingCommandsFactory,
165
+ selinuxFileContextCommandFactory,
166
+ selinuxPackagesCommandFactory,
167
+ selinuxRestoreconCommandFactory,
168
+ selinuxSshContextCommandsFactory,
169
+ selinuxSshPortCommandsFactory,
170
+ shellArgumentFactory,
171
+ } = SELinuxService;
172
+
173
+ export default SELinuxService;
174
+
175
+ export {
176
+ runSELinuxCommands,
177
+ selinuxContainerSharedContextCommandsFactory,
178
+ selinuxEnforcingCommandsFactory,
179
+ selinuxFileContextCommandFactory,
180
+ selinuxPackagesCommandFactory,
181
+ selinuxRestoreconCommandFactory,
182
+ selinuxSshContextCommandsFactory,
183
+ selinuxSshPortCommandsFactory,
184
+ shellArgumentFactory,
185
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * General-purpose systemd unit rendering and service lifecycle helpers.
3
+ *
4
+ * Command construction is deterministic and separate from execution. Callers
5
+ * can execute commands explicitly or pass a list to {@link runSystemdCommands}.
6
+ *
7
+ * @module src/server/systemd.js
8
+ * @namespace SystemdService
9
+ */
10
+ 'use strict';
11
+
12
+ /**
13
+ * Main systemd service utility.
14
+ * @class SystemdService
15
+ * @memberof SystemdService
16
+ */
17
+ class SystemdService {
18
+ static #valuesFactory(value) {
19
+ return Array.isArray(value) ? value : [value];
20
+ }
21
+
22
+ static #sectionFactory(name, directives = {}) {
23
+ const lines = Object.entries(directives).flatMap(([directive, value]) =>
24
+ SystemdService.#valuesFactory(value)
25
+ .filter((entry) => entry !== undefined && entry !== null && `${entry}` !== '')
26
+ .map((entry) => `${directive}=${entry}`),
27
+ );
28
+ return lines.length > 0 ? [`[${name}]`, ...lines].join('\n') : '';
29
+ }
30
+
31
+ static #daemonReloadCommandFactory({ sudo = true } = {}) {
32
+ return SystemdService.systemctlCommandFactory({ action: 'daemon-reload', sudo });
33
+ }
34
+
35
+ /**
36
+ * Checks whether a path is inside a user home directory.
37
+ * @param {string} path - Candidate path.
38
+ * @returns {boolean}
39
+ */
40
+ static homeDirectoryPathFactory(path) {
41
+ return /^\/root(\/|$)|^\/home\//.test(`${path || ''}`.trim());
42
+ }
43
+
44
+ /**
45
+ * Renders a systemd unit from named sections and directives.
46
+ * @param {{header?: string, sections?: Object<string, Object<string, *>>}} [options]
47
+ * @returns {string}
48
+ */
49
+ static systemdUnitFactory({ header = '', sections = {} } = {}) {
50
+ const rendered = Object.entries(sections)
51
+ .map(([name, directives]) => SystemdService.#sectionFactory(name, directives))
52
+ .filter(Boolean);
53
+ return [...(`${header}`.trim() ? [`${header}`.trim()] : []), ...rendered].join('\n\n') + '\n';
54
+ }
55
+
56
+ /**
57
+ * Builds a systemctl command.
58
+ * @param {{action?: string, name?: string, sudo?: boolean, stderr?: boolean, allowFailure?: boolean}} [options]
59
+ * @returns {string}
60
+ */
61
+ static systemctlCommandFactory({ action, name = '', sudo = true, stderr = false, allowFailure = false } = {}) {
62
+ return [
63
+ sudo ? 'sudo' : '',
64
+ 'systemctl',
65
+ `${action || ''}`.trim(),
66
+ `${name || ''}`.trim(),
67
+ stderr ? '2>/dev/null' : '',
68
+ allowFailure ? '|| true' : '',
69
+ ]
70
+ .filter(Boolean)
71
+ .join(' ');
72
+ }
73
+
74
+ /** @returns {string} Command that checks whether systemd-run is available. */
75
+ static systemdAvailableCommandFactory() {
76
+ return 'command -v systemd-run';
77
+ }
78
+
79
+ /**
80
+ * Builds a journalctl command for a service.
81
+ * @param {{name: string, lines?: number, follow?: boolean}} options
82
+ * @returns {string}
83
+ */
84
+ static journalctlCommandFactory({ name, lines, follow = false } = {}) {
85
+ return ['journalctl', '-u', name, lines ? `-n ${lines}` : '', follow ? '-f' : ''].filter(Boolean).join(' ');
86
+ }
87
+
88
+ /**
89
+ * Builds a transient systemd-run command.
90
+ * @param {{command?: string, user?: string, properties?: Object<string, *>, quiet?: boolean, collect?: boolean, wait?: boolean, sudo?: boolean}} [options]
91
+ * @returns {string}
92
+ */
93
+ static systemdRunCommandFactory({
94
+ command,
95
+ user,
96
+ properties = {},
97
+ quiet = true,
98
+ collect = true,
99
+ wait = true,
100
+ sudo = true,
101
+ } = {}) {
102
+ return [
103
+ sudo ? 'sudo' : '',
104
+ 'systemd-run',
105
+ quiet ? '--quiet' : '',
106
+ collect ? '--collect' : '',
107
+ wait ? '--wait' : '',
108
+ user ? `--uid=${user}` : '',
109
+ ...Object.entries(properties).map(([name, value]) => `--property=${name}=${value}`),
110
+ `${command || ''}`.trim(),
111
+ ]
112
+ .filter(Boolean)
113
+ .join(' ');
114
+ }
115
+
116
+ /**
117
+ * Builds convergent ensure and remove command lists for a service.
118
+ * @param {{changed?: boolean, name: string, unitPath: string}} options
119
+ * @returns {{ensure: string[], remove: string[]}}
120
+ */
121
+ static systemdServiceCommandsFactory({ changed = false, name, unitPath } = {}) {
122
+ return {
123
+ ensure: [
124
+ ...(changed ? [SystemdService.#daemonReloadCommandFactory()] : []),
125
+ SystemdService.systemctlCommandFactory({ action: 'enable', name, allowFailure: true }),
126
+ SystemdService.systemctlCommandFactory({
127
+ action: changed ? 'restart' : 'start',
128
+ name,
129
+ allowFailure: true,
130
+ }),
131
+ ],
132
+ remove: [
133
+ SystemdService.systemctlCommandFactory({
134
+ action: 'disable --now',
135
+ name,
136
+ stderr: true,
137
+ allowFailure: true,
138
+ }),
139
+ `sudo rm -f ${unitPath}`,
140
+ SystemdService.#daemonReloadCommandFactory(),
141
+ ],
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Builds service status and log commands.
147
+ * @param {string} name - Service name.
148
+ * @returns {{active: string, enabled: string, logs: string}}
149
+ */
150
+ static systemdStatusCommandsFactory(name) {
151
+ return {
152
+ active: SystemdService.systemctlCommandFactory({ action: 'is-active', name, sudo: false }),
153
+ enabled: SystemdService.systemctlCommandFactory({ action: 'is-enabled', name, sudo: false }),
154
+ logs: SystemdService.journalctlCommandFactory({ name }),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Builds a guarded reload command.
160
+ * @param {string} name - Service name.
161
+ * @returns {string}
162
+ */
163
+ static systemdReloadIfActiveCommandFactory(name) {
164
+ return `sudo sh -c 'systemctl is-active --quiet ${name} && systemctl reload ${name} || true'`;
165
+ }
166
+
167
+ /**
168
+ * Executes or reports a sequence of generated commands.
169
+ * @param {string[]} [commands]
170
+ * @param {{dryRun?: boolean, execute?: Function, onDryRun?: Function}} [options]
171
+ * @returns {*[]}
172
+ */
173
+ static runSystemdCommands(commands = [], { dryRun = false, execute, onDryRun = () => {} } = {}) {
174
+ if (!dryRun && typeof execute !== 'function') throw new TypeError('runSystemdCommands requires an executor');
175
+ return commands.map((command) => (dryRun ? onDryRun(command) : execute(command)));
176
+ }
177
+ }
178
+
179
+ const {
180
+ homeDirectoryPathFactory,
181
+ journalctlCommandFactory,
182
+ runSystemdCommands,
183
+ systemctlCommandFactory,
184
+ systemdAvailableCommandFactory,
185
+ systemdReloadIfActiveCommandFactory,
186
+ systemdRunCommandFactory,
187
+ systemdServiceCommandsFactory,
188
+ systemdStatusCommandsFactory,
189
+ systemdUnitFactory,
190
+ } = SystemdService;
191
+
192
+ export default SystemdService;
193
+
194
+ export {
195
+ homeDirectoryPathFactory,
196
+ journalctlCommandFactory,
197
+ runSystemdCommands,
198
+ systemctlCommandFactory,
199
+ systemdAvailableCommandFactory,
200
+ systemdReloadIfActiveCommandFactory,
201
+ systemdRunCommandFactory,
202
+ systemdServiceCommandsFactory,
203
+ systemdStatusCommandsFactory,
204
+ systemdUnitFactory,
205
+ };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Response compression policy for the Nginx workloads on the request path.
3
+ *
4
+ * @module src/server/underpost-compression.js
5
+ * @namespace UnderpostCompression
6
+ */
7
+
8
+ /**
9
+ * @constant UNDERPOST_COMPRESSION
10
+ * @description The compression policy both edge workloads render from.
11
+ *
12
+ * `text/html` appears in no type list on purpose: nginx and ngx_brotli both
13
+ * always compress it, and naming it again only invites the two lists to drift.
14
+ * Every entry is a format that is still text on the wire — already-compressed
15
+ * media (png, woff2, mp4) is excluded, since re-encoding it spends CPU to add
16
+ * bytes.
17
+ * @memberof UnderpostCompression
18
+ */
19
+ const UNDERPOST_COMPRESSION = {
20
+ types: [
21
+ 'application/atom+xml',
22
+ 'application/geo+json',
23
+ 'application/javascript',
24
+ 'application/json',
25
+ 'application/ld+json',
26
+ 'application/manifest+json',
27
+ 'application/rss+xml',
28
+ 'application/vnd.api+json',
29
+ 'application/wasm',
30
+ 'application/x-javascript',
31
+ 'application/xhtml+xml',
32
+ 'application/xml',
33
+ 'font/otf',
34
+ 'font/ttf',
35
+ 'image/svg+xml',
36
+ 'image/x-icon',
37
+ 'text/css',
38
+ 'text/javascript',
39
+ 'text/markdown',
40
+ 'text/plain',
41
+ 'text/xml',
42
+ ],
43
+ // Below roughly this size a compressed body plus its headers is no smaller
44
+ // than the original, and can be larger.
45
+ minLength: 512,
46
+ // Level 5 of 9: within a few percent of maximum ratio at a fraction of the
47
+ // CPU. The edge compresses every response of every host, so the cost of a
48
+ // higher level is paid on the shared workload, not on one deploy.
49
+ gzipLevel: 5,
50
+ // Brotli's scale runs to 11, where dynamic compression becomes slower than
51
+ // the transfer it saves. 5 is the usual on-the-fly ceiling.
52
+ brotliLevel: 5,
53
+ filterModule: 'ngx_http_brotli_filter_module.so',
54
+ staticModule: 'ngx_http_brotli_static_module.so',
55
+ defaultImage: 'nginx:alpine',
56
+ env: {
57
+ image: 'UNDERPOST_NGINX_IMAGE',
58
+ brotliModules: 'UNDERPOST_NGINX_BROTLI_MODULES',
59
+ enabled: 'UNDERPOST_NGINX_COMPRESSION',
60
+ },
61
+ };
62
+
63
+ /**
64
+ * @method nginxImageFactory
65
+ * @description The image both edge workloads run.
66
+ *
67
+ * Resolved in one place because the two have to agree: they sit on the same
68
+ * request path, and brotli is a property of the image rather than of the
69
+ * config. An image carrying ngx_brotli is the only way the brotli directives
70
+ * below can be rendered at all.
71
+ * @returns {string} Container image reference.
72
+ * @memberof UnderpostCompression
73
+ */
74
+ const nginxImageFactory = () =>
75
+ `${process.env[UNDERPOST_COMPRESSION.env.image] || ''}`.trim() || UNDERPOST_COMPRESSION.defaultImage;
76
+
77
+ /**
78
+ * @method brotliModuleDirFactory
79
+ * @description Directory holding the brotli dynamic modules, or an empty string
80
+ * when the image is not declared to carry them.
81
+ *
82
+ * Empty is the default and the safe state: gzip alone, on a stock image, with
83
+ * no `load_module` line that could fail to resolve.
84
+ * @returns {string} Absolute directory path, or `''`.
85
+ * @memberof UnderpostCompression
86
+ */
87
+ const brotliModuleDirFactory = () =>
88
+ `${process.env[UNDERPOST_COMPRESSION.env.brotliModules] || ''}`.trim().replace(/\/+$/, '');
89
+
90
+ /**
91
+ * @method compressionEnabledFactory
92
+ * @description Whether responses are compressed at all.
93
+ *
94
+ * The escape hatch for a CPU-bound edge node: compression trades cycles for
95
+ * bytes, and an operator who is out of the former rather than the latter needs
96
+ * to turn it off without editing generated configuration.
97
+ * @returns {boolean} True unless explicitly disabled.
98
+ * @memberof UnderpostCompression
99
+ */
100
+ const compressionEnabledFactory = () =>
101
+ !['off', '0', 'false', 'no'].includes(`${process.env[UNDERPOST_COMPRESSION.env.enabled] || ''}`.trim().toLowerCase());
102
+
103
+ /**
104
+ * @method compressionModulesConfFactory
105
+ * @description The `load_module` lines brotli needs, in nginx's main context.
106
+ *
107
+ * Rendered only alongside the brotli directives that require them, so a config
108
+ * never loads a module it does not use or uses one it did not load — using one
109
+ * that was not loaded is a start-up failure, and the reverse is surface on a
110
+ * workload that has no documents to serve from it.
111
+ * @param {string} [brotliModuleDir] - Directory holding the modules; empty renders nothing.
112
+ * @param {boolean} [enabled] - Whether compression is on at all.
113
+ * @param {boolean} [staticRoot] - Whether the workload serves documents from disk.
114
+ * @returns {string} Main-context directives, or an empty string.
115
+ * @memberof UnderpostCompression
116
+ */
117
+ const compressionModulesConfFactory = ({
118
+ brotliModuleDir = brotliModuleDirFactory(),
119
+ enabled = compressionEnabledFactory(),
120
+ staticRoot = false,
121
+ } = {}) => {
122
+ if (!enabled || !brotliModuleDir) return '';
123
+ return [
124
+ `load_module ${brotliModuleDir}/${UNDERPOST_COMPRESSION.filterModule};`,
125
+ ...(staticRoot ? [`load_module ${brotliModuleDir}/${UNDERPOST_COMPRESSION.staticModule};`] : []),
126
+ ].join('\n');
127
+ };
128
+
129
+ /**
130
+ * @method compressionConfFactory
131
+ * @description The compression block for an nginx `http` context.
132
+ *
133
+ * `gzip_proxied any` is what makes this worth rendering on a reverse proxy at
134
+ * all: the default is `off`, which suppresses compression for exactly the
135
+ * responses these workloads exist to forward. A response the upstream already
136
+ * encoded is passed through untouched either way — nginx never re-compresses
137
+ * one that carries `Content-Encoding`.
138
+ *
139
+ * `*_static` is only rendered where a document root exists. It costs one stat()
140
+ * per request to serve an operator-placed `.gz`/`.br` sibling with no CPU at
141
+ * all, and has nothing to look for on a workload that serves no files.
142
+ * @param {boolean} [enabled] - Whether compression is on at all.
143
+ * @param {string} [brotliModuleDir] - Directory holding the brotli modules; empty renders gzip alone.
144
+ * @param {boolean} [staticRoot] - Whether the workload serves documents from disk.
145
+ * @param {string} [indent] - Leading whitespace for each line.
146
+ * @returns {string} `http`-context directives, or an empty string.
147
+ * @memberof UnderpostCompression
148
+ */
149
+ const compressionConfFactory = ({
150
+ enabled = compressionEnabledFactory(),
151
+ brotliModuleDir = brotliModuleDirFactory(),
152
+ staticRoot = false,
153
+ indent = ' ',
154
+ } = {}) => {
155
+ if (!enabled) return '';
156
+ const types = UNDERPOST_COMPRESSION.types.join(' ');
157
+ const lines = [
158
+ 'gzip on;',
159
+ // Compressed and uncompressed bodies share a URL, so a cache that ignores
160
+ // this header serves one to a client that asked for the other.
161
+ 'gzip_vary on;',
162
+ `gzip_comp_level ${UNDERPOST_COMPRESSION.gzipLevel};`,
163
+ `gzip_min_length ${UNDERPOST_COMPRESSION.minLength};`,
164
+ 'gzip_proxied any;',
165
+ `gzip_types ${types};`,
166
+ ...(staticRoot ? ['gzip_static on;'] : []),
167
+ ];
168
+ if (brotliModuleDir)
169
+ lines.push(
170
+ 'brotli on;',
171
+ `brotli_comp_level ${UNDERPOST_COMPRESSION.brotliLevel};`,
172
+ `brotli_min_length ${UNDERPOST_COMPRESSION.minLength};`,
173
+ `brotli_types ${types};`,
174
+ ...(staticRoot ? ['brotli_static on;'] : []),
175
+ );
176
+ return lines.map((line) => `${indent}${line}`).join('\n');
177
+ };
178
+
179
+ export {
180
+ UNDERPOST_COMPRESSION,
181
+ brotliModuleDirFactory,
182
+ compressionConfFactory,
183
+ compressionEnabledFactory,
184
+ compressionModulesConfFactory,
185
+ nginxImageFactory,
186
+ };