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
@@ -7,8 +7,9 @@
7
7
  import { loggerFactory } from './logger.js';
8
8
  import { shellExec } from './process.js';
9
9
  import fs from 'fs-extra';
10
+ import dotenv from 'dotenv';
10
11
  import Underpost from '../index.js';
11
- import { getUnderpostRootPath, loadCronDeployEnv } from './conf.js';
12
+ import { getUnderpostRootPath } from './environment.js';
12
13
 
13
14
  const logger = loggerFactory(import.meta);
14
15
 
@@ -17,6 +18,45 @@ const enginePath = '/home/dd/engine';
17
18
  const cronVolumeName = 'underpost-cron-container-volume';
18
19
  const shareEnvVolumeName = 'underpost-share-env';
19
20
  const underpostContainerEnvDir = '/usr/lib/node_modules/underpost';
21
+ const DEFAULT_CRON_ID = 'dd-cron';
22
+
23
+ /**
24
+ * Resolves the deploy ID stored in `engine-private/deploy/dd.cron`.
25
+ * @returns {string|null}
26
+ * @memberof UnderpostCron
27
+ */
28
+ const cronDeployIdResolve = () => {
29
+ const path = './engine-private/deploy/dd.cron';
30
+ if (!fs.existsSync(path)) return null;
31
+ return fs.readFileSync(path, 'utf8').trim() || null;
32
+ };
33
+
34
+ /**
35
+ * Loads cron and router deployment environment files into `process.env`.
36
+ * @returns {void}
37
+ * @memberof UnderpostCron
38
+ */
39
+ const loadCronDeployEnv = () => {
40
+ const envName = process.env.NODE_ENV || 'production';
41
+ const cronDeployId = cronDeployIdResolve();
42
+
43
+ if (cronDeployId) {
44
+ const path = `./engine-private/conf/${cronDeployId}/.env.${envName}`;
45
+ if (fs.existsSync(path)) process.env = { ...process.env, ...dotenv.parse(fs.readFileSync(path, 'utf8')) };
46
+ }
47
+
48
+ const routerPath = './engine-private/deploy/dd.router';
49
+ if (!fs.existsSync(routerPath)) return;
50
+ for (const deployId of fs.readFileSync(routerPath, 'utf8').trim().split(',')) {
51
+ const id = deployId.trim();
52
+ const path = `./engine-private/conf/${id}/.env.${envName}`;
53
+ if (!id || !fs.existsSync(path)) continue;
54
+ const env = dotenv.parse(fs.readFileSync(path, 'utf8'));
55
+ for (const [key, value] of Object.entries(env)) {
56
+ if (!(key in process.env)) process.env[key] = value;
57
+ }
58
+ }
59
+ };
20
60
 
21
61
  /**
22
62
  * Generates a Kubernetes CronJob YAML manifest string.
@@ -36,6 +76,8 @@ const underpostContainerEnvDir = '/usr/lib/node_modules/underpost';
36
76
  * @param {boolean} [params.k3s=false] - Pass --k3s flag to the cron command inside the container
37
77
  * @param {boolean} [params.kind=false] - Pass --kind flag to the cron command inside the container
38
78
  * @param {boolean} [params.kubeadm=false] - Pass --kubeadm flag to the cron command inside the container
79
+ * @param {string} [params.nodeName] - Pin the Job pod to this node via a `kubernetes.io/hostname` nodeSelector.
80
+ * Placement is a manifest concern only, so it is never forwarded to the cron command inside the container.
39
81
  * @returns {string} Kubernetes CronJob YAML manifest
40
82
  * @memberof UnderpostCron
41
83
  */
