@studiometa/trafic-agent 0.1.49 → 0.1.51

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 (43) hide show
  1. package/README.md +158 -13
  2. package/dist/cli.js +134 -14
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +7 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -3
  7. package/dist/{server-DZAo8Clg.js → server-Cfk-O4DJ.js} +406 -30
  8. package/dist/server-Cfk-O4DJ.js.map +1 -0
  9. package/dist/server.d.ts +26 -2
  10. package/dist/server.d.ts.map +1 -1
  11. package/dist/server.js +2 -2
  12. package/dist/setup/agent.d.ts +11 -1
  13. package/dist/setup/agent.d.ts.map +1 -1
  14. package/dist/setup/migrations/0020__backup_dir.d.ts +23 -0
  15. package/dist/setup/migrations/0020__backup_dir.d.ts.map +1 -0
  16. package/dist/setup/migrations/index.d.ts.map +1 -1
  17. package/dist/{setup-DWgb6dAD.js → setup-CiWIirVb.js} +827 -209
  18. package/dist/setup-CiWIirVb.js.map +1 -0
  19. package/dist/tasks/access-logs.d.ts +8 -0
  20. package/dist/tasks/access-logs.d.ts.map +1 -0
  21. package/dist/tasks/backup.d.ts +48 -0
  22. package/dist/tasks/backup.d.ts.map +1 -0
  23. package/dist/tasks/destroy-project.d.ts +71 -0
  24. package/dist/tasks/destroy-project.d.ts.map +1 -0
  25. package/dist/tasks/destroy-scheduled.d.ts +41 -0
  26. package/dist/tasks/destroy-scheduled.d.ts.map +1 -0
  27. package/dist/tasks/running-limit.d.ts +54 -0
  28. package/dist/tasks/running-limit.d.ts.map +1 -0
  29. package/dist/tasks/stop-idle.d.ts +13 -2
  30. package/dist/tasks/stop-idle.d.ts.map +1 -1
  31. package/dist/types.d.ts +41 -2
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/utils/config.d.ts +6 -0
  34. package/dist/utils/config.d.ts.map +1 -1
  35. package/dist/utils/db.d.ts +76 -1
  36. package/dist/utils/db.d.ts.map +1 -1
  37. package/dist/utils/ddev.d.ts +12 -5
  38. package/dist/utils/ddev.d.ts.map +1 -1
  39. package/dist/utils/paths.d.ts +3 -0
  40. package/dist/utils/paths.d.ts.map +1 -0
  41. package/package.json +1 -1
  42. package/dist/server-DZAo8Clg.js.map +0 -1
  43. package/dist/setup-DWgb6dAD.js.map +0 -1
@@ -1,5 +1,8 @@
1
- import { $ as stopProject, A as getIdleProjects, B as info, C as getIdleTimeoutMs, G as success, H as resetSteps, I as nodeIo, K as warn, L as error, P as setProjectStatus, R as exec, S as readToolPorts, T as shouldNeverStop, U as setDryRun, V as isRoot, W as step, Y as getProjectInfo, Z as loadProjectList, _ as findRunningProjects, b as installSystemDeps, d as STATIC_CONFIG, f as TLS_STORE_CONFIG, g as findRunningProject, h as configureTraefik, m as configureDdev, ot as loadConfig, p as addDdevAptRepo, st as parseDuration, u as ROUTER_COMPOSE_OVERRIDE, v as getDockerGatewayIp, w as loadProjectConfig, x as purgeTraefikAcmeStorage, y as installDdev, z as execSilent } from "./server-DZAo8Clg.js";
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { $ as exec, A as shouldNeverStop, B as getScheduledDestroy, C as installSystemDeps, Ct as parseDuration, D as enforceRunningLimit, E as ddevRunningLimitDeps, Et as isInside, F as deleteUnlistedProjects, G as releaseStaleStatuses, H as initDb, J as startScheduledDestroy, K as scheduleDestroy, M as cleanOldLogs, O as getIdleTimeoutMs, P as deleteProject, Q as error, R as getIdleProjects, S as installDdev, St as loadConfig, T as readToolPorts, V as getScheduledDestroys, Y as systemClock, Z as nodeIo, _ as configureDdev, at as step, b as findRunningProjects, et as execSilent, ft as loadProjectList, g as addDdevAptRepo, h as TLS_STORE_CONFIG, ht as stopProject, it as setDryRun, k as loadProjectConfig, m as STATIC_CONFIG, mt as startProject, nt as isRoot, ot as success, p as ROUTER_COMPOSE_OVERRIDE, pt as nodeDdevRunner, q as setProjectStatus, rt as resetSteps, st as warn, tt as info, ut as getProjectInfo, v as configureTraefik, w as purgeTraefikAcmeStorage, x as getDockerGatewayIp, y as findRunningProject, z as getProject } from "./server-Cfk-O4DJ.js";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, posix, resolve } from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
3
6
  //#region src/setup/docker-firewall.ts
4
7
  /** Where the rule script lives, so systemd and a human can both run it. */
5
8
  var DOCKER_FIREWALL_SCRIPT = "/usr/local/sbin/trafic-docker-firewall";
