@studiometa/trafic-agent 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { _ as validateConfig, c as info, d as success, f as warn, h as loadConfig, i as runPendingMigrations, l as isRoot, n as setup, p as startIdleScheduler, r as listMigrations, s as exec, t as audit, u as step } from "./setup-DXihIjv9.js";
3
- import { o as closeDb, t as startServer } from "./server-zVWzPuj2.js";
2
+ import { d as validateConfig, i as runPendingMigrations, l as loadConfig, n as setup, r as listMigrations, s as startIdleScheduler, t as audit } from "./setup-vg_pqnPs.js";
3
+ import { _ as step, d as exec, m as isRoot, n as startServer, p as info, v as success, w as closeDb, y as warn } from "./server-BgOOBUhR.js";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { execSync, spawnSync } from "node:child_process";
6
6
  import { parseArgs } from "node:util";
@@ -108,7 +108,7 @@ function runUpgrade(dryRun = false, reExecArgs) {
108
108
  }
109
109
  const alreadyReExeced = process.env["TRAFIC_UPGRADE_REEXEC"] === "1";
110
110
  step("Check for updates");
111
- const current = "0.1.30";
111
+ const current = "0.1.32";
112
112
  info(`Current version: ${current}`);
113
113
  const latest = fetchLatestVersion();
114
114
  if (!latest) warn("Could not reach npm registry — skipping version check");
@@ -191,7 +191,7 @@ function printHelp() {
191
191
  console.log(HELP.trim());
192
192
  }
193
193
  function printVersion() {
194
- console.log("0.1.30");
194
+ console.log("0.1.32");
195
195
  }
196
196
  async function runStart(values) {
197
197
  const config = loadConfig(values.config);
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { _ as validateConfig, a as printAuditResults, g as parseDuration, h as loadConfig, m as stopIdleProjects, n as setup, o as runAudit, p as startIdleScheduler, t as audit } from "./setup-DXihIjv9.js";
2
- import { C as checkAuth, D as parseBearerToken, E as parseBasicAuth, S as watchProjectList, T as matchIp, _ as getProjectInfo, a as cleanOldLogs, b as startProject, c as getDb, d as initDb, f as logAccess, g as getProjectFromHostname, h as buildHostnameIndex, i as shouldNeverStop, l as getIdleProjects, m as updateProjectAccess, n as getIdleTimeoutMs, o as closeDb, p as setProjectStatus, r as loadProjectConfig, s as getAccessLogs, t as startServer, u as getProject, v as listProjects, w as matchHostname, x as stopProject, y as loadProjectList } from "./server-zVWzPuj2.js";
1
+ import { a as printAuditResults, c as stopIdleProjects, d as validateConfig, l as loadConfig, n as setup, o as runAudit, s as startIdleScheduler, t as audit, u as parseDuration } from "./setup-vg_pqnPs.js";
2
+ import { A as logAccess, B as watchProjectList, C as cleanOldLogs, D as getIdleProjects, E as getDb, F as getProjectInfo, G as parseBearerToken, H as matchHostname, I as listProjects, L as loadProjectList, M as updateProjectAccess, N as buildHostnameIndex, O as getProject, P as getProjectFromHostname, R as startProject, S as shouldNeverStop, T as getAccessLogs, U as matchIp, V as checkAuth, W as parseBasicAuth, b as getIdleTimeoutMs, j as setProjectStatus, k as initDb, n as startServer, w as closeDb, x as loadProjectConfig, z as stopProject } from "./server-BgOOBUhR.js";
3
3
  export { audit, buildHostnameIndex, checkAuth, cleanOldLogs, closeDb, getAccessLogs, getDb, getIdleProjects, getIdleTimeoutMs, getProject, getProjectFromHostname, getProjectInfo, initDb, listProjects, loadConfig, loadProjectConfig, loadProjectList, logAccess, matchHostname, matchIp, parseBasicAuth, parseBearerToken, parseDuration, printAuditResults, runAudit, setProjectStatus, setup, shouldNeverStop, startIdleScheduler, startProject, startServer, stopIdleProjects, stopProject, updateProjectAccess, validateConfig, watchProjectList };
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, watch } from "node:fs";
1
+ import { existsSync, readFileSync, watch, writeFileSync } from "node:fs";
2
2
  import { execSync } from "node:child_process";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -224,29 +224,49 @@ function applyDefaultPolicy(config, ip, authorization) {
224
224
  }
