@studiometa/trafic-agent 0.1.44 → 0.1.46

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.
@@ -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";
@@ -437,15 +322,21 @@ function installAgent(io = nodeIo) {
437
322
  * Create the agent configuration
438
323
  */
439
324
  function createAgentConfig(options, io = nodeIo) {
440
- const { tld, trustedProxyHops = 1 } = options;
325
+ const { tld, trustedProxyHops = 1, dnsProvider, dnsEnv = {} } = options;
441
326
  step("Create agent configuration");
442
327
  io.exec("mkdir -p /etc/trafic");
443
328
  io.exec("mkdir -p /var/lib/trafic");
444
329
  io.exec("chown ddev:ddev /var/lib/trafic");
445
330
  const configPath = "/etc/trafic/config.toml";
331
+ const tlsSection = buildTlsSection(dnsProvider, dnsEnv);
446
332
  if (io.fileExists(configPath)) {
447
333
  info("Configuration already exists at /etc/trafic/config.toml — skipping");
448
334
  info("Edit this file to configure authentication");
335
+ if (tlsSection) {
336
+ info("Add this to /etc/trafic/config.toml, then run `trafic-agent upgrade`:");
337
+ for (const line of buildTlsSection(dnsProvider, dnsEnv, true).trim().split("\n")) info(` ${line}`);
338
+ info("Replace each REPLACE_ME with the value you passed to --dns-env.");
339
+ }
449
340
  return;
450
341
  }
451
342
  const config = `# Trafic agent configuration
@@ -480,7 +371,7 @@ rules = []
480
371
  # Too low and allowed_ips matches a proxy instead of the client; too high and
481
372
  # it can be spoofed by a client-supplied header.
482
373
  trusted_proxy_hops = ${trustedProxyHops}
483
- `;
374
+ ${tlsSection}`;
484
375
  io.writeFile(configPath, config);
485
376
  io.exec("chmod 640 /etc/trafic/config.toml");
486
377
  io.exec("chown root:ddev /etc/trafic/config.toml");
@@ -488,6 +379,31 @@ trusted_proxy_hops = ${trustedProxyHops}
488
379
  info("Edit this file to configure authentication");
489
380
  }
490
381
  /**
382
+ * Build the [tls] section of the generated config.
383
+ *
384
+ * Empty without a DNS provider: per-host certificates stay the default, and
385
+ * an empty section would only invite a half-filled one.
386
+ */
387
+ function buildTlsSection(dnsProvider, dnsEnv, redact = false) {
388
+ if (!dnsProvider) return "";
389
+ const env = Object.entries(dnsEnv).map(([key, value]) => `${key} = "${redact ? "REPLACE_ME" : escapeTomlString(value)}"`).join("\n");
390
+ return `
391
+ # Wildcard certificate (DNS-01). One *.<tld> certificate replaces the
392
+ # per-hostname ones, so previews stop consuming the Let's Encrypt quota.
393
+ [tls]
394
+ dns_provider = "${escapeTomlString(dnsProvider)}"
395
+ # ca_server = "https://acme-staging-v02.api.letsencrypt.org/directory"
396
+
397
+ # Credentials the lego provider reads. Passed to the ddev-router container.
398
+ [tls.dns_env]
399
+ ${env}
400
+ `;
401
+ }
402
+ /** Escape a value for a basic (double-quoted) TOML string. */
403
+ function escapeTomlString(value) {
404
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
405
+ }
406
+ /**
491
407
  * Create systemd service for the agent
492
408
  */
493
409
  function createSystemdService(io = nodeIo) {
@@ -811,32 +727,21 @@ function hardenServer(options = {}, io = nodeIo) {
811
727
  //#endregion
812
728
  //#region src/setup/audit.ts
813
729
  /**
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
730
  * Check if a service is active
828
731
  */
829
- function isServiceActive(service) {
830
- return execSilent(`systemctl is-active ${service}`) === "active";
732
+ function isServiceActive(service, io) {
733
+ return io.execSilent(`systemctl is-active ${service}`) === "active";
734
+ }
735
+ /** Read a file, or "" when it does not exist. */
736
+ function readOrEmpty(path, io) {
737
+ return io.fileExists(path) ? io.readFile(path) : "";
831
738
  }
832
739
  /**
833
740
  * Audit SSH configuration
834
741
  */
835
- function auditSsh() {
742
+ function auditSsh(io = nodeIo) {
836
743
  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;
744
+ const config = readOrEmpty("/etc/ssh/sshd_config", io) + "\n" + readOrEmpty("/etc/ssh/sshd_config.d/trafic.conf", io);
840
745
  const rootLoginDisabled = config.includes("PermitRootLogin no") || config.includes("PermitRootLogin prohibit-password");
841
746
  checks.push({
842
747
  name: "SSH root login",
@@ -856,9 +761,9 @@ function auditSsh() {
856
761
  /**
857
762
  * Audit firewall configuration
858
763
  */
859
- function auditFirewall() {
764
+ function auditFirewall(io = nodeIo) {
860
765
  const checks = [];
861
- const ufwStatus = execSilent("ufw status");
766
+ const ufwStatus = io.execSilent("ufw status");
862
767
  const isActive = ufwStatus.includes("Status: active");
863
768
  checks.push({
864
769
  name: "Firewall (UFW)",
@@ -887,23 +792,23 @@ function auditFirewall() {
887
792
  /**
888
793
  * Audit security services
889
794
  */
890
- function auditServices() {
795
+ function auditServices(io = nodeIo) {
891
796
  const checks = [];
892
- const fail2banActive = isServiceActive("fail2ban");
797
+ const fail2banActive = isServiceActive("fail2ban", io);
893
798
  checks.push({
894
799
  name: "Fail2ban",
895
800
  status: fail2banActive ? "pass" : "warn",
896
801
  message: fail2banActive ? "Fail2ban is active" : "Fail2ban is not running",
897
802
  fix: "Run: apt install fail2ban && systemctl enable fail2ban --now"
898
803
  });
899
- const unattendedActive = isServiceActive("unattended-upgrades");
804
+ const unattendedActive = isServiceActive("unattended-upgrades", io);
900
805
  checks.push({
901
806
  name: "Automatic updates",
902
807
  status: unattendedActive ? "pass" : "warn",
903
808
  message: unattendedActive ? "Unattended upgrades enabled" : "Automatic updates not configured",
904
809
  fix: "Run: apt install unattended-upgrades && dpkg-reconfigure unattended-upgrades"
905
810
  });
906
- const agentActive = isServiceActive("trafic-agent");
811
+ const agentActive = isServiceActive("trafic-agent", io);
907
812
  checks.push({
908
813
  name: "Trafic agent",
909
814
  status: agentActive ? "pass" : "fail",
@@ -915,16 +820,16 @@ function auditServices() {
915
820
  /**
916
821
  * Audit Docker
917
822
  */
918
- function auditDocker() {
823
+ function auditDocker(io = nodeIo) {
919
824
  const checks = [];
920
- const dockerActive = isServiceActive("docker");
825
+ const dockerActive = isServiceActive("docker", io);
921
826
  checks.push({
922
827
  name: "Docker",
923
828
  status: dockerActive ? "pass" : "fail",
924
829
  message: dockerActive ? "Docker is running" : "Docker is not running",
925
830
  fix: "Run: systemctl start docker"
926
831
  });
927
- const danglingImages = execSilent("docker images -f dangling=true -q | wc -l");
832
+ const danglingImages = io.execSilent("docker images -f dangling=true -q | wc -l");
928
833
  const count = parseInt(danglingImages, 10) || 0;
929
834
  if (count > 0) checks.push({
930
835
  name: "Docker cleanup",
@@ -932,7 +837,7 @@ function auditDocker() {
932
837
  message: `${count} dangling Docker images`,
933
838
  fix: "Run: docker system prune -f"
934
839
  });
935
- const diskUsage = execSilent("df -h / | awk 'NR==2{print $5}'");
840
+ const diskUsage = io.execSilent("df -h / | awk 'NR==2{print $5}'");
936
841
  const usagePercent = parseInt(diskUsage, 10) || 0;
937
842
  checks.push({
938
843
  name: "Disk usage",
@@ -945,10 +850,10 @@ function auditDocker() {
945
850
  /**
946
851
  * Audit file permissions
947
852
  */
948
- function auditPermissions() {
853
+ function auditPermissions(io = nodeIo) {
949
854
  const checks = [];
950
- if (existsSync("/etc/trafic/config.toml")) {
951
- const stats = execSilent("stat -c '%a' /etc/trafic/config.toml");
855
+ if (io.fileExists("/etc/trafic/config.toml")) {
856
+ const stats = io.execSilent("stat -c '%a' /etc/trafic/config.toml");
952
857
  const isSecure = stats === "640" || stats === "600";
953
858
  checks.push({
954
859
  name: "Config permissions",
@@ -957,8 +862,8 @@ function auditPermissions() {
957
862
  fix: "Run: chmod 640 /etc/trafic/config.toml"
958
863
  });
959
864
  }
960
- if (existsSync("/home/ddev/www")) {
961
- const owner = execSilent("stat -c '%U:%G' /home/ddev/www");
865
+ if (io.fileExists("/home/ddev/www")) {
866
+ const owner = io.execSilent("stat -c '%U:%G' /home/ddev/www");
962
867
  const isCorrect = owner === "ddev:ddev";
963
868
  checks.push({
964
869
  name: "Projects directory",
@@ -970,15 +875,86 @@ function auditPermissions() {
970
875
  return checks;
971
876
  }
972
877
  /**
878
+ * Audit the wildcard certificate, where one is configured.
879
+ *
880
+ * Two things go wrong silently: the certificate is never issued (a wrong
881
+ * token, a zone the token cannot edit), and the token file ends up readable
882
+ * by everyone.
883
+ */
884
+ function auditWildcardTls(config, io = nodeIo) {
885
+ if (!config.tls.dnsProvider) return [];
886
+ const checks = [];
887
+ checks.push(wildcardCertificateCheck(config.tld, io));
888
+ if (io.fileExists("/home/ddev/.ddev/router-compose.trafic.yaml")) {
889
+ const mode = io.execSilent(`stat -c '%a' ${ROUTER_COMPOSE_OVERRIDE}`);
890
+ const isSecure = mode === "600";
891
+ checks.push({
892
+ name: "DNS credentials permissions",
893
+ status: isSecure ? "pass" : "warn",
894
+ message: isSecure ? "Router compose override is readable by its owner only" : `Router compose override permissions: ${mode} (should be 600)`,
895
+ fix: `Run: chmod 600 ${ROUTER_COMPOSE_OVERRIDE}`
896
+ });
897
+ }
898
+ return checks;
899
+ }
900
+ /** Look for the wildcard certificate in Traefik's DNS-01 ACME storage. */
901
+ function wildcardCertificateCheck(tld, io) {
902
+ const fix = "Check the router log for ACME errors: docker logs ddev-router 2>&1 | grep -i acme";
903
+ const mountpoint = io.execSilent("docker volume inspect ddev-global-cache --format '{{.Mountpoint}}'");
904
+ if (!mountpoint) return {
905
+ name: "Wildcard certificate",
906
+ status: "warn",
907
+ message: "Traefik's storage volume (ddev-global-cache) was not found",
908
+ fix
909
+ };
910
+ const storage = `${mountpoint}/traefik/acme-dns.json`;
911
+ if (!io.fileExists(storage)) return {
912
+ name: "Wildcard certificate",
913
+ status: "warn",
914
+ message: "No DNS-01 certificate has been issued yet",
915
+ fix
916
+ };
917
+ const issued = hasCertificateFor(io.readFile(storage), tld);
918
+ return {
919
+ name: "Wildcard certificate",
920
+ status: issued ? "pass" : "warn",
921
+ message: issued ? `Wildcard certificate issued for *.${tld}` : `No certificate for ${tld} in Traefik's DNS-01 storage`,
922
+ fix: issued ? void 0 : fix
923
+ };
924
+ }
925
+ /**
926
+ * Whether Traefik's ACME storage holds a certificate whose main domain is the
927
+ * TLD — which is the wildcard one, since that is what the default store asks
928
+ * for.
929
+ *
930
+ * The file is keyed by resolver name, each with a Certificates array.
931
+ */
932
+ function hasCertificateFor(storage, tld) {
933
+ let parsed;
934
+ try {
935
+ parsed = JSON.parse(storage);
936
+ } catch {
937
+ return false;
938
+ }
939
+ if (parsed === null || typeof parsed !== "object") return false;
940
+ for (const resolver of Object.values(parsed)) {
941
+ const certificates = resolver.Certificates;
942
+ if (!Array.isArray(certificates)) continue;
943
+ for (const certificate of certificates) if (certificate.domain?.main === tld) return true;
944
+ }
945
+ return false;
946
+ }
947
+ /**
973
948
  * Run all audit checks
974
949
  */
