@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.
@@ -1,618 +0,0 @@
1
- import { createServer } from "node:http";
2
- import { existsSync, watch, readFileSync } from "node:fs";
3
- import { join, dirname, resolve } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { execSync } from "node:child_process";
6
- import Database from "better-sqlite3";
7
- function matchIp(ip, pattern) {
8
- if (ip === pattern) return true;
9
- if (pattern.includes("/")) {
10
- return matchCidr(ip, pattern);
11
- }
12
- if (pattern.includes("*")) {
13
- const regex = new RegExp(
14
- "^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
15
- );
16
- return regex.test(ip);
17
- }
18
- return false;
19
- }
20
- function matchCidr(ip, cidr) {
21
- const [range, bits] = cidr.split("/");
22
- const mask = parseInt(bits, 10);
23
- const ipNum = ipToNumber(ip);
24
- const rangeNum = ipToNumber(range);
25
- if (ipNum === null || rangeNum === null) return false;
26
- const maskNum = ~((1 << 32 - mask) - 1);
27
- return (ipNum & maskNum) === (rangeNum & maskNum);
28
- }
29
- function ipToNumber(ip) {
30
- const parts = ip.split(".");
31
- if (parts.length !== 4) return null;
32
- let num = 0;
33
- for (const part of parts) {
34
- const n = parseInt(part, 10);
35
- if (isNaN(n) || n < 0 || n > 255) return null;
36
- num = num << 8 | n;
37
- }
38
- return num >>> 0;
39
- }
40
- function matchHostname(hostname, pattern) {
41
- if (hostname === pattern) return true;
42
- const regex = new RegExp(
43
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
44
- );
45
- return regex.test(hostname);
46
- }
47
- function parseBasicAuth(header) {
48
- if (!header.startsWith("Basic ")) return null;
49
- try {
50
- const encoded = header.slice(6);
51
- const decoded = Buffer.from(encoded, "base64").toString("utf-8");
52
- const colonIndex = decoded.indexOf(":");
53
- if (colonIndex === -1) return null;
54
- return {
55
- username: decoded.slice(0, colonIndex),
56
- password: decoded.slice(colonIndex + 1)
57
- };
58
- } catch {
59
- return null;
60
- }
61
- }
62
- function parseBearerToken(header) {
63
- if (!header.startsWith("Bearer ")) return null;
64
- return header.slice(7);
65
- }
66
- function checkAuth(request, config2) {
67
- const { hostname, ip, authorization } = request;
68
- const realIp = request.forwardedFor?.split(",")[0].trim() ?? ip;
69
- for (const pattern of config2.allowedIps) {
70
- if (matchIp(realIp, pattern)) {
71
- return { allowed: true, reason: "ip" };
72
- }
73
- }
74
- for (const rule of config2.rules) {
75
- if (matchHostname(hostname, rule.match)) {
76
- return checkRule(rule, realIp, authorization, config2);
77
- }
78
- }
79
- return applyDefaultPolicy(config2, realIp, authorization);
80
- }
81
- function checkRule(rule, ip, authorization, config2) {
82
- if (rule.allowedIps) {
83
- for (const pattern of rule.allowedIps) {
84
- if (matchIp(ip, pattern)) {
85
- return { allowed: true, reason: "rule" };
86
- }
87
- }
88
- }
89
- switch (rule.policy) {
90
- case "allow":
91
- return { allowed: true, reason: "rule" };
92
- case "deny":
93
- return { allowed: false, reason: "rule" };
94
- case "token": {
95
- if (!authorization) return { allowed: false, reason: "rule" };
96
- const token = parseBearerToken(authorization);
97
- const tokens = rule.tokens ?? config2.tokens;
98
- if (token && tokens.includes(token)) {
99
- return { allowed: true, reason: "token" };
100
- }
101
- return { allowed: false, reason: "rule" };
102
- }
103
- case "basic": {
104
- if (!authorization) return { allowed: false, reason: "rule" };
105
- const creds = parseBasicAuth(authorization);
106
- if (creds) {
107
- const credString = `${creds.username}:${creds.password}`;
108
- if (config2.basicAuth.includes(credString)) {
109
- return { allowed: true, reason: "basic" };
110
- }
111
- }
112
- return { allowed: false, reason: "rule" };
113
- }
114
- default:
115
- return { allowed: false, reason: "rule" };
116
- }
117
- }
118
- function applyDefaultPolicy(config2, ip, authorization) {
119
- switch (config2.defaultPolicy) {
120
- case "allow":
121
- return { allowed: true, reason: "default" };
122
- case "deny":
123
- return { allowed: false, reason: "default" };
124
- case "basic": {
125
- if (!authorization) return { allowed: false, reason: "default" };
126
- const creds = parseBasicAuth(authorization);
127
- if (creds) {
128
- const credString = `${creds.username}:${creds.password}`;
129
- if (config2.basicAuth.includes(credString)) {
130
- return { allowed: true, reason: "basic" };
131
- }
132
- }
133
- const token = parseBearerToken(authorization);
134
- if (token && config2.tokens.includes(token)) {
135
- return { allowed: true, reason: "token" };
136
- }
137
- return { allowed: false, reason: "default" };
138
- }
139
- case "token": {
140
- if (!authorization) return { allowed: false, reason: "default" };
141
- const token = parseBearerToken(authorization);
142
- if (token && config2.tokens.includes(token)) {
143
- return { allowed: true, reason: "token" };
144
- }
145
- return { allowed: false, reason: "default" };
146
- }
147
- default:
148
- return { allowed: false, reason: "default" };
149
- }
150
- }
151
- function parseSimpleYaml$1(content) {
152
- const result = {};
153
- const lines = content.split("\n");
154
- for (const line of lines) {
155
- const trimmed = line.trim();
156
- if (!trimmed || trimmed.startsWith("#")) continue;
157
- const colonIndex = trimmed.indexOf(":");
158
- if (colonIndex === -1) continue;
159
- const key = trimmed.slice(0, colonIndex).trim();
160
- let value = trimmed.slice(colonIndex + 1).trim();
161
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
162
- value = value.slice(1, -1);
163
- }
164
- result[key] = value;
165
- }
166
- return result;
167
- }
168
- function loadProjectList(projectListPath) {
169
- if (!existsSync(projectListPath)) {
170
- return /* @__PURE__ */ new Map();
171
- }
172
- const content = readFileSync(projectListPath, "utf-8");
173
- const parsed = parseSimpleYaml$1(content);
174
- return new Map(Object.entries(parsed));
175
- }
176
- function buildHostnameIndex(projects, tld) {
177
- const index = /* @__PURE__ */ new Map();
178
- for (const [name] of projects) {
179
- index.set(`${name}.${tld}`, name);
180
- }
181
- return index;
182
- }
183
- function getProjectFromHostname(hostname, index) {
184
- return index.get(hostname);
185
- }
186
- function watchProjectList(projectListPath, onChange) {
187
- if (!existsSync(projectListPath)) {
188
- console.warn(`Project list not found: ${projectListPath}`);
189
- return;
190
- }
191
- watch(projectListPath, (eventType) => {
192
- if (eventType === "change") {
193
- onChange();
194
- }
195
- });
196
- }
197
- function getProjectInfo(name) {
198
- try {
199
- const output = execSync(`ddev describe ${name} -j`, {
200
- encoding: "utf-8",
201
- timeout: 1e4
202
- });
203
- const data = JSON.parse(output);
204
- if (!data.raw) return void 0;
205
- const raw = data.raw;
206
- return {
207
- name: raw.name,
208
- status: raw.status,
209
- appRoot: raw.approot,
210
- httpURLs: raw.httpURLs ?? [],
211
- httpsURLs: raw.httpsURLs ?? [],
212
- type: raw.type,
213
- phpVersion: raw.php_version,
214
- dbType: raw.dbinfo?.dbType
215
- };
216
- } catch {
217
- return void 0;
218
- }
219
- }
220
- function startProject(name) {
221
- try {
222
- execSync(`ddev start ${name}`, {
223
- encoding: "utf-8",
224
- timeout: 12e4,
225
- // 2 minutes
226
- stdio: "pipe"
227
- });
228
- return true;
229
- } catch (error) {
230
- console.error(`Failed to start project ${name}:`, error);
231
- return false;
232
- }
233
- }
234
- function stopProject(name) {
235
- try {
236
- execSync(`ddev stop ${name}`, {
237
- encoding: "utf-8",
238
- timeout: 6e4,
239
- // 1 minute
240
- stdio: "pipe"
241
- });
242
- return true;
243
- } catch (error) {
244
- console.error(`Failed to stop project ${name}:`, error);
245
- return false;
246
- }
247
- }
248
- function listProjects() {
249
- try {
250
- const output = execSync("ddev list -j", {
251
- encoding: "utf-8",
252
- timeout: 3e4
253
- });
254
- const data = JSON.parse(output);
255
- if (!Array.isArray(data)) return [];
256
- return data.map((p) => ({
257
- name: p.name,
258
- status: p.status,
259
- appRoot: p.approot,
260
- httpURLs: p.httpURLs ?? [],
261
- httpsURLs: p.httpsURLs ?? [],
262
- type: p.type
263
- }));
264
- } catch {
265
- return [];
266
- }
267
- }
268
- let db = null;
269
- function initDb(dbPath) {
270
- db = new Database(dbPath);
271
- db.pragma("journal_mode = WAL");
272
- db.exec(`
273
- CREATE TABLE IF NOT EXISTS projects (
274
- name TEXT PRIMARY KEY,
275
- last_access INTEGER NOT NULL,
276
- status TEXT NOT NULL DEFAULT 'stopped'
277
- );
278
-
279
- CREATE TABLE IF NOT EXISTS access_logs (
280
- id INTEGER PRIMARY KEY AUTOINCREMENT,
281
- project TEXT NOT NULL,
282
- timestamp INTEGER NOT NULL,
283
- ip TEXT NOT NULL,
284
- user_agent TEXT NOT NULL,
285
- path TEXT NOT NULL
286
- );
287
-
288
- CREATE INDEX IF NOT EXISTS idx_access_logs_project ON access_logs(project);
289
- CREATE INDEX IF NOT EXISTS idx_access_logs_timestamp ON access_logs(timestamp);
290
- `);
291
- return db;
292
- }
293
- function getDb() {
294
- if (!db) {
295
- throw new Error("Database not initialized. Call initDb first.");
296
- }
297
- return db;
298
- }
299
- function closeDb() {
300
- if (db) {
301
- db.close();
302
- db = null;
303
- }
304
- }
305
- function updateProjectAccess(name) {
306
- const database = getDb();
307
- const stmt = database.prepare(`
308
- INSERT INTO projects (name, last_access, status)
309
- VALUES (?, ?, 'running')
310
- ON CONFLICT(name) DO UPDATE SET last_access = excluded.last_access
311
- `);
312
- stmt.run(name, Date.now());
313
- }
314
- function getProject(name) {
315
- const database = getDb();
316
- const stmt = database.prepare(
317
- "SELECT name, last_access as lastAccess, status FROM projects WHERE name = ?"
318
- );
319
- return stmt.get(name);
320
- }
321
- function setProjectStatus(name, status) {
322
- const database = getDb();
323
- const stmt = database.prepare(`
324
- INSERT INTO projects (name, last_access, status)
325
- VALUES (?, ?, ?)
326
- ON CONFLICT(name) DO UPDATE SET status = excluded.status
327
- `);
328
- stmt.run(name, Date.now(), status);
329
- }
330
- function getIdleProjects(thresholdMs) {
331
- const database = getDb();
332
- const cutoff = Date.now() - thresholdMs;
333
- const stmt = database.prepare(`
334
- SELECT name, last_access as lastAccess, status
335
- FROM projects
336
- WHERE last_access < ? AND status = 'running'
337
- `);
338
- return stmt.all(cutoff);
339
- }
340
- function logAccess(log) {
341
- const database = getDb();
342
- const stmt = database.prepare(`
343
- INSERT INTO access_logs (project, timestamp, ip, user_agent, path)
344
- VALUES (?, ?, ?, ?, ?)
345
- `);
346
- stmt.run(log.project, log.timestamp, log.ip, log.userAgent, log.path);
347
- }
348
- function getAccessLogs(project, limit = 100) {
349
- const database = getDb();
350
- const stmt = database.prepare(`
351
- SELECT id, project, timestamp, ip, user_agent as userAgent, path
352
- FROM access_logs
353
- WHERE project = ?
354
- ORDER BY timestamp DESC
355
- LIMIT ?
356
- `);
357
- return stmt.all(project, limit);
358
- }
359
- function cleanOldLogs(days) {
360
- const database = getDb();
361
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
362
- const stmt = database.prepare("DELETE FROM access_logs WHERE timestamp < ?");
363
- const result = stmt.run(cutoff);
364
- return result.changes;
365
- }
366
- function parseSimpleYaml(content) {
367
- const result = {};
368
- const lines = content.split("\n");
369
- for (const line of lines) {
370
- const trimmed = line.trim();
371
- if (!trimmed || trimmed.startsWith("#")) continue;
372
- const colonIndex = trimmed.indexOf(":");
373
- if (colonIndex === -1) continue;
374
- const key = trimmed.slice(0, colonIndex).trim();
375
- let value = trimmed.slice(colonIndex + 1).trim();
376
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
377
- value = value.slice(1, -1);
378
- }
379
- result[key] = value;
380
- }
381
- return result;
382
- }
383
- function loadProjectConfig(projectDir) {
384
- const configPath = join(projectDir, ".ddev", "config.trafic.yaml");
385
- if (!existsSync(configPath)) {
386
- return {};
387
- }
388
- try {
389
- const content = readFileSync(configPath, "utf-8");
390
- const parsed = parseSimpleYaml(content);
391
- const config2 = {};
392
- if (parsed.auth_policy) {
393
- const policy = parsed.auth_policy.toLowerCase();
394
- if (["allow", "deny", "basic", "token"].includes(policy)) {
395
- config2.auth_policy = policy;
396
- }
397
- }
398
- if (parsed.idle_timeout) {
399
- config2.idle_timeout = parsed.idle_timeout;
400
- }
401
- return config2;
402
- } catch (error) {
403
- console.warn(`Failed to load project config from ${configPath}:`, error);
404
- return {};
405
- }
406
- }
407
- function shouldNeverStop(config2) {
408
- return config2.idle_timeout?.toLowerCase() === "never";
409
- }
410
- function getIdleTimeoutMs(config2) {
411
- if (!config2.idle_timeout) return void 0;
412
- if (shouldNeverStop(config2)) return void 0;
413
- const regex = /(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/;
414
- const match = config2.idle_timeout.match(regex);
415
- if (!match) return void 0;
416
- const hours = parseInt(match[1] ?? "0", 10);
417
- const minutes = parseInt(match[2] ?? "0", 10);
418
- const seconds = parseInt(match[3] ?? "0", 10);
419
- const ms = (hours * 3600 + minutes * 60 + seconds) * 1e3;
420
- return ms > 0 ? ms : void 0;
421
- }
422
- const __dirname$1 = dirname(fileURLToPath(import.meta.url));
423
- let config;
424
- let projectList;
425
- let hostnameIndex;
426
- const projectConfigs = /* @__PURE__ */ new Map();
427
- function loadTemplate(name) {
428
- const templatePath = resolve(__dirname$1, "..", "templates", `${name}.html`);
429
- try {
430
- return readFileSync(templatePath, "utf-8");
431
- } catch {
432
- return `<html><body><h1>${name}</h1></body></html>`;
433
- }
434
- }
435
- function getEffectiveAuthConfig(projectName) {
436
- if (!projectName) return config.auth;
437
- const projectConfig = projectConfigs.get(projectName);
438
- if (!projectConfig?.auth_policy) return config.auth;
439
- return {
440
- ...config.auth,
441
- defaultPolicy: projectConfig.auth_policy
442
- };
443
- }
444
- function handleAuth(req, res) {
445
- const hostname = req.headers["x-forwarded-host"] ?? "";
446
- const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "";
447
- const authorization = req.headers["authorization"];
448
- const path = req.headers["x-forwarded-uri"] ?? "/";
449
- const projectName = hostnameIndex.get(hostname);
450
- const authConfig = getEffectiveAuthConfig(projectName);
451
- const result = checkAuth(
452
- {
453
- hostname,
454
- ip: ip.split(",")[0].trim(),
455
- authorization,
456
- forwardedFor: ip
457
- },
458
- authConfig
459
- );
460
- if (result.allowed) {
461
- if (projectName) {
462
- updateProjectAccess(projectName);
463
- logAccess({
464
- project: projectName,
465
- timestamp: Date.now(),
466
- ip: ip.split(",")[0].trim(),
467
- userAgent: req.headers["user-agent"] ?? "",
468
- path
469
- });
470
- }
471
- res.writeHead(200);
472
- res.end();
473
- } else {
474
- res.writeHead(401, {
475
- "WWW-Authenticate": 'Basic realm="Trafic"'
476
- });
477
- res.end("Unauthorized");
478
- }
479
- }
480
- async function handleErrors(req, res) {
481
- const hostname = req.headers["x-forwarded-host"] ?? req.headers.host ?? "";
482
- const projectName = hostnameIndex.get(hostname);
483
- if (!projectName) {
484
- const template2 = loadTemplate("error");
485
- res.writeHead(404, { "Content-Type": "text/html" });
486
- res.end(template2.replace("{{message}}", "Project not found"));
487
- return;
488
- }
489
- const record = getProject(projectName);
490
- if (record?.status === "starting") {
491
- const template2 = loadTemplate("wait");
492
- res.writeHead(503, {
493
- "Content-Type": "text/html",
494
- "Retry-After": "5"
495
- });
496
- res.end(
497
- template2.replace(/\{\{project\}\}/g, projectName).replace(/\{\{hostname\}\}/g, hostname)
498
- );
499
- return;
500
- }
501
- setProjectStatus(projectName, "starting");
502
- const template = loadTemplate("wait");
503
- res.writeHead(503, {
504
- "Content-Type": "text/html",
505
- "Retry-After": "5"
506
- });
507
- res.end(
508
- template.replace(/\{\{project\}\}/g, projectName).replace(/\{\{hostname\}\}/g, hostname)
509
- );
510
- setTimeout(() => {
511
- const success = startProject(projectName);
512
- setProjectStatus(projectName, success ? "running" : "stopped");
513
- }, 100);
514
- }
515
- function handleStatus(req, res) {
516
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
517
- const projectName = url.searchParams.get("project");
518
- if (!projectName) {
519
- res.writeHead(400, { "Content-Type": "application/json" });
520
- res.end(JSON.stringify({ error: "Missing project parameter" }));
521
- return;
522
- }
523
- const info = getProjectInfo(projectName);
524
- const record = getProject(projectName);
525
- res.writeHead(200, { "Content-Type": "application/json" });
526
- res.end(
527
- JSON.stringify({
528
- name: projectName,
529
- status: info?.status ?? record?.status ?? "unknown",
530
- ready: info?.status === "running"
531
- })
532
- );
533
- }
534
- async function handleRequest(req, res) {
535
- const url = req.url ?? "/";
536
- try {
537
- if (url === "/__auth__" || url.startsWith("/__auth__/")) {
538
- handleAuth(req, res);
539
- } else if (url === "/__status__" || url.startsWith("/__status__/")) {
540
- handleStatus(req, res);
541
- } else if (url === "/__health__") {
542
- res.writeHead(200, { "Content-Type": "application/json" });
543
- res.end(JSON.stringify({ status: "ok", version: "__VERSION__" }));
544
- } else {
545
- await handleErrors(req, res);
546
- }
547
- } catch (error) {
548
- console.error("Request error:", error);
549
- res.writeHead(500, { "Content-Type": "text/plain" });
550
- res.end("Internal Server Error");
551
- }
552
- }
553
- function reloadProjects() {
554
- projectList = loadProjectList(config.projectListPath);
555
- hostnameIndex = buildHostnameIndex(projectList, config.tld);
556
- projectConfigs.clear();
557
- for (const [name, projectDir] of projectList) {
558
- const projectConfig = loadProjectConfig(projectDir);
559
- if (projectConfig.auth_policy || projectConfig.idle_timeout) {
560
- projectConfigs.set(name, projectConfig);
561
- console.log(` ${name}: auth=${projectConfig.auth_policy ?? "default"}, idle=${projectConfig.idle_timeout ?? "default"}`);
562
- }
563
- }
564
- console.log(`Loaded ${projectList.size} projects (${projectConfigs.size} with custom config)`);
565
- }
566
- function startServer(agentConfig) {
567
- config = agentConfig;
568
- initDb(config.dbPath);
569
- reloadProjects();
570
- watchProjectList(config.projectListPath, () => {
571
- console.log("Project list changed, reloading...");
572
- reloadProjects();
573
- });
574
- const server = createServer((req, res) => {
575
- handleRequest(req, res).catch((error) => {
576
- console.error("Unhandled error:", error);
577
- if (!res.headersSent) {
578
- res.writeHead(500);
579
- res.end("Internal Server Error");
580
- }
581
- });
582
- });
583
- server.listen(config.port, () => {
584
- console.log(`Trafic agent listening on port ${config.port}`);
585
- console.log(`TLD: ${config.tld}`);
586
- console.log(`Projects: ${projectList.size}`);
587
- });
588
- }
589
- export {
590
- watchProjectList as A,
591
- cleanOldLogs as a,
592
- buildHostnameIndex as b,
593
- checkAuth as c,
594
- closeDb as d,
595
- getDb as e,
596
- getIdleProjects as f,
597
- getAccessLogs as g,
598
- getIdleTimeoutMs as h,
599
- getProject as i,
600
- getProjectFromHostname as j,
601
- getProjectInfo as k,
602
- initDb as l,
603
- listProjects as m,
604
- loadProjectConfig as n,
605
- loadProjectList as o,
606
- logAccess as p,
607
- matchHostname as q,
608
- matchIp as r,
609
- parseBasicAuth as s,
610
- parseBearerToken as t,
611
- setProjectStatus as u,
612
- shouldNeverStop as v,
613
- startProject as w,
614
- startServer as x,
615
- stopProject as y,
616
- updateProjectAccess as z
617
- };
618
- //# sourceMappingURL=server-CHT47EXr.js.map