ssh-mcp 1.4.0 → 2.0.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/README.md +488 -141
- package/build/audit/store.js +178 -0
- package/build/config/credential-resolver.js +107 -0
- package/build/config/loader.js +100 -0
- package/build/config/schema.js +53 -0
- package/build/guard/approval-grants.js +0 -0
- package/build/guard/elicitation.js +38 -0
- package/build/guard/redactor.js +67 -0
- package/build/guard/sanitizer.js +24 -0
- package/build/index.js +183 -618
- package/build/observability/tracer.js +27 -0
- package/build/policy/classifier.js +99 -0
- package/build/policy/engine.js +205 -0
- package/build/policy/quota.js +59 -0
- package/build/ssh/algorithms.js +33 -0
- package/build/ssh/channel-retry.js +15 -0
- package/build/ssh/connection-registry.js +102 -0
- package/build/ssh/connection.js +375 -0
- package/build/ssh/host-key.js +23 -0
- package/build/ssh/session-manager.js +164 -0
- package/build/ssh/session.js +313 -0
- package/build/ssh/sftp.js +130 -0
- package/build/tools/command-tools.js +82 -0
- package/build/tools/descriptions.js +21 -0
- package/build/tools/file-tools.js +36 -0
- package/build/tools/pipeline.js +185 -0
- package/build/tools/registry.js +23 -0
- package/build/tools/resources.js +73 -0
- package/build/tools/results.js +31 -0
- package/build/tools/session-tools.js +97 -0
- package/build/transport/http.js +237 -0
- package/build/types.js +2 -0
- package/build/version.js +9 -0
- package/package.json +28 -7
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { trace } from '@opentelemetry/api';
|
|
2
|
+
let initialized = false;
|
|
3
|
+
const tracer = trace.getTracer('ssh-mcp');
|
|
4
|
+
export { tracer };
|
|
5
|
+
export async function initTracing(endpoint, serviceName) {
|
|
6
|
+
if (initialized)
|
|
7
|
+
return;
|
|
8
|
+
initialized = true;
|
|
9
|
+
try {
|
|
10
|
+
const { NodeSDK } = await import('@opentelemetry/sdk-node');
|
|
11
|
+
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http');
|
|
12
|
+
const { resourceFromAttributes } = await import('@opentelemetry/resources');
|
|
13
|
+
const { ATTR_SERVICE_NAME } = await import('@opentelemetry/semantic-conventions');
|
|
14
|
+
const exporter = new OTLPTraceExporter({ url: `${endpoint}/v1/traces` });
|
|
15
|
+
const sdk = new NodeSDK({
|
|
16
|
+
resource: resourceFromAttributes({
|
|
17
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
18
|
+
}),
|
|
19
|
+
traceExporter: exporter,
|
|
20
|
+
});
|
|
21
|
+
await sdk.start();
|
|
22
|
+
console.error(`OpenTelemetry tracing enabled: ${endpoint} (service: ${serviceName})`);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.error('Failed to initialize OpenTelemetry:', err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const READ_ONLY_ALLOWLIST = new Set([
|
|
2
|
+
'ls', 'cat', 'grep', 'find', 'stat', 'df', 'du', 'head', 'tail', 'wc',
|
|
3
|
+
'ps', 'uname', 'uptime', 'hostname', 'id', 'who', 'whoami', 'date',
|
|
4
|
+
'env', 'printenv', 'pwd', 'echo', 'printf', 'test', 'true', 'false',
|
|
5
|
+
'which', 'whereis', 'file', 'readlink', 'realpath', 'basename', 'dirname',
|
|
6
|
+
'seq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm',
|
|
7
|
+
'systemctl status', 'journalctl', 'docker ps', 'docker logs', 'docker inspect',
|
|
8
|
+
'docker stats', 'docker images', 'free', 'top', 'htop', 'iostat', 'vmstat',
|
|
9
|
+
'netstat', 'ss', 'ifconfig', 'ip addr', 'ip route', 'arp', 'dig', 'nslookup',
|
|
10
|
+
'host', 'ping', 'traceroute', 'git status', 'git log',
|
|
11
|
+
'git diff', 'git branch', 'git show', 'git remote',
|
|
12
|
+
]);
|
|
13
|
+
// Deliberately NOT read-only: `curl` and `wget` fetch arbitrary URLs (SSRF to
|
|
14
|
+
// cloud metadata / internal services), post local files to a remote host
|
|
15
|
+
// (`curl -d @/etc/passwd`), and write remote files (`curl -o`, `wget -O`) —
|
|
16
|
+
// none of which need a shell metacharacter to escape the classifier. They fall
|
|
17
|
+
// through to the `safe` class, so run-command can still use them under policy.
|
|
18
|
+
//
|
|
19
|
+
// Residual risk kept on purpose: dig/nslookup/host/ping/traceroute can leak
|
|
20
|
+
// small amounts of data through DNS/ICMP queries. They cannot modify the host,
|
|
21
|
+
// so they stay read-only; tighten them via profile policy if egress matters.
|
|
22
|
+
/**
|
|
23
|
+
* Commands that are never allowed, whatever the role or approval policy.
|
|
24
|
+
*
|
|
25
|
+
* This is the policy engine's denylist — the single definition of it. The
|
|
26
|
+
* engine used to keep a parallel copy as regex *strings*, which had already
|
|
27
|
+
* drifted (it was missing the fork bomb and the recursive chown, and its `rm`
|
|
28
|
+
* pattern was narrower). Anything here is also destructive for classification
|
|
29
|
+
* purposes; see DESTRUCTIVE_DENYLIST below.
|
|
30
|
+
*/
|
|
31
|
+
const FORBIDDEN_PATTERNS = [
|
|
32
|
+
/rm\s+-rf?\s+\/(\s|$)/, // rm -rf / — the filesystem root itself
|
|
33
|
+
/mkfs\./,
|
|
34
|
+
/dd\s+.*\bof=\/dev\//,
|
|
35
|
+
/>\s*\/dev\/sd/,
|
|
36
|
+
/\bshutdown\b/,
|
|
37
|
+
/\breboot\b/,
|
|
38
|
+
/\bhalt\b/,
|
|
39
|
+
/\bpoweroff\b/,
|
|
40
|
+
/:\(\)\s*\{\s*:\|:\&\s*\}\s*;\s*:/, // fork bomb
|
|
41
|
+
/curl\s+.*\|\s*(sh|bash|zsh)/,
|
|
42
|
+
/wget\s+.*\|\s*(sh|bash|zsh)/,
|
|
43
|
+
/\beval\b/,
|
|
44
|
+
/>\s*\/etc\/cron/,
|
|
45
|
+
/>\s*\/etc\/systemd/,
|
|
46
|
+
/>\s*~\/.ssh\/authorized_keys/,
|
|
47
|
+
/\biptables\s+-F\b/,
|
|
48
|
+
/\bchmod\s+-R\s+777\s+\//,
|
|
49
|
+
/\bchown\s+-R\s+.*\s+\/\s*$/,
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* Commands that are destructive but legitimate under approval — e.g.
|
|
53
|
+
* `rm -rf /tmp/build`, which must NOT be confused with `rm -rf /`.
|
|
54
|
+
*/
|
|
55
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
56
|
+
/rm\s+-rf?\s+\//,
|
|
57
|
+
];
|
|
58
|
+
/** Everything that classifies as destructive: forbidden commands included. */
|
|
59
|
+
const DESTRUCTIVE_DENYLIST = [...FORBIDDEN_PATTERNS, ...DESTRUCTIVE_PATTERNS];
|
|
60
|
+
const PRIVILEGED_INDICATORS = [
|
|
61
|
+
/^\s*sudo\b/,
|
|
62
|
+
/^\s*su\b/,
|
|
63
|
+
/^\s*doas\b/,
|
|
64
|
+
/^\s*pkexec\b/,
|
|
65
|
+
];
|
|
66
|
+
export function extractBinary(command) {
|
|
67
|
+
let cmd = command.trim();
|
|
68
|
+
for (const prefix of PRIVILEGED_INDICATORS) {
|
|
69
|
+
cmd = cmd.replace(prefix, '').trim();
|
|
70
|
+
}
|
|
71
|
+
if (cmd.startsWith('-c ')) {
|
|
72
|
+
cmd = cmd.slice(3).trim();
|
|
73
|
+
}
|
|
74
|
+
const parts = cmd.split(/\s+/);
|
|
75
|
+
return parts[0] || '';
|
|
76
|
+
}
|
|
77
|
+
export function classifyCommand(command) {
|
|
78
|
+
const trimmed = command.trim();
|
|
79
|
+
const binary = extractBinary(trimmed);
|
|
80
|
+
const fullCommand = trimmed;
|
|
81
|
+
const isPrivileged = PRIVILEGED_INDICATORS.some((re) => re.test(trimmed));
|
|
82
|
+
if (isPrivileged) {
|
|
83
|
+
return { binary, fullCommand, class: 'privileged' };
|
|
84
|
+
}
|
|
85
|
+
for (const pattern of DESTRUCTIVE_DENYLIST) {
|
|
86
|
+
if (pattern.test(trimmed)) {
|
|
87
|
+
return { binary, fullCommand, class: 'destructive' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const twoWordPrefix = fullCommand.split(/\s+/).slice(0, 2).join(' ');
|
|
91
|
+
if (READ_ONLY_ALLOWLIST.has(binary) || READ_ONLY_ALLOWLIST.has(twoWordPrefix)) {
|
|
92
|
+
if (/[>;|]/.test(trimmed)) {
|
|
93
|
+
return { binary, fullCommand, class: 'safe' };
|
|
94
|
+
}
|
|
95
|
+
return { binary, fullCommand, class: 'read-only' };
|
|
96
|
+
}
|
|
97
|
+
return { binary, fullCommand, class: 'safe' };
|
|
98
|
+
}
|
|
99
|
+
export { READ_ONLY_ALLOWLIST, DESTRUCTIVE_DENYLIST, FORBIDDEN_PATTERNS };
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { classifyCommand, FORBIDDEN_PATTERNS } from './classifier.js';
|
|
2
|
+
/** Tiers, most restrictive first. Unknown/unset tiers resolve to the first. */
|
|
3
|
+
const HOST_GROUPS = ['prod', 'staging', 'dev'];
|
|
4
|
+
export const DEFAULT_RULES = {
|
|
5
|
+
roleBindings: {
|
|
6
|
+
viewer: {
|
|
7
|
+
prod: ['read-only'],
|
|
8
|
+
staging: ['read-only'],
|
|
9
|
+
dev: ['read-only', 'safe'],
|
|
10
|
+
},
|
|
11
|
+
operator: {
|
|
12
|
+
prod: ['read-only', 'safe'],
|
|
13
|
+
staging: ['read-only', 'safe', 'destructive'],
|
|
14
|
+
dev: ['read-only', 'safe', 'destructive'],
|
|
15
|
+
},
|
|
16
|
+
admin: {
|
|
17
|
+
prod: ['read-only', 'safe', 'destructive'],
|
|
18
|
+
staging: ['read-only', 'safe', 'destructive', 'privileged'],
|
|
19
|
+
dev: ['read-only', 'safe', 'destructive', 'privileged'],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export class PolicyEngine {
|
|
24
|
+
rules;
|
|
25
|
+
opaUrl = null;
|
|
26
|
+
/** Rate-limits the fail-open warning so one outage can't flood stderr. */
|
|
27
|
+
lastOpaWarning = 0;
|
|
28
|
+
/** Canonical patterns plus the operator's, compiled once at construction. */
|
|
29
|
+
denyPatterns;
|
|
30
|
+
constructor(rules = DEFAULT_RULES) {
|
|
31
|
+
this.rules = rules;
|
|
32
|
+
// Compile eagerly: a deny rule that silently degrades (the old code fell
|
|
33
|
+
// back to substring-matching the command against the regex *source*) is
|
|
34
|
+
// worse than a startup failure, because nothing surfaces the degradation.
|
|
35
|
+
const userPatterns = (rules.denylist ?? []).map((pattern) => {
|
|
36
|
+
try {
|
|
37
|
+
return new RegExp(pattern);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
throw new Error(`Invalid denylist pattern ${JSON.stringify(pattern)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
this.denyPatterns = [...FORBIDDEN_PATTERNS, ...userPatterns];
|
|
44
|
+
}
|
|
45
|
+
setOpaUrl(url) {
|
|
46
|
+
this.opaUrl = url;
|
|
47
|
+
}
|
|
48
|
+
evaluate(command, profile, _toolName) {
|
|
49
|
+
const parsed = classifyCommand(command);
|
|
50
|
+
const allowedClasses = this.getAllowedClasses(profile);
|
|
51
|
+
const classAllowed = allowedClasses.includes(parsed.class);
|
|
52
|
+
if (this.matchesDenylist(command)) {
|
|
53
|
+
return {
|
|
54
|
+
decision: 'deny',
|
|
55
|
+
commandClass: parsed.class,
|
|
56
|
+
binary: parsed.binary,
|
|
57
|
+
ruleId: 'denylist',
|
|
58
|
+
reason: 'Command matches denylist pattern',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const needsApproval = parsed.class === 'destructive' || parsed.class === 'privileged';
|
|
62
|
+
const approvalRequired = this.requiresApproval(profile, needsApproval);
|
|
63
|
+
if (!classAllowed) {
|
|
64
|
+
return {
|
|
65
|
+
decision: 'deny',
|
|
66
|
+
commandClass: parsed.class,
|
|
67
|
+
binary: parsed.binary,
|
|
68
|
+
ruleId: 'role-binding',
|
|
69
|
+
reason: `Role "${profile.role}" cannot run "${parsed.class}" commands`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (approvalRequired) {
|
|
73
|
+
// approvalPolicy "deny" rejects outright instead of prompting — otherwise
|
|
74
|
+
// it would behave exactly like ask-destructive and never deny anything.
|
|
75
|
+
if (profile.approvalPolicy === 'deny') {
|
|
76
|
+
return {
|
|
77
|
+
decision: 'deny',
|
|
78
|
+
commandClass: parsed.class,
|
|
79
|
+
binary: parsed.binary,
|
|
80
|
+
ruleId: 'approval-policy',
|
|
81
|
+
reason: `Profile "${profile.name}" denies ${parsed.class} commands (approvalPolicy = "deny")`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
decision: 'require-approval',
|
|
86
|
+
commandClass: parsed.class,
|
|
87
|
+
binary: parsed.binary,
|
|
88
|
+
ruleId: 'approval-policy',
|
|
89
|
+
reason: `Profile "${profile.name}" requires approval for ${parsed.class} commands`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
decision: 'allow',
|
|
94
|
+
commandClass: parsed.class,
|
|
95
|
+
binary: parsed.binary,
|
|
96
|
+
ruleId: 'default',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async evaluateWithOpa(command, profile, toolName) {
|
|
100
|
+
const local = this.evaluate(command, profile, toolName);
|
|
101
|
+
if (local.decision === 'deny') {
|
|
102
|
+
return local;
|
|
103
|
+
}
|
|
104
|
+
if (!this.opaUrl) {
|
|
105
|
+
return local;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const parsed = classifyCommand(command);
|
|
109
|
+
const input = {
|
|
110
|
+
subject: { role: profile.role, profile: profile.name },
|
|
111
|
+
action: { tool: toolName, commandClass: parsed.class },
|
|
112
|
+
resource: { command: parsed.fullCommand, binary: parsed.binary, host: profile.host },
|
|
113
|
+
context: { readOnly: profile.readOnly },
|
|
114
|
+
};
|
|
115
|
+
const resp = await fetch(`${this.opaUrl}/v1/data/ssh/mcp/allow`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ input }),
|
|
119
|
+
});
|
|
120
|
+
if (!resp.ok) {
|
|
121
|
+
this.warnOpaUnavailable(`HTTP ${resp.status} from ${this.opaUrl}`);
|
|
122
|
+
return local;
|
|
123
|
+
}
|
|
124
|
+
const data = await resp.json();
|
|
125
|
+
if (data.result === false) {
|
|
126
|
+
return {
|
|
127
|
+
decision: 'deny',
|
|
128
|
+
commandClass: parsed.class,
|
|
129
|
+
binary: parsed.binary,
|
|
130
|
+
ruleId: 'opa',
|
|
131
|
+
reason: 'Denied by OPA policy',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
this.warnOpaUnavailable(err instanceof Error ? err.message : String(err));
|
|
137
|
+
}
|
|
138
|
+
return local;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* OPA is an *additional* deny layer, so an outage falls back to the local
|
|
142
|
+
* decision rather than blocking all work. That is a deliberate trade-off, but
|
|
143
|
+
* it must be loud: an operator who deployed OPA as a hard authorization gate
|
|
144
|
+
* would otherwise have no signal that the gate is down while commands it
|
|
145
|
+
* would have denied keep running.
|
|
146
|
+
*/
|
|
147
|
+
warnOpaUnavailable(cause) {
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
if (now - this.lastOpaWarning < 60_000)
|
|
150
|
+
return;
|
|
151
|
+
this.lastOpaWarning = now;
|
|
152
|
+
console.error(`POLICY WARNING: OPA evaluation unavailable (${cause}). ` +
|
|
153
|
+
'Falling back to local policy — commands OPA would deny may now be allowed.');
|
|
154
|
+
}
|
|
155
|
+
getAllowedClasses(profile) {
|
|
156
|
+
if (profile.readOnly) {
|
|
157
|
+
return ['read-only'];
|
|
158
|
+
}
|
|
159
|
+
const roleBinding = this.rules.roleBindings[profile.role];
|
|
160
|
+
if (!roleBinding) {
|
|
161
|
+
return ['read-only'];
|
|
162
|
+
}
|
|
163
|
+
const hostGroup = this.resolveHostGroup(profile);
|
|
164
|
+
// Falls back to the most restrictive tier, never to the loosest one.
|
|
165
|
+
return roleBinding[hostGroup] || roleBinding[HOST_GROUPS[0]] || ['read-only'];
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Which tier's role binding applies to this profile.
|
|
169
|
+
*
|
|
170
|
+
* `profile.group` is authoritative. Without it we still infer from the name
|
|
171
|
+
* for convenience, but an unrecognised name resolves to the most restrictive
|
|
172
|
+
* tier: the previous default sent every unrecognised profile to `dev`, so a
|
|
173
|
+
* production host merely named "web-01" silently received the loosest
|
|
174
|
+
* permissions in the matrix.
|
|
175
|
+
*/
|
|
176
|
+
resolveHostGroup(profile) {
|
|
177
|
+
if (profile.group)
|
|
178
|
+
return profile.group;
|
|
179
|
+
const name = profile.name.toLowerCase();
|
|
180
|
+
if (name.includes('prod'))
|
|
181
|
+
return 'prod';
|
|
182
|
+
if (name.includes('staging'))
|
|
183
|
+
return 'staging';
|
|
184
|
+
if (/\b(dev|local|test|sandbox)\b/.test(name) || /(^|[-_])(dev|local|test|sandbox)([-_]|$)/.test(name)) {
|
|
185
|
+
return 'dev';
|
|
186
|
+
}
|
|
187
|
+
return HOST_GROUPS[0];
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Whether this command trips the profile's approval gate. What happens at the
|
|
191
|
+
* gate — prompt or deny — is decided by evaluate() from the approvalPolicy.
|
|
192
|
+
*/
|
|
193
|
+
requiresApproval(profile, needsApproval) {
|
|
194
|
+
const mode = profile.approvalPolicy;
|
|
195
|
+
if (mode === 'auto')
|
|
196
|
+
return false;
|
|
197
|
+
if (mode === 'ask-all')
|
|
198
|
+
return true;
|
|
199
|
+
// ask-destructive and deny both gate on destructive/privileged commands.
|
|
200
|
+
return needsApproval;
|
|
201
|
+
}
|
|
202
|
+
matchesDenylist(command) {
|
|
203
|
+
return this.denyPatterns.some((pattern) => pattern.test(command));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-profile command quota — a circuit breaker for runaway agents.
|
|
3
|
+
*
|
|
4
|
+
* The approval gate stops a *destructive* command, and the HTTP rate limiter
|
|
5
|
+
* caps request rate, but neither bounds total work: a prompt-injected agent
|
|
6
|
+
* looping over allowed commands stays under both. The quota bounds the day.
|
|
7
|
+
*
|
|
8
|
+
* Scope is the profile, not the "agent": MCP gives us no stable client
|
|
9
|
+
* identity (a stdio server serves exactly one client anyway), so claiming
|
|
10
|
+
* per-agent accounting would overstate what is actually enforced.
|
|
11
|
+
*/
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
export class CommandQuota {
|
|
14
|
+
windowMs;
|
|
15
|
+
/** Timestamps of counted commands, oldest first, per profile. */
|
|
16
|
+
hits = new Map();
|
|
17
|
+
/**
|
|
18
|
+
* A sliding 24h window rather than a calendar day: a fixed daily reset lets
|
|
19
|
+
* an agent burn the whole quota, wait for midnight, and immediately burn it
|
|
20
|
+
* again — twice the intended work in minutes.
|
|
21
|
+
*/
|
|
22
|
+
constructor(windowMs = DAY_MS) {
|
|
23
|
+
this.windowMs = windowMs;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Count one command against the profile's quota.
|
|
27
|
+
* `limit <= 0` (or undefined) means unlimited, and nothing is recorded.
|
|
28
|
+
*/
|
|
29
|
+
consume(profile, limit, now = Date.now()) {
|
|
30
|
+
if (!limit || limit <= 0)
|
|
31
|
+
return { allowed: true, remaining: Infinity };
|
|
32
|
+
const cutoff = now - this.windowMs;
|
|
33
|
+
const recent = (this.hits.get(profile) ?? []).filter((t) => t > cutoff);
|
|
34
|
+
if (recent.length >= limit) {
|
|
35
|
+
// Refusal must not consume budget, otherwise a blocked agent could keep
|
|
36
|
+
// pushing the window forward and never recover.
|
|
37
|
+
this.hits.set(profile, recent);
|
|
38
|
+
return {
|
|
39
|
+
allowed: false,
|
|
40
|
+
remaining: 0,
|
|
41
|
+
retryAt: new Date(recent[0] + this.windowMs),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
recent.push(now);
|
|
45
|
+
this.hits.set(profile, recent);
|
|
46
|
+
return { allowed: true, remaining: limit - recent.length };
|
|
47
|
+
}
|
|
48
|
+
/** Commands used in the current window, for status reporting. */
|
|
49
|
+
used(profile, now = Date.now()) {
|
|
50
|
+
const cutoff = now - this.windowMs;
|
|
51
|
+
return (this.hits.get(profile) ?? []).filter((t) => t > cutoff).length;
|
|
52
|
+
}
|
|
53
|
+
reset(profile) {
|
|
54
|
+
if (profile)
|
|
55
|
+
this.hits.delete(profile);
|
|
56
|
+
else
|
|
57
|
+
this.hits.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const FROZEN_ALGORITHMS = {
|
|
2
|
+
kex: [
|
|
3
|
+
'curve25519-sha256',
|
|
4
|
+
'curve25519-sha256@libssh.org',
|
|
5
|
+
'ecdh-sha2-nistp256',
|
|
6
|
+
'ecdh-sha2-nistp384',
|
|
7
|
+
'ecdh-sha2-nistp521',
|
|
8
|
+
'diffie-hellman-group16-sha512',
|
|
9
|
+
'diffie-hellman-group14-sha256',
|
|
10
|
+
],
|
|
11
|
+
cipher: [
|
|
12
|
+
'chacha20-poly1305@openssh.com',
|
|
13
|
+
'aes256-gcm@openssh.com',
|
|
14
|
+
'aes128-gcm@openssh.com',
|
|
15
|
+
'aes256-ctr',
|
|
16
|
+
'aes192-ctr',
|
|
17
|
+
'aes128-ctr',
|
|
18
|
+
],
|
|
19
|
+
serverHostKey: [
|
|
20
|
+
'ssh-ed25519',
|
|
21
|
+
'ecdsa-sha2-nistp256',
|
|
22
|
+
'ecdsa-sha2-nistp384',
|
|
23
|
+
'ecdsa-sha2-nistp521',
|
|
24
|
+
'rsa-sha2-512',
|
|
25
|
+
'rsa-sha2-256',
|
|
26
|
+
],
|
|
27
|
+
hmac: [
|
|
28
|
+
'hmac-sha2-256-etm@openssh.com',
|
|
29
|
+
'hmac-sha2-512-etm@openssh.com',
|
|
30
|
+
'hmac-sha2-256',
|
|
31
|
+
'hmac-sha2-512',
|
|
32
|
+
],
|
|
33
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function openWithRetry(open, { attempts = 3, delayMs = 150 } = {}) {
|
|
2
|
+
let lastError;
|
|
3
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
4
|
+
try {
|
|
5
|
+
return await open();
|
|
6
|
+
}
|
|
7
|
+
catch (err) {
|
|
8
|
+
lastError = err;
|
|
9
|
+
if (attempt < attempts) {
|
|
10
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
15
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { getProfile as getConfigProfile } from '../config/loader.js';
|
|
2
|
+
import { resolveCredentials } from '../config/credential-resolver.js';
|
|
3
|
+
import { SSHConnection } from './connection.js';
|
|
4
|
+
export class ConnectionRegistry {
|
|
5
|
+
connections = new Map();
|
|
6
|
+
pending = new Map();
|
|
7
|
+
knownHostsStore = new Map();
|
|
8
|
+
hostKeyMode;
|
|
9
|
+
config;
|
|
10
|
+
constructor(config, hostKeyMode = 'tofu') {
|
|
11
|
+
this.config = config;
|
|
12
|
+
this.hostKeyMode = hostKeyMode;
|
|
13
|
+
}
|
|
14
|
+
async getOrCreate(profileName) {
|
|
15
|
+
const profile = this.getProfile(profileName);
|
|
16
|
+
const existing = this.connections.get(profile.name);
|
|
17
|
+
if (existing && existing.isConnected()) {
|
|
18
|
+
return existing;
|
|
19
|
+
}
|
|
20
|
+
const inFlight = this.pending.get(profile.name);
|
|
21
|
+
if (inFlight) {
|
|
22
|
+
return inFlight;
|
|
23
|
+
}
|
|
24
|
+
const promise = (async () => {
|
|
25
|
+
try {
|
|
26
|
+
let conn = this.connections.get(profile.name);
|
|
27
|
+
// A ProxyJump connection runs over a forwarded channel on the bastion.
|
|
28
|
+
// Once the connection drops, that channel is dead, so reconnecting the
|
|
29
|
+
// same SSHConnection reuses a corpse and always fails — which used to
|
|
30
|
+
// burn one tool call on every reconnect before the retry rebuilt it.
|
|
31
|
+
// Discard the object so the hop is re-established below.
|
|
32
|
+
if (conn && profile.via && !conn.isConnected()) {
|
|
33
|
+
await conn.close().catch(() => { });
|
|
34
|
+
this.connections.delete(profile.name);
|
|
35
|
+
conn = undefined;
|
|
36
|
+
}
|
|
37
|
+
if (!conn) {
|
|
38
|
+
let bastionSock;
|
|
39
|
+
if (profile.via) {
|
|
40
|
+
const bastionConn = await this.getOrCreate(profile.via);
|
|
41
|
+
const bastionClient = bastionConn.getClient();
|
|
42
|
+
bastionSock = await new Promise((resolve, reject) => {
|
|
43
|
+
bastionClient.forwardOut('', 0, profile.host, profile.port, (err, stream) => {
|
|
44
|
+
if (err)
|
|
45
|
+
reject(new Error(`ProxyJump via "${profile.via}" failed: ${err.message}`));
|
|
46
|
+
else
|
|
47
|
+
resolve(stream);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
conn = new SSHConnection(profile, await resolveCredentials(profile), this.knownHostsStore, this.hostKeyMode, bastionSock);
|
|
52
|
+
this.connections.set(profile.name, conn);
|
|
53
|
+
}
|
|
54
|
+
await conn.ensureConnected();
|
|
55
|
+
return conn;
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
this.connections.delete(profile.name);
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
this.pending.delete(profile.name);
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
this.pending.set(profile.name, promise);
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
68
|
+
get(profileName) {
|
|
69
|
+
const profile = this.getProfile(profileName);
|
|
70
|
+
return this.connections.get(profile.name);
|
|
71
|
+
}
|
|
72
|
+
getProfile(profileName) {
|
|
73
|
+
return getConfigProfile(this.config, profileName);
|
|
74
|
+
}
|
|
75
|
+
listConnections() {
|
|
76
|
+
return Array.from(this.connections.values()).map((c) => c.toInfo());
|
|
77
|
+
}
|
|
78
|
+
listAllProfiles() {
|
|
79
|
+
return this.config.profiles;
|
|
80
|
+
}
|
|
81
|
+
reapIdleConnections() {
|
|
82
|
+
const idleThreshold = Date.now() - this.config.defaults.connectionIdleReapMs;
|
|
83
|
+
const viaTargets = new Set(this.config.profiles
|
|
84
|
+
.map((p) => p.via)
|
|
85
|
+
.filter((v) => !!v));
|
|
86
|
+
for (const [name, conn] of this.connections) {
|
|
87
|
+
if (viaTargets.has(name))
|
|
88
|
+
continue;
|
|
89
|
+
const info = conn.toInfo();
|
|
90
|
+
if (info.sessionCount === 0 && info.lastActivity && info.lastActivity.getTime() < idleThreshold) {
|
|
91
|
+
conn.close().catch(() => { });
|
|
92
|
+
this.connections.delete(name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async closeAll() {
|
|
97
|
+
for (const conn of this.connections.values()) {
|
|
98
|
+
await conn.close().catch(() => { });
|
|
99
|
+
}
|
|
100
|
+
this.connections.clear();
|
|
101
|
+
}
|
|
102
|
+
}
|