@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.
- package/README.md +129 -20
- package/dist/cli.js +49 -4
- package/dist/cli.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/{server-BAjHxgqh.js → server-EPU5dtPx.js} +355 -21
- package/dist/server-EPU5dtPx.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/setup/agent.d.ts +2 -0
- package/dist/setup/agent.d.ts.map +1 -1
- package/dist/setup/audit.d.ts +39 -1
- package/dist/setup/audit.d.ts.map +1 -1
- package/dist/setup/ddev.d.ts +102 -3
- package/dist/setup/ddev.d.ts.map +1 -1
- package/dist/setup/index.d.ts.map +1 -1
- package/dist/setup/migrations/0015__catchall_priority.d.ts +40 -0
- package/dist/setup/migrations/0015__catchall_priority.d.ts.map +1 -0
- package/dist/setup/migrations/0016__wildcard_dns_challenge.d.ts +63 -0
- package/dist/setup/migrations/0016__wildcard_dns_challenge.d.ts.map +1 -0
- package/dist/setup/migrations/index.d.ts.map +1 -1
- package/dist/setup/types.d.ts +11 -0
- package/dist/setup/types.d.ts.map +1 -1
- package/dist/{setup-BrMbM8w4.js → setup-B02lDDZU.js} +294 -170
- package/dist/setup-B02lDDZU.js.map +1 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/tls.d.ts +10 -0
- package/dist/utils/tls.d.ts.map +1 -0
- package/package.json +1 -1
- package/templates/wait.html +1 -0
- package/dist/server-BAjHxgqh.js.map +0 -1
- package/dist/setup-BrMbM8w4.js.map +0 -1
|
@@ -1,120 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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$
|
|
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$
|
|
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
|
|
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
|
|
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,
|
|
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-
|
|
1729
|
+
//# sourceMappingURL=setup-B02lDDZU.js.map
|