@@ -127,49 +130,816 @@ function configureDockerFirewall(toolPorts, io = nodeIo) {
127
130
  //#region src/tasks/stop-idle.ts
128
131
  /**
129
132
  * Stop idle projects that haven't been accessed recently
130
- * Respects per-project idle_timeout settings from .ddev/config.trafic.yaml
133
+ * A project's idle_timeout in .ddev/config.trafic.yaml replaces the global
134
+ * one, shorter or longer
135
+ *
136
+ * Only projects with the status "running" are idle: a project the agent is
137
+ * starting or removing is never stopped here. A start or a removal whose
138
+ * process was killed is set back to "running" first, so its project is not
139
+ * left up for good. Before that, the records of projects that DDEV no longer
140
+ * lists are deleted, so the sweep does not ask DDEV about them.
141
+ *
142
+ * Then brings the server back under max_running_projects: a deploy runs
143
+ * `ddev start` without asking the agent, so only the sweep sees it.
131
144
  */
132
- async function stopIdleProjects(config) {
145
+ async function stopIdleProjects(config, run = nodeDdevRunner) {
133
146
  const globalThresholdMs = parseDuration(config.idleTimeout);
134
147
  const projects = loadProjectList(config.projectListPath);
135
- const idleProjects = getIdleProjects(globalThresholdMs);
136
- for (const project of idleProjects) {
148
+ for (const name of deleteUnlistedProjects(new Set(projects.keys()))) console.log(`Removed ${name} from the agent database: DDEV no longer lists it`);
149
+ for (const name of releaseStaleStatuses()) console.log(`Set ${name} back to running: the process that started or removed it is gone`);
150
+ for (const project of getIdleProjects(0)) {
137
151
  const projectDir = projects.get(project.name);
138
- if (projectDir) {
139
- const projectConfig = loadProjectConfig(projectDir);
140
- if (shouldNeverStop(projectConfig)) continue;
141
- const projectThresholdMs = getIdleTimeoutMs(projectConfig);
142
- if (projectThresholdMs !== void 0) {
143
- if (Date.now() - project.lastAccess < projectThresholdMs) continue;
144
- }
145
- }
146
- if ((await getProjectInfo(project.name))?.status !== "running") {
152
+ const projectConfig = projectDir ? loadProjectConfig(projectDir) : {};
153
+ if (shouldNeverStop(projectConfig)) continue;
154
+ const thresholdMs = getIdleTimeoutMs(projectConfig) ?? globalThresholdMs;
155
+ if (Date.now() - project.lastAccess < thresholdMs) continue;
156
+ if ((await getProjectInfo(project.name, run))?.status !== "running") {
147
157
  setProjectStatus(project.name, "stopped");
148
158
  continue;
149
159
  }
150
160
  console.log(`Stopping idle project: ${project.name}`);
151
- if (await stopProject(project.name)) {
161
+ if (await stopProject(project.name, run)) {
152
162
  setProjectStatus(project.name, "stopped");
153
163
  console.log(`Stopped: ${project.name}`);
154
164
  } else console.error(`Failed to stop: ${project.name}`);
155
165
  }
166
+ await enforceRunningLimit({
167
+ max: config.maxRunningProjects,
168
+ neverStops: (name) => {
169
+ const projectDir = projects.get(name);
170
+ return projectDir ? shouldNeverStop(loadProjectConfig(projectDir)) : false;
171
+ }
172
+ }, ddevRunningLimitDeps(run));
156
173
  }
157
174
  /**
158
175
  * Start the idle check scheduler
159
176
  */
160
177
  /** An idle sweep that throws must not take the scheduler down with it. */
161
- function reportSweepError(error) {
178
+ function reportSweepError$1(error) {
162
179
  console.error("Idle sweep failed:", error);
163
180
  }
164
181
  function startIdleScheduler(config) {
165
182
  const intervalMs = parseDuration(config.idleCheckInterval);
166
183
  console.log(`Idle scheduler started: checking every ${config.idleCheckInterval}, timeout ${config.idleTimeout}`);
167
- stopIdleProjects(config).catch(reportSweepError);
184
+ stopIdleProjects(config).catch(reportSweepError$1);
168
185
  return setInterval(() => {
169
- stopIdleProjects(config).catch(reportSweepError);
186
+ stopIdleProjects(config).catch(reportSweepError$1);
170
187
  }, intervalMs);
171
188
  }
172
189
  //#endregion
190
+ //#region src/setup/agent.ts
191
+ /** Node.js major version required by the agent */
192
+ var NODE_MAJOR = 24;
193
+ /** NodeSource keyring path */
194
+ var NODESOURCE_KEYRING = "/etc/apt/keyrings/nodesource.gpg";
195
+ /**
196
+ * Add the NodeSource apt repository and GPG key.
197
+ * Idempotent: safe to call even if the repository is already configured.
198
+ *
199
+ * nodejs.org points at NodeSource for apt installs. The Ubuntu 24.04
200
+ * `nodejs` package is 18.x, far below what the agent needs.
201
+ */
202
+ function addNodeSourceAptRepo(io = nodeIo) {
203
+ io.exec("install -m 0755 -d /etc/apt/keyrings", { silent: true });
204
+ io.exec("curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/trafic-nodesource.key", { silent: true });
205
+ io.exec("gpg --batch --yes --dearmor -o /tmp/trafic-nodesource.gpg /tmp/trafic-nodesource.key", { silent: true });
206
+ io.exec(`install -m 0644 /tmp/trafic-nodesource.gpg ${NODESOURCE_KEYRING}`, { silent: true });
207
+ io.exec("rm -f /tmp/trafic-nodesource.key /tmp/trafic-nodesource.gpg", { silent: true });
208
+ io.exec(`echo "deb [signed-by=${NODESOURCE_KEYRING}] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null`, { silent: true });
209
+ }
210
+ /**
211
+ * Install Node.js from the NodeSource apt repository.
212
+ *
213
+ * apt keeps node and npm in /usr/bin — on root's PATH, so `which
214
+ * trafic-agent` resolves for the systemd unit — and unattended-upgrades
215
+ * patches them, which a version manager install never gets.
216
+ */
217
+ function installNode(io = nodeIo) {
218
+ step("Install Node.js");
219
+ if (io.commandExists("node")) {
220
+ const version = io.exec("node --version", { silent: true });
221
+ info(`Node.js already installed: ${version?.trim()}`);
222
+ return;
223
+ }
224
+ info("Adding NodeSource apt repository...");
225
+ addNodeSourceAptRepo(io);
226
+ info("Installing Node.js...");
227
+ io.exec("apt-get update -qq", { silent: true });
228
+ io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y nodejs", { silent: true });
229
+ success(`Node.js ${NODE_MAJOR} installed from apt`);
230
+ }
231
+ /**
232
+ * Install the Trafic agent globally, at the version that runs this setup.
233
+ *
234
+ * `trafic setup --agent-version` installs that version, then runs this
235
+ * setup: an install with no version would replace it by the latest one.
236
+ */
237
+ function installAgent(io = nodeIo) {
238
+ step("Install Trafic agent");
239
+ io.exec(`npm install -g @studiometa/trafic-agent@0.1.51`);
240
+ success(`Trafic agent 0.1.51 installed`);
241
+ }
242
+ /**
243
+ * Create the agent configuration
244
+ */
245
+ function createAgentConfig(options, io = nodeIo) {
246
+ const { tld, trustedProxyHops = 1, dnsProvider, dnsEnv = {} } = options;
247
+ step("Create agent configuration");
248
+ io.exec("mkdir -p /etc/trafic");
249
+ io.exec("mkdir -p /var/lib/trafic");
250
+ io.exec("chown ddev:ddev /var/lib/trafic");
251
+ createBackupDir$1(io);
252
+ const configPath = "/etc/trafic/config.toml";
253
+ const tlsSection = buildTlsSection(dnsProvider, dnsEnv);
254
+ if (io.fileExists(configPath)) {
255
+ info("Configuration already exists at /etc/trafic/config.toml — skipping");
256
+ info("Edit this file to configure authentication");
257
+ if (tlsSection) {
258
+ info("Add this to /etc/trafic/config.toml, then run `trafic-agent upgrade`:");
259
+ for (const line of buildTlsSection(dnsProvider, dnsEnv, true).trim().split("\n")) info(` ${line}`);
260
+ info("Replace each REPLACE_ME with the value you passed to --dns-env.");
261
+ }
262
+ return;
263
+ }
264
+ const config = `# Trafic agent configuration
265
+ # Generated by trafic setup
266
+
267
+ # Required: TLD for DDEV projects
268
+ tld = "${tld}"
269
+
270
+ # Agent HTTP server port
271
+ port = 9876
272
+
273
+ # Database and paths
274
+ db_path = "/var/lib/trafic/db.sqlite"
275
+ project_list_path = "/home/ddev/.ddev/project_list.yaml"
276
+ projects_dir = "/home/ddev/www"
277
+
278
+ # Scale-to-zero settings
279
+ idle_timeout = "4h"
280
+ idle_check_interval = "5m"
281
+
282
+ # Authentication
283
+ [auth]
284
+ default_policy = "basic"
285
+ allowed_ips = []
286
+ tokens = []
287
+ basic_auth = []
288
+ rules = []
289
+
290
+ # Proxies in front of the agent, counted so the client address can be read
291
+ # from X-Forwarded-For. 1 is ddev-router/Traefik alone. Raise it by one for
292
+ # every extra proxy — a CDN or load balancer ahead of Traefik makes it 2.
293
+ # Too low and allowed_ips matches a proxy instead of the client; too high and
294
+ # it can be spoofed by a client-supplied header.
295
+ trusted_proxy_hops = ${trustedProxyHops}
296
+ ${tlsSection}`;
297
+ io.writeFile(configPath, config);
298
+ io.exec("chmod 640 /etc/trafic/config.toml");
299
+ io.exec("chown root:ddev /etc/trafic/config.toml");
300
+ success("Configuration created at /etc/trafic/config.toml");
301
+ info("Edit this file to configure authentication");
302
+ }
303
+ /**
304
+ * Build the [tls] section of the generated config.
305
+ *
306
+ * Empty without a DNS provider: per-host certificates stay the default, and
307
+ * an empty section would only invite a half-filled one.
308
+ */
309
+ function buildTlsSection(dnsProvider, dnsEnv, redact = false) {
310
+ if (!dnsProvider) return "";
311
+ const env = Object.entries(dnsEnv).map(([key, value]) => `${key} = "${redact ? "REPLACE_ME" : escapeTomlString(value)}"`).join("\n");
312
+ return `
313
+ # Wildcard certificate (DNS-01). One *.<tld> certificate replaces the
314
+ # per-hostname ones, so previews stop consuming the Let's Encrypt quota.
315
+ [tls]
316
+ dns_provider = "${escapeTomlString(dnsProvider)}"
317
+ # ca_server = "https://acme-staging-v02.api.letsencrypt.org/directory"
318
+
319
+ # Credentials the lego provider reads. Passed to the ddev-router container.
320
+ [tls.dns_env]
321
+ ${env}
322
+ `;
323
+ }
324
+ /** Escape a value for a basic (double-quoted) TOML string. */
325
+ function escapeTomlString(value) {
326
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
327
+ }
328
+ /** Default backup directory, `[backup] dir` in the agent config. */
329
+ var BACKUP_DIR = "/var/backups/trafic";
330
+ /**
331
+ * Create the backup directory, writable by the ddev user only: it holds
332
+ * database dumps.
333
+ */
334
+ function createBackupDir$1(io = nodeIo) {
335
+ io.exec(`mkdir -p ${BACKUP_DIR}`);
336
+ io.exec(`chown ddev:ddev ${BACKUP_DIR}`);
337
+ io.exec(`chmod 700 ${BACKUP_DIR}`);
338
+ }
339
+ /**
340
+ * Create systemd service for the agent
341
+ */
342
+ function createSystemdService(io = nodeIo) {
343
+ step("Create systemd service");
344
+ const serviceContent = `[Unit]
345
+ Description=Trafic Agent - DDEV preview environment manager
346
+ After=network.target docker.service
347
+ Requires=docker.service
348
+
349
+ [Service]
350
+ Type=simple
351
+ User=ddev
352
+ Group=ddev
353
+ ExecStart=${io.exec("which trafic-agent", { silent: true })?.trim() || "/usr/bin/trafic-agent"} start --config /etc/trafic/config.toml
354
+ Restart=always
355
+ RestartSec=5
356
+ StandardOutput=journal
357
+ StandardError=journal
358
+ SyslogIdentifier=trafic-agent
359
+
360
+ # Environment
361
+ Environment=NODE_ENV=production
362
+ # Skip ddev-hostname /etc/hosts management — on a server, hostnames resolve
363
+ # to the public IP (not localhost), which would trigger ddev-hostname on every
364
+ # ddev start. DDEV_NONINTERACTIVE=true skips that entirely.
365
+ Environment=DDEV_NONINTERACTIVE=true
366
+
367
+ # Security hardening
368
+ NoNewPrivileges=true
369
+ ProtectSystem=strict
370
+ ProtectHome=read-only
371
+ # ddev start writes across its own home: the project directory
372
+ # (.ddev/.webimageBuild), buildx state in .docker, mutagen data. Listing
373
+ # individual paths is whack-a-mole across DDEV versions, so grant the home
374
+ # and leave the rest of /home read-only. The backup sweep removes old
375
+ # backups from ${BACKUP_DIR}; with "-", a missing backup directory does not
376
+ # stop the service from starting.
377
+ ReadWritePaths=/var/lib/trafic /home/ddev -${BACKUP_DIR}
378
+ PrivateTmp=true
379
+
380
+ [Install]
381
+ WantedBy=multi-user.target
382
+ `;
383
+ io.writeFile("/etc/systemd/system/trafic-agent.service", serviceContent);
384
+ io.exec("chmod 644 /etc/systemd/system/trafic-agent.service");
385
+ io.exec("systemctl daemon-reload");
386
+ io.exec("systemctl enable trafic-agent");
387
+ io.exec("systemctl start trafic-agent");
388
+ success("Trafic agent service started");
389
+ info("View logs: journalctl -u trafic-agent -f");
390
+ }
391
+ //#endregion
392
+ //#region src/tasks/backup.ts
393
+ var execFileAsync = promisify(execFile);
394
+ /** Where DDEV mounts the project in the web container. */
395
+ var CONTAINER_APPROOT = "/var/www/html";
396
+ /** Reading the upload directories is quick. */
397
+ var UPLOAD_DIRS_TIMEOUT_MS = 6e4;
398
+ /** Retention is counted in days, so an hourly sweep is enough. */
399
+ var SWEEP_INTERVAL_MS$1 = 36e5;
400
+ var DAY_MS = 864e5;
401
+ /** A backup directory name: an ISO date in UTC, with `-` for `:`. */
402
+ var TIMESTAMP = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})Z$/;
403
+ /** The name of a backup directory, e.g. `2026-10-14T07-40-24Z`. */
404
+ function backupTimestamp(date) {
405
+ return `${date.toISOString().slice(0, 19).replace(/:/g, "-")}Z`;
406
+ }
407
+ /** The time of a backup directory name, or undefined for any other name. */
408
+ function parseBackupTimestamp(name) {
409
+ const match = TIMESTAMP.exec(name);
410
+ if (!match) return;
411
+ const [, day, hours, minutes, seconds] = match;
412
+ const time = Date.parse(`${day}T${hours}:${minutes}:${seconds}Z`);
413
+ return Number.isNaN(time) ? void 0 : time;
414
+ }
415
+ /** Whether a path is an existing directory. */
416
+ function isDirectory$1(path) {
417
+ try {
418
+ return statSync(path).isDirectory();
419
+ } catch {
420
+ return false;
421
+ }
422
+ }
423
+ /**
424
+ * The upload directories of a project, relative to its directory.
425
+ *
426
+ * DDEV resolves them: `upload_dirs` from the project config, or the default
427
+ * of the project type (`wp-content/uploads` for WordPress, for instance).
428
+ * Neither `ddev describe` nor `ddev utility configyaml` shows the type
429
+ * default, but DDEV gives the result to the web container as
430
+ * `DDEV_FILES_DIRS`, comma-separated container paths. The project must be
431
+ * running.
432
+ *
433
+ * A directory that does not exist on the host is left out.
434
+ */
435
+ async function findUploadDirs(dir, timeoutMs, run) {
436
+ const { ok, stdout } = await run([
437
+ "exec",
438
+ "printenv",
439
+ "DDEV_FILES_DIRS"
440
+ ], timeoutMs, dir);
441
+ if (!ok) throw new Error("Could not read the upload directories (DDEV_FILES_DIRS)");
442
+ return stdout.trim().split(",").filter(Boolean).map((path) => posix.relative(CONTAINER_APPROOT, path)).filter((path) => path && !path.startsWith("..") && !posix.isAbsolute(path)).filter((path) => isDirectory$1(join(dir, path)));
443
+ }
444
+ /**
445
+ * Create the backup directory of one backup (see createBackupDir).
446
+ *
447
+ * The agent service runs in a systemd sandbox (`ProtectSystem=strict`) that
448
+ * can write to a few paths only. A `[backup] dir` outside of them is
449
+ * read-only for the service, so every scheduled backup fails and the
450
+ * project is never removed: say so in the error, which the sweep logs.
451
+ */
452
+ function createBackupTarget(target, backupDir, name) {
453
+ try {
454
+ mkdirSync(join(backupDir, name), { recursive: true });
455
+ createBackupDir(target, name);
456
+ } catch (error) {
457
+ const { code } = error;
458
+ if (code !== "EROFS" && code !== "EACCES") throw error;
459
+ throw new Error(`Cannot write to the backup directory ${backupDir} (${code}). The agent service runs in a systemd sandbox (ProtectSystem=strict) and can only write to /var/lib/trafic, /home/ddev and ${BACKUP_DIR}: set [backup] dir inside one of them, writable by the ddev user`, { cause: error });
460
+ }
461
+ }
462
+ /** The time left before the deadline of the backup. */
463
+ function timeLeft(deadline) {
464
+ const left = deadline - Date.now();
465
+ if (left <= 0) throw new Error("No time is left for the next step");
466
+ return left;
467
+ }
468
+ /** A duration in the largest whole unit: 1h, 40m or 90s. */
469
+ function formatDuration(ms) {
470
+ if (ms % 36e5 === 0) return `${ms / 36e5}h`;
471
+ if (ms % 6e4 === 0) return `${ms / 6e4}m`;
472
+ return `${Math.ceil(ms / 1e3)}s`;
473
+ }
474
+ /**
475
+ * The error of a backup that went over its time limit. The step that ran
476
+ * out of time only fails (ddev reports a failed command, tar is killed), so
477
+ * this says it was the time limit, and how to raise it.
478
+ */
479
+ function timeoutError(timeoutMs, cause) {
480
+ return new Error(`The backup timed out: it took longer than its time limit of ${formatDuration(timeoutMs)}. Raise the limit with --timeout`, { cause });
481
+ }
482
+ /**
483
+ * Write the database dump and the upload archive of a running project.
484
+ * Each step gets the time left before `deadline`.
485
+ */
486
+ async function writeBackup(hasDatabase, dir, target, deadline, run) {
487
+ if (hasDatabase) {
488
+ const file = join(target, "db.sql.gz");
489
+ console.log(`Exporting the database to ${file}`);
490
+ const { ok } = await run(["export-db", `--file=${file}`], timeLeft(deadline), dir);
491
+ if (!ok) throw new Error("Could not export the database");
492
+ } else console.log("No database container — skipping the database dump");
493
+ const uploadDirs = await findUploadDirs(dir, Math.min(UPLOAD_DIRS_TIMEOUT_MS, timeLeft(deadline)), run);
494
+ if (uploadDirs.length === 0) {
495
+ console.log("No upload directory — skipping the files archive");
496
+ return;
497
+ }
498
+ const file = join(target, "files.tar.gz");
499
+ console.log(`Archiving ${uploadDirs.join(", ")} to ${file}`);
500
+ await archive(file, dir, uploadDirs, timeLeft(deadline));
501
+ }
502
+ /**
503
+ * Archive paths of a directory to a tar.gz file.
504
+ *
505
+ * The project runs during the archive, and can write to its upload
506
+ * directories at any time. GNU tar then exits with code 1 ("some files
507
+ * differ"): the archive is written, with the changed file as tar read it.
508
+ * That is accepted with a warning. Code 2 or more (a fatal error, such as an
509
+ * unreadable file) and a timeout fail the archive.
510
+ */
511
+ async function archive(file, dir, paths, timeoutMs) {
512
+ try {
513
+ await execFileAsync("tar", [
514
+ "-czf",
515
+ file,
516
+ "-C",
517
+ dir,
518
+ "--",
519
+ ...paths
520
+ ], { timeout: timeoutMs });
521
+ } catch (error) {
522
+ const { code, killed } = error;
523
+ if (code !== 1 || killed) throw error;
524
+ console.warn("Some files changed while tar read them: the archive has them as tar read them");
525
+ }
526
+ }
527
+ /**
528
+ * Create the directory of one backup. It must not exist: two backups of a
529
+ * project in the same second would share it, and the one that fails would
530
+ * remove the files of the other. The second one fails here, before it
531
+ * changes anything.
532
+ */
533
+ function createBackupDir(target, name) {
534
+ try {
535
+ mkdirSync(target);
536
+ } catch (error) {
537
+ if (error.code === "EEXIST") throw new Error(`${target} already exists: another backup of ${name} runs or ran in the same second`);
538
+ throw error;
539
+ }
540
+ }
541
+ /**
542
+ * Back up a DDEV project to `<backupDir>/<name>/<timestamp>`.
543
+ *
544
+ * The backup holds:
545
+ * - `db.sql.gz`: the `ddev export-db` dump, unless the project omits the db
546
+ * container
547
+ * - `files.tar.gz`: the upload directories, with paths relative to the
548
+ * project directory, unless the project has none
549
+ *
550
+ * Both need the project running, so a stopped project is started, then
551
+ * stopped again. A failed backup, or a backup that takes longer than its
552
+ * time limit (`timeoutMs`), throws and leaves no backup directory. Returns
553
+ * the backup directory.
554
+ */
555
+ async function backupProject(name, dir, backupDir, options = {}, run = nodeDdevRunner) {
556
+ const info = await getProjectInfo(name, run);
557
+ if (!info) throw new Error(`Could not describe the DDEV project ${name}`);
558
+ const target = join(backupDir, name, backupTimestamp(/* @__PURE__ */ new Date()));
559
+ const wasRunning = info.status === "running";
560
+ createBackupTarget(target, backupDir, name);
561
+ try {
562
+ if (!wasRunning) {
563
+ console.log(`Starting ${name} for the backup`);
564
+ if (!await startProject(name, run)) throw new Error(`Could not start ${name} for the backup`);
565
+ }
566
+ const timeoutMs = options.timeoutMs ?? 24e5;
567
+ const deadline = Date.now() + timeoutMs;
568
+ try {
569
+ await writeBackup(info.dbType !== void 0, dir, target, deadline, run);
570
+ } catch (error) {
571
+ throw Date.now() >= deadline ? timeoutError(timeoutMs, error) : error;
572
+ } finally {
573
+ if (!wasRunning && !await stopProject(name, run)) console.warn(`Could not stop ${name} after the backup`);
574
+ }
575
+ } catch (error) {
576
+ rmSync(target, {
577
+ recursive: true,
578
+ force: true
579
+ });
580
+ throw error;
581
+ }
582
+ console.log(`Backup saved to ${target}`);
583
+ return target;
584
+ }
585
+ /** The subdirectories of a directory, or none when it does not exist. */
586
+ function listDirs(path) {
587
+ if (!existsSync(path)) return [];
588
+ return readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
589
+ }
590
+ /**
591
+ * Remove the backups older than `retainDays`, and the project directories
592
+ * that this removal leaves empty. Only directories with a backup timestamp
593
+ * name are removed, and a project directory only when this sweep removed a
594
+ * backup from it: `dir` can be a shared directory, with empty directories
595
+ * that are not backups. Returns the number of backups removed.
596
+ */
597
+ function removeOldBackups(config, now = Date.now()) {
598
+ const cutoff = now - config.retainDays * DAY_MS;
599
+ let removed = 0;
600
+ for (const project of listDirs(config.dir)) {
601
+ const projectDir = join(config.dir, project);
602
+ let removedFromProject = 0;
603
+ for (const name of listDirs(projectDir)) {
604
+ const time = parseBackupTimestamp(name);
605
+ if (time !== void 0 && time < cutoff) {
606
+ rmSync(join(projectDir, name), {
607
+ recursive: true,
608
+ force: true
609
+ });
610
+ console.log(`Removed old backup: ${project}/${name}`);
611
+ removedFromProject++;
612
+ }
613
+ }
614
+ removed += removedFromProject;
615
+ if (removedFromProject === 0) continue;
616
+ try {
617
+ if (readdirSync(projectDir).length === 0) rmdirSync(projectDir);
618
+ } catch {}
619
+ }
620
+ return removed;
621
+ }
622
+ /** A sweep that throws must not take the scheduler down with it. */
623
+ function sweepBackups(config) {
624
+ try {
625
+ removeOldBackups(config);
626
+ } catch (error) {
627
+ console.error("Backup sweep failed:", error);
628
+ }
629
+ }
630
+ /**
631
+ * Start the backup retention sweep
632
+ */
633
+ function startBackupSweepScheduler(config) {
634
+ console.log(`Backup sweep started: removing backups older than ${config.backup.retainDays} days from ${config.backup.dir}`);
635
+ sweepBackups(config.backup);
636
+ return setInterval(() => sweepBackups(config.backup), SWEEP_INTERVAL_MS$1);
637
+ }
638
+ //#endregion
639
+ //#region src/tasks/destroy-project.ts
640
+ /** Removing containers and volumes is slower than a stop. */
641
+ var DELETE_TIMEOUT_MS = 3e5;
642
+ /**
643
+ * What to do when `trafic-agent destroy` cannot read the config or open the
644
+ * database. Only the ddev user can do both: the config is readable by root
645
+ * and the ddev group only (640 root:ddev), and the database directory
646
+ * belongs to ddev. `trafic destroy` connects as ddev by default.
647
+ */
648
+ var DDEV_USER_HINT = "connect as the ddev user";
649
+ /** SQLite result codes of a database that the user cannot open or write. */
650
+ var SQLITE_ACCESS_ERRORS = /* @__PURE__ */ new Set([
651
+ 3,
652
+ 8,
653
+ 14
654
+ ]);
655
+ /**
656
+ * Read the agent config for `trafic-agent destroy`. A permission error says
657
+ * which user to connect as.
658
+ */
659
+ function loadDestroyConfig(configPath) {
660
+ try {
661
+ return loadConfig(configPath);
662
+ } catch (error) {
663
+ const { code, path } = error;
664
+ if (code !== "EACCES" && code !== "EPERM") throw error;
665
+ throw new Error(`cannot read ${path ?? configPath} (${code}): ${DDEV_USER_HINT}`, { cause: error });
666
+ }
667
+ }
668
+ /**
669
+ * Open the agent database for `trafic-agent destroy`. A permission error
670
+ * says which user to connect as.
671
+ */
672
+ function openDestroyDb(dbPath) {
673
+ try {
674
+ initDb(dbPath);
675
+ } catch (error) {
676
+ const { errcode } = error;
677
+ if (errcode === void 0 || !SQLITE_ACCESS_ERRORS.has(errcode & 255)) throw error;
678
+ throw new Error(`cannot open the database ${dbPath} (${error.message}): ${DDEV_USER_HINT}`, { cause: error });
679
+ }
680
+ }
681
+ /**
682
+ * Find the directory of a project, and refuse one outside the projects
683
+ * directory.
684
+ *
685
+ * DDEV's project list gives the real app root. A project DDEV has already
686
+ * forgotten is looked for at `<projects_dir>/<name>`, where `trafic deploy`
687
+ * puts it, so its directory can still be cleaned up.
688
+ *
689
+ * The check stands between a name typed on a command line and an
690
+ * `rm -rf`: an empty name, `..` or an app root of `/` must never reach it.
691
+ * The path is checked as written, then, when it exists, with its symbolic
692
+ * links followed: a symlinked project directory or parent directory could
693
+ * otherwise point `ddev delete` and the removal at a directory outside the
694
+ * projects directory.
695
+ */
696
+ function resolveProjectDir(name, config) {
697
+ if (!name.trim() || name === "." || name === ".." || name.includes("/")) throw new Error(`Invalid project name: "${name}"`);
698
+ const projectsDir = resolve(config.projectsDir);
699
+ const appRoot = loadProjectList(config.projectListPath).get(name);
700
+ const dir = resolve(appRoot || join(projectsDir, name));
701
+ if (!isInside(projectsDir, dir)) throw new Error(`Refusing to remove ${dir}: it is not inside the projects directory ${projectsDir}. Set projects_dir in the agent config to the directory that \`trafic deploy\` uses`);
702
+ checkRealPath(dir, projectsDir);
703
+ return dir;
704
+ }
705
+ /**
706
+ * Refuse an existing project directory that is a symbolic link, or that a
707
+ * symbolic link in its path takes outside the projects directory. A path
708
+ * that does not exist is left to the caller: there is nothing to remove.
709
+ */
710
+ function checkRealPath(dir, projectsDir) {
711
+ let stats;
712
+ try {
713
+ stats = lstatSync(dir);
714
+ } catch (error) {
715
+ const { code } = error;
716
+ if (code === "ENOENT" || code === "ENOTDIR") return;
717
+ throw error;
718
+ }
719
+ if (stats.isSymbolicLink()) throw new Error(`Refusing to remove ${dir}: it is a symbolic link`);
720
+ const realDir = realpathSync(dir);
721
+ const realProjectsDir = realpathSync(projectsDir);
722
+ if (!isInside(realProjectsDir, realDir)) throw new Error(`Refusing to remove ${dir}: it resolves to ${realDir}, which is not inside the projects directory ${realProjectsDir}`);
723
+ }
724
+ /**
725
+ * Refuse a backup directory that is the project directory or inside it: the
726
+ * removal would delete the backup right after it is written. validateConfig
727
+ * already refuses a backup.dir in projects_dir; this also follows symbolic
728
+ * links. It checks `<backup.dir>/<name>`, where the backup goes, so a
729
+ * symbolic link at that level is followed too.
730
+ */
731
+ function checkBackupDir(backupDir, name, dir) {
732
+ const target = resolve(backupDir, name);
733
+ const realTarget = realpathOfNearest(target);
734
+ const realDir = realpathSync(dir);
735
+ if (target === dir || isInside(dir, target) || realTarget === realDir || isInside(realDir, realTarget)) throw new Error(`Refusing to back up to ${backupDir}: it is inside the project directory ${dir}, which the removal deletes`);
736
+ }
737
+ /**
738
+ * The real path of a path that may not exist yet: the real path of its
739
+ * nearest existing ancestor, plus the rest of the path. The backup creates
740
+ * the missing directories through the symbolic links of that ancestor.
741
+ */
742
+ function realpathOfNearest(path) {
743
+ const rest = [];
744
+ let current = path;
745
+ while (!existsSync(current)) {
746
+ const parent = dirname(current);
747
+ if (parent === current) break;
748
+ rest.unshift(basename(current));
749
+ current = parent;
750
+ }
751
+ return join(realpathSync(current), ...rest);
752
+ }
753
+ /** Whether a path is an existing directory. */
754
+ function isDirectory(path) {
755
+ try {
756
+ return statSync(path).isDirectory();
757
+ } catch {
758
+ return false;
759
+ }
760
+ }
761
+ /**
762
+ * Remove a project from the server.
763
+ *
764
+ * Steps:
765
+ * 1. With `backup`, back up the database and the upload directories, all
766
+ * within `timeoutMs` when it is given
767
+ * 2. Delete the DDEV project (containers, volumes, no snapshot)
768
+ * 3. Remove the project directory
769
+ * 4. Delete the project's records from the agent database
770
+ *
771
+ * A failed backup throws before anything is removed.
772
+ *
773
+ * A backup can take many minutes. With `beforeDelete`, the removal calls it
774
+ * after the backup, just before step 2, and stops without removing anything
775
+ * when it returns false: a scheduled removal that a deploy cancelled
776
+ * meanwhile must not delete the new deploy.
777
+ *
778
+ * A failed `ddev delete` does not stop the removal: a project DDEV has
779
+ * already forgotten could otherwise never be cleaned up, and the next deploy
780
+ * would find a stale directory.
781
+ *
782
+ * While the removal runs, the project has the status "destroying", which the
783
+ * idle sweep and max_running_projects never stop, and the waiting page never
784
+ * starts: the backup can take many minutes, and a stop in the middle of it
785
+ * fails the backup. The status is in the database, so the service sees it
786
+ * when `trafic-agent destroy` runs from the command line. A removal that
787
+ * fails sets the status back: "running" when it was, else "stopped". A
788
+ * removal that is killed cannot: the status records the id of its process,
789
+ * and the service sets it back to "running" once that process is gone (see
790
+ * releaseStaleStatuses).
791
+ *
792
+ * The database must be open (`initDb`). Returns false when there was no
793
+ * project directory or the removal was cancelled. Without a project
794
+ * directory, the project's records are still deleted, so a retry after a run
795
+ * that failed between steps 2 and 3 cleans them up. A cancelled removal
796
+ * removes nothing and sets the status back.
797
+ */
798
+ async function destroyProject(name, config, options = {}, run = nodeDdevRunner) {
799
+ const dir = resolveProjectDir(name, config);
800
+ if (!isDirectory(dir)) {
801
+ console.warn(deleteProject(name) ? `Project directory ${dir} does not exist: removed ${name} from the agent database` : `Project directory ${dir} does not exist — nothing to destroy`);
802
+ return false;
803
+ }
804
+ if (options.backup) checkBackupDir(config.backup.dir, name, dir);
805
+ const previous = getProject(name)?.status;
806
+ setProjectStatus(name, "destroying");
807
+ let removed;
808
+ try {
809
+ removed = await removeProject(name, dir, config, options, run);
810
+ } catch (error) {
811
+ setProjectStatus(name, previous === "running" ? "running" : "stopped");
812
+ throw error;
813
+ }
814
+ if (!removed) setProjectStatus(name, previous === "running" ? "running" : "stopped");
815
+ return removed;
816
+ }
817
+ /**
818
+ * The steps of destroyProject, once the project is marked "destroying".
819
+ * Returns false when the removal was cancelled after the backup.
820
+ */
821
+ async function removeProject(name, dir, config, options, run) {
822
+ if (options.backup) {
823
+ console.log(`Backing up ${name}`);
824
+ await backupProject(name, dir, config.backup.dir, { timeoutMs: options.timeoutMs }, run);
825
+ }
826
+ if (options.beforeDelete && !options.beforeDelete()) {
827
+ console.log(`Removal of ${name} cancelled — nothing removed`);
828
+ return false;
829
+ }
830
+ console.log(`Deleting DDEV project: ${name}`);
831
+ const { ok } = await run(["delete", "-Oy"], DELETE_TIMEOUT_MS, dir);
832
+ if (!ok) console.warn("DDEV delete failed — project may not be a valid DDEV project");
833
+ console.log(`Removing ${dir}`);
834
+ rmSync(dir, {
835
+ recursive: true,
836
+ force: true
837
+ });
838
+ deleteProject(name);
839
+ console.log(`Destroyed: ${name}`);
840
+ return true;
841
+ }
842
+ //#endregion
843
+ //#region src/tasks/destroy-scheduled.ts
844
+ /** Hours, minutes and seconds, in this order: "48h", "1h30m", "90s". */
845
+ var DELAY_REGEX = /^(?:\d+h)?(?:\d+m)?(?:\d+s)?$/;
846
+ /**
847
+ * Schedule the removal of a project, `delay` from now ("48h", "1h30m").
848
+ *
849
+ * The date goes to the agent database, not to the project: a redeploy does
850
+ * not reliably rewrite the project files, and the sweep must find the date
851
+ * even for a project DDEV has forgotten. The name is checked now, so a bad
852
+ * name or a wrong `projects_dir` fails the CI job, not a sweep two days
853
+ * later. With `backup`, the removal backs the project up first, within
854
+ * `timeoutMs` when it is given. A removal that the sweep already started
855
+ * cannot be scheduled again. Returns the date of the removal.
856
+ */
857
+ function scheduleProjectDestroy(name, delay, config, options = {}, now = systemClock) {
858
+ const delayMs = DELAY_REGEX.test(delay) ? parseDuration(delay) : 0;
859
+ if (delayMs <= 0) throw new Error(`Invalid delay: "${delay}". Use hours, minutes or seconds, e.g. 48h or 1h30m`);
860
+ resolveProjectDir(name, config);
861
+ if (getScheduledDestroy(name)?.removing) throw new Error(`The removal of ${name} is in progress`);
862
+ const destroyAt = now() + delayMs;
863
+ scheduleDestroy(name, destroyAt, options.backup ?? false, options.timeoutMs);
864
+ return new Date(destroyAt);
865
+ }
866
+ /** A removal can take longer than the sweep interval; one sweep at a time. */
867
+ var sweeping = false;
868
+ /**
869
+ * Remove each project whose scheduled removal is due, through the same
870
+ * removal as `trafic-agent destroy`, with a backup when one was asked for.
871
+ *
872
+ * A failure on one project does not stop the sweep: it is logged, and the
873
+ * removal stays scheduled so the next sweep tries again. A project already
874
+ * gone has its schedule cleared.
875
+ *
876
+ * A removal can take minutes, and a deploy or a `--cancel` can cancel the
877
+ * removal of a project meanwhile. So the sweep reads each removal again just
878
+ * before it starts, and once more after the backup. There, it marks the
879
+ * removal as started (startScheduledDestroy) before `ddev delete`: a later
880
+ * cancel is refused, so the deploy fails instead of deploying a project
881
+ * that is about to go. A retry of a started removal does not back the
882
+ * project up again: the backup exists, and DDEV may have forgotten the
883
+ * project already.
884
+ */
885
+ async function destroyScheduledProjects(config, now = systemClock, run = nodeDdevRunner) {
886
+ if (sweeping) return;
887
+ sweeping = true;
888
+ try {
889
+ for (const { name } of getScheduledDestroys(now())) {
890
+ const scheduled = getScheduledDestroy(name, now());
891
+ if (!scheduled) continue;
892
+ console.log(`Destroying ${name}: its scheduled removal is ${scheduled.removing ? "in progress" : "due"}`);
893
+ try {
894
+ await destroyProject(name, config, {
895
+ backup: scheduled.backup && !scheduled.removing,
896
+ timeoutMs: scheduled.timeoutMs,
897
+ beforeDelete: () => startScheduledDestroy(name, now())
898
+ }, run);
899
+ } catch (error) {
900
+ console.error(`Scheduled removal of ${name} failed, retrying on the next sweep: ${error.message}`);
901
+ }
902
+ }
903
+ } finally {
904
+ sweeping = false;
905
+ }
906
+ }
907
+ /** A sweep that throws must not take the scheduler down with it. */
908
+ function reportSweepError(error) {
909
+ console.error("Scheduled removal sweep failed:", error);
910
+ }
911
+ /**
912
+ * Start the scheduled removal sweep, on the idle check interval
913
+ */
914
+ function startScheduledDestroyScheduler(config) {
915
+ const intervalMs = parseDuration(config.idleCheckInterval);
916
+ console.log(`Scheduled removal sweep started: checking every ${config.idleCheckInterval}`);
917
+ destroyScheduledProjects(config).catch(reportSweepError);
918
+ return setInterval(() => {
919
+ destroyScheduledProjects(config).catch(reportSweepError);
920
+ }, intervalMs);
921
+ }
922
+ /** Retention is counted in days, so an hourly sweep is enough. */
923
+ var SWEEP_INTERVAL_MS = 36e5;
924
+ /** A sweep that throws must not take the scheduler down with it. */
925
+ function sweepAccessLogs() {
926
+ try {
927
+ const removed = cleanOldLogs(30);
928
+ if (removed > 0) console.log(`Removed ${removed} access logs older than 30 days`);
929
+ } catch (error) {
930
+ console.error("Access log sweep failed:", error);
931
+ }
932
+ }
933
+ /**
934
+ * Start the access log retention sweep. Each request logs a row, so without
935
+ * it the database grows for good. The database must be open.
936
+ */
937
+ function startAccessLogSweepScheduler() {
938
+ console.log(`Access log sweep started: removing access logs older than 30 days`);
939
+ sweepAccessLogs();
940
+ return setInterval(sweepAccessLogs, SWEEP_INTERVAL_MS);
941
+ }
942
+ //#endregion
173
943
  //#region src/setup/user.ts
174
944
  /**
175
945
  * Write /etc/sudoers.d/trafic-ddev allowing the ddev user to run
@@ -357,191 +1127,6 @@ function setupDockerPrune(io = nodeIo) {
357
1127
  success("Docker prune scheduled weekly at 3am on Sundays");
358
1128
  }
359
1129
  //#endregion
360
- //#region src/setup/agent.ts
361
- /** Node.js major version required by the agent */
362
- var NODE_MAJOR = 24;
363
- /** NodeSource keyring path */
364
- var NODESOURCE_KEYRING = "/etc/apt/keyrings/nodesource.gpg";
365
- /**
366
- * Add the NodeSource apt repository and GPG key.
367
- * Idempotent: safe to call even if the repository is already configured.
368
- *
369
- * nodejs.org points at NodeSource for apt installs. The Ubuntu 24.04
370
- * `nodejs` package is 18.x, far below what the agent needs.
371
- */
372
- function addNodeSourceAptRepo(io = nodeIo) {
373
- io.exec("install -m 0755 -d /etc/apt/keyrings", { silent: true });
374
- io.exec("curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/trafic-nodesource.key", { silent: true });
375
- io.exec("gpg --batch --yes --dearmor -o /tmp/trafic-nodesource.gpg /tmp/trafic-nodesource.key", { silent: true });
376
- io.exec(`install -m 0644 /tmp/trafic-nodesource.gpg ${NODESOURCE_KEYRING}`, { silent: true });
377
- io.exec("rm -f /tmp/trafic-nodesource.key /tmp/trafic-nodesource.gpg", { silent: true });
378
- io.exec(`echo "deb [signed-by=${NODESOURCE_KEYRING}] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list > /dev/null`, { silent: true });
379
- }
380
- /**
381
- * Install Node.js from the NodeSource apt repository.
382
- *
383
- * apt keeps node and npm in /usr/bin — on root's PATH, so `which
384
- * trafic-agent` resolves for the systemd unit — and unattended-upgrades
385
- * patches them, which a version manager install never gets.
386
- */
387
- function installNode(io = nodeIo) {
388
- step("Install Node.js");
389
- if (io.commandExists("node")) {
390
- const version = io.exec("node --version", { silent: true });
391
- info(`Node.js already installed: ${version?.trim()}`);
392
- return;
393
- }
394
- info("Adding NodeSource apt repository...");
395
- addNodeSourceAptRepo(io);
396
- info("Installing Node.js...");
397
- io.exec("apt-get update -qq", { silent: true });
398
- io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y nodejs", { silent: true });
399
- success(`Node.js ${NODE_MAJOR} installed from apt`);
400
- }
401
- /**
402
- * Install the Trafic agent
403
- */
404
- function installAgent(io = nodeIo) {
405
- step("Install Trafic agent");
406
- io.exec("npm install -g @studiometa/trafic-agent");
407
- success("Trafic agent installed");
408
- }
409
- /**
410
- * Create the agent configuration
411
- */
412
- function createAgentConfig(options, io = nodeIo) {
413
- const { tld, trustedProxyHops = 1, dnsProvider, dnsEnv = {} } = options;
414
- step("Create agent configuration");
415
- io.exec("mkdir -p /etc/trafic");
416
- io.exec("mkdir -p /var/lib/trafic");
417
- io.exec("chown ddev:ddev /var/lib/trafic");
418
- const configPath = "/etc/trafic/config.toml";
419
- const tlsSection = buildTlsSection(dnsProvider, dnsEnv);
420
- if (io.fileExists(configPath)) {
421
- info("Configuration already exists at /etc/trafic/config.toml — skipping");
422
- info("Edit this file to configure authentication");
423
- if (tlsSection) {
424
- info("Add this to /etc/trafic/config.toml, then run `trafic-agent upgrade`:");
425
- for (const line of buildTlsSection(dnsProvider, dnsEnv, true).trim().split("\n")) info(` ${line}`);
426
- info("Replace each REPLACE_ME with the value you passed to --dns-env.");
427
- }
428
- return;
429
- }
430
- const config = `# Trafic agent configuration
431
- # Generated by trafic setup
432
-
433
- # Required: TLD for DDEV projects
434
- tld = "${tld}"
435
-
436
- # Agent HTTP server port
437
- port = 9876
438
-
439
- # Database and paths
440
- db_path = "/var/lib/trafic/db.sqlite"
441
- project_list_path = "/home/ddev/.ddev/project_list.yaml"
442
- projects_dir = "/home/ddev/www"
443
-
444
- # Scale-to-zero settings
445
- idle_timeout = "4h"
446
- idle_check_interval = "5m"
447
-
448
- # Authentication
449
- [auth]
450
- default_policy = "basic"
451
- allowed_ips = []
452
- tokens = []
453
- basic_auth = []
454
- rules = []
455
-
456
- # Proxies in front of the agent, counted so the client address can be read
457
- # from X-Forwarded-For. 1 is ddev-router/Traefik alone. Raise it by one for
458
- # every extra proxy — a CDN or load balancer ahead of Traefik makes it 2.
459
- # Too low and allowed_ips matches a proxy instead of the client; too high and
460
- # it can be spoofed by a client-supplied header.
461
- trusted_proxy_hops = ${trustedProxyHops}
462
- ${tlsSection}`;
463
- io.writeFile(configPath, config);
464
- io.exec("chmod 640 /etc/trafic/config.toml");
465
- io.exec("chown root:ddev /etc/trafic/config.toml");
466
- success("Configuration created at /etc/trafic/config.toml");
467
- info("Edit this file to configure authentication");
468
- }
469
- /**
470
- * Build the [tls] section of the generated config.
471
- *
472
- * Empty without a DNS provider: per-host certificates stay the default, and
473
- * an empty section would only invite a half-filled one.
474
- */
475
- function buildTlsSection(dnsProvider, dnsEnv, redact = false) {
476
- if (!dnsProvider) return "";
477
- const env = Object.entries(dnsEnv).map(([key, value]) => `${key} = "${redact ? "REPLACE_ME" : escapeTomlString(value)}"`).join("\n");
478
- return `
479
- # Wildcard certificate (DNS-01). One *.<tld> certificate replaces the
480
- # per-hostname ones, so previews stop consuming the Let's Encrypt quota.
481
- [tls]
482
- dns_provider = "${escapeTomlString(dnsProvider)}"
483
- # ca_server = "https://acme-staging-v02.api.letsencrypt.org/directory"
484
-
485
- # Credentials the lego provider reads. Passed to the ddev-router container.
486
- [tls.dns_env]
487
- ${env}
488
- `;
489
- }
490
- /** Escape a value for a basic (double-quoted) TOML string. */
491
- function escapeTomlString(value) {
492
- return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
493
- }
494
- /**
495
- * Create systemd service for the agent
496
- */
497
- function createSystemdService(io = nodeIo) {
498
- step("Create systemd service");
499
- const serviceContent = `[Unit]
500
- Description=Trafic Agent - DDEV preview environment manager
501
- After=network.target docker.service
502
- Requires=docker.service
503
-
504
- [Service]
505
- Type=simple
506
- User=ddev
507
- Group=ddev
508
- ExecStart=${io.exec("which trafic-agent", { silent: true })?.trim() || "/usr/bin/trafic-agent"} start --config /etc/trafic/config.toml
509
- Restart=always
510
- RestartSec=5
511
- StandardOutput=journal
512
- StandardError=journal
513
- SyslogIdentifier=trafic-agent
514
-
515
- # Environment
516
- Environment=NODE_ENV=production
517
- # Skip ddev-hostname /etc/hosts management — on a server, hostnames resolve
518
- # to the public IP (not localhost), which would trigger ddev-hostname on every
519
- # ddev start. DDEV_NONINTERACTIVE=true skips that entirely.
520
- Environment=DDEV_NONINTERACTIVE=true
521
-
522
- # Security hardening
523
- NoNewPrivileges=true
524
- ProtectSystem=strict
525
- ProtectHome=read-only
526
- # ddev start writes across its own home: the project directory
527
- # (.ddev/.webimageBuild), buildx state in .docker, mutagen data. Listing
528
- # individual paths is whack-a-mole across DDEV versions, so grant the home
529
- # and leave the rest of /home read-only.
530
- ReadWritePaths=/var/lib/trafic /home/ddev
531
- PrivateTmp=true
532
-
533
- [Install]
534
- WantedBy=multi-user.target
535
- `;
536
- io.writeFile("/etc/systemd/system/trafic-agent.service", serviceContent);
537
- io.exec("chmod 644 /etc/systemd/system/trafic-agent.service");
538
- io.exec("systemctl daemon-reload");
539
- io.exec("systemctl enable trafic-agent");
540
- io.exec("systemctl start trafic-agent");
541
- success("Trafic agent service started");
542
- info("View logs: journalctl -u trafic-agent -f");
543
- }
544
- //#endregion
545
1130
  //#region src/setup/hardening.ts
546
1131
  /** The sshd drop-in written by `setup`, and the only file hardening owns. */
547
1132
  var SSHD_DROPIN = "/etc/ssh/sshd_config.d/trafic.conf";
@@ -1297,7 +1882,7 @@ http:
1297
1882
  };
1298
1883
  //#endregion
1299
1884
  //#region src/setup/migrations/0009__scale_to_zero_fixes.ts
1300
- var UNIT_PATH = "/etc/systemd/system/trafic-agent.service";
1885
+ var UNIT_PATH$1 = "/etc/systemd/system/trafic-agent.service";
1301
1886
  var DYNAMIC_CONFIG$2 = "/home/ddev/.ddev/traefik/trafic.yaml";
1302
1887
  /**
1303
1888
  * Migration 0009: Make scale-to-zero actually work.
@@ -1324,7 +1909,7 @@ var migration0009ScaleToZeroFixes = {
1324
1909
  id: "0009__scale_to_zero_fixes",
1325
1910
  description: "Fix scale-to-zero: waiting page router and agent write access",
1326
1911
  run() {
1327
- const unitNeedsWrite = !existsSync(UNIT_PATH) || !/^ReadWritePaths=.*\/home\/ddev(\s|$)/m.test(readFileSync(UNIT_PATH, "utf-8"));
1912
+ const unitNeedsWrite = !existsSync(UNIT_PATH$1) || !/^ReadWritePaths=.*\/home\/ddev(\s|$)/m.test(readFileSync(UNIT_PATH$1, "utf-8"));
1328
1913
  if (!existsSync(DYNAMIC_CONFIG$2) || !readFileSync(DYNAMIC_CONFIG$2, "utf-8").includes("trafic-catchall")) {
1329
1914
  configureTraefik();
1330
1915
  exec("su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff && ddev start --all'", { silent: true });
@@ -1782,6 +2367,38 @@ function parseTasks(output) {
1782
2367
  }));
1783
2368
  }
1784
2369
  //#endregion
2370
+ //#region src/setup/migrations/0020__backup_dir.ts
2371
+ var UNIT_PATH = "/etc/systemd/system/trafic-agent.service";
2372
+ /**
2373
+ * Migration 0020: let the agent service write to the backup directory.
2374
+ *
2375
+ * The agent now removes old backups from /var/backups/trafic in a periodic
2376
+ * sweep, but the unit runs with `ProtectSystem=strict` and only lists
2377
+ * /var/lib/trafic and /home/ddev in `ReadWritePaths`, so every removal would
2378
+ * fail with a read-only file system error.
2379
+ *
2380
+ * Creates the directory first: hardening created it, but a server set up with
2381
+ * `--no-hardening` has none, and the ddev user cannot create it in
2382
+ * /var/backups. Then rewrites the unit, which lists the directory with the
2383
+ * `-` prefix: systemd refuses to start a unit whose `ReadWritePaths` names a
2384
+ * missing path, unless the path has that prefix.
2385
+ *
2386
+ * Idempotent: the unit is rewritten only while it does not list the backup
2387
+ * directory with the `-` prefix.
2388
+ */
2389
+ var migration0020BackupDir = {
2390
+ id: "0020__backup_dir",
2391
+ description: "Let the agent service write to the backup directory",
2392
+ run() {
2393
+ runBackupDirMigration();
2394
+ }
2395
+ };
2396
+ /** The migration body, with its effects injected so tests can drive it. */
2397
+ function runBackupDirMigration(io = nodeIo) {
2398
+ createBackupDir$1(io);
2399
+ if (!(io.fileExists(UNIT_PATH) && new RegExp(`^ReadWritePaths=.*\\s-/var/backups/trafic(\\s|$)`, "m").test(io.readFile(UNIT_PATH)))) createSystemdService(io);
2400
+ }
2401
+ //#endregion
1785
2402
  //#region src/setup/migrations/index.ts
1786
2403
  /** Path to the persisted migration state file */
1787
2404
  var MIGRATIONS_STATE_FILE = "/etc/trafic/.migrations.json";
@@ -1805,7 +2422,8 @@ var ALL_MIGRATIONS = [
1805
2422
  migration0016WildcardDnsChallenge,
1806
2423
  migration0018DockerAddressPools,
1807
2424
  migration0017DdevAptCloudsmith,
1808
- migration0019RecoverOrphanContainers
2425
+ migration0019RecoverOrphanContainers,
2426
+ migration0020BackupDir
1809
2427
  ];
1810
2428
  /**
1811
2429
  * Load the current migration state from disk.
@@ -1961,6 +2579,6 @@ async function audit() {
1961
2579
  if (checks.filter((c) => c.status === "fail").length > 0) process.exit(1);
1962
2580
  }
1963
2581
  //#endregion
1964
- export { printAuditResults as a, stopIdleProjects as c, buildDockerFirewallScript as d, buildDockerFirewallUnit as f, runPendingMigrations as i, DOCKER_FIREWALL_SCRIPT as l, setup as n, runAudit as o, configureDockerFirewall as p, listMigrations as r, startIdleScheduler as s, audit as t, DOCKER_FIREWALL_UNIT as u };
2582
+ export { configureDockerFirewall as C, buildDockerFirewallUnit as S, startIdleScheduler as _, printAuditResults as a, DOCKER_FIREWALL_UNIT as b, destroyScheduledProjects as c, destroyProject as d, loadDestroyConfig as f, startBackupSweepScheduler as g, removeOldBackups as h, runPendingMigrations as i, scheduleProjectDestroy as l, backupProject as m, setup as n, runAudit as o, openDestroyDb as p, listMigrations as r, startAccessLogSweepScheduler as s, audit as t, startScheduledDestroyScheduler as u, stopIdleProjects as v, buildDockerFirewallScript as x, DOCKER_FIREWALL_SCRIPT as y };
1965
2583
 
1966
- //# sourceMappingURL=setup-DWgb6dAD.js.map
2584
+ //# sourceMappingURL=setup-CiWIirVb.js.map