@lifeaitools/clauth 0.7.6 → 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/api.js +13 -5
- package/cli/commands/doctor.js +302 -0
- package/cli/commands/install.js +113 -0
- package/cli/commands/invite.js +175 -0
- package/cli/commands/join.js +179 -0
- package/cli/commands/serve.js +2024 -204
- package/cli/index.js +193 -16
- package/install.ps1 +7 -0
- package/install.sh +11 -0
- package/package.json +7 -2
- package/scripts/bootstrap.cjs +78 -0
- package/scripts/postinstall.js +52 -0
- package/supabase/functions/auth-vault/index.ts +27 -7
- package/supabase/migrations/003_clauth_config.sql +13 -0
package/cli/commands/serve.js
CHANGED
|
@@ -13,11 +13,50 @@ import { fileURLToPath } from "url";
|
|
|
13
13
|
import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
|
|
14
14
|
import * as api from "../api.js";
|
|
15
15
|
import chalk from "chalk";
|
|
16
|
+
import ora from "ora";
|
|
17
|
+
import { execSync as execSyncTop } from "child_process";
|
|
18
|
+
import Conf from "conf";
|
|
19
|
+
import { getConfOptions } from "../conf-path.js";
|
|
16
20
|
|
|
17
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
22
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
|
|
19
23
|
const VERSION = pkg.version;
|
|
20
24
|
|
|
25
|
+
// ── Embedded migrations ─────────────────────────────────────────
|
|
26
|
+
// All SQL must be idempotent (IF NOT EXISTS, etc.)
|
|
27
|
+
// type: "safe" = auto-apply | "breaking" = requires user confirmation in UI
|
|
28
|
+
const MIGRATIONS = [
|
|
29
|
+
{
|
|
30
|
+
version: 1,
|
|
31
|
+
name: "001_clauth_schema",
|
|
32
|
+
description: "Initial schema (clauth_services, clauth_machines, clauth_audit)",
|
|
33
|
+
type: "safe",
|
|
34
|
+
sql: null, // pre-existing, skip if schema_version >= 1
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
version: 2,
|
|
38
|
+
name: "002_lockout",
|
|
39
|
+
description: "Machine lockout (fail_count, locked columns on clauth_machines)",
|
|
40
|
+
type: "safe",
|
|
41
|
+
sql: `ALTER TABLE clauth_machines ADD COLUMN IF NOT EXISTS fail_count integer NOT NULL DEFAULT 0;
|
|
42
|
+
ALTER TABLE clauth_machines ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
version: 3,
|
|
46
|
+
name: "003_clauth_config",
|
|
47
|
+
description: "Daemon config store — tunnel hostname and schema versioning",
|
|
48
|
+
type: "safe",
|
|
49
|
+
sql: `CREATE TABLE IF NOT EXISTS clauth_config (
|
|
50
|
+
key text PRIMARY KEY,
|
|
51
|
+
value jsonb NOT NULL,
|
|
52
|
+
updated_at timestamptz DEFAULT now()
|
|
53
|
+
);
|
|
54
|
+
ALTER TABLE clauth_config ENABLE ROW LEVEL SECURITY;`,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const CURRENT_SCHEMA_VERSION = 3;
|
|
59
|
+
|
|
21
60
|
const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
|
|
22
61
|
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
23
62
|
|
|
@@ -47,7 +86,7 @@ function openBrowser(url) {
|
|
|
47
86
|
const cmd = os.platform() === "win32" ? `start "" "${url}"`
|
|
48
87
|
: os.platform() === "darwin" ? `open "${url}"`
|
|
49
88
|
: `xdg-open "${url}"`;
|
|
50
|
-
|
|
89
|
+
execSyncTop(cmd, { stdio: "ignore" });
|
|
51
90
|
} catch {}
|
|
52
91
|
}
|
|
53
92
|
|
|
@@ -190,6 +229,22 @@ function dashboardHtml(port, whitelist) {
|
|
|
190
229
|
.add-foot{display:flex;gap:8px;align-items:center}
|
|
191
230
|
.add-msg{font-size:.82rem}
|
|
192
231
|
.add-msg.ok{color:#4ade80} .add-msg.fail{color:#f87171}
|
|
232
|
+
.project-tabs{display:flex;gap:0;margin-bottom:1rem;border-bottom:1px solid #1e293b;overflow-x:auto}
|
|
233
|
+
.project-tab{background:none;border:none;border-bottom:2px solid transparent;color:#64748b;padding:8px 16px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap;transition:all .15s}
|
|
234
|
+
.project-tab:hover{color:#94a3b8;background:rgba(59,130,246,.05)}
|
|
235
|
+
.project-tab.active{color:#60a5fa;border-bottom-color:#3b82f6;background:rgba(59,130,246,.08)}
|
|
236
|
+
.project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
|
|
237
|
+
.project-tab.active .tab-count{color:#3b82f6}
|
|
238
|
+
.project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
|
|
239
|
+
.project-edit.open{display:flex;gap:6px;align-items:center}
|
|
240
|
+
.project-edit input{background:#1e293b;border:1px solid #334155;border-radius:4px;color:#e2e8f0;font-size:.78rem;padding:4px 8px;outline:none;flex:1;font-family:'Courier New',monospace;transition:border-color .15s}
|
|
241
|
+
.project-edit input:focus{border-color:#3b82f6}
|
|
242
|
+
.project-edit button{font-size:.72rem;padding:4px 8px;border-radius:4px;cursor:pointer;border:1px solid #334155;background:#1a1f2e;color:#94a3b8;transition:all .15s}
|
|
243
|
+
.project-edit button:hover{border-color:#60a5fa;color:#60a5fa}
|
|
244
|
+
.project-edit .pe-msg{font-size:.72rem;color:#4ade80}
|
|
245
|
+
.btn-project{font-size:.68rem;color:#64748b;background:none;border:1px solid #1e293b;border-radius:3px;padding:2px 6px;cursor:pointer;transition:all .15s}
|
|
246
|
+
.btn-project:hover{color:#3b82f6;border-color:#3b82f6}
|
|
247
|
+
.btn-mcp-setup.open{border-color:#f59e0b;color:#f59e0b;background:#1a1500}
|
|
193
248
|
.footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
|
|
194
249
|
.oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
|
|
195
250
|
.oauth-field{display:flex;flex-direction:column;gap:3px}
|
|
@@ -197,6 +252,68 @@ function dashboardHtml(port, whitelist) {
|
|
|
197
252
|
.oauth-hint{font-size:.71rem;color:#475569;font-style:italic}
|
|
198
253
|
.oauth-input{width:100%;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:7px 10px;outline:none;transition:border-color .2s}
|
|
199
254
|
.oauth-input:focus{border-color:#3b82f6}
|
|
255
|
+
.upgrade-banner {
|
|
256
|
+
background: linear-gradient(135deg, rgba(0,200,100,0.12), rgba(0,150,80,0.08));
|
|
257
|
+
border: 1px solid rgba(0,200,100,0.3);
|
|
258
|
+
border-radius: 6px;
|
|
259
|
+
padding: 10px 16px;
|
|
260
|
+
margin-bottom: 12px;
|
|
261
|
+
font-size: 13px;
|
|
262
|
+
}
|
|
263
|
+
.upgrade-banner .upgrade-content {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
gap: 12px;
|
|
267
|
+
flex-wrap: wrap;
|
|
268
|
+
}
|
|
269
|
+
.upgrade-banner button {
|
|
270
|
+
margin-left: auto;
|
|
271
|
+
background: none;
|
|
272
|
+
border: 1px solid rgba(0,200,100,0.4);
|
|
273
|
+
color: #0c6;
|
|
274
|
+
border-radius: 4px;
|
|
275
|
+
padding: 2px 8px;
|
|
276
|
+
cursor: pointer;
|
|
277
|
+
font-size: 12px;
|
|
278
|
+
}
|
|
279
|
+
.upgrade-details-list {
|
|
280
|
+
color: rgba(255,255,255,0.6);
|
|
281
|
+
font-size: 12px;
|
|
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}
|
|
200
317
|
</style>
|
|
201
318
|
</head>
|
|
202
319
|
<body>
|
|
@@ -215,6 +332,13 @@ function dashboardHtml(port, whitelist) {
|
|
|
215
332
|
|
|
216
333
|
<!-- ── Main view (shown after unlock) ──────── -->
|
|
217
334
|
<div id="main-view">
|
|
335
|
+
<div id="upgrade-banner" style="display:none" class="upgrade-banner">
|
|
336
|
+
<div class="upgrade-content">
|
|
337
|
+
<strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
|
|
338
|
+
<span id="upgrade-details"></span>
|
|
339
|
+
<button onclick="dismissUpgrade()">Dismiss ✕</button>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
218
342
|
<div class="header">
|
|
219
343
|
<div class="dot" id="dot"></div>
|
|
220
344
|
<h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span></h1>
|
|
@@ -256,6 +380,10 @@ function dashboardHtml(port, whitelist) {
|
|
|
256
380
|
<option value="">Loading…</option>
|
|
257
381
|
</select>
|
|
258
382
|
</div>
|
|
383
|
+
<div class="add-field">
|
|
384
|
+
<label>Project <span style="color:#475569;font-weight:400">(optional)</span></label>
|
|
385
|
+
<input class="add-input" id="add-project" type="text" placeholder="e.g. marketing-engine" autocomplete="off" spellcheck="false">
|
|
386
|
+
</div>
|
|
259
387
|
</div>
|
|
260
388
|
<div class="add-foot">
|
|
261
389
|
<button class="btn-chpw-save" onclick="addService()">Create Service</button>
|
|
@@ -284,15 +412,45 @@ function dashboardHtml(port, whitelist) {
|
|
|
284
412
|
</div>
|
|
285
413
|
|
|
286
414
|
<div class="tunnel-panel" id="tunnel-panel">
|
|
287
|
-
|
|
288
|
-
<div class="tunnel-
|
|
289
|
-
<
|
|
415
|
+
<!-- not_configured -->
|
|
416
|
+
<div class="tunnel-state not-configured" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
417
|
+
<div class="tunnel-dot off"></div>
|
|
418
|
+
<div class="tunnel-label"><strong>claude.ai MCP</strong> — No tunnel configured</div>
|
|
419
|
+
<button class="btn-tunnel-stop" onclick="runTunnelSetup()">Setup Tunnel</button>
|
|
420
|
+
</div>
|
|
421
|
+
<!-- missing_cloudflared -->
|
|
422
|
+
<div class="tunnel-state missing-cloudflared" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
423
|
+
<div class="tunnel-dot err"></div>
|
|
424
|
+
<div class="tunnel-label"><strong>claude.ai MCP</strong> — cloudflared not installed</div>
|
|
425
|
+
<a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" target="_blank" style="color:#60a5fa;font-size:.8rem">Download cloudflared ↗</a>
|
|
426
|
+
<span class="tunnel-err" style="display:inline;font-size:.78rem;color:#94a3b8">Then run: clauth tunnel setup</span>
|
|
427
|
+
</div>
|
|
428
|
+
<!-- starting -->
|
|
429
|
+
<div class="tunnel-state starting" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
430
|
+
<div class="tunnel-dot starting"></div>
|
|
431
|
+
<div class="tunnel-label"><strong>claude.ai MCP</strong> — Tunnel starting…</div>
|
|
432
|
+
</div>
|
|
433
|
+
<!-- live -->
|
|
434
|
+
<div class="tunnel-state live" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
435
|
+
<div class="tunnel-dot on"></div>
|
|
436
|
+
<div class="tunnel-label"><strong>claude.ai MCP</strong> — <span class="tunnel-url"><a href="" target="_blank" id="tunnel-live-url"></a></span></div>
|
|
437
|
+
<button class="btn-check" style="padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
|
|
438
|
+
<button class="btn-claude" onclick="openClaude()">Connect claude.ai</button>
|
|
439
|
+
<button class="btn-mcp-setup" id="btn-mcp-setup" onclick="toggleMcpSetup()">Setup MCP</button>
|
|
440
|
+
<button class="btn-tunnel-stop" onclick="toggleTunnel('stop')">Stop</button>
|
|
441
|
+
</div>
|
|
442
|
+
<!-- error -->
|
|
443
|
+
<div class="tunnel-state error" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
444
|
+
<div class="tunnel-dot err"></div>
|
|
445
|
+
<div class="tunnel-label"><strong>claude.ai MCP</strong> — Tunnel error — check cloudflared config</div>
|
|
446
|
+
<button class="btn-tunnel-stop" onclick="toggleTunnel('start')">Retry</button>
|
|
447
|
+
</div>
|
|
448
|
+
<!-- not_started -->
|
|
449
|
+
<div class="tunnel-state not-started" style="display:flex;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
|
|
450
|
+
<div class="tunnel-dot off"></div>
|
|
451
|
+
<div class="tunnel-label"><strong>claude.ai MCP</strong> — checking tunnel…</div>
|
|
452
|
+
<button class="btn-tunnel-stop" onclick="toggleTunnel('start')">Start Tunnel</button>
|
|
290
453
|
</div>
|
|
291
|
-
<button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
|
|
292
|
-
<button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
|
|
293
|
-
<button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
|
|
294
|
-
<button class="btn-mcp-setup" id="btn-mcp-setup" style="display:none" onclick="toggleMcpSetup()">Setup MCP</button>
|
|
295
|
-
<div class="tunnel-err" id="tunnel-err" style="display:none"></div>
|
|
296
454
|
</div>
|
|
297
455
|
|
|
298
456
|
<div class="mcp-setup" id="mcp-setup-panel">
|
|
@@ -317,6 +475,18 @@ function dashboardHtml(port, whitelist) {
|
|
|
317
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>
|
|
318
476
|
</div>
|
|
319
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
|
+
|
|
489
|
+
<div id="project-tabs" class="project-tabs" style="display:none"></div>
|
|
320
490
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
321
491
|
<div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
|
|
322
492
|
</div>
|
|
@@ -346,6 +516,19 @@ const KEY_URLS = {
|
|
|
346
516
|
"neo4j": "https://console.neo4j.io/",
|
|
347
517
|
"npm": "https://www.npmjs.com/settings/~/tokens",
|
|
348
518
|
"gmail": "https://console.cloud.google.com/apis/credentials",
|
|
519
|
+
"gcal": "https://console.cloud.google.com/apis/credentials",
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// Extra links shown below the primary KEY_URLS link
|
|
523
|
+
const EXTRA_LINKS = {
|
|
524
|
+
"gmail": [
|
|
525
|
+
{ label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
|
|
526
|
+
{ label: "↗ Enable Gmail API", url: "https://console.cloud.google.com/apis/library/gmail.googleapis.com" },
|
|
527
|
+
],
|
|
528
|
+
"gcal": [
|
|
529
|
+
{ label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
|
|
530
|
+
{ label: "↗ Enable Calendar API", url: "https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" },
|
|
531
|
+
],
|
|
349
532
|
};
|
|
350
533
|
|
|
351
534
|
// ── OAuth import config ─────────────────────
|
|
@@ -355,7 +538,11 @@ const KEY_URLS = {
|
|
|
355
538
|
const OAUTH_IMPORT = {
|
|
356
539
|
"gmail": {
|
|
357
540
|
jsonFields: ["client_id", "client_secret"],
|
|
358
|
-
extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground
|
|
541
|
+
extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground — select Gmail API scopes, authorize, then exchange for tokens" }]
|
|
542
|
+
},
|
|
543
|
+
"gcal": {
|
|
544
|
+
jsonFields: ["client_id", "client_secret"],
|
|
545
|
+
extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground — select Calendar API scopes, authorize, then exchange for tokens" }]
|
|
359
546
|
}
|
|
360
547
|
};
|
|
361
548
|
|
|
@@ -423,7 +610,40 @@ function showLockScreen() {
|
|
|
423
610
|
setTimeout(() => document.getElementById("lock-input").focus(), 50);
|
|
424
611
|
}
|
|
425
612
|
|
|
613
|
+
// ── Upgrade detection ────────────────────────────────────────────
|
|
614
|
+
function checkUpgrade(ping) {
|
|
615
|
+
const currentVersion = ping.app_version || ping.version;
|
|
616
|
+
if (!currentVersion) return;
|
|
617
|
+
|
|
618
|
+
const lastVersion = localStorage.getItem('clauth_last_version');
|
|
619
|
+
|
|
620
|
+
if (lastVersion && lastVersion !== currentVersion) {
|
|
621
|
+
// Version changed — show upgrade banner
|
|
622
|
+
document.getElementById('upgrade-to-version').textContent = currentVersion;
|
|
623
|
+
|
|
624
|
+
// Build details from migration result if available
|
|
625
|
+
let details = '';
|
|
626
|
+
if (ping.last_migrations?.applied?.length > 0) {
|
|
627
|
+
details = '· Migrations: ' + ping.last_migrations.applied.map(m => m.description).join(' · ');
|
|
628
|
+
}
|
|
629
|
+
// Show breaking migration warning if any pending
|
|
630
|
+
if (ping.pending_breaking?.length > 0) {
|
|
631
|
+
details += ' ⚠ Breaking migrations require confirmation — see below.';
|
|
632
|
+
}
|
|
633
|
+
document.getElementById('upgrade-details').textContent = details;
|
|
634
|
+
document.getElementById('upgrade-banner').style.display = 'block';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Always update stored version
|
|
638
|
+
localStorage.setItem('clauth_last_version', currentVersion);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function dismissUpgrade() {
|
|
642
|
+
document.getElementById('upgrade-banner').style.display = 'none';
|
|
643
|
+
}
|
|
644
|
+
|
|
426
645
|
function showMain(ping) {
|
|
646
|
+
checkUpgrade(ping);
|
|
427
647
|
document.getElementById("lock-screen").style.display = "none";
|
|
428
648
|
document.getElementById("main-view").style.display = "block";
|
|
429
649
|
if (ping) {
|
|
@@ -476,6 +696,82 @@ async function lockVault() {
|
|
|
476
696
|
}
|
|
477
697
|
|
|
478
698
|
// ── Load services ───────────────────────────
|
|
699
|
+
let allServices = [];
|
|
700
|
+
let activeProjectTab = "all";
|
|
701
|
+
|
|
702
|
+
function renderProjectTabs(services) {
|
|
703
|
+
const tabsEl = document.getElementById("project-tabs");
|
|
704
|
+
const projects = new Map(); // project -> count
|
|
705
|
+
let unassigned = 0;
|
|
706
|
+
for (const s of services) {
|
|
707
|
+
if (s.project) {
|
|
708
|
+
projects.set(s.project, (projects.get(s.project) || 0) + 1);
|
|
709
|
+
} else {
|
|
710
|
+
unassigned++;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Only show tabs if there's at least one project
|
|
714
|
+
if (projects.size === 0) { tabsEl.style.display = "none"; return; }
|
|
715
|
+
tabsEl.style.display = "flex";
|
|
716
|
+
const tabs = [
|
|
717
|
+
{ key: "all", label: "All", count: services.length },
|
|
718
|
+
...Array.from(projects.entries()).map(([name, count]) => ({ key: name, label: name, count })),
|
|
719
|
+
{ key: "unassigned", label: "Global", count: unassigned },
|
|
720
|
+
];
|
|
721
|
+
tabsEl.innerHTML = tabs.map(t =>
|
|
722
|
+
\`<button class="project-tab \${activeProjectTab === t.key ? "active" : ""}" onclick="switchProjectTab('\${t.key}')">\${t.label}<span class="tab-count">(\${t.count})</span></button>\`
|
|
723
|
+
).join("");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function switchProjectTab(key) {
|
|
727
|
+
activeProjectTab = key;
|
|
728
|
+
renderProjectTabs(allServices);
|
|
729
|
+
renderServiceGrid(allServices);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function renderServiceGrid(services) {
|
|
733
|
+
const grid = document.getElementById("grid");
|
|
734
|
+
let filtered = services;
|
|
735
|
+
if (activeProjectTab === "unassigned") {
|
|
736
|
+
filtered = services.filter(s => !s.project);
|
|
737
|
+
} else if (activeProjectTab !== "all") {
|
|
738
|
+
filtered = services.filter(s => s.project === activeProjectTab);
|
|
739
|
+
}
|
|
740
|
+
if (!filtered.length) { grid.innerHTML = '<p class="loading">No services in this group.</p>'; return; }
|
|
741
|
+
grid.innerHTML = filtered.map(s => \`
|
|
742
|
+
<div class="card">
|
|
743
|
+
<div style="display:flex;align-items:flex-start;justify-content:space-between">
|
|
744
|
+
<div>
|
|
745
|
+
<div class="card-name">\${s.name}</div>
|
|
746
|
+
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
|
747
|
+
<div class="card-type">\${s.key_type || "secret"}</div>
|
|
748
|
+
<span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
|
|
749
|
+
\${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${s.project}</span>\` : ""}
|
|
750
|
+
</div>
|
|
751
|
+
\${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
|
|
752
|
+
\${(EXTRA_LINKS[s.name] || []).map(l => \`<a class="card-getkey" href="\${l.url}" target="_blank" rel="noopener" style="margin-left:0">\${l.label}</a>\`).join("")}
|
|
753
|
+
</div>
|
|
754
|
+
<div class="status-dot" id="sdot-\${s.name}" title=""></div>
|
|
755
|
+
</div>
|
|
756
|
+
<div class="card-value" id="val-\${s.name}"></div>
|
|
757
|
+
<div class="card-actions">
|
|
758
|
+
<button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
|
|
759
|
+
<button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
|
|
760
|
+
<button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
|
|
761
|
+
<button class="btn-project" onclick="toggleProjectEdit('\${s.name}')">\${s.project ? "✎ Project" : "+ Project"}</button>
|
|
762
|
+
<button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
|
|
763
|
+
</div>
|
|
764
|
+
<div class="project-edit" id="pe-\${s.name}">
|
|
765
|
+
<input type="text" id="pe-input-\${s.name}" value="\${s.project || ""}" placeholder="Project name…" spellcheck="false" autocomplete="off">
|
|
766
|
+
<button onclick="saveProject('\${s.name}')">Save</button>
|
|
767
|
+
<button onclick="clearProject('\${s.name}')">Clear</button>
|
|
768
|
+
<span class="pe-msg" id="pe-msg-\${s.name}"></span>
|
|
769
|
+
</div>
|
|
770
|
+
\${renderSetPanel(s.name)}
|
|
771
|
+
</div>
|
|
772
|
+
\`).join("");
|
|
773
|
+
}
|
|
774
|
+
|
|
479
775
|
async function loadServices() {
|
|
480
776
|
const grid = document.getElementById("grid");
|
|
481
777
|
const err = document.getElementById("error-bar");
|
|
@@ -487,32 +783,11 @@ async function loadServices() {
|
|
|
487
783
|
if (status.locked) { showLockScreen(); return; }
|
|
488
784
|
if (status.error) throw new Error(status.error);
|
|
489
785
|
|
|
490
|
-
|
|
491
|
-
if (!
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
<div style="display:flex;align-items:flex-start;justify-content:space-between">
|
|
496
|
-
<div>
|
|
497
|
-
<div class="card-name">\${s.name}</div>
|
|
498
|
-
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
|
499
|
-
<div class="card-type">\${s.key_type || "secret"}</div>
|
|
500
|
-
<span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
|
|
501
|
-
</div>
|
|
502
|
-
\${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
|
|
503
|
-
</div>
|
|
504
|
-
<div class="status-dot" id="sdot-\${s.name}" title=""></div>
|
|
505
|
-
</div>
|
|
506
|
-
<div class="card-value" id="val-\${s.name}"></div>
|
|
507
|
-
<div class="card-actions">
|
|
508
|
-
<button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
|
|
509
|
-
<button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
|
|
510
|
-
<button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
|
|
511
|
-
<button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
|
|
512
|
-
</div>
|
|
513
|
-
\${renderSetPanel(s.name)}
|
|
514
|
-
</div>
|
|
515
|
-
\`).join("");
|
|
786
|
+
allServices = status.services || [];
|
|
787
|
+
if (!allServices.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
|
|
788
|
+
|
|
789
|
+
renderProjectTabs(allServices);
|
|
790
|
+
renderServiceGrid(allServices);
|
|
516
791
|
} catch (e) {
|
|
517
792
|
err.textContent = "⚠ " + e.message;
|
|
518
793
|
err.style.display = "block";
|
|
@@ -561,6 +836,47 @@ async function copyKey(name) {
|
|
|
561
836
|
} catch {}
|
|
562
837
|
}
|
|
563
838
|
|
|
839
|
+
// ── Project edit ────────────────────────────
|
|
840
|
+
function toggleProjectEdit(name) {
|
|
841
|
+
const panel = document.getElementById("pe-" + name);
|
|
842
|
+
const open = panel.classList.contains("open");
|
|
843
|
+
panel.classList.toggle("open");
|
|
844
|
+
if (!open) {
|
|
845
|
+
const svc = allServices.find(s => s.name === name);
|
|
846
|
+
document.getElementById("pe-input-" + name).value = svc ? (svc.project || "") : "";
|
|
847
|
+
document.getElementById("pe-msg-" + name).textContent = "";
|
|
848
|
+
document.getElementById("pe-input-" + name).focus();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function saveProject(name) {
|
|
853
|
+
const input = document.getElementById("pe-input-" + name);
|
|
854
|
+
const msg = document.getElementById("pe-msg-" + name);
|
|
855
|
+
const project = input.value.trim();
|
|
856
|
+
msg.textContent = "Saving…"; msg.style.color = "#94a3b8";
|
|
857
|
+
try {
|
|
858
|
+
const r = await fetch(BASE + "/update-service", {
|
|
859
|
+
method: "POST",
|
|
860
|
+
headers: { "Content-Type": "application/json" },
|
|
861
|
+
body: JSON.stringify({ service: name, project: project || "" })
|
|
862
|
+
}).then(r => r.json());
|
|
863
|
+
if (r.locked) { showLockScreen(); return; }
|
|
864
|
+
if (r.error) throw new Error(r.error);
|
|
865
|
+
msg.style.color = "#4ade80"; msg.textContent = "✓ Saved";
|
|
866
|
+
// Update local state
|
|
867
|
+
const svc = allServices.find(s => s.name === name);
|
|
868
|
+
if (svc) svc.project = project || null;
|
|
869
|
+
setTimeout(() => { loadServices(); }, 800);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
msg.style.color = "#f87171"; msg.textContent = err.message;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function clearProject(name) {
|
|
876
|
+
document.getElementById("pe-input-" + name).value = "";
|
|
877
|
+
await saveProject(name);
|
|
878
|
+
}
|
|
879
|
+
|
|
564
880
|
// ── Set key ─────────────────────────────────
|
|
565
881
|
function toggleSet(name) {
|
|
566
882
|
const panel = document.getElementById("set-panel-" + name);
|
|
@@ -793,6 +1109,7 @@ async function toggleAddService() {
|
|
|
793
1109
|
panel.style.display = open ? "none" : "block";
|
|
794
1110
|
if (!open) {
|
|
795
1111
|
document.getElementById("add-name").value = "";
|
|
1112
|
+
document.getElementById("add-project").value = "";
|
|
796
1113
|
document.getElementById("add-msg").textContent = "";
|
|
797
1114
|
// Fetch available key types from server
|
|
798
1115
|
const sel = document.getElementById("add-type");
|
|
@@ -815,6 +1132,7 @@ async function toggleAddService() {
|
|
|
815
1132
|
async function addService() {
|
|
816
1133
|
const name = document.getElementById("add-name").value.trim().toLowerCase();
|
|
817
1134
|
const type = document.getElementById("add-type").value;
|
|
1135
|
+
const project = document.getElementById("add-project").value.trim();
|
|
818
1136
|
const msg = document.getElementById("add-msg");
|
|
819
1137
|
|
|
820
1138
|
if (!name) { msg.className = "add-msg fail"; msg.textContent = "Service name is required."; return; }
|
|
@@ -822,10 +1140,12 @@ async function addService() {
|
|
|
822
1140
|
|
|
823
1141
|
msg.className = "add-msg"; msg.textContent = "Creating…";
|
|
824
1142
|
try {
|
|
1143
|
+
const payload = { name, key_type: type, label: name };
|
|
1144
|
+
if (project) payload.project = project;
|
|
825
1145
|
const r = await fetch(BASE + "/add-service", {
|
|
826
1146
|
method: "POST",
|
|
827
1147
|
headers: { "Content-Type": "application/json" },
|
|
828
|
-
body: JSON.stringify(
|
|
1148
|
+
body: JSON.stringify(payload)
|
|
829
1149
|
}).then(r => r.json());
|
|
830
1150
|
|
|
831
1151
|
if (r.locked) { showLockScreen(); return; }
|
|
@@ -852,143 +1172,109 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
852
1172
|
});
|
|
853
1173
|
});
|
|
854
1174
|
|
|
1175
|
+
// ── Shared fetch helper ─────────────────────
|
|
1176
|
+
async function apiFetch(path, opts) {
|
|
1177
|
+
try {
|
|
1178
|
+
return await fetch(BASE + path, opts).then(r => r.json());
|
|
1179
|
+
} catch { return null; }
|
|
1180
|
+
}
|
|
1181
|
+
|
|
855
1182
|
// ── Tunnel management ───────────────────────
|
|
856
1183
|
let tunnelPollTimer = null;
|
|
857
1184
|
|
|
858
1185
|
async function pollTunnel() {
|
|
859
1186
|
if (tunnelPollTimer) clearInterval(tunnelPollTimer);
|
|
860
|
-
await
|
|
861
|
-
|
|
1187
|
+
const r = await apiFetch("/tunnel");
|
|
1188
|
+
if (r) renderTunnelPanel(r.status, r.url, r.error);
|
|
1189
|
+
// Poll every 3s; slow to 10s once live
|
|
862
1190
|
tunnelPollTimer = setInterval(async () => {
|
|
863
|
-
const
|
|
864
|
-
if (
|
|
1191
|
+
const r = await apiFetch("/tunnel");
|
|
1192
|
+
if (!r) return;
|
|
1193
|
+
renderTunnelPanel(r.status, r.url, r.error);
|
|
1194
|
+
if (r.status === "live") {
|
|
865
1195
|
clearInterval(tunnelPollTimer);
|
|
866
|
-
tunnelPollTimer = setInterval(
|
|
1196
|
+
tunnelPollTimer = setInterval(async () => {
|
|
1197
|
+
const r2 = await apiFetch("/tunnel");
|
|
1198
|
+
if (r2) renderTunnelPanel(r2.status, r2.url, r2.error);
|
|
1199
|
+
}, 10000);
|
|
867
1200
|
}
|
|
868
1201
|
}, 3000);
|
|
869
1202
|
}
|
|
870
1203
|
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
if (
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
togBtn.textContent = "Start Tunnel";
|
|
891
|
-
togBtn.onclick = () => toggleTunnel("start");
|
|
892
|
-
togBtn.style.display = "inline-block";
|
|
893
|
-
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
894
|
-
return "error";
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
if (t.running && t.url && t.url.startsWith("http")) {
|
|
898
|
-
dot.className = "tunnel-dot on";
|
|
899
|
-
label.innerHTML = '<strong>claude.ai MCP</strong> — <span class="tunnel-url"><a href="' + t.sseUrl + '" target="_blank">' + t.sseUrl + '</a></span>';
|
|
900
|
-
btn.disabled = false;
|
|
901
|
-
document.getElementById("btn-tunnel-test").style.display = "inline-block";
|
|
902
|
-
togBtn.textContent = "Stop Tunnel";
|
|
903
|
-
togBtn.onclick = () => toggleTunnel("stop");
|
|
904
|
-
togBtn.style.display = "inline-block";
|
|
905
|
-
mcpBtn.style.display = "inline-block";
|
|
906
|
-
return "connected";
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
if (t.running) {
|
|
910
|
-
dot.className = "tunnel-dot starting";
|
|
911
|
-
label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
|
|
912
|
-
btn.disabled = true;
|
|
913
|
-
mcpBtn.style.display = "none";
|
|
914
|
-
togBtn.textContent = "Stop Tunnel";
|
|
915
|
-
togBtn.onclick = () => toggleTunnel("stop");
|
|
916
|
-
togBtn.style.display = "inline-block";
|
|
917
|
-
return "starting";
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// Not running
|
|
921
|
-
dot.className = "tunnel-dot off";
|
|
922
|
-
label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
|
|
923
|
-
btn.disabled = true;
|
|
924
|
-
mcpBtn.style.display = "none";
|
|
925
|
-
togBtn.textContent = "Start Tunnel";
|
|
926
|
-
togBtn.onclick = () => toggleTunnel("start");
|
|
927
|
-
togBtn.style.display = "inline-block";
|
|
928
|
-
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
929
|
-
return "stopped";
|
|
930
|
-
|
|
931
|
-
} catch {
|
|
932
|
-
dot.className = "tunnel-dot off";
|
|
933
|
-
label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
|
|
934
|
-
btn.disabled = true;
|
|
935
|
-
mcpBtn.style.display = "none";
|
|
936
|
-
togBtn.style.display = "none";
|
|
1204
|
+
function renderTunnelPanel(status, url, error) {
|
|
1205
|
+
const panel = document.getElementById("tunnel-panel");
|
|
1206
|
+
if (!panel) return;
|
|
1207
|
+
// Hide all state divs
|
|
1208
|
+
panel.querySelectorAll(".tunnel-state").forEach(el => el.style.display = "none");
|
|
1209
|
+
// Map status to CSS class (underscores → hyphens)
|
|
1210
|
+
const cls = (status || "not-started").replace(/_/g, "-");
|
|
1211
|
+
const active = panel.querySelector(".tunnel-state." + cls);
|
|
1212
|
+
if (active) active.style.display = "flex";
|
|
1213
|
+
// Update live URL
|
|
1214
|
+
if (status === "live" && url) {
|
|
1215
|
+
const sseUrl = url.startsWith("http") ? url + "/sse" : url;
|
|
1216
|
+
const link = panel.querySelector(".tunnel-state.live a");
|
|
1217
|
+
if (link) { link.href = sseUrl; link.textContent = sseUrl; }
|
|
1218
|
+
const liveUrlEl = document.getElementById("tunnel-live-url");
|
|
1219
|
+
if (liveUrlEl) { liveUrlEl.href = sseUrl; liveUrlEl.textContent = sseUrl; }
|
|
1220
|
+
}
|
|
1221
|
+
// Close MCP setup panel when not live
|
|
1222
|
+
if (status !== "live") {
|
|
937
1223
|
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
938
|
-
|
|
1224
|
+
const mcpBtn = document.getElementById("btn-mcp-setup");
|
|
1225
|
+
if (mcpBtn) mcpBtn.classList.remove("open");
|
|
939
1226
|
}
|
|
940
1227
|
}
|
|
941
1228
|
|
|
942
1229
|
async function toggleTunnel(action) {
|
|
943
|
-
const togBtn = document.getElementById("btn-tunnel-toggle");
|
|
944
|
-
togBtn.disabled = true; togBtn.textContent = "…";
|
|
945
1230
|
try {
|
|
946
1231
|
await fetch(BASE + "/tunnel", {
|
|
947
1232
|
method: "POST",
|
|
948
1233
|
headers: { "Content-Type": "application/json" },
|
|
949
1234
|
body: JSON.stringify({ action })
|
|
950
1235
|
});
|
|
951
|
-
//
|
|
952
|
-
setTimeout(
|
|
953
|
-
} catch {}
|
|
954
|
-
togBtn.disabled = false;
|
|
955
|
-
}
|
|
1236
|
+
// Re-poll after a beat
|
|
1237
|
+
setTimeout(pollTunnel, action === "start" ? 2000 : 500);
|
|
1238
|
+
} catch {}
|
|
956
1239
|
}
|
|
957
1240
|
|
|
1241
|
+
function runTunnelSetup() { openSetupWizard(); }
|
|
1242
|
+
|
|
958
1243
|
async function testTunnel() {
|
|
959
|
-
const
|
|
960
|
-
const
|
|
961
|
-
const
|
|
962
|
-
btn.disabled = true; btn.textContent = "Testing…";
|
|
963
|
-
dot.className = "tunnel-dot starting";
|
|
1244
|
+
const liveState = document.querySelector("#tunnel-panel .tunnel-state.live");
|
|
1245
|
+
const btn = liveState ? liveState.querySelector(".btn-check") : null;
|
|
1246
|
+
const labelEl = liveState ? liveState.querySelector(".tunnel-label") : null;
|
|
1247
|
+
if (btn) { btn.disabled = true; btn.textContent = "Testing…"; }
|
|
964
1248
|
|
|
965
1249
|
try {
|
|
966
1250
|
const r = await fetch(BASE + "/tunnel/test").then(r => r.json());
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1251
|
+
if (labelEl) {
|
|
1252
|
+
if (r.ok) {
|
|
1253
|
+
labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#4ade80">PASS</span> ' +
|
|
1254
|
+
r.latencyMs + 'ms · SSE ' + (r.sseReachable ? 'reachable' : 'unreachable') +
|
|
1255
|
+
' · <span class="tunnel-url"><a href="' + r.sseUrl + '" target="_blank">' + r.sseUrl + '</a></span>';
|
|
1256
|
+
} else {
|
|
1257
|
+
labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + (r.reason || "unknown error");
|
|
1258
|
+
}
|
|
975
1259
|
}
|
|
976
1260
|
} catch (e) {
|
|
977
|
-
|
|
978
|
-
label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
|
|
1261
|
+
if (labelEl) labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
|
|
979
1262
|
} finally {
|
|
980
|
-
btn.disabled = false; btn.textContent = "Test";
|
|
1263
|
+
if (btn) { btn.disabled = false; btn.textContent = "Test"; }
|
|
981
1264
|
}
|
|
982
1265
|
}
|
|
983
1266
|
|
|
984
1267
|
function openClaude() {
|
|
985
1268
|
// Copy the SSE URL to clipboard and open claude.ai settings
|
|
986
1269
|
fetch(BASE + "/tunnel").then(r => r.json()).then(t => {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
btn
|
|
991
|
-
|
|
1270
|
+
const sseUrl = t.sseUrl || (t.url && t.url.startsWith("http") ? t.url + "/sse" : null);
|
|
1271
|
+
if (sseUrl) {
|
|
1272
|
+
navigator.clipboard.writeText(sseUrl).then(() => {
|
|
1273
|
+
const btn = document.querySelector("#tunnel-panel .tunnel-state.live .btn-claude");
|
|
1274
|
+
if (btn) {
|
|
1275
|
+
btn.textContent = "SSE URL copied!";
|
|
1276
|
+
setTimeout(() => { btn.textContent = "Connect claude.ai"; }, 2000);
|
|
1277
|
+
}
|
|
992
1278
|
}).catch(() => {});
|
|
993
1279
|
window.open("https://claude.ai/settings/integrations", "_blank");
|
|
994
1280
|
}
|
|
@@ -997,7 +1283,10 @@ function openClaude() {
|
|
|
997
1283
|
|
|
998
1284
|
async function toggleMcpSetup() {
|
|
999
1285
|
const panel = document.getElementById("mcp-setup-panel");
|
|
1286
|
+
const btn = document.getElementById("btn-mcp-setup");
|
|
1000
1287
|
const isOpen = panel.classList.toggle("open");
|
|
1288
|
+
btn.classList.toggle("open", isOpen);
|
|
1289
|
+
btn.textContent = isOpen ? "Close MCP" : "Setup MCP";
|
|
1001
1290
|
if (isOpen) {
|
|
1002
1291
|
try {
|
|
1003
1292
|
const m = await fetch(BASE + "/mcp-setup").then(r => r.json());
|
|
@@ -1021,6 +1310,423 @@ function copyMcp(elId) {
|
|
|
1021
1310
|
}).catch(() => {});
|
|
1022
1311
|
}
|
|
1023
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
|
+
|
|
1024
1730
|
// ── Build Status ──────────────────────────────────
|
|
1025
1731
|
async function updateBuildStatus() {
|
|
1026
1732
|
try {
|
|
@@ -1074,7 +1780,10 @@ function readBody(req) {
|
|
|
1074
1780
|
}
|
|
1075
1781
|
|
|
1076
1782
|
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
1077
|
-
function createServer(initPassword, whitelist, port,
|
|
1783
|
+
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null) {
|
|
1784
|
+
// tunnelHostname may be updated at runtime (fetched from DB after unlock)
|
|
1785
|
+
let tunnelHostname = tunnelHostnameInit;
|
|
1786
|
+
|
|
1078
1787
|
// Ensure Windows system tools are reachable (bash shells may lack these on PATH)
|
|
1079
1788
|
if (os.platform() === "win32") {
|
|
1080
1789
|
const sys32 = "C:\\Windows\\System32";
|
|
@@ -1106,6 +1815,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1106
1815
|
get password() { return this._pw; },
|
|
1107
1816
|
set password(v) { this._pw = v; },
|
|
1108
1817
|
get machineHash() { return machineHash; },
|
|
1818
|
+
get tunnelUrl() { return tunnelUrl; },
|
|
1109
1819
|
whitelist,
|
|
1110
1820
|
failCount,
|
|
1111
1821
|
MAX_FAILS,
|
|
@@ -1120,6 +1830,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1120
1830
|
let tunnelProc = null;
|
|
1121
1831
|
let tunnelUrl = null;
|
|
1122
1832
|
let tunnelError = null;
|
|
1833
|
+
let tunnelStatus = "not_started"; // "not_started" | "not_configured" | "starting" | "live" | "error" | "missing_cloudflared"
|
|
1123
1834
|
|
|
1124
1835
|
// ── OAuth provider (self-contained for claude.ai MCP) ──────
|
|
1125
1836
|
const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
|
|
@@ -1169,6 +1880,28 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1169
1880
|
}
|
|
1170
1881
|
}
|
|
1171
1882
|
|
|
1883
|
+
// If binary is still the default name, verify it's actually on PATH
|
|
1884
|
+
if (cfBin === "cloudflared") {
|
|
1885
|
+
try {
|
|
1886
|
+
const { execSync } = await import("child_process");
|
|
1887
|
+
execSync("cloudflared --version", { stdio: "ignore" });
|
|
1888
|
+
} catch {
|
|
1889
|
+
tunnelError = "cloudflared is not installed or not on PATH.";
|
|
1890
|
+
tunnelStatus = "missing_cloudflared";
|
|
1891
|
+
const installMsg = [
|
|
1892
|
+
"",
|
|
1893
|
+
" \u2717 cloudflared not found.",
|
|
1894
|
+
" Download: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
1895
|
+
" Then run: clauth tunnel setup",
|
|
1896
|
+
"",
|
|
1897
|
+
].join("\n");
|
|
1898
|
+
console.error(installMsg);
|
|
1899
|
+
try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${tunnelError}\n`); } catch {}
|
|
1900
|
+
tunnelProc = null;
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1172
1905
|
// Named tunnel (fixed subdomain) or quick tunnel (random URL)
|
|
1173
1906
|
let args;
|
|
1174
1907
|
if (tunnelHostname) {
|
|
@@ -1196,6 +1929,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1196
1929
|
const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
1197
1930
|
if (match) {
|
|
1198
1931
|
tunnelUrl = match[0];
|
|
1932
|
+
tunnelStatus = "live";
|
|
1199
1933
|
const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
|
|
1200
1934
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1201
1935
|
}
|
|
@@ -1206,6 +1940,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1206
1940
|
tunnelError = err.code === "ENOENT"
|
|
1207
1941
|
? "cloudflared not found — install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
|
1208
1942
|
: err.message;
|
|
1943
|
+
tunnelStatus = "error";
|
|
1209
1944
|
tunnelProc = null;
|
|
1210
1945
|
const logLine = `[${new Date().toISOString()}] Tunnel error: ${tunnelError}\n`;
|
|
1211
1946
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
@@ -1215,6 +1950,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1215
1950
|
if (tunnelProc === proc) {
|
|
1216
1951
|
tunnelProc = null;
|
|
1217
1952
|
if (!tunnelError) tunnelError = `Tunnel exited with code ${code}`;
|
|
1953
|
+
tunnelStatus = "error";
|
|
1218
1954
|
const logLine = `[${new Date().toISOString()}] Tunnel exited: code ${code}\n`;
|
|
1219
1955
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1220
1956
|
}
|
|
@@ -1224,12 +1960,14 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1224
1960
|
await new Promise(r => setTimeout(r, 4000));
|
|
1225
1961
|
|
|
1226
1962
|
if (tunnelHostname && tunnelProc) {
|
|
1963
|
+
tunnelStatus = "live";
|
|
1227
1964
|
const logLine = `[${new Date().toISOString()}] Named tunnel started: ${tunnelUrl}\n`;
|
|
1228
1965
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1229
1966
|
}
|
|
1230
1967
|
|
|
1231
1968
|
} catch (err) {
|
|
1232
1969
|
tunnelError = err.message;
|
|
1970
|
+
tunnelStatus = "error";
|
|
1233
1971
|
tunnelProc = null;
|
|
1234
1972
|
}
|
|
1235
1973
|
}
|
|
@@ -1240,6 +1978,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1240
1978
|
tunnelProc = null;
|
|
1241
1979
|
tunnelUrl = null;
|
|
1242
1980
|
tunnelError = null;
|
|
1981
|
+
tunnelStatus = "not_started";
|
|
1243
1982
|
const logLine = `[${new Date().toISOString()}] Tunnel stopped\n`;
|
|
1244
1983
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1245
1984
|
}
|
|
@@ -1308,6 +2047,128 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1308
2047
|
fetchBuildStatus();
|
|
1309
2048
|
setInterval(fetchBuildStatus, 15000);
|
|
1310
2049
|
|
|
2050
|
+
applyPendingMigrations().then(result => {
|
|
2051
|
+
lastMigrationResult = result;
|
|
2052
|
+
if (result.applied?.length > 0) {
|
|
2053
|
+
const log = `[${new Date().toISOString()}] Migrations applied: ${result.applied.map(m => m.name).join(", ")}\n`;
|
|
2054
|
+
try { fs.appendFileSync(LOG_FILE, log); } catch {}
|
|
2055
|
+
}
|
|
2056
|
+
}).catch(() => {});
|
|
2057
|
+
|
|
2058
|
+
// ── Tunnel config (fetched from DB after unlock) ────────────
|
|
2059
|
+
async function fetchTunnelConfig() {
|
|
2060
|
+
try {
|
|
2061
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
2062
|
+
const sbKey = api.getAnonKey();
|
|
2063
|
+
if (!sbUrl || !sbKey) return null;
|
|
2064
|
+
|
|
2065
|
+
const r = await fetch(
|
|
2066
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
|
|
2067
|
+
{
|
|
2068
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` },
|
|
2069
|
+
signal: AbortSignal.timeout(5000),
|
|
2070
|
+
}
|
|
2071
|
+
);
|
|
2072
|
+
if (!r.ok) return null;
|
|
2073
|
+
const rows = await r.json();
|
|
2074
|
+
if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
|
|
2075
|
+
return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
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
|
+
|
|
2097
|
+
return null;
|
|
2098
|
+
} catch {
|
|
2099
|
+
return null;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// ── Auto-migration ────────────────────────────────────────────
|
|
2104
|
+
let pendingBreakingMigrations = [];
|
|
2105
|
+
let lastMigrationResult = null;
|
|
2106
|
+
|
|
2107
|
+
async function applyPendingMigrations() {
|
|
2108
|
+
try {
|
|
2109
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
2110
|
+
const sbKey = api.getAnonKey();
|
|
2111
|
+
if (!sbUrl || !sbKey) return { applied: [], errors: [] };
|
|
2112
|
+
|
|
2113
|
+
// Read current schema version (may not exist yet)
|
|
2114
|
+
let currentVersion = 0;
|
|
2115
|
+
try {
|
|
2116
|
+
const r = await fetch(
|
|
2117
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.schema_version&select=value`,
|
|
2118
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
2119
|
+
);
|
|
2120
|
+
if (r.ok) {
|
|
2121
|
+
const rows = await r.json();
|
|
2122
|
+
if (rows.length > 0) currentVersion = Number(rows[0].value) || 0;
|
|
2123
|
+
}
|
|
2124
|
+
} catch {}
|
|
2125
|
+
|
|
2126
|
+
if (currentVersion >= CURRENT_SCHEMA_VERSION) return { applied: [], errors: [], currentVersion };
|
|
2127
|
+
|
|
2128
|
+
const applied = [];
|
|
2129
|
+
const errors = [];
|
|
2130
|
+
|
|
2131
|
+
for (const m of MIGRATIONS) {
|
|
2132
|
+
if (m.version <= currentVersion || !m.sql) continue;
|
|
2133
|
+
if (m.type === "breaking") {
|
|
2134
|
+
// Queue for UI confirmation — do not auto-apply
|
|
2135
|
+
pendingBreakingMigrations.push(m);
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
try {
|
|
2139
|
+
// Apply via Edge Function proxy (anon key can't do DDL directly)
|
|
2140
|
+
const r = await fetch(`${(api.getBaseUrl() || "")}/run-migration`, {
|
|
2141
|
+
method: "POST",
|
|
2142
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json" },
|
|
2143
|
+
body: JSON.stringify({ sql: m.sql, version: m.version }),
|
|
2144
|
+
signal: AbortSignal.timeout(15000),
|
|
2145
|
+
});
|
|
2146
|
+
if (r.ok) {
|
|
2147
|
+
applied.push(m);
|
|
2148
|
+
currentVersion = m.version;
|
|
2149
|
+
// Update schema_version in clauth_config
|
|
2150
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
2151
|
+
method: "POST",
|
|
2152
|
+
headers: {
|
|
2153
|
+
apikey: sbKey, Authorization: `Bearer ${sbKey}`,
|
|
2154
|
+
"Content-Type": "application/json", Prefer: "resolution=merge-duplicates",
|
|
2155
|
+
},
|
|
2156
|
+
body: JSON.stringify({ key: "schema_version", value: m.version }),
|
|
2157
|
+
});
|
|
2158
|
+
} else {
|
|
2159
|
+
errors.push({ migration: m.name, error: await r.text() });
|
|
2160
|
+
}
|
|
2161
|
+
} catch (e) {
|
|
2162
|
+
errors.push({ migration: m.name, error: e.message });
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
return { applied, errors, currentVersion };
|
|
2167
|
+
} catch (e) {
|
|
2168
|
+
return { applied: [], errors: [{ migration: "init", error: e.message }] };
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
1311
2172
|
const server = http.createServer(async (req, res) => {
|
|
1312
2173
|
const remote = req.socket.remoteAddress;
|
|
1313
2174
|
const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
@@ -1667,17 +2528,22 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1667
2528
|
failures: failCount,
|
|
1668
2529
|
failures_remaining: MAX_FAILS - failCount,
|
|
1669
2530
|
services: whitelist || "all",
|
|
1670
|
-
port
|
|
2531
|
+
port,
|
|
2532
|
+
tunnel_status: tunnelStatus,
|
|
2533
|
+
tunnel_url: tunnelUrl || null,
|
|
2534
|
+
app_version: VERSION,
|
|
2535
|
+
schema_version: CURRENT_SCHEMA_VERSION,
|
|
1671
2536
|
});
|
|
1672
2537
|
}
|
|
1673
2538
|
|
|
1674
2539
|
// GET /tunnel — tunnel status (for dashboard polling)
|
|
1675
2540
|
if (method === "GET" && reqPath === "/tunnel") {
|
|
1676
2541
|
return ok(res, {
|
|
2542
|
+
status: tunnelStatus,
|
|
1677
2543
|
running: !!tunnelProc,
|
|
1678
|
-
url: tunnelUrl,
|
|
2544
|
+
url: tunnelUrl || null,
|
|
1679
2545
|
sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
|
|
1680
|
-
error: tunnelError,
|
|
2546
|
+
error: tunnelError || null,
|
|
1681
2547
|
});
|
|
1682
2548
|
}
|
|
1683
2549
|
|
|
@@ -1686,6 +2552,15 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1686
2552
|
return ok(res, buildStatus);
|
|
1687
2553
|
}
|
|
1688
2554
|
|
|
2555
|
+
// GET /migrations — migration registry and last run result
|
|
2556
|
+
if (method === "GET" && reqPath === "/migrations") {
|
|
2557
|
+
return ok(res, {
|
|
2558
|
+
schema_version: CURRENT_SCHEMA_VERSION,
|
|
2559
|
+
last_result: lastMigrationResult,
|
|
2560
|
+
pending_breaking: pendingBreakingMigrations,
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
|
|
1689
2564
|
// GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
|
|
1690
2565
|
if (method === "GET" && reqPath === "/mcp-setup") {
|
|
1691
2566
|
return ok(res, {
|
|
@@ -1695,7 +2570,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1695
2570
|
});
|
|
1696
2571
|
}
|
|
1697
2572
|
|
|
1698
|
-
// POST /tunnel — start or stop tunnel manually
|
|
2573
|
+
// POST /tunnel — start or stop tunnel manually (action in body)
|
|
1699
2574
|
if (method === "POST" && reqPath === "/tunnel") {
|
|
1700
2575
|
if (lockedGuard(res)) return;
|
|
1701
2576
|
let body;
|
|
@@ -1705,11 +2580,27 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1705
2580
|
}
|
|
1706
2581
|
if (body.action === "stop") {
|
|
1707
2582
|
stopTunnel();
|
|
1708
|
-
return ok(res, {
|
|
2583
|
+
return ok(res, { status: tunnelStatus, running: false });
|
|
1709
2584
|
}
|
|
1710
2585
|
// start
|
|
1711
2586
|
await startTunnel();
|
|
1712
|
-
return ok(res, {
|
|
2587
|
+
return ok(res, { status: tunnelStatus, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
// POST /tunnel/start — explicit start endpoint
|
|
2591
|
+
if (method === "POST" && reqPath === "/tunnel/start") {
|
|
2592
|
+
if (lockedGuard(res)) return;
|
|
2593
|
+
if (tunnelProc) return ok(res, { status: tunnelStatus, message: "already running" });
|
|
2594
|
+
tunnelStatus = "starting";
|
|
2595
|
+
startTunnel().catch(() => {});
|
|
2596
|
+
return ok(res, { status: "starting" });
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// POST /tunnel/stop — explicit stop endpoint
|
|
2600
|
+
if (method === "POST" && reqPath === "/tunnel/stop") {
|
|
2601
|
+
if (lockedGuard(res)) return;
|
|
2602
|
+
stopTunnel();
|
|
2603
|
+
return ok(res, { status: tunnelStatus });
|
|
1713
2604
|
}
|
|
1714
2605
|
|
|
1715
2606
|
// GET /shutdown (for daemon stop)
|
|
@@ -1756,12 +2647,14 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1756
2647
|
}
|
|
1757
2648
|
}
|
|
1758
2649
|
|
|
1759
|
-
// GET /status
|
|
2650
|
+
// GET /status?project=xxx
|
|
1760
2651
|
if (method === "GET" && reqPath === "/status") {
|
|
1761
2652
|
if (lockedGuard(res)) return;
|
|
1762
2653
|
try {
|
|
2654
|
+
const parsedUrl = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
2655
|
+
const projectFilter = parsedUrl.searchParams.get("project") || undefined;
|
|
1763
2656
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
1764
|
-
const result = await api.status(password, machineHash, token, timestamp);
|
|
2657
|
+
const result = await api.status(password, machineHash, token, timestamp, projectFilter);
|
|
1765
2658
|
if (result.error) return strike(res, 502, result.error);
|
|
1766
2659
|
if (whitelist) {
|
|
1767
2660
|
result.services = (result.services || []).filter(
|
|
@@ -1815,8 +2708,32 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1815
2708
|
password = pw; // unlock — store in process memory only
|
|
1816
2709
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
1817
2710
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1818
|
-
// Auto-start
|
|
1819
|
-
|
|
2711
|
+
// Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
|
|
2712
|
+
if (!tunnelHostname) {
|
|
2713
|
+
fetchTunnelConfig().then(configured => {
|
|
2714
|
+
if (configured) {
|
|
2715
|
+
tunnelHostname = configured;
|
|
2716
|
+
tunnelStatus = "starting";
|
|
2717
|
+
startTunnel().catch(() => {});
|
|
2718
|
+
} else {
|
|
2719
|
+
// No tunnel configured in DB and no --tunnel flag
|
|
2720
|
+
tunnelStatus = "not_configured";
|
|
2721
|
+
const msg = [
|
|
2722
|
+
`[${new Date().toISOString()}] No tunnel configured.`,
|
|
2723
|
+
" claude.ai web integration is inactive.",
|
|
2724
|
+
" To enable: run 'clauth tunnel setup'",
|
|
2725
|
+
].join("\n");
|
|
2726
|
+
try { fs.appendFileSync(LOG_FILE, msg + "\n"); } catch {}
|
|
2727
|
+
console.log("\n ⚠ No tunnel configured — claude.ai web integration inactive.");
|
|
2728
|
+
console.log(" Run: clauth tunnel setup\n");
|
|
2729
|
+
}
|
|
2730
|
+
}).catch(() => {
|
|
2731
|
+
tunnelStatus = "error";
|
|
2732
|
+
});
|
|
2733
|
+
} else {
|
|
2734
|
+
tunnelStatus = "starting";
|
|
2735
|
+
startTunnel().catch(() => {});
|
|
2736
|
+
}
|
|
1820
2737
|
return ok(res, { ok: true, locked: false });
|
|
1821
2738
|
} catch {
|
|
1822
2739
|
// Wrong password — not a lockout strike, just a UI auth attempt
|
|
@@ -1942,6 +2859,415 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1942
2859
|
}
|
|
1943
2860
|
}
|
|
1944
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
|
+
|
|
1945
3271
|
// POST /change-pw — change master password (must be unlocked)
|
|
1946
3272
|
if (method === "POST" && reqPath === "/change-pw") {
|
|
1947
3273
|
if (lockedGuard(res)) return;
|
|
@@ -2014,7 +3340,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
2014
3340
|
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
2015
3341
|
}
|
|
2016
3342
|
|
|
2017
|
-
const { name, label, key_type, description } = body;
|
|
3343
|
+
const { name, label, key_type, description, project } = body;
|
|
2018
3344
|
if (!name || typeof name !== "string" || !name.trim()) {
|
|
2019
3345
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
2020
3346
|
return res.end(JSON.stringify({ error: "name is required" }));
|
|
@@ -2028,7 +3354,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
2028
3354
|
|
|
2029
3355
|
try {
|
|
2030
3356
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
2031
|
-
const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "");
|
|
3357
|
+
const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "", project || undefined);
|
|
2032
3358
|
if (result.error) return strike(res, 502, result.error);
|
|
2033
3359
|
return ok(res, { ok: true, service: name.trim().toLowerCase() });
|
|
2034
3360
|
} catch (err) {
|
|
@@ -2036,6 +3362,42 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
2036
3362
|
}
|
|
2037
3363
|
}
|
|
2038
3364
|
|
|
3365
|
+
// POST /update-service — update service metadata (project, label, description)
|
|
3366
|
+
if (method === "POST" && reqPath === "/update-service") {
|
|
3367
|
+
if (lockedGuard(res)) return;
|
|
3368
|
+
|
|
3369
|
+
let body;
|
|
3370
|
+
try { body = await readBody(req); } catch {
|
|
3371
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3372
|
+
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
const { service, project, label, description } = body;
|
|
3376
|
+
if (!service || typeof service !== "string") {
|
|
3377
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3378
|
+
return res.end(JSON.stringify({ error: "service is required" }));
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
const updates = {};
|
|
3382
|
+
if (project !== undefined) updates.project = project;
|
|
3383
|
+
if (label !== undefined) updates.label = label;
|
|
3384
|
+
if (description !== undefined) updates.description = description;
|
|
3385
|
+
|
|
3386
|
+
if (Object.keys(updates).length === 0) {
|
|
3387
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
3388
|
+
return res.end(JSON.stringify({ error: "At least one field to update is required (project, label, description)" }));
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
try {
|
|
3392
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
3393
|
+
const result = await api.updateService(password, machineHash, token, timestamp, service.toLowerCase(), updates);
|
|
3394
|
+
if (result.error) return strike(res, 502, result.error);
|
|
3395
|
+
return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
|
|
3396
|
+
} catch (err) {
|
|
3397
|
+
return strike(res, 502, err.message);
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
|
|
2039
3401
|
// Unknown route — don't count browser/MCP noise as auth failures
|
|
2040
3402
|
// Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
|
|
2041
3403
|
const isBenign = reqPath.startsWith("/.well-known/") || [
|
|
@@ -2412,6 +3774,24 @@ const MCP_TOOLS = [
|
|
|
2412
3774
|
additionalProperties: false
|
|
2413
3775
|
}
|
|
2414
3776
|
},
|
|
3777
|
+
{
|
|
3778
|
+
name: "clauth_set_project",
|
|
3779
|
+
description: "Set or clear the project scope on a service. Pass empty string to clear.",
|
|
3780
|
+
inputSchema: {
|
|
3781
|
+
type: "object",
|
|
3782
|
+
properties: {
|
|
3783
|
+
service: { type: "string", description: "Service name (e.g. gmail, github)" },
|
|
3784
|
+
project: { type: "string", description: "Project name to assign (empty string to clear)" }
|
|
3785
|
+
},
|
|
3786
|
+
required: ["service", "project"],
|
|
3787
|
+
additionalProperties: false
|
|
3788
|
+
}
|
|
3789
|
+
},
|
|
3790
|
+
{
|
|
3791
|
+
name: "clauth_self_check",
|
|
3792
|
+
description: "Test whether the clauth MCP connector is reachable via the Cloudflare tunnel. Returns connectivity status and tunnel URL.",
|
|
3793
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
3794
|
+
},
|
|
2415
3795
|
];
|
|
2416
3796
|
|
|
2417
3797
|
function writeTempSecret(service, value) {
|
|
@@ -2479,13 +3859,14 @@ async function handleMcpTool(vault, name, args) {
|
|
|
2479
3859
|
if (vault.whitelist) {
|
|
2480
3860
|
services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
|
|
2481
3861
|
}
|
|
2482
|
-
const lines = ["SERVICE
|
|
2483
|
-
"
|
|
3862
|
+
const lines = ["SERVICE TYPE PROJECT STATUS KEY LAST RETRIEVED",
|
|
3863
|
+
"------- ---- ------- ------ --- --------------"];
|
|
2484
3864
|
for (const s of services) {
|
|
2485
3865
|
const status = s.enabled ? "ACTIVE" : (s.vault_key ? "SUSPENDED" : "NO KEY");
|
|
2486
3866
|
const hasKey = s.vault_key ? "yes" : "—";
|
|
2487
3867
|
const lastGet = s.last_retrieved ? new Date(s.last_retrieved).toLocaleDateString() : "never";
|
|
2488
|
-
|
|
3868
|
+
const proj = (s.project || "—").padEnd(22);
|
|
3869
|
+
lines.push(`${s.name.padEnd(24)} ${(s.key_type || "").padEnd(12)} ${proj} ${status.padEnd(12)} ${hasKey.padEnd(6)} ${lastGet}`);
|
|
2489
3870
|
}
|
|
2490
3871
|
return mcpResult(lines.join("\n"));
|
|
2491
3872
|
} catch (err) {
|
|
@@ -2652,6 +4033,57 @@ async function handleMcpTool(vault, name, args) {
|
|
|
2652
4033
|
}
|
|
2653
4034
|
}
|
|
2654
4035
|
|
|
4036
|
+
case "clauth_set_project": {
|
|
4037
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
4038
|
+
const service = (args.service || "").toLowerCase();
|
|
4039
|
+
const project = args.project;
|
|
4040
|
+
if (!service) return mcpError("service is required");
|
|
4041
|
+
if (project === undefined) return mcpError("project is required (empty string to clear)");
|
|
4042
|
+
try {
|
|
4043
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
4044
|
+
const result = await api.updateService(vault.password, vault.machineHash, token, timestamp, service, { project: project || "" });
|
|
4045
|
+
if (result.error) return mcpError(result.error);
|
|
4046
|
+
return mcpResult(project ? `${service} → project: ${project}` : `${service} → project cleared`);
|
|
4047
|
+
} catch (err) {
|
|
4048
|
+
return mcpError(err.message);
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
case "clauth_self_check": {
|
|
4053
|
+
// Test connectivity via the Cloudflare tunnel and/or localhost daemon
|
|
4054
|
+
const tunnelUrl = vault.tunnelUrl;
|
|
4055
|
+
const results = [];
|
|
4056
|
+
|
|
4057
|
+
// Check localhost daemon
|
|
4058
|
+
try {
|
|
4059
|
+
const r = await fetch("http://127.0.0.1:52437/ping", { signal: AbortSignal.timeout(3000) });
|
|
4060
|
+
const data = await r.json();
|
|
4061
|
+
results.push(`Local daemon: PASS (${data.locked ? "locked" : "unlocked"}, failures: ${data.failures ?? 0})`);
|
|
4062
|
+
} catch (err) {
|
|
4063
|
+
results.push(`Local daemon: FAIL — http://127.0.0.1:52437 not reachable (${err.message})`);
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
// Check tunnel
|
|
4067
|
+
if (tunnelUrl) {
|
|
4068
|
+
try {
|
|
4069
|
+
const r = await fetch(`${tunnelUrl}/ping`, { signal: AbortSignal.timeout(5000) });
|
|
4070
|
+
const data = await r.json();
|
|
4071
|
+
results.push(`Tunnel: PASS — ${tunnelUrl} reachable (${data.locked ? "locked" : "unlocked"})`);
|
|
4072
|
+
results.push(`claude.ai SSE endpoint: ${tunnelUrl}/sse`);
|
|
4073
|
+
results.push(`claude.ai MCP endpoint: ${tunnelUrl}/mcp`);
|
|
4074
|
+
} catch (err) {
|
|
4075
|
+
results.push(`Tunnel: FAIL — ${tunnelUrl} not reachable (${err.message})`);
|
|
4076
|
+
results.push("claude.ai MCP connector will not work until tunnel is restored.");
|
|
4077
|
+
results.push("Fix: clauth serve start --tunnel clauth.prtrust.fund");
|
|
4078
|
+
}
|
|
4079
|
+
} else {
|
|
4080
|
+
results.push("Tunnel: NOT RUNNING — claude.ai connector requires the tunnel.");
|
|
4081
|
+
results.push("Start with: clauth serve start --tunnel clauth.prtrust.fund");
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
return mcpResult(results.join("\n"));
|
|
4085
|
+
}
|
|
4086
|
+
|
|
2655
4087
|
default:
|
|
2656
4088
|
return mcpError(`Unknown tool: ${name}`);
|
|
2657
4089
|
}
|
|
@@ -2674,6 +4106,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
2674
4106
|
const vault = {
|
|
2675
4107
|
password: initPassword || null,
|
|
2676
4108
|
get machineHash() { return ensureMachineHash(); },
|
|
4109
|
+
get tunnelUrl() { return null; }, // stdio server has no tunnel — self_check will try localhost daemon
|
|
2677
4110
|
whitelist,
|
|
2678
4111
|
failCount: 0,
|
|
2679
4112
|
MAX_FAILS: 10,
|
|
@@ -2765,61 +4198,154 @@ async function actionMcp(opts) {
|
|
|
2765
4198
|
createMcpServer(password, whitelist);
|
|
2766
4199
|
}
|
|
2767
4200
|
|
|
2768
|
-
// ──
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
4201
|
+
// ── Cross-platform auto-start install / uninstall ────────────
|
|
4202
|
+
// Windows: DPAPI + Scheduled Task
|
|
4203
|
+
// macOS: Keychain + LaunchAgent
|
|
4204
|
+
// Linux: libsecret/encrypted file + systemd user service
|
|
4205
|
+
|
|
4206
|
+
const TASK_NAME = "ClauthAutostart";
|
|
4207
|
+
|
|
4208
|
+
function getAutostartDir() {
|
|
4209
|
+
const platform = os.platform();
|
|
4210
|
+
if (platform === "win32") {
|
|
4211
|
+
return path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
4212
|
+
} else if (platform === "darwin") {
|
|
4213
|
+
return path.join(os.homedir(), "Library", "LaunchAgents");
|
|
4214
|
+
} else {
|
|
4215
|
+
return path.join(os.homedir(), ".config", "systemd", "user");
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
function getBootKeyPath() {
|
|
4220
|
+
if (os.platform() === "win32") {
|
|
4221
|
+
return path.join(os.homedir(), "AppData", "Roaming", "clauth", "boot.key");
|
|
4222
|
+
} else if (os.platform() === "darwin") {
|
|
4223
|
+
return null; // stored in Keychain, not a file
|
|
4224
|
+
} else {
|
|
4225
|
+
return path.join(os.homedir(), ".config", "clauth", "boot.key");
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
2773
4228
|
|
|
2774
4229
|
async function actionInstall(opts) {
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
4230
|
+
const platform = os.platform();
|
|
4231
|
+
const { execSync } = await import("child_process");
|
|
4232
|
+
const config = new Conf(getConfOptions());
|
|
4233
|
+
|
|
4234
|
+
const tunnelHostname = opts.tunnel || null;
|
|
4235
|
+
|
|
4236
|
+
// Persist tunnel hostname in config if provided
|
|
4237
|
+
if (tunnelHostname) {
|
|
4238
|
+
config.set("tunnel_hostname", tunnelHostname);
|
|
4239
|
+
console.log(chalk.gray(`\n Tunnel hostname saved: ${tunnelHostname}`));
|
|
2778
4240
|
}
|
|
2779
4241
|
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
4242
|
+
// Check for cloudflared if tunnel is requested
|
|
4243
|
+
if (tunnelHostname) {
|
|
4244
|
+
try {
|
|
4245
|
+
execSync("cloudflared --version", { encoding: "utf8", stdio: "pipe" });
|
|
4246
|
+
} catch {
|
|
4247
|
+
console.log(chalk.yellow("\n cloudflared not found — required for tunnel support"));
|
|
4248
|
+
console.log(chalk.gray(" Install: winget install Cloudflare.cloudflared"));
|
|
4249
|
+
try {
|
|
4250
|
+
const { installCloudflared } = await import("./doctor.js");
|
|
4251
|
+
await installCloudflared();
|
|
4252
|
+
} catch {
|
|
4253
|
+
console.log(chalk.yellow(" Continuing without tunnel — install cloudflared manually later"));
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
2785
4257
|
|
|
2786
|
-
|
|
4258
|
+
// Two modes:
|
|
4259
|
+
// 1. Password provided via -p flag → encrypt + install (non-interactive)
|
|
4260
|
+
// 2. No password → install watchdog that starts daemon in locked mode
|
|
4261
|
+
// The browser dashboard opens, user enters password there.
|
|
4262
|
+
// For passwordless restart, user can later run: clauth serve seal
|
|
4263
|
+
const pw = opts.pw || null;
|
|
2787
4264
|
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
2795
|
-
encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
|
|
2796
|
-
fs.writeFileSync(BOOT_KEY_PATH, encrypted, "utf8");
|
|
2797
|
-
spinner.succeed(chalk.green("Password encrypted → boot.key"));
|
|
2798
|
-
} catch (err) {
|
|
2799
|
-
spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
|
|
2800
|
-
process.exit(1);
|
|
4265
|
+
if (platform === "win32") {
|
|
4266
|
+
await installWindows(pw, tunnelHostname, execSync);
|
|
4267
|
+
} else if (platform === "darwin") {
|
|
4268
|
+
await installMacOS(pw, tunnelHostname, execSync);
|
|
4269
|
+
} else {
|
|
4270
|
+
await installLinux(pw, tunnelHostname, execSync);
|
|
2801
4271
|
}
|
|
4272
|
+
}
|
|
2802
4273
|
|
|
2803
|
-
|
|
4274
|
+
// ── Windows: DPAPI + Scheduled Task ────────────────────────
|
|
4275
|
+
async function installWindows(pw, tunnelHostname, execSync) {
|
|
4276
|
+
const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
4277
|
+
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
4278
|
+
const psScriptPath = path.join(autostartDir, "autostart.ps1");
|
|
2804
4279
|
const cliEntry = path.resolve(__dirname, "../index.js").replace(/\\/g, "\\\\");
|
|
2805
4280
|
const nodeExe = process.execPath.replace(/\\/g, "\\\\");
|
|
2806
|
-
const bootKey =
|
|
2807
|
-
const
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
4281
|
+
const bootKey = bootKeyPath.replace(/\\/g, "\\\\");
|
|
4282
|
+
const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
|
|
4283
|
+
|
|
4284
|
+
fs.mkdirSync(autostartDir, { recursive: true });
|
|
4285
|
+
|
|
4286
|
+
if (pw) {
|
|
4287
|
+
// ── Sealed mode: DPAPI-encrypt password for fully unattended restart ──
|
|
4288
|
+
const spinner = ora("Encrypting password with Windows DPAPI...").start();
|
|
4289
|
+
try {
|
|
4290
|
+
const pwEscaped = pw.replace(/'/g, "''");
|
|
4291
|
+
const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
4292
|
+
const encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
|
|
4293
|
+
fs.writeFileSync(bootKeyPath, encrypted, "utf8");
|
|
4294
|
+
spinner.succeed(chalk.green("Password sealed via DPAPI → boot.key"));
|
|
4295
|
+
} catch (err) {
|
|
4296
|
+
spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
|
|
4297
|
+
process.exit(1);
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
// Watchdog script: decrypts password, starts daemon unlocked, monitors for crash
|
|
4301
|
+
const psScript = [
|
|
4302
|
+
"# clauth autostart + watchdog (sealed mode)",
|
|
4303
|
+
"# Starts unlocked and restarts on crash every 15s",
|
|
4304
|
+
"",
|
|
4305
|
+
`$enc = (Get-Content '${bootKey}' -Raw).Trim()`,
|
|
4306
|
+
`$pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))`,
|
|
4307
|
+
"",
|
|
4308
|
+
"while ($true) {",
|
|
4309
|
+
" try {",
|
|
4310
|
+
" $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
|
|
4311
|
+
" } catch {",
|
|
4312
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
|
|
4313
|
+
" Start-Sleep -Seconds 5",
|
|
4314
|
+
" }",
|
|
4315
|
+
" Start-Sleep -Seconds 15",
|
|
4316
|
+
"}",
|
|
4317
|
+
].join("\n");
|
|
4318
|
+
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
4319
|
+
} else {
|
|
4320
|
+
// ── First-run mode: no password yet — start locked, browser opens for setup ──
|
|
4321
|
+
// Watchdog starts the daemon in locked mode; user enters password in the browser dashboard.
|
|
4322
|
+
// After entering password in the browser, user can seal it for future unattended restarts
|
|
4323
|
+
// by running: clauth serve seal
|
|
4324
|
+
const psScript = [
|
|
4325
|
+
"# clauth autostart + watchdog (locked mode — browser password entry)",
|
|
4326
|
+
"# Starts daemon locked, opens browser for password. Restarts on crash every 15s.",
|
|
4327
|
+
"",
|
|
4328
|
+
"while ($true) {",
|
|
4329
|
+
" try {",
|
|
4330
|
+
" $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
|
|
4331
|
+
" } catch {",
|
|
4332
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
4333
|
+
" Start-Sleep -Seconds 5",
|
|
4334
|
+
" }",
|
|
4335
|
+
" Start-Sleep -Seconds 15",
|
|
4336
|
+
"}",
|
|
4337
|
+
].join("\n");
|
|
4338
|
+
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
4339
|
+
}
|
|
2814
4340
|
|
|
2815
4341
|
// Register Windows Scheduled Task — triggers on user logon
|
|
2816
|
-
const
|
|
4342
|
+
const mode = pw ? "sealed — fully unattended" : "locked — browser password entry";
|
|
4343
|
+
const spinner2 = ora(`Registering Scheduled Task (${mode})...`).start();
|
|
2817
4344
|
try {
|
|
2818
|
-
const
|
|
2819
|
-
const psScriptEsc = PS_SCRIPT_PATH.replace(/\\/g, "\\\\");
|
|
4345
|
+
const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
|
|
2820
4346
|
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
2821
4347
|
execSync(
|
|
2822
|
-
|
|
4348
|
+
`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
|
|
2823
4349
|
{ encoding: "utf8", stdio: "pipe" }
|
|
2824
4350
|
);
|
|
2825
4351
|
spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
|
|
@@ -2828,33 +4354,327 @@ async function actionInstall(opts) {
|
|
|
2828
4354
|
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
2829
4355
|
}
|
|
2830
4356
|
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
4357
|
+
// Start the daemon now
|
|
4358
|
+
const spinner3 = ora("Starting daemon...").start();
|
|
4359
|
+
try {
|
|
4360
|
+
if (pw) {
|
|
4361
|
+
execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start -p "${pw}"`, {
|
|
4362
|
+
encoding: "utf8", stdio: "pipe", timeout: 10000,
|
|
4363
|
+
});
|
|
4364
|
+
} else {
|
|
4365
|
+
execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start`, {
|
|
4366
|
+
encoding: "utf8", stdio: "pipe", timeout: 10000,
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
spinner3.succeed(chalk.green("Daemon started"));
|
|
4370
|
+
} catch {
|
|
4371
|
+
spinner3.succeed(chalk.green("Daemon starting..."));
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
// Open browser for first-run password setup (locked mode only)
|
|
4375
|
+
if (!pw) {
|
|
4376
|
+
try { openBrowser("http://127.0.0.1:52437"); } catch {}
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
console.log(chalk.cyan("\n Auto-start installed (Windows):\n"));
|
|
4380
|
+
if (pw) {
|
|
4381
|
+
console.log(chalk.gray(` mode: sealed (DPAPI — fully unattended restart)`));
|
|
4382
|
+
console.log(chalk.gray(` boot.key: ${bootKeyPath}`));
|
|
4383
|
+
} else {
|
|
4384
|
+
console.log(chalk.gray(` mode: locked (enter password in browser on restart)`));
|
|
4385
|
+
console.log(chalk.gray(` browser: http://127.0.0.1:52437`));
|
|
4386
|
+
console.log(chalk.gray(` seal later: clauth serve seal (enables unattended restart)`));
|
|
4387
|
+
}
|
|
4388
|
+
console.log(chalk.gray(` watchdog: ${psScriptPath}`));
|
|
4389
|
+
console.log(chalk.gray(` task: ${TASK_NAME} (restarts every 15s on crash)`));
|
|
4390
|
+
if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
|
|
4391
|
+
console.log(chalk.green("\n Done. Daemon will auto-start on login and restart on crash.\n"));
|
|
2836
4392
|
}
|
|
2837
4393
|
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
4394
|
+
// ── macOS: Keychain + LaunchAgent ──────────────────────────
|
|
4395
|
+
async function installMacOS(pw, tunnelHostname, execSync) {
|
|
4396
|
+
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
|
|
4397
|
+
const plistPath = path.join(launchAgentsDir, "com.lifeai.clauth.plist");
|
|
4398
|
+
const keychainService = "com.lifeai.clauth";
|
|
4399
|
+
const keychainAccount = "clauth-daemon";
|
|
4400
|
+
|
|
4401
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
4402
|
+
|
|
4403
|
+
// Store password in macOS Keychain
|
|
4404
|
+
const spinner = ora("Storing password in macOS Keychain...").start();
|
|
4405
|
+
try {
|
|
4406
|
+
// Delete existing entry if present (ignore errors)
|
|
4407
|
+
try {
|
|
4408
|
+
execSync(
|
|
4409
|
+
`security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
|
|
4410
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4411
|
+
);
|
|
4412
|
+
} catch {}
|
|
4413
|
+
|
|
4414
|
+
execSync(
|
|
4415
|
+
`security add-generic-password -s "${keychainService}" -a "${keychainAccount}" -w "${pw.replace(/"/g, '\\"')}" -U`,
|
|
4416
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4417
|
+
);
|
|
4418
|
+
spinner.succeed(chalk.green("Password stored in Keychain"));
|
|
4419
|
+
} catch (err) {
|
|
4420
|
+
spinner.fail(chalk.red(`Keychain storage failed: ${err.message}`));
|
|
2841
4421
|
process.exit(1);
|
|
2842
4422
|
}
|
|
4423
|
+
|
|
4424
|
+
// Find the node executable and cli entry
|
|
4425
|
+
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
4426
|
+
const nodeExe = process.execPath;
|
|
4427
|
+
|
|
4428
|
+
// Build shell command that reads from Keychain and starts clauth
|
|
4429
|
+
const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
|
|
4430
|
+
const shellCmd = `PW=$(security find-generic-password -s "${keychainService}" -a "${keychainAccount}" -w) && exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`;
|
|
4431
|
+
|
|
4432
|
+
// Create LaunchAgent plist
|
|
4433
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4434
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4435
|
+
<plist version="1.0">
|
|
4436
|
+
<dict>
|
|
4437
|
+
<key>Label</key>
|
|
4438
|
+
<string>com.lifeai.clauth</string>
|
|
4439
|
+
<key>ProgramArguments</key>
|
|
4440
|
+
<array>
|
|
4441
|
+
<string>/bin/sh</string>
|
|
4442
|
+
<string>-c</string>
|
|
4443
|
+
<string>${shellCmd.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</string>
|
|
4444
|
+
</array>
|
|
4445
|
+
<key>RunAtLoad</key>
|
|
4446
|
+
<true/>
|
|
4447
|
+
<key>KeepAlive</key>
|
|
4448
|
+
<true/>
|
|
4449
|
+
<key>StandardOutPath</key>
|
|
4450
|
+
<string>/tmp/clauth-serve.log</string>
|
|
4451
|
+
<key>StandardErrorPath</key>
|
|
4452
|
+
<string>/tmp/clauth-serve.log</string>
|
|
4453
|
+
</dict>
|
|
4454
|
+
</plist>`;
|
|
4455
|
+
|
|
4456
|
+
fs.writeFileSync(plistPath, plistContent, "utf8");
|
|
4457
|
+
|
|
4458
|
+
// Load the LaunchAgent
|
|
4459
|
+
const spinner2 = ora("Loading LaunchAgent...").start();
|
|
4460
|
+
try {
|
|
4461
|
+
// Unload first in case it's already loaded (ignore errors)
|
|
4462
|
+
try { execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" }); } catch {}
|
|
4463
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
4464
|
+
spinner2.succeed(chalk.green("LaunchAgent loaded"));
|
|
4465
|
+
} catch (err) {
|
|
4466
|
+
spinner2.fail(chalk.yellow(`LaunchAgent load failed (non-fatal): ${err.message}`));
|
|
4467
|
+
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
console.log(chalk.cyan("\n Auto-start installed (macOS):\n"));
|
|
4471
|
+
console.log(chalk.gray(` plist: ${plistPath}`));
|
|
4472
|
+
console.log(chalk.gray(` keychain: ${keychainService}`));
|
|
4473
|
+
if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
|
|
4474
|
+
console.log(chalk.green("\n Daemon will auto-start on login and restart on crash.\n"));
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
// ── Linux: encrypted file + systemd user service ───────────
|
|
4478
|
+
async function installLinux(pw, tunnelHostname, execSync) {
|
|
4479
|
+
const configDir = path.join(os.homedir(), ".config", "clauth");
|
|
4480
|
+
const bootKeyPath = path.join(configDir, "boot.key");
|
|
4481
|
+
const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
|
|
4482
|
+
const serviceFile = path.join(systemdDir, "clauth.service");
|
|
4483
|
+
|
|
4484
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
4485
|
+
fs.mkdirSync(systemdDir, { recursive: true });
|
|
4486
|
+
|
|
4487
|
+
// Try to store password using secret-tool (libsecret/GNOME Keyring)
|
|
4488
|
+
let useSecretTool = false;
|
|
4489
|
+
const spinner = ora("Storing password securely...").start();
|
|
4490
|
+
|
|
4491
|
+
try {
|
|
4492
|
+
execSync("which secret-tool", { encoding: "utf8", stdio: "pipe" });
|
|
4493
|
+
execSync(
|
|
4494
|
+
`echo -n "${pw.replace(/"/g, '\\"')}" | secret-tool store --label="clauth daemon password" service clauth account daemon`,
|
|
4495
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4496
|
+
);
|
|
4497
|
+
useSecretTool = true;
|
|
4498
|
+
spinner.succeed(chalk.green("Password stored via secret-tool (GNOME Keyring)"));
|
|
4499
|
+
} catch {
|
|
4500
|
+
// Fallback: encrypt with openssl and store in file
|
|
4501
|
+
// Uses a key derived from machine-id for basic protection at rest
|
|
4502
|
+
try {
|
|
4503
|
+
const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim();
|
|
4504
|
+
const encrypted = execSync(
|
|
4505
|
+
`echo -n "${pw.replace(/"/g, '\\"')}" | openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass pass:"${machineId}" -base64`,
|
|
4506
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4507
|
+
).trim();
|
|
4508
|
+
fs.writeFileSync(bootKeyPath, encrypted, { mode: 0o600 });
|
|
4509
|
+
spinner.succeed(chalk.green("Password encrypted -> boot.key (openssl fallback)"));
|
|
4510
|
+
} catch (err) {
|
|
4511
|
+
spinner.fail(chalk.red(`Password storage failed: ${err.message}`));
|
|
4512
|
+
process.exit(1);
|
|
4513
|
+
}
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
// Find the node executable and cli entry
|
|
4517
|
+
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
4518
|
+
const nodeExe = process.execPath;
|
|
4519
|
+
|
|
4520
|
+
// Build the ExecStart command
|
|
4521
|
+
const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
|
|
4522
|
+
let execStart;
|
|
4523
|
+
if (useSecretTool) {
|
|
4524
|
+
// Create a wrapper script that reads from secret-tool
|
|
4525
|
+
const wrapperPath = path.join(configDir, "start.sh");
|
|
4526
|
+
const wrapperContent = [
|
|
4527
|
+
"#!/bin/sh",
|
|
4528
|
+
`PW=$(secret-tool lookup service clauth account daemon)`,
|
|
4529
|
+
`exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`,
|
|
4530
|
+
].join("\n");
|
|
4531
|
+
fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
|
|
4532
|
+
execStart = `/bin/sh ${wrapperPath}`;
|
|
4533
|
+
} else {
|
|
4534
|
+
// Create a wrapper script that decrypts boot.key
|
|
4535
|
+
const wrapperPath = path.join(configDir, "start.sh");
|
|
4536
|
+
const wrapperContent = [
|
|
4537
|
+
"#!/bin/sh",
|
|
4538
|
+
`MACHINE_ID=$(cat /etc/machine-id)`,
|
|
4539
|
+
`PW=$(openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass pass:"$MACHINE_ID" -base64 -d < "${bootKeyPath}")`,
|
|
4540
|
+
`exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`,
|
|
4541
|
+
].join("\n");
|
|
4542
|
+
fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
|
|
4543
|
+
execStart = `/bin/sh ${wrapperPath}`;
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
// Create systemd user service
|
|
4547
|
+
const serviceContent = `[Unit]
|
|
4548
|
+
Description=clauth credential daemon
|
|
4549
|
+
After=network-online.target
|
|
4550
|
+
Wants=network-online.target
|
|
4551
|
+
|
|
4552
|
+
[Service]
|
|
4553
|
+
Type=simple
|
|
4554
|
+
ExecStart=${execStart}
|
|
4555
|
+
Restart=always
|
|
4556
|
+
RestartSec=5
|
|
4557
|
+
Environment=NODE_ENV=production
|
|
4558
|
+
|
|
4559
|
+
[Install]
|
|
4560
|
+
WantedBy=default.target
|
|
4561
|
+
`;
|
|
4562
|
+
|
|
4563
|
+
fs.writeFileSync(serviceFile, serviceContent, "utf8");
|
|
4564
|
+
|
|
4565
|
+
// Enable and start the service
|
|
4566
|
+
const spinner2 = ora("Enabling systemd user service...").start();
|
|
4567
|
+
try {
|
|
4568
|
+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
4569
|
+
execSync("systemctl --user enable clauth", { stdio: "pipe" });
|
|
4570
|
+
execSync("systemctl --user start clauth", { stdio: "pipe" });
|
|
4571
|
+
spinner2.succeed(chalk.green("systemd user service enabled and started"));
|
|
4572
|
+
} catch (err) {
|
|
4573
|
+
spinner2.fail(chalk.yellow(`systemd setup failed (non-fatal): ${err.message}`));
|
|
4574
|
+
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
console.log(chalk.cyan("\n Auto-start installed (Linux):\n"));
|
|
4578
|
+
console.log(chalk.gray(` service: ${serviceFile}`));
|
|
4579
|
+
console.log(chalk.gray(` password: ${useSecretTool ? "GNOME Keyring" : bootKeyPath}`));
|
|
4580
|
+
if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
|
|
4581
|
+
console.log(chalk.green("\n Daemon will auto-start on login and restart on crash.\n"));
|
|
4582
|
+
}
|
|
4583
|
+
|
|
4584
|
+
// ── Cross-platform uninstall ───────────────────────────────
|
|
4585
|
+
async function actionUninstall() {
|
|
4586
|
+
const platform = os.platform();
|
|
2843
4587
|
const { execSync } = await import("child_process");
|
|
2844
4588
|
|
|
4589
|
+
if (platform === "win32") {
|
|
4590
|
+
await uninstallWindows(execSync);
|
|
4591
|
+
} else if (platform === "darwin") {
|
|
4592
|
+
await uninstallMacOS(execSync);
|
|
4593
|
+
} else {
|
|
4594
|
+
await uninstallLinux(execSync);
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
async function uninstallWindows(execSync) {
|
|
4599
|
+
const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
4600
|
+
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
4601
|
+
const psScriptPath = path.join(autostartDir, "autostart.ps1");
|
|
4602
|
+
|
|
2845
4603
|
// Remove scheduled task
|
|
2846
4604
|
try {
|
|
2847
|
-
execSync(
|
|
4605
|
+
execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
|
|
2848
4606
|
console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
|
|
2849
4607
|
} catch { console.log(chalk.gray(` Task not found (already removed): ${TASK_NAME}`)); }
|
|
2850
4608
|
|
|
2851
4609
|
// Remove boot.key and autostart script
|
|
2852
|
-
for (const f of [
|
|
4610
|
+
for (const f of [bootKeyPath, psScriptPath]) {
|
|
4611
|
+
try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
|
|
4612
|
+
catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
console.log(chalk.cyan("\n Auto-start uninstalled (Windows).\n"));
|
|
4616
|
+
}
|
|
4617
|
+
|
|
4618
|
+
async function uninstallMacOS(execSync) {
|
|
4619
|
+
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.lifeai.clauth.plist");
|
|
4620
|
+
const keychainService = "com.lifeai.clauth";
|
|
4621
|
+
const keychainAccount = "clauth-daemon";
|
|
4622
|
+
|
|
4623
|
+
// Unload LaunchAgent
|
|
4624
|
+
try {
|
|
4625
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
4626
|
+
console.log(chalk.green(" Unloaded LaunchAgent"));
|
|
4627
|
+
} catch { console.log(chalk.gray(" LaunchAgent not loaded (already removed)")); }
|
|
4628
|
+
|
|
4629
|
+
// Remove plist
|
|
4630
|
+
try { fs.unlinkSync(plistPath); console.log(chalk.green(` Deleted: ${plistPath}`)); }
|
|
4631
|
+
catch { console.log(chalk.gray(` Not found (skipped): ${plistPath}`)); }
|
|
4632
|
+
|
|
4633
|
+
// Remove Keychain entry
|
|
4634
|
+
try {
|
|
4635
|
+
execSync(
|
|
4636
|
+
`security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
|
|
4637
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4638
|
+
);
|
|
4639
|
+
console.log(chalk.green(" Removed Keychain entry"));
|
|
4640
|
+
} catch { console.log(chalk.gray(" Keychain entry not found (already removed)")); }
|
|
4641
|
+
|
|
4642
|
+
console.log(chalk.cyan("\n Auto-start uninstalled (macOS).\n"));
|
|
4643
|
+
}
|
|
4644
|
+
|
|
4645
|
+
async function uninstallLinux(execSync) {
|
|
4646
|
+
const configDir = path.join(os.homedir(), ".config", "clauth");
|
|
4647
|
+
const bootKeyPath = path.join(configDir, "boot.key");
|
|
4648
|
+
const wrapperPath = path.join(configDir, "start.sh");
|
|
4649
|
+
const serviceFile = path.join(os.homedir(), ".config", "systemd", "user", "clauth.service");
|
|
4650
|
+
|
|
4651
|
+
// Stop and disable systemd service
|
|
4652
|
+
try {
|
|
4653
|
+
execSync("systemctl --user stop clauth", { stdio: "pipe" });
|
|
4654
|
+
execSync("systemctl --user disable clauth", { stdio: "pipe" });
|
|
4655
|
+
console.log(chalk.green(" Stopped and disabled systemd service"));
|
|
4656
|
+
} catch { console.log(chalk.gray(" systemd service not running (already removed)")); }
|
|
4657
|
+
|
|
4658
|
+
// Remove service file
|
|
4659
|
+
try { fs.unlinkSync(serviceFile); console.log(chalk.green(` Deleted: ${serviceFile}`)); }
|
|
4660
|
+
catch { console.log(chalk.gray(` Not found (skipped): ${serviceFile}`)); }
|
|
4661
|
+
|
|
4662
|
+
// Reload systemd
|
|
4663
|
+
try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch {}
|
|
4664
|
+
|
|
4665
|
+
// Remove boot.key and wrapper script
|
|
4666
|
+
for (const f of [bootKeyPath, wrapperPath]) {
|
|
2853
4667
|
try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
|
|
2854
4668
|
catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
|
|
2855
4669
|
}
|
|
2856
4670
|
|
|
2857
|
-
|
|
4671
|
+
// Try to remove secret-tool entry
|
|
4672
|
+
try {
|
|
4673
|
+
execSync("secret-tool clear service clauth account daemon", { stdio: "pipe" });
|
|
4674
|
+
console.log(chalk.green(" Removed secret-tool entry"));
|
|
4675
|
+
} catch { /* secret-tool may not be installed */ }
|
|
4676
|
+
|
|
4677
|
+
console.log(chalk.cyan("\n Auto-start uninstalled (Linux).\n"));
|
|
2858
4678
|
}
|
|
2859
4679
|
|
|
2860
4680
|
// ── Export ────────────────────────────────────────────────────
|