ssh-mcp-pro 1.0.0 → 1.1.3
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/ARCHITECTURE.md +1 -1
- package/CHANGELOG.md +71 -0
- package/LICENSES/MIT.txt +1 -1
- package/README.md +38 -1
- package/SUPPORT.md +95 -0
- package/dist/agent-bin.js +0 -0
- package/dist/config-parsers.d.ts +12 -0
- package/dist/config-parsers.d.ts.map +1 -0
- package/dist/config-parsers.js +60 -0
- package/dist/config-parsers.js.map +1 -0
- package/dist/config.d.ts +3 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -58
- package/dist/config.js.map +1 -1
- package/dist/ensure-pkg.d.ts +9 -0
- package/dist/ensure-pkg.d.ts.map +1 -0
- package/dist/ensure-pkg.js +105 -0
- package/dist/ensure-pkg.js.map +1 -0
- package/dist/ensure.d.ts.map +1 -1
- package/dist/ensure.js +2 -104
- package/dist/ensure.js.map +1 -1
- package/dist/fs-sftp.d.ts +58 -0
- package/dist/fs-sftp.d.ts.map +1 -0
- package/dist/fs-sftp.js +184 -0
- package/dist/fs-sftp.js.map +1 -0
- package/dist/fs-tools.d.ts.map +1 -1
- package/dist/fs-tools.js +2 -144
- package/dist/fs-tools.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +1 -1
- package/dist/remote/agent-executor.d.ts.map +1 -1
- package/dist/remote/agent-executor.js +16 -4
- package/dist/remote/agent-executor.js.map +1 -1
- package/dist/remote/agent-handler.d.ts +36 -0
- package/dist/remote/agent-handler.d.ts.map +1 -0
- package/dist/remote/agent-handler.js +255 -0
- package/dist/remote/agent-handler.js.map +1 -0
- package/dist/remote/control-plane.d.ts +4 -17
- package/dist/remote/control-plane.d.ts.map +1 -1
- package/dist/remote/control-plane.js +24 -651
- package/dist/remote/control-plane.js.map +1 -1
- package/dist/remote/http-util.d.ts +29 -0
- package/dist/remote/http-util.d.ts.map +1 -0
- package/dist/remote/http-util.js +159 -0
- package/dist/remote/http-util.js.map +1 -0
- package/dist/remote/oauth-handler.d.ts +47 -0
- package/dist/remote/oauth-handler.d.ts.map +1 -0
- package/dist/remote/oauth-handler.js +296 -0
- package/dist/remote/oauth-handler.js.map +1 -0
- package/dist/session-auth.d.ts +39 -0
- package/dist/session-auth.d.ts.map +1 -0
- package/dist/session-auth.js +148 -0
- package/dist/session-auth.js.map +1 -0
- package/dist/session.d.ts +0 -19
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +8 -154
- package/dist/session.js.map +1 -1
- package/docs/audit/2026-06-05-ecosystem-audit.md +146 -0
- package/docs/docker.md +39 -0
- package/docs/governance/issue-taxonomy.json +141 -0
- package/docs/testing.md +50 -0
- package/mcp.json +1 -1
- package/package.json +80 -71
- package/registry/ssh-mcp-pro/mcp.json +1 -1
- package/server.json +3 -3
|
@@ -1,161 +1,17 @@
|
|
|
1
|
-
import { createPublicKey } from "node:crypto";
|
|
2
1
|
import { URL } from "node:url";
|
|
3
|
-
import { ensurePemKeyPair, hashSecret, id,
|
|
2
|
+
import { ensurePemKeyPair, hashSecret, id, keyId, loadJwtKeyPair, nowIso, randomToken, signEnvelope, verifyRemoteAccessToken, } from "./crypto.js";
|
|
4
3
|
import { loadRemoteConfig } from "./config.js";
|
|
4
|
+
import { isRecord, asString, negotiateMcpProtocolVersion, quotePosixArg, quotePowerShellArg, addNoStore, safeError, isValidEd25519PublicKey, readJson, sanitizeAgent, } from "./http-util.js";
|
|
5
5
|
import { listRemoteToolDescriptors } from "./mcp-tools.js";
|
|
6
6
|
import { createAgentPolicy, mergeCustomPolicy } from "./policy.js";
|
|
7
|
-
import {
|
|
8
|
-
import { hasCapability
|
|
7
|
+
import { parseAgentHostMetadata } from "./schemas.js";
|
|
8
|
+
import { hasCapability } from "./scopes.js";
|
|
9
9
|
import { RemoteStore } from "./store.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { OAuthHandler } from "./oauth-handler.js";
|
|
11
|
+
import { AgentWebSocketHandler, } from "./agent-handler.js";
|
|
12
|
+
import { TOOL_CAPABILITY_MAP } from "./types.js";
|
|
13
|
+
import { jsonResponse } from "./util.js";
|
|
13
14
|
import { SERVER_VERSION } from "../mcp.js";
|
|
14
|
-
const AGENT_NONCE_TTL_MS = 300_000;
|
|
15
|
-
const MAX_AGENT_CONNECTION_NONCES = 4096;
|
|
16
|
-
function isRecord(value) {
|
|
17
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
-
}
|
|
19
|
-
function asString(value) {
|
|
20
|
-
return typeof value === "string" ? value : undefined;
|
|
21
|
-
}
|
|
22
|
-
function quotePosixArg(value) {
|
|
23
|
-
return `'${value.replace(/'/gu, `'"'"'`)}'`;
|
|
24
|
-
}
|
|
25
|
-
function quotePowerShellArg(value) {
|
|
26
|
-
return `'${value.replace(/'/gu, "''")}'`;
|
|
27
|
-
}
|
|
28
|
-
function asStringArray(value) {
|
|
29
|
-
if (!Array.isArray(value)) {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
return value.filter((item) => typeof item === "string");
|
|
33
|
-
}
|
|
34
|
-
function pruneNonceWindow(nonces, now = Date.now()) {
|
|
35
|
-
for (const [nonce, expiresAt] of nonces.entries()) {
|
|
36
|
-
if (expiresAt <= now) {
|
|
37
|
-
nonces.delete(nonce);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
function hasSeenNonce(nonces, nonce, now = Date.now()) {
|
|
42
|
-
pruneNonceWindow(nonces, now);
|
|
43
|
-
return nonces.has(nonce);
|
|
44
|
-
}
|
|
45
|
-
function rememberNonce(nonces, nonce, now = Date.now()) {
|
|
46
|
-
pruneNonceWindow(nonces, now);
|
|
47
|
-
nonces.set(nonce, now + AGENT_NONCE_TTL_MS);
|
|
48
|
-
while (nonces.size > MAX_AGENT_CONNECTION_NONCES) {
|
|
49
|
-
const oldest = nonces.keys().next().value;
|
|
50
|
-
if (typeof oldest !== "string") {
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
nonces.delete(oldest);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
function addNoStore(headers = {}) {
|
|
57
|
-
return {
|
|
58
|
-
"Cache-Control": "no-store",
|
|
59
|
-
Pragma: "no-cache",
|
|
60
|
-
...headers,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
function redirect(res, location) {
|
|
64
|
-
res.writeHead(302, { Location: location, "Cache-Control": "no-store" });
|
|
65
|
-
res.end();
|
|
66
|
-
}
|
|
67
|
-
function safeError(code, message, status = 400) {
|
|
68
|
-
return { code, message, status };
|
|
69
|
-
}
|
|
70
|
-
function isValidEd25519PublicKey(publicKeyPem) {
|
|
71
|
-
try {
|
|
72
|
-
return createPublicKey(publicKeyPem).asymmetricKeyType === "ed25519";
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
async function readBody(req, maxBytes = 1_000_000) {
|
|
79
|
-
const chunks = [];
|
|
80
|
-
let size = 0;
|
|
81
|
-
for await (const chunk of req) {
|
|
82
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
83
|
-
size += buffer.byteLength;
|
|
84
|
-
if (size > maxBytes) {
|
|
85
|
-
throw safeError("FORBIDDEN", "Request body is too large", 413);
|
|
86
|
-
}
|
|
87
|
-
chunks.push(buffer);
|
|
88
|
-
}
|
|
89
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
90
|
-
}
|
|
91
|
-
async function readJson(req) {
|
|
92
|
-
const raw = await readBody(req);
|
|
93
|
-
if (!raw.trim()) {
|
|
94
|
-
return {};
|
|
95
|
-
}
|
|
96
|
-
const parsed = JSON.parse(raw);
|
|
97
|
-
if (!isRecord(parsed)) {
|
|
98
|
-
throw safeError("INTERNAL_ERROR", "Expected JSON object");
|
|
99
|
-
}
|
|
100
|
-
return parsed;
|
|
101
|
-
}
|
|
102
|
-
async function readJsonOrForm(req) {
|
|
103
|
-
const raw = await readBody(req);
|
|
104
|
-
const contentType = req.headers["content-type"] ?? "";
|
|
105
|
-
if (String(contentType).includes("application/json")) {
|
|
106
|
-
const parsed = raw ? JSON.parse(raw) : {};
|
|
107
|
-
if (!isRecord(parsed)) {
|
|
108
|
-
throw safeError("INTERNAL_ERROR", "Expected JSON object");
|
|
109
|
-
}
|
|
110
|
-
return Object.fromEntries(Object.entries(parsed).map(([key, value]) => [
|
|
111
|
-
key,
|
|
112
|
-
typeof value === "string" ? value : String(value ?? ""),
|
|
113
|
-
]));
|
|
114
|
-
}
|
|
115
|
-
try {
|
|
116
|
-
return formDecode(raw);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
throw safeError("INVALID_CLIENT", "Duplicate form parameter");
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
function isSafeRedirectUri(uri) {
|
|
123
|
-
try {
|
|
124
|
-
const parsed = new URL(uri);
|
|
125
|
-
if (parsed.protocol === "https:") {
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
return (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname));
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
function pkceChallenge(verifier) {
|
|
135
|
-
return sha256Base64Url(verifier);
|
|
136
|
-
}
|
|
137
|
-
function scopeList(scope) {
|
|
138
|
-
const valid = new Set(REMOTE_SCOPES);
|
|
139
|
-
const rawScopes = scope.split(/\s+/u).filter(Boolean);
|
|
140
|
-
if (rawScopes.some((entry) => !valid.has(entry))) {
|
|
141
|
-
throw safeError("INVALID_SCOPE", "Requested scope is not supported");
|
|
142
|
-
}
|
|
143
|
-
const scopes = parseScopes(scope);
|
|
144
|
-
return scopes.length > 0 ? scopes : ["hosts:read", "agents:read", "status:read", "logs:read"];
|
|
145
|
-
}
|
|
146
|
-
function sanitizeAgent(agent) {
|
|
147
|
-
return {
|
|
148
|
-
id: agent.id,
|
|
149
|
-
alias: agent.alias,
|
|
150
|
-
status: agent.status,
|
|
151
|
-
profile: agent.profile,
|
|
152
|
-
policy_version: agent.policyVersion,
|
|
153
|
-
host_metadata: agent.hostMetadata,
|
|
154
|
-
last_seen_at: agent.lastSeenAt,
|
|
155
|
-
created_at: agent.createdAt,
|
|
156
|
-
updated_at: agent.updatedAt,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
15
|
export class RemoteControlPlane {
|
|
160
16
|
config;
|
|
161
17
|
store;
|
|
@@ -166,10 +22,14 @@ export class RemoteControlPlane {
|
|
|
166
22
|
cleanupInterval;
|
|
167
23
|
jwtKeyPair;
|
|
168
24
|
controlPlaneKeyPair;
|
|
25
|
+
oauth;
|
|
26
|
+
agentHandler;
|
|
169
27
|
constructor(config = loadRemoteConfig()) {
|
|
170
28
|
this.config = config;
|
|
171
29
|
this.store = new RemoteStore(config.databaseUrl);
|
|
172
30
|
this.controlPlaneKeyPair = ensurePemKeyPair(config.controlPlaneSigningKeyPath);
|
|
31
|
+
this.oauth = new OAuthHandler(config, this.store, this.authorizeTransactions, () => this.requireJwtKeyPair(), (event) => this.audit(event));
|
|
32
|
+
this.agentHandler = new AgentWebSocketHandler(config, this.store, this.agentConnections, this.agentHelloNonces, this.pendingActions, (event) => this.audit(event));
|
|
173
33
|
this.cleanupInterval = setInterval(() => this.cleanupEphemeralState(), 60_000);
|
|
174
34
|
this.cleanupInterval.unref?.();
|
|
175
35
|
}
|
|
@@ -192,48 +52,36 @@ export class RemoteControlPlane {
|
|
|
192
52
|
this.store.close();
|
|
193
53
|
}
|
|
194
54
|
cleanupEphemeralState(now = Date.now()) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this.authorizeTransactions.delete(transactionId);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
for (const [agentId, nonces] of this.agentHelloNonces.entries()) {
|
|
201
|
-
pruneNonceWindow(nonces, now);
|
|
202
|
-
if (nonces.size === 0) {
|
|
203
|
-
this.agentHelloNonces.delete(agentId);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
for (const live of this.agentConnections.values()) {
|
|
207
|
-
pruneNonceWindow(live.seenNonces, now);
|
|
208
|
-
}
|
|
55
|
+
this.oauth.cleanupExpired(now);
|
|
56
|
+
this.agentHandler.cleanupEphemeralState(now);
|
|
209
57
|
}
|
|
210
58
|
async handleHttp(req, res, pathname) {
|
|
211
59
|
if (pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
|
|
212
|
-
jsonResponse(res, 200, this.protectedResourceMetadata(), addNoStore());
|
|
60
|
+
jsonResponse(res, 200, this.oauth.protectedResourceMetadata(), addNoStore());
|
|
213
61
|
return true;
|
|
214
62
|
}
|
|
215
63
|
if (pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
216
|
-
jsonResponse(res, 200, this.authorizationServerMetadata(), addNoStore());
|
|
64
|
+
jsonResponse(res, 200, this.oauth.authorizationServerMetadata(), addNoStore());
|
|
217
65
|
return true;
|
|
218
66
|
}
|
|
219
67
|
if (pathname === "/oauth/register" && req.method === "POST") {
|
|
220
|
-
await this.handleRegister(req, res);
|
|
68
|
+
await this.oauth.handleRegister(req, res);
|
|
221
69
|
return true;
|
|
222
70
|
}
|
|
223
71
|
if (pathname === "/oauth/authorize" && req.method === "GET") {
|
|
224
|
-
await this.handleAuthorize(req, res);
|
|
72
|
+
await this.oauth.handleAuthorize(req, res);
|
|
225
73
|
return true;
|
|
226
74
|
}
|
|
227
75
|
if (pathname === "/oauth/callback/github" && req.method === "GET") {
|
|
228
|
-
await this.handleGitHubCallback(req, res);
|
|
76
|
+
await this.oauth.handleGitHubCallback(req, res);
|
|
229
77
|
return true;
|
|
230
78
|
}
|
|
231
79
|
if (pathname === "/oauth/token" && req.method === "POST") {
|
|
232
|
-
await this.handleToken(req, res);
|
|
80
|
+
await this.oauth.handleToken(req, res);
|
|
233
81
|
return true;
|
|
234
82
|
}
|
|
235
83
|
if (pathname === "/oauth/jwks.json" && req.method === "GET") {
|
|
236
|
-
await this.handleJwks(res);
|
|
84
|
+
await this.oauth.handleJwks(res);
|
|
237
85
|
return true;
|
|
238
86
|
}
|
|
239
87
|
if (pathname === "/readyz" && req.method === "GET") {
|
|
@@ -241,7 +89,7 @@ export class RemoteControlPlane {
|
|
|
241
89
|
ok: true,
|
|
242
90
|
service: "ssh-mcp-pro",
|
|
243
91
|
control_plane: true,
|
|
244
|
-
agents_online: this.
|
|
92
|
+
agents_online: this.agentHandler.connectedAgentCount,
|
|
245
93
|
});
|
|
246
94
|
return true;
|
|
247
95
|
}
|
|
@@ -256,281 +104,7 @@ export class RemoteControlPlane {
|
|
|
256
104
|
return false;
|
|
257
105
|
}
|
|
258
106
|
handleUpgrade(req, socket, head, pathname) {
|
|
259
|
-
|
|
260
|
-
return false;
|
|
261
|
-
}
|
|
262
|
-
const connection = acceptWebSocketUpgrade(req, socket, head);
|
|
263
|
-
connection.onText((message) => {
|
|
264
|
-
void this.handleAgentMessage(connection, message).catch(() => {
|
|
265
|
-
connection.sendJson({
|
|
266
|
-
type: "error",
|
|
267
|
-
code: "INTERNAL_ERROR",
|
|
268
|
-
message: "Agent message failed",
|
|
269
|
-
});
|
|
270
|
-
connection.close();
|
|
271
|
-
});
|
|
272
|
-
});
|
|
273
|
-
return true;
|
|
274
|
-
}
|
|
275
|
-
protectedResourceMetadata() {
|
|
276
|
-
return {
|
|
277
|
-
resource: this.config.mcpResourceUrl,
|
|
278
|
-
resource_name: "SshAutomator MCP",
|
|
279
|
-
authorization_servers: [this.config.publicBaseUrl],
|
|
280
|
-
bearer_methods_supported: ["header"],
|
|
281
|
-
scopes_supported: REMOTE_SCOPES,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
authorizationServerMetadata() {
|
|
285
|
-
return {
|
|
286
|
-
issuer: this.config.publicBaseUrl,
|
|
287
|
-
authorization_endpoint: `${this.config.publicBaseUrl}/oauth/authorize`,
|
|
288
|
-
token_endpoint: `${this.config.publicBaseUrl}/oauth/token`,
|
|
289
|
-
registration_endpoint: `${this.config.publicBaseUrl}/oauth/register`,
|
|
290
|
-
jwks_uri: `${this.config.publicBaseUrl}/oauth/jwks.json`,
|
|
291
|
-
response_types_supported: ["code"],
|
|
292
|
-
grant_types_supported: ["authorization_code"],
|
|
293
|
-
code_challenge_methods_supported: ["S256"],
|
|
294
|
-
token_endpoint_auth_methods_supported: ["none"],
|
|
295
|
-
scopes_supported: REMOTE_SCOPES,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
async handleRegister(req, res) {
|
|
299
|
-
const body = await readJson(req);
|
|
300
|
-
const redirectUris = asStringArray(body.redirect_uris);
|
|
301
|
-
if (redirectUris.length === 0 || redirectUris.some((uri) => !isSafeRedirectUri(uri))) {
|
|
302
|
-
throw safeError("INVALID_REDIRECT_URI", "redirect_uris must contain HTTPS URLs or localhost HTTP URLs");
|
|
303
|
-
}
|
|
304
|
-
if (this.store.countOAuthClients() >= this.config.maxOAuthClients) {
|
|
305
|
-
throw safeError("FORBIDDEN", "OAuth client registration limit reached", 429);
|
|
306
|
-
}
|
|
307
|
-
const now = nowIso();
|
|
308
|
-
const client = {
|
|
309
|
-
id: id("clirow"),
|
|
310
|
-
clientId: id("cli"),
|
|
311
|
-
clientName: asString(body.client_name) ?? "ChatGPT Connector",
|
|
312
|
-
redirectUris,
|
|
313
|
-
grantTypes: ["authorization_code"],
|
|
314
|
-
responseTypes: ["code"],
|
|
315
|
-
tokenEndpointAuthMethod: "none",
|
|
316
|
-
createdAt: now,
|
|
317
|
-
};
|
|
318
|
-
this.store.insertClient(client);
|
|
319
|
-
this.audit({
|
|
320
|
-
eventType: "oauth_client_registered",
|
|
321
|
-
severity: "info",
|
|
322
|
-
metadata: { client_id: client.clientId, redirect_uri_count: redirectUris.length },
|
|
323
|
-
});
|
|
324
|
-
jsonResponse(res, 201, {
|
|
325
|
-
client_id: client.clientId,
|
|
326
|
-
client_name: client.clientName,
|
|
327
|
-
redirect_uris: client.redirectUris,
|
|
328
|
-
grant_types: client.grantTypes,
|
|
329
|
-
response_types: client.responseTypes,
|
|
330
|
-
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
331
|
-
}, addNoStore());
|
|
332
|
-
}
|
|
333
|
-
async handleAuthorize(req, res) {
|
|
334
|
-
const url = new URL(req.url ?? "/oauth/authorize", this.config.publicBaseUrl);
|
|
335
|
-
const clientId = url.searchParams.get("client_id") ?? "";
|
|
336
|
-
const redirectUri = url.searchParams.get("redirect_uri") ?? "";
|
|
337
|
-
const responseType = url.searchParams.get("response_type") ?? "";
|
|
338
|
-
const codeChallenge = url.searchParams.get("code_challenge") ?? "";
|
|
339
|
-
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "";
|
|
340
|
-
const state = url.searchParams.get("state") ?? "";
|
|
341
|
-
const resource = url.searchParams.get("resource") ?? this.config.mcpResourceUrl;
|
|
342
|
-
const scope = url.searchParams.get("scope") ?? "hosts:read agents:read status:read logs:read";
|
|
343
|
-
this.validateAuthorizeParams(clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod, resource, scope);
|
|
344
|
-
const pending = {
|
|
345
|
-
clientId,
|
|
346
|
-
redirectUri,
|
|
347
|
-
codeChallenge,
|
|
348
|
-
resource,
|
|
349
|
-
scope,
|
|
350
|
-
state,
|
|
351
|
-
expiresAt: Date.now() + this.config.authCodeTtlSeconds * 1000,
|
|
352
|
-
};
|
|
353
|
-
const testUser = this.testGitHubUser();
|
|
354
|
-
if (testUser) {
|
|
355
|
-
const user = this.upsertGitHubUser(testUser);
|
|
356
|
-
const code = this.issueAuthorizationCode(pending, user.id);
|
|
357
|
-
const destination = new URL(redirectUri);
|
|
358
|
-
destination.searchParams.set("code", code);
|
|
359
|
-
if (state) {
|
|
360
|
-
destination.searchParams.set("state", state);
|
|
361
|
-
}
|
|
362
|
-
redirect(res, destination.toString());
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
if (!this.config.githubClientId || !this.config.githubClientSecret) {
|
|
366
|
-
throw safeError("FORBIDDEN", "GitHub OAuth is not configured", 503);
|
|
367
|
-
}
|
|
368
|
-
const transactionId = id("code");
|
|
369
|
-
this.authorizeTransactions.set(transactionId, pending);
|
|
370
|
-
const githubUrl = new URL("https://github.com/login/oauth/authorize");
|
|
371
|
-
githubUrl.searchParams.set("client_id", this.config.githubClientId);
|
|
372
|
-
githubUrl.searchParams.set("redirect_uri", this.config.githubCallbackUrl);
|
|
373
|
-
githubUrl.searchParams.set("scope", "read:user");
|
|
374
|
-
githubUrl.searchParams.set("state", transactionId);
|
|
375
|
-
redirect(res, githubUrl.toString());
|
|
376
|
-
}
|
|
377
|
-
validateAuthorizeParams(clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod, resource, scope) {
|
|
378
|
-
const client = this.store.getClient(clientId);
|
|
379
|
-
if (!client) {
|
|
380
|
-
throw safeError("INVALID_CLIENT", "Unknown client_id");
|
|
381
|
-
}
|
|
382
|
-
if (!client.redirectUris.includes(redirectUri) || !isSafeRedirectUri(redirectUri)) {
|
|
383
|
-
throw safeError("INVALID_REDIRECT_URI", "redirect_uri is not registered");
|
|
384
|
-
}
|
|
385
|
-
if (responseType !== "code") {
|
|
386
|
-
throw safeError("INVALID_CLIENT", "response_type must be code");
|
|
387
|
-
}
|
|
388
|
-
if (!codeChallenge || codeChallengeMethod !== "S256") {
|
|
389
|
-
throw safeError("PKCE_VALIDATION_FAILED", "PKCE S256 is required");
|
|
390
|
-
}
|
|
391
|
-
if (resource !== this.config.mcpResourceUrl) {
|
|
392
|
-
throw safeError("INVALID_TOKEN", "resource must match MCP resource URL");
|
|
393
|
-
}
|
|
394
|
-
scopeList(scope);
|
|
395
|
-
}
|
|
396
|
-
async handleGitHubCallback(req, res) {
|
|
397
|
-
const url = new URL(req.url ?? "/oauth/callback/github", this.config.publicBaseUrl);
|
|
398
|
-
const code = url.searchParams.get("code") ?? "";
|
|
399
|
-
const state = url.searchParams.get("state") ?? "";
|
|
400
|
-
const pending = this.authorizeTransactions.get(state);
|
|
401
|
-
this.authorizeTransactions.delete(state);
|
|
402
|
-
if (!code || !pending || pending.expiresAt < Date.now()) {
|
|
403
|
-
throw safeError("INVALID_TOKEN", "OAuth transaction is missing or expired");
|
|
404
|
-
}
|
|
405
|
-
const githubUser = await this.fetchGitHubUser(code);
|
|
406
|
-
const user = this.upsertGitHubUser(githubUser);
|
|
407
|
-
const authCode = this.issueAuthorizationCode(pending, user.id);
|
|
408
|
-
const destination = new URL(pending.redirectUri);
|
|
409
|
-
destination.searchParams.set("code", authCode);
|
|
410
|
-
if (pending.state) {
|
|
411
|
-
destination.searchParams.set("state", pending.state);
|
|
412
|
-
}
|
|
413
|
-
redirect(res, destination.toString());
|
|
414
|
-
}
|
|
415
|
-
async fetchGitHubUser(code) {
|
|
416
|
-
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
417
|
-
method: "POST",
|
|
418
|
-
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
419
|
-
body: JSON.stringify({
|
|
420
|
-
client_id: this.config.githubClientId,
|
|
421
|
-
client_secret: this.config.githubClientSecret,
|
|
422
|
-
code,
|
|
423
|
-
redirect_uri: this.config.githubCallbackUrl,
|
|
424
|
-
}),
|
|
425
|
-
});
|
|
426
|
-
const tokenPayload = (await tokenResponse.json());
|
|
427
|
-
const accessToken = asString(tokenPayload.access_token);
|
|
428
|
-
if (!accessToken) {
|
|
429
|
-
throw safeError("INVALID_TOKEN", "GitHub OAuth token exchange failed", 502);
|
|
430
|
-
}
|
|
431
|
-
const userResponse = await fetch("https://api.github.com/user", {
|
|
432
|
-
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" },
|
|
433
|
-
});
|
|
434
|
-
const userPayload = (await userResponse.json());
|
|
435
|
-
return { id: String(userPayload.id ?? ""), login: String(userPayload.login ?? "") };
|
|
436
|
-
}
|
|
437
|
-
testGitHubUser() {
|
|
438
|
-
const idValue = process.env.SSHAUTOMATOR_TEST_GITHUB_ID;
|
|
439
|
-
const login = process.env.SSHAUTOMATOR_TEST_GITHUB_LOGIN;
|
|
440
|
-
return idValue && login ? { id: idValue, login } : undefined;
|
|
441
|
-
}
|
|
442
|
-
upsertGitHubUser(githubUser) {
|
|
443
|
-
if (!this.isGitHubUserAllowed(githubUser)) {
|
|
444
|
-
throw safeError("FORBIDDEN", "GitHub user is not allowed");
|
|
445
|
-
}
|
|
446
|
-
const existing = this.store.getUserByGitHubId(githubUser.id);
|
|
447
|
-
const internalId = existing?.id ?? `github:${githubUser.id}`;
|
|
448
|
-
this.store.upsertUser({ ...githubUser, internalId, now: nowIso() });
|
|
449
|
-
this.audit({
|
|
450
|
-
userId: internalId,
|
|
451
|
-
eventType: "user_login",
|
|
452
|
-
severity: "info",
|
|
453
|
-
metadata: { github_id: githubUser.id, github_login: githubUser.login },
|
|
454
|
-
});
|
|
455
|
-
return { id: internalId, githubId: githubUser.id, githubLogin: githubUser.login };
|
|
456
|
-
}
|
|
457
|
-
isGitHubUserAllowed(user) {
|
|
458
|
-
return (this.config.allowAllUsers ||
|
|
459
|
-
this.config.allowedGitHubIds.includes(user.id) ||
|
|
460
|
-
this.config.allowedGitHubLogins.includes(user.login));
|
|
461
|
-
}
|
|
462
|
-
issueAuthorizationCode(pending, userId) {
|
|
463
|
-
const code = randomToken(32);
|
|
464
|
-
const now = nowIso();
|
|
465
|
-
const record = {
|
|
466
|
-
id: id("code"),
|
|
467
|
-
codeHash: hashSecret(code),
|
|
468
|
-
clientId: pending.clientId,
|
|
469
|
-
userId,
|
|
470
|
-
redirectUri: pending.redirectUri,
|
|
471
|
-
codeChallenge: pending.codeChallenge,
|
|
472
|
-
codeChallengeMethod: "S256",
|
|
473
|
-
resource: pending.resource,
|
|
474
|
-
scope: pending.scope,
|
|
475
|
-
expiresAt: new Date(Date.now() + this.config.authCodeTtlSeconds * 1000).toISOString(),
|
|
476
|
-
createdAt: now,
|
|
477
|
-
};
|
|
478
|
-
this.store.insertAuthorizationCode(record);
|
|
479
|
-
return code;
|
|
480
|
-
}
|
|
481
|
-
async handleToken(req, res) {
|
|
482
|
-
const body = await readJsonOrForm(req);
|
|
483
|
-
if (body.grant_type !== "authorization_code") {
|
|
484
|
-
throw safeError("INVALID_CLIENT", "grant_type must be authorization_code");
|
|
485
|
-
}
|
|
486
|
-
const clientId = body.client_id ?? "";
|
|
487
|
-
const client = this.store.getClient(clientId);
|
|
488
|
-
if (!client) {
|
|
489
|
-
throw safeError("INVALID_CLIENT", "Unknown client_id");
|
|
490
|
-
}
|
|
491
|
-
const code = body.code ?? "";
|
|
492
|
-
const redirectUri = body.redirect_uri ?? "";
|
|
493
|
-
const verifier = body.code_verifier ?? "";
|
|
494
|
-
const codeRecord = this.store.getAuthorizationCodeByHash(hashSecret(code));
|
|
495
|
-
if (codeRecord?.clientId !== clientId || codeRecord?.redirectUri !== redirectUri) {
|
|
496
|
-
throw safeError("INVALID_TOKEN", "Invalid authorization code");
|
|
497
|
-
}
|
|
498
|
-
if (codeRecord.usedAt || new Date(codeRecord.expiresAt).getTime() < Date.now()) {
|
|
499
|
-
throw safeError("INVALID_TOKEN", "Authorization code is expired or already used");
|
|
500
|
-
}
|
|
501
|
-
if (!verifier || pkceChallenge(verifier) !== codeRecord.codeChallenge) {
|
|
502
|
-
throw safeError("PKCE_VALIDATION_FAILED", "Invalid PKCE code_verifier");
|
|
503
|
-
}
|
|
504
|
-
const jwtKeyPair = this.requireJwtKeyPair();
|
|
505
|
-
const user = this.userFromId(codeRecord.userId);
|
|
506
|
-
const scopes = scopeList(codeRecord.scope);
|
|
507
|
-
try {
|
|
508
|
-
this.store.markAuthorizationCodeUsed(codeRecord.codeHash, nowIso());
|
|
509
|
-
}
|
|
510
|
-
catch {
|
|
511
|
-
throw safeError("INVALID_TOKEN", "Authorization code is expired or already used");
|
|
512
|
-
}
|
|
513
|
-
const token = await issueAccessToken(this.config, jwtKeyPair, user, scopes);
|
|
514
|
-
jsonResponse(res, 200, {
|
|
515
|
-
access_token: token.token,
|
|
516
|
-
token_type: "Bearer",
|
|
517
|
-
expires_in: this.config.accessTokenTtlSeconds,
|
|
518
|
-
scope: scopes.join(" "),
|
|
519
|
-
}, addNoStore());
|
|
520
|
-
}
|
|
521
|
-
userFromId(userId) {
|
|
522
|
-
if (userId.startsWith("github:")) {
|
|
523
|
-
const githubId = userId.slice("github:".length);
|
|
524
|
-
const user = this.store.getUserByGitHubId(githubId);
|
|
525
|
-
if (user) {
|
|
526
|
-
return user;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
throw safeError("UNAUTHORIZED", "User no longer exists", 401);
|
|
530
|
-
}
|
|
531
|
-
async handleJwks(res) {
|
|
532
|
-
const jwtKeyPair = this.requireJwtKeyPair();
|
|
533
|
-
jsonResponse(res, 200, { keys: [await publicJwkFromPem(jwtKeyPair.publicKeyPem)] }, addNoStore());
|
|
107
|
+
return this.agentHandler.handleUpgrade(req, socket, head, pathname);
|
|
534
108
|
}
|
|
535
109
|
async handleMcp(req, res) {
|
|
536
110
|
if (req.method === "OPTIONS") {
|
|
@@ -558,7 +132,7 @@ export class RemoteControlPlane {
|
|
|
558
132
|
jsonrpc: "2.0",
|
|
559
133
|
id: rpcId,
|
|
560
134
|
result: {
|
|
561
|
-
protocolVersion:
|
|
135
|
+
protocolVersion: negotiateMcpProtocolVersion(body.params),
|
|
562
136
|
capabilities: { tools: {} },
|
|
563
137
|
serverInfo: { name: "sshautomator-remote-agent", version: SERVER_VERSION },
|
|
564
138
|
},
|
|
@@ -699,207 +273,6 @@ export class RemoteControlPlane {
|
|
|
699
273
|
control_plane_public_key: this.controlPlaneKeyPair.publicKeyPem,
|
|
700
274
|
}, addNoStore());
|
|
701
275
|
}
|
|
702
|
-
async handleAgentMessage(connection, message) {
|
|
703
|
-
const payload = JSON.parse(message);
|
|
704
|
-
if (!isRecord(payload)) {
|
|
705
|
-
connection.sendJson({ type: "error", code: "INTERNAL_ERROR", message: "Invalid message" });
|
|
706
|
-
connection.close();
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
if (payload.type === "agent.hello") {
|
|
710
|
-
await this.handleAgentHello(connection, parseAgentHelloEnvelope(payload));
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
if (payload.type === "action.result") {
|
|
714
|
-
await this.handleActionResult(connection, parseActionResultEnvelope(payload));
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
connection.sendJson({ type: "error", code: "INTERNAL_ERROR", message: "Unknown message type" });
|
|
718
|
-
connection.close();
|
|
719
|
-
}
|
|
720
|
-
async handleAgentHello(connection, hello) {
|
|
721
|
-
const agent = this.store.getAgent(hello.agent_id);
|
|
722
|
-
if (!agent || agent.status === "revoked" || !agent.publicKey) {
|
|
723
|
-
connection.sendJson({
|
|
724
|
-
type: "error",
|
|
725
|
-
code: "AGENT_NOT_FOUND",
|
|
726
|
-
message: "Agent is not enrolled",
|
|
727
|
-
});
|
|
728
|
-
connection.close();
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
if (!verifyEnvelope(hello, agent.publicKey)) {
|
|
732
|
-
this.audit({
|
|
733
|
-
userId: agent.userId,
|
|
734
|
-
agentId: agent.id,
|
|
735
|
-
eventType: "agent_signature_invalid",
|
|
736
|
-
severity: "warn",
|
|
737
|
-
metadata: { message_type: "agent.hello" },
|
|
738
|
-
});
|
|
739
|
-
connection.sendJson({
|
|
740
|
-
type: "error",
|
|
741
|
-
code: "SIGNATURE_INVALID",
|
|
742
|
-
message: "Agent signature is invalid",
|
|
743
|
-
});
|
|
744
|
-
connection.close();
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
const timestampAgeMs = Math.abs(Date.now() - new Date(hello.timestamp).getTime());
|
|
748
|
-
if (timestampAgeMs > 300_000) {
|
|
749
|
-
this.audit({
|
|
750
|
-
userId: agent.userId,
|
|
751
|
-
agentId: agent.id,
|
|
752
|
-
eventType: "agent_hello_expired",
|
|
753
|
-
severity: "warn",
|
|
754
|
-
metadata: {},
|
|
755
|
-
});
|
|
756
|
-
connection.sendJson({
|
|
757
|
-
type: "error",
|
|
758
|
-
code: "ACTION_EXPIRED",
|
|
759
|
-
message: "Agent hello timestamp is stale",
|
|
760
|
-
});
|
|
761
|
-
connection.close();
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
const now = Date.now();
|
|
765
|
-
this.cleanupEphemeralState(now);
|
|
766
|
-
const existingConnection = this.agentConnections.get(agent.id);
|
|
767
|
-
if (existingConnection?.connection === connection) {
|
|
768
|
-
this.audit({
|
|
769
|
-
userId: agent.userId,
|
|
770
|
-
agentId: agent.id,
|
|
771
|
-
eventType: "agent_duplicate_hello_rejected",
|
|
772
|
-
severity: "warn",
|
|
773
|
-
metadata: {},
|
|
774
|
-
});
|
|
775
|
-
connection.sendJson({
|
|
776
|
-
type: "error",
|
|
777
|
-
code: "ACTION_REPLAY_DETECTED",
|
|
778
|
-
message: "Agent hello was already processed on this connection",
|
|
779
|
-
});
|
|
780
|
-
connection.close();
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
const helloNonces = this.agentHelloNonces.get(agent.id) ?? new Map();
|
|
784
|
-
if (helloNonces.has(hello.nonce)) {
|
|
785
|
-
this.audit({
|
|
786
|
-
userId: agent.userId,
|
|
787
|
-
agentId: agent.id,
|
|
788
|
-
eventType: "agent_hello_replay_detected",
|
|
789
|
-
severity: "warn",
|
|
790
|
-
metadata: {},
|
|
791
|
-
});
|
|
792
|
-
connection.sendJson({
|
|
793
|
-
type: "error",
|
|
794
|
-
code: "ACTION_REPLAY_DETECTED",
|
|
795
|
-
message: "Agent hello nonce was already used",
|
|
796
|
-
});
|
|
797
|
-
connection.close();
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
helloNonces.set(hello.nonce, now + AGENT_NONCE_TTL_MS);
|
|
801
|
-
this.agentHelloNonces.set(agent.id, helloNonces);
|
|
802
|
-
if (existingConnection) {
|
|
803
|
-
existingConnection.connection.close();
|
|
804
|
-
}
|
|
805
|
-
const seenNonces = new Map();
|
|
806
|
-
rememberNonce(seenNonces, hello.nonce, now);
|
|
807
|
-
this.agentConnections.set(agent.id, { agent, connection, seenNonces });
|
|
808
|
-
const online = {
|
|
809
|
-
...agent,
|
|
810
|
-
status: "online",
|
|
811
|
-
lastSeenAt: nowIso(),
|
|
812
|
-
hostMetadata: hello.host,
|
|
813
|
-
updatedAt: nowIso(),
|
|
814
|
-
};
|
|
815
|
-
this.store.updateAgent(online);
|
|
816
|
-
connection.onClose(() => {
|
|
817
|
-
const live = this.agentConnections.get(agent.id);
|
|
818
|
-
if (live?.connection !== connection) {
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
this.agentConnections.delete(agent.id);
|
|
822
|
-
const latest = this.store.getAgent(agent.id);
|
|
823
|
-
if (latest && latest.status !== "revoked") {
|
|
824
|
-
this.store.updateAgent({ ...latest, status: "offline", updatedAt: nowIso() });
|
|
825
|
-
}
|
|
826
|
-
this.audit({
|
|
827
|
-
userId: agent.userId,
|
|
828
|
-
agentId: agent.id,
|
|
829
|
-
eventType: "agent_disconnected",
|
|
830
|
-
severity: "info",
|
|
831
|
-
metadata: {},
|
|
832
|
-
});
|
|
833
|
-
});
|
|
834
|
-
this.audit({
|
|
835
|
-
userId: agent.userId,
|
|
836
|
-
agentId: agent.id,
|
|
837
|
-
eventType: "agent_connected",
|
|
838
|
-
severity: "info",
|
|
839
|
-
metadata: { agent_version: hello.agent_version, host: hello.host.hostname },
|
|
840
|
-
});
|
|
841
|
-
connection.sendJson({ type: "agent.ready", agent_id: agent.id, policy: agent.policy });
|
|
842
|
-
}
|
|
843
|
-
async handleActionResult(connection, result) {
|
|
844
|
-
const pending = this.pendingActions.get(result.action_id);
|
|
845
|
-
if (!pending) {
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
const agent = this.store.getAgent(result.agent_id);
|
|
849
|
-
if (!agent?.publicKey ||
|
|
850
|
-
!verifyEnvelope(result, agent.publicKey)) {
|
|
851
|
-
clearTimeout(pending.timeout);
|
|
852
|
-
this.pendingActions.delete(result.action_id);
|
|
853
|
-
this.audit({
|
|
854
|
-
userId: pending.action.userId,
|
|
855
|
-
agentId: pending.action.agentId,
|
|
856
|
-
actionId: pending.action.id,
|
|
857
|
-
eventType: "agent_result_signature_invalid",
|
|
858
|
-
severity: "warn",
|
|
859
|
-
metadata: {},
|
|
860
|
-
});
|
|
861
|
-
pending.reject(new Error("Agent result signature is invalid"));
|
|
862
|
-
return;
|
|
863
|
-
}
|
|
864
|
-
const live = this.agentConnections.get(result.agent_id);
|
|
865
|
-
if (pending.action.agentId !== result.agent_id || live?.connection !== connection) {
|
|
866
|
-
clearTimeout(pending.timeout);
|
|
867
|
-
this.pendingActions.delete(result.action_id);
|
|
868
|
-
this.audit({
|
|
869
|
-
userId: pending.action.userId,
|
|
870
|
-
agentId: pending.action.agentId,
|
|
871
|
-
actionId: pending.action.id,
|
|
872
|
-
eventType: "agent_result_connection_invalid",
|
|
873
|
-
severity: "warn",
|
|
874
|
-
metadata: {},
|
|
875
|
-
});
|
|
876
|
-
pending.reject(Object.assign(new Error("Agent result came from an unexpected connection"), {
|
|
877
|
-
code: "SIGNATURE_INVALID",
|
|
878
|
-
}));
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
const now = Date.now();
|
|
882
|
-
if (hasSeenNonce(live.seenNonces, result.nonce, now)) {
|
|
883
|
-
clearTimeout(pending.timeout);
|
|
884
|
-
this.pendingActions.delete(result.action_id);
|
|
885
|
-
this.audit({
|
|
886
|
-
userId: pending.action.userId,
|
|
887
|
-
agentId: pending.action.agentId,
|
|
888
|
-
actionId: pending.action.id,
|
|
889
|
-
eventType: "agent_result_replay_detected",
|
|
890
|
-
severity: "warn",
|
|
891
|
-
metadata: {},
|
|
892
|
-
});
|
|
893
|
-
pending.reject(Object.assign(new Error("Agent result nonce was already used"), {
|
|
894
|
-
code: "ACTION_REPLAY_DETECTED",
|
|
895
|
-
}));
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
rememberNonce(live.seenNonces, result.nonce, now);
|
|
899
|
-
clearTimeout(pending.timeout);
|
|
900
|
-
this.pendingActions.delete(result.action_id);
|
|
901
|
-
pending.resolve(result);
|
|
902
|
-
}
|
|
903
276
|
async authenticate(req) {
|
|
904
277
|
try {
|
|
905
278
|
return await verifyRemoteAccessToken(req.headers.authorization, this.config, this.requireJwtKeyPair());
|