@mgsoftwarebv/mg-dashboard-mcp 3.0.2 → 3.0.4

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
@@ -1016,7 +1016,226 @@ var RateLimiter = class {
1016
1016
  var authRateLimiter = new RateLimiter(5, 15 * 60 * 1e3);
1017
1017
  setInterval(() => {
1018
1018
  authRateLimiter.cleanup();
1019
+ toolRateLimiter.cleanup();
1019
1020
  }, 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
+ var SENSITIVE_KEYS = /* @__PURE__ */ new Set(["password", "private_key", "passphrase", "secret", "token", "key", "api_key", "credentials"]);
1053
+ function redactSensitiveArgs(args2) {
1054
+ const redacted = {};
1055
+ for (const [k, v] of Object.entries(args2)) {
1056
+ if (SENSITIVE_KEYS.has(k.toLowerCase())) {
1057
+ redacted[k] = "[REDACTED]";
1058
+ } else if (typeof v === "string" && v.length > 500) {
1059
+ redacted[k] = v.slice(0, 500) + "...[truncated]";
1060
+ } else {
1061
+ redacted[k] = v;
1062
+ }
1063
+ }
1064
+ return redacted;
1065
+ }
1066
+ async function writeAuditLog(entry) {
1067
+ if (!authContext) return;
1068
+ try {
1069
+ await supabase.from("mcp_audit_log").insert({
1070
+ api_key_id: authContext.apiKeyId,
1071
+ user_id: authContext.userId,
1072
+ tool_name: entry.toolName,
1073
+ arguments: entry.arguments ? redactSensitiveArgs(entry.arguments) : null,
1074
+ ip_address: entry.ipAddress || null,
1075
+ server_id: entry.serverId || null,
1076
+ result_status: entry.resultStatus,
1077
+ error_message: entry.errorMessage?.slice(0, 1e3) || null,
1078
+ duration_ms: entry.durationMs || null
1079
+ });
1080
+ } catch (err) {
1081
+ console.error("[Audit] Failed to write audit log:", err instanceof Error ? err.message : err);
1082
+ }
1083
+ }
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
+ }
1020
1239
  var MODULE_KEYS = [
1021
1240
  "users",
1022
1241
  "ssh_servers",
@@ -1102,7 +1321,7 @@ async function validateApiKey(key) {
1102
1321
  console.error(`Rate limited: too many failed auth attempts. Retry in ${retryMin} minute(s).`);
1103
1322
  return null;
1104
1323
  }
1105
- const { data, error } = await supabase.from("dashboard_mcp_api_key").select("id, created_by, allowed_server_ids, is_active, expires_at").eq("api_key_hash", keyHash).eq("is_active", true).single();
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();
1106
1325
  if (error || !data) {
1107
1326
  console.error(`API key not found or inactive (${rateCheck.remaining} attempts remaining)`);
1108
1327
  return null;
@@ -1128,10 +1347,13 @@ async function validateApiKey(key) {
1128
1347
  const moduleCount = MODULE_KEYS.filter((k) => permissions.modules[k]).length;
1129
1348
  console.error(`Authenticated as user ${data.created_by} (role: ${roleName}, modules: ${moduleCount}/${MODULE_KEYS.length})`);
1130
1349
  return {
1350
+ apiKeyId: data.id,
1351
+ apiKeyName: data.name || "Unknown",
1131
1352
  userId: data.created_by,
1132
1353
  allowedServerIds,
1133
1354
  permissions,
1134
- roleName
1355
+ roleName,
1356
+ requireIpApproval: data.require_ip_approval ?? true
1135
1357
  };
1136
1358
  }
1137
1359
  function assertServerAccess(serverId) {
@@ -2254,7 +2476,7 @@ var TOOLS = [
2254
2476
  // ----- Agent Reporting -----
2255
2477
  ...AGENT_TOOLS
2256
2478
  ];
2257
- var MCP_VERSION = "2.3.1";
2479
+ var MCP_VERSION = "3.0.4";
2258
2480
  async function handleListTools() {
2259
2481
  if (!authContext) return { tools: TOOLS };
2260
2482
  const accessible = TOOLS.filter((tool) => {
@@ -2279,19 +2501,54 @@ async function handleCallTool(request) {
2279
2501
  }]
2280
2502
  };
2281
2503
  }
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
+ const startTime = Date.now();
2522
+ const serverId = a.serverId || a.server_id;
2523
+ const result = await executeToolCall(name, a);
2524
+ const durationMs = Date.now() - startTime;
2525
+ const isError = result.content?.[0]?.text?.startsWith("Error:");
2526
+ void writeAuditLog({
2527
+ toolName: name,
2528
+ arguments: a,
2529
+ serverId,
2530
+ resultStatus: isError ? "error" : "success",
2531
+ errorMessage: isError ? result.content?.[0]?.text : void 0,
2532
+ durationMs
2533
+ });
2534
+ return result;
2535
+ }
2536
+ async function executeToolCall(name, a, _serverId) {
2537
+ const ctx = authContext;
2282
2538
  try {
2283
2539
  switch (name) {
2284
2540
  // ----- Servers -----
2285
2541
  case "list-servers": {
2286
- let query = supabase.from("ssh_server").select("id, name, hostname, port, username, tags, hosted_by, created_at").order("name");
2287
- if (authContext.allowedServerIds !== null) {
2288
- query = query.in("id", authContext.allowedServerIds);
2542
+ let query = supabase.from("ssh_server").select("id, name, hostname, port, username, tags, hosted_by, os_type, created_at").order("name");
2543
+ if (ctx.allowedServerIds !== null) {
2544
+ query = query.in("id", ctx.allowedServerIds);
2289
2545
  }
2290
2546
  const { data, error } = await query;
2291
2547
  if (error) throw new Error(error.message);
2292
2548
  const lines = (data || []).map((s) => {
2293
2549
  const tags = Array.isArray(s.tags) ? s.tags.join(", ") : "";
2294
- return `${s.id} ${s.name} ${s.hostname}:${s.port} ${s.username} [${tags}] ${s.hosted_by || ""}`;
2550
+ const os = s.os_type || "linux";
2551
+ return `${s.id} ${s.name} ${s.hostname}:${s.port} ${s.username} [${tags}] ${s.hosted_by || ""} os:${os}`;
2295
2552
  });
2296
2553
  return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No servers found" }] };
2297
2554
  }
@@ -2738,6 +2995,32 @@ function createMcpServer() {
2738
2995
  return s;
2739
2996
  }
2740
2997
  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
+ }
2741
3024
  async function main() {
2742
3025
  console.error("Starting MG Dashboard MCP Server...");
2743
3026
  authContext = await validateApiKey(apiKey);
@@ -2745,6 +3028,23 @@ async function main() {
2745
3028
  console.error("API key validation failed");
2746
3029
  process.exit(1);
2747
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.");
3040
+ process.exit(1);
3041
+ }
3042
+ console.error("[Security] IP approved, creating session...");
3043
+ await getOrCreateSession(publicIp);
3044
+ console.error("[Security] Session created");
3045
+ } else {
3046
+ console.error("[Security] IP approval disabled for this key");
3047
+ }
2748
3048
  const toolNames = TOOLS.map((t) => t.name).join(", ");
2749
3049
  if (httpMode) {
2750
3050
  console.error(`API key validated. Starting Streamable HTTP transport on port ${httpPort}...`);
@@ -2755,8 +3055,37 @@ async function main() {
2755
3055
  };
2756
3056
  const httpServer = createServer(async (req, res) => {
2757
3057
  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
+ }
2758
3076
  const restToolName = REST_TOOL_MAP[url.pathname];
2759
3077
  if (restToolName && req.method === "POST") {
3078
+ if (!authContext) {
3079
+ res.writeHead(401, { "Content-Type": "application/json" });
3080
+ res.end(JSON.stringify({ error: "Not authenticated" }));
3081
+ return;
3082
+ }
3083
+ const requiredModule = TOOL_MODULE_MAP[restToolName];
3084
+ if (requiredModule && authContext.permissions.modules[requiredModule] !== true) {
3085
+ res.writeHead(403, { "Content-Type": "application/json" });
3086
+ res.end(JSON.stringify({ error: `Access denied: no permission for "${requiredModule}" module` }));
3087
+ return;
3088
+ }
2760
3089
  const chunks = [];
2761
3090
  for await (const chunk of req) chunks.push(chunk);
2762
3091
  let toolArgs;