225
225
  //#endregion
226
226
  //#region src/utils/ddev.ts
227
- function parseSimpleYaml$1(content) {
228
- const result = {};
229
- const lines = content.split("\n");
230
- for (const line of lines) {
231
- const trimmed = line.trim();
232
- if (!trimmed || trimmed.startsWith("#")) continue;
233
- const colonIndex = trimmed.indexOf(":");
234
- if (colonIndex === -1) continue;
235
- const key = trimmed.slice(0, colonIndex).trim();
236
- let value = trimmed.slice(colonIndex + 1).trim();
237
- if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
238
- result[key] = value;
239
- }
240
- return result;
227
+ /** Strip a single pair of surrounding quotes. */
228
+ function unquote(value) {
229
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
230
+ return value;
241
231
  }
232
+ /** A project name at the start of a line, e.g. `my-project:` */
233
+ var PROJECT_LINE = /^(\S[^:]*):\s*$/;
234
+ /** An indented `approot: /path` belonging to the project above it */
235
+ var APPROOT_LINE = /^\s+approot:\s*(.+?)\s*$/;
242
236
  /**
243
237
  * Load projects from DDEV's project_list.yaml
244
238
  * Returns a map of project name -> app root path
239
+ *
240
+ * The file nests the path under the project name:
241
+ *
242
+ * scalian:
243
+ * approot: /home/ddev/www/scalian
244
+ *
245
+ * Indentation therefore has to be read. A flat key/value pass produces one
246
+ * entry per line instead: the project mapped to an empty string, plus a
247
+ * phantom project called `approot`. Both did damage — the empty path meant
248
+ * `loadProjectConfig` looked for `.ddev/config.trafic.yaml` relative to the
249
+ * working directory and never found it, so per-project settings were silently
250
+ * ignored, and the phantom entered the hostname index, where the TLS ask
251
+ * endpoint would vouch for `approot.<tld>` and have a certificate issued for
252
+ * a project that does not exist.
245
253
  */
246
254
  function loadProjectList(projectListPath) {
247
255
  if (!existsSync(projectListPath)) return /* @__PURE__ */ new Map();
248
- const parsed = parseSimpleYaml$1(readFileSync(projectListPath, "utf-8"));
249
- return new Map(Object.entries(parsed));
256
+ const projects = /* @__PURE__ */ new Map();
257
+ let current;
258
+ for (const line of readFileSync(projectListPath, "utf-8").split("\n")) {
259
+ if (!line.trim() || line.trimStart().startsWith("#")) continue;
260
+ const project = PROJECT_LINE.exec(line);
261
+ if (project) {
262
+ current = unquote(project[1].trim());
263
+ projects.set(current, "");
264
+ continue;
265
+ }
266
+ const approot = APPROOT_LINE.exec(line);
267
+ if (approot && current) projects.set(current, unquote(approot[1]));
268
+ }
269
+ return projects;
250
270
  }
251
271
  /**
252
272
  * Build a hostname -> project name index
@@ -536,6 +556,363 @@ function getIdleTimeoutMs(config) {
536
556
  return ms > 0 ? ms : void 0;
537
557
  }
538
558
  //#endregion
559
+ //#region src/setup/steps.ts
560
+ var stepNumber = 0;
561
+ var dryRun = false;
562
+ /**
563
+ * Set dry-run mode
564
+ */
565
+ function setDryRun(value) {
566
+ dryRun = value;
567
+ }
568
+ /**
569
+ * Reset step counter
570
+ */
571
+ function resetSteps() {
572
+ stepNumber = 0;
573
+ }
574
+ /**
575
+ * Log a step
576
+ */
577
+ function step(message) {
578
+ stepNumber++;
579
+ console.log(`\n\x1b[36m[${stepNumber}]\x1b[0m ${message}`);
580
+ }
581
+ /**
582
+ * Log success
583
+ */
584
+ function success(message) {
585
+ console.log(` \x1b[32m✓\x1b[0m ${message}`);
586
+ }
587
+ /**
588
+ * Log warning
589
+ */
590
+ function warn(message) {
591
+ console.log(` \x1b[33m⚠\x1b[0m ${message}`);
592
+ }
593
+ /**
594
+ * Log error
595
+ */
596
+ function error(message) {
597
+ console.log(` \x1b[31m✗\x1b[0m ${message}`);
598
+ }
599
+ /**
600
+ * Log info
601
+ */
602
+ function info(message) {
603
+ console.log(` ${message}`);
604
+ }
605
+ /**
606
+ * Execute a command (or show in dry-run mode)
607
+ */
608
+ function exec(command, options) {
609
+ if (dryRun) {
610
+ if (!options?.silent) console.log(` \x1b[90m$ ${command}\x1b[0m`);
611
+ return "";
612
+ }
613
+ try {
614
+ return execSync(command, {
615
+ encoding: "utf-8",
616
+ stdio: options?.silent ? "pipe" : "inherit"
617
+ });
618
+ } catch (err) {
619
+ throw new Error(`Command failed: ${command}\n${err.stderr ?? ""}`);
620
+ }
621
+ }
622
+ /**
623
+ * Execute a command and return output (silent)
624
+ */
625
+ function execSilent(command) {
626
+ if (dryRun) return "";
627
+ try {
628
+ return execSync(command, {
629
+ encoding: "utf-8",
630
+ stdio: "pipe"
631
+ }).trim();
632
+ } catch {
633
+ return "";
634
+ }
635
+ }
636
+ /**
637
+ * Check if a command exists
638
+ */
639
+ function commandExists(command) {
640
+ try {
641
+ execSync(`which ${command}`, { stdio: "pipe" });
642
+ return true;
643
+ } catch {
644
+ return false;
645
+ }
646
+ }
647
+ /**
648
+ * Check if running as root
649
+ */
650
+ function isRoot() {
651
+ return process.getuid?.() === 0;
652
+ }
653
+ /**
654
+ * Write a file (or show in dry-run mode)
655
+ */
656
+ function writeFile(path, content) {
657
+ if (dryRun) {
658
+ console.log(` \x1b[90mWrite ${path}:\x1b[0m`);
659
+ const lines = content.split("\n").slice(0, 5);
660
+ for (const line of lines) console.log(` \x1b[90m ${line}\x1b[0m`);
661
+ if (content.split("\n").length > 5) console.log(` \x1b[90m ...\x1b[0m`);
662
+ return;
663
+ }
664
+ writeFileSync(path, content);
665
+ }
666
+ //#endregion
667
+ //#region src/setup/io.ts
668
+ /**
669
+ * The real implementation, used when no io is injected.
670
+ *
671
+ * `writeFile` comes from steps.js rather than `node:fs` so writes are
672
+ * skipped in dry-run mode. Reads stay direct: they change nothing.
673
+ */
674
+ var nodeIo = {
675
+ exec,
676
+ execSilent,
677
+ commandExists,
678
+ writeFile,
679
+ fileExists: existsSync,
680
+ readFile: (path) => readFileSync(path, "utf-8"),
681
+ env: process.env
682
+ };
683
+ //#endregion
684
+ //#region src/setup/ddev.ts
685
+ /**
686
+ * Install system dependencies required by Trafic
687
+ */
688
+ function installSystemDeps(io = nodeIo) {
689
+ step("Install system dependencies");
690
+ io.exec("apt-get update -qq", { silent: true });
691
+ io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y jq curl rsync gnupg ca-certificates", { silent: true });
692
+ success("System dependencies installed: jq, curl, rsync, gnupg, ca-certificates");
693
+ }
694
+ /**
695
+ * Add the official DDEV apt repository and GPG key.
696
+ * Idempotent: safe to call even if the repository is already configured.
697
+ */
698
+ function addDdevAptRepo(io = nodeIo) {
699
+ io.exec("install -m 0755 -d /etc/apt/keyrings", { silent: true });
700
+ io.exec("curl -fsSL https://pkg.ddev.com/apt/gpg.key | gpg --dearmor | tee /etc/apt/keyrings/ddev.gpg > /dev/null", { silent: true });
701
+ io.exec("chmod a+r /etc/apt/keyrings/ddev.gpg", { silent: true });
702
+ io.exec("echo \"deb [signed-by=/etc/apt/keyrings/ddev.gpg] https://pkg.ddev.com/apt/ * *\" | tee /etc/apt/sources.list.d/ddev.list > /dev/null", { silent: true });
703
+ }
704
+ /**
705
+ * Install DDEV
706
+ */
707
+ function installDdev(io = nodeIo) {
708
+ step("Install DDEV");
709
+ if (io.commandExists("ddev")) {
710
+ info(`DDEV already installed: ${io.exec("ddev --version 2>/dev/null | head -1", { silent: true })?.trim() ?? "unknown version"}`);
711
+ return;
712
+ }
713
+ info("Adding DDEV apt repository...");
714
+ addDdevAptRepo(io);
715
+ info("Installing DDEV...");
716
+ io.exec("apt-get update -qq", { silent: true });
717
+ io.exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y ddev", { silent: true });
718
+ io.exec("HOME=/home/ddev mkcert -install", { silent: true });
719
+ io.exec("chown -R ddev:ddev /home/ddev/.local/share/mkcert", { silent: true });
720
+ success(`DDEV installed: ${io.exec("ddev --version 2>/dev/null | head -1", { silent: true })?.trim() ?? "unknown version"}`);
721
+ }
722
+ /**
723
+ * Configure DDEV global settings for production
724
+ *
725
+ * Note on DNS and /etc/hosts:
726
+ * When using a custom TLD (not ddev.site), DDEV may try to edit /etc/hosts
727
+ * if it can't resolve project hostnames via DNS. To avoid this:
728
+ * 1. Configure DNS so *.{tld} resolves to the server's IP (recommended)
729
+ * 2. Or install dnsmasq for local wildcard DNS resolution
730
+ *
731
+ * With proper DNS, DDEV won't need sudo for hostname management.
732
+ */
733
+ function configureDdev(tld, email, io = nodeIo) {
734
+ step("Configure DDEV");
735
+ const ddevCmd = (cmd) => `su - ddev -c '${cmd}'`;
736
+ io.exec(ddevCmd(`ddev config global --project-tld=${tld} --router-http-port=80 --router-https-port=443 --router-bind-all-interfaces=true --use-letsencrypt=${email ? "true" : "false"} ${email ? `--letsencrypt-email=${email}` : ""} --instrumentation-opt-in=false`), { silent: true });
737
+ success(`DDEV configured with TLD: ${tld}`);
738
+ info(`Ensure DNS is configured: *.${tld} → server IP`);
739
+ if (email) success(`Let's Encrypt enabled with email: ${email}`);
740
+ success("DDEV global settings configured");
741
+ }
742
+ /**
743
+ * Get the Docker gateway IP reachable from inside DDEV containers on Linux.
744
+ * DDEV runs containers on the `ddev_default` network — we inspect that first.
745
+ * Falls back to the default `bridge` network, then to 172.17.0.1.
746
+ * `host.docker.internal` is not available on Linux without extra configuration.
747
+ */
748
+ function getDockerGatewayIp(io = nodeIo) {
749
+ const ddevIp = io.execSilent("docker network inspect ddev_default --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'");
750
+ if (ddevIp) return ddevIp;
751
+ return io.execSilent("docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'") || "172.17.0.1";
752
+ }
753
+ /** DDEV tool ports, used when the global config does not say otherwise */
754
+ var DEFAULT_TOOL_PORTS = {
755
+ "mailpit-http-port": "8025",
756
+ "mailpit-https-port": "8026",
757
+ "xhgui-http-port": "8143",
758
+ "xhgui-https-port": "8142"
759
+ };
760
+ /**
761
+ * Read the ports DDEV publishes its tools on.
762
+ *
763
+ * ddev-router publishes these on 0.0.0.0, and Docker bypasses UFW for
764
+ * published ports, so a firewall rule cannot protect them — the forward auth
765
+ * middleware is the only thing that can. Read them rather than hardcode: they
766
+ * are configurable, and an entry point Traefik does not know about is ignored.
767
+ */
768
+ function readToolPorts(io = nodeIo) {
769
+ const output = io.execSilent("su - ddev -c 'ddev config global'");
770
+ const ports = [];
771
+ for (const [key, fallback] of Object.entries(DEFAULT_TOOL_PORTS)) {
772
+ const match = new RegExp(`^${key}=(\\d+)$`, "m").exec(output);
773
+ ports.push(match?.[1] ?? fallback);
774
+ }
775
+ return [...new Set(ports)];
776
+ }
777
+ /**
778
+ * Build the static config attaching forward auth to every entry point.
779
+ *
780
+ * The error page middleware stays on the web entry points only: a 502 from a
781
+ * stopped project should show the waiting page, while a tool port has nothing
782
+ * to wait for.
783
+ */
784
+ function buildStaticConfig(toolPorts) {
785
+ const webEntryPoint = (name) => ` ${name}:
786
+ http:
787
+ middlewares:
788
+ - trafic-auth@file
789
+ - trafic-errors@file
790
+ `;
791
+ const toolEntryPoint = (port) => ` http-${port}:
792
+ http:
793
+ middlewares:
794
+ - trafic-auth@file
795
+ `;
796
+ return `entryPoints:
797
+ ${webEntryPoint("http-80")}${webEntryPoint("http-443")}${toolPorts.map(toolEntryPoint).join("")}`;
798
+ }
799
+ /**
800
+ * Configure Traefik for forward auth
801
+ */
802
+ function configureTraefik(io = nodeIo) {
803
+ step("Configure Traefik for forward auth");
804
+ io.exec("mkdir -p /home/ddev/.ddev/traefik/custom-global-config", { silent: true });
805
+ io.exec("chown -R ddev:ddev /home/ddev/.ddev", { silent: true });
806
+ const gatewayIp = getDockerGatewayIp(io);
807
+ const dynamicConfig = `# Trafic: Forward auth middleware definition
808
+ http:
809
+ middlewares:
810
+ trafic-auth:
811
+ forwardAuth:
812
+ address: "http://${gatewayIp}:9876/__auth__"
813
+ authResponseHeaders:
814
+ - "X-Trafic-Project"
815
+
816
+ trafic-errors:
817
+ errors:
818
+ status:
819
+ - "502"
820
+ - "503"
821
+ service: trafic-service
822
+ query: "/"
823
+
824
+ routers:
825
+ # Lowest priority, so a DDEV project router always wins. It only matches
826
+ # when none does - which is what a stopped project produces, because DDEV
827
+ # removes that project's router and Traefik would otherwise answer 404.
828
+ # Without this the waiting page never appears and scale-to-zero never
829
+ # restarts anything. Auth is unaffected: it is attached at the entry
830
+ # point, not here, so this cannot reintroduce the bypass that #27 fixed.
831
+ #
832
+ # Two of them, because a router carrying tls only matches HTTPS entry
833
+ # points: one alone would leave plain HTTP answering 404.
834
+ trafic-catchall:
835
+ rule: "PathPrefix(\`/\`)"
836
+ priority: 1
837
+ service: trafic-service
838
+
839
+ trafic-catchall-tls:
840
+ rule: "PathPrefix(\`/\`)"
841
+ priority: 1
842
+ service: trafic-service
843
+ tls: {}
844
+
845
+ services:
846
+ trafic-service:
847
+ loadBalancer:
848
+ servers:
849
+ - url: "http://${gatewayIp}:9876"
850
+ `;
851
+ io.writeFile("/home/ddev/.ddev/traefik/trafic.yaml", dynamicConfig);
852
+ io.exec("chown ddev:ddev /home/ddev/.ddev/traefik/trafic.yaml", { silent: true });
853
+ io.writeFile("/home/ddev/.ddev/traefik/custom-global-config/trafic.yaml", dynamicConfig);
854
+ io.exec("chown ddev:ddev /home/ddev/.ddev/traefik/custom-global-config/trafic.yaml", { silent: true });
855
+ const staticConfig = buildStaticConfig(readToolPorts(io));
856
+ io.writeFile("/home/ddev/.ddev/traefik/static_config.trafic.yaml", staticConfig);
857
+ io.exec("chown ddev:ddev /home/ddev/.ddev/traefik/static_config.trafic.yaml", { silent: true });
858
+ success("Traefik configured with Trafic middleware");
859
+ }
860
+ //#endregion
861
+ //#region src/utils/traefik.ts
862
+ /**
863
+ * Files DDEV reads the dynamic Traefik configuration from, relative to the
864
+ * .ddev directory. Both are written by setup: DDEV 1.25+ picks up
865
+ * custom-global-config, older versions watch the traefik root.
866
+ */
867
+ var DYNAMIC_CONFIGS = ["traefik/trafic.yaml", "traefik/custom-global-config/trafic.yaml"];
868
+ /** Matches the host in an agent URL, e.g. http://172.17.0.1:9876/__auth__ */
869
+ var AGENT_URL = /http:\/\/(\d+\.\d+\.\d+\.\d+):(\d+)/g;
870
+ /**
871
+ * The address the forward-auth middleware currently points at.
872
+ */
873
+ function readForwardAuthAddress(content) {
874
+ return /http:\/\/(\d+\.\d+\.\d+\.\d+):\d+/.exec(content)?.[1];
875
+ }
876
+ /**
877
+ * Point every agent URL in a dynamic config at a different host, keeping
878
+ * each port as it was.
879
+ */
880
+ function rewriteForwardAuthAddress(content, gatewayIp) {
881
+ return content.replace(AGENT_URL, `http://${gatewayIp}:$2`);
882
+ }
883
+ /**
884
+ * Correct the forward-auth address if the Docker gateway has moved.
885
+ *
886
+ * setup runs `configureTraefik` before any DDEV project exists, so the
887
+ * `ddev_default` network is absent and the address falls back to the default
888
+ * bridge. Once a project starts, that address is stale. Migration 0006 exists
889
+ * to fix it on servers provisioned earlier, but `markAllMigrationsApplied()`
890
+ * marks it applied on fresh ones, so nothing ever recomputed it.
891
+ *
892
+ * Traefik watches the dynamic config, so rewriting the file is enough — no
893
+ * restart. Any failure is logged and ignored: a stale address is survivable,
894
+ * an agent that will not start is not.
895
+ */
896
+ function syncForwardAuthAddress(projectListPath, io = nodeIo) {
897
+ try {
898
+ const ddevDir = dirname(projectListPath);
899
+ const gatewayIp = getDockerGatewayIp(io);
900
+ const changed = [];
901
+ for (const relative of DYNAMIC_CONFIGS) {
902
+ const path = join(ddevDir, relative);
903
+ if (!io.fileExists(path)) continue;
904
+ const content = io.readFile(path);
905
+ const current = readForwardAuthAddress(content);
906
+ if (!current || current === gatewayIp) continue;
907
+ io.writeFile(path, rewriteForwardAuthAddress(content, gatewayIp));
908
+ changed.push(path);
909
+ }
910
+ if (changed.length > 0) console.log(`Traefik forward auth: corrected address to ${gatewayIp} in ${changed.length} file(s)`);
911
+ } catch (error) {
912
+ console.warn(`Could not check the Traefik forward auth address: ${error instanceof Error ? error.message : error}`);
913
+ }
914
+ }
915
+ //#endregion
539
916
  //#region src/server.ts
540
917
  var __dirname = dirname(fileURLToPath(import.meta.url));
541
918
  var config;
@@ -636,6 +1013,31 @@ async function handleErrors(req, res) {
636
1013
  }, 100);
