@studiometa/trafic-agent 0.1.45 → 0.1.47

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 (37) hide show
  1. package/README.md +129 -20
  2. package/dist/cli.js +49 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/{server-BAjHxgqh.js → server-EPU5dtPx.js} +355 -21
  6. package/dist/server-EPU5dtPx.js.map +1 -0
  7. package/dist/server.d.ts.map +1 -1
  8. package/dist/server.js +1 -1
  9. package/dist/setup/agent.d.ts +2 -0
  10. package/dist/setup/agent.d.ts.map +1 -1
  11. package/dist/setup/audit.d.ts +39 -1
  12. package/dist/setup/audit.d.ts.map +1 -1
  13. package/dist/setup/ddev.d.ts +102 -3
  14. package/dist/setup/ddev.d.ts.map +1 -1
  15. package/dist/setup/docker.d.ts +23 -0
  16. package/dist/setup/docker.d.ts.map +1 -1
  17. package/dist/setup/index.d.ts.map +1 -1
  18. package/dist/setup/migrations/0015__catchall_priority.d.ts +40 -0
  19. package/dist/setup/migrations/0015__catchall_priority.d.ts.map +1 -0
  20. package/dist/setup/migrations/0016__wildcard_dns_challenge.d.ts +63 -0
  21. package/dist/setup/migrations/0016__wildcard_dns_challenge.d.ts.map +1 -0
  22. package/dist/setup/migrations/0018__docker_address_pools.d.ts +29 -0
  23. package/dist/setup/migrations/0018__docker_address_pools.d.ts.map +1 -0
  24. package/dist/setup/migrations/index.d.ts.map +1 -1
  25. package/dist/setup/types.d.ts +11 -0
  26. package/dist/setup/types.d.ts.map +1 -1
  27. package/dist/{setup-BrMbM8w4.js → setup-4zlNJqDe.js} +373 -173
  28. package/dist/setup-4zlNJqDe.js.map +1 -0
  29. package/dist/types.d.ts +21 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/utils/config.d.ts.map +1 -1
  32. package/dist/utils/tls.d.ts +10 -0
  33. package/dist/utils/tls.d.ts.map +1 -0
  34. package/package.json +1 -1
  35. package/templates/wait.html +1 -0
  36. package/dist/server-BAjHxgqh.js.map +0 -1
  37. package/dist/setup-BrMbM8w4.js.map +0 -1
