@lifeaitools/clauth 1.1.0 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/serve.js +892 -3
- package/cli/index.js +63 -10
- package/package.json +1 -1
- package/cli/commands/tunnel.js +0 -210
package/cli/commands/serve.js
CHANGED
|
@@ -280,6 +280,40 @@ function dashboardHtml(port, whitelist) {
|
|
|
280
280
|
color: rgba(255,255,255,0.6);
|
|
281
281
|
font-size: 12px;
|
|
282
282
|
}
|
|
283
|
+
/* Tunnel setup wizard */
|
|
284
|
+
.wizard-panel{background:#0a1628;border:1px solid #1e3a5f;border-radius:10px;padding:1.5rem;margin-bottom:1.25rem;display:none}
|
|
285
|
+
.wizard-panel.open{display:block}
|
|
286
|
+
.wizard-header{display:flex;align-items:center;gap:10px;margin-bottom:1.25rem}
|
|
287
|
+
.wizard-title{font-size:1rem;font-weight:600;color:#f8fafc;flex:1}
|
|
288
|
+
.wizard-steps{display:flex;gap:6px;margin-bottom:1.5rem;flex-wrap:wrap}
|
|
289
|
+
.wstep{padding:4px 10px;border-radius:20px;font-size:.72rem;font-weight:600;border:1px solid #1e3a5f;color:#475569;background:#0f172a}
|
|
290
|
+
.wstep.active{border-color:#3b82f6;color:#60a5fa;background:rgba(59,130,246,.1)}
|
|
291
|
+
.wstep.done{border-color:#166534;color:#4ade80;background:rgba(74,222,128,.08)}
|
|
292
|
+
.wizard-body{min-height:80px}
|
|
293
|
+
.wizard-foot{display:flex;gap:8px;margin-top:1.25rem;align-items:center}
|
|
294
|
+
.btn-wiz-primary{background:#3b82f6;color:#fff;padding:8px 20px;font-size:.875rem;border-radius:7px;border:none;cursor:pointer;font-weight:600}
|
|
295
|
+
.btn-wiz-primary:hover{background:#2563eb}
|
|
296
|
+
.btn-wiz-primary:disabled{opacity:.4;cursor:not-allowed}
|
|
297
|
+
.btn-wiz-secondary{background:#1e293b;color:#94a3b8;padding:8px 16px;font-size:.875rem;border-radius:7px;border:1px solid #334155;cursor:pointer}
|
|
298
|
+
.btn-wiz-secondary:hover{color:#e2e8f0}
|
|
299
|
+
.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}
|
|
300
|
+
.wiz-input:focus{border-color:#3b82f6}
|
|
301
|
+
.wiz-label{font-size:.8rem;color:#64748b;margin-bottom:4px}
|
|
302
|
+
.wiz-msg{font-size:.82rem;margin-left:auto}
|
|
303
|
+
.wiz-msg.ok{color:#4ade80}.wiz-msg.fail{color:#f87171}
|
|
304
|
+
.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}
|
|
305
|
+
.wiz-tunnel-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
|
306
|
+
.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}
|
|
307
|
+
.wiz-tunnel-item:hover,.wiz-tunnel-item.selected{border-color:#3b82f6;background:rgba(59,130,246,.08)}
|
|
308
|
+
.wiz-tunnel-name{font-weight:600;color:#f8fafc;font-size:.9rem;flex:1}
|
|
309
|
+
.wiz-tunnel-id{font-family:'Courier New',monospace;font-size:.72rem;color:#475569}
|
|
310
|
+
.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)}
|
|
311
|
+
.wiz-desc{font-size:.85rem;color:#94a3b8;line-height:1.6;margin-bottom:.75rem}
|
|
312
|
+
.wiz-link{color:#60a5fa;text-decoration:none;font-size:.82rem}
|
|
313
|
+
.wiz-link:hover{text-decoration:underline}
|
|
314
|
+
.wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
|
|
315
|
+
.wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
|
|
316
|
+
.wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
|
|
283
317
|
</style>
|
|
284
318
|
</head>
|
|
285
319
|
<body>
|
|
@@ -441,6 +475,17 @@ function dashboardHtml(port, whitelist) {
|
|
|
441
475
|
<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
476
|
</div>
|
|
443
477
|
|
|
478
|
+
<div class="wizard-panel" id="wizard-panel">
|
|
479
|
+
<div class="wizard-header">
|
|
480
|
+
<div class="tunnel-dot off" id="wiz-dot" style="width:10px;height:10px;border-radius:50%"></div>
|
|
481
|
+
<div class="wizard-title">claude.ai Tunnel Setup</div>
|
|
482
|
+
<button class="btn-cancel" onclick="closeSetupWizard()">✕ Dismiss</button>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="wizard-steps" id="wizard-steps"></div>
|
|
485
|
+
<div class="wizard-body" id="wizard-body"><p class="loading">Checking setup state…</p></div>
|
|
486
|
+
<div class="wizard-foot" id="wizard-foot"></div>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
444
489
|
<div id="project-tabs" class="project-tabs" style="display:none"></div>
|
|
445
490
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
446
491
|
<div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
|
|
@@ -1193,9 +1238,7 @@ async function toggleTunnel(action) {
|
|
|
1193
1238
|
} catch {}
|
|
1194
1239
|
}
|
|
1195
1240
|
|
|
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
|
-
}
|
|
1241
|
+
function runTunnelSetup() { openSetupWizard(); }
|
|
1199
1242
|
|
|
1200
1243
|
async function testTunnel() {
|
|
1201
1244
|
const liveState = document.querySelector("#tunnel-panel .tunnel-state.live");
|
|
@@ -1267,6 +1310,423 @@ function copyMcp(elId) {
|
|
|
1267
1310
|
}).catch(() => {});
|
|
1268
1311
|
}
|
|
1269
1312
|
|
|
1313
|
+
// ── Tunnel Setup Wizard ─────────────────────
|
|
1314
|
+
let wizStep = null;
|
|
1315
|
+
let wizData = {};
|
|
1316
|
+
|
|
1317
|
+
async function openSetupWizard() {
|
|
1318
|
+
const panel = document.getElementById("wizard-panel");
|
|
1319
|
+
panel.classList.add("open");
|
|
1320
|
+
panel.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1321
|
+
wizStep = "loading";
|
|
1322
|
+
renderWizBody('<p class="loading">Checking setup state…</p>', [], []);
|
|
1323
|
+
|
|
1324
|
+
const state = await apiFetch("/tunnel/setup-state");
|
|
1325
|
+
if (!state) {
|
|
1326
|
+
renderWizBody('<p class="wiz-desc" style="color:#f87171">Could not reach daemon.</p>', [], []);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (state.step === "ready") {
|
|
1331
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1332
|
+
closeSetupWizard();
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
wizData = state;
|
|
1337
|
+
|
|
1338
|
+
if (state.step === "need_cf_token") wizShowCfToken();
|
|
1339
|
+
else if (state.step === "pick_tunnel") wizShowPickTunnel(state.tunnels);
|
|
1340
|
+
else if (state.step === "no_tunnels") wizShowCreateViaCfApi(state.accountId);
|
|
1341
|
+
else wizShowInstallCheck(); // no CF token — needs cloudflared login
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function closeSetupWizard() {
|
|
1345
|
+
document.getElementById("wizard-panel").classList.remove("open");
|
|
1346
|
+
wizStep = null; wizData = {};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function renderWizBody(html, steps, footHtml) {
|
|
1350
|
+
document.getElementById("wizard-body").innerHTML = html;
|
|
1351
|
+
|
|
1352
|
+
const stepLabels = ["CF Token","Tunnel","cloudflared","MCP Setup","Test"];
|
|
1353
|
+
const stepsEl = document.getElementById("wizard-steps");
|
|
1354
|
+
stepsEl.innerHTML = stepLabels.map((label, i) => {
|
|
1355
|
+
const stepKeys = ["need_cf_token","pick_tunnel","check_cf","mcp_setup","test"];
|
|
1356
|
+
const currentIdx = stepKeys.indexOf(wizStep);
|
|
1357
|
+
let cls = "wstep";
|
|
1358
|
+
if (i < currentIdx) cls += " done";
|
|
1359
|
+
else if (i === currentIdx) cls += " active";
|
|
1360
|
+
return \`<div class="\${cls}">\${i < currentIdx ? "✓ " : ""}\${label}</div>\`;
|
|
1361
|
+
}).join("");
|
|
1362
|
+
|
|
1363
|
+
document.getElementById("wizard-foot").innerHTML = footHtml.join("");
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Step: Enter CF API token
|
|
1367
|
+
function wizShowCfToken() {
|
|
1368
|
+
wizStep = "need_cf_token";
|
|
1369
|
+
renderWizBody(\`
|
|
1370
|
+
<div class="wiz-desc">A Cloudflare API token is needed to check for existing tunnels and configure new ones.</div>
|
|
1371
|
+
<div class="wiz-label">Cloudflare API Token</div>
|
|
1372
|
+
<input class="wiz-input" id="wiz-cf-token-input" type="password" placeholder="Paste your CF API token…" autocomplete="off" spellcheck="false">
|
|
1373
|
+
<div style="margin-top:8px">
|
|
1374
|
+
<a class="wiz-link" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">↗ Create token at Cloudflare Dashboard</a>
|
|
1375
|
+
<span style="color:#475569;font-size:.78rem;margin-left:8px">(needs Cloudflare Tunnel: Edit permission)</span>
|
|
1376
|
+
</div>
|
|
1377
|
+
<div class="wiz-msg fail" id="wiz-cf-err" style="display:none;margin-top:8px"></div>
|
|
1378
|
+
\`, [], [
|
|
1379
|
+
\`<button class="btn-wiz-primary" id="wiz-cf-btn" onclick="wizSubmitCfToken()">Verify & Save Token</button>\`,
|
|
1380
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
|
|
1381
|
+
\`<span class="wiz-msg" id="wiz-cf-msg"></span>\`
|
|
1382
|
+
]);
|
|
1383
|
+
|
|
1384
|
+
document.getElementById("wiz-cf-token-input").addEventListener("keydown", e => {
|
|
1385
|
+
if (e.key === "Enter") wizSubmitCfToken();
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async function wizSubmitCfToken() {
|
|
1390
|
+
const input = document.getElementById("wiz-cf-token-input");
|
|
1391
|
+
const btn = document.getElementById("wiz-cf-btn");
|
|
1392
|
+
const errEl = document.getElementById("wiz-cf-err");
|
|
1393
|
+
const token = input.value.trim();
|
|
1394
|
+
if (!token) return;
|
|
1395
|
+
|
|
1396
|
+
btn.disabled = true; btn.textContent = "Verifying…";
|
|
1397
|
+
if (errEl) errEl.style.display = "none";
|
|
1398
|
+
|
|
1399
|
+
const r = await fetch(BASE + "/tunnel/setup/cf-token", {
|
|
1400
|
+
method: "POST",
|
|
1401
|
+
headers: { "Content-Type": "application/json" },
|
|
1402
|
+
body: JSON.stringify({ token }),
|
|
1403
|
+
}).then(r => r.json()).catch(() => null);
|
|
1404
|
+
|
|
1405
|
+
btn.disabled = false; btn.textContent = "Verify & Save Token";
|
|
1406
|
+
|
|
1407
|
+
if (!r || r.error) {
|
|
1408
|
+
if (errEl) { errEl.textContent = r?.error || "Request failed"; errEl.style.display = "block"; }
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
wizData.accountId = r.accountId;
|
|
1413
|
+
const tunnelState = await apiFetch("/tunnel/setup-state");
|
|
1414
|
+
if (tunnelState?.step === "pick_tunnel") wizShowPickTunnel(tunnelState.tunnels);
|
|
1415
|
+
else wizShowInstallCheck();
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Step: Pick existing tunnel
|
|
1419
|
+
function wizShowPickTunnel(tunnels) {
|
|
1420
|
+
wizStep = "pick_tunnel";
|
|
1421
|
+
|
|
1422
|
+
const listHtml = tunnels.map(t => \`
|
|
1423
|
+
<div class="wiz-tunnel-item" id="wti-\${t.id}" onclick="wizSelectTunnel('\${t.id}','\${t.name}',this)">
|
|
1424
|
+
<div style="flex:1">
|
|
1425
|
+
<div class="wiz-tunnel-name">\${t.name}</div>
|
|
1426
|
+
<div class="wiz-tunnel-id">\${t.id}</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
<div class="wiz-tunnel-status">\${t.status || "active"}</div>
|
|
1429
|
+
</div>
|
|
1430
|
+
\`).join("");
|
|
1431
|
+
|
|
1432
|
+
renderWizBody(\`
|
|
1433
|
+
<div class="wiz-desc">Found \${tunnels.length} existing Cloudflare tunnel\${tunnels.length !== 1 ? "s" : ""}. Select one to use, or create a new one.</div>
|
|
1434
|
+
<div class="wiz-tunnel-list">\${listHtml}</div>
|
|
1435
|
+
<div style="margin-top:12px">
|
|
1436
|
+
<div class="wiz-label">Public hostname for selected tunnel (e.g. clauth.yourdomain.com)</div>
|
|
1437
|
+
<input class="wiz-input" id="wiz-hostname-input" type="text" placeholder="clauth.yourdomain.com" spellcheck="false" autocomplete="off">
|
|
1438
|
+
</div>
|
|
1439
|
+
<div class="wiz-msg fail" id="wiz-pick-err" style="display:none;margin-top:8px"></div>
|
|
1440
|
+
\`, [], [
|
|
1441
|
+
\`<button class="btn-wiz-primary" id="wiz-pick-btn" onclick="wizSaveTunnel()">Use Selected Tunnel</button>\`,
|
|
1442
|
+
\`<button class="btn-wiz-secondary" onclick="wizShowInstallCheck()">Create New Tunnel Instead</button>\`,
|
|
1443
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1444
|
+
]);
|
|
1445
|
+
|
|
1446
|
+
window._wizSelectedTunnel = null;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function wizSelectTunnel(id, name, el) {
|
|
1450
|
+
document.querySelectorAll(".wiz-tunnel-item").forEach(e => e.classList.remove("selected"));
|
|
1451
|
+
el.classList.add("selected");
|
|
1452
|
+
window._wizSelectedTunnel = { id, name };
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
async function wizSaveTunnel() {
|
|
1456
|
+
const sel = window._wizSelectedTunnel;
|
|
1457
|
+
const hostname = document.getElementById("wiz-hostname-input")?.value.trim();
|
|
1458
|
+
const errEl = document.getElementById("wiz-pick-err");
|
|
1459
|
+
const btn = document.getElementById("wiz-pick-btn");
|
|
1460
|
+
|
|
1461
|
+
if (!sel) { if (errEl) { errEl.textContent = "Select a tunnel first"; errEl.style.display="block"; } return; }
|
|
1462
|
+
if (!hostname) { if (errEl) { errEl.textContent = "Enter a public hostname"; errEl.style.display="block"; } return; }
|
|
1463
|
+
|
|
1464
|
+
btn.disabled = true; btn.textContent = "Saving…";
|
|
1465
|
+
|
|
1466
|
+
const r = await fetch(BASE + "/tunnel/setup/cf-save", {
|
|
1467
|
+
method: "POST",
|
|
1468
|
+
headers: { "Content-Type": "application/json" },
|
|
1469
|
+
body: JSON.stringify({ tunnelId: sel.id, tunnelName: sel.name, hostname }),
|
|
1470
|
+
}).then(r => r.json()).catch(() => null);
|
|
1471
|
+
|
|
1472
|
+
btn.disabled = false; btn.textContent = "Use Selected Tunnel";
|
|
1473
|
+
|
|
1474
|
+
if (!r?.ok) {
|
|
1475
|
+
if (errEl) { errEl.textContent = r?.error || "Save failed"; errEl.style.display="block"; }
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1480
|
+
wizShowMcpSetup(hostname);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Step: Create tunnel via CF API (CF token already in vault — no cloudflared login needed)
|
|
1484
|
+
function wizShowCreateViaCfApi(accountId) {
|
|
1485
|
+
wizStep = "pick_tunnel";
|
|
1486
|
+
wizData.accountId = accountId;
|
|
1487
|
+
renderWizBody(\`
|
|
1488
|
+
<div class="wiz-desc">No existing tunnels found. Create a new one using your Cloudflare API token.</div>
|
|
1489
|
+
<div class="wiz-label">Tunnel name</div>
|
|
1490
|
+
<input class="wiz-input" id="wiz-api-tname" type="text" value="clauth" spellcheck="false">
|
|
1491
|
+
<div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
|
|
1492
|
+
<input class="wiz-input" id="wiz-api-hostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
|
|
1493
|
+
<div class="wiz-msg fail" id="wiz-api-err" style="display:none;margin-top:8px"></div>
|
|
1494
|
+
\`, [], [
|
|
1495
|
+
\`<button class="btn-wiz-primary" id="wiz-api-btn" onclick="wizRunCreateViaCfApi()">Create Tunnel</button>\`,
|
|
1496
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
|
|
1497
|
+
\`<span class="wiz-msg" id="wiz-api-msg"></span>\`
|
|
1498
|
+
]);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
async function wizRunCreateViaCfApi() {
|
|
1502
|
+
const name = document.getElementById("wiz-api-tname")?.value.trim();
|
|
1503
|
+
const hostname = document.getElementById("wiz-api-hostname")?.value.trim();
|
|
1504
|
+
const btn = document.getElementById("wiz-api-btn");
|
|
1505
|
+
const err = document.getElementById("wiz-api-err");
|
|
1506
|
+
const msg = document.getElementById("wiz-api-msg");
|
|
1507
|
+
if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
|
|
1508
|
+
btn.disabled=true; btn.textContent="Creating…"; if(err) err.style.display="none";
|
|
1509
|
+
if(msg) msg.textContent="Calling Cloudflare API…";
|
|
1510
|
+
const r = await fetch(BASE + "/tunnel/setup/cf-create-api", {
|
|
1511
|
+
method: "POST",
|
|
1512
|
+
headers: { "Content-Type": "application/json" },
|
|
1513
|
+
body: JSON.stringify({ name, hostname, accountId: wizData.accountId }),
|
|
1514
|
+
}).then(r => r.json()).catch(() => null);
|
|
1515
|
+
btn.disabled=false; btn.textContent="Create Tunnel";
|
|
1516
|
+
if (!r?.ok) {
|
|
1517
|
+
if(err){err.textContent = r?.error || "Creation failed"; err.style.display="block";}
|
|
1518
|
+
if(msg) msg.textContent="";
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1522
|
+
wizShowMcpSetup(hostname);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Step: cloudflared install check (for new tunnel creation flow)
|
|
1526
|
+
async function wizShowInstallCheck() {
|
|
1527
|
+
wizStep = "check_cf";
|
|
1528
|
+
renderWizBody('<p class="loading">Checking cloudflared…</p>', [], []);
|
|
1529
|
+
|
|
1530
|
+
const r = await apiFetch("/tunnel/setup/check-cloudflared");
|
|
1531
|
+
|
|
1532
|
+
if (r?.installed) {
|
|
1533
|
+
wizShowCfLogin();
|
|
1534
|
+
} else {
|
|
1535
|
+
renderWizBody(\`
|
|
1536
|
+
<div class="wiz-desc">cloudflared is required to create and run Cloudflare tunnels. It is not currently installed.</div>
|
|
1537
|
+
<div style="display:flex;gap:12px;margin-top:12px;flex-wrap:wrap">
|
|
1538
|
+
<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>
|
|
1539
|
+
<span style="color:#475569;font-size:.8rem;align-self:center">or: <code style="color:#60a5fa">winget install Cloudflare.cloudflared</code></span>
|
|
1540
|
+
</div>
|
|
1541
|
+
<div class="wiz-desc" style="margin-top:12px;font-size:.78rem;color:#475569">After installing, click "Check Again".</div>
|
|
1542
|
+
\`, [], [
|
|
1543
|
+
\`<button class="btn-wiz-primary" onclick="wizShowInstallCheck()">Check Again</button>\`,
|
|
1544
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1545
|
+
]);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Step: cloudflared login (Cloudflare auth via browser)
|
|
1550
|
+
function wizShowCfLogin() {
|
|
1551
|
+
wizStep = "check_cf";
|
|
1552
|
+
renderWizBody(\`
|
|
1553
|
+
<div class="wiz-desc">Authenticate cloudflared with your Cloudflare account. This opens a browser window.</div>
|
|
1554
|
+
<div class="wiz-log" id="wiz-login-log" style="display:none"></div>
|
|
1555
|
+
\`, [], [
|
|
1556
|
+
\`<button class="btn-wiz-primary" id="wiz-login-btn" onclick="wizRunCfLogin()">Open Browser to Authenticate</button>\`,
|
|
1557
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1558
|
+
]);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async function wizRunCfLogin() {
|
|
1562
|
+
const btn = document.getElementById("wiz-login-btn");
|
|
1563
|
+
const log = document.getElementById("wiz-login-log");
|
|
1564
|
+
btn.disabled = true; btn.textContent = "Authenticating…";
|
|
1565
|
+
log.style.display = "block";
|
|
1566
|
+
|
|
1567
|
+
try {
|
|
1568
|
+
const resp = await fetch(BASE + "/tunnel/setup/cf-login", { method: "POST" });
|
|
1569
|
+
const reader = resp.body.getReader();
|
|
1570
|
+
const dec = new TextDecoder();
|
|
1571
|
+
let buf = "";
|
|
1572
|
+
while (true) {
|
|
1573
|
+
const { done, value } = await reader.read();
|
|
1574
|
+
if (done) break;
|
|
1575
|
+
buf += dec.decode(value, { stream: true });
|
|
1576
|
+
const lines = buf.split("\\n");
|
|
1577
|
+
buf = lines.pop();
|
|
1578
|
+
for (const line of lines) {
|
|
1579
|
+
if (!line.startsWith("data:")) continue;
|
|
1580
|
+
try {
|
|
1581
|
+
const d = JSON.parse(line.slice(5).trim());
|
|
1582
|
+
if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
|
|
1583
|
+
if (d.done) {
|
|
1584
|
+
if (d.code === 0) wizShowCreateTunnel();
|
|
1585
|
+
else { btn.disabled=false; btn.textContent="Retry"; }
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
} catch {}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
} catch(e) {
|
|
1592
|
+
log.textContent += "Error: " + e.message;
|
|
1593
|
+
btn.disabled = false; btn.textContent = "Retry";
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Step: Create new tunnel
|
|
1598
|
+
function wizShowCreateTunnel() {
|
|
1599
|
+
wizStep = "check_cf";
|
|
1600
|
+
renderWizBody(\`
|
|
1601
|
+
<div class="wiz-desc">Create a new named Cloudflare tunnel. Give it a name and a public hostname.</div>
|
|
1602
|
+
<div class="wiz-label">Tunnel name (e.g. clauth)</div>
|
|
1603
|
+
<input class="wiz-input" id="wiz-tname" type="text" value="clauth" spellcheck="false">
|
|
1604
|
+
<div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
|
|
1605
|
+
<input class="wiz-input" id="wiz-thostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
|
|
1606
|
+
<div class="wiz-log" id="wiz-create-log" style="display:none;margin-top:10px"></div>
|
|
1607
|
+
<div class="wiz-msg fail" id="wiz-create-err" style="display:none;margin-top:8px"></div>
|
|
1608
|
+
\`, [], [
|
|
1609
|
+
\`<button class="btn-wiz-primary" id="wiz-create-btn" onclick="wizRunCreateTunnel()">Create Tunnel</button>\`,
|
|
1610
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
|
|
1611
|
+
]);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
async function wizRunCreateTunnel() {
|
|
1615
|
+
const name = document.getElementById("wiz-tname")?.value.trim();
|
|
1616
|
+
const hostname = document.getElementById("wiz-thostname")?.value.trim();
|
|
1617
|
+
const btn = document.getElementById("wiz-create-btn");
|
|
1618
|
+
const log = document.getElementById("wiz-create-log");
|
|
1619
|
+
const err = document.getElementById("wiz-create-err");
|
|
1620
|
+
|
|
1621
|
+
if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
|
|
1622
|
+
|
|
1623
|
+
btn.disabled=true; btn.textContent="Creating…";
|
|
1624
|
+
log.style.display="block"; err.style.display="none";
|
|
1625
|
+
|
|
1626
|
+
try {
|
|
1627
|
+
const resp = await fetch(BASE + "/tunnel/setup/cf-create", {
|
|
1628
|
+
method: "POST",
|
|
1629
|
+
headers: { "Content-Type": "application/json" },
|
|
1630
|
+
body: JSON.stringify({ name, hostname }),
|
|
1631
|
+
});
|
|
1632
|
+
const reader = resp.body.getReader();
|
|
1633
|
+
const dec = new TextDecoder();
|
|
1634
|
+
let buf = "";
|
|
1635
|
+
while (true) {
|
|
1636
|
+
const { done, value } = await reader.read();
|
|
1637
|
+
if (done) break;
|
|
1638
|
+
buf += dec.decode(value, { stream: true });
|
|
1639
|
+
const lines = buf.split("\\n");
|
|
1640
|
+
buf = lines.pop();
|
|
1641
|
+
for (const line of lines) {
|
|
1642
|
+
if (!line.startsWith("data:")) continue;
|
|
1643
|
+
try {
|
|
1644
|
+
const d = JSON.parse(line.slice(5).trim());
|
|
1645
|
+
if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
|
|
1646
|
+
if (d.done && d.hostname) {
|
|
1647
|
+
await apiFetch("/tunnel/start", { method: "POST" });
|
|
1648
|
+
wizShowMcpSetup(d.hostname);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
if (d.error) { err.textContent = d.error; err.style.display="block"; btn.disabled=false; btn.textContent="Retry"; return; }
|
|
1652
|
+
} catch {}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
} catch(e) {
|
|
1656
|
+
if(err){err.textContent=e.message;err.style.display="block";}
|
|
1657
|
+
btn.disabled=false; btn.textContent="Retry";
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Step: MCP setup in claude.ai
|
|
1662
|
+
async function wizShowMcpSetup(hostname) {
|
|
1663
|
+
wizStep = "mcp_setup";
|
|
1664
|
+
const sseUrl = \`https://\${hostname}/sse\`;
|
|
1665
|
+
|
|
1666
|
+
const mcpData = await apiFetch("/mcp-setup");
|
|
1667
|
+
|
|
1668
|
+
renderWizBody(\`
|
|
1669
|
+
<div class="wiz-desc">Add clauth as an MCP server in claude.ai settings. Paste these values:</div>
|
|
1670
|
+
<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px">
|
|
1671
|
+
<div class="mcp-row">
|
|
1672
|
+
<span class="mcp-label">URL</span>
|
|
1673
|
+
<span class="mcp-val" id="wiz-mcp-url">\${sseUrl}</span>
|
|
1674
|
+
<button class="mcp-copy" onclick="wizCopy('wiz-mcp-url',this)">copy</button>
|
|
1675
|
+
</div>
|
|
1676
|
+
<div class="mcp-row">
|
|
1677
|
+
<span class="mcp-label">Client ID</span>
|
|
1678
|
+
<span class="mcp-val" id="wiz-mcp-cid">\${mcpData?.clientId || '(unlock required)'}</span>
|
|
1679
|
+
<button class="mcp-copy" onclick="wizCopy('wiz-mcp-cid',this)">copy</button>
|
|
1680
|
+
</div>
|
|
1681
|
+
<div class="mcp-row">
|
|
1682
|
+
<span class="mcp-label">Secret</span>
|
|
1683
|
+
<span class="mcp-val" id="wiz-mcp-sec">\${mcpData?.clientSecret || '(unlock required)'}</span>
|
|
1684
|
+
<button class="mcp-copy" onclick="wizCopy('wiz-mcp-sec',this)">copy</button>
|
|
1685
|
+
</div>
|
|
1686
|
+
</div>
|
|
1687
|
+
<div style="margin-top:10px;font-size:.78rem;color:#64748b">
|
|
1688
|
+
Paste into <a class="wiz-link" href="https://claude.ai/settings/integrations" target="_blank">claude.ai → Settings → Integrations</a>
|
|
1689
|
+
</div>
|
|
1690
|
+
\`, [], [
|
|
1691
|
+
\`<button class="btn-wiz-primary" onclick="window.open('https://claude.ai/settings/integrations','_blank');wizShowTest()">I've Added It — Test Now</button>\`,
|
|
1692
|
+
\`<button class="btn-wiz-secondary" onclick="wizShowTest()">Skip Test</button>\`,
|
|
1693
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Done</button>\`
|
|
1694
|
+
]);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function wizCopy(elId, btn) {
|
|
1698
|
+
const val = document.getElementById(elId)?.textContent?.trim();
|
|
1699
|
+
if (!val) return;
|
|
1700
|
+
navigator.clipboard.writeText(val).then(() => {
|
|
1701
|
+
const orig = btn.textContent;
|
|
1702
|
+
btn.textContent = "✓"; btn.classList.add("ok");
|
|
1703
|
+
setTimeout(() => { btn.textContent = orig; btn.classList.remove("ok"); }, 1500);
|
|
1704
|
+
}).catch(() => {});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Step: Test — verify MCP is working
|
|
1708
|
+
function wizShowTest() {
|
|
1709
|
+
wizStep = "test";
|
|
1710
|
+
renderWizBody(\`
|
|
1711
|
+
<div class="wiz-desc">In a new claude.ai chat, ask Claude to run this MCP tool call to verify the connection:</div>
|
|
1712
|
+
<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">
|
|
1713
|
+
Use the clauth MCP tool: GET /ping — what is the response?
|
|
1714
|
+
</div>
|
|
1715
|
+
<div class="wiz-desc">Claude should report back: <code style="color:#4ade80">{"status":"ok","version":"..."}</code></div>
|
|
1716
|
+
<div class="wiz-test-result" id="wiz-test-result"></div>
|
|
1717
|
+
\`, [], [
|
|
1718
|
+
\`<button class="btn-wiz-primary" onclick="wizMarkTestDone()">✓ It Worked — Done</button>\`,
|
|
1719
|
+
\`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Close</button>\`
|
|
1720
|
+
]);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function wizMarkTestDone() {
|
|
1724
|
+
const r = document.getElementById("wiz-test-result");
|
|
1725
|
+
r.className = "wiz-test-result ok"; r.style.display="block";
|
|
1726
|
+
r.textContent = "✓ Tunnel and MCP are working. claude.ai can now access your vault.";
|
|
1727
|
+
document.getElementById("wizard-foot").innerHTML = \`<button class="btn-wiz-primary" onclick="closeSetupWizard()">Close Setup</button>\`;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1270
1730
|
// ── Build Status ──────────────────────────────────
|
|
1271
1731
|
async function updateBuildStatus() {
|
|
1272
1732
|
try {
|
|
@@ -1614,6 +2074,26 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
1614
2074
|
if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
|
|
1615
2075
|
return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
1616
2076
|
}
|
|
2077
|
+
|
|
2078
|
+
// DB has no hostname — check ~/.cloudflared/config.yml
|
|
2079
|
+
try {
|
|
2080
|
+
const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
|
|
2081
|
+
if (fs.existsSync(cfConfig)) {
|
|
2082
|
+
const yml = fs.readFileSync(cfConfig, "utf8");
|
|
2083
|
+
const hostMatch = yml.match(/^\s*-\s*hostname:\s*(\S+)/m);
|
|
2084
|
+
if (hostMatch) {
|
|
2085
|
+
const hostname = hostMatch[1];
|
|
2086
|
+
// Save to DB so future loads skip this
|
|
2087
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
2088
|
+
method: "POST",
|
|
2089
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
2090
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
2091
|
+
}).catch(() => {});
|
|
2092
|
+
return hostname;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
} catch {}
|
|
2096
|
+
|
|
1617
2097
|
return null;
|
|
1618
2098
|
} catch {
|
|
1619
2099
|
return null;
|
|
@@ -2379,6 +2859,415 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2379
2859
|
}
|
|
2380
2860
|
}
|
|
2381
2861
|
|
|
2862
|
+
// GET /tunnel/setup-state
|
|
2863
|
+
if (method === "GET" && reqPath === "/tunnel/setup-state") {
|
|
2864
|
+
if (lockedGuard(res)) return;
|
|
2865
|
+
try {
|
|
2866
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
2867
|
+
const sbKey = api.getAnonKey();
|
|
2868
|
+
|
|
2869
|
+
// Check for existing hostname in DB
|
|
2870
|
+
const hostnameResp = await fetch(
|
|
2871
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
|
|
2872
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
2873
|
+
);
|
|
2874
|
+
if (hostnameResp.ok) {
|
|
2875
|
+
const rows = await hostnameResp.json();
|
|
2876
|
+
if (rows.length > 0 && rows[0].value && rows[0].value !== "null" && rows[0].value !== '""') {
|
|
2877
|
+
const hn = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
2878
|
+
if (hn) return ok(res, { step: "ready", hostname: hn });
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// Check ~/.cloudflared/config.yml for a previously configured tunnel
|
|
2883
|
+
try {
|
|
2884
|
+
const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
|
|
2885
|
+
if (fs.existsSync(cfConfig)) {
|
|
2886
|
+
const cfYml = fs.readFileSync(cfConfig, "utf8");
|
|
2887
|
+
// Parse hostname from ingress block: " - hostname: <hostname>"
|
|
2888
|
+
const hostMatch = cfYml.match(/^\s*-\s*hostname:\s*(\S+)/m);
|
|
2889
|
+
const tunnelMatch = cfYml.match(/^tunnel:\s*(\S+)/m);
|
|
2890
|
+
if (hostMatch && tunnelMatch) {
|
|
2891
|
+
const localHostname = hostMatch[1];
|
|
2892
|
+
const localTunnelId = tunnelMatch[1];
|
|
2893
|
+
// Save to DB so next load skips this check
|
|
2894
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
2895
|
+
method: "POST",
|
|
2896
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
2897
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(localHostname) }),
|
|
2898
|
+
});
|
|
2899
|
+
tunnelHostname = localHostname;
|
|
2900
|
+
return ok(res, { step: "ready", hostname: localHostname, tunnelId: localTunnelId, source: "local_config" });
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
} catch {}
|
|
2904
|
+
|
|
2905
|
+
// Check for CF token in vault
|
|
2906
|
+
let cfToken = null;
|
|
2907
|
+
try {
|
|
2908
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
2909
|
+
const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
|
|
2910
|
+
if (cr?.value) cfToken = cr.value;
|
|
2911
|
+
} catch {}
|
|
2912
|
+
|
|
2913
|
+
if (!cfToken) return ok(res, { step: "need_cf_token" });
|
|
2914
|
+
|
|
2915
|
+
// Get or fetch account ID
|
|
2916
|
+
let accountId = null;
|
|
2917
|
+
try {
|
|
2918
|
+
const acctResp = await fetch(
|
|
2919
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
|
|
2920
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
2921
|
+
);
|
|
2922
|
+
if (acctResp.ok) {
|
|
2923
|
+
const rows = await acctResp.json();
|
|
2924
|
+
if (rows.length > 0 && rows[0].value) {
|
|
2925
|
+
accountId = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
} catch {}
|
|
2929
|
+
|
|
2930
|
+
if (!accountId) {
|
|
2931
|
+
try {
|
|
2932
|
+
const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
|
|
2933
|
+
headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
|
|
2934
|
+
});
|
|
2935
|
+
const ad = await ar.json();
|
|
2936
|
+
accountId = ad?.result?.[0]?.id;
|
|
2937
|
+
if (accountId) {
|
|
2938
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
2939
|
+
method: "POST",
|
|
2940
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
2941
|
+
body: JSON.stringify({ key: "cf_account_id", value: accountId }),
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
} catch {}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
if (!accountId) return ok(res, { step: "setup_wizard", error: "Could not get Cloudflare account ID" });
|
|
2948
|
+
|
|
2949
|
+
// List tunnels
|
|
2950
|
+
try {
|
|
2951
|
+
const tr = await fetch(
|
|
2952
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
|
|
2953
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
|
|
2954
|
+
);
|
|
2955
|
+
const td = await tr.json();
|
|
2956
|
+
const tunnels = (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status }));
|
|
2957
|
+
if (tunnels.length > 0) return ok(res, { step: "pick_tunnel", tunnels, accountId });
|
|
2958
|
+
// CF token exists but no tunnels — create via API (no cloudflared login needed)
|
|
2959
|
+
return ok(res, { step: "no_tunnels", accountId });
|
|
2960
|
+
} catch {}
|
|
2961
|
+
|
|
2962
|
+
return ok(res, { step: "setup_wizard" });
|
|
2963
|
+
} catch (err) {
|
|
2964
|
+
return ok(res, { step: "setup_wizard", error: err.message });
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// POST /tunnel/setup/cf-token
|
|
2969
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
|
|
2970
|
+
if (lockedGuard(res)) return;
|
|
2971
|
+
let body;
|
|
2972
|
+
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
2973
|
+
const { token: cfToken } = body;
|
|
2974
|
+
if (!cfToken) return strike(res, 400, "token required");
|
|
2975
|
+
|
|
2976
|
+
try {
|
|
2977
|
+
const vr = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
|
|
2978
|
+
headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
|
|
2979
|
+
});
|
|
2980
|
+
const vd = await vr.json();
|
|
2981
|
+
if (!vd?.success) return strike(res, 400, "Invalid Cloudflare API token");
|
|
2982
|
+
|
|
2983
|
+
const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
|
|
2984
|
+
headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
|
|
2985
|
+
});
|
|
2986
|
+
const ad = await ar.json();
|
|
2987
|
+
const accountId = ad?.result?.[0]?.id;
|
|
2988
|
+
const accountName = ad?.result?.[0]?.name;
|
|
2989
|
+
|
|
2990
|
+
// Save token to vault using api.write (same as /set/:service)
|
|
2991
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
2992
|
+
await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
|
|
2993
|
+
|
|
2994
|
+
// Save accountId to clauth_config
|
|
2995
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
2996
|
+
const sbKey = api.getAnonKey();
|
|
2997
|
+
if (accountId) {
|
|
2998
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
2999
|
+
method: "POST",
|
|
3000
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3001
|
+
body: JSON.stringify({ key: "cf_account_id", value: accountId }),
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
return ok(res, { ok: true, accountId, accountName });
|
|
3006
|
+
} catch (err) {
|
|
3007
|
+
return strike(res, 502, err.message);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
// GET /tunnel/setup/cf-tunnels
|
|
3012
|
+
if (method === "GET" && reqPath === "/tunnel/setup/cf-tunnels") {
|
|
3013
|
+
if (lockedGuard(res)) return;
|
|
3014
|
+
try {
|
|
3015
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
3016
|
+
const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
|
|
3017
|
+
const cfToken = cr?.value;
|
|
3018
|
+
if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
|
|
3019
|
+
|
|
3020
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3021
|
+
const sbKey = api.getAnonKey();
|
|
3022
|
+
const acctResp = await fetch(
|
|
3023
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
|
|
3024
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
3025
|
+
);
|
|
3026
|
+
const acctRows = await acctResp.json();
|
|
3027
|
+
const accountId = acctRows?.[0]?.value ? JSON.parse(acctRows[0].value) : null;
|
|
3028
|
+
if (!accountId) return strike(res, 400, "No account ID — run cf-token first");
|
|
3029
|
+
|
|
3030
|
+
const tr = await fetch(
|
|
3031
|
+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
|
|
3032
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
|
|
3033
|
+
);
|
|
3034
|
+
const td = await tr.json();
|
|
3035
|
+
return ok(res, { tunnels: (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status, created_at: t.created_at })) });
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
return strike(res, 502, err.message);
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// POST /tunnel/setup/cf-save
|
|
3042
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-save") {
|
|
3043
|
+
if (lockedGuard(res)) return;
|
|
3044
|
+
let body;
|
|
3045
|
+
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
3046
|
+
const { tunnelId, tunnelName, hostname } = body;
|
|
3047
|
+
if (!hostname) return strike(res, 400, "hostname required");
|
|
3048
|
+
|
|
3049
|
+
try {
|
|
3050
|
+
const cfDir = path.join(os.homedir(), ".cloudflared");
|
|
3051
|
+
if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
|
|
3052
|
+
const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : path.join(cfDir, "tunnel.json");
|
|
3053
|
+
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`;
|
|
3054
|
+
fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
|
|
3055
|
+
|
|
3056
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3057
|
+
const sbKey = api.getAnonKey();
|
|
3058
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3059
|
+
method: "POST",
|
|
3060
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3061
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
3062
|
+
});
|
|
3063
|
+
|
|
3064
|
+
tunnelHostname = hostname;
|
|
3065
|
+
tunnelUrl = `https://${hostname}`;
|
|
3066
|
+
|
|
3067
|
+
return ok(res, { ok: true, hostname });
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
return strike(res, 502, err.message);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// POST /tunnel/setup/cf-create-api — create tunnel via CF API (no cloudflared login needed)
|
|
3074
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-create-api") {
|
|
3075
|
+
if (lockedGuard(res)) return;
|
|
3076
|
+
let body;
|
|
3077
|
+
try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
|
|
3078
|
+
const { name, hostname, accountId: bodyAccountId } = body;
|
|
3079
|
+
if (!name || !hostname) return strike(res, 400, "name and hostname required");
|
|
3080
|
+
try {
|
|
3081
|
+
const { token: t, timestamp } = deriveToken(password, machineHash);
|
|
3082
|
+
const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
|
|
3083
|
+
const cfToken = cr?.value;
|
|
3084
|
+
if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
|
|
3085
|
+
|
|
3086
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3087
|
+
const sbKey = api.getAnonKey();
|
|
3088
|
+
|
|
3089
|
+
// Get accountId
|
|
3090
|
+
let accountId = bodyAccountId;
|
|
3091
|
+
if (!accountId) {
|
|
3092
|
+
const acctResp = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
|
|
3093
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
|
|
3094
|
+
const rows = await acctResp.json();
|
|
3095
|
+
accountId = rows?.[0]?.value ? JSON.parse(rows[0].value) : null;
|
|
3096
|
+
}
|
|
3097
|
+
if (!accountId) return strike(res, 400, "No account ID — verify CF token first");
|
|
3098
|
+
|
|
3099
|
+
// Generate tunnel secret (32 random bytes base64)
|
|
3100
|
+
const { randomBytes } = await import("crypto");
|
|
3101
|
+
const tunnelSecret = randomBytes(32).toString("base64");
|
|
3102
|
+
|
|
3103
|
+
// Create tunnel via CF API
|
|
3104
|
+
const createResp = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
|
|
3105
|
+
method: "POST",
|
|
3106
|
+
headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
|
|
3107
|
+
body: JSON.stringify({ name, tunnel_secret: tunnelSecret }),
|
|
3108
|
+
signal: AbortSignal.timeout(10000),
|
|
3109
|
+
});
|
|
3110
|
+
const createData = await createResp.json();
|
|
3111
|
+
if (!createData?.success) return strike(res, 400, createData?.errors?.[0]?.message || "Tunnel creation failed");
|
|
3112
|
+
const tunnelId = createData.result.id;
|
|
3113
|
+
|
|
3114
|
+
// Write credentials file
|
|
3115
|
+
const cfDir = path.join(os.homedir(), ".cloudflared");
|
|
3116
|
+
if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
|
|
3117
|
+
const credFile = path.join(cfDir, `${tunnelId}.json`);
|
|
3118
|
+
fs.writeFileSync(credFile, JSON.stringify({ AccountTag: accountId, TunnelID: tunnelId, TunnelName: name, TunnelSecret: tunnelSecret }), "utf8");
|
|
3119
|
+
|
|
3120
|
+
// Write config.yml
|
|
3121
|
+
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`;
|
|
3122
|
+
fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
|
|
3123
|
+
|
|
3124
|
+
// Route DNS via CF API — find zone for hostname
|
|
3125
|
+
const domain = hostname.split(".").slice(-2).join(".");
|
|
3126
|
+
const zoneResp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${domain}`,
|
|
3127
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
|
|
3128
|
+
const zoneData = await zoneResp.json();
|
|
3129
|
+
const zoneId = zoneData?.result?.[0]?.id;
|
|
3130
|
+
if (zoneId) {
|
|
3131
|
+
await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
|
|
3132
|
+
method: "POST",
|
|
3133
|
+
headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
|
|
3134
|
+
body: JSON.stringify({ type: "CNAME", name: hostname, content: `${tunnelId}.cfargotunnel.com`, proxied: false, ttl: 1 }),
|
|
3135
|
+
signal: AbortSignal.timeout(8000),
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
// Save hostname to DB and in-process
|
|
3140
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3141
|
+
method: "POST",
|
|
3142
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3143
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
3144
|
+
});
|
|
3145
|
+
tunnelHostname = hostname;
|
|
3146
|
+
tunnelUrl = `https://${hostname}`;
|
|
3147
|
+
|
|
3148
|
+
return ok(res, { ok: true, tunnelId, hostname });
|
|
3149
|
+
} catch (err) {
|
|
3150
|
+
return strike(res, 502, err.message);
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// GET /tunnel/setup/check-cloudflared
|
|
3155
|
+
if (method === "GET" && reqPath === "/tunnel/setup/check-cloudflared") {
|
|
3156
|
+
let cfBin = "cloudflared";
|
|
3157
|
+
let installed = false;
|
|
3158
|
+
if (os.platform() === "win32") {
|
|
3159
|
+
const candidates = [
|
|
3160
|
+
"cloudflared",
|
|
3161
|
+
path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
|
|
3162
|
+
path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
|
|
3163
|
+
path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
|
|
3164
|
+
"C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
|
|
3165
|
+
];
|
|
3166
|
+
for (const c of candidates) {
|
|
3167
|
+
try { if (fs.statSync(c).isFile()) { cfBin = c; installed = true; break; } } catch {}
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
if (!installed) {
|
|
3171
|
+
try {
|
|
3172
|
+
const { execSync: es } = await import("child_process");
|
|
3173
|
+
es("cloudflared --version", { stdio: "ignore" });
|
|
3174
|
+
installed = true; cfBin = "cloudflared";
|
|
3175
|
+
} catch {}
|
|
3176
|
+
}
|
|
3177
|
+
return ok(res, { installed, path: installed ? cfBin : null });
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// POST /tunnel/setup/cf-login — SSE stream of cloudflared tunnel login
|
|
3181
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-login") {
|
|
3182
|
+
if (lockedGuard(res)) return;
|
|
3183
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
|
|
3184
|
+
const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
3185
|
+
try {
|
|
3186
|
+
const { spawn } = await import("child_process");
|
|
3187
|
+
const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"] });
|
|
3188
|
+
proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
3189
|
+
proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
|
|
3190
|
+
proc.on("close", code => { sendEvt({ done: true, code }); res.end(); });
|
|
3191
|
+
req.on("close", () => { try { proc.kill(); } catch {} });
|
|
3192
|
+
} catch (err) {
|
|
3193
|
+
sendEvt({ done: true, code: 1, error: err.message });
|
|
3194
|
+
res.end();
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
// POST /tunnel/setup/cf-create — SSE stream create + route DNS + save
|
|
3199
|
+
if (method === "POST" && reqPath === "/tunnel/setup/cf-create") {
|
|
3200
|
+
if (lockedGuard(res)) return;
|
|
3201
|
+
let body;
|
|
3202
|
+
try { body = await readBody(req); } catch {
|
|
3203
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3204
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
3205
|
+
}
|
|
3206
|
+
const { name, hostname } = body;
|
|
3207
|
+
if (!name || !hostname) {
|
|
3208
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3209
|
+
return res.end(JSON.stringify({ error: "name and hostname required" }));
|
|
3210
|
+
}
|
|
3211
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
|
|
3212
|
+
const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
3213
|
+
try {
|
|
3214
|
+
const { spawn } = await import("child_process");
|
|
3215
|
+
|
|
3216
|
+
// Step 1: create tunnel
|
|
3217
|
+
sendEvt({ line: `Creating tunnel "${name}"…`, step: 1 });
|
|
3218
|
+
let tunnelId = null;
|
|
3219
|
+
await new Promise((resolve, reject) => {
|
|
3220
|
+
const proc = spawn("cloudflared", ["tunnel", "create", name], { stdio: ["ignore","pipe","pipe"] });
|
|
3221
|
+
let output = "";
|
|
3222
|
+
proc.stdout.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
|
|
3223
|
+
proc.stderr.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
|
|
3224
|
+
proc.on("close", code => {
|
|
3225
|
+
const m = output.match(/Created tunnel .+ with id ([a-f0-9-]{36})/i);
|
|
3226
|
+
if (m) tunnelId = m[1];
|
|
3227
|
+
if (code !== 0 && !tunnelId) reject(new Error(`create failed (exit ${code})`));
|
|
3228
|
+
else resolve();
|
|
3229
|
+
});
|
|
3230
|
+
});
|
|
3231
|
+
|
|
3232
|
+
if (!tunnelId) {
|
|
3233
|
+
sendEvt({ error: "Could not parse tunnel ID from cloudflared output" });
|
|
3234
|
+
return res.end();
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// Step 2: route DNS
|
|
3238
|
+
sendEvt({ line: `Routing DNS: ${hostname}…`, step: 2 });
|
|
3239
|
+
await new Promise((resolve) => {
|
|
3240
|
+
const proc = spawn("cloudflared", ["tunnel", "route", "dns", name, hostname], { stdio: ["ignore","pipe","pipe"] });
|
|
3241
|
+
proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
|
|
3242
|
+
proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
|
|
3243
|
+
proc.on("close", () => resolve());
|
|
3244
|
+
});
|
|
3245
|
+
|
|
3246
|
+
// Step 3: save config
|
|
3247
|
+
const cfDir = path.join(os.homedir(), ".cloudflared");
|
|
3248
|
+
if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
|
|
3249
|
+
const credFile = path.join(cfDir, `${tunnelId}.json`);
|
|
3250
|
+
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`;
|
|
3251
|
+
fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
|
|
3252
|
+
|
|
3253
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3254
|
+
const sbKey = api.getAnonKey();
|
|
3255
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
3256
|
+
method: "POST",
|
|
3257
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
3258
|
+
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
3259
|
+
});
|
|
3260
|
+
tunnelHostname = hostname;
|
|
3261
|
+
tunnelUrl = `https://${hostname}`;
|
|
3262
|
+
|
|
3263
|
+
sendEvt({ done: true, tunnelId, hostname });
|
|
3264
|
+
res.end();
|
|
3265
|
+
} catch (err) {
|
|
3266
|
+
sendEvt({ error: err.message, done: true });
|
|
3267
|
+
res.end();
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
|
|
2382
3271
|
// POST /change-pw — change master password (must be unlocked)
|
|
2383
3272
|
if (method === "POST" && reqPath === "/change-pw") {
|
|
2384
3273
|
if (lockedGuard(res)) return;
|
package/cli/index.js
CHANGED
|
@@ -532,40 +532,93 @@ program
|
|
|
532
532
|
});
|
|
533
533
|
|
|
534
534
|
// ──────────────────────────────────────────────
|
|
535
|
-
// clauth tunnel
|
|
535
|
+
// clauth tunnel start|stop|status
|
|
536
|
+
// (setup moved to in-browser wizard at http://127.0.0.1:52437)
|
|
536
537
|
// ──────────────────────────────────────────────
|
|
537
538
|
const tunnelCmd = program.command("tunnel").description("Manage Cloudflare tunnel for claude.ai web integration");
|
|
538
539
|
|
|
539
540
|
tunnelCmd
|
|
540
541
|
.command("setup")
|
|
541
|
-
.description("
|
|
542
|
+
.description("Open the tunnel setup wizard in your browser")
|
|
542
543
|
.action(async () => {
|
|
543
|
-
|
|
544
|
-
|
|
544
|
+
console.log(chalk.cyan("\n Tunnel setup is now handled in the browser.\n"));
|
|
545
|
+
console.log(chalk.white(" 1. Start the daemon: clauth serve start"));
|
|
546
|
+
console.log(chalk.white(" 2. Open: http://127.0.0.1:52437"));
|
|
547
|
+
console.log(chalk.white(" 3. Unlock the vault and click \"Setup Tunnel\"\n"));
|
|
545
548
|
});
|
|
546
549
|
|
|
547
550
|
tunnelCmd
|
|
548
551
|
.command("start")
|
|
549
552
|
.description("Tell daemon to start the tunnel")
|
|
550
553
|
.action(async () => {
|
|
551
|
-
|
|
552
|
-
|
|
554
|
+
try {
|
|
555
|
+
const r = await fetch("http://127.0.0.1:52437/tunnel/start", {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: { "Content-Type": "application/json" },
|
|
558
|
+
signal: AbortSignal.timeout(5000),
|
|
559
|
+
});
|
|
560
|
+
const data = await r.json().catch(() => ({}));
|
|
561
|
+
if (!r.ok) {
|
|
562
|
+
console.error(` ✗ ${data.error || r.statusText}`);
|
|
563
|
+
if (r.status === 401) console.error(" Unlock the daemon first: http://127.0.0.1:52437");
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
console.log(` ✓ ${data.message || "Tunnel starting — check status with: clauth tunnel status"}`);
|
|
567
|
+
} catch (e) {
|
|
568
|
+
console.error(" ✗ Daemon not running. Start it with: clauth serve");
|
|
569
|
+
process.exit(1);
|
|
570
|
+
}
|
|
553
571
|
});
|
|
554
572
|
|
|
555
573
|
tunnelCmd
|
|
556
574
|
.command("stop")
|
|
557
575
|
.description("Tell daemon to stop the tunnel")
|
|
558
576
|
.action(async () => {
|
|
559
|
-
|
|
560
|
-
|
|
577
|
+
try {
|
|
578
|
+
const r = await fetch("http://127.0.0.1:52437/tunnel/stop", {
|
|
579
|
+
method: "POST",
|
|
580
|
+
headers: { "Content-Type": "application/json" },
|
|
581
|
+
signal: AbortSignal.timeout(5000),
|
|
582
|
+
});
|
|
583
|
+
const data = await r.json().catch(() => ({}));
|
|
584
|
+
if (!r.ok) {
|
|
585
|
+
console.error(` ✗ ${data.error || r.statusText}`);
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
console.log(" ✓ Tunnel stopped.");
|
|
589
|
+
} catch (e) {
|
|
590
|
+
console.error(" ✗ Daemon not running.");
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
561
593
|
});
|
|
562
594
|
|
|
563
595
|
tunnelCmd
|
|
564
596
|
.command("status")
|
|
565
597
|
.description("Show current tunnel status")
|
|
566
598
|
.action(async () => {
|
|
567
|
-
|
|
568
|
-
|
|
599
|
+
try {
|
|
600
|
+
const r = await fetch("http://127.0.0.1:52437/tunnel", {
|
|
601
|
+
signal: AbortSignal.timeout(5000),
|
|
602
|
+
});
|
|
603
|
+
const data = await r.json().catch(() => ({}));
|
|
604
|
+
const icons = {
|
|
605
|
+
live: "✓", starting: "◌", not_configured: "⚠",
|
|
606
|
+
not_started: "○", error: "✗", missing_cloudflared: "✗",
|
|
607
|
+
};
|
|
608
|
+
const labels = {
|
|
609
|
+
live: `Live — ${data.url || ""}`,
|
|
610
|
+
starting: "Starting...",
|
|
611
|
+
not_configured: "Not configured — open http://127.0.0.1:52437 and click Setup Tunnel",
|
|
612
|
+
not_started: "Not started — run: clauth tunnel start",
|
|
613
|
+
error: `Error${data.error ? ": " + data.error : ""} — check cloudflared config`,
|
|
614
|
+
missing_cloudflared: "cloudflared not installed — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
615
|
+
};
|
|
616
|
+
const status = data.status || "unknown";
|
|
617
|
+
console.log(`\n ${icons[status] || "?"} Tunnel: ${labels[status] || status}\n`);
|
|
618
|
+
} catch (e) {
|
|
619
|
+
console.error(" ✗ Daemon not running. Start it with: clauth serve");
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
569
622
|
});
|
|
570
623
|
|
|
571
624
|
// ──────────────────────────────────────────────
|
package/package.json
CHANGED
package/cli/commands/tunnel.js
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
// cli/commands/tunnel.js
|
|
2
|
-
// clauth tunnel setup — configures Cloudflare named tunnel for claude.ai web integration
|
|
3
|
-
|
|
4
|
-
import { execSync, spawnSync } from "child_process";
|
|
5
|
-
import os from "os";
|
|
6
|
-
import path from "path";
|
|
7
|
-
import fs from "fs";
|
|
8
|
-
import readline from "readline";
|
|
9
|
-
|
|
10
|
-
function ask(question) {
|
|
11
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
-
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function actionTunnelSetup() {
|
|
16
|
-
console.log("\n clauth tunnel setup\n");
|
|
17
|
-
console.log(" This wizard configures a Cloudflare named tunnel so claude.ai web");
|
|
18
|
-
console.log(" can connect to your local clauth daemon without exposing any ports.\n");
|
|
19
|
-
|
|
20
|
-
// Step 1: Check cloudflared is installed
|
|
21
|
-
try {
|
|
22
|
-
execSync("cloudflared --version", { stdio: "ignore" });
|
|
23
|
-
console.log(" ✓ cloudflared detected\n");
|
|
24
|
-
} catch {
|
|
25
|
-
console.log(" ✗ cloudflared not found.\n");
|
|
26
|
-
console.log(" Install it first:");
|
|
27
|
-
console.log(" Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
|
|
28
|
-
console.log(" Or via winget: winget install Cloudflare.cloudflared\n");
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Step 2: Authenticate with Cloudflare
|
|
33
|
-
console.log(" Step 1/4 — Authenticate with Cloudflare");
|
|
34
|
-
console.log(" This will open your browser to log in.\n");
|
|
35
|
-
const doAuth = await ask(" Proceed? (y/n): ");
|
|
36
|
-
if (doAuth.toLowerCase() !== "y") { console.log(" Aborted."); process.exit(0); }
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
spawnSync("cloudflared", ["tunnel", "login"], { stdio: "inherit" });
|
|
40
|
-
console.log(" ✓ Authenticated\n");
|
|
41
|
-
} catch (e) {
|
|
42
|
-
console.error(" ✗ Login failed:", e.message);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Step 3: Create tunnel
|
|
47
|
-
console.log(" Step 2/4 — Create named tunnel");
|
|
48
|
-
const tunnelName = await ask(" Tunnel name (default: clauth): ") || "clauth";
|
|
49
|
-
|
|
50
|
-
let tunnelId;
|
|
51
|
-
try {
|
|
52
|
-
const result = execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: "utf8" });
|
|
53
|
-
const match = result.match(/Created tunnel (.+) with id ([a-f0-9-]+)/i);
|
|
54
|
-
if (match) tunnelId = match[2];
|
|
55
|
-
console.log(` ✓ Tunnel created: ${tunnelName} (${tunnelId || "see above"})\n`);
|
|
56
|
-
} catch (e) {
|
|
57
|
-
// Tunnel may already exist
|
|
58
|
-
console.log(" (Tunnel may already exist — continuing)\n");
|
|
59
|
-
try {
|
|
60
|
-
const listResult = execSync(`cloudflared tunnel list`, { encoding: "utf8" });
|
|
61
|
-
const lines = listResult.split("\n");
|
|
62
|
-
for (const line of lines) {
|
|
63
|
-
if (line.toLowerCase().includes(tunnelName.toLowerCase())) {
|
|
64
|
-
const parts = line.trim().split(/\s+/);
|
|
65
|
-
if (parts[0] && parts[0].includes("-")) tunnelId = parts[0];
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
} catch {
|
|
69
|
-
// ignore list errors
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Step 4: Configure hostname
|
|
74
|
-
console.log(" Step 3/4 — Set public hostname");
|
|
75
|
-
const hostname = await ask(" Public hostname (e.g. clauth.yourdomain.com): ");
|
|
76
|
-
if (!hostname) { console.log(" Hostname required."); process.exit(1); }
|
|
77
|
-
|
|
78
|
-
// Write config.yml
|
|
79
|
-
const cfDir = path.join(os.homedir(), ".cloudflared");
|
|
80
|
-
if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
|
|
81
|
-
|
|
82
|
-
const configPath = path.join(cfDir, "config.yml");
|
|
83
|
-
const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : `<tunnel-id>.json`;
|
|
84
|
-
const config = `tunnel: ${tunnelId || tunnelName}
|
|
85
|
-
credentials-file: ${credFile}
|
|
86
|
-
ingress:
|
|
87
|
-
- hostname: ${hostname}
|
|
88
|
-
service: http://127.0.0.1:52437
|
|
89
|
-
- service: http_status:404
|
|
90
|
-
`;
|
|
91
|
-
fs.writeFileSync(configPath, config, "utf8");
|
|
92
|
-
console.log(` ✓ Config written: ${configPath}\n`);
|
|
93
|
-
|
|
94
|
-
// Route DNS
|
|
95
|
-
console.log(" Step 4/4 — Configure DNS");
|
|
96
|
-
try {
|
|
97
|
-
execSync(`cloudflared tunnel route dns ${tunnelName} ${hostname}`, { stdio: "inherit" });
|
|
98
|
-
console.log(` ✓ DNS configured: ${hostname}\n`);
|
|
99
|
-
} catch {
|
|
100
|
-
console.log(` ⚠ DNS route failed — add manually in Cloudflare dashboard:`);
|
|
101
|
-
console.log(` CNAME ${hostname} → ${tunnelId}.cfargotunnel.com\n`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Save hostname to clauth config via api module
|
|
105
|
-
try {
|
|
106
|
-
const apiMod = await import("../api.js").catch(() => null);
|
|
107
|
-
if (apiMod) {
|
|
108
|
-
const sbUrl = (apiMod.getBaseUrl?.() || "").replace("/functions/v1/auth-vault", "");
|
|
109
|
-
const sbKey = apiMod.getAnonKey?.();
|
|
110
|
-
if (sbUrl && sbKey) {
|
|
111
|
-
await fetch(
|
|
112
|
-
`${sbUrl}/rest/v1/clauth_config`,
|
|
113
|
-
{
|
|
114
|
-
method: "POST",
|
|
115
|
-
headers: {
|
|
116
|
-
apikey: sbKey,
|
|
117
|
-
Authorization: `Bearer ${sbKey}`,
|
|
118
|
-
"Content-Type": "application/json",
|
|
119
|
-
Prefer: "resolution=merge-duplicates",
|
|
120
|
-
},
|
|
121
|
-
body: JSON.stringify({ key: "tunnel_hostname", value: hostname }),
|
|
122
|
-
}
|
|
123
|
-
);
|
|
124
|
-
console.log(` ✓ Hostname saved to clauth config: ${hostname}`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
console.log(` ⚠ Could not save to DB. Hostname is set in config.yml — 'clauth serve' will pick it up.`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
console.log("\n Setup complete!");
|
|
132
|
-
console.log(` Restart the daemon: clauth serve restart`);
|
|
133
|
-
console.log(` Or use: npm run worker:restart (from clauth directory)\n`);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// clauth tunnel start — tells daemon to start the tunnel
|
|
137
|
-
async function actionTunnelStart() {
|
|
138
|
-
try {
|
|
139
|
-
const r = await fetch("http://127.0.0.1:52437/tunnel/start", {
|
|
140
|
-
method: "POST",
|
|
141
|
-
headers: { "Content-Type": "application/json" },
|
|
142
|
-
signal: AbortSignal.timeout(5000),
|
|
143
|
-
});
|
|
144
|
-
const data = await r.json().catch(() => ({}));
|
|
145
|
-
if (!r.ok) {
|
|
146
|
-
console.error(` ✗ ${data.error || r.statusText}`);
|
|
147
|
-
if (r.status === 401) console.error(" Unlock the daemon first: http://127.0.0.1:52437");
|
|
148
|
-
process.exit(1);
|
|
149
|
-
}
|
|
150
|
-
console.log(` ✓ ${data.message || "Tunnel starting — check status with: clauth tunnel status"}`);
|
|
151
|
-
} catch (e) {
|
|
152
|
-
console.error(" ✗ Daemon not running. Start it with: clauth serve");
|
|
153
|
-
process.exit(1);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// clauth tunnel stop — tells daemon to stop the tunnel
|
|
158
|
-
async function actionTunnelStop() {
|
|
159
|
-
try {
|
|
160
|
-
const r = await fetch("http://127.0.0.1:52437/tunnel/stop", {
|
|
161
|
-
method: "POST",
|
|
162
|
-
headers: { "Content-Type": "application/json" },
|
|
163
|
-
signal: AbortSignal.timeout(5000),
|
|
164
|
-
});
|
|
165
|
-
const data = await r.json().catch(() => ({}));
|
|
166
|
-
if (!r.ok) {
|
|
167
|
-
console.error(` ✗ ${data.error || r.statusText}`);
|
|
168
|
-
process.exit(1);
|
|
169
|
-
}
|
|
170
|
-
console.log(" ✓ Tunnel stopped.");
|
|
171
|
-
} catch (e) {
|
|
172
|
-
console.error(" ✗ Daemon not running.");
|
|
173
|
-
process.exit(1);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// clauth tunnel status — shows current tunnel status from daemon
|
|
178
|
-
async function actionTunnelStatus() {
|
|
179
|
-
try {
|
|
180
|
-
const r = await fetch("http://127.0.0.1:52437/tunnel", {
|
|
181
|
-
signal: AbortSignal.timeout(5000),
|
|
182
|
-
});
|
|
183
|
-
const data = await r.json().catch(() => ({}));
|
|
184
|
-
|
|
185
|
-
const icons = {
|
|
186
|
-
live: "✓",
|
|
187
|
-
starting: "◌",
|
|
188
|
-
not_configured: "⚠",
|
|
189
|
-
not_started: "○",
|
|
190
|
-
error: "✗",
|
|
191
|
-
missing_cloudflared: "✗",
|
|
192
|
-
};
|
|
193
|
-
const labels = {
|
|
194
|
-
live: `Live — ${data.url || ""}`,
|
|
195
|
-
starting: "Starting...",
|
|
196
|
-
not_configured: "Not configured — run: clauth tunnel setup",
|
|
197
|
-
not_started: "Not started — run: clauth tunnel start",
|
|
198
|
-
error: `Error${data.error ? ": " + data.error : ""} — check cloudflared config`,
|
|
199
|
-
missing_cloudflared: "cloudflared not installed — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const status = data.status || "unknown";
|
|
203
|
-
console.log(`\n ${icons[status] || "?"} Tunnel: ${labels[status] || status}\n`);
|
|
204
|
-
} catch (e) {
|
|
205
|
-
console.error(" ✗ Daemon not running. Start it with: clauth serve");
|
|
206
|
-
process.exit(1);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export { actionTunnelSetup, actionTunnelStart, actionTunnelStop, actionTunnelStatus };
|