@studiometa/trafic-agent 0.1.8 → 0.1.10

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.
@@ -0,0 +1,933 @@
1
+ import { _ as getProjectInfo, i as shouldNeverStop, l as getIdleProjects, n as getIdleTimeoutMs, p as setProjectStatus, r as loadProjectConfig, x as stopProject, y as loadProjectList } from "./server-D0N_6y4c.js";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { parse } from "smol-toml";
4
+ import { execSync } from "node:child_process";
5
+ var DEFAULT_CONFIG_PATHS = [
6
+ "/etc/trafic/config.toml",
7
+ "./config.toml",
8
+ "./trafic.toml"
9
+ ];
10
+ var DEFAULT_CONFIG = {
11
+ tld: "",
12
+ port: 9876,
13
+ dbPath: "/var/lib/trafic/db.sqlite",
14
+ projectListPath: "/home/ddev/.ddev/project_list.yaml",
15
+ projectsDir: "/home/ddev/www",
16
+ idleTimeout: "30m",
17
+ idleCheckInterval: "5m",
18
+ auth: {
19
+ defaultPolicy: "basic",
20
+ allowedIps: [],
21
+ tokens: [],
22
+ basicAuth: [],
23
+ rules: []
24
+ }
25
+ };
26
+ /**
27
+ * Parse a duration string (e.g., "30m", "1h", "2h30m") to milliseconds
28
+ */
29
+ function parseDuration(duration) {
30
+ const match = duration.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/);
31
+ if (!match) return 0;
32
+ const hours = parseInt(match[1] ?? "0", 10);
33
+ const minutes = parseInt(match[2] ?? "0", 10);
34
+ const seconds = parseInt(match[3] ?? "0", 10);
35
+ return (hours * 3600 + minutes * 60 + seconds) * 1e3;
36
+ }
37
+ /**
38
+ * Load configuration from TOML file
39
+ */
40
+ function loadConfig(configPath) {
41
+ let path = configPath;
42
+ if (!path) path = DEFAULT_CONFIG_PATHS.find((p) => existsSync(p));
43
+ if (!path || !existsSync(path)) {
44
+ console.warn("No config file found, using defaults");
45
+ return DEFAULT_CONFIG;
46
+ }
47
+ const parsed = parse(readFileSync(path, "utf-8"));
48
+ const auth = mergeAuthConfig(parsed.auth);
49
+ return {
50
+ tld: parsed.tld ?? DEFAULT_CONFIG.tld,
51
+ port: parsed.port ?? DEFAULT_CONFIG.port,
52
+ dbPath: parsed.db_path ?? DEFAULT_CONFIG.dbPath,
53
+ projectListPath: parsed.project_list_path ?? DEFAULT_CONFIG.projectListPath,
54
+ projectsDir: parsed.projects_dir ?? DEFAULT_CONFIG.projectsDir,
55
+ idleTimeout: parsed.idle_timeout ?? DEFAULT_CONFIG.idleTimeout,
56
+ idleCheckInterval: parsed.idle_check_interval ?? DEFAULT_CONFIG.idleCheckInterval,
57
+ auth
58
+ };
59
+ }
60
+ function mergeAuthConfig(raw) {
61
+ if (!raw) return DEFAULT_CONFIG.auth;
62
+ const rules = raw.rules ?? [];
63
+ return {
64
+ defaultPolicy: raw.default_policy ?? DEFAULT_CONFIG.auth.defaultPolicy,
65
+ allowedIps: raw.allowed_ips ?? DEFAULT_CONFIG.auth.allowedIps,
66
+ tokens: raw.tokens ?? DEFAULT_CONFIG.auth.tokens,
67
+ basicAuth: raw.basic_auth ?? DEFAULT_CONFIG.auth.basicAuth,
68
+ rules: rules.map((r) => ({
69
+ match: r.match,
70
+ policy: r.policy,
71
+ tokens: r.tokens,
72
+ allowedIps: r.allowed_ips
73
+ }))
74
+ };
75
+ }
76
+ /**
77
+ * Validate configuration
78
+ */
79
+ function validateConfig(config) {
80
+ const errors = [];
81
+ if (!config.tld) errors.push("tld is required");
82
+ if (config.port < 1 || config.port > 65535) errors.push("port must be between 1 and 65535");
83
+ 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\"");
84
+ return errors;
85
+ }
86
+ /**
87
+ * Stop idle projects that haven't been accessed recently
88
+ * Respects per-project idle_timeout settings from .ddev/config.trafic.yaml
89
+ */
90
+ function stopIdleProjects(config) {
91
+ const globalThresholdMs = parseDuration(config.idleTimeout);
92
+ const projects = loadProjectList(config.projectListPath);
93
+ const idleProjects = getIdleProjects(globalThresholdMs);
94
+ for (const project of idleProjects) {
95
+ const projectDir = projects.get(project.name);
96
+ if (projectDir) {
97
+ const projectConfig = loadProjectConfig(projectDir);
98
+ if (shouldNeverStop(projectConfig)) continue;
99
+ const projectThresholdMs = getIdleTimeoutMs(projectConfig);
100
+ if (projectThresholdMs !== void 0) {
101
+ if (Date.now() - project.lastAccess < projectThresholdMs) continue;
102
+ }
103
+ }
104
+ if (getProjectInfo(project.name)?.status !== "running") {
105
+ setProjectStatus(project.name, "stopped");
106
+ continue;
107
+ }
108
+ console.log(`Stopping idle project: ${project.name}`);
109
+ if (stopProject(project.name)) {
110
+ setProjectStatus(project.name, "stopped");
111
+ console.log(`Stopped: ${project.name}`);
112
+ } else console.error(`Failed to stop: ${project.name}`);
113
+ }
114
+ }
115
+ /**
116
+ * Start the idle check scheduler
117
+ */
118
+ function startIdleScheduler(config) {
119
+ const intervalMs = parseDuration(config.idleCheckInterval);
120
+ console.log(`Idle scheduler started: checking every ${config.idleCheckInterval}, timeout ${config.idleTimeout}`);
121
+ stopIdleProjects(config);
122
+ return setInterval(() => {
123
+ stopIdleProjects(config);
124
+ }, intervalMs);
125
+ }
126
+ var stepNumber = 0;
127
+ var dryRun = false;
128
+ /**
129
+ * Set dry-run mode
130
+ */
131
+ function setDryRun(value) {
132
+ dryRun = value;
133
+ }
134
+ /**
135
+ * Reset step counter
136
+ */
137
+ function resetSteps() {
138
+ stepNumber = 0;
139
+ }
140
+ /**
141
+ * Log a step
142
+ */
143
+ function step(message) {
144
+ stepNumber++;
145
+ console.log(`\n\x1b[36m[${stepNumber}]\x1b[0m ${message}`);
146
+ }
147
+ /**
148
+ * Log success
149
+ */
150
+ function success(message) {
151
+ console.log(` \x1b[32m✓\x1b[0m ${message}`);
152
+ }
153
+ /**
154
+ * Log warning
155
+ */
156
+ function warn(message) {
157
+ console.log(` \x1b[33m⚠\x1b[0m ${message}`);
158
+ }
159
+ /**
160
+ * Log error
161
+ */
162
+ function error(message) {
163
+ console.log(` \x1b[31m✗\x1b[0m ${message}`);
164
+ }
165
+ /**
166
+ * Log info
167
+ */
168
+ function info(message) {
169
+ console.log(` ${message}`);
170
+ }
171
+ /**
172
+ * Execute a command (or show in dry-run mode)
173
+ */
174
+ function exec(command, options) {
175
+ if (dryRun) {
176
+ if (!options?.silent) console.log(` \x1b[90m$ ${command}\x1b[0m`);
177
+ return "";
178
+ }
179
+ try {
180
+ return execSync(command, {
181
+ encoding: "utf-8",
182
+ stdio: options?.silent ? "pipe" : "inherit"
183
+ });
184
+ } catch (err) {
185
+ const e = err;
186
+ throw new Error(`Command failed: ${command}\n${e.stderr ?? ""}`);
187
+ }
188
+ }
189
+ /**
190
+ * Execute a command and return output (silent)
191
+ */
192
+ function execSilent$1(command) {
193
+ if (dryRun) return "";
194
+ try {
195
+ return execSync(command, {
196
+ encoding: "utf-8",
197
+ stdio: "pipe"
198
+ }).trim();
199
+ } catch {
200
+ return "";
201
+ }
202
+ }
203
+ /**
204
+ * Check if a command exists
205
+ */
206
+ function commandExists(command) {
207
+ try {
208
+ execSync(`which ${command}`, { stdio: "pipe" });
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+ /**
215
+ * Check if running as root
216
+ */
217
+ function isRoot() {
218
+ return process.getuid?.() === 0;
219
+ }
220
+ /**
221
+ * Create the ddev user with proper permissions
222
+ */
223
+ function createDdevUser() {
224
+ step("Create ddev user");
225
+ if (execSilent$1("id -u ddev 2>/dev/null")) info("User 'ddev' already exists");
226
+ else {
227
+ exec("useradd -m -s /bin/bash ddev");
228
+ success("Created user 'ddev'");
229
+ }
230
+ exec("usermod -aG docker ddev 2>/dev/null || true", { silent: true });
231
+ exec("mkdir -p /home/ddev/www");
232
+ exec("chown ddev:ddev /home/ddev/www");
233
+ exec("chmod 750 /home/ddev/www");
234
+ success("Created /home/ddev/www");
235
+ exec("mkdir -p /home/ddev/.ssh");
236
+ exec("chmod 700 /home/ddev/.ssh");
237
+ exec("chown ddev:ddev /home/ddev/.ssh");
238
+ success("User ddev configured (no sudo needed with proper DNS)");
239
+ }
240
+ /**
241
+ * Install Docker using the official script
242
+ */
243
+ function installDocker() {
244
+ step("Install Docker");
245
+ if (commandExists("docker")) {
246
+ info(`Docker already installed: ${exec("docker --version", { silent: true })?.trim() ?? "unknown version"}`);
247
+ return;
248
+ }
249
+ info("Downloading and running Docker install script...");
250
+ exec("curl -fsSL https://get.docker.com -o /tmp/get-docker.sh");
251
+ exec("sh /tmp/get-docker.sh", { silent: true });
252
+ exec("rm /tmp/get-docker.sh");
253
+ exec("systemctl enable docker", { silent: true });
254
+ exec("systemctl start docker", { silent: true });
255
+ success("Docker installed and started");
256
+ exec("usermod -aG docker ddev", { silent: true });
257
+ success("Added ddev user to docker group");
258
+ }
259
+ /**
260
+ * Configure Docker for production use
261
+ */
262
+ function configureDocker() {
263
+ step("Configure Docker");
264
+ const daemonConfig = {
265
+ "log-driver": "json-file",
266
+ "log-opts": {
267
+ "max-size": "10m",
268
+ "max-file": "3"
269
+ },
270
+ "storage-driver": "overlay2",
271
+ "live-restore": true
272
+ };
273
+ exec("mkdir -p /etc/docker");
274
+ const configPath = "/etc/docker/daemon.json";
275
+ if (existsSync(configPath)) try {
276
+ const merged = {
277
+ ...JSON.parse(readFileSync(configPath, "utf-8")),
278
+ ...daemonConfig
279
+ };
280
+ writeFileSync(configPath, JSON.stringify(merged, null, 2));
281
+ info("Merged with existing Docker config");
282
+ } catch {
283
+ warn("Could not parse existing Docker config, skipping");
284
+ return;
285
+ }
286
+ else writeFileSync(configPath, JSON.stringify(daemonConfig, null, 2));
287
+ exec("systemctl reload docker || systemctl restart docker");
288
+ success("Docker configured with production settings");
289
+ }
290
+ /**
291
+ * Setup Docker system prune cron job
292
+ */
293
+ function setupDockerPrune() {
294
+ step("Setup Docker cleanup cron");
295
+ writeFileSync("/etc/cron.d/trafic-docker-prune", `# Trafic: Clean up Docker resources weekly
296
+ 0 3 * * 0 root docker system prune -af --volumes 2>&1 | logger -t docker-prune
297
+ `);
298
+ exec("chmod 644 /etc/cron.d/trafic-docker-prune");
299
+ success("Docker prune scheduled weekly at 3am on Sundays");
300
+ }
301
+ /**
302
+ * Install system dependencies required by Trafic
303
+ */
304
+ function installSystemDeps() {
305
+ step("Install system dependencies");
306
+ exec("apt-get update -qq", { silent: true });
307
+ exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y jq curl rsync", { silent: true });
308
+ success("System dependencies installed: jq, curl, rsync");
309
+ }
310
+ /**
311
+ * Install DDEV
312
+ */
313
+ function installDdev() {
314
+ step("Install DDEV");
315
+ if (commandExists("ddev")) {
316
+ info(`DDEV already installed: ${exec("ddev --version 2>/dev/null | head -1", { silent: true })?.trim() ?? "unknown version"}`);
317
+ return;
318
+ }
319
+ info("Adding DDEV apt repository...");
320
+ exec("install -m 0755 -d /etc/apt/keyrings", { silent: true });
321
+ exec("curl -fsSL https://pkg.ddev.com/apt/gpg.key | gpg --dearmor | tee /etc/apt/keyrings/ddev.gpg > /dev/null", { silent: true });
322
+ exec("chmod a+r /etc/apt/keyrings/ddev.gpg", { silent: true });
323
+ 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 });
324
+ info("Installing DDEV...");
325
+ exec("apt-get update -qq", { silent: true });
326
+ exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y ddev", { silent: true });
327
+ exec("mkcert -install", { silent: true });
328
+ success(`DDEV installed: ${exec("ddev --version 2>/dev/null | head -1", { silent: true })?.trim() ?? "unknown version"}`);
329
+ }
330
+ /**
331
+ * Configure DDEV global settings for production
332
+ *
333
+ * Note on DNS and /etc/hosts:
334
+ * When using a custom TLD (not ddev.site), DDEV may try to edit /etc/hosts
335
+ * if it can't resolve project hostnames via DNS. To avoid this:
336
+ * 1. Configure DNS so *.{tld} resolves to the server's IP (recommended)
337
+ * 2. Or install dnsmasq for local wildcard DNS resolution
338
+ *
339
+ * With proper DNS, DDEV won't need sudo for hostname management.
340
+ */
341
+ function configureDdev(tld, email) {
342
+ step("Configure DDEV");
343
+ const ddevCmd = (cmd) => `su - ddev -c '${cmd}'`;
344
+ exec(ddevCmd(`ddev config global --project-tld=${tld} --router-http-port=80 --router-https-port=443 --use-letsencrypt=${email ? "true" : "false"} ${email ? `--letsencrypt-email=${email}` : ""} --instrumentation-opt-in=false`), { silent: true });
345
+ success(`DDEV configured with TLD: ${tld}`);
346
+ info(`Ensure DNS is configured: *.${tld} → server IP`);
347
+ if (email) success(`Let's Encrypt enabled with email: ${email}`);
348
+ success("DDEV global settings configured");
349
+ }
350
+ /**
351
+ * Configure Traefik for forward auth
352
+ */
353
+ function configureTraefik() {
354
+ step("Configure Traefik for forward auth");
355
+ exec("mkdir -p /home/ddev/.ddev/traefik", { silent: true });
356
+ exec("chown -R ddev:ddev /home/ddev/.ddev", { silent: true });
357
+ writeFileSync("/home/ddev/.ddev/traefik/trafic.yaml", `# Trafic: Forward auth middleware
358
+ http:
359
+ middlewares:
360
+ trafic-auth:
361
+ forwardAuth:
362
+ address: "http://host.docker.internal:9876/__auth__"
363
+ authResponseHeaders:
364
+ - "X-Trafic-Project"
365
+
366
+ trafic-errors:
367
+ errors:
368
+ status:
369
+ - "502"
370
+ - "503"
371
+ service: trafic-service
372
+ query: "/"
373
+
374
+ services:
375
+ trafic-service:
376
+ loadBalancer:
377
+ servers:
378
+ - url: "http://host.docker.internal:9876"
379
+ `);
380
+ exec("chown ddev:ddev /home/ddev/.ddev/traefik/trafic.yaml", { silent: true });
381
+ writeFileSync("/home/ddev/.ddev/traefik/dynamic.yaml", `# Trafic: Default router configuration
382
+ # This file makes all DDEV projects use the Trafic auth middleware
383
+ http:
384
+ routers:
385
+ trafic-catch-all:
386
+ rule: "HostRegexp(\`.+\`)"
387
+ priority: 1
388
+ middlewares:
389
+ - trafic-auth
390
+ - trafic-errors
391
+ service: api@internal
392
+ `);
393
+ exec("chown ddev:ddev /home/ddev/.ddev/traefik/dynamic.yaml", { silent: true });
394
+ success("Traefik configured with Trafic middleware");
395
+ }
396
+ /**
397
+ * Install Node.js using fnm
398
+ */
399
+ function installNode() {
400
+ step("Install Node.js");
401
+ if (commandExists("node")) {
402
+ info(`Node.js already installed: ${exec("node --version", { silent: true })?.trim()}`);
403
+ return;
404
+ }
405
+ exec("curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir \"/opt/fnm\" --skip-shell");
406
+ writeFileSync("/etc/profile.d/fnm.sh", `# Trafic: fnm (Node.js version manager)
407
+ export FNM_DIR="/opt/fnm"
408
+ export PATH="/opt/fnm:$PATH"
409
+ eval "$(fnm env)"
410
+ `);
411
+ exec("chmod 644 /etc/profile.d/fnm.sh");
412
+ exec("export FNM_DIR=/opt/fnm && /opt/fnm/fnm install 24");
413
+ exec("export FNM_DIR=/opt/fnm && /opt/fnm/fnm default 24");
414
+ exec("ln -sf /opt/fnm/aliases/default/bin/node /usr/local/bin/node || true");
415
+ exec("ln -sf /opt/fnm/aliases/default/bin/npm /usr/local/bin/npm || true");
416
+ exec("ln -sf /opt/fnm/aliases/default/bin/npx /usr/local/bin/npx || true");
417
+ success("Node.js 24 installed via fnm");
418
+ }
419
+ /**
420
+ * Install the Trafic agent
421
+ */
422
+ function installAgent() {
423
+ step("Install Trafic agent");
424
+ exec("npm install -g @studiometa/trafic-agent");
425
+ success("Trafic agent installed");
426
+ }
427
+ /**
428
+ * Create the agent configuration
429
+ */
430
+ function createAgentConfig(tld, _email) {
431
+ step("Create agent configuration");
432
+ exec("mkdir -p /etc/trafic");
433
+ exec("mkdir -p /var/lib/trafic");
434
+ exec("chown ddev:ddev /var/lib/trafic");
435
+ const configPath = "/etc/trafic/config.toml";
436
+ if (existsSync(configPath)) {
437
+ info("Configuration already exists at /etc/trafic/config.toml — skipping");
438
+ info("Edit this file to configure authentication");
439
+ return;
440
+ }
441
+ writeFileSync(configPath, `# Trafic agent configuration
442
+ # Generated by trafic setup
443
+
444
+ # Required: TLD for DDEV projects
445
+ tld = "${tld}"
446
+
447
+ # Agent HTTP server port
448
+ port = 9876
449
+
450
+ # Database and paths
451
+ db_path = "/var/lib/trafic/db.sqlite"
452
+ project_list_path = "/home/ddev/.ddev/project_list.yaml"
453
+ projects_dir = "/home/ddev/www"
454
+
455
+ # Scale-to-zero settings
456
+ idle_timeout = "4h"
457
+ idle_check_interval = "5m"
458
+
459
+ # Authentication
460
+ [auth]
461
+ default_policy = "basic"
462
+ allowed_ips = []
463
+ tokens = []
464
+ basic_auth = []
465
+ rules = []
466
+ `);
467
+ exec("chmod 640 /etc/trafic/config.toml");
468
+ exec("chown root:ddev /etc/trafic/config.toml");
469
+ success("Configuration created at /etc/trafic/config.toml");
470
+ info("Edit this file to configure authentication");
471
+ }
472
+ /**
473
+ * Create systemd service for the agent
474
+ */
475
+ function createSystemdService() {
476
+ step("Create systemd service");
477
+ writeFileSync("/etc/systemd/system/trafic-agent.service", `[Unit]
478
+ Description=Trafic Agent - DDEV preview environment manager
479
+ After=network.target docker.service
480
+ Requires=docker.service
481
+
482
+ [Service]
483
+ Type=simple
484
+ User=ddev
485
+ Group=ddev
486
+ ExecStart=${exec("which trafic-agent", { silent: true })?.trim() || "/usr/bin/trafic-agent"} start --config /etc/trafic/config.toml
487
+ Restart=always
488
+ RestartSec=5
489
+ StandardOutput=journal
490
+ StandardError=journal
491
+ SyslogIdentifier=trafic-agent
492
+
493
+ # Environment
494
+ Environment=NODE_ENV=production
495
+ # Skip ddev-hostname /etc/hosts management — on a server, hostnames resolve
496
+ # to the public IP (not localhost), which would trigger ddev-hostname on every
497
+ # ddev start. DDEV_NONINTERACTIVE=true skips that entirely.
498
+ Environment=DDEV_NONINTERACTIVE=true
499
+
500
+ # Security hardening
501
+ NoNewPrivileges=true
502
+ ProtectSystem=strict
503
+ ProtectHome=read-only
504
+ ReadWritePaths=/var/lib/trafic /home/ddev/.ddev
505
+ PrivateTmp=true
506
+
507
+ [Install]
508
+ WantedBy=multi-user.target
509
+ `);
510
+ exec("chmod 644 /etc/systemd/system/trafic-agent.service");
511
+ exec("systemctl daemon-reload");
512
+ exec("systemctl enable trafic-agent");
513
+ exec("systemctl start trafic-agent");
514
+ success("Trafic agent service started");
515
+ info("View logs: journalctl -u trafic-agent -f");
516
+ }
517
+ /**
518
+ * Configure SSH hardening
519
+ */
520
+ function hardenSsh(allowedUsers = ["ddev"]) {
521
+ step("Harden SSH configuration");
522
+ writeFileSync("/etc/ssh/sshd_config.d/trafic.conf", `# Trafic SSH hardening
523
+ # Generated by trafic setup
524
+
525
+ # Allow root login via SSH key only (no password) — required for server management
526
+ PermitRootLogin prohibit-password
527
+
528
+ # Disable password authentication (keys only)
529
+ PasswordAuthentication no
530
+ PubkeyAuthentication yes
531
+
532
+ # Limit authentication attempts
533
+ MaxAuthTries 3
534
+
535
+ # Idle timeout (5 minutes)
536
+ ClientAliveInterval 300
537
+ ClientAliveCountMax 2
538
+
539
+ # Allowed users (root kept for emergency access)
540
+ AllowUsers root ${allowedUsers.join(" ")}
541
+
542
+ # Disable X11 forwarding
543
+ X11Forwarding no
544
+
545
+ # Disable TCP forwarding
546
+ AllowTcpForwarding no
547
+
548
+ # Use strong algorithms only
549
+ KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256
550
+ Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
551
+ MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
552
+ `);
553
+ exec("chmod 644 /etc/ssh/sshd_config.d/trafic.conf");
554
+ if (exec("sshd -t 2>&1 || echo 'error'", { silent: true })?.includes("error")) {
555
+ warn("SSH config test failed, reverting...");
556
+ exec("rm /etc/ssh/sshd_config.d/trafic.conf");
557
+ return;
558
+ }
559
+ exec("systemctl reload ssh || systemctl reload sshd");
560
+ success("SSH hardened: password auth disabled, root key-only login retained");
561
+ info(`Allowed users: root, ${allowedUsers.join(", ")}`);
562
+ }
563
+ /**
564
+ * Configure UFW firewall
565
+ */
566
+ function configureFirewall() {
567
+ step("Configure firewall (UFW)");
568
+ if (!commandExists("ufw")) exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y ufw", { silent: true });
569
+ exec("ufw --force reset", { silent: true });
570
+ exec("ufw default deny incoming");
571
+ exec("ufw default allow outgoing");
572
+ exec("ufw allow 22/tcp comment 'SSH'");
573
+ exec("ufw allow 80/tcp comment 'HTTP'");
574
+ exec("ufw allow 443/tcp comment 'HTTPS'");
575
+ exec("ufw --force enable");
576
+ success("Firewall enabled: SSH (22), HTTP (80), HTTPS (443) allowed");
577
+ }
578
+ /**
579
+ * Configure automatic security updates
580
+ */
581
+ function configureUnattendedUpgrades() {
582
+ step("Configure automatic security updates");
583
+ exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y unattended-upgrades apt-listchanges", { silent: true });
584
+ writeFileSync("/etc/apt/apt.conf.d/50unattended-upgrades", `// Trafic: Automatic security updates
585
+ Unattended-Upgrade::Allowed-Origins {
586
+ "\${distro_id}:\${distro_codename}";
587
+ "\${distro_id}:\${distro_codename}-security";
588
+ "\${distro_id}ESMApps:\${distro_codename}-apps-security";
589
+ "\${distro_id}ESM:\${distro_codename}-infra-security";
590
+ };
591
+
592
+ // Do not automatically reboot
593
+ Unattended-Upgrade::Automatic-Reboot "false";
594
+
595
+ // Remove unused dependencies
596
+ Unattended-Upgrade::Remove-Unused-Dependencies "true";
597
+
598
+ // Email notifications (optional)
599
+ // Unattended-Upgrade::Mail "root";
600
+ `);
601
+ writeFileSync("/etc/apt/apt.conf.d/20auto-upgrades", `APT::Periodic::Update-Package-Lists "1";
602
+ APT::Periodic::Download-Upgradeable-Packages "1";
603
+ APT::Periodic::AutocleanInterval "7";
604
+ APT::Periodic::Unattended-Upgrade "1";
605
+ `);
606
+ exec("systemctl enable unattended-upgrades");
607
+ exec("systemctl start unattended-upgrades");
608
+ success("Automatic security updates enabled");
609
+ }
610
+ /**
611
+ * Configure Fail2ban
612
+ */
613
+ function configureFail2ban() {
614
+ step("Configure Fail2ban");
615
+ if (!commandExists("fail2ban-client")) exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y fail2ban", { silent: true });
616
+ writeFileSync("/etc/fail2ban/jail.local", `# Trafic: Fail2ban configuration
617
+ [DEFAULT]
618
+ bantime = 3600
619
+ findtime = 600
620
+ maxretry = 5
621
+ backend = auto
622
+
623
+ [sshd]
624
+ enabled = true
625
+ port = ssh
626
+ filter = sshd
627
+ logpath = /var/log/auth.log
628
+ maxretry = 3
629
+ bantime = 7200
630
+ `);
631
+ exec("chmod 644 /etc/fail2ban/jail.local");
632
+ exec("systemctl enable fail2ban");
633
+ exec("systemctl restart fail2ban");
634
+ success("Fail2ban enabled: SSH brute force protection active");
635
+ }
636
+ /**
637
+ * Configure system limits
638
+ */
639
+ function configureSystemLimits() {
640
+ step("Configure system limits");
641
+ writeFileSync("/etc/security/limits.d/trafic.conf", `# Trafic: System limits for ddev user
642
+ ddev soft nofile 65536
643
+ ddev hard nofile 65536
644
+ ddev soft nproc 4096
645
+ ddev hard nproc 4096
646
+ `);
647
+ exec("chmod 644 /etc/security/limits.d/trafic.conf");
648
+ success("System limits configured for ddev user");
649
+ }
650
+ /**
651
+ * Set proper file permissions
652
+ */
653
+ function configureFilePermissions() {
654
+ step("Configure file permissions");
655
+ exec("mkdir -p /etc/trafic");
656
+ exec("mkdir -p /var/lib/trafic");
657
+ exec("mkdir -p /var/backups/trafic");
658
+ exec("chmod 755 /etc/trafic");
659
+ exec("chmod 700 /var/lib/trafic");
660
+ exec("chmod 700 /var/backups/trafic");
661
+ exec("chown root:ddev /etc/trafic");
662
+ exec("chown ddev:ddev /var/lib/trafic");
663
+ exec("chown ddev:ddev /var/backups/trafic");
664
+ if (existsSync("/etc/trafic/config.toml")) {
665
+ exec("chmod 640 /etc/trafic/config.toml");
666
+ exec("chown root:ddev /etc/trafic/config.toml");
667
+ }
668
+ success("File permissions configured");
669
+ }
670
+ /**
671
+ * Run all hardening steps
672
+ */
673
+ function hardenServer(sshUsers = ["ddev"]) {
674
+ hardenSsh(sshUsers);
675
+ configureFirewall();
676
+ configureUnattendedUpgrades();
677
+ configureFail2ban();
678
+ configureSystemLimits();
679
+ configureFilePermissions();
680
+ }
681
+ /**
682
+ * Run a command silently and return output
683
+ */
684
+ function execSilent(command) {
685
+ try {
686
+ return execSync(command, {
687
+ encoding: "utf-8",
688
+ stdio: "pipe"
689
+ }).trim();
690
+ } catch {
691
+ return "";
692
+ }
693
+ }
694
+ /**
695
+ * Check if a service is active
696
+ */
697
+ function isServiceActive(service) {
698
+ return execSilent(`systemctl is-active ${service}`) === "active";
699
+ }
700
+ /**
701
+ * Audit SSH configuration
702
+ */
703
+ function auditSsh() {
704
+ const checks = [];
705
+ const sshdConfig = existsSync("/etc/ssh/sshd_config") ? readFileSync("/etc/ssh/sshd_config", "utf-8") : "";
706
+ const traficConfig = existsSync("/etc/ssh/sshd_config.d/trafic.conf") ? readFileSync("/etc/ssh/sshd_config.d/trafic.conf", "utf-8") : "";
707
+ const config = sshdConfig + "\n" + traficConfig;
708
+ const rootLoginDisabled = config.includes("PermitRootLogin no") || config.includes("PermitRootLogin prohibit-password");
709
+ checks.push({
710
+ name: "SSH root login",
711
+ status: rootLoginDisabled ? "pass" : "fail",
712
+ message: rootLoginDisabled ? "Root login disabled" : "Root login may be enabled",
713
+ fix: "Add 'PermitRootLogin no' to /etc/ssh/sshd_config.d/trafic.conf"
714
+ });
715
+ const passwordAuthDisabled = config.includes("PasswordAuthentication no");
716
+ checks.push({
717
+ name: "SSH password auth",
718
+ status: passwordAuthDisabled ? "pass" : "warn",
719
+ message: passwordAuthDisabled ? "Password authentication disabled" : "Password authentication may be enabled",
720
+ fix: "Add 'PasswordAuthentication no' to /etc/ssh/sshd_config.d/trafic.conf"
721
+ });
722
+ return checks;
723
+ }
724
+ /**
725
+ * Audit firewall configuration
726
+ */
727
+ function auditFirewall() {
728
+ const checks = [];
729
+ const ufwStatus = execSilent("ufw status");
730
+ const isActive = ufwStatus.includes("Status: active");
731
+ checks.push({
732
+ name: "Firewall (UFW)",
733
+ status: isActive ? "pass" : "fail",
734
+ message: isActive ? "UFW is active" : "UFW is not active",
735
+ fix: "Run: ufw enable"
736
+ });
737
+ if (isActive) {
738
+ const allows22 = ufwStatus.includes("22/tcp");
739
+ const allows80 = ufwStatus.includes("80/tcp");
740
+ const allows443 = ufwStatus.includes("443/tcp");
741
+ if (allows22 && allows80 && allows443) checks.push({
742
+ name: "Firewall ports",
743
+ status: "pass",
744
+ message: "Required ports (22, 80, 443) are open"
745
+ });
746
+ else checks.push({
747
+ name: "Firewall ports",
748
+ status: "warn",
749
+ message: "Some required ports may be blocked",
750
+ fix: "Run: ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp"
751
+ });
752
+ }
753
+ return checks;
754
+ }
755
+ /**
756
+ * Audit security services
757
+ */
758
+ function auditServices() {
759
+ const checks = [];
760
+ const fail2banActive = isServiceActive("fail2ban");
761
+ checks.push({
762
+ name: "Fail2ban",
763
+ status: fail2banActive ? "pass" : "warn",
764
+ message: fail2banActive ? "Fail2ban is active" : "Fail2ban is not running",
765
+ fix: "Run: apt install fail2ban && systemctl enable fail2ban --now"
766
+ });
767
+ const unattendedActive = isServiceActive("unattended-upgrades");
768
+ checks.push({
769
+ name: "Automatic updates",
770
+ status: unattendedActive ? "pass" : "warn",
771
+ message: unattendedActive ? "Unattended upgrades enabled" : "Automatic updates not configured",
772
+ fix: "Run: apt install unattended-upgrades && dpkg-reconfigure unattended-upgrades"
773
+ });
774
+ const agentActive = isServiceActive("trafic-agent");
775
+ checks.push({
776
+ name: "Trafic agent",
777
+ status: agentActive ? "pass" : "fail",
778
+ message: agentActive ? "Trafic agent is running" : "Trafic agent is not running",
779
+ fix: "Run: systemctl start trafic-agent"
780
+ });
781
+ return checks;
782
+ }
783
+ /**
784
+ * Audit Docker
785
+ */
786
+ function auditDocker() {
787
+ const checks = [];
788
+ const dockerActive = isServiceActive("docker");
789
+ checks.push({
790
+ name: "Docker",
791
+ status: dockerActive ? "pass" : "fail",
792
+ message: dockerActive ? "Docker is running" : "Docker is not running",
793
+ fix: "Run: systemctl start docker"
794
+ });
795
+ const danglingImages = execSilent("docker images -f dangling=true -q | wc -l");
796
+ const count = parseInt(danglingImages, 10) || 0;
797
+ if (count > 0) checks.push({
798
+ name: "Docker cleanup",
799
+ status: "warn",
800
+ message: `${count} dangling Docker images`,
801
+ fix: "Run: docker system prune -f"
802
+ });
803
+ const diskUsage = execSilent("df -h / | awk 'NR==2{print $5}'");
804
+ const usagePercent = parseInt(diskUsage, 10) || 0;
805
+ checks.push({
806
+ name: "Disk usage",
807
+ status: usagePercent > 90 ? "fail" : usagePercent > 80 ? "warn" : "pass",
808
+ message: `Disk usage: ${diskUsage}`,
809
+ fix: usagePercent > 80 ? "Consider cleaning up old projects or Docker resources" : void 0
810
+ });
811
+ return checks;
812
+ }
813
+ /**
814
+ * Audit file permissions
815
+ */
816
+ function auditPermissions() {
817
+ const checks = [];
818
+ if (existsSync("/etc/trafic/config.toml")) {
819
+ const stats = execSilent("stat -c '%a' /etc/trafic/config.toml");
820
+ const isSecure = stats === "640" || stats === "600";
821
+ checks.push({
822
+ name: "Config permissions",
823
+ status: isSecure ? "pass" : "warn",
824
+ message: isSecure ? "Config file has secure permissions" : `Config file permissions: ${stats} (should be 640)`,
825
+ fix: "Run: chmod 640 /etc/trafic/config.toml"
826
+ });
827
+ }
828
+ if (existsSync("/home/ddev/www")) {
829
+ const owner = execSilent("stat -c '%U:%G' /home/ddev/www");
830
+ const isCorrect = owner === "ddev:ddev";
831
+ checks.push({
832
+ name: "Projects directory",
833
+ status: isCorrect ? "pass" : "warn",
834
+ message: isCorrect ? "Projects directory owned by ddev" : `Projects directory owner: ${owner}`,
835
+ fix: "Run: chown ddev:ddev /home/ddev/www"
836
+ });
837
+ }
838
+ return checks;
839
+ }
840
+ /**
841
+ * Run all audit checks
842
+ */
843
+ function runAudit() {
844
+ return [
845
+ ...auditSsh(),
846
+ ...auditFirewall(),
847
+ ...auditServices(),
848
+ ...auditDocker(),
849
+ ...auditPermissions()
850
+ ];
851
+ }
852
+ /**
853
+ * Print audit results
854
+ */
855
+ function printAuditResults(checks) {
856
+ console.log("\n\x1B[1mSecurity Audit Results\x1B[0m\n");
857
+ for (const check of checks) {
858
+ const icon = check.status === "pass" ? "\x1B[32m✓\x1B[0m" : check.status === "warn" ? "\x1B[33m⚠\x1B[0m" : "\x1B[31m✗\x1B[0m";
859
+ console.log(`${icon} \x1b[1m${check.name}\x1b[0m: ${check.message}`);
860
+ if (check.fix && check.status !== "pass") console.log(` \x1b[90m${check.fix}\x1b[0m`);
861
+ }
862
+ const passed = checks.filter((c) => c.status === "pass").length;
863
+ const warnings = checks.filter((c) => c.status === "warn").length;
864
+ const failed = checks.filter((c) => c.status === "fail").length;
865
+ console.log(`\n\x1b[1mSummary:\x1b[0m ${passed} passed, ${warnings} warnings, ${failed} failed`);
866
+ }
867
+ /**
868
+ * Run the full server setup
869
+ */
870
+ async function setup(options) {
871
+ resetSteps();
872
+ setDryRun(options.dryRun ?? false);
873
+ console.log("\n\x1B[1m🚦 Trafic Server Setup\x1B[0m\n");
874
+ if (options.dryRun) console.log("\x1B[33m⚠ Dry-run mode: no changes will be made\x1B[0m\n");
875
+ if (!isRoot() && !options.dryRun) {
876
+ error("This command must be run as root");
877
+ console.log(" Run: sudo trafic-agent setup ...");
878
+ process.exit(1);
879
+ }
880
+ info(`TLD: ${options.tld}`);
881
+ if (options.email) info(`Let's Encrypt email: ${options.email}`);
882
+ if (options.noHardening) info("Hardening: disabled");
883
+ try {
884
+ installSystemDeps();
885
+ createDdevUser();
886
+ if (!options.noDocker) {
887
+ installDocker();
888
+ configureDocker();
889
+ setupDockerPrune();
890
+ } else {
891
+ step("Install Docker");
892
+ info("Skipped (--no-docker)");
893
+ }
894
+ if (!options.noDdev) {
895
+ installDdev();
896
+ configureDdev(options.tld, options.email);
897
+ configureTraefik();
898
+ } else {
899
+ step("Install DDEV");
900
+ info("Skipped (--no-ddev)");
901
+ }
902
+ installNode();
903
+ installAgent();
904
+ createAgentConfig(options.tld, options.email);
905
+ createSystemdService();
906
+ if (!options.noHardening) hardenServer(options.sshUsers ?? ["ddev"]);
907
+ else {
908
+ step("Server hardening");
909
+ info("Skipped (--no-hardening)");
910
+ }
911
+ console.log("\n\x1B[32m✓ Setup complete!\x1B[0m\n");
912
+ console.log("Next steps:");
913
+ console.log(` 1. Edit /etc/trafic/config.toml to configure authentication`);
914
+ console.log(` 2. Add SSH public keys to /home/ddev/.ssh/authorized_keys`);
915
+ console.log(` 3. Deploy your first project:`);
916
+ console.log(` npx @studiometa/trafic-cli deploy --host=<this-server> --name=my-app`);
917
+ console.log(`\nYour projects will be available at https://<name>.${options.tld}`);
918
+ } catch (err) {
919
+ error(`Setup failed: ${err instanceof Error ? err.message : err}`);
920
+ process.exit(1);
921
+ }
922
+ }
923
+ /**
924
+ * Run security audit
925
+ */
926
+ async function audit() {
927
+ const checks = runAudit();
928
+ printAuditResults(checks);
929
+ if (checks.filter((c) => c.status === "fail").length > 0) process.exit(1);
930
+ }
931
+ export { startIdleScheduler as a, parseDuration as c, runAudit as i, validateConfig as l, setup as n, stopIdleProjects as o, printAuditResults as r, loadConfig as s, audit as t };
932
+
933
+ //# sourceMappingURL=setup-BfiuQiwa.js.map