underpost 3.2.90 → 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 (71) 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 +110 -1
  6. package/CLI-HELP.md +139 -9
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +1 -1
  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/package.json +5 -5
  19. package/scripts/audit-selinux.sh +64 -0
  20. package/scripts/coverall-test.sh +24 -0
  21. package/scripts/gpu-diag.sh +0 -0
  22. package/scripts/ip-info.sh +0 -0
  23. package/scripts/k3s-node-setup.sh +18 -15
  24. package/scripts/kubeadm-node-setup.sh +12 -23
  25. package/scripts/link-local-underpost-cli.sh +0 -0
  26. package/scripts/lxd-vm-setup.sh +0 -0
  27. package/scripts/maas-nat-firewalld.sh +0 -0
  28. package/scripts/nat-iptables.sh +2 -0
  29. package/scripts/rhel-grpc-setup.sh +0 -0
  30. package/scripts/rocky-kickstart.sh +25 -9
  31. package/scripts/test-monitor.sh +1 -1
  32. package/src/cli/baremetal.js +1 -2
  33. package/src/cli/cloud-init.js +1 -1
  34. package/src/cli/cluster.js +73 -68
  35. package/src/cli/db.js +9 -2
  36. package/src/cli/deploy.js +21 -5
  37. package/src/cli/docker-compose.js +1 -1
  38. package/src/cli/env.js +1 -1
  39. package/src/cli/image.js +0 -1
  40. package/src/cli/index.js +121 -9
  41. package/src/cli/lxd.js +1 -1
  42. package/src/cli/monitor.js +1 -1
  43. package/src/cli/release.js +57 -22
  44. package/src/cli/repository.js +11 -9
  45. package/src/cli/run.js +36 -9
  46. package/src/cli/ssh.js +198 -77
  47. package/src/cli/system.js +26 -13
  48. package/src/cli/test.js +1 -1
  49. package/src/cli/vultr.js +583 -0
  50. package/src/cli/wireguard.js +2125 -0
  51. package/src/client-builder/client-build.js +20 -14
  52. package/src/db/mongo/MongooseDB.js +4 -0
  53. package/src/index.js +25 -1
  54. package/src/projects/underpost/catalog-underpost.js +4 -1
  55. package/src/server/backup.js +1 -1
  56. package/src/server/conf.js +18 -108
  57. package/src/server/cron.js +249 -51
  58. package/src/server/dns.js +100 -6
  59. package/src/server/environment.js +98 -0
  60. package/src/server/forward-proxy.js +549 -0
  61. package/src/server/middlewares.js +56 -1
  62. package/src/server/process.js +0 -1
  63. package/src/server/selinux.js +185 -0
  64. package/src/server/systemd.js +205 -0
  65. package/src/server/underpost-compression.js +186 -0
  66. package/src/server/underpost-gateway.js +20 -10
  67. package/src/server/underpost-ingress.js +18 -2
  68. package/test/selinux.test.js +71 -0
  69. package/test/underpost-gateway.test.js +41 -0
  70. package/test/underpost-ingress.test.js +52 -0
  71. package/test/wireguard-edge.test.js +1177 -0
@@ -2,6 +2,7 @@
2
2
  * Express middleware and controller/router helpers for engine APIs.
3
3
  *
4
4
  * @module src/server/middlewares.js
5
+ * @namespace Middlewares
5
6
  */
6
7
 
7
8
  import { loggerFactory } from './logger.js';
@@ -12,8 +13,11 @@ const logger = loggerFactory(import.meta);
12
13
  /**
13
14
  * The public-read CORS policy: reflect the request origin (or allow any)
14
15
  * and mark the resource embeddable cross-origin.
16
+ * @method setCrossOriginHeaders
15
17
  * @param {import('express').Request} req
16
18
  * @param {import('express').Response} res
19
+ * @returns {void}
20
+ * @memberof Middlewares
17
21
  */
