@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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup-CiWIirVb.js","names":[],"sources":["../src/setup/docker-firewall.ts","../src/tasks/stop-idle.ts","../src/setup/agent.ts","../src/tasks/backup.ts","../src/tasks/destroy-project.ts","../src/tasks/destroy-scheduled.ts","../src/tasks/access-logs.ts","../src/setup/user.ts","../src/setup/docker.ts","../src/setup/hardening.ts","../src/setup/audit.ts","../src/setup/migrations/0001__ddev_apt_repo.ts","../src/setup/migrations/0002__mkcert_ddev_user.ts","../src/setup/migrations/0003__ddev_router_bind_all_interfaces.ts","../src/setup/migrations/0004__ddev_hostname_sudoers.ts","../src/setup/migrations/0005__traefik_gateway_ip.ts","../src/setup/migrations/0006__traefik_gateway_ip_fix.ts","../src/setup/migrations/0007__ufw_docker_trafic_port.ts","../src/setup/migrations/0008__traefik_entrypoint_middleware.ts","../src/setup/migrations/0009__scale_to_zero_fixes.ts","../src/setup/migrations/0010__catchall_entrypoints.ts","../src/setup/migrations/0011__purge_acme_when_disabled.ts","../src/setup/migrations/0012__docker_user_tool_ports.ts","../src/setup/migrations/0013__ssh_kex_defaults.ts","../src/setup/migrations/0014__unattended_upgrades_docker_origin.ts","../src/setup/migrations/0015__catchall_priority.ts","../src/setup/migrations/0016__wildcard_dns_challenge.ts","../src/setup/migrations/0017__ddev_apt_cloudsmith.ts","../src/setup/migrations/0018__docker_address_pools.ts","../src/setup/migrations/0019__recover_orphan_containers.ts","../src/setup/migrations/0020__backup_dir.ts","../src/setup/migrations/index.ts","../src/setup/index.ts"],"sourcesContent":["import { nodeIo, type SetupIo } from \"./io.js\";\nimport { step, success, info } from \"./steps.js\";\n\n/** Where the rule script lives, so systemd and a human can both run it. */\nexport const DOCKER_FIREWALL_SCRIPT = \"/usr/local/sbin/trafic-docker-firewall\";\n\n/** The unit that reapplies the rules after Docker starts. */\nexport const DOCKER_FIREWALL_UNIT =\n \"/etc/systemd/system/trafic-docker-firewall.service\";\n\n/**\n * Docker's own bridge ranges, exempted so containers and the host gateway can\n * still reach a published port. Containers normally talk to each other by\n * container address rather than through the published port, so this is a\n * safety valve rather than a requirement.\n */\nconst DOCKER_IPV4_NETS = \"172.16.0.0/12\";\nconst DOCKER_IPV6_NETS = \"fd00::/8\";\n\n/**\n * Build the script that restricts the ports ddev-router publishes for DDEV's\n * tools (Mailpit, xhgui) to Docker's own networks.\n *\n * UFW cannot express this. Docker puts its DNAT in `nat/PREROUTING` and its\n * filtering in the `DOCKER` chain of `FORWARD`, both evaluated before UFW's\n * chains, so `ufw default deny incoming` never sees these packets.\n * `DOCKER-USER` is the hook Docker provides for exactly this case: it is\n * jumped to first in `FORWARD` and Docker never flushes it.\n *\n * A network-edge firewall is not a substitute. Measured on an OVH server with\n * its Edge Firewall enabled and a correct deny-all rule: a published tool port\n * still accepted a connection from another host inside OVH in 114ms, while a\n * port with nothing listening was dropped and timed out at 12s. Anyone who can\n * rent a VM from the same provider is inside that blind spot. These rules\n * apply to every packet regardless of where it came from.\n *\n * The match is on the *original* destination port. By the time a packet\n * reaches `FORWARD`, DNAT has rewritten the destination to the container, so\n * `--dport` would be matching the container's port — the same number today,\n * but not if a published port and a container port ever differ.\n */\nexport function buildDockerFirewallScript(toolPorts: string[]): string {\n return `#!/bin/sh\n# Trafic: restrict the ports ddev-router publishes for DDEV's tools.\n#\n# Generated by trafic-agent setup. Reapplied at boot by\n# trafic-docker-firewall.service, because Docker recreates DOCKER-USER empty\n# and nothing else restores what we put there.\n#\n# Why not UFW: Docker's DNAT sits in nat/PREROUTING and its filtering in the\n# DOCKER chain of FORWARD, both evaluated before UFW's chains, so\n# \"ufw default deny incoming\" never sees these packets. DOCKER-USER is the\n# hook Docker provides — jumped to first in FORWARD, never flushed by Docker.\n#\n# Why not only a provider firewall: measured on OVH with its Edge Firewall\n# enabled and denying these ports, a connection from another host inside OVH\n# still succeeded. A network-edge filter can have blind spots for traffic that\n# never crosses the edge. These rules do not.\n#\n# The match is on the ORIGINAL destination port: after DNAT the destination is\n# the container, so --dport would match the wrong side of the mapping.\nset -eu\n\nPORTS=\"${toolPorts.join(\" \")}\"\n\napply() {\n cmd=\"$1\"\n nets=\"$2\"\n\n command -v \"$cmd\" >/dev/null 2>&1 || return 0\n\n for port in $PORTS; do\n # Drop any previous copy first, so running this twice does not stack rules\n while \"$cmd\" -C DOCKER-USER -p tcp -m conntrack --ctorigdstport \"$port\" \\\\\n --ctdir ORIGINAL -j DROP 2>/dev/null; do\n \"$cmd\" -D DOCKER-USER -p tcp -m conntrack --ctorigdstport \"$port\" \\\\\n --ctdir ORIGINAL -j DROP\n done\n while \"$cmd\" -C DOCKER-USER -p tcp -s \"$nets\" -m conntrack \\\\\n --ctorigdstport \"$port\" --ctdir ORIGINAL -j RETURN 2>/dev/null; do\n \"$cmd\" -D DOCKER-USER -p tcp -s \"$nets\" -m conntrack \\\\\n --ctorigdstport \"$port\" --ctdir ORIGINAL -j RETURN\n done\n\n # Insert the DROP first, then the RETURN above it, so Docker's own\n # networks keep working and everything else is dropped\n \"$cmd\" -I DOCKER-USER -p tcp -m conntrack --ctorigdstport \"$port\" \\\\\n --ctdir ORIGINAL -j DROP\n \"$cmd\" -I DOCKER-USER -p tcp -s \"$nets\" -m conntrack \\\\\n --ctorigdstport \"$port\" --ctdir ORIGINAL -j RETURN\n done\n}\n\napply iptables ${DOCKER_IPV4_NETS}\napply ip6tables ${DOCKER_IPV6_NETS}\n`;\n}\n\n/** The systemd unit that reapplies the rules after Docker starts. */\nexport function buildDockerFirewallUnit(): string {\n return `[Unit]\nDescription=Trafic - restrict Docker-published DDEV tool ports\n# Docker recreates DOCKER-USER empty on start, so the rules have to go back\n# afterwards. PartOf means a docker restart reapplies them too.\nAfter=docker.service\nRequires=docker.service\nPartOf=docker.service\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${DOCKER_FIREWALL_SCRIPT}\n\n[Install]\nWantedBy=multi-user.target\n`;\n}\n\n/**\n * Install and enable the DOCKER-USER rules.\n */\nexport function configureDockerFirewall(\n toolPorts: string[],\n io: SetupIo = nodeIo,\n): void {\n step(\"Restrict Docker-published tool ports\");\n\n io.writeFile(DOCKER_FIREWALL_SCRIPT, buildDockerFirewallScript(toolPorts));\n io.exec(`chmod 0755 ${DOCKER_FIREWALL_SCRIPT}`, { silent: true });\n\n io.writeFile(DOCKER_FIREWALL_UNIT, buildDockerFirewallUnit());\n io.exec(\"systemctl daemon-reload\", { silent: true });\n io.exec(\"systemctl enable --now trafic-docker-firewall.service\", {\n silent: true,\n });\n\n success(`Tool ports restricted to Docker networks: ${toolPorts.join(\", \")}`);\n info(\"These live in DOCKER-USER, not UFW — `ufw status` will not show them\");\n}\n","import {\n deleteUnlistedProjects,\n getIdleProjects,\n releaseStaleStatuses,\n setProjectStatus,\n} from \"../utils/db.js\";\nimport {\n stopProject,\n getProjectInfo,\n loadProjectList,\n nodeDdevRunner,\n type DdevRunner,\n} from \"../utils/ddev.js\";\nimport { parseDuration } from \"../utils/config.js\";\nimport { loadProjectConfig, shouldNeverStop, getIdleTimeoutMs } from \"../utils/project-config.js\";\nimport { ddevRunningLimitDeps, enforceRunningLimit } from \"./running-limit.js\";\nimport type { AgentConfig } from \"../types.js\";\n\n/**\n * Stop idle projects that haven't been accessed recently\n * A project's idle_timeout in .ddev/config.trafic.yaml replaces the global\n * one, shorter or longer\n *\n * Only projects with the status \"running\" are idle: a project the agent is\n * starting or removing is never stopped here. A start or a removal whose\n * process was killed is set back to \"running\" first, so its project is not\n * left up for good. Before that, the records of projects that DDEV no longer\n * lists are deleted, so the sweep does not ask DDEV about them.\n *\n * Then brings the server back under max_running_projects: a deploy runs\n * `ddev start` without asking the agent, so only the sweep sees it.\n */\nexport async function stopIdleProjects(\n config: AgentConfig,\n run: DdevRunner = nodeDdevRunner,\n): Promise<void> {\n const globalThresholdMs = parseDuration(config.idleTimeout);\n\n // Get all projects to check their individual configs\n const projects = loadProjectList(config.projectListPath);\n\n for (const name of deleteUnlistedProjects(new Set(projects.keys()))) {\n console.log(`Removed ${name} from the agent database: DDEV no longer lists it`);\n }\n\n for (const name of releaseStaleStatuses()) {\n console.log(`Set ${name} back to running: the process that started or removed it is gone`);\n }\n\n // Every running project: its own idle_timeout can be shorter than the\n // global one, so the global one cannot pick the candidates\n for (const project of getIdleProjects(0)) {\n const projectDir = projects.get(project.name);\n const projectConfig = projectDir ? loadProjectConfig(projectDir) : {};\n\n if (shouldNeverStop(projectConfig)) {\n continue;\n }\n\n // The project's idle_timeout replaces the global one, shorter or longer\n const thresholdMs = getIdleTimeoutMs(projectConfig) ?? globalThresholdMs;\n\n if (Date.now() - project.lastAccess < thresholdMs) {\n continue;\n }\n\n // Double-check project is actually running via DDEV\n const info = await getProjectInfo(project.name, run);\n if (info?.status !== \"running\") {\n // Already stopped, just update our record\n setProjectStatus(project.name, \"stopped\");\n continue;\n }\n\n console.log(`Stopping idle project: ${project.name}`);\n const success = await stopProject(project.name, run);\n\n if (success) {\n setProjectStatus(project.name, \"stopped\");\n console.log(`Stopped: ${project.name}`);\n } else {\n console.error(`Failed to stop: ${project.name}`);\n }\n }\n\n await enforceRunningLimit(\n {\n max: config.maxRunningProjects,\n neverStops: (name) => {\n const projectDir = projects.get(name);\n return projectDir ? shouldNeverStop(loadProjectConfig(projectDir)) : false;\n },\n },\n ddevRunningLimitDeps(run),\n );\n}\n\n/**\n * Start the idle check scheduler\n */\n/** An idle sweep that throws must not take the scheduler down with it. */\nfunction reportSweepError(error: unknown): void {\n console.error(\"Idle sweep failed:\", error);\n}\n\nexport function startIdleScheduler(config: AgentConfig): NodeJS.Timeout {\n const intervalMs = parseDuration(config.idleCheckInterval);\n\n console.log(\n `Idle scheduler started: checking every ${config.idleCheckInterval}, timeout ${config.idleTimeout}`,\n );\n\n // Run immediately on start\n void stopIdleProjects(config).catch(reportSweepError);\n\n // Then run on interval\n return setInterval(() => {\n void stopIdleProjects(config).catch(reportSweepError);\n }, intervalMs);\n}\n","import { nodeIo, type SetupIo } from \"./io.js\";\nimport { step, success, info } from \"./steps.js\";\n\ndeclare const __VERSION__: string;\n\n/** Node.js major version required by the agent */\nconst NODE_MAJOR = 24;\n\n/** Proxies in front of the agent when --trusted-proxy-hops is not given */\nexport const DEFAULT_TRUSTED_PROXY_HOPS = 1;\n\n/** NodeSource keyring path */\nconst NODESOURCE_KEYRING = \"/etc/apt/keyrings/nodesource.gpg\";\n\n/**\n * Add the NodeSource apt repository and GPG key.\n * Idempotent: safe to call even if the repository is already configured.\n *\n * nodejs.org points at NodeSource for apt installs. The Ubuntu 24.04\n * `nodejs` package is 18.x, far below what the agent needs.\n */\nexport function addNodeSourceAptRepo(io: SetupIo = nodeIo): void {\n io.exec(\"install -m 0755 -d /etc/apt/keyrings\", { silent: true });\n // Download and dearmor as separate commands: piping curl into gpg into tee\n // would report only tee's exit code and hide a failed key download, which\n // then surfaces as a confusing GPG error on the next apt-get update\n io.exec(\n \"curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/trafic-nodesource.key\",\n { silent: true },\n );\n io.exec(\n \"gpg --batch --yes --dearmor -o /tmp/trafic-nodesource.gpg /tmp/trafic-nodesource.key\",\n { silent: true },\n );\n io.exec(`install -m 0644 /tmp/trafic-nodesource.gpg ${NODESOURCE_KEYRING}`, {\n silent: true,\n });\n io.exec(\"rm -f /tmp/trafic-nodesource.key /tmp/trafic-nodesource.gpg\", {\n silent: true,\n });\n io.exec(\n `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`,\n { silent: true },\n );\n}\n\n/**\n * Install Node.js from the NodeSource apt repository.\n *\n * apt keeps node and npm in /usr/bin — on root's PATH, so `which\n * trafic-agent` resolves for the systemd unit — and unattended-upgrades\n * patches them, which a version manager install never gets.\n */\nexport function installNode(io: SetupIo = nodeIo): void {\n step(\"Install Node.js\");\n\n // Check if Node is already installed\n if (io.commandExists(\"node\")) {\n const version = io.exec(\"node --version\", { silent: true });\n info(`Node.js already installed: ${version?.trim()}`);\n return;\n }\n\n info(\"Adding NodeSource apt repository...\");\n addNodeSourceAptRepo(io);\n\n info(\"Installing Node.js...\");\n io.exec(\"apt-get update -qq\", { silent: true });\n io.exec(\n \"DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y nodejs\",\n { silent: true },\n );\n\n success(`Node.js ${NODE_MAJOR} installed from apt`);\n}\n\n/**\n * Install the Trafic agent globally, at the version that runs this setup.\n *\n * `trafic setup --agent-version` installs that version, then runs this\n * setup: an install with no version would replace it by the latest one.\n */\nexport function installAgent(io: SetupIo = nodeIo): void {\n step(\"Install Trafic agent\");\n\n io.exec(`npm install -g @studiometa/trafic-agent@${__VERSION__}`);\n\n success(`Trafic agent ${__VERSION__} installed`);\n}\n\n/**\n * Create the agent configuration\n */\nexport function createAgentConfig(\n options: {\n tld: string;\n trustedProxyHops?: number;\n dnsProvider?: string;\n dnsEnv?: Record<string, string>;\n },\n io: SetupIo = nodeIo,\n): void {\n const {\n tld,\n trustedProxyHops = DEFAULT_TRUSTED_PROXY_HOPS,\n dnsProvider,\n dnsEnv = {},\n } = options;\n step(\"Create agent configuration\");\n\n // Create directories. The unit lists the state directory in\n // ReadWritePaths, and systemd refuses to start it when it is missing. The\n // ddev user cannot create the backup directory in /var/backups itself.\n io.exec(\"mkdir -p /etc/trafic\");\n io.exec(\"mkdir -p /var/lib/trafic\");\n io.exec(\"chown ddev:ddev /var/lib/trafic\");\n createBackupDir(io);\n\n const configPath = \"/etc/trafic/config.toml\";\n\n const tlsSection = buildTlsSection(dnsProvider, dnsEnv);\n\n if (io.fileExists(configPath)) {\n info(\"Configuration already exists at /etc/trafic/config.toml — skipping\");\n info(\"Edit this file to configure authentication\");\n\n if (tlsSection) {\n // Never rewrite an existing config — print what to add instead, or the\n // DNS credentials would silently go nowhere. The values are masked:\n // this output goes to a terminal and to the CI job log of whoever ran\n // `trafic setup`, and a provider token does not belong in either.\n info(\"Add this to /etc/trafic/config.toml, then run `trafic-agent upgrade`:\");\n for (const line of buildTlsSection(dnsProvider, dnsEnv, true).trim().split(\"\\n\")) {\n info(` ${line}`);\n }\n info(\"Replace each REPLACE_ME with the value you passed to --dns-env.\");\n }\n\n return;\n }\n\n const config = `# Trafic agent configuration\n# Generated by trafic setup\n\n# Required: TLD for DDEV projects\ntld = \"${tld}\"\n\n# Agent HTTP server port\nport = 9876\n\n# Database and paths\ndb_path = \"/var/lib/trafic/db.sqlite\"\nproject_list_path = \"/home/ddev/.ddev/project_list.yaml\"\nprojects_dir = \"/home/ddev/www\"\n\n# Scale-to-zero settings\nidle_timeout = \"4h\"\nidle_check_interval = \"5m\"\n\n# Authentication\n[auth]\ndefault_policy = \"basic\"\nallowed_ips = []\ntokens = []\nbasic_auth = []\nrules = []\n\n# Proxies in front of the agent, counted so the client address can be read\n# from X-Forwarded-For. 1 is ddev-router/Traefik alone. Raise it by one for\n# every extra proxy — a CDN or load balancer ahead of Traefik makes it 2.\n# Too low and allowed_ips matches a proxy instead of the client; too high and\n# it can be spoofed by a client-supplied header.\ntrusted_proxy_hops = ${trustedProxyHops}\n${tlsSection}`;\n\n io.writeFile(configPath, config);\n io.exec(\"chmod 640 /etc/trafic/config.toml\");\n io.exec(\"chown root:ddev /etc/trafic/config.toml\");\n\n success(\"Configuration created at /etc/trafic/config.toml\");\n info(\"Edit this file to configure authentication\");\n}\n\n/**\n * Build the [tls] section of the generated config.\n *\n * Empty without a DNS provider: per-host certificates stay the default, and\n * an empty section would only invite a half-filled one.\n */\nfunction buildTlsSection(\n dnsProvider: string | undefined,\n dnsEnv: Record<string, string>,\n redact = false,\n): string {\n if (!dnsProvider) {\n return \"\";\n }\n\n const env = Object.entries(dnsEnv)\n .map(([key, value]) => `${key} = \"${redact ? \"REPLACE_ME\" : escapeTomlString(value)}\"`)\n .join(\"\\n\");\n\n return `\n# Wildcard certificate (DNS-01). One *.<tld> certificate replaces the\n# per-hostname ones, so previews stop consuming the Let's Encrypt quota.\n[tls]\ndns_provider = \"${escapeTomlString(dnsProvider)}\"\n# ca_server = \"https://acme-staging-v02.api.letsencrypt.org/directory\"\n\n# Credentials the lego provider reads. Passed to the ddev-router container.\n[tls.dns_env]\n${env}\n`;\n}\n\n/** Escape a value for a basic (double-quoted) TOML string. */\nfunction escapeTomlString(value: string): string {\n return value\n .replace(/\\\\/g, \"\\\\\\\\\")\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, \"\\\\n\")\n .replace(/\\r/g, \"\\\\r\")\n .replace(/\\t/g, \"\\\\t\");\n}\n\n/** Default backup directory, `[backup] dir` in the agent config. */\nexport const BACKUP_DIR = \"/var/backups/trafic\";\n\n/**\n * Create the backup directory, writable by the ddev user only: it holds\n * database dumps.\n */\nexport function createBackupDir(io: SetupIo = nodeIo): void {\n io.exec(`mkdir -p ${BACKUP_DIR}`);\n io.exec(`chown ddev:ddev ${BACKUP_DIR}`);\n io.exec(`chmod 700 ${BACKUP_DIR}`);\n}\n\n/**\n * Create systemd service for the agent\n */\nexport function createSystemdService(io: SetupIo = nodeIo): void {\n step(\"Create systemd service\");\n\n // Find the trafic-agent binary path\n const agentPath = io.exec(\"which trafic-agent\", { silent: true })?.trim() || \"/usr/bin/trafic-agent\";\n\n const serviceContent = `[Unit]\nDescription=Trafic Agent - DDEV preview environment manager\nAfter=network.target docker.service\nRequires=docker.service\n\n[Service]\nType=simple\nUser=ddev\nGroup=ddev\nExecStart=${agentPath} start --config /etc/trafic/config.toml\nRestart=always\nRestartSec=5\nStandardOutput=journal\nStandardError=journal\nSyslogIdentifier=trafic-agent\n\n# Environment\nEnvironment=NODE_ENV=production\n# Skip ddev-hostname /etc/hosts management — on a server, hostnames resolve\n# to the public IP (not localhost), which would trigger ddev-hostname on every\n# ddev start. DDEV_NONINTERACTIVE=true skips that entirely.\nEnvironment=DDEV_NONINTERACTIVE=true\n\n# Security hardening\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=read-only\n# ddev start writes across its own home: the project directory\n# (.ddev/.webimageBuild), buildx state in .docker, mutagen data. Listing\n# individual paths is whack-a-mole across DDEV versions, so grant the home\n# and leave the rest of /home read-only. The backup sweep removes old\n# backups from ${BACKUP_DIR}; with \"-\", a missing backup directory does not\n# stop the service from starting.\nReadWritePaths=/var/lib/trafic /home/ddev -${BACKUP_DIR}\nPrivateTmp=true\n\n[Install]\nWantedBy=multi-user.target\n`;\n\n io.writeFile(\"/etc/systemd/system/trafic-agent.service\", serviceContent);\n io.exec(\"chmod 644 /etc/systemd/system/trafic-agent.service\");\n\n // Reload systemd and enable service\n io.exec(\"systemctl daemon-reload\");\n io.exec(\"systemctl enable trafic-agent\");\n io.exec(\"systemctl start trafic-agent\");\n\n success(\"Trafic agent service started\");\n info(\"View logs: journalctl -u trafic-agent -f\");\n}\n","import { execFile } from \"node:child_process\";\nimport { existsSync, mkdirSync, readdirSync, rmdirSync, rmSync, statSync } from \"node:fs\";\nimport { join, posix } from \"node:path\";\nimport { promisify } from \"node:util\";\nimport {\n getProjectInfo,\n nodeDdevRunner,\n startProject,\n stopProject,\n type DdevRunner,\n} from \"../utils/ddev.js\";\nimport { BACKUP_DIR } from \"../setup/agent.js\";\nimport type { AgentConfig, BackupConfig } from \"../types.js\";\n\nconst execFileAsync = promisify(execFile);\n\n/** Where DDEV mounts the project in the web container. */\nconst CONTAINER_APPROOT = \"/var/www/html\";\n\n/**\n * Default time limit of a backup, the database dump and the files archive\n * together: a large database takes minutes, and upload directories can hold\n * gigabytes.\n */\nexport const BACKUP_TIMEOUT_MS = 40 * 60 * 1000;\n\n/** Reading the upload directories is quick. */\nconst UPLOAD_DIRS_TIMEOUT_MS = 60_000;\n\n/** Retention is counted in days, so an hourly sweep is enough. */\nconst SWEEP_INTERVAL_MS = 60 * 60 * 1000;\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\n/** A backup directory name: an ISO date in UTC, with `-` for `:`. */\nconst TIMESTAMP = /^(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})Z$/;\n\n/** The name of a backup directory, e.g. `2026-10-14T07-40-24Z`. */\nexport function backupTimestamp(date: Date): string {\n return `${date.toISOString().slice(0, 19).replace(/:/g, \"-\")}Z`;\n}\n\n/** The time of a backup directory name, or undefined for any other name. */\nfunction parseBackupTimestamp(name: string): number | undefined {\n const match = TIMESTAMP.exec(name);\n\n if (!match) {\n return undefined;\n }\n\n const [, day, hours, minutes, seconds] = match;\n const time = Date.parse(`${day}T${hours}:${minutes}:${seconds}Z`);\n\n return Number.isNaN(time) ? undefined : time;\n}\n\n/** Whether a path is an existing directory. */\nfunction isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n return false;\n }\n}\n\n/**\n * The upload directories of a project, relative to its directory.\n *\n * DDEV resolves them: `upload_dirs` from the project config, or the default\n * of the project type (`wp-content/uploads` for WordPress, for instance).\n * Neither `ddev describe` nor `ddev utility configyaml` shows the type\n * default, but DDEV gives the result to the web container as\n * `DDEV_FILES_DIRS`, comma-separated container paths. The project must be\n * running.\n *\n * A directory that does not exist on the host is left out.\n */\nasync function findUploadDirs(dir: string, timeoutMs: number, run: DdevRunner): Promise<string[]> {\n const { ok, stdout } = await run([\"exec\", \"printenv\", \"DDEV_FILES_DIRS\"], timeoutMs, dir);\n\n if (!ok) {\n throw new Error(\"Could not read the upload directories (DDEV_FILES_DIRS)\");\n }\n\n return stdout\n .trim()\n .split(\",\")\n .filter(Boolean)\n .map((path) => posix.relative(CONTAINER_APPROOT, path))\n .filter((path) => path && !path.startsWith(\"..\") && !posix.isAbsolute(path))\n .filter((path) => isDirectory(join(dir, path)));\n}\n\n/**\n * Create the backup directory of one backup (see createBackupDir).\n *\n * The agent service runs in a systemd sandbox (`ProtectSystem=strict`) that\n * can write to a few paths only. A `[backup] dir` outside of them is\n * read-only for the service, so every scheduled backup fails and the\n * project is never removed: say so in the error, which the sweep logs.\n */\nfunction createBackupTarget(target: string, backupDir: string, name: string): void {\n try {\n mkdirSync(join(backupDir, name), { recursive: true });\n createBackupDir(target, name);\n } catch (error) {\n const { code } = error as NodeJS.ErrnoException;\n\n if (code !== \"EROFS\" && code !== \"EACCES\") {\n throw error;\n }\n\n throw new Error(\n `Cannot write to the backup directory ${backupDir} (${code}). ` +\n \"The agent service runs in a systemd sandbox (ProtectSystem=strict) and can only write to \" +\n `/var/lib/trafic, /home/ddev and ${BACKUP_DIR}: set [backup] dir inside one of them, ` +\n \"writable by the ddev user\",\n { cause: error },\n );\n }\n}\n\n/** Options of a backup. */\nexport interface BackupOptions {\n /**\n * Time limit of the backup: the database dump, the reading of the upload\n * directories and the files archive together (default:\n * BACKUP_TIMEOUT_MS). Starting and stopping the project are not part of\n * it.\n */\n timeoutMs?: number;\n}\n\n/** The time left before the deadline of the backup. */\nfunction timeLeft(deadline: number): number {\n const left = deadline - Date.now();\n\n if (left <= 0) {\n throw new Error(\"No time is left for the next step\");\n }\n\n return left;\n}\n\n/** A duration in the largest whole unit: 1h, 40m or 90s. */\nfunction formatDuration(ms: number): string {\n if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`;\n if (ms % 60_000 === 0) return `${ms / 60_000}m`;\n return `${Math.ceil(ms / 1000)}s`;\n}\n\n/**\n * The error of a backup that went over its time limit. The step that ran\n * out of time only fails (ddev reports a failed command, tar is killed), so\n * this says it was the time limit, and how to raise it.\n */\nfunction timeoutError(timeoutMs: number, cause: unknown): Error {\n return new Error(\n `The backup timed out: it took longer than its time limit of ${formatDuration(timeoutMs)}. ` +\n \"Raise the limit with --timeout\",\n { cause },\n );\n}\n\n/**\n * Write the database dump and the upload archive of a running project.\n * Each step gets the time left before `deadline`.\n */\nasync function writeBackup(\n hasDatabase: boolean,\n dir: string,\n target: string,\n deadline: number,\n run: DdevRunner,\n): Promise<void> {\n if (hasDatabase) {\n const file = join(target, \"db.sql.gz\");\n console.log(`Exporting the database to ${file}`);\n const { ok } = await run([\"export-db\", `--file=${file}`], timeLeft(deadline), dir);\n\n if (!ok) {\n throw new Error(\"Could not export the database\");\n }\n } else {\n console.log(\"No database container — skipping the database dump\");\n }\n\n const uploadDirs = await findUploadDirs(\n dir,\n Math.min(UPLOAD_DIRS_TIMEOUT_MS, timeLeft(deadline)),\n run,\n );\n\n if (uploadDirs.length === 0) {\n console.log(\"No upload directory — skipping the files archive\");\n return;\n }\n\n const file = join(target, \"files.tar.gz\");\n console.log(`Archiving ${uploadDirs.join(\", \")} to ${file}`);\n await archive(file, dir, uploadDirs, timeLeft(deadline));\n}\n\n/**\n * Archive paths of a directory to a tar.gz file.\n *\n * The project runs during the archive, and can write to its upload\n * directories at any time. GNU tar then exits with code 1 (\"some files\n * differ\"): the archive is written, with the changed file as tar read it.\n * That is accepted with a warning. Code 2 or more (a fatal error, such as an\n * unreadable file) and a timeout fail the archive.\n */\nasync function archive(file: string, dir: string, paths: string[], timeoutMs: number): Promise<void> {\n try {\n await execFileAsync(\"tar\", [\"-czf\", file, \"-C\", dir, \"--\", ...paths], { timeout: timeoutMs });\n } catch (error) {\n const { code, killed } = error as { code?: unknown; killed?: boolean };\n\n if (code !== 1 || killed) {\n throw error;\n }\n\n console.warn(\"Some files changed while tar read them: the archive has them as tar read them\");\n }\n}\n\n/**\n * Create the directory of one backup. It must not exist: two backups of a\n * project in the same second would share it, and the one that fails would\n * remove the files of the other. The second one fails here, before it\n * changes anything.\n */\nfunction createBackupDir(target: string, name: string): void {\n try {\n mkdirSync(target);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"EEXIST\") {\n throw new Error(`${target} already exists: another backup of ${name} runs or ran in the same second`);\n }\n\n throw error;\n }\n}\n\n/**\n * Back up a DDEV project to `<backupDir>/<name>/<timestamp>`.\n *\n * The backup holds:\n * - `db.sql.gz`: the `ddev export-db` dump, unless the project omits the db\n * container\n * - `files.tar.gz`: the upload directories, with paths relative to the\n * project directory, unless the project has none\n *\n * Both need the project running, so a stopped project is started, then\n * stopped again. A failed backup, or a backup that takes longer than its\n * time limit (`timeoutMs`), throws and leaves no backup directory. Returns\n * the backup directory.\n */\nexport async function backupProject(\n name: string,\n dir: string,\n backupDir: string,\n options: BackupOptions = {},\n run: DdevRunner = nodeDdevRunner,\n): Promise<string> {\n const info = await getProjectInfo(name, run);\n\n if (!info) {\n throw new Error(`Could not describe the DDEV project ${name}`);\n }\n\n const target = join(backupDir, name, backupTimestamp(new Date()));\n const wasRunning = info.status === \"running\";\n\n createBackupTarget(target, backupDir, name);\n\n try {\n if (!wasRunning) {\n console.log(`Starting ${name} for the backup`);\n\n if (!(await startProject(name, run))) {\n throw new Error(`Could not start ${name} for the backup`);\n }\n }\n\n // One time limit for all the steps, so the caller knows how long the\n // backup can take\n const timeoutMs = options.timeoutMs ?? BACKUP_TIMEOUT_MS;\n const deadline = Date.now() + timeoutMs;\n\n try {\n await writeBackup(info.dbType !== undefined, dir, target, deadline, run);\n } catch (error) {\n throw Date.now() >= deadline ? timeoutError(timeoutMs, error) : error;\n } finally {\n if (!wasRunning && !(await stopProject(name, run))) {\n console.warn(`Could not stop ${name} after the backup`);\n }\n }\n } catch (error) {\n rmSync(target, { recursive: true, force: true });\n throw error;\n }\n\n console.log(`Backup saved to ${target}`);\n return target;\n}\n\n/** The subdirectories of a directory, or none when it does not exist. */\nfunction listDirs(path: string): string[] {\n if (!existsSync(path)) {\n return [];\n }\n\n return readdirSync(path, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => entry.name);\n}\n\n/**\n * Remove the backups older than `retainDays`, and the project directories\n * that this removal leaves empty. Only directories with a backup timestamp\n * name are removed, and a project directory only when this sweep removed a\n * backup from it: `dir` can be a shared directory, with empty directories\n * that are not backups. Returns the number of backups removed.\n */\nexport function removeOldBackups(config: BackupConfig, now = Date.now()): number {\n const cutoff = now - config.retainDays * DAY_MS;\n let removed = 0;\n\n for (const project of listDirs(config.dir)) {\n const projectDir = join(config.dir, project);\n let removedFromProject = 0;\n\n for (const name of listDirs(projectDir)) {\n const time = parseBackupTimestamp(name);\n\n if (time !== undefined && time < cutoff) {\n rmSync(join(projectDir, name), { recursive: true, force: true });\n console.log(`Removed old backup: ${project}/${name}`);\n removedFromProject++;\n }\n }\n\n removed += removedFromProject;\n\n if (removedFromProject === 0) {\n continue;\n }\n\n try {\n if (readdirSync(projectDir).length === 0) {\n rmdirSync(projectDir);\n }\n } catch {\n // A backup started meanwhile: the directory is in use again\n }\n }\n\n return removed;\n}\n\n/** A sweep that throws must not take the scheduler down with it. */\nfunction sweepBackups(config: BackupConfig): void {\n try {\n removeOldBackups(config);\n } catch (error) {\n console.error(\"Backup sweep failed:\", error);\n }\n}\n\n/**\n * Start the backup retention sweep\n */\nexport function startBackupSweepScheduler(config: AgentConfig): NodeJS.Timeout {\n console.log(\n `Backup sweep started: removing backups older than ${config.backup.retainDays} days from ${config.backup.dir}`,\n );\n\n // Run immediately on start\n sweepBackups(config.backup);\n\n // Then run on interval\n return setInterval(() => sweepBackups(config.backup), SWEEP_INTERVAL_MS);\n}\n","import { existsSync, lstatSync, realpathSync, rmSync, statSync } from \"node:fs\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { backupProject, type BackupOptions } from \"./backup.js\";\nimport { deleteProject, getProject, initDb, setProjectStatus } from \"../utils/db.js\";\nimport { loadProjectList, nodeDdevRunner, type DdevRunner } from \"../utils/ddev.js\";\nimport { loadConfig } from \"../utils/config.js\";\nimport { isInside } from \"../utils/paths.js\";\nimport type { AgentConfig } from \"../types.js\";\n\n/** Removing containers and volumes is slower than a stop. */\nconst DELETE_TIMEOUT_MS = 5 * 60 * 1000;\n\n/**\n * What to do when `trafic-agent destroy` cannot read the config or open the\n * database. Only the ddev user can do both: the config is readable by root\n * and the ddev group only (640 root:ddev), and the database directory\n * belongs to ddev. `trafic destroy` connects as ddev by default.\n */\nconst DDEV_USER_HINT = \"connect as the ddev user\";\n\n/** SQLite result codes of a database that the user cannot open or write. */\nconst SQLITE_ACCESS_ERRORS = new Set([3 /* PERM */, 8 /* READONLY */, 14 /* CANTOPEN */]);\n\n/**\n * Read the agent config for `trafic-agent destroy`. A permission error says\n * which user to connect as.\n */\nexport function loadDestroyConfig(configPath?: string): AgentConfig {\n try {\n return loadConfig(configPath);\n } catch (error) {\n const { code, path } = error as NodeJS.ErrnoException;\n\n if (code !== \"EACCES\" && code !== \"EPERM\") {\n throw error;\n }\n\n throw new Error(`cannot read ${path ?? configPath} (${code}): ${DDEV_USER_HINT}`, { cause: error });\n }\n}\n\n/**\n * Open the agent database for `trafic-agent destroy`. A permission error\n * says which user to connect as.\n */\nexport function openDestroyDb(dbPath: string): void {\n try {\n initDb(dbPath);\n } catch (error) {\n // The extended result code holds the primary one in its low byte\n const { errcode } = error as { errcode?: number };\n\n if (errcode === undefined || !SQLITE_ACCESS_ERRORS.has(errcode & 0xff)) {\n throw error;\n }\n\n throw new Error(`cannot open the database ${dbPath} (${(error as Error).message}): ${DDEV_USER_HINT}`, {\n cause: error,\n });\n }\n}\n\n/**\n * Find the directory of a project, and refuse one outside the projects\n * directory.\n *\n * DDEV's project list gives the real app root. A project DDEV has already\n * forgotten is looked for at `<projects_dir>/<name>`, where `trafic deploy`\n * puts it, so its directory can still be cleaned up.\n *\n * The check stands between a name typed on a command line and an\n * `rm -rf`: an empty name, `..` or an app root of `/` must never reach it.\n * The path is checked as written, then, when it exists, with its symbolic\n * links followed: a symlinked project directory or parent directory could\n * otherwise point `ddev delete` and the removal at a directory outside the\n * projects directory.\n */\nexport function resolveProjectDir(name: string, config: AgentConfig): string {\n if (!name.trim() || name === \".\" || name === \"..\" || name.includes(\"/\")) {\n throw new Error(`Invalid project name: \"${name}\"`);\n }\n\n const projectsDir = resolve(config.projectsDir);\n const appRoot = loadProjectList(config.projectListPath).get(name);\n const dir = resolve(appRoot || join(projectsDir, name));\n\n if (!isInside(projectsDir, dir)) {\n throw new Error(\n `Refusing to remove ${dir}: it is not inside the projects directory ${projectsDir}. ` +\n \"Set projects_dir in the agent config to the directory that `trafic deploy` uses\",\n );\n }\n\n checkRealPath(dir, projectsDir);\n\n return dir;\n}\n\n/**\n * Refuse an existing project directory that is a symbolic link, or that a\n * symbolic link in its path takes outside the projects directory. A path\n * that does not exist is left to the caller: there is nothing to remove.\n */\nfunction checkRealPath(dir: string, projectsDir: string): void {\n let stats;\n\n try {\n stats = lstatSync(dir);\n } catch (error) {\n const { code } = error as NodeJS.ErrnoException;\n\n if (code === \"ENOENT\" || code === \"ENOTDIR\") {\n return;\n }\n\n throw error;\n }\n\n if (stats.isSymbolicLink()) {\n throw new Error(`Refusing to remove ${dir}: it is a symbolic link`);\n }\n\n const realDir = realpathSync(dir);\n const realProjectsDir = realpathSync(projectsDir);\n\n if (!isInside(realProjectsDir, realDir)) {\n throw new Error(\n `Refusing to remove ${dir}: it resolves to ${realDir}, ` +\n `which is not inside the projects directory ${realProjectsDir}`,\n );\n }\n}\n\n/**\n * Refuse a backup directory that is the project directory or inside it: the\n * removal would delete the backup right after it is written. validateConfig\n * already refuses a backup.dir in projects_dir; this also follows symbolic\n * links. It checks `<backup.dir>/<name>`, where the backup goes, so a\n * symbolic link at that level is followed too.\n */\nfunction checkBackupDir(backupDir: string, name: string, dir: string): void {\n const target = resolve(backupDir, name);\n const realTarget = realpathOfNearest(target);\n const realDir = realpathSync(dir);\n\n if (target === dir || isInside(dir, target) || realTarget === realDir || isInside(realDir, realTarget)) {\n throw new Error(\n `Refusing to back up to ${backupDir}: it is inside the project directory ${dir}, which the removal deletes`,\n );\n }\n}\n\n/**\n * The real path of a path that may not exist yet: the real path of its\n * nearest existing ancestor, plus the rest of the path. The backup creates\n * the missing directories through the symbolic links of that ancestor.\n */\nfunction realpathOfNearest(path: string): string {\n const rest: string[] = [];\n let current = path;\n\n while (!existsSync(current)) {\n const parent = dirname(current);\n\n if (parent === current) break;\n\n rest.unshift(basename(current));\n current = parent;\n }\n\n return join(realpathSync(current), ...rest);\n}\n\n/** Whether a path is an existing directory. */\nfunction isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n return false;\n }\n}\n\n/**\n * Remove a project from the server.\n *\n * Steps:\n * 1. With `backup`, back up the database and the upload directories, all\n * within `timeoutMs` when it is given\n * 2. Delete the DDEV project (containers, volumes, no snapshot)\n * 3. Remove the project directory\n * 4. Delete the project's records from the agent database\n *\n * A failed backup throws before anything is removed.\n *\n * A backup can take many minutes. With `beforeDelete`, the removal calls it\n * after the backup, just before step 2, and stops without removing anything\n * when it returns false: a scheduled removal that a deploy cancelled\n * meanwhile must not delete the new deploy.\n *\n * A failed `ddev delete` does not stop the removal: a project DDEV has\n * already forgotten could otherwise never be cleaned up, and the next deploy\n * would find a stale directory.\n *\n * While the removal runs, the project has the status \"destroying\", which the\n * idle sweep and max_running_projects never stop, and the waiting page never\n * starts: the backup can take many minutes, and a stop in the middle of it\n * fails the backup. The status is in the database, so the service sees it\n * when `trafic-agent destroy` runs from the command line. A removal that\n * fails sets the status back: \"running\" when it was, else \"stopped\". A\n * removal that is killed cannot: the status records the id of its process,\n * and the service sets it back to \"running\" once that process is gone (see\n * releaseStaleStatuses).\n *\n * The database must be open (`initDb`). Returns false when there was no\n * project directory or the removal was cancelled. Without a project\n * directory, the project's records are still deleted, so a retry after a run\n * that failed between steps 2 and 3 cleans them up. A cancelled removal\n * removes nothing and sets the status back.\n */\nexport async function destroyProject(\n name: string,\n config: AgentConfig,\n options: { backup?: boolean; beforeDelete?: () => boolean } & BackupOptions = {},\n run: DdevRunner = nodeDdevRunner,\n): Promise<boolean> {\n const dir = resolveProjectDir(name, config);\n\n if (!isDirectory(dir)) {\n console.warn(\n deleteProject(name)\n ? `Project directory ${dir} does not exist: removed ${name} from the agent database`\n : `Project directory ${dir} does not exist — nothing to destroy`,\n );\n return false;\n }\n\n if (options.backup) {\n checkBackupDir(config.backup.dir, name, dir);\n }\n\n const previous = getProject(name)?.status;\n setProjectStatus(name, \"destroying\");\n\n let removed: boolean;\n\n try {\n removed = await removeProject(name, dir, config, options, run);\n } catch (error) {\n setProjectStatus(name, previous === \"running\" ? \"running\" : \"stopped\");\n throw error;\n }\n\n if (!removed) {\n setProjectStatus(name, previous === \"running\" ? \"running\" : \"stopped\");\n }\n\n return removed;\n}\n\n/**\n * The steps of destroyProject, once the project is marked \"destroying\".\n * Returns false when the removal was cancelled after the backup.\n */\nasync function removeProject(\n name: string,\n dir: string,\n config: AgentConfig,\n options: { backup?: boolean; beforeDelete?: () => boolean } & BackupOptions,\n run: DdevRunner,\n): Promise<boolean> {\n if (options.backup) {\n console.log(`Backing up ${name}`);\n await backupProject(name, dir, config.backup.dir, { timeoutMs: options.timeoutMs }, run);\n }\n\n if (options.beforeDelete && !options.beforeDelete()) {\n console.log(`Removal of ${name} cancelled — nothing removed`);\n return false;\n }\n\n console.log(`Deleting DDEV project: ${name}`);\n const { ok } = await run([\"delete\", \"-Oy\"], DELETE_TIMEOUT_MS, dir);\n\n if (!ok) {\n console.warn(\"DDEV delete failed — project may not be a valid DDEV project\");\n }\n\n console.log(`Removing ${dir}`);\n rmSync(dir, { recursive: true, force: true });\n\n deleteProject(name);\n console.log(`Destroyed: ${name}`);\n return true;\n}\n","import { destroyProject, resolveProjectDir } from \"./destroy-project.js\";\nimport {\n getScheduledDestroy,\n getScheduledDestroys,\n scheduleDestroy,\n startScheduledDestroy,\n systemClock,\n type Clock,\n} from \"../utils/db.js\";\nimport { parseDuration } from \"../utils/config.js\";\n\n/** Hours, minutes and seconds, in this order: \"48h\", \"1h30m\", \"90s\". */\nconst DELAY_REGEX = /^(?:\\d+h)?(?:\\d+m)?(?:\\d+s)?$/;\nimport { nodeDdevRunner, type DdevRunner } from \"../utils/ddev.js\";\nimport type { AgentConfig } from \"../types.js\";\n\n/**\n * Schedule the removal of a project, `delay` from now (\"48h\", \"1h30m\").\n *\n * The date goes to the agent database, not to the project: a redeploy does\n * not reliably rewrite the project files, and the sweep must find the date\n * even for a project DDEV has forgotten. The name is checked now, so a bad\n * name or a wrong `projects_dir` fails the CI job, not a sweep two days\n * later. With `backup`, the removal backs the project up first, within\n * `timeoutMs` when it is given. A removal that the sweep already started\n * cannot be scheduled again. Returns the date of the removal.\n */\nexport function scheduleProjectDestroy(\n name: string,\n delay: string,\n config: AgentConfig,\n options: { backup?: boolean; timeoutMs?: number } = {},\n now: Clock = systemClock,\n): Date {\n // The whole string: parseDuration reads \"1h30\" as 1h and drops the rest\n const delayMs = DELAY_REGEX.test(delay) ? parseDuration(delay) : 0;\n\n if (delayMs <= 0) {\n throw new Error(`Invalid delay: \"${delay}\". Use hours, minutes or seconds, e.g. 48h or 1h30m`);\n }\n\n resolveProjectDir(name, config);\n\n if (getScheduledDestroy(name)?.removing) {\n throw new Error(`The removal of ${name} is in progress`);\n }\n\n const destroyAt = now() + delayMs;\n scheduleDestroy(name, destroyAt, options.backup ?? false, options.timeoutMs);\n\n return new Date(destroyAt);\n}\n\n/** A removal can take longer than the sweep interval; one sweep at a time. */\nlet sweeping = false;\n\n/**\n * Remove each project whose scheduled removal is due, through the same\n * removal as `trafic-agent destroy`, with a backup when one was asked for.\n *\n * A failure on one project does not stop the sweep: it is logged, and the\n * removal stays scheduled so the next sweep tries again. A project already\n * gone has its schedule cleared.\n *\n * A removal can take minutes, and a deploy or a `--cancel` can cancel the\n * removal of a project meanwhile. So the sweep reads each removal again just\n * before it starts, and once more after the backup. There, it marks the\n * removal as started (startScheduledDestroy) before `ddev delete`: a later\n * cancel is refused, so the deploy fails instead of deploying a project\n * that is about to go. A retry of a started removal does not back the\n * project up again: the backup exists, and DDEV may have forgotten the\n * project already.\n */\nexport async function destroyScheduledProjects(\n config: AgentConfig,\n now: Clock = systemClock,\n run: DdevRunner = nodeDdevRunner,\n): Promise<void> {\n if (sweeping) {\n return;\n }\n\n sweeping = true;\n\n try {\n for (const { name } of getScheduledDestroys(now())) {\n // Not the row read above: an earlier removal of this sweep took time\n const scheduled = getScheduledDestroy(name, now());\n\n if (!scheduled) {\n continue;\n }\n\n console.log(\n `Destroying ${name}: its scheduled removal is ${scheduled.removing ? \"in progress\" : \"due\"}`,\n );\n\n try {\n // destroyProject clears the schedule, also for a project already gone\n await destroyProject(\n name,\n config,\n {\n backup: scheduled.backup && !scheduled.removing,\n timeoutMs: scheduled.timeoutMs,\n beforeDelete: () => startScheduledDestroy(name, now()),\n },\n run,\n );\n } catch (error) {\n console.error(\n `Scheduled removal of ${name} failed, retrying on the next sweep: ${(error as Error).message}`,\n );\n }\n }\n } finally {\n sweeping = false;\n }\n}\n\n/** A sweep that throws must not take the scheduler down with it. */\nfunction reportSweepError(error: unknown): void {\n console.error(\"Scheduled removal sweep failed:\", error);\n}\n\n/**\n * Start the scheduled removal sweep, on the idle check interval\n */\nexport function startScheduledDestroyScheduler(config: AgentConfig): NodeJS.Timeout {\n const intervalMs = parseDuration(config.idleCheckInterval);\n\n console.log(`Scheduled removal sweep started: checking every ${config.idleCheckInterval}`);\n\n // Run immediately on start\n void destroyScheduledProjects(config).catch(reportSweepError);\n\n // Then run on interval\n return setInterval(() => {\n void destroyScheduledProjects(config).catch(reportSweepError);\n }, intervalMs);\n}\n","import { cleanOldLogs } from \"../utils/db.js\";\n\n/** Days the agent keeps its access logs. */\nexport const ACCESS_LOG_RETAIN_DAYS = 30;\n\n/** Retention is counted in days, so an hourly sweep is enough. */\nconst SWEEP_INTERVAL_MS = 60 * 60 * 1000;\n\n/** A sweep that throws must not take the scheduler down with it. */\nfunction sweepAccessLogs(): void {\n try {\n const removed = cleanOldLogs(ACCESS_LOG_RETAIN_DAYS);\n\n if (removed > 0) {\n console.log(`Removed ${removed} access logs older than ${ACCESS_LOG_RETAIN_DAYS} days`);\n }\n } catch (error) {\n console.error(\"Access log sweep failed:\", error);\n }\n}\n\n/**\n * Start the access log retention sweep. Each request logs a row, so without\n * it the database grows for good. The database must be open.\n */\nexport function startAccessLogSweepScheduler(): NodeJS.Timeout {\n console.log(`Access log sweep started: removing access logs older than ${ACCESS_LOG_RETAIN_DAYS} days`);\n\n // Run immediately on start\n sweepAccessLogs();\n\n // Then run on interval\n return setInterval(sweepAccessLogs, SWEEP_INTERVAL_MS);\n}\n","import { nodeIo, type SetupIo } from \"./io.js\";\nimport { step, success, info, warn } from \"./steps.js\";\n\n/**\n * Write /etc/sudoers.d/trafic-ddev allowing the ddev user to run\n * ddev-hostname without a password. Idempotent — safe to call multiple times.\n */\nexport function configureDdevSudoers(io: SetupIo = nodeIo): void {\n const sudoersContent = `# Trafic: allow ddev user to run ddev-hostname without a password.\n# ddev-hostname manages /etc/hosts and always calls sudo internally.\nddev ALL=(ALL) NOPASSWD: /usr/bin/ddev-hostname\n`;\n io.writeFile(\"/etc/sudoers.d/trafic-ddev\", sudoersContent);\n io.exec(\"chmod 440 /etc/sudoers.d/trafic-ddev\", { silent: true });\n success(\"Configured passwordless sudo for ddev-hostname\");\n}\n\n/**\n * Create the ddev user with proper permissions\n */\nexport function createDdevUser(io: SetupIo = nodeIo): void {\n step(\"Create ddev user\");\n\n // Check if user exists\n const userExists = io.execSilent(\"id -u ddev\");\n if (userExists) {\n info(\"User 'ddev' already exists\");\n } else {\n io.exec(\"useradd -m -s /bin/bash ddev\");\n success(\"Created user 'ddev'\");\n }\n\n // Add to docker group (will be created by Docker install)\n io.exec(\"usermod -aG docker ddev 2>/dev/null || true\", { silent: true });\n\n // Create www directory\n io.exec(\"mkdir -p /home/ddev/www\");\n io.exec(\"chown ddev:ddev /home/ddev/www\");\n io.exec(\"chmod 750 /home/ddev/www\");\n success(\"Created /home/ddev/www\");\n\n // Create .ssh directory if needed\n io.exec(\"mkdir -p /home/ddev/.ssh\");\n io.exec(\"chmod 700 /home/ddev/.ssh\");\n io.exec(\"chown ddev:ddev /home/ddev/.ssh\");\n\n // Allow ddev user to run ddev-hostname without a password.\n // ddev-hostname manages /etc/hosts entries and always calls sudo internally,\n // even when DDEV_NONINTERACTIVE=true. Without this rule, any ddev command\n // that touches hostname resolution (start, restart, poweroff + start) will\n // fail with \"sudo: a terminal is required to read the password\".\n configureDdevSudoers(io);\n success(\"User ddev configured\");\n}\n\n/**\n * Setup authorized_keys for ddev user\n */\nexport function setupAuthorizedKeys(\n publicKey?: string,\n io: SetupIo = nodeIo,\n): void {\n if (!publicKey) {\n warn(\"No SSH public key provided, skipping authorized_keys setup\");\n return;\n }\n\n step(\"Setup SSH authorized_keys\");\n\n const authKeysPath = \"/home/ddev/.ssh/authorized_keys\";\n io.exec(`touch ${authKeysPath}`);\n io.exec(`chmod 600 ${authKeysPath}`);\n io.exec(`chown ddev:ddev ${authKeysPath}`);\n\n // Check if key already exists\n const existing = io.execSilent(`cat ${authKeysPath}`);\n if (existing.includes(publicKey.trim())) {\n info(\"SSH key already authorized\");\n } else {\n io.exec(`echo '${publicKey}' >> ${authKeysPath}`);\n success(\"Added SSH public key\");\n }\n}\n","import { nodeIo, type SetupIo } from \"./io.js\";\nimport { step, success, info, warn } from \"./steps.js\";\nimport { findRunningProjects } from \"./ddev.js\";\n\n/**\n * Install Docker using the official script\n */\nexport function installDocker(io: SetupIo = nodeIo): void {\n step(\"Install Docker\");\n\n if (io.commandExists(\"docker\")) {\n const version = io.exec(\"docker --version\", { silent: true });\n info(`Docker already installed: ${version?.trim() ?? \"unknown version\"}`);\n return;\n }\n\n // Install Docker using official script\n info(\"Downloading and running Docker install script...\");\n io.exec(\"curl -fsSL https://get.docker.com -o /tmp/get-docker.sh\");\n io.exec(\"sh /tmp/get-docker.sh\", { silent: true });\n io.exec(\"rm /tmp/get-docker.sh\");\n\n // Enable and start Docker\n io.exec(\"systemctl enable docker\", { silent: true });\n io.exec(\"systemctl start docker\", { silent: true });\n\n success(\"Docker installed and started\");\n\n // Add ddev user to docker group\n io.exec(\"usermod -aG docker ddev\", { silent: true });\n success(\"Added ddev user to docker group\");\n}\n\n/** Docker daemon config file */\nexport const DAEMON_CONFIG = \"/etc/docker/daemon.json\";\n\n/**\n * Address pools for the networks Docker creates.\n *\n * Every DDEV project gets its own network. Docker's built-in pools hold only\n * about 31 of them (15 × /16 in 172.17–31.x, 16 × /20 in 192.168.x), so a\n * server with ~30 projects fails with \"all predefined address pools have\n * been fully subnetted\". /24 subnets in 172.16.0.0/12 give 4096 networks,\n * and keep every network inside the range the firewall rules already allow\n * (hardening.ts, docker-firewall.ts) — the built-in 192.168.x pools are not.\n */\nexport const DOCKER_ADDRESS_POOLS = [{ base: \"172.16.0.0/12\", size: 24 }];\n\n/**\n * Add the address pools to an existing daemon config.\n *\n * Returns true when the config was changed. Leaves a config that already\n * sets pools alone (an operator choice), and one it cannot parse.\n */\nexport function addDockerAddressPools(io: SetupIo = nodeIo): boolean {\n let config: Record<string, unknown> = {};\n\n if (io.fileExists(DAEMON_CONFIG)) {\n try {\n config = JSON.parse(io.readFile(DAEMON_CONFIG));\n } catch {\n warn(\"Could not parse existing Docker config, skipping\");\n return false;\n }\n }\n\n if (\"default-address-pools\" in config) {\n return false;\n }\n\n io.exec(\"mkdir -p /etc/docker\");\n io.writeFile(\n DAEMON_CONFIG,\n JSON.stringify({ ...config, \"default-address-pools\": DOCKER_ADDRESS_POOLS }, null, 2),\n );\n return true;\n}\n\n/**\n * Configure Docker for production use\n */\nexport function configureDocker(io: SetupIo = nodeIo): void {\n step(\"Configure Docker\");\n\n // Create daemon.json with production settings\n const daemonConfig = {\n \"log-driver\": \"json-file\",\n \"log-opts\": {\n \"max-size\": \"10m\",\n \"max-file\": \"3\",\n },\n \"storage-driver\": \"overlay2\",\n \"live-restore\": true,\n \"default-address-pools\": DOCKER_ADDRESS_POOLS,\n };\n\n io.exec(\"mkdir -p /etc/docker\");\n\n const configPath = DAEMON_CONFIG;\n\n // Check if config already exists\n if (io.fileExists(configPath)) {\n try {\n const existing = JSON.parse(io.readFile(configPath));\n // Merge with existing config\n const merged = { ...existing, ...daemonConfig };\n io.writeFile(configPath, JSON.stringify(merged, null, 2));\n info(\"Merged with existing Docker config\");\n } catch {\n warn(\"Could not parse existing Docker config, skipping\");\n return;\n }\n } else {\n io.writeFile(configPath, JSON.stringify(daemonConfig, null, 2));\n }\n\n // A reload applies neither default-address-pools nor storage-driver\n restartDocker(io);\n success(\"Docker configured with production settings\");\n}\n\n/**\n * Restart Docker to apply daemon.json, without orphaning running containers.\n *\n * `live-restore` keeps containers running across a restart, but only when\n * the storage driver stays the same. Before 0.1.47, setup applied daemon.json\n * with a reload, and a reload does not apply `storage-driver`. Docker 29\n * defaults a fresh install to the containerd image store (`overlayfs`), so\n * such a server runs `overlayfs` while daemon.json says `overlay2`. The first\n * full restart switches the driver, and dockerd silently skips every\n * container created with the other one. Their processes keep running and\n * keep the locks on the database volumes. DDEV then creates new containers\n * that exit at once on \"Can't lock aria control file\". Seen on a live server\n * after migration 0018: 66 containers lost, 27 of them databases.\n *\n * So when the driver is about to change, the DDEV projects are stopped first,\n * which shuts their databases down cleanly, and the projects that were running\n * are started again on the new driver. Volumes do not depend on the storage\n * driver, so no data moves. Images are pulled again for the new driver.\n */\nexport function restartDocker(io: SetupIo = nodeIo): void {\n if (!switchesStorageDriver(io)) {\n io.exec(\"systemctl restart docker\");\n return;\n }\n\n const running = findRunningProjects(io);\n\n warn(\"Docker changes its storage driver on restart, stopping DDEV projects first\");\n\n if (io.commandExists(\"ddev\")) {\n io.exec(\"su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff'\", { silent: true });\n }\n\n io.exec(\"systemctl restart docker\");\n\n if (running.length > 0) {\n info(`Starting ${running.length} project(s) again: ${running.join(\", \")}`);\n io.exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${running.join(\" \")}'`, {\n silent: true,\n });\n }\n}\n\n/**\n * Whether a restart makes Docker use another storage driver.\n *\n * Compares the driver the daemon runs now with the one daemon.json asks for.\n * A config without `storage-driver` keeps the current one.\n */\nfunction switchesStorageDriver(io: SetupIo): boolean {\n const active = io.execSilent(\"docker info --format '{{.Driver}}'\");\n\n if (!active || !io.fileExists(DAEMON_CONFIG)) {\n return false;\n }\n\n try {\n const configured = JSON.parse(io.readFile(DAEMON_CONFIG))[\"storage-driver\"];\n return typeof configured === \"string\" && configured !== active;\n } catch {\n return false;\n }\n}\n\n/**\n * Setup Docker system prune cron job\n */\nexport function setupDockerPrune(io: SetupIo = nodeIo): void {\n step(\"Setup Docker cleanup cron\");\n\n const cronContent = `# Trafic: Clean up Docker resources weekly\n0 3 * * 0 root docker system prune -af --volumes 2>&1 | logger -t docker-prune\n`;\n\n io.writeFile(\"/etc/cron.d/trafic-docker-prune\", cronContent);\n io.exec(\"chmod 644 /etc/cron.d/trafic-docker-prune\");\n\n success(\"Docker prune scheduled weekly at 3am on Sundays\");\n}\n","import { nodeIo, type SetupIo } from \"./io.js\";\nimport { step, success, info, warn } from \"./steps.js\";\n\n/** The sshd drop-in written by `setup`, and the only file hardening owns. */\nexport const SSHD_DROPIN = \"/etc/ssh/sshd_config.d/trafic.conf\";\n\n/**\n * Resolve the users listed in AllowUsers.\n *\n * The user who invoked sudo is always included. Without them, the\n * AllowUsers directive locks them out on their next connection, and\n * reloading sshd keeps the current session alive so nothing looks wrong\n * until it is too late. root needs no entry — it is always listed.\n */\nexport function resolveAllowedUsers(\n allowedUsers: string[],\n sudoUser: string | undefined,\n): string[] {\n const users = allowedUsers.map((user) => user.trim()).filter(Boolean);\n\n if (sudoUser && sudoUser !== \"root\" && !users.includes(sudoUser)) {\n users.push(sudoUser);\n }\n\n return users;\n}\n\n/**\n * Configure SSH hardening\n */\nexport function hardenSsh(\n options: { allowedUsers?: string[]; noRootSsh?: boolean } = {},\n io: SetupIo = nodeIo,\n): void {\n step(\"Harden SSH configuration\");\n\n const { allowedUsers = [\"ddev\"], noRootSsh = false } = options;\n const users = resolveAllowedUsers(allowedUsers, io.env.SUDO_USER);\n\n // Dropping root from AllowUsers with nobody left would lock everyone out,\n // and sshd would reject the config anyway\n if (noRootSsh && users.length === 0) {\n throw new Error(\n \"Refusing to disable root SSH with an empty user list — nobody could log in\",\n );\n }\n\n if (noRootSsh && !io.env.SUDO_USER) {\n warn(\n \"Disabling root SSH without a sudo user: make sure ddev has an authorized key,\",\n );\n warn(\" or the only way back in is the provider's rescue mode\");\n }\n\n const allowList = noRootSsh ? users : [\"root\", ...users];\n\n const rootLogin = noRootSsh\n ? `# Root login disabled (--no-root-ssh) — recovery is via rescue mode\nPermitRootLogin no`\n : `# Allow root login via SSH key only (no password) — required for server management\nPermitRootLogin prohibit-password`;\n\n const sshConfig = `# Trafic SSH hardening\n# Generated by trafic setup\n\n${rootLogin}\n\n# Disable password authentication (keys only)\nPasswordAuthentication no\nPubkeyAuthentication yes\n\n# Limit authentication attempts\nMaxAuthTries 3\n\n# Idle timeout (5 minutes)\nClientAliveInterval 300\nClientAliveCountMax 2\n\n# Allowed users\nAllowUsers ${allowList.join(\" \")}\n\n# Disable X11 forwarding\nX11Forwarding no\n\n# Disable TCP forwarding\nAllowTcpForwarding no\n\n# Pin ciphers and MACs to strong AEAD and encrypt-then-MAC algorithms.\n# Key exchange is left to OpenSSH's own defaults, which are kept current and\n# include the post-quantum hybrids a fixed list here would silently exclude.\nCiphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com\nMACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com\n`;\n\n io.writeFile(SSHD_DROPIN, sshConfig);\n io.exec(`chmod 644 ${SSHD_DROPIN}`);\n\n // Test config before restarting\n const testResult = io.exec(\"sshd -t 2>&1 || echo 'error'\", { silent: true });\n if (testResult?.includes(\"error\")) {\n warn(\"SSH config test failed, reverting...\");\n io.exec(`rm ${SSHD_DROPIN}`);\n return;\n }\n\n io.exec(\"systemctl reload ssh || systemctl reload sshd\");\n success(\n noRootSsh\n ? \"SSH hardened: password auth disabled, root login disabled\"\n : \"SSH hardened: password auth disabled, root key-only login retained\",\n );\n info(`Allowed users: ${allowList.join(\", \")}`);\n}\n\n/**\n * Drop the KexAlgorithms line from the sshd drop-in.\n *\n * `setup` used to pin key exchange to two classical algorithms. A\n * KexAlgorithms line with no `+`, `-` or `^` prefix replaces OpenSSH's whole\n * default list, so the post-quantum hybrids it enables by itself\n * (mlkem768x25519-sha256, sntrup761x25519-sha512) were excluded, and an\n * OpenSSH 10.1+ client warns on every connection that the session is not\n * using a post-quantum key exchange. Removing the line hands key exchange\n * back to OpenSSH's maintained defaults; Ciphers and MACs stay pinned.\n *\n * Returns whether the file was changed. Idempotent: a drop-in without the\n * line, or no drop-in at all, is left alone.\n */\nexport function removeSshKexAlgorithms(io: SetupIo = nodeIo): boolean {\n if (!io.fileExists(SSHD_DROPIN)) {\n return false;\n }\n\n const original = io.readFile(SSHD_DROPIN);\n const lines = original.split(\"\\n\");\n const kept = lines.filter((line) => !line.startsWith(\"KexAlgorithms\"));\n\n if (kept.length === lines.length) {\n return false;\n }\n\n io.writeFile(SSHD_DROPIN, kept.join(\"\\n\"));\n\n const testResult = io.exec(\"sshd -t 2>&1 || echo 'error'\", { silent: true });\n if (testResult?.includes(\"error\")) {\n warn(\"SSH config test failed after removing KexAlgorithms, reverting...\");\n io.writeFile(SSHD_DROPIN, original);\n return false;\n }\n\n io.exec(\"systemctl reload ssh || systemctl reload sshd\");\n return true;\n}\n\n/**\n * Configure UFW firewall\n */\nexport function configureFirewall(io: SetupIo = nodeIo): void {\n step(\"Configure firewall (UFW)\");\n\n // Install UFW if not present\n if (!io.commandExists(\"ufw\")) {\n io.exec(\"DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y ufw\", { silent: true });\n }\n\n // Reset and configure\n io.exec(\"ufw --force reset\", { silent: true });\n io.exec(\"ufw default deny incoming\");\n io.exec(\"ufw default allow outgoing\");\n io.exec(\"ufw allow 22/tcp comment 'SSH'\");\n io.exec(\"ufw allow 80/tcp comment 'HTTP'\");\n io.exec(\"ufw allow 443/tcp comment 'HTTPS'\");\n // Allow trafic-agent port from Docker bridge subnets so ddev-router can\n // reach the forward-auth endpoint. Docker typically uses 172.16.0.0/12.\n io.exec(\"ufw allow from 172.16.0.0/12 to any port 9876 comment 'trafic-agent from Docker'\");\n\n // Enable firewall\n io.exec(\"ufw --force enable\");\n\n success(\"Firewall enabled: SSH (22), HTTP (80), HTTPS (443), trafic-agent (9876 from Docker) allowed\");\n\n // Say it plainly rather than let `ufw status` imply a guarantee it cannot\n // make: Docker's rules run before UFW's, so published ports stay reachable\n info(\"UFW does not cover ports Docker publishes — DDEV's tool ports (Mailpit, xhgui)\");\n info(\"are protected by forward auth, not the firewall. For network-level closure use a\");\n info(\"firewall in front of the server; see the agent readme, 'Network exposure'\");\n}\n\n/** The unattended-upgrades config written by `setup`, and owned by Trafic. */\nexport const UNATTENDED_UPGRADES_CONFIG = \"/etc/apt/apt.conf.d/50unattended-upgrades\";\n\n/**\n * The Allowed-Origins entry covering Docker Engine.\n *\n * `origin=Docker` is the `Origin:` field of the Release file served by\n * download.docker.com (its `Label:` is `Docker CE`), and that repository's\n * dist is the Ubuntu codename, so `codename=${distro_codename}` pins the\n * entry to the release the server runs.\n */\nexport const DOCKER_ORIGIN_LINE = '\"origin=Docker,codename=${distro_codename}\"';\n\n/**\n * Build the contents of /etc/apt/apt.conf.d/50unattended-upgrades.\n *\n * Pure, so `setup` and migration 0014 write the exact same file.\n */\nexport function buildUnattendedUpgradesConfig(): string {\n return `// Trafic: Automatic security updates\nUnattended-Upgrade::Allowed-Origins {\n \"\\${distro_id}:\\${distro_codename}\";\n \"\\${distro_id}:\\${distro_codename}-security\";\n \"\\${distro_id}ESMApps:\\${distro_codename}-apps-security\";\n \"\\${distro_id}ESM:\\${distro_codename}-infra-security\";\n // Docker Engine ships from download.docker.com, outside Ubuntu's own\n // pockets, so without this entry its security releases were never applied.\n // DDEV is deliberately left out: a DDEV major landing unattended can break\n // running previews, so DDEV is updated by \\`trafic-agent upgrade\\` instead,\n // when an operator is there to see it.\n ${DOCKER_ORIGIN_LINE};\n};\n\n// Do not automatically reboot\nUnattended-Upgrade::Automatic-Reboot \"false\";\n\n// Remove unused dependencies\nUnattended-Upgrade::Remove-Unused-Dependencies \"true\";\n\n// Email notifications (optional)\n// Unattended-Upgrade::Mail \"root\";\n`;\n}\n\n/**\n * Add the Docker origin to an existing unattended-upgrades config.\n *\n * Returns whether the file was changed. Idempotent: a server with no such\n * file was set up without hardening and is left alone, and a config that\n * already names the Docker origin is not rewritten. The whole file is\n * regenerated rather than patched because Trafic generates and owns it — the\n * header comment says so.\n *\n * No service restart is needed: unattended-upgrades reads the config on each\n * daily run.\n */\nexport function addDockerOriginToUnattendedUpgrades(io: SetupIo = nodeIo): boolean {\n if (!io.fileExists(UNATTENDED_UPGRADES_CONFIG)) {\n return false;\n }\n\n if (io.readFile(UNATTENDED_UPGRADES_CONFIG).includes(DOCKER_ORIGIN_LINE)) {\n return false;\n }\n\n io.writeFile(UNATTENDED_UPGRADES_CONFIG, buildUnattendedUpgradesConfig());\n return true;\n}\n\n/**\n * Configure automatic security updates\n */\nexport function configureUnattendedUpgrades(io: SetupIo = nodeIo): void {\n step(\"Configure automatic security updates\");\n\n // Install unattended-upgrades\n io.exec(\n \"DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y unattended-upgrades apt-listchanges\",\n { silent: true },\n );\n\n // Ubuntu's release and security pockets, plus Docker Engine\n io.writeFile(UNATTENDED_UPGRADES_CONFIG, buildUnattendedUpgradesConfig());\n\n // Enable automatic updates\n const autoConfig = `APT::Periodic::Update-Package-Lists \"1\";\nAPT::Periodic::Download-Upgradeable-Packages \"1\";\nAPT::Periodic::AutocleanInterval \"7\";\nAPT::Periodic::Unattended-Upgrade \"1\";\n`;\n\n io.writeFile(\"/etc/apt/apt.conf.d/20auto-upgrades\", autoConfig);\n\n // Enable the service\n io.exec(\"systemctl enable unattended-upgrades\");\n io.exec(\"systemctl start unattended-upgrades\");\n\n success(\"Automatic security updates enabled\");\n}\n\n/**\n * Configure Fail2ban\n */\nexport function configureFail2ban(io: SetupIo = nodeIo): void {\n step(\"Configure Fail2ban\");\n\n // Install fail2ban\n if (!io.commandExists(\"fail2ban-client\")) {\n io.exec(\"DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y fail2ban\", { silent: true });\n }\n\n // Configure jail\n const jailConfig = `# Trafic: Fail2ban configuration\n[DEFAULT]\nbantime = 3600\nfindtime = 600\nmaxretry = 5\nbackend = auto\n\n[sshd]\nenabled = true\nport = ssh\nfilter = sshd\nlogpath = /var/log/auth.log\nmaxretry = 3\nbantime = 7200\n`;\n\n io.writeFile(\"/etc/fail2ban/jail.local\", jailConfig);\n io.exec(\"chmod 644 /etc/fail2ban/jail.local\");\n\n // Restart fail2ban\n io.exec(\"systemctl enable fail2ban\");\n io.exec(\"systemctl restart fail2ban\");\n\n success(\"Fail2ban enabled: SSH brute force protection active\");\n}\n\n/**\n * Configure system limits\n */\nexport function configureSystemLimits(io: SetupIo = nodeIo): void {\n step(\"Configure system limits\");\n\n const limitsConfig = `# Trafic: System limits for ddev user\nddev soft nofile 65536\nddev hard nofile 65536\nddev soft nproc 4096\nddev hard nproc 4096\n`;\n\n io.writeFile(\"/etc/security/limits.d/trafic.conf\", limitsConfig);\n io.exec(\"chmod 644 /etc/security/limits.d/trafic.conf\");\n\n success(\"System limits configured for ddev user\");\n}\n\n/**\n * Set proper file permissions\n */\nexport function configureFilePermissions(io: SetupIo = nodeIo): void {\n step(\"Configure file permissions\");\n\n // Create directories\n io.exec(\"mkdir -p /etc/trafic\");\n io.exec(\"mkdir -p /var/lib/trafic\");\n io.exec(\"mkdir -p /var/backups/trafic\");\n\n // Set permissions\n io.exec(\"chmod 755 /etc/trafic\");\n io.exec(\"chmod 700 /var/lib/trafic\");\n io.exec(\"chmod 700 /var/backups/trafic\");\n io.exec(\"chown root:ddev /etc/trafic\");\n io.exec(\"chown ddev:ddev /var/lib/trafic\");\n io.exec(\"chown ddev:ddev /var/backups/trafic\");\n\n // Config file (if exists)\n if (io.fileExists(\"/etc/trafic/config.toml\")) {\n io.exec(\"chmod 640 /etc/trafic/config.toml\");\n io.exec(\"chown root:ddev /etc/trafic/config.toml\");\n }\n\n success(\"File permissions configured\");\n}\n\n/**\n * Run all hardening steps\n */\nexport function hardenServer(\n options: { sshUsers?: string[]; noRootSsh?: boolean } = {},\n io: SetupIo = nodeIo,\n): void {\n hardenSsh(\n { allowedUsers: options.sshUsers, noRootSsh: options.noRootSsh },\n io,\n );\n configureFirewall(io);\n configureUnattendedUpgrades(io);\n configureFail2ban(io);\n configureSystemLimits(io);\n configureFilePermissions(io);\n}\n","import type { AuditCheck } from \"./types.js\";\nimport { nodeIo, type SetupIo } from \"./io.js\";\nimport { loadConfig } from \"../utils/config.js\";\nimport { ROUTER_COMPOSE_OVERRIDE } from \"./ddev.js\";\nimport type { AgentConfig } from \"../types.js\";\n\n/**\n * Check if a service is active\n */\nfunction isServiceActive(service: string, io: SetupIo): boolean {\n return io.execSilent(`systemctl is-active ${service}`) === \"active\";\n}\n\n/** Read a file, or \"\" when it does not exist. */\nfunction readOrEmpty(path: string, io: SetupIo): string {\n return io.fileExists(path) ? io.readFile(path) : \"\";\n}\n\n/**\n * Audit SSH configuration\n */\nexport function auditSsh(io: SetupIo = nodeIo): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Check root login\n const config =\n readOrEmpty(\"/etc/ssh/sshd_config\", io) +\n \"\\n\" +\n readOrEmpty(\"/etc/ssh/sshd_config.d/trafic.conf\", io);\n\n const rootLoginDisabled =\n config.includes(\"PermitRootLogin no\") ||\n config.includes(\"PermitRootLogin prohibit-password\");\n checks.push({\n name: \"SSH root login\",\n status: rootLoginDisabled ? \"pass\" : \"fail\",\n message: rootLoginDisabled\n ? \"Root login disabled\"\n : \"Root login may be enabled\",\n fix: \"Add 'PermitRootLogin no' to /etc/ssh/sshd_config.d/trafic.conf\",\n });\n\n const passwordAuthDisabled = config.includes(\"PasswordAuthentication no\");\n checks.push({\n name: \"SSH password auth\",\n status: passwordAuthDisabled ? \"pass\" : \"warn\",\n message: passwordAuthDisabled\n ? \"Password authentication disabled\"\n : \"Password authentication may be enabled\",\n fix: \"Add 'PasswordAuthentication no' to /etc/ssh/sshd_config.d/trafic.conf\",\n });\n\n return checks;\n}\n\n/**\n * Audit firewall configuration\n */\nexport function auditFirewall(io: SetupIo = nodeIo): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n const ufwStatus = io.execSilent(\"ufw status\");\n const isActive = ufwStatus.includes(\"Status: active\");\n\n checks.push({\n name: \"Firewall (UFW)\",\n status: isActive ? \"pass\" : \"fail\",\n message: isActive ? \"UFW is active\" : \"UFW is not active\",\n fix: \"Run: ufw enable\",\n });\n\n if (isActive) {\n const allows22 = ufwStatus.includes(\"22/tcp\");\n const allows80 = ufwStatus.includes(\"80/tcp\");\n const allows443 = ufwStatus.includes(\"443/tcp\");\n\n if (allows22 && allows80 && allows443) {\n checks.push({\n name: \"Firewall ports\",\n status: \"pass\",\n message: \"Required ports (22, 80, 443) are open\",\n });\n } else {\n checks.push({\n name: \"Firewall ports\",\n status: \"warn\",\n message: \"Some required ports may be blocked\",\n fix: \"Run: ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp\",\n });\n }\n }\n\n return checks;\n}\n\n/**\n * Audit security services\n */\nexport function auditServices(io: SetupIo = nodeIo): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Fail2ban\n const fail2banActive = isServiceActive(\"fail2ban\", io);\n checks.push({\n name: \"Fail2ban\",\n status: fail2banActive ? \"pass\" : \"warn\",\n message: fail2banActive ? \"Fail2ban is active\" : \"Fail2ban is not running\",\n fix: \"Run: apt install fail2ban && systemctl enable fail2ban --now\",\n });\n\n // Unattended upgrades\n const unattendedActive = isServiceActive(\"unattended-upgrades\", io);\n checks.push({\n name: \"Automatic updates\",\n status: unattendedActive ? \"pass\" : \"warn\",\n message: unattendedActive\n ? \"Unattended upgrades enabled\"\n : \"Automatic updates not configured\",\n fix: \"Run: apt install unattended-upgrades && dpkg-reconfigure unattended-upgrades\",\n });\n\n // Trafic agent\n const agentActive = isServiceActive(\"trafic-agent\", io);\n checks.push({\n name: \"Trafic agent\",\n status: agentActive ? \"pass\" : \"fail\",\n message: agentActive\n ? \"Trafic agent is running\"\n : \"Trafic agent is not running\",\n fix: \"Run: systemctl start trafic-agent\",\n });\n\n return checks;\n}\n\n/**\n * Audit Docker\n */\nexport function auditDocker(io: SetupIo = nodeIo): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Docker running\n const dockerActive = isServiceActive(\"docker\", io);\n checks.push({\n name: \"Docker\",\n status: dockerActive ? \"pass\" : \"fail\",\n message: dockerActive ? \"Docker is running\" : \"Docker is not running\",\n fix: \"Run: systemctl start docker\",\n });\n\n // Dangling images\n const danglingImages = io.execSilent(\"docker images -f dangling=true -q | wc -l\");\n const count = parseInt(danglingImages, 10) || 0;\n if (count > 0) {\n checks.push({\n name: \"Docker cleanup\",\n status: \"warn\",\n message: `${count} dangling Docker images`,\n fix: \"Run: docker system prune -f\",\n });\n }\n\n // Disk usage\n const diskUsage = io.execSilent(\"df -h / | awk 'NR==2{print $5}'\");\n const usagePercent = parseInt(diskUsage, 10) || 0;\n checks.push({\n name: \"Disk usage\",\n status: usagePercent > 90 ? \"fail\" : usagePercent > 80 ? \"warn\" : \"pass\",\n message: `Disk usage: ${diskUsage}`,\n fix: usagePercent > 80 ? \"Consider cleaning up old projects or Docker resources\" : undefined,\n });\n\n return checks;\n}\n\n/**\n * Audit file permissions\n */\nexport function auditPermissions(io: SetupIo = nodeIo): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Config file\n if (io.fileExists(\"/etc/trafic/config.toml\")) {\n const stats = io.execSilent(\"stat -c '%a' /etc/trafic/config.toml\");\n const isSecure = stats === \"640\" || stats === \"600\";\n checks.push({\n name: \"Config permissions\",\n status: isSecure ? \"pass\" : \"warn\",\n message: isSecure\n ? \"Config file has secure permissions\"\n : `Config file permissions: ${stats} (should be 640)`,\n fix: \"Run: chmod 640 /etc/trafic/config.toml\",\n });\n }\n\n // Projects directory\n if (io.fileExists(\"/home/ddev/www\")) {\n const owner = io.execSilent(\"stat -c '%U:%G' /home/ddev/www\");\n const isCorrect = owner === \"ddev:ddev\";\n checks.push({\n name: \"Projects directory\",\n status: isCorrect ? \"pass\" : \"warn\",\n message: isCorrect\n ? \"Projects directory owned by ddev\"\n : `Projects directory owner: ${owner}`,\n fix: \"Run: chown ddev:ddev /home/ddev/www\",\n });\n }\n\n return checks;\n}\n\n/**\n * Audit the wildcard certificate, where one is configured.\n *\n * Two things go wrong silently: the certificate is never issued (a wrong\n * token, a zone the token cannot edit), and the token file ends up readable\n * by everyone.\n */\nexport function auditWildcardTls(\n config: AgentConfig,\n io: SetupIo = nodeIo,\n): AuditCheck[] {\n if (!config.tls.dnsProvider) {\n return [];\n }\n\n const checks: AuditCheck[] = [];\n\n checks.push(wildcardCertificateCheck(config.tld, io));\n\n if (io.fileExists(ROUTER_COMPOSE_OVERRIDE)) {\n const mode = io.execSilent(`stat -c '%a' ${ROUTER_COMPOSE_OVERRIDE}`);\n const isSecure = mode === \"600\";\n\n checks.push({\n name: \"DNS credentials permissions\",\n status: isSecure ? \"pass\" : \"warn\",\n message: isSecure\n ? \"Router compose override is readable by its owner only\"\n : `Router compose override permissions: ${mode} (should be 600)`,\n fix: `Run: chmod 600 ${ROUTER_COMPOSE_OVERRIDE}`,\n });\n }\n\n return checks;\n}\n\n/** Look for the wildcard certificate in Traefik's DNS-01 ACME storage. */\nfunction wildcardCertificateCheck(tld: string, io: SetupIo): AuditCheck {\n const fix =\n \"Check the router log for ACME errors: docker logs ddev-router 2>&1 | grep -i acme\";\n\n const mountpoint = io.execSilent(\n \"docker volume inspect ddev-global-cache --format '{{.Mountpoint}}'\",\n );\n\n if (!mountpoint) {\n return {\n name: \"Wildcard certificate\",\n status: \"warn\",\n message: \"Traefik's storage volume (ddev-global-cache) was not found\",\n fix,\n };\n }\n\n const storage = `${mountpoint}/traefik/acme-dns.json`;\n\n if (!io.fileExists(storage)) {\n return {\n name: \"Wildcard certificate\",\n status: \"warn\",\n message: \"No DNS-01 certificate has been issued yet\",\n fix,\n };\n }\n\n const issued = hasCertificateFor(io.readFile(storage), tld);\n\n return {\n name: \"Wildcard certificate\",\n status: issued ? \"pass\" : \"warn\",\n message: issued\n ? `Wildcard certificate issued for *.${tld}`\n : `No certificate for ${tld} in Traefik's DNS-01 storage`,\n fix: issued ? undefined : fix,\n };\n}\n\n/**\n * Whether Traefik's ACME storage holds a certificate whose main domain is the\n * TLD — which is the wildcard one, since that is what the default store asks\n * for.\n *\n * The file is keyed by resolver name, each with a Certificates array.\n */\nexport function hasCertificateFor(storage: string, tld: string): boolean {\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(storage);\n } catch {\n return false;\n }\n\n if (parsed === null || typeof parsed !== \"object\") {\n return false;\n }\n\n for (const resolver of Object.values(parsed as Record<string, unknown>)) {\n const certificates = (resolver as { Certificates?: unknown }).Certificates;\n\n if (!Array.isArray(certificates)) {\n continue;\n }\n\n for (const certificate of certificates) {\n const main = (certificate as { domain?: { main?: unknown } }).domain?.main;\n\n if (main === tld) {\n return true;\n }\n }\n }\n\n return false;\n}\n\n/**\n * Run all audit checks\n */\nexport function runAudit(io: SetupIo = nodeIo, config: AgentConfig = loadConfig()): AuditCheck[] {\n return [\n ...auditSsh(io),\n ...auditFirewall(io),\n ...auditServices(io),\n ...auditDocker(io),\n ...auditPermissions(io),\n ...auditWildcardTls(config, io),\n ];\n}\n\n/**\n * Print audit results\n */\nexport function printAuditResults(checks: AuditCheck[]): void {\n console.log(\"\\n\\x1b[1mSecurity Audit Results\\x1b[0m\\n\");\n\n for (const check of checks) {\n const icon =\n check.status === \"pass\"\n ? \"\\x1b[32m✓\\x1b[0m\"\n : check.status === \"warn\"\n ? \"\\x1b[33m⚠\\x1b[0m\"\n : \"\\x1b[31m✗\\x1b[0m\";\n\n console.log(`${icon} \\x1b[1m${check.name}\\x1b[0m: ${check.message}`);\n\n if (check.fix && check.status !== \"pass\") {\n console.log(` \\x1b[90m${check.fix}\\x1b[0m`);\n }\n }\n\n const passed = checks.filter((c) => c.status === \"pass\").length;\n const warnings = checks.filter((c) => c.status === \"warn\").length;\n const failed = checks.filter((c) => c.status === \"fail\").length;\n\n console.log(\n `\\n\\x1b[1mSummary:\\x1b[0m ${passed} passed, ${warnings} warnings, ${failed} failed`,\n );\n}\n","import { existsSync } from \"node:fs\";\nimport { exec } from \"../steps.js\";\nimport { addDdevAptRepo } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0001: Migrate DDEV from manual tarball to apt repository.\n *\n * Old installs downloaded a tarball from GitHub releases. This migration\n * removes the manually-installed binaries and reinstalls DDEV via the\n * official apt repository so future updates are handled by apt.\n *\n * Condition: skip if /etc/apt/sources.list.d/ddev.list already exists,\n * which means the apt repository is already configured.\n */\nexport const migration0001DdevAptRepo: Migration = {\n id: \"0001__ddev_apt_repo\",\n description: \"Migrate DDEV from manual tarball to apt repository\",\n\n run(): void {\n // Already using the apt repository — nothing to do\n if (existsSync(\"/etc/apt/sources.list.d/ddev.list\")) {\n return;\n }\n\n // 1. Remove manually-installed DDEV binaries\n exec(\"rm -f /usr/local/bin/ddev /usr/local/bin/ddev-hostname /usr/local/bin/mkcert\", {\n silent: true,\n });\n\n // 2. Add the official DDEV apt repository\n addDdevAptRepo();\n\n // 3. Install DDEV via apt\n exec(\"apt-get update -qq\", { silent: true });\n exec(\"DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y ddev\", {\n silent: true,\n });\n\n // 4. Re-install mkcert CA (mkcert is now bundled with DDEV via apt)\n exec(\"mkcert -install\", { silent: true });\n },\n};\n","import { existsSync } from \"node:fs\";\nimport { exec } from \"../steps.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0002: Install mkcert CA in the ddev user trust store.\n *\n * Previously, `mkcert -install` was called as root without overriding HOME,\n * so the CA was stored under ~root/.local/share/mkcert. DDEV runs as the\n * `ddev` user and looks for the CA in its own home directory — so it never\n * found it, reporting mkcert as not installed.\n *\n * Fix: run `mkcert -install` with HOME=/home/ddev so the CA lands in the\n * ddev user trust store, then chown the directory to ddev:ddev.\n *\n * Condition: skip if /home/ddev/.local/share/mkcert already exists,\n * which means the CA is already installed for the ddev user.\n */\nexport const migration0002MkcertDdevUser: Migration = {\n id: \"0002__mkcert_ddev_user\",\n description: \"Install mkcert CA in the ddev user trust store\",\n\n run(): void {\n // Already installed for the ddev user — nothing to do\n if (existsSync(\"/home/ddev/.local/share/mkcert\")) {\n return;\n }\n\n exec(\"HOME=/home/ddev mkcert -install\", { silent: true });\n exec(\"chown -R ddev:ddev /home/ddev/.local/share/mkcert\", { silent: true });\n },\n};\n","import { exec } from \"../steps.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0003: Enable router_bind_all_interfaces in DDEV global config.\n *\n * The initial setup did not set --router-bind-all-interfaces=true, which\n * caused ddev-router to bind only on 127.0.0.1. External traffic (including\n * Cloudflare proxying) could not reach the router, resulting in 521 errors.\n *\n * This migration enables the flag and restarts the DDEV router so the change\n * takes effect immediately.\n *\n * Idempotent: ddev config global is safe to re-run with the same value.\n */\nexport const migration0003DdevRouterBindAllInterfaces: Migration = {\n id: \"0003__ddev_router_bind_all_interfaces\",\n description: \"Bind ddev-router on all interfaces (fix 521 for external traffic)\",\n\n run(): void {\n exec(\"su - ddev -c 'ddev config global --router-bind-all-interfaces=true'\", { silent: true });\n // Restart all running projects so the router picks up the new bind setting\n exec(\"su - ddev -c 'ddev poweroff'\", { silent: true });\n exec(\"su - ddev -c 'ddev start --all'\", { silent: true });\n },\n};\n","import { existsSync } from \"node:fs\";\nimport { configureDdevSudoers } from \"../user.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0004: Allow ddev user to run ddev-hostname without a password.\n *\n * ddev-hostname manages /etc/hosts entries and always calls sudo internally,\n * even when DDEV_NONINTERACTIVE=true. Without this sudoers rule, any ddev\n * command that touches hostname resolution (start, restart, poweroff + start)\n * fails with \"sudo: a terminal is required to read the password\".\n *\n * Condition: skip if /etc/sudoers.d/trafic-ddev already exists.\n */\nexport const migration0004DdevHostnameSudoers: Migration = {\n id: \"0004__ddev_hostname_sudoers\",\n description: \"Allow ddev user to run ddev-hostname without a password\",\n\n run(): void {\n if (existsSync(\"/etc/sudoers.d/trafic-ddev\")) {\n return;\n }\n\n configureDdevSudoers();\n },\n};\n","import { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { getDockerGatewayIp } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\nconst TRAFIC_YAML = \"/home/ddev/.ddev/traefik/trafic.yaml\";\n\n/**\n * Migration 0005: Replace host.docker.internal with the Docker bridge gateway IP\n * in the Traefik forward-auth config.\n *\n * host.docker.internal is a Docker Desktop / macOS feature and is not available\n * on Linux without explicit configuration. The Traefik router container cannot\n * resolve it, so forward-auth requests silently fail and all traffic bypasses\n * authentication.\n *\n * Fix: replace all occurrences of host.docker.internal in trafic.yaml with the\n * actual Docker bridge gateway IP (typically 172.17.0.1 or 172.18.0.1).\n *\n * Condition: skip if trafic.yaml doesn't exist or already uses an IP address.\n */\nexport const migration0005TraefikGatewayIp: Migration = {\n id: \"0005__traefik_gateway_ip\",\n description: \"Replace host.docker.internal with Docker bridge gateway IP in Traefik config\",\n\n run(): void {\n if (!existsSync(TRAFIC_YAML)) {\n return;\n }\n\n const content = readFileSync(TRAFIC_YAML, \"utf-8\");\n const gatewayIp = getDockerGatewayIp();\n\n // Replace host.docker.internal (original broken value) or any previously\n // written IP that no longer matches the current gateway (e.g. if the\n // migration ran before ddev_default existed and wrote 172.17.0.1 instead).\n const updated = content\n .replaceAll(\"host.docker.internal\", gatewayIp)\n .replace(/http:\\/\\/\\d+\\.\\d+\\.\\d+\\.\\d+:9876/g, `http://${gatewayIp}:9876`);\n\n if (updated === content) {\n return;\n }\n\n writeFileSync(TRAFIC_YAML, updated, \"utf-8\");\n },\n};\n","import { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { getDockerGatewayIp } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\nconst TRAFIC_YAML = \"/home/ddev/.ddev/traefik/trafic.yaml\";\n\n/**\n * Migration 0006: Fix the Docker gateway IP in trafic.yaml.\n *\n * Migration 0005 inspected the default `bridge` network (172.17.0.1) instead\n * of the `ddev_default` network (typically 172.18.0.1) that ddev-router\n * actually runs on. This migration rewrites trafic.yaml with the correct IP.\n *\n * Idempotent: re-reads the file and only writes if the IP has changed.\n */\nexport const migration0006TraefikGatewayIpFix: Migration = {\n id: \"0006__traefik_gateway_ip_fix\",\n description: \"Fix Docker gateway IP in Traefik config (use ddev_default network)\",\n\n run(): void {\n if (!existsSync(TRAFIC_YAML)) {\n return;\n }\n\n const content = readFileSync(TRAFIC_YAML, \"utf-8\");\n const gatewayIp = getDockerGatewayIp();\n\n const updated = content.replace(\n /http:\\/\\/[\\w.]+:9876/g,\n `http://${gatewayIp}:9876`,\n );\n\n if (updated === content) {\n return;\n }\n\n writeFileSync(TRAFIC_YAML, updated, \"utf-8\");\n },\n};\n","import { exec, execSilent } from \"../steps.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0007: Allow trafic-agent port 9876 from Docker bridge subnets in UFW.\n *\n * UFW does not automatically allow traffic from Docker bridge networks.\n * Without this rule, ddev-router cannot reach the trafic-agent forward-auth\n * endpoint (172.18.0.1:9876), causing all auth checks to time out and traffic\n * to pass through unauthenticated.\n *\n * Condition: skip if the rule already exists.\n */\nexport const migration0007UfwDockerTraficPort: Migration = {\n id: \"0007__ufw_docker_trafic_port\",\n description: \"Allow trafic-agent port 9876 from Docker bridge subnets in UFW\",\n\n run(): void {\n const existing = execSilent(\"ufw status | grep '9876'\");\n if (existing.includes(\"9876\")) {\n return;\n }\n\n exec(\"ufw allow from 172.16.0.0/12 to any port 9876 comment 'trafic-agent from Docker'\", {\n silent: true,\n });\n },\n};\n","import { writeFileSync, rmSync, existsSync } from \"node:fs\";\nimport { exec } from \"../steps.js\";\nimport { getDockerGatewayIp } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0008: Switch Traefik from catch-all router to entry point middleware.\n *\n * The previous approach used a low-priority catch-all router (priority=1) with\n * trafic-auth middleware in dynamic.yaml. This failed because DDEV's project-specific\n * routers always win — they have higher priority and no trafic-auth middleware.\n *\n * The correct approach (from the previous trafic v1 with DDEV 1.24) is to attach\n * the middleware at the entry point level via a static_config.*.yaml file that DDEV\n * merges into .static_config.yaml on every start. This ensures every request on\n * http-80 and http-443 passes through auth regardless of which router handles it.\n *\n * Changes:\n * - Write static_config.trafic.yaml: attaches trafic-auth@file + trafic-errors@file\n * to the http-80 and http-443 entry points\n * - Write trafic.yaml to custom-global-config/ (survives DDEV 1.25+ config purge)\n * - Remove the old dynamic.yaml catch-all router\n */\nexport const migration0008TraefikEntrypointMiddleware: Migration = {\n id: \"0008__traefik_entrypoint_middleware\",\n description:\n \"Switch Traefik auth from catch-all router to entry point middleware\",\n\n run(): void {\n const gatewayIp = getDockerGatewayIp();\n\n // Dynamic config: middleware + service definitions\n const dynamicConfig = `# Trafic: Forward auth middleware definition\nhttp:\n middlewares:\n trafic-auth:\n forwardAuth:\n address: \"http://${gatewayIp}:9876/__auth__\"\n authResponseHeaders:\n - \"X-Trafic-Project\"\n\n trafic-errors:\n errors:\n status:\n - \"502\"\n - \"503\"\n service: trafic-service\n query: \"/\"\n\n services:\n trafic-service:\n loadBalancer:\n servers:\n - url: \"http://${gatewayIp}:9876\"\n`;\n\n exec(\"mkdir -p /home/ddev/.ddev/traefik/custom-global-config\", { silent: true });\n writeFileSync(\"/home/ddev/.ddev/traefik/trafic.yaml\", dynamicConfig);\n writeFileSync(\"/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml\", dynamicConfig);\n exec(\"chown -R ddev:ddev /home/ddev/.ddev/traefik\", { silent: true });\n\n // Static config: attach middleware to entry points\n const staticConfig = `entryPoints:\n http-80:\n http:\n middlewares:\n - trafic-auth@file\n - trafic-errors@file\n http-443:\n http:\n middlewares:\n - trafic-auth@file\n - trafic-errors@file\n`;\n\n writeFileSync(\"/home/ddev/.ddev/traefik/static_config.trafic.yaml\", staticConfig);\n exec(\"chown ddev:ddev /home/ddev/.ddev/traefik/static_config.trafic.yaml\", { silent: true });\n\n // Remove the old catch-all router config — it's superseded by entry point middleware\n const oldDynamicYaml = \"/home/ddev/.ddev/traefik/dynamic.yaml\";\n if (existsSync(oldDynamicYaml)) {\n rmSync(oldDynamicYaml);\n }\n\n // Restart DDEV router to apply the new static config\n exec(\"su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff && ddev start --all'\", {\n silent: false,\n });\n },\n};\n","import { existsSync, readFileSync } from \"node:fs\";\nimport { exec } from \"../steps.js\";\nimport { configureTraefik } from \"../ddev.js\";\nimport { createSystemdService } from \"../agent.js\";\nimport type { Migration } from \"../types.js\";\n\nconst UNIT_PATH = \"/etc/systemd/system/trafic-agent.service\";\nconst DYNAMIC_CONFIG = \"/home/ddev/.ddev/traefik/trafic.yaml\";\n\n/**\n * Migration 0009: Make scale-to-zero actually work.\n *\n * Two independent faults meant a stopped project never came back.\n *\n * The waiting page never appeared. `ddev stop` removes the project's router,\n * so no router matches and Traefik answers 404 — and entry point middlewares\n * do not run without a router match, so `trafic-errors` (which only lists 502\n * and 503) never saw it. Migration 0008 removed the catch-all router to fix an\n * auth bypass, which was right, but it also removed the only thing catching\n * these requests. A catch-all at priority 1 is safe now that auth lives on the\n * entry point: a project router always outranks it.\n *\n * Auto-start could not have worked either. The unit set\n * `ReadWritePaths=/var/lib/trafic /home/ddev/.ddev`, but `ddev start` writes\n * to /home/ddev/www/<project>/.ddev/.webimageBuild and to buildx state in\n * /home/ddev/.docker, both of which ProtectHome=read-only refused.\n *\n * Rewrites both the Traefik config and the systemd unit. Idempotent: skipped\n * once the catch-all is present and the unit grants the home.\n */\nexport const migration0009ScaleToZeroFixes: Migration = {\n id: \"0009__scale_to_zero_fixes\",\n description: \"Fix scale-to-zero: waiting page router and agent write access\",\n\n run(): void {\n const unitNeedsWrite =\n !existsSync(UNIT_PATH) ||\n !/^ReadWritePaths=.*\\/home\\/ddev(\\s|$)/m.test(\n readFileSync(UNIT_PATH, \"utf-8\"),\n );\n\n const configNeedsCatchall =\n !existsSync(DYNAMIC_CONFIG) ||\n !readFileSync(DYNAMIC_CONFIG, \"utf-8\").includes(\"trafic-catchall\");\n\n if (configNeedsCatchall) {\n // Rewrites both dynamic config copies and the static config, and\n // recomputes the gateway IP while it is there\n configureTraefik();\n\n // Traefik reads its config from the ddev-global-cache volume, and DDEV\n // only copies ~/.ddev/traefik into it when a project starts. Restarting\n // the router alone leaves the old config in place.\n exec(\"su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff && ddev start --all'\", {\n silent: true,\n });\n }\n\n if (unitNeedsWrite) {\n // Rewrites the unit with the corrected ReadWritePaths, then reloads and\n // restarts the agent\n createSystemdService();\n }\n },\n};\n","import { existsSync, readFileSync } from \"node:fs\";\nimport { exec } from \"../steps.js\";\nimport { configureTraefik } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\nconst DYNAMIC_CONFIG =\n \"/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml\";\n\n/** The copy setup used to write for DDEV versions that watched the root. */\nconst STALE_CONFIG = \"/home/ddev/.ddev/traefik/trafic.yaml\";\n\n/**\n * Migration 0010: pin the catch-all routers to their entry points.\n *\n * The routers added by 0009 named no entry point, so Traefik instantiated\n * each on every one. DDEV's router health check counts router *definitions*\n * across its config files and compares that to what Traefik reports, so two\n * definitions showing up as fourteen instances never matched. The check waited\n * out its timeout and `ddev start` failed after 60 seconds — on every server\n * setup produces, because setup configures the tool entry points.\n *\n * Observed on a live server: 37 routers loaded against 22 expected, health\n * still \"starting\" after 91s. With the routers pinned: 23 loaded, healthy\n * immediately.\n *\n * Traefik reads its config from the ddev-global-cache volume and DDEV only\n * copies `~/.ddev/traefik` into it when a project starts, so this restarts\n * DDEV rather than the router alone.\n *\n * Idempotent: skipped once the config names entry points.\n */\nexport const migration0010CatchallEntrypoints: Migration = {\n id: \"0010__catchall_entrypoints\",\n description: \"Pin the Traefik catch-all routers to their entry points\",\n\n run(): void {\n const pinned =\n existsSync(DYNAMIC_CONFIG) &&\n readFileSync(DYNAMIC_CONFIG, \"utf-8\").includes(\"entryPoints:\");\n\n if (pinned && !existsSync(STALE_CONFIG)) {\n return;\n }\n\n if (!pinned) {\n // Rewrites both configs from the server's actual router ports\n configureTraefik();\n }\n\n // Nothing reads this on DDEV 1.25, and leaving it behind means two\n // sources of truth for anyone debugging the router\n if (existsSync(STALE_CONFIG)) {\n exec(`rm -f ${STALE_CONFIG}`, { silent: true });\n }\n\n if (!pinned) {\n exec(\n \"su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff && ddev start --all'\",\n { silent: true },\n );\n }\n },\n};\n","import { execSilent } from \"../steps.js\";\nimport { purgeTraefikAcmeStorage } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0011: drop Traefik's Let's Encrypt storage where it is disabled.\n *\n * `use_letsencrypt=false` stops DDEV asking for new certificates but leaves\n * the issued ones in acme.json inside the ddev-global-cache volume. Traefik\n * loads them again on restart, so a server that had Let's Encrypt on at some\n * point keeps serving those certificates from a port that is now only\n * reachable through a host ingress.\n *\n * That broke a live server: the ingress verifies the loopback hop against the\n * mkcert root, the resurrected Let's Encrypt certificate failed validation,\n * and every request answered 502 with \"certificate signed by unknown\n * authority\" until the storage was moved aside.\n *\n * Only runs where Let's Encrypt is off — with it on, those certificates are\n * the ones being served on purpose.\n *\n * Traefik holds the certificate in memory, so removing the file is not enough\n * on its own; the router is restarted. Restarting the router is safe here\n * because no config needs re-copying from the host — unlike a config change,\n * which needs a project start.\n */\nexport const migration0011PurgeAcmeWhenDisabled: Migration = {\n id: \"0011__purge_acme_when_disabled\",\n description: \"Remove Traefik's Let's Encrypt storage when it is disabled\",\n\n run(): void {\n const globalConfig = execSilent(\"su - ddev -c 'ddev config global'\");\n\n if (!/^use-letsencrypt=false$/m.test(globalConfig)) {\n // Enabled, or the config could not be read — leave the certificates be\n return;\n }\n\n if (!purgeTraefikAcmeStorage()) {\n return;\n }\n\n // The running Traefik still holds what it loaded at startup\n execSilent(\"docker restart ddev-router\");\n },\n};\n","import { existsSync } from \"node:fs\";\nimport { configureDockerFirewall, DOCKER_FIREWALL_SCRIPT } from \"../docker-firewall.js\";\nimport { readToolPorts } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0012: restrict the Docker-published DDEV tool ports.\n *\n * `ufw default deny incoming` never applied to them. Docker's DNAT sits in\n * `nat/PREROUTING` and its filtering in the `DOCKER` chain of `FORWARD`, both\n * evaluated before UFW's chains, so the ports ddev-router publishes for\n * Mailpit and xhgui have been reachable on every server regardless of what\n * `ufw status` showed.\n *\n * Forward auth answers those requests with a 401, so this was never an open\n * door — but it is a single control, and it was silently missing from those\n * exact entry points until 0.1.30, which is how xhgui came to answer the\n * internet unauthenticated.\n *\n * A provider firewall does not close it either. Measured on an OVH server\n * with the Edge Firewall enabled and denying these ports: a connection from\n * another host inside OVH still completed in 114ms, while a port with nothing\n * listening was dropped and timed out at 12s. Anyone able to rent a VM from\n * the same provider sits inside that blind spot.\n *\n * Idempotent: skipped once the script is installed. The script itself removes\n * any previous copy of each rule before inserting it, so reapplying is safe.\n */\nexport const migration0012DockerUserToolPorts: Migration = {\n id: \"0012__docker_user_tool_ports\",\n description: \"Restrict Docker-published DDEV tool ports via DOCKER-USER\",\n\n run(): void {\n if (existsSync(DOCKER_FIREWALL_SCRIPT)) {\n return;\n }\n\n configureDockerFirewall(readToolPorts());\n },\n};\n","import { removeSshKexAlgorithms } from \"../hardening.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0013: leave SSH key exchange on the OpenSSH defaults.\n *\n * `setup` wrote a `KexAlgorithms curve25519-sha256@libssh.org,...` line into\n * /etc/ssh/sshd_config.d/trafic.conf. Without a `+`, `-` or `^` prefix that\n * directive replaces OpenSSH's default list rather than adding to it, and the\n * two algorithms named are classical only. The post-quantum hybrids OpenSSH\n * enables by itself — mlkem768x25519-sha256 and sntrup761x25519-sha512 — were\n * therefore excluded, so an OpenSSH 10.1+ client prints a warning on every\n * connection that the session is not using a post-quantum key exchange and\n * may be vulnerable to \"store now, decrypt later\" attacks. Confirmed via\n * `sshd -T` on Ubuntu 26.04 with OpenSSH 10.2.\n *\n * `hardenSsh` no longer writes the line, which covers fresh installs. Servers\n * set up by an earlier release still carry it, so this migration removes it\n * from the existing drop-in. Ciphers and MACs are left pinned — neither is\n * involved in the warning.\n *\n * Idempotent: the helper does nothing when the drop-in is missing or already\n * free of the line, and restores the file when `sshd -t` rejects the result.\n */\nexport const migration0013SshKexDefaults: Migration = {\n id: \"0013__ssh_kex_defaults\",\n description: \"Remove the KexAlgorithms pin from the SSH drop-in\",\n\n run(): void {\n removeSshKexAlgorithms();\n },\n};\n","import { addDockerOriginToUnattendedUpgrades } from \"../hardening.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0014: apply Docker Engine security updates unattended.\n *\n * `setup` wrote an `Unattended-Upgrade::Allowed-Origins` list covering only\n * Ubuntu's own release, security and ESM pockets. Docker Engine is installed\n * from download.docker.com, which is none of them, so `docker-ce` was never\n * upgraded unless someone ran `apt-get upgrade` by hand — a Docker security\n * release could sit unapplied on a server indefinitely. The Release file\n * served by that repository carries `Origin: Docker` and `Label: Docker CE`,\n * and its dist is the Ubuntu codename, so\n * `\"origin=Docker,codename=${distro_codename}\"` is the entry that matches it.\n *\n * DDEV, installed from its own apt repository, is deliberately not added: a\n * DDEV major landing unattended can break running previews. DDEV is updated\n * by `trafic-agent upgrade` instead, when an operator is there to see it.\n *\n * `configureUnattendedUpgrades` now writes the Docker entry, which covers\n * fresh installs. Servers set up by an earlier release still carry the old\n * list, so this migration regenerates the file — Trafic generates and owns\n * it, as its header comment states.\n *\n * Idempotent: the helper does nothing when the file is missing (a server set\n * up without hardening) or already names the Docker origin. No service\n * restart is needed — unattended-upgrades reads the config on each daily run.\n */\nexport const migration0014UnattendedUpgradesDockerOrigin: Migration = {\n id: \"0014__unattended_upgrades_docker_origin\",\n description: \"Allow unattended upgrades of Docker Engine\",\n\n run(): void {\n addDockerOriginToUnattendedUpgrades();\n },\n};\n","import { existsSync, readFileSync } from \"node:fs\";\nimport { exec, info } from \"../steps.js\";\nimport { configureTraefik, findRunningProject } from \"../ddev.js\";\nimport type { Migration } from \"../types.js\";\n\nconst DYNAMIC_CONFIG = \"/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml\";\n\n/**\n * Migration 0015: raise the Trafic catch-all routers above DDEV's own.\n *\n * DDEV 1.25.4's router image adds two catch-all routers of its own in\n * `default_config.yaml` — `ddev-router-fallback-http` and\n * `ddev-router-fallback-https`, from the `traefik_global_config_template.yaml`\n * template in ddev/ddev. They carry the rule `PathPrefix(`/`)` at priority 1,\n * on every router entry point, and point at a socat responder that answers\n * 404. Trafic's own catch-all routers used priority 1 too.\n *\n * With equal priority and identical rules Traefik picked DDEV's, so a stopped\n * project answered DDEV's 404 page instead of the Trafic waiting page, and\n * scale-to-zero never restarted it — the waiting page is what triggers the\n * restart. Observed in the access log of a live server:\n * `\"GET /rien-a-mettre.html HTTP/2.0\" 404 ...\n * \"http-443-ddev-router-fallback-https@file\"`.\n *\n * `trafic-agent upgrade` installs DDEV 1.25.4, so every upgraded server is\n * affected. Priority 2 puts the Trafic routers back in front, and is still far\n * below any project router, so per-project routing and auth are unchanged.\n *\n * Traefik reads its config from the ddev-global-cache volume, and DDEV copies\n * `~/.ddev/traefik/custom-global-config/*.yaml` into it on any `ddev start` —\n * `PushGlobalTraefikConfig` runs even when the router is already up. Starting\n * one project that is already running is therefore enough to load the new\n * priority; `ddev poweroff` would stop every preview on the server for no\n * gain, so it is not used. When nothing runs, there is nothing to push to and\n * the next project start applies it.\n *\n * Unrelated and not addressed here: DDEV 1.25.4 also logs \"Router count\n * mismatch\" warnings, because its two fallback definitions attach to every\n * entry point while its health check counts definitions. That is DDEV's own\n * check against DDEV's own routers.\n *\n * Idempotent: skipped when the config is missing, and when no `priority: 1`\n * is left in it.\n */\nexport const migration0015CatchallPriority: Migration = {\n id: \"0015__catchall_priority\",\n description: \"Raise the Traefik catch-all routers above DDEV's fallback routers\",\n\n run(): void {\n if (!existsSync(DYNAMIC_CONFIG)) {\n return;\n }\n\n if (!readFileSync(DYNAMIC_CONFIG, \"utf-8\").includes(\"priority: 1\")) {\n return;\n }\n\n // Rewrites both configs from the server's actual router ports\n configureTraefik();\n\n const project = findRunningProject();\n\n if (!project) {\n info(\"No project is running — the new priority applies on the next project start.\");\n return;\n }\n\n exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${project}'`, { silent: true });\n },\n};\n","import { info } from \"../steps.js\";\nimport {\n configureTraefik,\n findRunningProject,\n DNS_RESOLVER,\n ROUTER_COMPOSE_OVERRIDE,\n STATIC_CONFIG,\n TLS_STORE_CONFIG,\n} from \"../ddev.js\";\nimport { nodeIo, type SetupIo } from \"../io.js\";\nimport { loadConfig } from \"../../utils/config.js\";\nimport type { Migration } from \"../types.js\";\nimport type { TlsConfig } from \"../../types.js\";\n\n/**\n * Migration 0016: apply the wildcard DNS-01 certificate on servers that ask\n * for one in their config.\n *\n * `tls.dns_provider` is read by `configureTraefik`, which setup runs on a\n * fresh server. An existing server has the config but none of the files, so\n * this writes them: the resolver in `static_config.trafic.yaml`, the default\n * TLS store in `custom-global-config/0-trafic-tls.yaml`, and the provider\n * credentials in `router-compose.trafic.yaml`.\n *\n * Why the router is removed and a project started, rather than restarted\n * (the 0010 rationale, plus one more): Traefik reads its configuration from\n * the ddev-global-cache volume, and DDEV only copies `~/.ddev/traefik` into\n * that volume and merges `static_config.*.yaml` into `.static_config.yaml`\n * when a project starts. And DDEV 1.25 reads `router-compose.*.yaml` only\n * when it *recreates* the router: a running, healthy router just gets the\n * Traefik config pushed (StartDdevRouter, `needsRecreation`). Seen on a live\n * server — the static config was merged, the credentials never reached the\n * container. Removing the router first forces the recreation; `ddev start`\n * brings it back within seconds with the new environment.\n *\n * The container is found by its compose label, not by name: when compose\n * recreates a container it renames the old one `<id>_ddev-router`, and a\n * failed recreation can leave it that way. `docker restart ddev-router` then\n * silently does nothing — also seen on that server.\n *\n * Two details this depends on:\n * - Traefik's file provider keeps the FIRST `tls.stores.default` it reads in\n * directory order and logs \"TLS store default already configured,\n * skipping\" for the rest. DDEV writes an empty one in\n * `default_config.yaml`, so the agent's file is named `0-trafic-tls.yaml`\n * to sort ahead of it.\n * - DDEV keeps `certResolver: acme-tlsChallenge` on every project router, and\n * that is fine: Traefik skips a per-host ACME request once the default\n * store already holds a certificate matching the host with wildcard\n * semantics. So `use_letsencrypt` stays true — the ACME account email comes\n * from it — and the per-host quota use stops on its own.\n *\n * `ddev poweroff` is deliberately not used: the server runs live previews and\n * a project start is enough to regenerate the config and recreate the router.\n *\n * Idempotent: skipped when no DNS provider is configured, and when all three\n * files are already in place.\n */\nexport const migration0016WildcardDnsChallenge: Migration = {\n id: \"0016__wildcard_dns_challenge\",\n description: \"Apply the wildcard DNS-01 certificate where one is configured\",\n\n run(): void {\n runWildcardMigration();\n },\n};\n\n/**\n * The migration body, with its effects injected so tests can drive it.\n *\n * `tls` defaults to the server's own config: the migration runs after setup\n * wrote /etc/trafic/config.toml, so that file is the only source.\n */\nexport function runWildcardMigration(\n io: SetupIo = nodeIo,\n tls: TlsConfig = loadConfig().tls,\n): void {\n if (!tls.dnsProvider) {\n // Per-host certificates stay as they are\n return;\n }\n\n const resolverConfigured =\n io.fileExists(STATIC_CONFIG) && io.readFile(STATIC_CONFIG).includes(`${DNS_RESOLVER}:`);\n\n if (\n resolverConfigured &&\n io.fileExists(TLS_STORE_CONFIG) &&\n io.fileExists(ROUTER_COMPOSE_OVERRIDE)\n ) {\n return;\n }\n\n configureTraefik({ tls }, io);\n\n const project = findRunningProject(io);\n\n if (!project) {\n info(\"No running project — the next deploy applies the wildcard certificate\");\n return;\n }\n\n // A running router keeps its environment: DDEV only re-reads the compose\n // override when it recreates the container\n const router = findRouterContainer(io);\n\n if (router) {\n io.execSilent(`docker rm -f ${router}`);\n }\n\n // Regenerates .static_config.yaml and the router compose from the files\n // above, and recreates ddev-router with the provider credentials\n io.exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${project}'`, {\n silent: true,\n });\n}\n\n/**\n * The ddev-router container id, whatever its current name.\n *\n * Compose labels survive the `<id>_ddev-router` rename a recreation leaves\n * behind; the container name does not.\n */\nexport function findRouterContainer(io: SetupIo = nodeIo): string | undefined {\n const id = io\n .execSilent(\"docker ps -aq --filter label=com.docker.compose.service=ddev-router\")\n .trim();\n\n return id.split(\"\\n\")[0] || undefined;\n}\n","import { addDdevAptRepo, DDEV_SOURCES } from \"../ddev.js\";\nimport { nodeIo, type SetupIo } from \"../io.js\";\nimport type { Migration } from \"../types.js\";\n\n/** The Gemfury source file setup wrote before this release. */\nconst OLD_SOURCES = \"/etc/apt/sources.list.d/ddev.list\";\n\n/** The dearmoured keyring that source was signed by. */\nconst OLD_KEYRING = \"/etc/apt/keyrings/ddev.gpg\";\n\n/**\n * Migration 0017: move the DDEV apt repository from Gemfury to Cloudsmith.\n *\n * DDEV 1.25.4 moved its Debian and RPM packages to Cloudsmith\n * (ddev/ddev#8698) and its install docs now name\n * `https://packages.ddev.com/public/deb/ubuntu` with the key at\n * `https://packages.ddev.com/public/gpg.key`. The former Gemfury host,\n * `pkg.ddev.com`, still receives packages and DDEV has announced no\n * retirement date, so nothing is broken today — but a server left on the old\n * repository would stop seeing new DDEV releases the day it is switched off,\n * and `trafic-agent upgrade` runs `apt-get install --only-upgrade ddev`\n * against whatever repository is configured.\n *\n * The old files are removed rather than left alongside the new ones, as\n * DDEV's own instructions do: two sources offering the same package make apt\n * pick by version and would leave the Gemfury host in the picture for as\n * long as it answers. `setup` writes only the Cloudsmith source, so a fresh\n * server and an upgraded one end up identical.\n *\n * Idempotent: skipped once the Cloudsmith source file is in place, and each\n * removal is guarded by the file's existence.\n */\nexport const migration0017DdevAptCloudsmith: Migration = {\n id: \"0017__ddev_apt_cloudsmith\",\n description: \"Move the DDEV apt repository from Gemfury to Cloudsmith\",\n\n run(): void {\n runDdevAptCloudsmithMigration();\n },\n};\n\n/** The migration body, with its effects injected so tests can drive it. */\nexport function runDdevAptCloudsmithMigration(io: SetupIo = nodeIo): void {\n if (io.fileExists(DDEV_SOURCES)) {\n // Already on Cloudsmith\n return;\n }\n\n if (io.fileExists(OLD_SOURCES)) {\n io.exec(`rm -f ${OLD_SOURCES}`, { silent: true });\n }\n\n if (io.fileExists(OLD_KEYRING)) {\n io.exec(`rm -f ${OLD_KEYRING}`, { silent: true });\n }\n\n addDdevAptRepo(io);\n\n // So the next apt-get install --only-upgrade ddev sees the new repository\n io.exec(\"apt-get update -qq\", { silent: true });\n}\n","import { addDockerAddressPools, restartDocker } from \"../docker.js\";\nimport { nodeIo, type SetupIo } from \"../io.js\";\nimport type { Migration } from \"../types.js\";\n\n/**\n * Migration 0018: give Docker enough address space for one network per\n * project.\n *\n * `setup` wrote a daemon.json without `default-address-pools`, so Docker\n * used its built-in pools: about 31 networks. Each DDEV project creates its\n * own `ddev-<name>_default` network, and a server with ~30 projects failed\n * to start the next one with \"all predefined address pools have been fully\n * subnetted\". The last 16 of those built-in networks are in 192.168.x,\n * outside the 172.16.0.0/12 range the firewall allows to reach the agent.\n *\n * `configureDocker` now writes /24 pools in 172.16.0.0/12, which covers\n * fresh installs. This migration adds them to existing servers.\n *\n * Docker reads the pools only at start, so the daemon is restarted.\n * `live-restore`, set by setup, keeps the running containers up. Existing\n * networks keep their subnets; only new networks get a /24, and Docker skips\n * subnets an existing network already uses.\n *\n * Changed after release: the first version ran `systemctl restart docker`\n * directly. On servers set up before 0.1.47 that restart also switched the\n * storage driver, which `live-restore` cannot survive, and orphaned every\n * running container (see restartDocker). The result on a server is the same\n * either way; only servers that have not run it yet get the safe restart.\n * Servers that already ran it are repaired by migration 0019.\n *\n * Idempotent: does nothing when the config already sets pools.\n */\nexport const migration0018DockerAddressPools: Migration = {\n id: \"0018__docker_address_pools\",\n description: \"Give Docker address pools for one network per project\",\n\n run(): void {\n runDockerAddressPoolsMigration();\n },\n};\n\n/**\n * The migration body, with its effects injected so tests can drive it.\n */\nexport function runDockerAddressPoolsMigration(io: SetupIo = nodeIo): void {\n if (addDockerAddressPools(io)) {\n restartDocker(io);\n }\n}\n","import { info, warn } from \"../steps.js\";\nimport { nodeIo, type SetupIo } from \"../io.js\";\nimport type { Migration } from \"../types.js\";\n\n/** How long a container gets to stop after SIGTERM, in seconds. */\nexport const STOP_TIMEOUT = 600;\n\n/**\n * Migration 0019: stop the containers Docker lost track of in migration 0018.\n *\n * 0018 restarted Docker to apply the address pools. On a server set up before\n * 0.1.47, Docker ran the containerd image store (`overlayfs`) although\n * daemon.json asked for `overlay2`, and the restart switched the driver.\n * dockerd skips containers created with another driver, so it forgot every\n * running container — but `live-restore` had left their processes running.\n * Those orphans keep the locks on the project volumes: each new database\n * container exits on \"Can't lock aria control file\", each Elasticsearch on\n * \"failed to obtain node locks\", and the project never starts again. Seen\n * on a live server: 66 orphans, 27 of them databases.\n *\n * containerd still knows the orphans: they are the tasks in the `moby`\n * namespace with no matching Docker container. Each one gets SIGTERM, which\n * MariaDB, MySQL and Elasticsearch handle as a clean shutdown, and as long as\n * it needs. No SIGKILL: a database killed mid-write is the one outcome worse\n * than the outage, so a process that does not stop is reported and left\n * alone. The stopped tasks are then deleted, which ends their shims.\n *\n * Nothing is restarted here: the volumes are free again, and the agent starts\n * each project on its next request, on the new driver.\n *\n * Idempotent: does nothing when every containerd task belongs to a container\n * Docker knows.\n */\nexport const migration0019RecoverOrphanContainers: Migration = {\n id: \"0019__recover_orphan_containers\",\n description: \"Stop the containers Docker lost track of when it changed storage driver\",\n\n run(): void {\n recoverOrphanContainers();\n },\n};\n\n/** A containerd task: the container id, its PID 1 on the host, its status. */\ninterface Task {\n id: string;\n pid: string;\n status: string;\n}\n\n/**\n * The migration body, with its effects injected so tests can drive it.\n */\nexport function recoverOrphanContainers(io: SetupIo = nodeIo): void {\n const known = new Set(io.execSilent(\"docker ps -aq --no-trunc\").split(\"\\n\").filter(Boolean));\n const orphans = parseTasks(io.execSilent(\"ctr -n moby tasks ls\")).filter(\n (task) => !known.has(task.id),\n );\n\n if (orphans.length === 0) {\n return;\n }\n\n info(`Stopping ${orphans.length} container(s) Docker no longer tracks`);\n\n const running = orphans.filter((task) => task.status === \"RUNNING\");\n\n for (const task of running) {\n io.execSilent(`kill -TERM ${task.pid}`);\n }\n\n const stuck = running.filter((task) => {\n io.execSilent(`timeout ${STOP_TIMEOUT} tail --pid=${task.pid} -f /dev/null`);\n return io.fileExists(`/proc/${task.pid}`);\n });\n\n for (const task of orphans.filter((task) => !stuck.includes(task))) {\n io.execSilent(`ctr -n moby tasks delete ${task.id}`);\n }\n\n for (const task of stuck) {\n warn(`Container ${task.id.slice(0, 12)} (PID ${task.pid}) did not stop, left running`);\n }\n}\n\n/**\n * Parse `ctr tasks ls`: a header, then one `TASK PID STATUS` row per task.\n */\nfunction parseTasks(output: string): Task[] {\n return output\n .split(\"\\n\")\n .slice(1)\n .map((line) => line.trim().split(/\\s+/))\n .filter((columns) => columns.length >= 3)\n .map(([id, pid, status]) => ({ id: id!, pid: pid!, status: status! }));\n}\n","import { BACKUP_DIR, createBackupDir, createSystemdService } from \"../agent.js\";\nimport { nodeIo, type SetupIo } from \"../io.js\";\nimport type { Migration } from \"../types.js\";\n\nconst UNIT_PATH = \"/etc/systemd/system/trafic-agent.service\";\n\n/**\n * Migration 0020: let the agent service write to the backup directory.\n *\n * The agent now removes old backups from /var/backups/trafic in a periodic\n * sweep, but the unit runs with `ProtectSystem=strict` and only lists\n * /var/lib/trafic and /home/ddev in `ReadWritePaths`, so every removal would\n * fail with a read-only file system error.\n *\n * Creates the directory first: hardening created it, but a server set up with\n * `--no-hardening` has none, and the ddev user cannot create it in\n * /var/backups. Then rewrites the unit, which lists the directory with the\n * `-` prefix: systemd refuses to start a unit whose `ReadWritePaths` names a\n * missing path, unless the path has that prefix.\n *\n * Idempotent: the unit is rewritten only while it does not list the backup\n * directory with the `-` prefix.\n */\nexport const migration0020BackupDir: Migration = {\n id: \"0020__backup_dir\",\n description: \"Let the agent service write to the backup directory\",\n\n run(): void {\n runBackupDirMigration();\n },\n};\n\n/** The migration body, with its effects injected so tests can drive it. */\nexport function runBackupDirMigration(io: SetupIo = nodeIo): void {\n createBackupDir(io);\n\n const unitAllowsBackups =\n io.fileExists(UNIT_PATH) &&\n new RegExp(`^ReadWritePaths=.*\\\\s-${BACKUP_DIR}(\\\\s|$)`, \"m\").test(io.readFile(UNIT_PATH));\n\n if (!unitAllowsBackups) {\n // Rewrites the unit, then reloads systemd; upgrade restarts the agent\n createSystemdService(io);\n }\n}\n","import { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport type { Migration, MigrationState, MigrationListEntry } from \"../types.js\";\nimport { migration0001DdevAptRepo } from \"./0001__ddev_apt_repo.js\";\nimport { migration0002MkcertDdevUser } from \"./0002__mkcert_ddev_user.js\";\nimport { migration0003DdevRouterBindAllInterfaces } from \"./0003__ddev_router_bind_all_interfaces.js\";\nimport { migration0004DdevHostnameSudoers } from \"./0004__ddev_hostname_sudoers.js\";\nimport { migration0005TraefikGatewayIp } from \"./0005__traefik_gateway_ip.js\";\nimport { migration0006TraefikGatewayIpFix } from \"./0006__traefik_gateway_ip_fix.js\";\nimport { migration0007UfwDockerTraficPort } from \"./0007__ufw_docker_trafic_port.js\";\nimport { migration0008TraefikEntrypointMiddleware } from \"./0008__traefik_entrypoint_middleware.js\";\nimport { migration0009ScaleToZeroFixes } from \"./0009__scale_to_zero_fixes.js\";\nimport { migration0010CatchallEntrypoints } from \"./0010__catchall_entrypoints.js\";\nimport { migration0011PurgeAcmeWhenDisabled } from \"./0011__purge_acme_when_disabled.js\";\nimport { migration0012DockerUserToolPorts } from \"./0012__docker_user_tool_ports.js\";\nimport { migration0013SshKexDefaults } from \"./0013__ssh_kex_defaults.js\";\nimport { migration0014UnattendedUpgradesDockerOrigin } from \"./0014__unattended_upgrades_docker_origin.js\";\nimport { migration0015CatchallPriority } from \"./0015__catchall_priority.js\";\nimport { migration0016WildcardDnsChallenge } from \"./0016__wildcard_dns_challenge.js\";\nimport { migration0017DdevAptCloudsmith } from \"./0017__ddev_apt_cloudsmith.js\";\nimport { migration0018DockerAddressPools } from \"./0018__docker_address_pools.js\";\nimport { migration0019RecoverOrphanContainers } from \"./0019__recover_orphan_containers.js\";\nimport { migration0020BackupDir } from \"./0020__backup_dir.js\";\n\n/** Path to the persisted migration state file */\nexport const MIGRATIONS_STATE_FILE = \"/etc/trafic/.migrations.json\";\n\n/** Ordered registry of all known migrations */\nexport const ALL_MIGRATIONS: Migration[] = [\n migration0001DdevAptRepo,\n migration0002MkcertDdevUser,\n migration0003DdevRouterBindAllInterfaces,\n migration0004DdevHostnameSudoers,\n migration0005TraefikGatewayIp,\n migration0006TraefikGatewayIpFix,\n migration0007UfwDockerTraficPort,\n migration0008TraefikEntrypointMiddleware,\n migration0009ScaleToZeroFixes,\n migration0010CatchallEntrypoints,\n migration0011PurgeAcmeWhenDisabled,\n migration0012DockerUserToolPorts,\n migration0013SshKexDefaults,\n migration0014UnattendedUpgradesDockerOrigin,\n migration0015CatchallPriority,\n migration0016WildcardDnsChallenge,\n migration0018DockerAddressPools,\n migration0017DdevAptCloudsmith,\n migration0019RecoverOrphanContainers,\n migration0020BackupDir,\n];\n\n// ---------------------------------------------------------------------------\n// State helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Load the current migration state from disk.\n * Returns an empty state if the file does not exist yet.\n */\nexport function loadMigrationState(): MigrationState {\n if (!existsSync(MIGRATIONS_STATE_FILE)) {\n return { applied: [] };\n }\n\n try {\n const raw = readFileSync(MIGRATIONS_STATE_FILE, \"utf-8\");\n const parsed = JSON.parse(raw) as unknown;\n if (\n parsed !== null &&\n typeof parsed === \"object\" &&\n \"applied\" in parsed &&\n Array.isArray((parsed as { applied: unknown }).applied)\n ) {\n return parsed as MigrationState;\n }\n } catch {\n // Corrupted file — start fresh\n }\n\n return { applied: [] };\n}\n\n/**\n * Persist the migration state to disk.\n * Creates /etc/trafic/ if it does not exist.\n */\nexport function saveMigrationState(state: MigrationState): void {\n mkdirSync(\"/etc/trafic\", { recursive: true });\n writeFileSync(MIGRATIONS_STATE_FILE, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Return all migrations with their current status.\n */\nexport function listMigrations(): MigrationListEntry[] {\n const state = loadMigrationState();\n return ALL_MIGRATIONS.map((migration) => ({\n migration,\n status: state.applied.includes(migration.id) ? \"applied\" : \"pending\",\n }));\n}\n\n/**\n * Run all pending migrations and persist state after each one.\n *\n * When `dryRun` is true the runner prints what it would do without\n * executing anything or touching the state file.\n */\nexport function runPendingMigrations(dryRun = false): void {\n const state = loadMigrationState();\n const pending = ALL_MIGRATIONS.filter((m) => !state.applied.includes(m.id));\n\n if (pending.length === 0) {\n console.log(\" No pending migrations.\");\n return;\n }\n\n for (const migration of pending) {\n console.log(`\\n → \\x1b[1m${migration.id}\\x1b[0m ${migration.description}`);\n\n if (dryRun) {\n console.log(` \\x1b[90m(dry-run: skipped)\\x1b[0m`);\n continue;\n }\n\n migration.run();\n\n // Persist state immediately so a partial failure leaves a consistent state\n state.applied.push(migration.id);\n saveMigrationState(state);\n\n console.log(` \\x1b[32m✓ Applied\\x1b[0m`);\n }\n}\n\n/**\n * Mark all known migrations as applied without running them.\n *\n * Called at the end of a fresh `setup()` run: a brand-new server has\n * already been configured correctly, so migrations that fix older\n * installations are not needed.\n */\nexport function markAllMigrationsApplied(): void {\n const state = loadMigrationState();\n\n for (const migration of ALL_MIGRATIONS) {\n if (!state.applied.includes(migration.id)) {\n state.applied.push(migration.id);\n }\n }\n\n saveMigrationState(state);\n}\n","import type { SetupOptions } from \"./types.js\";\nimport { step, info, error, setDryRun, resetSteps, isRoot } from \"./steps.js\";\nimport { createDdevUser } from \"./user.js\";\nimport { installDocker, configureDocker, setupDockerPrune } from \"./docker.js\";\nimport {\n installSystemDeps,\n installDdev,\n configureDdev,\n configureTraefik,\n readToolPorts,\n} from \"./ddev.js\";\nimport { configureDockerFirewall } from \"./docker-firewall.js\";\nimport { installNode, installAgent, createAgentConfig, createSystemdService } from \"./agent.js\";\nimport { hardenServer } from \"./hardening.js\";\nimport { runAudit, printAuditResults } from \"./audit.js\";\nimport { markAllMigrationsApplied } from \"./migrations/index.js\";\n\nexport * from \"./types.js\";\nexport * from \"./audit.js\";\n\n/**\n * Run the full server setup\n */\nexport async function setup(options: SetupOptions): Promise<void> {\n resetSteps();\n setDryRun(options.dryRun ?? false);\n\n console.log(\"\\n\\x1b[1m🚦 Trafic Server Setup\\x1b[0m\\n\");\n\n if (options.dryRun) {\n console.log(\"\\x1b[33m⚠ Dry-run mode: no changes will be made\\x1b[0m\\n\");\n }\n\n // Check root\n if (!isRoot() && !options.dryRun) {\n error(\"This command must be run as root\");\n console.log(\" Run: sudo trafic-agent setup ...\");\n process.exit(1);\n }\n\n info(`TLD: ${options.tld}`);\n if (options.email) {\n info(`Let's Encrypt email: ${options.email}`);\n }\n if (options.dnsProvider) {\n info(`Wildcard certificate: DNS-01 via ${options.dnsProvider}`);\n }\n if (options.noHardening) {\n info(\"Hardening: disabled\");\n }\n if (options.noRootSsh) {\n info(\"Root SSH login: disabled\");\n }\n\n try {\n // Step 1: Install system dependencies\n installSystemDeps();\n\n // Step 2: Create ddev user\n createDdevUser();\n\n // Step 3: Install Docker\n if (!options.noDocker) {\n installDocker();\n configureDocker();\n setupDockerPrune();\n } else {\n step(\"Install Docker\");\n info(\"Skipped (--no-docker)\");\n }\n\n // Step 4: Install DDEV\n if (!options.noDdev) {\n installDdev();\n configureDdev(options.tld, options.email);\n configureTraefik({\n tld: options.tld,\n tls: {\n dnsProvider: options.dnsProvider,\n caServer: options.caServer,\n dnsEnv: options.dnsEnv ?? {},\n },\n });\n // After DDEV, so the tool ports are known. UFW cannot reach these:\n // Docker's rules run before its chains.\n configureDockerFirewall(readToolPorts());\n } else {\n step(\"Install DDEV\");\n info(\"Skipped (--no-ddev)\");\n }\n\n // Step 5: Install Node.js\n installNode();\n\n // Step 6: Install and configure agent\n installAgent();\n createAgentConfig({\n tld: options.tld,\n trustedProxyHops: options.trustedProxyHops,\n dnsProvider: options.dnsProvider,\n dnsEnv: options.dnsEnv,\n });\n createSystemdService();\n\n // Step 7: Server hardening\n if (!options.noHardening) {\n hardenServer({\n sshUsers: options.sshUsers ?? [\"ddev\"],\n noRootSsh: options.noRootSsh,\n });\n } else {\n step(\"Server hardening\");\n info(\"Skipped (--no-hardening)\");\n }\n\n // Mark all migrations as applied — fresh servers are already up-to-date\n markAllMigrationsApplied();\n\n // Done\n console.log(\"\\n\\x1b[32m✓ Setup complete!\\x1b[0m\\n\");\n console.log(\"Next steps:\");\n console.log(` 1. Edit /etc/trafic/config.toml to configure authentication`);\n console.log(` 2. Add SSH public keys to /home/ddev/.ssh/authorized_keys`);\n console.log(` 3. Deploy your first project:`);\n console.log(` npx @studiometa/trafic-cli deploy --host=<this-server> --name=my-app`);\n console.log(`\\nYour projects will be available at https://<name>.${options.tld}`);\n } catch (err) {\n error(`Setup failed: ${err instanceof Error ? err.message : err}`);\n process.exit(1);\n }\n}\n\n/**\n * Run security audit\n */\nexport async function audit(): Promise<void> {\n const checks = runAudit();\n printAuditResults(checks);\n\n const failed = checks.filter((c) => c.status === \"fail\").length;\n if (failed > 0) {\n process.exit(1);\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /** Days the agent keeps its access logs. */
2
+ export declare const ACCESS_LOG_RETAIN_DAYS = 30;
3
+ /**
4
+ * Start the access log retention sweep. Each request logs a row, so without
5
+ * it the database grows for good. The database must be open.
6
+ */
7
+ export declare function startAccessLogSweepScheduler(): NodeJS.Timeout;
8
+ //# sourceMappingURL=access-logs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-logs.d.ts","sourceRoot":"","sources":["../../src/tasks/access-logs.ts"],"names":[],"mappings":"AAEA,4CAA4C;AAC5C,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAkBzC;;;GAGG;AACH,wBAAgB,4BAA4B,IAAI,MAAM,CAAC,OAAO,CAQ7D"}
@@ -0,0 +1,48 @@
1
+ import { type DdevRunner } from "../utils/ddev.js";
2
+ import type { AgentConfig, BackupConfig } from "../types.js";
3
+ /**
4
+ * Default time limit of a backup, the database dump and the files archive
5
+ * together: a large database takes minutes, and upload directories can hold
6
+ * gigabytes.
7
+ */
8
+ export declare const BACKUP_TIMEOUT_MS: number;
9
+ /** The name of a backup directory, e.g. `2026-10-14T07-40-24Z`. */
10
+ export declare function backupTimestamp(date: Date): string;
11
+ /** Options of a backup. */
12
+ export interface BackupOptions {
13
+ /**
14
+ * Time limit of the backup: the database dump, the reading of the upload
15
+ * directories and the files archive together (default:
16
+ * BACKUP_TIMEOUT_MS). Starting and stopping the project are not part of
17
+ * it.
18
+ */
19
+ timeoutMs?: number;
20
+ }
21
+ /**
22
+ * Back up a DDEV project to `<backupDir>/<name>/<timestamp>`.
23
+ *
24
+ * The backup holds:
25
+ * - `db.sql.gz`: the `ddev export-db` dump, unless the project omits the db
26
+ * container
27
+ * - `files.tar.gz`: the upload directories, with paths relative to the
28
+ * project directory, unless the project has none
29
+ *
30
+ * Both need the project running, so a stopped project is started, then
31
+ * stopped again. A failed backup, or a backup that takes longer than its
32
+ * time limit (`timeoutMs`), throws and leaves no backup directory. Returns
33
+ * the backup directory.
34
+ */
35
+ export declare function backupProject(name: string, dir: string, backupDir: string, options?: BackupOptions, run?: DdevRunner): Promise<string>;
36
+ /**
37
+ * Remove the backups older than `retainDays`, and the project directories
38
+ * that this removal leaves empty. Only directories with a backup timestamp
39
+ * name are removed, and a project directory only when this sweep removed a
40
+ * backup from it: `dir` can be a shared directory, with empty directories
41
+ * that are not backups. Returns the number of backups removed.
42
+ */
43
+ export declare function removeOldBackups(config: BackupConfig, now?: number): number;
44
+ /**
45
+ * Start the backup retention sweep
46
+ */
47
+ export declare function startBackupSweepScheduler(config: AgentConfig): NodeJS.Timeout;
48
+ //# sourceMappingURL=backup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup.d.ts","sourceRoot":"","sources":["../../src/tasks/backup.ts"],"names":[],"mappings":"AAIA,OAAO,EAKL,KAAK,UAAU,EAChB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAO7D;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,QAAiB,CAAC;AAahD,mEAAmE;AACnE,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAElD;AAkFD,2BAA2B;AAC3B,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAiHD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,aAAkB,EAC3B,GAAG,GAAE,UAA2B,GAC/B,OAAO,CAAC,MAAM,CAAC,CA0CjB;AAaD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,SAAa,GAAG,MAAM,CAkC/E;AAWD;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAAC,OAAO,CAU7E"}
@@ -0,0 +1,71 @@
1
+ import { type BackupOptions } from "./backup.js";
2
+ import { type DdevRunner } from "../utils/ddev.js";
3
+ import type { AgentConfig } from "../types.js";
4
+ /**
5
+ * Read the agent config for `trafic-agent destroy`. A permission error says
6
+ * which user to connect as.
7
+ */
8
+ export declare function loadDestroyConfig(configPath?: string): AgentConfig;
9
+ /**
10
+ * Open the agent database for `trafic-agent destroy`. A permission error
11
+ * says which user to connect as.
12
+ */
13
+ export declare function openDestroyDb(dbPath: string): void;
14
+ /**
15
+ * Find the directory of a project, and refuse one outside the projects
16
+ * directory.
17
+ *
18
+ * DDEV's project list gives the real app root. A project DDEV has already
19
+ * forgotten is looked for at `<projects_dir>/<name>`, where `trafic deploy`
20
+ * puts it, so its directory can still be cleaned up.
21
+ *
22
+ * The check stands between a name typed on a command line and an
23
+ * `rm -rf`: an empty name, `..` or an app root of `/` must never reach it.
24
+ * The path is checked as written, then, when it exists, with its symbolic
25
+ * links followed: a symlinked project directory or parent directory could
26
+ * otherwise point `ddev delete` and the removal at a directory outside the
27
+ * projects directory.
28
+ */
29
+ export declare function resolveProjectDir(name: string, config: AgentConfig): string;
30
+ /**
31
+ * Remove a project from the server.
32
+ *
33
+ * Steps:
34
+ * 1. With `backup`, back up the database and the upload directories, all
35
+ * within `timeoutMs` when it is given
36
+ * 2. Delete the DDEV project (containers, volumes, no snapshot)
37
+ * 3. Remove the project directory
38
+ * 4. Delete the project's records from the agent database
39
+ *
40
+ * A failed backup throws before anything is removed.
41
+ *
42
+ * A backup can take many minutes. With `beforeDelete`, the removal calls it
43
+ * after the backup, just before step 2, and stops without removing anything
44
+ * when it returns false: a scheduled removal that a deploy cancelled
45
+ * meanwhile must not delete the new deploy.
46
+ *
47
+ * A failed `ddev delete` does not stop the removal: a project DDEV has
48
+ * already forgotten could otherwise never be cleaned up, and the next deploy
49
+ * would find a stale directory.
50
+ *
51
+ * While the removal runs, the project has the status "destroying", which the
52
+ * idle sweep and max_running_projects never stop, and the waiting page never
53
+ * starts: the backup can take many minutes, and a stop in the middle of it
54
+ * fails the backup. The status is in the database, so the service sees it
55
+ * when `trafic-agent destroy` runs from the command line. A removal that
56
+ * fails sets the status back: "running" when it was, else "stopped". A
57
+ * removal that is killed cannot: the status records the id of its process,
58
+ * and the service sets it back to "running" once that process is gone (see
59
+ * releaseStaleStatuses).
60
+ *
61
+ * The database must be open (`initDb`). Returns false when there was no
62
+ * project directory or the removal was cancelled. Without a project
63
+ * directory, the project's records are still deleted, so a retry after a run
64
+ * that failed between steps 2 and 3 cleans them up. A cancelled removal
65
+ * removes nothing and sets the status back.
66
+ */
67
+ export declare function destroyProject(name: string, config: AgentConfig, options?: {
68
+ backup?: boolean;
69
+ beforeDelete?: () => boolean;
70
+ } & BackupOptions, run?: DdevRunner): Promise<boolean>;
71
+ //# sourceMappingURL=destroy-project.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"destroy-project.d.ts","sourceRoot":"","sources":["../../src/tasks/destroy-project.ts"],"names":[],"mappings":"AAEA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAEhE,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAgB/C;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,WAAW,CAYlE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAelD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,CAmB3E;AAsFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,EACnB,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAA;CAAE,GAAG,aAAkB,EAChF,GAAG,GAAE,UAA2B,GAC/B,OAAO,CAAC,OAAO,CAAC,CAiClB"}
@@ -0,0 +1,41 @@
1
+ import { type Clock } from "../utils/db.js";
2
+ import { type DdevRunner } from "../utils/ddev.js";
3
+ import type { AgentConfig } from "../types.js";
4
+ /**
5
+ * Schedule the removal of a project, `delay` from now ("48h", "1h30m").
6
+ *
7
+ * The date goes to the agent database, not to the project: a redeploy does
8
+ * not reliably rewrite the project files, and the sweep must find the date
9
+ * even for a project DDEV has forgotten. The name is checked now, so a bad
10
+ * name or a wrong `projects_dir` fails the CI job, not a sweep two days
11
+ * later. With `backup`, the removal backs the project up first, within
12
+ * `timeoutMs` when it is given. A removal that the sweep already started
13
+ * cannot be scheduled again. Returns the date of the removal.
14
+ */
15
+ export declare function scheduleProjectDestroy(name: string, delay: string, config: AgentConfig, options?: {
16
+ backup?: boolean;
17
+ timeoutMs?: number;
18
+ }, now?: Clock): Date;
19
+ /**
20
+ * Remove each project whose scheduled removal is due, through the same
21
+ * removal as `trafic-agent destroy`, with a backup when one was asked for.
22
+ *
23
+ * A failure on one project does not stop the sweep: it is logged, and the
24
+ * removal stays scheduled so the next sweep tries again. A project already
25
+ * gone has its schedule cleared.
26
+ *
27
+ * A removal can take minutes, and a deploy or a `--cancel` can cancel the
28
+ * removal of a project meanwhile. So the sweep reads each removal again just
29
+ * before it starts, and once more after the backup. There, it marks the
30
+ * removal as started (startScheduledDestroy) before `ddev delete`: a later
31
+ * cancel is refused, so the deploy fails instead of deploying a project
32
+ * that is about to go. A retry of a started removal does not back the
33
+ * project up again: the backup exists, and DDEV may have forgotten the
34
+ * project already.
35
+ */
36
+ export declare function destroyScheduledProjects(config: AgentConfig, now?: Clock, run?: DdevRunner): Promise<void>;
37
+ /**
38
+ * Start the scheduled removal sweep, on the idle check interval
39
+ */
40
+ export declare function startScheduledDestroyScheduler(config: AgentConfig): NodeJS.Timeout;
41
+ //# sourceMappingURL=destroy-scheduled.d.ts.map