@lifeaitools/clauth 0.4.1 → 0.5.1
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 -291
- package/cli/commands/scrub.js +231 -231
- package/cli/commands/serve.js +526 -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,138 @@ 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
|
+
const tunnelName = "clauth";
|
|
902
|
+
|
|
903
|
+
// Resolve cloudflared binary — may not be on PATH in bash shells
|
|
904
|
+
let cfBin = "cloudflared";
|
|
905
|
+
if (os.platform() === "win32") {
|
|
906
|
+
const candidates = [
|
|
907
|
+
"cloudflared",
|
|
908
|
+
path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
|
|
909
|
+
path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
|
|
910
|
+
path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
|
|
911
|
+
"C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
|
|
912
|
+
];
|
|
913
|
+
for (const c of candidates) {
|
|
914
|
+
try { if (fs.statSync(c).isFile()) { cfBin = c; break; } } catch {}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const proc = spawnProc(cfBin, ["tunnel", "run", tunnelName], {
|
|
919
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
tunnelProc = proc;
|
|
923
|
+
|
|
924
|
+
// cloudflared logs to stderr — parse for connection info
|
|
925
|
+
let stderrBuf = "";
|
|
926
|
+
proc.stderr.on("data", (chunk) => {
|
|
927
|
+
const text = chunk.toString();
|
|
928
|
+
stderrBuf += text;
|
|
929
|
+
|
|
930
|
+
// Detect connection registered (named tunnel is live)
|
|
931
|
+
if (!tunnelUrl && stderrBuf.includes("Registered tunnel connection")) {
|
|
932
|
+
// Named tunnel URL comes from config, not stderr.
|
|
933
|
+
// Read it from cloudflared config if available.
|
|
934
|
+
try {
|
|
935
|
+
const cfgPath = path.join(os.homedir(), ".cloudflared", "config.yml");
|
|
936
|
+
const cfgText = fs.readFileSync(cfgPath, "utf8");
|
|
937
|
+
const hostnameMatch = cfgText.match(/hostname:\s*(\S+)/);
|
|
938
|
+
if (hostnameMatch) {
|
|
939
|
+
tunnelUrl = hostnameMatch[1].startsWith("http") ? hostnameMatch[1] : `https://${hostnameMatch[1]}`;
|
|
940
|
+
}
|
|
941
|
+
} catch {}
|
|
942
|
+
if (!tunnelUrl) tunnelUrl = `(named tunnel "${tunnelName}" connected — check DNS)`;
|
|
943
|
+
const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
|
|
944
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Quick tunnel fallback: trycloudflare.com URL
|
|
948
|
+
if (!tunnelUrl) {
|
|
949
|
+
const quickMatch = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
950
|
+
if (quickMatch) {
|
|
951
|
+
tunnelUrl = quickMatch[0];
|
|
952
|
+
const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
|
|
953
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
proc.on("error", (err) => {
|
|
959
|
+
tunnelError = err.code === "ENOENT"
|
|
960
|
+
? "cloudflared not found — install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
|
|
961
|
+
: err.message;
|
|
962
|
+
tunnelProc = null;
|
|
963
|
+
const logLine = `[${new Date().toISOString()}] Tunnel error: ${tunnelError}\n`;
|
|
964
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
proc.on("exit", (code) => {
|
|
968
|
+
if (tunnelProc === proc) {
|
|
969
|
+
tunnelProc = null;
|
|
970
|
+
if (!tunnelError) tunnelError = `Tunnel exited with code ${code}`;
|
|
971
|
+
const logLine = `[${new Date().toISOString()}] Tunnel exited: code ${code}\n`;
|
|
972
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// Give it a moment to start
|
|
977
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
978
|
+
|
|
979
|
+
} catch (err) {
|
|
980
|
+
tunnelError = err.message;
|
|
981
|
+
tunnelProc = null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function stopTunnel() {
|
|
986
|
+
if (tunnelProc) {
|
|
987
|
+
try { tunnelProc.kill(); } catch {}
|
|
988
|
+
tunnelProc = null;
|
|
989
|
+
tunnelUrl = null;
|
|
990
|
+
tunnelError = null;
|
|
991
|
+
const logLine = `[${new Date().toISOString()}] Tunnel stopped\n`;
|
|
992
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Auto-start tunnel if vault is already unlocked (--pw flag)
|
|
997
|
+
if (password) {
|
|
998
|
+
startTunnel().catch(() => {});
|
|
999
|
+
}
|
|
1000
|
+
|
|
671
1001
|
function strike(res, code, message) {
|
|
672
1002
|
failCount++;
|
|
673
1003
|
const remaining = MAX_FAILS - failCount;
|
|
@@ -715,6 +1045,116 @@ function createServer(initPassword, whitelist, port) {
|
|
|
715
1045
|
const reqPath = url.pathname;
|
|
716
1046
|
const method = req.method;
|
|
717
1047
|
|
|
1048
|
+
// ── MCP SSE transport ─────────────────────────────────
|
|
1049
|
+
// GET /sse — open SSE stream, receive endpoint event
|
|
1050
|
+
if (method === "GET" && reqPath === "/sse") {
|
|
1051
|
+
const sessionId = `ses_${++sseCounter}_${Date.now()}`;
|
|
1052
|
+
|
|
1053
|
+
res.writeHead(200, {
|
|
1054
|
+
"Content-Type": "text/event-stream",
|
|
1055
|
+
"Cache-Control": "no-cache",
|
|
1056
|
+
"Connection": "keep-alive",
|
|
1057
|
+
...CORS,
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
sseSessions.set(sessionId, { res, initialized: false });
|
|
1061
|
+
|
|
1062
|
+
// Send the endpoint URI the client should POST to
|
|
1063
|
+
const endpoint = `/message?sessionId=${sessionId}`;
|
|
1064
|
+
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
|
|
1065
|
+
|
|
1066
|
+
// Keepalive every 15s
|
|
1067
|
+
const keepalive = setInterval(() => {
|
|
1068
|
+
try { res.write(": keepalive\n\n"); } catch {}
|
|
1069
|
+
}, 15_000);
|
|
1070
|
+
|
|
1071
|
+
// Cleanup on disconnect
|
|
1072
|
+
req.on("close", () => {
|
|
1073
|
+
clearInterval(keepalive);
|
|
1074
|
+
sseSessions.delete(sessionId);
|
|
1075
|
+
const logLine = `[${new Date().toISOString()}] SSE session closed: ${sessionId}\n`;
|
|
1076
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
const logLine = `[${new Date().toISOString()}] SSE session opened: ${sessionId}\n`;
|
|
1080
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// POST /message?sessionId=xxx — JSON-RPC over SSE
|
|
1085
|
+
if (method === "POST" && reqPath === "/message") {
|
|
1086
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
1087
|
+
const session = sessionId ? sseSessions.get(sessionId) : null;
|
|
1088
|
+
|
|
1089
|
+
if (!session) {
|
|
1090
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
1091
|
+
return res.end(JSON.stringify({ error: "Unknown or expired session" }));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
let body;
|
|
1095
|
+
try { body = await readBody(req); } catch {
|
|
1096
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1097
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const id = body.id;
|
|
1101
|
+
const rpcMethod = body.method;
|
|
1102
|
+
|
|
1103
|
+
// Acknowledge the POST immediately
|
|
1104
|
+
res.writeHead(202, { "Content-Type": "application/json", ...CORS });
|
|
1105
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1106
|
+
|
|
1107
|
+
// Handle JSON-RPC methods
|
|
1108
|
+
if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
|
|
1109
|
+
session.initialized = true;
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (rpcMethod === "initialize") {
|
|
1114
|
+
sseSend(session.res, "message", {
|
|
1115
|
+
jsonrpc: "2.0", id,
|
|
1116
|
+
result: {
|
|
1117
|
+
protocolVersion: "2024-11-05",
|
|
1118
|
+
serverInfo: { name: "clauth", version: VERSION },
|
|
1119
|
+
capabilities: { tools: {} }
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (rpcMethod === "tools/list") {
|
|
1126
|
+
sseSend(session.res, "message", {
|
|
1127
|
+
jsonrpc: "2.0", id,
|
|
1128
|
+
result: { tools: MCP_TOOLS }
|
|
1129
|
+
});
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (rpcMethod === "tools/call") {
|
|
1134
|
+
const { name, arguments: args } = body.params || {};
|
|
1135
|
+
const vault = sseVault();
|
|
1136
|
+
try {
|
|
1137
|
+
const result = await handleMcpTool(vault, name, args || {});
|
|
1138
|
+
// Sync vault mutations back (e.g. unlock sets password)
|
|
1139
|
+
password = vault.password;
|
|
1140
|
+
sseSend(session.res, "message", { jsonrpc: "2.0", id, result });
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
sseSend(session.res, "message", {
|
|
1143
|
+
jsonrpc: "2.0", id,
|
|
1144
|
+
result: mcpError(`Internal error: ${err.message}`)
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Unknown method
|
|
1151
|
+
sseSend(session.res, "message", {
|
|
1152
|
+
jsonrpc: "2.0", id,
|
|
1153
|
+
error: { code: -32601, message: `Unknown method: ${rpcMethod}` }
|
|
1154
|
+
});
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
718
1158
|
// GET / — built-in web dashboard
|
|
719
1159
|
if (method === "GET" && reqPath === "/") {
|
|
720
1160
|
res.writeHead(200, { "Content-Type": "text/html", ...CORS });
|
|
@@ -734,8 +1174,36 @@ function createServer(initPassword, whitelist, port) {
|
|
|
734
1174
|
});
|
|
735
1175
|
}
|
|
736
1176
|
|
|
1177
|
+
// GET /tunnel — tunnel status (for dashboard polling)
|
|
1178
|
+
if (method === "GET" && reqPath === "/tunnel") {
|
|
1179
|
+
return ok(res, {
|
|
1180
|
+
running: !!tunnelProc,
|
|
1181
|
+
url: tunnelUrl,
|
|
1182
|
+
sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
|
|
1183
|
+
error: tunnelError,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// POST /tunnel — start or stop tunnel manually
|
|
1188
|
+
if (method === "POST" && reqPath === "/tunnel") {
|
|
1189
|
+
if (lockedGuard(res)) return;
|
|
1190
|
+
let body;
|
|
1191
|
+
try { body = await readBody(req); } catch {
|
|
1192
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1193
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1194
|
+
}
|
|
1195
|
+
if (body.action === "stop") {
|
|
1196
|
+
stopTunnel();
|
|
1197
|
+
return ok(res, { ok: true, running: false });
|
|
1198
|
+
}
|
|
1199
|
+
// start
|
|
1200
|
+
await startTunnel();
|
|
1201
|
+
return ok(res, { ok: true, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
|
|
1202
|
+
}
|
|
1203
|
+
|
|
737
1204
|
// GET /shutdown (for daemon stop)
|
|
738
1205
|
if (method === "GET" && reqPath === "/shutdown") {
|
|
1206
|
+
stopTunnel();
|
|
739
1207
|
ok(res, { ok: true, message: "shutting down" });
|
|
740
1208
|
removePid();
|
|
741
1209
|
setTimeout(() => process.exit(0), 100);
|
|
@@ -811,6 +1279,8 @@ function createServer(initPassword, whitelist, port) {
|
|
|
811
1279
|
password = pw; // unlock — store in process memory only
|
|
812
1280
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
813
1281
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1282
|
+
// Auto-start Cloudflare Tunnel for claude.ai MCP
|
|
1283
|
+
startTunnel().catch(() => {});
|
|
814
1284
|
return ok(res, { ok: true, locked: false });
|
|
815
1285
|
} catch {
|
|
816
1286
|
// Wrong password — not a lockout strike, just a UI auth attempt
|
|
@@ -822,6 +1292,7 @@ function createServer(initPassword, whitelist, port) {
|
|
|
822
1292
|
// POST /lock — clear password from memory
|
|
823
1293
|
if (method === "POST" && reqPath === "/lock") {
|
|
824
1294
|
password = null;
|
|
1295
|
+
stopTunnel();
|
|
825
1296
|
const logLine = `[${new Date().toISOString()}] Vault locked\n`;
|
|
826
1297
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
827
1298
|
return ok(res, { ok: true, locked: true });
|
|
@@ -879,12 +1350,62 @@ function createServer(initPassword, whitelist, port) {
|
|
|
879
1350
|
results[svc.name] = { ok: false, reason: e.message };
|
|
880
1351
|
}
|
|
881
1352
|
}
|
|
1353
|
+
// Include tunnel health in check-all
|
|
1354
|
+
const tunnelHealth = { running: !!tunnelProc, url: tunnelUrl, error: tunnelError };
|
|
1355
|
+
if (tunnelProc && tunnelUrl && tunnelUrl.startsWith("http")) {
|
|
1356
|
+
try {
|
|
1357
|
+
const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(5000) });
|
|
1358
|
+
const data = await resp.json();
|
|
1359
|
+
tunnelHealth.ok = data.status === "ok";
|
|
1360
|
+
tunnelHealth.latencyMs = null; // could measure but ping is fast
|
|
1361
|
+
} catch (e) {
|
|
1362
|
+
tunnelHealth.ok = false;
|
|
1363
|
+
tunnelHealth.reason = e.message;
|
|
1364
|
+
}
|
|
1365
|
+
} else {
|
|
1366
|
+
tunnelHealth.ok = false;
|
|
1367
|
+
tunnelHealth.reason = tunnelError || (tunnelProc ? "URL not yet available" : "Not running");
|
|
1368
|
+
}
|
|
1369
|
+
results["__tunnel__"] = tunnelHealth;
|
|
1370
|
+
|
|
882
1371
|
return ok(res, { results });
|
|
883
1372
|
} catch (err) {
|
|
884
1373
|
return strike(res, 502, err.message);
|
|
885
1374
|
}
|
|
886
1375
|
}
|
|
887
1376
|
|
|
1377
|
+
// GET /tunnel/test — end-to-end tunnel health check (hits /ping through the tunnel)
|
|
1378
|
+
if (method === "GET" && reqPath === "/tunnel/test") {
|
|
1379
|
+
if (lockedGuard(res)) return;
|
|
1380
|
+
if (!tunnelUrl || !tunnelUrl.startsWith("http")) {
|
|
1381
|
+
return ok(res, { ok: false, reason: "Tunnel not connected" });
|
|
1382
|
+
}
|
|
1383
|
+
const start = Date.now();
|
|
1384
|
+
try {
|
|
1385
|
+
// Hit /ping through the public tunnel URL — proves full roundtrip
|
|
1386
|
+
const resp = await fetch(tunnelUrl + "/ping", { signal: AbortSignal.timeout(8000) });
|
|
1387
|
+
const data = await resp.json();
|
|
1388
|
+
const latencyMs = Date.now() - start;
|
|
1389
|
+
if (data.status === "ok") {
|
|
1390
|
+
// Also verify SSE endpoint is reachable
|
|
1391
|
+
const sseResp = await fetch(tunnelUrl + "/sse", { signal: AbortSignal.timeout(5000) });
|
|
1392
|
+
const sseOk = sseResp.headers.get("content-type")?.includes("text/event-stream");
|
|
1393
|
+
sseResp.body?.cancel?.();
|
|
1394
|
+
return ok(res, {
|
|
1395
|
+
ok: true,
|
|
1396
|
+
latencyMs,
|
|
1397
|
+
tunnelUrl,
|
|
1398
|
+
sseUrl: tunnelUrl + "/sse",
|
|
1399
|
+
sseReachable: !!sseOk,
|
|
1400
|
+
pid: data.pid,
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
return ok(res, { ok: false, reason: "Ping returned unexpected response", data });
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
return ok(res, { ok: false, reason: e.message, latencyMs: Date.now() - start });
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
888
1409
|
// POST /change-pw — change master password (must be unlocked)
|
|
889
1410
|
if (method === "POST" && reqPath === "/change-pw") {
|
|
890
1411
|
if (lockedGuard(res)) return;
|
|
@@ -1066,6 +1587,8 @@ async function actionStart(opts) {
|
|
|
1066
1587
|
console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
|
|
1067
1588
|
}
|
|
1068
1589
|
console.log(chalk.gray(` Stop: clauth serve stop\n`));
|
|
1590
|
+
// Auto-open browser
|
|
1591
|
+
openBrowser(`http://127.0.0.1:${info.port}`);
|
|
1069
1592
|
} else {
|
|
1070
1593
|
console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
|
|
1071
1594
|
process.exit(1);
|
|
@@ -1176,6 +1699,8 @@ async function actionForeground(opts) {
|
|
|
1176
1699
|
console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
|
|
1177
1700
|
if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
|
|
1178
1701
|
console.log(chalk.gray(" Ctrl+C to stop\n"));
|
|
1702
|
+
// Auto-open browser
|
|
1703
|
+
openBrowser(`http://127.0.0.1:${port}`);
|
|
1179
1704
|
});
|
|
1180
1705
|
|
|
1181
1706
|
server.on("error", err => {
|
|
@@ -1200,7 +1725,7 @@ async function actionForeground(opts) {
|
|
|
1200
1725
|
// Secrets are delivered via temp files — never in the MCP response.
|
|
1201
1726
|
|
|
1202
1727
|
import { createInterface } from "readline";
|
|
1203
|
-
import { execSync } from "child_process";
|
|
1728
|
+
import { execSync, spawn as spawnProc } from "child_process";
|
|
1204
1729
|
|
|
1205
1730
|
const ENV_MAP = {
|
|
1206
1731
|
"github": "GITHUB_TOKEN",
|