underpost 3.2.9 → 3.2.11

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 (104) hide show
  1. package/.github/workflows/npmpkg.ci.yml +1 -0
  2. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  3. package/.github/workflows/release.cd.yml +1 -0
  4. package/.vscode/extensions.json +9 -9
  5. package/.vscode/settings.json +20 -4
  6. package/CHANGELOG.md +195 -1
  7. package/CLI-HELP.md +92 -23
  8. package/README.md +38 -9
  9. package/bin/build.js +27 -7
  10. package/bin/build.template.js +187 -0
  11. package/bin/deploy.js +12 -2
  12. package/bin/index.js +2 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -7
  15. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/dd-test-development/deployment.yaml +4 -2
  19. package/manifests/kind-config-dev.yaml +8 -0
  20. package/manifests/lxd/lxd-admin-profile.yaml +12 -3
  21. package/manifests/mongodb/pv-pvc.yaml +44 -8
  22. package/manifests/mongodb/statefulset.yaml +55 -68
  23. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  24. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  25. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  26. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  27. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  28. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  29. package/manifests/valkey/statefulset.yaml +1 -1
  30. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  31. package/package.json +27 -12
  32. package/scripts/ipxe-setup.sh +52 -49
  33. package/scripts/k3s-node-setup.sh +81 -46
  34. package/scripts/lxd-vm-setup.sh +193 -8
  35. package/scripts/maas-nat-firewalld.sh +145 -0
  36. package/src/api/core/core.router.js +19 -14
  37. package/src/api/core/core.service.js +5 -5
  38. package/src/api/default/default.router.js +22 -18
  39. package/src/api/default/default.service.js +5 -5
  40. package/src/api/document/document.router.js +28 -23
  41. package/src/api/document/document.service.js +100 -23
  42. package/src/api/file/file.router.js +19 -13
  43. package/src/api/file/file.service.js +9 -7
  44. package/src/api/test/test.router.js +17 -12
  45. package/src/api/types.js +24 -0
  46. package/src/api/user/guest.service.js +5 -4
  47. package/src/api/user/user.router.js +297 -288
  48. package/src/api/user/user.service.js +100 -35
  49. package/src/cli/baremetal.js +132 -101
  50. package/src/cli/cluster.js +700 -232
  51. package/src/cli/db.js +59 -60
  52. package/src/cli/deploy.js +216 -137
  53. package/src/cli/fs.js +13 -3
  54. package/src/cli/index.js +80 -15
  55. package/src/cli/ipfs.js +4 -6
  56. package/src/cli/kubectl.js +4 -1
  57. package/src/cli/lxd.js +1099 -223
  58. package/src/cli/monitor.js +9 -3
  59. package/src/cli/release.js +334 -140
  60. package/src/cli/repository.js +68 -23
  61. package/src/cli/run.js +191 -47
  62. package/src/cli/secrets.js +11 -2
  63. package/src/cli/test.js +9 -3
  64. package/src/client/Default.index.js +9 -3
  65. package/src/client/components/core/Auth.js +5 -0
  66. package/src/client/components/core/ClientEvents.js +76 -0
  67. package/src/client/components/core/EventBus.js +4 -0
  68. package/src/client/components/core/Modal.js +82 -41
  69. package/src/client/components/core/PanelForm.js +56 -52
  70. package/src/client/components/core/Worker.js +162 -363
  71. package/src/client/sw/core.sw.js +174 -112
  72. package/src/db/DataBaseProvider.js +115 -15
  73. package/src/db/mariadb/MariaDB.js +2 -1
  74. package/src/db/mongo/MongoBootstrap.js +657 -0
  75. package/src/db/mongo/MongooseDB.js +129 -21
  76. package/src/index.js +1 -1
  77. package/src/runtime/express/Express.js +2 -2
  78. package/src/runtime/wp/Wp.js +8 -5
  79. package/src/server/auth.js +2 -2
  80. package/src/server/client-build-docs.js +1 -1
  81. package/src/server/client-build.js +94 -129
  82. package/src/server/conf.js +81 -79
  83. package/src/server/process.js +180 -19
  84. package/src/server/proxy.js +9 -2
  85. package/src/server/runtime.js +1 -1
  86. package/src/server/start.js +16 -4
  87. package/src/server/valkey.js +2 -0
  88. package/src/ws/IoInterface.js +16 -16
  89. package/src/ws/core/channels/core.ws.chat.js +11 -11
  90. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  91. package/src/ws/core/channels/core.ws.stream.js +19 -19
  92. package/src/ws/core/core.ws.connection.js +8 -8
  93. package/src/ws/core/core.ws.server.js +6 -5
  94. package/src/ws/default/channels/default.ws.main.js +10 -10
  95. package/src/ws/default/default.ws.connection.js +4 -4
  96. package/src/ws/default/default.ws.server.js +4 -3
  97. package/bin/file.js +0 -202
  98. package/bin/vs.js +0 -74
  99. package/bin/zed.js +0 -84
  100. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  101. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  102. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  103. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  104. /package/src/client/ssr/{pages → views}/Test.js +0 -0