@@ -54,6 +96,7 @@ const cronJobYamlFactory = ({
54
96
  k3s = false,
55
97
  kind = false,
56
98
  kubeadm = false,
99
+ nodeName = '',
57
100
  }) => {
58
101
  const containerImage = image || `underpost/underpost-engine:${Underpost.version}`;
59
102
 
@@ -64,9 +107,11 @@ const cronJobYamlFactory = ({
64
107
  .replace(/^-|-$/g, '')
65
108
  .substring(0, 52);
66
109
 
110
+ const cronDeployId = cronDeployIdResolve();
111
+
67
112
  const cronBin = 'node bin'; // dev ? 'node bin' : 'underpost';
68
113
  const flags = `${git ? '--git ' : ''}${dev ? '--dev ' : ''}${dryRun ? '--dry-run ' : ''}${k3s ? '--k3s ' : ''}${kind ? '--kind ' : ''}${kubeadm ? '--kubeadm ' : ''}`;
69
- const commands = [`cd ${enginePath}`]; // `node bin run secret`
114
+ const commands = [`cd ${enginePath}`, `node bin env ${cronDeployId} ${dev ? `development` : `production`}`]; // `node bin run secret`
70
115
  if (cmd) commands.push(cmd);
71
116
  commands.push(`${cronBin} cron ${deployList} ${jobList} ${flags}`);
72
117
  const fullCommand = commands.join(' &&\n ');
@@ -87,6 +132,10 @@ spec:
87
132
  failedJobsHistoryLimit: 1
88
133
  suspend: ${suspend}
89
134
  jobTemplate:
135
+ metadata:
136
+ labels:
137
+ app: ${sanitizedName}
138
+ managed-by: underpost
90
139
  spec:
91
140
  template:
92
141
  metadata:
@@ -94,7 +143,13 @@ spec:
94
143
  app: ${sanitizedName}
95
144
  managed-by: underpost
96
145
  spec:
97
- containers:
146
+ ${
147
+ nodeName
148
+ ? ` nodeSelector:
149
+ kubernetes.io/hostname: ${nodeName}
150
+ `
151
+ : ''
152
+ } containers:
98
153
  - name: ${sanitizedName}
99
154
  image: ${containerImage}
100
155
  command:
@@ -138,20 +193,90 @@ const syncEngineToKindWorker = () => {
138
193
 
139
194
  /**
140
195
  * Resolves the deploy-id to use for cron job generation.
141
- * When deployId is provided directly, uses it. Otherwise reads from dd.cron file.
196
+ * Uses the explicit value or the deploy ID stored in `dd.cron`.
142
197
  *
143
198
  * @param {string} [deployId] - Explicit deploy-id override
144
199
  * @memberof UnderpostCron
145
200
  * @returns {string|null} Resolved deploy-id or null if not found
146
201
  */
147
- const resolveDeployId = (deployId) => {
148
- if (deployId) return deployId;
202
+ const resolveDeployId = (deployId) => deployId || cronDeployIdResolve();
149
203
 
150
- const cronDeployFilePath = './engine-private/deploy/dd.cron';
151
- if (!fs.existsSync(cronDeployFilePath)) {
152
- return null;
153
- }
154
- return fs.readFileSync(cronDeployFilePath, 'utf8').trim();
204
+ /**
205
+ * Parses a comma-separated CLI list into a trimmed, non-empty array.
206
+ *
207
+ * @param {string|string[]} [value] - Raw CLI value
208
+ * @memberof UnderpostCron
209
+ * @returns {string[]} Parsed entries
210
+ */
211
+ const parseList = (value) => {
212
+ if (Array.isArray(value)) return value.map((entry) => `${entry}`.trim()).filter(Boolean);
213
+ if (typeof value !== 'string') return [];
214
+ return value
215
+ .split(',')
216
+ .map((entry) => entry.trim())
217
+ .filter(Boolean);
218
+ };
219
+
220
+ /**
221
+ * Normalizes and validates a `--node-name` value before it reaches a manifest or a shell.
222
+ *
223
+ * @param {string} [nodeName] - Raw CLI value
224
+ * @memberof UnderpostCron
225
+ * @returns {string} Trimmed node name, or '' when unset
226
+ * @throws {Error} When the value is not a valid Kubernetes node name
227
+ */
228
+ const resolveNodeName = (nodeName) => {
229
+ const node = `${nodeName || ''}`.trim();
230
+ if (node && !/^[a-zA-Z0-9._-]+$/.test(node)) throw new Error(`Invalid Kubernetes node name: ${node}`);
231
+ return node;
232
+ };
233
+
234
+ /**
235
+ * Checks whether a node is registered on the cluster.
236
+ *
237
+ * @param {string} nodeName - Node name
238
+ * @memberof UnderpostCron
239
+ * @returns {boolean} True when the node exists
240
+ */
241
+ const nodeExists = (nodeName) => {
242
+ const stdout = shellExec(`kubectl get node ${nodeName} -o name`, {
243
+ silent: true,
244
+ stdout: true,
245
+ silentOnError: true,
246
+ disableLog: true,
247
+ });
248
+ return `${stdout || ''}`.trim().length > 0;
249
+ };
250
+
251
+ /**
252
+ * Checks whether a CronJob is already published on the cluster.
253
+ *
254
+ * @param {string} cronJobName - Sanitized CronJob name
255
+ * @param {string} namespace - Kubernetes namespace
256
+ * @memberof UnderpostCron
257
+ * @returns {boolean} True when the CronJob exists
258
+ */
259
+ const cronJobExists = (cronJobName, namespace) => {
260
+ const stdout = shellExec(`kubectl get cronjob ${cronJobName} -n ${namespace} --ignore-not-found -o name`, {
261
+ silent: true,
262
+ stdout: true,
263
+ silentOnError: true,
264
+ disableLog: true,
265
+ });
266
+ return `${stdout || ''}`.trim().length > 0;
267
+ };
268
+
269
+ /**
270
+ * Resolves the manifest owner deploy-id from the `deploy-list` positional argument.
271
+ * The `default` sentinel means "not provided", deferring to the dd.cron file.
272
+ *
273
+ * @param {string} [deployList] - Comma-separated deploy IDs from the CLI
274
+ * @memberof UnderpostCron
275
+ * @returns {string|undefined} Owner deploy-id, or undefined when unspecified
276
+ */
277
+ const deployIdFromList = (deployList) => {
278
+ const [deployId] = parseList(deployList);
279
+ return !deployId || deployId === 'default' ? undefined : deployId;
155
280
  };
156
281
 
157
282
  /**
@@ -165,6 +290,7 @@ class UnderpostCron {
165
290
  return {
166
291
  dns: Underpost.dns,
167
292
  backup: Underpost.backup,
293
+ vultr: Underpost.vultr,
168
294
  };
169
295
  }
170
296
 
@@ -172,8 +298,12 @@ class UnderpostCron {
172
298
  /**
173
299
  * CLI entry point for the `underpost cron` command.
174
300
  *
175
- * @param {string} deployList - Comma-separated deploy IDs
176
- * @param {string} jobList - Comma-separated job IDs
301
+ * Manifest modes (`--setup-start`, `--generate-k8s-cronjobs`, `--apply`, `--create-job-now`)
302
+ * never run job callbacks in this process: they write and publish manifests, and hand the
303
+ * work to the cluster. All of them are scoped to `job-list` when given.
304
+ *
305
+ * @param {string} deployList - Comma-separated deploy IDs; in manifest modes its first entry is the manifest owner deploy-id
306
+ * @param {string} jobList - Comma-separated job IDs; in manifest modes it restricts which conf.cron.json jobs are generated
177
307
  * @param {Object} options - CLI flags
178
308
  * @param {boolean} [options.generateK8sCronjobs] - Generate K8s CronJob YAML manifests
179
309
  * @param {boolean} [options.apply] - Apply manifests to the cluster
@@ -182,32 +312,44 @@ class UnderpostCron {
182
312
  * @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
183
313
  * @param {string} [options.namespace] - Kubernetes namespace
184
314
  * @param {string} [options.image] - Custom container image
185
- * @param {string} [options.setupStart] - Deploy-id to setup: updates its package.json start and generates+applies cron jobs
315
+ * @param {boolean} [options.setupStart] - Update the deploy-id package.json start script and generate+apply its cron jobs
186
316
  * @param {boolean} [options.k3s] - Use k3s cluster context (apply directly on host)
187
317
  * @param {boolean} [options.kind] - Use kind cluster context (apply via kind-worker container)
188
318
  * @param {boolean} [options.kubeadm] - Use kubeadm cluster context (apply directly on host)
189
319
  * @param {boolean} [options.dryRun] - Preview cron jobs without executing them
190
320
  * @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob (requires --apply)
321
+ * @param {string} [options.nodeName] - Pin generated CronJob pods to this node (manifest modes only)
191
322
  * @memberof UnderpostCron
192
323
  */
193
- callback: async function (
194
- deployList = 'default',
195
- jobList = Object.keys(Underpost.cron.JOB).join(','),
196
- options = {},
197
- ) {
324
+ callback: async function (deployList, jobList, options = {}) {
198
325
  loadCronDeployEnv();
199
- if (options.setupStart) return await Underpost.cron.setupDeployStart(options.setupStart, options);
200
326
 
201
- if (options.generateK8sCronjobs) return await Underpost.cron.generateK8sCronJobs(options);
327
+ const jobFilter = parseList(jobList);
328
+
329
+ if (options.setupStart) return await Underpost.cron.setupDeployStart(deployList, { ...options, jobFilter });
330
+
331
+ if (options.generateK8sCronjobs || options.apply || options.createJobNow)
332
+ return await Underpost.cron.generateK8sCronJobs({
333
+ ...options,
334
+ deployId: deployIdFromList(deployList),
335
+ jobFilter,
336
+ });
337
+
338
+ const resolvedDeployList = deployList || 'default';
339
+ const resolvedJobList = jobFilter.length > 0 ? jobFilter : Object.keys(Underpost.cron.JOB);
340
+
341
+ if (options.nodeName)
342
+ logger.warn(`--node-name is a manifest placement flag and has no effect on direct execution`, {
343
+ nodeName: options.nodeName,
344
+ });
202
345
 
203
- for (const _jobId of jobList.split(',')) {
204
- const jobId = _jobId.trim();
346
+ for (const jobId of resolvedJobList) {
205
347
  if (Underpost.cron.JOB[jobId]) {
206
348
  if (options.dryRun) {
207
- logger.info(`[dry-run] Would execute cron job`, { jobId, deployList, options });
349
+ logger.info(`[dry-run] Would execute cron job`, { jobId, deployList: resolvedDeployList, options });
208
350
  } else {
209
- logger.info(`Executing cron job`, { jobId, deployList, options });
210
- await Underpost.cron.JOB[jobId].callback(deployList, options);
351
+ logger.info(`Executing cron job`, { jobId, deployList: resolvedDeployList, options });
352
+ await Underpost.cron.JOB[jobId].callback(resolvedDeployList, options);
211
353
  }
212
354
  } else {
213
355
  logger.warn(`Unknown cron job: ${jobId}`);
@@ -218,14 +360,16 @@ class UnderpostCron {
218
360
  /**
219
361
  * Update the package.json start script for the given deploy-id and generate+apply its K8s CronJob manifests.
220
362
  *
221
- * @param {string} deployId - The deploy-id whose package.json will be updated
363
+ * @param {string} [deployList] - Comma-separated deploy IDs; its first entry is the deploy-id whose package.json is updated. Falls back to the dd.cron file
222
364
  * @param {Object} [options] - Additional options forwarded to generateK8sCronJobs
365
+ * @param {string[]} [options.jobFilter] - Restrict the setup to these job IDs
223
366
  * @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob
224
367
  * @param {boolean} [options.dryRun] - Pass --dry-run=client to kubectl commands
225
368
  * @param {boolean} [options.apply] - Whether to apply generated manifests to the cluster
226
369
  * @param {boolean} [options.git] - Pass --git flag to cron CLI commands
227
370
  * @param {boolean} [options.dev] - Use local ./ base path instead of global underpost installation
228
371
  * @param {string} [options.cmd] - Optional pre-script commands to run before cron execution
372
+ * @param {string} [options.nodeName] - Pin every generated CronJob's pod to this node
229
373
  * @param {string} [options.namespace] - Kubernetes namespace for the CronJobs
230
374
  * @param {string} [options.image] - Custom container image override for the CronJobs
231
375
  * @param {boolean} [options.k3s] - k3s cluster context (apply directly on host)
@@ -233,8 +377,18 @@ class UnderpostCron {
233
377
  * @param {boolean} [options.kubeadm] - kubeadm cluster context (apply directly on host)
234
378
  * @memberof UnderpostCron
235
379
  */
236
- setupDeployStart: async function (deployId, options = {}) {
237
- if (!deployId || deployId === true) deployId = resolveDeployId();
380
+ setupDeployStart: async function (deployList, options = {}) {
381
+ // Validated up front: an invalid node name must not leave a rewritten package.json behind.
382
+ const nodeName = resolveNodeName(options.nodeName);
383
+ const requestedDeployId = deployIdFromList(deployList);
384
+ const deployId = resolveDeployId(requestedDeployId);
385
+ if (!deployId) {
386
+ logger.warn(
387
+ 'Could not resolve deploy-id. Provide it as the deploy-list argument or create engine-private/deploy/dd.cron',
388
+ );
389
+ return;
390
+ }
391
+ if (!requestedDeployId) logger.info(`Resolved cron deploy-id from dd.cron`, { deployId });
238
392
  const confDir = `./engine-private/conf/${deployId}`;
239
393
  const packageJsonPath = `${confDir}/package.json`;
240
394
  const confCronPath = `${confDir}/conf.cron.json`;
@@ -251,17 +405,23 @@ class UnderpostCron {
251
405
  return;
252
406
  }
253
407
 
254
- const hasEnabledJobs = Object.values(confCron.jobs).some((job) => job.enabled !== false);
255
- if (!hasEnabledJobs) {
256
- logger.warn(`No enabled cron jobs for deploy-id: ${deployId}`);
408
+ const jobFilter = parseList(options.jobFilter);
409
+ const enabledJobs = Object.keys(confCron.jobs).filter(
410
+ (job) => confCron.jobs[job].enabled !== false && (jobFilter.length === 0 || jobFilter.includes(job)),
411
+ );
412
+ if (enabledJobs.length === 0) {
413
+ logger.warn(
414
+ `No enabled cron jobs for deploy-id: ${deployId}`,
415
+ jobFilter.length > 0 ? { jobFilter } : undefined,
416
+ );
257
417
  return;
258
418
  }
259
419
 
260
- // Update package.json start script
420
+ // Start script only references manifests generateK8sCronJobs actually writes
261
421
  if (fs.existsSync(packageJsonPath)) {
262
422
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
263
423
  let startCommand = 'echo "Starting cron jobs..."';
264
- for (const job of Object.keys(confCron.jobs))
424
+ for (const job of enabledJobs)
265
425
  startCommand += ` && kubectl apply -f ./manifests/cronjobs/${deployId}/${deployId}-${job}.yaml`;
266
426
  if (!packageJson.scripts) packageJson.scripts = {};
267
427
  packageJson.scripts.start = startCommand;
@@ -274,6 +434,8 @@ class UnderpostCron {
274
434
 
275
435
  await Underpost.cron.generateK8sCronJobs({
276
436
  deployId,
437
+ jobFilter,
438
+ nodeName,
277
439
  namespace: options.namespace,
278
440
  image: options.image,
279
441
  apply: options.apply,
@@ -281,7 +443,7 @@ class UnderpostCron {
281
443
  git: !!options.git,
282
444
  dev: !!options.dev,
283
445
  kubeadm: !!options.kubeadm,
284
- cmd: options.cmd || `node bin env ${deployId} production`,
446
+ cmd: options.cmd,
285
447
  k3s: !!options.k3s,
286
448
  kind: !!options.kind,
287
449
  dryRun: !!options.dryRun,
@@ -295,6 +457,7 @@ class UnderpostCron {
295
457
  *
296
458
  * @param {Object} options
297
459
  * @param {string} [options.deployId] - Explicit deploy-id (overrides dd.cron file lookup)
460
+ * @param {string|string[]} [options.jobFilter] - Restrict generation/apply to these job IDs (empty means all)
298
461
  * @param {boolean} [options.git=false] - Pass --git flag to cron CLI commands
299
462
  * @param {boolean} [options.dev=false] - Use local ./ base path instead of global underpost
300
463
  * @param {string} [options.cmd] - Optional pre-script commands
@@ -306,15 +469,17 @@ class UnderpostCron {
306
469
  * @param {boolean} [options.kubeadm=false] - kubeadm cluster context (apply directly on host)
307
470
  * @param {boolean} [options.createJobNow=false] - After applying, create a Job from each CronJob immediately
308
471
  * @param {boolean} [options.dryRun=false] - Pass --dry-run=client to kubectl commands
472
+ * @param {string} [options.nodeName] - Pin every generated CronJob's pod to this node
309
473
  * @memberof UnderpostCron
310
474
  */
311
475
  generateK8sCronJobs: async function (options = {}) {
312
476
  const namespace = options.namespace || 'default';
477
+ const nodeName = resolveNodeName(options.nodeName);
313
478
  const jobDeployId = resolveDeployId(options.deployId);
314
479
 
315
480
  if (!jobDeployId) {
316
481
  logger.warn(
317
- 'Could not resolve deploy-id. Provide --setup-start <deploy-id> or create engine-private/deploy/dd.cron',
482
+ 'Could not resolve deploy-id. Provide it as the deploy-list argument or create engine-private/deploy/dd.cron',
318
483
  );
319
484
  return;
320
485
  }
@@ -333,12 +498,26 @@ class UnderpostCron {
333
498
  return;
334
499
  }
335
500
 
501
+ const jobFilter = parseList(options.jobFilter);
502
+ const targetJobs = Object.keys(confCronConfig.jobs).filter(
503
+ (job) => jobFilter.length === 0 || jobFilter.includes(job),
504
+ );
505
+
506
+ if (targetJobs.length === 0) {
507
+ logger.warn(`No cron jobs matched the requested job list`, {
508
+ deployId: jobDeployId,
509
+ jobFilter,
510
+ available: Object.keys(confCronConfig.jobs),
511
+ });
512
+ return;
513
+ }
514
+
336
515
  const outputDir = `./manifests/cronjobs/${jobDeployId}`;
337
516
  fs.mkdirSync(outputDir, { recursive: true });
338
517
 
339
518
  const generatedFiles = [];
340
519
 
341
- for (const job of Object.keys(confCronConfig.jobs)) {
520
+ for (const job of targetJobs) {
342
521
  const jobConfig = confCronConfig.jobs[job];
343
522
 
344
523
  if (jobConfig.enabled === false) {
@@ -365,18 +544,29 @@ class UnderpostCron {
365
544
  k3s: !!options.k3s,
366
545
  kind: !!options.kind,
367
546
  kubeadm: !!options.kubeadm,
547
+ nodeName,
368
548
  });
369
549
 
370
550
  const yamlFilePath = `${outputDir}/${cronJobName}.yaml`;
371
551
  fs.writeFileSync(yamlFilePath, yamlContent, 'utf8');
372
552
  generatedFiles.push(yamlFilePath);
373
553
 
374
- logger.info(`Generated CronJob manifest: ${yamlFilePath}`, { job, expression, namespace });
554
+ logger.info(`Generated CronJob manifest: ${yamlFilePath}`, {
555
+ job,
556
+ expression,
557
+ namespace,
558
+ ...(nodeName ? { nodeName } : {}),
559
+ });
375
560
  }
376
561
 
377
562
  if (options.apply) {
563
+ // A nodeSelector naming a node that is not registered leaves every Job Pending at its
564
+ // next fire, silently. Warn rather than throw: the node may join before the schedule.
565
+ if (nodeName && !nodeExists(nodeName))
566
+ logger.warn(`Target node not found on the cluster; pods will stay Pending until it joins`, { nodeName });
567
+
378
568
  // Delete existing CronJobs before applying new ones
379
- for (const job of Object.keys(confCronConfig.jobs)) {
569
+ for (const job of targetJobs) {
380
570
  const cronJobName = `${jobDeployId}-${job}`;
381
571
  shellExec(`kubectl delete cronjob ${cronJobName} --namespace=${namespace} --ignore-not-found`);
382
572
  }
@@ -406,9 +596,10 @@ class UnderpostCron {
406
596
  } else {
407
597
  logger.info(`Manifests generated in ${outputDir}. Use --apply to deploy to the cluster.`);
408
598
  }
409
- // Create an immediate Job from each CronJob if requested
599
+ // Create an immediate Job from each CronJob if requested. Runs after --apply so the
600
+ // Job is always cloned from the manifest this invocation just published.
410
601
  if (options.createJobNow) {
411
- for (const job of Object.keys(confCronConfig.jobs)) {
602
+ for (const job of targetJobs) {
412
603
  const jobConfig = confCronConfig.jobs[job];
413
604
  if (jobConfig.enabled === false) continue;
414
605
 
@@ -419,6 +610,15 @@ class UnderpostCron {
419
610
  .replace(/^-|-$/g, '')
420
611
  .substring(0, 52);
421
612
 
613
+ if (!cronJobExists(cronJobName, namespace)) {
614
+ logger.warn(`CronJob not found on the cluster, skipping immediate Job`, {
615
+ cronJobName,
616
+ namespace,
617
+ hint: 'add --apply to publish the manifest first',
618
+ });
619
+ continue;
620
+ }
621
+
422
622
  const immediateJobName = `${cronJobName}-now-${Date.now()}`.substring(0, 63);
423
623
  logger.info(`Creating immediate Job from CronJob: ${cronJobName}`, { jobName: immediateJobName });
424
624
  shellExec(`kubectl create job ${immediateJobName} --from=cronjob/${cronJobName} -n ${namespace}`);
@@ -436,17 +636,15 @@ class UnderpostCron {
436
636
  * @memberof UnderpostCron
437
637
  */
438
638
  getRelatedDeployIdList(jobId) {
439
- const deployFilePath =
440
- jobId === 'backup' ? './engine-private/deploy/dd.router' : './engine-private/deploy/dd.cron';
441
-
442
- if (!fs.existsSync(deployFilePath)) {
443
- logger.warn(`Deploy file not found: ${deployFilePath}, using default`);
444
- return fs.existsSync('./engine-private/deploy/dd.cron')
445
- ? fs.readFileSync('./engine-private/deploy/dd.cron', 'utf8').trim()
446
- : 'dd-cron';
639
+ if (jobId === 'backup') {
640
+ const routerFilePath = './engine-private/deploy/dd.router';
641
+ if (fs.existsSync(routerFilePath)) return fs.readFileSync(routerFilePath, 'utf8').trim();
642
+ logger.warn(`Deploy file not found: ${routerFilePath}, falling back to the cron deploy-id`);
447
643
  }
448
644
 
449
- return fs.readFileSync(deployFilePath, 'utf8').trim();
645
+ const cronDeployId = cronDeployIdResolve();
646
+ if (!cronDeployId) logger.warn(`Cron deploy-id not resolved, using default`, { jobId, default: DEFAULT_CRON_ID });
647
+ return cronDeployId || DEFAULT_CRON_ID;
450
648
  },
451
649
 
452
650
  /**
@@ -473,4 +671,4 @@ class UnderpostCron {
473
671
 
474
672
  export default UnderpostCron;
475
673
 
476
- export { cronJobYamlFactory, resolveDeployId };
674
+ export { cronDeployIdResolve, cronJobYamlFactory, loadCronDeployEnv, resolveDeployId };
package/src/server/dns.js CHANGED
@@ -5,15 +5,13 @@
5
5
  * @namespace UnderpostDns
6
6
  */
7
7
  import axios from 'axios';
8
- import fs from 'fs';
9
8
  import validator from 'validator';
10
9
  import { loggerFactory } from './logger.js';
11
10
  import dns from 'node:dns';
12
11
  import os from 'node:os';
13
12
  import { shellExec, pbcopy } from './process.js';
14
13
  import Underpost from '../index.js';
15
- import { writeEnv, readConfJson, loadCronDeployEnv } from './conf.js';
16
- import { resolveDeployId } from './cron.js';
14
+ import { readConfJson } from './conf.js';
17
15
 
18
16
  const logger = loggerFactory(import.meta);
19
17
 
@@ -271,6 +269,83 @@ class Dns {
271
269
  logger.info('Cleared all egress bans.');
272
270
  }
273
271
 
272
+ /**
273
+ * Blocks all outbound traffic from this host, except for established/related connections.
274
+ * This is useful for security purposes, especially in a dynamic DNS context where you want to prevent
275
+ * any new outbound connections while still allowing existing ones (like SSH) to continue.
276
+ * @static
277
+ * @memberof UnderpostDns
278
+ */
279
+ static blockAllEgress() {
280
+ // Clear any existing egress rules.
281
+ shellExec(`sudo nft flush chain inet filter output`, { silent: true });
282
+ shellExec(`sudo nft flush chain inet filter forward`, { silent: true });
283
+
284
+ // Allow return traffic for established/related connections.
285
+ // This keeps existing inbound connections such as SSH alive.
286
+ shellExec(`sudo nft add rule inet filter output ct state established,related counter accept`, { silent: true });
287
+
288
+ // Block all new outbound connections from this host and forwarded traffic.
289
+ shellExec(`sudo nft chain inet filter output '{ policy drop; }'`, { silent: true });
290
+ shellExec(`sudo nft chain inet filter forward '{ policy drop; }'`, { silent: true });
291
+
292
+ logger.info('All outbound traffic blocked.');
293
+ }
294
+
295
+ /**
296
+ * Unblocks all outbound traffic from this host and forwarded interfaces.
297
+ * Restores default output and forward chain policies to ACCEPT and clears egress rules.
298
+ * @static
299
+ * @memberof UnderpostDns
300
+ */
301
+ static unblockAllEgress() {
302
+ // Restore default chain policies to accept all traffic.
303
+ shellExec(`sudo nft chain inet filter output '{ policy accept; }'`, { silent: true });
304
+ shellExec(`sudo nft chain inet filter forward '{ policy accept; }'`, { silent: true });
305
+
306
+ // Clear any existing egress blocking rules.
307
+ shellExec(`sudo nft flush chain inet filter output`, { silent: true });
308
+ shellExec(`sudo nft flush chain inet filter forward`, { silent: true });
309
+
310
+ logger.info('All outbound traffic unblocked and restored to default ACCEPT policy.');
311
+ }
312
+
313
+ /**
314
+ * Blocks all new inbound traffic to this host, except for established/related connections.
315
+ * This prevents any new incoming connections while keeping existing sessions (like SSH) alive.
316
+ * @static
317
+ * @memberof UnderpostDns
318
+ */
319
+ static blockAllIngress() {
320
+ // Clear any existing ingress rules.
321
+ shellExec(`sudo nft flush chain inet filter input`, { silent: true });
322
+
323
+ // Allow return traffic for established/related connections.
324
+ // This keeps active inbound/outbound sessions alive.
325
+ shellExec(`sudo nft add rule inet filter input ct state established,related counter accept`, { silent: true });
326
+
327
+ // Block all new inbound connections to this host.
328
+ shellExec(`sudo nft chain inet filter input '{ policy drop; }'`, { silent: true });
329
+
330
+ logger.info('All new inbound traffic blocked.');
331
+ }
332
+
333
+ /**
334
+ * Unblocks all inbound traffic to this host.
335
+ * Restores default input chain policy to ACCEPT and clears ingress rules.
336
+ * @static
337
+ * @memberof UnderpostDns
338
+ */
339
+ static unblockAllIngress() {
340
+ // Restore default chain policy to accept all incoming traffic.
341
+ shellExec(`sudo nft chain inet filter input '{ policy accept; }'`, { silent: true });
342
+
343
+ // Clear any existing ingress blocking rules.
344
+ shellExec(`sudo nft flush chain inet filter input`, { silent: true });
345
+
346
+ logger.info('All inbound traffic unblocked and restored to default ACCEPT policy.');
347
+ }
348
+
274
349
  /**
275
350
  * Performs the dynamic DNS update logic.
276
351
  * It checks if the public IP has changed and, if so, updates the configured DNS records.
@@ -279,10 +354,8 @@ class Dns {
279
354
  * @memberof UnderpostDns
280
355
  * @param {string} deployList Comma-separated string of deployment IDs to process.
281
356
  * @returns {Promise<void>}
282
- */
357
+ */
283
358
  static async callback(deployList) {
284
- // loadCronDeployEnv();
285
-
286
359
  const isOnline = await Dns.isInternetConnection();
287
360
 
288
361
  if (!isOnline) return;
@@ -440,6 +513,10 @@ class Dns {
440
513
  * @property {boolean} [options.banEgressClear=false] - Clear all banned egress IPs.
441
514
  * @property {boolean} [options.banBothAdd=false] - Ban IPs from both ingress and egress.
442
515
  * @property {boolean} [options.banBothRemove=false] - Unban IPs from both ingress and egress.
516
+ * @property {boolean} [options.blockAllEgress=false] - Block all outbound traffic from this host.
517
+ * @property {boolean} [options.unblockAllEgress=false] - Unblock all outbound traffic.
518
+ * @property {boolean} [options.blockAllIngress=false] - Block all new inbound traffic to this host.
519
+ * @property {boolean} [options.unblockAllIngress=false] - Unblock all inbound traffic.
443
520
  * @property {boolean} [options.dhcp=false] - Get local DHCP IP instead of public IP.
444
521
  * @property {boolean} [options.copy=false] - Copy the public IP to clipboard.
445
522
  * @return {Promise<string|void>} The public IP if no ban/unban action is taken.
@@ -457,6 +534,10 @@ class Dns {
457
534
  banEgressClear: false,
458
535
  banBothAdd: false,
459
536
  banBothRemove: false,
537
+ blockAllEgress: false,
538
+ unblockAllEgress: false,
539
+ blockAllIngress: false,
540
+ unblockAllIngress: false,
460
541
  copy: false,
461
542
  dhcp: false,
462
543
  },
@@ -507,6 +588,19 @@ class Dns {
507
588
  });
508
589
  }
509
590
 
591
+ if (options.blockAllEgress) {
592
+ return Dns.blockAllEgress();
593
+ }
594
+ if (options.unblockAllEgress) {
595
+ return Dns.unblockAllEgress();
596
+ }
597
+ if (options.blockAllIngress) {
598
+ return Dns.blockAllIngress();
599
+ }
600
+ if (options.unblockAllIngress) {
601
+ return Dns.unblockAllIngress();
602
+ }
603
+
510
604
  if (options.mac) {
511
605
  const mac = Dns.getMainInterfaceMac();
512
606
  console.log(mac);