637
1014
  }
638
1015
  /**
1016
+ * Decide whether Caddy may obtain a certificate for a hostname.
1017
+ *
1018
+ * Caddy's on-demand TLS asks before issuing for a name it has not seen.
1019
+ * Without a check, anyone pointing a DNS record at this server would make it
1020
+ * request a certificate, and Let's Encrypt quota is per registered domain —
1021
+ * a handful of strangers could exhaust it for every preview.
1022
+ */
1023
+ function allowTlsFor(domain, index) {
1024
+ return index.has(domain.toLowerCase());
1025
+ }
1026
+ /**
1027
+ * Handle Caddy's on-demand TLS ask. 2xx allows issuance, anything else denies.
1028
+ */
1029
+ function handleTlsAsk(req, res) {
1030
+ const domain = new URL(req.url ?? "/", `http://${req.headers.host}`).searchParams.get("domain") ?? "";
1031
+ if (allowTlsFor(domain, hostnameIndex)) {
1032
+ res.writeHead(200);
1033
+ res.end("ok");
1034
+ return;
1035
+ }
1036
+ console.log(`TLS ask denied for unknown hostname: ${domain || "(none)"}`);
1037
+ res.writeHead(404, { "Content-Type": "text/plain" });
1038
+ res.end("Unknown project");
1039
+ }
1040
+ /**
639
1041
  * Handle status polling requests
640
1042
  */
