@lifeaitools/clauth 0.5.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/serve.js +653 -21
- package/cli/fingerprint.js +9 -7
- package/cli/index.js +7 -2
- package/package.json +1 -1
package/cli/commands/serve.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Supports: start (background daemon), stop, restart, ping, foreground
|
|
6
6
|
|
|
7
7
|
import http from "http";
|
|
8
|
+
import crypto from "crypto";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import path from "path";
|
|
@@ -153,6 +154,32 @@ function dashboardHtml(port, whitelist) {
|
|
|
153
154
|
.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
155
|
.btn-tunnel-stop:hover{background:#2d1f1f;border-color:#f87171}
|
|
155
156
|
.tunnel-err{font-size:.78rem;color:#f87171;width:100%;margin-top:4px}
|
|
157
|
+
.mcp-setup{background:#0f1d2d;border:1px solid #1e3a5f;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1.25rem;display:none}
|
|
158
|
+
.mcp-setup.open{display:block}
|
|
159
|
+
.mcp-setup-title{font-size:.85rem;font-weight:600;color:#e2e8f0;margin-bottom:.75rem}
|
|
160
|
+
.mcp-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
|
161
|
+
.mcp-label{font-size:.72rem;color:#64748b;min-width:80px;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
|
162
|
+
.mcp-val{flex:1;font-family:'Courier New',monospace;font-size:.82rem;color:#60a5fa;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;padding:6px 10px;word-break:break-all;user-select:all}
|
|
163
|
+
.mcp-copy{background:none;border:1px solid #334155;color:#94a3b8;border-radius:4px;padding:4px 8px;cursor:pointer;font-size:.75rem;font-family:'Courier New',monospace;transition:all .15s;flex-shrink:0}
|
|
164
|
+
.mcp-copy:hover{border-color:#60a5fa;color:#60a5fa}
|
|
165
|
+
.mcp-copy.ok{border-color:#4ade80;color:#4ade80}
|
|
166
|
+
.btn-mcp-setup{background:#1e293b;color:#94a3b8;border:1px solid #334155;padding:6px 12px;font-size:.8rem;border-radius:6px;cursor:pointer;font-weight:500;transition:all .15s}
|
|
167
|
+
.btn-mcp-setup:hover{border-color:#60a5fa;color:#60a5fa}
|
|
168
|
+
.btn-mcp-setup:disabled{opacity:.4;cursor:not-allowed}
|
|
169
|
+
.btn-add{background:#1a2e1a;color:#4ade80;border:1px solid #166534;padding:7px 16px;font-size:.85rem;border-radius:7px;cursor:pointer;font-weight:500;transition:all .15s}
|
|
170
|
+
.btn-add:hover{background:#1a3d22;border-color:#4ade80}
|
|
171
|
+
.add-panel{display:none;background:#1a1f2e;border:1px solid #334155;border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
|
|
172
|
+
.add-panel h3{font-size:.9rem;font-weight:600;color:#f8fafc;margin-bottom:1rem}
|
|
173
|
+
.add-row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:.75rem}
|
|
174
|
+
.add-field{display:flex;flex-direction:column;gap:4px}
|
|
175
|
+
.add-field label{font-size:.75rem;color:#64748b}
|
|
176
|
+
.add-input{background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.88rem;padding:7px 12px;outline:none;width:200px;transition:border-color .2s}
|
|
177
|
+
.add-input:focus{border-color:#3b82f6}
|
|
178
|
+
.add-select{background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-size:.88rem;padding:7px 12px;outline:none;width:200px;transition:border-color .2s;cursor:pointer}
|
|
179
|
+
.add-select:focus{border-color:#3b82f6}
|
|
180
|
+
.add-foot{display:flex;gap:8px;align-items:center}
|
|
181
|
+
.add-msg{font-size:.82rem}
|
|
182
|
+
.add-msg.ok{color:#4ade80} .add-msg.fail{color:#f87171}
|
|
156
183
|
.footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
|
|
157
184
|
.oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
|
|
158
185
|
.oauth-field{display:flex;flex-direction:column;gap:3px}
|
|
@@ -191,11 +218,33 @@ function dashboardHtml(port, whitelist) {
|
|
|
191
218
|
</div>
|
|
192
219
|
<div class="toolbar">
|
|
193
220
|
<button class="btn-refresh" onclick="loadServices()">↻ Refresh</button>
|
|
221
|
+
<button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
|
|
194
222
|
<button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
|
|
195
223
|
<button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
|
|
196
224
|
<button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
|
|
197
225
|
</div>
|
|
198
226
|
|
|
227
|
+
<div class="add-panel" id="add-panel">
|
|
228
|
+
<h3>Add New Service</h3>
|
|
229
|
+
<div class="add-row">
|
|
230
|
+
<div class="add-field">
|
|
231
|
+
<label>Service name</label>
|
|
232
|
+
<input class="add-input" id="add-name" type="text" placeholder="e.g. coolify-admin" autocomplete="off" spellcheck="false">
|
|
233
|
+
</div>
|
|
234
|
+
<div class="add-field">
|
|
235
|
+
<label>Key type</label>
|
|
236
|
+
<select class="add-select" id="add-type">
|
|
237
|
+
<option value="">Loading…</option>
|
|
238
|
+
</select>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
<div class="add-foot">
|
|
242
|
+
<button class="btn-chpw-save" onclick="addService()">Create Service</button>
|
|
243
|
+
<button class="btn-cancel" onclick="toggleAddService()">Cancel</button>
|
|
244
|
+
<span class="add-msg" id="add-msg"></span>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
199
248
|
<div class="chpw-panel" id="chpw-panel">
|
|
200
249
|
<h3>Change Master Password</h3>
|
|
201
250
|
<div class="chpw-row">
|
|
@@ -223,11 +272,34 @@ function dashboardHtml(port, whitelist) {
|
|
|
223
272
|
<button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
|
|
224
273
|
<button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
|
|
225
274
|
<button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
|
|
275
|
+
<button class="btn-mcp-setup" id="btn-mcp-setup" style="display:none" onclick="toggleMcpSetup()">Setup MCP</button>
|
|
226
276
|
<div class="tunnel-err" id="tunnel-err" style="display:none"></div>
|
|
227
277
|
</div>
|
|
228
278
|
|
|
279
|
+
<div class="mcp-setup" id="mcp-setup-panel">
|
|
280
|
+
<div class="mcp-setup-title">claude.ai MCP Integration</div>
|
|
281
|
+
<div class="oauth-fields">
|
|
282
|
+
<div class="mcp-row">
|
|
283
|
+
<span class="mcp-label">URL</span>
|
|
284
|
+
<span class="mcp-val" id="mcp-url">—</span>
|
|
285
|
+
<button class="mcp-copy" onclick="copyMcp('mcp-url')">copy</button>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="mcp-row">
|
|
288
|
+
<span class="mcp-label">Client ID</span>
|
|
289
|
+
<span class="mcp-val" id="mcp-client-id">—</span>
|
|
290
|
+
<button class="mcp-copy" onclick="copyMcp('mcp-client-id')">copy</button>
|
|
291
|
+
</div>
|
|
292
|
+
<div class="mcp-row">
|
|
293
|
+
<span class="mcp-label">Secret</span>
|
|
294
|
+
<span class="mcp-val" id="mcp-client-secret">—</span>
|
|
295
|
+
<button class="mcp-copy" onclick="copyMcp('mcp-client-secret')">copy</button>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
<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>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
229
301
|
<div id="grid" class="grid"><p class="loading">Loading services…</p></div>
|
|
230
|
-
<div class="footer">localhost:${port} · 127.0.0.1 only ·
|
|
302
|
+
<div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
|
|
231
303
|
</div>
|
|
232
304
|
|
|
233
305
|
<script>
|
|
@@ -686,11 +758,78 @@ async function changePassword() {
|
|
|
686
758
|
}
|
|
687
759
|
}
|
|
688
760
|
|
|
689
|
-
//
|
|
761
|
+
// ── Add Service ──────────────────────────────
|
|
762
|
+
const TYPE_LABELS = {
|
|
763
|
+
token: "token (API key)",
|
|
764
|
+
secret: "secret (password, secret)",
|
|
765
|
+
keypair: "keypair (user:key pair)",
|
|
766
|
+
connstring: "connstring (connection string)",
|
|
767
|
+
oauth: "oauth (OAuth credentials)",
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
async function toggleAddService() {
|
|
771
|
+
const panel = document.getElementById("add-panel");
|
|
772
|
+
const open = panel.style.display === "block";
|
|
773
|
+
panel.style.display = open ? "none" : "block";
|
|
774
|
+
if (!open) {
|
|
775
|
+
document.getElementById("add-name").value = "";
|
|
776
|
+
document.getElementById("add-msg").textContent = "";
|
|
777
|
+
// Fetch available key types from server
|
|
778
|
+
const sel = document.getElementById("add-type");
|
|
779
|
+
sel.innerHTML = '<option value="">Loading…</option>';
|
|
780
|
+
try {
|
|
781
|
+
const r = await fetch(BASE + "/meta").then(r => r.json());
|
|
782
|
+
const types = r.key_types || ["token", "secret", "keypair", "connstring", "oauth"];
|
|
783
|
+
sel.innerHTML = types.map(t =>
|
|
784
|
+
\`<option value="\${t}">\${TYPE_LABELS[t] || t}</option>\`
|
|
785
|
+
).join("");
|
|
786
|
+
} catch {
|
|
787
|
+
sel.innerHTML = ["token","secret","keypair","connstring","oauth"].map(t =>
|
|
788
|
+
\`<option value="\${t}">\${TYPE_LABELS[t] || t}</option>\`
|
|
789
|
+
).join("");
|
|
790
|
+
}
|
|
791
|
+
document.getElementById("add-name").focus();
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function addService() {
|
|
796
|
+
const name = document.getElementById("add-name").value.trim().toLowerCase();
|
|
797
|
+
const type = document.getElementById("add-type").value;
|
|
798
|
+
const msg = document.getElementById("add-msg");
|
|
799
|
+
|
|
800
|
+
if (!name) { msg.className = "add-msg fail"; msg.textContent = "Service name is required."; return; }
|
|
801
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { msg.className = "add-msg fail"; msg.textContent = "Lowercase letters, numbers, hyphens, underscores only."; return; }
|
|
802
|
+
|
|
803
|
+
msg.className = "add-msg"; msg.textContent = "Creating…";
|
|
804
|
+
try {
|
|
805
|
+
const r = await fetch(BASE + "/add-service", {
|
|
806
|
+
method: "POST",
|
|
807
|
+
headers: { "Content-Type": "application/json" },
|
|
808
|
+
body: JSON.stringify({ name, key_type: type, label: name })
|
|
809
|
+
}).then(r => r.json());
|
|
810
|
+
|
|
811
|
+
if (r.locked) { showLockScreen(); return; }
|
|
812
|
+
if (r.error) throw new Error(r.error);
|
|
813
|
+
|
|
814
|
+
msg.className = "add-msg ok"; msg.textContent = "✓ " + name + " created";
|
|
815
|
+
setTimeout(() => {
|
|
816
|
+
document.getElementById("add-panel").style.display = "none";
|
|
817
|
+
msg.textContent = "";
|
|
818
|
+
loadServices();
|
|
819
|
+
}, 1200);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
msg.className = "add-msg fail"; msg.textContent = "✗ " + e.message;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Enter key on lock screen and add-service panel
|
|
690
826
|
document.addEventListener("DOMContentLoaded", () => {
|
|
691
827
|
document.getElementById("lock-input").addEventListener("keydown", e => {
|
|
692
828
|
if (e.key === "Enter") unlock();
|
|
693
829
|
});
|
|
830
|
+
document.getElementById("add-name").addEventListener("keydown", e => {
|
|
831
|
+
if (e.key === "Enter") addService();
|
|
832
|
+
});
|
|
694
833
|
});
|
|
695
834
|
|
|
696
835
|
// ── Tunnel management ───────────────────────
|
|
@@ -714,6 +853,7 @@ async function updateTunnelUI() {
|
|
|
714
853
|
const label = document.getElementById("tunnel-label");
|
|
715
854
|
const btn = document.getElementById("btn-claude");
|
|
716
855
|
const togBtn = document.getElementById("btn-tunnel-toggle");
|
|
856
|
+
const mcpBtn = document.getElementById("btn-mcp-setup");
|
|
717
857
|
const errEl = document.getElementById("tunnel-err");
|
|
718
858
|
|
|
719
859
|
try {
|
|
@@ -725,10 +865,12 @@ async function updateTunnelUI() {
|
|
|
725
865
|
dot.className = "tunnel-dot err";
|
|
726
866
|
label.innerHTML = '<strong>claude.ai MCP</strong> — ' + t.error;
|
|
727
867
|
btn.disabled = true;
|
|
868
|
+
mcpBtn.style.display = "none";
|
|
728
869
|
togBtn.style.display = "none";
|
|
729
870
|
togBtn.textContent = "Start Tunnel";
|
|
730
871
|
togBtn.onclick = () => toggleTunnel("start");
|
|
731
872
|
togBtn.style.display = "inline-block";
|
|
873
|
+
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
732
874
|
return "error";
|
|
733
875
|
}
|
|
734
876
|
|
|
@@ -740,6 +882,7 @@ async function updateTunnelUI() {
|
|
|
740
882
|
togBtn.textContent = "Stop Tunnel";
|
|
741
883
|
togBtn.onclick = () => toggleTunnel("stop");
|
|
742
884
|
togBtn.style.display = "inline-block";
|
|
885
|
+
mcpBtn.style.display = "inline-block";
|
|
743
886
|
return "connected";
|
|
744
887
|
}
|
|
745
888
|
|
|
@@ -747,6 +890,7 @@ async function updateTunnelUI() {
|
|
|
747
890
|
dot.className = "tunnel-dot starting";
|
|
748
891
|
label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
|
|
749
892
|
btn.disabled = true;
|
|
893
|
+
mcpBtn.style.display = "none";
|
|
750
894
|
togBtn.textContent = "Stop Tunnel";
|
|
751
895
|
togBtn.onclick = () => toggleTunnel("stop");
|
|
752
896
|
togBtn.style.display = "inline-block";
|
|
@@ -757,16 +901,20 @@ async function updateTunnelUI() {
|
|
|
757
901
|
dot.className = "tunnel-dot off";
|
|
758
902
|
label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
|
|
759
903
|
btn.disabled = true;
|
|
904
|
+
mcpBtn.style.display = "none";
|
|
760
905
|
togBtn.textContent = "Start Tunnel";
|
|
761
906
|
togBtn.onclick = () => toggleTunnel("start");
|
|
762
907
|
togBtn.style.display = "inline-block";
|
|
908
|
+
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
763
909
|
return "stopped";
|
|
764
910
|
|
|
765
911
|
} catch {
|
|
766
912
|
dot.className = "tunnel-dot off";
|
|
767
913
|
label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
|
|
768
914
|
btn.disabled = true;
|
|
915
|
+
mcpBtn.style.display = "none";
|
|
769
916
|
togBtn.style.display = "none";
|
|
917
|
+
document.getElementById("mcp-setup-panel").classList.remove("open");
|
|
770
918
|
return "error";
|
|
771
919
|
}
|
|
772
920
|
}
|
|
@@ -827,6 +975,32 @@ function openClaude() {
|
|
|
827
975
|
});
|
|
828
976
|
}
|
|
829
977
|
|
|
978
|
+
async function toggleMcpSetup() {
|
|
979
|
+
const panel = document.getElementById("mcp-setup-panel");
|
|
980
|
+
const isOpen = panel.classList.toggle("open");
|
|
981
|
+
if (isOpen) {
|
|
982
|
+
try {
|
|
983
|
+
const m = await fetch(BASE + "/mcp-setup").then(r => r.json());
|
|
984
|
+
document.getElementById("mcp-url").textContent = m.url || "(tunnel not running)";
|
|
985
|
+
document.getElementById("mcp-client-id").textContent = m.clientId || "—";
|
|
986
|
+
document.getElementById("mcp-client-secret").textContent = m.clientSecret || "—";
|
|
987
|
+
} catch {
|
|
988
|
+
document.getElementById("mcp-url").textContent = "(error fetching)";
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function copyMcp(elId) {
|
|
994
|
+
const val = document.getElementById(elId).textContent;
|
|
995
|
+
if (!val || val === "—" || val.startsWith("(")) return;
|
|
996
|
+
const btn = document.getElementById(elId).nextElementSibling;
|
|
997
|
+
navigator.clipboard.writeText(val).then(() => {
|
|
998
|
+
btn.textContent = "ok";
|
|
999
|
+
btn.classList.add("ok");
|
|
1000
|
+
setTimeout(() => { btn.textContent = "copy"; btn.classList.remove("ok"); }, 1500);
|
|
1001
|
+
}).catch(() => {});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
830
1004
|
boot();
|
|
831
1005
|
</script>
|
|
832
1006
|
</body>
|
|
@@ -844,7 +1018,7 @@ function readBody(req) {
|
|
|
844
1018
|
}
|
|
845
1019
|
|
|
846
1020
|
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
847
|
-
function createServer(initPassword, whitelist, port) {
|
|
1021
|
+
function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
848
1022
|
// Ensure Windows system tools are reachable (bash shells may lack these on PATH)
|
|
849
1023
|
if (os.platform() === "win32") {
|
|
850
1024
|
const sys32 = "C:\\Windows\\System32";
|
|
@@ -855,7 +1029,7 @@ function createServer(initPassword, whitelist, port) {
|
|
|
855
1029
|
process.env.PATH = (process.env.PATH || "") + ";" + sys32;
|
|
856
1030
|
}
|
|
857
1031
|
}
|
|
858
|
-
const MAX_FAILS =
|
|
1032
|
+
const MAX_FAILS = 10;
|
|
859
1033
|
let failCount = 0;
|
|
860
1034
|
let password = initPassword || null; // null = locked; set via POST /auth
|
|
861
1035
|
const machineHash = getMachineHash();
|
|
@@ -863,7 +1037,7 @@ function createServer(initPassword, whitelist, port) {
|
|
|
863
1037
|
const CORS = {
|
|
864
1038
|
"Access-Control-Allow-Origin": "*",
|
|
865
1039
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
866
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
1040
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id",
|
|
867
1041
|
};
|
|
868
1042
|
|
|
869
1043
|
// ── MCP SSE session tracking ──────────────────────────────
|
|
@@ -891,13 +1065,39 @@ function createServer(initPassword, whitelist, port) {
|
|
|
891
1065
|
let tunnelUrl = null;
|
|
892
1066
|
let tunnelError = null;
|
|
893
1067
|
|
|
1068
|
+
// ── OAuth provider (self-contained for claude.ai MCP) ──────
|
|
1069
|
+
const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
|
|
1070
|
+
const oauthCodes = new Map(); // code → { client_id, redirect_uri, code_challenge, expires }
|
|
1071
|
+
const oauthTokens = new Set(); // active access tokens
|
|
1072
|
+
|
|
1073
|
+
// Pre-generate a stable client for claude.ai (shown at startup)
|
|
1074
|
+
const OAUTH_CLIENT_ID = crypto.randomBytes(16).toString("hex");
|
|
1075
|
+
const OAUTH_CLIENT_SECRET = crypto.randomBytes(32).toString("hex");
|
|
1076
|
+
oauthClients.set(OAUTH_CLIENT_ID, {
|
|
1077
|
+
client_id: OAUTH_CLIENT_ID, client_secret: OAUTH_CLIENT_SECRET,
|
|
1078
|
+
client_name: "claude.ai", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
|
|
1079
|
+
grant_types: ["authorization_code"], response_types: ["code"],
|
|
1080
|
+
token_endpoint_auth_method: "client_secret_post",
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
function oauthBase() { return tunnelUrl || `http://127.0.0.1:${port}`; }
|
|
1084
|
+
function sha256base64url(str) { return crypto.createHash("sha256").update(str).digest("base64url"); }
|
|
1085
|
+
|
|
1086
|
+
function readRawBody(req) {
|
|
1087
|
+
return new Promise((resolve, reject) => {
|
|
1088
|
+
let data = "";
|
|
1089
|
+
req.on("data", chunk => { data += chunk; if (data.length > 65536) reject(new Error("Body too large")); });
|
|
1090
|
+
req.on("end", () => resolve(data));
|
|
1091
|
+
req.on("error", reject);
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
894
1095
|
async function startTunnel() {
|
|
895
1096
|
if (tunnelProc) return; // already running
|
|
896
1097
|
tunnelUrl = null;
|
|
897
1098
|
tunnelError = null;
|
|
898
1099
|
|
|
899
1100
|
try {
|
|
900
|
-
// Quick tunnel — no DNS/config needed, new random URL each session
|
|
901
1101
|
// Resolve cloudflared binary — may not be on PATH in bash shells
|
|
902
1102
|
let cfBin = "cloudflared";
|
|
903
1103
|
if (os.platform() === "win32") {
|
|
@@ -913,17 +1113,30 @@ function createServer(initPassword, whitelist, port) {
|
|
|
913
1113
|
}
|
|
914
1114
|
}
|
|
915
1115
|
|
|
916
|
-
|
|
1116
|
+
// Named tunnel (fixed subdomain) or quick tunnel (random URL)
|
|
1117
|
+
let args;
|
|
1118
|
+
if (tunnelHostname) {
|
|
1119
|
+
// Named tunnel: cloudflared tunnel run (uses ~/.cloudflared/config.yml)
|
|
1120
|
+
// Config maps hostname → local service, so no --url needed
|
|
1121
|
+
args = ["tunnel", "run"];
|
|
1122
|
+
tunnelUrl = `https://${tunnelHostname}`;
|
|
1123
|
+
} else {
|
|
1124
|
+
// Quick tunnel — random URL each session
|
|
1125
|
+
args = ["tunnel", "--url", `http://127.0.0.1:${port}`];
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const proc = spawnProc(cfBin, args, {
|
|
917
1129
|
stdio: ["ignore", "pipe", "pipe"],
|
|
918
1130
|
});
|
|
919
1131
|
|
|
920
1132
|
tunnelProc = proc;
|
|
921
1133
|
|
|
922
|
-
// cloudflared prints
|
|
1134
|
+
// cloudflared prints output to stderr
|
|
923
1135
|
let stderrBuf = "";
|
|
924
1136
|
proc.stderr.on("data", (chunk) => {
|
|
925
1137
|
stderrBuf += chunk.toString();
|
|
926
|
-
|
|
1138
|
+
// For quick tunnels, capture the random URL from stderr
|
|
1139
|
+
if (!tunnelHostname && !tunnelUrl) {
|
|
927
1140
|
const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
928
1141
|
if (match) {
|
|
929
1142
|
tunnelUrl = match[0];
|
|
@@ -954,6 +1167,11 @@ function createServer(initPassword, whitelist, port) {
|
|
|
954
1167
|
// Give it a moment to start
|
|
955
1168
|
await new Promise(r => setTimeout(r, 4000));
|
|
956
1169
|
|
|
1170
|
+
if (tunnelHostname && tunnelProc) {
|
|
1171
|
+
const logLine = `[${new Date().toISOString()}] Named tunnel started: ${tunnelUrl}\n`;
|
|
1172
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
957
1175
|
} catch (err) {
|
|
958
1176
|
tunnelError = err.message;
|
|
959
1177
|
tunnelProc = null;
|
|
@@ -1006,9 +1224,18 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1006
1224
|
}
|
|
1007
1225
|
|
|
1008
1226
|
const server = http.createServer(async (req, res) => {
|
|
1009
|
-
// Hard reject anything not from loopback
|
|
1010
1227
|
const remote = req.socket.remoteAddress;
|
|
1011
1228
|
const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
1229
|
+
|
|
1230
|
+
const url = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
1231
|
+
const reqPath = url.pathname;
|
|
1232
|
+
const method = req.method;
|
|
1233
|
+
|
|
1234
|
+
// Log every request
|
|
1235
|
+
const logLine = `[${new Date().toISOString()}] ${method} ${reqPath} from=${remote} local=${isLocal}\n`;
|
|
1236
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1237
|
+
|
|
1238
|
+
// Hard reject anything not from loopback
|
|
1012
1239
|
if (!isLocal) {
|
|
1013
1240
|
return strike(res, 403, `Rejected non-local address: ${remote}`);
|
|
1014
1241
|
}
|
|
@@ -1019,11 +1246,216 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1019
1246
|
return res.end();
|
|
1020
1247
|
}
|
|
1021
1248
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1249
|
+
// ── OAuth Discovery (RFC 9728 + RFC 8414) ──────────────
|
|
1250
|
+
if (reqPath === "/.well-known/oauth-protected-resource" ||
|
|
1251
|
+
reqPath === "/.well-known/oauth-protected-resource/mcp" ||
|
|
1252
|
+
reqPath === "/.well-known/oauth-protected-resource/sse") {
|
|
1253
|
+
const base = oauthBase();
|
|
1254
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1255
|
+
return res.end(JSON.stringify({
|
|
1256
|
+
resource: `${base}/mcp`,
|
|
1257
|
+
authorization_servers: [base],
|
|
1258
|
+
scopes_supported: ["mcp:tools"],
|
|
1259
|
+
bearer_methods_supported: ["header"],
|
|
1260
|
+
}));
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (reqPath === "/.well-known/oauth-authorization-server") {
|
|
1264
|
+
const base = oauthBase();
|
|
1265
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1266
|
+
return res.end(JSON.stringify({
|
|
1267
|
+
issuer: base,
|
|
1268
|
+
authorization_endpoint: `${base}/authorize`,
|
|
1269
|
+
token_endpoint: `${base}/token`,
|
|
1270
|
+
registration_endpoint: `${base}/register`,
|
|
1271
|
+
response_types_supported: ["code"],
|
|
1272
|
+
grant_types_supported: ["authorization_code"],
|
|
1273
|
+
code_challenge_methods_supported: ["S256"],
|
|
1274
|
+
scopes_supported: ["mcp:tools"],
|
|
1275
|
+
}));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// ── Dynamic Client Registration (RFC 7591) ──────────────
|
|
1279
|
+
if (method === "POST" && reqPath === "/register") {
|
|
1280
|
+
let body;
|
|
1281
|
+
try { body = await readBody(req); } catch {
|
|
1282
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1283
|
+
return res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1284
|
+
}
|
|
1285
|
+
const clientId = crypto.randomBytes(16).toString("hex");
|
|
1286
|
+
const clientSecret = crypto.randomBytes(32).toString("hex");
|
|
1287
|
+
const client = {
|
|
1288
|
+
client_id: clientId, client_secret: clientSecret,
|
|
1289
|
+
client_name: body.client_name || "unknown",
|
|
1290
|
+
redirect_uris: body.redirect_uris || [],
|
|
1291
|
+
grant_types: body.grant_types || ["authorization_code"],
|
|
1292
|
+
response_types: body.response_types || ["code"],
|
|
1293
|
+
token_endpoint_auth_method: body.token_endpoint_auth_method || "client_secret_post",
|
|
1294
|
+
};
|
|
1295
|
+
oauthClients.set(clientId, client);
|
|
1296
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: registered client ${clientId} (${client.client_name})\n`;
|
|
1297
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1298
|
+
res.writeHead(201, { "Content-Type": "application/json", ...CORS });
|
|
1299
|
+
return res.end(JSON.stringify(client));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ── Authorization endpoint — auto-approve ──────────────
|
|
1303
|
+
if (method === "GET" && reqPath === "/authorize") {
|
|
1304
|
+
const clientId = url.searchParams.get("client_id");
|
|
1305
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
1306
|
+
const state = url.searchParams.get("state");
|
|
1307
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
1308
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
1309
|
+
|
|
1310
|
+
if (!clientId || !redirectUri) {
|
|
1311
|
+
res.writeHead(400, { "Content-Type": "text/plain", ...CORS });
|
|
1312
|
+
return res.end("Missing client_id or redirect_uri");
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const code = crypto.randomBytes(32).toString("hex");
|
|
1316
|
+
oauthCodes.set(code, {
|
|
1317
|
+
client_id: clientId, redirect_uri: redirectUri,
|
|
1318
|
+
code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod,
|
|
1319
|
+
expires: Date.now() + 300_000,
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
const redirect = new URL(redirectUri);
|
|
1323
|
+
redirect.searchParams.set("code", code);
|
|
1324
|
+
if (state) redirect.searchParams.set("state", state);
|
|
1325
|
+
|
|
1326
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: authorize → code issued for ${clientId}, redirecting to ${redirect.origin}\n`;
|
|
1327
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1328
|
+
res.writeHead(302, { Location: redirect.toString(), ...CORS });
|
|
1329
|
+
return res.end();
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// ── Token endpoint ──────────────────────────────────────
|
|
1333
|
+
if (method === "POST" && reqPath === "/token") {
|
|
1334
|
+
let body;
|
|
1335
|
+
const ct = req.headers["content-type"] || "";
|
|
1336
|
+
try {
|
|
1337
|
+
if (ct.includes("application/json")) {
|
|
1338
|
+
body = await readBody(req);
|
|
1339
|
+
} else {
|
|
1340
|
+
const raw = await readRawBody(req);
|
|
1341
|
+
body = Object.fromEntries(new URLSearchParams(raw));
|
|
1342
|
+
}
|
|
1343
|
+
} catch {
|
|
1344
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1345
|
+
return res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (body.grant_type !== "authorization_code") {
|
|
1349
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1350
|
+
return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const stored = oauthCodes.get(body.code);
|
|
1354
|
+
if (!stored || stored.expires < Date.now()) {
|
|
1355
|
+
oauthCodes.delete(body.code);
|
|
1356
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1357
|
+
return res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// PKCE verification
|
|
1361
|
+
if (stored.code_challenge && body.code_verifier) {
|
|
1362
|
+
const computed = sha256base64url(body.code_verifier);
|
|
1363
|
+
if (computed !== stored.code_challenge) {
|
|
1364
|
+
oauthCodes.delete(body.code);
|
|
1365
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1366
|
+
return res.end(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }));
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
oauthCodes.delete(body.code);
|
|
1371
|
+
const accessToken = crypto.randomBytes(32).toString("hex");
|
|
1372
|
+
oauthTokens.add(accessToken);
|
|
1373
|
+
|
|
1374
|
+
const logMsg = `[${new Date().toISOString()}] OAuth: token issued for client ${stored.client_id}\n`;
|
|
1375
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1376
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1377
|
+
return res.end(JSON.stringify({ access_token: accessToken, token_type: "Bearer", expires_in: 86400 }));
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ── MCP OAuth-protected endpoint (for claude.ai web) ──
|
|
1381
|
+
// POST /mcp — requires Bearer token; returns 401 to trigger OAuth flow
|
|
1382
|
+
if (method === "POST" && reqPath === "/mcp") {
|
|
1383
|
+
const authHeader = req.headers.authorization;
|
|
1384
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
1385
|
+
const base = oauthBase();
|
|
1386
|
+
res.writeHead(401, {
|
|
1387
|
+
"Content-Type": "application/json",
|
|
1388
|
+
"WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`,
|
|
1389
|
+
...CORS,
|
|
1390
|
+
});
|
|
1391
|
+
return res.end(JSON.stringify({ error: "unauthorized" }));
|
|
1392
|
+
}
|
|
1393
|
+
const token = authHeader.slice(7);
|
|
1394
|
+
if (!oauthTokens.has(token)) {
|
|
1395
|
+
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
1396
|
+
return res.end(JSON.stringify({ error: "invalid_token" }));
|
|
1397
|
+
}
|
|
1398
|
+
// Token valid — mark as remote caller (claude.ai via tunnel)
|
|
1399
|
+
req._clauthRemote = true;
|
|
1400
|
+
// fall through to MCP handling below
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ── MCP Streamable HTTP transport (2025-03-26 spec) ──
|
|
1404
|
+
// POST /sse or POST /mcp — JSON-RPC over HTTP
|
|
1405
|
+
if (method === "POST" && (reqPath === "/sse" || reqPath === "/mcp")) {
|
|
1406
|
+
let body;
|
|
1407
|
+
try { body = await readBody(req); } catch {
|
|
1408
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1409
|
+
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const id = body.id;
|
|
1413
|
+
const rpcMethod = body.method;
|
|
1414
|
+
const logMsg = `[${new Date().toISOString()}] Streamable HTTP: ${rpcMethod} id=${id}\n`;
|
|
1415
|
+
try { fs.appendFileSync(LOG_FILE, logMsg); } catch {}
|
|
1416
|
+
|
|
1417
|
+
// Notifications — no response needed
|
|
1418
|
+
if (rpcMethod === "notifications/initialized" || rpcMethod === "initialized") {
|
|
1419
|
+
res.writeHead(204, CORS);
|
|
1420
|
+
return res.end();
|
|
1421
|
+
}
|
|
1025
1422
|
|
|
1026
|
-
|
|
1423
|
+
if (rpcMethod === "initialize") {
|
|
1424
|
+
const result = {
|
|
1425
|
+
protocolVersion: "2025-03-26",
|
|
1426
|
+
serverInfo: { name: "clauth", version: VERSION },
|
|
1427
|
+
capabilities: { tools: {} }
|
|
1428
|
+
};
|
|
1429
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1430
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (rpcMethod === "tools/list") {
|
|
1434
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1435
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: { tools: MCP_TOOLS } }));
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (rpcMethod === "tools/call") {
|
|
1439
|
+
const { name, arguments: args } = body.params || {};
|
|
1440
|
+
const vault = sseVault();
|
|
1441
|
+
vault.remote = !!req._clauthRemote;
|
|
1442
|
+
try {
|
|
1443
|
+
const result = await handleMcpTool(vault, name, args || {});
|
|
1444
|
+
password = vault.password;
|
|
1445
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1446
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1449
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: mcpError(`Internal error: ${err.message}`) }));
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Unknown method
|
|
1454
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
1455
|
+
return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${rpcMethod}` } }));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ── MCP SSE transport (legacy) ───────────────────────
|
|
1027
1459
|
// GET /sse — open SSE stream, receive endpoint event
|
|
1028
1460
|
if (method === "GET" && reqPath === "/sse") {
|
|
1029
1461
|
const sessionId = `ses_${++sseCounter}_${Date.now()}`;
|
|
@@ -1038,7 +1470,9 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1038
1470
|
sseSessions.set(sessionId, { res, initialized: false });
|
|
1039
1471
|
|
|
1040
1472
|
// Send the endpoint URI the client should POST to
|
|
1041
|
-
|
|
1473
|
+
// Use absolute URL when tunnel is active so remote clients can resolve it
|
|
1474
|
+
const basePath = tunnelUrl || `http://127.0.0.1:${port}`;
|
|
1475
|
+
const endpoint = `${basePath}/message?sessionId=${sessionId}`;
|
|
1042
1476
|
res.write(`event: endpoint\ndata: ${endpoint}\n\n`);
|
|
1043
1477
|
|
|
1044
1478
|
// Keepalive every 15s
|
|
@@ -1162,6 +1596,15 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1162
1596
|
});
|
|
1163
1597
|
}
|
|
1164
1598
|
|
|
1599
|
+
// GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
|
|
1600
|
+
if (method === "GET" && reqPath === "/mcp-setup") {
|
|
1601
|
+
return ok(res, {
|
|
1602
|
+
url: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/mcp` : null,
|
|
1603
|
+
clientId: OAUTH_CLIENT_ID,
|
|
1604
|
+
clientSecret: OAUTH_CLIENT_SECRET,
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1165
1608
|
// POST /tunnel — start or stop tunnel manually
|
|
1166
1609
|
if (method === "POST" && reqPath === "/tunnel") {
|
|
1167
1610
|
if (lockedGuard(res)) return;
|
|
@@ -1198,6 +1641,31 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1198
1641
|
return false;
|
|
1199
1642
|
}
|
|
1200
1643
|
|
|
1644
|
+
// GET /meta — return valid key_types from DB check constraint
|
|
1645
|
+
if (method === "GET" && reqPath === "/meta") {
|
|
1646
|
+
if (lockedGuard(res)) return;
|
|
1647
|
+
try {
|
|
1648
|
+
const baseUrl = api.getBaseUrl().replace("/functions/v1/auth-vault", "");
|
|
1649
|
+
const anonKey = api.getAnonKey();
|
|
1650
|
+
const sql = `SELECT check_clause FROM information_schema.check_constraints WHERE constraint_name = 'clauth_services_key_type_check'`;
|
|
1651
|
+
const r = await fetch(`${baseUrl}/rest/v1/rpc/`, {
|
|
1652
|
+
method: "POST",
|
|
1653
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${anonKey}`, "apikey": anonKey }
|
|
1654
|
+
}).catch(() => null);
|
|
1655
|
+
// Fallback: parse from a direct SQL query via postgrest isn't possible without an RPC,
|
|
1656
|
+
// so we query the status endpoint which returns services with their key_types
|
|
1657
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
1658
|
+
const statusResult = await api.status(password, machineHash, token, timestamp);
|
|
1659
|
+
const existingTypes = [...new Set((statusResult.services || []).map(s => s.key_type).filter(Boolean))];
|
|
1660
|
+
// Merge with known types (in case no service of that type exists yet)
|
|
1661
|
+
const knownTypes = ["token", "secret", "keypair", "connstring", "oauth"];
|
|
1662
|
+
const allTypes = [...new Set([...knownTypes, ...existingTypes])];
|
|
1663
|
+
return ok(res, { key_types: allTypes });
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
return ok(res, { key_types: ["token", "secret", "keypair", "connstring", "oauth"] });
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1201
1669
|
// GET /status
|
|
1202
1670
|
if (method === "GET" && reqPath === "/status") {
|
|
1203
1671
|
if (lockedGuard(res)) return;
|
|
@@ -1446,10 +1914,53 @@ function createServer(initPassword, whitelist, port) {
|
|
|
1446
1914
|
}
|
|
1447
1915
|
}
|
|
1448
1916
|
|
|
1449
|
-
//
|
|
1917
|
+
// POST /add-service — register a new service in the vault
|
|
1918
|
+
if (method === "POST" && reqPath === "/add-service") {
|
|
1919
|
+
if (lockedGuard(res)) return;
|
|
1920
|
+
|
|
1921
|
+
let body;
|
|
1922
|
+
try { body = await readBody(req); } catch {
|
|
1923
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1924
|
+
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
const { name, label, key_type, description } = body;
|
|
1928
|
+
if (!name || typeof name !== "string" || !name.trim()) {
|
|
1929
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1930
|
+
return res.end(JSON.stringify({ error: "name is required" }));
|
|
1931
|
+
}
|
|
1932
|
+
const validTypes = ["token", "keypair", "connstring", "oauth", "secret"];
|
|
1933
|
+
const type = (key_type || "token").toLowerCase();
|
|
1934
|
+
if (!validTypes.includes(type)) {
|
|
1935
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
1936
|
+
return res.end(JSON.stringify({ error: `key_type must be one of: ${validTypes.join(", ")}` }));
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
try {
|
|
1940
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
1941
|
+
const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "");
|
|
1942
|
+
if (result.error) return strike(res, 502, result.error);
|
|
1943
|
+
return ok(res, { ok: true, service: name.trim().toLowerCase() });
|
|
1944
|
+
} catch (err) {
|
|
1945
|
+
return strike(res, 502, err.message);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// Unknown route — don't count browser/MCP noise as auth failures
|
|
1950
|
+
// Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
|
|
1951
|
+
const isBenign = reqPath.startsWith("/.well-known/") || [
|
|
1952
|
+
"/favicon.ico", "/robots.txt", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png",
|
|
1953
|
+
"/sse", "/mcp", "/message", "/register", "/authorize", "/token",
|
|
1954
|
+
].includes(reqPath);
|
|
1955
|
+
if (isBenign) {
|
|
1956
|
+
res.writeHead(404, { "Content-Type": "application/json", ...CORS });
|
|
1957
|
+
return res.end(JSON.stringify({ error: "Not found" }));
|
|
1958
|
+
}
|
|
1450
1959
|
return strike(res, 404, `Unknown endpoint: ${reqPath}`);
|
|
1451
1960
|
});
|
|
1452
1961
|
|
|
1962
|
+
server.__oauthClientId = OAUTH_CLIENT_ID;
|
|
1963
|
+
server.__oauthClientSecret = OAUTH_CLIENT_SECRET;
|
|
1453
1964
|
return server;
|
|
1454
1965
|
}
|
|
1455
1966
|
|
|
@@ -1465,6 +1976,7 @@ async function verifyAuth(password) {
|
|
|
1465
1976
|
async function actionStart(opts) {
|
|
1466
1977
|
const port = parseInt(opts.port || "52437", 10);
|
|
1467
1978
|
const password = opts.pw;
|
|
1979
|
+
const tunnelHostname = opts.tunnel || null;
|
|
1468
1980
|
const whitelist = opts.services
|
|
1469
1981
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
1470
1982
|
: null;
|
|
@@ -1490,7 +2002,7 @@ async function actionStart(opts) {
|
|
|
1490
2002
|
}
|
|
1491
2003
|
}
|
|
1492
2004
|
|
|
1493
|
-
const server = createServer(password, whitelist, port);
|
|
2005
|
+
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
1494
2006
|
server.listen(port, "127.0.0.1", () => {
|
|
1495
2007
|
writePid(process.pid, port);
|
|
1496
2008
|
const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
|
|
@@ -1534,6 +2046,7 @@ async function actionStart(opts) {
|
|
|
1534
2046
|
const childArgs = [cliEntry, "serve", "start", "--port", String(port)];
|
|
1535
2047
|
if (password) childArgs.push("--pw", password);
|
|
1536
2048
|
if (opts.services) childArgs.push("--services", opts.services);
|
|
2049
|
+
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
1537
2050
|
|
|
1538
2051
|
const out = fs.openSync(LOG_FILE, "a");
|
|
1539
2052
|
const child = spawn(process.execPath, childArgs, {
|
|
@@ -1650,6 +2163,7 @@ async function actionRestart(opts) {
|
|
|
1650
2163
|
async function actionForeground(opts) {
|
|
1651
2164
|
const port = parseInt(opts.port || "52437", 10);
|
|
1652
2165
|
const password = opts.pw || null;
|
|
2166
|
+
const tunnelHostname = opts.tunnel || null;
|
|
1653
2167
|
const whitelist = opts.services
|
|
1654
2168
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
1655
2169
|
: null;
|
|
@@ -1671,10 +2185,19 @@ async function actionForeground(opts) {
|
|
|
1671
2185
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
1672
2186
|
console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
|
|
1673
2187
|
|
|
1674
|
-
const server = createServer(password, whitelist, port);
|
|
2188
|
+
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
1675
2189
|
server.listen(port, "127.0.0.1", () => {
|
|
1676
2190
|
writePid(process.pid, port);
|
|
1677
2191
|
console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
|
|
2192
|
+
if (tunnelHostname) {
|
|
2193
|
+
console.log(chalk.cyan(` Tunnel: https://${tunnelHostname}/sse`));
|
|
2194
|
+
console.log("");
|
|
2195
|
+
console.log(chalk.yellow(" ── claude.ai Custom Connector ──"));
|
|
2196
|
+
console.log(chalk.white(` URL: https://${tunnelHostname}/mcp`));
|
|
2197
|
+
console.log(chalk.white(` Client ID: ${server.__oauthClientId}`));
|
|
2198
|
+
console.log(chalk.white(` Client Secret: ${server.__oauthClientSecret}`));
|
|
2199
|
+
console.log(chalk.gray(" (paste these into Advanced Settings when adding the connector)"));
|
|
2200
|
+
}
|
|
1678
2201
|
if (!password) console.log(chalk.cyan(` 👉 Open http://127.0.0.1:${port} to unlock`));
|
|
1679
2202
|
console.log(chalk.gray(" Ctrl+C to stop\n"));
|
|
1680
2203
|
// Auto-open browser
|
|
@@ -1926,7 +2449,14 @@ async function handleMcpTool(vault, name, args) {
|
|
|
1926
2449
|
};
|
|
1927
2450
|
}
|
|
1928
2451
|
|
|
1929
|
-
//
|
|
2452
|
+
// Remote caller (claude.ai via tunnel) — can't read local temp files
|
|
2453
|
+
// Return value inline in MCP response (safe: stays in AI context, not a shell transcript)
|
|
2454
|
+
if (vault.remote) {
|
|
2455
|
+
const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
|
|
2456
|
+
return mcpResult(`${envVar}=${value}`);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// Default: file (local callers)
|
|
1930
2460
|
const envVar = ENV_MAP[service] || service.toUpperCase().replace(/-/g, "_");
|
|
1931
2461
|
const filePath = writeTempSecret(service, value);
|
|
1932
2462
|
return mcpResult(`${service} → ${filePath} (auto-deletes in 30s)\nEnv var: ${envVar}\nUsage: export ${envVar}=$(cat ${filePath.replace(/\\/g, "/")})`);
|
|
@@ -1962,6 +2492,14 @@ async function handleMcpTool(vault, name, args) {
|
|
|
1962
2492
|
|
|
1963
2493
|
if (!lines.length) return mcpError(`No services retrieved:\n${errors.join("\n")}`);
|
|
1964
2494
|
|
|
2495
|
+
// Remote caller — return env vars inline
|
|
2496
|
+
if (vault.remote) {
|
|
2497
|
+
let msg = lines.join("\n");
|
|
2498
|
+
if (errors.length) msg += `\n\nErrors:\n${errors.join("\n")}`;
|
|
2499
|
+
return mcpResult(msg);
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// Local caller — write temp env file
|
|
1965
2503
|
const envFilePath = path.join(os.tmpdir(), ".clauth-env");
|
|
1966
2504
|
fs.writeFileSync(envFilePath, lines.join("\n") + "\n", { mode: 0o600 });
|
|
1967
2505
|
setTimeout(() => { try { fs.unlinkSync(envFilePath); } catch {} }, 30_000);
|
|
@@ -2048,7 +2586,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
2048
2586
|
get machineHash() { return ensureMachineHash(); },
|
|
2049
2587
|
whitelist,
|
|
2050
2588
|
failCount: 0,
|
|
2051
|
-
MAX_FAILS:
|
|
2589
|
+
MAX_FAILS: 10,
|
|
2052
2590
|
};
|
|
2053
2591
|
|
|
2054
2592
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
@@ -2137,6 +2675,98 @@ async function actionMcp(opts) {
|
|
|
2137
2675
|
createMcpServer(password, whitelist);
|
|
2138
2676
|
}
|
|
2139
2677
|
|
|
2678
|
+
// ── DPAPI auto-start install / uninstall (Windows only) ──────
|
|
2679
|
+
const AUTOSTART_DIR = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
2680
|
+
const BOOT_KEY_PATH = path.join(AUTOSTART_DIR, "boot.key");
|
|
2681
|
+
const PS_SCRIPT_PATH = path.join(AUTOSTART_DIR, "autostart.ps1");
|
|
2682
|
+
const TASK_NAME = "ClauthAutostart";
|
|
2683
|
+
|
|
2684
|
+
async function actionInstall(opts) {
|
|
2685
|
+
if (os.platform() !== "win32") {
|
|
2686
|
+
console.log(chalk.red("\n serve install is only supported on Windows\n"));
|
|
2687
|
+
process.exit(1);
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
const { default: inquirer } = await import("inquirer");
|
|
2691
|
+
const { pw } = await inquirer.prompt([{
|
|
2692
|
+
type: "password", name: "pw",
|
|
2693
|
+
message: "Enter clauth password to store for auto-start:", mask: "*"
|
|
2694
|
+
}]);
|
|
2695
|
+
|
|
2696
|
+
fs.mkdirSync(AUTOSTART_DIR, { recursive: true });
|
|
2697
|
+
|
|
2698
|
+
// Encrypt password with Windows DPAPI (CurrentUser scope — machine+user bound)
|
|
2699
|
+
const spinner = ora("Encrypting password with Windows DPAPI...").start();
|
|
2700
|
+
let encrypted;
|
|
2701
|
+
try {
|
|
2702
|
+
const { execSync } = await import("child_process");
|
|
2703
|
+
const pwEscaped = pw.replace(/'/g, "''");
|
|
2704
|
+
const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
2705
|
+
encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
|
|
2706
|
+
fs.writeFileSync(BOOT_KEY_PATH, encrypted, "utf8");
|
|
2707
|
+
spinner.succeed(chalk.green("Password encrypted → boot.key"));
|
|
2708
|
+
} catch (err) {
|
|
2709
|
+
spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
|
|
2710
|
+
process.exit(1);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// Write PowerShell autostart script — decrypts boot.key and pipes to clauth serve start
|
|
2714
|
+
const cliEntry = path.resolve(__dirname, "../index.js").replace(/\\/g, "\\\\");
|
|
2715
|
+
const nodeExe = process.execPath.replace(/\\/g, "\\\\");
|
|
2716
|
+
const bootKey = BOOT_KEY_PATH.replace(/\\/g, "\\\\");
|
|
2717
|
+
const psScript = [
|
|
2718
|
+
"# clauth autostart — generated by clauth serve install",
|
|
2719
|
+
`$enc = (Get-Content '${bootKey}' -Raw).Trim()`,
|
|
2720
|
+
`$pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))`,
|
|
2721
|
+
`Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw" -WindowStyle Hidden`,
|
|
2722
|
+
].join("\n");
|
|
2723
|
+
fs.writeFileSync(PS_SCRIPT_PATH, psScript, "utf8");
|
|
2724
|
+
|
|
2725
|
+
// Register Windows Scheduled Task — triggers on user logon
|
|
2726
|
+
const spinner2 = ora("Registering Windows Scheduled Task...").start();
|
|
2727
|
+
try {
|
|
2728
|
+
const { execSync } = await import("child_process");
|
|
2729
|
+
const psScriptEsc = PS_SCRIPT_PATH.replace(/\\/g, "\\\\");
|
|
2730
|
+
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
2731
|
+
execSync(
|
|
2732
|
+
`schtasks /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
|
|
2733
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
2734
|
+
);
|
|
2735
|
+
spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
|
|
2736
|
+
} catch (err) {
|
|
2737
|
+
spinner2.fail(chalk.yellow(`Scheduled task failed (non-fatal): ${err.message}`));
|
|
2738
|
+
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
console.log(chalk.cyan("\n Auto-start installed:\n"));
|
|
2742
|
+
console.log(chalk.gray(` boot.key: ${BOOT_KEY_PATH}`));
|
|
2743
|
+
console.log(chalk.gray(` script: ${PS_SCRIPT_PATH}`));
|
|
2744
|
+
console.log(chalk.gray(` task: ${TASK_NAME}\n`));
|
|
2745
|
+
console.log(chalk.green(" Daemon will auto-start on next Windows login.\n"));
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
async function actionUninstall() {
|
|
2749
|
+
if (os.platform() !== "win32") {
|
|
2750
|
+
console.log(chalk.red("\n serve uninstall is only supported on Windows\n"));
|
|
2751
|
+
process.exit(1);
|
|
2752
|
+
}
|
|
2753
|
+
const { execSync } = await import("child_process");
|
|
2754
|
+
|
|
2755
|
+
// Remove scheduled task
|
|
2756
|
+
try {
|
|
2757
|
+
execSync(`schtasks /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
|
|
2758
|
+
console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
|
|
2759
|
+
} catch { console.log(chalk.gray(` Task not found (already removed): ${TASK_NAME}`)); }
|
|
2760
|
+
|
|
2761
|
+
// Remove boot.key and autostart script
|
|
2762
|
+
for (const f of [BOOT_KEY_PATH, PS_SCRIPT_PATH]) {
|
|
2763
|
+
try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
|
|
2764
|
+
catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
console.log(chalk.cyan("\n Auto-start uninstalled.\n"));
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2140
2770
|
// ── Export ────────────────────────────────────────────────────
|
|
2141
2771
|
export async function runServe(opts) {
|
|
2142
2772
|
const action = opts.action || "foreground";
|
|
@@ -2148,9 +2778,11 @@ export async function runServe(opts) {
|
|
|
2148
2778
|
case "ping": return actionPing();
|
|
2149
2779
|
case "foreground": return actionForeground(opts);
|
|
2150
2780
|
case "mcp": return actionMcp(opts);
|
|
2781
|
+
case "install": return actionInstall(opts);
|
|
2782
|
+
case "uninstall": return actionUninstall();
|
|
2151
2783
|
default:
|
|
2152
2784
|
console.log(chalk.red(`\n Unknown serve action: ${action}`));
|
|
2153
|
-
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp\n"));
|
|
2785
|
+
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall\n"));
|
|
2154
2786
|
process.exit(1);
|
|
2155
2787
|
}
|
|
2156
2788
|
}
|
package/cli/fingerprint.js
CHANGED
|
@@ -15,16 +15,18 @@ function getMachineId() {
|
|
|
15
15
|
|
|
16
16
|
try {
|
|
17
17
|
if (platform === "win32") {
|
|
18
|
-
// Primary: BIOS UUID via
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// Primary: BIOS UUID via PowerShell (wmic removed in Win11 26xxx+)
|
|
19
|
+
const psPath = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
|
|
20
|
+
const uuid = execSync(
|
|
21
|
+
`${psPath} -NoProfile -Command "(Get-CimInstance Win32_ComputerSystemProduct).UUID"`,
|
|
22
|
+
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
|
|
23
|
+
).trim();
|
|
22
24
|
|
|
23
|
-
// Secondary: Windows MachineGuid from registry
|
|
25
|
+
// Secondary: Windows MachineGuid from registry (via PowerShell for Win11 compat)
|
|
24
26
|
const machineGuid = execSync(
|
|
25
|
-
"
|
|
27
|
+
`${psPath} -NoProfile -Command "(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid"`,
|
|
26
28
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
|
|
27
|
-
).
|
|
29
|
+
).trim();
|
|
28
30
|
|
|
29
31
|
if (!uuid || !machineGuid) throw new Error("Could not read Windows machine IDs");
|
|
30
32
|
return { primary: uuid, secondary: machineGuid, platform: "win32" };
|
package/cli/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import * as api from "./api.js";
|
|
|
12
12
|
import os from "os";
|
|
13
13
|
|
|
14
14
|
const config = new Conf(getConfOptions());
|
|
15
|
-
const VERSION = "0.
|
|
15
|
+
const VERSION = "0.7.0";
|
|
16
16
|
|
|
17
17
|
// ============================================================
|
|
18
18
|
// Password prompt helper
|
|
@@ -464,10 +464,11 @@ program.addHelpText("beforeAll", chalk.cyan(`
|
|
|
464
464
|
// ──────────────────────────────────────────────
|
|
465
465
|
program
|
|
466
466
|
.command("serve [action]")
|
|
467
|
-
.description("Manage localhost HTTP vault daemon (start|stop|restart|ping)")
|
|
467
|
+
.description("Manage localhost HTTP vault daemon (start|stop|restart|ping|install|uninstall)")
|
|
468
468
|
.option("--port <n>", "Port (default: 52437)")
|
|
469
469
|
.option("-p, --pw <password>", "clauth password (optional — omit to start locked, unlock in browser)")
|
|
470
470
|
.option("--services <list>", "Comma-separated service whitelist (default: all)")
|
|
471
|
+
.option("--tunnel <hostname>", "Fixed tunnel hostname (e.g. clauth.prtrust.fund) — uses named Cloudflare Tunnel instead of random URL")
|
|
471
472
|
.option("--action <action>", "Internal: action override for daemon child")
|
|
472
473
|
.addHelpText("after", `
|
|
473
474
|
Actions:
|
|
@@ -477,6 +478,8 @@ Actions:
|
|
|
477
478
|
ping Check if the daemon is running
|
|
478
479
|
foreground Run in foreground (Ctrl+C to stop) — default if no action given
|
|
479
480
|
mcp Run as MCP stdio server for Claude Code (JSON-RPC over stdin/stdout)
|
|
481
|
+
install (Windows) Encrypt password with DPAPI + create Scheduled Task for auto-start on login
|
|
482
|
+
uninstall (Windows) Remove Scheduled Task + delete boot.key
|
|
480
483
|
|
|
481
484
|
MCP SSE (built into start/foreground):
|
|
482
485
|
The HTTP daemon also serves MCP SSE transport at GET /sse + POST /message.
|
|
@@ -491,6 +494,8 @@ Examples:
|
|
|
491
494
|
clauth serve start --services github,vercel
|
|
492
495
|
clauth serve mcp Start MCP server for Claude Code
|
|
493
496
|
clauth serve mcp -p mypass Start MCP server pre-unlocked
|
|
497
|
+
clauth serve install (Windows) Set up auto-start on login via DPAPI
|
|
498
|
+
clauth serve uninstall (Windows) Remove auto-start
|
|
494
499
|
`)
|
|
495
500
|
.action(async (action, opts) => {
|
|
496
501
|
const resolvedAction = opts.action || action || "foreground";
|