@ystemsrx/cfshare 0.1.2 → 0.1.3

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,2251 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import { createReadStream, createWriteStream } from "node:fs";
5
+ import http from "node:http";
6
+ import https from "node:https";
7
+ import net from "node:net";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { pipeline } from "node:stream/promises";
11
+ import ignore from "ignore";
12
+ import { lookup as mimeLookup } from "mime-types";
13
+ import yazl from "yazl";
14
+ import { renderFileExplorerTemplate } from "./templates/fileExplorerTemplate.js";
15
+ import { renderMarkdownPreviewTemplate } from "./templates/markdownPreviewTemplate.js";
16
+ import { loadPolicy } from "./policy.js";
17
+ const MAX_LOG_LINES = 4000;
18
+ const MAX_RESPONSE_MANIFEST_ITEMS = 200;
19
+ const MAX_RESPONSE_MANIFEST_ITEMS_MULTI_GET = 20;
20
+ const MAX_EXPOSURE_GET_ITEMS = 200;
21
+ const MAX_EXPOSURE_LOG_ITEMS = 100;
22
+ const MAX_EXPOSURE_LOG_LINES_RESPONSE = 1000;
23
+ const DEFAULT_PROBE_TIMEOUT_MS = 3000;
24
+ const CLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/gi;
25
+ const MARKDOWN_PREVIEW_EXTENSIONS = new Set([".md", ".rmd", ".qmd"]);
26
+ const INLINE_TEXT_PREVIEW_EXTENSIONS = new Set([".html", ".htm", ".svg"]);
27
+ const BINARY_MIME_PREFIXES = ["image/", "audio/", "video/", "font/"];
28
+ const BINARY_MIME_EXACT = new Set([
29
+ "application/pdf",
30
+ "application/zip",
31
+ "application/x-zip-compressed",
32
+ "application/gzip",
33
+ "application/x-gzip",
34
+ "application/x-7z-compressed",
35
+ "application/x-rar-compressed",
36
+ "application/octet-stream",
37
+ ]);
38
+ const INVALID_QUICK_TUNNEL_SUBDOMAINS = new Set(["api"]);
39
+ const WINDOWS_METADATA_SUFFIX_RE = /:zone\.identifier$/i;
40
+ function nowIso() {
41
+ return toLocalIso(new Date());
42
+ }
43
+ function toLocalIso(date) {
44
+ const year = date.getFullYear();
45
+ const month = String(date.getMonth() + 1).padStart(2, "0");
46
+ const day = String(date.getDate()).padStart(2, "0");
47
+ const hours = String(date.getHours()).padStart(2, "0");
48
+ const minutes = String(date.getMinutes()).padStart(2, "0");
49
+ const seconds = String(date.getSeconds()).padStart(2, "0");
50
+ const millis = String(date.getMilliseconds()).padStart(3, "0");
51
+ const offsetMinutes = -date.getTimezoneOffset();
52
+ const sign = offsetMinutes >= 0 ? "+" : "-";
53
+ const absOffsetMinutes = Math.abs(offsetMinutes);
54
+ const offsetHours = String(Math.trunc(absOffsetMinutes / 60)).padStart(2, "0");
55
+ const offsetMins = String(absOffsetMinutes % 60).padStart(2, "0");
56
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millis}${sign}${offsetHours}:${offsetMins}`;
57
+ }
58
+ function timestampMs(input) {
59
+ if (!input) {
60
+ return undefined;
61
+ }
62
+ const ms = Date.parse(input);
63
+ return Number.isFinite(ms) ? ms : undefined;
64
+ }
65
+ function randomId(prefix) {
66
+ return `${prefix}_${Date.now().toString(36)}_${crypto.randomBytes(3).toString("hex")}`;
67
+ }
68
+ function maskSecret(value) {
69
+ if (!value) {
70
+ return undefined;
71
+ }
72
+ if (value.length <= 6) {
73
+ return "*".repeat(value.length);
74
+ }
75
+ return `${value.slice(0, 3)}***${value.slice(-2)}`;
76
+ }
77
+ function isSubPath(target, base) {
78
+ const rel = path.relative(base, target);
79
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
80
+ }
81
+ function normalizePathForIgnore(input) {
82
+ const normalized = input.split(path.sep).join("/");
83
+ if (!normalized || normalized === "." || normalized === "..") {
84
+ return undefined;
85
+ }
86
+ if (normalized.startsWith("/") ||
87
+ normalized.startsWith("../") ||
88
+ normalized.includes("/../") ||
89
+ normalized.endsWith("/..")) {
90
+ return undefined;
91
+ }
92
+ return normalized.replace(/^\.\/+/, "");
93
+ }
94
+ function getIgnoreMatchCandidates(realPath) {
95
+ const candidates = new Set();
96
+ const relToCwd = normalizePathForIgnore(path.relative(process.cwd(), realPath));
97
+ const relToRoot = normalizePathForIgnore(path.relative(path.parse(realPath).root, realPath));
98
+ const baseName = normalizePathForIgnore(path.basename(realPath));
99
+ if (relToCwd) {
100
+ candidates.add(relToCwd);
101
+ }
102
+ if (relToRoot) {
103
+ candidates.add(relToRoot);
104
+ }
105
+ if (baseName) {
106
+ candidates.add(baseName);
107
+ }
108
+ return Array.from(candidates);
109
+ }
110
+ function normalizeAllowlistPaths(values) {
111
+ if (!values) {
112
+ return [];
113
+ }
114
+ const out = values
115
+ .filter((entry) => typeof entry === "string")
116
+ .map((entry) => entry.trim())
117
+ .filter(Boolean)
118
+ .map((entry) => (entry.startsWith("/") ? entry : `/${entry}`));
119
+ return Array.from(new Set(out));
120
+ }
121
+ function normalizeAccessMode(value, fallback) {
122
+ if (value === "token" || value === "basic" || value === "none") {
123
+ return value;
124
+ }
125
+ return fallback;
126
+ }
127
+ function normalizeTtl(value, policy) {
128
+ const n = typeof value === "number" ? Math.trunc(value) : policy.defaultTtlSeconds;
129
+ return Math.max(60, Math.min(policy.maxTtlSeconds, n));
130
+ }
131
+ function normalizeFilePresentation(value) {
132
+ if (value === "preview" || value === "raw" || value === "download") {
133
+ return value;
134
+ }
135
+ return "download";
136
+ }
137
+ function parseRequestedPresentation(input) {
138
+ if (input === "preview" || input === "raw" || input === "download") {
139
+ return input;
140
+ }
141
+ return undefined;
142
+ }
143
+ function resolveRequestPresentation(url, fallback) {
144
+ const requested = parseRequestedPresentation(ensureString(url.searchParams.get("delivery"))) ??
145
+ parseRequestedPresentation(ensureString(url.searchParams.get("presentation"))) ??
146
+ parseRequestedPresentation(ensureString(url.searchParams.get("view")));
147
+ return requested ?? fallback;
148
+ }
149
+ function normalizeWorkspaceRelativePath(input) {
150
+ if (!input) {
151
+ return undefined;
152
+ }
153
+ const normalized = input.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
154
+ if (!normalized || normalized === ".") {
155
+ return undefined;
156
+ }
157
+ const safe = path.posix.normalize(normalized);
158
+ if (!safe || safe === "." || safe === ".." || safe.startsWith("../") || safe.includes("/../")) {
159
+ return undefined;
160
+ }
161
+ return safe;
162
+ }
163
+ function sanitizeFilename(input) {
164
+ // Keep Unicode to preserve original filenames, but replace characters that are
165
+ // invalid or problematic across common filesystems (Windows in particular).
166
+ // We only sanitize a single path segment (basename), not a full path.
167
+ //
168
+ // Reference set:
169
+ // - ASCII control chars: 0x00-0x1F and 0x7F
170
+ // - Windows reserved: <>:"/\\|?*
171
+ // - Trailing dots/spaces are invalid on Windows
172
+ const cleaned = input
173
+ .replace(/[\u0000-\u001F\u007F]/g, "_")
174
+ .replace(/[<>:"/\\|?*]/g, "_")
175
+ .replace(/_+/g, "_")
176
+ .replace(/[. ]+$/g, "_");
177
+ const trimmed = cleaned.trim();
178
+ return trimmed || "item";
179
+ }
180
+ function ensureString(input) {
181
+ if (typeof input !== "string") {
182
+ return undefined;
183
+ }
184
+ const trimmed = input.trim();
185
+ return trimmed || undefined;
186
+ }
187
+ function buildContentDisposition(params) {
188
+ if (params.mode === "raw") {
189
+ return undefined;
190
+ }
191
+ const verb = params.mode === "preview" ? "inline" : "attachment";
192
+ const filename = params.downloadName ?? path.basename(params.filePath);
193
+ return `${verb}; filename*=UTF-8''${encodeURIComponent(filename)}`;
194
+ }
195
+ function isTextLikeMime(mime) {
196
+ const base = mime.split(";")[0]?.trim().toLowerCase() ?? "";
197
+ if (!base) {
198
+ return false;
199
+ }
200
+ if (base.startsWith("text/")) {
201
+ return true;
202
+ }
203
+ if (base.endsWith("+json") || base.endsWith("+xml")) {
204
+ return true;
205
+ }
206
+ return (base === "application/json" ||
207
+ base === "application/xml" ||
208
+ base === "application/javascript" ||
209
+ base === "application/x-javascript" ||
210
+ base === "application/typescript" ||
211
+ base === "application/x-typescript" ||
212
+ base === "application/yaml" ||
213
+ base === "application/x-yaml" ||
214
+ base === "application/toml" ||
215
+ base === "application/graphql" ||
216
+ base === "application/sql");
217
+ }
218
+ function isLikelyBinaryMime(mime) {
219
+ const base = mime.split(";")[0]?.trim().toLowerCase() ?? "";
220
+ if (!base) {
221
+ return false;
222
+ }
223
+ if (isTextLikeMime(base)) {
224
+ return false;
225
+ }
226
+ if (BINARY_MIME_EXACT.has(base)) {
227
+ return true;
228
+ }
229
+ return BINARY_MIME_PREFIXES.some((prefix) => base.startsWith(prefix));
230
+ }
231
+ async function sniffFileSample(filePath, maxBytes = 8192) {
232
+ const handle = await fs.open(filePath, "r");
233
+ try {
234
+ const buf = Buffer.alloc(maxBytes);
235
+ const { bytesRead } = await handle.read(buf, 0, maxBytes, 0);
236
+ return buf.subarray(0, bytesRead);
237
+ }
238
+ finally {
239
+ await handle.close();
240
+ }
241
+ }
242
+ function isLikelyTextBuffer(sample) {
243
+ if (sample.length === 0) {
244
+ return true;
245
+ }
246
+ for (const byte of sample) {
247
+ if (byte === 0) {
248
+ return false;
249
+ }
250
+ }
251
+ let suspicious = 0;
252
+ for (const byte of sample) {
253
+ if (byte === 9 || byte === 10 || byte === 13) {
254
+ continue;
255
+ }
256
+ if (byte < 32 || byte === 127) {
257
+ suspicious += 1;
258
+ }
259
+ }
260
+ try {
261
+ new TextDecoder("utf-8", { fatal: true }).decode(sample);
262
+ return suspicious / sample.length < 0.2;
263
+ }
264
+ catch {
265
+ return suspicious / sample.length < 0.05;
266
+ }
267
+ }
268
+ async function detectFileDeliveryCapabilities(params) {
269
+ const ext = path.extname(params.filePath).toLowerCase();
270
+ const mimeBase = params.mime.split(";")[0]?.trim().toLowerCase() ?? "";
271
+ const likelyBinaryByMime = isLikelyBinaryMime(params.mime);
272
+ const sample = await sniffFileSample(params.filePath);
273
+ const textByContent = isLikelyTextBuffer(sample);
274
+ const isBinary = sample.length > 0 ? !textByContent : likelyBinaryByMime;
275
+ const imagePreviewByMime = mimeBase.startsWith("image/");
276
+ const imagePreviewByExt = [
277
+ ".png",
278
+ ".jpg",
279
+ ".jpeg",
280
+ ".gif",
281
+ ".webp",
282
+ ".bmp",
283
+ ".tif",
284
+ ".tiff",
285
+ ".ico",
286
+ ".heic",
287
+ ".svg",
288
+ ].includes(ext);
289
+ const mediaPreviewByMime = mimeBase.startsWith("audio/") || mimeBase.startsWith("video/");
290
+ const mediaPreviewByExt = [
291
+ ".mp3",
292
+ ".wav",
293
+ ".flac",
294
+ ".ogg",
295
+ ".m4a",
296
+ ".aac",
297
+ ".mp4",
298
+ ".mov",
299
+ ".webm",
300
+ ".m4v",
301
+ ].includes(ext);
302
+ const previewSupported = (isBinary &&
303
+ (ext === ".pdf" ||
304
+ mimeBase === "application/pdf" ||
305
+ imagePreviewByMime ||
306
+ imagePreviewByExt ||
307
+ mediaPreviewByMime ||
308
+ mediaPreviewByExt)) ||
309
+ (!isBinary && (MARKDOWN_PREVIEW_EXTENSIONS.has(ext) || INLINE_TEXT_PREVIEW_EXTENSIONS.has(ext)));
310
+ return { isBinary, previewSupported };
311
+ }
312
+ function shouldRenderMarkdownPreview(filePath, presentation) {
313
+ if (presentation !== "preview") {
314
+ return false;
315
+ }
316
+ return MARKDOWN_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
317
+ }
318
+ function stripLeadingFrontMatter(input) {
319
+ const source = input.replace(/^\uFEFF/, "");
320
+ const match = source.match(/^---\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)\r?\n?/);
321
+ if (!match) {
322
+ return source;
323
+ }
324
+ return source.slice(match[0].length);
325
+ }
326
+ function buildMarkdownPreviewHtml(params) {
327
+ const payload = Buffer.from(stripLeadingFrontMatter(params.markdown), "utf8").toString("base64");
328
+ return renderMarkdownPreviewTemplate({
329
+ title: params.title,
330
+ payload,
331
+ });
332
+ }
333
+ function resolveBinPath(bin) {
334
+ if (path.isAbsolute(bin)) {
335
+ return bin;
336
+ }
337
+ const pathEnv = process.env.PATH ?? "";
338
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
339
+ const candidates = process.platform === "win32" ? [bin, `${bin}.exe`, `${bin}.cmd`, `${bin}.bat`] : [bin];
340
+ for (const dir of dirs) {
341
+ for (const candidate of candidates) {
342
+ const full = path.join(dir, candidate);
343
+ try {
344
+ const stat = spawnSync(full, ["--version"], {
345
+ timeout: 1500,
346
+ stdio: "ignore",
347
+ });
348
+ if (!stat.error) {
349
+ return full;
350
+ }
351
+ }
352
+ catch {
353
+ // ignore candidate
354
+ }
355
+ }
356
+ }
357
+ return undefined;
358
+ }
359
+ function extractCloudflaredVersion(output) {
360
+ const match = output.match(/version\s+([0-9]+\.[0-9]+\.[0-9]+)/i);
361
+ return match?.[1];
362
+ }
363
+ async function fileExists(input) {
364
+ try {
365
+ await fs.access(input);
366
+ return true;
367
+ }
368
+ catch {
369
+ return false;
370
+ }
371
+ }
372
+ async function mkdirp(input) {
373
+ await fs.mkdir(input, { recursive: true });
374
+ }
375
+ async function findFreePort() {
376
+ return await new Promise((resolve, reject) => {
377
+ const server = net.createServer();
378
+ server.once("error", reject);
379
+ server.listen(0, "127.0.0.1", () => {
380
+ const address = server.address();
381
+ if (!address || typeof address === "string") {
382
+ server.close();
383
+ reject(new Error("failed to resolve free port"));
384
+ return;
385
+ }
386
+ const port = address.port;
387
+ server.close((error) => {
388
+ if (error) {
389
+ reject(error);
390
+ return;
391
+ }
392
+ resolve(port);
393
+ });
394
+ });
395
+ });
396
+ }
397
+ async function probeLocalPort(port) {
398
+ return await new Promise((resolve) => {
399
+ const socket = net.connect({ port, host: "127.0.0.1" });
400
+ const done = (ok) => {
401
+ socket.removeAllListeners();
402
+ socket.destroy();
403
+ resolve(ok);
404
+ };
405
+ socket.once("connect", () => done(true));
406
+ socket.once("error", () => done(false));
407
+ socket.setTimeout(1200, () => done(false));
408
+ });
409
+ }
410
+ function formatRelativeUrl(input) {
411
+ return input
412
+ .split(path.sep)
413
+ .map((segment) => encodeURIComponent(segment))
414
+ .join("/");
415
+ }
416
+ async function walkFiles(dir, baseDir = dir) {
417
+ const entries = await fs.readdir(dir, { withFileTypes: true });
418
+ const files = [];
419
+ for (const entry of entries) {
420
+ if (WINDOWS_METADATA_SUFFIX_RE.test(entry.name)) {
421
+ continue;
422
+ }
423
+ const abs = path.join(dir, entry.name);
424
+ if (entry.isDirectory()) {
425
+ files.push(...(await walkFiles(abs, baseDir)));
426
+ continue;
427
+ }
428
+ if (entry.isFile()) {
429
+ files.push(path.relative(baseDir, abs));
430
+ }
431
+ }
432
+ return files;
433
+ }
434
+ async function sha256File(input) {
435
+ const hash = crypto.createHash("sha256");
436
+ const stream = createReadStream(input);
437
+ for await (const chunk of stream) {
438
+ hash.update(chunk);
439
+ }
440
+ return hash.digest("hex");
441
+ }
442
+ function parseBasicAuth(input) {
443
+ if (!input || !input.startsWith("Basic ")) {
444
+ return null;
445
+ }
446
+ const encoded = input.slice("Basic ".length).trim();
447
+ try {
448
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
449
+ const index = decoded.indexOf(":");
450
+ if (index < 0) {
451
+ return null;
452
+ }
453
+ return { username: decoded.slice(0, index), password: decoded.slice(index + 1) };
454
+ }
455
+ catch {
456
+ return null;
457
+ }
458
+ }
459
+ function parseBearerToken(authHeader) {
460
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
461
+ return undefined;
462
+ }
463
+ const token = authHeader.slice("Bearer ".length).trim();
464
+ return token || undefined;
465
+ }
466
+ function shouldAllowPath(pathname, allowlist) {
467
+ if (allowlist.length === 0) {
468
+ return true;
469
+ }
470
+ return allowlist.some((prefix) => {
471
+ if (prefix === "/") {
472
+ return true;
473
+ }
474
+ if (pathname === prefix) {
475
+ return true;
476
+ }
477
+ return pathname.startsWith(`${prefix.endsWith("/") ? prefix : `${prefix}/`}`);
478
+ });
479
+ }
480
+ function isValidQuickTunnelUrl(input) {
481
+ try {
482
+ const parsed = new URL(input);
483
+ if (parsed.protocol !== "https:") {
484
+ return false;
485
+ }
486
+ const host = parsed.hostname.toLowerCase();
487
+ if (!host.endsWith(".trycloudflare.com")) {
488
+ return false;
489
+ }
490
+ const subdomain = host.slice(0, -".trycloudflare.com".length);
491
+ if (!subdomain || subdomain.includes(".")) {
492
+ return false;
493
+ }
494
+ if (INVALID_QUICK_TUNNEL_SUBDOMAINS.has(subdomain)) {
495
+ return false;
496
+ }
497
+ return /^[a-z0-9-]+$/i.test(subdomain);
498
+ }
499
+ catch {
500
+ return false;
501
+ }
502
+ }
503
+ function pickQuickTunnelUrlFromLine(line) {
504
+ const matches = line.match(CLOUDFLARE_URL_RE);
505
+ if (!matches) {
506
+ return undefined;
507
+ }
508
+ for (const candidate of matches) {
509
+ if (isValidQuickTunnelUrl(candidate)) {
510
+ return candidate;
511
+ }
512
+ }
513
+ return undefined;
514
+ }
515
+ function matchAuditFilters(event, filters) {
516
+ const fromMs = timestampMs(filters.from_ts);
517
+ const toMs = timestampMs(filters.to_ts);
518
+ const eventMs = timestampMs(event.ts);
519
+ if (filters.id && event.id !== filters.id) {
520
+ return false;
521
+ }
522
+ if (filters.event && event.event !== filters.event) {
523
+ return false;
524
+ }
525
+ if (filters.type && event.type !== filters.type) {
526
+ return false;
527
+ }
528
+ if (filters.from_ts) {
529
+ if (fromMs !== undefined && eventMs !== undefined) {
530
+ if (eventMs < fromMs) {
531
+ return false;
532
+ }
533
+ }
534
+ else if (event.ts < filters.from_ts) {
535
+ return false;
536
+ }
537
+ }
538
+ if (filters.to_ts) {
539
+ if (toMs !== undefined && eventMs !== undefined) {
540
+ if (eventMs > toMs) {
541
+ return false;
542
+ }
543
+ }
544
+ else if (event.ts > filters.to_ts) {
545
+ return false;
546
+ }
547
+ }
548
+ return true;
549
+ }
550
+ export class CfshareManager {
551
+ logger;
552
+ resolvePath;
553
+ pluginConfig;
554
+ cloudflaredPathInput;
555
+ stateDir;
556
+ policyFile;
557
+ ignoreFile;
558
+ workspaceRoot;
559
+ auditFile;
560
+ sessionsFile;
561
+ exportsDir;
562
+ initialized = false;
563
+ initializing;
564
+ policy;
565
+ policyWarnings = [];
566
+ ignoreMatcher = ignore();
567
+ cloudflaredResolvedPath;
568
+ guardTimer;
569
+ sessions = new Map();
570
+ constructor(api) {
571
+ this.logger = api.logger;
572
+ this.resolvePath = api.resolvePath;
573
+ this.pluginConfig = (api.pluginConfig ?? {});
574
+ this.stateDir = this.resolvePath(this.pluginConfig.stateDir ?? "~/.openclaw/cfshare");
575
+ this.policyFile = this.resolvePath(this.pluginConfig.policyFile ?? path.join(this.stateDir, "policy.json"));
576
+ this.ignoreFile = this.resolvePath(this.pluginConfig.ignoreFile ?? path.join(this.stateDir, "policy.ignore"));
577
+ this.workspaceRoot = path.join(this.stateDir, "workspaces");
578
+ this.auditFile = path.join(this.stateDir, "audit.jsonl");
579
+ this.sessionsFile = path.join(this.stateDir, "sessions.json");
580
+ this.exportsDir = path.join(this.stateDir, "exports");
581
+ this.cloudflaredPathInput = this.pluginConfig.cloudflaredPath ?? "cloudflared";
582
+ }
583
+ async ensureInitialized() {
584
+ if (this.initialized) {
585
+ return;
586
+ }
587
+ if (!this.initializing) {
588
+ this.initializing = this.initialize();
589
+ }
590
+ await this.initializing;
591
+ }
592
+ async initialize() {
593
+ await mkdirp(this.stateDir);
594
+ await mkdirp(this.workspaceRoot);
595
+ await mkdirp(this.exportsDir);
596
+ await this.reloadPolicy();
597
+ this.startGuard();
598
+ this.initialized = true;
599
+ }
600
+ async reloadPolicy() {
601
+ const loaded = await loadPolicy({
602
+ policyFile: this.policyFile,
603
+ ignoreFile: this.ignoreFile,
604
+ pluginConfig: this.pluginConfig,
605
+ });
606
+ this.policy = loaded.effective;
607
+ this.policyWarnings = loaded.warnings;
608
+ this.ignoreMatcher = loaded.matcher;
609
+ }
610
+ appendLog(session, component, line) {
611
+ const entry = { ts: nowIso(), component, line };
612
+ session.logs.push(entry);
613
+ if (session.logs.length > MAX_LOG_LINES) {
614
+ session.logs.splice(0, session.logs.length - MAX_LOG_LINES);
615
+ }
616
+ }
617
+ async writeAudit(event) {
618
+ try {
619
+ await fs.appendFile(this.auditFile, `${JSON.stringify(event)}\n`, "utf8");
620
+ }
621
+ catch (error) {
622
+ this.logger.warn(`cfshare: failed to write audit event: ${String(error)}`);
623
+ }
624
+ }
625
+ async persistSessionsSnapshot() {
626
+ const records = Array.from(this.sessions.values()).map((session) => ({
627
+ id: session.id,
628
+ type: session.type,
629
+ status: session.status,
630
+ expiresAt: session.expiresAt,
631
+ workspaceDir: session.workspaceDir,
632
+ processPid: session.process?.pid,
633
+ }));
634
+ await fs.writeFile(this.sessionsFile, JSON.stringify(records, null, 2), "utf8");
635
+ }
636
+ makeAccessState(params) {
637
+ const allowlistPaths = normalizeAllowlistPaths(params.allowlistPaths);
638
+ if (params.mode === "token") {
639
+ return {
640
+ mode: "token",
641
+ protectOrigin: params.protectOrigin,
642
+ allowlistPaths,
643
+ token: crypto.randomBytes(16).toString("hex"),
644
+ };
645
+ }
646
+ if (params.mode === "basic") {
647
+ return {
648
+ mode: "basic",
649
+ protectOrigin: params.protectOrigin,
650
+ allowlistPaths,
651
+ username: "cfshare",
652
+ password: crypto.randomBytes(12).toString("base64url"),
653
+ };
654
+ }
655
+ return {
656
+ mode: "none",
657
+ protectOrigin: params.protectOrigin,
658
+ allowlistPaths,
659
+ };
660
+ }
661
+ makeResponsePublicUrl(session) {
662
+ const base = session.publicUrl;
663
+ if (!base) {
664
+ return undefined;
665
+ }
666
+ if (session.access.mode !== "token" || !session.access.token) {
667
+ return base;
668
+ }
669
+ try {
670
+ const out = new URL(base);
671
+ out.searchParams.set("token", session.access.token);
672
+ return out.toString();
673
+ }
674
+ catch {
675
+ const sep = base.includes("?") ? "&" : "?";
676
+ return `${base}${sep}token=${encodeURIComponent(session.access.token)}`;
677
+ }
678
+ }
679
+ buildRateLimiter(policy) {
680
+ if (!policy.enabled) {
681
+ return () => true;
682
+ }
683
+ const state = new Map();
684
+ return (ip) => {
685
+ const now = Date.now();
686
+ const row = state.get(ip);
687
+ if (!row || now - row.windowStart >= policy.windowMs) {
688
+ state.set(ip, { windowStart: now, count: 1 });
689
+ return true;
690
+ }
691
+ if (row.count >= policy.maxRequests) {
692
+ return false;
693
+ }
694
+ row.count += 1;
695
+ return true;
696
+ };
697
+ }
698
+ isAuthorized(req, access) {
699
+ if (!access.protectOrigin || access.mode === "none") {
700
+ return true;
701
+ }
702
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
703
+ if (access.mode === "token") {
704
+ const queryToken = ensureString(url.searchParams.get("token"));
705
+ const headerToken = ensureString(req.headers["x-cfshare-token"]);
706
+ const bearer = parseBearerToken(req.headers.authorization);
707
+ return queryToken === access.token || headerToken === access.token || bearer === access.token;
708
+ }
709
+ const basic = parseBasicAuth(req.headers.authorization);
710
+ return basic?.username === access.username && basic?.password === access.password;
711
+ }
712
+ async startReverseProxy(params) {
713
+ const proxyPort = await findFreePort();
714
+ const allowRequest = this.buildRateLimiter(this.policy.rateLimit);
715
+ const upstreamBase = `http://127.0.0.1:${params.upstreamPort}`;
716
+ const server = http.createServer((req, res) => {
717
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
718
+ const clientIp = req.socket.remoteAddress ?? "unknown";
719
+ params.session.stats.requests += 1;
720
+ params.session.stats.lastAccessAt = nowIso();
721
+ if (!allowRequest(clientIp)) {
722
+ res.writeHead(429, { "content-type": "application/json" });
723
+ res.end(JSON.stringify({ error: "rate_limited" }));
724
+ this.appendLog(params.session, "origin", `rate limit blocked ip=${clientIp}`);
725
+ return;
726
+ }
727
+ if (!shouldAllowPath(url.pathname, params.access.allowlistPaths)) {
728
+ res.writeHead(403, { "content-type": "application/json" });
729
+ res.end(JSON.stringify({ error: "path_not_allowed", path: url.pathname }));
730
+ return;
731
+ }
732
+ if (!this.isAuthorized(req, params.access)) {
733
+ const headers = {
734
+ "content-type": "application/json",
735
+ };
736
+ if (params.access.mode === "basic") {
737
+ headers["www-authenticate"] = 'Basic realm="cfshare"';
738
+ }
739
+ res.writeHead(401, headers);
740
+ res.end(JSON.stringify({ error: "unauthorized" }));
741
+ return;
742
+ }
743
+ const upstreamUrl = new URL(url.pathname + url.search, upstreamBase);
744
+ const transport = upstreamUrl.protocol === "https:" ? https : http;
745
+ const proxyReq = transport.request({
746
+ protocol: upstreamUrl.protocol,
747
+ hostname: upstreamUrl.hostname,
748
+ port: upstreamUrl.port,
749
+ method: req.method,
750
+ path: upstreamUrl.pathname + upstreamUrl.search,
751
+ headers: {
752
+ ...req.headers,
753
+ host: upstreamUrl.host,
754
+ },
755
+ }, (proxyRes) => {
756
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
757
+ proxyRes.on("data", (chunk) => {
758
+ params.session.stats.bytesSent += chunk.length;
759
+ });
760
+ proxyRes.pipe(res);
761
+ });
762
+ proxyReq.on("error", (error) => {
763
+ this.appendLog(params.session, "origin", `proxy error: ${String(error)}`);
764
+ if (!res.headersSent) {
765
+ res.writeHead(502, { "content-type": "application/json" });
766
+ res.end(JSON.stringify({ error: "proxy_error" }));
767
+ return;
768
+ }
769
+ res.end();
770
+ });
771
+ req.pipe(proxyReq);
772
+ });
773
+ await new Promise((resolve, reject) => {
774
+ server.once("error", reject);
775
+ server.listen(proxyPort, "127.0.0.1", () => resolve());
776
+ });
777
+ this.appendLog(params.session, "origin", `reverse proxy listening on 127.0.0.1:${proxyPort}`);
778
+ return { server, port: proxyPort };
779
+ }
780
+ async sendFileResponse(params) {
781
+ const stat = await fs.stat(params.filePath);
782
+ const detectedMime = String(mimeLookup(params.filePath) || "application/octet-stream");
783
+ const capabilities = await detectFileDeliveryCapabilities({
784
+ filePath: params.filePath,
785
+ mime: detectedMime,
786
+ });
787
+ let presentation = params.presentation ?? "download";
788
+ if (presentation === "preview" && !capabilities.previewSupported) {
789
+ presentation = "raw";
790
+ }
791
+ const mime = presentation === "raw" && !capabilities.isBinary
792
+ ? "text/plain; charset=utf-8"
793
+ : detectedMime;
794
+ const method = (params.req.method ?? "GET").toUpperCase();
795
+ if (method !== "GET" && method !== "HEAD") {
796
+ params.res.writeHead(405, { "content-type": "application/json" });
797
+ params.res.end(JSON.stringify({ error: "method_not_allowed" }));
798
+ return;
799
+ }
800
+ if (shouldRenderMarkdownPreview(params.filePath, presentation)) {
801
+ const fileRaw = await fs.readFile(params.filePath, "utf8");
802
+ const previewHtml = buildMarkdownPreviewHtml({
803
+ title: params.downloadName ?? path.basename(params.filePath),
804
+ markdown: fileRaw,
805
+ });
806
+ const body = Buffer.from(previewHtml, "utf8");
807
+ const headers = {
808
+ "content-type": "text/html; charset=utf-8",
809
+ "cache-control": "no-store",
810
+ "x-content-type-options": "nosniff",
811
+ "content-length": String(body.length),
812
+ };
813
+ params.res.writeHead(200, headers);
814
+ if (method === "HEAD") {
815
+ params.res.end();
816
+ return;
817
+ }
818
+ if (params.countAsDownload) {
819
+ params.session.stats.downloads += 1;
820
+ }
821
+ params.session.stats.bytesSent += body.length;
822
+ params.res.end(body);
823
+ return;
824
+ }
825
+ const headers = {
826
+ "content-type": String(mime),
827
+ "accept-ranges": "bytes",
828
+ "cache-control": "no-store",
829
+ "x-content-type-options": "nosniff",
830
+ };
831
+ const contentDisposition = buildContentDisposition({
832
+ mode: presentation,
833
+ filePath: params.filePath,
834
+ downloadName: params.downloadName,
835
+ });
836
+ if (contentDisposition) {
837
+ headers["content-disposition"] = contentDisposition;
838
+ }
839
+ const range = ensureString(params.req.headers.range);
840
+ if (!range) {
841
+ headers["content-length"] = String(stat.size);
842
+ params.res.writeHead(200, headers);
843
+ if (method === "HEAD") {
844
+ params.res.end();
845
+ return;
846
+ }
847
+ if (params.countAsDownload) {
848
+ params.session.stats.downloads += 1;
849
+ }
850
+ params.session.stats.bytesSent += stat.size;
851
+ await pipeline(createReadStream(params.filePath), params.res);
852
+ return;
853
+ }
854
+ const match = range.match(/^bytes=(\d*)-(\d*)$/i);
855
+ if (!match) {
856
+ params.res.writeHead(416, { "content-type": "application/json" });
857
+ params.res.end(JSON.stringify({ error: "invalid_range" }));
858
+ return;
859
+ }
860
+ const start = match[1] ? Number.parseInt(match[1], 10) : 0;
861
+ const end = match[2] ? Number.parseInt(match[2], 10) : stat.size - 1;
862
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || end >= stat.size) {
863
+ params.res.writeHead(416, { "content-type": "application/json" });
864
+ params.res.end(JSON.stringify({ error: "invalid_range" }));
865
+ return;
866
+ }
867
+ headers["content-length"] = String(end - start + 1);
868
+ headers["content-range"] = `bytes ${start}-${end}/${stat.size}`;
869
+ params.res.writeHead(206, headers);
870
+ if (method === "HEAD") {
871
+ params.res.end();
872
+ return;
873
+ }
874
+ if (params.countAsDownload) {
875
+ params.session.stats.downloads += 1;
876
+ }
877
+ params.session.stats.bytesSent += end - start + 1;
878
+ await pipeline(createReadStream(params.filePath, { start, end }), params.res);
879
+ }
880
+ async createZipArchive(workspaceDir) {
881
+ const zipPath = path.join(workspaceDir, "_cfshare_bundle.zip");
882
+ const files = await walkFiles(workspaceDir);
883
+ await new Promise((resolve, reject) => {
884
+ const zip = new yazl.ZipFile();
885
+ const out = createWriteStream(zipPath);
886
+ out.once("error", reject);
887
+ out.once("close", () => resolve());
888
+ zip.outputStream.pipe(out);
889
+ for (const relPath of files) {
890
+ // Zip entries should use "/" separators regardless of OS.
891
+ const zipEntry = relPath.split(path.sep).join("/");
892
+ if (zipEntry === path.basename(zipPath)) {
893
+ continue;
894
+ }
895
+ zip.addFile(path.join(workspaceDir, relPath), zipEntry);
896
+ }
897
+ zip.end();
898
+ });
899
+ const stat = await fs.stat(zipPath);
900
+ return { zipPath, size: stat.size };
901
+ }
902
+ async createFolderZipArchive(params) {
903
+ const safeFolderPath = normalizeWorkspaceRelativePath(params.folderPath);
904
+ if (!safeFolderPath) {
905
+ throw new Error("invalid_folder_path");
906
+ }
907
+ const folderAbs = path.join(params.workspaceDir, safeFolderPath);
908
+ if (!isSubPath(folderAbs, params.workspaceDir) || !(await fileExists(folderAbs))) {
909
+ throw new Error("folder_not_found");
910
+ }
911
+ const stat = await fs.stat(folderAbs);
912
+ if (!stat.isDirectory()) {
913
+ throw new Error("not_a_directory");
914
+ }
915
+ const zipName = `${sanitizeFilename(path.basename(folderAbs) || "folder")}.zip`;
916
+ const tempZipName = `.cfshare_folder_${randomId("zip")}.zip`;
917
+ const zipPath = path.join(params.workspaceDir, tempZipName);
918
+ const files = await walkFiles(folderAbs);
919
+ await new Promise((resolve, reject) => {
920
+ const zip = new yazl.ZipFile();
921
+ const out = createWriteStream(zipPath);
922
+ out.once("error", reject);
923
+ out.once("close", () => resolve());
924
+ zip.outputStream.pipe(out);
925
+ for (const relPath of files) {
926
+ const zipEntry = path.posix.join(path.basename(folderAbs), relPath.split(path.sep).join("/"));
927
+ zip.addFile(path.join(folderAbs, relPath), zipEntry);
928
+ }
929
+ zip.end();
930
+ });
931
+ const zipStat = await fs.stat(zipPath);
932
+ return {
933
+ zipPath,
934
+ size: zipStat.size,
935
+ downloadName: zipName,
936
+ };
937
+ }
938
+ async startFileServer(params) {
939
+ const port = await findFreePort();
940
+ const manifest = [];
941
+ let zipBundle;
942
+ if (params.mode === "normal") {
943
+ const files = await walkFiles(params.workspaceDir);
944
+ for (const relPath of files) {
945
+ if (relPath === "_cfshare_bundle.zip") {
946
+ continue;
947
+ }
948
+ const abs = path.join(params.workspaceDir, relPath);
949
+ const stat = await fs.stat(abs);
950
+ const detectedMime = String(mimeLookup(abs) || "application/octet-stream");
951
+ const capabilities = await detectFileDeliveryCapabilities({
952
+ filePath: abs,
953
+ mime: detectedMime,
954
+ });
955
+ manifest.push({
956
+ name: relPath,
957
+ size: stat.size,
958
+ sha256: await sha256File(abs),
959
+ relative_url: `/${formatRelativeUrl(relPath)}`,
960
+ modified_at: toLocalIso(stat.mtime),
961
+ is_binary: capabilities.isBinary,
962
+ preview_supported: capabilities.previewSupported,
963
+ });
964
+ }
965
+ }
966
+ else {
967
+ zipBundle = await this.createZipArchive(params.workspaceDir);
968
+ manifest.push({
969
+ name: "download.zip",
970
+ size: zipBundle.size,
971
+ sha256: await sha256File(zipBundle.zipPath),
972
+ relative_url: "/__cfshare__/download.zip",
973
+ modified_at: nowIso(),
974
+ is_binary: true,
975
+ preview_supported: false,
976
+ });
977
+ }
978
+ const explorerManifest = manifest;
979
+ const allowRequest = this.buildRateLimiter(this.policy.rateLimit);
980
+ const server = http.createServer(async (req, res) => {
981
+ const clientIp = req.socket.remoteAddress ?? "unknown";
982
+ params.session.stats.requests += 1;
983
+ params.session.stats.lastAccessAt = nowIso();
984
+ if (!allowRequest(clientIp)) {
985
+ res.writeHead(429, { "content-type": "application/json" });
986
+ res.end(JSON.stringify({ error: "rate_limited" }));
987
+ return;
988
+ }
989
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
990
+ const pathname = decodeURIComponent(url.pathname);
991
+ const requestedPresentation = resolveRequestPresentation(url, params.presentation);
992
+ const checkMaxDownloads = async () => {
993
+ if (typeof params.maxDownloads !== "number") {
994
+ return;
995
+ }
996
+ if (params.session.stats.downloads >= params.maxDownloads) {
997
+ await this.stopExposure(params.session.id, { reason: "max_downloads_reached", expired: false });
998
+ }
999
+ };
1000
+ try {
1001
+ if (pathname === "/") {
1002
+ if (params.mode === "normal" && params.directSingleFileRoot && explorerManifest.length === 1) {
1003
+ const filePath = path.join(params.workspaceDir, explorerManifest[0].name);
1004
+ await this.sendFileResponse({
1005
+ req,
1006
+ res,
1007
+ session: params.session,
1008
+ filePath,
1009
+ presentation: requestedPresentation,
1010
+ countAsDownload: true,
1011
+ });
1012
+ await checkMaxDownloads();
1013
+ return;
1014
+ }
1015
+ const body = Buffer.from(renderFileExplorerTemplate({
1016
+ title: "cfshare",
1017
+ mode: params.mode,
1018
+ presentation: params.presentation,
1019
+ manifest: explorerManifest,
1020
+ autoEnterSingleDirectory: params.autoEnterSingleDirectory,
1021
+ rootDirectories: params.rootDirectories,
1022
+ }), "utf8");
1023
+ res.writeHead(200, {
1024
+ "content-type": "text/html; charset=utf-8",
1025
+ "cache-control": "no-store",
1026
+ "x-content-type-options": "nosniff",
1027
+ "content-length": String(body.length),
1028
+ });
1029
+ if ((req.method ?? "GET").toUpperCase() === "HEAD") {
1030
+ res.end();
1031
+ return;
1032
+ }
1033
+ params.session.stats.bytesSent += body.length;
1034
+ res.end(body);
1035
+ return;
1036
+ }
1037
+ if (pathname === "/__cfshare__/download-folder.zip" && params.mode === "normal") {
1038
+ const folderPath = normalizeWorkspaceRelativePath(ensureString(url.searchParams.get("path")));
1039
+ if (!folderPath) {
1040
+ res.writeHead(400, { "content-type": "application/json" });
1041
+ res.end(JSON.stringify({ error: "invalid_folder_path" }));
1042
+ return;
1043
+ }
1044
+ let bundle;
1045
+ try {
1046
+ bundle = await this.createFolderZipArchive({
1047
+ workspaceDir: params.workspaceDir,
1048
+ folderPath,
1049
+ });
1050
+ }
1051
+ catch (error) {
1052
+ const message = error instanceof Error ? error.message : String(error);
1053
+ const status = message === "folder_not_found" ? 404 : 400;
1054
+ res.writeHead(status, { "content-type": "application/json" });
1055
+ res.end(JSON.stringify({ error: message }));
1056
+ return;
1057
+ }
1058
+ try {
1059
+ await this.sendFileResponse({
1060
+ req,
1061
+ res,
1062
+ session: params.session,
1063
+ filePath: bundle.zipPath,
1064
+ downloadName: bundle.downloadName,
1065
+ presentation: "download",
1066
+ countAsDownload: true,
1067
+ });
1068
+ }
1069
+ finally {
1070
+ await fs.unlink(bundle.zipPath).catch(() => undefined);
1071
+ }
1072
+ await checkMaxDownloads();
1073
+ return;
1074
+ }
1075
+ if (pathname === "/__cfshare__/download.zip" && params.mode === "zip" && zipBundle) {
1076
+ await this.sendFileResponse({
1077
+ req,
1078
+ res,
1079
+ session: params.session,
1080
+ filePath: zipBundle.zipPath,
1081
+ downloadName: "download.zip",
1082
+ presentation: "download",
1083
+ countAsDownload: true,
1084
+ });
1085
+ await checkMaxDownloads();
1086
+ return;
1087
+ }
1088
+ if (params.mode === "zip") {
1089
+ res.writeHead(404, { "content-type": "application/json" });
1090
+ res.end(JSON.stringify({ error: "not_found" }));
1091
+ return;
1092
+ }
1093
+ const normalized = pathname.replace(/^\/+/, "");
1094
+ const target = path.join(params.workspaceDir, normalized);
1095
+ if (!isSubPath(target, params.workspaceDir) || !(await fileExists(target))) {
1096
+ res.writeHead(404, { "content-type": "application/json" });
1097
+ res.end(JSON.stringify({ error: "not_found" }));
1098
+ return;
1099
+ }
1100
+ const stat = await fs.stat(target);
1101
+ if (!stat.isFile()) {
1102
+ res.writeHead(404, { "content-type": "application/json" });
1103
+ res.end(JSON.stringify({ error: "not_file" }));
1104
+ return;
1105
+ }
1106
+ await this.sendFileResponse({
1107
+ req,
1108
+ res,
1109
+ session: params.session,
1110
+ filePath: target,
1111
+ presentation: requestedPresentation,
1112
+ countAsDownload: true,
1113
+ });
1114
+ await checkMaxDownloads();
1115
+ }
1116
+ catch (error) {
1117
+ this.appendLog(params.session, "origin", `file server error: ${String(error)}`);
1118
+ if (!res.headersSent) {
1119
+ res.writeHead(500, { "content-type": "application/json" });
1120
+ res.end(JSON.stringify({ error: "internal_error" }));
1121
+ return;
1122
+ }
1123
+ res.end();
1124
+ }
1125
+ });
1126
+ await new Promise((resolve, reject) => {
1127
+ server.once("error", reject);
1128
+ server.listen(port, "127.0.0.1", () => resolve());
1129
+ });
1130
+ this.appendLog(params.session, "origin", `file server listening on 127.0.0.1:${port}`);
1131
+ return { server, port, manifest };
1132
+ }
1133
+ async detectWorkspaceRootBehavior(workspaceDir) {
1134
+ const entries = await fs.readdir(workspaceDir, { withFileTypes: true });
1135
+ const rootDirectories = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1136
+ if (entries.length !== 1) {
1137
+ return {
1138
+ directSingleFileRoot: false,
1139
+ autoEnterSingleDirectory: false,
1140
+ rootDirectories,
1141
+ };
1142
+ }
1143
+ const rootEntry = entries[0];
1144
+ return {
1145
+ directSingleFileRoot: rootEntry.isFile(),
1146
+ autoEnterSingleDirectory: rootEntry.isDirectory(),
1147
+ rootDirectories,
1148
+ };
1149
+ }
1150
+ async copyInputsToWorkspace(pathsInput, workspaceDir, ctx) {
1151
+ const allowedRoots = this.policy.allowedPathRoots.map((root) => this.resolvePath(root));
1152
+ const workspaceRoot = ctx?.workspaceDir ? this.resolvePath(ctx.workspaceDir) : undefined;
1153
+ for (const item of pathsInput) {
1154
+ const resolved = path.resolve(item);
1155
+ const real = await fs.realpath(resolved).catch(() => resolved);
1156
+ const ignoreCandidates = getIgnoreMatchCandidates(real);
1157
+ if (ignoreCandidates.some((candidate) => this.ignoreMatcher.ignores(candidate))) {
1158
+ throw new Error(`path blocked by ignore policy: ${item}`);
1159
+ }
1160
+ if (allowedRoots.length > 0 && !allowedRoots.some((root) => isSubPath(real, root))) {
1161
+ throw new Error(`path outside allowed roots: ${item}`);
1162
+ }
1163
+ if (workspaceRoot && !isSubPath(real, workspaceRoot)) {
1164
+ this.logger.warn(`cfshare: exposing path outside workspace (${item})`);
1165
+ }
1166
+ const sourceStat = await fs.stat(real);
1167
+ const baseName = sanitizeFilename(path.basename(real) || "item");
1168
+ const makeCandidate = (n) => {
1169
+ if (n === 0) {
1170
+ return baseName;
1171
+ }
1172
+ // For directories, treat dots as part of the name (do not split extension).
1173
+ if (sourceStat.isDirectory()) {
1174
+ return `${baseName}_${n}`;
1175
+ }
1176
+ // For files, keep extension stable: "a.txt" -> "a_1.txt".
1177
+ const parsed = path.parse(baseName);
1178
+ return `${parsed.name || "item"}_${n}${parsed.ext || ""}`;
1179
+ };
1180
+ let target = path.join(workspaceDir, makeCandidate(0));
1181
+ let seq = 1;
1182
+ while (await fileExists(target)) {
1183
+ target = path.join(workspaceDir, makeCandidate(seq));
1184
+ seq += 1;
1185
+ }
1186
+ if (sourceStat.isDirectory()) {
1187
+ await fs.cp(real, target, { recursive: true, dereference: true });
1188
+ }
1189
+ else if (sourceStat.isFile()) {
1190
+ await mkdirp(path.dirname(target));
1191
+ await fs.copyFile(real, target);
1192
+ }
1193
+ else {
1194
+ throw new Error(`unsupported path type: ${item}`);
1195
+ }
1196
+ }
1197
+ }
1198
+ makeExposureRecord(session) {
1199
+ return {
1200
+ id: session.id,
1201
+ type: session.type,
1202
+ status: session.status,
1203
+ public_url: this.makeResponsePublicUrl(session),
1204
+ local_url: session.localUrl,
1205
+ expires_at: session.expiresAt,
1206
+ };
1207
+ }
1208
+ async summarizeExposeInputs(pathsInput) {
1209
+ const out = [];
1210
+ for (const rawPath of pathsInput) {
1211
+ const resolved = path.resolve(rawPath);
1212
+ const real = await fs.realpath(resolved).catch(() => resolved);
1213
+ const stat = await fs.stat(real);
1214
+ if (stat.isDirectory()) {
1215
+ out.push({
1216
+ input_path: rawPath,
1217
+ name: path.basename(real) || rawPath,
1218
+ type: "directory",
1219
+ });
1220
+ continue;
1221
+ }
1222
+ if (stat.isFile()) {
1223
+ out.push({
1224
+ input_path: rawPath,
1225
+ name: path.basename(real) || rawPath,
1226
+ type: "file",
1227
+ size: stat.size,
1228
+ });
1229
+ }
1230
+ }
1231
+ return out;
1232
+ }
1233
+ buildExposeFilesResponseManifest(params) {
1234
+ const allFiles = params.inputs.length > 0 && params.inputs.every((entry) => entry.type === "file");
1235
+ const shouldReturnDetailed = allFiles && params.inputs.length > 1;
1236
+ const totalSizeBytes = params.fullManifest.reduce((sum, entry) => sum + entry.size, 0);
1237
+ if (shouldReturnDetailed) {
1238
+ const limit = Math.max(1, Math.trunc(params.detailedLimit ?? MAX_RESPONSE_MANIFEST_ITEMS));
1239
+ const manifest = params.fullManifest.slice(0, limit);
1240
+ return {
1241
+ manifest,
1242
+ manifest_mode: "detailed",
1243
+ manifest_meta: {
1244
+ total_count: params.fullManifest.length,
1245
+ returned_count: manifest.length,
1246
+ truncated: params.fullManifest.length > manifest.length,
1247
+ total_size_bytes: totalSizeBytes,
1248
+ },
1249
+ };
1250
+ }
1251
+ return {
1252
+ manifest: params.inputs,
1253
+ manifest_mode: "summary",
1254
+ manifest_meta: {
1255
+ total_count: params.inputs.length,
1256
+ returned_count: params.inputs.length,
1257
+ truncated: false,
1258
+ total_size_bytes: totalSizeBytes,
1259
+ },
1260
+ };
1261
+ }
1262
+ makeManifestResponse(manifest, limit = MAX_RESPONSE_MANIFEST_ITEMS) {
1263
+ const source = manifest ?? [];
1264
+ const safeLimit = Math.max(1, Math.trunc(limit));
1265
+ const sliced = source.slice(0, safeLimit);
1266
+ const totalSizeBytes = source.reduce((sum, entry) => sum + entry.size, 0);
1267
+ return {
1268
+ manifest: sliced,
1269
+ manifest_meta: {
1270
+ total_count: source.length,
1271
+ returned_count: sliced.length,
1272
+ truncated: source.length > sliced.length,
1273
+ total_size_bytes: totalSizeBytes,
1274
+ },
1275
+ };
1276
+ }
1277
+ startGuard() {
1278
+ if (this.guardTimer) {
1279
+ return;
1280
+ }
1281
+ this.guardTimer = setInterval(() => {
1282
+ void this.reapExpired();
1283
+ }, 30_000);
1284
+ this.guardTimer.unref();
1285
+ }
1286
+ async reapExpired() {
1287
+ const now = Date.now();
1288
+ const toStop = Array.from(this.sessions.values()).filter((session) => session.status === "running" && new Date(session.expiresAt).getTime() <= now);
1289
+ for (const session of toStop) {
1290
+ await this.stopExposure(session.id, { reason: "expired", expired: true });
1291
+ }
1292
+ }
1293
+ async startTunnel(session, targetPort) {
1294
+ const cloudflaredBin = this.cloudflaredResolvedPath ?? resolveBinPath(this.cloudflaredPathInput);
1295
+ if (!cloudflaredBin) {
1296
+ throw new Error(`cloudflared not found in PATH: ${this.cloudflaredPathInput}`);
1297
+ }
1298
+ this.cloudflaredResolvedPath = cloudflaredBin;
1299
+ const edgeIpVersion = this.policy.tunnel.edgeIpVersion;
1300
+ const protocol = this.policy.tunnel.protocol;
1301
+ const args = [
1302
+ "tunnel",
1303
+ "--url",
1304
+ `http://127.0.0.1:${targetPort}`,
1305
+ "--edge-ip-version",
1306
+ edgeIpVersion,
1307
+ "--protocol",
1308
+ protocol,
1309
+ "--no-autoupdate",
1310
+ ];
1311
+ this.appendLog(session, "tunnel", `spawn: ${cloudflaredBin} ${args.join(" ")}`);
1312
+ const proc = spawn(cloudflaredBin, args, {
1313
+ stdio: ["pipe", "pipe", "pipe"],
1314
+ });
1315
+ let settled = false;
1316
+ return await new Promise((resolve, reject) => {
1317
+ const timeout = setTimeout(() => {
1318
+ if (settled) {
1319
+ return;
1320
+ }
1321
+ settled = true;
1322
+ proc.kill("SIGTERM");
1323
+ reject(new Error("timed out waiting for cloudflared URL"));
1324
+ }, 30_000);
1325
+ const onLine = (line) => {
1326
+ this.appendLog(session, "tunnel", line);
1327
+ const url = pickQuickTunnelUrlFromLine(line);
1328
+ if (url && !settled) {
1329
+ settled = true;
1330
+ clearTimeout(timeout);
1331
+ resolve({ process: proc, publicUrl: url });
1332
+ }
1333
+ };
1334
+ const attachStream = (stream) => {
1335
+ let buffer = "";
1336
+ stream.on("data", (chunk) => {
1337
+ buffer += chunk.toString();
1338
+ const lines = buffer.split(/\r?\n/);
1339
+ buffer = lines.pop() ?? "";
1340
+ for (const line of lines) {
1341
+ if (line.trim()) {
1342
+ onLine(line.trim());
1343
+ }
1344
+ }
1345
+ });
1346
+ stream.on("end", () => {
1347
+ if (buffer.trim()) {
1348
+ onLine(buffer.trim());
1349
+ }
1350
+ });
1351
+ };
1352
+ attachStream(proc.stdout);
1353
+ attachStream(proc.stderr);
1354
+ proc.once("error", (error) => {
1355
+ if (settled) {
1356
+ return;
1357
+ }
1358
+ settled = true;
1359
+ clearTimeout(timeout);
1360
+ reject(error);
1361
+ });
1362
+ proc.once("exit", (code, signal) => {
1363
+ if (!settled) {
1364
+ settled = true;
1365
+ clearTimeout(timeout);
1366
+ reject(new Error(`cloudflared exited before URL (code=${String(code)}, signal=${String(signal)})`));
1367
+ }
1368
+ });
1369
+ });
1370
+ }
1371
+ async startTunnelWithRetry(params) {
1372
+ const maxAttempts = Math.max(1, Math.trunc(params.maxAttempts ?? 2));
1373
+ let lastError = "unknown";
1374
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1375
+ let tunnel;
1376
+ try {
1377
+ this.appendLog(params.session, "manager", `starting tunnel attempt ${attempt}/${maxAttempts} on port ${params.targetPort}`);
1378
+ tunnel = await this.startTunnel(params.session, params.targetPort);
1379
+ if (!isValidQuickTunnelUrl(tunnel.publicUrl)) {
1380
+ throw new Error(`invalid quick tunnel url: ${tunnel.publicUrl}`);
1381
+ }
1382
+ this.appendLog(params.session, "manager", `tunnel ready on attempt ${attempt}/${maxAttempts}: ${tunnel.publicUrl}`);
1383
+ return tunnel;
1384
+ }
1385
+ catch (error) {
1386
+ lastError = error instanceof Error ? error.message : String(error);
1387
+ this.appendLog(params.session, "manager", `tunnel attempt ${attempt}/${maxAttempts} failed: ${lastError}`);
1388
+ if (tunnel?.process) {
1389
+ this.appendLog(params.session, "manager", "stopping failed tunnel process before retry");
1390
+ await this.terminateProcess(tunnel.process);
1391
+ }
1392
+ }
1393
+ }
1394
+ throw new Error(`failed to start cloudflared tunnel after ${maxAttempts} attempts: ${lastError}`);
1395
+ }
1396
+ async terminateProcess(proc) {
1397
+ if (!proc || proc.killed) {
1398
+ return;
1399
+ }
1400
+ const pid = proc.pid;
1401
+ if (!pid) {
1402
+ return;
1403
+ }
1404
+ try {
1405
+ process.kill(pid, 0);
1406
+ }
1407
+ catch {
1408
+ return;
1409
+ }
1410
+ proc.kill("SIGTERM");
1411
+ await new Promise((resolve) => {
1412
+ const timer = setTimeout(() => {
1413
+ try {
1414
+ proc.kill("SIGKILL");
1415
+ }
1416
+ catch {
1417
+ // ignore
1418
+ }
1419
+ resolve();
1420
+ }, 2500);
1421
+ proc.once("exit", () => {
1422
+ clearTimeout(timer);
1423
+ resolve();
1424
+ });
1425
+ });
1426
+ }
1427
+ async envCheck() {
1428
+ await this.ensureInitialized();
1429
+ await this.reloadPolicy();
1430
+ const warnings = [...this.policyWarnings];
1431
+ let cloudflaredPath = resolveBinPath(this.cloudflaredPathInput);
1432
+ let cloudflaredVersion;
1433
+ if (!cloudflaredPath && path.isAbsolute(this.cloudflaredPathInput)) {
1434
+ cloudflaredPath = this.cloudflaredPathInput;
1435
+ }
1436
+ if (cloudflaredPath) {
1437
+ const result = spawnSync(cloudflaredPath, ["--version"], {
1438
+ timeout: 2000,
1439
+ encoding: "utf8",
1440
+ });
1441
+ if (result.error) {
1442
+ warnings.push(`cloudflared exists but version check failed: ${String(result.error)}`);
1443
+ }
1444
+ else {
1445
+ const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
1446
+ cloudflaredVersion = extractCloudflaredVersion(combined) ?? combined.trim().split(/\r?\n/)[0];
1447
+ }
1448
+ }
1449
+ else {
1450
+ warnings.push(`cloudflared binary not found (${this.cloudflaredPathInput})`);
1451
+ }
1452
+ const defaults = {
1453
+ ...this.policy,
1454
+ state_dir: this.stateDir,
1455
+ policy_file: this.policyFile,
1456
+ ignore_file: this.ignoreFile,
1457
+ os: {
1458
+ platform: process.platform,
1459
+ arch: process.arch,
1460
+ release: os.release(),
1461
+ uid: typeof process.getuid === "function" ? process.getuid() : undefined,
1462
+ },
1463
+ };
1464
+ return {
1465
+ cloudflared: {
1466
+ ok: Boolean(cloudflaredPath && cloudflaredVersion),
1467
+ path: cloudflaredPath,
1468
+ version: cloudflaredVersion,
1469
+ },
1470
+ defaults,
1471
+ warnings,
1472
+ };
1473
+ }
1474
+ async exposePort(params) {
1475
+ await this.ensureInitialized();
1476
+ const port = Math.trunc(params.port);
1477
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1478
+ throw new Error("invalid port");
1479
+ }
1480
+ if (this.policy.blockedPorts.includes(port)) {
1481
+ throw new Error(`port blocked by policy: ${port}`);
1482
+ }
1483
+ const alive = await probeLocalPort(port);
1484
+ if (!alive) {
1485
+ throw new Error(`local service is not reachable on 127.0.0.1:${port}`);
1486
+ }
1487
+ const ttlSeconds = normalizeTtl(params.opts?.ttl_seconds, this.policy);
1488
+ const expiresAt = toLocalIso(new Date(Date.now() + ttlSeconds * 1000));
1489
+ const accessMode = normalizeAccessMode(params.opts?.access, this.policy.defaultExposePortAccess);
1490
+ const protectOrigin = typeof params.opts?.protect_origin === "boolean" ? params.opts.protect_origin : accessMode !== "none";
1491
+ const id = randomId("port");
1492
+ const session = {
1493
+ id,
1494
+ type: "port",
1495
+ status: "starting",
1496
+ createdAt: nowIso(),
1497
+ expiresAt,
1498
+ localUrl: `http://127.0.0.1:${port}`,
1499
+ sourcePort: port,
1500
+ originPort: port,
1501
+ tunnelPort: port,
1502
+ logs: [],
1503
+ stats: { requests: 0, downloads: 0, bytesSent: 0 },
1504
+ access: this.makeAccessState({
1505
+ mode: accessMode,
1506
+ protectOrigin,
1507
+ allowlistPaths: params.opts?.allowlist_paths,
1508
+ }),
1509
+ };
1510
+ this.sessions.set(id, session);
1511
+ try {
1512
+ let tunnelTargetPort = port;
1513
+ if (protectOrigin || session.access.allowlistPaths.length > 0 || this.policy.rateLimit.enabled) {
1514
+ const proxy = await this.startReverseProxy({
1515
+ upstreamPort: port,
1516
+ session,
1517
+ access: session.access,
1518
+ });
1519
+ session.proxyServer = proxy.server;
1520
+ session.tunnelPort = proxy.port;
1521
+ tunnelTargetPort = proxy.port;
1522
+ }
1523
+ const tunnel = await this.startTunnelWithRetry({
1524
+ session,
1525
+ targetPort: tunnelTargetPort,
1526
+ maxAttempts: 2,
1527
+ });
1528
+ session.process = tunnel.process;
1529
+ session.publicUrl = tunnel.publicUrl;
1530
+ session.status = "running";
1531
+ session.process.on("exit", (code, signal) => {
1532
+ if (session.status === "running") {
1533
+ session.status = "error";
1534
+ session.lastError = `cloudflared exited (code=${String(code)}, signal=${String(signal)})`;
1535
+ this.appendLog(session, "tunnel", session.lastError);
1536
+ }
1537
+ });
1538
+ session.timeoutHandle = setTimeout(() => {
1539
+ void this.stopExposure(session.id, { reason: "expired", expired: true });
1540
+ }, ttlSeconds * 1000);
1541
+ session.timeoutHandle.unref();
1542
+ await this.persistSessionsSnapshot();
1543
+ await this.writeAudit({
1544
+ ts: nowIso(),
1545
+ event: "exposure_started",
1546
+ id: session.id,
1547
+ type: session.type,
1548
+ details: {
1549
+ source_port: port,
1550
+ public_url: session.publicUrl,
1551
+ expires_at: expiresAt,
1552
+ access_mode: session.access.mode,
1553
+ },
1554
+ });
1555
+ return {
1556
+ id: session.id,
1557
+ public_url: this.makeResponsePublicUrl(session),
1558
+ local_url: session.localUrl,
1559
+ expires_at: session.expiresAt,
1560
+ access_info: {
1561
+ mode: session.access.mode,
1562
+ protect_origin: session.access.protectOrigin,
1563
+ token: maskSecret(session.access.token),
1564
+ username: session.access.username,
1565
+ password: maskSecret(session.access.password),
1566
+ allowlist_paths: session.access.allowlistPaths,
1567
+ },
1568
+ };
1569
+ }
1570
+ catch (error) {
1571
+ session.status = "error";
1572
+ session.lastError = error instanceof Error ? error.message : String(error);
1573
+ await this.stopExposure(id, { reason: session.lastError, expired: false, keepAudit: true });
1574
+ throw error;
1575
+ }
1576
+ }
1577
+ async exposeFiles(params, ctx) {
1578
+ await this.ensureInitialized();
1579
+ if (!Array.isArray(params.paths) || params.paths.length === 0) {
1580
+ throw new Error("paths is required");
1581
+ }
1582
+ const ttlSeconds = normalizeTtl(params.opts?.ttl_seconds, this.policy);
1583
+ const expiresAt = toLocalIso(new Date(Date.now() + ttlSeconds * 1000));
1584
+ const mode = params.opts?.mode ?? "normal";
1585
+ const presentation = normalizeFilePresentation(params.opts?.presentation);
1586
+ const accessMode = normalizeAccessMode(params.opts?.access, this.policy.defaultExposeFilesAccess);
1587
+ const protectOrigin = accessMode !== "none";
1588
+ const id = randomId("files");
1589
+ const workspaceDir = path.join(this.workspaceRoot, id);
1590
+ await mkdirp(workspaceDir);
1591
+ const inputSummary = await this.summarizeExposeInputs(params.paths);
1592
+ const session = {
1593
+ id,
1594
+ type: "files",
1595
+ status: "starting",
1596
+ createdAt: nowIso(),
1597
+ expiresAt,
1598
+ localUrl: "",
1599
+ originPort: 0,
1600
+ tunnelPort: 0,
1601
+ workspaceDir,
1602
+ fileMode: mode,
1603
+ filePresentation: presentation,
1604
+ logs: [],
1605
+ stats: { requests: 0, downloads: 0, bytesSent: 0 },
1606
+ access: this.makeAccessState({
1607
+ mode: accessMode,
1608
+ protectOrigin,
1609
+ }),
1610
+ maxDownloads: typeof params.opts?.max_downloads === "number" ? Math.trunc(params.opts.max_downloads) : undefined,
1611
+ };
1612
+ this.sessions.set(id, session);
1613
+ try {
1614
+ await this.copyInputsToWorkspace(params.paths, workspaceDir, ctx);
1615
+ const rootBehavior = await this.detectWorkspaceRootBehavior(workspaceDir);
1616
+ const fileServer = await this.startFileServer({
1617
+ session,
1618
+ workspaceDir,
1619
+ mode,
1620
+ presentation,
1621
+ maxDownloads: session.maxDownloads,
1622
+ directSingleFileRoot: mode === "normal" && rootBehavior.directSingleFileRoot,
1623
+ autoEnterSingleDirectory: mode === "normal" && rootBehavior.autoEnterSingleDirectory,
1624
+ rootDirectories: mode === "normal" ? rootBehavior.rootDirectories : [],
1625
+ });
1626
+ session.originServer = fileServer.server;
1627
+ session.manifest = fileServer.manifest;
1628
+ session.originPort = fileServer.port;
1629
+ session.localUrl = `http://127.0.0.1:${fileServer.port}`;
1630
+ let tunnelTargetPort = fileServer.port;
1631
+ if (protectOrigin) {
1632
+ const proxy = await this.startReverseProxy({
1633
+ upstreamPort: fileServer.port,
1634
+ session,
1635
+ access: session.access,
1636
+ });
1637
+ session.proxyServer = proxy.server;
1638
+ session.tunnelPort = proxy.port;
1639
+ tunnelTargetPort = proxy.port;
1640
+ }
1641
+ if (!session.tunnelPort) {
1642
+ session.tunnelPort = tunnelTargetPort;
1643
+ }
1644
+ const tunnel = await this.startTunnelWithRetry({
1645
+ session,
1646
+ targetPort: tunnelTargetPort,
1647
+ maxAttempts: 2,
1648
+ });
1649
+ session.process = tunnel.process;
1650
+ session.publicUrl = tunnel.publicUrl;
1651
+ session.status = "running";
1652
+ session.process.on("exit", (code, signal) => {
1653
+ if (session.status === "running") {
1654
+ session.status = "error";
1655
+ session.lastError = `cloudflared exited (code=${String(code)}, signal=${String(signal)})`;
1656
+ this.appendLog(session, "tunnel", session.lastError);
1657
+ }
1658
+ });
1659
+ session.timeoutHandle = setTimeout(() => {
1660
+ void this.stopExposure(session.id, { reason: "expired", expired: true });
1661
+ }, ttlSeconds * 1000);
1662
+ session.timeoutHandle.unref();
1663
+ await this.persistSessionsSnapshot();
1664
+ await this.writeAudit({
1665
+ ts: nowIso(),
1666
+ event: "exposure_started",
1667
+ id: session.id,
1668
+ type: session.type,
1669
+ details: {
1670
+ public_url: session.publicUrl,
1671
+ expires_at: session.expiresAt,
1672
+ files_count: session.manifest?.length ?? 0,
1673
+ mode,
1674
+ presentation,
1675
+ },
1676
+ });
1677
+ const responseManifest = mode === "zip"
1678
+ ? {
1679
+ ...this.makeManifestResponse(session.manifest, MAX_RESPONSE_MANIFEST_ITEMS),
1680
+ manifest_mode: "detailed",
1681
+ }
1682
+ : this.buildExposeFilesResponseManifest({
1683
+ inputs: inputSummary,
1684
+ fullManifest: session.manifest ?? [],
1685
+ detailedLimit: MAX_RESPONSE_MANIFEST_ITEMS,
1686
+ });
1687
+ return {
1688
+ id: session.id,
1689
+ public_url: this.makeResponsePublicUrl(session),
1690
+ expires_at: session.expiresAt,
1691
+ mode,
1692
+ presentation,
1693
+ manifest_mode: responseManifest.manifest_mode,
1694
+ manifest_meta: responseManifest.manifest_meta,
1695
+ manifest: responseManifest.manifest,
1696
+ };
1697
+ }
1698
+ catch (error) {
1699
+ session.status = "error";
1700
+ session.lastError = error instanceof Error ? error.message : String(error);
1701
+ await this.stopExposure(id, { reason: session.lastError, expired: false, keepAudit: true });
1702
+ throw error;
1703
+ }
1704
+ }
1705
+ exposureList() {
1706
+ return Array.from(this.sessions.values()).map((session) => this.makeExposureRecord(session));
1707
+ }
1708
+ normalizeRequestedIds(rawIds) {
1709
+ const out = [];
1710
+ const seen = new Set();
1711
+ for (const item of rawIds) {
1712
+ if (typeof item !== "string") {
1713
+ continue;
1714
+ }
1715
+ const id = item.trim();
1716
+ if (!id || seen.has(id)) {
1717
+ continue;
1718
+ }
1719
+ seen.add(id);
1720
+ out.push(id);
1721
+ }
1722
+ return out;
1723
+ }
1724
+ matchesExposureFilter(session, filter) {
1725
+ if (!filter) {
1726
+ return true;
1727
+ }
1728
+ if (filter.status && session.status !== filter.status) {
1729
+ return false;
1730
+ }
1731
+ if (filter.type && session.type !== filter.type) {
1732
+ return false;
1733
+ }
1734
+ return true;
1735
+ }
1736
+ resolveExposureSelection(query) {
1737
+ const explicitIds = this.normalizeRequestedIds([
1738
+ ...(typeof query.id === "string" ? [query.id] : []),
1739
+ ...((query.ids ?? []).filter((value) => typeof value === "string")),
1740
+ ]);
1741
+ const hasAll = explicitIds.includes("all");
1742
+ const allSessions = Array.from(this.sessions.values());
1743
+ if (hasAll) {
1744
+ const selectedIds = allSessions
1745
+ .filter((session) => this.matchesExposureFilter(session, query.filter))
1746
+ .map((session) => session.id);
1747
+ return { selectorUsed: true, selectedIds, missingIds: [] };
1748
+ }
1749
+ if (explicitIds.length > 0) {
1750
+ const selectedIds = [];
1751
+ const missingIds = [];
1752
+ for (const id of explicitIds) {
1753
+ const session = this.sessions.get(id);
1754
+ if (!session) {
1755
+ missingIds.push(id);
1756
+ continue;
1757
+ }
1758
+ if (this.matchesExposureFilter(session, query.filter)) {
1759
+ selectedIds.push(id);
1760
+ }
1761
+ }
1762
+ return { selectorUsed: true, selectedIds, missingIds };
1763
+ }
1764
+ if (query.filter) {
1765
+ const selectedIds = allSessions
1766
+ .filter((session) => this.matchesExposureFilter(session, query.filter))
1767
+ .map((session) => session.id);
1768
+ return { selectorUsed: true, selectedIds, missingIds: [] };
1769
+ }
1770
+ return { selectorUsed: false, selectedIds: [], missingIds: [] };
1771
+ }
1772
+ async buildExposureDetail(session, opts) {
1773
+ const tunnelAlive = Boolean(session.process && !session.process.killed);
1774
+ const originAlive = session.type === "port"
1775
+ ? await probeLocalPort(session.sourcePort ?? session.originPort)
1776
+ : Boolean(session.originServer?.listening);
1777
+ let publicProbe;
1778
+ if (opts?.probe_public && session.publicUrl) {
1779
+ try {
1780
+ const probeUrl = new URL(session.publicUrl);
1781
+ if (session.access.mode === "token" && session.access.token) {
1782
+ probeUrl.searchParams.set("token", session.access.token);
1783
+ }
1784
+ const controller = new AbortController();
1785
+ const timer = setTimeout(() => controller.abort(), DEFAULT_PROBE_TIMEOUT_MS);
1786
+ const headers = {};
1787
+ if (session.access.mode === "basic" && session.access.username && session.access.password) {
1788
+ headers.authorization = `Basic ${Buffer.from(`${session.access.username}:${session.access.password}`).toString("base64")}`;
1789
+ }
1790
+ const response = await fetch(probeUrl.toString(), {
1791
+ method: "HEAD",
1792
+ signal: controller.signal,
1793
+ headers,
1794
+ });
1795
+ clearTimeout(timer);
1796
+ publicProbe = { ok: response.ok, status: response.status };
1797
+ }
1798
+ catch (error) {
1799
+ publicProbe = { ok: false, error: String(error) };
1800
+ }
1801
+ }
1802
+ const fileSharing = session.type === "files"
1803
+ ? {
1804
+ mode: session.fileMode ?? "normal",
1805
+ presentation: session.filePresentation ?? "download",
1806
+ }
1807
+ : undefined;
1808
+ const includeManifest = Boolean(opts?.include_manifest);
1809
+ const manifestBundle = session.type === "files" ? this.makeManifestResponse(session.manifest, opts?.manifest_limit) : undefined;
1810
+ const detail = {
1811
+ id: session.id,
1812
+ type: session.type,
1813
+ created_at: session.createdAt,
1814
+ status: {
1815
+ state: session.status,
1816
+ tunnel_alive: tunnelAlive,
1817
+ origin_alive: originAlive,
1818
+ public_probe: publicProbe,
1819
+ },
1820
+ port: {
1821
+ source_port: session.sourcePort,
1822
+ origin_port: session.originPort,
1823
+ tunnel_port: session.tunnelPort,
1824
+ },
1825
+ public_url: this.makeResponsePublicUrl(session),
1826
+ expires_at: session.expiresAt,
1827
+ local_url: session.localUrl,
1828
+ stats: session.stats,
1829
+ file_sharing: fileSharing,
1830
+ last_error: session.lastError,
1831
+ };
1832
+ if (manifestBundle) {
1833
+ detail.manifest_meta = manifestBundle.manifest_meta;
1834
+ if (includeManifest) {
1835
+ detail.manifest = manifestBundle.manifest;
1836
+ }
1837
+ }
1838
+ return detail;
1839
+ }
1840
+ projectExposureDetail(detail, fields) {
1841
+ if (!fields || fields.length === 0) {
1842
+ return detail;
1843
+ }
1844
+ const out = {
1845
+ id: detail.id,
1846
+ };
1847
+ for (const field of fields) {
1848
+ if (field === "id") {
1849
+ continue;
1850
+ }
1851
+ if (Object.prototype.hasOwnProperty.call(detail, field)) {
1852
+ out[field] = detail[field];
1853
+ if (field === "manifest" && Object.prototype.hasOwnProperty.call(detail, "manifest_meta")) {
1854
+ out.manifest_meta = detail.manifest_meta;
1855
+ }
1856
+ }
1857
+ }
1858
+ return out;
1859
+ }
1860
+ makeExposureGetNotFound(id, fields) {
1861
+ const out = { id, error: "not_found" };
1862
+ if (fields?.includes("status")) {
1863
+ out.status = "not_found";
1864
+ }
1865
+ return out;
1866
+ }
1867
+ async exposureGet(params) {
1868
+ await this.ensureInitialized();
1869
+ const fields = Array.isArray(params.fields) ? this.normalizeRequestedIds(params.fields) : undefined;
1870
+ const typedFields = fields;
1871
+ const legacySingle = Boolean(params.id) && params.id !== "all" && !params.ids && !params.filter && !params.fields;
1872
+ const selection = this.resolveExposureSelection({
1873
+ id: params.id,
1874
+ ids: params.ids,
1875
+ filter: params.filter,
1876
+ });
1877
+ if (!selection.selectorUsed) {
1878
+ throw new Error("id, ids, or filter is required");
1879
+ }
1880
+ if (legacySingle) {
1881
+ const legacyId = params.id;
1882
+ const session = this.sessions.get(legacyId);
1883
+ if (!session) {
1884
+ return { id: legacyId, status: "not_found" };
1885
+ }
1886
+ return await this.buildExposureDetail(session, {
1887
+ probe_public: params.opts?.probe_public,
1888
+ include_manifest: true,
1889
+ manifest_limit: MAX_RESPONSE_MANIFEST_ITEMS,
1890
+ });
1891
+ }
1892
+ const manifestRequested = typedFields?.includes("manifest") ?? false;
1893
+ const manifestLimit = selection.selectedIds.length > 1
1894
+ ? MAX_RESPONSE_MANIFEST_ITEMS_MULTI_GET
1895
+ : MAX_RESPONSE_MANIFEST_ITEMS;
1896
+ const responseSelectedIds = selection.selectedIds.slice(0, MAX_EXPOSURE_GET_ITEMS);
1897
+ const selectedIdsTruncated = selection.selectedIds.length > responseSelectedIds.length;
1898
+ const items = [];
1899
+ for (const id of responseSelectedIds) {
1900
+ const session = this.sessions.get(id);
1901
+ if (!session) {
1902
+ items.push(this.makeExposureGetNotFound(id, typedFields));
1903
+ continue;
1904
+ }
1905
+ const detail = await this.buildExposureDetail(session, {
1906
+ probe_public: params.opts?.probe_public,
1907
+ include_manifest: manifestRequested,
1908
+ manifest_limit: manifestLimit,
1909
+ });
1910
+ items.push(this.projectExposureDetail(detail, typedFields));
1911
+ }
1912
+ for (const missingId of selection.missingIds) {
1913
+ items.push(this.makeExposureGetNotFound(missingId, typedFields));
1914
+ }
1915
+ return {
1916
+ items,
1917
+ count: items.length,
1918
+ matched_ids: responseSelectedIds,
1919
+ matched_total_count: selection.selectedIds.length,
1920
+ matched_ids_truncated: selectedIdsTruncated,
1921
+ missing_ids: selection.missingIds,
1922
+ filter: params.filter,
1923
+ fields: typedFields,
1924
+ };
1925
+ }
1926
+ async stopExposure(idOrIds, opts) {
1927
+ await this.ensureInitialized();
1928
+ const requested = this.normalizeRequestedIds(Array.isArray(idOrIds) ? idOrIds : [idOrIds]);
1929
+ if (requested.length === 0) {
1930
+ return { stopped: [], failed: [{ id: "unknown", error: "id or ids is required" }], cleaned: [] };
1931
+ }
1932
+ const includeAll = requested.includes("all");
1933
+ const ids = [];
1934
+ const failed = [];
1935
+ if (includeAll) {
1936
+ ids.push(...Array.from(this.sessions.keys()));
1937
+ if (ids.length === 0) {
1938
+ return { stopped: [], failed: [{ id: "all", error: "not_found" }], cleaned: [] };
1939
+ }
1940
+ }
1941
+ else {
1942
+ for (const id of requested) {
1943
+ if (this.sessions.has(id)) {
1944
+ ids.push(id);
1945
+ }
1946
+ else {
1947
+ failed.push({ id, error: "not_found" });
1948
+ }
1949
+ }
1950
+ }
1951
+ const stopIds = this.normalizeRequestedIds(ids);
1952
+ if (stopIds.length === 0) {
1953
+ return { stopped: [], failed, cleaned: [] };
1954
+ }
1955
+ const stopped = [];
1956
+ const cleaned = [];
1957
+ for (const id of stopIds) {
1958
+ const session = this.sessions.get(id);
1959
+ if (!session) {
1960
+ failed.push({ id, error: "not_found" });
1961
+ continue;
1962
+ }
1963
+ try {
1964
+ if (session.timeoutHandle) {
1965
+ clearTimeout(session.timeoutHandle);
1966
+ session.timeoutHandle = undefined;
1967
+ }
1968
+ await this.terminateProcess(session.process);
1969
+ if (session.proxyServer?.listening) {
1970
+ await new Promise((resolve) => session.proxyServer?.close(() => resolve()));
1971
+ }
1972
+ if (session.originServer?.listening) {
1973
+ await new Promise((resolve) => session.originServer?.close(() => resolve()));
1974
+ }
1975
+ if (session.workspaceDir && (await fileExists(session.workspaceDir))) {
1976
+ await fs.rm(session.workspaceDir, { recursive: true, force: true });
1977
+ cleaned.push(session.workspaceDir);
1978
+ }
1979
+ session.status = opts?.expired ? "expired" : "stopped";
1980
+ if (opts?.reason) {
1981
+ session.lastError = opts.reason;
1982
+ this.appendLog(session, "manager", `stop reason: ${opts.reason}`);
1983
+ }
1984
+ stopped.push(id);
1985
+ await this.writeAudit({
1986
+ ts: nowIso(),
1987
+ event: opts?.expired ? "exposure_expired" : "exposure_stopped",
1988
+ id: session.id,
1989
+ type: session.type,
1990
+ details: {
1991
+ reason: opts?.reason,
1992
+ public_url: session.publicUrl,
1993
+ },
1994
+ });
1995
+ }
1996
+ catch (error) {
1997
+ failed.push({ id, error: String(error) });
1998
+ }
1999
+ finally {
2000
+ this.sessions.delete(id);
2001
+ }
2002
+ }
2003
+ await this.persistSessionsSnapshot();
2004
+ return { stopped, failed, cleaned };
2005
+ }
2006
+ exposureLogsOne(session, opts) {
2007
+ const lines = Math.max(1, Math.min(MAX_EXPOSURE_LOG_LINES_RESPONSE, Math.trunc(opts?.lines ?? 200)));
2008
+ const component = opts?.component ?? "all";
2009
+ const threshold = typeof opts?.since_seconds === "number"
2010
+ ? Date.now() - Math.max(1, Math.trunc(opts.since_seconds)) * 1000
2011
+ : undefined;
2012
+ const filtered = session.logs.filter((entry) => {
2013
+ if (component !== "all" && entry.component !== component) {
2014
+ return false;
2015
+ }
2016
+ if (threshold !== undefined && new Date(entry.ts).getTime() < threshold) {
2017
+ return false;
2018
+ }
2019
+ return true;
2020
+ });
2021
+ return {
2022
+ id: session.id,
2023
+ component,
2024
+ lines: filtered
2025
+ .slice(-lines)
2026
+ .map((entry) => `${entry.ts} [${entry.component}] ${entry.line}`),
2027
+ };
2028
+ }
2029
+ exposureLogs(idOrIds, opts) {
2030
+ const requested = this.normalizeRequestedIds(Array.isArray(idOrIds) ? idOrIds : [idOrIds]);
2031
+ if (requested.length === 0) {
2032
+ return { items: [], missing_ids: [], error: "id or ids is required" };
2033
+ }
2034
+ const includeAll = requested.includes("all");
2035
+ const targetIds = includeAll ? Array.from(this.sessions.keys()) : requested;
2036
+ const responseTargetIds = targetIds.slice(0, MAX_EXPOSURE_LOG_ITEMS);
2037
+ const targetIdsTruncated = targetIds.length > responseTargetIds.length;
2038
+ const singleLegacy = !Array.isArray(idOrIds) && !includeAll;
2039
+ if (singleLegacy) {
2040
+ const session = this.sessions.get(targetIds[0] ?? "");
2041
+ if (!session) {
2042
+ return { id: idOrIds, component: opts?.component ?? "all", lines: [], error: "not_found" };
2043
+ }
2044
+ return this.exposureLogsOne(session, opts);
2045
+ }
2046
+ const missingIds = [];
2047
+ const items = [];
2048
+ for (const id of responseTargetIds) {
2049
+ const session = this.sessions.get(id);
2050
+ if (!session) {
2051
+ missingIds.push(id);
2052
+ items.push({ id, component: opts?.component ?? "all", lines: [], error: "not_found" });
2053
+ continue;
2054
+ }
2055
+ items.push(this.exposureLogsOne(session, opts));
2056
+ }
2057
+ return {
2058
+ items,
2059
+ requested_count: responseTargetIds.length,
2060
+ requested_total_count: targetIds.length,
2061
+ requested_ids_truncated: targetIdsTruncated,
2062
+ found_count: responseTargetIds.length - missingIds.length,
2063
+ missing_ids: missingIds,
2064
+ };
2065
+ }
2066
+ async maintenance(action, opts) {
2067
+ await this.ensureInitialized();
2068
+ if (action === "start_guard") {
2069
+ this.startGuard();
2070
+ return {
2071
+ ok: true,
2072
+ action,
2073
+ details: {
2074
+ running: Boolean(this.guardTimer),
2075
+ },
2076
+ };
2077
+ }
2078
+ if (action === "run_gc") {
2079
+ const details = await this.runGc();
2080
+ return { ok: true, action, details };
2081
+ }
2082
+ if (!opts?.policy && !opts?.ignore_patterns) {
2083
+ return {
2084
+ ok: false,
2085
+ action,
2086
+ details: "set_policy requires opts.policy or opts.ignore_patterns",
2087
+ };
2088
+ }
2089
+ const current = await this.readPolicyJson();
2090
+ const nextPolicy = this.deepMerge(current, (opts.policy ?? {}));
2091
+ await fs.writeFile(this.policyFile, `${JSON.stringify(nextPolicy, null, 2)}\n`, "utf8");
2092
+ if (Array.isArray(opts.ignore_patterns)) {
2093
+ await fs.writeFile(this.ignoreFile, `${opts.ignore_patterns.join("\n")}\n`, "utf8");
2094
+ }
2095
+ await this.reloadPolicy();
2096
+ await this.writeAudit({
2097
+ ts: nowIso(),
2098
+ event: "policy_updated",
2099
+ details: {
2100
+ policy_file: this.policyFile,
2101
+ },
2102
+ });
2103
+ return {
2104
+ ok: true,
2105
+ action,
2106
+ details: {
2107
+ policy_file: this.policyFile,
2108
+ ignore_file: this.ignoreFile,
2109
+ effective: this.policy,
2110
+ },
2111
+ };
2112
+ }
2113
+ deepMerge(base, patch) {
2114
+ const out = { ...base };
2115
+ for (const [key, value] of Object.entries(patch)) {
2116
+ if (value &&
2117
+ typeof value === "object" &&
2118
+ !Array.isArray(value) &&
2119
+ typeof out[key] === "object" &&
2120
+ out[key] !== null &&
2121
+ !Array.isArray(out[key])) {
2122
+ out[key] = this.deepMerge(out[key], value);
2123
+ }
2124
+ else {
2125
+ out[key] = value;
2126
+ }
2127
+ }
2128
+ return out;
2129
+ }
2130
+ async readPolicyJson() {
2131
+ try {
2132
+ const raw = await fs.readFile(this.policyFile, "utf8");
2133
+ const parsed = JSON.parse(raw);
2134
+ if (parsed && typeof parsed === "object") {
2135
+ return parsed;
2136
+ }
2137
+ }
2138
+ catch {
2139
+ // fallback to empty
2140
+ }
2141
+ return {};
2142
+ }
2143
+ async runGc() {
2144
+ const removedWorkspaces = [];
2145
+ const killedPids = [];
2146
+ const activeWorkspaces = new Set(Array.from(this.sessions.values())
2147
+ .map((session) => session.workspaceDir)
2148
+ .filter((entry) => Boolean(entry)));
2149
+ const workspaces = await fs.readdir(this.workspaceRoot, { withFileTypes: true }).catch(() => []);
2150
+ for (const entry of workspaces) {
2151
+ if (!entry.isDirectory()) {
2152
+ continue;
2153
+ }
2154
+ const abs = path.join(this.workspaceRoot, entry.name);
2155
+ if (activeWorkspaces.has(abs)) {
2156
+ continue;
2157
+ }
2158
+ await fs.rm(abs, { recursive: true, force: true });
2159
+ removedWorkspaces.push(abs);
2160
+ }
2161
+ try {
2162
+ const raw = await fs.readFile(this.sessionsFile, "utf8");
2163
+ const rows = JSON.parse(raw);
2164
+ for (const row of rows ?? []) {
2165
+ if (!row.processPid || this.sessions.has(String(row.id ?? ""))) {
2166
+ continue;
2167
+ }
2168
+ try {
2169
+ process.kill(row.processPid, 0);
2170
+ process.kill(row.processPid, "SIGTERM");
2171
+ killedPids.push(row.processPid);
2172
+ }
2173
+ catch {
2174
+ // ignore
2175
+ }
2176
+ }
2177
+ }
2178
+ catch {
2179
+ // ignore missing snapshot
2180
+ }
2181
+ await this.persistSessionsSnapshot();
2182
+ await this.writeAudit({
2183
+ ts: nowIso(),
2184
+ event: "gc_run",
2185
+ details: {
2186
+ removed_workspaces: removedWorkspaces.length,
2187
+ killed_pids: killedPids.length,
2188
+ },
2189
+ });
2190
+ return {
2191
+ removed_workspaces: removedWorkspaces,
2192
+ killed_pids: killedPids,
2193
+ };
2194
+ }
2195
+ async auditQuery(filters) {
2196
+ await this.ensureInitialized();
2197
+ const limit = Math.max(1, Math.min(10_000, Math.trunc(filters?.limit ?? 500)));
2198
+ let raw = "";
2199
+ try {
2200
+ raw = await fs.readFile(this.auditFile, "utf8");
2201
+ }
2202
+ catch (error) {
2203
+ if (error?.code === "ENOENT") {
2204
+ return [];
2205
+ }
2206
+ throw error;
2207
+ }
2208
+ const events = raw
2209
+ .split(/\r?\n/)
2210
+ .map((line) => line.trim())
2211
+ .filter(Boolean)
2212
+ .map((line) => {
2213
+ try {
2214
+ return JSON.parse(line);
2215
+ }
2216
+ catch {
2217
+ return null;
2218
+ }
2219
+ })
2220
+ .filter((event) => Boolean(event));
2221
+ return events.filter((event) => matchAuditFilters(event, filters ?? {})).slice(-limit);
2222
+ }
2223
+ async auditExport(range) {
2224
+ await this.ensureInitialized();
2225
+ const events = await this.auditQuery({
2226
+ id: range?.id,
2227
+ event: range?.event,
2228
+ type: range?.type,
2229
+ from_ts: range?.from_ts,
2230
+ to_ts: range?.to_ts,
2231
+ limit: 10_000,
2232
+ });
2233
+ const outputPath = this.resolvePath(range?.output_path ?? path.join(this.exportsDir, `audit-${Date.now().toString(36)}.jsonl`));
2234
+ await mkdirp(path.dirname(outputPath));
2235
+ const data = events.map((event) => JSON.stringify(event)).join("\n");
2236
+ await fs.writeFile(outputPath, data ? `${data}\n` : "", "utf8");
2237
+ await this.writeAudit({
2238
+ ts: nowIso(),
2239
+ event: "audit_exported",
2240
+ details: {
2241
+ output_path: outputPath,
2242
+ count: events.length,
2243
+ },
2244
+ });
2245
+ return {
2246
+ ok: true,
2247
+ output_path: outputPath,
2248
+ count: events.length,
2249
+ };
2250
+ }
2251
+ }