@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 +408 -280
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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
|
|
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 --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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
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
|
-
|
|
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) {
|