@studiometa/trafic-agent 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +156 -13
  2. package/dist/cli.js +134 -14
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +7 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -3
  7. package/dist/{server-DZAo8Clg.js → server-B_iFvS6v.js} +366 -28
  8. package/dist/server-B_iFvS6v.js.map +1 -0
  9. package/dist/server.d.ts +21 -2
  10. package/dist/server.d.ts.map +1 -1
  11. package/dist/server.js +2 -2
  12. package/dist/setup/agent.d.ts +11 -1
  13. package/dist/setup/agent.d.ts.map +1 -1
  14. package/dist/setup/migrations/0020__backup_dir.d.ts +23 -0
  15. package/dist/setup/migrations/0020__backup_dir.d.ts.map +1 -0
  16. package/dist/setup/migrations/index.d.ts.map +1 -1
  17. package/dist/{setup-DWgb6dAD.js → setup-BbXL1ZFY.js} +826 -209
  18. package/dist/setup-BbXL1ZFY.js.map +1 -0
  19. package/dist/tasks/access-logs.d.ts +8 -0
  20. package/dist/tasks/access-logs.d.ts.map +1 -0
  21. package/dist/tasks/backup.d.ts +48 -0
  22. package/dist/tasks/backup.d.ts.map +1 -0
  23. package/dist/tasks/destroy-project.d.ts +71 -0
  24. package/dist/tasks/destroy-project.d.ts.map +1 -0
  25. package/dist/tasks/destroy-scheduled.d.ts +41 -0
  26. package/dist/tasks/destroy-scheduled.d.ts.map +1 -0
  27. package/dist/tasks/running-limit.d.ts +54 -0
  28. package/dist/tasks/running-limit.d.ts.map +1 -0
  29. package/dist/tasks/stop-idle.d.ts +12 -2
  30. package/dist/tasks/stop-idle.d.ts.map +1 -1
  31. package/dist/types.d.ts +41 -2
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/utils/config.d.ts +6 -0
  34. package/dist/utils/config.d.ts.map +1 -1
  35. package/dist/utils/db.d.ts +59 -1
  36. package/dist/utils/db.d.ts.map +1 -1
  37. package/dist/utils/ddev.d.ts +10 -3
  38. package/dist/utils/ddev.d.ts.map +1 -1
  39. package/dist/utils/paths.d.ts +3 -0
  40. package/dist/utils/paths.d.ts.map +1 -0
  41. package/package.json +1 -1
  42. package/dist/server-DZAo8Clg.js.map +0 -1
  43. package/dist/setup-DWgb6dAD.js.map +0 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @studiometa/trafic-agent
2
2
 
3
- Server agent for DDEV preview environments — auth, scale-to-zero, and auto-start.
3
+ Server agent for DDEV preview environments — auth, scale-to-zero, auto-start, scheduled removals and backups.
4
4
 
