@mgsoftwarebv/mg-dashboard-mcp 3.1.1 → 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) {
@@ -1016,39 +1226,7 @@ var RateLimiter = class {
1016
1226
  var authRateLimiter = new RateLimiter(5, 15 * 60 * 1e3);
1017
1227
  setInterval(() => {
1018
1228
  authRateLimiter.cleanup();
1019
- toolRateLimiter.cleanup();
1020
1229
  }, 5 * 60 * 1e3).unref();
1021
- var TOOL_RATE_CATEGORIES = {
1022
- "ssh-execute": "ssh",
1023
- "sftp-write": "sftp_write",
1024
- "sftp-delete": "sftp_write",
1025
- "sftp-list": "sftp_read",
1026
- "sftp-read": "sftp_read",
1027
- "db-query": "db",
1028
- "db-discover": "db",
1029
- "db-tables": "db",
1030
- "db-describe": "db"
1031
- };
1032
- var TOOL_RATE_LIMITS = {
1033
- ssh: { max: 120, windowMs: 60 * 60 * 1e3 },
1034
- sftp_write: { max: 60, windowMs: 60 * 60 * 1e3 },
1035
- sftp_read: { max: 200, windowMs: 60 * 60 * 1e3 },
1036
- db: { max: 200, windowMs: 60 * 60 * 1e3 },
1037
- default: { max: 300, windowMs: 60 * 60 * 1e3 }
1038
- };
1039
- var toolRateLimiter = new RateLimiter(300, 60 * 60 * 1e3);
1040
- var toolRateBuckets = /* @__PURE__ */ new Map();
1041
- function checkToolRateLimit(toolName) {
1042
- const category = TOOL_RATE_CATEGORIES[toolName] || "default";
1043
- const limits = TOOL_RATE_LIMITS[category] ?? TOOL_RATE_LIMITS.default;
1044
- let limiter = toolRateBuckets.get(category);
1045
- if (!limiter) {
1046
- limiter = new RateLimiter(limits.max, limits.windowMs);
1047
- toolRateBuckets.set(category, limiter);
1048
- }
1049
- const result = limiter.check(authContext?.apiKeyId || "unknown");
1050
- return { allowed: result.allowed, category, retryAfterMs: result.retryAfterMs };
1051
- }
1052
1230
  var SENSITIVE_KEYS = /* @__PURE__ */ new Set(["password", "private_key", "passphrase", "secret", "token", "key", "api_key", "credentials"]);
1053
1231
  function redactSensitiveArgs(args2) {
1054
1232
  const redacted = {};
@@ -1081,161 +1259,6 @@ async function writeAuditLog(entry) {
1081
1259
  console.error("[Audit] Failed to write audit log:", err instanceof Error ? err.message : err);
1082
1260
  }
1083
1261
  }
1084
- var TELEGRAM_API = "https://api.telegram.org/bot";
1085
- var cachedTelegramConfig = null;
1086
- async function getTelegramBotConfig() {
1087
- if (cachedTelegramConfig) return cachedTelegramConfig;
1088
- const { data, error } = await supabase.from("telegram_bot_config").select("bot_token_encrypted, chat_id, notifications_enabled").limit(1).maybeSingle();
1089
- if (error) {
1090
- console.error("[Telegram Config] Query error:", error.message);
1091
- return null;
1092
- }
1093
- if (!data?.bot_token_encrypted || !data?.chat_id) {
1094
- console.error("[Telegram Config] Missing bot_token or chat_id in telegram_bot_config table");
1095
- return null;
1096
- }
1097
- if (!data.notifications_enabled) {
1098
- console.error("[Telegram Config] Notifications disabled in telegram_bot_config");
1099
- return null;
1100
- }
1101
- try {
1102
- const token = decrypt(data.bot_token_encrypted);
1103
- cachedTelegramConfig = { botToken: token, chatId: data.chat_id };
1104
- console.error("[Telegram Config] Loaded successfully");
1105
- return cachedTelegramConfig;
1106
- } catch (err) {
1107
- console.error("[Telegram Config] Failed to decrypt bot token:", err instanceof Error ? err.message : err);
1108
- return null;
1109
- }
1110
- }
1111
- function escapeHtmlTg(str) {
1112
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1113
- }
1114
- async function sendTelegramMessage(token, chatId, text, replyMarkup) {
1115
- try {
1116
- const body = { chat_id: chatId, text, parse_mode: "HTML" };
1117
- if (replyMarkup) body.reply_markup = replyMarkup;
1118
- const res = await fetch(`${TELEGRAM_API}${token}/sendMessage`, {
1119
- method: "POST",
1120
- headers: { "Content-Type": "application/json" },
1121
- body: JSON.stringify(body)
1122
- });
1123
- const result = await res.json();
1124
- return { ok: result.ok, messageId: result.result?.message_id };
1125
- } catch {
1126
- return { ok: false };
1127
- }
1128
- }
1129
- async function sendTelegramAlert(text) {
1130
- const config = await getTelegramBotConfig();
1131
- if (!config) return;
1132
- await sendTelegramMessage(config.botToken, config.chatId, text);
1133
- }
1134
- async function checkIpAllowlist(ipAddress) {
1135
- if (!authContext || !authContext.requireIpApproval) {
1136
- console.error(`[IP Check] Skipped (requireIpApproval=${authContext?.requireIpApproval})`);
1137
- return { allowed: true };
1138
- }
1139
- console.error(`[IP Check] Checking IP ${ipAddress} for key ${authContext.apiKeyId}`);
1140
- const { data: existing, error: lookupErr } = await supabase.from("mcp_ip_allowlist").select("id").eq("api_key_id", authContext.apiKeyId).eq("ip_address", ipAddress).maybeSingle();
1141
- if (lookupErr) {
1142
- console.error(`[IP Check] Allowlist lookup error: ${lookupErr.message}`);
1143
- }
1144
- if (existing) {
1145
- console.error(`[IP Check] IP ${ipAddress} found in allowlist`);
1146
- return { allowed: true };
1147
- }
1148
- console.error(`[IP Check] IP ${ipAddress} NOT in allowlist, requesting Telegram approval...`);
1149
- const tgConfig = await getTelegramBotConfig();
1150
- if (!tgConfig) {
1151
- console.error("[IP Check] BLOCKING: Unknown IP and no Telegram configured");
1152
- return { allowed: false, reason: "Unknown IP and no approval channel configured" };
1153
- }
1154
- let geoInfo = null;
1155
- try {
1156
- const geoRes = await fetch(`http://ip-api.com/json/${ipAddress}?fields=country,city,isp`);
1157
- if (geoRes.ok) geoInfo = await geoRes.json();
1158
- } catch {
1159
- }
1160
- const geoLabel = geoInfo ? `${geoInfo.city || "?"}, ${geoInfo.country || "?"} (${geoInfo.isp || "?"})` : "Onbekend";
1161
- const { data: request, error: insertErr } = await supabase.from("mcp_ip_approval_request").insert({
1162
- api_key_id: authContext.apiKeyId,
1163
- ip_address: ipAddress,
1164
- status: "pending",
1165
- geo_info: geoInfo
1166
- }).select("id").single();
1167
- if (insertErr || !request) {
1168
- console.error(`[IP Check] Failed to create approval request: ${insertErr?.message || "no data returned"}`);
1169
- return { allowed: false, reason: `Failed to create approval request: ${insertErr?.message || "unknown"}` };
1170
- }
1171
- console.error(`[IP Check] Created approval request ${request.id}`);
1172
- const message = [
1173
- `<b>MCP IP Approval</b>`,
1174
- ``,
1175
- `<b>IP:</b> <code>${escapeHtmlTg(ipAddress)}</code>`,
1176
- `<b>Locatie:</b> ${escapeHtmlTg(geoLabel)}`,
1177
- `<b>API Key:</b> ${escapeHtmlTg(authContext.apiKeyName)}`,
1178
- `<b>User:</b> ${escapeHtmlTg(authContext.userId)}`,
1179
- ``,
1180
- `<i>Onbekend IP probeert verbinding te maken met de MCP server.</i>`
1181
- ].join("\n");
1182
- const tgResult = await sendTelegramMessage(tgConfig.botToken, tgConfig.chatId, message, {
1183
- inline_keyboard: [
1184
- [
1185
- { text: "Goedkeuren", callback_data: `mcp_ip_approve:${request.id}` },
1186
- { text: "Weigeren", callback_data: `mcp_ip_deny:${request.id}` }
1187
- ]
1188
- ]
1189
- });
1190
- if (tgResult.messageId) {
1191
- await supabase.from("mcp_ip_approval_request").update({ telegram_message_id: tgResult.messageId }).eq("id", request.id);
1192
- }
1193
- console.error(`[IP Check] Waiting for Telegram approval for IP ${ipAddress}...`);
1194
- const deadline = Date.now() + 6e4;
1195
- while (Date.now() < deadline) {
1196
- await new Promise((r) => setTimeout(r, 2e3));
1197
- const { data: check } = await supabase.from("mcp_ip_approval_request").select("status").eq("id", request.id).single();
1198
- if (check?.status === "approved") {
1199
- await supabase.from("mcp_ip_allowlist").insert({
1200
- api_key_id: authContext.apiKeyId,
1201
- ip_address: ipAddress,
1202
- label: geoLabel,
1203
- approved_by: "telegram"
1204
- });
1205
- console.error(`[IP Check] IP ${ipAddress} approved via Telegram`);
1206
- return { allowed: true };
1207
- }
1208
- if (check?.status === "denied") {
1209
- console.error(`[IP Check] IP ${ipAddress} denied via Telegram`);
1210
- return { allowed: false, reason: "IP denied via Telegram" };
1211
- }
1212
- }
1213
- await supabase.from("mcp_ip_approval_request").update({ status: "denied", resolved_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", request.id);
1214
- console.error(`[IP Check] IP ${ipAddress} approval timed out`);
1215
- return { allowed: false, reason: "Approval request timed out (60s)" };
1216
- }
1217
- var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
1218
- var SESSION_MAX_ABSOLUTE_MS = 24 * 60 * 60 * 1e3;
1219
- async function getOrCreateSession(ipAddress) {
1220
- if (!authContext) return { valid: false, reason: "Not authenticated" };
1221
- const now = /* @__PURE__ */ new Date();
1222
- 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();
1223
- if (existing) {
1224
- const createdAt = new Date(existing.created_at).getTime();
1225
- if (now.getTime() - createdAt > SESSION_MAX_ABSOLUTE_MS) {
1226
- await supabase.from("mcp_session").delete().eq("id", existing.id);
1227
- return { valid: false, reason: "Session exceeded absolute TTL (24h)" };
1228
- }
1229
- await supabase.from("mcp_session").update({ last_activity_at: now.toISOString() }).eq("id", existing.id);
1230
- return { valid: true, sessionId: existing.id };
1231
- }
1232
- const { data: newSession } = await supabase.from("mcp_session").insert({
1233
- api_key_id: authContext.apiKeyId,
1234
- ip_address: ipAddress,
1235
- expires_at: new Date(now.getTime() + SESSION_TTL_MS).toISOString()
1236
- }).select("id").single();
1237
- return { valid: true, sessionId: newSession?.id };
1238
- }
1239
1262
  var MODULE_KEYS = [
1240
1263
  "users",
1241
1264
  "ssh_servers",
@@ -1321,7 +1344,7 @@ async function validateApiKey(key) {
1321
1344
  console.error(`Rate limited: too many failed auth attempts. Retry in ${retryMin} minute(s).`);
1322
1345
  return null;
1323
1346
  }
1324
- 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();
1325
1348
  if (error || !data) {
1326
1349
  console.error(`API key not found or inactive (${rateCheck.remaining} attempts remaining)`);
1327
1350
  return null;
@@ -1352,8 +1375,182 @@ async function validateApiKey(key) {
1352
1375
  userId: data.created_by,
1353
1376
  allowedServerIds,
1354
1377
  permissions,
1355
- roleName,
1356
- 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
1357
1554
  };
1358
1555
  }
1359
1556
  function assertServerAccess(serverId) {
@@ -2476,7 +2673,7 @@ var TOOLS = [
2476
2673
  // ----- Agent Reporting -----
2477
2674
  ...AGENT_TOOLS
2478
2675
  ];
2479
- var MCP_VERSION = "3.0.6";
2676
+ var MCP_VERSION = "3.1.1";
2480
2677
  async function handleListTools() {
2481
2678
  if (!authContext) return { tools: TOOLS };
2482
2679
  const accessible = TOOLS.filter((tool) => {
@@ -2501,23 +2698,6 @@ async function handleCallTool(request) {
2501
2698
  }]
2502
2699
  };
2503
2700
  }
2504
- const rateResult = checkToolRateLimit(name);
2505
- if (!rateResult.allowed) {
2506
- const retryMin = Math.ceil(rateResult.retryAfterMs / 6e4);
2507
- void sendTelegramAlert(
2508
- `<b>MCP Rate Limit</b>
2509
-
2510
- Tool category <code>${escapeHtmlTg(rateResult.category)}</code> rate limited.
2511
- Key: ${escapeHtmlTg(authContext.apiKeyName)}
2512
- Retry in ${retryMin} min.`
2513
- );
2514
- return {
2515
- content: [{
2516
- type: "text",
2517
- text: `Rate limited: too many ${rateResult.category} calls. Retry in ${retryMin} minute(s).`
2518
- }]
2519
- };
2520
- }
2521
2701
  const startTime = Date.now();
2522
2702
  const serverId = a.serverId || a.server_id;
2523
2703
  const result = await executeToolCall(name, a);
@@ -2995,56 +3175,22 @@ function createMcpServer() {
2995
3175
  return s;
2996
3176
  }
2997
3177
  var server = createMcpServer();
2998
- async function resolvePublicIp() {
2999
- const services = [
3000
- "https://api.ipify.org",
3001
- "https://ifconfig.me/ip",
3002
- "https://icanhazip.com"
3003
- ];
3004
- for (const url of services) {
3005
- try {
3006
- const controller = new AbortController();
3007
- const timer = setTimeout(() => controller.abort(), 5e3);
3008
- const res = await fetch(url, { signal: controller.signal });
3009
- clearTimeout(timer);
3010
- if (res.ok) {
3011
- const ip = (await res.text()).trim();
3012
- if (ip && /^[\d.:a-f]+$/i.test(ip)) {
3013
- console.error(`[IP Resolve] Got public IP: ${ip} (from ${url})`);
3014
- return ip;
3015
- }
3016
- }
3017
- } catch (err) {
3018
- console.error(`[IP Resolve] Failed ${url}: ${err instanceof Error ? err.message : err}`);
3019
- }
3020
- }
3021
- console.error("[IP Resolve] All services failed, returning unknown");
3022
- return "unknown";
3023
- }
3024
3178
  async function main() {
3025
3179
  console.error("Starting MG Dashboard MCP Server...");
3026
- authContext = await validateApiKey(apiKey);
3027
- if (!authContext) {
3028
- console.error("API key validation failed");
3029
- process.exit(1);
3030
- }
3031
- console.error(`[Security] MCP v${MCP_VERSION} | Key: ${authContext.apiKeyName} | requireIpApproval: ${authContext.requireIpApproval}`);
3032
- if (authContext.requireIpApproval) {
3033
- console.error("[Security] IP approval enabled, resolving public IP...");
3034
- const publicIp = await resolvePublicIp();
3035
- console.error(`[Security] Public IP: ${publicIp}`);
3036
- const ipResult = await checkIpAllowlist(publicIp);
3037
- if (!ipResult.allowed) {
3038
- console.error(`[Security] BLOCKED \u2014 ${ipResult.reason}`);
3039
- 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");
3040
3184
  process.exit(1);
3041
3185
  }
3042
- console.error("[Security] IP approved, creating session...");
3043
- await getOrCreateSession(publicIp);
3044
- console.error("[Security] Session created");
3045
3186
  } else {
3046
- 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
+ }
3047
3192
  }
3193
+ console.error(`[Security] MCP v${MCP_VERSION} | Key: ${authContext.apiKeyName}`);
3048
3194
  const toolNames = TOOLS.map((t) => t.name).join(", ");
3049
3195
  if (httpMode) {
3050
3196
  console.error(`API key validated. Starting Streamable HTTP transport on port ${httpPort}...`);
@@ -3055,24 +3201,6 @@ async function main() {
3055
3201
  };
3056
3202
  const httpServer = createServer(async (req, res) => {
3057
3203
  const url = new URL(req.url ?? "/", `http://localhost:${httpPort}`);
3058
- const clientIp = (req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.headers["x-real-ip"] || req.socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
3059
- if (authContext?.requireIpApproval) {
3060
- const { data: allowed } = await supabase.from("mcp_ip_allowlist").select("id").eq("api_key_id", authContext.apiKeyId).eq("ip_address", clientIp).maybeSingle();
3061
- if (!allowed) {
3062
- res.writeHead(403, { "Content-Type": "application/json" });
3063
- res.end(JSON.stringify({ error: `IP ${clientIp} not in allowlist. Approve via Telegram first.` }));
3064
- return;
3065
- }
3066
- }
3067
- if (authContext) {
3068
- const sessionResult = await getOrCreateSession(clientIp);
3069
- if (!sessionResult.valid) {
3070
- console.error(`[Session] Session expired: ${sessionResult.reason}`);
3071
- res.writeHead(401, { "Content-Type": "application/json" });
3072
- res.end(JSON.stringify({ error: `Session expired: ${sessionResult.reason}. Restart the MCP server to re-authenticate.` }));
3073
- return;
3074
- }
3075
- }
3076
3204
  const restToolName = REST_TOOL_MAP[url.pathname];
3077
3205
  if (restToolName && req.method === "POST") {
3078
3206
  if (!authContext) {