underpost 3.2.0 → 3.2.3

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.
@@ -132,10 +132,11 @@ class WpService {
132
132
  const subDir = pathRoute && pathRoute !== '/' ? pathRoute.replace(/^\/+/, '').replace(/\/+$/, '') : '';
133
133
  const wpDir = subDir ? path.join(vhostDir, subDir) : vhostDir;
134
134
 
135
+ let freshInstall = false;
135
136
  if (repository) {
136
- WpService.provisionClone({ host, siteRoot: wpDir, repository, db, wp, subDir });
137
+ ({ freshInstall } = WpService.provisionClone({ host, siteRoot: wpDir, repository, db, wp, subDir }));
137
138
  } else {
138
- WpService.provisionFresh({ host, siteRoot: wpDir, db, wp, subDir });
139
+ ({ freshInstall } = WpService.provisionFresh({ host, siteRoot: wpDir, db, wp, subDir }));
139
140
  }
140
141
 
141
142
  // Ensure git is initialized and linked to the backup repository.
@@ -152,6 +153,9 @@ class WpService {
152
153
  WpService.ensureSubdirHtaccess({ vhostDir, subDir });
153
154
  }
154
155
 
156
+ // Write security rules into the WordPress root .htaccess
157
+ WpService.ensureSecurityHtaccess({ dir: wpDir });
158
+
155
159
  // Make the site writable by the XAMPP Apache process (runs as daemon:daemon).
156
160
  // This is required for plugins like Wordfence WAF and Sucuri that write config/upload files.
157
161
  shellExec(`sudo chown -R daemon:daemon "${vhostDir}"`);
@@ -170,6 +174,13 @@ class WpService {
170
174
  resetRouter,
171
175
  });
172
176
 
177
+ // Immediately commit and push all generated files (wp-config.php, .htaccess,
178
+ // security rules, plugins, etc.) so that on rollout/restart the clone will
179
+ // have a complete working state and won't fall back to fresh install again.
180
+ if (repository && freshInstall) {
181
+ WpService.persistToRepo({ siteRoot: wpDir, repository, host });
182
+ }
183
+
173
184
  return { disabled };
174
185
  }
175
186
 