@@ -1,120 +1,5 @@
1
- import { A as error, C as getIdleProjects, D as setProjectStatus, F as resetSteps, H as getProjectInfo, I as setDryRun, K as stopProject, L as step, M as execSilent$1, N as info, P as isRoot, R as success, W as loadProjectList, _ as loadProjectConfig, c as addDdevAptRepo, d as getDockerGatewayIp, f as installDdev, g as getIdleTimeoutMs, h as readToolPorts, j as exec, k as nodeIo, l as configureDdev, m as purgeTraefikAcmeStorage, p as installSystemDeps, u as configureTraefik, v as shouldNeverStop, z as warn } from "./server-BAjHxgqh.js";
1
+ import { B as resetSteps, C as shouldNeverStop, F as error, H as step, I as exec, L as execSilent, M as setProjectStatus, O as getIdleProjects, P as nodeIo, R as info, S as loadProjectConfig, U as success, V as setDryRun, W as warn, Y as loadProjectList, Z as stopProject, _ as installDdev, at as parseDuration, b as readToolPorts, d as TLS_STORE_CONFIG, f as addDdevAptRepo, g as getDockerGatewayIp, h as findRunningProject, it as loadConfig, l as ROUTER_COMPOSE_OVERRIDE, m as configureTraefik, p as configureDdev, q as getProjectInfo, u as STATIC_CONFIG, v as installSystemDeps, x as getIdleTimeoutMs, y as purgeTraefikAcmeStorage, z as isRoot } from "./server-EPU5dtPx.js";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { parse } from "smol-toml";
4
- import { execSync } from "node:child_process";
5
- //#region src/utils/config.ts
6
- var DEFAULT_CONFIG_PATHS = [
7
- "/etc/trafic/config.toml",
8
- "./config.toml",
9
- "./trafic.toml"
10
- ];
11
- var DEFAULT_CONFIG = {
12
- tld: "",
13
- port: 9876,
14
- dbPath: "/var/lib/trafic/db.sqlite",
15
- projectListPath: "/home/ddev/.ddev/project_list.yaml",
16
- projectsDir: "/home/ddev/www",
17
- idleTimeout: "30m",
18
- idleCheckInterval: "5m",
19
- auth: {
20
- defaultPolicy: "basic",
21
- allowedIps: [],
22
- tokens: [],
23
- basicAuth: [],
24
- rules: [],
25
- trustedProxyHops: 1
26
- }
27
- };
28
- /**
29
- * Parse a duration string (e.g., "30m", "1h", "2h30m") to milliseconds
30
- */
31
- function parseDuration(duration) {
32
- const match = duration.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/);
33
- if (!match) return 0;
34
- const hours = parseInt(match[1] ?? "0", 10);
35
- const minutes = parseInt(match[2] ?? "0", 10);
36
- const seconds = parseInt(match[3] ?? "0", 10);
37
- return (hours * 3600 + minutes * 60 + seconds) * 1e3;
38
- }
39
- /**
40
- * Load configuration from TOML file
41
- */
42
- function loadConfig(configPath) {
43
- let path = configPath;
44
- if (!path) path = DEFAULT_CONFIG_PATHS.find((p) => existsSync(p));
45
- if (!path || !existsSync(path)) {
46
- console.warn("No config file found, using defaults");
47
- return DEFAULT_CONFIG;
48
- }
49
- const content = readFileSync(path, "utf-8");
50
- const parsed = parse(content);
51
- const auth = mergeAuthConfig(parsed.auth);
52
- return {
53
- tld: parsed.tld ?? DEFAULT_CONFIG.tld,
54
- port: parsed.port ?? DEFAULT_CONFIG.port,
55
- dbPath: parsed.db_path ?? DEFAULT_CONFIG.dbPath,
56
- projectListPath: parsed.project_list_path ?? DEFAULT_CONFIG.projectListPath,
57
- projectsDir: parsed.projects_dir ?? DEFAULT_CONFIG.projectsDir,
58
- idleTimeout: parsed.idle_timeout ?? DEFAULT_CONFIG.idleTimeout,
59
- idleCheckInterval: parsed.idle_check_interval ?? DEFAULT_CONFIG.idleCheckInterval,
60
- auth
61
- };
62
- }
63
- /**
64
- * Parse basic_auth entries from TOML.
65
- * Accepts either plain strings ("user:pass") or objects ({username, password}).
66
- */
67
- function parseBasicAuthEntries(raw) {
68
- if (!Array.isArray(raw)) return null;
69
- return raw.map((entry) => {
70
- if (typeof entry === "string") return entry;
71
- if (typeof entry === "object" && entry !== null) {
72
- const { username, password } = entry;
73
- return `${username}:${password}`;
74
- }
75
- return String(entry);
76
- });
77
- }
78
- function mergeAuthConfig(raw) {
79
- if (!raw) return DEFAULT_CONFIG.auth;
80
- const rules = raw.rules ?? [];
81
- return {
82
- defaultPolicy: raw.default_policy ?? DEFAULT_CONFIG.auth.defaultPolicy,
83
- allowedIps: raw.allowed_ips ?? DEFAULT_CONFIG.auth.allowedIps,
84
- tokens: raw.tokens ?? DEFAULT_CONFIG.auth.tokens,
85
- basicAuth: parseBasicAuthEntries(raw.basic_auth) ?? DEFAULT_CONFIG.auth.basicAuth,
86
- rules: rules.map((r) => ({
87
- match: r.match,
88
- policy: r.policy,
89
- tokens: r.tokens,
90
- allowedIps: r.allowed_ips
91
- })),
92
- trustedProxyHops: parseTrustedProxyHops(raw.trusted_proxy_hops)
93
- };
94
- }
95
- /**
96
- * Parse trusted_proxy_hops, ignoring values that cannot be a hop count.
97
- *
98
- * A bad value falls back to the default rather than 0, because 0 would read
99
- * the socket peer — always the proxy — and quietly stop the IP allowlist and
100
- * per-hostname IP rules from ever matching.
101
- */
102
- function parseTrustedProxyHops(raw) {
103
- const hops = typeof raw === "number" ? raw : Number(raw);
104
- if (!Number.isInteger(hops) || hops < 1) return DEFAULT_CONFIG.auth.trustedProxyHops;
105
- return hops;
106
- }
107
- /**
108
- * Validate configuration
109
- */
110
- function validateConfig(config) {
111
- const errors = [];
112
- if (!config.tld) errors.push("tld is required");
113
- if (config.port < 1 || config.port > 65535) errors.push("port must be between 1 and 65535");
114
- if (config.auth.defaultPolicy !== "allow" && config.auth.defaultPolicy !== "deny" && config.auth.defaultPolicy !== "basic" && config.auth.defaultPolicy !== "token") errors.push("auth.default_policy must be \"allow\", \"deny\", \"basic\", or \"token\"");
115
- return errors;
116
- }
117
- //#endregion
118
3
  //#region src/setup/docker-firewall.ts
119
4
  /** Where the rule script lives, so systemd and a human can both run it. */
120
5
  var DOCKER_FIREWALL_SCRIPT = "/usr/local/sbin/trafic-docker-firewall";
@@ -341,6 +226,44 @@ function installDocker(io = nodeIo) {
341
226
  io.exec("usermod -aG docker ddev", { silent: true });
342
227
  success("Added ddev user to docker group");
343
228
  }
229
+ /** Docker daemon config file */
230
+ var DAEMON_CONFIG = "/etc/docker/daemon.json";
231
+ /**
232
+ * Address pools for the networks Docker creates.
233
+ *
234
+ * Every DDEV project gets its own network. Docker's built-in pools hold only
235
+ * about 31 of them (15 × /16 in 172.17–31.x, 16 × /20 in 192.168.x), so a
236
+ * server with ~30 projects fails with "all predefined address pools have
237
+ * been fully subnetted". /24 subnets in 172.16.0.0/12 give 4096 networks,
238
+ * and keep every network inside the range the firewall rules already allow
239
+ * (hardening.ts, docker-firewall.ts) — the built-in 192.168.x pools are not.
240
+ */
241
+ var DOCKER_ADDRESS_POOLS = [{
242
+ base: "172.16.0.0/12",
243
+ size: 24
244
+ }];
245
+ /**
246
+ * Add the address pools to an existing daemon config.
247
+ *
248
+ * Returns true when the config was changed. Leaves a config that already
249
+ * sets pools alone (an operator choice), and one it cannot parse.
250
+ */
251
+ function addDockerAddressPools(io = nodeIo) {
252
+ let config = {};
253
+ if (io.fileExists("/etc/docker/daemon.json")) try {
254
+ config = JSON.parse(io.readFile(DAEMON_CONFIG));
255
+ } catch {
256
+ warn("Could not parse existing Docker config, skipping");
257
+ return false;
258
+ }
259
+ if ("default-address-pools" in config) return false;
260
+ io.exec("mkdir -p /etc/docker");
261
+ io.writeFile(DAEMON_CONFIG, JSON.stringify({
262
+ ...config,
263
+ "default-address-pools": DOCKER_ADDRESS_POOLS
264
+ }, null, 2));
265
+ return true;
266
+ }
344
267
  /**
345
268
  * Configure Docker for production use
346
269
  */