641
1043
  function handleStatus(req, res) {
@@ -662,6 +1064,7 @@ async function handleRequest(req, res) {
662
1064
  try {
663
1065
  if (url === "/__auth__" || url.startsWith("/__auth__/")) handleAuth(req, res);
664
1066
  else if (url === "/__status__" || url.startsWith("/__status__/")) handleStatus(req, res);
1067
+ else if (url === "/__tls__" || url.startsWith("/__tls__?")) handleTlsAsk(req, res);
665
1068
  else if (url === "/__health__") {
666
1069
  res.writeHead(200, { "Content-Type": "application/json" });
667
1070
  res.end(JSON.stringify({
@@ -697,6 +1100,7 @@ function reloadProjects() {
697
1100
  function startServer(agentConfig) {
698
1101
  config = agentConfig;
699
1102
  initDb(config.dbPath);
1103
+ syncForwardAuthAddress(config.projectListPath);
700
1104
  reloadProjects();
701
1105
  watchProjectList(config.projectListPath, () => {
702
1106
  console.log("Project list changed, reloading...");
@@ -717,6 +1121,6 @@ function startServer(agentConfig) {
717
1121
  });
718
1122
  }
719
1123
  //#endregion
720
- export { checkAuth as C, parseBearerToken as D, parseBasicAuth as E, watchProjectList as S, matchIp as T, getProjectInfo as _, cleanOldLogs as a, startProject as b, getDb as c, initDb as d, logAccess as f, getProjectFromHostname as g, buildHostnameIndex as h, shouldNeverStop as i, getIdleProjects as l, updateProjectAccess as m, getIdleTimeoutMs as n, closeDb as o, setProjectStatus as p, loadProjectConfig as r, getAccessLogs as s, startServer as t, getProject as u, listProjects as v, matchHostname as w, stopProject as x, loadProjectList as y };
1124
+ export { logAccess as A, watchProjectList as B, cleanOldLogs as C, getIdleProjects as D, getDb as E, getProjectInfo as F, parseBearerToken as G, matchHostname as H, listProjects as I, loadProjectList as L, updateProjectAccess as M, buildHostnameIndex as N, getProject as O, getProjectFromHostname as P, startProject as R, shouldNeverStop as S, getAccessLogs as T, matchIp as U, checkAuth as V, parseBasicAuth as W, step as _, configureTraefik as a, getIdleTimeoutMs as b, installSystemDeps as c, exec as d, execSilent as f, setDryRun as g, resetSteps as h, configureDdev as i, setProjectStatus as j, initDb as k, nodeIo as l, isRoot as m, startServer as n, getDockerGatewayIp as o, info as p, addDdevAptRepo as r, installDdev as s, allowTlsFor as t, error as u, success as v, closeDb as w, loadProjectConfig as x, warn as y, stopProject as z };
721
1125
 
722
- //# sourceMappingURL=server-zVWzPuj2.js.map
1126
+ //# sourceMappingURL=server-BgOOBUhR.js.map