18
22
  const setCrossOriginHeaders = (req, res) => {
19
23
  if (req && req.headers && req.headers.origin) res.set('Access-Control-Allow-Origin', req.headers.origin);
@@ -21,7 +25,15 @@ const setCrossOriginHeaders = (req, res) => {
21
25
  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
22
26
  };
23
27
 
24
- /** Express middleware form of {@link setCrossOriginHeaders}. */
28
+ /**
29
+ * Express middleware form of {@link setCrossOriginHeaders}.
30
+ * @method crossOriginMiddleware
31
+ * @param {import('express').Request} req
32
+ * @param {import('express').Response} res
33
+ * @param {import('express').NextFunction} next
34
+ * @returns {void}
35
+ * @memberof Middlewares
36
+ */
25
37
  const crossOriginMiddleware = (req, res, next) => {
26
38
  setCrossOriginHeaders(req, res);
27
39
  next();
@@ -31,7 +43,10 @@ const crossOriginMiddleware = (req, res, next) => {
31
43
  * Shallow request copy with `page`/`limit` parsed to integers.
32
44
  * `path` and `params` are copied explicitly because spreading an Express
33
45
  * request drops prototype getters.
46
+ * @method withParsedPagination
34
47
  * @param {import('express').Request} req
48
+ * @returns {import('express').Request} Request-like object with parsed pagination.
49
+ * @memberof Middlewares
35
50
  */
36
51
  const withParsedPagination = (req) => {
37
52
  const { page, limit } = req.query;
@@ -43,15 +58,35 @@ const withParsedPagination = (req) => {
43
58
  };
44
59
  };
45
60
 
61
+ /**
62
+ * Sends the standard success response envelope.
63
+ * @method sendSuccess
64
+ * @param {import('express').Response} res
65
+ * @param {*} data - Response payload.
66
+ * @returns {import('express').Response} JSON response.
67
+ * @memberof Middlewares
68
+ */
46
69
  const sendSuccess = (res, data) => res.status(200).json({ status: 'success', data });
47
70
 
71
+ /**
72
+ * Sends the standard error response envelope.
73
+ * @method sendError
74
+ * @param {import('express').Response} res
75
+ * @param {Error} error - Error to expose.
76
+ * @param {number} [status=400] - HTTP status code.
77
+ * @returns {import('express').Response} JSON response.
78
+ * @memberof Middlewares
79
+ */
48
80
  const sendError = (res, error, status = 400) => res.status(status).json({ status: 'error', message: error.message });
49
81
 
50
82
  /**
51
83
  * Binary response with cross-origin and content headers.
84
+ * @method sendBlob
52
85
  * @param {import('express').Request} req
53
86
  * @param {import('express').Response} res
54
87
  * @param {{ buffer: Buffer, mimetype: string, filename: string, disposition?: 'inline'|'attachment' }} blob
88
+ * @returns {import('express').Response} Completed binary response.
89
+ * @memberof Middlewares
55
90
  */
56
91
  const sendBlob = (req, res, { buffer, mimetype, filename, disposition = 'inline' }) => {
57
92
  setCrossOriginHeaders(req, res);
@@ -63,8 +98,11 @@ const sendBlob = (req, res, { buffer, mimetype, filename, disposition = 'inline'
63
98
 
64
99
  /**
65
100
  * Wraps a controller body with error logging and the error response envelope.
101
+ * @method controllerHandler
66
102
  * @param {(req, res, options) => Promise<any>} fn
67
103
  * @param {{ errorStatus?: number }} [config]
104
+ * @returns {Function} Async Express-compatible controller handler.
105
+ * @memberof Middlewares
68
106
  */
69
107
  const controllerHandler =
70
108
  (fn, { errorStatus = 400 } = {}) =>
@@ -80,8 +118,11 @@ const controllerHandler =
80
118
  /**
81
119
  * Builds a controller method that delegates to a service method and wraps the
82
120
  * result in the success envelope.
121
+ * @method serviceHandler
83
122
  * @param {(req, res, options) => Promise<any>} serviceFn
84
123
  * @param {{ errorStatus?: number, crossOrigin?: boolean, pagination?: boolean }} [config]
124
+ * @returns {Function} Async Express-compatible controller handler.
125
+ * @memberof Middlewares
85
126
  */
86
127
  const serviceHandler = (serviceFn, { errorStatus = 400, crossOrigin = false, pagination = false } = {}) =>
87
128
  controllerHandler(
@@ -96,14 +137,26 @@ const serviceHandler = (serviceFn, { errorStatus = 400, crossOrigin = false, pag
96
137
  /**
97
138
  * Builds a standard CRUD controller class (static post/get/put/delete) from a
98
139
  * service exposing the same methods. `get` parses pagination.
140
+ * @method buildCrudController
99
141
  * @param {{ post, get, put, delete }} service
100
142
  * @param {Object<string, Function>} [extend] - Extra or overriding static handlers.
143
+ * @returns {Function} CRUD controller class.
144
+ * @memberof Middlewares
101
145
  */
102
146
  const buildCrudController = (service, extend = {}) => {
147
+ /**
148
+ * Generated controller namespace containing static CRUD handlers.
149
+ * @class CrudController
150
+ * @memberof Middlewares
151
+ */
103
152
  class CrudController {
153
+ /** @static @memberof Middlewares */
104
154
  static post = serviceHandler(service.post);
155
+ /** @static @memberof Middlewares */
105
156
  static get = serviceHandler(service.get, { pagination: true });
157
+ /** @static @memberof Middlewares */
106
158
  static put = serviceHandler(service.put);
159
+ /** @static @memberof Middlewares */
107
160
  static delete = serviceHandler(service.delete);
108
161
  }
109
162
  Object.assign(CrudController, extend);
@@ -115,6 +168,7 @@ const buildCrudController = (service, extend = {}) => {
115
168
  * public reads, moderator-guarded writes, admin-guarded collection delete.
116
169
  * Custom routes must be registered before calling this (generic `/:id` routes
117
170
  * capture everything).
171
+ * @method registerCrudRoutes
118
172
  * @param {import('express').Router} router
119
173
  * @param {{ post, get, put, delete }} Controller
120
174
  * @param {import('../../api/types.js').RouterOptions} options
@@ -122,6 +176,7 @@ const buildCrudController = (service, extend = {}) => {
122
176
  * Pass empty arrays for unguarded endpoints (e.g. player-written progress)
123
177
  * or explicit guard chains (e.g. admin-only reads).
124
178
  * @returns {import('express').Router}
179
+ * @memberof Middlewares
125
180
  */
126
181
  const registerCrudRoutes = (router, Controller, options, { readGuards = [], writeGuards, deleteAllGuards } = {}) => {
127
182
  const write = writeGuards ?? [options.authMiddleware, moderatorGuard];
@@ -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
+ };