@@ -353,10 +276,11 @@ function configureDocker(io = nodeIo) {
353
276
  "max-file": "3"
354
277
  },
355
278
  "storage-driver": "overlay2",
356
- "live-restore": true
279
+ "live-restore": true,
280
+ "default-address-pools": DOCKER_ADDRESS_POOLS
357
281
  };
358
282
  io.exec("mkdir -p /etc/docker");
359
- const configPath = "/etc/docker/daemon.json";
283
+ const configPath = DAEMON_CONFIG;
360
284
  if (io.fileExists(configPath)) try {
361
285
  const merged = {
362
286
  ...JSON.parse(io.readFile(configPath)),
@@ -369,7 +293,7 @@ function configureDocker(io = nodeIo) {
369
293
  return;
370
294
  }
371
295
  else io.writeFile(configPath, JSON.stringify(daemonConfig, null, 2));
372
- io.exec("systemctl reload docker || systemctl restart docker");
296
+ io.exec("systemctl restart docker");
373
297
  success("Docker configured with production settings");
374
298
  }
375
299
  /**
@@ -437,15 +361,21 @@ function installAgent(io = nodeIo) {
437
361
  * Create the agent configuration
438
362
  */
439
363
  function createAgentConfig(options, io = nodeIo) {
440
- const { tld, trustedProxyHops = 1 } = options;
364
+ const { tld, trustedProxyHops = 1, dnsProvider, dnsEnv = {} } = options;
441
365
  step("Create agent configuration");
442
366
  io.exec("mkdir -p /etc/trafic");
443
367
  io.exec("mkdir -p /var/lib/trafic");
444
368
  io.exec("chown ddev:ddev /var/lib/trafic");
445
369
  const configPath = "/etc/trafic/config.toml";
370
+ const tlsSection = buildTlsSection(dnsProvider, dnsEnv);
446
371
  if (io.fileExists(configPath)) {
447
372
  info("Configuration already exists at /etc/trafic/config.toml — skipping");
448
373
  info("Edit this file to configure authentication");
374
+ if (tlsSection) {
375
+ info("Add this to /etc/trafic/config.toml, then run `trafic-agent upgrade`:");
376
+ for (const line of buildTlsSection(dnsProvider, dnsEnv, true).trim().split("\n")) info(` ${line}`);
377
+ info("Replace each REPLACE_ME with the value you passed to --dns-env.");
378
+ }
449
379
  return;
450
380
  }
451
381
  const config = `# Trafic agent configuration
@@ -480,7 +410,7 @@ rules = []
480
410
  # Too low and allowed_ips matches a proxy instead of the client; too high and
481
411
  # it can be spoofed by a client-supplied header.
482
412
  trusted_proxy_hops = ${trustedProxyHops}
483
- `;
413
+ ${tlsSection}`;
484
414
  io.writeFile(configPath, config);
485
415
  io.exec("chmod 640 /etc/trafic/config.toml");
486
416
  io.exec("chown root:ddev /etc/trafic/config.toml");
@@ -488,6 +418,31 @@ trusted_proxy_hops = ${trustedProxyHops}
488
418
  info("Edit this file to configure authentication");
489
419
  }
490
420
  /**
421
+ * Build the [tls] section of the generated config.
422
+ *
423
+ * Empty without a DNS provider: per-host certificates stay the default, and
424
+ * an empty section would only invite a half-filled one.
425
+ */
426
+ function buildTlsSection(dnsProvider, dnsEnv, redact = false) {
427
+ if (!dnsProvider) return "";
428
+ const env = Object.entries(dnsEnv).map(([key, value]) => `${key} = "${redact ? "REPLACE_ME" : escapeTomlString(value)}"`).join("\n");
429
+ return `
430
+ # Wildcard certificate (DNS-01). One *.<tld> certificate replaces the
431
+ # per-hostname ones, so previews stop consuming the Let's Encrypt quota.
432
+ [tls]
433
+ dns_provider = "${escapeTomlString(dnsProvider)}"
434
+ # ca_server = "https://acme-staging-v02.api.letsencrypt.org/directory"
435
+
436
+ # Credentials the lego provider reads. Passed to the ddev-router container.
437
+ [tls.dns_env]
438
+ ${env}
439
+ `;
440
+ }
441
+ /** Escape a value for a basic (double-quoted) TOML string. */
442
+ function escapeTomlString(value) {
443
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
444
+ }
445
+ /**
491
446
  * Create systemd service for the agent
492
447
  */
493
448
  function createSystemdService(io = nodeIo) {
@@ -811,32 +766,21 @@ function hardenServer(options = {}, io = nodeIo) {
811
766
  //#endregion
812
767
  //#region src/setup/audit.ts
813
768
  /**
814
- * Run a command silently and return output
815
- */
816
- function execSilent(command) {
817
- try {
818
- return execSync(command, {
819
- encoding: "utf-8",
820
- stdio: "pipe"
821
- }).trim();
822
- } catch {
823
- return "";
824
- }
825
- }
826
- /**
827
769
  * Check if a service is active
828
770
  */
829
- function isServiceActive(service) {
830
- return execSilent(`systemctl is-active ${service}`) === "active";
771
+ function isServiceActive(service, io) {
772
+ return io.execSilent(`systemctl is-active ${service}`) === "active";
773
+ }
774
+ /** Read a file, or "" when it does not exist. */
775
+ function readOrEmpty(path, io) {
776
+ return io.fileExists(path) ? io.readFile(path) : "";
831
777
  }
832
778
  /**
833
779
  * Audit SSH configuration
834
780
  */
835
- function auditSsh() {
781
+ function auditSsh(io = nodeIo) {
836
782
  const checks = [];
837
- const sshdConfig = existsSync("/etc/ssh/sshd_config") ? readFileSync("/etc/ssh/sshd_config", "utf-8") : "";
838
- const traficConfig = existsSync("/etc/ssh/sshd_config.d/trafic.conf") ? readFileSync("/etc/ssh/sshd_config.d/trafic.conf", "utf-8") : "";
839
- const config = sshdConfig + "\n" + traficConfig;
783
+ const config = readOrEmpty("/etc/ssh/sshd_config", io) + "\n" + readOrEmpty("/etc/ssh/sshd_config.d/trafic.conf", io);
840
784
  const rootLoginDisabled = config.includes("PermitRootLogin no") || config.includes("PermitRootLogin prohibit-password");
841
785
  checks.push({
842
786
  name: "SSH root login",
@@ -856,9 +800,9 @@ function auditSsh() {
856
800
  /**
857
801
  * Audit firewall configuration
858
802
  */
859
- function auditFirewall() {
803
+ function auditFirewall(io = nodeIo) {
860
804
  const checks = [];
861
- const ufwStatus = execSilent("ufw status");
805
+ const ufwStatus = io.execSilent("ufw status");
862
806
  const isActive = ufwStatus.includes("Status: active");
863
807
  checks.push({
864
808
  name: "Firewall (UFW)",
@@ -887,23 +831,23 @@ function auditFirewall() {
887
831
  /**
888
832
  * Audit security services
889
833
  */
890
- function auditServices() {
834
+ function auditServices(io = nodeIo) {
891
835
  const checks = [];
892
- const fail2banActive = isServiceActive("fail2ban");
836
+ const fail2banActive = isServiceActive("fail2ban", io);
893
837
  checks.push({
894
838
  name: "Fail2ban",
895
839
  status: fail2banActive ? "pass" : "warn",
896
840
  message: fail2banActive ? "Fail2ban is active" : "Fail2ban is not running",
897
841
  fix: "Run: apt install fail2ban && systemctl enable fail2ban --now"
898
842
  });
899
- const unattendedActive = isServiceActive("unattended-upgrades");
843
+ const unattendedActive = isServiceActive("unattended-upgrades", io);
900
844
  checks.push({
901
845
  name: "Automatic updates",
902
846
  status: unattendedActive ? "pass" : "warn",
903
847
  message: unattendedActive ? "Unattended upgrades enabled" : "Automatic updates not configured",
904
848
  fix: "Run: apt install unattended-upgrades && dpkg-reconfigure unattended-upgrades"
905
849
  });
906
- const agentActive = isServiceActive("trafic-agent");
850
+ const agentActive = isServiceActive("trafic-agent", io);
907
851
  checks.push({
908
852
  name: "Trafic agent",
909
853
  status: agentActive ? "pass" : "fail",
@@ -915,16 +859,16 @@ function auditServices() {
915
859
  /**
916
860
  * Audit Docker
917
861
  */
918
- function auditDocker() {
862
+ function auditDocker(io = nodeIo) {
919
863
  const checks = [];
920
- const dockerActive = isServiceActive("docker");
864
+ const dockerActive = isServiceActive("docker", io);
921
865
  checks.push({
922
866
  name: "Docker",
923
867
  status: dockerActive ? "pass" : "fail",
924
868
  message: dockerActive ? "Docker is running" : "Docker is not running",
925
869
  fix: "Run: systemctl start docker"
926
870
  });
927
- const danglingImages = execSilent("docker images -f dangling=true -q | wc -l");
871
+ const danglingImages = io.execSilent("docker images -f dangling=true -q | wc -l");
928
872
  const count = parseInt(danglingImages, 10) || 0;
929
873
  if (count > 0) checks.push({
930
874
  name: "Docker cleanup",
@@ -932,7 +876,7 @@ function auditDocker() {
932
876
  message: `${count} dangling Docker images`,
933
877
  fix: "Run: docker system prune -f"
934
878
  });
935
- const diskUsage = execSilent("df -h / | awk 'NR==2{print $5}'");
879
+ const diskUsage = io.execSilent("df -h / | awk 'NR==2{print $5}'");
936
880
  const usagePercent = parseInt(diskUsage, 10) || 0;
937
881
  checks.push({
938
882
  name: "Disk usage",
@@ -945,10 +889,10 @@ function auditDocker() {
945
889
  /**
946
890
  * Audit file permissions
947
891
  */
948
- function auditPermissions() {
892
+ function auditPermissions(io = nodeIo) {
949
893
  const checks = [];
950
- if (existsSync("/etc/trafic/config.toml")) {
951
- const stats = execSilent("stat -c '%a' /etc/trafic/config.toml");
894
+ if (io.fileExists("/etc/trafic/config.toml")) {
895
+ const stats = io.execSilent("stat -c '%a' /etc/trafic/config.toml");
952
896
  const isSecure = stats === "640" || stats === "600";
953
897
  checks.push({
954
898
  name: "Config permissions",
@@ -957,8 +901,8 @@ function auditPermissions() {
957
901
  fix: "Run: chmod 640 /etc/trafic/config.toml"
958
902
  });
959
903
  }
960
- if (existsSync("/home/ddev/www")) {
961
- const owner = execSilent("stat -c '%U:%G' /home/ddev/www");
904
+ if (io.fileExists("/home/ddev/www")) {
905
+ const owner = io.execSilent("stat -c '%U:%G' /home/ddev/www");
962
906
  const isCorrect = owner === "ddev:ddev";
963
907
  checks.push({
964
908
  name: "Projects directory",
@@ -970,15 +914,86 @@ function auditPermissions() {
970
914
  return checks;
971
915
  }
972
916
  /**
917
+ * Audit the wildcard certificate, where one is configured.
918
+ *
919
+ * Two things go wrong silently: the certificate is never issued (a wrong
920
+ * token, a zone the token cannot edit), and the token file ends up readable
921
+ * by everyone.
922
+ */
923
+ function auditWildcardTls(config, io = nodeIo) {
924
+ if (!config.tls.dnsProvider) return [];
925
+ const checks = [];
926
+ checks.push(wildcardCertificateCheck(config.tld, io));
927
+ if (io.fileExists("/home/ddev/.ddev/router-compose.trafic.yaml")) {
928
+ const mode = io.execSilent(`stat -c '%a' ${ROUTER_COMPOSE_OVERRIDE}`);
929
+ const isSecure = mode === "600";
930
+ checks.push({
931
+ name: "DNS credentials permissions",
932
+ status: isSecure ? "pass" : "warn",
933
+ message: isSecure ? "Router compose override is readable by its owner only" : `Router compose override permissions: ${mode} (should be 600)`,
934
+ fix: `Run: chmod 600 ${ROUTER_COMPOSE_OVERRIDE}`
935
+ });
936
+ }
937
+ return checks;
938
+ }
939
+ /** Look for the wildcard certificate in Traefik's DNS-01 ACME storage. */
940
+ function wildcardCertificateCheck(tld, io) {
941
+ const fix = "Check the router log for ACME errors: docker logs ddev-router 2>&1 | grep -i acme";
942
+ const mountpoint = io.execSilent("docker volume inspect ddev-global-cache --format '{{.Mountpoint}}'");
943
+ if (!mountpoint) return {
944
+ name: "Wildcard certificate",
945
+ status: "warn",
946
+ message: "Traefik's storage volume (ddev-global-cache) was not found",
947
+ fix
948
+ };
949
+ const storage = `${mountpoint}/traefik/acme-dns.json`;
950
+ if (!io.fileExists(storage)) return {
951
+ name: "Wildcard certificate",
952
+ status: "warn",
953
+ message: "No DNS-01 certificate has been issued yet",
954
+ fix
955
+ };
956
+ const issued = hasCertificateFor(io.readFile(storage), tld);
957
+ return {
958
+ name: "Wildcard certificate",
959
+ status: issued ? "pass" : "warn",
960
+ message: issued ? `Wildcard certificate issued for *.${tld}` : `No certificate for ${tld} in Traefik's DNS-01 storage`,
961
+ fix: issued ? void 0 : fix
962
+ };
963
+ }
964
+ /**
965
+ * Whether Traefik's ACME storage holds a certificate whose main domain is the
966
+ * TLD — which is the wildcard one, since that is what the default store asks
967
+ * for.
968
+ *
969
+ * The file is keyed by resolver name, each with a Certificates array.
970
+ */
971
+ function hasCertificateFor(storage, tld) {
972
+ let parsed;
973
+ try {
974
+ parsed = JSON.parse(storage);
975
+ } catch {
976
+ return false;
977
+ }
978
+ if (parsed === null || typeof parsed !== "object") return false;
979
+ for (const resolver of Object.values(parsed)) {
980
+ const certificates = resolver.Certificates;
981
+ if (!Array.isArray(certificates)) continue;
982
+ for (const certificate of certificates) if (certificate.domain?.main === tld) return true;
983
+ }
984
+ return false;
985
+ }
986
+ /**
973
987
  * Run all audit checks
974
988
  */
