@underpostnet/underpost 2.90.4 → 2.95.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 (41) hide show
  1. package/.github/workflows/pwa-microservices-template-page.cd.yml +5 -4
  2. package/.github/workflows/release.cd.yml +7 -7
  3. package/README.md +7 -8
  4. package/bin/build.js +6 -1
  5. package/bin/deploy.js +2 -196
  6. package/cli.md +154 -80
  7. package/manifests/deployment/dd-default-development/deployment.yaml +4 -4
  8. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  9. package/package.json +1 -1
  10. package/scripts/disk-clean.sh +216 -0
  11. package/scripts/rocky-setup.sh +1 -0
  12. package/scripts/ssh-cluster-info.sh +4 -3
  13. package/src/cli/cluster.js +1 -1
  14. package/src/cli/db.js +1143 -201
  15. package/src/cli/deploy.js +93 -24
  16. package/src/cli/env.js +2 -2
  17. package/src/cli/image.js +198 -133
  18. package/src/cli/index.js +111 -44
  19. package/src/cli/lxd.js +73 -74
  20. package/src/cli/monitor.js +20 -9
  21. package/src/cli/repository.js +212 -5
  22. package/src/cli/run.js +207 -74
  23. package/src/cli/ssh.js +642 -14
  24. package/src/client/components/core/CommonJs.js +0 -1
  25. package/src/db/mongo/MongooseDB.js +5 -1
  26. package/src/index.js +1 -1
  27. package/src/monitor.js +11 -1
  28. package/src/server/backup.js +1 -1
  29. package/src/server/conf.js +1 -1
  30. package/src/server/dns.js +242 -1
  31. package/src/server/process.js +6 -1
  32. package/src/server/start.js +2 -0
  33. package/scripts/snap-clean.sh +0 -26
  34. package/src/client/public/default/plantuml/client-conf.svg +0 -1
  35. package/src/client/public/default/plantuml/client-schema.svg +0 -1
  36. package/src/client/public/default/plantuml/cron-conf.svg +0 -1
  37. package/src/client/public/default/plantuml/cron-schema.svg +0 -1
  38. package/src/client/public/default/plantuml/server-conf.svg +0 -1
  39. package/src/client/public/default/plantuml/server-schema.svg +0 -1
  40. package/src/client/public/default/plantuml/ssr-conf.svg +0 -1
  41. package/src/client/public/default/plantuml/ssr-schema.svg +0 -1
package/src/cli/ssh.js CHANGED
@@ -4,7 +4,14 @@
4
4
  * @namespace UnderpostSSH
5
5
  */
6
6
 
7
- import { shellExec } from '../server/process.js';
7
+ import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
8
+ import Dns from '../server/dns.js';
9
+ import { pbcopy, shellExec } from '../server/process.js';
10
+ import { loggerFactory } from '../server/logger.js';
11
+ import fs from 'fs-extra';
12
+ import UnderpostRootEnv from './env.js';
13
+
14
+ const logger = loggerFactory(import.meta);
8
15
 