@@ -1,3 +1,12 @@
1
+ /**
2
+ * User service module for handling user account operations.
3
+ * Provides REST API handlers for authentication, registration, profile management,
4
+ * email verification, password recovery, and guest user lifecycle management.
5
+ *
6
+ * @module src/api/user/user.service.js
7
+ * @namespace UserService
8
+ */
9
+
1
10
  import { loggerFactory } from '../../server/logger.js';
2
11
  import { DataQuery } from '../../server/data-query.js';
3
12
  import {
@@ -15,21 +24,40 @@ import { MailerProvider } from '../../mailer/MailerProvider.js';
15
24
  import { CoreWsEmitter } from '../../ws/core/core.ws.emit.js';
16
25
  import { CoreWsMailerChannel } from '../../ws/core/channels/core.ws.mailer.js';
17
26
  import validator from 'validator';
18
- import { DataBaseProvider } from '../../db/DataBaseProvider.js';
27
+ import { DataBaseProviderService } from '../../db/DataBaseProvider.js';
19
28
  import { FileFactory, FileCleanup } from '../file/file.service.js';
20
29
  import { UserDto } from './user.model.js';
21
30
  import { timer } from '../../client/components/core/CommonJs.js';
22
31
  import { GuestService } from './guest.service.js';
32
+ import { resolveHostKeyContext } from '../../server/conf.js';
23
33
 
24
34
  const logger = loggerFactory(import.meta);
25
35
 
26
- const UserService = {
27
- post: async (req, res, options) => {
28
- /** @type {import('./user.model.js').UserModel} */
29
- const User = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.User;
30
-
36
+ /**
37
+ * User Service for handling REST API user operations.
38
+ * Manages authentication, registration, profile CRUD, email verification,
39
+ * password recovery, session management, and guest user lifecycle.
40
+ * @namespace UserService
41
+ */
42
+ class UserService {
43
+ /**
44
+ * POST - Create or authenticate users.
45
+ * Supports authentication, guest account creation, email verification,
46
+ * and password recovery email sending.
47
+ * @async
48
+ * @function post
49
+ * @memberof UserService
50
+ * @param {Object} req - Express request object.
51
+ * @param {Object} res - Express response object.
52
+ * @param {Object} options - Request options containing host and path.
53
+ * @returns {Promise<Object>} User data with auth token, or status message.
54
+ * @throws {Error} If authentication fails, email is invalid, or email send error.
55
+ */
56
+ static post = async (req, res, options) => {
31
57
  /** @type {import('../file/file.model.js').FileModel} */
32
- const File = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.File;
58
+ const File = DataBaseProviderService.getModel('File', options);
59
+ /** @type {import('./user.model.js').UserModel} */
60
+ const User = DataBaseProviderService.getModel('User', options);
33
61
 
34
62
  if (req.params.id === 'recover-verify-email') {
35
63
  const user = await User.findOne({
@@ -44,11 +72,10 @@ const UserService = {
44
72
 
45
73
  const token = jwtSign({ email: req.body.email }, options, 15);
46
74
  const payloadToken = jwtSign({ email: req.body.email }, options, 15);
47
- const id = `${options.host}${options.path}`;
75
+ const id = resolveHostKeyContext(options);
48
76
  const translate = MailerProvider.instance[id].translateTemplates.recoverEmail;
49
- const recoverUrl = `${process.env.NODE_ENV === 'development' ? 'http://' : 'https://'}${req.body.hostname}${
50
- req.body.proxyPath
51
- }recover?payload=${payloadToken}`;
77
+ const recoverUrl = `${process.env.NODE_ENV === 'development' ? 'http://' : 'https://'}${req.body.hostname}${req.body.proxyPath
78
+ }recover?payload=${payloadToken}`;
52
79
  const sendResult = await MailerProvider.send({
53
80
  id,
54
81
  sendOptions: {
@@ -81,7 +108,7 @@ const UserService = {
81
108
  if (!validator.isEmail(req.body.email)) throw { message: 'invalid email' };
82
109
 
83
110
  const token = jwtSign({ email: req.body.email }, options, 15);
84
- const id = `${options.host}${options.path}`;
111
+ const id = resolveHostKeyContext(options);
85
112
  const user = await User.findById(req.auth.user._id);
86
113
 
87
114
  if (user.emailConfirmed) throw new Error('email already confirmed');
@@ -130,10 +157,9 @@ const UserService = {
130
157
  });
131
158
  const getMinutesRemaining = () => (-1 * user.failedLoginAttempts - new Date().getTime()) / (1000 * 60);
132
159
  const accountLocketMessage = () =>
133
- `Account locked. Please try again in: ${
134
- getMinutesRemaining() < 1
135
- ? `${(getMinutesRemaining() * 60).toFixed(0)} s`
136
- : `${getMinutesRemaining().toFixed(0)} min`
160
+ `Account locked. Please try again in: ${getMinutesRemaining() < 1
161
+ ? `${(getMinutesRemaining() * 60).toFixed(0)} s`
162
+ : `${getMinutesRemaining().toFixed(0)} min`
137
163
  }.`;
138
164
 
139
165
  if (user) {
@@ -231,13 +257,27 @@ const UserService = {
231
257
  default:
232
258
  return await createUserAndSession(req, res, User, options);
233
259
  }
234
- },
235
- get: async (req, res, options) => {
236
- /** @type {import('./user.model.js').UserModel} */
237
- const User = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.User;
238
-
260
+ };
261
+
262
+ /**
263
+ * GET - Retrieve user data.
264
+ * Supports public profile lookup by username, asset retrieval,
265
+ * email lookup, password recovery flow, email verification,
266
+ * admin user listing, authentication refresh, and profile retrieval.
267
+ * @async
268
+ * @function get
269
+ * @memberof UserService
270
+ * @param {Object} req - Express request object.
271
+ * @param {Object} res - Express response object.
272
+ * @param {Object} options - Request options containing host and path.
273
+ * @returns {Promise<Object>} User data with optional session token.
274
+ * @throws {Error} If user not found, profile is private, or token invalid.
275
+ */
276
+ static get = async (req, res, options) => {
239
277
  /** @type {import('../file/file.model.js').FileModel} */
240
- const File = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.File;
278
+ const File = DataBaseProviderService.getModel('File', options);
279
+ /** @type {import('./user.model.js').UserModel} */
280
+ const User = DataBaseProviderService.getModel('User', options);
241
281
 
242
282
  if (req.path.startsWith('/u/')) {
243
283
  // First lookup user by username
@@ -309,7 +349,7 @@ const UserService = {
309
349
  {
310
350
  const user = await User.findByIdAndUpdate(_id, { emailConfirmed: true }, { runValidators: true });
311
351
  }
312
- const userWsId = CoreWsMailerChannel.getUserWsId(`${options.host}${options.path}`, user._id.toString());
352
+ const userWsId = CoreWsMailerChannel.getUserWsId(resolveHostKeyContext(options), user._id.toString());
313
353
  if (userWsId && CoreWsMailerChannel.client[userWsId]) {
314
354
  CoreWsEmitter.emit(CoreWsMailerChannel.channel, CoreWsMailerChannel.client[userWsId], {
315
355
  status: 'email-confirmed',
@@ -384,10 +424,23 @@ const UserService = {
384
424
  }
385
425
  }
386
426
  }
387
- },
388
- delete: async (req, res, options) => {
427
+ };
428
+
429
+ /**
430
+ * DELETE - Remove user accounts or log out sessions.
431
+ * Supports admin bulk deletion, user self-deletion, and session logout.
432
+ * @async
433
+ * @function delete
434
+ * @memberof UserService
435
+ * @param {Object} req - Express request object.
436
+ * @param {Object} res - Express response object.
437
+ * @param {Object} options - Request options containing host and path.
438
+ * @returns {Promise<Object>} Deleted user data or logout status message.
439
+ * @throws {Error} If logout fails, user not found, or token invalid.
440
+ */
441
+ static delete = async (req, res, options) => {
389
442
  /** @type {import('./user.model.js').UserModel} */
390
- const User = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.User;
443
+ const User = DataBaseProviderService.getModel('User', options);
391
444
 
392
445
  if (req.params.id === 'logout') {
393
446
  const result = await logoutSession(User, req, res);
@@ -417,13 +470,25 @@ const UserService = {
417
470
  }
418
471
  }
419
472
  }
420
- },
421
- put: async (req, res, options) => {
422
- /** @type {import('./user.model.js').UserModel} */
423
- const User = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.User;
424
-
473
+ };
474
+
475
+ /**
476
+ * PUT - Update user data.
477
+ * Supports profile image upload, password recovery, and profile field updates.
478
+ * @async
479
+ * @function put
480
+ * @memberof UserService
481
+ * @param {Object} req - Express request object.
482
+ * @param {Object} res - Express response object.
483
+ * @param {Object} options - Request options containing host and path.
484
+ * @returns {Promise<Object>} Updated user data.
485
+ * @throws {Error} If user not found, token invalid, or invalid file.
486
+ */
487
+ static put = async (req, res, options) => {
425
488
  /** @type {import('../file/file.model.js').FileModel} */
426
- const File = DataBaseProvider.instance[`${options.host}${options.path}`].mongoose.models.File;
489
+ const File = DataBaseProviderService.getModel('File', options);
490
+ /** @type {import('./user.model.js').UserModel} */
491
+ const User = DataBaseProviderService.getModel('User', options);
427
492
 
428
493
  // req.path | req.baseUrl
429
494
 
@@ -511,7 +576,7 @@ const UserService = {
511
576
  }
512
577
  }
513
578
  }
514
- },
515
- };
579
+ };
580
+ }
516
581
 
517
- export { UserService };
582
+ export { UserService };
@@ -27,6 +27,13 @@ const logger = loggerFactory(import.meta);
27
27
  * and system provisioning for different architectures.
28
28
  */
29
29
  class UnderpostBaremetal {
30
+ // NFSv3 RPC ports. Single source of truth shared by the firewall/export setup
31
+ // (rebuildNfsServer) and the kernel `nfsroot=` mount options so the client mount and the
32
+ // opened firewall ports always agree.
33
+ // rpc.statd rejects identical listen and outgoing ports (exit 255 "Listening and outgoing ports cannot be the same!").
34
+ // statd=32765 (listen), statdOutgoing=32766 (SM_NOTIFY source port) is the standard split.
35
+ static NFS_V3_PORTS = { mountd: 20048, statd: 32765, statdOutgoing: 32766, lockd: 32803 };
36
+
30
37
  static API = {
31
38
  /**
32
39
  * @method callback
@@ -356,7 +363,7 @@ class UnderpostBaremetal {
356
363
 
357
364
  // Build phase (skip if upload-only mode)
358
365
  if (options.packerMaasImageBuild) {
359
- if (shellExec('packer version').code !== 0) {
366
+ if (shellExec('packer version', { silentOnError: true }).code !== 0) {
360
367
  throw new Error('Packer is not installed. Please install Packer to proceed.');
361
368
  }
362
369
 
@@ -424,7 +431,9 @@ rm -rf ${artifacts.join(' ')}`);
424
431
  const uploadCmd = `${uploadScript} ${process.env.MAAS_ADMIN_USERNAME} "${workflow.maas.name}" "${workflow.maas.title}" "${workflow.maas.architecture}" "${workflow.maas.base_image}" "${workflow.maas.filetype}" "${tarballPath}"`;
425
432
 
426
433
  logger.info(`Uploading to MAAS using: ${uploadScript}`);
427
- const uploadResult = shellExec(uploadCmd);
434
+ // silentOnError: caller logs stdout/stderr structure on failure
435
+ // before throwing its own, more informative error.
436
+ const uploadResult = shellExec(uploadCmd, { silentOnError: true });
428
437
  if (uploadResult.code !== 0) {
429
438
  logger.error(`Upload failed with exit code: ${uploadResult.code}`);
430
439
  if (uploadResult.stdout) {
@@ -496,10 +505,13 @@ rm -rf ${artifacts.join(' ')}`);
496
505
 
497
506
  // Handle control server installation.
498
507
  if (options.controlServerInstall === true) {
499
- // Ensure scripts are executable and then run them.
508
+ // Ensure the MAAS setup script is executable and then run it.
500
509
  shellExec(`chmod +x ${underpostRoot}/scripts/maas-setup.sh`);
501
- shellExec(`chmod +x ${underpostRoot}/scripts/nat-iptables.sh`);
510
+ if (!fs.existsSync(`${process.env.HOME}/.ssh/id_rsa.pub`)) shellExec(`node bin ssh --generate`);
502
511
  shellExec(`${underpostRoot}/scripts/maas-setup.sh`);
512
+ // Install GRUB modules into the NFS root filesystem to
513
+ // ensure the necessary files are present for bootloader installation later.
514
+ Underpost.baremetal.installGrubModules();
503
515
  return;
504
516
  }
505
517
 
@@ -599,7 +611,7 @@ rm -rf ${artifacts.join(' ')}`);
599
611
  }
600
612
 
601
613
  // Create a podman container to extract QEMU static binaries.
602
- shellExec(`sudo podman create --name extract multiarch/qemu-user-static`);
614
+ shellExec(`sudo podman create --name extract docker.io/multiarch/qemu-user-static`);
603
615
  shellExec(`podman ps -a`); // List all podman containers for verification.
604
616
 
605
617
  // If cross-architecture, copy the QEMU static binary into the chroot.
@@ -915,9 +927,6 @@ rm -rf ${artifacts.join(' ')}`);
915
927
  isoUrl: workflowsConfig[workflowId].isoUrl,
916
928
  });
917
929
 
918
- // Set up iptables rules for NAT and port forwarding to enable network connectivity for the baremetal machines.
919
- shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
920
-
921
930
  // Start HTTP bootstrap server if commissioning or if ISO URL is used (for ISO-based workflows).
922
931
  if (options.bootstrapHttpServerRun || options.commission) {
923
932
  Underpost.baremetal.httpBootstrapServerRunnerFactory({
@@ -931,7 +940,7 @@ rm -rf ${artifacts.join(' ')}`);
931
940
  });
932
941
  }
933
942
 
934
- // Rebuild NFS server configuration.
943
+ // Rebuild NFS exports and the matching MAAS/firewalld host configuration.
935
944
  if (
936
945
  (options.nfsBuildServer === true || options.commission === true) &&
937
946
  (workflowsConfig[workflowId].type === 'iso-nfs' ||
@@ -941,6 +950,7 @@ rm -rf ${artifacts.join(' ')}`);
941
950
  Underpost.baremetal.rebuildNfsServer({
942
951
  nfsHostPath,
943
952
  nfsReset: options.nfsReset,
953
+ underpostRoot,
944
954
  });
945
955
 
946
956
  // Handle commissioning tasks
@@ -1397,7 +1407,9 @@ rm -rf ${artifacts.join(' ')}`);
1397
1407
  shellExec(`mkdir -p ${mountPoint}`);
1398
1408
 
1399
1409
  // Ensure mount point is not already mounted
1400
- shellExec(`sudo umount ${mountPoint} 2>/dev/null`);
1410
+ shellExec(`sudo umount ${mountPoint}`, {
1411
+ silentOnError: true, // Ignore errors if not mounted
1412
+ });
1401
1413
 
1402
1414
  try {
1403
1415
  // Mount the ISO
@@ -2340,39 +2352,36 @@ fi
2340
2352
  // Determine OS family from osIdLike
2341
2353
  const { isDebianBased, isRhelBased } = Underpost.baremetal.getFamilyBaseOs(options.osIdLike);
2342
2354
 
2343
- const ipParam =
2344
- `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}` +
2345
- `:${networkInterfaceName ? networkInterfaceName : 'eth0'}:${ipConfig}:${dnsServer}`;
2346
-
2347
- const nfsOptions = `${
2348
- type === 'chroot-debootstrap' || type === 'chroot-container'
2349
- ? [
2350
- 'tcp',
2351
- 'nfsvers=3',
2352
- 'nolock',
2353
- // 'protocol=tcp',
2354
- // 'hard=true',
2355
- 'port=2049',
2356
- // 'sec=none',
2357
- 'hard',
2358
- 'intr',
2359
- 'rsize=32768',
2360
- 'wsize=32768',
2361
- 'acregmin=0',
2362
- 'acregmax=0',
2363
- 'acdirmin=0',
2364
- 'acdirmax=0',
2365
- 'noac',
2366
- // 'nodev',
2367
- // 'nosuid',
2368
- ]
2369
- : []
2370
- }`;
2371
-
2372
- const nfsRootParam = `nfsroot=${ipFileServer}:${process.env.NFS_EXPORT_PATH}/${hostname}${
2373
- nfsOptions ? `,${nfsOptions}` : ''
2374
- }`;
2375
-
2355
+ const ifaceName = networkInterfaceName ? networkInterfaceName : 'eth0';
2356
+ const isStaticIp = ipConfig === 'none' || ipConfig === 'off';
2357
+ const ipParam = isStaticIp
2358
+ ? `ip=${ipClient}:${ipFileServer}:${ipDhcpServer}:${netmask}:${hostname}:${ifaceName}:${ipConfig}:${dnsServer}`
2359
+ : `ip=::::${hostname}:${ifaceName}:${ipConfig}`;
2360
+
2361
+ let nfsMountOptions = [];
2362
+ if (type === 'chroot-debootstrap' || type === 'chroot-container') {
2363
+ nfsMountOptions = [
2364
+ 'tcp',
2365
+ 'nfsvers=3',
2366
+ 'nolock',
2367
+ 'port=2049',
2368
+ 'hard',
2369
+ 'intr',
2370
+ 'rsize=32768',
2371
+ 'wsize=32768',
2372
+ 'acregmin=0',
2373
+ 'acregmax=0',
2374
+ 'acdirmin=0',
2375
+ 'acdirmax=0',
2376
+ 'noac',
2377
+ ];
2378
+ } else if (type === 'iso-nfs' && isDebianBased) {
2379
+ nfsMountOptions = ['nolock', 'nfsvers=3', 'tcp', 'hard', 'port=2049', 'rsize=32768', 'wsize=32768'];
2380
+ }
2381
+ const nfsOptions = nfsMountOptions.join(',');
2382
+ const nfsServerPath = `${ipFileServer}:${process.env.NFS_EXPORT_PATH}/${hostname}`;
2383
+ const nfsRootParam = `nfsroot=${nfsServerPath}${nfsOptions ? `,${nfsOptions}` : ''}`;
2384
+ const casperNfsParams = [`nfsroot=${nfsServerPath}`, ...(nfsOptions ? [`nfsopts=${nfsOptions}`] : [])];
2376
2385
  const permissionsParams = [
2377
2386
  `rw`,
2378
2387
  // `ro`
@@ -2445,8 +2454,12 @@ fi
2445
2454
  let qemuNfsRootParams = [`root=/dev/nfs`, `rootfstype=nfs`];
2446
2455
  cmd = [ipParam, ...qemuNfsRootParams, nfsRootParam, ...kernelParams];
2447
2456
  } else {
2448
- // 'iso-nfs' — Debian/Ubuntu NFS root boot: kernel/initrd from ISO, root filesystem served via NFS.
2449
- cmd = [ipParam, `netboot=nfs`, nfsRootParam, ...kernelParams, ...performanceParams];
2457
+ // 'iso-nfs' — kernel/initrd from ISO, root filesystem served via NFS.
2458
+ if (isDebianBased) {
2459
+ cmd = [ipParam, `boot=casper`, `netboot=nfs`, ...casperNfsParams, ...kernelParams];
2460
+ } else {
2461
+ cmd = [ipParam, `netboot=nfs`, nfsRootParam, ...kernelParams, ...performanceParams];
2462
+ }
2450
2463
  }
2451
2464
 
2452
2465
  // Add RHEL/Rocky/Fedora based images specific parameters
@@ -2456,7 +2469,7 @@ fi
2456
2469
  }
2457
2470
  // Add Debian/Ubuntu based images specific parameters
2458
2471
  else if (isDebianBased) {
2459
- cmd = cmd.concat([`initrd=initrd.img`, `init=/sbin/init`]);
2472
+ if (type !== 'iso-nfs') cmd = cmd.concat([`initrd=initrd.img`, `init=/sbin/init`]);
2460
2473
  if (options.dev) cmd = cmd.concat([`debug`, `ignore_loglevel`]);
2461
2474
  }
2462
2475
 
@@ -2702,7 +2715,7 @@ fi
2702
2715
  shellExec(`sudo podman run --rm --privileged docker.io/multiarch/qemu-user-static:latest --reset -p yes`);
2703
2716
  // Mount binfmt_misc filesystem.
2704
2717
  shellExec(`sudo modprobe binfmt_misc`);
2705
- shellExec(`sudo mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc`);
2718
+ shellExec(`sudo mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc`, { silentOnError: true });
2706
2719
  },
2707
2720
 
2708
2721
  /**
@@ -2759,6 +2772,19 @@ fi
2759
2772
  await Underpost.baremetal.macMonitor({ nfsHostPath });
2760
2773
  },
2761
2774
 
2775
+ /**
2776
+ * @method installGrubModules
2777
+ * @description Installs the necessary GRUB modules for both ARM64 and AMD64 architectures.
2778
+ * This ensures that the GRUB bootloader can properly load the kernel and initrd images
2779
+ * during the network boot process, regardless of the target architecture.
2780
+ * @memberof UnderpostBaremetal
2781
+ * @returns {void}
2782
+ */
2783
+ installGrubModules() {
2784
+ if (!fs.existsSync('/usr/lib/grub/x86_64-efi')) shellExec(`sudo dnf install -y grub2-efi-x64-modules`);
2785
+ if (!fs.existsSync('/usr/lib/grub/arm64-efi')) shellExec(`sudo dnf install -y grub2-efi-aa64-modules`);
2786
+ },
2787
+
2762
2788
  /**
2763
2789
  * @method crossArchBinFactory
2764
2790
  * @description Copies the appropriate QEMU static binary into the NFS root filesystem
@@ -2784,9 +2810,6 @@ fi
2784
2810
  logger.warn(`Unsupported bootstrap architecture: ${bootstrapArch}`);
2785
2811
  break;
2786
2812
  }
2787
- // Install GRUB EFI modules for both architectures to ensure compatibility.
2788
- shellExec(`sudo dnf install -y grub2-efi-aa64-modules`);
2789
- shellExec(`sudo dnf install -y grub2-efi-x64-modules`);
2790
2813
  },
2791
2814
 
2792
2815
  /**
@@ -2895,9 +2918,17 @@ EOF`);
2895
2918
  for (const mountPath of mounts[mountCmd]) {
2896
2919
  const hostMountPath = `${process.env.NFS_EXPORT_PATH}/${hostname}${mountPath}`;
2897
2920
  // Check if the path is already mounted using `mountpoint` command.
2898
- const isPathMounted = !shellExec(`mountpoint ${hostMountPath}`, { silent: true, stdout: true }).match(
2899
- 'not a mountpoint',
2900
- );
2921
+ // `mountpoint` exits 1 when the path is not a mountpoint — silentOnError
2922
+ // prevents ShellExecError so we can inspect stdout/stderr for the string.
2923
+ const mountpointOut = shellExec(`mountpoint ${hostMountPath}`, {
2924
+ silent: true,
2925
+ stdout: true,
2926
+ silentOnError: true,
2927
+ });
2928
+ const isPathMounted =
2929
+ typeof mountpointOut === 'string' && mountpointOut.length > 0
2930
+ ? !mountpointOut.match('not a mountpoint') && !mountpointOut.match('No such file')
2931
+ : false;
2901
2932
 
2902
2933
  if (isPathMounted) {
2903
2934
  logger.warn('Nfs path already mounted', mountPath);
@@ -2957,54 +2988,48 @@ EOF`);
2957
2988
 
2958
2989
  /**
2959
2990
  * @method rebuildNfsServer
2960
- * @description Configures and restarts the NFS server to export the specified path.
2961
- * This is crucial for allowing baremetal machines to boot via NFS.
2991
+ * @description Configures NFS exports and aligns host firewall/NFS daemon ports for MAAS workflows.
2962
2992
  * @param {object} params - The parameters for the function.
2963
2993
  * @param {string} params.nfsHostPath - The path to the NFS server export.
2964
2994
  * @memberof UnderpostBaremetal
2965
2995
  * @param {string} [params.subnet='192.168.1.0/24'] - The subnet allowed to access the NFS export.
2966
2996
  * @param {boolean} [params.nfsReset=false] - Flag to completely reset the NFS server (restart service).
2997
+ * @param {string} [params.underpostRoot] - Repository root used to locate helper scripts.
2967
2998
  * @returns {void}
2968
2999
  */
2969
- rebuildNfsServer({ nfsHostPath, subnet, nfsReset }) {
3000
+ rebuildNfsServer({ nfsHostPath, subnet, nfsReset, underpostRoot }) {
2970
3001
  if (!subnet) subnet = '192.168.1.0/24'; // Default subnet if not provided.
3002
+ if (!underpostRoot) {
3003
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
3004
+ underpostRoot = path.resolve(__dirname, '../..');
3005
+ }
3006
+
3007
+ const maasNatFirewalldPath = path.resolve(underpostRoot, 'scripts/maas-nat-firewalld.sh');
3008
+ const nfsPorts = UnderpostBaremetal.NFS_V3_PORTS;
3009
+ const exportOptions = ['rw', 'sync', 'no_root_squash', 'no_subtree_check', 'insecure'].join(',');
3010
+
3011
+ if (!fs.existsSync(maasNatFirewalldPath)) {
3012
+ throw new Error(`MAAS firewalld helper not found: ${maasNatFirewalldPath}`);
3013
+ }
3014
+
2971
3015
  // Write the NFS exports configuration to /etc/exports.
2972
- fs.writeFileSync(
2973
- `/etc/exports`,
2974
- `${nfsHostPath} ${subnet}(${[
2975
- 'rw', // Read-write access.
2976
- // 'all_squash', // Squash all client UIDs/GIDs to anonymous.
2977
- 'sync', // Synchronous writes.
2978
- 'no_root_squash', // Do not squash root user.
2979
- 'no_subtree_check', // Disable subtree checking.
2980
- 'insecure', // Allow connections from non-privileged ports.
2981
- ]})`,
2982
- 'utf8',
2983
- );
3016
+ if (!fs.existsSync(nfsHostPath)) fs.mkdirSync(nfsHostPath, { recursive: true });
3017
+ fs.writeFileSync(`/etc/exports`, `${nfsHostPath} ${subnet}(${exportOptions})`, 'utf8');
2984
3018
 
2985
- logger.info('Writing NFS server configuration to /etc/nfs.conf...');
2986
- // Write NFS daemon configuration, including port settings.
2987
- fs.writeFileSync(
2988
- `/etc/nfs.conf`,
2989
- `[mountd]
2990
- port = 20048
2991
-
2992
- [statd]
2993
- port = 32765
2994
- outgoing-port = 32765
2995
-
2996
- [nfsd]
2997
- # Enable RDMA support if desired and hardware supports it.
2998
- rdma=y
2999
- rdma-port=20049
3000
-
3001
- [lockd]
3002
- port = 32766
3003
- udp-port = 32766
3004
- `,
3005
- 'utf8',
3019
+ logger.info('Configuring MAAS firewalld and fixed NFSv3 ports...');
3020
+ shellExec(
3021
+ [
3022
+ `MAAS_LAN_CIDR=${subnet}`,
3023
+ `NFS_MODE=v3`,
3024
+ `CONFIGURE_NFS_V3_PORTS=true`,
3025
+ `NFS_MOUNTD_PORT=${nfsPorts.mountd}`,
3026
+ `NFS_STATD_PORT=${nfsPorts.statd}`,
3027
+ `NFS_STATD_OUTGOING_PORT=${nfsPorts.statdOutgoing}`,
3028
+ `NFS_LOCKD_PORT=${nfsPorts.lockd}`,
3029
+ `NFS_LOCKD_UDP_PORT=${nfsPorts.lockd}`,
3030
+ `bash "${maasNatFirewalldPath}"`,
3031
+ ].join(' '),
3006
3032
  );
3007
- logger.info('NFS configuration written.');
3008
3033
 
3009
3034
  logger.info('Reloading NFS exports...');
3010
3035
  shellExec(`sudo exportfs -rav`);
@@ -3013,12 +3038,18 @@ udp-port = 32766
3013
3038
  logger.info('Displaying active NFS exports');
3014
3039
  shellExec(`sudo exportfs -s`);
3015
3040
 
3016
- // Restart the nfs-server service to apply all configuration changes,
3017
- // including port settings from /etc/nfs.conf and export changes.
3018
3041
  if (nfsReset) {
3019
- logger.info('Restarting nfs-server service...');
3020
- shellExec(`sudo systemctl restart nfs-server`);
3021
- logger.info('NFS server restarted.');
3042
+ logger.info('Restarting NFS server service...');
3043
+ let restarted = false;
3044
+ for (const unit of ['nfs-server', 'nfs-kernel-server']) {
3045
+ const result = shellExec(`sudo systemctl restart ${unit}`, { silentOnError: true });
3046
+ if (result.code === 0) {
3047
+ restarted = true;
3048
+ logger.info(`NFS server restarted via ${unit}.`);
3049
+ break;
3050
+ }
3051
+ }
3052
+ if (!restarted) logger.warn('Unable to restart nfs-server or nfs-kernel-server after export reload.');
3022
3053
  }
3023
3054
  },
3024
3055
 
@@ -3041,10 +3072,10 @@ udp-port = 32766
3041
3072
  // Check both /usr/local/bin (compiled) and system paths
3042
3073
  let qemuAarch64Path = null;
3043
3074
 
3044
- if (shellExec('test -x /usr/local/bin/qemu-system-aarch64').code === 0) {
3075
+ if (shellExec('test -x /usr/local/bin/qemu-system-aarch64', { silentOnError: true }).code === 0) {
3045
3076
  qemuAarch64Path = '/usr/local/bin/qemu-system-aarch64';
3046
- } else if (shellExec('which qemu-system-aarch64').code === 0) {
3047
- qemuAarch64Path = shellExec('which qemu-system-aarch64').stdout.trim();
3077
+ } else if (shellExec('which qemu-system-aarch64', { silentOnError: true }).code === 0) {
3078
+ qemuAarch64Path = shellExec('which qemu-system-aarch64', { stdout: true }).trim();
3048
3079
  }
3049
3080
 
3050
3081
  if (!qemuAarch64Path) {
@@ -3070,10 +3101,10 @@ udp-port = 32766
3070
3101
  // Check both /usr/local/bin (compiled) and system paths
3071
3102
  let qemuX86Path = null;
3072
3103
 
3073
- if (shellExec('test -x /usr/local/bin/qemu-system-x86_64').code === 0) {
3104
+ if (shellExec('test -x /usr/local/bin/qemu-system-x86_64', { silentOnError: true }).code === 0) {
3074
3105
  qemuX86Path = '/usr/local/bin/qemu-system-x86_64';
3075
- } else if (shellExec('which qemu-system-x86_64').code === 0) {
3076
- qemuX86Path = shellExec('which qemu-system-x86_64').stdout.trim();
3106
+ } else if (shellExec('which qemu-system-x86_64', { silentOnError: true }).code === 0) {
3107
+ qemuX86Path = shellExec('which qemu-system-x86_64', { stdout: true }).trim();
3077
3108
  }
3078
3109
 
3079
3110
  if (!qemuX86Path) {