@lifeaitools/clauth 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clauth-skill/SKILL.md +184 -184
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/.clauth-skill/references/operator-guide.md +148 -148
- package/README.md +125 -125
- package/cli/api.js +113 -113
- package/cli/commands/install.js +291 -265
- package/cli/commands/scrub.js +231 -231
- package/cli/commands/serve.js +511 -1
- package/cli/commands/uninstall.js +164 -164
- package/cli/fingerprint.js +91 -91
- package/cli/index.js +5 -1
- package/install.ps1 +44 -44
- package/install.sh +38 -38
- package/package.json +54 -54
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bootstrap.cjs +43 -43
- package/scripts/build.sh +45 -45
- package/supabase/functions/auth-vault/index.ts +235 -235
- package/supabase/migrations/001_clauth_schema.sql +103 -103
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
package/cli/commands/serve.js
CHANGED
|
@@ -41,6 +41,15 @@ function isProcessAlive(pid) {
|
|
|
41
41
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function openBrowser(url) {
|
|
45
|
+
try {
|
|
46
|
+
const cmd = os.platform() === "win32" ? `start "" "${url}"`
|
|
47
|
+
: os.platform() === "darwin" ? `open "${url}"`
|
|
48
|
+
: `xdg-open "${url}"`;
|
|
49
|
+
execSync(cmd, { stdio: "ignore" });
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
// ── Dashboard HTML ───────────────────────────────────────────
|
|
45
54
|
function dashboardHtml(port, whitelist) {
|
|
46
55
|
return `<!DOCTYPE html>
|
|
@@ -127,6 +136,23 @@ function dashboardHtml(port, whitelist) {
|
|
|
127
136
|
@keyframes sdot-fade{to{opacity:0}} .status-dot.fading{animation:sdot-fade 1.5s forwards}
|
|
128
137
|
.error-bar{background:#7f1d1d;color:#fca5a5;border:1px solid #991b1b;padding:10px 14px;border-radius:8px;margin-bottom:1rem;display:none;font-size:.85rem}
|
|
129
138
|
.loading{color:#64748b;font-style:italic}
|
|
139
|
+
.tunnel-panel{background:#0f1d2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
140
|
+
.tunnel-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
|
141
|
+
.tunnel-dot.off{background:#64748b}
|
|
142
|
+
.tunnel-dot.starting{background:#f59e0b;animation:pulse 1s infinite}
|
|
143
|
+
.tunnel-dot.on{background:#22c55e;animation:pulse 2s infinite}
|
|
144
|
+
.tunnel-dot.err{background:#ef4444}
|
|
145
|
+
.tunnel-label{font-size:.85rem;color:#94a3b8;flex:1}
|
|
146
|
+
.tunnel-label strong{color:#e2e8f0}
|
|
147
|
+
.tunnel-url{font-family:'Courier New',monospace;font-size:.78rem;color:#60a5fa;word-break:break-all}
|
|
148
|
+
.tunnel-url a{color:#60a5fa;text-decoration:none}
|
|
149
|
+
.tunnel-url a:hover{text-decoration:underline}
|
|
150
|
+
.btn-claude{background:linear-gradient(135deg,#d97706,#f59e0b);color:#0a0f1a;padding:8px 18px;font-size:.85rem;border-radius:7px;border:none;cursor:pointer;font-weight:700;letter-spacing:.3px;transition:all .15s;white-space:nowrap}
|
|
151
|
+
.btn-claude:hover{filter:brightness(1.1);transform:translateY(-1px)}
|
|
152
|
+
.btn-claude:disabled{opacity:.4;cursor:not-allowed;transform:none;filter:none}
|
|
153
|
+
.btn-tunnel-stop{background:#1e293b;color:#f87171;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500}
|
|
154
|
+
.btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
|
|
155
|
+
.tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
|
|
130
156
|
.footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
|
|
131
157
|
.oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
|
|
132
158
|
.oauth-field{display:flex;flex-direction:column;gap:3px}
|
|
@@ -189,6 +215,17 @@ function dashboardHtml(port, whitelist) {
|
|
|
189
215
|
</div>
|
|
190
216
|
</div>
|
|
191
217
|
|
|
218
|
+
<div class="tunnel-panel" id="tunnel-panel">
|
|
219
|
+
<div class="tunnel-dot off" id="tunnel-dot"></div>
|
|
220
|
+
<div class="tunnel-label" id="tunnel-label">
|
|
221
|
+
<strong>claude.ai MCP</strong> — checking tunnel…
|
|
222
|
+
</div>
|
|
223
|
+
<button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
|
|
224
|
+
<button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
|
|
225
|
+
<button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
|
|
226
|
+
<div class="tunnel-err" id="tunnel-err" style="display:none"></div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
192
229
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
193
230
|
<div class="footer">localhost:${port} · 127.0.0.1 only · 3-strike lockout</div>
|
|
194
231
|
</div>
|
|
@@ -303,6 +340,7 @@ function showMain(ping) {
|
|
|
303
340
|
document.getElementById("s-fails").textContent =
|
|
304
341
|
ping.failures + "/" + (ping.failures + ping.failures_remaining);
|
|
305
342
|
}
|
|
343
|
+
pollTunnel();
|
|
306
344
|
}
|
|
307
345
|
|
|
308
346
|
// ── Unlock ──────────────────────────────────
|
|
@@ -562,6 +600,22 @@ async function checkAll() {
|
|
|
562
600
|
|
|
563
601
|
const results = r.results || {};
|
|
564
602
|
for (const [name, result] of Object.entries(results)) {
|
|
603
|
+
if (name === "__tunnel__") {
|
|
604
|
+
// Update tunnel panel with health result
|
|
605
|
+
const tDot = document.getElementById("tunnel-dot");
|
|
606
|
+
const tLabel = document.getElementById("tunnel-label");
|
|
607
|
+
if (result.ok) {
|
|
608
|
+
tDot.className = "tunnel-dot on";
|
|
609
|
+
tLabel.innerHTML = '<strong>claude.ai MCP</strong> — tunnel healthy';
|
|
610
|
+
} else if (result.running) {
|
|
611
|
+
tDot.className = "tunnel-dot starting";
|
|
612
|
+
tLabel.innerHTML = '<strong>claude.ai MCP</strong> — ' + (result.reason || "starting…");
|
|
613
|
+
} else {
|
|
614
|
+
tDot.className = "tunnel-dot err";
|
|
615
|
+
tLabel.innerHTML = '<strong>claude.ai MCP</strong> — ' + (result.reason || "not running");
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
565
619
|
const dot = document.getElementById("sdot-" + name);
|
|
566
620
|
if (!dot) continue;
|
|
567
621
|
if (result.ok) {
|
|
@@ -639,6 +693,140 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
639
693
|
});
|
|
640
694
|
});
|
|
641
695
|
|
|
696
|
+
// ── Tunnel management ───────────────────────
|
|
697
|
+
let tunnelPollTimer = null;
|
|
698
|
+
|
|
699
|
+
async function pollTunnel() {
|
|
700
|
+
if (tunnelPollTimer) clearInterval(tunnelPollTimer);
|
|
701
|
+
await updateTunnelUI();
|
|
702
|
+
// Poll every 3s until URL is found, then slow to 10s
|
|
703
|
+
tunnelPollTimer = setInterval(async () => {
|
|
704
|
+
const state = await updateTunnelUI();
|
|
705
|
+
if (state === "connected") {
|
|
706
|
+
clearInterval(tunnelPollTimer);
|
|
707
|
+
tunnelPollTimer = setInterval(updateTunnelUI, 10000);
|
|
708
|
+
}
|
|
709
|
+
}, 3000);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function updateTunnelUI() {
|
|
713
|
+
const dot = document.getElementById("tunnel-dot");
|
|
714
|
+
const label = document.getElementById("tunnel-label");
|
|
715
|
+
const btn = document.getElementById("btn-claude");
|
|
716
|
+
const togBtn = document.getElementById("btn-tunnel-toggle");
|
|
717
|
+
const errEl = document.getElementById("tunnel-err");
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
const t = await fetch(BASE + "/tunnel").then(r => r.json());
|
|
721
|
+
|
|
722
|
+
errEl.style.display = "none";
|
|
723
|
+
|
|
724
|
+
if (t.error) {
|
|
725
|
+
dot.className = "tunnel-dot err";
|
|
726
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — ' + t.error;
|
|
727
|
+
btn.disabled = true;
|
|
728
|
+
togBtn.style.display = "none";
|
|
729
|
+
togBtn.textContent = "Start Tunnel";
|
|
730
|
+
togBtn.onclick = () => toggleTunnel("start");
|
|
731
|
+
togBtn.style.display = "inline-block";
|
|
732
|
+
return "error";
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (t.running && t.url && t.url.startsWith("http")) {
|
|
736
|
+
dot.className = "tunnel-dot on";
|
|
737
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — <span class="tunnel-url"><a href="' + t.sseUrl + '" target="_blank">' + t.sseUrl + '</a></span>';
|
|
738
|
+
btn.disabled = false;
|
|
739
|
+
document.getElementById("btn-tunnel-test").style.display = "inline-block";
|
|
740
|
+
togBtn.textContent = "Stop Tunnel";
|
|
741
|
+
togBtn.onclick = () => toggleTunnel("stop");
|
|
742
|
+
togBtn.style.display = "inline-block";
|
|
743
|
+
return "connected";
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (t.running) {
|
|
747
|
+
dot.className = "tunnel-dot starting";
|
|
748
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
|
|
749
|
+
btn.disabled = true;
|
|
750
|
+
togBtn.textContent = "Stop Tunnel";
|
|
751
|
+
togBtn.onclick = () => toggleTunnel("stop");
|
|
752
|
+
togBtn.style.display = "inline-block";
|
|
753
|
+
return "starting";
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Not running
|
|
757
|
+
dot.className = "tunnel-dot off";
|
|
758
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
|
|
759
|
+
btn.disabled = true;
|
|
760
|
+
togBtn.textContent = "Start Tunnel";
|
|
761
|
+
togBtn.onclick = () => toggleTunnel("start");
|
|
762
|
+
togBtn.style.display = "inline-block";
|
|
763
|
+
return "stopped";
|
|
764
|
+
|
|
765
|
+
} catch {
|
|
766
|
+
dot.className = "tunnel-dot off";
|
|
767
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
|
|
768
|
+
btn.disabled = true;
|
|
769
|
+
togBtn.style.display = "none";
|
|
770
|
+
return "error";
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function toggleTunnel(action) {
|
|
775
|
+
const togBtn = document.getElementById("btn-tunnel-toggle");
|
|
776
|
+
togBtn.disabled = true; togBtn.textContent = "…";
|
|
777
|
+
try {
|
|
778
|
+
await fetch(BASE + "/tunnel", {
|
|
779
|
+
method: "POST",
|
|
780
|
+
headers: { "Content-Type": "application/json" },
|
|
781
|
+
body: JSON.stringify({ action })
|
|
782
|
+
});
|
|
783
|
+
// Wait a beat for tunnel to start/stop, then refresh
|
|
784
|
+
setTimeout(updateTunnelUI, action === "start" ? 5000 : 500);
|
|
785
|
+
} catch {} finally {
|
|
786
|
+
togBtn.disabled = false;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function testTunnel() {
|
|
791
|
+
const btn = document.getElementById("btn-tunnel-test");
|
|
792
|
+
const dot = document.getElementById("tunnel-dot");
|
|
793
|
+
const label = document.getElementById("tunnel-label");
|
|
794
|
+
btn.disabled = true; btn.textContent = "Testing…";
|
|
795
|
+
dot.className = "tunnel-dot starting";
|
|
796
|
+
|
|
797
|
+
try {
|
|
798
|
+
const r = await fetch(BASE + "/tunnel/test").then(r => r.json());
|
|
799
|
+
if (r.ok) {
|
|
800
|
+
dot.className = "tunnel-dot on";
|
|
801
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#4ade80">PASS</span> ' +
|
|
802
|
+
r.latencyMs + 'ms roundtrip · SSE ' + (r.sseReachable ? 'reachable' : 'unreachable') +
|
|
803
|
+
' · <span class="tunnel-url"><a href="' + r.sseUrl + '" target="_blank">' + r.sseUrl + '</a></span>';
|
|
804
|
+
} else {
|
|
805
|
+
dot.className = "tunnel-dot err";
|
|
806
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + (r.reason || "unknown error");
|
|
807
|
+
}
|
|
808
|
+
} catch (e) {
|
|
809
|
+
dot.className = "tunnel-dot err";
|
|
810
|
+
label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
|
|
811
|
+
} finally {
|
|
812
|
+
btn.disabled = false; btn.textContent = "Test";
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function openClaude() {
|
|
817
|
+
// Copy the SSE URL to clipboard and open claude.ai settings
|
|
818
|
+
fetch(BASE + "/tunnel").then(r => r.json()).then(t => {
|
|
819
|
+
if (t.sseUrl) {
|
|
820
|
+
navigator.clipboard.writeText(t.sseUrl).then(() => {
|
|
821
|
+
const btn = document.getElementById("btn-claude");
|
|
822
|
+
btn.textContent = "SSE URL copied!";
|
|
823
|
+
setTimeout(() => { btn.textContent = "Connect claude.ai"; }, 2000);
|
|
824
|
+
}).catch(() => {});
|
|
825
|
+
window.open("https://claude.ai/settings/integrations", "_blank");
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
642
830
|
boot();
|
|
643
831
|
</script>
|
|
644
832
|
</body>
|
|
@@ -657,6 +845,16 @@ function readBody(req) {
|
|
|
657
845
|
|
|
658
846
|
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
659
847
|
function createServer(initPassword, whitelist, port) {
|
|
848
|
+
// Ensure Windows system tools are reachable (bash shells may lack these on PATH)
|
|
849
|
+
if (os.platform() === "win32") {
|
|
850
|
+
const sys32 = "C:\\Windows\\System32";
|
|
851
|
+
if (!process.env.PATH?.includes(sys32 + "\\Wbem")) {
|
|
852
|
+
process.env.PATH = (process.env.PATH || "") + ";" + sys32 + "\\Wbem";
|
|
853
|
+
}
|
|
854
|
+
if (!process.env.PATH?.includes(sys32 + ";") && !process.env.PATH?.endsWith(sys32)) {
|
|
855
|
+
process.env.PATH = (process.env.PATH || "") + ";" + sys32;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
660
858
|
const MAX_FAILS = 3;
|
|
661
859
|
let failCount = 0;
|
|
662
860
|
let password = initPassword || null; // null = locked; set via POST /auth
|
|
@@ -668,6 +866,123 @@ function createServer(initPassword, whitelist, port) {
|
|
|
668
866
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
669
867
|
};
|
|
670
868
|
|
|
869
|
+
// ── MCP SSE session tracking ──────────────────────────────
|
|
870
|
+
const sseSessions = new Map(); // sessionId → { res, initialized }
|
|
871
|
+
let sseCounter = 0;
|
|
872
|
+
|
|
873
|
+
function sseVault() {
|
|
874
|
+
return {
|
|
875
|
+
_pw: password,
|
|
876
|
+
get password() { return this._pw; },
|
|
877
|
+
set password(v) { this._pw = v; },
|
|
878
|
+
get machineHash() { return machineHash; },
|
|
879
|
+
whitelist,
|
|
880
|
+
failCount,
|
|
881
|
+
MAX_FAILS,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function sseSend(sessionRes, event, data) {
|
|
886
|
+
sessionRes.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ── Cloudflare Tunnel management ──────────────────────────
|
|
890
|
+
let tunnelProc = null;
|
|
891
|
+
let tunnelUrl = null;
|
|
892
|
+
let tunnelError = null;
|
|
893
|
+
|
|
894
|
+
async function startTunnel() {
|
|
895
|
+
if (tunnelProc) return; // already running
|
|
896
|
+
tunnelUrl = null;
|
|
897
|
+
tunnelError = null;
|
|
898
|
+
|
|
899
|
+
try {
|
|
900
|
+
// Named tunnel "clauth" — ingress configured in ~/.cloudflared/config.yml
|
|
901
|
+
// Falls back to quick tunnel if named tunnel fails
|
|
902
|
+
const tunnelName = "clauth";
|
|
903
|
+
const proc = spawnProc("cloudflared", ["tunnel", "run", tunnelName], {
|
|
904
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
tunnelProc = proc;
|
|
908
|
+
|
|
909
|
+
// cloudflared logs to stderr — parse for connection info
|
|
910
|
+
let stderrBuf = "";
|
|
911
|
+
proc.stderr.on("data", (chunk) => {
|
|
912
|
+
const text = chunk.toString();
|
|
913
|
+
stderrBuf += text;
|
|
914
|
+
|
|
915
|
+
// Detect connection registered (named tunnel is live)
|
|
916
|
+
if (!tunnelUrl && stderrBuf.includes("Registered tunnel connection")) {
|
|
917
|
+
// Named tunnel URL comes from config, not stderr.
|
|
918
|
+
// Read it from cloudflared config if available.
|
|
919
|
+
try {
|
|
920
|
+
const cfgPath = path.join(os.homedir(), ".cloudflared", "config.yml");
|
|
921
|
+
const cfgText = fs.readFileSync(cfgPath, "utf8");
|
|
922
|
+
const hostnameMatch = cfgText.match(/hostname:\s*(\S+)/);
|
|
923
|
+
if (hostnameMatch) {
|
|
924
|
+
tunnelUrl = hostnameMatch[1].startsWith("http") ? hostnameMatch[1] : `https://${hostnameMatch[1]}`;
|
|
925
|
+
}
|
|
926
|
+
} catch {}
|
|
927
|
+
if (!tunnelUrl) tunnelUrl = `(named tunnel "${tunnelName}" connected — check DNS)`;
|
|
928
|
+
const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
|
|
929
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Quick tunnel fallback: trycloudflare.com URL
|
|
933
|
+
if (!tunnelUrl) {
|
|
934
|
+
const quickMatch = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
935
|
+
if (quickMatch) {
|
|
936
|
+
tunnelUrl = quickMatch[0];
|
|
937
|
+
const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
|
|
938
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
proc.on("error", (err) => {
|
|
944
|
+
tunnelError = err.code === "ENOENT"
|
|
945
|
+
? "cloudflared not found — install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
|
946
|
+
: err.message;
|
|
947
|
+
tunnelProc = null;
|
|
948
|
+
const logLine = `[${new Date().toISOString()}] Tunnel error: ${tunnelError}\n`;
|
|
949
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
proc.on("exit", (code) => {
|
|
953
|
+
if (tunnelProc === proc) {
|
|
954
|
+
tunnelProc = null;
|
|
955
|
+
if (!tunnelError) tunnelError = `Tunnel exited with code ${code}`;
|
|
956
|
+
const logLine = `[${new Date().toISOString()}] Tunnel exited: code ${code}\n`;
|
|
957
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Give it a moment to start
|
|
962
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
963
|
+
|
|
964
|
+
} catch (err) {
|
|
965
|
+
tunnelError = err.message;
|
|
966
|
+
tunnelProc = null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function stopTunnel() {
|
|
971
|
+
if (tunnelProc) {
|
|
972
|
+
try { tunnelProc.kill(); } catch {}
|
|
973
|
+
tunnelProc = null;
|
|
974
|
+
tunnelUrl = null;
|
|
975
|
+
tunnelError = null;
|
|
976
|
+
const logLine = `[${new Date().toISOString()}] Tunnel stopped\n`;
|
|
977
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Auto-start tunnel if vault is already unlocked (--pw flag)
|
|
982
|
+
if (password) {
|
|
983
|
+
startTunnel().catch(() => {});
|
|
984
|
+
}
|
|
985
|
+
|
|
671
986
|
function strike(res, code, message) {
|
|
672
987
|
failCount++;
|
|
673
988
|
const remaining = MAX_FAILS - failCount;
|
|
@@ -715,6 +1030,116 @@ function createServer(initPassword, whitelist, port) {
|
|
|
715
1030
|
const reqPath = url.pathname;
|
|
716
1031
|
const method = req.method;
|
|
717
1032
|
|
|
1033
|
+
// ── MCP SSE transport ─────────────────────────────────
|
|
1034
|
+
// GET /sse — open SSE stream, receive endpoint event
|
|
1035
|
+
if (method === "GET" && reqPath === "/sse") {
|
|
1036
|
+
const sessionId = `ses_${++sseCounter}_${Date.now()}`;
|
|
1037
|
+
|
|
1038
|
+
res.writeHead(200, {
|
|
1039
|
+
"Content-Type": "text/event-stream",
|
|
1040
|
+
"Cache-Control": "no-cache",
|
|
1041
|
+
"Connection": "keep-alive",
|
|
1042
|
+
...CORS,
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
sseSessions.set(sessionId, { res, initialized: false });
|
|
1046
|
+
|
|
1047
|
+
// Send the endpoint URI the client should POST to
|
|
1048
|
+
const endpoint = `/message?sessionId=${sessionId}`;
|
|
1049
|
+
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
|
|
1050
|
+
|
|
1051
|
+
// Keepalive every 15s
|
|
1052
|
+
const keepalive = setInterval(() => {
|
|
1053
|
+
try { res.write(": keepalive\n\n"); } catch {}
|
|
1054
|
+
}, 15_000);
|
|
1055
|
+
|
|
1056
|
+
// Cleanup on disconnect
|
|
1057
|
+
req.on("close", () => {
|
|
1058
|
+
clearInterval(keepalive);
|
|
1059
|
+
sseSessions.delete(sessionId);
|
|
1060
|
+
const logLine = `[${new Date().toISOString()}] SSE session closed: ${sessionId}\n`;
|
|
1061
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
const logLine = `[${new Date().toISOString()}] SSE session opened: ${sessionId}\n`;
|
|
1065
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// POST /message?sessionId=xxx — JSON-RPC over SSE
|
|
1070
|
+
if (method === "POST" && reqPath === "/message") {
|
|
1071
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
1072
|
+
const session = sessionId ? sseSessions.get(sessionId) : null;
|
|
1073
|
+
|
|
1074
|
+
if (!session) {
|
|
1075
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
1076
|
+
return res.end(JSON.stringify({ error: "Unknown or expired session" }));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
let body;
|
|
1080
|
+
try { body = await readBody(req); } catch {
|
|
1081
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1082
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const id = body.id;
|
|
1086
|
+
const rpcMethod = body.method;
|
|
1087
|
+
|
|
1088
|
+
// Acknowledge the POST immediately
|
|
1089
|
+
res.writeHead(202, { "Content-Type": "application/json", ...CORS });
|
|
1090
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1091
|
+
|
|
1092
|
+
// Handle JSON-RPC methods
|
|
1093
|
+
if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
|
|
1094
|
+
session.initialized = true;
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (rpcMethod === "initialize") {
|
|
1099
|
+
sseSend(session.res, "message", {
|
|
1100
|
+
jsonrpc: "2.0", id,
|
|
1101
|
+
result: {
|
|
1102
|
+
protocolVersion: "2024-11-05",
|
|
1103
|
+
serverInfo: { name: "clauth", version: VERSION },
|
|
1104
|
+
capabilities: { tools: {} }
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (rpcMethod === "tools/list") {
|
|
1111
|
+
sseSend(session.res, "message", {
|
|
1112
|
+
jsonrpc: "2.0", id,
|
|
1113
|
+
result: { tools: MCP_TOOLS }
|
|
1114
|
+
});
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (rpcMethod === "tools/call") {
|
|
1119
|
+
const { name, arguments: args } = body.params || {};
|
|
1120
|
+
const vault = sseVault();
|
|
1121
|
+
try {
|
|
1122
|
+
const result = await handleMcpTool(vault, name, args || {});
|
|
1123
|
+
// Sync vault mutations back (e.g. unlock sets password)
|
|
1124
|
+
password = vault.password;
|
|
1125
|
+
sseSend(session.res, "message", { jsonrpc: "2.0", id, result });
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
sseSend(session.res, "message", {
|
|
1128
|
+
jsonrpc: "2.0", id,
|
|
1129
|
+
result: mcpError(`Internal error: ${err.message}`)
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Unknown method
|
|
1136
|
+
sseSend(session.res, "message", {
|
|
1137
|
+
jsonrpc: "2.0", id,
|
|
1138
|
+
error: { code: -32601, message: `Unknown method: ${rpcMethod}` }
|
|
1139
|
+
});
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
718
1143
|
// GET / — built-in web dashboard
|
|
719
1144
|
if (method === "GET" && reqPath === "/") {
|
|
720
1145
|
res.writeHead(200, { "Content-Type": "text/html", ...CORS });
|
|
@@ -734,8 +1159,36 @@ function createServer(initPassword, whitelist, port) {
|
|
|
734
1159
|
});
|
|
735
1160
|
}
|
|
736
1161
|
|
|
1162
|
+
// GET /tunnel — tunnel status (for dashboard polling)
|
|
1163
|
+
if (method === "GET" && reqPath === "/tunnel") {
|
|
1164
|
+
return ok(res, {
|
|
1165
|
+
running: !!tunnelProc,
|
|
1166
|
+
url: tunnelUrl,
|
|
1167
|
+
sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
|
|
1168
|
+
error: tunnelError,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// POST /tunnel — start or stop tunnel manually
|
|
1173
|
+
if (method === "POST" && reqPath === "/tunnel") {
|
|
1174
|
+
if (lockedGuard(res)) return;
|
|
1175
|
+
let body;
|
|
1176
|
+
try { body = await readBody(req); } catch {
|
|
1177
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1178
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1179
|
+
}
|
|
1180
|
+
if (body.action === "stop") {
|
|
1181
|
+
stopTunnel();
|
|
1182
|
+
return ok(res, { ok: true, running: false });
|
|
1183
|
+
}
|
|
1184
|
+
// start
|
|
1185
|
+
await startTunnel();
|
|
1186
|
+
return ok(res, { ok: true, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
|
|
1187
|
+
}
|
|
1188
|
+
|
|
737
1189
|
// GET /shutdown (for daemon stop)
|
|
738
1190
|
if (method === "GET" && reqPath === "/shutdown") {
|
|
1191
|
+
stopTunnel();
|
|
739
1192
|
ok(res, { ok: true, message: "shutting down" });
|
|
740
1193
|
removePid();
|
|
741
1194
|
setTimeout(() => process.exit(0), 100);
|
|
@@ -811,6 +1264,8 @@ function createServer(initPassword, whitelist, port) {
|
|
|
811
1264
|
password = pw; // unlock — store in process memory only
|
|
812
1265
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
813
1266
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1267
|
+
// Auto-start Cloudflare Tunnel for claude.ai MCP
|
|
1268
|
+
startTunnel().catch(() => {});
|
|
814
1269
|
return ok(res, { ok: true, locked: false });
|
|
815
1270
|
} catch {
|
|
816
1271
|
// Wrong password — not a lockout strike, just a UI auth attempt
|
|
@@ -822,6 +1277,7 @@ function createServer(initPassword, whitelist, port) {
|
|
|
822
1277
|
// POST /lock — clear password from memory
|
|
823
1278
|
if (method === "POST" && reqPath === "/lock") {
|
|
824
1279
|
password = null;
|
|
1280
|
+
stopTunnel();
|
|
825
1281
|
const logLine = `[${new Date().toISOString()}] Vault locked\n`;
|
|
826
1282
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
827
1283
|
return ok(res, { ok: true, locked: true });
|
|
@@ -879,12 +1335,62 @@ function createServer(initPassword, whitelist, port) {
|
|
|
879
1335
|
results[svc.name] = { ok: false, reason: e.message };
|
|
880
1336
|
}
|
|
881
1337
|
}
|
|
1338
|
+
// Include tunnel health in check-all
|
|
1339
|
+
const tunnelHealth = { running: !!tunnelProc, url: tunnelUrl, error: tunnelError };
|
|
1340
|
+
if (tunnelProc && tunnelUrl && tunnelUrl.startsWith("http")) {
|
|
1341
|
+
try {
|
|
1342
|
+
const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(5000) });
|
|
1343
|
+
const data = await resp.json();
|
|
1344
|
+
tunnelHealth.ok = data.status === "ok";
|
|
1345
|
+
tunnelHealth.latencyMs = null; // could measure but ping is fast
|
|
1346
|
+
} catch (e) {
|
|
1347
|
+
tunnelHealth.ok = false;
|
|
1348
|
+
tunnelHealth.reason = e.message;
|
|
1349
|
+
}
|
|
1350
|
+
} else {
|
|
1351
|
+
tunnelHealth.ok = false;
|
|
1352
|
+
tunnelHealth.reason = tunnelError || (tunnelProc ? "URL not yet available" : "Not running");
|
|
1353
|
+
}
|
|
1354
|
+
results["__tunnel__"] = tunnelHealth;
|
|
1355
|
+
|
|
882
1356
|
return ok(res, { results });
|
|
883
1357
|
} catch (err) {
|
|
884
1358
|
return strike(res, 502, err.message);
|
|
885
1359
|
}
|
|
886
1360
|
}
|
|
887
1361
|
|
|
1362
|
+
// GET /tunnel/test — end-to-end tunnel health check (hits /ping through the tunnel)
|
|
1363
|
+
if (method === "GET" && reqPath === "/tunnel/test") {
|
|
1364
|
+
if (lockedGuard(res)) return;
|
|
1365
|
+
if (!tunnelUrl || !tunnelUrl.startsWith("http")) {
|
|
1366
|
+
return ok(res, { ok: false, reason: "Tunnel not connected" });
|
|
1367
|
+
}
|
|
1368
|
+
const start = Date.now();
|
|
1369
|
+
try {
|
|
1370
|
+
// Hit /ping through the public tunnel URL — proves full roundtrip
|
|
1371
|
+
const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(8000) });
|
|
1372
|
+
const data = await resp.json();
|
|
1373
|
+
const latencyMs = Date.now() - start;
|
|
1374
|
+
if (data.status === "ok") {
|
|
1375
|
+
// Also verify SSE endpoint is reachable
|
|
1376
|
+
const sseResp = await fetch(tunnelUrl + "/sse", { signal: AbortSignal.timeout(5000) });
|
|
1377
|
+
const sseOk = sseResp.headers.get("content-type")?.includes("text/event-stream");
|
|
1378
|
+
sseResp.body?.cancel?.();
|
|
1379
|
+
return ok(res, {
|
|
1380
|
+
ok: true,
|
|
1381
|
+
latencyMs,
|
|
1382
|
+
tunnelUrl,
|
|
1383
|
+
sseUrl: tunnelUrl + "/sse",
|
|
1384
|
+
sseReachable: !!sseOk,
|
|
1385
|
+
pid: data.pid,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
return ok(res, { ok: false, reason: "Ping returned unexpected response", data });
|
|
1389
|
+
} catch (e) {
|
|
1390
|
+
return ok(res, { ok: false, reason: e.message, latencyMs: Date.now() - start });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
888
1394
|
// POST /change-pw — change master password (must be unlocked)
|
|
889
1395
|
if (method === "POST" && reqPath === "/change-pw") {
|
|
890
1396
|
if (lockedGuard(res)) return;
|
|
@@ -1066,6 +1572,8 @@ async function actionStart(opts) {
|
|
|
1066
1572
|
console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
|
|
1067
1573
|
}
|
|
1068
1574
|
console.log(chalk.gray(` Stop: clauth serve stop\n`));
|
|
1575
|
+
// Auto-open browser
|
|
1576
|
+
openBrowser(`http://127.0.0.1:${info.port}`);
|
|
1069
1577
|
} else {
|
|
1070
1578
|
console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
|
|
1071
1579
|
process.exit(1);
|
|
@@ -1176,6 +1684,8 @@ async function actionForeground(opts) {
|
|
|
1176
1684
|
console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
|
|
1177
1685
|
if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
|
|
1178
1686
|
console.log(chalk.gray(" Ctrl+C to stop\n"));
|
|
1687
|
+
// Auto-open browser
|
|
1688
|
+
openBrowser(`http://127.0.0.1:${port}`);
|
|
1179
1689
|
});
|
|
1180
1690
|
|
|
1181
1691
|
server.on("error", err => {
|
|
@@ -1200,7 +1710,7 @@ async function actionForeground(opts) {
|
|
|
1200
1710
|
// Secrets are delivered via temp files — never in the MCP response.
|
|
1201
1711
|
|
|
1202
1712
|
import { createInterface } from "readline";
|
|
1203
|
-
import { execSync } from "child_process";
|
|
1713
|
+
import { execSync, spawn as spawnProc } from "child_process";
|
|
1204
1714
|
|
|
1205
1715
|
const ENV_MAP = {
|
|
1206
1716
|
"github": "GITHUB_TOKEN",
|