9
16
  /**
10
17
  * @class UnderpostSSH
@@ -14,31 +21,652 @@ import { shellExec } from '../server/process.js';
14
21
  class UnderpostSSH {
15
22
  static API = {
16
23
  /**
17
- * @method callback
18
- * @description Manages SSH key generation and connection setup based on the default deployment ID.
19
- * This function will either generate a new SSH key pair or import an existing one,
20
- * then initiate the SSH connection process.
21
- * @param {object} [options={ generate: false }] - Options for the SSH callback.
22
- * @param {boolean} [options.generate=false] - If true, generates a new SSH key pair. Otherwise, it imports the existing one.
24
+ * Loads configuration node from disk or returns default empty config.
25
+ * @private
26
+ * @function loadConfigNode
27
+ * @memberof UnderpostSSH
28
+ * @param {string} deployId - Deployment ID for the config path
29
+ * @returns {{confNode: Object, confNodePath: string}} Configuration node and its file path
30
+ * @description Loads or creates a config node with users object structure
31
+ */
32
+ loadConfigNode: (deployId) => {
33
+ const confNodePath = `./engine-private/conf/${deployId}/conf.node.json`;
34
+ const confNode = fs.existsSync(confNodePath) ? JSON.parse(fs.readFileSync(confNodePath, 'utf8')) : { users: {} };
35
+ return { confNode, confNodePath };
36
+ },
37
+
38
+ /**
39
+ * Saves configuration node to disk.
40
+ * @private
41
+ * @function saveConfigNode
42
+ * @memberof UnderpostSSH
43
+ * @param {string} confNodePath - Path to the configuration file
44
+ * @param {Object} confNode - Configuration object to save
45
+ * @returns {void}
46
+ */
47
+ saveConfigNode: (confNodePath, confNode) => {
48
+ fs.outputFileSync(confNodePath, JSON.stringify(confNode, null, 4), 'utf8');
49
+ },
50
+
51
+ /**
52
+ * Checks if a system user exists.
53
+ * @private
54
+ * @function checkUserExists
55
+ * @memberof UnderpostSSH
56
+ * @param {string} username - Username to check
57
+ * @returns {boolean} True if user exists, false otherwise
58
+ */
59
+ checkUserExists: (username) => {
60
+ const result = shellExec(`id -u ${username} 2>/dev/null || echo "not_found"`, {
61
+ silent: true,
62
+ stdout: true,
63
+ }).trim();
64
+ return result !== 'not_found';
65
+ },
66
+
67
+ /**
68
+ * Gets the home directory for a given user.
69
+ * @private
70
+ * @function getUserHome
71
+ * @memberof UnderpostSSH
72
+ * @param {string} username - Username to get home directory for
73
+ * @returns {string} User's home directory path
74
+ */
75
+ getUserHome: (username) => {
76
+ return shellExec(`getent passwd ${username} | cut -d: -f6`, {
77
+ silent: true,
78
+ stdout: true,
79
+ }).trim();
80
+ },
81
+
82
+ /**
83
+ * Creates a system user with password and groups.
84
+ * @private
85
+ * @function createSystemUser
86
+ * @memberof UnderpostSSH
87
+ * @param {string} username - Username to create
88
+ * @param {string} password - Password for the user
89
+ * @param {string} groups - Comma-separated list of groups
90
+ * @returns {void}
91
+ */
92
+ createSystemUser: (username, password, groups) => {
93
+ shellExec(`useradd -m -s /bin/bash ${username}`);
94
+ shellExec(`echo "${username}:${password}" | chpasswd`);
95
+ if (groups) {
96
+ for (const group of groups.split(',').map((g) => g.trim())) {
97
+ shellExec(`usermod -aG "${group}" "${username}"`);
98
+ }
99
+ }
100
+ },
101
+
102
+ /**
103
+ * Ensures SSH directory exists with proper permissions.
104
+ * @private
105
+ * @function ensureSSHDirectory
106
+ * @memberof UnderpostSSH
107
+ * @param {string} sshDir - Path to SSH directory
108
+ * @returns {void}
109
+ */
110
+ ensureSSHDirectory: (sshDir) => {
111
+ if (!fs.existsSync(sshDir)) {
112
+ shellExec(`mkdir -p ${sshDir}`);
113
+ shellExec(`chmod 700 ${sshDir}`);
114
+ }
115
+ },
116
+
117
+ /**
118
+ * Sets proper permissions on SSH files.
119
+ * @private
120
+ * @function setSSHFilePermissions
121
+ * @memberof UnderpostSSH
122
+ * @param {string} sshDir - SSH directory path
123
+ * @param {string} username - Username for ownership
124
+ * @param {string} [keyPath] - Optional private key path
125
+ * @param {string} [pubKeyPath] - Optional public key path
126
+ * @returns {void}
127
+ */
128
+ setSSHFilePermissions: (sshDir, username, keyPath, pubKeyPath) => {
129
+ shellExec(`chmod 600 ${sshDir}/authorized_keys`);
130
+ shellExec(`chmod 644 ${sshDir}/known_hosts`);
131
+ if (keyPath) shellExec(`chmod 600 ${keyPath}`);
132
+ if (pubKeyPath) shellExec(`chmod 644 ${pubKeyPath}`);
133
+ shellExec(`chown -R ${username}:${username} ${sshDir}`);
134
+ },
135
+
136
+ /**
137
+ * Configures authorized_keys for a user.
138
+ * @private
139
+ * @function configureAuthorizedKeys
140
+ * @memberof UnderpostSSH
141
+ * @param {string} sshDir - SSH directory path
142
+ * @param {string} pubKeyPath - Public key file path
143
+ * @param {boolean} disablePassword - Whether to add no-forwarding restrictions
144
+ * @returns {void}
145
+ */
146
+ configureAuthorizedKeys: (sshDir, pubKeyPath, disablePassword) => {
147
+ if (disablePassword) {
148
+ shellExec(`cat >> ${sshDir}/authorized_keys <<EOF
149
+ no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${fs.readFileSync(pubKeyPath, 'utf8')}
150
+ EOF`);
151
+ } else {
152
+ shellExec(`cat ${pubKeyPath} >> ${sshDir}/authorized_keys`);
153
+ }
154
+ },
155
+
156
+ /**
157
+ * Configures known_hosts with SSH server keys.
158
+ * @private
159
+ * @function configureKnownHosts
160
+ * @memberof UnderpostSSH
161
+ * @param {string} sshDir - SSH directory path
162
+ * @param {number} port - SSH port number
163
+ * @param {string} [host] - Optional external host to add
164
+ * @returns {void}
165
+ */
166
+ configureKnownHosts: (sshDir, port, host) => {
167
+ shellExec(`ssh-keyscan -p ${port} -H localhost >> ${sshDir}/known_hosts`);
168
+ shellExec(`ssh-keyscan -p ${port} -H 127.0.0.1 >> ${sshDir}/known_hosts`);
169
+ if (host) shellExec(`ssh-keyscan -p ${port} -H ${host} >> ${sshDir}/known_hosts`);
170
+ },
171
+
172
+ /**
173
+ * Configures sudoers for passwordless sudo or sets user password.
174
+ * @private
175
+ * @function configureSudoAccess
176
+ * @memberof UnderpostSSH
177
+ * @param {string} username - Username to configure
178
+ * @param {string} password - User password
179
+ * @param {boolean} disablePassword - Whether to enable passwordless sudo
180
+ * @returns {void}
181
+ */
182
+ configureSudoAccess: (username, password, disablePassword) => {
183
+ if (disablePassword) {
184
+ shellExec(`echo '${username} ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/90_${username}`);
185
+ } else {
186
+ shellExec(`echo "${username}:${password}" | sudo chpasswd`);
187
+ }
188
+ },
189
+
190
+ /**
191
+ * Main callback function for SSH operations including user management, key import/export, and SSH service configuration.
192
+ * @async
193
+ * @function callback
23
194
  * @memberof UnderpostSSH
195
+ * @param {Object} options - Configuration options for SSH operations
196
+ * @param {string} [options.deployId=''] - Deployment ID context for SSH operations
197
+ * @param {boolean} [options.generate=false] - Generate new SSH credentials
198
+ * @param {string} [options.user=''] - SSH user name (defaults to 'root')
199
+ * @param {string} [options.password=''] - SSH user password (auto-generated if not provided, overridden by saved config if user exists)
200
+ * @param {string} [options.host=''] - SSH host address (defaults to public IP, overridden by saved config if user exists)
201
+ * @param {string} [options.filter=''] - Filter for user/group listings
202
+ * @param {string} [options.groups=''] - Comma-separated list of groups for the user (defaults to 'wheel')
203
+ * @param {number} [options.port=22] - SSH port number
204
+ * @param {boolean} [options.start=false] - Start SSH service with hardened configuration
205
+ * @param {boolean} [options.userAdd=false] - Add a new SSH user and generate keys
206
+ * @param {boolean} [options.userRemove=false] - Remove an SSH user and cleanup keys
207
+ * @param {boolean} [options.userLs=false] - List all SSH users and groups
208
+ * @param {boolean} [options.reset=false] - Reset SSH configuration (clear authorized_keys and known_hosts)
209
+ * @param {boolean} [options.keysList=false] - List authorized SSH keys
210
+ * @param {boolean} [options.hostsList=false] - List known SSH hosts
211
+ * @param {boolean} [options.disablePassword=false] - If true, enable passwordless sudo and add SSH restrictions
212
+ * @param {boolean} [options.keyTest=false] - Test SSH key generation
213
+ * @param {boolean} [options.stop=false] - Stop SSH service
214
+ * @param {boolean} [options.status=false] - Check SSH service status
215
+ * @param {boolean} [options.connectUri=false] - Output SSH connection URI
216
+ * @param {boolean} [options.copy=false] - Copy SSH connection URI to clipboard
24
217
  * @returns {Promise<void>}
218
+ * @description
219
+ * Handles various SSH operations:
220
+ * - User creation with automatic key generation and backup
221
+ * - User removal with key cleanup
222
+ * - Key import/export between SSH directory and private backup location
223
+ * - SSH service initialization and hardening
224
+ * - User and group listing with optional filtering
25
225
  */
