@studiometa/trafic-agent 0.1.42 → 0.1.44

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.
package/README.md CHANGED
@@ -42,6 +42,8 @@ trafic-agent setup --tld previews.example.com
42
42
  - Root access (for initial setup)
43
43
  - Wildcard DNS pointing to the server
44
44
 
45
+ **Automatic security updates.** Setup installs `unattended-upgrades` and writes `/etc/apt/apt.conf.d/50unattended-upgrades`, allowing Ubuntu's release, security and ESM pockets plus Docker Engine (`origin=Docker`, from `download.docker.com`). Docker security releases are therefore applied daily with the rest. Note that a Docker Engine update restarts the daemon and stops running preview containers; the waiting page starts a project again on its next request. DDEV is deliberately excluded — it is updated by `trafic-agent upgrade` instead. The server never reboots on its own (`Unattended-Upgrade::Automatic-Reboot "false"`).
46
+
45
47
  ### `trafic-agent upgrade` / `trafic-agent update`
46
48
 
47
49
  Upgrade the server to the latest version of `trafic-agent` in one command. `update` is an alias for `upgrade`.
@@ -50,7 +52,10 @@ Steps:
50
52
  1. **Check for updates** — queries the npm registry for the latest version
51
53
  2. **Install** — runs `npm install -g @studiometa/trafic-agent@latest` if a newer version is available
52
54
  3. **Migrations** — runs any pending server migrations (forward-only, idempotent)
53
- 4. **Restart** — restarts the `trafic-agent` systemd service
55
+ 4. **DDEV** — upgrades the `ddev` package from its apt repository, printing the version before and after. Skipped when DDEV is not installed; a failure is reported as a warning and does not stop the upgrade
56
+ 5. **Restart** — restarts the `trafic-agent` systemd service
57
+
58
+ DDEV is updated here rather than by `unattended-upgrades` on purpose: a DDEV major landing unattended can break running previews, so it happens when an operator runs the command and can watch the result.
54
59
 
55
60
  Fresh servers set up with `trafic-agent setup` have all migrations automatically marked as applied, so migrations only run when needed on existing deployments.
56
61
 
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { g as validateConfig, i as runPendingMigrations, m as loadConfig, n as setup, r as listMigrations, s as startIdleScheduler, t as audit } from "./setup-BIfcVIa7.js";
3
- import { L as step, N as info, P as isRoot, R as success, b as closeDb, j as exec, s as startServer, z as warn } from "./server-BAjHxgqh.js";
2
+ import { g as validateConfig, i as runPendingMigrations, m as loadConfig, n as setup, r as listMigrations, s as startIdleScheduler, t as audit } from "./setup-BrMbM8w4.js";
3
+ import { L as step, M as execSilent, N as info, P as isRoot, R as success, b as closeDb, j as exec, s as startServer, z as warn } from "./server-BAjHxgqh.js";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { execSync, spawnSync } from "node:child_process";
6
6
  import { parseArgs } from "node:util";
