bosun 0.36.0 → 0.36.2
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/.env.example +98 -16
- package/README.md +27 -0
- package/agent-event-bus.mjs +5 -5
- package/agent-pool.mjs +129 -12
- package/agent-prompts.mjs +7 -1
- package/agent-sdk.mjs +13 -2
- package/agent-supervisor.mjs +2 -2
- package/agent-work-report.mjs +1 -1
- package/anomaly-detector.mjs +6 -6
- package/autofix.mjs +15 -15
- package/bosun-skills.mjs +4 -4
- package/bosun.schema.json +160 -4
- package/claude-shell.mjs +11 -11
- package/cli.mjs +21 -21
- package/codex-config.mjs +19 -19
- package/codex-shell.mjs +180 -29
- package/config-doctor.mjs +27 -2
- package/config.mjs +60 -7
- package/copilot-shell.mjs +4 -4
- package/error-detector.mjs +1 -1
- package/fleet-coordinator.mjs +2 -2
- package/gemini-shell.mjs +692 -0
- package/github-oauth-portal.mjs +1 -1
- package/github-reconciler.mjs +2 -2
- package/kanban-adapter.mjs +741 -168
- package/merge-strategy.mjs +25 -25
- package/monitor.mjs +123 -105
- package/opencode-shell.mjs +22 -22
- package/package.json +7 -1
- package/postinstall.mjs +22 -22
- package/pr-cleanup-daemon.mjs +6 -6
- package/prepublish-check.mjs +4 -4
- package/presence.mjs +2 -2
- package/primary-agent.mjs +85 -7
- package/publish.mjs +1 -1
- package/review-agent.mjs +1 -1
- package/session-tracker.mjs +11 -0
- package/setup-web-server.mjs +429 -21
- package/setup.mjs +367 -12
- package/shared-knowledge.mjs +1 -1
- package/startup-service.mjs +9 -9
- package/stream-resilience.mjs +58 -4
- package/sync-engine.mjs +2 -2
- package/task-assessment.mjs +9 -9
- package/task-cli.mjs +1 -1
- package/task-complexity.mjs +71 -2
- package/task-context.mjs +1 -2
- package/task-executor.mjs +104 -41
- package/telegram-bot.mjs +825 -494
- package/telegram-sentinel.mjs +28 -28
- package/ui/app.js +256 -23
- package/ui/app.monolith.js +1 -1
- package/ui/components/agent-selector.js +4 -3
- package/ui/components/chat-view.js +101 -28
- package/ui/components/diff-viewer.js +3 -3
- package/ui/components/kanban-board.js +3 -3
- package/ui/components/session-list.js +255 -35
- package/ui/components/workspace-switcher.js +3 -3
- package/ui/demo.html +209 -194
- package/ui/index.html +3 -3
- package/ui/modules/icon-utils.js +206 -142
- package/ui/modules/icons.js +2 -27
- package/ui/modules/settings-schema.js +29 -5
- package/ui/modules/streaming.js +30 -2
- package/ui/modules/vision-stream.js +275 -0
- package/ui/modules/voice-client.js +102 -9
- package/ui/modules/voice-fallback.js +62 -6
- package/ui/modules/voice-overlay.js +594 -59
- package/ui/modules/voice.js +31 -38
- package/ui/setup.html +284 -34
- package/ui/styles/components.css +47 -0
- package/ui/styles/sessions.css +75 -0
- package/ui/tabs/agents.js +73 -43
- package/ui/tabs/chat.js +37 -40
- package/ui/tabs/control.js +2 -2
- package/ui/tabs/dashboard.js +1 -1
- package/ui/tabs/infra.js +10 -10
- package/ui/tabs/library.js +8 -8
- package/ui/tabs/logs.js +10 -10
- package/ui/tabs/settings.js +20 -20
- package/ui/tabs/tasks.js +76 -47
- package/ui-server.mjs +1761 -124
- package/update-check.mjs +13 -13
- package/ve-kanban.mjs +1 -1
- package/whatsapp-channel.mjs +5 -5
- package/workflow-engine.mjs +20 -1
- package/workflow-nodes.mjs +904 -4
- package/workflow-templates/agents.mjs +321 -7
- package/workflow-templates/ci-cd.mjs +6 -6
- package/workflow-templates/github.mjs +156 -84
- package/workflow-templates/planning.mjs +8 -8
- package/workflow-templates/reliability.mjs +8 -8
- package/workflow-templates/security.mjs +3 -3
- package/workflow-templates.mjs +15 -9
- package/workspace-manager.mjs +85 -1
- package/workspace-monitor.mjs +2 -2
- package/workspace-registry.mjs +2 -2
- package/worktree-manager.mjs +1 -1
package/ui-server.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { createHmac, randomBytes, timingSafeEqual, X509Certificate } from "node:crypto";
|
|
2
|
+
import { argon2, createHash, createHmac, randomBytes, timingSafeEqual, X509Certificate } from "node:crypto";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, chmodSync, createWriteStream, createReadStream, writeFileSync, unlinkSync, watchFile, unwatchFile } from "node:fs";
|
|
4
4
|
import { open, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { get as httpsGet } from "node:https";
|
|
7
7
|
import { createServer as createHttpsServer } from "node:https";
|
|
8
|
-
import { networkInterfaces, homedir } from "node:os";
|
|
8
|
+
import { networkInterfaces, homedir, userInfo as getOsUserInfo } from "node:os";
|
|
9
9
|
import { connect as netConnect } from "node:net";
|
|
10
10
|
import { resolve, extname, dirname, basename, relative } from "node:path";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -130,10 +130,73 @@ import {
|
|
|
130
130
|
|
|
131
131
|
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
132
132
|
const repoRoot = resolveRepoRoot();
|
|
133
|
-
const uiRootPreferred = resolve(__dirname, "
|
|
134
|
-
const uiRootFallback = resolve(__dirname, "ui");
|
|
133
|
+
const uiRootPreferred = resolve(__dirname, "ui");
|
|
134
|
+
const uiRootFallback = resolve(__dirname, "site", "ui");
|
|
135
135
|
const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
|
|
136
136
|
let libraryInitAttempted = false;
|
|
137
|
+
const MAX_VISION_FRAME_BYTES = Math.max(
|
|
138
|
+
128_000,
|
|
139
|
+
Number.parseInt(process.env.VISION_FRAME_MAX_BYTES || "", 10) || 2_000_000,
|
|
140
|
+
);
|
|
141
|
+
const DEFAULT_VISION_ANALYSIS_INTERVAL_MS = Math.min(
|
|
142
|
+
30_000,
|
|
143
|
+
Math.max(
|
|
144
|
+
500,
|
|
145
|
+
Number.parseInt(process.env.VISION_ANALYSIS_INTERVAL_MS || "", 10) || 1500,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
const _visionSessionState = new Map();
|
|
149
|
+
|
|
150
|
+
function getVisionSessionState(sessionId) {
|
|
151
|
+
const key = String(sessionId || "").trim();
|
|
152
|
+
if (!key) return null;
|
|
153
|
+
if (!_visionSessionState.has(key)) {
|
|
154
|
+
_visionSessionState.set(key, {
|
|
155
|
+
lastFrameHash: null,
|
|
156
|
+
lastReceiptAt: 0,
|
|
157
|
+
lastAnalyzedHash: null,
|
|
158
|
+
lastAnalyzedAt: 0,
|
|
159
|
+
lastSummary: "",
|
|
160
|
+
inFlight: null,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return _visionSessionState.get(key);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sanitizeVisionSource(value) {
|
|
167
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
168
|
+
if (raw === "camera") return "camera";
|
|
169
|
+
if (raw === "screen" || raw === "display") return "screen";
|
|
170
|
+
return "screen";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeVisionInterval(rawValue) {
|
|
174
|
+
const parsed = Number.parseInt(String(rawValue || ""), 10);
|
|
175
|
+
if (!Number.isFinite(parsed)) return DEFAULT_VISION_ANALYSIS_INTERVAL_MS;
|
|
176
|
+
return Math.min(30_000, Math.max(300, parsed));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseVisionFrameDataUrl(dataUrl) {
|
|
180
|
+
const raw = String(dataUrl || "").trim();
|
|
181
|
+
const match = raw.match(/^data:(image\/(?:jpeg|jpg|png|webp));base64,([A-Za-z0-9+/=]+)$/i);
|
|
182
|
+
if (!match) {
|
|
183
|
+
return { ok: false, error: "frameDataUrl must be a base64 image data URL (jpeg/png/webp)" };
|
|
184
|
+
}
|
|
185
|
+
const mimeType = String(match[1] || "").toLowerCase();
|
|
186
|
+
const base64Data = String(match[2] || "");
|
|
187
|
+
const approxBytes = Math.floor((base64Data.length * 3) / 4);
|
|
188
|
+
if (approxBytes <= 0) {
|
|
189
|
+
return { ok: false, error: "frameDataUrl was empty" };
|
|
190
|
+
}
|
|
191
|
+
if (approxBytes > MAX_VISION_FRAME_BYTES) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
statusCode: 413,
|
|
195
|
+
error: `frameDataUrl too large (${approxBytes} bytes > ${MAX_VISION_FRAME_BYTES} bytes limit)`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, mimeType, base64Data, approxBytes, raw };
|
|
199
|
+
}
|
|
137
200
|
|
|
138
201
|
function ensureLibraryInitialized() {
|
|
139
202
|
if (libraryInitAttempted) return;
|
|
@@ -162,6 +225,12 @@ let _wfRecommendedInstalled = false;
|
|
|
162
225
|
let _wfInitPromise = null;
|
|
163
226
|
let _wfInitDone = false;
|
|
164
227
|
let _wfLoadedBase = null;
|
|
228
|
+
let workflowEventDedupWindowMs = (() => {
|
|
229
|
+
const parsed = Number.parseInt(process.env.WORKFLOW_EVENT_DEDUP_WINDOW_MS || "15000", 10);
|
|
230
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return 15_000;
|
|
231
|
+
return Math.min(300_000, Math.max(250, parsed));
|
|
232
|
+
})();
|
|
233
|
+
const workflowEventDedup = new Map();
|
|
165
234
|
|
|
166
235
|
function parseBooleanEnv(rawValue, fallback = false) {
|
|
167
236
|
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
|
|
@@ -347,10 +416,19 @@ async function getWorkflowEngineModule() {
|
|
|
347
416
|
console.warn("[workflows] prompt resolver failed:", err.message);
|
|
348
417
|
}
|
|
349
418
|
|
|
419
|
+
let meetingService = null;
|
|
420
|
+
try {
|
|
421
|
+
const { createMeetingWorkflowService } = await import("./meeting-workflow-service.mjs");
|
|
422
|
+
meetingService = createMeetingWorkflowService();
|
|
423
|
+
} catch (err) {
|
|
424
|
+
console.warn("[workflows] meeting service unavailable:", err.message);
|
|
425
|
+
}
|
|
426
|
+
|
|
350
427
|
const services = {
|
|
351
428
|
telegram: telegramService,
|
|
352
429
|
agentPool: agentPoolService,
|
|
353
430
|
kanban: kanbanService,
|
|
431
|
+
meeting: meetingService,
|
|
354
432
|
prompts: promptBundle?.prompts || null,
|
|
355
433
|
};
|
|
356
434
|
_wfEngine.getWorkflowEngine({ services });
|
|
@@ -445,6 +523,113 @@ async function getWorkflowEngineModule() {
|
|
|
445
523
|
return _wfEngine;
|
|
446
524
|
}
|
|
447
525
|
|
|
526
|
+
function allowWorkflowEvent(dedupKey, windowMs = workflowEventDedupWindowMs) {
|
|
527
|
+
if (!dedupKey) return true;
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
const lastSeen = workflowEventDedup.get(dedupKey) || 0;
|
|
530
|
+
if (now - lastSeen < windowMs) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
workflowEventDedup.set(dedupKey, now);
|
|
534
|
+
if (workflowEventDedup.size > 1000) {
|
|
535
|
+
const cutoff = now - windowMs * 2;
|
|
536
|
+
for (const [key, ts] of workflowEventDedup.entries()) {
|
|
537
|
+
if (ts < cutoff) workflowEventDedup.delete(key);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function buildWorkflowEventPayload(eventType, eventData = {}, triggerSource = "ui-event") {
|
|
544
|
+
const payload = eventData && typeof eventData === "object"
|
|
545
|
+
? { ...eventData }
|
|
546
|
+
: {};
|
|
547
|
+
payload.eventType = eventType;
|
|
548
|
+
if (String(eventType || "").startsWith("pr.")) {
|
|
549
|
+
const prEvent = String(eventType).slice(3).trim();
|
|
550
|
+
if (prEvent) payload.prEvent = prEvent;
|
|
551
|
+
}
|
|
552
|
+
payload._triggerSource = triggerSource;
|
|
553
|
+
payload._triggerEventType = eventType;
|
|
554
|
+
payload._triggeredAt = new Date().toISOString();
|
|
555
|
+
return payload;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function dispatchWorkflowEvent(eventType, eventData = {}, opts = {}) {
|
|
559
|
+
try {
|
|
560
|
+
if (!parseBooleanEnv(process.env.WORKFLOW_AUTOMATION_ENABLED, true)) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const dedupKey = String(opts?.dedupKey || "").trim();
|
|
565
|
+
if (dedupKey && !allowWorkflowEvent(dedupKey)) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const wfMod = await getWorkflowEngineModule();
|
|
570
|
+
if (!wfMod?.getWorkflowEngine) return false;
|
|
571
|
+
|
|
572
|
+
const engine = wfMod.getWorkflowEngine();
|
|
573
|
+
if (!engine?.evaluateTriggers || !engine?.execute) return false;
|
|
574
|
+
|
|
575
|
+
const payload = buildWorkflowEventPayload(eventType, eventData, "ui-server");
|
|
576
|
+
let triggered = [];
|
|
577
|
+
try {
|
|
578
|
+
triggered = await engine.evaluateTriggers(eventType, payload);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
console.warn(
|
|
581
|
+
`[workflows] trigger evaluation failed for ${eventType}: ${err?.message || err}`,
|
|
582
|
+
);
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (!Array.isArray(triggered) || triggered.length === 0) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
for (const match of triggered) {
|
|
591
|
+
const workflowId = String(match?.workflowId || "").trim();
|
|
592
|
+
if (!workflowId) continue;
|
|
593
|
+
|
|
594
|
+
const runPayload = {
|
|
595
|
+
...payload,
|
|
596
|
+
_triggeredBy: match?.triggeredBy || null,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
Promise.resolve()
|
|
600
|
+
.then(() => engine.execute(workflowId, runPayload))
|
|
601
|
+
.then((ctx) => {
|
|
602
|
+
const runStatus =
|
|
603
|
+
Array.isArray(ctx?.errors) && ctx.errors.length > 0
|
|
604
|
+
? "failed"
|
|
605
|
+
: "completed";
|
|
606
|
+
console.log(
|
|
607
|
+
`[workflows] auto-run ${runStatus} workflow=${workflowId} runId=${ctx?.id || "unknown"} event=${eventType}`,
|
|
608
|
+
);
|
|
609
|
+
})
|
|
610
|
+
.catch((err) => {
|
|
611
|
+
console.warn(
|
|
612
|
+
`[workflows] auto-run failed workflow=${workflowId} event=${eventType}: ${err?.message || err}`,
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
console.log(
|
|
618
|
+
`[workflows] event "${eventType}" triggered ${triggered.length} workflow run(s)`,
|
|
619
|
+
);
|
|
620
|
+
return true;
|
|
621
|
+
} catch (err) {
|
|
622
|
+
console.warn(`[workflows] dispatchWorkflowEvent error for ${eventType}: ${err?.message || err}`);
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function queueWorkflowEvent(eventType, eventData = {}, opts = {}) {
|
|
628
|
+
dispatchWorkflowEvent(eventType, eventData, opts).catch((err) => {
|
|
629
|
+
console.warn(`[workflows] queueWorkflowEvent failure for ${eventType}: ${err?.message || err}`);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
448
633
|
// ── Vendor module map ─────────────────────────────────────────────────────────
|
|
449
634
|
// Served at /vendor/<name>.js — no auth required (public browser libraries).
|
|
450
635
|
//
|
|
@@ -1159,6 +1344,16 @@ const INTERNAL_EXECUTOR_MAP = {
|
|
|
1159
1344
|
POLL_MS: ["internalExecutor", "pollIntervalMs"],
|
|
1160
1345
|
REVIEW_AGENT_ENABLED: ["internalExecutor", "reviewAgentEnabled"],
|
|
1161
1346
|
REPLENISH_ENABLED: ["internalExecutor", "backlogReplenishment", "enabled"],
|
|
1347
|
+
STREAM_MAX_RETRIES: ["internalExecutor", "stream", "maxRetries"],
|
|
1348
|
+
STREAM_RETRY_BASE_MS: ["internalExecutor", "stream", "retryBaseMs"],
|
|
1349
|
+
STREAM_RETRY_MAX_MS: ["internalExecutor", "stream", "retryMaxMs"],
|
|
1350
|
+
STREAM_FIRST_EVENT_TIMEOUT_MS: [
|
|
1351
|
+
"internalExecutor",
|
|
1352
|
+
"stream",
|
|
1353
|
+
"firstEventTimeoutMs",
|
|
1354
|
+
],
|
|
1355
|
+
STREAM_MAX_ITEMS_PER_TURN: ["internalExecutor", "stream", "maxItemsPerTurn"],
|
|
1356
|
+
STREAM_MAX_ITEM_CHARS: ["internalExecutor", "stream", "maxItemChars"],
|
|
1162
1357
|
};
|
|
1163
1358
|
const CONFIG_PATH_OVERRIDES = {
|
|
1164
1359
|
EXECUTOR_MODE: ["internalExecutor", "mode"],
|
|
@@ -1757,6 +1952,314 @@ function persistLastUiPort(port) {
|
|
|
1757
1952
|
}
|
|
1758
1953
|
}
|
|
1759
1954
|
|
|
1955
|
+
const FALLBACK_AUTH_STATE_FILE = "ui-fallback-auth.json";
|
|
1956
|
+
const FALLBACK_AUTH_ALGORITHM = "argon2id";
|
|
1957
|
+
const FALLBACK_AUTH_SALT_BYTES = 16;
|
|
1958
|
+
const FALLBACK_AUTH_TAG_LENGTH = 32;
|
|
1959
|
+
const FALLBACK_AUTH_MEMORY_KIB = 64 * 1024;
|
|
1960
|
+
const FALLBACK_AUTH_PASSES = 3;
|
|
1961
|
+
const FALLBACK_AUTH_PARALLELISM = 1;
|
|
1962
|
+
const FALLBACK_AUTH_MIN_PASSWORD_LENGTH = 10;
|
|
1963
|
+
const FALLBACK_AUTH_MIN_PIN_LENGTH = 6;
|
|
1964
|
+
|
|
1965
|
+
let fallbackAuthRecordCache = null;
|
|
1966
|
+
const fallbackAuthRateLimitByIp = new Map();
|
|
1967
|
+
let fallbackAuthGlobalWindow = { windowStart: 0, count: 0 };
|
|
1968
|
+
const fallbackAuthRuntime = {
|
|
1969
|
+
failedAttempts: 0,
|
|
1970
|
+
lockoutUntil: 0,
|
|
1971
|
+
transientCooldownUntil: 0,
|
|
1972
|
+
lastFailureAt: 0,
|
|
1973
|
+
lastSuccessAt: 0,
|
|
1974
|
+
};
|
|
1975
|
+
|
|
1976
|
+
function getFallbackAuthConfig() {
|
|
1977
|
+
const enabled = parseBooleanEnv(
|
|
1978
|
+
process.env.TELEGRAM_UI_FALLBACK_AUTH_ENABLED,
|
|
1979
|
+
true,
|
|
1980
|
+
);
|
|
1981
|
+
const perIpPerMin = Math.max(
|
|
1982
|
+
1,
|
|
1983
|
+
Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_IP_PER_MIN || "10"),
|
|
1984
|
+
);
|
|
1985
|
+
const globalPerMin = Math.max(
|
|
1986
|
+
1,
|
|
1987
|
+
Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_GLOBAL_PER_MIN || "60"),
|
|
1988
|
+
);
|
|
1989
|
+
const maxFailures = Math.max(
|
|
1990
|
+
1,
|
|
1991
|
+
Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_MAX_FAILURES || "5"),
|
|
1992
|
+
);
|
|
1993
|
+
const lockoutMs = Math.max(
|
|
1994
|
+
10_000,
|
|
1995
|
+
Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_LOCKOUT_MS || "600000"),
|
|
1996
|
+
);
|
|
1997
|
+
const transientCooldownMs = Math.max(
|
|
1998
|
+
1000,
|
|
1999
|
+
Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_TRANSIENT_COOLDOWN_MS || "5000"),
|
|
2000
|
+
);
|
|
2001
|
+
const rotateDays = Math.max(
|
|
2002
|
+
1,
|
|
2003
|
+
Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_ROTATE_DAYS || "30"),
|
|
2004
|
+
);
|
|
2005
|
+
return {
|
|
2006
|
+
enabled,
|
|
2007
|
+
perIpPerMin,
|
|
2008
|
+
globalPerMin,
|
|
2009
|
+
maxFailures,
|
|
2010
|
+
lockoutMs,
|
|
2011
|
+
transientCooldownMs,
|
|
2012
|
+
rotateDays,
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function getFallbackAuthStatePath() {
|
|
2017
|
+
return resolveUiCachePath(FALLBACK_AUTH_STATE_FILE);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function readFallbackAuthRecord() {
|
|
2021
|
+
if (fallbackAuthRecordCache && typeof fallbackAuthRecordCache === "object") {
|
|
2022
|
+
return fallbackAuthRecordCache;
|
|
2023
|
+
}
|
|
2024
|
+
try {
|
|
2025
|
+
const statePath = getFallbackAuthStatePath();
|
|
2026
|
+
if (!existsSync(statePath)) return null;
|
|
2027
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8"));
|
|
2028
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2029
|
+
if (
|
|
2030
|
+
typeof parsed.hash !== "string"
|
|
2031
|
+
|| typeof parsed.salt !== "string"
|
|
2032
|
+
|| parsed.algorithm !== FALLBACK_AUTH_ALGORITHM
|
|
2033
|
+
) {
|
|
2034
|
+
return null;
|
|
2035
|
+
}
|
|
2036
|
+
fallbackAuthRecordCache = parsed;
|
|
2037
|
+
return parsed;
|
|
2038
|
+
} catch {
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function writeFallbackAuthRecord(record) {
|
|
2044
|
+
fallbackAuthRecordCache = record;
|
|
2045
|
+
try {
|
|
2046
|
+
const statePath = getFallbackAuthStatePath();
|
|
2047
|
+
writeFileSync(statePath, JSON.stringify(record, null, 2), "utf8");
|
|
2048
|
+
} catch {
|
|
2049
|
+
// best effort
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function clearFallbackAuthRecord() {
|
|
2054
|
+
fallbackAuthRecordCache = null;
|
|
2055
|
+
try {
|
|
2056
|
+
const statePath = getFallbackAuthStatePath();
|
|
2057
|
+
if (existsSync(statePath)) unlinkSync(statePath);
|
|
2058
|
+
} catch {
|
|
2059
|
+
// best effort
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function isFallbackSecretStrong(secret) {
|
|
2064
|
+
const normalized = String(secret || "").trim();
|
|
2065
|
+
if (!normalized) return false;
|
|
2066
|
+
if (/^\d+$/.test(normalized)) {
|
|
2067
|
+
return normalized.length >= FALLBACK_AUTH_MIN_PIN_LENGTH;
|
|
2068
|
+
}
|
|
2069
|
+
return normalized.length >= FALLBACK_AUTH_MIN_PASSWORD_LENGTH;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
async function deriveFallbackAuthHash(secret, saltBuffer) {
|
|
2073
|
+
return new Promise((resolveHash, rejectHash) => {
|
|
2074
|
+
argon2(
|
|
2075
|
+
FALLBACK_AUTH_ALGORITHM,
|
|
2076
|
+
{
|
|
2077
|
+
message: Buffer.from(String(secret || ""), "utf8"),
|
|
2078
|
+
nonce: saltBuffer,
|
|
2079
|
+
parallelism: FALLBACK_AUTH_PARALLELISM,
|
|
2080
|
+
tagLength: FALLBACK_AUTH_TAG_LENGTH,
|
|
2081
|
+
memory: FALLBACK_AUTH_MEMORY_KIB,
|
|
2082
|
+
passes: FALLBACK_AUTH_PASSES,
|
|
2083
|
+
},
|
|
2084
|
+
(err, hash) => {
|
|
2085
|
+
if (err) return rejectHash(err);
|
|
2086
|
+
resolveHash(hash);
|
|
2087
|
+
},
|
|
2088
|
+
);
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
async function setFallbackAuthSecret(secret, { actor = "api" } = {}) {
|
|
2093
|
+
if (!isFallbackSecretStrong(secret)) {
|
|
2094
|
+
throw new Error(
|
|
2095
|
+
`Fallback secret must be >= ${FALLBACK_AUTH_MIN_PIN_LENGTH} digits (PIN) or >= ${FALLBACK_AUTH_MIN_PASSWORD_LENGTH} chars (password)`,
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
const salt = randomBytes(FALLBACK_AUTH_SALT_BYTES);
|
|
2099
|
+
const hash = await deriveFallbackAuthHash(secret, salt);
|
|
2100
|
+
const existing = readFallbackAuthRecord();
|
|
2101
|
+
const now = Date.now();
|
|
2102
|
+
writeFallbackAuthRecord({
|
|
2103
|
+
version: 1,
|
|
2104
|
+
algorithm: FALLBACK_AUTH_ALGORITHM,
|
|
2105
|
+
hash: hash.toString("base64"),
|
|
2106
|
+
salt: salt.toString("base64"),
|
|
2107
|
+
tagLength: FALLBACK_AUTH_TAG_LENGTH,
|
|
2108
|
+
memoryKiB: FALLBACK_AUTH_MEMORY_KIB,
|
|
2109
|
+
passes: FALLBACK_AUTH_PASSES,
|
|
2110
|
+
parallelism: FALLBACK_AUTH_PARALLELISM,
|
|
2111
|
+
createdAt: Number(existing?.createdAt || now),
|
|
2112
|
+
updatedAt: now,
|
|
2113
|
+
rotatedAt: now,
|
|
2114
|
+
updatedBy: String(actor || "api"),
|
|
2115
|
+
});
|
|
2116
|
+
fallbackAuthRuntime.failedAttempts = 0;
|
|
2117
|
+
fallbackAuthRuntime.lockoutUntil = 0;
|
|
2118
|
+
fallbackAuthRuntime.transientCooldownUntil = 0;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function resetFallbackAuthSecret() {
|
|
2122
|
+
clearFallbackAuthRecord();
|
|
2123
|
+
fallbackAuthRuntime.failedAttempts = 0;
|
|
2124
|
+
fallbackAuthRuntime.lockoutUntil = 0;
|
|
2125
|
+
fallbackAuthRuntime.transientCooldownUntil = 0;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
function getRequestIp(req) {
|
|
2129
|
+
return String(
|
|
2130
|
+
req?.headers?.["x-forwarded-for"]
|
|
2131
|
+
|| req?.socket?.remoteAddress
|
|
2132
|
+
|| "unknown",
|
|
2133
|
+
).split(",")[0].trim().toLowerCase();
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function consumeFallbackRateLimits(req, config) {
|
|
2137
|
+
const now = Date.now();
|
|
2138
|
+
const windowMs = 60_000;
|
|
2139
|
+
const ip = getRequestIp(req);
|
|
2140
|
+
|
|
2141
|
+
const globalBucket = fallbackAuthGlobalWindow;
|
|
2142
|
+
if (!globalBucket.windowStart || now - globalBucket.windowStart > windowMs) {
|
|
2143
|
+
fallbackAuthGlobalWindow = { windowStart: now, count: 0 };
|
|
2144
|
+
}
|
|
2145
|
+
fallbackAuthGlobalWindow.count += 1;
|
|
2146
|
+
if (fallbackAuthGlobalWindow.count > config.globalPerMin) {
|
|
2147
|
+
return { ok: false, reason: "global_rate_limited" };
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
let bucket = fallbackAuthRateLimitByIp.get(ip);
|
|
2151
|
+
if (!bucket || now - bucket.windowStart > windowMs) {
|
|
2152
|
+
bucket = { windowStart: now, count: 0 };
|
|
2153
|
+
fallbackAuthRateLimitByIp.set(ip, bucket);
|
|
2154
|
+
}
|
|
2155
|
+
bucket.count += 1;
|
|
2156
|
+
if (bucket.count > config.perIpPerMin) {
|
|
2157
|
+
return { ok: false, reason: "ip_rate_limited" };
|
|
2158
|
+
}
|
|
2159
|
+
return { ok: true };
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
async function verifyFallbackAuthSecret(secret) {
|
|
2163
|
+
const record = readFallbackAuthRecord();
|
|
2164
|
+
if (!record?.hash || !record?.salt) return false;
|
|
2165
|
+
const storedHash = Buffer.from(String(record.hash || ""), "base64");
|
|
2166
|
+
const salt = Buffer.from(String(record.salt || ""), "base64");
|
|
2167
|
+
const derived = await deriveFallbackAuthHash(secret, salt);
|
|
2168
|
+
if (derived.length !== storedHash.length) return false;
|
|
2169
|
+
return timingSafeEqual(derived, storedHash);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async function attemptFallbackAuth(req, secret) {
|
|
2173
|
+
const cfg = getFallbackAuthConfig();
|
|
2174
|
+
const now = Date.now();
|
|
2175
|
+
if (!cfg.enabled) return { ok: false, reason: "disabled" };
|
|
2176
|
+
if (isAllowUnsafe()) return { ok: false, reason: "unsafe_mode_enabled" };
|
|
2177
|
+
if (!readFallbackAuthRecord()) return { ok: false, reason: "missing_credential" };
|
|
2178
|
+
|
|
2179
|
+
if (fallbackAuthRuntime.transientCooldownUntil > now) {
|
|
2180
|
+
return { ok: false, reason: "transient_cooldown" };
|
|
2181
|
+
}
|
|
2182
|
+
if (fallbackAuthRuntime.lockoutUntil > now) {
|
|
2183
|
+
return { ok: false, reason: "locked" };
|
|
2184
|
+
}
|
|
2185
|
+
const rateCheck = consumeFallbackRateLimits(req, cfg);
|
|
2186
|
+
if (!rateCheck.ok) return { ok: false, reason: rateCheck.reason };
|
|
2187
|
+
|
|
2188
|
+
try {
|
|
2189
|
+
const valid = await verifyFallbackAuthSecret(secret);
|
|
2190
|
+
if (valid) {
|
|
2191
|
+
fallbackAuthRuntime.failedAttempts = 0;
|
|
2192
|
+
fallbackAuthRuntime.lockoutUntil = 0;
|
|
2193
|
+
fallbackAuthRuntime.lastSuccessAt = now;
|
|
2194
|
+
ensureSessionToken();
|
|
2195
|
+
return { ok: true, reason: "success" };
|
|
2196
|
+
}
|
|
2197
|
+
} catch {
|
|
2198
|
+
fallbackAuthRuntime.transientCooldownUntil = now + cfg.transientCooldownMs;
|
|
2199
|
+
return { ok: false, reason: "transient_verify_error" };
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
fallbackAuthRuntime.failedAttempts += 1;
|
|
2203
|
+
fallbackAuthRuntime.lastFailureAt = now;
|
|
2204
|
+
if (fallbackAuthRuntime.failedAttempts >= cfg.maxFailures) {
|
|
2205
|
+
fallbackAuthRuntime.lockoutUntil = now + cfg.lockoutMs;
|
|
2206
|
+
fallbackAuthRuntime.failedAttempts = 0;
|
|
2207
|
+
}
|
|
2208
|
+
return { ok: false, reason: "invalid_secret" };
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function getFallbackAuthStatus() {
|
|
2212
|
+
const cfg = getFallbackAuthConfig();
|
|
2213
|
+
const record = readFallbackAuthRecord();
|
|
2214
|
+
const now = Date.now();
|
|
2215
|
+
const rotationDueAt = record?.updatedAt
|
|
2216
|
+
? Number(record.updatedAt) + cfg.rotateDays * 24 * 60 * 60 * 1000
|
|
2217
|
+
: 0;
|
|
2218
|
+
const rotationDue = rotationDueAt > 0 && now >= rotationDueAt;
|
|
2219
|
+
const locked = fallbackAuthRuntime.lockoutUntil > now;
|
|
2220
|
+
const remediation = [];
|
|
2221
|
+
if (!record) remediation.push("missing_credential");
|
|
2222
|
+
if (!cfg.enabled) remediation.push("disabled_by_env");
|
|
2223
|
+
if (isAllowUnsafe()) remediation.push("unsafe_mode_enabled");
|
|
2224
|
+
if (locked) remediation.push("locked_temporarily");
|
|
2225
|
+
if (rotationDue) remediation.push("rotation_due");
|
|
2226
|
+
return {
|
|
2227
|
+
configured: Boolean(record?.hash),
|
|
2228
|
+
enabled: cfg.enabled,
|
|
2229
|
+
active: cfg.enabled && Boolean(record?.hash) && !isAllowUnsafe(),
|
|
2230
|
+
locked,
|
|
2231
|
+
lockoutUntil: locked ? fallbackAuthRuntime.lockoutUntil : 0,
|
|
2232
|
+
transientCooldownUntil:
|
|
2233
|
+
fallbackAuthRuntime.transientCooldownUntil > now
|
|
2234
|
+
? fallbackAuthRuntime.transientCooldownUntil
|
|
2235
|
+
: 0,
|
|
2236
|
+
rotationDue,
|
|
2237
|
+
rotationDueAt,
|
|
2238
|
+
rotateDays: cfg.rotateDays,
|
|
2239
|
+
updatedAt: Number(record?.updatedAt || 0) || 0,
|
|
2240
|
+
updatedBy: String(record?.updatedBy || ""),
|
|
2241
|
+
rateLimits: {
|
|
2242
|
+
perIpPerMin: cfg.perIpPerMin,
|
|
2243
|
+
globalPerMin: cfg.globalPerMin,
|
|
2244
|
+
maxFailures: cfg.maxFailures,
|
|
2245
|
+
lockoutMs: cfg.lockoutMs,
|
|
2246
|
+
},
|
|
2247
|
+
remediation,
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
setInterval(() => {
|
|
2252
|
+
const now = Date.now();
|
|
2253
|
+
for (const [ip, bucket] of fallbackAuthRateLimitByIp) {
|
|
2254
|
+
if (now - bucket.windowStart > 120_000) {
|
|
2255
|
+
fallbackAuthRateLimitByIp.delete(ip);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
if (now - fallbackAuthGlobalWindow.windowStart > 120_000) {
|
|
2259
|
+
fallbackAuthGlobalWindow = { windowStart: 0, count: 0 };
|
|
2260
|
+
}
|
|
2261
|
+
}, 300_000).unref();
|
|
2262
|
+
|
|
1760
2263
|
const projectSyncWebhookMetrics = {
|
|
1761
2264
|
received: 0,
|
|
1762
2265
|
processed: 0,
|
|
@@ -1785,10 +2288,17 @@ const SETTINGS_KNOWN_KEYS = [
|
|
|
1785
2288
|
"TELEGRAM_HISTORY_RETENTION_DAYS",
|
|
1786
2289
|
"PROJECT_NAME", "TELEGRAM_MINIAPP_ENABLED", "TELEGRAM_UI_PORT", "TELEGRAM_UI_HOST",
|
|
1787
2290
|
"TELEGRAM_UI_PUBLIC_HOST", "TELEGRAM_UI_BASE_URL", "TELEGRAM_UI_ALLOW_UNSAFE",
|
|
1788
|
-
"TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL",
|
|
2291
|
+
"TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL", "TELEGRAM_UI_ALLOW_QUICK_TUNNEL_FALLBACK",
|
|
2292
|
+
"TELEGRAM_UI_FALLBACK_AUTH_ENABLED", "TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_IP_PER_MIN",
|
|
2293
|
+
"TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_GLOBAL_PER_MIN", "TELEGRAM_UI_FALLBACK_AUTH_MAX_FAILURES",
|
|
2294
|
+
"TELEGRAM_UI_FALLBACK_AUTH_LOCKOUT_MS", "TELEGRAM_UI_FALLBACK_AUTH_ROTATE_DAYS",
|
|
2295
|
+
"TELEGRAM_UI_FALLBACK_AUTH_TRANSIENT_COOLDOWN_MS",
|
|
1789
2296
|
"EXECUTOR_MODE", "INTERNAL_EXECUTOR_PARALLEL", "INTERNAL_EXECUTOR_SDK",
|
|
1790
2297
|
"INTERNAL_EXECUTOR_TIMEOUT_MS", "INTERNAL_EXECUTOR_MAX_RETRIES", "INTERNAL_EXECUTOR_POLL_MS",
|
|
1791
2298
|
"INTERNAL_EXECUTOR_REVIEW_AGENT_ENABLED", "INTERNAL_EXECUTOR_REPLENISH_ENABLED",
|
|
2299
|
+
"INTERNAL_EXECUTOR_STREAM_MAX_RETRIES", "INTERNAL_EXECUTOR_STREAM_RETRY_BASE_MS",
|
|
2300
|
+
"INTERNAL_EXECUTOR_STREAM_RETRY_MAX_MS", "INTERNAL_EXECUTOR_STREAM_FIRST_EVENT_TIMEOUT_MS",
|
|
2301
|
+
"INTERNAL_EXECUTOR_STREAM_MAX_ITEMS_PER_TURN", "INTERNAL_EXECUTOR_STREAM_MAX_ITEM_CHARS",
|
|
1792
2302
|
"CODEX_SDK_DISABLED", "COPILOT_SDK_DISABLED", "CLAUDE_SDK_DISABLED",
|
|
1793
2303
|
"PRIMARY_AGENT", "EXECUTORS", "EXECUTOR_DISTRIBUTION", "FAILOVER_STRATEGY",
|
|
1794
2304
|
"COMPLEXITY_ROUTING_ENABLED", "PROJECT_REQUIREMENTS_PROFILE",
|
|
@@ -1812,7 +2322,10 @@ const SETTINGS_KNOWN_KEYS = [
|
|
|
1812
2322
|
"GITHUB_PROJECT_SYNC_ALERT_FAILURE_THRESHOLD", "GITHUB_PROJECT_SYNC_RATE_LIMIT_ALERT_THRESHOLD",
|
|
1813
2323
|
"VK_TARGET_BRANCH", "CODEX_ANALYZE_MERGE_STRATEGY", "DEPENDABOT_AUTO_MERGE",
|
|
1814
2324
|
"GH_RECONCILE_ENABLED",
|
|
1815
|
-
"CLOUDFLARE_TUNNEL_NAME", "CLOUDFLARE_TUNNEL_CREDENTIALS",
|
|
2325
|
+
"CLOUDFLARE_TUNNEL_NAME", "CLOUDFLARE_TUNNEL_CREDENTIALS", "CLOUDFLARE_BASE_DOMAIN",
|
|
2326
|
+
"CLOUDFLARE_TUNNEL_HOSTNAME", "CLOUDFLARE_USERNAME_HOSTNAME_POLICY",
|
|
2327
|
+
"CLOUDFLARE_ZONE_ID", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_DNS_SYNC_ENABLED",
|
|
2328
|
+
"CLOUDFLARE_DNS_MAX_RETRIES", "CLOUDFLARE_DNS_RETRY_BASE_MS",
|
|
1816
2329
|
"TELEGRAM_PRESENCE_INTERVAL_SEC", "TELEGRAM_PRESENCE_DISABLED",
|
|
1817
2330
|
"VE_INSTANCE_LABEL", "VE_COORDINATOR_ELIGIBLE", "VE_COORDINATOR_PRIORITY",
|
|
1818
2331
|
"FLEET_ENABLED", "FLEET_BUFFER_MULTIPLIER", "FLEET_SYNC_INTERVAL_MS",
|
|
@@ -1843,7 +2356,7 @@ const SETTINGS_SENSITIVE_KEYS = new Set([
|
|
|
1843
2356
|
"OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "CODEX_MODEL_PROFILE_XL_API_KEY", "CODEX_MODEL_PROFILE_M_API_KEY",
|
|
1844
2357
|
"ANTHROPIC_API_KEY", "COPILOT_CLI_TOKEN", "GITHUB_PROJECT_WEBHOOK_SECRET",
|
|
1845
2358
|
"BOSUN_GITHUB_CLIENT_SECRET", "BOSUN_GITHUB_WEBHOOK_SECRET", "BOSUN_GITHUB_USER_TOKEN",
|
|
1846
|
-
"CLOUDFLARE_TUNNEL_CREDENTIALS",
|
|
2359
|
+
"CLOUDFLARE_TUNNEL_CREDENTIALS", "CLOUDFLARE_API_TOKEN",
|
|
1847
2360
|
]);
|
|
1848
2361
|
|
|
1849
2362
|
const SETTINGS_KNOWN_SET = new Set(SETTINGS_KNOWN_KEYS);
|
|
@@ -2403,29 +2916,467 @@ export async function openFirewallPort(port) {
|
|
|
2403
2916
|
|
|
2404
2917
|
// ── Cloudflared tunnel for trusted TLS ──────────────────────────────
|
|
2405
2918
|
|
|
2919
|
+
const TUNNEL_MODE_NAMED = "named";
|
|
2920
|
+
const TUNNEL_MODE_QUICK = "quick";
|
|
2921
|
+
const TUNNEL_MODE_DISABLED = "disabled";
|
|
2922
|
+
const DEFAULT_TUNNEL_MODE = TUNNEL_MODE_NAMED;
|
|
2923
|
+
const DEFAULT_CLOUDFLARE_DNS_RETRY_MAX = 3;
|
|
2924
|
+
const DEFAULT_CLOUDFLARE_DNS_RETRY_BASE_MS = 750;
|
|
2925
|
+
const RESERVED_HOSTNAME_LABELS = new Set([
|
|
2926
|
+
"www",
|
|
2927
|
+
"api",
|
|
2928
|
+
"admin",
|
|
2929
|
+
"root",
|
|
2930
|
+
"mail",
|
|
2931
|
+
"ftp",
|
|
2932
|
+
"smtp",
|
|
2933
|
+
"autodiscover",
|
|
2934
|
+
"localhost",
|
|
2935
|
+
]);
|
|
2936
|
+
|
|
2406
2937
|
let tunnelUrl = null;
|
|
2407
2938
|
let tunnelProcess = null;
|
|
2939
|
+
let tunnelPublicHostname = "";
|
|
2940
|
+
let tunnelRuntimeState = {
|
|
2941
|
+
mode: TUNNEL_MODE_DISABLED,
|
|
2942
|
+
tunnelName: "",
|
|
2943
|
+
tunnelId: "",
|
|
2944
|
+
hostname: "",
|
|
2945
|
+
dnsAction: "none",
|
|
2946
|
+
dnsStatus: "not_configured",
|
|
2947
|
+
fallbackToQuick: false,
|
|
2948
|
+
lastError: "",
|
|
2949
|
+
};
|
|
2950
|
+
let quickTunnelRestartTimer = null;
|
|
2951
|
+
let quickTunnelRestartAttempts = 0;
|
|
2952
|
+
let quickTunnelRestartSuppressed = false;
|
|
2408
2953
|
|
|
2409
|
-
/** Return the tunnel URL (
|
|
2954
|
+
/** Return the active tunnel URL (named or quick) or null. */
|
|
2410
2955
|
export function getTunnelUrl() {
|
|
2411
2956
|
return tunnelUrl;
|
|
2412
2957
|
}
|
|
2413
2958
|
|
|
2959
|
+
export function getTunnelStatus() {
|
|
2960
|
+
return {
|
|
2961
|
+
mode: tunnelRuntimeState.mode,
|
|
2962
|
+
tunnelName: tunnelRuntimeState.tunnelName || null,
|
|
2963
|
+
tunnelId: tunnelRuntimeState.tunnelId || null,
|
|
2964
|
+
hostname: tunnelRuntimeState.hostname || tunnelPublicHostname || null,
|
|
2965
|
+
publicUrl: tunnelUrl || null,
|
|
2966
|
+
dns: {
|
|
2967
|
+
status: tunnelRuntimeState.dnsStatus || "unknown",
|
|
2968
|
+
action: tunnelRuntimeState.dnsAction || "none",
|
|
2969
|
+
},
|
|
2970
|
+
fallbackToQuick: Boolean(tunnelRuntimeState.fallbackToQuick),
|
|
2971
|
+
active: Boolean(tunnelProcess && tunnelUrl),
|
|
2972
|
+
lastError: tunnelRuntimeState.lastError || null,
|
|
2973
|
+
};
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2414
2976
|
let _tunnelReadyCallbacks = [];
|
|
2415
2977
|
|
|
2416
2978
|
/** Register a callback to be called whenever the tunnel URL changes. */
|
|
2417
2979
|
export function onTunnelUrlChange(cb) {
|
|
2418
|
-
if (typeof cb ===
|
|
2980
|
+
if (typeof cb === "function") _tunnelReadyCallbacks.push(cb);
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function setTunnelRuntimeState(next = {}) {
|
|
2984
|
+
tunnelRuntimeState = {
|
|
2985
|
+
...tunnelRuntimeState,
|
|
2986
|
+
...next,
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
export function normalizeTunnelMode(rawValue) {
|
|
2991
|
+
const value = String(rawValue || "").trim().toLowerCase();
|
|
2992
|
+
if (!value) return DEFAULT_TUNNEL_MODE;
|
|
2993
|
+
if (["disabled", "off", "false", "0"].includes(value)) return TUNNEL_MODE_DISABLED;
|
|
2994
|
+
if (["quick", "quick-tunnel", "ephemeral", "trycloudflare"].includes(value)) {
|
|
2995
|
+
return TUNNEL_MODE_QUICK;
|
|
2996
|
+
}
|
|
2997
|
+
if (["cloudflared", "auto", "named", "permanent"].includes(value)) {
|
|
2998
|
+
return TUNNEL_MODE_NAMED;
|
|
2999
|
+
}
|
|
3000
|
+
return DEFAULT_TUNNEL_MODE;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function parseBooleanEnvValue(rawValue, fallback = false) {
|
|
3004
|
+
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
|
|
3005
|
+
return fallback;
|
|
3006
|
+
}
|
|
3007
|
+
const normalized = String(rawValue).trim().toLowerCase();
|
|
3008
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
3009
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
3010
|
+
return fallback;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
function parseBoundedInt(rawValue, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
3014
|
+
const parsed = Number(rawValue);
|
|
3015
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
3016
|
+
return Math.min(max, Math.max(min, Math.trunc(parsed)));
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
function normalizeDomainName(rawValue) {
|
|
3020
|
+
const value = String(rawValue || "").trim().toLowerCase();
|
|
3021
|
+
if (!value) return "";
|
|
3022
|
+
const withoutScheme = value.replace(/^https?:\/\//, "");
|
|
3023
|
+
const withoutPath = withoutScheme.split("/")[0].replace(/:\d+$/, "");
|
|
3024
|
+
return withoutPath.replace(/\.+$/, "");
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
export function sanitizeHostnameLabel(rawValue, fallback = "operator") {
|
|
3028
|
+
const normalized = String(rawValue || "")
|
|
3029
|
+
.trim()
|
|
3030
|
+
.toLowerCase()
|
|
3031
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
3032
|
+
.replace(/-+/g, "-")
|
|
3033
|
+
.replace(/^-+/, "")
|
|
3034
|
+
.replace(/-+$/, "");
|
|
3035
|
+
if (!normalized) return fallback;
|
|
3036
|
+
return normalized.slice(0, 63).replace(/-+$/, "") || fallback;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
function getTunnelIdentity() {
|
|
3040
|
+
const candidates = [
|
|
3041
|
+
process.env.CLOUDFLARE_TUNNEL_USERNAME,
|
|
3042
|
+
process.env.CLOUDFLARE_HOSTNAME_USER,
|
|
3043
|
+
process.env.BOSUN_OPERATOR_ID,
|
|
3044
|
+
process.env.USERNAME,
|
|
3045
|
+
process.env.USER,
|
|
3046
|
+
];
|
|
3047
|
+
for (const value of candidates) {
|
|
3048
|
+
const trimmed = String(value || "").trim();
|
|
3049
|
+
if (trimmed) return trimmed;
|
|
3050
|
+
}
|
|
3051
|
+
try {
|
|
3052
|
+
const info = getOsUserInfo();
|
|
3053
|
+
if (info?.username) return info.username;
|
|
3054
|
+
} catch {
|
|
3055
|
+
// best effort
|
|
3056
|
+
}
|
|
3057
|
+
return "operator";
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
const HOSTNAME_MAP_FILE = "cloudflare-hostname-map.json";
|
|
3061
|
+
let _hostnameMapCache = null;
|
|
3062
|
+
|
|
3063
|
+
function readHostnameMapStore() {
|
|
3064
|
+
if (_hostnameMapCache && typeof _hostnameMapCache === "object") {
|
|
3065
|
+
return _hostnameMapCache;
|
|
3066
|
+
}
|
|
3067
|
+
const fallback = { version: 1, domains: {} };
|
|
3068
|
+
try {
|
|
3069
|
+
const mapPath = resolveUiCachePath(HOSTNAME_MAP_FILE);
|
|
3070
|
+
if (!existsSync(mapPath)) {
|
|
3071
|
+
_hostnameMapCache = fallback;
|
|
3072
|
+
return fallback;
|
|
3073
|
+
}
|
|
3074
|
+
const parsed = JSON.parse(readFileSync(mapPath, "utf8"));
|
|
3075
|
+
if (!parsed || typeof parsed !== "object") {
|
|
3076
|
+
_hostnameMapCache = fallback;
|
|
3077
|
+
return fallback;
|
|
3078
|
+
}
|
|
3079
|
+
if (!parsed.domains || typeof parsed.domains !== "object") {
|
|
3080
|
+
parsed.domains = {};
|
|
3081
|
+
}
|
|
3082
|
+
_hostnameMapCache = parsed;
|
|
3083
|
+
return parsed;
|
|
3084
|
+
} catch {
|
|
3085
|
+
_hostnameMapCache = fallback;
|
|
3086
|
+
return fallback;
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
function writeHostnameMapStore(data) {
|
|
3091
|
+
try {
|
|
3092
|
+
const mapPath = resolveUiCachePath(HOSTNAME_MAP_FILE);
|
|
3093
|
+
writeFileSync(mapPath, JSON.stringify(data, null, 2), "utf8");
|
|
3094
|
+
} catch {
|
|
3095
|
+
// best effort
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function getDomainMap(store, baseDomain) {
|
|
3100
|
+
const normalizedBaseDomain = normalizeDomainName(baseDomain);
|
|
3101
|
+
if (!normalizedBaseDomain) {
|
|
3102
|
+
return { normalizedBaseDomain: "", map: { byIdentity: {}, byLabel: {} } };
|
|
3103
|
+
}
|
|
3104
|
+
if (!store.domains[normalizedBaseDomain] || typeof store.domains[normalizedBaseDomain] !== "object") {
|
|
3105
|
+
store.domains[normalizedBaseDomain] = {
|
|
3106
|
+
byIdentity: {},
|
|
3107
|
+
byLabel: {},
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
const domainMap = store.domains[normalizedBaseDomain];
|
|
3111
|
+
if (!domainMap.byIdentity || typeof domainMap.byIdentity !== "object") {
|
|
3112
|
+
domainMap.byIdentity = {};
|
|
3113
|
+
}
|
|
3114
|
+
if (!domainMap.byLabel || typeof domainMap.byLabel !== "object") {
|
|
3115
|
+
domainMap.byLabel = {};
|
|
3116
|
+
}
|
|
3117
|
+
return { normalizedBaseDomain, map: domainMap };
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
function allocateStableHostnameLabel(domainMap, identity, preferredLabel) {
|
|
3121
|
+
const normalizedIdentity = String(identity || "").trim().toLowerCase();
|
|
3122
|
+
const existing = String(domainMap.byIdentity?.[normalizedIdentity] || "").trim().toLowerCase();
|
|
3123
|
+
if (existing && domainMap.byLabel?.[existing] === normalizedIdentity) {
|
|
3124
|
+
return existing;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
const safeBaseLabel = sanitizeHostnameLabel(preferredLabel, "operator");
|
|
3128
|
+
let baseLabel = RESERVED_HOSTNAME_LABELS.has(safeBaseLabel)
|
|
3129
|
+
? `${safeBaseLabel}-user`
|
|
3130
|
+
: safeBaseLabel;
|
|
3131
|
+
baseLabel = sanitizeHostnameLabel(baseLabel, "operator");
|
|
3132
|
+
let candidate = baseLabel;
|
|
3133
|
+
let suffix = 1;
|
|
3134
|
+
|
|
3135
|
+
while (true) {
|
|
3136
|
+
const owner = String(domainMap.byLabel?.[candidate] || "").trim().toLowerCase();
|
|
3137
|
+
const reserved = RESERVED_HOSTNAME_LABELS.has(candidate);
|
|
3138
|
+
if ((!owner || owner === normalizedIdentity) && !reserved) {
|
|
3139
|
+
domainMap.byLabel[candidate] = normalizedIdentity;
|
|
3140
|
+
domainMap.byIdentity[normalizedIdentity] = candidate;
|
|
3141
|
+
return candidate;
|
|
3142
|
+
}
|
|
3143
|
+
suffix += 1;
|
|
3144
|
+
candidate = sanitizeHostnameLabel(`${baseLabel}-${suffix}`, baseLabel);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
export function resolveDeterministicTunnelHostname({
|
|
3149
|
+
baseDomain,
|
|
3150
|
+
explicitHostname,
|
|
3151
|
+
username,
|
|
3152
|
+
policy,
|
|
3153
|
+
} = {}) {
|
|
3154
|
+
const normalizedBaseDomain = normalizeDomainName(
|
|
3155
|
+
baseDomain || process.env.CLOUDFLARE_BASE_DOMAIN || process.env.CF_BASE_DOMAIN,
|
|
3156
|
+
);
|
|
3157
|
+
const explicit = normalizeDomainName(
|
|
3158
|
+
explicitHostname || process.env.CLOUDFLARE_TUNNEL_HOSTNAME || process.env.CF_TUNNEL_HOSTNAME,
|
|
3159
|
+
);
|
|
3160
|
+
if (explicit) {
|
|
3161
|
+
return {
|
|
3162
|
+
hostname: explicit,
|
|
3163
|
+
baseDomain: explicit.split(".").slice(1).join("."),
|
|
3164
|
+
label: explicit.split(".")[0] || "operator",
|
|
3165
|
+
identity: "",
|
|
3166
|
+
policy: "explicit",
|
|
3167
|
+
source: "explicit",
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
if (!normalizedBaseDomain) {
|
|
3171
|
+
throw new Error(
|
|
3172
|
+
"Missing CLOUDFLARE_BASE_DOMAIN (or CLOUDFLARE_TUNNEL_HOSTNAME) for named tunnel hostname resolution",
|
|
3173
|
+
);
|
|
3174
|
+
}
|
|
3175
|
+
const resolvedPolicy = String(
|
|
3176
|
+
policy || process.env.CLOUDFLARE_USERNAME_HOSTNAME_POLICY || "per-user-fixed",
|
|
3177
|
+
)
|
|
3178
|
+
.trim()
|
|
3179
|
+
.toLowerCase();
|
|
3180
|
+
const perUserFixed = resolvedPolicy !== "fixed";
|
|
3181
|
+
const identityRaw = username || getTunnelIdentity();
|
|
3182
|
+
const identity = sanitizeHostnameLabel(identityRaw, "operator");
|
|
3183
|
+
|
|
3184
|
+
if (!perUserFixed) {
|
|
3185
|
+
const fixedLabel = sanitizeHostnameLabel(
|
|
3186
|
+
process.env.CLOUDFLARE_FIXED_HOST_LABEL || process.env.CF_FIXED_HOST_LABEL || "bosun",
|
|
3187
|
+
"bosun",
|
|
3188
|
+
);
|
|
3189
|
+
const label = RESERVED_HOSTNAME_LABELS.has(fixedLabel)
|
|
3190
|
+
? sanitizeHostnameLabel(`${fixedLabel}-app`, "bosun")
|
|
3191
|
+
: fixedLabel;
|
|
3192
|
+
return {
|
|
3193
|
+
hostname: `${label}.${normalizedBaseDomain}`,
|
|
3194
|
+
baseDomain: normalizedBaseDomain,
|
|
3195
|
+
label,
|
|
3196
|
+
identity,
|
|
3197
|
+
policy: "fixed",
|
|
3198
|
+
source: "fixed",
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
const store = readHostnameMapStore();
|
|
3203
|
+
const { map } = getDomainMap(store, normalizedBaseDomain);
|
|
3204
|
+
const label = allocateStableHostnameLabel(map, identity, identity);
|
|
3205
|
+
writeHostnameMapStore(store);
|
|
3206
|
+
return {
|
|
3207
|
+
hostname: `${label}.${normalizedBaseDomain}`,
|
|
3208
|
+
baseDomain: normalizedBaseDomain,
|
|
3209
|
+
label,
|
|
3210
|
+
identity,
|
|
3211
|
+
policy: "per-user-fixed",
|
|
3212
|
+
source: "map",
|
|
3213
|
+
};
|
|
2419
3214
|
}
|
|
2420
3215
|
|
|
2421
3216
|
function _notifyTunnelChange(url) {
|
|
2422
3217
|
for (const cb of _tunnelReadyCallbacks) {
|
|
2423
|
-
try {
|
|
3218
|
+
try {
|
|
3219
|
+
cb(url);
|
|
3220
|
+
} catch (err) {
|
|
2424
3221
|
console.warn(`[telegram-ui] tunnel change callback error: ${err.message}`);
|
|
2425
3222
|
}
|
|
2426
3223
|
}
|
|
2427
3224
|
}
|
|
2428
3225
|
|
|
3226
|
+
function getCloudflareApiConfig() {
|
|
3227
|
+
const token = String(
|
|
3228
|
+
process.env.CLOUDFLARE_API_TOKEN || process.env.CF_API_TOKEN || "",
|
|
3229
|
+
).trim();
|
|
3230
|
+
const zoneId = String(
|
|
3231
|
+
process.env.CLOUDFLARE_ZONE_ID || process.env.CF_ZONE_ID || "",
|
|
3232
|
+
).trim();
|
|
3233
|
+
const enabled = parseBooleanEnvValue(process.env.CLOUDFLARE_DNS_SYNC_ENABLED, true);
|
|
3234
|
+
return {
|
|
3235
|
+
enabled,
|
|
3236
|
+
token,
|
|
3237
|
+
zoneId,
|
|
3238
|
+
baseUrl: "https://api.cloudflare.com/client/v4",
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
function getCloudflareDnsRetryConfig() {
|
|
3243
|
+
const maxRetries = parseBoundedInt(
|
|
3244
|
+
process.env.CLOUDFLARE_DNS_MAX_RETRIES,
|
|
3245
|
+
DEFAULT_CLOUDFLARE_DNS_RETRY_MAX,
|
|
3246
|
+
{ min: 1, max: 8 },
|
|
3247
|
+
);
|
|
3248
|
+
const retryBaseMs = parseBoundedInt(
|
|
3249
|
+
process.env.CLOUDFLARE_DNS_RETRY_BASE_MS,
|
|
3250
|
+
DEFAULT_CLOUDFLARE_DNS_RETRY_BASE_MS,
|
|
3251
|
+
{ min: 100, max: 5000 },
|
|
3252
|
+
);
|
|
3253
|
+
return { maxRetries, retryBaseMs };
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
async function sleep(ms) {
|
|
3257
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
async function cloudflareApiRequest(api, path, { method = "GET", body = undefined } = {}) {
|
|
3261
|
+
const { maxRetries, retryBaseMs } = getCloudflareDnsRetryConfig();
|
|
3262
|
+
if (!api?.token || !api?.zoneId) {
|
|
3263
|
+
throw new Error("Cloudflare API token/zone not configured");
|
|
3264
|
+
}
|
|
3265
|
+
let lastError = null;
|
|
3266
|
+
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
|
3267
|
+
try {
|
|
3268
|
+
const res = await fetch(`${api.baseUrl}${path}`, {
|
|
3269
|
+
method,
|
|
3270
|
+
headers: {
|
|
3271
|
+
Authorization: `Bearer ${api.token}`,
|
|
3272
|
+
"Content-Type": "application/json",
|
|
3273
|
+
},
|
|
3274
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
3275
|
+
});
|
|
3276
|
+
const payload = await res.json().catch(() => ({}));
|
|
3277
|
+
if (!res.ok || payload?.success === false) {
|
|
3278
|
+
const errMessage = Array.isArray(payload?.errors)
|
|
3279
|
+
? payload.errors.map((entry) => entry?.message).filter(Boolean).join("; ")
|
|
3280
|
+
: `${res.status} ${res.statusText || ""}`.trim();
|
|
3281
|
+
const err = new Error(`Cloudflare API ${method} ${path} failed: ${errMessage}`);
|
|
3282
|
+
err.status = res.status;
|
|
3283
|
+
throw err;
|
|
3284
|
+
}
|
|
3285
|
+
return payload;
|
|
3286
|
+
} catch (err) {
|
|
3287
|
+
lastError = err;
|
|
3288
|
+
const status = Number(err?.status || 0);
|
|
3289
|
+
const retryable = status === 429 || status >= 500 || status === 0;
|
|
3290
|
+
if (!retryable || attempt >= maxRetries) break;
|
|
3291
|
+
const backoff = retryBaseMs * 2 ** (attempt - 1);
|
|
3292
|
+
const jitter = Math.floor(Math.random() * Math.min(500, Math.max(100, backoff / 3)));
|
|
3293
|
+
await sleep(backoff + jitter);
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
throw lastError || new Error(`Cloudflare API ${method} ${path} failed`);
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
export async function ensureCloudflareDnsCname({
|
|
3300
|
+
hostname,
|
|
3301
|
+
target,
|
|
3302
|
+
proxied = true,
|
|
3303
|
+
api = getCloudflareApiConfig(),
|
|
3304
|
+
} = {}) {
|
|
3305
|
+
const normalizedHostname = normalizeDomainName(hostname);
|
|
3306
|
+
const normalizedTarget = normalizeDomainName(target);
|
|
3307
|
+
if (!normalizedHostname || !normalizedTarget) {
|
|
3308
|
+
throw new Error("Missing hostname/target for Cloudflare DNS sync");
|
|
3309
|
+
}
|
|
3310
|
+
if (!api?.enabled) {
|
|
3311
|
+
return { ok: true, changed: false, action: "disabled" };
|
|
3312
|
+
}
|
|
3313
|
+
if (!api.token || !api.zoneId) {
|
|
3314
|
+
return { ok: false, changed: false, action: "missing_credentials" };
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
const query = `/zones/${encodeURIComponent(api.zoneId)}/dns_records?type=CNAME&name=${encodeURIComponent(normalizedHostname)}&per_page=100`;
|
|
3318
|
+
const listed = await cloudflareApiRequest(api, query, { method: "GET" });
|
|
3319
|
+
const records = Array.isArray(listed?.result) ? listed.result : [];
|
|
3320
|
+
const existing = records.find(
|
|
3321
|
+
(record) => String(record?.type || "").toUpperCase() === "CNAME"
|
|
3322
|
+
&& String(record?.name || "").toLowerCase() === normalizedHostname,
|
|
3323
|
+
);
|
|
3324
|
+
const payload = {
|
|
3325
|
+
type: "CNAME",
|
|
3326
|
+
name: normalizedHostname,
|
|
3327
|
+
content: normalizedTarget,
|
|
3328
|
+
proxied: Boolean(proxied),
|
|
3329
|
+
ttl: 1,
|
|
3330
|
+
};
|
|
3331
|
+
|
|
3332
|
+
if (existing) {
|
|
3333
|
+
const sameTarget = String(existing.content || "").toLowerCase() === normalizedTarget;
|
|
3334
|
+
const sameProxy = Boolean(existing.proxied) === Boolean(payload.proxied);
|
|
3335
|
+
if (sameTarget && sameProxy) {
|
|
3336
|
+
return { ok: true, changed: false, action: "noop", id: existing.id };
|
|
3337
|
+
}
|
|
3338
|
+
const updated = await cloudflareApiRequest(
|
|
3339
|
+
api,
|
|
3340
|
+
`/zones/${encodeURIComponent(api.zoneId)}/dns_records/${encodeURIComponent(existing.id)}`,
|
|
3341
|
+
{ method: "PUT", body: payload },
|
|
3342
|
+
);
|
|
3343
|
+
return {
|
|
3344
|
+
ok: true,
|
|
3345
|
+
changed: true,
|
|
3346
|
+
action: "updated",
|
|
3347
|
+
id: updated?.result?.id || existing.id,
|
|
3348
|
+
};
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
const created = await cloudflareApiRequest(
|
|
3352
|
+
api,
|
|
3353
|
+
`/zones/${encodeURIComponent(api.zoneId)}/dns_records`,
|
|
3354
|
+
{ method: "POST", body: payload },
|
|
3355
|
+
);
|
|
3356
|
+
return {
|
|
3357
|
+
ok: true,
|
|
3358
|
+
changed: true,
|
|
3359
|
+
action: "created",
|
|
3360
|
+
id: created?.result?.id || null,
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
function getTunnelStartupConfig() {
|
|
3365
|
+
return {
|
|
3366
|
+
mode: normalizeTunnelMode(process.env.TELEGRAM_UI_TUNNEL || DEFAULT_TUNNEL_MODE),
|
|
3367
|
+
namedTunnel: String(
|
|
3368
|
+
process.env.CLOUDFLARE_TUNNEL_NAME || process.env.CF_TUNNEL_NAME || "",
|
|
3369
|
+
).trim(),
|
|
3370
|
+
credentialsPath: String(
|
|
3371
|
+
process.env.CLOUDFLARE_TUNNEL_CREDENTIALS || process.env.CF_TUNNEL_CREDENTIALS || "",
|
|
3372
|
+
).trim(),
|
|
3373
|
+
allowQuickFallback: parseBooleanEnvValue(
|
|
3374
|
+
process.env.TELEGRAM_UI_ALLOW_QUICK_TUNNEL_FALLBACK,
|
|
3375
|
+
false,
|
|
3376
|
+
),
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
|
|
2429
3380
|
// ── Cloudflared binary auto-download ─────────────────────────────────
|
|
2430
3381
|
|
|
2431
3382
|
const CF_BIN_NAME = osPlatform() === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
@@ -2528,24 +3479,27 @@ async function findCloudflared() {
|
|
|
2528
3479
|
/**
|
|
2529
3480
|
* Start a cloudflared tunnel for the given local URL.
|
|
2530
3481
|
*
|
|
2531
|
-
*
|
|
2532
|
-
*
|
|
2533
|
-
*
|
|
2534
|
-
*
|
|
2535
|
-
* Pros: Stable URL, custom domain. Cons: Requires cloudflare account + tunnel setup.
|
|
2536
|
-
*
|
|
2537
|
-
* Named tunnel setup:
|
|
2538
|
-
* 1. Create a tunnel: `cloudflared tunnel create <name>`
|
|
2539
|
-
* 2. Create DNS record: `cloudflared tunnel route dns <name> <subdomain.yourdomain.com>`
|
|
2540
|
-
* 3. Set env vars:
|
|
2541
|
-
* - CLOUDFLARE_TUNNEL_NAME=<name>
|
|
2542
|
-
* - CLOUDFLARE_TUNNEL_CREDENTIALS=/path/to/<tunnel-id>.json
|
|
3482
|
+
* Modes:
|
|
3483
|
+
* - named (default): persistent per-user hostname + DNS orchestration
|
|
3484
|
+
* - quick: random trycloudflare hostname (explicit fallback mode)
|
|
3485
|
+
* - disabled: no tunnel
|
|
2543
3486
|
*
|
|
2544
3487
|
* Returns the assigned public URL or null on failure.
|
|
2545
3488
|
*/
|
|
2546
3489
|
async function startTunnel(localPort) {
|
|
2547
|
-
const
|
|
2548
|
-
|
|
3490
|
+
const tunnelCfg = getTunnelStartupConfig();
|
|
3491
|
+
setTunnelRuntimeState({
|
|
3492
|
+
mode: tunnelCfg.mode,
|
|
3493
|
+
tunnelName: tunnelCfg.namedTunnel || "",
|
|
3494
|
+
tunnelId: "",
|
|
3495
|
+
hostname: "",
|
|
3496
|
+
dnsAction: "none",
|
|
3497
|
+
dnsStatus: "not_configured",
|
|
3498
|
+
fallbackToQuick: false,
|
|
3499
|
+
lastError: "",
|
|
3500
|
+
});
|
|
3501
|
+
|
|
3502
|
+
if (tunnelCfg.mode === TUNNEL_MODE_DISABLED) {
|
|
2549
3503
|
console.log("[telegram-ui] tunnel disabled via TELEGRAM_UI_TUNNEL=disabled");
|
|
2550
3504
|
return null;
|
|
2551
3505
|
}
|
|
@@ -2553,7 +3507,7 @@ async function startTunnel(localPort) {
|
|
|
2553
3507
|
// ── SECURITY: Block tunnel when auth is disabled ─────────────────────
|
|
2554
3508
|
if (isAllowUnsafe()) {
|
|
2555
3509
|
console.error(
|
|
2556
|
-
"[telegram-ui]
|
|
3510
|
+
"[telegram-ui] :ban: REFUSING to start Cloudflare tunnel — TELEGRAM_UI_ALLOW_UNSAFE=true\n" +
|
|
2557
3511
|
" A public tunnel with no authentication lets ANYONE on the internet\n" +
|
|
2558
3512
|
" control your agents, read secrets, and execute commands.\n" +
|
|
2559
3513
|
"\n" +
|
|
@@ -2568,26 +3522,39 @@ async function startTunnel(localPort) {
|
|
|
2568
3522
|
|
|
2569
3523
|
const cfBin = await findCloudflared();
|
|
2570
3524
|
if (!cfBin) {
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
"[telegram-ui] cloudflared unavailable — Telegram Mini App will use self-signed cert (may be rejected by Telegram webview).",
|
|
2574
|
-
);
|
|
2575
|
-
return null;
|
|
2576
|
-
}
|
|
2577
|
-
console.warn("[telegram-ui] cloudflared not found but TELEGRAM_UI_TUNNEL=cloudflared requested");
|
|
3525
|
+
setTunnelRuntimeState({ lastError: "cloudflared_not_found" });
|
|
3526
|
+
console.warn("[telegram-ui] cloudflared unavailable; tunnel not started");
|
|
2578
3527
|
return null;
|
|
2579
3528
|
}
|
|
2580
3529
|
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
3530
|
+
if (tunnelCfg.mode === TUNNEL_MODE_QUICK) {
|
|
3531
|
+
return startQuickTunnel(cfBin, localPort);
|
|
3532
|
+
}
|
|
2584
3533
|
|
|
2585
|
-
|
|
2586
|
-
|
|
3534
|
+
const namedTunnelResult = await startNamedTunnel(
|
|
3535
|
+
cfBin,
|
|
3536
|
+
{
|
|
3537
|
+
tunnelName: tunnelCfg.namedTunnel,
|
|
3538
|
+
credentialsPath: tunnelCfg.credentialsPath,
|
|
3539
|
+
},
|
|
3540
|
+
localPort,
|
|
3541
|
+
);
|
|
3542
|
+
if (namedTunnelResult) return namedTunnelResult;
|
|
3543
|
+
|
|
3544
|
+
if (tunnelCfg.allowQuickFallback) {
|
|
3545
|
+
console.warn("[telegram-ui] named tunnel failed; falling back to quick tunnel (explicitly allowed)");
|
|
3546
|
+
setTunnelRuntimeState({
|
|
3547
|
+
fallbackToQuick: true,
|
|
3548
|
+
mode: TUNNEL_MODE_QUICK,
|
|
3549
|
+
});
|
|
3550
|
+
return startQuickTunnel(cfBin, localPort);
|
|
2587
3551
|
}
|
|
2588
3552
|
|
|
2589
|
-
|
|
2590
|
-
|
|
3553
|
+
setTunnelRuntimeState({
|
|
3554
|
+
fallbackToQuick: false,
|
|
3555
|
+
lastError: tunnelRuntimeState.lastError || "named_tunnel_failed",
|
|
3556
|
+
});
|
|
3557
|
+
return null;
|
|
2591
3558
|
}
|
|
2592
3559
|
|
|
2593
3560
|
/**
|
|
@@ -2595,7 +3562,7 @@ async function startTunnel(localPort) {
|
|
|
2595
3562
|
* Returns the child process or throws after max retries.
|
|
2596
3563
|
*/
|
|
2597
3564
|
function spawnCloudflared(cfBin, args, maxRetries = 3) {
|
|
2598
|
-
for (let attempt = 1; attempt <= maxRetries; attempt
|
|
3565
|
+
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
|
2599
3566
|
try {
|
|
2600
3567
|
return spawn(cfBin, args, {
|
|
2601
3568
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -2618,25 +3585,104 @@ function spawnCloudflared(cfBin, args, maxRetries = 3) {
|
|
|
2618
3585
|
|
|
2619
3586
|
/**
|
|
2620
3587
|
* Start a cloudflared **named tunnel** with persistent URL.
|
|
2621
|
-
* Requires
|
|
3588
|
+
* Requires tunnel credentials + DNS hostname (explicit or deterministic per-user mapping).
|
|
2622
3589
|
*/
|
|
2623
|
-
|
|
2624
|
-
if (!existsSync(credentialsPath))
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
3590
|
+
function parseNamedTunnelCredentials(credentialsPath) {
|
|
3591
|
+
if (!existsSync(credentialsPath)) return null;
|
|
3592
|
+
try {
|
|
3593
|
+
const parsed = JSON.parse(readFileSync(credentialsPath, "utf8"));
|
|
3594
|
+
const tunnelId = String(parsed?.TunnelID || parsed?.tunnel_id || "").trim();
|
|
3595
|
+
if (!tunnelId) return null;
|
|
3596
|
+
return {
|
|
3597
|
+
tunnelId,
|
|
3598
|
+
credentialsPath,
|
|
3599
|
+
};
|
|
3600
|
+
} catch {
|
|
3601
|
+
return null;
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
async function startNamedTunnel(cfBin, { tunnelName, credentialsPath }, localPort) {
|
|
3606
|
+
const normalizedTunnelName = String(tunnelName || "").trim();
|
|
3607
|
+
const normalizedCredsPath = String(credentialsPath || "").trim();
|
|
3608
|
+
if (!normalizedTunnelName || !normalizedCredsPath) {
|
|
3609
|
+
setTunnelRuntimeState({
|
|
3610
|
+
lastError: "missing_named_tunnel_config",
|
|
3611
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3612
|
+
});
|
|
3613
|
+
console.warn(
|
|
3614
|
+
"[telegram-ui] named tunnel requires CLOUDFLARE_TUNNEL_NAME + CLOUDFLARE_TUNNEL_CREDENTIALS",
|
|
3615
|
+
);
|
|
3616
|
+
return null;
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
const parsedCreds = parseNamedTunnelCredentials(normalizedCredsPath);
|
|
3620
|
+
if (!parsedCreds) {
|
|
3621
|
+
setTunnelRuntimeState({
|
|
3622
|
+
lastError: "invalid_named_tunnel_credentials",
|
|
3623
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3624
|
+
tunnelName: normalizedTunnelName,
|
|
3625
|
+
});
|
|
3626
|
+
console.warn(`[telegram-ui] named tunnel credentials not found or invalid: ${normalizedCredsPath}`);
|
|
3627
|
+
return null;
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
let hostnameInfo;
|
|
3631
|
+
try {
|
|
3632
|
+
hostnameInfo = resolveDeterministicTunnelHostname();
|
|
3633
|
+
} catch (err) {
|
|
3634
|
+
setTunnelRuntimeState({
|
|
3635
|
+
lastError: "hostname_resolution_failed",
|
|
3636
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3637
|
+
tunnelName: normalizedTunnelName,
|
|
3638
|
+
tunnelId: parsedCreds.tunnelId,
|
|
3639
|
+
});
|
|
3640
|
+
console.warn(`[telegram-ui] named tunnel hostname resolution failed: ${err.message}`);
|
|
3641
|
+
return null;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
const dnsTarget = `${parsedCreds.tunnelId}.cfargotunnel.com`;
|
|
3645
|
+
const cfApi = getCloudflareApiConfig();
|
|
3646
|
+
let dnsSync = { ok: false, changed: false, action: "missing_credentials" };
|
|
3647
|
+
try {
|
|
3648
|
+
dnsSync = await ensureCloudflareDnsCname({
|
|
3649
|
+
hostname: hostnameInfo.hostname,
|
|
3650
|
+
target: dnsTarget,
|
|
3651
|
+
proxied: true,
|
|
3652
|
+
api: cfApi,
|
|
3653
|
+
});
|
|
3654
|
+
if (dnsSync.ok) {
|
|
3655
|
+
console.log(
|
|
3656
|
+
`[telegram-ui] cloudflare DNS ${dnsSync.action}: ${hostnameInfo.hostname} -> ${dnsTarget}`,
|
|
3657
|
+
);
|
|
3658
|
+
} else if (dnsSync.action === "missing_credentials") {
|
|
3659
|
+
console.warn(
|
|
3660
|
+
"[telegram-ui] Cloudflare DNS sync skipped (missing CLOUDFLARE_API_TOKEN/CLOUDFLARE_ZONE_ID)",
|
|
3661
|
+
);
|
|
3662
|
+
}
|
|
3663
|
+
} catch (err) {
|
|
3664
|
+
setTunnelRuntimeState({
|
|
3665
|
+
lastError: "dns_sync_failed",
|
|
3666
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3667
|
+
tunnelName: normalizedTunnelName,
|
|
3668
|
+
tunnelId: parsedCreds.tunnelId,
|
|
3669
|
+
hostname: hostnameInfo.hostname,
|
|
3670
|
+
dnsAction: "error",
|
|
3671
|
+
dnsStatus: "error",
|
|
3672
|
+
});
|
|
3673
|
+
console.warn(`[telegram-ui] Cloudflare DNS sync failed: ${err.message}`);
|
|
3674
|
+
return null;
|
|
2628
3675
|
}
|
|
2629
3676
|
|
|
2630
3677
|
// Named tunnels require config file with ingress rules.
|
|
2631
|
-
// We'll create a temporary config on the fly.
|
|
2632
3678
|
const configPath = resolveUiCachePath("cloudflared-config.yml");
|
|
2633
|
-
|
|
3679
|
+
const credsPathYaml = normalizedCredsPath.replace(/\\/g, "/");
|
|
2634
3680
|
const configYaml = `
|
|
2635
|
-
tunnel: ${
|
|
2636
|
-
credentials-file: ${
|
|
3681
|
+
tunnel: ${normalizedTunnelName}
|
|
3682
|
+
credentials-file: ${credsPathYaml}
|
|
2637
3683
|
|
|
2638
3684
|
ingress:
|
|
2639
|
-
- hostname: "
|
|
3685
|
+
- hostname: "${hostnameInfo.hostname}"
|
|
2640
3686
|
service: https://localhost:${localPort}
|
|
2641
3687
|
originRequest:
|
|
2642
3688
|
noTLSVerify: true
|
|
@@ -2645,41 +3691,51 @@ ingress:
|
|
|
2645
3691
|
|
|
2646
3692
|
writeFileSync(configPath, configYaml, "utf8");
|
|
2647
3693
|
|
|
2648
|
-
// Read the tunnel ID from credentials to construct the public URL
|
|
2649
|
-
let publicUrl = null;
|
|
2650
|
-
try {
|
|
2651
|
-
const creds = JSON.parse(readFileSync(credentialsPath, "utf8"));
|
|
2652
|
-
const tunnelId = creds.TunnelID || creds.tunnel_id;
|
|
2653
|
-
if (tunnelId) {
|
|
2654
|
-
publicUrl = `https://${tunnelId}.cfargotunnel.com`;
|
|
2655
|
-
}
|
|
2656
|
-
} catch (err) {
|
|
2657
|
-
console.warn(`[telegram-ui] failed to parse tunnel credentials: ${err.message}`);
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
3694
|
return new Promise((resolvePromise) => {
|
|
2661
3695
|
const args = ["tunnel", "--config", configPath, "run"];
|
|
2662
|
-
|
|
3696
|
+
const publicUrl = `https://${hostnameInfo.hostname}`;
|
|
3697
|
+
console.log(
|
|
3698
|
+
`[telegram-ui] starting named tunnel: ${normalizedTunnelName} -> ${publicUrl} -> https://localhost:${localPort}`,
|
|
3699
|
+
);
|
|
2663
3700
|
|
|
2664
3701
|
let child;
|
|
2665
3702
|
try {
|
|
2666
3703
|
child = spawnCloudflared(cfBin, args);
|
|
2667
3704
|
} catch (err) {
|
|
3705
|
+
setTunnelRuntimeState({
|
|
3706
|
+
lastError: "named_tunnel_spawn_failed",
|
|
3707
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3708
|
+
tunnelName: normalizedTunnelName,
|
|
3709
|
+
tunnelId: parsedCreds.tunnelId,
|
|
3710
|
+
hostname: hostnameInfo.hostname,
|
|
3711
|
+
});
|
|
2668
3712
|
console.warn(`[telegram-ui] named tunnel spawn failed: ${err.message}`);
|
|
2669
3713
|
return resolvePromise(null);
|
|
2670
3714
|
}
|
|
2671
3715
|
|
|
2672
3716
|
let resolved = false;
|
|
2673
3717
|
let output = "";
|
|
2674
|
-
// Named tunnels emit "Connection <UUID> registered"
|
|
2675
|
-
const readyPattern = /Connection [a-f0-9-]+ registered
|
|
3718
|
+
// Named tunnels emit "Connection <UUID> registered" (or "Registered tunnel connection")
|
|
3719
|
+
const readyPattern = /Connection [a-f0-9-]+ registered|Registered tunnel connection/i;
|
|
3720
|
+
const namedTunnelTimeoutMs = parseBoundedInt(
|
|
3721
|
+
process.env.TELEGRAM_UI_NAMED_TUNNEL_TIMEOUT_MS,
|
|
3722
|
+
60_000,
|
|
3723
|
+
{ min: 10_000, max: 300_000 },
|
|
3724
|
+
);
|
|
2676
3725
|
const timeout = setTimeout(() => {
|
|
2677
3726
|
if (!resolved) {
|
|
2678
3727
|
resolved = true;
|
|
2679
|
-
|
|
3728
|
+
setTunnelRuntimeState({
|
|
3729
|
+
lastError: "named_tunnel_timeout",
|
|
3730
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3731
|
+
tunnelName: normalizedTunnelName,
|
|
3732
|
+
tunnelId: parsedCreds.tunnelId,
|
|
3733
|
+
hostname: hostnameInfo.hostname,
|
|
3734
|
+
});
|
|
3735
|
+
console.warn(`[telegram-ui] named tunnel timed out after ${namedTunnelTimeoutMs}ms`);
|
|
2680
3736
|
resolvePromise(null);
|
|
2681
3737
|
}
|
|
2682
|
-
},
|
|
3738
|
+
}, namedTunnelTimeoutMs);
|
|
2683
3739
|
|
|
2684
3740
|
function parseOutput(chunk) {
|
|
2685
3741
|
output += chunk;
|
|
@@ -2687,9 +3743,21 @@ ingress:
|
|
|
2687
3743
|
resolved = true;
|
|
2688
3744
|
clearTimeout(timeout);
|
|
2689
3745
|
tunnelUrl = publicUrl;
|
|
3746
|
+
tunnelPublicHostname = hostnameInfo.hostname;
|
|
2690
3747
|
tunnelProcess = child;
|
|
3748
|
+
quickTunnelRestartAttempts = 0;
|
|
3749
|
+
setTunnelRuntimeState({
|
|
3750
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3751
|
+
tunnelName: normalizedTunnelName,
|
|
3752
|
+
tunnelId: parsedCreds.tunnelId,
|
|
3753
|
+
hostname: hostnameInfo.hostname,
|
|
3754
|
+
dnsAction: dnsSync.action || "none",
|
|
3755
|
+
dnsStatus: dnsSync.ok ? "ok" : "not_configured",
|
|
3756
|
+
fallbackToQuick: false,
|
|
3757
|
+
lastError: "",
|
|
3758
|
+
});
|
|
2691
3759
|
_notifyTunnelChange(publicUrl);
|
|
2692
|
-
console.log(`[telegram-ui] named tunnel active: ${publicUrl
|
|
3760
|
+
console.log(`[telegram-ui] named tunnel active: ${publicUrl}`);
|
|
2693
3761
|
resolvePromise(publicUrl);
|
|
2694
3762
|
}
|
|
2695
3763
|
}
|
|
@@ -2701,6 +3769,13 @@ ingress:
|
|
|
2701
3769
|
if (!resolved) {
|
|
2702
3770
|
resolved = true;
|
|
2703
3771
|
clearTimeout(timeout);
|
|
3772
|
+
setTunnelRuntimeState({
|
|
3773
|
+
lastError: "named_tunnel_runtime_error",
|
|
3774
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3775
|
+
tunnelName: normalizedTunnelName,
|
|
3776
|
+
tunnelId: parsedCreds.tunnelId,
|
|
3777
|
+
hostname: hostnameInfo.hostname,
|
|
3778
|
+
});
|
|
2704
3779
|
console.warn(`[telegram-ui] named tunnel failed: ${err.message}`);
|
|
2705
3780
|
resolvePromise(null);
|
|
2706
3781
|
}
|
|
@@ -2709,12 +3784,24 @@ ingress:
|
|
|
2709
3784
|
child.on("exit", (code) => {
|
|
2710
3785
|
tunnelProcess = null;
|
|
2711
3786
|
tunnelUrl = null;
|
|
3787
|
+
tunnelPublicHostname = "";
|
|
3788
|
+
_notifyTunnelChange(null);
|
|
2712
3789
|
if (!resolved) {
|
|
2713
3790
|
resolved = true;
|
|
2714
3791
|
clearTimeout(timeout);
|
|
3792
|
+
setTunnelRuntimeState({
|
|
3793
|
+
lastError: "named_tunnel_exited_early",
|
|
3794
|
+
mode: TUNNEL_MODE_NAMED,
|
|
3795
|
+
tunnelName: normalizedTunnelName,
|
|
3796
|
+
tunnelId: parsedCreds.tunnelId,
|
|
3797
|
+
hostname: hostnameInfo.hostname,
|
|
3798
|
+
});
|
|
2715
3799
|
console.warn(`[telegram-ui] named tunnel exited with code ${code}`);
|
|
2716
3800
|
resolvePromise(null);
|
|
2717
3801
|
} else if (code !== 0 && code !== null) {
|
|
3802
|
+
setTunnelRuntimeState({
|
|
3803
|
+
lastError: "named_tunnel_exited",
|
|
3804
|
+
});
|
|
2718
3805
|
console.warn(`[telegram-ui] named tunnel exited (code ${code})`);
|
|
2719
3806
|
}
|
|
2720
3807
|
});
|
|
@@ -2725,26 +3812,88 @@ ingress:
|
|
|
2725
3812
|
* Start a cloudflared **quick tunnel** (random *.trycloudflare.com URL).
|
|
2726
3813
|
* Quick tunnels are free, require no account, but the URL changes on each restart.
|
|
2727
3814
|
*/
|
|
3815
|
+
function clearQuickTunnelRestartTimer() {
|
|
3816
|
+
if (quickTunnelRestartTimer) {
|
|
3817
|
+
clearTimeout(quickTunnelRestartTimer);
|
|
3818
|
+
quickTunnelRestartTimer = null;
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
function getQuickTunnelRestartConfig() {
|
|
3823
|
+
const maxAttempts = parseBoundedInt(
|
|
3824
|
+
process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_MAX_ATTEMPTS,
|
|
3825
|
+
6,
|
|
3826
|
+
{ min: 1, max: 50 },
|
|
3827
|
+
);
|
|
3828
|
+
const baseDelayMs = parseBoundedInt(
|
|
3829
|
+
process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_BASE_DELAY_MS
|
|
3830
|
+
|| process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_BASE_MS,
|
|
3831
|
+
5000,
|
|
3832
|
+
{ min: 250, max: 60_000 },
|
|
3833
|
+
);
|
|
3834
|
+
const maxDelayMs = parseBoundedInt(
|
|
3835
|
+
process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_MAX_DELAY_MS,
|
|
3836
|
+
120_000,
|
|
3837
|
+
{ min: 1000, max: 900_000 },
|
|
3838
|
+
);
|
|
3839
|
+
return { maxAttempts, baseDelayMs, maxDelayMs };
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
function scheduleQuickTunnelRestart(cfBin, localPort) {
|
|
3843
|
+
if (quickTunnelRestartSuppressed) return;
|
|
3844
|
+
const cfg = getQuickTunnelRestartConfig();
|
|
3845
|
+
if (quickTunnelRestartAttempts >= cfg.maxAttempts) {
|
|
3846
|
+
console.warn(
|
|
3847
|
+
`[telegram-ui] quick tunnel restart exhausted after ${quickTunnelRestartAttempts} attempts (max ${cfg.maxAttempts})`,
|
|
3848
|
+
);
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3851
|
+
quickTunnelRestartAttempts += 1;
|
|
3852
|
+
const backoff = Math.min(cfg.maxDelayMs, cfg.baseDelayMs * 2 ** (quickTunnelRestartAttempts - 1));
|
|
3853
|
+
const jitter = Math.floor(Math.random() * Math.max(200, Math.floor(backoff * 0.2)));
|
|
3854
|
+
const restartDelayMs = Math.min(cfg.maxDelayMs, backoff + jitter);
|
|
3855
|
+
clearQuickTunnelRestartTimer();
|
|
3856
|
+
console.warn(
|
|
3857
|
+
`[telegram-ui] quick tunnel restart scheduled (${quickTunnelRestartAttempts}/${cfg.maxAttempts}) in ${restartDelayMs}ms`,
|
|
3858
|
+
);
|
|
3859
|
+
quickTunnelRestartTimer = setTimeout(() => {
|
|
3860
|
+
startQuickTunnel(cfBin, localPort).catch((err) => {
|
|
3861
|
+
console.warn(`[telegram-ui] quick tunnel restart failed: ${err.message}`);
|
|
3862
|
+
});
|
|
3863
|
+
}, restartDelayMs);
|
|
3864
|
+
quickTunnelRestartTimer.unref?.();
|
|
3865
|
+
}
|
|
3866
|
+
|
|
2728
3867
|
async function startQuickTunnel(cfBin, localPort) {
|
|
3868
|
+
quickTunnelRestartSuppressed = false;
|
|
3869
|
+
clearQuickTunnelRestartTimer();
|
|
2729
3870
|
return new Promise((resolvePromise) => {
|
|
2730
3871
|
const localUrl = `https://localhost:${localPort}`;
|
|
2731
3872
|
const args = ["tunnel", "--url", localUrl, "--no-autoupdate", "--no-tls-verify"];
|
|
2732
|
-
console.log(`[telegram-ui] starting quick tunnel
|
|
3873
|
+
console.log(`[telegram-ui] starting quick tunnel -> ${localUrl}`);
|
|
2733
3874
|
|
|
2734
3875
|
let child;
|
|
2735
3876
|
try {
|
|
2736
3877
|
child = spawnCloudflared(cfBin, args);
|
|
2737
3878
|
} catch (err) {
|
|
3879
|
+
setTunnelRuntimeState({
|
|
3880
|
+
mode: TUNNEL_MODE_QUICK,
|
|
3881
|
+
lastError: "quick_tunnel_spawn_failed",
|
|
3882
|
+
});
|
|
2738
3883
|
console.warn(`[telegram-ui] quick tunnel spawn failed: ${err.message}`);
|
|
2739
3884
|
return resolvePromise(null);
|
|
2740
3885
|
}
|
|
2741
3886
|
|
|
2742
3887
|
let resolved = false;
|
|
2743
3888
|
let output = "";
|
|
2744
|
-
const urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com
|
|
3889
|
+
const urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
2745
3890
|
const timeout = setTimeout(() => {
|
|
2746
3891
|
if (!resolved) {
|
|
2747
3892
|
resolved = true;
|
|
3893
|
+
setTunnelRuntimeState({
|
|
3894
|
+
mode: TUNNEL_MODE_QUICK,
|
|
3895
|
+
lastError: "quick_tunnel_timeout",
|
|
3896
|
+
});
|
|
2748
3897
|
console.warn("[telegram-ui] quick tunnel timed out after 30s");
|
|
2749
3898
|
resolvePromise(null);
|
|
2750
3899
|
}
|
|
@@ -2756,9 +3905,21 @@ async function startQuickTunnel(cfBin, localPort) {
|
|
|
2756
3905
|
if (match && !resolved) {
|
|
2757
3906
|
resolved = true;
|
|
2758
3907
|
clearTimeout(timeout);
|
|
2759
|
-
tunnelUrl = match[0];
|
|
3908
|
+
tunnelUrl = String(match[0]).toLowerCase();
|
|
3909
|
+
tunnelPublicHostname = "";
|
|
2760
3910
|
tunnelProcess = child;
|
|
2761
|
-
|
|
3911
|
+
quickTunnelRestartAttempts = 0;
|
|
3912
|
+
setTunnelRuntimeState({
|
|
3913
|
+
mode: TUNNEL_MODE_QUICK,
|
|
3914
|
+
tunnelName: "",
|
|
3915
|
+
tunnelId: "",
|
|
3916
|
+
hostname: "",
|
|
3917
|
+
dnsAction: "none",
|
|
3918
|
+
dnsStatus: "disabled",
|
|
3919
|
+
fallbackToQuick: true,
|
|
3920
|
+
lastError: "",
|
|
3921
|
+
});
|
|
3922
|
+
_notifyTunnelChange(tunnelUrl);
|
|
2762
3923
|
console.log(`[telegram-ui] quick tunnel active: ${tunnelUrl}`);
|
|
2763
3924
|
resolvePromise(tunnelUrl);
|
|
2764
3925
|
}
|
|
@@ -2771,6 +3932,10 @@ async function startQuickTunnel(cfBin, localPort) {
|
|
|
2771
3932
|
if (!resolved) {
|
|
2772
3933
|
resolved = true;
|
|
2773
3934
|
clearTimeout(timeout);
|
|
3935
|
+
setTunnelRuntimeState({
|
|
3936
|
+
mode: TUNNEL_MODE_QUICK,
|
|
3937
|
+
lastError: "quick_tunnel_runtime_error",
|
|
3938
|
+
});
|
|
2774
3939
|
console.warn(`[telegram-ui] quick tunnel failed: ${err.message}`);
|
|
2775
3940
|
resolvePromise(null);
|
|
2776
3941
|
}
|
|
@@ -2779,13 +3944,24 @@ async function startQuickTunnel(cfBin, localPort) {
|
|
|
2779
3944
|
child.on("exit", (code) => {
|
|
2780
3945
|
tunnelProcess = null;
|
|
2781
3946
|
tunnelUrl = null;
|
|
3947
|
+
tunnelPublicHostname = "";
|
|
3948
|
+
_notifyTunnelChange(null);
|
|
2782
3949
|
if (!resolved) {
|
|
2783
3950
|
resolved = true;
|
|
2784
3951
|
clearTimeout(timeout);
|
|
3952
|
+
setTunnelRuntimeState({
|
|
3953
|
+
mode: TUNNEL_MODE_QUICK,
|
|
3954
|
+
lastError: "quick_tunnel_exited_early",
|
|
3955
|
+
});
|
|
2785
3956
|
console.warn(`[telegram-ui] quick tunnel exited with code ${code}`);
|
|
2786
3957
|
resolvePromise(null);
|
|
2787
3958
|
} else if (code !== 0 && code !== null) {
|
|
3959
|
+
setTunnelRuntimeState({
|
|
3960
|
+
mode: TUNNEL_MODE_QUICK,
|
|
3961
|
+
lastError: "quick_tunnel_exited",
|
|
3962
|
+
});
|
|
2788
3963
|
console.warn(`[telegram-ui] quick tunnel exited (code ${code})`);
|
|
3964
|
+
scheduleQuickTunnelRestart(cfBin, localPort);
|
|
2789
3965
|
}
|
|
2790
3966
|
});
|
|
2791
3967
|
});
|
|
@@ -2793,13 +3969,29 @@ async function startQuickTunnel(cfBin, localPort) {
|
|
|
2793
3969
|
|
|
2794
3970
|
/** Stop the tunnel if running. */
|
|
2795
3971
|
export function stopTunnel() {
|
|
3972
|
+
quickTunnelRestartSuppressed = true;
|
|
3973
|
+
clearQuickTunnelRestartTimer();
|
|
2796
3974
|
if (tunnelProcess) {
|
|
2797
3975
|
try {
|
|
2798
3976
|
tunnelProcess.kill("SIGTERM");
|
|
2799
|
-
} catch {
|
|
3977
|
+
} catch {
|
|
3978
|
+
/* ignore */
|
|
3979
|
+
}
|
|
2800
3980
|
tunnelProcess = null;
|
|
2801
3981
|
tunnelUrl = null;
|
|
2802
|
-
|
|
3982
|
+
tunnelPublicHostname = "";
|
|
3983
|
+
_notifyTunnelChange(null);
|
|
3984
|
+
}
|
|
3985
|
+
setTunnelRuntimeState({
|
|
3986
|
+
mode: TUNNEL_MODE_DISABLED,
|
|
3987
|
+
tunnelName: "",
|
|
3988
|
+
tunnelId: "",
|
|
3989
|
+
hostname: "",
|
|
3990
|
+
dnsAction: "none",
|
|
3991
|
+
dnsStatus: "not_configured",
|
|
3992
|
+
fallbackToQuick: false,
|
|
3993
|
+
lastError: "",
|
|
3994
|
+
});
|
|
2803
3995
|
}
|
|
2804
3996
|
|
|
2805
3997
|
export function injectUiDependencies(deps = {}) {
|
|
@@ -3018,21 +4210,50 @@ function checkSessionToken(req) {
|
|
|
3018
4210
|
return false;
|
|
3019
4211
|
}
|
|
3020
4212
|
|
|
3021
|
-
function
|
|
3022
|
-
|
|
3023
|
-
// Session token (browser access)
|
|
3024
|
-
if (checkSessionToken(req)) return true;
|
|
3025
|
-
// Telegram initData HMAC
|
|
3026
|
-
const initData =
|
|
4213
|
+
function getTelegramInitData(req, url = null) {
|
|
4214
|
+
return String(
|
|
3027
4215
|
req.headers["x-telegram-initdata"] ||
|
|
3028
4216
|
req.headers["x-telegram-init-data"] ||
|
|
3029
4217
|
req.headers["x-telegram-init"] ||
|
|
3030
4218
|
req.headers["x-telegram-webapp"] ||
|
|
3031
4219
|
req.headers["x-telegram-webapp-data"] ||
|
|
3032
|
-
""
|
|
4220
|
+
url?.searchParams?.get("initData") ||
|
|
4221
|
+
"",
|
|
4222
|
+
);
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
function buildSessionCookieHeader() {
|
|
4226
|
+
const secure = uiServerTls ? "; Secure" : "";
|
|
4227
|
+
const token = ensureSessionToken();
|
|
4228
|
+
return `ve_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400${secure}`;
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
function getHeaderString(value) {
|
|
4232
|
+
if (Array.isArray(value)) return String(value[0] || "");
|
|
4233
|
+
return String(value || "");
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
async function requireAuth(req) {
|
|
4237
|
+
if (isAllowUnsafe()) return { ok: true, source: "unsafe", issueSessionCookie: false };
|
|
4238
|
+
// Session token (browser access)
|
|
4239
|
+
if (checkSessionToken(req)) return { ok: true, source: "session", issueSessionCookie: false };
|
|
4240
|
+
// Telegram initData HMAC
|
|
4241
|
+
const initData = getTelegramInitData(req);
|
|
3033
4242
|
const token = process.env.TELEGRAM_BOT_TOKEN || "";
|
|
3034
|
-
if (
|
|
3035
|
-
|
|
4243
|
+
if (initData && validateInitData(initData, token)) {
|
|
4244
|
+
return { ok: true, source: "telegram", issueSessionCookie: false };
|
|
4245
|
+
}
|
|
4246
|
+
// Fallback auth header is only evaluated after session + Telegram auth fails.
|
|
4247
|
+
const fallbackSecret = getHeaderString(
|
|
4248
|
+
req.headers["x-bosun-fallback-auth"] || req.headers["x-admin-fallback-auth"],
|
|
4249
|
+
).trim();
|
|
4250
|
+
if (fallbackSecret) {
|
|
4251
|
+
const result = await attemptFallbackAuth(req, fallbackSecret);
|
|
4252
|
+
if (result.ok) {
|
|
4253
|
+
return { ok: true, source: "fallback", issueSessionCookie: true };
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
return { ok: false, source: "unauthorized", issueSessionCookie: false };
|
|
3036
4257
|
}
|
|
3037
4258
|
|
|
3038
4259
|
function requireWsAuth(req, url) {
|
|
@@ -3048,12 +4269,7 @@ function requireWsAuth(req, url) {
|
|
|
3048
4269
|
}
|
|
3049
4270
|
}
|
|
3050
4271
|
// Telegram initData HMAC
|
|
3051
|
-
const initData =
|
|
3052
|
-
req.headers["x-telegram-initdata"] ||
|
|
3053
|
-
req.headers["x-telegram-init-data"] ||
|
|
3054
|
-
req.headers["x-telegram-init"] ||
|
|
3055
|
-
url.searchParams.get("initData") ||
|
|
3056
|
-
"";
|
|
4272
|
+
const initData = getTelegramInitData(req, url);
|
|
3057
4273
|
const token = process.env.TELEGRAM_BOT_TOKEN || "";
|
|
3058
4274
|
if (!initData) return false;
|
|
3059
4275
|
return validateInitData(String(initData), token);
|
|
@@ -4286,30 +5502,71 @@ async function ensurePresenceLoaded() {
|
|
|
4286
5502
|
}
|
|
4287
5503
|
|
|
4288
5504
|
async function handleApi(req, res, url) {
|
|
5505
|
+
const path = url.pathname;
|
|
4289
5506
|
if (req.method === "OPTIONS") {
|
|
4290
5507
|
res.writeHead(204, {
|
|
4291
5508
|
"Access-Control-Allow-Origin": "*",
|
|
4292
5509
|
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
4293
|
-
"Access-Control-Allow-Headers": "Content-Type,X-Telegram-InitData",
|
|
5510
|
+
"Access-Control-Allow-Headers": "Content-Type,X-Telegram-InitData,X-Bosun-Fallback-Auth",
|
|
4294
5511
|
});
|
|
4295
5512
|
res.end();
|
|
4296
5513
|
return;
|
|
4297
5514
|
}
|
|
4298
5515
|
|
|
4299
|
-
if (
|
|
5516
|
+
if (path === "/api/auth/fallback/status" && req.method === "GET") {
|
|
5517
|
+
jsonResponse(res, 200, {
|
|
5518
|
+
ok: true,
|
|
5519
|
+
data: {
|
|
5520
|
+
fallbackAuth: getFallbackAuthStatus(),
|
|
5521
|
+
tunnel: getTunnelStatus(),
|
|
5522
|
+
},
|
|
5523
|
+
});
|
|
5524
|
+
return;
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5527
|
+
if (path === "/api/auth/fallback/login" && req.method === "POST") {
|
|
5528
|
+
try {
|
|
5529
|
+
const body = await readJsonBody(req);
|
|
5530
|
+
const secret = String(body?.secret || body?.password || body?.pin || "").trim();
|
|
5531
|
+
const attempt = await attemptFallbackAuth(req, secret);
|
|
5532
|
+
if (!attempt.ok) {
|
|
5533
|
+
jsonResponse(res, 401, {
|
|
5534
|
+
ok: false,
|
|
5535
|
+
error: "Authentication failed.",
|
|
5536
|
+
});
|
|
5537
|
+
return;
|
|
5538
|
+
}
|
|
5539
|
+
res.setHeader("Set-Cookie", buildSessionCookieHeader());
|
|
5540
|
+
jsonResponse(res, 200, {
|
|
5541
|
+
ok: true,
|
|
5542
|
+
tokenIssued: true,
|
|
5543
|
+
auth: "fallback",
|
|
5544
|
+
});
|
|
5545
|
+
} catch {
|
|
5546
|
+
jsonResponse(res, 401, {
|
|
5547
|
+
ok: false,
|
|
5548
|
+
error: "Authentication failed.",
|
|
5549
|
+
});
|
|
5550
|
+
}
|
|
5551
|
+
return;
|
|
5552
|
+
}
|
|
5553
|
+
|
|
5554
|
+
const authResult = await requireAuth(req);
|
|
5555
|
+
if (!authResult?.ok) {
|
|
4300
5556
|
jsonResponse(res, 401, {
|
|
4301
5557
|
ok: false,
|
|
4302
|
-
error: "Unauthorized.
|
|
5558
|
+
error: "Unauthorized.",
|
|
4303
5559
|
});
|
|
4304
5560
|
return;
|
|
4305
5561
|
}
|
|
5562
|
+
if (authResult.issueSessionCookie) {
|
|
5563
|
+
res.setHeader("Set-Cookie", buildSessionCookieHeader());
|
|
5564
|
+
}
|
|
4306
5565
|
|
|
4307
5566
|
if (req.method === "POST" && !checkRateLimit(req, 30)) {
|
|
4308
5567
|
jsonResponse(res, 429, { ok: false, error: "Rate limit exceeded. Try again later." });
|
|
4309
5568
|
return;
|
|
4310
5569
|
}
|
|
4311
|
-
|
|
4312
|
-
const path = url.pathname;
|
|
4313
5570
|
if (path.startsWith("/api/attachments/") && req.method === "GET") {
|
|
4314
5571
|
const rel = decodeURIComponent(path.slice("/api/attachments/".length));
|
|
4315
5572
|
const root = ATTACHMENTS_ROOT;
|
|
@@ -4342,6 +5599,44 @@ async function handleApi(req, res, url) {
|
|
|
4342
5599
|
return;
|
|
4343
5600
|
}
|
|
4344
5601
|
|
|
5602
|
+
if (
|
|
5603
|
+
(path === "/api/auth/fallback/set" || path === "/api/auth/fallback/rotate")
|
|
5604
|
+
&& req.method === "POST"
|
|
5605
|
+
) {
|
|
5606
|
+
try {
|
|
5607
|
+
const body = await readJsonBody(req);
|
|
5608
|
+
const secret = String(body?.secret || body?.password || body?.pin || "").trim();
|
|
5609
|
+
const confirm = String(body?.confirm || "").trim();
|
|
5610
|
+
if (!secret || (confirm && confirm !== secret)) {
|
|
5611
|
+
jsonResponse(res, 400, {
|
|
5612
|
+
ok: false,
|
|
5613
|
+
error: "Invalid fallback credential payload.",
|
|
5614
|
+
});
|
|
5615
|
+
return;
|
|
5616
|
+
}
|
|
5617
|
+
await setFallbackAuthSecret(secret, { actor: "api" });
|
|
5618
|
+
jsonResponse(res, 200, {
|
|
5619
|
+
ok: true,
|
|
5620
|
+
data: getFallbackAuthStatus(),
|
|
5621
|
+
});
|
|
5622
|
+
} catch (err) {
|
|
5623
|
+
jsonResponse(res, 400, {
|
|
5624
|
+
ok: false,
|
|
5625
|
+
error: err?.message || "Failed to set fallback credential.",
|
|
5626
|
+
});
|
|
5627
|
+
}
|
|
5628
|
+
return;
|
|
5629
|
+
}
|
|
5630
|
+
|
|
5631
|
+
if (path === "/api/auth/fallback/reset" && req.method === "POST") {
|
|
5632
|
+
resetFallbackAuthSecret();
|
|
5633
|
+
jsonResponse(res, 200, {
|
|
5634
|
+
ok: true,
|
|
5635
|
+
data: getFallbackAuthStatus(),
|
|
5636
|
+
});
|
|
5637
|
+
return;
|
|
5638
|
+
}
|
|
5639
|
+
|
|
4345
5640
|
if (path === "/api/executor") {
|
|
4346
5641
|
const executor = uiDeps.getInternalExecutor?.();
|
|
4347
5642
|
const mode = uiDeps.getExecutorMode?.() || "internal";
|
|
@@ -6623,6 +7918,8 @@ async function handleApi(req, res, url) {
|
|
|
6623
7918
|
sdk: process.env.EXECUTOR_SDK || "auto",
|
|
6624
7919
|
kanbanBackend: runtimeKanbanBackend,
|
|
6625
7920
|
regions,
|
|
7921
|
+
tunnel: getTunnelStatus(),
|
|
7922
|
+
fallbackAuth: getFallbackAuthStatus(),
|
|
6626
7923
|
});
|
|
6627
7924
|
return;
|
|
6628
7925
|
}
|
|
@@ -6681,6 +7978,8 @@ async function handleApi(req, res, url) {
|
|
|
6681
7978
|
configExists,
|
|
6682
7979
|
configSchemaPath: CONFIG_SCHEMA_PATH,
|
|
6683
7980
|
configSchemaLoaded: Boolean(configSchema),
|
|
7981
|
+
tunnel: getTunnelStatus(),
|
|
7982
|
+
fallbackAuth: getFallbackAuthStatus(),
|
|
6684
7983
|
},
|
|
6685
7984
|
});
|
|
6686
7985
|
} catch (err) {
|
|
@@ -6768,6 +8067,8 @@ async function handleApi(req, res, url) {
|
|
|
6768
8067
|
updatedConfig: configUpdate.updated || [],
|
|
6769
8068
|
configPath: configUpdate.path || null,
|
|
6770
8069
|
configDir,
|
|
8070
|
+
tunnel: getTunnelStatus(),
|
|
8071
|
+
fallbackAuth: getFallbackAuthStatus(),
|
|
6771
8072
|
});
|
|
6772
8073
|
} catch (err) {
|
|
6773
8074
|
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
@@ -6827,10 +8128,13 @@ async function handleApi(req, res, url) {
|
|
|
6827
8128
|
const webhookSecretSet = Boolean(process.env.BOSUN_GITHUB_WEBHOOK_SECRET);
|
|
6828
8129
|
const appWebhookPath = getAppWebhookPath();
|
|
6829
8130
|
|
|
6830
|
-
// Build public URLs from tunnel URL if available, else from request host
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
8131
|
+
// Build public URLs from tunnel URL if available, else from request host.
|
|
8132
|
+
let baseUrl = String(getTunnelUrl() || "").replace(/\/+$/, "");
|
|
8133
|
+
if (!baseUrl) {
|
|
8134
|
+
const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost";
|
|
8135
|
+
const proto = uiServerTls || req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
|
|
8136
|
+
baseUrl = `${proto}://${host}`;
|
|
8137
|
+
}
|
|
6834
8138
|
|
|
6835
8139
|
jsonResponse(res, 200, {
|
|
6836
8140
|
ok: true,
|
|
@@ -7577,7 +8881,7 @@ async function handleApi(req, res, url) {
|
|
|
7577
8881
|
tracker.recordEvent(sessionId, {
|
|
7578
8882
|
role: "system",
|
|
7579
8883
|
type: "error",
|
|
7580
|
-
content: "
|
|
8884
|
+
content: ":alert: No agent is available to process this message. The primary agent may not be initialized — try restarting bosun or check the Logs tab for details.",
|
|
7581
8885
|
timestamp: new Date().toISOString(),
|
|
7582
8886
|
});
|
|
7583
8887
|
jsonResponse(res, 200, { ok: true, messageId, warning: "no_agent_available" });
|
|
@@ -7718,9 +9022,16 @@ async function handleApi(req, res, url) {
|
|
|
7718
9022
|
available: availability.available,
|
|
7719
9023
|
tier: availability.tier,
|
|
7720
9024
|
provider: availability.provider,
|
|
9025
|
+
reason: availability.reason || "",
|
|
7721
9026
|
voiceId: config.voiceId,
|
|
7722
9027
|
turnDetection: config.turnDetection,
|
|
7723
9028
|
model: config.model,
|
|
9029
|
+
visionModel: config.visionModel,
|
|
9030
|
+
vision: {
|
|
9031
|
+
enabled: true,
|
|
9032
|
+
frameMaxBytes: MAX_VISION_FRAME_BYTES,
|
|
9033
|
+
defaultIntervalMs: DEFAULT_VISION_ANALYSIS_INTERVAL_MS,
|
|
9034
|
+
},
|
|
7724
9035
|
fallbackMode: config.fallbackMode,
|
|
7725
9036
|
connectionInfo,
|
|
7726
9037
|
});
|
|
@@ -7733,9 +9044,19 @@ async function handleApi(req, res, url) {
|
|
|
7733
9044
|
// POST /api/voice/token
|
|
7734
9045
|
if (path === "/api/voice/token" && req.method === "POST") {
|
|
7735
9046
|
try {
|
|
9047
|
+
const body = await readJsonBody(req).catch(() => ({}));
|
|
9048
|
+
const callContext = {
|
|
9049
|
+
sessionId: String(body?.sessionId || "").trim() || undefined,
|
|
9050
|
+
executor: String(body?.executor || "").trim() || undefined,
|
|
9051
|
+
mode: String(body?.mode || "").trim() || undefined,
|
|
9052
|
+
model: String(body?.model || "").trim() || undefined,
|
|
9053
|
+
};
|
|
7736
9054
|
const { createEphemeralToken, getVoiceToolDefinitions } = await import("./voice-relay.mjs");
|
|
7737
|
-
const
|
|
7738
|
-
|
|
9055
|
+
const delegateOnly =
|
|
9056
|
+
body?.delegateOnly === true ||
|
|
9057
|
+
(body?.delegateOnly !== false && Boolean(callContext.sessionId));
|
|
9058
|
+
const tools = await getVoiceToolDefinitions({ delegateOnly });
|
|
9059
|
+
const tokenData = await createEphemeralToken(tools, callContext);
|
|
7739
9060
|
|
|
7740
9061
|
jsonResponse(res, 200, tokenData);
|
|
7741
9062
|
} catch (err) {
|
|
@@ -7748,13 +9069,33 @@ async function handleApi(req, res, url) {
|
|
|
7748
9069
|
if (path === "/api/voice/tool" && req.method === "POST") {
|
|
7749
9070
|
try {
|
|
7750
9071
|
const body = await readJsonBody(req);
|
|
7751
|
-
const {
|
|
7752
|
-
|
|
9072
|
+
const {
|
|
9073
|
+
toolName,
|
|
9074
|
+
args,
|
|
9075
|
+
sessionId: voiceSessionId,
|
|
9076
|
+
executor,
|
|
9077
|
+
mode,
|
|
9078
|
+
model,
|
|
9079
|
+
} = body || {};
|
|
9080
|
+
const normalizedToolName = String(toolName || "").trim();
|
|
9081
|
+
if (!normalizedToolName) {
|
|
7753
9082
|
jsonResponse(res, 400, { error: "toolName required" });
|
|
7754
9083
|
return;
|
|
7755
9084
|
}
|
|
7756
9085
|
const { executeVoiceTool } = await import("./voice-relay.mjs");
|
|
7757
|
-
const
|
|
9086
|
+
const context = {
|
|
9087
|
+
sessionId: String(voiceSessionId || "").trim() || undefined,
|
|
9088
|
+
executor: String(executor || "").trim() || undefined,
|
|
9089
|
+
mode: String(mode || "").trim() || undefined,
|
|
9090
|
+
model: String(model || "").trim() || undefined,
|
|
9091
|
+
};
|
|
9092
|
+
if (context.sessionId && normalizedToolName !== "delegate_to_agent") {
|
|
9093
|
+
jsonResponse(res, 400, {
|
|
9094
|
+
error: "Session-bound calls only allow delegate_to_agent",
|
|
9095
|
+
});
|
|
9096
|
+
return;
|
|
9097
|
+
}
|
|
9098
|
+
const result = await executeVoiceTool(normalizedToolName, args || {}, context);
|
|
7758
9099
|
|
|
7759
9100
|
jsonResponse(res, 200, result);
|
|
7760
9101
|
} catch (err) {
|
|
@@ -7763,14 +9104,242 @@ async function handleApi(req, res, url) {
|
|
|
7763
9104
|
return;
|
|
7764
9105
|
}
|
|
7765
9106
|
|
|
9107
|
+
// POST /api/voice/transcript
|
|
9108
|
+
if (path === "/api/voice/transcript" && req.method === "POST") {
|
|
9109
|
+
try {
|
|
9110
|
+
const body = await readJsonBody(req);
|
|
9111
|
+
const sessionId = String(body?.sessionId || "").trim();
|
|
9112
|
+
const role = String(body?.role || "").trim().toLowerCase();
|
|
9113
|
+
const content = String(body?.content || "").trim();
|
|
9114
|
+
if (!sessionId) {
|
|
9115
|
+
jsonResponse(res, 400, { ok: false, error: "sessionId required" });
|
|
9116
|
+
return;
|
|
9117
|
+
}
|
|
9118
|
+
if (!["user", "assistant", "system"].includes(role)) {
|
|
9119
|
+
jsonResponse(res, 400, { ok: false, error: "role must be user|assistant|system" });
|
|
9120
|
+
return;
|
|
9121
|
+
}
|
|
9122
|
+
if (!content) {
|
|
9123
|
+
jsonResponse(res, 400, { ok: false, error: "content required" });
|
|
9124
|
+
return;
|
|
9125
|
+
}
|
|
9126
|
+
|
|
9127
|
+
const tracker = getSessionTracker();
|
|
9128
|
+
let session = tracker.getSessionById(sessionId);
|
|
9129
|
+
if (!session) {
|
|
9130
|
+
session = tracker.createSession({
|
|
9131
|
+
id: sessionId,
|
|
9132
|
+
type: "primary",
|
|
9133
|
+
metadata: {
|
|
9134
|
+
agent: String(body?.executor || getPrimaryAgentName() || ""),
|
|
9135
|
+
mode: String(body?.mode || getAgentMode() || ""),
|
|
9136
|
+
model: String(body?.model || "").trim() || undefined,
|
|
9137
|
+
},
|
|
9138
|
+
});
|
|
9139
|
+
}
|
|
9140
|
+
|
|
9141
|
+
tracker.recordEvent(session.id || sessionId, {
|
|
9142
|
+
role,
|
|
9143
|
+
type: "voice_transcript",
|
|
9144
|
+
content,
|
|
9145
|
+
timestamp: new Date().toISOString(),
|
|
9146
|
+
meta: {
|
|
9147
|
+
source: "voice",
|
|
9148
|
+
provider: String(body?.provider || "").trim() || undefined,
|
|
9149
|
+
eventType: String(body?.eventType || "").trim() || undefined,
|
|
9150
|
+
},
|
|
9151
|
+
});
|
|
9152
|
+
|
|
9153
|
+
const provider = String(body?.provider || "").trim() || null;
|
|
9154
|
+
const transcriptEventType = String(body?.eventType || "").trim().toLowerCase() || null;
|
|
9155
|
+
const executor = String(body?.executor || "").trim() || null;
|
|
9156
|
+
const mode = String(body?.mode || "").trim() || null;
|
|
9157
|
+
const model = String(body?.model || "").trim() || null;
|
|
9158
|
+
const normalizedSessionId = String(session.id || sessionId).trim();
|
|
9159
|
+
const contentHash = createHash("sha1")
|
|
9160
|
+
.update(`${role}:${content}`)
|
|
9161
|
+
.digest("hex")
|
|
9162
|
+
.slice(0, 16);
|
|
9163
|
+
const workflowPayload = {
|
|
9164
|
+
sessionId: normalizedSessionId,
|
|
9165
|
+
meetingSessionId: normalizedSessionId,
|
|
9166
|
+
role,
|
|
9167
|
+
content,
|
|
9168
|
+
source: "voice",
|
|
9169
|
+
provider,
|
|
9170
|
+
transcriptEventType,
|
|
9171
|
+
executor,
|
|
9172
|
+
mode,
|
|
9173
|
+
model,
|
|
9174
|
+
};
|
|
9175
|
+
|
|
9176
|
+
queueWorkflowEvent(
|
|
9177
|
+
"meeting.transcript",
|
|
9178
|
+
workflowPayload,
|
|
9179
|
+
{
|
|
9180
|
+
dedupKey: `workflow-event:meeting.transcript:${normalizedSessionId}:${role}:${contentHash}`,
|
|
9181
|
+
},
|
|
9182
|
+
);
|
|
9183
|
+
if (transcriptEventType === "wake_phrase" || transcriptEventType === "wake-phrase") {
|
|
9184
|
+
queueWorkflowEvent(
|
|
9185
|
+
"meeting.wake_phrase",
|
|
9186
|
+
workflowPayload,
|
|
9187
|
+
{
|
|
9188
|
+
dedupKey: `workflow-event:meeting.wake_phrase:${normalizedSessionId}:${role}:${contentHash}`,
|
|
9189
|
+
},
|
|
9190
|
+
);
|
|
9191
|
+
}
|
|
9192
|
+
|
|
9193
|
+
jsonResponse(res, 200, { ok: true });
|
|
9194
|
+
} catch (err) {
|
|
9195
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9196
|
+
}
|
|
9197
|
+
return;
|
|
9198
|
+
}
|
|
9199
|
+
|
|
9200
|
+
// POST /api/vision/frame
|
|
9201
|
+
if (path === "/api/vision/frame" && req.method === "POST") {
|
|
9202
|
+
try {
|
|
9203
|
+
const body = await readJsonBody(req);
|
|
9204
|
+
const sessionId = String(body?.sessionId || "").trim();
|
|
9205
|
+
const source = sanitizeVisionSource(body?.source);
|
|
9206
|
+
const frame = parseVisionFrameDataUrl(body?.frameDataUrl);
|
|
9207
|
+
const forceAnalyze = body?.forceAnalyze === true;
|
|
9208
|
+
const minIntervalMs = normalizeVisionInterval(body?.minIntervalMs);
|
|
9209
|
+
const width = Number.isFinite(Number(body?.width)) ? Number(body.width) : null;
|
|
9210
|
+
const height = Number.isFinite(Number(body?.height)) ? Number(body.height) : null;
|
|
9211
|
+
|
|
9212
|
+
if (!sessionId) {
|
|
9213
|
+
jsonResponse(res, 400, { ok: false, error: "sessionId required" });
|
|
9214
|
+
return;
|
|
9215
|
+
}
|
|
9216
|
+
if (!frame.ok) {
|
|
9217
|
+
jsonResponse(res, frame.statusCode || 400, { ok: false, error: frame.error });
|
|
9218
|
+
return;
|
|
9219
|
+
}
|
|
9220
|
+
|
|
9221
|
+
const state = getVisionSessionState(sessionId);
|
|
9222
|
+
if (!state) {
|
|
9223
|
+
jsonResponse(res, 400, { ok: false, error: "Invalid sessionId" });
|
|
9224
|
+
return;
|
|
9225
|
+
}
|
|
9226
|
+
|
|
9227
|
+
const frameHash = createHash("sha1").update(frame.base64Data).digest("hex");
|
|
9228
|
+
const now = Date.now();
|
|
9229
|
+
|
|
9230
|
+
state.lastFrameHash = frameHash;
|
|
9231
|
+
state.lastReceiptAt = now;
|
|
9232
|
+
|
|
9233
|
+
if (!forceAnalyze && state.inFlight) {
|
|
9234
|
+
jsonResponse(res, 202, {
|
|
9235
|
+
ok: true,
|
|
9236
|
+
analyzed: false,
|
|
9237
|
+
skipped: true,
|
|
9238
|
+
reason: "analysis_in_progress",
|
|
9239
|
+
summary: state.lastSummary || undefined,
|
|
9240
|
+
});
|
|
9241
|
+
return;
|
|
9242
|
+
}
|
|
9243
|
+
|
|
9244
|
+
if (!forceAnalyze && frameHash === state.lastAnalyzedHash) {
|
|
9245
|
+
jsonResponse(res, 200, {
|
|
9246
|
+
ok: true,
|
|
9247
|
+
analyzed: false,
|
|
9248
|
+
skipped: true,
|
|
9249
|
+
reason: "duplicate_frame",
|
|
9250
|
+
summary: state.lastSummary || undefined,
|
|
9251
|
+
});
|
|
9252
|
+
return;
|
|
9253
|
+
}
|
|
9254
|
+
|
|
9255
|
+
if (!forceAnalyze && now - state.lastAnalyzedAt < minIntervalMs) {
|
|
9256
|
+
jsonResponse(res, 202, {
|
|
9257
|
+
ok: true,
|
|
9258
|
+
analyzed: false,
|
|
9259
|
+
skipped: true,
|
|
9260
|
+
reason: "throttled",
|
|
9261
|
+
summary: state.lastSummary || undefined,
|
|
9262
|
+
});
|
|
9263
|
+
return;
|
|
9264
|
+
}
|
|
9265
|
+
|
|
9266
|
+
const callContext = {
|
|
9267
|
+
sessionId,
|
|
9268
|
+
executor: String(body?.executor || "").trim() || undefined,
|
|
9269
|
+
mode: String(body?.mode || "").trim() || undefined,
|
|
9270
|
+
model: String(body?.model || "").trim() || undefined,
|
|
9271
|
+
};
|
|
9272
|
+
const prompt = String(body?.prompt || "").trim() || undefined;
|
|
9273
|
+
const model = String(body?.visionModel || "").trim() || undefined;
|
|
9274
|
+
|
|
9275
|
+
const { analyzeVisionFrame } = await import("./voice-relay.mjs");
|
|
9276
|
+
const pending = analyzeVisionFrame(frame.raw, {
|
|
9277
|
+
source,
|
|
9278
|
+
context: callContext,
|
|
9279
|
+
prompt,
|
|
9280
|
+
model,
|
|
9281
|
+
});
|
|
9282
|
+
state.inFlight = pending;
|
|
9283
|
+
let analysis;
|
|
9284
|
+
try {
|
|
9285
|
+
analysis = await pending;
|
|
9286
|
+
} finally {
|
|
9287
|
+
if (state.inFlight === pending) {
|
|
9288
|
+
state.inFlight = null;
|
|
9289
|
+
}
|
|
9290
|
+
}
|
|
9291
|
+
|
|
9292
|
+
const tracker = getSessionTracker();
|
|
9293
|
+
let session = tracker.getSessionById(sessionId);
|
|
9294
|
+
if (!session) {
|
|
9295
|
+
session = tracker.createSession({
|
|
9296
|
+
id: sessionId,
|
|
9297
|
+
type: "primary",
|
|
9298
|
+
metadata: {
|
|
9299
|
+
agent: String(body?.executor || getPrimaryAgentName() || ""),
|
|
9300
|
+
mode: String(body?.mode || getAgentMode() || ""),
|
|
9301
|
+
model: String(body?.model || "").trim() || undefined,
|
|
9302
|
+
},
|
|
9303
|
+
});
|
|
9304
|
+
}
|
|
9305
|
+
|
|
9306
|
+
const dimension = width && height ? ` (${width}x${height})` : "";
|
|
9307
|
+
tracker.recordEvent(session.id || sessionId, {
|
|
9308
|
+
role: "system",
|
|
9309
|
+
content: `[Vision ${source}${dimension}] ${analysis.summary}`,
|
|
9310
|
+
timestamp: new Date().toISOString(),
|
|
9311
|
+
});
|
|
9312
|
+
|
|
9313
|
+
state.lastAnalyzedHash = frameHash;
|
|
9314
|
+
state.lastAnalyzedAt = Date.now();
|
|
9315
|
+
state.lastSummary = String(analysis.summary || "").trim();
|
|
9316
|
+
|
|
9317
|
+
jsonResponse(res, 200, {
|
|
9318
|
+
ok: true,
|
|
9319
|
+
analyzed: true,
|
|
9320
|
+
summary: state.lastSummary,
|
|
9321
|
+
provider: analysis.provider || null,
|
|
9322
|
+
model: analysis.model || null,
|
|
9323
|
+
frameHash,
|
|
9324
|
+
});
|
|
9325
|
+
} catch (err) {
|
|
9326
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9327
|
+
}
|
|
9328
|
+
return;
|
|
9329
|
+
}
|
|
9330
|
+
|
|
7766
9331
|
jsonResponse(res, 404, { ok: false, error: "Unknown API endpoint" });
|
|
7767
9332
|
}
|
|
7768
9333
|
|
|
7769
9334
|
async function handleStatic(req, res, url) {
|
|
7770
|
-
|
|
9335
|
+
const authResult = await requireAuth(req);
|
|
9336
|
+
if (!authResult?.ok) {
|
|
7771
9337
|
textResponse(res, 401, "Unauthorized");
|
|
7772
9338
|
return;
|
|
7773
9339
|
}
|
|
9340
|
+
if (authResult.issueSessionCookie) {
|
|
9341
|
+
res.setHeader("Set-Cookie", buildSessionCookieHeader());
|
|
9342
|
+
}
|
|
7774
9343
|
|
|
7775
9344
|
const rawPathname = String(url.pathname || "/").trim() || "/";
|
|
7776
9345
|
const pathname = rawPathname === "/" ? "/index.html" : rawPathname;
|
|
@@ -7915,10 +9484,16 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
7915
9484
|
const provided = Buffer.from(qToken);
|
|
7916
9485
|
const expected = Buffer.from(sessionToken);
|
|
7917
9486
|
if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
|
|
7918
|
-
const
|
|
9487
|
+
const cleanUrl = new URL(url.toString());
|
|
9488
|
+
cleanUrl.searchParams.delete("token");
|
|
9489
|
+
const redirectPath =
|
|
9490
|
+
cleanUrl.pathname +
|
|
9491
|
+
(cleanUrl.searchParams.toString()
|
|
9492
|
+
? `?${cleanUrl.searchParams.toString()}`
|
|
9493
|
+
: "");
|
|
7919
9494
|
res.writeHead(302, {
|
|
7920
|
-
"Set-Cookie":
|
|
7921
|
-
Location:
|
|
9495
|
+
"Set-Cookie": buildSessionCookieHeader(),
|
|
9496
|
+
Location: redirectPath || "/",
|
|
7922
9497
|
});
|
|
7923
9498
|
res.end();
|
|
7924
9499
|
return;
|
|
@@ -7972,6 +9547,14 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
7972
9547
|
return;
|
|
7973
9548
|
}
|
|
7974
9549
|
|
|
9550
|
+
// /demo and /ui/demo are convenience aliases for /demo.html (the self-contained mock UI demo)
|
|
9551
|
+
if (url.pathname === "/demo" || url.pathname === "/ui/demo") {
|
|
9552
|
+
const qs = url.search || "";
|
|
9553
|
+
res.writeHead(302, { Location: `/demo.html${qs}` });
|
|
9554
|
+
res.end();
|
|
9555
|
+
return;
|
|
9556
|
+
}
|
|
9557
|
+
|
|
7975
9558
|
// Telegram initData exchange: ?tgWebAppData=... or ?initData=... → set session cookie and redirect
|
|
7976
9559
|
const initDataQuery =
|
|
7977
9560
|
url.searchParams.get("tgWebAppData") ||
|
|
@@ -7984,14 +9567,13 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
7984
9567
|
) {
|
|
7985
9568
|
const token = process.env.TELEGRAM_BOT_TOKEN || "";
|
|
7986
9569
|
if (validateInitData(String(initDataQuery), token)) {
|
|
7987
|
-
const secure = uiServerTls ? "; Secure" : "";
|
|
7988
9570
|
const cleanUrl = new URL(url.toString());
|
|
7989
9571
|
cleanUrl.searchParams.delete("tgWebAppData");
|
|
7990
9572
|
cleanUrl.searchParams.delete("initData");
|
|
7991
9573
|
const redirectPath =
|
|
7992
9574
|
cleanUrl.pathname + (cleanUrl.searchParams.toString() ? `?${cleanUrl.searchParams.toString()}` : "");
|
|
7993
9575
|
res.writeHead(302, {
|
|
7994
|
-
"Set-Cookie":
|
|
9576
|
+
"Set-Cookie": buildSessionCookieHeader(),
|
|
7995
9577
|
Location: redirectPath || "/",
|
|
7996
9578
|
});
|
|
7997
9579
|
res.end();
|
|
@@ -8059,10 +9641,43 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
8059
9641
|
stopLogStream(socket);
|
|
8060
9642
|
} else if (message?.type === "voice-tool-call") {
|
|
8061
9643
|
// Voice tool call via WebSocket
|
|
8062
|
-
const {
|
|
9644
|
+
const {
|
|
9645
|
+
toolName,
|
|
9646
|
+
args,
|
|
9647
|
+
callId,
|
|
9648
|
+
sessionId: voiceSessionId,
|
|
9649
|
+
executor,
|
|
9650
|
+
mode,
|
|
9651
|
+
model,
|
|
9652
|
+
} = message;
|
|
9653
|
+
const normalizedToolName = String(toolName || "").trim();
|
|
9654
|
+
if (!normalizedToolName) {
|
|
9655
|
+
sendWsMessage(socket, {
|
|
9656
|
+
type: "voice-tool-result",
|
|
9657
|
+
callId,
|
|
9658
|
+
error: "toolName required",
|
|
9659
|
+
ts: Date.now(),
|
|
9660
|
+
});
|
|
9661
|
+
return;
|
|
9662
|
+
}
|
|
9663
|
+
const normalizedSessionId = String(voiceSessionId || "").trim() || undefined;
|
|
9664
|
+
if (normalizedSessionId && normalizedToolName !== "delegate_to_agent") {
|
|
9665
|
+
sendWsMessage(socket, {
|
|
9666
|
+
type: "voice-tool-result",
|
|
9667
|
+
callId,
|
|
9668
|
+
error: "Session-bound calls only allow delegate_to_agent",
|
|
9669
|
+
ts: Date.now(),
|
|
9670
|
+
});
|
|
9671
|
+
return;
|
|
9672
|
+
}
|
|
8063
9673
|
import("./voice-relay.mjs").then(async (relay) => {
|
|
8064
9674
|
try {
|
|
8065
|
-
const result = await relay.executeVoiceTool(
|
|
9675
|
+
const result = await relay.executeVoiceTool(normalizedToolName, args || {}, {
|
|
9676
|
+
sessionId: normalizedSessionId,
|
|
9677
|
+
executor: String(executor || "").trim() || undefined,
|
|
9678
|
+
mode: String(mode || "").trim() || undefined,
|
|
9679
|
+
model: String(model || "").trim() || undefined,
|
|
9680
|
+
});
|
|
8066
9681
|
sendWsMessage(socket, {
|
|
8067
9682
|
type: "voice-tool-result",
|
|
8068
9683
|
callId,
|
|
@@ -8130,12 +9745,34 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
8130
9745
|
// Reuse a recent session token when possible so browser sessions survive restarts.
|
|
8131
9746
|
ensureSessionToken();
|
|
8132
9747
|
|
|
8133
|
-
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
9748
|
+
const listenHost = options.host || DEFAULT_HOST;
|
|
9749
|
+
const listenOnce = (targetPort) =>
|
|
9750
|
+
new Promise((resolveReady, rejectReady) => {
|
|
9751
|
+
const onError = (err) => {
|
|
9752
|
+
uiServer.off("listening", onListening);
|
|
9753
|
+
rejectReady(err);
|
|
9754
|
+
};
|
|
9755
|
+
const onListening = () => {
|
|
9756
|
+
uiServer.off("error", onError);
|
|
9757
|
+
resolveReady();
|
|
9758
|
+
};
|
|
9759
|
+
uiServer.once("error", onError);
|
|
9760
|
+
uiServer.once("listening", onListening);
|
|
9761
|
+
uiServer.listen(targetPort, listenHost);
|
|
8137
9762
|
});
|
|
8138
|
-
|
|
9763
|
+
|
|
9764
|
+
try {
|
|
9765
|
+
await listenOnce(port);
|
|
9766
|
+
} catch (err) {
|
|
9767
|
+
const code = String(err?.code || "").toUpperCase();
|
|
9768
|
+
const canRetryWithEphemeral =
|
|
9769
|
+
allowEphemeralPort && port > 0 && (code === "EADDRINUSE" || code === "EACCES");
|
|
9770
|
+
if (!canRetryWithEphemeral) throw err;
|
|
9771
|
+
console.warn(
|
|
9772
|
+
`[telegram-ui] failed to bind ${listenHost}:${port} (${code || "unknown"}); retrying with ephemeral port`,
|
|
9773
|
+
);
|
|
9774
|
+
await listenOnce(0);
|
|
9775
|
+
}
|
|
8139
9776
|
} catch (err) {
|
|
8140
9777
|
releaseUiInstanceLock();
|
|
8141
9778
|
throw err;
|
|
@@ -8176,17 +9813,17 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
8176
9813
|
|
|
8177
9814
|
// ── SECURITY: Warn loudly when auth is disabled ──────────────────────
|
|
8178
9815
|
if (isAllowUnsafe()) {
|
|
8179
|
-
const tunnelMode = (process.env.TELEGRAM_UI_TUNNEL ||
|
|
8180
|
-
const tunnelActive = tunnelMode !==
|
|
9816
|
+
const tunnelMode = normalizeTunnelMode(process.env.TELEGRAM_UI_TUNNEL || DEFAULT_TUNNEL_MODE);
|
|
9817
|
+
const tunnelActive = tunnelMode !== TUNNEL_MODE_DISABLED;
|
|
8181
9818
|
const border = "═".repeat(68);
|
|
8182
9819
|
console.warn(`\n╔${border}╗`);
|
|
8183
|
-
console.warn(`║
|
|
9820
|
+
console.warn(`║ :ban: DANGER: TELEGRAM_UI_ALLOW_UNSAFE=true — ALL AUTH IS DISABLED ║`);
|
|
8184
9821
|
console.warn(`║ ║`);
|
|
8185
9822
|
console.warn(`║ Anyone with your URL can control agents, read secrets, and ║`);
|
|
8186
9823
|
console.warn(`║ execute arbitrary commands on this machine. ║`);
|
|
8187
9824
|
if (tunnelActive) {
|
|
8188
9825
|
console.warn(`║ ║`);
|
|
8189
|
-
console.warn(`║
|
|
9826
|
+
console.warn(`║ :dot: TUNNEL IS ACTIVE — your UI is exposed to the PUBLIC INTERNET ║`);
|
|
8190
9827
|
console.warn(`║ This means ANYONE can discover your URL and take control. ║`);
|
|
8191
9828
|
console.warn(`║ Set TELEGRAM_UI_TUNNEL=disabled or TELEGRAM_UI_ALLOW_UNSAFE=false ║`);
|
|
8192
9829
|
}
|
|
@@ -8250,7 +9887,7 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
8250
9887
|
if (firewallState) {
|
|
8251
9888
|
if (firewallState.blocked) {
|
|
8252
9889
|
console.warn(
|
|
8253
|
-
`[telegram-ui]
|
|
9890
|
+
`[telegram-ui] :alert: Port ${actualPort}/tcp appears BLOCKED by ${firewallState.firewall} for LAN access.`,
|
|
8254
9891
|
);
|
|
8255
9892
|
console.warn(
|
|
8256
9893
|
`[telegram-ui] To fix, run: ${firewallState.allowCmd}`,
|
|
@@ -8266,7 +9903,7 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
8266
9903
|
console.log(`[telegram-ui] Telegram Mini App URL: ${tUrl}`);
|
|
8267
9904
|
if (firewallState?.blocked) {
|
|
8268
9905
|
console.log(
|
|
8269
|
-
`[telegram-ui]
|
|
9906
|
+
`[telegram-ui] :help: Tunnel active — Telegram Mini App works regardless of firewall. ` +
|
|
8270
9907
|
`LAN browser access still requires port ${actualPort}/tcp to be open.`,
|
|
8271
9908
|
);
|
|
8272
9909
|
}
|