@@ -194,8 +205,7 @@ class WpService {
194
205
  logger.info(`${host}: remote accessible = ${repoAccessible} (${repository})`);
195
206
  if (!repoAccessible) {
196
207
  logger.warn(`${host}: remote repository not accessible (${repository}) — running fresh install`);
197
- WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
198
- return;
208
+ return WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
199
209
  }
200
210
 
201
211
  // Step 1 — clone if the directory does not exist yet
@@ -219,8 +229,10 @@ class WpService {
219
229
  if (!fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
220
230
  logger.warn(`${host}: wp-config.php not found — wiping site root and running fresh install`);
221
231
  shellExec(`sudo rm -rf "${siteRoot}"`);
222
- WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
232
+ return WpService.provisionFresh({ host, siteRoot, db, wp, subDir });
223
233
  }
234
+
235
+ return { freshInstall: false };
224
236
  }
225
237
 
226
238
  /**
@@ -235,7 +247,7 @@ class WpService {
235
247
  // Validator: wp-config.php presence means installation is complete/valid
236
248
  if (fs.existsSync(path.join(siteRoot, 'wp-config.php'))) {
237
249
  logger.info(`${host}: wp-config.php found at ${siteRoot}, skipping fresh install`);
238
- return;
250
+ return { freshInstall: false };
239
251
  }
240
252
 
241
253
  logger.info(`${host}: fresh install → ${siteRoot}`);
@@ -267,6 +279,8 @@ class WpService {
267
279
  } else {
268
280
  logger.warn(`${host}: no db config provided — wp-config.php not written`);
269
281
  }
282
+
283
+ return { freshInstall: true };
270
284
  }
271
285
 
272
286
  /**
@@ -378,6 +392,134 @@ ${marker} end`;
378
392
  logger.info(`subdirectory .htaccess updated`, { vhostDir, subDir });
379
393
  }
380
394
 
395
+ /**
396
+ * Writes security rules into the WordPress site root `.htaccess`.
397
+ * Protects `.git` directories, sensitive config files, and SQL dumps
398
+ * from being served by Apache. Idempotent — uses marker comments to
399
+ * detect and replace existing blocks on re-runs.
400
+ * @param {{ dir: string }} opts
401
+ * @param {string} opts.dir - Absolute path to the WordPress root (where .htaccess lives).
402
+ */
403
+ static ensureSecurityHtaccess({ dir }) {
404
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
405
+ const htaccessPath = path.join(dir, '.htaccess');
406
+
407
+ const marker = '# -- wp-security --';
408
+ const block = `${marker}
409
+ # Block access to .git directories and files
410
+ RedirectMatch 404 /\\.git
411
+
412
+ # Block access to sensitive dotfiles
413
+ <FilesMatch "^\\.(env|htpasswd|htaccess\\.bak|DS_Store)">
414
+ Require all denied
415
+ </FilesMatch>
416
+
417
+ # Block access to WordPress config backups and SQL dumps
418
+ <FilesMatch "(wp-config\\.php\\.bak|wp-config-sample\\.php|\\.sql|\\.sql\\.gz)$">
419
+ Require all denied
420
+ </FilesMatch>
421
+
422
+ # Block direct access to PHP files in uploads
423
+ <IfModule mod_rewrite.c>
424
+ RewriteEngine On
425
+ RewriteRule ^wp-content/uploads/.*\\.php$ - [F,L]
426
+ </IfModule>
427
+
428
+ # Block access to xmlrpc.php (common attack vector)
429
+ <Files "xmlrpc.php">
430
+ Require all denied
431
+ </Files>
432
+
433
+ # Block access to readme.html and license.txt (version disclosure)
434
+ <FilesMatch "^(readme\\.html|license\\.txt)$">
435
+ Require all denied
436
+ </FilesMatch>
437
+ ${marker} end`;
438
+
439
+ let existing = '';
440
+ if (fs.existsSync(htaccessPath)) {
441
+ existing = fs.readFileSync(htaccessPath, 'utf8');
442
+ }
443
+
444
+ const markerRegex = new RegExp(
445
+ `${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} end`,
446
+ );
447
+
448
+ if (markerRegex.test(existing)) {
449
+ existing = existing.replace(markerRegex, block);
450
+ } else {
451
+ existing = existing ? `${existing}\n${block}\n` : `${block}\n`;
452
+ }
453
+
454
+ fs.writeFileSync(htaccessPath, existing, 'utf8');
455
+ logger.info(`security .htaccess updated`, { dir });
456
+ }
457
+
458
+ /**
459
+ * Ensures a WordPress-specific `.gitignore` exists in the site root so that
460
+ * large/transient files are excluded from the backup repository while
461
+ * wp-config.php and security .htaccess ARE tracked.
462
+ * Idempotent — only writes when the file is missing.
463
+ * @param {{ dir: string }} opts
464
+ */
465
+ static ensureGitignore({ dir }) {
466
+ const gitignorePath = path.join(dir, '.gitignore');
467
+ if (fs.existsSync(gitignorePath)) return;
468
+ const content = `# WordPress .gitignore
469
+ # Cache and temp
470
+ wp-content/cache/
471
+ wp-content/upgrade/
472
+ wp-content/backup-db/
473
+ wp-content/backups/
474
+ wp-content/blogs.dir/
475
+ wp-content/advanced-cache.php
476
+ wp-content/wp-cache-config.php
477
+ wp-content/debug.log
478
+
479
+ # OS / editor
480
+ .DS_Store
481
+ Thumbs.db
482
+ *.swp
483
+ *.swo
484
+ *~
485
+ `;
486
+ fs.writeFileSync(gitignorePath, content, 'utf8');
487
+ logger.info(`.gitignore written`, { dir });
488
+ }
489
+
490
+ /**
491
+ * Commits all files in the WordPress site root and pushes to the remote
492
+ * repository. This persists wp-config.php, .htaccess security rules,
493
+ * installed plugins, and theme files so that on pod rollout/restart a
494
+ * `git clone` yields a fully working site without needing a fresh install.
495
+ *
496
+ * Safe to call repeatedly — `git commit` is a no-op when the working tree
497
+ * is clean (`|| true` prevents non-zero exit).
498
+ *
499
+ * @param {object} opts
500
+ * @param {string} opts.siteRoot - Absolute path to the WordPress root.
501
+ * @param {string} opts.repository - Git remote URL.
502
+ * @param {string} opts.host - Virtual-host name (for logging/commit msg).
503
+ */
504
+ static persistToRepo({ siteRoot, repository, host }) {
505
+ if (!fs.existsSync(path.join(siteRoot, '.git'))) {
506
+ logger.warn(`persistToRepo: .git missing at ${siteRoot} — skipping`);
507
+ return;
508
+ }
509
+
510
+ WpService.ensureGitignore({ dir: siteRoot });
511
+
512
+ const githubOrg = process.env.GITHUB_USERNAME || 'underpostnet';
513
+ const repoName = repository.split('/').pop().split('.')[0];
514
+
515
+ logger.info(`${host}: persisting site to repository`);
516
+ shellExec(
517
+ `cd "${siteRoot}" && git add -A && git commit -m "wp provision ${host} $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true`,
518
+ );
519
+ shellExec(`cd "${siteRoot}" && underpost push . ${githubOrg}/${repoName} -f`);
520
+ logger.info(`${host}: initial commit pushed to ${githubOrg}/${repoName}`);
521
+ }
522
+
381
523
  /**
382
524
  * Drops and recreates a MariaDB database to ensure a clean state for fresh installs.
383
525
  * @param {{ host: string, name: string, user: string, password: string }} db
@@ -6,10 +6,8 @@
6
6
 
7
7
  import fs from 'fs-extra';
8
8
  import { loggerFactory } from './logger.js';
9
- import { shellExec } from './process.js';
10
9
  import Underpost from '../index.js';
11
- import { loadCronDeployEnv, readConfJson } from './conf.js';
12
- import { WpService } from '../runtime/wp/Wp.js';
10
+ import { loadCronDeployEnv } from './conf.js';
13
11
 
14
12
  const logger = loggerFactory(import.meta);
15
13
 
@@ -22,16 +20,24 @@ class BackUp {
22
20
  /**
23
21
  * @method callback
24
22
  * @description Initiates a backup operation for the specified deployment list.
25
- * @param {string} deployList - The list of deployments to backup.
23
+ * Orchestrates two backup phases per deployment:
24
+ * 1. Database export (MariaDB / MongoDB dump via `node bin db --export`).
25
+ * 2. Repository backup (git commit+push inside the deployment pod via `node bin db --repo-backup`).
26
+ *
27
+ * Commands are always forwarded to the host node via SSH because the CronJob
28
+ * container itself has no kubectl access. GITHUB_TOKEN and GITHUB_USERNAME
29
+ * are passed as ephemeral inline env vars so they never touch the host filesystem.
30
+ *
31
+ * @param {string} deployList - Comma-separated list of deployment IDs.
26
32
  * @param {Object} options - The options for the backup operation.
27
33
  * @param {boolean} options.git - Whether to backup data using Git.
28
34
  * @param {boolean} [options.k3s] - Use k3s cluster context.
29
35
  * @param {boolean} [options.kind] - Use kind cluster context.
30
36
  * @param {boolean} [options.kubeadm] - Use kubeadm cluster context.
31
- * @param {boolean} [options.ssh] - Execute backup commands via SSH on the remote node.
32
37
  * @memberof UnderpostBakcUp
33
38
  */
34
39
  static callback = async function (deployList, options = { git: false }) {
40
+ const firstDeployId = deployList && deployList !== 'dd' ? deployList.split(',')[0].trim() : '';
35
41
  loadCronDeployEnv();
36
42
  if ((!deployList || deployList === 'dd') && fs.existsSync(`./engine-private/deploy/dd.router`))
37
43
  deployList = fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').trim();
@@ -45,35 +51,40 @@ class BackUp {
45
51
  const deployId = _deployId.trim();
46
52
  if (!deployId) continue;
47
53
 
48
- const command = `node bin db ${options.git ? '--git --force-clone ' : ''}--export --primary-pod${clusterFlag} ${deployId}`;
54
+ const dbCommand = `node bin db ${options.git ? '--git --force-clone ' : ''}--export --primary-pod${clusterFlag} ${deployId}`;
55
+ const repoCommand = `node bin db --repo-backup${clusterFlag} ${deployId}`;
49
56
 
50
- if (options.ssh) {
57
+ // Pass GITHUB_TOKEN and GITHUB_USERNAME ephemerally through the SSH command
58
+ // so git operations can push backups without relying on host env files.
59
+ const envPrefix = [
60
+ process.env.GITHUB_TOKEN ? `GITHUB_TOKEN=${process.env.GITHUB_TOKEN}` : '',
61
+ process.env.GITHUB_USERNAME ? `GITHUB_USERNAME=${process.env.GITHUB_USERNAME}` : '',
62
+ ]
63
+ .filter(Boolean)
64
+ .join(' ');
65
+ const prefixCmd = (cmd) => (envPrefix ? `${envPrefix} ${cmd}` : cmd);
66
+
67
+ try {
51
68
  logger.info('Executing database export via SSH for', deployId);
52
- await Underpost.ssh.sshRemoteRunner(command, {
69
+ await Underpost.ssh.sshRemoteRunner(prefixCmd(dbCommand), {
53
70
  remote: true,
54
71
  useSudo: true,
55
72
  cd: '/home/dd/engine',
56
73
  });
57
- } else {
58
- logger.info('Executing database export for', deployId);
59
- shellExec(command);
74
+ } catch (err) {
75
+ logger.error(`Error during database export for ${deployId}:`, err);
60
76
  }
61
- {
62
- const confServer = readConfJson(deployId, 'server');
63
- for (const host of Object.keys(confServer)) {
64
- for (const path of Object.keys(confServer[host])) {
65
- const entry = confServer[host][path];
66
- try {
67
- switch (entry.runtime) {
68
- case 'wp':
69
- WpService.backup({ host, repository: entry.repository });
70
- break;
71
- }
72
- } catch (err) {
73
- logger.error(`Error during entry runtime backup for ${host}${path}:`, err);
74
- }
75
- }
76
- }
77
+
78
+ // Repository backup: Cron container → SSH to host → host finds pod → kubectl exec git backup
79
+ try {
80
+ logger.info('Executing repository backup via SSH for', deployId);
81
+ await Underpost.ssh.sshRemoteRunner(prefixCmd(repoCommand), {
82
+ remote: true,
83
+ useSudo: true,
84
+ cd: '/home/dd/engine',
85
+ });
86
+ } catch (err) {
87
+ logger.error(`Error during repository backup for ${deployId}:`, err);
77
88
  }
78
89
  }
79
90
  };
@@ -33,7 +33,9 @@ const underpostContainerEnvPath = '/usr/lib/node_modules/underpost/.env';
33
33
  * @param {string} [params.cmd] - Optional pre-script commands to run before cron execution
34
34
  * @param {boolean} [params.suspend=false] - Whether the CronJob is suspended
35
35
  * @param {boolean} [params.dryRun=false] - Pass --dry-run flag to the cron command inside the container
36
- * @param {boolean} [params.ssh=false] - Execute backup commands via SSH on the remote node
36
+ * @param {boolean} [params.k3s=false] - Pass --k3s flag to the cron command inside the container
37
+ * @param {boolean} [params.kind=false] - Pass --kind flag to the cron command inside the container
38
+ * @param {boolean} [params.kubeadm=false] - Pass --kubeadm flag to the cron command inside the container
37
39
  * @returns {string} Kubernetes CronJob YAML manifest
38
40
  * @memberof UnderpostCron
39
41
  */
@@ -49,7 +51,9 @@ const cronJobYamlFactory = ({
49
51
  cmd,
50
52
  suspend = false,
51
53
  dryRun = false,
52
- ssh = false,
54
+ k3s = false,
55
+ kind = false,
56
+ kubeadm = false,
53
57
  }) => {
54
58
  const containerImage = image || `underpost/underpost-engine:${Underpost.version}`;
55
59
 
@@ -60,10 +64,12 @@ const cronJobYamlFactory = ({
60
64
  .replace(/^-|-$/g, '')
61
65
  .substring(0, 52);
62
66
 
63
- const cmdPart = cmd ? `${cmd} && ` : '';
64
67
  const cronBin = dev ? 'node bin' : 'underpost';
65
- const flags = `${git ? '--git ' : ''}${dev ? '--dev ' : ''}${dryRun ? '--dry-run ' : ''}${ssh ? '--ssh ' : ''}`;
66
- const cronCommand = `${cmdPart}${cronBin} cron ${flags}${deployList} ${jobList}`;
68
+ const flags = `${git ? '--git ' : ''}${dev ? '--dev ' : ''}${dryRun ? '--dry-run ' : ''}${k3s ? '--k3s ' : ''}${kind ? '--kind ' : ''}${kubeadm ? '--kubeadm ' : ''}`;
69
+ const commands = [`cd ${enginePath}`, `node bin run secret`];
70
+ if (cmd) commands.push(cmd);
71
+ commands.push(`${cronBin} cron ${flags}${deployList} ${jobList}`);
72
+ const fullCommand = commands.join(' &&\n ');
67
73
 
68
74
  return `apiVersion: batch/v1
69
75
  kind: CronJob
@@ -95,7 +101,7 @@ spec:
95
101
  - /bin/sh
96
102
  - -c
97
103
  - >
98
- ${cronCommand}
104
+ ${fullCommand}
99
105
  volumeMounts:
100
106
  - mountPath: ${enginePath}
101
107
  name: ${cronVolumeName}
@@ -183,7 +189,6 @@ class UnderpostCron {
183
189
  * @param {boolean} [options.kubeadm] - Use kubeadm cluster context (apply directly on host)
184
190
  * @param {boolean} [options.dryRun] - Preview cron jobs without executing them
185
191
  * @param {boolean} [options.createJobNow] - After applying, immediately create a Job from each CronJob (requires --apply)
186
- * @param {boolean} [options.ssh] - Execute backup commands via SSH on the remote node
187
192
  * @memberof UnderpostCron
188
193
  */
189
194
  callback: async function (
@@ -227,7 +232,6 @@ class UnderpostCron {
227
232
  * @param {boolean} [options.k3s] - k3s cluster context (apply directly on host)
228
233
  * @param {boolean} [options.kind] - kind cluster context (apply via kind-worker container)
229
234
  * @param {boolean} [options.kubeadm] - kubeadm cluster context (apply directly on host)
230
- * @param {boolean} [options.ssh] - Execute backup commands via SSH on the remote node
231
235
  * @memberof UnderpostCron
232
236
  */
233
237
  setupDeployStart: async function (deployId, options = {}) {
@@ -270,20 +274,20 @@ class UnderpostCron {
270
274
  }
271
275
 
272
276
  // Generate and apply cron job manifests for this deploy-id
277
+ const hasExplicitCluster = options.k3s || options.kind || options.kubeadm;
273
278
  await Underpost.cron.generateK8sCronJobs({
274
279
  deployId,
275
280
  namespace: options.namespace,
276
281
  image: options.image,
277
282
  apply: options.apply,
278
283
  createJobNow: options.createJobNow,
279
- git: true,
280
- dev: true,
281
- kubeadm: true,
282
- ssh: true,
283
- cmd: ` cd ${enginePath} && node bin env ${deployId} production`,
284
- k3s: false,
285
- kind: false,
286
- dryRun: false,
284
+ git: options.git !== undefined ? options.git : true,
285
+ dev: options.dev !== undefined ? options.dev : true,
286
+ kubeadm: hasExplicitCluster ? !!options.kubeadm : true,
287
+ cmd: options.cmd || `node bin env ${deployId} production`,
288
+ k3s: !!options.k3s,
289
+ kind: !!options.kind,
290
+ dryRun: !!options.dryRun,
287
291
  });
288
292
  },
289
293
 
@@ -305,7 +309,6 @@ class UnderpostCron {
305
309
  * @param {boolean} [options.kubeadm=false] - kubeadm cluster context (apply directly on host)
306
310
  * @param {boolean} [options.createJobNow=false] - After applying, create a Job from each CronJob immediately
307
311
  * @param {boolean} [options.dryRun=false] - Pass --dry-run=client to kubectl commands
308
- * @param {boolean} [options.ssh=false] - Execute backup commands via SSH on the remote node
309
312
  * @memberof UnderpostCron
310
313
  */
311
314
  generateK8sCronJobs: async function (options = {}) {
@@ -362,7 +365,9 @@ class UnderpostCron {
362
365
  cmd: options.cmd,
363
366
  suspend: false,
364
367
  dryRun: !!options.dryRun,
365
- ssh: !!options.ssh,
368
+ k3s: !!options.k3s,
369
+ kind: !!options.kind,
370
+ kubeadm: !!options.kubeadm,
366
371
  });
367
372
 
368
373
  const yamlFilePath = `${outputDir}/${cronJobName}.yaml`;
@@ -9,7 +9,6 @@ import { awaitDeployMonitor } from './conf.js';
9
9
  import { actionInitLog, loggerFactory } from './logger.js';
10
10
  import { shellCd, shellExec } from './process.js';
11
11
  import Underpost from '../index.js';
12
- import isInsideContainer from 'is-inside-container';
13
12
  const logger = loggerFactory(import.meta);
14
13
 
15
14
  /**
@@ -164,10 +163,9 @@ class UnderpostStartUp {
164
163
  shellExec(`mkdir -p ${buildBasePath}/engine`);
165
164
  shellExec(`cd ${buildBasePath} && sudo cp -a ./${repoName}/. ./engine`);
166
165
  shellExec(`cd ${buildBasePath} && sudo rm -rf ./${repoName}`);
167
- shellExec(`cd ${buildBasePath}/engine && underpost clone ${process.env.GITHUB_USERNAME}/${repoName}-private`);
168
- shellExec(`cd ${buildBasePath}/engine && sudo mv ./${repoName}-private ./engine-private`);
169
166
  }
170
167
  shellCd(`${buildBasePath}/engine`);
168
+ Underpost.repo.privateEngineRepoFactory(deployId);
171
169
  shellExec(options?.underpostQuicklyInstall ? `underpost install` : `npm install`);
172
170
  shellExec(`node bin env ${deployId} ${env}`);
173
171
  if (fs.existsSync('./engine-private/itc-scripts')) {
@@ -198,12 +196,8 @@ class UnderpostStartUp {
198
196
  shellExec(`node bin env ${deployId} ${env}`);
199
197
  shellExec(`npm ${runCmd} ${deployId}`, { async: true });
200
198
  await awaitDeployMonitor(true);
199
+ if (env === 'production' && Underpost.env.isInsideContainer()) Underpost.secret.globalSecretClean();
201
200
  Underpost.env.set('container-status', `${deployId}-${env}-running-deployment`);
202
- if (env === 'production' && isInsideContainer()) {
203
- Underpost.env.clean();
204
- shellExec(`sudo rm -rf /home/dd/engine/engine-private`);
205
- if (fs.existsSync('/etc/config/.env.production')) fs.removeSync('/etc/config/.env.production');
206
- }
207
201
  },
208
202
  };
209
203
  }