@studiometa/trafic-agent 0.1.8 → 0.1.9

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,941 @@
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("Fetching latest DDEV version...");
320
+ const latestVersion = exec("curl -fsSL https://api.github.com/repos/ddev/ddev/releases/latest | grep '\"tag_name\"' | sed -E 's/.*\"([^\"]+)\".*/\\1/'", { silent: true })?.trim();
321
+ if (!latestVersion) throw new Error("Could not determine latest DDEV version");
322
+ info(`Installing DDEV ${latestVersion}...`);
323
+ const arch = exec("uname -m", { silent: true })?.trim() === "x86_64" ? "amd64" : "arm64";
324
+ const tarball = `ddev_linux-${arch}.${latestVersion}.tar.gz`;
325
+ exec(`curl -fsSL -o /tmp/${tarball} ${`https://github.com/ddev/ddev/releases/download/${latestVersion}/${tarball}`}`, { silent: true });
326
+ exec(`tar -xzf /tmp/${tarball} -C /tmp ddev`, { silent: true });
327
+ exec("mv /tmp/ddev /usr/local/bin/ddev", { silent: true });
328
+ exec("chmod +x /usr/local/bin/ddev", { silent: true });
329
+ exec(`rm -f /tmp/${tarball}`, { silent: true });
330
+ info("Installing mkcert...");
331
+ const mkcertVersion = exec("curl -fsSL https://api.github.com/repos/FiloSottile/mkcert/releases/latest | grep '\"tag_name\"' | sed -E 's/.*\"([^\"]+)\".*/\\1/'", { silent: true })?.trim();
332
+ if (mkcertVersion) {
333
+ exec(`curl -fsSL -o /usr/local/bin/mkcert https://github.com/FiloSottile/mkcert/releases/download/${mkcertVersion}/${`mkcert-${mkcertVersion}-linux-${arch}`}`, { silent: true });
334
+ exec("chmod +x /usr/local/bin/mkcert", { silent: true });
335
+ }
336
+ success(`DDEV ${latestVersion} installed`);
337
+ }
338
+ /**
339
+ * Configure DDEV global settings for production
340
+ *
341
+ * Note on DNS and /etc/hosts:
342
+ * When using a custom TLD (not ddev.site), DDEV may try to edit /etc/hosts
343
+ * if it can't resolve project hostnames via DNS. To avoid this:
344
+ * 1. Configure DNS so *.{tld} resolves to the server's IP (recommended)
345
+ * 2. Or install dnsmasq for local wildcard DNS resolution
346
+ *
347
+ * With proper DNS, DDEV won't need sudo for hostname management.
348
+ */
349
+ function configureDdev(tld, email) {
350
+ step("Configure DDEV");
351
+ const ddevCmd = (cmd) => `su - ddev -c '${cmd}'`;
352
+ 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 });
353
+ success(`DDEV configured with TLD: ${tld}`);
354
+ info(`Ensure DNS is configured: *.${tld} → server IP`);
355
+ if (email) success(`Let's Encrypt enabled with email: ${email}`);
356
+ success("DDEV global settings configured");
357
+ }
358
+ /**
359
+ * Configure Traefik for forward auth
360
+ */
361
+ function configureTraefik() {
362
+ step("Configure Traefik for forward auth");
363
+ exec("mkdir -p /home/ddev/.ddev/traefik", { silent: true });
364
+ exec("chown -R ddev:ddev /home/ddev/.ddev", { silent: true });
365
+ writeFileSync("/home/ddev/.ddev/traefik/trafic.yaml", `# Trafic: Forward auth middleware
366
+ http:
367
+ middlewares:
368
+ trafic-auth:
369
+ forwardAuth:
370
+ address: "http://host.docker.internal:9876/__auth__"
371
+ authResponseHeaders:
372
+ - "X-Trafic-Project"
373
+
374
+ trafic-errors:
375
+ errors:
376
+ status:
377
+ - "502"
378
+ - "503"
379
+ service: trafic-service
380
+ query: "/"
381
+
382
+ services:
383
+ trafic-service:
384
+ loadBalancer:
385
+ servers:
386
+ - url: "http://host.docker.internal:9876"
387
+ `);
388
+ exec("chown ddev:ddev /home/ddev/.ddev/traefik/trafic.yaml", { silent: true });
389
+ writeFileSync("/home/ddev/.ddev/traefik/dynamic.yaml", `# Trafic: Default router configuration
390
+ # This file makes all DDEV projects use the Trafic auth middleware
391
+ http:
392
+ routers:
393
+ trafic-catch-all:
394
+ rule: "HostRegexp(\`.+\`)"
395
+ priority: 1
396
+ middlewares:
397
+ - trafic-auth
398
+ - trafic-errors
399
+ service: api@internal
400
+ `);
401
+ exec("chown ddev:ddev /home/ddev/.ddev/traefik/dynamic.yaml", { silent: true });
402
+ success("Traefik configured with Trafic middleware");
403
+ }
404
+ /**
405
+ * Install Node.js using fnm
406
+ */
407
+ function installNode() {
408
+ step("Install Node.js");
409
+ if (commandExists("node")) {
410
+ info(`Node.js already installed: ${exec("node --version", { silent: true })?.trim()}`);
411
+ return;
412
+ }
413
+ exec("curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir \"/opt/fnm\" --skip-shell");
414
+ writeFileSync("/etc/profile.d/fnm.sh", `# Trafic: fnm (Node.js version manager)
415
+ export FNM_DIR="/opt/fnm"
416
+ export PATH="/opt/fnm:$PATH"
417
+ eval "$(fnm env)"
418
+ `);
419
+ exec("chmod 644 /etc/profile.d/fnm.sh");
420
+ exec("export FNM_DIR=/opt/fnm && /opt/fnm/fnm install 24");
421
+ exec("export FNM_DIR=/opt/fnm && /opt/fnm/fnm default 24");
422
+ exec("ln -sf /opt/fnm/aliases/default/bin/node /usr/local/bin/node || true");
423
+ exec("ln -sf /opt/fnm/aliases/default/bin/npm /usr/local/bin/npm || true");
424
+ exec("ln -sf /opt/fnm/aliases/default/bin/npx /usr/local/bin/npx || true");
425
+ success("Node.js 24 installed via fnm");
426
+ }
427
+ /**
428
+ * Install the Trafic agent
429
+ */
430
+ function installAgent() {
431
+ step("Install Trafic agent");
432
+ exec("npm install -g @studiometa/trafic-agent");
433
+ success("Trafic agent installed");
434
+ }
435
+ /**
436
+ * Create the agent configuration
437
+ */
438
+ function createAgentConfig(tld, _email) {
439
+ step("Create agent configuration");
440
+ exec("mkdir -p /etc/trafic");
441
+ exec("mkdir -p /var/lib/trafic");
442
+ exec("chown ddev:ddev /var/lib/trafic");
443
+ const configPath = "/etc/trafic/config.toml";
444
+ if (existsSync(configPath)) {
445
+ info("Configuration already exists at /etc/trafic/config.toml — skipping");
446
+ info("Edit this file to configure authentication");
447
+ return;
448
+ }
449
+ writeFileSync(configPath, `# Trafic agent configuration
450
+ # Generated by trafic setup
451
+
452
+ # Required: TLD for DDEV projects
453
+ tld = "${tld}"
454
+
455
+ # Agent HTTP server port
456
+ port = 9876
457
+
458
+ # Database and paths
459
+ db_path = "/var/lib/trafic/db.sqlite"
460
+ project_list_path = "/home/ddev/.ddev/project_list.yaml"
461
+ projects_dir = "/home/ddev/www"
462
+
463
+ # Scale-to-zero settings
464
+ idle_timeout = "4h"
465
+ idle_check_interval = "5m"
466
+
467
+ # Authentication
468
+ [auth]
469
+ default_policy = "basic"
470
+ allowed_ips = []
471
+ tokens = []
472
+ basic_auth = []
473
+ rules = []
474
+ `);
475
+ exec("chmod 640 /etc/trafic/config.toml");
476
+ exec("chown root:ddev /etc/trafic/config.toml");
477
+ success("Configuration created at /etc/trafic/config.toml");
478
+ info("Edit this file to configure authentication");
479
+ }
480
+ /**
481
+ * Create systemd service for the agent
482
+ */
483
+ function createSystemdService() {
484
+ step("Create systemd service");
485
+ writeFileSync("/etc/systemd/system/trafic-agent.service", `[Unit]
486
+ Description=Trafic Agent - DDEV preview environment manager
487
+ After=network.target docker.service
488
+ Requires=docker.service
489
+
490
+ [Service]
491
+ Type=simple
492
+ User=ddev
493
+ Group=ddev
494
+ ExecStart=${exec("which trafic-agent", { silent: true })?.trim() || "/usr/bin/trafic-agent"} start --config /etc/trafic/config.toml
495
+ Restart=always
496
+ RestartSec=5
497
+ StandardOutput=journal
498
+ StandardError=journal
499
+ SyslogIdentifier=trafic-agent
500
+
501
+ # Environment
502
+ Environment=NODE_ENV=production
503
+ # Skip ddev-hostname /etc/hosts management — on a server, hostnames resolve
504
+ # to the public IP (not localhost), which would trigger ddev-hostname on every
505
+ # ddev start. DDEV_NONINTERACTIVE=true skips that entirely.
506
+ Environment=DDEV_NONINTERACTIVE=true
507
+
508
+ # Security hardening
509
+ NoNewPrivileges=true
510
+ ProtectSystem=strict
511
+ ProtectHome=read-only
512
+ ReadWritePaths=/var/lib/trafic /home/ddev/.ddev
513
+ PrivateTmp=true
514
+
515
+ [Install]
516
+ WantedBy=multi-user.target
517
+ `);
518
+ exec("chmod 644 /etc/systemd/system/trafic-agent.service");
519
+ exec("systemctl daemon-reload");
520
+ exec("systemctl enable trafic-agent");
521
+ exec("systemctl start trafic-agent");
522
+ success("Trafic agent service started");
523
+ info("View logs: journalctl -u trafic-agent -f");
524
+ }
525
+ /**
526
+ * Configure SSH hardening
527
+ */
528
+ function hardenSsh(allowedUsers = ["ddev"]) {
529
+ step("Harden SSH configuration");
530
+ writeFileSync("/etc/ssh/sshd_config.d/trafic.conf", `# Trafic SSH hardening
531
+ # Generated by trafic setup
532
+
533
+ # Allow root login via SSH key only (no password) — required for server management
534
+ PermitRootLogin prohibit-password
535
+
536
+ # Disable password authentication (keys only)
537
+ PasswordAuthentication no
538
+ PubkeyAuthentication yes
539
+
540
+ # Limit authentication attempts
541
+ MaxAuthTries 3
542
+
543
+ # Idle timeout (5 minutes)
544
+ ClientAliveInterval 300
545
+ ClientAliveCountMax 2
546
+
547
+ # Allowed users (root kept for emergency access)
548
+ AllowUsers root ${allowedUsers.join(" ")}
549
+
550
+ # Disable X11 forwarding
551
+ X11Forwarding no
552
+
553
+ # Disable TCP forwarding
554
+ AllowTcpForwarding no
555
+
556
+ # Use strong algorithms only
557
+ KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256
558
+ Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
559
+ MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
560
+ `);
561
+ exec("chmod 644 /etc/ssh/sshd_config.d/trafic.conf");
562
+ if (exec("sshd -t 2>&1 || echo 'error'", { silent: true })?.includes("error")) {
563
+ warn("SSH config test failed, reverting...");
564
+ exec("rm /etc/ssh/sshd_config.d/trafic.conf");
565
+ return;
566
+ }
567
+ exec("systemctl reload ssh || systemctl reload sshd");
568
+ success("SSH hardened: password auth disabled, root key-only login retained");
569
+ info(`Allowed users: root, ${allowedUsers.join(", ")}`);
570
+ }
571
+ /**
572
+ * Configure UFW firewall
573
+ */
574
+ function configureFirewall() {
575
+ step("Configure firewall (UFW)");
576
+ if (!commandExists("ufw")) exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y ufw", { silent: true });
577
+ exec("ufw --force reset", { silent: true });
578
+ exec("ufw default deny incoming");
579
+ exec("ufw default allow outgoing");
580
+ exec("ufw allow 22/tcp comment 'SSH'");
581
+ exec("ufw allow 80/tcp comment 'HTTP'");
582
+ exec("ufw allow 443/tcp comment 'HTTPS'");
583
+ exec("ufw --force enable");
584
+ success("Firewall enabled: SSH (22), HTTP (80), HTTPS (443) allowed");
585
+ }
586
+ /**
587
+ * Configure automatic security updates
588
+ */
589
+ function configureUnattendedUpgrades() {
590
+ step("Configure automatic security updates");
591
+ exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y unattended-upgrades apt-listchanges", { silent: true });
592
+ writeFileSync("/etc/apt/apt.conf.d/50unattended-upgrades", `// Trafic: Automatic security updates
593
+ Unattended-Upgrade::Allowed-Origins {
594
+ "\${distro_id}:\${distro_codename}";
595
+ "\${distro_id}:\${distro_codename}-security";
596
+ "\${distro_id}ESMApps:\${distro_codename}-apps-security";
597
+ "\${distro_id}ESM:\${distro_codename}-infra-security";
598
+ };
599
+
600
+ // Do not automatically reboot
601
+ Unattended-Upgrade::Automatic-Reboot "false";
602
+
603
+ // Remove unused dependencies
604
+ Unattended-Upgrade::Remove-Unused-Dependencies "true";
605
+
606
+ // Email notifications (optional)
607
+ // Unattended-Upgrade::Mail "root";
608
+ `);
609
+ writeFileSync("/etc/apt/apt.conf.d/20auto-upgrades", `APT::Periodic::Update-Package-Lists "1";
610
+ APT::Periodic::Download-Upgradeable-Packages "1";
611
+ APT::Periodic::AutocleanInterval "7";
612
+ APT::Periodic::Unattended-Upgrade "1";
613
+ `);
614
+ exec("systemctl enable unattended-upgrades");
615
+ exec("systemctl start unattended-upgrades");
616
+ success("Automatic security updates enabled");
617
+ }
618
+ /**
619
+ * Configure Fail2ban
620
+ */
621
+ function configureFail2ban() {
622
+ step("Configure Fail2ban");
623
+ if (!commandExists("fail2ban-client")) exec("DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y fail2ban", { silent: true });
624
+ writeFileSync("/etc/fail2ban/jail.local", `# Trafic: Fail2ban configuration
625
+ [DEFAULT]
626
+ bantime = 3600
627
+ findtime = 600
628
+ maxretry = 5
629
+ backend = auto
630
+
631
+ [sshd]
632
+ enabled = true
633
+ port = ssh
634
+ filter = sshd
635
+ logpath = /var/log/auth.log
636
+ maxretry = 3
637
+ bantime = 7200
638
+ `);
639
+ exec("chmod 644 /etc/fail2ban/jail.local");
640
+ exec("systemctl enable fail2ban");
641
+ exec("systemctl restart fail2ban");
642
+ success("Fail2ban enabled: SSH brute force protection active");
643
+ }
644
+ /**
645
+ * Configure system limits
646
+ */
647
+ function configureSystemLimits() {
648
+ step("Configure system limits");
649
+ writeFileSync("/etc/security/limits.d/trafic.conf", `# Trafic: System limits for ddev user
650
+ ddev soft nofile 65536
651
+ ddev hard nofile 65536
652
+ ddev soft nproc 4096
653
+ ddev hard nproc 4096
654
+ `);
655
+ exec("chmod 644 /etc/security/limits.d/trafic.conf");
656
+ success("System limits configured for ddev user");
657
+ }
658
+ /**
659
+ * Set proper file permissions
660
+ */
661
+ function configureFilePermissions() {
662
+ step("Configure file permissions");
663
+ exec("mkdir -p /etc/trafic");
664
+ exec("mkdir -p /var/lib/trafic");
665
+ exec("mkdir -p /var/backups/trafic");
666
+ exec("chmod 755 /etc/trafic");
667
+ exec("chmod 700 /var/lib/trafic");
668
+ exec("chmod 700 /var/backups/trafic");
669
+ exec("chown root:ddev /etc/trafic");
670
+ exec("chown ddev:ddev /var/lib/trafic");
671
+ exec("chown ddev:ddev /var/backups/trafic");
672
+ if (existsSync("/etc/trafic/config.toml")) {
673
+ exec("chmod 640 /etc/trafic/config.toml");
674
+ exec("chown root:ddev /etc/trafic/config.toml");
675
+ }
676
+ success("File permissions configured");
677
+ }
678
+ /**
679
+ * Run all hardening steps
680
+ */
681
+ function hardenServer(sshUsers = ["ddev"]) {
682
+ hardenSsh(sshUsers);
683
+ configureFirewall();
684
+ configureUnattendedUpgrades();
685
+ configureFail2ban();
686
+ configureSystemLimits();
687
+ configureFilePermissions();
688
+ }
689
+ /**
690
+ * Run a command silently and return output
691
+ */
692
+ function execSilent(command) {
693
+ try {
694
+ return execSync(command, {
695
+ encoding: "utf-8",
696
+ stdio: "pipe"
697
+ }).trim();
698
+ } catch {
699
+ return "";
700
+ }
701
+ }
702
+ /**
703
+ * Check if a service is active
704
+ */
705
+ function isServiceActive(service) {
706
+ return execSilent(`systemctl is-active ${service}`) === "active";
707
+ }
708
+ /**
709
+ * Audit SSH configuration
710
+ */
711
+ function auditSsh() {
712
+ const checks = [];
713
+ const sshdConfig = existsSync("/etc/ssh/sshd_config") ? readFileSync("/etc/ssh/sshd_config", "utf-8") : "";
714
+ const traficConfig = existsSync("/etc/ssh/sshd_config.d/trafic.conf") ? readFileSync("/etc/ssh/sshd_config.d/trafic.conf", "utf-8") : "";
715
+ const config = sshdConfig + "\n" + traficConfig;
716
+ const rootLoginDisabled = config.includes("PermitRootLogin no") || config.includes("PermitRootLogin prohibit-password");
717
+ checks.push({
718
+ name: "SSH root login",
719
+ status: rootLoginDisabled ? "pass" : "fail",
720
+ message: rootLoginDisabled ? "Root login disabled" : "Root login may be enabled",
721
+ fix: "Add 'PermitRootLogin no' to /etc/ssh/sshd_config.d/trafic.conf"
722
+ });
723
+ const passwordAuthDisabled = config.includes("PasswordAuthentication no");
724
+ checks.push({
725
+ name: "SSH password auth",
726
+ status: passwordAuthDisabled ? "pass" : "warn",
727
+ message: passwordAuthDisabled ? "Password authentication disabled" : "Password authentication may be enabled",
728
+ fix: "Add 'PasswordAuthentication no' to /etc/ssh/sshd_config.d/trafic.conf"
729
+ });
730
+ return checks;
731
+ }
732
+ /**
733
+ * Audit firewall configuration
734
+ */
735
+ function auditFirewall() {
736
+ const checks = [];
737
+ const ufwStatus = execSilent("ufw status");
738
+ const isActive = ufwStatus.includes("Status: active");
739
+ checks.push({
740
+ name: "Firewall (UFW)",
741
+ status: isActive ? "pass" : "fail",
742
+ message: isActive ? "UFW is active" : "UFW is not active",
743
+ fix: "Run: ufw enable"
744
+ });
745
+ if (isActive) {
746
+ const allows22 = ufwStatus.includes("22/tcp");
747
+ const allows80 = ufwStatus.includes("80/tcp");
748
+ const allows443 = ufwStatus.includes("443/tcp");
749
+ if (allows22 && allows80 && allows443) checks.push({
750
+ name: "Firewall ports",
751
+ status: "pass",
752
+ message: "Required ports (22, 80, 443) are open"
753
+ });
754
+ else checks.push({
755
+ name: "Firewall ports",
756
+ status: "warn",
757
+ message: "Some required ports may be blocked",
758
+ fix: "Run: ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp"
759
+ });
760
+ }
761
+ return checks;
762
+ }
763
+ /**
764
+ * Audit security services
765
+ */
766
+ function auditServices() {
767
+ const checks = [];
768
+ const fail2banActive = isServiceActive("fail2ban");
769
+ checks.push({
770
+ name: "Fail2ban",
771
+ status: fail2banActive ? "pass" : "warn",
772
+ message: fail2banActive ? "Fail2ban is active" : "Fail2ban is not running",
773
+ fix: "Run: apt install fail2ban && systemctl enable fail2ban --now"
774
+ });
775
+ const unattendedActive = isServiceActive("unattended-upgrades");
776
+ checks.push({
777
+ name: "Automatic updates",
778
+ status: unattendedActive ? "pass" : "warn",
779
+ message: unattendedActive ? "Unattended upgrades enabled" : "Automatic updates not configured",
780
+ fix: "Run: apt install unattended-upgrades && dpkg-reconfigure unattended-upgrades"
781
+ });
782
+ const agentActive = isServiceActive("trafic-agent");
783
+ checks.push({
784
+ name: "Trafic agent",
785
+ status: agentActive ? "pass" : "fail",
786
+ message: agentActive ? "Trafic agent is running" : "Trafic agent is not running",
787
+ fix: "Run: systemctl start trafic-agent"
788
+ });
789
+ return checks;
790
+ }
791
+ /**
792
+ * Audit Docker
793
+ */
794
+ function auditDocker() {
795
+ const checks = [];
796
+ const dockerActive = isServiceActive("docker");
797
+ checks.push({
798
+ name: "Docker",
799
+ status: dockerActive ? "pass" : "fail",
800
+ message: dockerActive ? "Docker is running" : "Docker is not running",
801
+ fix: "Run: systemctl start docker"
802
+ });
803
+ const danglingImages = execSilent("docker images -f dangling=true -q | wc -l");
804
+ const count = parseInt(danglingImages, 10) || 0;
805
+ if (count > 0) checks.push({
806
+ name: "Docker cleanup",
807
+ status: "warn",
808
+ message: `${count} dangling Docker images`,
809
+ fix: "Run: docker system prune -f"
810
+ });
811
+ const diskUsage = execSilent("df -h / | awk 'NR==2{print $5}'");
812
+ const usagePercent = parseInt(diskUsage, 10) || 0;
813
+ checks.push({
814
+ name: "Disk usage",
815
+ status: usagePercent > 90 ? "fail" : usagePercent > 80 ? "warn" : "pass",
816
+ message: `Disk usage: ${diskUsage}`,
817
+ fix: usagePercent > 80 ? "Consider cleaning up old projects or Docker resources" : void 0
818
+ });
819
+ return checks;
820
+ }
821
+ /**
822
+ * Audit file permissions
823
+ */
824
+ function auditPermissions() {
825
+ const checks = [];
826
+ if (existsSync("/etc/trafic/config.toml")) {
827
+ const stats = execSilent("stat -c '%a' /etc/trafic/config.toml");
828
+ const isSecure = stats === "640" || stats === "600";
829
+ checks.push({
830
+ name: "Config permissions",
831
+ status: isSecure ? "pass" : "warn",
832
+ message: isSecure ? "Config file has secure permissions" : `Config file permissions: ${stats} (should be 640)`,
833
+ fix: "Run: chmod 640 /etc/trafic/config.toml"
834
+ });
835
+ }
836
+ if (existsSync("/home/ddev/www")) {
837
+ const owner = execSilent("stat -c '%U:%G' /home/ddev/www");
838
+ const isCorrect = owner === "ddev:ddev";
839
+ checks.push({
840
+ name: "Projects directory",
841
+ status: isCorrect ? "pass" : "warn",
842
+ message: isCorrect ? "Projects directory owned by ddev" : `Projects directory owner: ${owner}`,
843
+ fix: "Run: chown ddev:ddev /home/ddev/www"
844
+ });
845
+ }
846
+ return checks;
847
+ }
848
+ /**
849
+ * Run all audit checks
850
+ */
851
+ function runAudit() {
852
+ return [
853
+ ...auditSsh(),
854
+ ...auditFirewall(),
855
+ ...auditServices(),
856
+ ...auditDocker(),
857
+ ...auditPermissions()
858
+ ];
859
+ }
860
+ /**
861
+ * Print audit results
862
+ */
863
+ function printAuditResults(checks) {
864
+ console.log("\n\x1B[1mSecurity Audit Results\x1B[0m\n");
865
+ for (const check of checks) {
866
+ const icon = check.status === "pass" ? "\x1B[32m✓\x1B[0m" : check.status === "warn" ? "\x1B[33m⚠\x1B[0m" : "\x1B[31m✗\x1B[0m";
867
+ console.log(`${icon} \x1b[1m${check.name}\x1b[0m: ${check.message}`);
868
+ if (check.fix && check.status !== "pass") console.log(` \x1b[90m${check.fix}\x1b[0m`);
869
+ }
870
+ const passed = checks.filter((c) => c.status === "pass").length;
871
+ const warnings = checks.filter((c) => c.status === "warn").length;
872
+ const failed = checks.filter((c) => c.status === "fail").length;
873
+ console.log(`\n\x1b[1mSummary:\x1b[0m ${passed} passed, ${warnings} warnings, ${failed} failed`);
874
+ }
875
+ /**
876
+ * Run the full server setup
877
+ */
878
+ async function setup(options) {
879
+ resetSteps();
880
+ setDryRun(options.dryRun ?? false);
881
+ console.log("\n\x1B[1m🚦 Trafic Server Setup\x1B[0m\n");
882
+ if (options.dryRun) console.log("\x1B[33m⚠ Dry-run mode: no changes will be made\x1B[0m\n");
883
+ if (!isRoot() && !options.dryRun) {
884
+ error("This command must be run as root");
885
+ console.log(" Run: sudo trafic-agent setup ...");
886
+ process.exit(1);
887
+ }
888
+ info(`TLD: ${options.tld}`);
889
+ if (options.email) info(`Let's Encrypt email: ${options.email}`);
890
+ if (options.noHardening) info("Hardening: disabled");
891
+ try {
892
+ installSystemDeps();
893
+ createDdevUser();
894
+ if (!options.noDocker) {
895
+ installDocker();
896
+ configureDocker();
897
+ setupDockerPrune();
898
+ } else {
899
+ step("Install Docker");
900
+ info("Skipped (--no-docker)");
901
+ }
902
+ if (!options.noDdev) {
903
+ installDdev();
904
+ configureDdev(options.tld, options.email);
905
+ configureTraefik();
906
+ } else {
907
+ step("Install DDEV");
908
+ info("Skipped (--no-ddev)");
909
+ }
910
+ installNode();
911
+ installAgent();
912
+ createAgentConfig(options.tld, options.email);
913
+ createSystemdService();
914
+ if (!options.noHardening) hardenServer(options.sshUsers ?? ["ddev"]);
915
+ else {
916
+ step("Server hardening");
917
+ info("Skipped (--no-hardening)");
918
+ }
919
+ console.log("\n\x1B[32m✓ Setup complete!\x1B[0m\n");
920
+ console.log("Next steps:");
921
+ console.log(` 1. Edit /etc/trafic/config.toml to configure authentication`);
922
+ console.log(` 2. Add SSH public keys to /home/ddev/.ssh/authorized_keys`);
923
+ console.log(` 3. Deploy your first project:`);
924
+ console.log(` npx @studiometa/trafic-cli deploy --host=<this-server> --name=my-app`);
925
+ console.log(`\nYour projects will be available at https://<name>.${options.tld}`);
926
+ } catch (err) {
927
+ error(`Setup failed: ${err instanceof Error ? err.message : err}`);
928
+ process.exit(1);
929
+ }
930
+ }
931
+ /**
932
+ * Run security audit
933
+ */
934
+ async function audit() {
935
+ const checks = runAudit();
936
+ printAuditResults(checks);
937
+ if (checks.filter((c) => c.status === "fail").length > 0) process.exit(1);
938
+ }
939
+ 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 };
940
+
941
+ //# sourceMappingURL=setup-CVf8YfPW.js.map