26
226
  callback: async (
27
227
  options = {
228
+ deployId: '',
28
229
  generate: false,
230
+ user: '',
231
+ password: '',
232
+ host: '',
233
+ filter: '',
234
+ groups: '',
235
+ port: 22,
236
+ start: false,
237
+ userAdd: false,
238
+ userRemove: false,
239
+ userLs: false,
240
+ reset: false,
241
+ keysList: false,
242
+ hostsList: false,
243
+ disablePassword: false,
244
+ keyTest: false,
245
+ stop: false,
246
+ status: false,
247
+ connectUri: false,
248
+ copy: false,
29
249
  },
30
250
  ) => {
31
- // Example usage for importing an existing key:
32
- // node bin/deploy ssh root@<host> <password> import
251
+ let confNode, confNodePath;
252
+
253
+ // Set defaults
254
+ if (!options.user) options.user = 'root';
255
+ if (!options.host) options.host = await Dns.getPublicIp();
256
+ if (!options.password) options.password = options.disablePassword ? '' : generateRandomPasswordSelection(16);
257
+ if (!options.groups) options.groups = 'wheel';
258
+ if (!options.port) options.port = 22; // Handle connect uri
259
+
260
+ const userHome = UnderpostSSH.API.getUserHome(options.user);
261
+ options.userHome = userHome;
262
+
263
+ // Load config and override password and host if user exists in config
264
+ if (options.deployId) {
265
+ const config = UnderpostSSH.API.loadConfigNode(options.deployId);
266
+ confNode = config.confNode;
267
+ confNodePath = config.confNodePath;
268
+
269
+ if (confNode.users && confNode.users[options.user]) {
270
+ if (confNode.users[options.user].host) {
271
+ options.host = confNode.users[options.user].host;
272
+ logger.info(`Using saved host for user ${options.user}: ${options.host}`);
273
+ }
274
+ if (confNode.users[options.user].password === '') {
275
+ options.disablePassword = true;
276
+ options.password = '';
277
+ logger.info(`Using saved empty password for user ${options.user}`);
278
+ } else if (confNode.users[options.user].password) {
279
+ options.disablePassword = false;
280
+ options.password = confNode.users[options.user].password;
281
+ logger.info(`Using saved password for user ${options.user}`);
282
+ }
283
+ options.port = confNode.users[options.user].port || options.port;
284
+ }
285
+ }
286
+
287
+ logger.info('options', options);
288
+
289
+ // Handle connect uri
290
+ if (options.connectUri) {
291
+ const keyPath = `${userHome}/.ssh/id_rsa`;
292
+ const uri = `ssh ${options.user}@${options.host} -i ${keyPath} -p ${options.port}`;
293
+ if (options.copy) {
294
+ pbcopy(uri);
295
+ } else console.log(uri);
296
+ return;
297
+ }
298
+
299
+ // Handle reset operation
300
+ if (options.reset) {
301
+ shellExec(`> ${userHome}/.ssh/authorized_keys`);
302
+ shellExec(`> ${userHome}/.ssh/known_hosts`);
303
+ }
304
+
305
+ if (options.userLs) {
306
+ const filter = options.filter ? `${options.filter}` : '';
307
+ const groupsOut = shellExec(`getent group${filter ? ` | grep '${filter}'` : ''}`, {
308
+ silent: true,
309
+ stdout: true,
310
+ });
311
+ const usersOut = shellExec(`getent passwd${filter ? ` | grep '${filter}'` : ''}`, {
312
+ silent: true,
313
+ stdout: true,
314
+ });
315
+ console.log('Groups'.bold.blue);
316
+ console.log(`group_name : password_x : GID(Internal Group ID) : user_list`.blue);
317
+ console.log(filter ? groupsOut.replaceAll(filter, filter.red) : groupsOut);
318
+ console.log('Users'.bold.blue);
319
+ console.log(`usuario : x : UID : GID : GECOS : home_dir : shell`.blue);
320
+ console.log(filter ? usersOut.replaceAll(filter, filter.red) : usersOut);
321
+ }
322
+
323
+ // Handle user removal (works with or without deployId)
324
+ if (options.userRemove) {
325
+ const groups = shellExec(`id -Gn ${options.user}`, { silent: true, stdout: true }).trim().replace(/ /g, ', ');
326
+ shellExec(`userdel -r ${options.user}`);
327
+
328
+ // Remove sudoers file if it exists
329
+ const sudoersFile = `/etc/sudoers.d/90_${options.user}`;
330
+ if (fs.existsSync(sudoersFile)) {
331
+ shellExec(`sudo rm -f ${sudoersFile}`);
332
+ logger.info(`Sudoers file removed: ${sudoersFile}`);
333
+ }
334
+
335
+ // Remove the private key copy folder and update config only if deployId is provided
336
+ if (options.deployId) {
337
+ if (!confNode) {
338
+ const config = UnderpostSSH.API.loadConfigNode(options.deployId);
339
+ confNode = config.confNode;
340
+ confNodePath = config.confNodePath;
341
+ }
342
+
343
+ const privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
344
+ if (fs.existsSync(privateCopyDir)) {
345
+ fs.removeSync(privateCopyDir);
346
+ logger.info(`Private key copy removed from ${privateCopyDir}`);
347
+ }
348
+
349
+ delete confNode.users[options.user];
350
+ UnderpostSSH.API.saveConfigNode(confNodePath, confNode);
351
+ }
33
352
 
34
- // Example usage for generating a new key:
35
- // node bin/deploy ssh root@<host> <password>
353
+ logger.info(`User removed`);
354
+ if (groups) logger.info(`User removed from groups: ${groups}`);
355
+ return;
356
+ }
36
357
 
358
+ // Handle user addition (works with or without deployId)
359
+ if (options.userAdd) {
360
+ let privateCopyDir, privateKeyPath, publicKeyPath, keysExistInBackup, userExistsInConfig;
361
+
362
+ // If deployId is provided, check for existing config and backup keys
363
+ if (options.deployId) {
364
+ if (!confNode) {
365
+ const config = UnderpostSSH.API.loadConfigNode(options.deployId);
366
+ confNode = config.confNode;
367
+ confNodePath = config.confNodePath;
368
+ }
369
+
370
+ userExistsInConfig = confNode.users && confNode.users[options.user];
371
+ privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
372
+ privateKeyPath = `${privateCopyDir}/id_rsa`;
373
+ publicKeyPath = `${privateCopyDir}/id_rsa.pub`;
374
+ keysExistInBackup = fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath);
375
+
376
+ // If user exists in config AND keys exist in backup, import those keys
377
+ if (userExistsInConfig && keysExistInBackup) {
378
+ logger.info(`User ${options.user} already exists in config. Importing existing keys...`);
379
+
380
+ // Create system user if it doesn't exist
381
+ const userExists = UnderpostSSH.API.checkUserExists(options.user);
382
+ if (!userExists) {
383
+ UnderpostSSH.API.createSystemUser(options.user, options.password, options.groups);
384
+ }
385
+
386
+ const userHome = UnderpostSSH.API.getUserHome(options.user);
387
+ const sshDir = `${userHome}/.ssh`;
388
+ UnderpostSSH.API.ensureSSHDirectory(sshDir);
389
+
390
+ const userKeyPath = `${sshDir}/id_rsa`;
391
+ const userPubKeyPath = `${sshDir}/id_rsa.pub`;
392
+
393
+ // Import keys from backup
394
+ fs.copyFileSync(privateKeyPath, userKeyPath);
395
+ fs.copyFileSync(publicKeyPath, userPubKeyPath);
396
+
397
+ UnderpostSSH.API.configureAuthorizedKeys(sshDir, userPubKeyPath, options.disablePassword);
398
+ UnderpostSSH.API.configureSudoAccess(options.user, options.password, options.disablePassword);
399
+ UnderpostSSH.API.configureKnownHosts(sshDir, options.port, options.host);
400
+ UnderpostSSH.API.setSSHFilePermissions(sshDir, options.user, userKeyPath, userPubKeyPath);
401
+
402
+ logger.info(`Keys imported from ${privateCopyDir} to ${sshDir}`);
403
+ logger.info(`User added with existing keys`);
404
+ return;
405
+ }
406
+ }
407
+
408
+ // New user or no existing keys - create new user and generate keys
409
+ UnderpostSSH.API.createSystemUser(options.user, options.password, options.groups);
410
+
411
+ const userHome = UnderpostSSH.API.getUserHome(options.user);
412
+ const sshDir = `${userHome}/.ssh`;
413
+ UnderpostSSH.API.ensureSSHDirectory(sshDir);
414
+
415
+ const keyPath = `${sshDir}/id_rsa`;
416
+ const pubKeyPath = `${sshDir}/id_rsa.pub`;
417
+
418
+ if (!fs.existsSync(keyPath)) {
419
+ shellExec(
420
+ `ssh-keygen -t ed25519 -f ${keyPath} -N "${options.password}" -q -C "${options.user}@${options.host}"`,
421
+ );
422
+ }
423
+
424
+ UnderpostSSH.API.configureAuthorizedKeys(sshDir, pubKeyPath, options.disablePassword);
425
+ UnderpostSSH.API.configureSudoAccess(options.user, options.password, options.disablePassword);
426
+ UnderpostSSH.API.configureKnownHosts(sshDir, options.port, options.host);
427
+ UnderpostSSH.API.setSSHFilePermissions(sshDir, options.user, keyPath, pubKeyPath);
428
+
429
+ // Save a copy of the keys to the private folder only if deployId is provided
430
+ if (options.deployId) {
431
+ if (!privateCopyDir) privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
432
+ fs.ensureDirSync(privateCopyDir);
433
+
434
+ const privateKeyCopyPath = `${privateCopyDir}/id_rsa`;
435
+ const publicKeyCopyPath = `${privateCopyDir}/id_rsa.pub`;
436
+
437
+ fs.copyFileSync(keyPath, privateKeyCopyPath);
438
+ fs.copyFileSync(pubKeyPath, publicKeyCopyPath);
439
+
440
+ logger.info(`Keys backed up to ${privateCopyDir}`);
441
+
442
+ confNode.users[options.user] = {
443
+ ...confNode.users[options.user],
444
+ ...options,
445
+ keyPath,
446
+ pubKeyPath,
447
+ privateKeyCopyPath,
448
+ publicKeyCopyPath,
449
+ };
450
+ UnderpostSSH.API.saveConfigNode(confNodePath, confNode);
451
+ }
452
+
453
+ logger.info(`User added`);
454
+ return;
455
+ }
456
+
457
+ // Handle config user listing (only with deployId)
458
+ if (options.deployId) {
459
+ if (!confNode) {
460
+ const config = UnderpostSSH.API.loadConfigNode(options.deployId);
461
+ confNode = config.confNode;
462
+ confNodePath = config.confNodePath;
463
+ }
464
+
465
+ if (options.userLs && confNode && confNode.users) {
466
+ logger.info(`Users in config:`);
467
+ Object.keys(confNode.users).forEach((user) => {
468
+ logger.info(`- ${user}`);
469
+ });
470
+ }
471
+ }
472
+
473
+ // Handle generate root keys
474
+ if (options.generate)
475
+ UnderpostSSH.API.generateKeys({ user: options.user, password: options.password, host: options.host });
476
+
477
+ // Handle list operations
478
+ if (options.keysList) shellExec(`cat ${userHome}/.ssh/authorized_keys`);
479
+ if (options.hostsList) shellExec(`cat ${userHome}/.ssh/known_hosts`);
480
+
481
+ // Handle key test
482
+ if (options.keyTest) {
483
+ const keyPath = `${userHome}/.ssh/id_rsa`;
484
+ shellExec(`ssh-keygen -y -f ${keyPath} -P "${options.password}"`);
485
+ }
486
+
487
+ // Handle stop server
488
+ if (options.stop) shellExec('service sshd stop');
489
+
490
+ // Handle start server
491
+ if (options.start) {
492
+ UnderpostSSH.API.chmod({ user: options.user });
493
+ UnderpostSSH.API.initService({ port: options.port });
494
+ }
495
+
496
+ // Handle status server
497
+ if (options.status) shellExec('service sshd status');
498
+ },
499
+
500
+ /**
501
+ * Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API.
502
+ * @async
503
+ * @function setDefautlSshCredentials
504
+ * @memberof UnderpostSSH
505
+ * @param {Object} options - Options for setting default SSH credentials
506
+ * @param {string} options.deployId - Deployment ID for the config path
507
+ * @param {string} options.user - SSH user name
508
+ * @returns {Promise<void>}
509
+ */
510
+ setDefautlSshCredentials: async (options = { deployId: '', user: '' }) => {
511
+ const confNodePath = `./engine-private/conf/${options.deployId}/conf.node.json`;
512
+ if (fs.existsSync(confNodePath)) {
513
+ const { users } = JSON.parse(fs.readFileSync(confNodePath, 'utf8'));
514
+ const { user, host, keyPath, port } = users[options.user];
515
+ UnderpostRootEnv.API.set('DEFAULT_SSH_USER', user);
516
+ UnderpostRootEnv.API.set('DEFAULT_SSH_HOST', host);
517
+ UnderpostRootEnv.API.set('DEFAULT_SSH_KEY_PATH', keyPath);
518
+ UnderpostRootEnv.API.set('DEFAULT_SSH_PORT', port);
519
+ } else logger.warn(`No SSH config found at ${confNodePath}`);
520
+ },
521
+
522
+ /**
523
+ * Generates new SSH ED25519 key pair and stores copies in multiple locations.
524
+ * @function generateKeys
525
+ * @memberof UnderpostSSH
526
+ * @param {Object} params - Key generation parameters
527
+ * @param {string} params.user - Username for the SSH key comment
528
+ * @param {string} params.password - Password to encrypt the private key
529
+ * @param {string} params.host - Host address for the SSH key comment
530
+ * @returns {void}
531
+ * @description
532
+ * Creates a new SSH ED25519 key pair and distributes it to:
533
+ * - User's ~/.ssh/ directory
534
+ * - ./engine-private/deploy/ directory
535
+ * Cleans up temporary key files after copying.
536
+ */
537
+ generateKeys: ({ user, password, host }) => {
538
+ shellExec(`sudo rm -rf ./id_rsa`);
539
+ shellExec(`sudo rm -rf ./id_rsa.pub`);
540
+
541
+ shellExec(`ssh-keygen -t ed25519 -f id_rsa -N "${password}" -q -C "${user}@${host}"`);
542
+
543
+ shellExec(`sudo cp ./id_rsa ~/.ssh/id_rsa`);
544
+ shellExec(`sudo cp ./id_rsa.pub ~/.ssh/id_rsa.pub`);
545
+
546
+ shellExec(`sudo cp ./id_rsa ./engine-private/deploy/id_rsa`);
547
+ shellExec(`sudo cp ./id_rsa.pub ./engine-private/deploy/id_rsa.pub`);
548
+
549
+ shellExec(`sudo rm -rf ./id_rsa`);
550
+ shellExec(`sudo rm -rf ./id_rsa.pub`);
551
+ },
552
+
553
+ /**
554
+ * Sets proper permissions and ownership for SSH directories and files.
555
+ * @function chmod
556
+ * @memberof UnderpostSSH
557
+ * @param {Object} params - Permission configuration parameters
558
+ * @param {string} params.user - Username for setting ownership
559
+ * @returns {void}
560
+ * @description
561
+ * Applies secure permissions to SSH files:
562
+ * - ~/.ssh/ directory: 700
563
+ * - ~/.ssh/authorized_keys: 600
564
+ * - ~/.ssh/known_hosts: 644
565
+ * - ~/.ssh/id_rsa: 600
566
+ * - /etc/ssh/ssh_host_ed25519_key: 600
567
+ * Sets ownership to specified user for ~/.ssh/ and contents.
568
+ */
569
+ chmod: ({ user }) => {
570
+ shellExec(`sudo chmod 700 ~/.ssh/`);
571
+ shellExec(`sudo chmod 600 ~/.ssh/authorized_keys`);
572
+ shellExec(`sudo chmod 644 ~/.ssh/known_hosts`);
573
+ shellExec(`sudo chmod 600 ~/.ssh/id_rsa`);
574
+ shellExec(`sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`);
575
+ shellExec(`chown -R ${user}:${user} ~/.ssh`);
576
+ },
577
+
578
+ /**
579
+ * Initializes and hardens SSH service configuration for RHEL-based systems.
580
+ * @function initService
581
+ * @memberof UnderpostSSH
582
+ * @param {Object} params - Service configuration parameters
583
+ * @param {number} params.port - Port number for SSH service
584
+ * @returns {void}
585
+ * @description
586
+ * Configures SSH daemon with hardened security settings:
587
+ * - Disables password authentication (key-only)
588
+ * - Disables root login
589
+ * - Enables ED25519 host key
590
+ * - Disables X11 forwarding and TCP forwarding
591
+ * - Sets client alive intervals to prevent ghost connections
592
+ * - Configures PAM for RHEL/SELinux compatibility
593
+ *
594
+ * After configuration:
595
+ * - Enables sshd service for auto-start on boot
596
+ * - Restarts sshd service to apply changes
597
+ * - Displays service status with colored output
598
+ */
599
+ initService: ({ port }) => {
37
600
  shellExec(
38
- `node bin/deploy ssh root@${process.env.DEFAULT_DEPLOY_HOST} ${process.env.DEFAULT_DEPLOY_PASSWORD ?? `''`}${
39
- options.generate === true ? '' : ' import'
40
- }`,
601
+ `sudo tee /etc/ssh/sshd_config <<EOF
602
+ # ==============================================================
603
+ # RHEL Hardened SSHD Configuration
604
+ # ==============================================================
605
+
606
+ # --- Network Settings ---
607
+ Port ${port ? port : '22'}
608
+ # Explicitly listen on all interfaces (IPv4 and IPv6)
609
+
610
+ # --- Host Keys ---
611
+ # ED25519 is the modern standard (Fast & Secure)
612
+ HostKey /etc/ssh/ssh_host_ed25519_key
613
+ # RSA is kept for compatibility with older clients (Optional)
614
+ # HostKey /etc/ssh/ssh_host_rsa_key
615
+
616
+ # --- Logging ---
617
+ SyslogFacility AUTHPRIV
618
+ # VERBOSE logs the key fingerprint used for login (Audit trail)
619
+ LogLevel VERBOSE
620
+
621
+ # --- Authentication & Security ---
622
+ # STRICTLY KEY-BASED AUTHENTICATION
623
+ PubkeyAuthentication yes
624
+ PasswordAuthentication no
625
+ ChallengeResponseAuthentication no
626
+ PermitEmptyPasswords no
627
+
628
+ # PREVENT ROOT LOGIN
629
+ # Administrators should log in as a standard user and use 'sudo'
630
+ PermitRootLogin no
631
+
632
+ # Security checks on ownership of ~/.ssh/ files
633
+ StrictModes yes
634
+ MaxAuthTries 3
635
+ LoginGraceTime 60
636
+
637
+ # --- PAM (Pluggable Authentication Modules) ---
638
+ # REQUIRED: Must be 'yes' on RHEL for proper session/SELinux handling.
639
+ # Since PasswordAuthentication is 'no', PAM will not ask for passwords.
640
+ UsePAM yes
641
+
642
+ # --- Session & Network Health ---
643
+ # Disconnect idle sessions after 5 minutes (300s * 0) to prevent ghost connections
644
+ ClientAliveInterval 300
645
+ ClientAliveCountMax 0
646
+
647
+ # --- Feature Restrictions ---
648
+ # Disable GUI forwarding unless explicitly needed
649
+ X11Forwarding no
650
+ # Disable DNS checks for faster logins (unless you use Host based auth)
651
+ UseDNS no
652
+ # Disable tunneling unless needed
653
+ PermitTunnel no
654
+ AllowTcpForwarding no
655
+
656
+ # --- Subsystem ---
657
+ Subsystem sftp /usr/libexec/openssh/sftp-server
658
+ EOF`,
659
+ { disableLog: true },
41
660
  );
661
+ shellExec(`sudo systemctl enable sshd`);
662
+ shellExec(`sudo systemctl restart sshd`);
663
+
664
+ const status = shellExec(`sudo systemctl status sshd`, { silent: true, stdout: true });
665
+ if (status.match('running')) console.log(status.replaceAll(`running`, `running`.green));
666
+ else {
667
+ logger.error('SSHD service failed to start');
668
+ console.log(status);
669
+ }
42
670
  },
43
671
  };
44
672
  }
@@ -798,7 +798,6 @@ const generateRandomPasswordSelection = (length) => {
798
798
  ',',
799
799
  '.',
800
800
  '|',
801
- '\\',
802
801
  ];
803
802
  const numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
804
803