5
5
  Part of [Trafic](https://github.com/studiometa/trafic), a tool for managing DDEV preview environments on Linux servers.
6
6
 
@@ -10,6 +10,9 @@ Part of [Trafic](https://github.com/studiometa/trafic), a tool for managing DDEV
10
10
  - **Scale-to-zero** — Automatically stop idle DDEV projects to save RAM
11
11
  - **Auto-start** — Show a waiting page and restart stopped projects on request
12
12
  - **Per-project config** — Override auth and idle timeout per project
13
+ - **Backups on removal** — Dump the database and archive the upload directories before a project is removed
14
+ - **Scheduled removal** — Remove a project after a delay; a new deploy cancels it
15
+ - **Running limit** — `max_running_projects` stops the least recently used projects
13
16
 
14
17
  ## Installation
15
18
 
@@ -19,24 +22,47 @@ npm install -g @studiometa/trafic-agent
19
22
 
20
23
  ## Commands
21
24
 
22
- ### `trafic-agent serve`
25
+ ### `trafic-agent start`
23
26
 
24
- Start the agent server.
27
+ Start the agent server. Setup installs a systemd service that runs this command.
25
28
 
26
29
  ```bash
27
- trafic-agent serve
30
+ trafic-agent start
28
31
  # or with custom config
29
- trafic-agent serve --config /etc/trafic/config.toml
32
+ trafic-agent start --config /etc/trafic/config.toml
33
+ # or on another port than the one in the config
34
+ trafic-agent start --port 9877
30
35
  ```
31
36
 
37
+ | Option | Description | Default |
38
+ |--------|-------------|---------|
39
+ | `-c`, `--config` | Path to the config file | first of `/etc/trafic/config.toml`, `./config.toml`, `./trafic.toml` |
40
+ | `-p`, `--port` | Port of the agent HTTP server. Replaces `port` from the config | `port` from the config |
41
+
32
42
  ### `trafic-agent setup`
33
43
 
34
- Interactive server setup — installs Docker, DDEV, configures DNS, and sets up the agent as a systemd service.
44
+ Server setup, run as root. It installs Docker, DDEV and Node.js, configures DDEV and Traefik for forward auth, writes `/etc/trafic/config.toml`, installs the agent as a systemd service and hardens the server. You configure the wildcard DNS yourself. Setup takes its options from flags only: it asks no questions.
35
45
 
36
46
  ```bash
37
- trafic-agent setup --tld previews.example.com
47
+ sudo trafic-agent setup --tld=previews.example.com
38
48
  ```
39
49
 
50
+ `trafic setup` runs this command over SSH. See the [CLI README](../trafic-cli/README.md#trafic-setup).
51
+
52
+ | Option | Description | Default |
53
+ |--------|-------------|---------|
54
+ | `--tld` | TLD for DDEV projects. Required on the first run; a re-run reuses `tld` from the config | `tld` from the config |
55
+ | `--email` | Email for Let's Encrypt certificates. Without it, projects use a mkcert certificate | - |
56
+ | `--dns-provider` | DNS-01 provider (lego name, e.g. `cloudflare`), for one wildcard `*.<tld>` certificate. Requires `--email`. A re-run reuses `[tls] dns_provider` from the config, and then `--email` is required again. See [TLS](#tls) | `[tls] dns_provider` from the config |
57
+ | `--dns-env` | Credential for that provider as `KEY=VALUE`, repeatable | `[tls.dns_env]` from the config |
58
+ | `--ssh-users` | SSH users to allow, comma-separated | `ddev` |
59
+ | `--trusted-proxy-hops` | Proxies in front of the agent (`2` behind a CDN). Written to a new config only; ignored when the config exists | `1` |
60
+ | `--no-hardening` | Skip server hardening | `false` |
61
+ | `--no-root-ssh` | Disable root SSH login (recovery via rescue mode only) | `false` |
62
+ | `--no-docker` | Skip Docker installation | `false` |
63
+ | `--no-ddev` | Skip DDEV installation | `false` |
64
+ | `--dry-run` | Show what would be done without making changes | `false` |
65
+
40
66
  **Requirements:**
41
67
  - Ubuntu 24.04 LTS
42
68
  - Root access (for initial setup)
@@ -44,6 +70,90 @@ trafic-agent setup --tld previews.example.com
44
70
 
45
71
  **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
72
 
73
+ ### `trafic-agent audit`
74
+
75
+ Check the security state of the server: SSH, firewall, Fail2ban, automatic updates, the agent service, Docker, disk usage, the permissions of the config, the owner of the projects directory and, with a `[tls]` section, the wildcard certificate and the permissions of its credentials. The command exits with code 1 when a check fails. Run it as root: `ufw status` and the files under `/var/lib/docker` need root.
76
+
77
+ ```bash
78
+ sudo trafic-agent audit
79
+ ```
80
+
81
+ ### `trafic-agent version` / `trafic-agent help`
82
+
83
+ `trafic-agent version` (also `-v`, `--version`) prints the version. `trafic-agent help` (also `-h`, `--help`) prints the help with every option.
84
+
85
+ ### `trafic-agent skill`
86
+
87
+ Print the [`trafic-agent` agent skill](../../skills/trafic-agent/SKILL.md) of this version to stdout. A coding agent reads it to operate the server. To install the skill from the repository instead, run `npx skills add studiometa/trafic` or `gh skill install studiometa/trafic`.
88
+
89
+ ```bash
90
+ trafic-agent skill
91
+ ```
92
+
93
+ ### `trafic-agent destroy`
94
+
95
+ Remove a project from the server. Run it as the `ddev` user, the user the service runs as. The command refuses to run as root: DDEV does not run as root, and root could leave files in the database directory that the service cannot write to.
96
+
97
+ ```bash
98
+ trafic-agent destroy preview-42--my-app
99
+ # or with custom config
100
+ trafic-agent destroy preview-42--my-app --config /etc/trafic/config.toml
101
+ # back up the project first
102
+ trafic-agent destroy preview-42--my-app --backup
103
+ # give the backup up to one hour
104
+ trafic-agent destroy preview-42--my-app --backup --timeout 1h
105
+ # remove the project in 48 hours, after a backup
106
+ trafic-agent destroy preview-42--my-app --delay 48h --backup
107
+ # cancel that removal
108
+ trafic-agent destroy preview-42--my-app --cancel
109
+ # list the scheduled removals
110
+ trafic-agent destroy --list
111
+ ```
112
+
113
+ Steps:
114
+ 1. **Backup** (with `--backup` only) — see [Backups](#backups). If the backup fails, the command removes nothing and exits with an error
115
+ 2. **DDEV** — runs `ddev delete -Oy` in the project directory (containers and volumes, no snapshot). A failure is reported as a warning and does not stop the removal
116
+ 3. **Directory** — removes the project directory
117
+ 4. **Database** — deletes the project's rows from the agent database (`projects`, `access_logs` and `scheduled_destroys`)
118
+
119
+ The project directory is the `approot` from DDEV's `project_list.yaml`, or `<projects_dir>/<name>` when DDEV no longer knows the project. When the directory does not exist, the command prints a warning, does not run DDEV and only deletes the project's rows from the agent database, so a retry after a failed run cleans them up. The command refuses a directory that is not inside `projects_dir`, also after it follows symbolic links, and refuses a project directory that is a symbolic link. It also refuses an empty name, `.`, `..` or a name with a `/`.
120
+
121
+ The command checks the config as `start` does, and exits with an error before it changes anything when the config is not valid (for example a relative `[backup] dir`).
122
+
123
+ `trafic destroy` runs this command over SSH.
124
+
125
+ ### Scheduled removals
126
+
127
+ `trafic-agent destroy <name> --delay <duration>` removes nothing. It records the removal date (now plus the delay) in the agent database and exits. The delay uses hours, minutes and seconds, in this order and without spaces: `48h`, `1h30m`, `90m`. The command refuses any other delay, for example `2d`, `1h30` or `48h 30m`. With `--backup`, the agent backs up the project before the scheduled removal, and keeps `--timeout` as the time limit of that backup. A new `--delay` for the same project replaces the date, the backup choice and the timeout, unless the removal has started.
128
+
129
+ The agent service checks the scheduled removals every `idle_check_interval` (default `5m`). It removes each project whose date is past with the same steps as `trafic-agent destroy`. When a removal fails (for example, the backup fails), the agent logs the error, keeps the removal scheduled and tries again on the next check. A removal that fails after its backup (for example, the service stops during `ddev delete`) does not back up again on the next check: the backup is already made. A failure on one project does not stop the removal of the others.
130
+
131
+ The service makes the backup of a scheduled removal itself, inside its systemd sandbox (`ProtectSystem=strict`). The sandbox lets it write to `/var/backups/trafic`, `/home/ddev` and `/var/lib/trafic` only. When `[backup] dir` is another directory, each scheduled backup fails with a `Cannot write to the backup directory` error that names the sandbox, and the project is never removed. Keep `[backup] dir` inside one of these directories.
132
+
133
+ `trafic deploy` cancels the scheduled removal of the project it deploys. To cancel it by hand, run `trafic-agent destroy <name> --cancel`. A cancel stops a removal that is due but not started, or that is still in its backup. After the backup, the agent marks the removal as started, just before `ddev delete`. From then on, `--cancel` fails with `the removal of <name> is in progress` and the project is removed, so a `trafic deploy` fails at its first step instead of deploying a project that is about to go. `trafic-agent destroy --list` shows the scheduled removals, soonest first, and marks a started removal as `in progress`.
134
+
135
+ The date is kept in the agent database, not in `.ddev/config.trafic.yaml`: the agent finds it even when DDEV no longer knows the project, and a deploy cancels it with one command.
136
+
137
+ ### Backups
138
+
139
+ `trafic-agent destroy --backup` writes a backup to `<backup.dir>/<project>/<timestamp>/`, for example `/var/backups/trafic/preview-42--my-app/2026-10-14T07-40-24Z/`:
140
+
141
+ - `db.sql.gz` — the `ddev export-db` dump. Skipped when the project has no database (`omit_containers: [db]`)
142
+ - `files.tar.gz` — the upload directories, with paths relative to the project directory. Skipped when the project has none
143
+
144
+ The upload directories are the ones DDEV uses: `upload_dirs` from the project's `.ddev/config.yaml`, or the default of the project type (for example `wp-content/uploads` for WordPress). The agent reads them from `DDEV_FILES_DIRS` in the web container. Both steps need the project to run, so the agent starts a stopped project and stops it again after the backup. While it removes a project, the idle sweep and [`max_running_projects`](#running-project-limit) do not stop it, and the waiting page does not start it again (it shows an error page): the project has the status `destroying` in the agent database. When the removal is killed (for example, the CI job is cancelled or the SSH connection drops), the agent sets the status back to `running` once the `trafic-agent destroy` process is gone, at the next idle sweep or request. The idle sweep then stops the project when it is idle.
145
+
146
+ The backup has one time limit for the dump and the archive together: 40 minutes by default. `--timeout` sets it, as a duration such as `90s`, `10m` or `1h` (a bare number is minutes). The start and the stop of the project are not part of it. A backup that takes longer fails, and nothing is removed. `trafic destroy --backup` passes its own `--timeout` (default `10m`). A file that changes while the agent archives it (the project runs during the backup) does not fail the backup: the archive has the file as `tar` read it, and the agent prints a warning.
147
+
148
+ The agent service removes backups older than `retain_days` once an hour. It can only write to `/var/backups/trafic`, `/home/ddev` and `/var/lib/trafic`: if you set `dir` to another path, remove old backups yourself, and do not use `--delay` with `--backup` (see [Scheduled removals](#scheduled-removals)). `dir` must not be `projects_dir`, be inside it or contain it (so not `/home/ddev` itself, with the default `projects_dir`), because the removal of a project would remove its backup: the agent refuses such a config. Setup creates `/var/backups/trafic`, owned by `ddev` with mode `700`.
149
+
150
+ To restore a backup into a project:
151
+
152
+ ```bash
153
+ ddev import-db --file=/var/backups/trafic/<project>/<timestamp>/db.sql.gz
154
+ tar -xzf /var/backups/trafic/<project>/<timestamp>/files.tar.gz -C <project directory>
155
+ ```
156
+
47
157
  ### `trafic-agent upgrade` / `trafic-agent update`
48
158
 
49
159
  Upgrade the server to the latest version of `trafic-agent` in one command. `update` is an alias for `upgrade`.
@@ -81,7 +191,9 @@ Migration state is stored in `/etc/trafic/.migrations.json` and updated after ea
81
191
 
82
192
  ## Configuration
83
193
 
84
- Create `/etc/trafic/config.toml`:
194
+ Setup writes `/etc/trafic/config.toml`. Edit it, then run `sudo systemctl restart trafic-agent`. See [examples/config.toml](../../examples/config.toml) for every key.
195
+
196
+ Without `--config`, the agent reads the first file it finds of `/etc/trafic/config.toml`, `./config.toml` and `./trafic.toml`. When there is none, it uses the defaults.
85
197
 
86
198
  ```toml
87
199
  # Required: TLD for DDEV projects
@@ -90,12 +202,26 @@ tld = "previews.example.com"
90
202
  # Agent HTTP server port (default: 9876)
91
203
  port = 9876
92
204
 
93
- # Scale-to-zero: stop idle projects after this duration
205
+ # Must match trafic deploy --projects-dir (default: /home/ddev/www)
206
+ projects_dir = "/home/ddev/www"
207
+
208
+ # Scale-to-zero: stop idle projects after this duration (default: 30m; setup writes 4h)
94
209
  idle_timeout = "4h"
95
210
 
211
+ # Idle, running-limit and scheduled-removal sweeps (default: 5m)
212
+ idle_check_interval = "5m"
213
+
214
+ # Most projects running at the same time (default: 0, no limit)
215
+ max_running_projects = 8
216
+
217
+ # Backups made by `trafic-agent destroy --backup`
218
+ [backup]
219
+ dir = "/var/backups/trafic" # default
220
+ retain_days = 14 # default; older backups are removed
221
+
96
222
  # Authentication
97
223
  [auth]
98
- default_policy = "basic" # allow, deny, basic, or token
224
+ default_policy = "basic" # allow, deny, basic, or token; basic also accepts the tokens
99
225
 
100
226
  # Proxies in front of the agent (default: 1 = ddev-router/Traefik alone).
101
227
  # Raise by one for each extra proxy: a CDN or load balancer ahead of
@@ -109,10 +235,10 @@ allowed_ips = ["192.168.1.0/24", "10.0.0.0/8"]
109
235
  # Bearer tokens for CI/API access
110
236
  tokens = ["your-ci-token"]
111
237
 
112
- # Basic auth credentials
238
+ # Basic auth credentials, as "user:password" or { username, password } tables
113
239
  basic_auth = ["user:password"]
114
240
 
115
- # Per-hostname rules
241
+ # Per-hostname rules (first match wins, after the global allowed_ips)
116
242
  [[auth.rules]]
117
243
  match = "*.public.*"
118
244
  policy = "allow"
@@ -120,6 +246,7 @@ policy = "allow"
120
246
  [[auth.rules]]
121
247
  match = "admin.*"
122
248
  policy = "basic"
249
+ allowed_ips = ["203.0.113.0/24"] # optional: these IPs skip basic auth
123
250
 
124
251
  # Optional: one wildcard certificate instead of one per preview.
125
252
  # See the TLS section below.
@@ -142,6 +269,20 @@ auth_policy: allow # allow, deny, basic, or token
142
269
  idle_timeout: never
143
270
  ```
144
271
 
272
+ - `idle_timeout` replaces the global `idle_timeout` for this project, shorter or longer. The idle sweep reads it at each check.
273
+ - `auth_policy` replaces only `default_policy`. The global `allowed_ips` and a matching `[[auth.rules]]` still apply first.
274
+ - The agent reads `auth_policy` when it starts and when DDEV's `project_list.yaml` changes. After you change it, restart the agent: `sudo systemctl restart trafic-agent`.
275
+
276
+ ## Running project limit
277
+
278
+ `max_running_projects` limits how many projects run at the same time, so that a burst of traffic on many previews does not use all the memory of the server. Unset or `0` means no limit.
279
+
280
+ - Before the waiting page starts a stopped project, the agent counts the running projects: the projects that `ddev list` shows up, and the projects the agent is starting. The project to start is one of them. Over the limit, it stops the running projects with the oldest access first. When many previews start at the same time, each start frees its own slot.
281
+ - `trafic deploy` runs `ddev start` without the agent. The idle sweep (every `idle_check_interval`) stops the projects with the oldest access until the server is back under the limit. A project that a deploy started counts as accessed when the sweep first sees it, so it is not the first one stopped.
282
+ - Projects with `idle_timeout: never` are never stopped by this rule. When no other project can be stopped, the agent starts the project anyway and logs a warning.
283
+ - A project that `trafic-agent destroy` is removing (also from a scheduled removal) is never stopped by this rule or by the idle sweep: its backup starts the project, and a stop would fail the backup. It still counts as running.
284
+ - A start that the agent service did not finish (the service restarted during `ddev start`) does not keep its slot: when the service starts again, it sets the project back to `running`, and the idle sweep checks it against DDEV.
285
+
145
286
  ## How it works
146
287
 
147
288
  ```
@@ -158,6 +299,7 @@ Traefik (DDEV router)
158
299
  └─► 502 error → errors middleware → trafic-agent
159
300
  │
160
301
  ├─► known project → Waiting page + auto-start
302
+ ├─► project being removed → Error page (503)
161
303
  └─► unknown → Error page
162
304
  ```
163
305
 
@@ -167,6 +309,7 @@ The agent:
167
309
  3. Tracks project activity for scale-to-zero
168
310
  4. Starts stopped projects when requested
169
311
  5. Shows a waiting page while projects start
312
+ 6. Removes projects whose [scheduled removal](#scheduled-removals) is due
170
313
 
171
314
  ## Endpoints
172
315
 
@@ -248,7 +391,7 @@ router container and starts one running project. DDEV regenerates the static
248
391
  config on any project start, but reads `router-compose.*.yaml` only when it
249
392
  recreates the router, so the removal is what gets the credentials into the
250
393
  container. The router is back within seconds. With no project running, the
251
- next deploy applies it. `trafic-agent audit` reports whether the certificate
394
+ next deploy applies it. `sudo trafic-agent audit` reports whether the certificate
252
395
  was issued.
253
396
 
254
397
  The first order takes about a minute after the router starts: lego waits for
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { B as info, D as closeDb, G as success, K as warn, R as exec, V as isRoot, W as step, ct as validateConfig, ot as loadConfig, s as startServer, z as execSilent } from "./server-DZAo8Clg.js";
3
- import { i as runPendingMigrations, n as setup, r as listMigrations, s as startIdleScheduler, t as audit } from "./setup-DWgb6dAD.js";
2
+ import { $ as info, A as cancelScheduledDestroy, Ct as validateConfig, M as closeDb, Q as execSilent, St as parseTimeout, Z as exec, at as warn, bt as loadConfig, et as isRoot, it as success, l as startServer, rt as step, z as getScheduledDestroys } from "./server-B_iFvS6v.js";
3
+ import { _ as startIdleScheduler, d as destroyProject, f as loadDestroyConfig, g as startBackupSweepScheduler, i as runPendingMigrations, l as scheduleProjectDestroy, n as setup, p as openDestroyDb, r as listMigrations, s as startAccessLogSweepScheduler, t as audit, u as startScheduledDestroyScheduler } from "./setup-BbXL1ZFY.js";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { execSync, spawnSync } from "node:child_process";
6
6
  import { parseArgs } from "node:util";
@@ -113,7 +113,7 @@ function upgradeDdev(dryRun) {
113
113
  /** The real collaborators, used unless a caller passes their own. */
114
114
  function nodeUpgradeIo() {
115
115
  return {
116
- currentVersion: "0.1.48",
116
+ currentVersion: "0.1.50",
117
117
  alreadyReExeced: process.env["TRAFIC_UPGRADE_REEXEC"] === "1",
118
118
  isRoot,
119
119
  fetchLatestVersion,
@@ -192,6 +192,9 @@ function upgradeDdevStep(dryRun, io) {
192
192
  else success("DDEV up to date");
193
193
  }
194
194
  //#endregion
195
+ //#region ../../skills/trafic-agent/SKILL.md?raw
196
+ var SKILL_default = "---\nname: trafic-agent\ndescription: Operate a Trafic preview server with trafic-agent (@studiometa/trafic-agent), the service that runs next to DDEV and Traefik. Covers setup flags, the trafic-agent systemd service and its logs, upgrade and migrations, audit, destroy (--backup, --timeout, --delay, --cancel, --list), the keys of /etc/trafic/config.toml (auth, idle timeout, max_running_projects, [backup], [tls]) and the per-project .ddev/config.trafic.yaml. Use when you install, configure, upgrade, debug or clean up a server that hosts DDEV previews deployed with trafic, or when you edit its config.toml.\nlicense: MIT\n---\n\n# trafic-agent\n\n`trafic-agent` runs on the server that hosts the DDEV previews. It does these jobs:\n\n- Forward authentication for Traefik: IP allowlist, bearer tokens, basic auth, rules per hostname.\n- Scale-to-zero: it stops idle projects, and starts a stopped project on its next request behind a waiting page.\n- A limit on the number of running projects (`max_running_projects`).\n- Project removal, with an optional backup, now or at a scheduled date.\n\nThis file can be older or newer than the agent on the server. `trafic-agent skill` prints the copy of this skill that matches the installed version: run it on the server and prefer its content when it differs.\n\n## Users and files\n\n| Item | Value |\n|---|---|\n| Service | `trafic-agent.service`, runs `trafic-agent start --config /etc/trafic/config.toml` as user `ddev` |\n| Config | `/etc/trafic/config.toml`, mode `640`, owner `root:ddev`. Edit it as root |\n| Database | `/var/lib/trafic/db.sqlite` (projects, access logs, scheduled removals), owned by `ddev` |\n| Migration state | `/etc/trafic/.migrations.json` |\n| Projects | `/home/ddev/www/<project>` (`projects_dir`) |\n| Backups | `/var/backups/trafic/<project>/<timestamp>/` (`[backup] dir`), owner `ddev`, mode `700` |\n| HTTP port | `9876` (`port`), open to the Docker bridge only |\n\nWhich user runs which command:\n\n- As root (or with `sudo`): `setup`, `upgrade`, `audit`, edits of `config.toml`, `systemctl`. The `ddev` user has no general sudo.\n- As `ddev`: `destroy`. It refuses to run as root, because DDEV does not run as root and root could leave files in the database directory that the service cannot write.\n- As any user: `upgrade --list`, `version`, `help`, `skill`.\n\n## Service and logs\n\n```bash\nsudo systemctl status trafic-agent\nsudo systemctl restart trafic-agent # after each edit of config.toml\njournalctl -u trafic-agent -f # follow the logs\njournalctl -u trafic-agent -n 200 --no-pager\ncurl -s http://127.0.0.1:9876/__health__ # agent health and version\n```\n\nThe unit has a systemd sandbox (`ProtectSystem=strict`). The service can write only to `/var/lib/trafic`, `/home/ddev` and `/var/backups/trafic`. When the config is not valid, `start` prints `Configuration errors:` with one line per error, exits with code 1, and systemd restarts it every 5 seconds. Read the journal, fix the config, restart.\n\n## `trafic-agent setup`\n\nRun as root on a fresh Ubuntu 24.04 server, or remotely with `trafic setup` from the CLI. It installs Docker, DDEV and Node.js, configures DDEV and Traefik for forward auth, writes `/etc/trafic/config.toml` (only when it does not exist), creates the systemd service and hardens the server (SSH, UFW, Fail2ban, unattended-upgrades, `DOCKER-USER` firewall rules). It asks no questions. A second run is safe: each step is skipped when the server is already in the target state.\n\n```bash\nsudo trafic-agent setup --tld=previews.example.com --email=admin@example.com\n```\n\n| Flag | Use |\n|---|---|\n| `--tld` | Domain of the previews. Required on the first run. A second run reads `tld` from the config |\n| `--email` | Let's Encrypt email. Without it, projects use a mkcert certificate |\n| `--dns-provider <lego name>` | One wildcard `*.<tld>` certificate through DNS-01. Requires `--email`, also on a second run that reads it from the config |\n| `--dns-env KEY=VALUE` | Credential of the DNS provider, repeatable |\n| `--ssh-users <list>` | Users that SSH allows, comma-separated. Default `ddev` |\n| `--trusted-proxy-hops <n>` | `2` when a CDN or load balancer is in front of Traefik. Written to a new config only |\n| `--no-hardening`, `--no-root-ssh`, `--no-docker`, `--no-ddev` | Skip or change these steps. With `--no-root-ssh`, only the rescue mode of the provider can recover root access |\n| `--dry-run` | Show what setup would do |\n\nSetup does not create the wildcard DNS record (`*.<tld>` to the server IP) and does not add SSH keys. Add CI keys to `/home/ddev/.ssh/authorized_keys`.\n\nA second run applies the flags you give it, not the flags of the first run. Pass the same `--email`, `--ssh-users`, `--no-root-ssh` and `--no-*` flags again: without `--email`, it turns Let's Encrypt off and deletes the issued certificates; without `--ssh-users`, SSH allows only `ddev`, root and the user who ran `sudo`. It reads only `tld` and `[tls]` from the config.\n\nWhen the config exists, setup does not change it. With a new `--dns-provider`, it prints the `[tls]` section to add by hand.\n\n## `trafic-agent upgrade`\n\n`update` is an alias.\n\n```bash\ntrafic-agent upgrade --list # migrations and their status, no change\nsudo trafic-agent upgrade --dry-run\nsudo trafic-agent upgrade\n```\n\nSteps: install the latest `@studiometa/trafic-agent` from npm when it is newer, run the pending migrations, upgrade the `ddev` apt package (a failure is only a warning), restart `trafic-agent`.\n\n- Upgrade the agent **before** you raise the CLI version in CI. `trafic deploy` and `trafic destroy` call `trafic-agent destroy` on the server, and an older agent does not know the new options.\n- Migrations fix the state of existing servers. They run once per server, in order, and the state file records each one after it succeeds. A fresh `setup` marks all of them as applied.\n- DDEV is upgraded only here, never by unattended-upgrades, so that a new major version does not break previews without an operator.\n\n## `trafic-agent audit`\n\n```bash\nsudo trafic-agent audit\n```\n\nRun it as root. `ufw status` and the files under `/var/lib/docker` need root, so as another user the firewall and wildcard certificate checks fail. It checks SSH, the firewall, Fail2ban, automatic updates, the agent service, Docker, disk usage, the permissions of the config, the owner of the projects directory and, with a `[tls]` section, the wildcard certificate and the permissions of its credentials. It exits with code 1 when a check fails, and each failed check prints a fix.\n\n## `trafic-agent destroy`\n\nRun it as `ddev`. `trafic destroy` from the CLI runs the same command over SSH.\n\n```bash\ntrafic-agent destroy preview-42--my-app\ntrafic-agent destroy preview-42--my-app --backup\ntrafic-agent destroy preview-42--my-app --backup --timeout 1h\ntrafic-agent destroy preview-42--my-app --delay 48h --backup\ntrafic-agent destroy preview-42--my-app --cancel\ntrafic-agent destroy --list\n```\n\nSteps: backup (with `--backup`; if it fails, nothing is removed), `ddev delete -Oy` (a failure is only a warning), removal of the project directory, removal of the project's rows in the agent database.\n\n| Flag | Use |\n|---|---|\n| `-c`, `--config` | Config file. Default: first of `/etc/trafic/config.toml`, `./config.toml`, `./trafic.toml` |\n| `--backup` | Dump the database (`db.sql.gz`) and archive the upload directories (`files.tar.gz`) to `<backup dir>/<project>/<timestamp>/` first. The agent starts a stopped project for the backup and stops it again |\n| `--timeout <duration>` | Time limit of the dump and the archive together. Default `40m`. `10m`, `90s`, `1h`; a bare number is minutes |\n| `--delay <duration>` | Remove nothing now. Record a removal at now plus the delay. Hours, minutes and seconds, in that order, no spaces: `48h`, `1h30m`, `90m` |\n| `--cancel` | Cancel the scheduled removal of the project |\n| `--list` | List the scheduled removals, soonest first. `in progress` marks a removal that has started |\n\nSafety rules of the command:\n\n- It refuses a project directory outside `projects_dir`, also after it follows symbolic links, and a project directory that is a symbolic link. It refuses an empty name, `.`, `..` and names with `/`.\n- When the directory does not exist, it only deletes the database rows. So you can run it again after a failed removal.\n- It checks the config as `start` does, and stops before any change when the config is not valid.\n\nScheduled removals:\n\n- The service checks them every `idle_check_interval`, and removes each project whose date is past with the same steps.\n- A failed scheduled removal is logged in the journal and tried again at the next check. A removal that fails after its backup does not back up again.\n- `trafic deploy` cancels the scheduled removal of the project it deploys. After the backup, the agent marks the removal as started, just before `ddev delete`. From then on, `--cancel` and a deploy fail with `the removal of <project> is in progress`.\n- The service makes the backup of a scheduled removal inside its sandbox. If `[backup] dir` is outside `/var/backups/trafic`, `/home/ddev` and `/var/lib/trafic`, each scheduled backup fails with `Cannot write to the backup directory` and the project is never removed.\n\nRestore a backup:\n\n```bash\ncd <project directory>\nddev import-db --file=/var/backups/trafic/<project>/<timestamp>/db.sql.gz\ntar -xzf /var/backups/trafic/<project>/<timestamp>/files.tar.gz -C <project directory>\n```\n\n## `config.toml`\n\nEdit `/etc/trafic/config.toml` as root, then `sudo systemctl restart trafic-agent`. Values not in the file take their default.\n\n| Key | Default | Use |\n|---|---|---|\n| `tld` | none, required | Domain of the previews |\n| `port` | `9876` | Agent HTTP port |\n| `db_path` | `/var/lib/trafic/db.sqlite` | Agent database |\n| `project_list_path` | `/home/ddev/.ddev/project_list.yaml` | DDEV project list, watched for changes |\n| `projects_dir` | `/home/ddev/www` | Must be the same as `trafic deploy --projects-dir` (default `~/www`). `destroy` never removes a directory outside it |\n| `idle_timeout` | `30m` (setup writes `4h`) | Stop a project after this time without requests |\n| `idle_check_interval` | `5m` | Interval of the idle sweep, the running limit and the scheduled removals |\n| `max_running_projects` | `0` (no limit) | Most projects that run at the same time. See below |\n| `[backup] dir` | `/var/backups/trafic` | Absolute path. Must not be `projects_dir`, be inside it or contain it (so not `/home/ddev`) |\n| `[backup] retain_days` | `14` | The service removes older backups once an hour. Whole number, 1 or more |\n| `[auth] default_policy` | `basic` | `allow`, `deny`, `basic` or `token`. `basic` also accepts the tokens |\n| `[auth] trusted_proxy_hops` | `1` | Proxies in front of the agent. `2` with a CDN. Set it before `allowed_ips` |\n| `[auth] allowed_ips` | `[]` | IPs or CIDR ranges that skip authentication |\n| `[auth] tokens` | `[]` | Bearer tokens, sent as `Authorization: Bearer <token>` |\n| `[auth] basic_auth` | `[]` | `\"user:password\"` strings, or `{ username, password }` tables |\n| `[[auth.rules]]` | none | `match` (hostname glob), `policy`, optional `tokens` and `allowed_ips`. First match wins, after the global `allowed_ips` |\n| `[tls] dns_provider`, `ca_server`, `[tls.dns_env]` | none | Wildcard certificate through DNS-01. A restart does not apply a change: migration `0016__wildcard_dns_challenge` applies a first `[tls]` on a server set up before it (`sudo trafic-agent upgrade`); otherwise run `sudo trafic-agent setup` again with the flags of the first run |\n\nExample:\n\n```toml\ntld = \"previews.example.com\"\nprojects_dir = \"/home/ddev/www\"\nidle_timeout = \"4h\"\nidle_check_interval = \"5m\"\nmax_running_projects = 8\n\n[backup]\ndir = \"/var/backups/trafic\"\nretain_days = 14\n\n[auth]\ndefault_policy = \"basic\"\ntrusted_proxy_hops = 1\nallowed_ips = [\"203.0.113.0/24\"]\ntokens = [\"ci-token\"]\nbasic_auth = [\"preview:change-me\"]\n\n[[auth.rules]]\nmatch = \"*.public.*\"\npolicy = \"allow\"\n```\n\nValidation errors that stop `start` and `destroy`: `tld is required`, `port must be between 1 and 65535`, `max_running_projects must be a whole number, 0 or more`, `auth.default_policy must be ...`, `backup.dir must be an absolute path`, `backup.dir must not be projects_dir, be inside it or contain it`, `backup.retain_days must be a whole number of 1 or more`, and errors on `tls.dns_env` keys.\n\n## `max_running_projects`\n\n- Before the waiting page starts a stopped project, the agent counts the running projects and the projects it is starting. Over the limit, it stops the projects with the oldest access first.\n- `trafic deploy` starts projects without the agent. The idle sweep then stops the projects with the oldest access until the server is under the limit.\n- Projects with `idle_timeout: never` are never stopped by this rule. If no other project can stop, the agent starts the project anyway and logs a warning.\n- A project that `destroy` is removing is never stopped by this rule or by the idle sweep.\n\n## Per-project config: `.ddev/config.trafic.yaml`\n\nCommitted in the project repository. Flat `key: value` lines only.\n\n```yaml\nauth_policy: allow # allow, deny, basic or token\nidle_timeout: never # or a duration such as 2h\n```\n\n- `idle_timeout` replaces the global value for this project, shorter or longer. The idle sweep reads it at each check.\n- `auth_policy` replaces only `default_policy`. The global `allowed_ips` and a matching `[[auth.rules]]` still apply first. The agent reads it when it starts and when DDEV's `project_list.yaml` changes; after a change, restart the service.\n\n## Troubleshooting\n\n| Symptom | Check and fix |\n|---|---|\n| Service restarts in a loop | `journalctl -u trafic-agent -n 50`. Fix the `Configuration errors:` lines, then restart |\n| Preview asks for a password | `default_policy = \"basic\"`. Add the client IP to `allowed_ips`, a rule, or `auth_policy: allow` in the project |\n| `allowed_ips` does not match | `trusted_proxy_hops` is wrong. Use `2` behind a CDN |\n| Preview stays on the waiting page | `ddev list`, then `ddev start` in the project directory to see the DDEV error. Check `max_running_projects` and the journal |\n| Project stops too soon | Raise `idle_timeout`, or set `idle_timeout: never` in the project |\n| `cannot read ... (EACCES): connect as the ddev user` | Run `destroy` as `ddev`, not as another user |\n| `Refusing to remove ...: it is not inside the projects directory` | Set `projects_dir` to the directory that `trafic deploy` uses |\n| `The backup timed out ...` | Run again with a larger `--timeout`, for example `1h` |\n| `Cannot write to the backup directory` | Put `[backup] dir` in `/var/backups/trafic`, `/home/ddev` (not `projects_dir`) or `/var/lib/trafic`, owned by `ddev` |\n| Certificate warning on previews | `sudo trafic-agent audit`. Without `--email` at setup, projects use mkcert. Per-host certificates are limited to 50 new ones per domain per week; use `[tls]` for one wildcard |\n| Ports 8025, 8026, 8142, 8143 reachable | The `DOCKER-USER` rules are missing: `sudo iptables -S DOCKER-USER`, `sudo systemctl status trafic-docker-firewall` |\n\n## CI side\n\nFor the CI jobs that call this server (`trafic deploy`, `trafic destroy`), use the `trafic-cli` skill, or run `trafic skill` with the CLI version that CI uses.\n";
197
+ //#endregion
195
198
  //#region src/cli.ts
196
199
  var HELP = `
197
200
  trafic-agent — Server agent for DDEV preview environments
@@ -202,14 +205,18 @@ Usage:
202
205
  Commands:
203
206
  start Start the agent server
204
207
  setup Setup a new server (Docker, DDEV, hardening)
205
- upgrade Check for a new version, install it, run migrations, restart the service
208
+ upgrade Install the latest agent, run migrations, upgrade DDEV, restart the service
206
209
  update Alias for upgrade
207
210
  audit Run security audit checks
208
- version Show version
209
- help Show this help
211
+ destroy <name> Remove a project: DDEV project, directory, database records,
212
+ now or after a delay
213
+ skill Print the agent skill (SKILL.md) of this version
214
+ version Show version (also -v, --version)
215
+ help Show this help (also -h, --help)
210
216
 
211
217
  Start options:
212
- -c, --config <path> Path to config file (default: /etc/trafic/config.toml)
218
+ -c, --config <path> Path to config file (default: first of /etc/trafic/config.toml,
219
+ ./config.toml, ./trafic.toml)
213
220
  -p, --port <port> Override port from config
214
221
 
215
222
  Setup options:
@@ -227,6 +234,20 @@ Setup options:
227
234
  Written to the generated config; ignored if one exists
228
235
  --dry-run Show what would be done without making changes
229
236
 
237
+ Destroy options:
238
+ -c, --config <path> Path to config file (default: first of /etc/trafic/config.toml,
239
+ ./config.toml, ./trafic.toml)
240
+ --backup Back up the database and upload directories first, to
241
+ [backup] dir. If the backup fails, nothing is removed
242
+ --timeout <duration> Time limit of the backup, the database dump and the files
243
+ archive together: 10m, 90s, 1h; a bare number is minutes
244
+ (default: 40m)
245
+ --delay <duration> Remove nothing now: schedule the removal after this delay
246
+ (hours, minutes, seconds only: 48h, 1h30m). The agent
247
+ removes the project when it is due
248
+ --cancel Cancel the scheduled removal of the project
249
+ --list List the scheduled removals
250
+
230
251
  Upgrade/update options:
231
252
  --dry-run Show what would be done without making changes
232
253
  --list List all migrations and their status (no install or restart)
@@ -247,30 +268,42 @@ Examples:
247
268
 
248
269
  sudo trafic-agent setup --tld=previews.example.com --no-hardening --dry-run
249
270
 
250
- # Upgrade: install latest version, run migrations, restart service
271
+ # Upgrade: install the latest agent, run migrations, upgrade DDEV, restart the service
251
272
  sudo trafic-agent upgrade
252
273
  sudo trafic-agent upgrade --dry-run
253
274
  trafic-agent upgrade --list
254
275
  sudo trafic-agent update # alias for upgrade
255
276
 
256
277
  # Run security audit
257
- trafic-agent audit
278
+ sudo trafic-agent audit
279
+
280
+ # Remove a project (as the ddev user, never as root)
281
+ trafic-agent destroy preview-42--my-app
282
+ trafic-agent destroy preview-42--my-app --backup
283
+ trafic-agent destroy preview-42--my-app --backup --timeout 1h
284
+ trafic-agent destroy preview-42--my-app --delay 48h --backup
285
+ trafic-agent destroy preview-42--my-app --cancel
286
+ trafic-agent destroy --list
258
287
  `;
259
288
  function printHelp() {
260
289
  console.log(HELP.trim());
261
290
  }
262
291
  function printVersion() {
263
- console.log("0.1.48");
292
+ console.log("0.1.50");
264
293
  }
265
- async function runStart(values) {
266
- const config = loadConfig(values.config);
267
- if (values.port) config.port = parseInt(values.port, 10);
294
+ /** Print the configuration errors and exit with code 1, if there are any. */
295
+ function exitOnConfigErrors(config) {
268
296
  const errors = validateConfig(config);
269
297
  if (errors.length > 0) {
270
298
  console.error("Configuration errors:");
271
299
  for (const error of errors) console.error(` - ${error}`);
272
300
  process.exit(1);
273
301
  }
302
+ }
303
+ async function runStart(values) {
304
+ const config = loadConfig(values.config);
305
+ if (values.port) config.port = parseInt(values.port, 10);
306
+ exitOnConfigErrors(config);
274
307
  const shutdown = () => {
275
308
  console.log("\nShutting down...");
276
309
  closeDb();
@@ -280,6 +313,75 @@ async function runStart(values) {
280
313
  process.on("SIGTERM", shutdown);
281
314
  startServer(config);
282
315
  startIdleScheduler(config);
316
+ startBackupSweepScheduler(config);
317
+ startAccessLogSweepScheduler();
318
+ startScheduledDestroyScheduler(config);
319
+ }
320
+ /**
321
+ * Remove a project. Runs as the ddev user, next to the running service: both
322
+ * open the same database, so the config is read the same way `start` reads it.
323
+ */
324
+ async function runDestroy(values, name) {
325
+ if (isRoot()) {
326
+ console.error("Error: run trafic-agent destroy as the ddev user, not root");
327
+ process.exit(1);
328
+ }
329
+ if (!name && !values.list) {
330
+ console.error("Error: a project name is required");
331
+ console.error("Example: trafic-agent destroy preview-42--my-app");
332
+ process.exit(1);
333
+ }
334
+ const timeout = values.timeout;
335
+ const timeoutMs = timeout === void 0 ? void 0 : parseTimeout(timeout);
336
+ if (timeout !== void 0 && timeoutMs === void 0) {
337
+ console.error(`Error: invalid --timeout "${timeout}". Expected a duration such as 10m, 90s or 1h`);
338
+ process.exit(1);
339
+ }
340
+ const backup = values.backup;
341
+ const delay = values.delay;
342
+ try {
343
+ const config = loadDestroyConfig(values.config);
344
+ exitOnConfigErrors(config);
345
+ openDestroyDb(config.dbPath);
346
+ if (values.list) printScheduledDestroys();
347
+ else if (values.cancel) cancelDestroy(name);
348
+ else if (delay !== void 0) {
349
+ const destroyAt = scheduleProjectDestroy(name, delay, config, {
350
+ backup,
351
+ timeoutMs
352
+ });
353
+ console.log(`Scheduled the removal of ${name} at ${destroyAt.toISOString()}${backup ? ", after a backup" : ""}`);
354
+ } else await destroyProject(name, config, {
355
+ backup,
356
+ timeoutMs
357
+ });
358
+ } catch (error) {
359
+ console.error(`Error: ${error.message}`);
360
+ process.exitCode = 1;
361
+ } finally {
362
+ closeDb();
363
+ }
364
+ }
365
+ /**
366
+ * Cancel the scheduled removal of a project. Fails when the sweep already
367
+ * removes the project, so a deploy that cancels first stops there.
368
+ */
369
+ function cancelDestroy(name) {
370
+ const result = cancelScheduledDestroy(name);
371
+ if (result === "removing") throw new Error(`the removal of ${name} is in progress, it is too late to cancel it`);
372
+ console.log(result === "cancelled" ? `Cancelled the scheduled removal of ${name}` : `No removal scheduled for ${name}`);
373
+ }
374
+ /** Print the scheduled removals, soonest first. */
375
+ function printScheduledDestroys() {
376
+ const scheduled = getScheduledDestroys();
377
+ if (scheduled.length === 0) {
378
+ console.log("No removal scheduled");
379
+ return;
380
+ }
381
+ for (const { name, destroyAt, backup, removing } of scheduled) {
382
+ const notes = [backup && "backup", removing && "in progress"].filter(Boolean);
383
+ console.log(`${new Date(destroyAt).toISOString()} ${name}${notes.length ? ` (${notes.join(", ")})` : ""}`);
384
+ }
283
385
  }
284
386
  function handleUpgrade(values) {
285
387
  const dryRun = values["dry-run"];
@@ -374,6 +476,10 @@ async function main() {
374
476
  type: "boolean",
375
477
  short: "h"
376
478
  },
479
+ version: {
480
+ type: "boolean",
481
+ short: "v"
482
+ },
377
483
  config: {
378
484
  type: "string",
379
485
  short: "c"
@@ -396,7 +502,11 @@ async function main() {
396
502
  },
397
503
  "trusted-proxy-hops": { type: "string" },
398
504
  "dry-run": { type: "boolean" },
399
- list: { type: "boolean" }
505
+ list: { type: "boolean" },
506
+ backup: { type: "boolean" },
507
+ timeout: { type: "string" },
508
+ delay: { type: "string" },
509
+ cancel: { type: "boolean" }
400
510
  }
401
511
  });
402
512
  const command = positionals[0] ?? "help";
@@ -404,6 +514,10 @@ async function main() {
404
514
  printHelp();
405
515
  process.exit(0);
406
516
  }
517
+ if (values.version) {
518
+ printVersion();
519
+ return;
520
+ }
407
521
  switch (command) {
408
522
  case "help":
409
523
  printHelp();
@@ -411,6 +525,9 @@ async function main() {
411
525
  case "version":
412
526
  printVersion();
413
527
  break;
528
+ case "skill":
529
+ process.stdout.write(SKILL_default);
530
+ break;
414
531
  case "start":
415
532
  await runStart(values);
416
533
  break;
@@ -424,6 +541,9 @@ async function main() {
424
541
  case "audit":
425
542
  await audit();
426
543
  break;
544
+ case "destroy":
545
+ await runDestroy(values, positionals[1]);
546
+ break;
427
547
  default:
428
548
  console.error(`Unknown command: ${command}`);
429
549
  printHelp();