@@ -92,10 +92,28 @@ function reExecNewBinary(args) {
92
92
  function restartAgentService(dryRun) {
93
93
  exec("systemctl restart trafic-agent", { silent: !dryRun });
94
94
  }
95
+ /**
96
+ * The installed apt package version of DDEV, or null when it cannot be read.
97
+ */
98
+ function ddevVersion() {
99
+ return execSilent("dpkg-query --show --showformat='${Version}' ddev") || null;
100
+ }
101
+ /**
102
+ * Upgrade DDEV from its apt repository. Throws when apt fails.
103
+ *
104
+ * DDEV is left out of unattended-upgrades on purpose — a major landing on its
105
+ * own can break running previews — so it is updated here, where an operator
106
+ * is watching. `--only-upgrade` never installs DDEV on a server that does not
107
+ * already have it.
108
+ */
109
+ function upgradeDdev(dryRun) {
110
+ exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update", { silent: !dryRun });
111
+ exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --only-upgrade ddev", { silent: !dryRun });
112
+ }
95
113
  /** The real collaborators, used unless a caller passes their own. */
96
114
  function nodeUpgradeIo() {
97
115
  return {
98
- currentVersion: "0.1.42",
116
+ currentVersion: "0.1.44",
99
117
  alreadyReExeced: process.env["TRAFIC_UPGRADE_REEXEC"] === "1",
100
118
  isRoot,
101
119
  fetchLatestVersion,
@@ -103,7 +121,9 @@ function nodeUpgradeIo() {
103
121
  getInstalledVersion,
104
122
  reExecNewBinary,
105
123
  restartAgentService,
106
- runPendingMigrations
124
+ runPendingMigrations,
125
+ ddevVersion,
126
+ upgradeDdev
107
127
  };
108
128
  }
109
129
  /**
@@ -112,7 +132,8 @@ function nodeUpgradeIo() {
112
132
  * 2. Install it globally if one is found — then re-exec the new binary
113
133
  * so that steps 3 and 4 run with the new migration registry
114
134
  * 3. Run pending migrations
115
- * 4. Restart the systemd service
135
+ * 4. Update DDEV from its apt repository
136
+ * 5. Restart the systemd service
116
137
  */
117
138
  function runUpgrade(dryRun = false, reExecArgs, io = nodeUpgradeIo()) {
118
139
  if (!io.isRoot() && !dryRun) {
@@ -140,10 +161,36 @@ function runUpgrade(dryRun = false, reExecArgs, io = nodeUpgradeIo()) {
140
161
  } else success(`Already up to date (${current})`);
141
162
  step("Run pending migrations");
142
163
  io.runPendingMigrations(dryRun);
164
+ step("Update DDEV");
165
+ upgradeDdevStep(dryRun, io);
143
166
  step("Restart trafic-agent service");
144
167
  io.restartAgentService(dryRun);
145
168
  if (!dryRun) success("Service restarted");
146
169
  }
170
+ /**
171
+ * Update DDEV, reporting the version on either side of the apt call.
172
+ *
173
+ * A failed apt run must not cost the server its service restart, so the
174
+ * error is warned about and the upgrade carries on.
175
+ */
176
+ function upgradeDdevStep(dryRun, io) {
177
+ const before = io.ddevVersion();
178
+ if (!dryRun && before === null) {
179
+ info("DDEV is not installed — skipping");
180
+ return;
181
+ }
182
+ if (before) info(`Current: ${before}`);
183
+ try {
184
+ io.upgradeDdev(dryRun);
185
+ } catch (err) {
186
+ warn(`DDEV update failed: ${err instanceof Error ? err.message : String(err)}`);
187
+ return;
188
+ }
189
+ if (dryRun) return;
190
+ const after = io.ddevVersion();
191
+ if (after && after !== before) success(`DDEV updated: ${after}`);
192
+ else success("DDEV up to date");
193
+ }
147
194
  //#endregion
148
195
  //#region src/cli.ts
149
196
  var HELP = `
@@ -205,7 +252,7 @@ function printHelp() {
205
252
  console.log(HELP.trim());
206
253
  }
207
254
  function printVersion() {
208
- console.log("0.1.42");
255
+ console.log("0.1.44");
209
256
  }
210
257
  async function runStart(values) {
211
258
  const config = loadConfig(values.config);
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../src/setup/upgrade.ts","../src/cli.ts"],"sourcesContent":["import { execSync, spawnSync } from \"node:child_process\";\nimport { readFileSync } from \"node:fs\";\nimport { step, success, info, warn, exec, isRoot } from \"./steps.js\";\nimport { runPendingMigrations } from \"./migrations/index.js\";\n\ndeclare const __VERSION__: string;\n\n/**\n * Fetch the latest published version of @studiometa/trafic-agent from the npm registry.\n * Returns null if the registry is unreachable.\n */\nexport function fetchLatestVersion(): string | null {\n try {\n return execSync(\"npm show @studiometa/trafic-agent version\", {\n encoding: \"utf-8\",\n stdio: \"pipe\",\n }).trim();\n } catch {\n return null;\n }\n}\n\n/**\n * Compare two semver strings. Returns true if `next` is strictly greater than `current`.\n * Handles simple MAJOR.MINOR.PATCH without pre-release suffixes.\n */\nexport function isNewer(current: string, next: string): boolean {\n const parse = (v: string): [number, number, number] => {\n const parts = v.replace(/^v/, \"\").split(\".\").map(Number);\n return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];\n };\n\n const [cMaj, cMin, cPat] = parse(current);\n const [nMaj, nMin, nPat] = parse(next);\n\n if (nMaj !== cMaj) return nMaj > cMaj;\n if (nMin !== cMin) return nMin > cMin;\n return nPat > cPat;\n}\n\n/**\n * Install the latest version of @studiometa/trafic-agent globally via npm.\n * Uses --prefer-online to bypass the local npm cache and always fetch the\n * latest published tarball.\n */\nexport function installLatestAgent(dryRun: boolean): void {\n exec(\"npm install -g @studiometa/trafic-agent@latest --prefer-online\", { silent: !dryRun });\n}\n\n/**\n * Read the actual installed version of @studiometa/trafic-agent from its\n * package.json on disk — not from __VERSION__ which is baked in at build time.\n *\n * Resolves the package root by walking up from the trafic-agent binary:\n * /usr/bin/trafic-agent -> /usr/lib/node_modules/@studiometa/trafic-agent/dist/cli.js\n * -> /usr/lib/node_modules/@studiometa/trafic-agent/package.json\n *\n * Returns null if the file can't be read.\n */\nexport function getInstalledVersion(): string | null {\n try {\n // Resolve the real path of the binary (follow symlinks)\n const realBinary = execSync(\"readlink -f $(which trafic-agent)\", {\n encoding: \"utf-8\",\n stdio: \"pipe\",\n }).trim();\n // dist/cli.js -> up two dirs -> package root\n const pkgJson = `${realBinary.replace(/\\/dist\\/cli\\.js$/, \"\")}/package.json`;\n const pkg = JSON.parse(readFileSync(pkgJson, \"utf-8\")) as { version: string };\n return pkg.version;\n } catch {\n return null;\n }\n}\n\n/**\n * Re-exec the newly installed trafic-agent binary with the same arguments.\n * Used after a self-update so the new binary runs its own migrations.\n * This function never returns — it replaces the current process.\n */\nexport function reExecNewBinary(args: string[]): never {\n const binary = execSync(\"which trafic-agent\", { encoding: \"utf-8\", stdio: \"pipe\" }).trim();\n const result = spawnSync(binary, args, {\n stdio: \"inherit\",\n env: { ...process.env, TRAFIC_UPGRADE_REEXEC: \"1\" },\n });\n process.exit(result.status ?? 0);\n}\n\n/**\n * Restart the trafic-agent systemd service.\n */\nexport function restartAgentService(dryRun: boolean): void {\n exec(\"systemctl restart trafic-agent\", { silent: !dryRun });\n}\n\n/**\n * What the upgrade sequence needs from the outside world.\n *\n * Injected so the orchestration can be tested without reaching npm, writing\n * to the filesystem or replacing the running process — the last of which is\n * why `reExecNewBinary` is here rather than called directly.\n */\nexport interface UpgradeIo {\n /** The version of the process running the upgrade. */\n currentVersion: string;\n /** Whether an earlier run already re-execed, guarding against a loop. */\n alreadyReExeced: boolean;\n isRoot: () => boolean;\n fetchLatestVersion: () => string | null;\n installLatestAgent: (dryRun: boolean) => void;\n getInstalledVersion: () => string | null;\n reExecNewBinary: (args: string[]) => never;\n restartAgentService: (dryRun: boolean) => void;\n runPendingMigrations: (dryRun: boolean) => void;\n}\n\n/** The real collaborators, used unless a caller passes their own. */\nexport function nodeUpgradeIo(): UpgradeIo {\n return {\n currentVersion: __VERSION__,\n alreadyReExeced: process.env[\"TRAFIC_UPGRADE_REEXEC\"] === \"1\",\n isRoot,\n fetchLatestVersion,\n installLatestAgent,\n getInstalledVersion,\n reExecNewBinary,\n restartAgentService,\n runPendingMigrations,\n };\n}\n\n/**\n * Full upgrade sequence:\n * 1. Check for a new version on npm\n * 2. Install it globally if one is found — then re-exec the new binary\n * so that steps 3 and 4 run with the new migration registry\n * 3. Run pending migrations\n * 4. Restart the systemd service\n */\nexport function runUpgrade(\n dryRun = false,\n reExecArgs?: string[],\n io: UpgradeIo = nodeUpgradeIo(),\n): void {\n if (!io.isRoot() && !dryRun) {\n console.error(\" \\x1b[31m✗\\x1b[0m This command must be run as root\");\n console.log(\" Run: sudo trafic-agent upgrade\");\n process.exit(1);\n }\n\n // Guard against infinite re-exec loops: only re-exec once per upgrade run.\n const alreadyReExeced = io.alreadyReExeced;\n\n // ── Step 1: Check for updates ─────────────────────────────────────────────\n step(\"Check for updates\");\n\n const current = io.currentVersion;\n info(`Current version: ${current}`);\n\n const latest = io.fetchLatestVersion();\n\n if (!latest) {\n warn(\"Could not reach npm registry — skipping version check\");\n } else if (isNewer(current, latest)) {\n success(`New version available: ${latest}`);\n\n // ── Step 2: Install ───────────────────────────────────────────────────\n step(\"Install latest version\");\n io.installLatestAgent(dryRun);\n\n if (!dryRun) {\n // Verify the installed version actually changed before re-execing —\n // npm can serve stale cache and leave the old binary in place.\n const installedVersion = io.getInstalledVersion();\n\n if (!alreadyReExeced && installedVersion && isNewer(current, installedVersion)) {\n success(`Installed @studiometa/trafic-agent@${installedVersion}`);\n // Re-exec the newly installed binary so steps 3 and 4 run with the\n // new migration registry — the current process only knows about\n // migrations that existed at the time it was compiled.\n io.reExecNewBinary(reExecArgs ?? [\"upgrade\"]);\n } else if (installedVersion) {\n success(`Installed @studiometa/trafic-agent@${installedVersion}`);\n }\n }\n } else {\n success(`Already up to date (${current})`);\n }\n\n // ── Step 3: Run pending migrations ───────────────────────────────────────\n step(\"Run pending migrations\");\n io.runPendingMigrations(dryRun);\n\n // ── Step 4: Restart service ───────────────────────────────────────────────\n step(\"Restart trafic-agent service\");\n io.restartAgentService(dryRun);\n if (!dryRun) {\n success(\"Service restarted\");\n }\n}\n","#!/usr/bin/env node\n\nimport { parseArgs } from \"node:util\";\nimport { loadConfig, validateConfig } from \"./utils/config.js\";\nimport { startServer } from \"./server.js\";\nimport { startIdleScheduler } from \"./tasks/stop-idle.js\";\nimport { closeDb } from \"./utils/db.js\";\nimport { setup, audit } from \"./setup/index.js\";\nimport { listMigrations } from \"./setup/migrations/index.js\";\nimport { runUpgrade } from \"./setup/upgrade.js\";\n\ndeclare const __VERSION__: string;\n\nconst HELP = `\ntrafic-agent — Server agent for DDEV preview environments\n\nUsage:\n trafic-agent <command> [options]\n\nCommands:\n start Start the agent server\n setup Setup a new server (Docker, DDEV, hardening)\n upgrade Check for a new version, install it, run migrations, restart the service\n update Alias for upgrade\n audit Run security audit checks\n version Show version\n help Show this help\n\nStart options:\n -c, --config <path> Path to config file (default: /etc/trafic/config.toml)\n -p, --port <port> Override port from config\n\nSetup options:\n --tld <domain> TLD for DDEV projects (required on first run, reused from config on re-runs)\n --email <email> Email for Let's Encrypt certificates\n --no-hardening Skip server hardening steps\n --no-root-ssh Disable root SSH login (recovery via rescue mode only)\n --no-docker Skip Docker installation\n --no-ddev Skip DDEV installation\n --ssh-users <users> Comma-separated list of SSH users to allow (default: ddev)\n --trusted-proxy-hops <n> Proxies in front of the agent (default: 1; 2 behind a CDN).\n Written to the generated config; ignored if one exists\n --dry-run Show what would be done without making changes\n\nUpgrade/update options:\n --dry-run Show what would be done without making changes\n --list List all migrations and their status (no install or restart)\n\nExamples:\n # Start the agent\n trafic-agent start\n trafic-agent start --config /path/to/config.toml\n\n # Setup a new server\n sudo trafic-agent setup --tld=previews.example.com\n sudo trafic-agent setup --tld=previews.example.com --trusted-proxy-hops=2\n sudo trafic-agent setup --tld=previews.example.com --email=admin@example.com\n sudo trafic-agent setup --tld=previews.example.com --no-hardening --dry-run\n\n # Upgrade: install latest version, run migrations, restart service\n sudo trafic-agent upgrade\n sudo trafic-agent upgrade --dry-run\n trafic-agent upgrade --list\n sudo trafic-agent update # alias for upgrade\n\n # Run security audit\n trafic-agent audit\n`;\n\nfunction printHelp(): void {\n console.log(HELP.trim());\n}\n\nfunction printVersion(): void {\n console.log(__VERSION__);\n}\n\nasync function runStart(values: Record<string, unknown>): Promise<void> {\n // Load config\n const config = loadConfig(values.config as string | undefined);\n\n // Override port if specified\n if (values.port) {\n config.port = parseInt(values.port as string, 10);\n }\n\n // Validate config\n const errors = validateConfig(config);\n if (errors.length > 0) {\n console.error(\"Configuration errors:\");\n for (const error of errors) {\n console.error(` - ${error}`);\n }\n process.exit(1);\n }\n\n // Graceful shutdown\n const shutdown = (): void => {\n console.log(\"\\nShutting down...\");\n closeDb();\n process.exit(0);\n };\n\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n\n // Start server and scheduler\n startServer(config);\n startIdleScheduler(config);\n}\n\nfunction handleUpgrade(values: Record<string, unknown>): void {\n const dryRun = values[\"dry-run\"] as boolean | undefined;\n const list = values.list as boolean | undefined;\n\n if (list) {\n const entries = listMigrations();\n console.log(\"\\nMigrations:\\n\");\n\n for (const { migration, status } of entries) {\n const icon = status === \"applied\" ? \"\\x1b[32m✓\\x1b[0m\" : \"\\x1b[36m→\\x1b[0m\";\n const label = status === \"applied\" ? \"\\x1b[90m(applied)\\x1b[0m\" : \"(pending)\";\n console.log(` ${icon} \\x1b[1m${migration.id.padEnd(30)}\\x1b[0m ${migration.description.padEnd(55)} ${label}`);\n }\n\n console.log(\"\");\n return;\n }\n\n console.log(\"\\n\\x1b[1m🚦 Trafic Server Upgrade\\x1b[0m\\n\");\n\n if (dryRun) {\n console.log(\"\\x1b[33m⚠ Dry-run mode: no changes will be made\\x1b[0m\\n\");\n }\n\n // Build re-exec args so flags survive the binary swap after a self-update\n const reExecArgs = [\"upgrade\", ...(dryRun ? [\"--dry-run\"] : [])];\n runUpgrade(dryRun ?? false, reExecArgs);\n\n if (!dryRun) {\n console.log(\"\\n\\x1b[32m✓ Upgrade complete!\\x1b[0m\\n\");\n }\n}\n\n/**\n * Parse --trusted-proxy-hops, failing loudly rather than writing a config\n * that would quietly break the IP allowlist.\n */\nfunction parseTrustedProxyHops(raw?: string): number | undefined {\n if (raw === undefined) {\n return undefined;\n }\n\n const hops = Number(raw);\n\n if (!Number.isInteger(hops) || hops < 1) {\n console.error(\n `Error: --trusted-proxy-hops must be a whole number of 1 or more, got \"${raw}\"`,\n );\n console.error(\" 1 = ddev-router/Traefik alone, 2 = a CDN in front of it\");\n process.exit(1);\n }\n\n return hops;\n}\n\nasync function runSetup(values: Record<string, unknown>): Promise<void> {\n // Load existing config to use as defaults on re-runs\n const existingConfig = loadConfig();\n const tld = (values.tld as string | undefined) ?? (existingConfig.tld || undefined);\n\n if (!tld) {\n console.error(\"Error: --tld is required for setup\");\n console.error(\"Example: trafic-agent setup --tld=previews.example.com\");\n process.exit(1);\n }\n\n await setup({\n tld,\n trustedProxyHops: parseTrustedProxyHops(values[\"trusted-proxy-hops\"] as string | undefined),\n email: values.email as string | undefined,\n noHardening: values[\"no-hardening\"] as boolean | undefined,\n noRootSsh: values[\"no-root-ssh\"] as boolean | undefined,\n noDocker: values[\"no-docker\"] as boolean | undefined,\n noDdev: values[\"no-ddev\"] as boolean | undefined,\n sshUsers: values[\"ssh-users\"]\n ? (values[\"ssh-users\"] as string).split(\",\").map((s) => s.trim())\n : undefined,\n dryRun: values[\"dry-run\"] as boolean | undefined,\n });\n}\n\nasync function main(): Promise<void> {\n const { values, positionals } = parseArgs({\n allowPositionals: true,\n options: {\n // Common\n help: { type: \"boolean\", short: \"h\" },\n\n // Start options\n config: { type: \"string\", short: \"c\" },\n port: { type: \"string\", short: \"p\" },\n\n // Setup options\n tld: { type: \"string\" },\n email: { type: \"string\" },\n \"no-hardening\": { type: \"boolean\" },\n \"no-root-ssh\": { type: \"boolean\" },\n \"no-docker\": { type: \"boolean\" },\n \"no-ddev\": { type: \"boolean\" },\n \"ssh-users\": { type: \"string\" },\n \"trusted-proxy-hops\": { type: \"string\" },\n \"dry-run\": { type: \"boolean\" },\n\n // Upgrade options\n list: { type: \"boolean\" },\n },\n });\n\n const command = positionals[0] ?? \"help\";\n\n if (values.help) {\n printHelp();\n process.exit(0);\n }\n\n switch (command) {\n case \"help\":\n printHelp();\n break;\n\n case \"version\":\n printVersion();\n break;\n\n case \"start\":\n await runStart(values);\n break;\n\n case \"setup\":\n await runSetup(values);\n break;\n\n case \"upgrade\":\n case \"update\":\n handleUpgrade(values);\n break;\n\n case \"audit\":\n await audit();\n break;\n\n default:\n console.error(`Unknown command: ${command}`);\n printHelp();\n process.exit(1);\n }\n}\n\nmain().catch((error) => {\n console.error(\"Fatal error:\", error);\n process.exit(1);\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/setup/upgrade.ts","../src/cli.ts"],"sourcesContent":["import { execSync, spawnSync } from \"node:child_process\";\nimport { readFileSync } from \"node:fs\";\nimport { step, success, info, warn, exec, execSilent, isRoot } from \"./steps.js\";\nimport { runPendingMigrations } from \"./migrations/index.js\";\n\ndeclare const __VERSION__: string;\n\n/**\n * Fetch the latest published version of @studiometa/trafic-agent from the npm registry.\n * Returns null if the registry is unreachable.\n */\nexport function fetchLatestVersion(): string | null {\n try {\n return execSync(\"npm show @studiometa/trafic-agent version\", {\n encoding: \"utf-8\",\n stdio: \"pipe\",\n }).trim();\n } catch {\n return null;\n }\n}\n\n/**\n * Compare two semver strings. Returns true if `next` is strictly greater than `current`.\n * Handles simple MAJOR.MINOR.PATCH without pre-release suffixes.\n */\nexport function isNewer(current: string, next: string): boolean {\n const parse = (v: string): [number, number, number] => {\n const parts = v.replace(/^v/, \"\").split(\".\").map(Number);\n return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];\n };\n\n const [cMaj, cMin, cPat] = parse(current);\n const [nMaj, nMin, nPat] = parse(next);\n\n if (nMaj !== cMaj) return nMaj > cMaj;\n if (nMin !== cMin) return nMin > cMin;\n return nPat > cPat;\n}\n\n/**\n * Install the latest version of @studiometa/trafic-agent globally via npm.\n * Uses --prefer-online to bypass the local npm cache and always fetch the\n * latest published tarball.\n */\nexport function installLatestAgent(dryRun: boolean): void {\n exec(\"npm install -g @studiometa/trafic-agent@latest --prefer-online\", { silent: !dryRun });\n}\n\n/**\n * Read the actual installed version of @studiometa/trafic-agent from its\n * package.json on disk — not from __VERSION__ which is baked in at build time.\n *\n * Resolves the package root by walking up from the trafic-agent binary:\n * /usr/bin/trafic-agent -> /usr/lib/node_modules/@studiometa/trafic-agent/dist/cli.js\n * -> /usr/lib/node_modules/@studiometa/trafic-agent/package.json\n *\n * Returns null if the file can't be read.\n */\nexport function getInstalledVersion(): string | null {\n try {\n // Resolve the real path of the binary (follow symlinks)\n const realBinary = execSync(\"readlink -f $(which trafic-agent)\", {\n encoding: \"utf-8\",\n stdio: \"pipe\",\n }).trim();\n // dist/cli.js -> up two dirs -> package root\n const pkgJson = `${realBinary.replace(/\\/dist\\/cli\\.js$/, \"\")}/package.json`;\n const pkg = JSON.parse(readFileSync(pkgJson, \"utf-8\")) as { version: string };\n return pkg.version;\n } catch {\n return null;\n }\n}\n\n/**\n * Re-exec the newly installed trafic-agent binary with the same arguments.\n * Used after a self-update so the new binary runs its own migrations.\n * This function never returns — it replaces the current process.\n */\nexport function reExecNewBinary(args: string[]): never {\n const binary = execSync(\"which trafic-agent\", { encoding: \"utf-8\", stdio: \"pipe\" }).trim();\n const result = spawnSync(binary, args, {\n stdio: \"inherit\",\n env: { ...process.env, TRAFIC_UPGRADE_REEXEC: \"1\" },\n });\n process.exit(result.status ?? 0);\n}\n\n/**\n * Restart the trafic-agent systemd service.\n */\nexport function restartAgentService(dryRun: boolean): void {\n exec(\"systemctl restart trafic-agent\", { silent: !dryRun });\n}\n\n/**\n * The installed apt package version of DDEV, or null when it cannot be read.\n */\nexport function ddevVersion(): string | null {\n return execSilent(\"dpkg-query --show --showformat='${Version}' ddev\") || null;\n}\n\n/**\n * Upgrade DDEV from its apt repository. Throws when apt fails.\n *\n * DDEV is left out of unattended-upgrades on purpose — a major landing on its\n * own can break running previews — so it is updated here, where an operator\n * is watching. `--only-upgrade` never installs DDEV on a server that does not\n * already have it.\n */\nexport function upgradeDdev(dryRun: boolean): void {\n exec(\n \"DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update\",\n { silent: !dryRun },\n );\n exec(\n \"DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --only-upgrade ddev\",\n { silent: !dryRun },\n );\n}\n\n/**\n * What the upgrade sequence needs from the outside world.\n *\n * Injected so the orchestration can be tested without reaching npm, writing\n * to the filesystem or replacing the running process — the last of which is\n * why `reExecNewBinary` is here rather than called directly.\n */\nexport interface UpgradeIo {\n /** The version of the process running the upgrade. */\n currentVersion: string;\n /** Whether an earlier run already re-execed, guarding against a loop. */\n alreadyReExeced: boolean;\n isRoot: () => boolean;\n fetchLatestVersion: () => string | null;\n installLatestAgent: (dryRun: boolean) => void;\n getInstalledVersion: () => string | null;\n reExecNewBinary: (args: string[]) => never;\n restartAgentService: (dryRun: boolean) => void;\n runPendingMigrations: (dryRun: boolean) => void;\n /** The installed DDEV version, or null when DDEV is absent. */\n ddevVersion: () => string | null;\n upgradeDdev: (dryRun: boolean) => void;\n}\n\n/** The real collaborators, used unless a caller passes their own. */\nexport function nodeUpgradeIo(): UpgradeIo {\n return {\n currentVersion: __VERSION__,\n alreadyReExeced: process.env[\"TRAFIC_UPGRADE_REEXEC\"] === \"1\",\n isRoot,\n fetchLatestVersion,\n installLatestAgent,\n getInstalledVersion,\n reExecNewBinary,\n restartAgentService,\n runPendingMigrations,\n ddevVersion,\n upgradeDdev,\n };\n}\n\n/**\n * Full upgrade sequence:\n * 1. Check for a new version on npm\n * 2. Install it globally if one is found — then re-exec the new binary\n * so that steps 3 and 4 run with the new migration registry\n * 3. Run pending migrations\n * 4. Update DDEV from its apt repository\n * 5. Restart the systemd service\n */\nexport function runUpgrade(\n dryRun = false,\n reExecArgs?: string[],\n io: UpgradeIo = nodeUpgradeIo(),\n): void {\n if (!io.isRoot() && !dryRun) {\n console.error(\" \\x1b[31m✗\\x1b[0m This command must be run as root\");\n console.log(\" Run: sudo trafic-agent upgrade\");\n process.exit(1);\n }\n\n // Guard against infinite re-exec loops: only re-exec once per upgrade run.\n const alreadyReExeced = io.alreadyReExeced;\n\n // ── Step 1: Check for updates ─────────────────────────────────────────────\n step(\"Check for updates\");\n\n const current = io.currentVersion;\n info(`Current version: ${current}`);\n\n const latest = io.fetchLatestVersion();\n\n if (!latest) {\n warn(\"Could not reach npm registry — skipping version check\");\n } else if (isNewer(current, latest)) {\n success(`New version available: ${latest}`);\n\n // ── Step 2: Install ───────────────────────────────────────────────────\n step(\"Install latest version\");\n io.installLatestAgent(dryRun);\n\n if (!dryRun) {\n // Verify the installed version actually changed before re-execing —\n // npm can serve stale cache and leave the old binary in place.\n const installedVersion = io.getInstalledVersion();\n\n if (!alreadyReExeced && installedVersion && isNewer(current, installedVersion)) {\n success(`Installed @studiometa/trafic-agent@${installedVersion}`);\n // Re-exec the newly installed binary so steps 3 and 4 run with the\n // new migration registry — the current process only knows about\n // migrations that existed at the time it was compiled.\n io.reExecNewBinary(reExecArgs ?? [\"upgrade\"]);\n } else if (installedVersion) {\n success(`Installed @studiometa/trafic-agent@${installedVersion}`);\n }\n }\n } else {\n success(`Already up to date (${current})`);\n }\n\n // ── Step 3: Run pending migrations ───────────────────────────────────────\n step(\"Run pending migrations\");\n io.runPendingMigrations(dryRun);\n\n // ── Step 4: Update DDEV ──────────────────────────────────────────────────\n step(\"Update DDEV\");\n upgradeDdevStep(dryRun, io);\n\n // ── Step 5: Restart service ───────────────────────────────────────────────\n step(\"Restart trafic-agent service\");\n io.restartAgentService(dryRun);\n if (!dryRun) {\n success(\"Service restarted\");\n }\n}\n\n/**\n * Update DDEV, reporting the version on either side of the apt call.\n *\n * A failed apt run must not cost the server its service restart, so the\n * error is warned about and the upgrade carries on.\n */\nfunction upgradeDdevStep(dryRun: boolean, io: UpgradeIo): void {\n const before = io.ddevVersion();\n\n if (!dryRun && before === null) {\n info(\"DDEV is not installed — skipping\");\n return;\n }\n\n if (before) {\n info(`Current: ${before}`);\n }\n\n try {\n io.upgradeDdev(dryRun);\n } catch (err) {\n warn(`DDEV update failed: ${err instanceof Error ? err.message : String(err)}`);\n return;\n }\n\n if (dryRun) {\n return;\n }\n\n const after = io.ddevVersion();\n if (after && after !== before) {\n success(`DDEV updated: ${after}`);\n } else {\n success(\"DDEV up to date\");\n }\n}\n","#!/usr/bin/env node\n\nimport { parseArgs } from \"node:util\";\nimport { loadConfig, validateConfig } from \"./utils/config.js\";\nimport { startServer } from \"./server.js\";\nimport { startIdleScheduler } from \"./tasks/stop-idle.js\";\nimport { closeDb } from \"./utils/db.js\";\nimport { setup, audit } from \"./setup/index.js\";\nimport { listMigrations } from \"./setup/migrations/index.js\";\nimport { runUpgrade } from \"./setup/upgrade.js\";\n\ndeclare const __VERSION__: string;\n\nconst HELP = `\ntrafic-agent — Server agent for DDEV preview environments\n\nUsage:\n trafic-agent <command> [options]\n\nCommands:\n start Start the agent server\n setup Setup a new server (Docker, DDEV, hardening)\n upgrade Check for a new version, install it, run migrations, restart the service\n update Alias for upgrade\n audit Run security audit checks\n version Show version\n help Show this help\n\nStart options:\n -c, --config <path> Path to config file (default: /etc/trafic/config.toml)\n -p, --port <port> Override port from config\n\nSetup options:\n --tld <domain> TLD for DDEV projects (required on first run, reused from config on re-runs)\n --email <email> Email for Let's Encrypt certificates\n --no-hardening Skip server hardening steps\n --no-root-ssh Disable root SSH login (recovery via rescue mode only)\n --no-docker Skip Docker installation\n --no-ddev Skip DDEV installation\n --ssh-users <users> Comma-separated list of SSH users to allow (default: ddev)\n --trusted-proxy-hops <n> Proxies in front of the agent (default: 1; 2 behind a CDN).\n Written to the generated config; ignored if one exists\n --dry-run Show what would be done without making changes\n\nUpgrade/update options:\n --dry-run Show what would be done without making changes\n --list List all migrations and their status (no install or restart)\n\nExamples:\n # Start the agent\n trafic-agent start\n trafic-agent start --config /path/to/config.toml\n\n # Setup a new server\n sudo trafic-agent setup --tld=previews.example.com\n sudo trafic-agent setup --tld=previews.example.com --trusted-proxy-hops=2\n sudo trafic-agent setup --tld=previews.example.com --email=admin@example.com\n sudo trafic-agent setup --tld=previews.example.com --no-hardening --dry-run\n\n # Upgrade: install latest version, run migrations, restart service\n sudo trafic-agent upgrade\n sudo trafic-agent upgrade --dry-run\n trafic-agent upgrade --list\n sudo trafic-agent update # alias for upgrade\n\n # Run security audit\n trafic-agent audit\n`;\n\nfunction printHelp(): void {\n console.log(HELP.trim());\n}\n\nfunction printVersion(): void {\n console.log(__VERSION__);\n}\n\nasync function runStart(values: Record<string, unknown>): Promise<void> {\n // Load config\n const config = loadConfig(values.config as string | undefined);\n\n // Override port if specified\n if (values.port) {\n config.port = parseInt(values.port as string, 10);\n }\n\n // Validate config\n const errors = validateConfig(config);\n if (errors.length > 0) {\n console.error(\"Configuration errors:\");\n for (const error of errors) {\n console.error(` - ${error}`);\n }\n process.exit(1);\n }\n\n // Graceful shutdown\n const shutdown = (): void => {\n console.log(\"\\nShutting down...\");\n closeDb();\n process.exit(0);\n };\n\n process.on(\"SIGINT\", shutdown);\n process.on(\"SIGTERM\", shutdown);\n\n // Start server and scheduler\n startServer(config);\n startIdleScheduler(config);\n}\n\nfunction handleUpgrade(values: Record<string, unknown>): void {\n const dryRun = values[\"dry-run\"] as boolean | undefined;\n const list = values.list as boolean | undefined;\n\n if (list) {\n const entries = listMigrations();\n console.log(\"\\nMigrations:\\n\");\n\n for (const { migration, status } of entries) {\n const icon = status === \"applied\" ? \"\\x1b[32m✓\\x1b[0m\" : \"\\x1b[36m→\\x1b[0m\";\n const label = status === \"applied\" ? \"\\x1b[90m(applied)\\x1b[0m\" : \"(pending)\";\n console.log(` ${icon} \\x1b[1m${migration.id.padEnd(30)}\\x1b[0m ${migration.description.padEnd(55)} ${label}`);\n }\n\n console.log(\"\");\n return;\n }\n\n console.log(\"\\n\\x1b[1m🚦 Trafic Server Upgrade\\x1b[0m\\n\");\n\n if (dryRun) {\n console.log(\"\\x1b[33m⚠ Dry-run mode: no changes will be made\\x1b[0m\\n\");\n }\n\n // Build re-exec args so flags survive the binary swap after a self-update\n const reExecArgs = [\"upgrade\", ...(dryRun ? [\"--dry-run\"] : [])];\n runUpgrade(dryRun ?? false, reExecArgs);\n\n if (!dryRun) {\n console.log(\"\\n\\x1b[32m✓ Upgrade complete!\\x1b[0m\\n\");\n }\n}\n\n/**\n * Parse --trusted-proxy-hops, failing loudly rather than writing a config\n * that would quietly break the IP allowlist.\n */\nfunction parseTrustedProxyHops(raw?: string): number | undefined {\n if (raw === undefined) {\n return undefined;\n }\n\n const hops = Number(raw);\n\n if (!Number.isInteger(hops) || hops < 1) {\n console.error(\n `Error: --trusted-proxy-hops must be a whole number of 1 or more, got \"${raw}\"`,\n );\n console.error(\" 1 = ddev-router/Traefik alone, 2 = a CDN in front of it\");\n process.exit(1);\n }\n\n return hops;\n}\n\nasync function runSetup(values: Record<string, unknown>): Promise<void> {\n // Load existing config to use as defaults on re-runs\n const existingConfig = loadConfig();\n const tld = (values.tld as string | undefined) ?? (existingConfig.tld || undefined);\n\n if (!tld) {\n console.error(\"Error: --tld is required for setup\");\n console.error(\"Example: trafic-agent setup --tld=previews.example.com\");\n process.exit(1);\n }\n\n await setup({\n tld,\n trustedProxyHops: parseTrustedProxyHops(values[\"trusted-proxy-hops\"] as string | undefined),\n email: values.email as string | undefined,\n noHardening: values[\"no-hardening\"] as boolean | undefined,\n noRootSsh: values[\"no-root-ssh\"] as boolean | undefined,\n noDocker: values[\"no-docker\"] as boolean | undefined,\n noDdev: values[\"no-ddev\"] as boolean | undefined,\n sshUsers: values[\"ssh-users\"]\n ? (values[\"ssh-users\"] as string).split(\",\").map((s) => s.trim())\n : undefined,\n dryRun: values[\"dry-run\"] as boolean | undefined,\n });\n}\n\nasync function main(): Promise<void> {\n const { values, positionals } = parseArgs({\n allowPositionals: true,\n options: {\n // Common\n help: { type: \"boolean\", short: \"h\" },\n\n // Start options\n config: { type: \"string\", short: \"c\" },\n port: { type: \"string\", short: \"p\" },\n\n // Setup options\n tld: { type: \"string\" },\n email: { type: \"string\" },\n \"no-hardening\": { type: \"boolean\" },\n \"no-root-ssh\": { type: \"boolean\" },\n \"no-docker\": { type: \"boolean\" },\n \"no-ddev\": { type: \"boolean\" },\n \"ssh-users\": { type: \"string\" },\n \"trusted-proxy-hops\": { type: \"string\" },\n \"dry-run\": { type: \"boolean\" },\n\n // Upgrade options\n list: { type: \"boolean\" },\n },\n });\n\n const command = positionals[0] ?? \"help\";\n\n if (values.help) {\n printHelp();\n process.exit(0);\n }\n\n switch (command) {\n case \"help\":\n printHelp();\n break;\n\n case \"version\":\n printVersion();\n break;\n\n case \"start\":\n await runStart(values);\n break;\n\n case \"setup\":\n await runSetup(values);\n break;\n\n case \"upgrade\":\n case \"update\":\n handleUpgrade(values);\n break;\n\n case \"audit\":\n await audit();\n break;\n\n default:\n console.error(`Unknown command: ${command}`);\n printHelp();\n process.exit(1);\n }\n}\n\nmain().catch((error) => {\n console.error(\"Fatal error:\", error);\n process.exit(1);\n});\n"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as printAuditResults, c as stopIdleProjects, d as buildDockerFirewallScript, f as buildDockerFirewallUnit, g as validateConfig, h as parseDuration, l as DOCKER_FIREWALL_SCRIPT, m as loadConfig, n as setup, o as runAudit, p as configureDockerFirewall, s as startIdleScheduler, t as audit, u as DOCKER_FIREWALL_UNIT } from "./setup-BIfcVIa7.js";
1
+ import { a as printAuditResults, c as stopIdleProjects, d as buildDockerFirewallScript, f as buildDockerFirewallUnit, g as validateConfig, h as parseDuration, l as DOCKER_FIREWALL_SCRIPT, m as loadConfig, n as setup, o as runAudit, p as configureDockerFirewall, s as startIdleScheduler, t as audit, u as DOCKER_FIREWALL_UNIT } from "./setup-BrMbM8w4.js";
2
2
  import { B as buildHostnameIndex, C as getIdleProjects, D as setProjectStatus, E as logAccess, G as startProject, H as getProjectInfo, J as checkAuth, K as stopProject, O as updateProjectAccess, Q as parseBearerToken, S as getDb, T as initDb, U as listProjects, V as getProjectFromHostname, W as loadProjectList, X as matchIp, Y as matchHostname, Z as parseBasicAuth, _ as loadProjectConfig, b as closeDb, g as getIdleTimeoutMs, q as watchProjectList, s as startServer, v as shouldNeverStop, w as getProject, x as getAccessLogs, y as cleanOldLogs } from "./server-BAjHxgqh.js";
3
3
  export { DOCKER_FIREWALL_SCRIPT, DOCKER_FIREWALL_UNIT, audit, buildDockerFirewallScript, buildDockerFirewallUnit, buildHostnameIndex, checkAuth, cleanOldLogs, closeDb, configureDockerFirewall, getAccessLogs, getDb, getIdleProjects, getIdleTimeoutMs, getProject, getProjectFromHostname, getProjectInfo, initDb, listProjects, loadConfig, loadProjectConfig, loadProjectList, logAccess, matchHostname, matchIp, parseBasicAuth, parseBearerToken, parseDuration, printAuditResults, runAudit, setProjectStatus, setup, shouldNeverStop, startIdleScheduler, startProject, startServer, stopIdleProjects, stopProject, updateProjectAccess, validateConfig, watchProjectList };
@@ -36,6 +36,36 @@ export declare function removeSshKexAlgorithms(io?: SetupIo): boolean;
36
36
  * Configure UFW firewall
37
37
  */
38
38
  export declare function configureFirewall(io?: SetupIo): void;
39
+ /** The unattended-upgrades config written by `setup`, and owned by Trafic. */
40
+ export declare const UNATTENDED_UPGRADES_CONFIG = "/etc/apt/apt.conf.d/50unattended-upgrades";
41
+ /**
42
+ * The Allowed-Origins entry covering Docker Engine.
43
+ *
44
+ * `origin=Docker` is the `Origin:` field of the Release file served by
45
+ * download.docker.com (its `Label:` is `Docker CE`), and that repository's
46
+ * dist is the Ubuntu codename, so `codename=${distro_codename}` pins the
47
+ * entry to the release the server runs.
48
+ */
49
+ export declare const DOCKER_ORIGIN_LINE = "\"origin=Docker,codename=${distro_codename}\"";
50
+ /**
51
+ * Build the contents of /etc/apt/apt.conf.d/50unattended-upgrades.
52
+ *
53
+ * Pure, so `setup` and migration 0014 write the exact same file.
54
+ */
55
+ export declare function buildUnattendedUpgradesConfig(): string;
56
+ /**
57
+ * Add the Docker origin to an existing unattended-upgrades config.
58
+ *
59
+ * Returns whether the file was changed. Idempotent: a server with no such
60
+ * file was set up without hardening and is left alone, and a config that
61
+ * already names the Docker origin is not rewritten. The whole file is
62
+ * regenerated rather than patched because Trafic generates and owns it — the
63
+ * header comment says so.
64
+ *
65
+ * No service restart is needed: unattended-upgrades reads the config on each
66
+ * daily run.
67
+ */
68
+ export declare function addDockerOriginToUnattendedUpgrades(io?: SetupIo): boolean;
39
69
  /**
40
70
  * Configure automatic security updates
41
71
  */
@@ -1 +1 @@
1
- {"version":3,"file":"hardening.d.ts","sourceRoot":"","sources":["../../src/setup/hardening.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAG/C,6EAA6E;AAC7E,eAAO,MAAM,WAAW,uCAAuC,CAAC;AAEhE;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,YAAY,EAAE,MAAM,EAAE,EACtB,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,EAAE,CAQV;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,EAC9D,EAAE,GAAE,OAAgB,GACnB,IAAI,CA+EN;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,GAAE,OAAgB,GAAG,OAAO,CAwBpE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CA6B5D;AAED;;GAEG;AACH,wBAAgB,2BAA2B,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CA+CtE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CAiC5D;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CAchE;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CAuBnE;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,EAC1D,EAAE,GAAE,OAAgB,GACnB,IAAI,CAUN"}
1
+ {"version":3,"file":"hardening.d.ts","sourceRoot":"","sources":["../../src/setup/hardening.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAG/C,6EAA6E;AAC7E,eAAO,MAAM,WAAW,uCAAuC,CAAC;AAEhE;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,YAAY,EAAE,MAAM,EAAE,EACtB,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,EAAE,CAQV;AAED;;GAEG;AACH,wBAAgB,SAAS,CACvB,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,EAC9D,EAAE,GAAE,OAAgB,GACnB,IAAI,CA+EN;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,GAAE,OAAgB,GAAG,OAAO,CAwBpE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CA6B5D;AAED,8EAA8E;AAC9E,eAAO,MAAM,0BAA0B,8CAA8C,CAAC;AAEtF;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,kDAAgD,CAAC;AAEhF;;;;GAIG;AACH,wBAAgB,6BAA6B,IAAI,MAAM,CAwBtD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mCAAmC,CAAC,EAAE,GAAE,OAAgB,GAAG,OAAO,CAWjF;AAED;;GAEG;AACH,wBAAgB,2BAA2B,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CA0BtE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CAiC5D;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CAchE;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,GAAE,OAAgB,GAAG,IAAI,CAuBnE;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,EAC1D,EAAE,GAAE,OAAgB,GACnB,IAAI,CAUN"}
@@ -0,0 +1,28 @@
1
+ import type { Migration } from "../types.js";
2
+ /**
3
+ * Migration 0014: apply Docker Engine security updates unattended.
4
+ *
5
+ * `setup` wrote an `Unattended-Upgrade::Allowed-Origins` list covering only
6
+ * Ubuntu's own release, security and ESM pockets. Docker Engine is installed
7
+ * from download.docker.com, which is none of them, so `docker-ce` was never
8
+ * upgraded unless someone ran `apt-get upgrade` by hand — a Docker security
9
+ * release could sit unapplied on a server indefinitely. The Release file
10
+ * served by that repository carries `Origin: Docker` and `Label: Docker CE`,
11
+ * and its dist is the Ubuntu codename, so
12
+ * `"origin=Docker,codename=${distro_codename}"` is the entry that matches it.
13
+ *
14
+ * DDEV, installed from pkg.ddev.com, is deliberately not added: a DDEV major
15
+ * landing unattended can break running previews. DDEV is updated by
16
+ * `trafic-agent upgrade` instead, when an operator is there to see it.
17
+ *
18
+ * `configureUnattendedUpgrades` now writes the Docker entry, which covers
19
+ * fresh installs. Servers set up by an earlier release still carry the old
20
+ * list, so this migration regenerates the file — Trafic generates and owns
21
+ * it, as its header comment states.
22
+ *
23
+ * Idempotent: the helper does nothing when the file is missing (a server set
24
+ * up without hardening) or already names the Docker origin. No service
25
+ * restart is needed — unattended-upgrades reads the config on each daily run.
26
+ */
27
+ export declare const migration0014UnattendedUpgradesDockerOrigin: Migration;
28
+ //# sourceMappingURL=0014__unattended_upgrades_docker_origin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"0014__unattended_upgrades_docker_origin.d.ts","sourceRoot":"","sources":["../../../src/setup/migrations/0014__unattended_upgrades_docker_origin.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,2CAA2C,EAAE,SAOzD,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/setup/migrations/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAejF,iDAAiD;AACjD,eAAO,MAAM,qBAAqB,iCAAiC,CAAC;AAEpE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,SAAS,EAcrC,CAAC;AAMF;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,cAAc,CAqBnD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,CAG9D;AAMD;;GAEG;AACH,wBAAgB,cAAc,IAAI,kBAAkB,EAAE,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,UAAQ,GAAG,IAAI,CAyBzD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAU/C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/setup/migrations/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAgBjF,iDAAiD;AACjD,eAAO,MAAM,qBAAqB,iCAAiC,CAAC;AAEpE,+CAA+C;AAC/C,eAAO,MAAM,cAAc,EAAE,SAAS,EAerC,CAAC;AAMF;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,cAAc,CAqBnD;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,CAG9D;AAMD;;GAEG;AACH,wBAAgB,cAAc,IAAI,kBAAkB,EAAE,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,UAAQ,GAAG,IAAI,CAyBzD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAU/C"}
@@ -35,6 +35,19 @@ export declare function reExecNewBinary(args: string[]): never;
35
35
  * Restart the trafic-agent systemd service.
36
36
  */
37
37
  export declare function restartAgentService(dryRun: boolean): void;
38
+ /**
39
+ * The installed apt package version of DDEV, or null when it cannot be read.
40
+ */
41
+ export declare function ddevVersion(): string | null;
42
+ /**
43
+ * Upgrade DDEV from its apt repository. Throws when apt fails.
44
+ *
45
+ * DDEV is left out of unattended-upgrades on purpose — a major landing on its
46
+ * own can break running previews — so it is updated here, where an operator
47
+ * is watching. `--only-upgrade` never installs DDEV on a server that does not
48
+ * already have it.
49
+ */
50
+ export declare function upgradeDdev(dryRun: boolean): void;
38
51
  /**
39
52
  * What the upgrade sequence needs from the outside world.
40
53
  *
@@ -54,6 +67,9 @@ export interface UpgradeIo {
54
67
  reExecNewBinary: (args: string[]) => never;
55
68
  restartAgentService: (dryRun: boolean) => void;
56
69
  runPendingMigrations: (dryRun: boolean) => void;
70
+ /** The installed DDEV version, or null when DDEV is absent. */
71
+ ddevVersion: () => string | null;
72
+ upgradeDdev: (dryRun: boolean) => void;
57
73
  }
58
74
  /** The real collaborators, used unless a caller passes their own. */
59
75
  export declare function nodeUpgradeIo(): UpgradeIo;
@@ -63,7 +79,8 @@ export declare function nodeUpgradeIo(): UpgradeIo;
63
79
  * 2. Install it globally if one is found — then re-exec the new binary
64
80
  * so that steps 3 and 4 run with the new migration registry
65
81
  * 3. Run pending migrations
66
- * 4. Restart the systemd service
82
+ * 4. Update DDEV from its apt repository
83
+ * 5. Restart the systemd service
67
84
  */
68
85
  export declare function runUpgrade(dryRun?: boolean, reExecArgs?: string[], io?: UpgradeIo): void;
69
86
  //# sourceMappingURL=upgrade.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"upgrade.d.ts","sourceRoot":"","sources":["../../src/setup/upgrade.ts"],"names":[],"mappings":"AAOA;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,GAAG,IAAI,CASlD;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAY9D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAExD;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,GAAG,IAAI,CAcnD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,KAAK,CAOrD;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAEzD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,SAAS;IACxB,sDAAsD;IACtD,cAAc,EAAE,MAAM,CAAC;IACvB,yEAAyE;IACzE,eAAe,EAAE,OAAO,CAAC;IACzB,MAAM,EAAE,MAAM,OAAO,CAAC;IACtB,kBAAkB,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACxC,kBAAkB,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAC9C,mBAAmB,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACzC,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,KAAK,CAAC;IAC3C,mBAAmB,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/C,oBAAoB,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CACjD;AAED,qEAAqE;AACrE,wBAAgB,aAAa,IAAI,SAAS,CAYzC;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,MAAM,UAAQ,EACd,UAAU,CAAC,EAAE,MAAM,EAAE,EACrB,EAAE,GAAE,SAA2B,GAC9B,IAAI,CAwDN"}
1
+ {"version":3,"file":"upgrade.d.ts","sourceRoot":"","sources":["../../src/setup/upgrade.ts"],"names":[],"mappings":"AAOA;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,GAAG,IAAI,CASlD;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAY9D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAExD;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,GAAG,IAAI,CAcnD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,KAAK,CAOrD;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAEzD;AAED;;GAEG;AACH,wBAAgB,WAAW,IAAI,MAAM,GAAG,IAAI,CAE3C;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CASjD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,SAAS;IACxB,sDAAsD;IACtD,cAAc,EAAE,MAAM,CAAC;IACvB,yEAAyE;IACzE,eAAe,EAAE,OAAO,CAAC;IACzB,MAAM,EAAE,MAAM,OAAO,CAAC;IACtB,kBAAkB,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACxC,kBAAkB,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAC9C,mBAAmB,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACzC,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,KAAK,CAAC;IAC3C,mBAAmB,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/C,oBAAoB,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD,+DAA+D;IAC/D,WAAW,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACjC,WAAW,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CACxC;AAED,qEAAqE;AACrE,wBAAgB,aAAa,IAAI,SAAS,CAczC;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,MAAM,UAAQ,EACd,UAAU,CAAC,EAAE,MAAM,EAAE,EACrB,EAAE,GAAE,SAA2B,GAC9B,IAAI,CA4DN"}
@@ -659,18 +659,35 @@ function configureFirewall(io = nodeIo) {
659
659
  info("are protected by forward auth, not the firewall. For network-level closure use a");
660
660
  info("firewall in front of the server; see the agent readme, 'Network exposure'");
661
661
  }
662
+ /** The unattended-upgrades config written by `setup`, and owned by Trafic. */
663
+ var UNATTENDED_UPGRADES_CONFIG = "/etc/apt/apt.conf.d/50unattended-upgrades";
662
664
  /**
663
- * Configure automatic security updates
665
+ * The Allowed-Origins entry covering Docker Engine.
666
+ *
667
+ * `origin=Docker` is the `Origin:` field of the Release file served by
668
+ * download.docker.com (its `Label:` is `Docker CE`), and that repository's
669
+ * dist is the Ubuntu codename, so `codename=${distro_codename}` pins the
670
+ * entry to the release the server runs.
664
671
  */
665
- function configureUnattendedUpgrades(io = nodeIo) {
666
- step("Configure automatic security updates");
667
- io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y unattended-upgrades apt-listchanges", { silent: true });
668
- io.writeFile("/etc/apt/apt.conf.d/50unattended-upgrades", `// Trafic: Automatic security updates
672
+ var DOCKER_ORIGIN_LINE = "\"origin=Docker,codename=${distro_codename}\"";
673
+ /**
674
+ * Build the contents of /etc/apt/apt.conf.d/50unattended-upgrades.
675
+ *
676
+ * Pure, so `setup` and migration 0014 write the exact same file.
677
+ */
678
+ function buildUnattendedUpgradesConfig() {
679
+ return `// Trafic: Automatic security updates
669
680
  Unattended-Upgrade::Allowed-Origins {
670
681
  "\${distro_id}:\${distro_codename}";
671
682
  "\${distro_id}:\${distro_codename}-security";
672
683
  "\${distro_id}ESMApps:\${distro_codename}-apps-security";
673
684
  "\${distro_id}ESM:\${distro_codename}-infra-security";
685
+ // Docker Engine ships from download.docker.com, outside Ubuntu's own
686
+ // pockets, so without this entry its security releases were never applied.
687
+ // DDEV is deliberately left out: a DDEV major landing unattended can break
688
+ // running previews, so DDEV is updated by \`trafic-agent upgrade\` instead,
689
+ // when an operator is there to see it.
690
+ ${DOCKER_ORIGIN_LINE};
674
691
  };
675
692
 
676
693
  // Do not automatically reboot
@@ -681,7 +698,33 @@ Unattended-Upgrade::Remove-Unused-Dependencies "true";
681
698
 
682
699
  // Email notifications (optional)
683
700
  // Unattended-Upgrade::Mail "root";
684
- `);
701
+ `;
702
+ }
703
+ /**
704
+ * Add the Docker origin to an existing unattended-upgrades config.
705
+ *
706
+ * Returns whether the file was changed. Idempotent: a server with no such
707
+ * file was set up without hardening and is left alone, and a config that
708
+ * already names the Docker origin is not rewritten. The whole file is
709
+ * regenerated rather than patched because Trafic generates and owns it — the
710
+ * header comment says so.
711
+ *
712
+ * No service restart is needed: unattended-upgrades reads the config on each
713
+ * daily run.
714
+ */
715
+ function addDockerOriginToUnattendedUpgrades(io = nodeIo) {
716
+ if (!io.fileExists("/etc/apt/apt.conf.d/50unattended-upgrades")) return false;
717
+ if (io.readFile("/etc/apt/apt.conf.d/50unattended-upgrades").includes("\"origin=Docker,codename=${distro_codename}\"")) return false;
718
+ io.writeFile(UNATTENDED_UPGRADES_CONFIG, buildUnattendedUpgradesConfig());
719
+ return true;
720
+ }
721
+ /**
722
+ * Configure automatic security updates
723
+ */
724
+ function configureUnattendedUpgrades(io = nodeIo) {
725
+ step("Configure automatic security updates");
726
+ io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y unattended-upgrades apt-listchanges", { silent: true });
727
+ io.writeFile(UNATTENDED_UPGRADES_CONFIG, buildUnattendedUpgradesConfig());
685
728
  io.writeFile("/etc/apt/apt.conf.d/20auto-upgrades", `APT::Periodic::Update-Package-Lists "1";
686
729
  APT::Periodic::Download-Upgradeable-Packages "1";
687
730
  APT::Periodic::AutocleanInterval "7";
@@ -1359,6 +1402,40 @@ var migration0013SshKexDefaults = {
1359
1402
  }
1360
1403
  };
1361
1404
  //#endregion
1405
+ //#region src/setup/migrations/0014__unattended_upgrades_docker_origin.ts
1406
+ /**
1407
+ * Migration 0014: apply Docker Engine security updates unattended.
1408
+ *
1409
+ * `setup` wrote an `Unattended-Upgrade::Allowed-Origins` list covering only
1410
+ * Ubuntu's own release, security and ESM pockets. Docker Engine is installed
1411
+ * from download.docker.com, which is none of them, so `docker-ce` was never
1412
+ * upgraded unless someone ran `apt-get upgrade` by hand — a Docker security
1413
+ * release could sit unapplied on a server indefinitely. The Release file
1414
+ * served by that repository carries `Origin: Docker` and `Label: Docker CE`,
1415
+ * and its dist is the Ubuntu codename, so
1416
+ * `"origin=Docker,codename=${distro_codename}"` is the entry that matches it.
1417
+ *
1418
+ * DDEV, installed from pkg.ddev.com, is deliberately not added: a DDEV major
1419
+ * landing unattended can break running previews. DDEV is updated by
1420
+ * `trafic-agent upgrade` instead, when an operator is there to see it.
1421
+ *
1422
+ * `configureUnattendedUpgrades` now writes the Docker entry, which covers
1423
+ * fresh installs. Servers set up by an earlier release still carry the old
1424
+ * list, so this migration regenerates the file — Trafic generates and owns
1425
+ * it, as its header comment states.
1426
+ *
1427
+ * Idempotent: the helper does nothing when the file is missing (a server set
1428
+ * up without hardening) or already names the Docker origin. No service
1429
+ * restart is needed — unattended-upgrades reads the config on each daily run.
1430
+ */
1431
+ var migration0014UnattendedUpgradesDockerOrigin = {
1432
+ id: "0014__unattended_upgrades_docker_origin",
1433
+ description: "Allow unattended upgrades of Docker Engine",
1434
+ run() {
1435
+ addDockerOriginToUnattendedUpgrades();
1436
+ }
1437
+ };
1438
+ //#endregion
1362
1439
  //#region src/setup/migrations/index.ts
1363
1440
  /** Path to the persisted migration state file */
1364
1441
  var MIGRATIONS_STATE_FILE = "/etc/trafic/.migrations.json";
@@ -1376,7 +1453,8 @@ var ALL_MIGRATIONS = [
1376
1453
  migration0010CatchallEntrypoints,
1377
1454
  migration0011PurgeAcmeWhenDisabled,
1378
1455
  migration0012DockerUserToolPorts,
1379
- migration0013SshKexDefaults
1456
+ migration0013SshKexDefaults,
1457
+ migration0014UnattendedUpgradesDockerOrigin
1380
1458
  ];
1381
1459
  /**
1382
1460
  * Load the current migration state from disk.
@@ -1524,4 +1602,4 @@ async function audit() {
1524
1602
  //#endregion
1525
1603
  export { printAuditResults as a, stopIdleProjects as c, buildDockerFirewallScript as d, buildDockerFirewallUnit as f, validateConfig as g, parseDuration as h, runPendingMigrations as i, DOCKER_FIREWALL_SCRIPT as l, loadConfig as m, setup as n, runAudit as o, configureDockerFirewall as p, listMigrations as r, startIdleScheduler as s, audit as t, DOCKER_FIREWALL_UNIT as u };
1526
1604
 
1527
- //# sourceMappingURL=setup-BIfcVIa7.js.map
1605
+ //# sourceMappingURL=setup-BrMbM8w4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup-BrMbM8w4.js","names":[],"sources":["../src/utils/config.ts","../src/setup/docker-firewall.ts","../src/tasks/stop-idle.ts","../src/setup/user.ts","../src/setup/docker.ts","../src/setup/agent.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/index.ts","../src/setup/index.ts"],"sourcesContent":["import { readFileSync, existsSync } from \"node:fs\";\nimport { parse } from \"smol-toml\";\nimport type { AgentConfig, AuthConfig, AuthRule } from \"../types.js\";\n\nconst DEFAULT_CONFIG_PATHS = [\n \"/etc/trafic/config.toml\",\n \"./config.toml\",\n \"./trafic.toml\",\n];\n\nconst DEFAULT_CONFIG: AgentConfig = {\n tld: \"\",\n port: 9876,\n dbPath: \"/var/lib/trafic/db.sqlite\",\n projectListPath: \"/home/ddev/.ddev/project_list.yaml\",\n projectsDir: \"/home/ddev/www\",\n idleTimeout: \"30m\",\n idleCheckInterval: \"5m\",\n auth: {\n defaultPolicy: \"basic\",\n allowedIps: [],\n tokens: [],\n basicAuth: [],\n rules: [],\n trustedProxyHops: 1,\n },\n};\n\n/**\n * Parse a duration string (e.g., \"30m\", \"1h\", \"2h30m\") to milliseconds\n */\nexport function parseDuration(duration: string): number {\n const regex = /(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?/;\n const match = duration.match(regex);\n if (!match) return 0;\n\n const hours = parseInt(match[1] ?? \"0\", 10);\n const minutes = parseInt(match[2] ?? \"0\", 10);\n const seconds = parseInt(match[3] ?? \"0\", 10);\n\n return (hours * 3600 + minutes * 60 + seconds) * 1000;\n}\n\n/**\n * Load configuration from TOML file\n */\nexport function loadConfig(configPath?: string): AgentConfig {\n // Find config file\n let path = configPath;\n if (!path) {\n path = DEFAULT_CONFIG_PATHS.find((p) => existsSync(p));\n }\n\n if (!path || !existsSync(path)) {\n console.warn(\"No config file found, using defaults\");\n return DEFAULT_CONFIG;\n }\n\n const content = readFileSync(path, \"utf-8\");\n const parsed = parse(content) as Record<string, unknown>;\n\n // Merge with defaults\n const auth = mergeAuthConfig(parsed.auth as Record<string, unknown>);\n\n return {\n tld: (parsed.tld as string) ?? DEFAULT_CONFIG.tld,\n port: (parsed.port as number) ?? DEFAULT_CONFIG.port,\n dbPath: (parsed.db_path as string) ?? DEFAULT_CONFIG.dbPath,\n projectListPath:\n (parsed.project_list_path as string) ?? DEFAULT_CONFIG.projectListPath,\n projectsDir: (parsed.projects_dir as string) ?? DEFAULT_CONFIG.projectsDir,\n idleTimeout:\n (parsed.idle_timeout as string) ?? DEFAULT_CONFIG.idleTimeout,\n idleCheckInterval:\n (parsed.idle_check_interval as string) ??\n DEFAULT_CONFIG.idleCheckInterval,\n auth,\n };\n}\n\n/**\n * Parse basic_auth entries from TOML.\n * Accepts either plain strings (\"user:pass\") or objects ({username, password}).\n */\nfunction parseBasicAuthEntries(raw: unknown): string[] | null {\n if (!Array.isArray(raw)) return null;\n return raw.map((entry) => {\n if (typeof entry === \"string\") return entry;\n if (typeof entry === \"object\" && entry !== null) {\n const { username, password } = entry as Record<string, unknown>;\n return `${username}:${password}`;\n }\n return String(entry);\n });\n}\n\nfunction mergeAuthConfig(raw?: Record<string, unknown>): AuthConfig {\n if (!raw) return DEFAULT_CONFIG.auth;\n\n const rules = (raw.rules as Array<Record<string, unknown>>) ?? [];\n\n return {\n defaultPolicy:\n (raw.default_policy as AuthConfig[\"defaultPolicy\"]) ??\n DEFAULT_CONFIG.auth.defaultPolicy,\n allowedIps:\n (raw.allowed_ips as string[]) ?? DEFAULT_CONFIG.auth.allowedIps,\n tokens: (raw.tokens as string[]) ?? DEFAULT_CONFIG.auth.tokens,\n basicAuth: parseBasicAuthEntries(raw.basic_auth) ?? DEFAULT_CONFIG.auth.basicAuth,\n rules: rules.map(\n (r): AuthRule => ({\n match: r.match as string,\n policy: r.policy as AuthRule[\"policy\"],\n tokens: r.tokens as string[] | undefined,\n allowedIps: r.allowed_ips as string[] | undefined,\n }),\n ),\n trustedProxyHops: parseTrustedProxyHops(raw.trusted_proxy_hops),\n };\n}\n\n/**\n * Parse trusted_proxy_hops, ignoring values that cannot be a hop count.\n *\n * A bad value falls back to the default rather than 0, because 0 would read\n * the socket peer — always the proxy — and quietly stop the IP allowlist and\n * per-hostname IP rules from ever matching.\n */\nfunction parseTrustedProxyHops(raw: unknown): number {\n const hops = typeof raw === \"number\" ? raw : Number(raw);\n\n if (!Number.isInteger(hops) || hops < 1) {\n return DEFAULT_CONFIG.auth.trustedProxyHops;\n }\n\n return hops;\n}\n\n/**\n * Validate configuration\n */\nexport function validateConfig(config: AgentConfig): string[] {\n const errors: string[] = [];\n\n if (!config.tld) {\n errors.push(\"tld is required\");\n }\n\n if (config.port < 1 || config.port > 65535) {\n errors.push(\"port must be between 1 and 65535\");\n }\n\n if (\n config.auth.defaultPolicy !== \"allow\" &&\n config.auth.defaultPolicy !== \"deny\" &&\n config.auth.defaultPolicy !== \"basic\" &&\n config.auth.defaultPolicy !== \"token\"\n ) {\n errors.push('auth.default_policy must be \"allow\", \"deny\", \"basic\", or \"token\"');\n }\n\n return errors;\n}\n","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 { getIdleProjects, setProjectStatus } from \"../utils/db.js\";\nimport { stopProject, getProjectInfo, loadProjectList } from \"../utils/ddev.js\";\nimport { parseDuration } from \"../utils/config.js\";\nimport { loadProjectConfig, shouldNeverStop, getIdleTimeoutMs } from \"../utils/project-config.js\";\nimport type { AgentConfig } from \"../types.js\";\n\n/**\n * Stop idle projects that haven't been accessed recently\n * Respects per-project idle_timeout settings from .ddev/config.trafic.yaml\n */\nexport async function stopIdleProjects(config: AgentConfig): 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 // Get projects idle according to global threshold\n const idleProjects = getIdleProjects(globalThresholdMs);\n\n for (const project of idleProjects) {\n const projectDir = projects.get(project.name);\n\n // Load per-project config if available\n if (projectDir) {\n const projectConfig = loadProjectConfig(projectDir);\n\n // Skip if project is configured to never stop\n if (shouldNeverStop(projectConfig)) {\n continue;\n }\n\n // Check project-specific idle timeout\n const projectThresholdMs = getIdleTimeoutMs(projectConfig);\n if (projectThresholdMs !== undefined) {\n const idleDuration = Date.now() - project.lastAccess;\n if (idleDuration < projectThresholdMs) {\n // Not idle according to project-specific timeout\n continue;\n }\n }\n }\n\n // Double-check project is actually running via DDEV\n const info = await getProjectInfo(project.name);\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);\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\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, 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\";\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/**\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 };\n\n io.exec(\"mkdir -p /etc/docker\");\n\n const configPath = \"/etc/docker/daemon.json\";\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 // Reload Docker to apply config\n io.exec(\"systemctl reload docker || systemctl restart docker\");\n success(\"Docker configured with production settings\");\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 } from \"./steps.js\";\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\n */\nexport function installAgent(io: SetupIo = nodeIo): void {\n step(\"Install Trafic agent\");\n\n // Install the agent globally\n io.exec(\"npm install -g @studiometa/trafic-agent\");\n\n success(\"Trafic agent installed\");\n}\n\n/**\n * Create the agent configuration\n */\nexport function createAgentConfig(\n options: { tld: string; trustedProxyHops?: number },\n io: SetupIo = nodeIo,\n): void {\n const { tld, trustedProxyHops = DEFAULT_TRUSTED_PROXY_HOPS } = options;\n step(\"Create agent configuration\");\n\n // Create directories\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\n const configPath = \"/etc/trafic/config.toml\";\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 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`;\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 * 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.\nReadWritePaths=/var/lib/trafic /home/ddev\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 { 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 { execSync } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport type { AuditCheck } from \"./types.js\";\n\n/**\n * Run a command silently and return output\n */\nfunction execSilent(command: string): string {\n try {\n return execSync(command, { encoding: \"utf-8\", stdio: \"pipe\" }).trim();\n } catch {\n return \"\";\n }\n}\n\n/**\n * Check if a service is active\n */\nfunction isServiceActive(service: string): boolean {\n const status = execSilent(`systemctl is-active ${service}`);\n return status === \"active\";\n}\n\n/**\n * Audit SSH configuration\n */\nfunction auditSsh(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Check root login\n const sshdConfig = existsSync(\"/etc/ssh/sshd_config\")\n ? readFileSync(\"/etc/ssh/sshd_config\", \"utf-8\")\n : \"\";\n const traficConfig = existsSync(\"/etc/ssh/sshd_config.d/trafic.conf\")\n ? readFileSync(\"/etc/ssh/sshd_config.d/trafic.conf\", \"utf-8\")\n : \"\";\n\n const config = sshdConfig + \"\\n\" + traficConfig;\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 */\nfunction auditFirewall(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n const ufwStatus = 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 */\nfunction auditServices(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Fail2ban\n const fail2banActive = isServiceActive(\"fail2ban\");\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\");\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\");\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 */\nfunction auditDocker(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Docker running\n const dockerActive = isServiceActive(\"docker\");\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 = execSilent(\n \"docker images -f dangling=true -q | wc -l\",\n );\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 = 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 */\nfunction auditPermissions(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Config file\n if (existsSync(\"/etc/trafic/config.toml\")) {\n const stats = 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 (existsSync(\"/home/ddev/www\")) {\n const owner = 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 * Run all audit checks\n */\nexport function runAudit(): AuditCheck[] {\n return [\n ...auditSsh(),\n ...auditFirewall(),\n ...auditServices(),\n ...auditDocker(),\n ...auditPermissions(),\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 pkg.ddev.com, is deliberately not added: a DDEV major\n * landing unattended can break running previews. DDEV is updated by\n * `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, 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\";\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];\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.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 // 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 });\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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studiometa/trafic-agent",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Server agent for DDEV preview environments — auth, scale-to-zero, auto-start",
5
5
  "keywords": [
6
6
  "agent",
@@ -1 +0,0 @@
1
- {"version":3,"file":"setup-BIfcVIa7.js","names":[],"sources":["../src/utils/config.ts","../src/setup/docker-firewall.ts","../src/tasks/stop-idle.ts","../src/setup/user.ts","../src/setup/docker.ts","../src/setup/agent.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/index.ts","../src/setup/index.ts"],"sourcesContent":["import { readFileSync, existsSync } from \"node:fs\";\nimport { parse } from \"smol-toml\";\nimport type { AgentConfig, AuthConfig, AuthRule } from \"../types.js\";\n\nconst DEFAULT_CONFIG_PATHS = [\n \"/etc/trafic/config.toml\",\n \"./config.toml\",\n \"./trafic.toml\",\n];\n\nconst DEFAULT_CONFIG: AgentConfig = {\n tld: \"\",\n port: 9876,\n dbPath: \"/var/lib/trafic/db.sqlite\",\n projectListPath: \"/home/ddev/.ddev/project_list.yaml\",\n projectsDir: \"/home/ddev/www\",\n idleTimeout: \"30m\",\n idleCheckInterval: \"5m\",\n auth: {\n defaultPolicy: \"basic\",\n allowedIps: [],\n tokens: [],\n basicAuth: [],\n rules: [],\n trustedProxyHops: 1,\n },\n};\n\n/**\n * Parse a duration string (e.g., \"30m\", \"1h\", \"2h30m\") to milliseconds\n */\nexport function parseDuration(duration: string): number {\n const regex = /(?:(\\d+)h)?(?:(\\d+)m)?(?:(\\d+)s)?/;\n const match = duration.match(regex);\n if (!match) return 0;\n\n const hours = parseInt(match[1] ?? \"0\", 10);\n const minutes = parseInt(match[2] ?? \"0\", 10);\n const seconds = parseInt(match[3] ?? \"0\", 10);\n\n return (hours * 3600 + minutes * 60 + seconds) * 1000;\n}\n\n/**\n * Load configuration from TOML file\n */\nexport function loadConfig(configPath?: string): AgentConfig {\n // Find config file\n let path = configPath;\n if (!path) {\n path = DEFAULT_CONFIG_PATHS.find((p) => existsSync(p));\n }\n\n if (!path || !existsSync(path)) {\n console.warn(\"No config file found, using defaults\");\n return DEFAULT_CONFIG;\n }\n\n const content = readFileSync(path, \"utf-8\");\n const parsed = parse(content) as Record<string, unknown>;\n\n // Merge with defaults\n const auth = mergeAuthConfig(parsed.auth as Record<string, unknown>);\n\n return {\n tld: (parsed.tld as string) ?? DEFAULT_CONFIG.tld,\n port: (parsed.port as number) ?? DEFAULT_CONFIG.port,\n dbPath: (parsed.db_path as string) ?? DEFAULT_CONFIG.dbPath,\n projectListPath:\n (parsed.project_list_path as string) ?? DEFAULT_CONFIG.projectListPath,\n projectsDir: (parsed.projects_dir as string) ?? DEFAULT_CONFIG.projectsDir,\n idleTimeout:\n (parsed.idle_timeout as string) ?? DEFAULT_CONFIG.idleTimeout,\n idleCheckInterval:\n (parsed.idle_check_interval as string) ??\n DEFAULT_CONFIG.idleCheckInterval,\n auth,\n };\n}\n\n/**\n * Parse basic_auth entries from TOML.\n * Accepts either plain strings (\"user:pass\") or objects ({username, password}).\n */\nfunction parseBasicAuthEntries(raw: unknown): string[] | null {\n if (!Array.isArray(raw)) return null;\n return raw.map((entry) => {\n if (typeof entry === \"string\") return entry;\n if (typeof entry === \"object\" && entry !== null) {\n const { username, password } = entry as Record<string, unknown>;\n return `${username}:${password}`;\n }\n return String(entry);\n });\n}\n\nfunction mergeAuthConfig(raw?: Record<string, unknown>): AuthConfig {\n if (!raw) return DEFAULT_CONFIG.auth;\n\n const rules = (raw.rules as Array<Record<string, unknown>>) ?? [];\n\n return {\n defaultPolicy:\n (raw.default_policy as AuthConfig[\"defaultPolicy\"]) ??\n DEFAULT_CONFIG.auth.defaultPolicy,\n allowedIps:\n (raw.allowed_ips as string[]) ?? DEFAULT_CONFIG.auth.allowedIps,\n tokens: (raw.tokens as string[]) ?? DEFAULT_CONFIG.auth.tokens,\n basicAuth: parseBasicAuthEntries(raw.basic_auth) ?? DEFAULT_CONFIG.auth.basicAuth,\n rules: rules.map(\n (r): AuthRule => ({\n match: r.match as string,\n policy: r.policy as AuthRule[\"policy\"],\n tokens: r.tokens as string[] | undefined,\n allowedIps: r.allowed_ips as string[] | undefined,\n }),\n ),\n trustedProxyHops: parseTrustedProxyHops(raw.trusted_proxy_hops),\n };\n}\n\n/**\n * Parse trusted_proxy_hops, ignoring values that cannot be a hop count.\n *\n * A bad value falls back to the default rather than 0, because 0 would read\n * the socket peer — always the proxy — and quietly stop the IP allowlist and\n * per-hostname IP rules from ever matching.\n */\nfunction parseTrustedProxyHops(raw: unknown): number {\n const hops = typeof raw === \"number\" ? raw : Number(raw);\n\n if (!Number.isInteger(hops) || hops < 1) {\n return DEFAULT_CONFIG.auth.trustedProxyHops;\n }\n\n return hops;\n}\n\n/**\n * Validate configuration\n */\nexport function validateConfig(config: AgentConfig): string[] {\n const errors: string[] = [];\n\n if (!config.tld) {\n errors.push(\"tld is required\");\n }\n\n if (config.port < 1 || config.port > 65535) {\n errors.push(\"port must be between 1 and 65535\");\n }\n\n if (\n config.auth.defaultPolicy !== \"allow\" &&\n config.auth.defaultPolicy !== \"deny\" &&\n config.auth.defaultPolicy !== \"basic\" &&\n config.auth.defaultPolicy !== \"token\"\n ) {\n errors.push('auth.default_policy must be \"allow\", \"deny\", \"basic\", or \"token\"');\n }\n\n return errors;\n}\n","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 { getIdleProjects, setProjectStatus } from \"../utils/db.js\";\nimport { stopProject, getProjectInfo, loadProjectList } from \"../utils/ddev.js\";\nimport { parseDuration } from \"../utils/config.js\";\nimport { loadProjectConfig, shouldNeverStop, getIdleTimeoutMs } from \"../utils/project-config.js\";\nimport type { AgentConfig } from \"../types.js\";\n\n/**\n * Stop idle projects that haven't been accessed recently\n * Respects per-project idle_timeout settings from .ddev/config.trafic.yaml\n */\nexport async function stopIdleProjects(config: AgentConfig): 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 // Get projects idle according to global threshold\n const idleProjects = getIdleProjects(globalThresholdMs);\n\n for (const project of idleProjects) {\n const projectDir = projects.get(project.name);\n\n // Load per-project config if available\n if (projectDir) {\n const projectConfig = loadProjectConfig(projectDir);\n\n // Skip if project is configured to never stop\n if (shouldNeverStop(projectConfig)) {\n continue;\n }\n\n // Check project-specific idle timeout\n const projectThresholdMs = getIdleTimeoutMs(projectConfig);\n if (projectThresholdMs !== undefined) {\n const idleDuration = Date.now() - project.lastAccess;\n if (idleDuration < projectThresholdMs) {\n // Not idle according to project-specific timeout\n continue;\n }\n }\n }\n\n // Double-check project is actually running via DDEV\n const info = await getProjectInfo(project.name);\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);\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\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, 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\";\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/**\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 };\n\n io.exec(\"mkdir -p /etc/docker\");\n\n const configPath = \"/etc/docker/daemon.json\";\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 // Reload Docker to apply config\n io.exec(\"systemctl reload docker || systemctl restart docker\");\n success(\"Docker configured with production settings\");\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 } from \"./steps.js\";\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\n */\nexport function installAgent(io: SetupIo = nodeIo): void {\n step(\"Install Trafic agent\");\n\n // Install the agent globally\n io.exec(\"npm install -g @studiometa/trafic-agent\");\n\n success(\"Trafic agent installed\");\n}\n\n/**\n * Create the agent configuration\n */\nexport function createAgentConfig(\n options: { tld: string; trustedProxyHops?: number },\n io: SetupIo = nodeIo,\n): void {\n const { tld, trustedProxyHops = DEFAULT_TRUSTED_PROXY_HOPS } = options;\n step(\"Create agent configuration\");\n\n // Create directories\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\n const configPath = \"/etc/trafic/config.toml\";\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 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`;\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 * 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.\nReadWritePaths=/var/lib/trafic /home/ddev\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 { 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/**\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 // Configure for security updates only\n const upgradesConfig = `// 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};\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 io.writeFile(\n \"/etc/apt/apt.conf.d/50unattended-upgrades\",\n upgradesConfig,\n );\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 { execSync } from \"node:child_process\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport type { AuditCheck } from \"./types.js\";\n\n/**\n * Run a command silently and return output\n */\nfunction execSilent(command: string): string {\n try {\n return execSync(command, { encoding: \"utf-8\", stdio: \"pipe\" }).trim();\n } catch {\n return \"\";\n }\n}\n\n/**\n * Check if a service is active\n */\nfunction isServiceActive(service: string): boolean {\n const status = execSilent(`systemctl is-active ${service}`);\n return status === \"active\";\n}\n\n/**\n * Audit SSH configuration\n */\nfunction auditSsh(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Check root login\n const sshdConfig = existsSync(\"/etc/ssh/sshd_config\")\n ? readFileSync(\"/etc/ssh/sshd_config\", \"utf-8\")\n : \"\";\n const traficConfig = existsSync(\"/etc/ssh/sshd_config.d/trafic.conf\")\n ? readFileSync(\"/etc/ssh/sshd_config.d/trafic.conf\", \"utf-8\")\n : \"\";\n\n const config = sshdConfig + \"\\n\" + traficConfig;\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 */\nfunction auditFirewall(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n const ufwStatus = 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 */\nfunction auditServices(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Fail2ban\n const fail2banActive = isServiceActive(\"fail2ban\");\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\");\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\");\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 */\nfunction auditDocker(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Docker running\n const dockerActive = isServiceActive(\"docker\");\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 = execSilent(\n \"docker images -f dangling=true -q | wc -l\",\n );\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 = 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 */\nfunction auditPermissions(): AuditCheck[] {\n const checks: AuditCheck[] = [];\n\n // Config file\n if (existsSync(\"/etc/trafic/config.toml\")) {\n const stats = 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 (existsSync(\"/home/ddev/www\")) {\n const owner = 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 * Run all audit checks\n */\nexport function runAudit(): AuditCheck[] {\n return [\n ...auditSsh(),\n ...auditFirewall(),\n ...auditServices(),\n ...auditDocker(),\n ...auditPermissions(),\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 { 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\";\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];\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.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 // 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 });\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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"}