@mgsoftwarebv/mg-dashboard-mcp 3.1.2 → 3.4.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 +409 -224
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
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';
|
|
@@ -6,8 +11,206 @@ import { ListToolsRequestSchema, CallToolRequestSchema, isInitializeRequest } fr
|
|
|
6
11
|
import { createServer } from 'http';
|
|
7
12
|
import { randomUUID, createHash, randomBytes, 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
|
|
1159
|
+
function getArg2(name) {
|
|
957
1160
|
return args.find((a) => a.startsWith(`--${name}=`))?.split("=").slice(1).join("=");
|
|
958
1161
|
}
|
|
959
|
-
var
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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(
|
|
966
|
-
if (!apiKey) {
|
|
967
|
-
console.error("
|
|
1175
|
+
var httpPort = Number(getArg2("port")) || 3100;
|
|
1176
|
+
if (!apiKey || !sshKeyPath) {
|
|
1177
|
+
console.error("Authentication required. Use both --api-key=dk_xxx and --ssh-key=PATH (path to your SSH private or public key), or set MG_DASHBOARD_API_KEY and 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
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,188 @@ async function validateApiKey(key) {
|
|
|
1315
1375
|
userId: data.created_by,
|
|
1316
1376
|
allowedServerIds,
|
|
1317
1377
|
permissions,
|
|
1318
|
-
roleName
|
|
1319
|
-
|
|
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, expectedApiKeyId) {
|
|
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
|
+
if (keyRow.api_key_id !== expectedApiKeyId) {
|
|
1517
|
+
console.error(
|
|
1518
|
+
`SSH key "${keyRow.name}" is registered, but it is not linked to the provided MCP API key. Add this SSH key under the same MCP API Key entry used by --api-key.`
|
|
1519
|
+
);
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
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();
|
|
1523
|
+
if (apiErr || !apiRow) {
|
|
1524
|
+
console.error("SSH key is linked to an inactive or missing MCP API key entry.");
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
if (apiRow.expires_at && new Date(apiRow.expires_at) < /* @__PURE__ */ new Date()) {
|
|
1528
|
+
console.error("Linked MCP API key entry has expired.");
|
|
1529
|
+
return null;
|
|
1530
|
+
}
|
|
1531
|
+
const { data: userData, error: userError } = await supabase.from("user").select("permissions, role:role!role_id(name, default_permissions)").eq("id", apiRow.created_by).single();
|
|
1532
|
+
if (userError || !userData) {
|
|
1533
|
+
console.error(`User not found for SSH key creator: ${apiRow.created_by}`);
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
const roleName = userData.role?.name || "user";
|
|
1537
|
+
const roleDefaults = userData.role?.default_permissions ?? {};
|
|
1538
|
+
const userOverrides = userData.permissions ?? null;
|
|
1539
|
+
const permissions = resolvePermissions(roleName, roleDefaults, userOverrides);
|
|
1540
|
+
const allowedServerIds = intersectServerAccess(
|
|
1541
|
+
apiRow.allowed_server_ids,
|
|
1542
|
+
permissions.resources.ssh_servers
|
|
1543
|
+
);
|
|
1544
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
1545
|
+
await Promise.all([
|
|
1546
|
+
supabase.from("dashboard_mcp_ssh_key").update({ last_used_at: nowIso }).eq("id", keyRow.id),
|
|
1547
|
+
supabase.from("dashboard_mcp_api_key").update({ last_used_at: nowIso }).eq("id", apiRow.id)
|
|
1548
|
+
]);
|
|
1549
|
+
const moduleCount = MODULE_KEYS.filter((k) => permissions.modules[k]).length;
|
|
1550
|
+
console.error(
|
|
1551
|
+
`Authenticated via SSH key "${keyRow.name}" (fp ${fingerprint.slice(0, 24)}...) as user ${apiRow.created_by} (role: ${roleName}, modules: ${moduleCount}/${MODULE_KEYS.length})`
|
|
1552
|
+
);
|
|
1553
|
+
return {
|
|
1554
|
+
apiKeyId: apiRow.id,
|
|
1555
|
+
apiKeyName: `${apiRow.name || "Unknown"} (ssh: ${keyRow.name})`,
|
|
1556
|
+
userId: apiRow.created_by,
|
|
1557
|
+
allowedServerIds,
|
|
1558
|
+
permissions,
|
|
1559
|
+
roleName
|
|
1320
1560
|
};
|
|
1321
1561
|
}
|
|
1322
1562
|
function assertServerAccess(serverId) {
|
|
@@ -2941,56 +3181,19 @@ function createMcpServer() {
|
|
|
2941
3181
|
return s;
|
|
2942
3182
|
}
|
|
2943
3183
|
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
3184
|
async function main() {
|
|
2971
3185
|
console.error("Starting MG Dashboard MCP Server...");
|
|
2972
|
-
|
|
2973
|
-
if (!
|
|
3186
|
+
const apiAuthContext = await validateApiKey(apiKey);
|
|
3187
|
+
if (!apiAuthContext) {
|
|
2974
3188
|
console.error("API key validation failed");
|
|
2975
3189
|
process.exit(1);
|
|
2976
3190
|
}
|
|
2977
|
-
|
|
2978
|
-
if (authContext
|
|
2979
|
-
console.error("
|
|
2980
|
-
|
|
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.");
|
|
2986
|
-
process.exit(1);
|
|
2987
|
-
}
|
|
2988
|
-
console.error("[Security] IP approved, creating session...");
|
|
2989
|
-
await getOrCreateSession(publicIp);
|
|
2990
|
-
console.error("[Security] Session created");
|
|
2991
|
-
} else {
|
|
2992
|
-
console.error("[Security] IP approval disabled for this key");
|
|
3191
|
+
authContext = await validateSshKey(sshKeyPath, apiAuthContext.apiKeyId);
|
|
3192
|
+
if (!authContext) {
|
|
3193
|
+
console.error("SSH-key authentication failed");
|
|
3194
|
+
process.exit(1);
|
|
2993
3195
|
}
|
|
3196
|
+
console.error(`[Security] MCP v${MCP_VERSION} | Key: ${authContext.apiKeyName}`);
|
|
2994
3197
|
const toolNames = TOOLS.map((t) => t.name).join(", ");
|
|
2995
3198
|
if (httpMode) {
|
|
2996
3199
|
console.error(`API key validated. Starting Streamable HTTP transport on port ${httpPort}...`);
|
|
@@ -3001,24 +3204,6 @@ async function main() {
|
|
|
3001
3204
|
};
|
|
3002
3205
|
const httpServer = createServer(async (req, res) => {
|
|
3003
3206
|
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
3207
|
const restToolName = REST_TOOL_MAP[url.pathname];
|
|
3023
3208
|
if (restToolName && req.method === "POST") {
|
|
3024
3209
|
if (!authContext) {
|