@lifeaitools/clauth 1.1.0 → 1.3.0
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/cli/commands/serve.js +1213 -41
- package/cli/index.js +66 -11
- package/package.json +1 -1
- package/cli/commands/tunnel.js +0 -210
package/cli/commands/serve.js
CHANGED
|
@@ -58,7 +58,10 @@ ALTER TABLE clauth_config ENABLE ROW LEVEL SECURITY;`,
|
|
|
58
58
|
const CURRENT_SCHEMA_VERSION = 3;
|
|
59
59
|
|
|
60
60
|
const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
|
|
61
|
+
const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
|
|
61
62
|
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
63
|
+
const LIVE_PORT = 52437;
|
|
64
|
+
const STAGED_PORT = 52438;
|
|
62
65
|
|
|
63
66
|
// ── PID helpers ──────────────────────────────────────────────
|
|
64
67
|
function readPid() {
|
|
@@ -73,6 +76,22 @@ function writePid(pid, port) {
|
|
|
73
76
|
fs.writeFileSync(PID_FILE, `${pid}:${port}`, "utf8");
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
function readStagedPid() {
|
|
80
|
+
try {
|
|
81
|
+
const raw = fs.readFileSync(STAGED_PID_FILE, "utf8").trim();
|
|
82
|
+
const [pid, port] = raw.split(":");
|
|
83
|
+
return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
|
|
84
|
+
} catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeStagedPid(pid, port) {
|
|
88
|
+
fs.writeFileSync(STAGED_PID_FILE, `${pid}:${port}`, "utf8");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function removeStagedPid() {
|
|
92
|
+
try { fs.unlinkSync(STAGED_PID_FILE); } catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
function removePid() {
|
|
77
96
|
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
78
97
|
}
|
|
@@ -91,7 +110,7 @@ function openBrowser(url) {
|
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
// ── Dashboard HTML ───────────────────────────────────────────
|
|
94
|
-
function dashboardHtml(port, whitelist) {
|
|
113
|
+
function dashboardHtml(port, whitelist, isStaged = false) {
|
|
95
114
|
return `<!DOCTYPE html>
|
|
96
115
|
<html lang="en">
|
|
97
116
|
<head>
|
|
@@ -280,6 +299,40 @@ function dashboardHtml(port, whitelist) {
|
|
|
280
299
|
color: rgba(255,255,255,0.6);
|
|
281
300
|
font-size: 12px;
|
|
282
301
|
}
|
|
302
|
+
/* Tunnel setup wizard */
|
|
303
|
+
.wizard-panel{background:#0a1628;border:1px solid #1e3a5f;border-radius:10px;padding:1.5rem;margin-bottom:1.25rem;display:none}
|
|
304
|
+
.wizard-panel.open{display:block}
|
|
305
|
+
.wizard-header{display:flex;align-items:center;gap:10px;margin-bottom:1.25rem}
|
|
306
|
+
.wizard-title{font-size:1rem;font-weight:600;color:#f8fafc;flex:1}
|
|
307
|
+
.wizard-steps{display:flex;gap:6px;margin-bottom:1.5rem;flex-wrap:wrap}
|
|
308
|
+
.wstep{padding:4px 10px;border-radius:20px;font-size:.72rem;font-weight:600;border:1px solid #1e3a5f;color:#475569;background:#0f172a}
|
|
309
|
+
.wstep.active{border-color:#3b82f6;color:#60a5fa;background:rgba(59,130,246,.1)}
|
|
310
|
+
.wstep.done{border-color:#166534;color:#4ade80;background:rgba(74,222,128,.08)}
|
|
311
|
+
.wizard-body{min-height:80px}
|
|
312
|
+
.wizard-foot{display:flex;gap:8px;margin-top:1.25rem;align-items:center}
|
|
313
|
+
.btn-wiz-primary{background:#3b82f6;color:#fff;padding:8px 20px;font-size:.875rem;border-radius:7px;border:none;cursor:pointer;font-weight:600}
|
|
314
|
+
.btn-wiz-primary:hover{background:#2563eb}
|
|
315
|
+
.btn-wiz-primary:disabled{opacity:.4;cursor:not-allowed}
|
|
316
|
+
.btn-wiz-secondary{background:#1e293b;color:#94a3b8;padding:8px 16px;font-size:.875rem;border-radius:7px;border:1px solid #334155;cursor:pointer}
|
|
317
|
+
.btn-wiz-secondary:hover{color:#e2e8f0}
|
|
318
|
+
.wiz-input{width:100%;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.9rem;padding:9px 12px;outline:none;margin-top:8px;transition:border-color .2s}
|
|
319
|
+
.wiz-input:focus{border-color:#3b82f6}
|
|
320
|
+
.wiz-label{font-size:.8rem;color:#64748b;margin-bottom:4px}
|
|
321
|
+
.wiz-msg{font-size:.82rem;margin-left:auto}
|
|
322
|
+
.wiz-msg.ok{color:#4ade80}.wiz-msg.fail{color:#f87171}
|
|
323
|
+
.wiz-log{background:#030712;border:1px solid #1e293b;border-radius:6px;padding:10px;font-family:'Courier New',monospace;font-size:.75rem;color:#6ee7b7;max-height:160px;overflow-y:auto;margin-top:10px;white-space:pre-wrap}
|
|
324
|
+
.wiz-tunnel-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
|
325
|
+
.wiz-tunnel-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:#0f172a;border:1px solid #1e3a5f;border-radius:6px;cursor:pointer;transition:border-color .15s}
|
|
326
|
+
.wiz-tunnel-item:hover,.wiz-tunnel-item.selected{border-color:#3b82f6;background:rgba(59,130,246,.08)}
|
|
327
|
+
.wiz-tunnel-name{font-weight:600;color:#f8fafc;font-size:.9rem;flex:1}
|
|
328
|
+
.wiz-tunnel-id{font-family:'Courier New',monospace;font-size:.72rem;color:#475569}
|
|
329
|
+
.wiz-tunnel-status{font-size:.72rem;padding:2px 7px;border-radius:4px;background:rgba(74,222,128,.1);color:#4ade80;border:1px solid rgba(74,222,128,.2)}
|
|
330
|
+
.wiz-desc{font-size:.85rem;color:#94a3b8;line-height:1.6;margin-bottom:.75rem}
|
|
331
|
+
.wiz-link{color:#60a5fa;text-decoration:none;font-size:.82rem}
|
|
332
|
+
.wiz-link:hover{text-decoration:underline}
|
|
333
|
+
.wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
|
|
334
|
+
.wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
|
|
335
|
+
.wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
|
|
283
336
|
</style>
|
|
284
337
|
</head>
|
|
285
338
|
<body>
|
|
@@ -307,8 +360,15 @@ function dashboardHtml(port, whitelist) {
|
|
|
307
360
|
</div>
|
|
308
361
|
<div class="header">
|
|
309
362
|
<div class="dot" id="dot"></div>
|
|
310
|
-
<h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span
|
|
363
|
+
<h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span>${isStaged ? `<span style="font-size:0.5em;background:#b45309;color:#fef3c7;border-radius:4px;padding:2px 10px;margin-left:12px;font-weight:600;letter-spacing:.5px">STAGED</span>` : ""}</h1>
|
|
311
364
|
</div>
|
|
365
|
+
${isStaged ? `<div id="staged-banner" style="background:linear-gradient(135deg,#78350f,#92400e);border:1px solid #b45309;border-radius:6px;padding:12px 16px;margin:0 16px 8px;display:flex;align-items:center;justify-content:space-between">
|
|
366
|
+
<div>
|
|
367
|
+
<strong style="color:#fef3c7">⚡ Staged deployment</strong>
|
|
368
|
+
<span style="color:#fcd34d;font-size:.85rem;margin-left:8px">Running on port ${port} — verify before making live</span>
|
|
369
|
+
</div>
|
|
370
|
+
<button onclick="makeLive()" style="background:#15803d;border:1px solid #16a34a;color:#dcfce7;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600;font-size:.85rem">✓ Make Live</button>
|
|
371
|
+
</div>` : ""}
|
|
312
372
|
<div id="error-bar" class="error-bar"></div>
|
|
313
373
|
<div class="status-bar">
|
|
314
374
|
<div>PID: <span id="s-pid">—</span></div>
|
|
@@ -330,6 +390,7 @@ function dashboardHtml(port, whitelist) {
|
|
|
330
390
|
<button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
|
|
331
391
|
<button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
|
|
332
392
|
<button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
|
|
393
|
+
<button class="btn-stop" onclick="stopDaemon()" style="background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5" title="Stop daemon — password required on next start">⏹ Stop</button>
|
|
333
394
|
<button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
|
|
334
395
|
</div>
|
|
335
396
|
|
|
@@ -441,6 +502,17 @@ function dashboardHtml(port, whitelist) {
|
|
|
441
502
|
<div style="font-size:.72rem;color:#64748b;margin-top:4px">Paste these into <a href="https://claude.ai/settings/integrations" target="_blank" style="color:#60a5fa">claude.ai Settings → Integrations</a></div>
|
|
442
503
|
</div>
|
|
443
504
|
|
|
505
|
+
<div class="wizard-panel" id="wizard-panel">
|
|
506
|
+
<div class="wizard-header">
|
|
507
|
+
<div class="tunnel-dot off" id="wiz-dot" style="width:10px;height:10px;border-radius:50%"></div>
|
|
508
|
+
<div class="wizard-title">claude.ai Tunnel Setup</div>
|
|
509
|
+
<button class="btn-cancel" onclick="closeSetupWizard()">✕ Dismiss</button>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="wizard-steps" id="wizard-steps"></div>
|
|
512
|
+
<div class="wizard-body" id="wizard-body"><p class="loading">Checking setup state…</p></div>
|
|
513
|
+
<div class="wizard-foot" id="wizard-foot"></div>
|
|
514
|
+
</div>
|
|
515
|
+
|
|
444
516
|
<div id="project-tabs" class="project-tabs" style="display:none"></div>
|
|
445
517
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
446
518
|
<div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
|
|
@@ -650,6 +722,34 @@ async function lockVault() {
|
|
|
650
722
|
showLockScreen();
|
|
651
723
|
}
|
|
652
724
|
|
|
725
|
+
// ── Make Live (blue-green: promote staged instance to live port) ──
|
|
726
|
+
async function makeLive() {
|
|
727
|
+
if (!confirm("Promote this staged instance to live?\\n\\nThe current live daemon (port ${LIVE_PORT}) will be stopped and this instance will restart on port ${LIVE_PORT}.")) return;
|
|
728
|
+
const banner = document.getElementById("staged-banner");
|
|
729
|
+
if (banner) banner.innerHTML = '<div style="color:#fef3c7"><strong>⏳ Promoting to live...</strong></div>';
|
|
730
|
+
try {
|
|
731
|
+
const resp = await fetch(BASE + "/make-live", { method: "POST" });
|
|
732
|
+
const data = await resp.json();
|
|
733
|
+
if (data.ok) {
|
|
734
|
+
document.getElementById("main-view").innerHTML = '<div style="text-align:center;padding:80px 20px"><div style="font-size:3rem;margin-bottom:16px">✅</div><div style="font-size:1.1rem;color:#4ade80">Promoted to live!</div><div style="font-size:.85rem;color:#94a3b8;margin-top:8px">Restarting on port ${LIVE_PORT}... page will reload in 5 seconds.</div></div>';
|
|
735
|
+
setTimeout(() => { window.location.href = "http://127.0.0.1:" + ${LIVE_PORT}; }, 5000);
|
|
736
|
+
} else {
|
|
737
|
+
if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + (data.error || 'unknown') + '</div>';
|
|
738
|
+
}
|
|
739
|
+
} catch (err) {
|
|
740
|
+
if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + err.message + '</div>';
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ── Stop daemon (user-initiated — clears boot.key, requires password on restart) ──
|
|
745
|
+
async function stopDaemon() {
|
|
746
|
+
if (!confirm("Stop the daemon?\\n\\nCredentials will need to be re-entered on next start.")) return;
|
|
747
|
+
try {
|
|
748
|
+
await fetch(BASE + "/shutdown-ui", { method: "POST" });
|
|
749
|
+
} catch {}
|
|
750
|
+
document.getElementById("main-view").innerHTML = '<div style="text-align:center;padding:80px 20px"><div style="font-size:3rem;margin-bottom:16px">⏹</div><div style="font-size:1.1rem;color:#94a3b8">Daemon stopped</div><div style="font-size:.85rem;color:#64748b;margin-top:8px">Restart with: clauth serve start</div></div>';
|
|
751
|
+
}
|
|
752
|
+
|
|
653
753
|
// ── Load services ───────────────────────────
|
|
654
754
|
let allServices = [];
|
|
655
755
|
let activeProjectTab = "all";
|
|
@@ -1193,9 +1293,7 @@ async function toggleTunnel(action) {
|
|
|
1193
1293
|
} catch {}
|
|
1194
1294
|
}
|
|
1195
1295
|
|
|
1196
|
-
function runTunnelSetup() {
|
|
1197
|
-
alert("Run in your terminal:\\n\\n clauth tunnel setup\\n\\nThis will guide you through Cloudflare authentication and tunnel creation.");
|
|
1198
|
-
}
|
|
1296
|
+
function runTunnelSetup() { openSetupWizard(); }
|
|
1199
1297
|
|
|
1200
1298
|
async function testTunnel() {
|
|
1201
1299
|
const liveState = document.querySelector("#tunnel-panel .tunnel-state.live");
|
|
@@ -1267,6 +1365,423 @@ function copyMcp(elId) {
|
|
|
1267
1365
|
}).catch(() => {});
|
|
1268
1366
|
}
|
|
1269
1367
|
|
|
1368
|
+
// ── Tunnel Setup Wizard ─────────────────────
|
|
1369
|
+
let wizStep = null;
|
|
1370
|
+
let wizData = {};
|
|
1371
|
+
|
|
1372
|
+
async function openSetupWizard() {
|
|
1373
|
+
const panel = document.getElementById("wizard-panel");
|
|
1374
|
+
panel.classList.add("open");
|
|
1375
|
+
panel.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1376
|
+
wizStep = "loading";
|
|
1377
|
+
renderWizBody('<p class="loading">Checking setup state…</p>', [], []);
|
|
1378
|
+
|
|
1379
|
+
const state = await apiFetch("/tunnel/setup-state");
|
|
1380
|
+
if (!state) {
|
|
1381
|
+
renderWizBody('<p class="wiz-desc" style="color:#f87171">Could not reach daemon.</p>', [], []);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
if (state.step === "ready") {
|
|
1386
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1387
|
+
closeSetupWizard();
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
wizData = state;
|
|
1392
|
+
|
|
1393
|
+
if (state.step === "need_cf_token") wizShowCfToken();
|
|
1394
|
+
else if (state.step === "pick_tunnel") wizShowPickTunnel(state.tunnels);
|
|
1395
|
+
else if (state.step === "no_tunnels") wizShowCreateViaCfApi(state.accountId);
|
|
1396
|
+
else wizShowInstallCheck(); // no CF token — needs cloudflared login
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function closeSetupWizard() {
|
|
1400
|
+
document.getElementById("wizard-panel").classList.remove("open");
|
|
1401
|
+
wizStep = null; wizData = {};
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function renderWizBody(html, steps, footHtml) {
|
|
1405
|
+
document.getElementById("wizard-body").innerHTML = html;
|
|
1406
|
+
|
|
1407
|
+
const stepLabels = ["CF Token","Tunnel","cloudflared","MCP Setup","Test"];
|
|
1408
|
+
const stepsEl = document.getElementById("wizard-steps");
|
|
1409
|
+
stepsEl.innerHTML = stepLabels.map((label, i) => {
|
|
1410
|
+
const stepKeys = ["need_cf_token","pick_tunnel","check_cf","mcp_setup","test"];
|
|
1411
|
+
const currentIdx = stepKeys.indexOf(wizStep);
|
|
1412
|
+
let cls = "wstep";
|
|
1413
|
+
if (i < currentIdx) cls += " done";
|
|
1414
|
+
else if (i === currentIdx) cls += " active";
|
|
1415
|
+
return \`<div class="\${cls}">\${i < currentIdx ? "✓ " : ""}\${label}</div>\`;
|
|
1416
|
+
}).join("");
|
|
1417
|
+
|
|
1418
|
+
document.getElementById("wizard-foot").innerHTML = footHtml.join("");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Step: Enter CF API token
|
|
1422
|
+
function wizShowCfToken() {
|
|
1423
|
+
wizStep = "need_cf_token";
|
|
1424
|
+
renderWizBody(\`
|
|
1425
|
+
<div class="wiz-desc">A Cloudflare API token is needed to check for existing tunnels and configure new ones.</div>
|
|
1426
|
+
<div class="wiz-label">Cloudflare API Token</div>
|
|
1427
|
+
<input class="wiz-input" id="wiz-cf-token-input" type="password" placeholder="Paste your CF API token…" autocomplete="off" spellcheck="false">
|
|
1428
|
+
<div style="margin-top:8px">
|
|
1429
|
+
<a class="wiz-link" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">↗ Create token at Cloudflare Dashboard</a>
|
|
1430
|
+
<span style="color:#475569;font-size:.78rem;margin-left:8px">(needs Cloudflare Tunnel: Edit permission)</span>
|
|
1431
|
+
</div>
|
|
1432
|
+
<div class="wiz-msg fail" id="wiz-cf-err" style="display:none;margin-top:8px"></div>
|
|
1433
|
+
\`, [], [
|
|
1434
|
+
\`<button class="btn-wiz-primary" id="wiz-cf-btn" onclick="wizSubmitCfToken()">Verify & Save Token</button>\`,
|
|
1435
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
|
|
1436
|
+
\`<span class="wiz-msg" id="wiz-cf-msg"></span>\`
|
|
1437
|
+
]);
|
|
1438
|
+
|
|
1439
|
+
document.getElementById("wiz-cf-token-input").addEventListener("keydown", e => {
|
|
1440
|
+
if (e.key === "Enter") wizSubmitCfToken();
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function wizSubmitCfToken() {
|
|
1445
|
+
const input = document.getElementById("wiz-cf-token-input");
|
|
1446
|
+
const btn = document.getElementById("wiz-cf-btn");
|
|
1447
|
+
const errEl = document.getElementById("wiz-cf-err");
|
|
1448
|
+
const token = input.value.trim();
|
|
1449
|
+
if (!token) return;
|
|
1450
|
+
|
|
1451
|
+
btn.disabled = true; btn.textContent = "Verifying…";
|
|
1452
|
+
if (errEl) errEl.style.display = "none";
|
|
1453
|
+
|
|
1454
|
+
const r = await fetch(BASE + "/tunnel/setup/cf-token", {
|
|
1455
|
+
method: "POST",
|
|
1456
|
+
headers: { "Content-Type": "application/json" },
|
|
1457
|
+
body: JSON.stringify({ token }),
|
|
1458
|
+
}).then(r => r.json()).catch(() => null);
|
|
1459
|
+
|
|
1460
|
+
btn.disabled = false; btn.textContent = "Verify & Save Token";
|
|
1461
|
+
|
|
1462
|
+
if (!r || r.error) {
|
|
1463
|
+
if (errEl) { errEl.textContent = r?.error || "Request failed"; errEl.style.display = "block"; }
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
wizData.accountId = r.accountId;
|
|
1468
|
+
const tunnelState = await apiFetch("/tunnel/setup-state");
|
|
1469
|
+
if (tunnelState?.step === "pick_tunnel") wizShowPickTunnel(tunnelState.tunnels);
|
|
1470
|
+
else wizShowInstallCheck();
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Step: Pick existing tunnel
|
|
1474
|
+
function wizShowPickTunnel(tunnels) {
|
|
1475
|
+
wizStep = "pick_tunnel";
|
|
1476
|
+
|
|
1477
|
+
const listHtml = tunnels.map(t => \`
|
|
1478
|
+
<div class="wiz-tunnel-item" id="wti-\${t.id}" onclick="wizSelectTunnel('\${t.id}','\${t.name}',this)">
|
|
1479
|
+
<div style="flex:1">
|
|
1480
|
+
<div class="wiz-tunnel-name">\${t.name}</div>
|
|
1481
|
+
<div class="wiz-tunnel-id">\${t.id}</div>
|
|
1482
|
+
</div>
|
|
1483
|
+
<div class="wiz-tunnel-status">\${t.status || "active"}</div>
|
|
1484
|
+
</div>
|
|
1485
|
+
\`).join("");
|
|
1486
|
+
|
|
1487
|
+
renderWizBody(\`
|
|
1488
|
+
<div class="wiz-desc">Found \${tunnels.length} existing Cloudflare tunnel\${tunnels.length !== 1 ? "s" : ""}. Select one to use, or create a new one.</div>
|
|
1489
|
+
<div class="wiz-tunnel-list">\${listHtml}</div>
|
|
1490
|
+
<div style="margin-top:12px">
|
|
1491
|
+
<div class="wiz-label">Public hostname for selected tunnel (e.g. clauth.yourdomain.com)</div>
|
|
1492
|
+
<input class="wiz-input" id="wiz-hostname-input" type="text" placeholder="clauth.yourdomain.com" spellcheck="false" autocomplete="off">
|
|
1493
|
+
</div>
|
|
1494
|
+
<div class="wiz-msg fail" id="wiz-pick-err" style="display:none;margin-top:8px"></div>
|
|
1495
|
+
\`, [], [
|
|
1496
|
+
\`<button class="btn-wiz-primary" id="wiz-pick-btn" onclick="wizSaveTunnel()">Use Selected Tunnel</button>\`,
|
|
1497
|
+
\`<button class="btn-wiz-secondary" onclick="wizShowInstallCheck()">Create New Tunnel Instead</button>\`,
|
|
1498
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1499
|
+
]);
|
|
1500
|
+
|
|
1501
|
+
window._wizSelectedTunnel = null;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function wizSelectTunnel(id, name, el) {
|
|
1505
|
+
document.querySelectorAll(".wiz-tunnel-item").forEach(e => e.classList.remove("selected"));
|
|
1506
|
+
el.classList.add("selected");
|
|
1507
|
+
window._wizSelectedTunnel = { id, name };
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
async function wizSaveTunnel() {
|
|
1511
|
+
const sel = window._wizSelectedTunnel;
|
|
1512
|
+
const hostname = document.getElementById("wiz-hostname-input")?.value.trim();
|
|
1513
|
+
const errEl = document.getElementById("wiz-pick-err");
|
|
1514
|
+
const btn = document.getElementById("wiz-pick-btn");
|
|
1515
|
+
|
|
1516
|
+
if (!sel) { if (errEl) { errEl.textContent = "Select a tunnel first"; errEl.style.display="block"; } return; }
|
|
1517
|
+
if (!hostname) { if (errEl) { errEl.textContent = "Enter a public hostname"; errEl.style.display="block"; } return; }
|
|
1518
|
+
|
|
1519
|
+
btn.disabled = true; btn.textContent = "Saving…";
|
|
1520
|
+
|
|
1521
|
+
const r = await fetch(BASE + "/tunnel/setup/cf-save", {
|
|
1522
|
+
method: "POST",
|
|
1523
|
+
headers: { "Content-Type": "application/json" },
|
|
1524
|
+
body: JSON.stringify({ tunnelId: sel.id, tunnelName: sel.name, hostname }),
|
|
1525
|
+
}).then(r => r.json()).catch(() => null);
|
|
1526
|
+
|
|
1527
|
+
btn.disabled = false; btn.textContent = "Use Selected Tunnel";
|
|
1528
|
+
|
|
1529
|
+
if (!r?.ok) {
|
|
1530
|
+
if (errEl) { errEl.textContent = r?.error || "Save failed"; errEl.style.display="block"; }
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1535
|
+
wizShowMcpSetup(hostname);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Step: Create tunnel via CF API (CF token already in vault — no cloudflared login needed)
|
|
1539
|
+
function wizShowCreateViaCfApi(accountId) {
|
|
1540
|
+
wizStep = "pick_tunnel";
|
|
1541
|
+
wizData.accountId = accountId;
|
|
1542
|
+
renderWizBody(\`
|
|
1543
|
+
<div class="wiz-desc">No existing tunnels found. Create a new one using your Cloudflare API token.</div>
|
|
1544
|
+
<div class="wiz-label">Tunnel name</div>
|
|
1545
|
+
<input class="wiz-input" id="wiz-api-tname" type="text" value="clauth" spellcheck="false">
|
|
1546
|
+
<div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
|
|
1547
|
+
<input class="wiz-input" id="wiz-api-hostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
|
|
1548
|
+
<div class="wiz-msg fail" id="wiz-api-err" style="display:none;margin-top:8px"></div>
|
|
1549
|
+
\`, [], [
|
|
1550
|
+
\`<button class="btn-wiz-primary" id="wiz-api-btn" onclick="wizRunCreateViaCfApi()">Create Tunnel</button>\`,
|
|
1551
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
|
|
1552
|
+
\`<span class="wiz-msg" id="wiz-api-msg"></span>\`
|
|
1553
|
+
]);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async function wizRunCreateViaCfApi() {
|
|
1557
|
+
const name = document.getElementById("wiz-api-tname")?.value.trim();
|
|
1558
|
+
const hostname = document.getElementById("wiz-api-hostname")?.value.trim();
|
|
1559
|
+
const btn = document.getElementById("wiz-api-btn");
|
|
1560
|
+
const err = document.getElementById("wiz-api-err");
|
|
1561
|
+
const msg = document.getElementById("wiz-api-msg");
|
|
1562
|
+
if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
|
|
1563
|
+
btn.disabled=true; btn.textContent="Creating…"; if(err) err.style.display="none";
|
|
1564
|
+
if(msg) msg.textContent="Calling Cloudflare API…";
|
|
1565
|
+
const r = await fetch(BASE + "/tunnel/setup/cf-create-api", {
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
headers: { "Content-Type": "application/json" },
|
|
1568
|
+
body: JSON.stringify({ name, hostname, accountId: wizData.accountId }),
|
|
1569
|
+
}).then(r => r.json()).catch(() => null);
|
|
1570
|
+
btn.disabled=false; btn.textContent="Create Tunnel";
|
|
1571
|
+
if (!r?.ok) {
|
|
1572
|
+
if(err){err.textContent = r?.error || "Creation failed"; err.style.display="block";}
|
|
1573
|
+
if(msg) msg.textContent="";
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1577
|
+
wizShowMcpSetup(hostname);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Step: cloudflared install check (for new tunnel creation flow)
|
|
1581
|
+
async function wizShowInstallCheck() {
|
|
1582
|
+
wizStep = "check_cf";
|
|
1583
|
+
renderWizBody('<p class="loading">Checking cloudflared…</p>', [], []);
|
|
1584
|
+
|
|
1585
|
+
const r = await apiFetch("/tunnel/setup/check-cloudflared");
|
|
1586
|
+
|
|
1587
|
+
if (r?.installed) {
|
|
1588
|
+
wizShowCfLogin();
|
|
1589
|
+
} else {
|
|
1590
|
+
renderWizBody(\`
|
|
1591
|
+
<div class="wiz-desc">cloudflared is required to create and run Cloudflare tunnels. It is not currently installed.</div>
|
|
1592
|
+
<div style="display:flex;gap:12px;margin-top:12px;flex-wrap:wrap">
|
|
1593
|
+
<a class="btn-wiz-secondary" href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" target="_blank" style="text-decoration:none">↗ Download cloudflared</a>
|
|
1594
|
+
<span style="color:#475569;font-size:.8rem;align-self:center">or: <code style="color:#60a5fa">winget install Cloudflare.cloudflared</code></span>
|
|
1595
|
+
</div>
|
|
1596
|
+
<div class="wiz-desc" style="margin-top:12px;font-size:.78rem;color:#475569">After installing, click "Check Again".</div>
|
|
1597
|
+
\`, [], [
|
|
1598
|
+
\`<button class="btn-wiz-primary" onclick="wizShowInstallCheck()">Check Again</button>\`,
|
|
1599
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1600
|
+
]);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Step: cloudflared login (Cloudflare auth via browser)
|
|
1605
|
+
function wizShowCfLogin() {
|
|
1606
|
+
wizStep = "check_cf";
|
|
1607
|
+
renderWizBody(\`
|
|
1608
|
+
<div class="wiz-desc">Authenticate cloudflared with your Cloudflare account. This opens a browser window.</div>
|
|
1609
|
+
<div class="wiz-log" id="wiz-login-log" style="display:none"></div>
|
|
1610
|
+
\`, [], [
|
|
1611
|
+
\`<button class="btn-wiz-primary" id="wiz-login-btn" onclick="wizRunCfLogin()">Open Browser to Authenticate</button>\`,
|
|
1612
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1613
|
+
]);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
async function wizRunCfLogin() {
|
|
1617
|
+
const btn = document.getElementById("wiz-login-btn");
|
|
1618
|
+
const log = document.getElementById("wiz-login-log");
|
|
1619
|
+
btn.disabled = true; btn.textContent = "Authenticating…";
|
|
1620
|
+
log.style.display = "block";
|
|
1621
|
+
|
|
1622
|
+
try {
|
|
1623
|
+
const resp = await fetch(BASE + "/tunnel/setup/cf-login", { method: "POST" });
|
|
1624
|
+
const reader = resp.body.getReader();
|
|
1625
|
+
const dec = new TextDecoder();
|
|
1626
|
+
let buf = "";
|
|
1627
|
+
while (true) {
|
|
1628
|
+
const { done, value } = await reader.read();
|
|
1629
|
+
if (done) break;
|
|
1630
|
+
buf += dec.decode(value, { stream: true });
|
|
1631
|
+
const lines = buf.split("\\n");
|
|
1632
|
+
buf = lines.pop();
|
|
1633
|
+
for (const line of lines) {
|
|
1634
|
+
if (!line.startsWith("data:")) continue;
|
|
1635
|
+
try {
|
|
1636
|
+
const d = JSON.parse(line.slice(5).trim());
|
|
1637
|
+
if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
|
|
1638
|
+
if (d.done) {
|
|
1639
|
+
if (d.code === 0) wizShowCreateTunnel();
|
|
1640
|
+
else { btn.disabled=false; btn.textContent="Retry"; }
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
} catch {}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
} catch(e) {
|
|
1647
|
+
log.textContent += "Error: " + e.message;
|
|
1648
|
+
btn.disabled = false; btn.textContent = "Retry";
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Step: Create new tunnel
|
|
1653
|
+
function wizShowCreateTunnel() {
|
|
1654
|
+
wizStep = "check_cf";
|
|
1655
|
+
renderWizBody(\`
|
|
1656
|
+
<div class="wiz-desc">Create a new named Cloudflare tunnel. Give it a name and a public hostname.</div>
|
|
1657
|
+
<div class="wiz-label">Tunnel name (e.g. clauth)</div>
|
|
1658
|
+
<input class="wiz-input" id="wiz-tname" type="text" value="clauth" spellcheck="false">
|
|
1659
|
+
<div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
|
|
1660
|
+
<input class="wiz-input" id="wiz-thostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
|
|
1661
|
+
<div class="wiz-log" id="wiz-create-log" style="display:none;margin-top:10px"></div>
|
|
1662
|
+
<div class="wiz-msg fail" id="wiz-create-err" style="display:none;margin-top:8px"></div>
|
|
1663
|
+
\`, [], [
|
|
1664
|
+
\`<button class="btn-wiz-primary" id="wiz-create-btn" onclick="wizRunCreateTunnel()">Create Tunnel</button>\`,
|
|
1665
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1666
|
+
]);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
async function wizRunCreateTunnel() {
|
|
1670
|
+
const name = document.getElementById("wiz-tname")?.value.trim();
|
|
1671
|
+
const hostname = document.getElementById("wiz-thostname")?.value.trim();
|
|
1672
|
+
const btn = document.getElementById("wiz-create-btn");
|
|
1673
|
+
const log = document.getElementById("wiz-create-log");
|
|
1674
|
+
const err = document.getElementById("wiz-create-err");
|
|
1675
|
+
|
|
1676
|
+
if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
|
|
1677
|
+
|
|
1678
|
+
btn.disabled=true; btn.textContent="Creating…";
|
|
1679
|
+
log.style.display="block"; err.style.display="none";
|
|
1680
|
+
|
|
1681
|
+
try {
|
|
1682
|
+
const resp = await fetch(BASE + "/tunnel/setup/cf-create", {
|
|
1683
|
+
method: "POST",
|
|
1684
|
+
headers: { "Content-Type": "application/json" },
|
|
1685
|
+
body: JSON.stringify({ name, hostname }),
|
|
1686
|
+
});
|
|
1687
|
+
const reader = resp.body.getReader();
|
|
1688
|
+
const dec = new TextDecoder();
|
|
1689
|
+
let buf = "";
|
|
1690
|
+
while (true) {
|
|
1691
|
+
const { done, value } = await reader.read();
|
|
1692
|
+
if (done) break;
|
|
1693
|
+
buf += dec.decode(value, { stream: true });
|
|
1694
|
+
const lines = buf.split("\\n");
|
|
1695
|
+
buf = lines.pop();
|
|
1696
|
+
for (const line of lines) {
|
|
1697
|
+
if (!line.startsWith("data:")) continue;
|
|
1698
|
+
try {
|
|
1699
|
+
const d = JSON.parse(line.slice(5).trim());
|
|
1700
|
+
if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
|
|
1701
|
+
if (d.done && d.hostname) {
|
|
1702
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1703
|
+
wizShowMcpSetup(d.hostname);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
if (d.error) { err.textContent = d.error; err.style.display="block"; btn.disabled=false; btn.textContent="Retry"; return; }
|
|
1707
|
+
} catch {}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
} catch(e) {
|
|
1711
|
+
if(err){err.textContent=e.message;err.style.display="block";}
|
|
1712
|
+
btn.disabled=false; btn.textContent="Retry";
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Step: MCP setup in claude.ai
|
|
1717
|
+
async function wizShowMcpSetup(hostname) {
|
|
1718
|
+
wizStep = "mcp_setup";
|
|
1719
|
+
const sseUrl = \`https://\${hostname}/sse\`;
|
|
1720
|
+
|
|
1721
|
+
const mcpData = await apiFetch("/mcp-setup");
|
|
1722
|
+
|
|
1723
|
+
renderWizBody(\`
|
|
1724
|
+
<div class="wiz-desc">Add clauth as an MCP server in claude.ai settings. Paste these values:</div>
|
|
1725
|
+
<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px">
|
|
1726
|
+
<div class="mcp-row">
|
|
1727
|
+
<span class="mcp-label">URL</span>
|
|
1728
|
+
<span class="mcp-val" id="wiz-mcp-url">\${sseUrl}</span>
|
|
1729
|
+
<button class="mcp-copy" onclick="wizCopy('wiz-mcp-url',this)">copy</button>
|
|
1730
|
+
</div>
|
|
1731
|
+
<div class="mcp-row">
|
|
1732
|
+
<span class="mcp-label">Client ID</span>
|
|
1733
|
+
<span class="mcp-val" id="wiz-mcp-cid">\${mcpData?.clientId || '(unlock required)'}</span>
|
|
1734
|
+
<button class="mcp-copy" onclick="wizCopy('wiz-mcp-cid',this)">copy</button>
|
|
1735
|
+
</div>
|
|
1736
|
+
<div class="mcp-row">
|
|
1737
|
+
<span class="mcp-label">Secret</span>
|
|
1738
|
+
<span class="mcp-val" id="wiz-mcp-sec">\${mcpData?.clientSecret || '(unlock required)'}</span>
|
|
1739
|
+
<button class="mcp-copy" onclick="wizCopy('wiz-mcp-sec',this)">copy</button>
|
|
1740
|
+
</div>
|
|
1741
|
+
</div>
|
|
1742
|
+
<div style="margin-top:10px;font-size:.78rem;color:#64748b">
|
|
1743
|
+
Paste into <a class="wiz-link" href="https://claude.ai/settings/integrations" target="_blank">claude.ai → Settings → Integrations</a>
|
|
1744
|
+
</div>
|
|
1745
|
+
\`, [], [
|
|
1746
|
+
\`<button class="btn-wiz-primary" onclick="window.open('https://claude.ai/settings/integrations','_blank');wizShowTest()">I've Added It — Test Now</button>\`,
|
|
1747
|
+
\`<button class="btn-wiz-secondary" onclick="wizShowTest()">Skip Test</button>\`,
|
|
1748
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Done</button>\`
|
|
1749
|
+
]);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function wizCopy(elId, btn) {
|
|
1753
|
+
const val = document.getElementById(elId)?.textContent?.trim();
|
|
1754
|
+
if (!val) return;
|
|
1755
|
+
navigator.clipboard.writeText(val).then(() => {
|
|
1756
|
+
const orig = btn.textContent;
|
|
1757
|
+
btn.textContent = "✓"; btn.classList.add("ok");
|
|
1758
|
+
setTimeout(() => { btn.textContent = orig; btn.classList.remove("ok"); }, 1500);
|
|
1759
|
+
}).catch(() => {});
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Step: Test — verify MCP is working
|
|
1763
|
+
function wizShowTest() {
|
|
1764
|
+
wizStep = "test";
|
|
1765
|
+
renderWizBody(\`
|
|
1766
|
+
<div class="wiz-desc">In a new claude.ai chat, ask Claude to run this MCP tool call to verify the connection:</div>
|
|
1767
|
+
<div style="background:#030712;border:1px solid #1e293b;border-radius:6px;padding:12px;margin:10px 0;font-family:'Courier New',monospace;font-size:.82rem;color:#6ee7b7;user-select:all">
|
|
1768
|
+
Use the clauth MCP tool: GET /ping — what is the response?
|
|
1769
|
+
</div>
|
|
1770
|
+
<div class="wiz-desc">Claude should report back: <code style="color:#4ade80">{"status":"ok","version":"..."}</code></div>
|
|
1771
|
+
<div class="wiz-test-result" id="wiz-test-result"></div>
|
|
1772
|
+
\`, [], [
|
|
1773
|
+
\`<button class="btn-wiz-primary" onclick="wizMarkTestDone()">✓ It Worked — Done</button>\`,
|
|
1774
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Close</button>\`
|
|
1775
|
+
]);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function wizMarkTestDone() {
|
|
1779
|
+
const r = document.getElementById("wiz-test-result");
|
|
1780
|
+
r.className = "wiz-test-result ok"; r.style.display="block";
|
|
1781
|
+
r.textContent = "✓ Tunnel and MCP are working. claude.ai can now access your vault.";
|
|
1782
|
+
document.getElementById("wizard-foot").innerHTML = \`<button class="btn-wiz-primary" onclick="closeSetupWizard()">Close Setup</button>\`;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1270
1785
|
// ── Build Status ──────────────────────────────────
|
|
1271
1786
|
async function updateBuildStatus() {
|
|
1272
1787
|
try {
|
|
@@ -1320,7 +1835,7 @@ function readBody(req) {
|
|
|
1320
1835
|
}
|
|
1321
1836
|
|
|
1322
1837
|
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
1323
|
-
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null) {
|
|
1838
|
+
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
|
|
1324
1839
|
// tunnelHostname may be updated at runtime (fetched from DB after unlock)
|
|
1325
1840
|
let tunnelHostname = tunnelHostnameInit;
|
|
1326
1841
|
|
|
@@ -1456,6 +1971,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
1456
1971
|
|
|
1457
1972
|
const proc = spawnProc(cfBin, args, {
|
|
1458
1973
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1974
|
+
windowsHide: true,
|
|
1459
1975
|
});
|
|
1460
1976
|
|
|
1461
1977
|
tunnelProc = proc;
|
|
@@ -1614,6 +2130,26 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
1614
2130
|
if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
|
|
1615
2131
|
return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
1616
2132
|
}
|
|
2133
|
+
|
|
2134
|
+
// DB has no hostname — check ~/.cloudflared/config.yml
|
|
2135
|
+
try {
|
|
2136
|
+
const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
|
|
2137
|
+
if (fs.existsSync(cfConfig)) {
|
|
2138
|
+
const yml = fs.readFileSync(cfConfig, "utf8");
|
|
2139
|
+
const hostMatch = yml.match(/^\s*-\s*hostname:\s*(\S+)/m);
|
|
2140
|
+
if (hostMatch) {
|
|
2141
|
+
const hostname = hostMatch[1];
|
|
2142
|
+
// Save to DB so future loads skip this
|
|
2143
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
2144
|
+
method: "POST",
|
|
2145
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
2146
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
2147
|
+
}).catch(() => {});
|
|
2148
|
+
return hostname;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
} catch {}
|
|
2152
|
+
|
|
1617
2153
|
return null;
|
|
1618
2154
|
} catch {
|
|
1619
2155
|
return null;
|
|
@@ -2036,7 +2572,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2036
2572
|
// GET / — built-in web dashboard
|
|
2037
2573
|
if (method === "GET" && reqPath === "/") {
|
|
2038
2574
|
res.writeHead(200, { "Content-Type": "text/html", ...CORS });
|
|
2039
|
-
return res.end(dashboardHtml(port, whitelist));
|
|
2575
|
+
return res.end(dashboardHtml(port, whitelist, isStaged));
|
|
2040
2576
|
}
|
|
2041
2577
|
|
|
2042
2578
|
// GET /ping
|
|
@@ -2052,6 +2588,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2052
2588
|
tunnel_status: tunnelStatus,
|
|
2053
2589
|
tunnel_url: tunnelUrl || null,
|
|
2054
2590
|
app_version: VERSION,
|
|
2591
|
+
staged: isStaged,
|
|
2055
2592
|
schema_version: CURRENT_SCHEMA_VERSION,
|
|
2056
2593
|
});
|
|
2057
2594
|
}
|
|
@@ -2123,7 +2660,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2123
2660
|
return ok(res, { status: tunnelStatus });
|
|
2124
2661
|
}
|
|
2125
2662
|
|
|
2126
|
-
// GET /shutdown (for daemon stop)
|
|
2663
|
+
// GET /shutdown (for daemon stop — programmatic, keeps boot.key)
|
|
2127
2664
|
if (method === "GET" && reqPath === "/shutdown") {
|
|
2128
2665
|
stopTunnel();
|
|
2129
2666
|
ok(res, { ok: true, message: "shutting down" });
|
|
@@ -2132,6 +2669,100 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2132
2669
|
return;
|
|
2133
2670
|
}
|
|
2134
2671
|
|
|
2672
|
+
// POST /shutdown-ui (user-initiated stop — clears boot.key so password is required on restart)
|
|
2673
|
+
if (method === "POST" && reqPath === "/shutdown-ui") {
|
|
2674
|
+
stopTunnel();
|
|
2675
|
+
// Clear boot.key so watchdog can't auto-unlock on restart
|
|
2676
|
+
const bootKeyPath = getBootKeyPath();
|
|
2677
|
+
if (bootKeyPath) {
|
|
2678
|
+
try { fs.unlinkSync(bootKeyPath); } catch {}
|
|
2679
|
+
}
|
|
2680
|
+
ok(res, { ok: true, message: "shutting down (user-initiated, boot.key cleared)" });
|
|
2681
|
+
const logLine = `[${new Date().toISOString()}] User-initiated shutdown via UI — boot.key cleared\n`;
|
|
2682
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
2683
|
+
removePid();
|
|
2684
|
+
setTimeout(() => process.exit(0), 100);
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// POST /make-live (blue-green: staged instance promotes itself to live port)
|
|
2689
|
+
if (method === "POST" && reqPath === "/make-live") {
|
|
2690
|
+
if (!isStaged) {
|
|
2691
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
2692
|
+
return res.end(JSON.stringify({ error: "Not a staged instance" }));
|
|
2693
|
+
}
|
|
2694
|
+
const logLine = `[${new Date().toISOString()}] Make-live: promoting staged (port ${port}) to live (port ${LIVE_PORT})\n`;
|
|
2695
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
2696
|
+
|
|
2697
|
+
// Stop the current live daemon if running
|
|
2698
|
+
try { await fetch(`http://127.0.0.1:${LIVE_PORT}/shutdown`); } catch {}
|
|
2699
|
+
|
|
2700
|
+
// Wait for port to be released (Windows is slow to release sockets)
|
|
2701
|
+
let portFree = false;
|
|
2702
|
+
for (let i = 0; i < 10; i++) {
|
|
2703
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
2704
|
+
try {
|
|
2705
|
+
await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
|
|
2706
|
+
// Still responding — not dead yet
|
|
2707
|
+
} catch {
|
|
2708
|
+
portFree = true;
|
|
2709
|
+
break;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
if (!portFree) {
|
|
2713
|
+
const errLog = `[${new Date().toISOString()}] Make-live: live daemon on port ${LIVE_PORT} failed to stop within 10s\n`;
|
|
2714
|
+
try { fs.appendFileSync(LOG_FILE, errLog); } catch {}
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// Stop tunnel on this instance (will restart on new port)
|
|
2718
|
+
stopTunnel();
|
|
2719
|
+
|
|
2720
|
+
// Clean up staged PID file
|
|
2721
|
+
removeStagedPid();
|
|
2722
|
+
|
|
2723
|
+
// Spawn a new daemon on the live port with same credentials
|
|
2724
|
+
const { spawn } = await import("child_process");
|
|
2725
|
+
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
2726
|
+
const childArgs = [cliEntry, "serve", "start", "--port", String(LIVE_PORT)];
|
|
2727
|
+
if (password) childArgs.push("--pw", password);
|
|
2728
|
+
if (whitelist) childArgs.push("--services", whitelist.join(","));
|
|
2729
|
+
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
2730
|
+
|
|
2731
|
+
const out = fs.openSync(LOG_FILE, "a");
|
|
2732
|
+
const childEnv = { ...process.env, __CLAUTH_DAEMON: "1" };
|
|
2733
|
+
delete childEnv.__CLAUTH_STAGED; // Clear staged flag — child is live
|
|
2734
|
+
const child = spawn(process.execPath, childArgs, {
|
|
2735
|
+
detached: true,
|
|
2736
|
+
stdio: ["ignore", out, out],
|
|
2737
|
+
env: childEnv,
|
|
2738
|
+
});
|
|
2739
|
+
child.unref();
|
|
2740
|
+
|
|
2741
|
+
// Wait for new live daemon to be reachable
|
|
2742
|
+
let promoted = false;
|
|
2743
|
+
for (let i = 0; i < 8; i++) {
|
|
2744
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
2745
|
+
try {
|
|
2746
|
+
const resp = await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
|
|
2747
|
+
if (resp.ok) { promoted = true; break; }
|
|
2748
|
+
} catch {}
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
if (promoted) {
|
|
2752
|
+
const okLog = `[${new Date().toISOString()}] Make-live: promoted to live on port ${LIVE_PORT}\n`;
|
|
2753
|
+
try { fs.appendFileSync(LOG_FILE, okLog); } catch {}
|
|
2754
|
+
ok(res, { ok: true, message: "promoted to live", live_port: LIVE_PORT });
|
|
2755
|
+
} else {
|
|
2756
|
+
const failLog = `[${new Date().toISOString()}] Make-live: new daemon failed to start on port ${LIVE_PORT}\n`;
|
|
2757
|
+
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
2758
|
+
ok(res, { ok: false, error: "New daemon failed to start on live port — check log" });
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// Self-terminate after response is sent
|
|
2762
|
+
setTimeout(() => process.exit(0), 500);
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2135
2766
|
// Locked guard — returns true if locked and already responded
|
|
2136
2767
|
function lockedGuard(res) {
|
|
2137
2768
|
if (!password) {
|
|
@@ -2228,6 +2859,25 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2228
2859
|
password = pw; // unlock — store in process memory only
|
|
2229
2860
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
2230
2861
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
2862
|
+
// Auto-seal: DPAPI-encrypt password to boot.key for passwordless crash recovery
|
|
2863
|
+
// Only on Windows; only if autostart dir exists (i.e., install was run)
|
|
2864
|
+
if (os.platform() === "win32") {
|
|
2865
|
+
const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
2866
|
+
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
2867
|
+
if (fs.existsSync(autostartDir)) {
|
|
2868
|
+
try {
|
|
2869
|
+
const pwEscaped = pw.replace(/'/g, "''");
|
|
2870
|
+
const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
2871
|
+
const encrypted = execSyncTop(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8", timeout: 5000 }).trim();
|
|
2872
|
+
fs.writeFileSync(bootKeyPath, encrypted, "utf8");
|
|
2873
|
+
const sealLog = `[${new Date().toISOString()}] Auto-sealed boot.key via DPAPI (crash recovery enabled)\n`;
|
|
2874
|
+
try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
|
|
2875
|
+
} catch (sealErr) {
|
|
2876
|
+
const sealLog = `[${new Date().toISOString()}] Auto-seal failed (non-fatal): ${sealErr.message}\n`;
|
|
2877
|
+
try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2231
2881
|
// Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
|
|
2232
2882
|
if (!tunnelHostname) {
|
|
2233
2883
|
fetchTunnelConfig().then(configured => {
|
|
@@ -2379,6 +3029,415 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2379
3029
|
}
|
|
2380
3030
|
}
|
|
2381
3031
|
|
|
3032
|
+
// GET /tunnel/setup-state
|
|
3033
|
+
if (method === "GET" && reqPath === "/tunnel/setup-state") {
|
|
3034
|
+
if (lockedGuard(res)) return;
|
|
3035
|
+
try {
|
|
3036
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3037
|
+
const sbKey = api.getAnonKey();
|
|
3038
|
+
|
|
3039
|
+
// Check for existing hostname in DB
|
|
3040
|
+
const hostnameResp = await fetch(
|
|
3041
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
|
|
3042
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
3043
|
+
);
|
|
3044
|
+
if (hostnameResp.ok) {
|
|
3045
|
+
const rows = await hostnameResp.json();
|
|
3046
|
+
if (rows.length > 0 && rows[0].value && rows[0].value !== "null" && rows[0].value !== '""') {
|
|
3047
|
+
const hn = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
3048
|
+
if (hn) return ok(res, { step: "ready", hostname: hn });
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
// Check ~/.cloudflared/config.yml for a previously configured tunnel
|
|
3053
|
+
try {
|
|
3054
|
+
const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
|
|
3055
|
+
if (fs.existsSync(cfConfig)) {
|
|
3056
|
+
const cfYml = fs.readFileSync(cfConfig, "utf8");
|
|
3057
|
+
// Parse hostname from ingress block: " - hostname: <hostname>"
|
|
3058
|
+
const hostMatch = cfYml.match(/^\s*-\s*hostname:\s*(\S+)/m);
|
|
3059
|
+
const tunnelMatch = cfYml.match(/^tunnel:\s*(\S+)/m);
|
|
3060
|
+
if (hostMatch && tunnelMatch) {
|
|
3061
|
+
const localHostname = hostMatch[1];
|
|
3062
|
+
const localTunnelId = tunnelMatch[1];
|
|
3063
|
+
// Save to DB so next load skips this check
|
|
3064
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3065
|
+
method: "POST",
|
|
3066
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3067
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(localHostname) }),
|
|
3068
|
+
});
|
|
3069
|
+
tunnelHostname = localHostname;
|
|
3070
|
+
return ok(res, { step: "ready", hostname: localHostname, tunnelId: localTunnelId, source: "local_config" });
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
} catch {}
|
|
3074
|
+
|
|
3075
|
+
// Check for CF token in vault
|
|
3076
|
+
let cfToken = null;
|
|
3077
|
+
try {
|
|
3078
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
3079
|
+
const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
|
|
3080
|
+
if (cr?.value) cfToken = cr.value;
|
|
3081
|
+
} catch {}
|
|
3082
|
+
|
|
3083
|
+
if (!cfToken) return ok(res, { step: "need_cf_token" });
|
|
3084
|
+
|
|
3085
|
+
// Get or fetch account ID
|
|
3086
|
+
let accountId = null;
|
|
3087
|
+
try {
|
|
3088
|
+
const acctResp = await fetch(
|
|
3089
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
|
|
3090
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
3091
|
+
);
|
|
3092
|
+
if (acctResp.ok) {
|
|
3093
|
+
const rows = await acctResp.json();
|
|
3094
|
+
if (rows.length > 0 && rows[0].value) {
|
|
3095
|
+
accountId = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
} catch {}
|
|
3099
|
+
|
|
3100
|
+
if (!accountId) {
|
|
3101
|
+
try {
|
|
3102
|
+
const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
|
|
3103
|
+
headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
|
|
3104
|
+
});
|
|
3105
|
+
const ad = await ar.json();
|
|
3106
|
+
accountId = ad?.result?.[0]?.id;
|
|
3107
|
+
if (accountId) {
|
|
3108
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3109
|
+
method: "POST",
|
|
3110
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3111
|
+
body: JSON.stringify({ key: "cf_account_id", value: accountId }),
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
} catch {}
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
if (!accountId) return ok(res, { step: "setup_wizard", error: "Could not get Cloudflare account ID" });
|
|
3118
|
+
|
|
3119
|
+
// List tunnels
|
|
3120
|
+
try {
|
|
3121
|
+
const tr = await fetch(
|
|
3122
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
|
|
3123
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
|
|
3124
|
+
);
|
|
3125
|
+
const td = await tr.json();
|
|
3126
|
+
const tunnels = (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status }));
|
|
3127
|
+
if (tunnels.length > 0) return ok(res, { step: "pick_tunnel", tunnels, accountId });
|
|
3128
|
+
// CF token exists but no tunnels — create via API (no cloudflared login needed)
|
|
3129
|
+
return ok(res, { step: "no_tunnels", accountId });
|
|
3130
|
+
} catch {}
|
|
3131
|
+
|
|
3132
|
+
return ok(res, { step: "setup_wizard" });
|
|
3133
|
+
} catch (err) {
|
|
3134
|
+
return ok(res, { step: "setup_wizard", error: err.message });
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// POST /tunnel/setup/cf-token
|
|
3139
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
|
|
3140
|
+
if (lockedGuard(res)) return;
|
|
3141
|
+
let body;
|
|
3142
|
+
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
3143
|
+
const { token: cfToken } = body;
|
|
3144
|
+
if (!cfToken) return strike(res, 400, "token required");
|
|
3145
|
+
|
|
3146
|
+
try {
|
|
3147
|
+
const vr = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
3148
|
+
headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
|
|
3149
|
+
});
|
|
3150
|
+
const vd = await vr.json();
|
|
3151
|
+
if (!vd?.success) return strike(res, 400, "Invalid Cloudflare API token");
|
|
3152
|
+
|
|
3153
|
+
const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
|
|
3154
|
+
headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
|
|
3155
|
+
});
|
|
3156
|
+
const ad = await ar.json();
|
|
3157
|
+
const accountId = ad?.result?.[0]?.id;
|
|
3158
|
+
const accountName = ad?.result?.[0]?.name;
|
|
3159
|
+
|
|
3160
|
+
// Save token to vault using api.write (same as /set/:service)
|
|
3161
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
3162
|
+
await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
|
|
3163
|
+
|
|
3164
|
+
// Save accountId to clauth_config
|
|
3165
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3166
|
+
const sbKey = api.getAnonKey();
|
|
3167
|
+
if (accountId) {
|
|
3168
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3169
|
+
method: "POST",
|
|
3170
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3171
|
+
body: JSON.stringify({ key: "cf_account_id", value: accountId }),
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
return ok(res, { ok: true, accountId, accountName });
|
|
3176
|
+
} catch (err) {
|
|
3177
|
+
return strike(res, 502, err.message);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
// GET /tunnel/setup/cf-tunnels
|
|
3182
|
+
if (method === "GET" && reqPath === "/tunnel/setup/cf-tunnels") {
|
|
3183
|
+
if (lockedGuard(res)) return;
|
|
3184
|
+
try {
|
|
3185
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
3186
|
+
const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
|
|
3187
|
+
const cfToken = cr?.value;
|
|
3188
|
+
if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
|
|
3189
|
+
|
|
3190
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3191
|
+
const sbKey = api.getAnonKey();
|
|
3192
|
+
const acctResp = await fetch(
|
|
3193
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
|
|
3194
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
3195
|
+
);
|
|
3196
|
+
const acctRows = await acctResp.json();
|
|
3197
|
+
const accountId = acctRows?.[0]?.value ? JSON.parse(acctRows[0].value) : null;
|
|
3198
|
+
if (!accountId) return strike(res, 400, "No account ID — run cf-token first");
|
|
3199
|
+
|
|
3200
|
+
const tr = await fetch(
|
|
3201
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
|
|
3202
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
|
|
3203
|
+
);
|
|
3204
|
+
const td = await tr.json();
|
|
3205
|
+
return ok(res, { tunnels: (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status, created_at: t.created_at })) });
|
|
3206
|
+
} catch (err) {
|
|
3207
|
+
return strike(res, 502, err.message);
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
// POST /tunnel/setup/cf-save
|
|
3212
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-save") {
|
|
3213
|
+
if (lockedGuard(res)) return;
|
|
3214
|
+
let body;
|
|
3215
|
+
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
3216
|
+
const { tunnelId, tunnelName, hostname } = body;
|
|
3217
|
+
if (!hostname) return strike(res, 400, "hostname required");
|
|
3218
|
+
|
|
3219
|
+
try {
|
|
3220
|
+
const cfDir = path.join(os.homedir(), ".cloudflared");
|
|
3221
|
+
if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
|
|
3222
|
+
const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : path.join(cfDir, "tunnel.json");
|
|
3223
|
+
const configContent = `tunnel: ${tunnelId || tunnelName || "clauth"}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
|
|
3224
|
+
fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
|
|
3225
|
+
|
|
3226
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3227
|
+
const sbKey = api.getAnonKey();
|
|
3228
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3229
|
+
method: "POST",
|
|
3230
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3231
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
tunnelHostname = hostname;
|
|
3235
|
+
tunnelUrl = `https://${hostname}`;
|
|
3236
|
+
|
|
3237
|
+
return ok(res, { ok: true, hostname });
|
|
3238
|
+
} catch (err) {
|
|
3239
|
+
return strike(res, 502, err.message);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
// POST /tunnel/setup/cf-create-api — create tunnel via CF API (no cloudflared login needed)
|
|
3244
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-create-api") {
|
|
3245
|
+
if (lockedGuard(res)) return;
|
|
3246
|
+
let body;
|
|
3247
|
+
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
3248
|
+
const { name, hostname, accountId: bodyAccountId } = body;
|
|
3249
|
+
if (!name || !hostname) return strike(res, 400, "name and hostname required");
|
|
3250
|
+
try {
|
|
3251
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
3252
|
+
const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
|
|
3253
|
+
const cfToken = cr?.value;
|
|
3254
|
+
if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
|
|
3255
|
+
|
|
3256
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3257
|
+
const sbKey = api.getAnonKey();
|
|
3258
|
+
|
|
3259
|
+
// Get accountId
|
|
3260
|
+
let accountId = bodyAccountId;
|
|
3261
|
+
if (!accountId) {
|
|
3262
|
+
const acctResp = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
|
|
3263
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
|
|
3264
|
+
const rows = await acctResp.json();
|
|
3265
|
+
accountId = rows?.[0]?.value ? JSON.parse(rows[0].value) : null;
|
|
3266
|
+
}
|
|
3267
|
+
if (!accountId) return strike(res, 400, "No account ID — verify CF token first");
|
|
3268
|
+
|
|
3269
|
+
// Generate tunnel secret (32 random bytes base64)
|
|
3270
|
+
const { randomBytes } = await import("crypto");
|
|
3271
|
+
const tunnelSecret = randomBytes(32).toString("base64");
|
|
3272
|
+
|
|
3273
|
+
// Create tunnel via CF API
|
|
3274
|
+
const createResp = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
|
|
3275
|
+
method: "POST",
|
|
3276
|
+
headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
|
|
3277
|
+
body: JSON.stringify({ name, tunnel_secret: tunnelSecret }),
|
|
3278
|
+
signal: AbortSignal.timeout(10000),
|
|
3279
|
+
});
|
|
3280
|
+
const createData = await createResp.json();
|
|
3281
|
+
if (!createData?.success) return strike(res, 400, createData?.errors?.[0]?.message || "Tunnel creation failed");
|
|
3282
|
+
const tunnelId = createData.result.id;
|
|
3283
|
+
|
|
3284
|
+
// Write credentials file
|
|
3285
|
+
const cfDir = path.join(os.homedir(), ".cloudflared");
|
|
3286
|
+
if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
|
|
3287
|
+
const credFile = path.join(cfDir, `${tunnelId}.json`);
|
|
3288
|
+
fs.writeFileSync(credFile, JSON.stringify({ AccountTag: accountId, TunnelID: tunnelId, TunnelName: name, TunnelSecret: tunnelSecret }), "utf8");
|
|
3289
|
+
|
|
3290
|
+
// Write config.yml
|
|
3291
|
+
const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
|
|
3292
|
+
fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
|
|
3293
|
+
|
|
3294
|
+
// Route DNS via CF API — find zone for hostname
|
|
3295
|
+
const domain = hostname.split(".").slice(-2).join(".");
|
|
3296
|
+
const zoneResp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${domain}`,
|
|
3297
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
|
|
3298
|
+
const zoneData = await zoneResp.json();
|
|
3299
|
+
const zoneId = zoneData?.result?.[0]?.id;
|
|
3300
|
+
if (zoneId) {
|
|
3301
|
+
await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
|
|
3302
|
+
method: "POST",
|
|
3303
|
+
headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
|
|
3304
|
+
body: JSON.stringify({ type: "CNAME", name: hostname, content: `${tunnelId}.cfargotunnel.com`, proxied: false, ttl: 1 }),
|
|
3305
|
+
signal: AbortSignal.timeout(8000),
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// Save hostname to DB and in-process
|
|
3310
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3311
|
+
method: "POST",
|
|
3312
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3313
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
3314
|
+
});
|
|
3315
|
+
tunnelHostname = hostname;
|
|
3316
|
+
tunnelUrl = `https://${hostname}`;
|
|
3317
|
+
|
|
3318
|
+
return ok(res, { ok: true, tunnelId, hostname });
|
|
3319
|
+
} catch (err) {
|
|
3320
|
+
return strike(res, 502, err.message);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// GET /tunnel/setup/check-cloudflared
|
|
3325
|
+
if (method === "GET" && reqPath === "/tunnel/setup/check-cloudflared") {
|
|
3326
|
+
let cfBin = "cloudflared";
|
|
3327
|
+
let installed = false;
|
|
3328
|
+
if (os.platform() === "win32") {
|
|
3329
|
+
const candidates = [
|
|
3330
|
+
"cloudflared",
|
|
3331
|
+
path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
|
|
3332
|
+
path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
|
|
3333
|
+
path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
|
|
3334
|
+
"C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
|
|
3335
|
+
];
|
|
3336
|
+
for (const c of candidates) {
|
|
3337
|
+
try { if (fs.statSync(c).isFile()) { cfBin = c; installed = true; break; } } catch {}
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
if (!installed) {
|
|
3341
|
+
try {
|
|
3342
|
+
const { execSync: es } = await import("child_process");
|
|
3343
|
+
es("cloudflared --version", { stdio: "ignore" });
|
|
3344
|
+
installed = true; cfBin = "cloudflared";
|
|
3345
|
+
} catch {}
|
|
3346
|
+
}
|
|
3347
|
+
return ok(res, { installed, path: installed ? cfBin : null });
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
// POST /tunnel/setup/cf-login — SSE stream of cloudflared tunnel login
|
|
3351
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-login") {
|
|
3352
|
+
if (lockedGuard(res)) return;
|
|
3353
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
|
|
3354
|
+
const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
3355
|
+
try {
|
|
3356
|
+
const { spawn } = await import("child_process");
|
|
3357
|
+
const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"] });
|
|
3358
|
+
proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
3359
|
+
proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
3360
|
+
proc.on("close", code => { sendEvt({ done: true, code }); res.end(); });
|
|
3361
|
+
req.on("close", () => { try { proc.kill(); } catch {} });
|
|
3362
|
+
} catch (err) {
|
|
3363
|
+
sendEvt({ done: true, code: 1, error: err.message });
|
|
3364
|
+
res.end();
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
// POST /tunnel/setup/cf-create — SSE stream create + route DNS + save
|
|
3369
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-create") {
|
|
3370
|
+
if (lockedGuard(res)) return;
|
|
3371
|
+
let body;
|
|
3372
|
+
try { body = await readBody(req); } catch {
|
|
3373
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3374
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
3375
|
+
}
|
|
3376
|
+
const { name, hostname } = body;
|
|
3377
|
+
if (!name || !hostname) {
|
|
3378
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3379
|
+
return res.end(JSON.stringify({ error: "name and hostname required" }));
|
|
3380
|
+
}
|
|
3381
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
|
|
3382
|
+
const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
3383
|
+
try {
|
|
3384
|
+
const { spawn } = await import("child_process");
|
|
3385
|
+
|
|
3386
|
+
// Step 1: create tunnel
|
|
3387
|
+
sendEvt({ line: `Creating tunnel "${name}"…`, step: 1 });
|
|
3388
|
+
let tunnelId = null;
|
|
3389
|
+
await new Promise((resolve, reject) => {
|
|
3390
|
+
const proc = spawn("cloudflared", ["tunnel", "create", name], { stdio: ["ignore","pipe","pipe"] });
|
|
3391
|
+
let output = "";
|
|
3392
|
+
proc.stdout.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
|
|
3393
|
+
proc.stderr.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
|
|
3394
|
+
proc.on("close", code => {
|
|
3395
|
+
const m = output.match(/Created tunnel .+ with id ([a-f0-9-]{36})/i);
|
|
3396
|
+
if (m) tunnelId = m[1];
|
|
3397
|
+
if (code !== 0 && !tunnelId) reject(new Error(`create failed (exit ${code})`));
|
|
3398
|
+
else resolve();
|
|
3399
|
+
});
|
|
3400
|
+
});
|
|
3401
|
+
|
|
3402
|
+
if (!tunnelId) {
|
|
3403
|
+
sendEvt({ error: "Could not parse tunnel ID from cloudflared output" });
|
|
3404
|
+
return res.end();
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
// Step 2: route DNS
|
|
3408
|
+
sendEvt({ line: `Routing DNS: ${hostname}…`, step: 2 });
|
|
3409
|
+
await new Promise((resolve) => {
|
|
3410
|
+
const proc = spawn("cloudflared", ["tunnel", "route", "dns", name, hostname], { stdio: ["ignore","pipe","pipe"] });
|
|
3411
|
+
proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
|
|
3412
|
+
proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
|
|
3413
|
+
proc.on("close", () => resolve());
|
|
3414
|
+
});
|
|
3415
|
+
|
|
3416
|
+
// Step 3: save config
|
|
3417
|
+
const cfDir = path.join(os.homedir(), ".cloudflared");
|
|
3418
|
+
if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
|
|
3419
|
+
const credFile = path.join(cfDir, `${tunnelId}.json`);
|
|
3420
|
+
const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
|
|
3421
|
+
fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
|
|
3422
|
+
|
|
3423
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3424
|
+
const sbKey = api.getAnonKey();
|
|
3425
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3426
|
+
method: "POST",
|
|
3427
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3428
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
3429
|
+
});
|
|
3430
|
+
tunnelHostname = hostname;
|
|
3431
|
+
tunnelUrl = `https://${hostname}`;
|
|
3432
|
+
|
|
3433
|
+
sendEvt({ done: true, tunnelId, hostname });
|
|
3434
|
+
res.end();
|
|
3435
|
+
} catch (err) {
|
|
3436
|
+
sendEvt({ error: err.message, done: true });
|
|
3437
|
+
res.end();
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
|
|
2382
3441
|
// POST /change-pw — change master password (must be unlocked)
|
|
2383
3442
|
if (method === "POST" && reqPath === "/change-pw") {
|
|
2384
3443
|
if (lockedGuard(res)) return;
|
|
@@ -2537,19 +3596,36 @@ async function verifyAuth(password) {
|
|
|
2537
3596
|
}
|
|
2538
3597
|
|
|
2539
3598
|
async function actionStart(opts) {
|
|
2540
|
-
const
|
|
3599
|
+
const isStaged = !!opts.staged || process.env.__CLAUTH_STAGED === "1";
|
|
3600
|
+
const port = isStaged ? STAGED_PORT : parseInt(opts.port || String(LIVE_PORT), 10);
|
|
2541
3601
|
const password = opts.pw;
|
|
2542
3602
|
const tunnelHostname = opts.tunnel || null;
|
|
2543
3603
|
const whitelist = opts.services
|
|
2544
3604
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
2545
3605
|
: null;
|
|
2546
3606
|
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
3607
|
+
if (isStaged) {
|
|
3608
|
+
// Staged mode: allow running alongside live instance
|
|
3609
|
+
const existingStaged = readStagedPid();
|
|
3610
|
+
if (existingStaged) {
|
|
3611
|
+
try {
|
|
3612
|
+
const resp = await fetch(`http://127.0.0.1:${existingStaged.port}/ping`);
|
|
3613
|
+
if (resp.ok) {
|
|
3614
|
+
console.log(chalk.yellow(`\n Staged instance already running (PID ${existingStaged.pid}, port ${existingStaged.port})`));
|
|
3615
|
+
console.log(chalk.gray(` Open http://127.0.0.1:${existingStaged.port} to verify, then click Make Live\n`));
|
|
3616
|
+
process.exit(1);
|
|
3617
|
+
}
|
|
3618
|
+
} catch {}
|
|
3619
|
+
removeStagedPid();
|
|
3620
|
+
}
|
|
3621
|
+
} else {
|
|
3622
|
+
// Normal mode: check for existing instance
|
|
3623
|
+
const existing = readPid();
|
|
3624
|
+
if (existing && isProcessAlive(existing.pid)) {
|
|
3625
|
+
console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
|
|
3626
|
+
console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
|
|
3627
|
+
process.exit(1);
|
|
3628
|
+
}
|
|
2553
3629
|
}
|
|
2554
3630
|
|
|
2555
3631
|
// If we're the daemon child, run the server directly
|
|
@@ -2565,10 +3641,15 @@ async function actionStart(opts) {
|
|
|
2565
3641
|
}
|
|
2566
3642
|
}
|
|
2567
3643
|
|
|
2568
|
-
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
3644
|
+
const server = createServer(password, whitelist, port, tunnelHostname, isStaged);
|
|
2569
3645
|
server.listen(port, "127.0.0.1", () => {
|
|
2570
|
-
|
|
2571
|
-
|
|
3646
|
+
if (isStaged) {
|
|
3647
|
+
writeStagedPid(process.pid, port);
|
|
3648
|
+
} else {
|
|
3649
|
+
writePid(process.pid, port);
|
|
3650
|
+
}
|
|
3651
|
+
const label = isStaged ? "STAGED" : "LIVE";
|
|
3652
|
+
const msg = `[${new Date().toISOString()}] clauth serve started [${label}] — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
|
|
2572
3653
|
fs.appendFileSync(LOG_FILE, msg);
|
|
2573
3654
|
});
|
|
2574
3655
|
|
|
@@ -2578,7 +3659,10 @@ async function actionStart(opts) {
|
|
|
2578
3659
|
process.exit(1);
|
|
2579
3660
|
});
|
|
2580
3661
|
|
|
2581
|
-
const shutdown = () => {
|
|
3662
|
+
const shutdown = () => {
|
|
3663
|
+
if (isStaged) { removeStagedPid(); } else { removePid(); }
|
|
3664
|
+
process.exit(0);
|
|
3665
|
+
};
|
|
2582
3666
|
process.on("SIGTERM", shutdown);
|
|
2583
3667
|
process.on("SIGINT", shutdown);
|
|
2584
3668
|
return;
|
|
@@ -2610,12 +3694,13 @@ async function actionStart(opts) {
|
|
|
2610
3694
|
if (password) childArgs.push("--pw", password);
|
|
2611
3695
|
if (opts.services) childArgs.push("--services", opts.services);
|
|
2612
3696
|
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
3697
|
+
if (isStaged) childArgs.push("--staged");
|
|
2613
3698
|
|
|
2614
3699
|
const out = fs.openSync(LOG_FILE, "a");
|
|
2615
3700
|
const child = spawn(process.execPath, childArgs, {
|
|
2616
3701
|
detached: true,
|
|
2617
3702
|
stdio: ["ignore", out, out],
|
|
2618
|
-
env: { ...process.env, __CLAUTH_DAEMON: "1" },
|
|
3703
|
+
env: { ...process.env, __CLAUTH_DAEMON: "1", ...(isStaged ? { __CLAUTH_STAGED: "1" } : {}) },
|
|
2619
3704
|
});
|
|
2620
3705
|
child.unref();
|
|
2621
3706
|
|
|
@@ -2630,14 +3715,18 @@ async function actionStart(opts) {
|
|
|
2630
3715
|
} catch {}
|
|
2631
3716
|
}
|
|
2632
3717
|
|
|
2633
|
-
const
|
|
3718
|
+
const readInfo = isStaged ? readStagedPid : readPid;
|
|
3719
|
+
const info = readInfo();
|
|
2634
3720
|
if (started && info) {
|
|
2635
|
-
|
|
3721
|
+
const label = isStaged ? "⚡ STAGED" : "🔐";
|
|
3722
|
+
console.log(chalk.green(`\n ${label} clauth serve started`));
|
|
2636
3723
|
console.log(chalk.gray(` PID: ${info.pid}`));
|
|
2637
3724
|
console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
|
|
2638
3725
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
2639
3726
|
console.log(chalk.gray(` Log: ${LOG_FILE}`));
|
|
2640
|
-
if (
|
|
3727
|
+
if (isStaged) {
|
|
3728
|
+
console.log(chalk.yellow(`\n ⚡ Staged on port ${port} — open dashboard to verify, then click "Make Live"`));
|
|
3729
|
+
} else if (!password) {
|
|
2641
3730
|
console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
|
|
2642
3731
|
}
|
|
2643
3732
|
console.log(chalk.gray(` Stop: clauth serve stop\n`));
|
|
@@ -3428,19 +4517,33 @@ async function installWindows(pw, tunnelHostname, execSync) {
|
|
|
3428
4517
|
].join("\n");
|
|
3429
4518
|
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
3430
4519
|
} else {
|
|
3431
|
-
// ──
|
|
3432
|
-
//
|
|
3433
|
-
//
|
|
3434
|
-
// by running: clauth serve seal
|
|
4520
|
+
// ── Adaptive mode: check for boot.key on each restart ──
|
|
4521
|
+
// If boot.key exists (auto-sealed after browser unlock), decrypt and start unlocked.
|
|
4522
|
+
// If boot.key missing (user clicked Stop, or first run), start locked + open browser.
|
|
3435
4523
|
const psScript = [
|
|
3436
|
-
"# clauth autostart + watchdog (
|
|
3437
|
-
"#
|
|
4524
|
+
"# clauth autostart + watchdog (adaptive: sealed if boot.key exists, locked otherwise)",
|
|
4525
|
+
"# Restarts on crash every 15s. Auto-unlock if DPAPI boot.key available.",
|
|
4526
|
+
"",
|
|
4527
|
+
`$bootKey = '${bootKey}'`,
|
|
3438
4528
|
"",
|
|
3439
4529
|
"while ($true) {",
|
|
3440
4530
|
" try {",
|
|
3441
4531
|
" $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
|
|
3442
4532
|
" } catch {",
|
|
3443
|
-
|
|
4533
|
+
" if (Test-Path $bootKey) {",
|
|
4534
|
+
" # boot.key exists — decrypt via DPAPI and start unlocked",
|
|
4535
|
+
" try {",
|
|
4536
|
+
" $enc = (Get-Content $bootKey -Raw).Trim()",
|
|
4537
|
+
" $pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))",
|
|
4538
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
|
|
4539
|
+
" } catch {",
|
|
4540
|
+
" # DPAPI decrypt failed — start locked",
|
|
4541
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
4542
|
+
" }",
|
|
4543
|
+
" } else {",
|
|
4544
|
+
" # No boot.key — start locked, user enters password in browser",
|
|
4545
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
4546
|
+
" }",
|
|
3444
4547
|
" Start-Sleep -Seconds 5",
|
|
3445
4548
|
" }",
|
|
3446
4549
|
" Start-Sleep -Seconds 15",
|
|
@@ -3449,20 +4552,35 @@ async function installWindows(pw, tunnelHostname, execSync) {
|
|
|
3449
4552
|
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
3450
4553
|
}
|
|
3451
4554
|
|
|
3452
|
-
// Register
|
|
4555
|
+
// Register auto-start — try HKCU\Run first (no admin), fall back to Scheduled Task
|
|
3453
4556
|
const mode = pw ? "sealed — fully unattended" : "locked — browser password entry";
|
|
3454
|
-
const spinner2 = ora(`Registering
|
|
4557
|
+
const spinner2 = ora(`Registering auto-start (${mode})...`).start();
|
|
4558
|
+
const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
|
|
4559
|
+
const regValue = `powershell.exe -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
4560
|
+
let autoStartOk = false;
|
|
4561
|
+
|
|
4562
|
+
// Method 1: HKCU\Run registry key (no elevation needed)
|
|
3455
4563
|
try {
|
|
3456
|
-
const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
|
|
3457
|
-
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
3458
4564
|
execSync(
|
|
3459
|
-
|
|
4565
|
+
`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${TASK_NAME}" /t REG_SZ /d "${regValue}" /f`,
|
|
3460
4566
|
{ encoding: "utf8", stdio: "pipe" }
|
|
3461
4567
|
);
|
|
3462
|
-
spinner2.succeed(chalk.green(`
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
4568
|
+
spinner2.succeed(chalk.green(`Registry auto-start registered (HKCU\\Run)`));
|
|
4569
|
+
autoStartOk = true;
|
|
4570
|
+
} catch {
|
|
4571
|
+
// Method 2: Scheduled Task (needs admin)
|
|
4572
|
+
try {
|
|
4573
|
+
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
4574
|
+
execSync(
|
|
4575
|
+
`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
|
|
4576
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4577
|
+
);
|
|
4578
|
+
spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
|
|
4579
|
+
autoStartOk = true;
|
|
4580
|
+
} catch (err) {
|
|
4581
|
+
spinner2.fail(chalk.yellow(`Auto-start registration failed (non-fatal): ${err.message}`));
|
|
4582
|
+
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
4583
|
+
}
|
|
3466
4584
|
}
|
|
3467
4585
|
|
|
3468
4586
|
// Start the daemon now
|
|
@@ -3711,7 +4829,13 @@ async function uninstallWindows(execSync) {
|
|
|
3711
4829
|
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
3712
4830
|
const psScriptPath = path.join(autostartDir, "autostart.ps1");
|
|
3713
4831
|
|
|
3714
|
-
// Remove
|
|
4832
|
+
// Remove HKCU\Run registry key
|
|
4833
|
+
try {
|
|
4834
|
+
execSync(`reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${TASK_NAME}" /f`, { encoding: "utf8", stdio: "pipe" });
|
|
4835
|
+
console.log(chalk.green(` Removed Registry auto-start: ${TASK_NAME}`));
|
|
4836
|
+
} catch { console.log(chalk.gray(` Registry key not found (already removed): ${TASK_NAME}`)); }
|
|
4837
|
+
|
|
4838
|
+
// Remove scheduled task (legacy)
|
|
3715
4839
|
try {
|
|
3716
4840
|
execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
|
|
3717
4841
|
console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
|
|
@@ -3789,6 +4913,53 @@ async function uninstallLinux(execSync) {
|
|
|
3789
4913
|
}
|
|
3790
4914
|
|
|
3791
4915
|
// ── Export ────────────────────────────────────────────────────
|
|
4916
|
+
// ── Blue-green upgrade: npm update + start staged + open dashboard ──
|
|
4917
|
+
async function actionUpgrade(opts) {
|
|
4918
|
+
console.log(chalk.cyan("\n ⚡ Blue-green upgrade\n"));
|
|
4919
|
+
|
|
4920
|
+
// Step 1: Check current live version
|
|
4921
|
+
const liveInfo = readPid();
|
|
4922
|
+
let liveVersion = "unknown";
|
|
4923
|
+
if (liveInfo) {
|
|
4924
|
+
try {
|
|
4925
|
+
const resp = await fetch(`http://127.0.0.1:${liveInfo.port}/ping`);
|
|
4926
|
+
const data = await resp.json();
|
|
4927
|
+
liveVersion = data.app_version || "unknown";
|
|
4928
|
+
} catch {}
|
|
4929
|
+
}
|
|
4930
|
+
console.log(chalk.gray(` Live version: v${liveVersion} (port ${liveInfo?.port || LIVE_PORT})`));
|
|
4931
|
+
|
|
4932
|
+
// Step 2: npm update
|
|
4933
|
+
const spinner = ora("Updating @lifeaitools/clauth from npm...").start();
|
|
4934
|
+
try {
|
|
4935
|
+
execSyncTop("npm install -g @lifeaitools/clauth@latest", { encoding: "utf8", stdio: "pipe", timeout: 60000 });
|
|
4936
|
+
// Read new version from installed package
|
|
4937
|
+
const newPkg = JSON.parse(fs.readFileSync(
|
|
4938
|
+
path.join(path.dirname(process.execPath), "..", "lib", "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
|
|
4939
|
+
).toString());
|
|
4940
|
+
spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
|
|
4941
|
+
console.log(chalk.gray(` Staged version: v${newPkg.version} (port ${STAGED_PORT})`));
|
|
4942
|
+
} catch (err) {
|
|
4943
|
+
// Try alternate npm global path (Windows)
|
|
4944
|
+
try {
|
|
4945
|
+
const npmGlobal = path.join(os.homedir(), "AppData", "Roaming", "npm");
|
|
4946
|
+
const newPkg = JSON.parse(fs.readFileSync(
|
|
4947
|
+
path.join(npmGlobal, "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
|
|
4948
|
+
).toString());
|
|
4949
|
+
spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
|
|
4950
|
+
} catch {
|
|
4951
|
+
spinner.fail(chalk.yellow(`npm update may have failed: ${err.message}`));
|
|
4952
|
+
console.log(chalk.gray(" Continuing with staged start anyway..."));
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
// Step 3: Start staged instance
|
|
4957
|
+
console.log(chalk.gray(`\n Starting staged instance on port ${STAGED_PORT}...`));
|
|
4958
|
+
opts.staged = true;
|
|
4959
|
+
opts.port = String(STAGED_PORT);
|
|
4960
|
+
return actionStart(opts);
|
|
4961
|
+
}
|
|
4962
|
+
|
|
3792
4963
|
export async function runServe(opts) {
|
|
3793
4964
|
const action = opts.action || "foreground";
|
|
3794
4965
|
|
|
@@ -3801,9 +4972,10 @@ export async function runServe(opts) {
|
|
|
3801
4972
|
case "mcp": return actionMcp(opts);
|
|
3802
4973
|
case "install": return actionInstall(opts);
|
|
3803
4974
|
case "uninstall": return actionUninstall();
|
|
4975
|
+
case "upgrade": return actionUpgrade(opts);
|
|
3804
4976
|
default:
|
|
3805
4977
|
console.log(chalk.red(`\n Unknown serve action: ${action}`));
|
|
3806
|
-
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall\n"));
|
|
4978
|
+
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall | upgrade\n"));
|
|
3807
4979
|
process.exit(1);
|
|
3808
4980
|
}
|
|
3809
4981
|
}
|