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.
- package/.github/workflows/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +291 -1
- package/CLI-HELP.md +174 -23
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +19 -17
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +19 -19
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +12 -4
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +786 -96
- package/src/cli/db.js +11 -4
- package/src/cli/deploy.js +1698 -177
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +15 -7
- package/src/cli/index.js +245 -44
- package/src/cli/ipfs.js +82 -11
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +2 -2
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +12 -10
- package/src/cli/run.js +2195 -427
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +206 -105
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +102 -13
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +51 -32
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +1216 -168
- package/src/server/cri.js +70 -0
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +1083 -0
- package/src/server/underpost-ingress.js +380 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/selinux.test.js +71 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +510 -0
- package/test/underpost-ingress.test.js +305 -0
- package/test/wireguard-edge.test.js +1177 -0
package/src/server/process.js
CHANGED
|
@@ -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
|
+
};
|