975
- function runAudit() {
989
+ function runAudit(io = nodeIo, config = loadConfig()) {
976
990
  return [
977
- ...auditSsh(),
978
- ...auditFirewall(),
979
- ...auditServices(),
980
- ...auditDocker(),
981
- ...auditPermissions()
991
+ ...auditSsh(io),
992
+ ...auditFirewall(io),
993
+ ...auditServices(io),
994
+ ...auditDocker(io),
995
+ ...auditPermissions(io),
996
+ ...auditWildcardTls(config, io)
982
997
  ];
983
998
  }
984
999
  /**
@@ -1157,7 +1172,7 @@ var migration0007UfwDockerTraficPort = {
1157
1172
  id: "0007__ufw_docker_trafic_port",
1158
1173
  description: "Allow trafic-agent port 9876 from Docker bridge subnets in UFW",
1159
1174
  run() {
1160
- if (execSilent$1("ufw status | grep '9876'").includes("9876")) return;
1175
+ if (execSilent("ufw status | grep '9876'").includes("9876")) return;
1161
1176
  exec("ufw allow from 172.16.0.0/12 to any port 9876 comment 'trafic-agent from Docker'", { silent: true });
1162
1177
  }
1163
1178
  };
@@ -1234,7 +1249,7 @@ http:
1234
1249
  //#endregion
1235
1250
  //#region src/setup/migrations/0009__scale_to_zero_fixes.ts
1236
1251
  var UNIT_PATH = "/etc/systemd/system/trafic-agent.service";
1237
- var DYNAMIC_CONFIG$1 = "/home/ddev/.ddev/traefik/trafic.yaml";
1252
+ var DYNAMIC_CONFIG$2 = "/home/ddev/.ddev/traefik/trafic.yaml";
1238
1253
  /**
1239
1254
  * Migration 0009: Make scale-to-zero actually work.
1240
1255
  *
@@ -1261,7 +1276,7 @@ var migration0009ScaleToZeroFixes = {
1261
1276
  description: "Fix scale-to-zero: waiting page router and agent write access",
1262
1277
  run() {
1263
1278
  const unitNeedsWrite = !existsSync(UNIT_PATH) || !/^ReadWritePaths=.*\/home\/ddev(\s|$)/m.test(readFileSync(UNIT_PATH, "utf-8"));
1264
- if (!existsSync(DYNAMIC_CONFIG$1) || !readFileSync(DYNAMIC_CONFIG$1, "utf-8").includes("trafic-catchall")) {
1279
+ if (!existsSync(DYNAMIC_CONFIG$2) || !readFileSync(DYNAMIC_CONFIG$2, "utf-8").includes("trafic-catchall")) {
1265
1280
  configureTraefik();
1266
1281
  exec("su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff && ddev start --all'", { silent: true });
1267
1282
  }
@@ -1270,7 +1285,7 @@ var migration0009ScaleToZeroFixes = {
1270
1285
  };
1271
1286
  //#endregion
1272
1287
  //#region src/setup/migrations/0010__catchall_entrypoints.ts
1273
- var DYNAMIC_CONFIG = "/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml";
1288
+ var DYNAMIC_CONFIG$1 = "/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml";
1274
1289
  /** The copy setup used to write for DDEV versions that watched the root. */
1275
1290
  var STALE_CONFIG = "/home/ddev/.ddev/traefik/trafic.yaml";
1276
1291
  /**
@@ -1297,7 +1312,7 @@ var migration0010CatchallEntrypoints = {
1297
1312
  id: "0010__catchall_entrypoints",
1298
1313
  description: "Pin the Traefik catch-all routers to their entry points",
1299
1314
  run() {
1300
- const pinned = existsSync(DYNAMIC_CONFIG) && readFileSync(DYNAMIC_CONFIG, "utf-8").includes("entryPoints:");
1315
+ const pinned = existsSync(DYNAMIC_CONFIG$1) && readFileSync(DYNAMIC_CONFIG$1, "utf-8").includes("entryPoints:");
1301
1316
  if (pinned && !existsSync(STALE_CONFIG)) return;
1302
1317
  if (!pinned) configureTraefik();
1303
1318
  if (existsSync(STALE_CONFIG)) exec(`rm -f ${STALE_CONFIG}`, { silent: true });
@@ -1332,10 +1347,10 @@ var migration0011PurgeAcmeWhenDisabled = {
1332
1347
  id: "0011__purge_acme_when_disabled",
1333
1348
  description: "Remove Traefik's Let's Encrypt storage when it is disabled",
1334
1349
  run() {
1335
- const globalConfig = execSilent$1("su - ddev -c 'ddev config global'");
1350
+ const globalConfig = execSilent("su - ddev -c 'ddev config global'");
1336
1351
  if (!/^use-letsencrypt=false$/m.test(globalConfig)) return;
1337
1352
  if (!purgeTraefikAcmeStorage()) return;
1338
- execSilent$1("docker restart ddev-router");
1353
+ execSilent("docker restart ddev-router");
1339
1354
  }
1340
1355
  };
1341
1356
  //#endregion
@@ -1436,6 +1451,178 @@ var migration0014UnattendedUpgradesDockerOrigin = {
1436
1451
  }
1437
1452
  };
1438
1453
  //#endregion
1454
+ //#region src/setup/migrations/0015__catchall_priority.ts
1455
+ var DYNAMIC_CONFIG = "/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml";
1456
+ /**
1457
+ * Migration 0015: raise the Trafic catch-all routers above DDEV's own.
1458
+ *
1459
+ * DDEV 1.25.4's router image adds two catch-all routers of its own in
1460
+ * `default_config.yaml` — `ddev-router-fallback-http` and
1461
+ * `ddev-router-fallback-https`, from the `traefik_global_config_template.yaml`
1462
+ * template in ddev/ddev. They carry the rule `PathPrefix(`/`)` at priority 1,
1463
+ * on every router entry point, and point at a socat responder that answers
1464
+ * 404. Trafic's own catch-all routers used priority 1 too.
1465
+ *
1466
+ * With equal priority and identical rules Traefik picked DDEV's, so a stopped
1467
+ * project answered DDEV's 404 page instead of the Trafic waiting page, and
1468
+ * scale-to-zero never restarted it — the waiting page is what triggers the
1469
+ * restart. Observed in the access log of a live server:
1470
+ * `"GET /rien-a-mettre.html HTTP/2.0" 404 ...
1471
+ * "http-443-ddev-router-fallback-https@file"`.
1472
+ *
1473
+ * `trafic-agent upgrade` installs DDEV 1.25.4, so every upgraded server is
1474
+ * affected. Priority 2 puts the Trafic routers back in front, and is still far
1475
+ * below any project router, so per-project routing and auth are unchanged.
1476
+ *
1477
+ * Traefik reads its config from the ddev-global-cache volume, and DDEV copies
1478
+ * `~/.ddev/traefik/custom-global-config/*.yaml` into it on any `ddev start` —
1479
+ * `PushGlobalTraefikConfig` runs even when the router is already up. Starting
1480
+ * one project that is already running is therefore enough to load the new
1481
+ * priority; `ddev poweroff` would stop every preview on the server for no
1482
+ * gain, so it is not used. When nothing runs, there is nothing to push to and
1483
+ * the next project start applies it.
1484
+ *
1485
+ * Unrelated and not addressed here: DDEV 1.25.4 also logs "Router count
1486
+ * mismatch" warnings, because its two fallback definitions attach to every
1487
+ * entry point while its health check counts definitions. That is DDEV's own
1488
+ * check against DDEV's own routers.
1489
+ *
1490
+ * Idempotent: skipped when the config is missing, and when no `priority: 1`
1491
+ * is left in it.
1492
+ */
1493
+ var migration0015CatchallPriority = {
1494
+ id: "0015__catchall_priority",
1495
+ description: "Raise the Traefik catch-all routers above DDEV's fallback routers",
1496
+ run() {
1497
+ if (!existsSync(DYNAMIC_CONFIG)) return;
1498
+ if (!readFileSync(DYNAMIC_CONFIG, "utf-8").includes("priority: 1")) return;
1499
+ configureTraefik();
1500
+ const project = findRunningProject();
1501
+ if (!project) {
1502
+ info("No project is running — the new priority applies on the next project start.");
1503
+ return;
1504
+ }
1505
+ exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${project}'`, { silent: true });
1506
+ }
1507
+ };
1508
+ //#endregion
1509
+ //#region src/setup/migrations/0016__wildcard_dns_challenge.ts
1510
+ /**
1511
+ * Migration 0016: apply the wildcard DNS-01 certificate on servers that ask
1512
+ * for one in their config.
1513
+ *
1514
+ * `tls.dns_provider` is read by `configureTraefik`, which setup runs on a
1515
+ * fresh server. An existing server has the config but none of the files, so
1516
+ * this writes them: the resolver in `static_config.trafic.yaml`, the default
1517
+ * TLS store in `custom-global-config/0-trafic-tls.yaml`, and the provider
1518
+ * credentials in `router-compose.trafic.yaml`.
1519
+ *
1520
+ * Why the router is removed and a project started, rather than restarted
1521
+ * (the 0010 rationale, plus one more): Traefik reads its configuration from
1522
+ * the ddev-global-cache volume, and DDEV only copies `~/.ddev/traefik` into
1523
+ * that volume and merges `static_config.*.yaml` into `.static_config.yaml`
1524
+ * when a project starts. And DDEV 1.25 reads `router-compose.*.yaml` only
1525
+ * when it *recreates* the router: a running, healthy router just gets the
1526
+ * Traefik config pushed (StartDdevRouter, `needsRecreation`). Seen on a live
1527
+ * server — the static config was merged, the credentials never reached the
1528
+ * container. Removing the router first forces the recreation; `ddev start`
1529
+ * brings it back within seconds with the new environment.
1530
+ *
1531
+ * The container is found by its compose label, not by name: when compose
1532
+ * recreates a container it renames the old one `<id>_ddev-router`, and a
1533
+ * failed recreation can leave it that way. `docker restart ddev-router` then
1534
+ * silently does nothing — also seen on that server.
1535
+ *
1536
+ * Two details this depends on:
1537
+ * - Traefik's file provider keeps the FIRST `tls.stores.default` it reads in
1538
+ * directory order and logs "TLS store default already configured,
1539
+ * skipping" for the rest. DDEV writes an empty one in
1540
+ * `default_config.yaml`, so the agent's file is named `0-trafic-tls.yaml`
1541
+ * to sort ahead of it.
1542
+ * - DDEV keeps `certResolver: acme-tlsChallenge` on every project router, and
1543
+ * that is fine: Traefik skips a per-host ACME request once the default
1544
+ * store already holds a certificate matching the host with wildcard
1545
+ * semantics. So `use_letsencrypt` stays true — the ACME account email comes
1546
+ * from it — and the per-host quota use stops on its own.
1547
+ *
1548
+ * `ddev poweroff` is deliberately not used: the server runs live previews and
1549
+ * a project start is enough to regenerate the config and recreate the router.
1550
+ *
1551
+ * Idempotent: skipped when no DNS provider is configured, and when all three
1552
+ * files are already in place.
1553
+ */
1554
+ var migration0016WildcardDnsChallenge = {
1555
+ id: "0016__wildcard_dns_challenge",
1556
+ description: "Apply the wildcard DNS-01 certificate where one is configured",
1557
+ run() {
1558
+ runWildcardMigration();
1559
+ }
1560
+ };
1561
+ /**
1562
+ * The migration body, with its effects injected so tests can drive it.
1563
+ *
1564
+ * `tls` defaults to the server's own config: the migration runs after setup
1565
+ * wrote /etc/trafic/config.toml, so that file is the only source.
1566
+ */
1567
+ function runWildcardMigration(io = nodeIo, tls = loadConfig().tls) {
1568
+ if (!tls.dnsProvider) return;
1569
+ if (io.fileExists(STATIC_CONFIG) && io.readFile(STATIC_CONFIG).includes(`acme-dns:`) && io.fileExists(TLS_STORE_CONFIG) && io.fileExists("/home/ddev/.ddev/router-compose.trafic.yaml")) return;
1570
+ configureTraefik({ tls }, io);
1571
+ const project = findRunningProject(io);
1572
+ if (!project) {
1573
+ info("No running project — the next deploy applies the wildcard certificate");
1574
+ return;
1575
+ }
1576
+ const router = findRouterContainer(io);
1577
+ if (router) io.execSilent(`docker rm -f ${router}`);
1578
+ io.exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${project}'`, { silent: true });
1579
+ }
1580
+ /**
1581
+ * The ddev-router container id, whatever its current name.
1582
+ *
1583
+ * Compose labels survive the `<id>_ddev-router` rename a recreation leaves
1584
+ * behind; the container name does not.
1585
+ */
1586
+ function findRouterContainer(io = nodeIo) {
1587
+ return io.execSilent("docker ps -aq --filter label=com.docker.compose.service=ddev-router").trim().split("\n")[0] || void 0;
1588
+ }
1589
+ //#endregion
1590
+ //#region src/setup/migrations/0018__docker_address_pools.ts
1591
+ /**
1592
+ * Migration 0018: give Docker enough address space for one network per
1593
+ * project.
1594
+ *
1595
+ * `setup` wrote a daemon.json without `default-address-pools`, so Docker
1596
+ * used its built-in pools: about 31 networks. Each DDEV project creates its
1597
+ * own `ddev-<name>_default` network, and a server with ~30 projects failed
1598
+ * to start the next one with "all predefined address pools have been fully
1599
+ * subnetted". The last 16 of those built-in networks are in 192.168.x,
1600
+ * outside the 172.16.0.0/12 range the firewall allows to reach the agent.
1601
+ *
1602
+ * `configureDocker` now writes /24 pools in 172.16.0.0/12, which covers
1603
+ * fresh installs. This migration adds them to existing servers.
1604
+ *
1605
+ * Docker reads the pools only at start, so the daemon is restarted.
1606
+ * `live-restore`, set by setup, keeps the running containers up. Existing
1607
+ * networks keep their subnets; only new networks get a /24, and Docker skips
1608
+ * subnets an existing network already uses.
1609
+ *
1610
+ * Idempotent: does nothing when the config already sets pools.
1611
+ */
1612
+ var migration0018DockerAddressPools = {
1613
+ id: "0018__docker_address_pools",
1614
+ description: "Give Docker address pools for one network per project",
1615
+ run() {
1616
+ runDockerAddressPoolsMigration();
1617
+ }
1618
+ };
1619
+ /**
1620
+ * The migration body, with its effects injected so tests can drive it.
1621
+ */
1622
+ function runDockerAddressPoolsMigration(io = nodeIo) {
1623
+ if (addDockerAddressPools(io)) io.exec("systemctl restart docker");
1624
+ }
1625
+ //#endregion
1439
1626
  //#region src/setup/migrations/index.ts
1440
1627
  /** Path to the persisted migration state file */
1441
1628
  var MIGRATIONS_STATE_FILE = "/etc/trafic/.migrations.json";
@@ -1454,7 +1641,10 @@ var ALL_MIGRATIONS = [
1454
1641
  migration0011PurgeAcmeWhenDisabled,
1455
1642
  migration0012DockerUserToolPorts,
1456
1643
  migration0013SshKexDefaults,
1457
- migration0014UnattendedUpgradesDockerOrigin
1644
+ migration0014UnattendedUpgradesDockerOrigin,
1645
+ migration0015CatchallPriority,
1646
+ migration0016WildcardDnsChallenge,
1647
+ migration0018DockerAddressPools
1458
1648
  ];
1459
1649
  /**
1460
1650
  * Load the current migration state from disk.
@@ -1541,6 +1731,7 @@ async function setup(options) {
1541
1731
  }
1542
1732
  info(`TLD: ${options.tld}`);
1543
1733
  if (options.email) info(`Let's Encrypt email: ${options.email}`);
1734
+ if (options.dnsProvider) info(`Wildcard certificate: DNS-01 via ${options.dnsProvider}`);
1544
1735
  if (options.noHardening) info("Hardening: disabled");
1545
1736
  if (options.noRootSsh) info("Root SSH login: disabled");
1546
1737
  try {
@@ -1557,7 +1748,14 @@ async function setup(options) {
1557
1748
  if (!options.noDdev) {
1558
1749
  installDdev();
1559
1750
  configureDdev(options.tld, options.email);
1560
- configureTraefik();
1751
+ configureTraefik({
1752
+ tld: options.tld,
1753
+ tls: {
1754
+ dnsProvider: options.dnsProvider,
1755
+ caServer: options.caServer,
1756
+ dnsEnv: options.dnsEnv ?? {}
1757
+ }
1758
+ });
1561
1759
  configureDockerFirewall(readToolPorts());
1562
1760
  } else {
1563
1761
  step("Install DDEV");
@@ -1567,7 +1765,9 @@ async function setup(options) {
1567
1765
  installAgent();
1568
1766
  createAgentConfig({
1569
1767
  tld: options.tld,
1570
- trustedProxyHops: options.trustedProxyHops
1768
+ trustedProxyHops: options.trustedProxyHops,
1769
+ dnsProvider: options.dnsProvider,
1770
+ dnsEnv: options.dnsEnv
1571
1771
  });
1572
1772
  createSystemdService();
1573
1773
  if (!options.noHardening) hardenServer({
@@ -1600,6 +1800,6 @@ async function audit() {
1600
1800
  if (checks.filter((c) => c.status === "fail").length > 0) process.exit(1);
1601
1801
  }
1602
1802
  //#endregion
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 };
1803
+ export { printAuditResults as a, stopIdleProjects as c, buildDockerFirewallScript as d, buildDockerFirewallUnit as f, runPendingMigrations as i, DOCKER_FIREWALL_SCRIPT as l, setup as n, runAudit as o, configureDockerFirewall as p, listMigrations as r, startIdleScheduler as s, audit as t, DOCKER_FIREWALL_UNIT as u };
1604
1804
 
1605
- //# sourceMappingURL=setup-BrMbM8w4.js.map
1805
+ //# sourceMappingURL=setup-4zlNJqDe.js.map