@mgsoftwarebv/mg-dashboard-mcp 3.1.2 → 3.3.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/dist/index.js CHANGED
@@ -1,13 +1,216 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from 'child_process';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { Client as Client$1 } from '@modelcontextprotocol/sdk/client/index.js';
6
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
2
7
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
9
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
10
  import { ListToolsRequestSchema, CallToolRequestSchema, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
6
11
  import { createServer } from 'http';
7
- import { randomUUID, createHash, randomBytes, createCipheriv, createDecipheriv } from 'crypto';
12
+ import { randomUUID, randomBytes, createHash, createCipheriv, createDecipheriv } from 'crypto';
8
13
  import { createClient } from '@supabase/supabase-js';
14
+ import { readFile, mkdtemp, writeFile, rm } from 'fs/promises';
15
+ import { tmpdir } from 'os';
9
16
  import { Client } from 'ssh2';
10
17
 
18
+ var __defProp = Object.defineProperty;
19
+ var __getOwnPropNames = Object.getOwnPropertyNames;
20
+ var __esm = (fn, res) => function __init() {
21
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
22
+ };
23
+ var __export = (target, all) => {
24
+ for (var name in all)
25
+ __defProp(target, name, { get: all[name], enumerable: true });
26
+ };
27
+
28
+ // src/proxy-mode.ts
29
+ var proxy_mode_exports = {};
30
+ __export(proxy_mode_exports, {
31
+ runProxyMode: () => runProxyMode
32
+ });
33
+ function getArg(args2, name) {
34
+ return args2.find((a) => a.startsWith(`--${name}=`))?.split("=").slice(1).join("=");
35
+ }
36
+ function expandHome(path) {
37
+ if (!path.startsWith("~")) return path;
38
+ const home = process.env.HOME || process.env.USERPROFILE || "";
39
+ return join(home, path.slice(1));
40
+ }
41
+ function resolvePubkeyPath(input) {
42
+ const candidate = expandHome(input);
43
+ let pubPath;
44
+ if (candidate.endsWith(".pub")) {
45
+ pubPath = candidate;
46
+ } else if (existsSync(`${candidate}.pub`)) {
47
+ pubPath = `${candidate}.pub`;
48
+ } else if (existsSync(candidate)) {
49
+ pubPath = candidate;
50
+ } else {
51
+ throw new Error(`SSH key file not found: ${candidate} (also tried ${candidate}.pub)`);
52
+ }
53
+ const pubText = readFileSync(pubPath, "utf8").trim();
54
+ if (!pubText) throw new Error(`SSH public-key file is empty: ${pubPath}`);
55
+ return { pubPath, pubText };
56
+ }
57
+ function runProcess(command, argv, options = {}) {
58
+ return new Promise((resolve) => {
59
+ const child = spawn(command, argv, { stdio: ["pipe", "pipe", "pipe"] });
60
+ let stdout = "";
61
+ let stderr = "";
62
+ child.stdout.on("data", (d) => {
63
+ stdout += d.toString();
64
+ });
65
+ child.stderr.on("data", (d) => {
66
+ stderr += d.toString();
67
+ });
68
+ child.on("error", (err) => {
69
+ resolve({ stdout, stderr: stderr + String(err), code: -1 });
70
+ });
71
+ child.on("close", (code) => {
72
+ resolve({ stdout, stderr, code: code ?? -1 });
73
+ });
74
+ if (options.stdin !== void 0) {
75
+ child.stdin.write(options.stdin);
76
+ child.stdin.end();
77
+ } else {
78
+ child.stdin.end();
79
+ }
80
+ });
81
+ }
82
+ function deriveAuthBaseUrl(proxyUrl2) {
83
+ const u = new URL(proxyUrl2);
84
+ return `${u.protocol}//${u.host}`;
85
+ }
86
+ async function performHandshake(proxyUrl2, pubPath, pubText) {
87
+ const base = deriveAuthBaseUrl(proxyUrl2);
88
+ const challengeRes = await fetch(`${base}/v1/auth/ssh/challenge`, {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: "{}"
92
+ });
93
+ if (!challengeRes.ok) {
94
+ const txt = await challengeRes.text().catch(() => "");
95
+ throw new Error(`Challenge request failed (${challengeRes.status}): ${txt}`);
96
+ }
97
+ const challenge = await challengeRes.json();
98
+ const sign = await runProcess(
99
+ "ssh-keygen",
100
+ ["-Y", "sign", "-f", pubPath, "-n", challenge.namespace, "-q"],
101
+ { stdin: challenge.nonce }
102
+ );
103
+ if (sign.code !== 0 || !sign.stdout.includes("BEGIN SSH SIGNATURE")) {
104
+ throw new Error(
105
+ `ssh-keygen sign failed (exit ${sign.code}). Make sure ssh-agent is running and has the matching private key loaded. stderr: ${sign.stderr.trim() || "(empty)"}`
106
+ );
107
+ }
108
+ const verifyRes = await fetch(`${base}/v1/auth/ssh/verify`, {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify({
112
+ challenge_id: challenge.challenge_id,
113
+ pubkey: pubText,
114
+ signature: sign.stdout
115
+ })
116
+ });
117
+ if (!verifyRes.ok) {
118
+ const txt = await verifyRes.text().catch(() => "");
119
+ throw new Error(`Verify request failed (${verifyRes.status}): ${txt}`);
120
+ }
121
+ const verified = await verifyRes.json();
122
+ return {
123
+ token: verified.token,
124
+ expiresAt: Date.now() + verified.expires_in_seconds * 1e3,
125
+ proxyKeyName: verified.proxy_key_name
126
+ };
127
+ }
128
+ async function runBridge(handshake, proxyUrl2, refresh) {
129
+ let currentToken = handshake.token;
130
+ const upstreamTransport = new StreamableHTTPClientTransport(new URL(proxyUrl2), {
131
+ requestInit: () => ({
132
+ headers: { Authorization: `Bearer ${currentToken}` }
133
+ })
134
+ });
135
+ const upstream = new Client$1({ name: "mg-dashboard-mcp-proxy-bridge", version: "1.0.0" });
136
+ const scheduleRefresh = (result) => {
137
+ const msUntilRefresh = Math.max(3e4, result.expiresAt - Date.now() - 12e4);
138
+ setTimeout(async () => {
139
+ try {
140
+ const fresh = await refresh();
141
+ currentToken = fresh.token;
142
+ console.error(`[Proxy] Token refreshed (key: ${fresh.proxyKeyName})`);
143
+ scheduleRefresh(fresh);
144
+ } catch (err) {
145
+ console.error(`[Proxy] Token refresh failed: ${err instanceof Error ? err.message : String(err)}`);
146
+ setTimeout(() => scheduleRefresh({ ...result, expiresAt: Date.now() + 6e4 }), 3e4);
147
+ }
148
+ }, msUntilRefresh).unref();
149
+ };
150
+ scheduleRefresh(handshake);
151
+ const stdioServer = new Server(
152
+ { name: "mg-dashboard-mcp-proxy", version: "1.0.0" },
153
+ { capabilities: { tools: {}, prompts: {}, resources: {}, logging: {} } }
154
+ );
155
+ stdioServer.fallbackRequestHandler = async (request) => {
156
+ return await upstream.request(
157
+ request,
158
+ // We don't care about the schema check on the way through — let the
159
+ // upstream's actual response flow back unmodified.
160
+ void 0
161
+ );
162
+ };
163
+ stdioServer.fallbackNotificationHandler = async (notification) => {
164
+ await upstream.notification(notification);
165
+ };
166
+ await upstream.connect(upstreamTransport);
167
+ upstream.fallbackNotificationHandler = async (notification) => {
168
+ await stdioServer.notification(notification);
169
+ };
170
+ await stdioServer.connect(new StdioServerTransport());
171
+ console.error(`[Proxy] Bridge ready. Forwarding stdio \u2192 ${proxyUrl2}`);
172
+ }
173
+ async function runProxyMode(args2) {
174
+ const proxyUrl2 = getArg(args2, "proxy-url") || process.env.MG_DASHBOARD_PROXY_URL;
175
+ const sshKey = getArg(args2, "ssh-key") || process.env.MG_DASHBOARD_SSH_KEY;
176
+ if (!proxyUrl2) {
177
+ console.error("--proxy-url is required (or set MG_DASHBOARD_PROXY_URL)");
178
+ process.exit(1);
179
+ }
180
+ if (!sshKey) {
181
+ console.error("--ssh-key is required in proxy mode (or set MG_DASHBOARD_SSH_KEY)");
182
+ process.exit(1);
183
+ }
184
+ console.error(`[Proxy] Starting in proxy-bridge mode \u2192 ${proxyUrl2}`);
185
+ let resolved;
186
+ try {
187
+ resolved = resolvePubkeyPath(sshKey);
188
+ } catch (err) {
189
+ console.error(`[Proxy] ${err instanceof Error ? err.message : String(err)}`);
190
+ process.exit(1);
191
+ }
192
+ const refresh = () => performHandshake(proxyUrl2, resolved.pubPath, resolved.pubText);
193
+ let handshake;
194
+ try {
195
+ handshake = await refresh();
196
+ } catch (err) {
197
+ console.error(`[Proxy] Handshake failed: ${err instanceof Error ? err.message : String(err)}`);
198
+ process.exit(1);
199
+ }
200
+ console.error(`[Proxy] Authenticated as "${handshake.proxyKeyName}"`);
201
+ console.error(`[Proxy] Token valid for ${Math.floor((handshake.expiresAt - Date.now()) / 6e4)} min`);
202
+ try {
203
+ await runBridge(handshake, proxyUrl2, refresh);
204
+ } catch (err) {
205
+ console.error(`[Proxy] Bridge crashed: ${err instanceof Error ? err.message : String(err)}`);
206
+ process.exit(1);
207
+ }
208
+ }
209
+ var init_proxy_mode = __esm({
210
+ "src/proxy-mode.ts"() {
211
+ }
212
+ });
213
+
11
214
  // src/trigger-tools.ts
12
215
  var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
13
216
  "COMPLETED",
@@ -953,18 +1156,25 @@ LinkedIn: ${pageLinkedIn.join(", ")}`;
953
1156
 
954
1157
  // src/index.ts
955
1158
  var args = process.argv.slice(2);
956
- function getArg(name) {
1159
+ function getArg2(name) {
957
1160
  return args.find((a) => a.startsWith(`--${name}=`))?.split("=").slice(1).join("=");
958
1161
  }
959
- var apiKey = getArg("api-key") || process.env.MG_DASHBOARD_API_KEY;
960
- var supabaseUrl = getArg("supabase-url") || process.env.SUPABASE_URL;
961
- var supabaseKey = getArg("supabase-key") || process.env.SUPABASE_SERVICE_ROLE_KEY;
962
- var encryptionKey = getArg("encryption-key") || process.env.ENCRYPTION_KEY;
963
- var mijnhostApiKey = getArg("mijnhost-api-key") || process.env.MIJNHOST_API_KEY;
1162
+ var proxyUrl = getArg2("proxy-url") || process.env.MG_DASHBOARD_PROXY_URL;
1163
+ if (proxyUrl) {
1164
+ const { runProxyMode: runProxyMode2 } = await Promise.resolve().then(() => (init_proxy_mode(), proxy_mode_exports));
1165
+ await runProxyMode2(args);
1166
+ process.exit(0);
1167
+ }
1168
+ var apiKey = getArg2("api-key") || process.env.MG_DASHBOARD_API_KEY;
1169
+ var sshKeyPath = getArg2("ssh-key") || process.env.MG_DASHBOARD_SSH_KEY;
1170
+ var supabaseUrl = getArg2("supabase-url") || process.env.SUPABASE_URL;
1171
+ var supabaseKey = getArg2("supabase-key") || process.env.SUPABASE_SERVICE_ROLE_KEY;
1172
+ var encryptionKey = getArg2("encryption-key") || process.env.ENCRYPTION_KEY;
1173
+ var mijnhostApiKey = getArg2("mijnhost-api-key") || process.env.MIJNHOST_API_KEY;
964
1174
  var httpMode = args.includes("--http");
965
- var httpPort = Number(getArg("port")) || 3100;
966
- if (!apiKey) {
967
- console.error("API key is required. Use --api-key=dk_xxx or set MG_DASHBOARD_API_KEY");
1175
+ var httpPort = Number(getArg2("port")) || 3100;
1176
+ if (!apiKey && !sshKeyPath) {
1177
+ console.error("Authentication required. Use --api-key=dk_xxx, --ssh-key=PATH (path to your SSH private or public key), or set MG_DASHBOARD_API_KEY / MG_DASHBOARD_SSH_KEY.");
968
1178
  process.exit(1);
969
1179
  }
970
1180
  if (!supabaseUrl || !supabaseKey) {
@@ -1049,156 +1259,6 @@ async function writeAuditLog(entry) {
1049
1259
  console.error("[Audit] Failed to write audit log:", err instanceof Error ? err.message : err);
1050
1260
  }
1051
1261
  }
1052
- var TELEGRAM_API = "https://api.telegram.org/bot";
1053
- var cachedTelegramConfig = null;
1054
- async function getTelegramBotConfig() {
1055
- if (cachedTelegramConfig) return cachedTelegramConfig;
1056
- const { data, error } = await supabase.from("telegram_bot_config").select("bot_token_encrypted, chat_id, notifications_enabled").limit(1).maybeSingle();
1057
- if (error) {
1058
- console.error("[Telegram Config] Query error:", error.message);
1059
- return null;
1060
- }
1061
- if (!data?.bot_token_encrypted || !data?.chat_id) {
1062
- console.error("[Telegram Config] Missing bot_token or chat_id in telegram_bot_config table");
1063
- return null;
1064
- }
1065
- if (!data.notifications_enabled) {
1066
- console.error("[Telegram Config] Notifications disabled in telegram_bot_config");
1067
- return null;
1068
- }
1069
- try {
1070
- const token = decrypt(data.bot_token_encrypted);
1071
- cachedTelegramConfig = { botToken: token, chatId: data.chat_id };
1072
- console.error("[Telegram Config] Loaded successfully");
1073
- return cachedTelegramConfig;
1074
- } catch (err) {
1075
- console.error("[Telegram Config] Failed to decrypt bot token:", err instanceof Error ? err.message : err);
1076
- return null;
1077
- }
1078
- }
1079
- function escapeHtmlTg(str) {
1080
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1081
- }
1082
- async function sendTelegramMessage(token, chatId, text, replyMarkup) {
1083
- try {
1084
- const body = { chat_id: chatId, text, parse_mode: "HTML" };
1085
- if (replyMarkup) body.reply_markup = replyMarkup;
1086
- const res = await fetch(`${TELEGRAM_API}${token}/sendMessage`, {
1087
- method: "POST",
1088
- headers: { "Content-Type": "application/json" },
1089
- body: JSON.stringify(body)
1090
- });
1091
- const result = await res.json();
1092
- return { ok: result.ok, messageId: result.result?.message_id };
1093
- } catch {
1094
- return { ok: false };
1095
- }
1096
- }
1097
- async function checkIpAllowlist(ipAddress) {
1098
- if (!authContext || !authContext.requireIpApproval) {
1099
- console.error(`[IP Check] Skipped (requireIpApproval=${authContext?.requireIpApproval})`);
1100
- return { allowed: true };
1101
- }
1102
- console.error(`[IP Check] Checking IP ${ipAddress} for key ${authContext.apiKeyId}`);
1103
- const { data: existing, error: lookupErr } = await supabase.from("mcp_ip_allowlist").select("id").eq("api_key_id", authContext.apiKeyId).eq("ip_address", ipAddress).maybeSingle();
1104
- if (lookupErr) {
1105
- console.error(`[IP Check] Allowlist lookup error: ${lookupErr.message}`);
1106
- }
1107
- if (existing) {
1108
- console.error(`[IP Check] IP ${ipAddress} found in allowlist`);
1109
- return { allowed: true };
1110
- }
1111
- console.error(`[IP Check] IP ${ipAddress} NOT in allowlist, requesting Telegram approval...`);
1112
- const tgConfig = await getTelegramBotConfig();
1113
- if (!tgConfig) {
1114
- console.error("[IP Check] BLOCKING: Unknown IP and no Telegram configured");
1115
- return { allowed: false, reason: "Unknown IP and no approval channel configured" };
1116
- }
1117
- let geoInfo = null;
1118
- try {
1119
- const geoRes = await fetch(`http://ip-api.com/json/${ipAddress}?fields=country,city,isp`);
1120
- if (geoRes.ok) geoInfo = await geoRes.json();
1121
- } catch {
1122
- }
1123
- const geoLabel = geoInfo ? `${geoInfo.city || "?"}, ${geoInfo.country || "?"} (${geoInfo.isp || "?"})` : "Onbekend";
1124
- const { data: request, error: insertErr } = await supabase.from("mcp_ip_approval_request").insert({
1125
- api_key_id: authContext.apiKeyId,
1126
- ip_address: ipAddress,
1127
- status: "pending",
1128
- geo_info: geoInfo
1129
- }).select("id").single();
1130
- if (insertErr || !request) {
1131
- console.error(`[IP Check] Failed to create approval request: ${insertErr?.message || "no data returned"}`);
1132
- return { allowed: false, reason: `Failed to create approval request: ${insertErr?.message || "unknown"}` };
1133
- }
1134
- console.error(`[IP Check] Created approval request ${request.id}`);
1135
- const message = [
1136
- `<b>MCP IP Approval</b>`,
1137
- ``,
1138
- `<b>IP:</b> <code>${escapeHtmlTg(ipAddress)}</code>`,
1139
- `<b>Locatie:</b> ${escapeHtmlTg(geoLabel)}`,
1140
- `<b>API Key:</b> ${escapeHtmlTg(authContext.apiKeyName)}`,
1141
- `<b>User:</b> ${escapeHtmlTg(authContext.userId)}`,
1142
- ``,
1143
- `<i>Onbekend IP probeert verbinding te maken met de MCP server.</i>`
1144
- ].join("\n");
1145
- const tgResult = await sendTelegramMessage(tgConfig.botToken, tgConfig.chatId, message, {
1146
- inline_keyboard: [
1147
- [
1148
- { text: "Goedkeuren", callback_data: `mcp_ip_approve:${request.id}` },
1149
- { text: "Weigeren", callback_data: `mcp_ip_deny:${request.id}` }
1150
- ]
1151
- ]
1152
- });
1153
- if (tgResult.messageId) {
1154
- await supabase.from("mcp_ip_approval_request").update({ telegram_message_id: tgResult.messageId }).eq("id", request.id);
1155
- }
1156
- console.error(`[IP Check] Waiting for Telegram approval for IP ${ipAddress}...`);
1157
- const deadline = Date.now() + 6e4;
1158
- while (Date.now() < deadline) {
1159
- await new Promise((r) => setTimeout(r, 2e3));
1160
- const { data: check } = await supabase.from("mcp_ip_approval_request").select("status").eq("id", request.id).single();
1161
- if (check?.status === "approved") {
1162
- await supabase.from("mcp_ip_allowlist").insert({
1163
- api_key_id: authContext.apiKeyId,
1164
- ip_address: ipAddress,
1165
- label: geoLabel,
1166
- approved_by: "telegram"
1167
- });
1168
- console.error(`[IP Check] IP ${ipAddress} approved via Telegram`);
1169
- return { allowed: true };
1170
- }
1171
- if (check?.status === "denied") {
1172
- console.error(`[IP Check] IP ${ipAddress} denied via Telegram`);
1173
- return { allowed: false, reason: "IP denied via Telegram" };
1174
- }
1175
- }
1176
- await supabase.from("mcp_ip_approval_request").update({ status: "denied", resolved_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", request.id);
1177
- console.error(`[IP Check] IP ${ipAddress} approval timed out`);
1178
- return { allowed: false, reason: "Approval request timed out (60s)" };
1179
- }
1180
- var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
1181
- var SESSION_MAX_ABSOLUTE_MS = 24 * 60 * 60 * 1e3;
1182
- async function getOrCreateSession(ipAddress) {
1183
- if (!authContext) return { valid: false, reason: "Not authenticated" };
1184
- const now = /* @__PURE__ */ new Date();
1185
- const { data: existing } = await supabase.from("mcp_session").select("id, created_at, expires_at").eq("api_key_id", authContext.apiKeyId).eq("ip_address", ipAddress).gt("expires_at", now.toISOString()).order("created_at", { ascending: false }).limit(1).maybeSingle();
1186
- if (existing) {
1187
- const createdAt = new Date(existing.created_at).getTime();
1188
- if (now.getTime() - createdAt > SESSION_MAX_ABSOLUTE_MS) {
1189
- await supabase.from("mcp_session").delete().eq("id", existing.id);
1190
- return { valid: false, reason: "Session exceeded absolute TTL (24h)" };
1191
- }
1192
- await supabase.from("mcp_session").update({ last_activity_at: now.toISOString() }).eq("id", existing.id);
1193
- return { valid: true, sessionId: existing.id };
1194
- }
1195
- const { data: newSession } = await supabase.from("mcp_session").insert({
1196
- api_key_id: authContext.apiKeyId,
1197
- ip_address: ipAddress,
1198
- expires_at: new Date(now.getTime() + SESSION_TTL_MS).toISOString()
1199
- }).select("id").single();
1200
- return { valid: true, sessionId: newSession?.id };
1201
- }
1202
1262
  var MODULE_KEYS = [
1203
1263
  "users",
1204
1264
  "ssh_servers",
@@ -1284,7 +1344,7 @@ async function validateApiKey(key) {
1284
1344
  console.error(`Rate limited: too many failed auth attempts. Retry in ${retryMin} minute(s).`);
1285
1345
  return null;
1286
1346
  }
1287
- const { data, error } = await supabase.from("dashboard_mcp_api_key").select("id, name, created_by, allowed_server_ids, is_active, expires_at, require_ip_approval").eq("api_key_hash", keyHash).eq("is_active", true).single();
1347
+ const { data, error } = await supabase.from("dashboard_mcp_api_key").select("id, name, created_by, allowed_server_ids, is_active, expires_at").eq("api_key_hash", keyHash).eq("is_active", true).single();
1288
1348
  if (error || !data) {
1289
1349
  console.error(`API key not found or inactive (${rateCheck.remaining} attempts remaining)`);
1290
1350
  return null;
@@ -1315,8 +1375,182 @@ async function validateApiKey(key) {
1315
1375
  userId: data.created_by,
1316
1376
  allowedServerIds,
1317
1377
  permissions,
1318
- roleName,
1319
- requireIpApproval: data.require_ip_approval ?? true
1378
+ roleName
1379
+ };
1380
+ }
1381
+ function runProcess2(command, argv, options = {}) {
1382
+ return new Promise((resolve) => {
1383
+ const child = spawn(command, argv, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"] });
1384
+ let stdout = "";
1385
+ let stderr = "";
1386
+ child.stdout.on("data", (d) => {
1387
+ stdout += d.toString();
1388
+ });
1389
+ child.stderr.on("data", (d) => {
1390
+ stderr += d.toString();
1391
+ });
1392
+ child.on("error", (err) => {
1393
+ resolve({ stdout, stderr: stderr + String(err), code: -1 });
1394
+ });
1395
+ child.on("close", (code) => {
1396
+ resolve({ stdout, stderr, code: code ?? -1 });
1397
+ });
1398
+ if (options.stdin !== void 0) {
1399
+ child.stdin.write(options.stdin);
1400
+ child.stdin.end();
1401
+ } else {
1402
+ child.stdin.end();
1403
+ }
1404
+ });
1405
+ }
1406
+ async function resolvePubkeyFile(input) {
1407
+ const expand = (p) => p.startsWith("~") ? join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1)) : p;
1408
+ const candidate = expand(input);
1409
+ let pubPath;
1410
+ if (candidate.endsWith(".pub")) {
1411
+ pubPath = candidate;
1412
+ } else if (existsSync(`${candidate}.pub`)) {
1413
+ pubPath = `${candidate}.pub`;
1414
+ } else if (existsSync(candidate)) {
1415
+ pubPath = candidate;
1416
+ } else {
1417
+ throw new Error(`SSH key file not found: ${candidate} (also tried ${candidate}.pub)`);
1418
+ }
1419
+ const pubText = (await readFile(pubPath, "utf8")).trim();
1420
+ if (!pubText) {
1421
+ throw new Error(`SSH public-key file is empty: ${pubPath}`);
1422
+ }
1423
+ return { pubPath, pubText };
1424
+ }
1425
+ function computeOpenSshFingerprint(pubkeyLine) {
1426
+ const parts = pubkeyLine.trim().split(/\s+/);
1427
+ if (parts.length < 2 || !parts[1]) {
1428
+ throw new Error('Invalid SSH public-key line (expected "<type> <base64> [comment]")');
1429
+ }
1430
+ const bodyBytes = Buffer.from(parts[1], "base64");
1431
+ const hash = createHash("sha256").update(bodyBytes).digest("base64").replace(/=+$/, "");
1432
+ return `SHA256:${hash}`;
1433
+ }
1434
+ async function sshKeygenSign(pubkeyPath, challenge, namespace) {
1435
+ const result = await runProcess2(
1436
+ "ssh-keygen",
1437
+ ["-Y", "sign", "-f", pubkeyPath, "-n", namespace, "-q"],
1438
+ { stdin: challenge }
1439
+ );
1440
+ if (result.code !== 0) {
1441
+ throw new Error(
1442
+ `ssh-keygen sign failed (exit ${result.code}). Make sure ssh-agent is running and has the matching private key loaded (or that the private key is at the path next to the .pub file). stderr: ${result.stderr.trim() || "(empty)"}`
1443
+ );
1444
+ }
1445
+ if (!result.stdout.includes("BEGIN SSH SIGNATURE")) {
1446
+ throw new Error(`ssh-keygen sign produced no signature (stderr: ${result.stderr.trim() || "(empty)"})`);
1447
+ }
1448
+ return result.stdout;
1449
+ }
1450
+ async function sshKeygenVerify(pubkeyLine, signature, challenge, namespace) {
1451
+ const dir = await mkdtemp(join(tmpdir(), "mcp-ssh-verify-"));
1452
+ try {
1453
+ const allowedSigners = `mcp@local ${pubkeyLine.trim()}
1454
+ `;
1455
+ const allowedPath = join(dir, "allowed_signers");
1456
+ const sigPath = join(dir, "sig");
1457
+ await writeFile(allowedPath, allowedSigners, "utf8");
1458
+ await writeFile(sigPath, signature, "utf8");
1459
+ const result = await runProcess2(
1460
+ "ssh-keygen",
1461
+ ["-Y", "verify", "-f", allowedPath, "-I", "mcp@local", "-n", namespace, "-s", sigPath, "-q"],
1462
+ { stdin: challenge }
1463
+ );
1464
+ return result.code === 0;
1465
+ } finally {
1466
+ await rm(dir, { recursive: true, force: true }).catch(() => void 0);
1467
+ }
1468
+ }
1469
+ async function validateSshKey(pubkeyPathInput) {
1470
+ let pubPath;
1471
+ let pubText;
1472
+ try {
1473
+ const resolved = await resolvePubkeyFile(pubkeyPathInput);
1474
+ pubPath = resolved.pubPath;
1475
+ pubText = resolved.pubText;
1476
+ } catch (err) {
1477
+ console.error(`SSH key error: ${err instanceof Error ? err.message : String(err)}`);
1478
+ return null;
1479
+ }
1480
+ let fingerprint;
1481
+ try {
1482
+ fingerprint = computeOpenSshFingerprint(pubText);
1483
+ } catch (err) {
1484
+ console.error(`SSH key error: ${err instanceof Error ? err.message : String(err)}`);
1485
+ return null;
1486
+ }
1487
+ const rateCheck = authRateLimiter.check(fingerprint);
1488
+ if (!rateCheck.allowed) {
1489
+ const retryMin = Math.ceil(rateCheck.retryAfterMs / 6e4);
1490
+ console.error(`Rate limited: too many failed SSH-key auth attempts. Retry in ${retryMin} minute(s).`);
1491
+ return null;
1492
+ }
1493
+ const skipVerify = process.env.MG_DASHBOARD_SSH_SKIP_VERIFY === "1";
1494
+ if (!skipVerify) {
1495
+ try {
1496
+ const challenge = randomBytes(32).toString("hex");
1497
+ const signature = await sshKeygenSign(pubPath, challenge, "mg-dashboard-mcp");
1498
+ const verified = await sshKeygenVerify(pubText, signature, challenge, "mg-dashboard-mcp");
1499
+ if (!verified) {
1500
+ console.error("SSH-key challenge verification failed (signature did not validate against the public key).");
1501
+ return null;
1502
+ }
1503
+ } catch (err) {
1504
+ console.error(`SSH-key challenge failed: ${err instanceof Error ? err.message : String(err)}`);
1505
+ console.error("Tip: set MG_DASHBOARD_SSH_SKIP_VERIFY=1 to skip the challenge if you only need to print the fingerprint for enrollment.");
1506
+ return null;
1507
+ }
1508
+ }
1509
+ const { data: keyRow, error: keyErr } = await supabase.from("dashboard_mcp_ssh_key").select("id, name, api_key_id, is_active").eq("fingerprint_sha256", fingerprint).eq("is_active", true).maybeSingle();
1510
+ if (keyErr || !keyRow) {
1511
+ console.error(
1512
+ `SSH key not registered (fingerprint ${fingerprint}; ${rateCheck.remaining} attempts remaining). Add it under MCP API Keys \u2192 SSH Keys in the dashboard.`
1513
+ );
1514
+ return null;
1515
+ }
1516
+ const { data: apiRow, error: apiErr } = await supabase.from("dashboard_mcp_api_key").select("id, name, created_by, allowed_server_ids, is_active, expires_at").eq("id", keyRow.api_key_id).eq("is_active", true).maybeSingle();
1517
+ if (apiErr || !apiRow) {
1518
+ console.error("SSH key is linked to an inactive or missing MCP API key entry.");
1519
+ return null;
1520
+ }
1521
+ if (apiRow.expires_at && new Date(apiRow.expires_at) < /* @__PURE__ */ new Date()) {
1522
+ console.error("Linked MCP API key entry has expired.");
1523
+ return null;
1524
+ }
1525
+ const { data: userData, error: userError } = await supabase.from("user").select("permissions, role:role!role_id(name, default_permissions)").eq("id", apiRow.created_by).single();
1526
+ if (userError || !userData) {
1527
+ console.error(`User not found for SSH key creator: ${apiRow.created_by}`);
1528
+ return null;
1529
+ }
1530
+ const roleName = userData.role?.name || "user";
1531
+ const roleDefaults = userData.role?.default_permissions ?? {};
1532
+ const userOverrides = userData.permissions ?? null;
1533
+ const permissions = resolvePermissions(roleName, roleDefaults, userOverrides);
1534
+ const allowedServerIds = intersectServerAccess(
1535
+ apiRow.allowed_server_ids,
1536
+ permissions.resources.ssh_servers
1537
+ );
1538
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1539
+ await Promise.all([
1540
+ supabase.from("dashboard_mcp_ssh_key").update({ last_used_at: nowIso }).eq("id", keyRow.id),
1541
+ supabase.from("dashboard_mcp_api_key").update({ last_used_at: nowIso }).eq("id", apiRow.id)
1542
+ ]);
1543
+ const moduleCount = MODULE_KEYS.filter((k) => permissions.modules[k]).length;
1544
+ console.error(
1545
+ `Authenticated via SSH key "${keyRow.name}" (fp ${fingerprint.slice(0, 24)}...) as user ${apiRow.created_by} (role: ${roleName}, modules: ${moduleCount}/${MODULE_KEYS.length})`
1546
+ );
1547
+ return {
1548
+ apiKeyId: apiRow.id,
1549
+ apiKeyName: `${apiRow.name || "Unknown"} (ssh: ${keyRow.name})`,
1550
+ userId: apiRow.created_by,
1551
+ allowedServerIds,
1552
+ permissions,
1553
+ roleName
1320
1554
  };
1321
1555
  }
1322
1556
  function assertServerAccess(serverId) {
@@ -2941,56 +3175,22 @@ function createMcpServer() {
2941
3175
  return s;
2942
3176
  }
2943
3177
  var server = createMcpServer();
2944
- async function resolvePublicIp() {
2945
- const services = [
2946
- "https://api.ipify.org",
2947
- "https://ifconfig.me/ip",
2948
- "https://icanhazip.com"
2949
- ];
2950
- for (const url of services) {
2951
- try {
2952
- const controller = new AbortController();
2953
- const timer = setTimeout(() => controller.abort(), 5e3);
2954
- const res = await fetch(url, { signal: controller.signal });
2955
- clearTimeout(timer);
2956
- if (res.ok) {
2957
- const ip = (await res.text()).trim();
2958
- if (ip && /^[\d.:a-f]+$/i.test(ip)) {
2959
- console.error(`[IP Resolve] Got public IP: ${ip} (from ${url})`);
2960
- return ip;
2961
- }
2962
- }
2963
- } catch (err) {
2964
- console.error(`[IP Resolve] Failed ${url}: ${err instanceof Error ? err.message : err}`);
2965
- }
2966
- }
2967
- console.error("[IP Resolve] All services failed, returning unknown");
2968
- return "unknown";
2969
- }
2970
3178
  async function main() {
2971
3179
  console.error("Starting MG Dashboard MCP Server...");
2972
- authContext = await validateApiKey(apiKey);
2973
- if (!authContext) {
2974
- console.error("API key validation failed");
2975
- process.exit(1);
2976
- }
2977
- console.error(`[Security] MCP v${MCP_VERSION} | Key: ${authContext.apiKeyName} | requireIpApproval: ${authContext.requireIpApproval}`);
2978
- if (authContext.requireIpApproval) {
2979
- console.error("[Security] IP approval enabled, resolving public IP...");
2980
- const publicIp = await resolvePublicIp();
2981
- console.error(`[Security] Public IP: ${publicIp}`);
2982
- const ipResult = await checkIpAllowlist(publicIp);
2983
- if (!ipResult.allowed) {
2984
- console.error(`[Security] BLOCKED \u2014 ${ipResult.reason}`);
2985
- console.error("[Security] MCP server will not start. Approve the IP via Telegram or add it manually in the dashboard.");
3180
+ if (sshKeyPath) {
3181
+ authContext = await validateSshKey(sshKeyPath);
3182
+ if (!authContext) {
3183
+ console.error("SSH-key authentication failed");
2986
3184
  process.exit(1);
2987
3185
  }
2988
- console.error("[Security] IP approved, creating session...");
2989
- await getOrCreateSession(publicIp);
2990
- console.error("[Security] Session created");
2991
3186
  } else {
2992
- console.error("[Security] IP approval disabled for this key");
3187
+ authContext = await validateApiKey(apiKey);
3188
+ if (!authContext) {
3189
+ console.error("API key validation failed");
3190
+ process.exit(1);
3191
+ }
2993
3192
  }
3193
+ console.error(`[Security] MCP v${MCP_VERSION} | Key: ${authContext.apiKeyName}`);
2994
3194
  const toolNames = TOOLS.map((t) => t.name).join(", ");
2995
3195
  if (httpMode) {
2996
3196
  console.error(`API key validated. Starting Streamable HTTP transport on port ${httpPort}...`);
@@ -3001,24 +3201,6 @@ async function main() {
3001
3201
  };
3002
3202
  const httpServer = createServer(async (req, res) => {
3003
3203
  const url = new URL(req.url ?? "/", `http://localhost:${httpPort}`);
3004
- const clientIp = (req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
3005
- if (authContext?.requireIpApproval) {
3006
- const { data: allowed } = await supabase.from("mcp_ip_allowlist").select("id").eq("api_key_id", authContext.apiKeyId).eq("ip_address", clientIp).maybeSingle();
3007
- if (!allowed) {
3008
- res.writeHead(403, { "Content-Type": "application/json" });
3009
- res.end(JSON.stringify({ error: `IP ${clientIp} not in allowlist. Approve via Telegram first.` }));
3010
- return;
3011
- }
3012
- }
3013
- if (authContext) {
3014
- const sessionResult = await getOrCreateSession(clientIp);
3015
- if (!sessionResult.valid) {
3016
- console.error(`[Session] Session expired: ${sessionResult.reason}`);
3017
- res.writeHead(401, { "Content-Type": "application/json" });
3018
- res.end(JSON.stringify({ error: `Session expired: ${sessionResult.reason}. Restart the MCP server to re-authenticate.` }));
3019
- return;
3020
- }
3021
- }
3022
3204
  const restToolName = REST_TOOL_MAP[url.pathname];
3023
3205
  if (restToolName && req.method === "POST") {
3024
3206
  if (!authContext) {