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
package/src/cli/ssh.js CHANGED
@@ -7,6 +7,15 @@
7
7
  import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
8
8
  import { pbcopy, shellExec } from '../server/process.js';
9
9
  import { loggerFactory } from '../server/logger.js';
10
+ import { waitForPort } from '../server/conf.js';
11
+ import {
12
+ runSELinuxCommands,
13
+ selinuxPackagesCommandFactory,
14
+ selinuxRestoreconCommandFactory,
15
+ selinuxSshContextCommandsFactory,
16
+ selinuxSshPortCommandsFactory,
17
+ shellArgumentFactory,
18
+ } from '../server/selinux.js';
10
19
  import fs from 'fs-extra';
11
20
  import Underpost from '../index.js';
12
21
 
@@ -56,7 +65,7 @@ class UnderpostSSH {
56
65
  * @returns {boolean} True if user exists, false otherwise
57
66
  */
58
67
  checkUserExists: (username) => {
59
- const result = shellExec(`id -u ${username} 2>/dev/null || echo "not_found"`, {
68
+ const result = shellExec(`id -u ${shellArgumentFactory(username)} 2>/dev/null || echo "not_found"`, {
60
69
  silent: true,
61
70
  stdout: true,
62
71
  }).trim();
@@ -72,7 +81,7 @@ class UnderpostSSH {
72
81
  * @returns {string} User's home directory path
73
82
  */
74
83
  getUserHome: (username) => {
75
- return shellExec(`getent passwd ${username} | cut -d: -f6`, {
84
+ return shellExec(`getent passwd ${shellArgumentFactory(username)} | cut -d: -f6`, {
76
85
  silent: true,
77
86
  stdout: true,
78
87
  }).trim();
@@ -89,11 +98,13 @@ class UnderpostSSH {
89
98
  * @returns {void}
90
99
  */
91
100
  createSystemUser: (username, password, groups) => {
92
- shellExec(`useradd -m -s /bin/bash ${username}`);
93
- shellExec(`echo "${username}:${password}" | chpasswd`);
101
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(username)) throw new TypeError('Invalid system username');
102
+ shellExec(`useradd -m -s /bin/bash ${shellArgumentFactory(username)}`);
103
+ shellExec(`printf '%s\n' ${shellArgumentFactory(`${username}:${password}`)} | chpasswd`, { disableLog: true });
94
104
  if (groups) {
95
105
  for (const group of groups.split(',').map((g) => g.trim())) {
96
- shellExec(`usermod -aG "${group}" "${username}"`);
106
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(group)) throw new TypeError('Invalid system group');
107
+ shellExec(`usermod -aG ${shellArgumentFactory(group)} ${shellArgumentFactory(username)}`);
97
108
  }
98
109
  }
99
110
  },
@@ -108,8 +119,8 @@ class UnderpostSSH {
108
119
  */
109
120
  ensureSSHDirectory: (sshDir) => {
110
121
  if (!fs.existsSync(sshDir)) {
111
- shellExec(`mkdir -p ${sshDir}`);
112
- shellExec(`chmod 700 ${sshDir}`);
122
+ shellExec(`mkdir -p ${shellArgumentFactory(sshDir)}`);
123
+ shellExec(`chmod 700 ${shellArgumentFactory(sshDir)}`);
113
124
  }
114
125
  },
115
126
 
@@ -125,11 +136,12 @@ class UnderpostSSH {
125
136
  * @returns {void}
126
137
  */
127
138
  setSSHFilePermissions: (sshDir, username, keyPath, pubKeyPath) => {
128
- shellExec(`chmod 600 ${sshDir}/authorized_keys`);
129
- shellExec(`chmod 644 ${sshDir}/known_hosts`);
130
- if (keyPath) shellExec(`chmod 600 ${keyPath}`);
131
- if (pubKeyPath) shellExec(`chmod 644 ${pubKeyPath}`);
132
- shellExec(`chown -R ${username}:${username} ${sshDir}`);
139
+ shellExec(`chmod 600 ${shellArgumentFactory(`${sshDir}/authorized_keys`)}`);
140
+ shellExec(`chmod 644 ${shellArgumentFactory(`${sshDir}/known_hosts`)}`);
141
+ if (keyPath) shellExec(`chmod 600 ${shellArgumentFactory(keyPath)}`);
142
+ if (pubKeyPath) shellExec(`chmod 644 ${shellArgumentFactory(pubKeyPath)}`);
143
+ shellExec(`chown -R ${shellArgumentFactory(`${username}:${username}`)} ${shellArgumentFactory(sshDir)}`);
144
+ runSELinuxCommands(selinuxSshContextCommandsFactory({ sshDirectory: sshDir }), { execute: shellExec });
133
145
  },
134
146
 
135
147
  /**
@@ -143,13 +155,12 @@ class UnderpostSSH {
143
155
  * @returns {void}
144
156
  */
145
157
  configureAuthorizedKeys: (sshDir, pubKeyPath, disablePassword) => {
146
- if (disablePassword) {
147
- shellExec(`cat >> ${sshDir}/authorized_keys <<EOF
148
- no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${fs.readFileSync(pubKeyPath, 'utf8')}
149
- EOF`);
150
- } else {
151
- shellExec(`cat ${pubKeyPath} >> ${sshDir}/authorized_keys`);
152
- }
158
+ const key = fs.readFileSync(pubKeyPath, 'utf8').trim();
159
+ const entry = disablePassword ? `no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${key}` : key;
160
+ const authorizedKeysPath = `${sshDir}/authorized_keys`;
161
+ shellExec(
162
+ `grep -qxF ${shellArgumentFactory(entry)} ${shellArgumentFactory(authorizedKeysPath)} || printf '%s\n' ${shellArgumentFactory(entry)} >> ${shellArgumentFactory(authorizedKeysPath)}`,
163
+ );
153
164
  },
154
165
 
155
166
  /**
@@ -163,9 +174,13 @@ EOF`);
163
174
  * @returns {void}
164
175
  */
165
176
  configureKnownHosts: (sshDir, port, host) => {
166
- shellExec(`ssh-keyscan -p ${port} -H localhost >> ${sshDir}/known_hosts`);
167
- shellExec(`ssh-keyscan -p ${port} -H 127.0.0.1 >> ${sshDir}/known_hosts`);
168
- if (host) shellExec(`ssh-keyscan -p ${port} -H ${host} >> ${sshDir}/known_hosts`);
177
+ const sshPort = Number(port);
178
+ if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535)
179
+ throw new RangeError('SSH port must be 1-65535');
180
+ const knownHostsPath = shellArgumentFactory(`${sshDir}/known_hosts`);
181
+ shellExec(`ssh-keyscan -p ${sshPort} -H localhost >> ${knownHostsPath}`);
182
+ shellExec(`ssh-keyscan -p ${sshPort} -H 127.0.0.1 >> ${knownHostsPath}`);
183
+ if (host) shellExec(`ssh-keyscan -p ${sshPort} -H ${shellArgumentFactory(host)} >> ${knownHostsPath}`);
169
184
  },
170
185
 
171
186
  /**
@@ -180,10 +195,78 @@ EOF`);
180
195
  */
181
196
  configureSudoAccess: (username, password, disablePassword) => {
182
197
  if (disablePassword) {
183
- shellExec(`echo '${username} ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/90_${username}`);
198
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(username)) throw new TypeError('Invalid sudo username');
199
+ const sudoersPath = `/etc/sudoers.d/90_${username}`;
200
+ const temporaryPath = `/tmp/underpost-sudoers-${process.pid}-${username}`;
201
+ const rule = `${username} ALL=(ALL) NOPASSWD: ALL`;
202
+ try {
203
+ shellExec(`printf '%s\n' ${shellArgumentFactory(rule)} > ${shellArgumentFactory(temporaryPath)}`);
204
+ shellExec(`sudo visudo -cf ${shellArgumentFactory(temporaryPath)}`);
205
+ shellExec(
206
+ `sudo install -m 0440 -o root -g root ${shellArgumentFactory(temporaryPath)} ${shellArgumentFactory(sudoersPath)}`,
207
+ );
208
+ runSELinuxCommands([selinuxRestoreconCommandFactory(sudoersPath, { recursive: false })], {
209
+ execute: shellExec,
210
+ });
211
+ } finally {
212
+ fs.removeSync(temporaryPath);
213
+ }
184
214
  } else {
185
- shellExec(`echo "${username}:${password}" | sudo chpasswd`);
215
+ shellExec(`printf '%s\n' ${shellArgumentFactory(`${username}:${password}`)} | sudo chpasswd`, {
216
+ disableLog: true,
217
+ });
218
+ }
219
+ },
220
+
221
+ /**
222
+ * Initializes the default SSH files and credentials for a local SSH service.
223
+ * @method
224
+ * @function initializeDefaultSshConfig
225
+ * @memberof UnderpostSSH
226
+ * @param {object} params - SSH configuration values.
227
+ * @param {string} params.userHome - Home directory of the SSH user.
228
+ * @param {string} params.user - SSH user name.
229
+ * @param {string} params.password - SSH key passphrase and user password.
230
+ * @param {string} params.host - Host used in the SSH key comment.
231
+ * @param {number} params.port - SSH port.
232
+ * @param {boolean} params.disablePassword - Whether to configure passwordless sudo.
233
+ * @param {string} [params.controllerPubKeyPath='./engine-private/deploy/id_rsa.pub'] - Preferred controller public key to authorize.
234
+ * @returns {void}
235
+ */
236
+ initializeDefaultSshConfig: ({
237
+ userHome,
238
+ user,
239
+ password,
240
+ host,
241
+ port,
242
+ disablePassword,
243
+ controllerPubKeyPath = './engine-private/deploy/id_rsa.pub',
244
+ }) => {
245
+ const sshDir = `${userHome}/.ssh`;
246
+ const keyPath = `${sshDir}/id_rsa`;
247
+ const pubKeyPath = `${sshDir}/id_rsa.pub`;
248
+
249
+ Underpost.ssh.ensureSSHDirectory(sshDir);
250
+ fs.ensureFileSync(`${sshDir}/authorized_keys`);
251
+ fs.ensureFileSync(`${sshDir}/known_hosts`);
252
+
253
+ if (!fs.existsSync(keyPath)) {
254
+ shellExec(
255
+ `ssh-keygen -t ed25519 -f ${shellArgumentFactory(keyPath)} -N ${shellArgumentFactory(password)} -q -C ${shellArgumentFactory(`${user}@${host}`)}`,
256
+ { disableLog: true },
257
+ );
186
258
  }
259
+ if (!fs.existsSync(pubKeyPath))
260
+ shellExec(
261
+ `ssh-keygen -y -f ${shellArgumentFactory(keyPath)} -P ${shellArgumentFactory(password)} > ${shellArgumentFactory(pubKeyPath)}`,
262
+ { disableLog: true },
263
+ );
264
+
265
+ const authorizedKeyPath = fs.existsSync(controllerPubKeyPath) ? controllerPubKeyPath : pubKeyPath;
266
+ Underpost.ssh.configureAuthorizedKeys(sshDir, authorizedKeyPath, disablePassword);
267
+ Underpost.ssh.configureKnownHosts(sshDir, port, host);
268
+ Underpost.ssh.configureSudoAccess(user, password, disablePassword);
269
+ Underpost.ssh.setSSHFilePermissions(sshDir, user, keyPath, pubKeyPath);
187
270
  },
188
271
 
189
272
  /**
@@ -488,7 +571,44 @@ EOF`);
488
571
 
489
572
  // Handle start server
490
573
  if (options.start) {
491
- Underpost.ssh.chmod({ user: options.user });
574
+ if (!options.deployId) {
575
+ Underpost.ssh.initializeDefaultSshConfig({
576
+ userHome,
577
+ user: options.user,
578
+ password: options.password,
579
+ host: options.host,
580
+ port: options.port,
581
+ disablePassword: options.disablePassword,
582
+ });
583
+ } else {
584
+ // Hardening sshd on a host where the account does not exist produces a
585
+ // daemon that starts cleanly and refuses the only user it was meant to
586
+ // admit. Nothing downstream reports that, so the run has to.
587
+ if (!Underpost.ssh.checkUserExists(options.user))
588
+ throw new Error(
589
+ `[ssh] user '${options.user}' does not exist on this host; ` +
590
+ `run: node bin ssh --deploy-id ${options.deployId} --user ${options.user} --user-add`,
591
+ );
592
+ Underpost.ssh.chmod({ user: options.user });
593
+ // The registry records the key pair this deploy authenticates with, so
594
+ // a box whose authorized_keys was lost converges here rather than
595
+ // needing the key re-issued — which would invalidate every other host
596
+ // holding it.
597
+ const recordedPubKey = confNode?.users?.[options.user]?.publicKeyCopyPath;
598
+ if (recordedPubKey && fs.existsSync(recordedPubKey)) {
599
+ const sshDir = `${userHome}/.ssh`;
600
+ Underpost.ssh.ensureSSHDirectory(sshDir);
601
+ fs.ensureFileSync(`${sshDir}/authorized_keys`);
602
+ Underpost.ssh.configureAuthorizedKeys(sshDir, recordedPubKey, options.disablePassword);
603
+ Underpost.ssh.chmod({ user: options.user });
604
+ logger.info(`Authorized key present for ${options.user}`, { source: recordedPubKey });
605
+ } else if (recordedPubKey) {
606
+ logger.warn('Recorded public key is missing; authorized_keys left untouched', {
607
+ user: options.user,
608
+ expected: recordedPubKey,
609
+ });
610
+ }
611
+ }
492
612
  Underpost.ssh.initService({ port: options.port });
493
613
  }
494
614
 
@@ -606,8 +726,8 @@ EOF
606
726
  },
607
727
 
608
728
  /**
609
- * Waits until a TCP SSH port becomes reachable on a host.
610
- * @async
729
+ * Waits until a TCP SSH port becomes reachable on a host. Delegates to
730
+ * {@link ServerConfBuilder.waitForPort}, which owns the probe.
611
731
  * @function waitForSshPort
612
732
  * @memberof UnderpostSSH
613
733
  * @param {object} params
@@ -617,25 +737,14 @@ EOF
617
737
  * @param {number} [params.intervalMs=3000] - Poll interval.
618
738
  * @returns {Promise<boolean>} True once the port accepts connections, false on timeout.
619
739
  */
620
- waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => {
621
- const deadline = Date.now() + timeoutMs;
622
- while (Date.now() < deadline) {
623
- const probe = shellExec(
624
- `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
625
- { silent: true, stdout: true, silentOnError: true, disableLog: true },
626
- );
627
- if (`${probe}`.trim() === 'open') return true;
628
- await new Promise((r) => setTimeout(r, intervalMs));
629
- }
630
- logger.warn(`SSH port ${host}:${port} not reachable within timeout`);
631
- return false;
632
- },
740
+ waitForSshPort: ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) =>
741
+ waitForPort({ host, port, open: true, timeoutMs, intervalMs }),
633
742
 
634
743
  /**
635
744
  * Waits until a host's SSH port stops accepting connections (e.g. while it
636
745
  * reboots). Used to detect a reboot edge before waiting for the port to come
637
746
  * back up, so callers don't latch onto the pre-reboot (ephemeral) sshd.
638
- * @async
747
+ * Delegates to {@link ServerConfBuilder.waitForPort}.
639
748
  * @function waitForSshPortClosed
640
749
  * @memberof UnderpostSSH
641
750
  * @param {object} params
@@ -645,18 +754,8 @@ EOF
645
754
  * @param {number} [params.intervalMs=3000] - Poll interval.
646
755
  * @returns {Promise<boolean>} True once the port is closed, false on timeout.
647
756
  */
648
- waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => {
649
- const deadline = Date.now() + timeoutMs;
650
- while (Date.now() < deadline) {
651
- const probe = shellExec(
652
- `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
653
- { silent: true, stdout: true, silentOnError: true, disableLog: true },
654
- );
655
- if (`${probe}`.trim() === 'closed') return true;
656
- await new Promise((r) => setTimeout(r, intervalMs));
657
- }
658
- return false;
659
- },
757
+ waitForSshPortClosed: ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) =>
758
+ waitForPort({ host, port, open: false, timeoutMs, intervalMs }),
660
759
 
661
760
  /**
662
761
  * Orchestrates a non-interactive, key-only SSH session against a freshly
@@ -868,12 +967,26 @@ EOF
868
967
  * Sets ownership to specified user for ~/.ssh/ and contents.
869
968
  */
870
969
  chmod: ({ user }) => {
871
- shellExec(`sudo chmod 700 ~/.ssh/`);
872
- shellExec(`sudo chmod 600 ~/.ssh/authorized_keys`);
873
- shellExec(`sudo chmod 644 ~/.ssh/known_hosts`);
874
- shellExec(`sudo chmod 600 ~/.ssh/id_rsa`);
970
+ const sshDirectory = `${Underpost.ssh.getUserHome(user)}/.ssh`;
971
+ shellExec(`sudo chmod 700 ${shellArgumentFactory(sshDirectory)}`);
972
+ shellExec(
973
+ `if [ -f ${shellArgumentFactory(`${sshDirectory}/authorized_keys`)} ]; then sudo chmod 600 ${shellArgumentFactory(`${sshDirectory}/authorized_keys`)}; fi`,
974
+ );
975
+ shellExec(
976
+ `if [ -f ${shellArgumentFactory(`${sshDirectory}/known_hosts`)} ]; then sudo chmod 644 ${shellArgumentFactory(`${sshDirectory}/known_hosts`)}; fi`,
977
+ );
978
+ shellExec(
979
+ `if [ -f ${shellArgumentFactory(`${sshDirectory}/id_rsa`)} ]; then sudo chmod 600 ${shellArgumentFactory(`${sshDirectory}/id_rsa`)}; fi`,
980
+ );
875
981
  shellExec(`sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`);
876
- shellExec(`chown -R ${user}:${user} ~/.ssh`);
982
+ shellExec(`sudo chown -R ${shellArgumentFactory(`${user}:${user}`)} ${shellArgumentFactory(sshDirectory)}`);
983
+ runSELinuxCommands(
984
+ [
985
+ ...selinuxSshContextCommandsFactory({ sshDirectory }),
986
+ selinuxRestoreconCommandFactory('/etc/ssh/ssh_host_ed25519_key', { recursive: false }),
987
+ ],
988
+ { execute: shellExec },
989
+ );
877
990
  },
878
991
 
879
992
  /**
@@ -886,7 +999,7 @@ EOF
886
999
  * @description
887
1000
  * Configures SSH daemon with hardened security settings:
888
1001
  * - Disables password authentication (key-only)
889
- * - Disables root login
1002
+ * - Allows key-only root login
890
1003
  * - Enables ED25519 host key
891
1004
  * - Disables X11 forwarding and TCP forwarding
892
1005
  * - Sets client alive intervals to prevent ghost connections
@@ -898,69 +1011,57 @@ EOF
898
1011
  * - Displays service status with colored output
899
1012
  */
900
1013
  initService: ({ port }) => {
901
- shellExec(
902
- `sudo tee /etc/ssh/sshd_config <<EOF
903
- # ==============================================================
904
- # RHEL Hardened SSHD Configuration
905
- # ==============================================================
906
-
907
- # --- Network Settings ---
908
- Port ${port ? port : '22'}
909
- # Explicitly listen on all interfaces (IPv4 and IPv6)
910
-
911
- # --- Host Keys ---
912
- # ED25519 is the modern standard (Fast & Secure)
1014
+ const sshPort = Number(port || 22);
1015
+ const portCommands = selinuxSshPortCommandsFactory({ port: sshPort });
1016
+ const temporaryPath = `/tmp/underpost-sshd-config-${process.pid}`;
1017
+ const configuration = `Port ${sshPort}
913
1018
  HostKey /etc/ssh/ssh_host_ed25519_key
914
- # RSA is kept for compatibility with older clients (Optional)
915
- # HostKey /etc/ssh/ssh_host_rsa_key
916
-
917
- # --- Logging ---
918
1019
  SyslogFacility AUTHPRIV
919
- # VERBOSE logs the key fingerprint used for login (Audit trail)
920
1020
  LogLevel VERBOSE
921
-
922
- # --- Authentication & Security ---
923
- # STRICTLY KEY-BASED AUTHENTICATION
924
1021
  PubkeyAuthentication yes
925
1022
  PasswordAuthentication no
926
- ChallengeResponseAuthentication no
1023
+ KbdInteractiveAuthentication no
927
1024
  PermitEmptyPasswords no
928
-
929
- # PREVENT ROOT LOGIN
930
- # Administrators should log in as a standard user and use 'sudo'
931
- PermitRootLogin no
932
-
933
- # Security checks on ownership of ~/.ssh/ files
1025
+ PermitRootLogin prohibit-password
934
1026
  StrictModes yes
935
1027
  MaxAuthTries 3
936
1028
  LoginGraceTime 60
937
-
938
- # --- PAM (Pluggable Authentication Modules) ---
939
- # REQUIRED: Must be 'yes' on RHEL for proper session/SELinux handling.
940
- # Since PasswordAuthentication is 'no', PAM will not ask for passwords.
941
1029
  UsePAM yes
942
-
943
- # --- Session & Network Health ---
944
- # Disconnect idle sessions after 5 minutes (300s * 0) to prevent ghost connections
945
1030
  ClientAliveInterval 300
946
1031
  ClientAliveCountMax 0
947
-
948
- # --- Feature Restrictions ---
949
- # Disable GUI forwarding unless explicitly needed
950
1032
  X11Forwarding no
951
- # Disable DNS checks for faster logins (unless you use Host based auth)
952
1033
  UseDNS no
953
- # Disable tunneling unless needed
954
1034
  PermitTunnel no
955
1035
  AllowTcpForwarding no
956
-
957
- # --- Subsystem ---
1036
+ Include /etc/ssh/sshd_config.d/*.conf
958
1037
  Subsystem sftp /usr/libexec/openssh/sftp-server
959
- EOF`,
960
- { disableLog: true },
961
- );
962
- shellExec(`sudo systemctl enable sshd`);
963
- shellExec(`sudo systemctl restart sshd`);
1038
+ `;
1039
+
1040
+ fs.writeFileSync(temporaryPath, configuration, { mode: 0o600 });
1041
+ try {
1042
+ if (sshPort !== 22) {
1043
+ shellExec(
1044
+ `if command -v dnf >/dev/null 2>&1 && ! command -v semanage >/dev/null 2>&1; then ${selinuxPackagesCommandFactory()}; fi`,
1045
+ );
1046
+ }
1047
+ runSELinuxCommands(portCommands, { execute: shellExec });
1048
+ shellExec(`sudo ssh-keygen -A`);
1049
+ shellExec(`sudo /usr/sbin/sshd -t -f ${shellArgumentFactory(temporaryPath)}`);
1050
+ shellExec(`sudo install -m 0600 -o root -g root ${shellArgumentFactory(temporaryPath)} /etc/ssh/sshd_config`);
1051
+ runSELinuxCommands(
1052
+ [selinuxRestoreconCommandFactory(['/etc/ssh/sshd_config', '/etc/ssh/ssh_host_ed25519_key'])],
1053
+ { execute: shellExec },
1054
+ );
1055
+ if (sshPort !== 22) {
1056
+ shellExec(
1057
+ `if command -v firewall-cmd >/dev/null 2>&1 && sudo firewall-cmd --state >/dev/null 2>&1; then sudo firewall-cmd --permanent --add-port=${sshPort}/tcp && sudo firewall-cmd --reload; fi`,
1058
+ );
1059
+ }
1060
+ shellExec(`sudo systemctl enable sshd`);
1061
+ shellExec(`sudo systemctl restart sshd`);
1062
+ } finally {
1063
+ fs.removeSync(temporaryPath);
1064
+ }
964
1065
 
965
1066
  const status = shellExec(`sudo systemctl status sshd`, { silent: true, stdout: true });
966
1067
  if (status.match('running')) console.log(status.replaceAll(`running`, `running`.green));
package/src/cli/system.js CHANGED
@@ -8,6 +8,12 @@
8
8
  */
9
9
 
10
10
  import fs from 'fs-extra';
11
+ import {
12
+ selinuxEnforcingCommandsFactory,
13
+ selinuxPackagesCommandFactory,
14
+ selinuxSshContextCommandsFactory,
15
+ shellArgumentFactory,
16
+ } from '../server/selinux.js';
11
17
 
12
18
  /**
13
19
  * @class UnderpostSystemProvisionig
@@ -196,6 +202,8 @@ logdir /var/log/chrony
196
202
 
197
203
  // Install essential system tools (avoiding duplicates from container packages)
198
204
  `dnf -y install --allowerasing bzip2 openssh-server nano vim-enhanced less openssl-devel git gnupg2 libnsl perl`,
205
+ selinuxPackagesCommandFactory({ sudo: false }),
206
+ ...selinuxEnforcingCommandsFactory({ sudo: false }),
199
207
  `dnf clean all`,
200
208
 
201
209
  // Install Node.js
@@ -218,19 +226,24 @@ logdir /var/log/chrony
218
226
  * @memberof UnderpostSystemProvisionig
219
227
  * @returns {string[]} An array of shell commands.
220
228
  */
221
- user: () => [
222
- `useradd -m -s /bin/bash -G wheel root`, // Create a root user with bash shell and wheel group (sudo on RHEL)
223
- `echo 'root:root' | chpasswd`, // Set a default password for the root user
224
- `mkdir -p /home/root/.ssh`, // Create .ssh directory for authorized keys
225
- // Add the public SSH key to authorized_keys for passwordless login
226
- `echo '${fs.readFileSync(
227
- `/home/dd/engine/engine-private/deploy/id_rsa.pub`,
228
- 'utf8',
229
- )}' > /home/root/.ssh/authorized_keys`,
230
- `chown -R root:root /home/root/.ssh`, // Set ownership for security
231
- `chmod 700 /home/root/.ssh`, // Set permissions for the .ssh directory
232
- `chmod 600 /home/root/.ssh/authorized_keys`, // Set permissions for authorized_keys
233
- ],
229
+ user: ({ username = 'root', publicKeyPath = '/home/dd/engine/engine-private/deploy/id_rsa.pub' } = {}) => {
230
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(username)) throw new TypeError('Invalid Rocky Linux username');
231
+ const home = username === 'root' ? '/root' : `/home/${username}`;
232
+ const sshDirectory = `${home}/.ssh`;
233
+ const publicKey = fs.readFileSync(publicKeyPath, 'utf8').trim();
234
+ const userCommand =
235
+ username === 'root'
236
+ ? `usermod -s /bin/bash root`
237
+ : `id -u ${shellArgumentFactory(username)} >/dev/null 2>&1 || useradd -m -s /bin/bash -G wheel ${shellArgumentFactory(username)}`;
238
+ return [
239
+ userCommand,
240
+ `install -d -m 0700 -o ${shellArgumentFactory(username)} -g ${shellArgumentFactory(username)} ${shellArgumentFactory(sshDirectory)}`,
241
+ `printf '%s\n' ${shellArgumentFactory(publicKey)} > ${shellArgumentFactory(`${sshDirectory}/authorized_keys`)}`,
242
+ `chown ${shellArgumentFactory(`${username}:${username}`)} ${shellArgumentFactory(`${sshDirectory}/authorized_keys`)}`,
243
+ `chmod 0600 ${shellArgumentFactory(`${sshDirectory}/authorized_keys`)}`,
244
+ ...selinuxSshContextCommandsFactory({ sshDirectory, sudo: false }),
245
+ ];
246
+ },
234
247
  /**
235
248
  * @method timezone
236
249
  * @description Generates shell commands for configuring the system timezone on Rocky Linux.
package/src/cli/test.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import fs from 'fs-extra';
8
8
  import { timer } from '../client/components/core/CommonJs.js';
9
9
  import { MariaDB } from '../db/mariadb/MariaDB.js';
10
- import { getNpmRootPath } from '../server/conf.js';
10
+ import { getNpmRootPath } from '../server/environment.js';
11
11
  import { actionInitLog, loggerFactory, setUpInfo } from '../server/logger.js';
12
12
  import { pbcopy, shellExec } from '../server/process.js';
13
13
  import Underpost from '../index.js';