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