975
- function runAudit() {
950
+ function runAudit(io = nodeIo, config = loadConfig()) {
976
951
  return [
977
- ...auditSsh(),
978
- ...auditFirewall(),
979
- ...auditServices(),
980
- ...auditDocker(),
981
- ...auditPermissions()
952
+ ...auditSsh(io),
953
+ ...auditFirewall(io),
954
+ ...auditServices(io),
955
+ ...auditDocker(io),
956
+ ...auditPermissions(io),
957
+ ...auditWildcardTls(config, io)
982
958
  ];
983
959
  }
984
960
  /**
@@ -1157,7 +1133,7 @@ var migration0007UfwDockerTraficPort = {
1157
1133
  id: "0007__ufw_docker_trafic_port",
1158
1134
  description: "Allow trafic-agent port 9876 from Docker bridge subnets in UFW",
1159
1135
  run() {
1160
- if (execSilent$1("ufw status | grep '9876'").includes("9876")) return;
1136
+ if (execSilent("ufw status | grep '9876'").includes("9876")) return;
1161
1137
  exec("ufw allow from 172.16.0.0/12 to any port 9876 comment 'trafic-agent from Docker'", { silent: true });
1162
1138
  }
1163
1139
  };
@@ -1234,7 +1210,7 @@ http:
1234
1210
  //#endregion
1235
1211
  //#region src/setup/migrations/0009__scale_to_zero_fixes.ts
1236
1212
  var UNIT_PATH = "/etc/systemd/system/trafic-agent.service";
1237
- var DYNAMIC_CONFIG$1 = "/home/ddev/.ddev/traefik/trafic.yaml";
1213
+ var DYNAMIC_CONFIG$2 = "/home/ddev/.ddev/traefik/trafic.yaml";
1238
1214
  /**
1239
1215
  * Migration 0009: Make scale-to-zero actually work.
1240
1216
  *
@@ -1261,7 +1237,7 @@ var migration0009ScaleToZeroFixes = {
1261
1237
  description: "Fix scale-to-zero: waiting page router and agent write access",
1262
1238
  run() {
1263
1239
  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")) {
1240
+ if (!existsSync(DYNAMIC_CONFIG$2) || !readFileSync(DYNAMIC_CONFIG$2, "utf-8").includes("trafic-catchall")) {
1265
1241
  configureTraefik();
1266
1242
  exec("su - ddev -c 'DDEV_NONINTERACTIVE=true ddev poweroff && ddev start --all'", { silent: true });
1267
1243
  }
@@ -1270,7 +1246,7 @@ var migration0009ScaleToZeroFixes = {
1270
1246
  };
1271
1247
  //#endregion
1272
1248
  //#region src/setup/migrations/0010__catchall_entrypoints.ts
1273
- var DYNAMIC_CONFIG = "/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml";
1249
+ var DYNAMIC_CONFIG$1 = "/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml";
1274
1250
  /** The copy setup used to write for DDEV versions that watched the root. */
1275
1251
  var STALE_CONFIG = "/home/ddev/.ddev/traefik/trafic.yaml";
1276
1252
  /**
@@ -1297,7 +1273,7 @@ var migration0010CatchallEntrypoints = {
1297
1273
  id: "0010__catchall_entrypoints",
1298
1274
  description: "Pin the Traefik catch-all routers to their entry points",
1299
1275
  run() {
1300
- const pinned = existsSync(DYNAMIC_CONFIG) && readFileSync(DYNAMIC_CONFIG, "utf-8").includes("entryPoints:");
1276
+ const pinned = existsSync(DYNAMIC_CONFIG$1) && readFileSync(DYNAMIC_CONFIG$1, "utf-8").includes("entryPoints:");
1301
1277
  if (pinned && !existsSync(STALE_CONFIG)) return;
1302
1278
  if (!pinned) configureTraefik();
1303
1279
  if (existsSync(STALE_CONFIG)) exec(`rm -f ${STALE_CONFIG}`, { silent: true });
@@ -1332,10 +1308,10 @@ var migration0011PurgeAcmeWhenDisabled = {
1332
1308
  id: "0011__purge_acme_when_disabled",
1333
1309
  description: "Remove Traefik's Let's Encrypt storage when it is disabled",
1334
1310
  run() {
1335
- const globalConfig = execSilent$1("su - ddev -c 'ddev config global'");
1311
+ const globalConfig = execSilent("su - ddev -c 'ddev config global'");
1336
1312
  if (!/^use-letsencrypt=false$/m.test(globalConfig)) return;
1337
1313
  if (!purgeTraefikAcmeStorage()) return;
1338
- execSilent$1("docker restart ddev-router");
1314
+ execSilent("docker restart ddev-router");
1339
1315
  }
1340
1316
  };
1341
1317
  //#endregion
@@ -1436,6 +1412,142 @@ var migration0014UnattendedUpgradesDockerOrigin = {
1436
1412
  }
1437
1413
  };
1438
1414
  //#endregion
1415
+ //#region src/setup/migrations/0015__catchall_priority.ts
1416
+ var DYNAMIC_CONFIG = "/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml";
1417
+ /**
1418
+ * Migration 0015: raise the Trafic catch-all routers above DDEV's own.
1419
+ *
1420
+ * DDEV 1.25.4's router image adds two catch-all routers of its own in
1421
+ * `default_config.yaml` — `ddev-router-fallback-http` and
1422
+ * `ddev-router-fallback-https`, from the `traefik_global_config_template.yaml`
1423
+ * template in ddev/ddev. They carry the rule `PathPrefix(`/`)` at priority 1,
1424
+ * on every router entry point, and point at a socat responder that answers
1425
+ * 404. Trafic's own catch-all routers used priority 1 too.
1426
+ *
1427
+ * With equal priority and identical rules Traefik picked DDEV's, so a stopped
1428
+ * project answered DDEV's 404 page instead of the Trafic waiting page, and
1429
+ * scale-to-zero never restarted it — the waiting page is what triggers the
1430
+ * restart. Observed in the access log of a live server:
1431
+ * `"GET /rien-a-mettre.html HTTP/2.0" 404 ...
1432
+ * "http-443-ddev-router-fallback-https@file"`.
1433
+ *
1434
+ * `trafic-agent upgrade` installs DDEV 1.25.4, so every upgraded server is
1435
+ * affected. Priority 2 puts the Trafic routers back in front, and is still far
1436
+ * below any project router, so per-project routing and auth are unchanged.
1437
+ *
1438
+ * Traefik reads its config from the ddev-global-cache volume, and DDEV copies
1439
+ * `~/.ddev/traefik/custom-global-config/*.yaml` into it on any `ddev start` —
1440
+ * `PushGlobalTraefikConfig` runs even when the router is already up. Starting
1441
+ * one project that is already running is therefore enough to load the new
1442
+ * priority; `ddev poweroff` would stop every preview on the server for no
1443
+ * gain, so it is not used. When nothing runs, there is nothing to push to and
1444
+ * the next project start applies it.
1445
+ *
1446
+ * Unrelated and not addressed here: DDEV 1.25.4 also logs "Router count
1447
+ * mismatch" warnings, because its two fallback definitions attach to every
1448
+ * entry point while its health check counts definitions. That is DDEV's own
1449
+ * check against DDEV's own routers.
1450
+ *
1451
+ * Idempotent: skipped when the config is missing, and when no `priority: 1`
1452
+ * is left in it.
1453
+ */
1454
+ var migration0015CatchallPriority = {
1455
+ id: "0015__catchall_priority",
1456
+ description: "Raise the Traefik catch-all routers above DDEV's fallback routers",
1457
+ run() {
1458
+ if (!existsSync(DYNAMIC_CONFIG)) return;
1459
+ if (!readFileSync(DYNAMIC_CONFIG, "utf-8").includes("priority: 1")) return;
1460
+ configureTraefik();
1461
+ const project = findRunningProject();
1462
+ if (!project) {
1463
+ info("No project is running — the new priority applies on the next project start.");
1464
+ return;
1465
+ }
1466
+ exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${project}'`, { silent: true });
1467
+ }
1468
+ };
1469
+ //#endregion
1470
+ //#region src/setup/migrations/0016__wildcard_dns_challenge.ts
1471
+ /**
1472
+ * Migration 0016: apply the wildcard DNS-01 certificate on servers that ask
1473
+ * for one in their config.
1474
+ *
1475
+ * `tls.dns_provider` is read by `configureTraefik`, which setup runs on a
1476
+ * fresh server. An existing server has the config but none of the files, so
1477
+ * this writes them: the resolver in `static_config.trafic.yaml`, the default
1478
+ * TLS store in `custom-global-config/0-trafic-tls.yaml`, and the provider
1479
+ * credentials in `router-compose.trafic.yaml`.
1480
+ *
1481
+ * Why the router is removed and a project started, rather than restarted
1482
+ * (the 0010 rationale, plus one more): Traefik reads its configuration from
1483
+ * the ddev-global-cache volume, and DDEV only copies `~/.ddev/traefik` into
1484
+ * that volume and merges `static_config.*.yaml` into `.static_config.yaml`
1485
+ * when a project starts. And DDEV 1.25 reads `router-compose.*.yaml` only
1486
+ * when it *recreates* the router: a running, healthy router just gets the
1487
+ * Traefik config pushed (StartDdevRouter, `needsRecreation`). Seen on a live
1488
+ * server — the static config was merged, the credentials never reached the
1489
+ * container. Removing the router first forces the recreation; `ddev start`
1490
+ * brings it back within seconds with the new environment.
1491
+ *
1492
+ * The container is found by its compose label, not by name: when compose
1493
+ * recreates a container it renames the old one `<id>_ddev-router`, and a
1494
+ * failed recreation can leave it that way. `docker restart ddev-router` then
1495
+ * silently does nothing — also seen on that server.
1496
+ *
1497
+ * Two details this depends on:
1498
+ * - Traefik's file provider keeps the FIRST `tls.stores.default` it reads in
1499
+ * directory order and logs "TLS store default already configured,
1500
+ * skipping" for the rest. DDEV writes an empty one in
1501
+ * `default_config.yaml`, so the agent's file is named `0-trafic-tls.yaml`
1502
+ * to sort ahead of it.
1503
+ * - DDEV keeps `certResolver: acme-tlsChallenge` on every project router, and
1504
+ * that is fine: Traefik skips a per-host ACME request once the default
1505
+ * store already holds a certificate matching the host with wildcard
1506
+ * semantics. So `use_letsencrypt` stays true — the ACME account email comes
1507
+ * from it — and the per-host quota use stops on its own.
1508
+ *
1509
+ * `ddev poweroff` is deliberately not used: the server runs live previews and
1510
+ * a project start is enough to regenerate the config and recreate the router.
1511
+ *
1512
+ * Idempotent: skipped when no DNS provider is configured, and when all three
1513
+ * files are already in place.
1514
+ */
1515
+ var migration0016WildcardDnsChallenge = {
1516
+ id: "0016__wildcard_dns_challenge",
1517
+ description: "Apply the wildcard DNS-01 certificate where one is configured",
1518
+ run() {
1519
+ runWildcardMigration();
1520
+ }
1521
+ };
1522
+ /**
1523
+ * The migration body, with its effects injected so tests can drive it.
1524
+ *
1525
+ * `tls` defaults to the server's own config: the migration runs after setup
1526
+ * wrote /etc/trafic/config.toml, so that file is the only source.
1527
+ */
1528
+ function runWildcardMigration(io = nodeIo, tls = loadConfig().tls) {
1529
+ if (!tls.dnsProvider) return;
1530
+ 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;
1531
+ configureTraefik({ tls }, io);
1532
+ const project = findRunningProject(io);
1533
+ if (!project) {
1534
+ info("No running project — the next deploy applies the wildcard certificate");
1535
+ return;
1536
+ }
1537
+ const router = findRouterContainer(io);
1538
+ if (router) io.execSilent(`docker rm -f ${router}`);
1539
+ io.exec(`su - ddev -c 'DDEV_NONINTERACTIVE=true ddev start ${project}'`, { silent: true });
1540
+ }
1541
+ /**
1542
+ * The ddev-router container id, whatever its current name.
1543
+ *
1544
+ * Compose labels survive the `<id>_ddev-router` rename a recreation leaves
1545
+ * behind; the container name does not.
1546
+ */
1547
+ function findRouterContainer(io = nodeIo) {
1548
+ return io.execSilent("docker ps -aq --filter label=com.docker.compose.service=ddev-router").trim().split("\n")[0] || void 0;
1549
+ }
1550
+ //#endregion
1439
1551
  //#region src/setup/migrations/index.ts
1440
1552
  /** Path to the persisted migration state file */
1441
1553
  var MIGRATIONS_STATE_FILE = "/etc/trafic/.migrations.json";
@@ -1454,7 +1566,9 @@ var ALL_MIGRATIONS = [
1454
1566
  migration0011PurgeAcmeWhenDisabled,
1455
1567
  migration0012DockerUserToolPorts,
1456
1568
  migration0013SshKexDefaults,
1457
- migration0014UnattendedUpgradesDockerOrigin
1569
+ migration0014UnattendedUpgradesDockerOrigin,
1570
+ migration0015CatchallPriority,
1571
+ migration0016WildcardDnsChallenge
1458
1572
  ];
1459
1573
  /**
1460
1574
  * Load the current migration state from disk.
@@ -1541,6 +1655,7 @@ async function setup(options) {
1541
1655
  }
1542
1656
  info(`TLD: ${options.tld}`);
1543
1657
  if (options.email) info(`Let's Encrypt email: ${options.email}`);
1658
+ if (options.dnsProvider) info(`Wildcard certificate: DNS-01 via ${options.dnsProvider}`);
1544
1659
  if (options.noHardening) info("Hardening: disabled");
1545
1660
  if (options.noRootSsh) info("Root SSH login: disabled");
1546
1661
  try {
@@ -1557,7 +1672,14 @@ async function setup(options) {
1557
1672
  if (!options.noDdev) {
1558
1673
  installDdev();
1559
1674
  configureDdev(options.tld, options.email);
1560
- configureTraefik();
1675
+ configureTraefik({
1676
+ tld: options.tld,
1677
+ tls: {
1678
+ dnsProvider: options.dnsProvider,
1679
+ caServer: options.caServer,
1680
+ dnsEnv: options.dnsEnv ?? {}
1681
+ }
1682
+ });
1561
1683
  configureDockerFirewall(readToolPorts());
1562
1684
  } else {
1563
1685
  step("Install DDEV");
@@ -1567,7 +1689,9 @@ async function setup(options) {
1567
1689
  installAgent();
1568
1690
  createAgentConfig({
1569
1691
  tld: options.tld,
1570
- trustedProxyHops: options.trustedProxyHops
1692
+ trustedProxyHops: options.trustedProxyHops,
1693
+ dnsProvider: options.dnsProvider,
1694
+ dnsEnv: options.dnsEnv
1571
1695
  });
1572
1696
  createSystemdService();
1573
1697
  if (!options.noHardening) hardenServer({
@@ -1600,6 +1724,6 @@ async function audit() {
1600
1724
  if (checks.filter((c) => c.status === "fail").length > 0) process.exit(1);
1601
1725
  }
1602
1726
  //#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 };
1727
+ 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
1728
 
1605
- //# sourceMappingURL=setup-BrMbM8w4.js.map
1729
+ //# sourceMappingURL=setup-B02lDDZU.js.map