@imenam/mcp-ssh-gate 0.0.0-stage → 1.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 +128 -2
- package/dist/shared/model.d.ts +76 -0
- package/dist/shared/model.js +7 -0
- package/dist/src/cli/setup-mcp.d.ts +21 -0
- package/dist/src/cli/setup-mcp.js +137 -0
- package/dist/src/core/config.d.ts +14 -0
- package/dist/src/core/config.js +36 -0
- package/dist/src/core/errors.d.ts +4 -0
- package/dist/src/core/errors.js +4 -0
- package/dist/src/core/executor.d.ts +46 -0
- package/dist/src/core/executor.js +328 -0
- package/dist/src/core/files.d.ts +9 -0
- package/dist/src/core/files.js +63 -0
- package/dist/src/core/heartbeat.d.ts +5 -0
- package/dist/src/core/heartbeat.js +26 -0
- package/dist/src/core/request-store.d.ts +26 -0
- package/dist/src/core/request-store.js +115 -0
- package/dist/src/core/server-store.d.ts +42 -0
- package/dist/src/core/server-store.js +177 -0
- package/dist/src/core/ssh-session.d.ts +72 -0
- package/dist/src/core/ssh-session.js +243 -0
- package/dist/src/core/vault.d.ts +8 -0
- package/dist/src/core/vault.js +45 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +212 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +149 -0
- package/dist/src/tools/format.d.ts +4 -0
- package/dist/src/tools/format.js +66 -0
- package/dist/src/tools/tools.d.ts +14 -0
- package/dist/src/tools/tools.js +247 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +16 -0
- package/gui/dist/assets/index-B2ykxkbY.css +1 -0
- package/gui/dist/assets/index-D1Fg5r8H.js +10 -0
- package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/gui/dist/index.html +13 -0
- package/package.json +58 -4
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ssh2 from "ssh2";
|
|
4
|
+
import { MAX_NOTES_LENGTH } from "../../shared/model.js";
|
|
5
|
+
import { GateError } from "./errors.js";
|
|
6
|
+
import { readJson, withFileLock, writeJsonAtomic } from "./files.js";
|
|
7
|
+
import { decryptSecret, encryptSecret } from "./vault.js";
|
|
8
|
+
const USERNAME_PATTERN = /^[a-z_][a-z0-9_.-]*\$?$/i;
|
|
9
|
+
const HOST_PATTERN = /^[a-z0-9.:\-\[\]]+$/i;
|
|
10
|
+
/** Contrôle et normalise les champs saisis pour un serveur. */
|
|
11
|
+
export function validateServerInput(input) {
|
|
12
|
+
const name = String(input.name ?? "").trim();
|
|
13
|
+
const host = String(input.host ?? "").trim();
|
|
14
|
+
const username = String(input.username ?? "").trim();
|
|
15
|
+
const port = Number(input.port);
|
|
16
|
+
if (!name)
|
|
17
|
+
throw new GateError("Le nom du serveur est obligatoire.");
|
|
18
|
+
if (name.length > 60)
|
|
19
|
+
throw new GateError("Le nom du serveur dépasse 60 caractères.");
|
|
20
|
+
if (!host)
|
|
21
|
+
throw new GateError("L'hôte est obligatoire.");
|
|
22
|
+
if (!HOST_PATTERN.test(host))
|
|
23
|
+
throw new GateError(`Hôte invalide : « ${host} ».`);
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
25
|
+
throw new GateError("Le port doit être un entier entre 1 et 65535.");
|
|
26
|
+
if (!username)
|
|
27
|
+
throw new GateError("L'utilisateur est obligatoire.");
|
|
28
|
+
if (!USERNAME_PATTERN.test(username))
|
|
29
|
+
throw new GateError(`Utilisateur invalide : « ${username} ».`);
|
|
30
|
+
return { name, host, port, username };
|
|
31
|
+
}
|
|
32
|
+
function slugify(name) {
|
|
33
|
+
const slug = name
|
|
34
|
+
.normalize("NFD")
|
|
35
|
+
.replace(/[̀-ͯ]/g, "")
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
38
|
+
.replace(/^-+|-+$/g, "");
|
|
39
|
+
return slug || "serveur";
|
|
40
|
+
}
|
|
41
|
+
export function toPublic(server) {
|
|
42
|
+
const { privateKeyEncrypted: _privateKey, ...rest } = server;
|
|
43
|
+
return rest;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Serveurs SSH : `<DATA_DIR>/servers.json`. La paire de clés est générée à la
|
|
47
|
+
* création ; seule la clé publique est lisible, la clé privée est chiffrée.
|
|
48
|
+
*/
|
|
49
|
+
export class ServerStore {
|
|
50
|
+
vaultKey;
|
|
51
|
+
file;
|
|
52
|
+
lockFile;
|
|
53
|
+
constructor(dataDir, vaultKey) {
|
|
54
|
+
this.vaultKey = vaultKey;
|
|
55
|
+
this.file = path.join(dataDir, "servers.json");
|
|
56
|
+
this.lockFile = `${this.file}.lock`;
|
|
57
|
+
}
|
|
58
|
+
list() {
|
|
59
|
+
if (!fs.existsSync(this.file))
|
|
60
|
+
return [];
|
|
61
|
+
return readJson(this.file).servers.map((server) => ({
|
|
62
|
+
...server,
|
|
63
|
+
notes: server.notes ?? "",
|
|
64
|
+
notesUpdatedAt: server.notesUpdatedAt ?? null,
|
|
65
|
+
notesUpdatedBy: server.notesUpdatedBy ?? null,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
/** Retrouve un serveur par identifiant ou par nom (sans tenir compte de la casse). */
|
|
69
|
+
get(idOrName) {
|
|
70
|
+
const wanted = String(idOrName ?? "").trim().toLowerCase();
|
|
71
|
+
const servers = this.list();
|
|
72
|
+
const found = servers.find((s) => s.id === wanted) ?? servers.find((s) => s.name.toLowerCase() === wanted);
|
|
73
|
+
if (found)
|
|
74
|
+
return found;
|
|
75
|
+
const known = servers.map((s) => s.id).join(", ") || "none";
|
|
76
|
+
throw new GateError(`Unknown server "${idOrName}". Known servers: ${known}.`);
|
|
77
|
+
}
|
|
78
|
+
create(input) {
|
|
79
|
+
const fields = validateServerInput(input);
|
|
80
|
+
return this.mutate((servers) => {
|
|
81
|
+
const base = slugify(fields.name);
|
|
82
|
+
let id = base;
|
|
83
|
+
for (let n = 2; servers.some((s) => s.id === id); n++)
|
|
84
|
+
id = `${base}-${n}`;
|
|
85
|
+
const keys = ssh2.utils.generateKeyPairSync("ed25519", { comment: `mcp-ssh-gate@${id}` });
|
|
86
|
+
const server = {
|
|
87
|
+
id,
|
|
88
|
+
...fields,
|
|
89
|
+
publicKey: keys.public,
|
|
90
|
+
hostKeyFingerprint: null,
|
|
91
|
+
createdAt: new Date().toISOString(),
|
|
92
|
+
notes: "",
|
|
93
|
+
notesUpdatedAt: null,
|
|
94
|
+
notesUpdatedBy: null,
|
|
95
|
+
privateKeyEncrypted: encryptSecret(keys.private, this.vaultKey),
|
|
96
|
+
};
|
|
97
|
+
servers.push(server);
|
|
98
|
+
return server;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Modifie les champs saisis. Un changement d'hôte ou de port oublie l'empreinte : c'est une autre machine. */
|
|
102
|
+
update(id, input) {
|
|
103
|
+
const fields = validateServerInput(input);
|
|
104
|
+
return this.mutate((servers) => {
|
|
105
|
+
const server = servers.find((s) => s.id === id);
|
|
106
|
+
if (!server)
|
|
107
|
+
throw new GateError(`Unknown server "${id}".`);
|
|
108
|
+
if (server.host !== fields.host || server.port !== fields.port)
|
|
109
|
+
server.hostKeyFingerprint = null;
|
|
110
|
+
Object.assign(server, fields);
|
|
111
|
+
return server;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
setFingerprint(id, fingerprint) {
|
|
115
|
+
return this.mutate((servers) => {
|
|
116
|
+
const server = servers.find((s) => s.id === id);
|
|
117
|
+
if (!server)
|
|
118
|
+
throw new GateError(`Unknown server "${id}".`);
|
|
119
|
+
server.hostKeyFingerprint = fingerprint;
|
|
120
|
+
return server;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Remplace les notes. Avec `expectedUpdatedAt`, l'écriture est refusée si les
|
|
125
|
+
* notes ont changé depuis cette date : une saisie ne doit pas écraser une
|
|
126
|
+
* mise à jour faite entre-temps par l'agent ou par un autre onglet.
|
|
127
|
+
*/
|
|
128
|
+
setNotes(id, text, by, expectedUpdatedAt) {
|
|
129
|
+
const notes = String(text ?? "").replace(/\r\n/g, "\n").trim();
|
|
130
|
+
if (notes.length > MAX_NOTES_LENGTH) {
|
|
131
|
+
throw new GateError(`Notes are limited to ${MAX_NOTES_LENGTH} characters (${notes.length} given).`);
|
|
132
|
+
}
|
|
133
|
+
return this.mutate((servers) => {
|
|
134
|
+
const server = servers.find((s) => s.id === id);
|
|
135
|
+
if (!server)
|
|
136
|
+
throw new GateError(`Unknown server "${id}".`);
|
|
137
|
+
if (expectedUpdatedAt !== undefined && (server.notesUpdatedAt ?? null) !== expectedUpdatedAt) {
|
|
138
|
+
const author = server.notesUpdatedBy === "agent" ? "l'agent" : "un autre onglet";
|
|
139
|
+
throw new GateError(`Les notes ont été modifiées par ${author} pendant ta saisie. Copie ton texte, annule, puis reprends à partir de la nouvelle version.`);
|
|
140
|
+
}
|
|
141
|
+
server.notes = notes;
|
|
142
|
+
server.notesUpdatedAt = new Date().toISOString();
|
|
143
|
+
server.notesUpdatedBy = by;
|
|
144
|
+
return server;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
delete(id) {
|
|
148
|
+
this.mutate((servers) => {
|
|
149
|
+
const index = servers.findIndex((s) => s.id === id);
|
|
150
|
+
if (index === -1)
|
|
151
|
+
throw new GateError(`Unknown server "${id}".`);
|
|
152
|
+
servers.splice(index, 1);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
privateKey(id) {
|
|
156
|
+
return decryptSecret(this.get(id).privateKeyEncrypted, this.vaultKey);
|
|
157
|
+
}
|
|
158
|
+
/** Vérifie que VAULT_KEY déchiffre toutes les clés enregistrées. */
|
|
159
|
+
verifyVault() {
|
|
160
|
+
for (const server of this.list()) {
|
|
161
|
+
try {
|
|
162
|
+
decryptSecret(server.privateKeyEncrypted, this.vaultKey);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new GateError(`VAULT_KEY does not decrypt the private key of server "${server.name}": it is not the key that was set when that server was added.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
mutate(fn) {
|
|
170
|
+
return withFileLock(this.lockFile, "servers.json", () => {
|
|
171
|
+
const servers = this.list();
|
|
172
|
+
const result = fn(servers);
|
|
173
|
+
writeJsonAtomic(this.file, { servers }, 0o600);
|
|
174
|
+
return result;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface ShellTarget {
|
|
2
|
+
host: string;
|
|
3
|
+
port: number;
|
|
4
|
+
username: string;
|
|
5
|
+
privateKey: string;
|
|
6
|
+
/** Empreinte attendue de la clé d'hôte ; null au premier test de connexion. */
|
|
7
|
+
expectedFingerprint: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface CommandResult {
|
|
10
|
+
exitCode: number;
|
|
11
|
+
cwd: string;
|
|
12
|
+
}
|
|
13
|
+
/** Empreinte au format d'OpenSSH : `SHA256:<base64 sans remplissage>`. */
|
|
14
|
+
export declare function fingerprintOf(key: Buffer): string;
|
|
15
|
+
/**
|
|
16
|
+
* Script écrit dans le shell pour une commande. Le groupe `{ }` s'exécute dans
|
|
17
|
+
* le shell courant (un `cd` ou un `export` persiste), l'entrée vient de
|
|
18
|
+
* /dev/null (une commande qui attend une saisie reçoit une fin de fichier) et
|
|
19
|
+
* stderr est fusionné dans stdout. Le marqueur final porte le code de retour et
|
|
20
|
+
* le répertoire courant.
|
|
21
|
+
*/
|
|
22
|
+
export declare function wrapCommand(command: string, token: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Découpe le flux du shell : transmet la sortie au fil de l'eau et détecte le
|
|
25
|
+
* marqueur de fin de la commande. La fin du tampon qui pourrait être un début
|
|
26
|
+
* de marqueur est retenue jusqu'au fragment suivant.
|
|
27
|
+
*/
|
|
28
|
+
export declare class CommandOutputParser {
|
|
29
|
+
private readonly onOutput;
|
|
30
|
+
private buffer;
|
|
31
|
+
private readonly marker;
|
|
32
|
+
constructor(token: string, onOutput: (text: string) => void);
|
|
33
|
+
/** Ajoute un fragment ; renvoie le résultat quand le marqueur complet est arrivé. */
|
|
34
|
+
push(chunk: string): CommandResult | null;
|
|
35
|
+
private emit;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Connexion SSH et shell bash persistant, sans terminal : les commandes
|
|
39
|
+
* s'enchaînent dans le même shell, une à la fois.
|
|
40
|
+
*/
|
|
41
|
+
export declare class ShellSession {
|
|
42
|
+
private readonly client;
|
|
43
|
+
private readonly channel;
|
|
44
|
+
readonly shellPid: number;
|
|
45
|
+
cwd: string;
|
|
46
|
+
readonly fingerprint: string;
|
|
47
|
+
private readonly onClose;
|
|
48
|
+
private readonly stdoutDecoder;
|
|
49
|
+
private readonly stderrDecoder;
|
|
50
|
+
private current;
|
|
51
|
+
private closedReason;
|
|
52
|
+
private constructor();
|
|
53
|
+
/**
|
|
54
|
+
* Ouvre la connexion et le shell. Avec une empreinte attendue, une clé d'hôte
|
|
55
|
+
* différente fait échouer la connexion ; sans, l'empreinte vue est renvoyée.
|
|
56
|
+
*/
|
|
57
|
+
static open(target: ShellTarget, onClose: (reason: string) => void): Promise<ShellSession>;
|
|
58
|
+
get closed(): boolean;
|
|
59
|
+
/** Exécute une commande dans le shell ; `onOutput` reçoit la sortie au fil de l'eau. */
|
|
60
|
+
run(command: string, onOutput: (text: string) => void): Promise<CommandResult>;
|
|
61
|
+
/** Vérifie la syntaxe avec `bash -n`, sans rien exécuter ; renvoie le message d'erreur, ou null. */
|
|
62
|
+
checkSyntax(command: string): Promise<string | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Envoie SIGINT aux processus lancés par le shell, comme un Ctrl+C. Échoue
|
|
65
|
+
* quand aucun processus n'a reçu le signal : une boucle exécutée par le shell
|
|
66
|
+
* lui-même n'a pas de processus fils à interrompre.
|
|
67
|
+
*/
|
|
68
|
+
interrupt(): Promise<void>;
|
|
69
|
+
close(): void;
|
|
70
|
+
private receive;
|
|
71
|
+
private handleClose;
|
|
72
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import ssh2 from "ssh2";
|
|
4
|
+
import { GateError } from "./errors.js";
|
|
5
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
6
|
+
const READY_MARKER = /__SSHGATE_READY_(\d+)_(.*)__\n/;
|
|
7
|
+
const END_MARKER_PREFIX = "__SSHGATE_END_";
|
|
8
|
+
/** Empreinte au format d'OpenSSH : `SHA256:<base64 sans remplissage>`. */
|
|
9
|
+
export function fingerprintOf(key) {
|
|
10
|
+
return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Script écrit dans le shell pour une commande. Le groupe `{ }` s'exécute dans
|
|
14
|
+
* le shell courant (un `cd` ou un `export` persiste), l'entrée vient de
|
|
15
|
+
* /dev/null (une commande qui attend une saisie reçoit une fin de fichier) et
|
|
16
|
+
* stderr est fusionné dans stdout. Le marqueur final porte le code de retour et
|
|
17
|
+
* le répertoire courant.
|
|
18
|
+
*/
|
|
19
|
+
export function wrapCommand(command, token) {
|
|
20
|
+
return `{\n${command}\n} </dev/null 2>&1\n__sg_rc=$?\nprintf '\\n${END_MARKER_PREFIX}%s_%s_%s__\\n' '${token}' "$__sg_rc" "$PWD"\n`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Découpe le flux du shell : transmet la sortie au fil de l'eau et détecte le
|
|
24
|
+
* marqueur de fin de la commande. La fin du tampon qui pourrait être un début
|
|
25
|
+
* de marqueur est retenue jusqu'au fragment suivant.
|
|
26
|
+
*/
|
|
27
|
+
export class CommandOutputParser {
|
|
28
|
+
onOutput;
|
|
29
|
+
buffer = "";
|
|
30
|
+
marker;
|
|
31
|
+
constructor(token, onOutput) {
|
|
32
|
+
this.onOutput = onOutput;
|
|
33
|
+
this.marker = `\n${END_MARKER_PREFIX}${token}_`;
|
|
34
|
+
}
|
|
35
|
+
/** Ajoute un fragment ; renvoie le résultat quand le marqueur complet est arrivé. */
|
|
36
|
+
push(chunk) {
|
|
37
|
+
this.buffer += chunk;
|
|
38
|
+
const at = this.buffer.indexOf(this.marker);
|
|
39
|
+
if (at === -1) {
|
|
40
|
+
const keep = Math.min(this.buffer.length, this.marker.length - 1);
|
|
41
|
+
this.emit(this.buffer.length - keep);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
this.emit(at);
|
|
45
|
+
const lineEnd = this.buffer.indexOf("\n", this.marker.length);
|
|
46
|
+
if (lineEnd === -1)
|
|
47
|
+
return null;
|
|
48
|
+
const line = this.buffer.slice(this.marker.length, lineEnd);
|
|
49
|
+
this.buffer = this.buffer.slice(lineEnd + 1);
|
|
50
|
+
const match = /^(\d+)_(.*)__$/.exec(line);
|
|
51
|
+
if (!match)
|
|
52
|
+
throw new GateError(`Unreadable end marker from the shell: "${line}".`);
|
|
53
|
+
return { exitCode: Number(match[1]), cwd: match[2] };
|
|
54
|
+
}
|
|
55
|
+
emit(length) {
|
|
56
|
+
if (length <= 0)
|
|
57
|
+
return;
|
|
58
|
+
this.onOutput(this.buffer.slice(0, length));
|
|
59
|
+
this.buffer = this.buffer.slice(length);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function connectionErrorMessage(err, target) {
|
|
63
|
+
if (err?.level === "client-authentication") {
|
|
64
|
+
return `Authentification refusée pour ${target.username}@${target.host} : la clé publique n'est pas dans ~/.ssh/authorized_keys de cet utilisateur.`;
|
|
65
|
+
}
|
|
66
|
+
if (err?.level === "client-timeout")
|
|
67
|
+
return `Le serveur ${target.host}:${target.port} n'a pas répondu dans les ${CONNECT_TIMEOUT_MS / 1000} s.`;
|
|
68
|
+
return `Connexion SSH impossible à ${target.host}:${target.port} : ${err?.message ?? err}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Connexion SSH et shell bash persistant, sans terminal : les commandes
|
|
72
|
+
* s'enchaînent dans le même shell, une à la fois.
|
|
73
|
+
*/
|
|
74
|
+
export class ShellSession {
|
|
75
|
+
client;
|
|
76
|
+
channel;
|
|
77
|
+
shellPid;
|
|
78
|
+
cwd;
|
|
79
|
+
fingerprint;
|
|
80
|
+
onClose;
|
|
81
|
+
stdoutDecoder = new StringDecoder("utf8");
|
|
82
|
+
stderrDecoder = new StringDecoder("utf8");
|
|
83
|
+
current = null;
|
|
84
|
+
closedReason = null;
|
|
85
|
+
constructor(client, channel, shellPid, cwd, fingerprint, onClose) {
|
|
86
|
+
this.client = client;
|
|
87
|
+
this.channel = channel;
|
|
88
|
+
this.shellPid = shellPid;
|
|
89
|
+
this.cwd = cwd;
|
|
90
|
+
this.fingerprint = fingerprint;
|
|
91
|
+
this.onClose = onClose;
|
|
92
|
+
channel.on("data", (chunk) => this.receive(this.stdoutDecoder.write(chunk)));
|
|
93
|
+
channel.stderr.on("data", (chunk) => this.receive(this.stderrDecoder.write(chunk)));
|
|
94
|
+
channel.on("close", () => this.handleClose("Le shell s'est fermé."));
|
|
95
|
+
client.on("error", (err) => this.handleClose(`Connexion SSH perdue : ${err.message}`));
|
|
96
|
+
client.on("close", () => this.handleClose("Connexion SSH fermée."));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Ouvre la connexion et le shell. Avec une empreinte attendue, une clé d'hôte
|
|
100
|
+
* différente fait échouer la connexion ; sans, l'empreinte vue est renvoyée.
|
|
101
|
+
*/
|
|
102
|
+
static open(target, onClose) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const client = new ssh2.Client();
|
|
105
|
+
let seenFingerprint = null;
|
|
106
|
+
let settled = false;
|
|
107
|
+
const fail = (message) => {
|
|
108
|
+
if (settled)
|
|
109
|
+
return;
|
|
110
|
+
settled = true;
|
|
111
|
+
client.end();
|
|
112
|
+
reject(new GateError(message));
|
|
113
|
+
};
|
|
114
|
+
client.on("ready", () => {
|
|
115
|
+
client.exec("bash -l", (err, channel) => {
|
|
116
|
+
if (err)
|
|
117
|
+
return fail(`Impossible d'ouvrir un shell sur ${target.host} : ${err.message}`);
|
|
118
|
+
const decoder = new StringDecoder("utf8");
|
|
119
|
+
let buffer = "";
|
|
120
|
+
const timer = setTimeout(() => fail(`Le shell de ${target.host} n'a pas répondu dans les ${CONNECT_TIMEOUT_MS / 1000} s : bash est-il installé ?`), CONNECT_TIMEOUT_MS);
|
|
121
|
+
const onData = (chunk) => {
|
|
122
|
+
buffer += decoder.write(chunk);
|
|
123
|
+
const match = READY_MARKER.exec(buffer);
|
|
124
|
+
if (!match || settled)
|
|
125
|
+
return;
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
channel.off("data", onData);
|
|
128
|
+
settled = true;
|
|
129
|
+
resolve(new ShellSession(client, channel, Number(match[1]), match[2], seenFingerprint, onClose));
|
|
130
|
+
};
|
|
131
|
+
channel.on("data", onData);
|
|
132
|
+
channel.on("close", () => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
fail(`Le shell de ${target.host} s'est fermé au démarrage.`);
|
|
135
|
+
});
|
|
136
|
+
channel.write(`printf '__SSHGATE_READY_%s_%s__\\n' "$$" "$PWD"\n`);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
client.on("error", (err) => {
|
|
140
|
+
if (target.expectedFingerprint && seenFingerprint && seenFingerprint !== target.expectedFingerprint) {
|
|
141
|
+
return fail(`La clé d'hôte de ${target.host} a changé (${seenFingerprint}, attendue ${target.expectedFingerprint}). Connexion refusée : vérifie le serveur, puis modifie-le dans l'interface pour enregistrer la nouvelle empreinte.`);
|
|
142
|
+
}
|
|
143
|
+
fail(connectionErrorMessage(err, target));
|
|
144
|
+
});
|
|
145
|
+
client.connect({
|
|
146
|
+
host: target.host,
|
|
147
|
+
port: target.port,
|
|
148
|
+
username: target.username,
|
|
149
|
+
privateKey: target.privateKey,
|
|
150
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
151
|
+
keepaliveInterval: 30_000,
|
|
152
|
+
hostVerifier: (key) => {
|
|
153
|
+
seenFingerprint = fingerprintOf(key);
|
|
154
|
+
return target.expectedFingerprint === null || seenFingerprint === target.expectedFingerprint;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
get closed() {
|
|
160
|
+
return this.closedReason !== null;
|
|
161
|
+
}
|
|
162
|
+
/** Exécute une commande dans le shell ; `onOutput` reçoit la sortie au fil de l'eau. */
|
|
163
|
+
run(command, onOutput) {
|
|
164
|
+
if (this.closedReason)
|
|
165
|
+
return Promise.reject(new GateError(this.closedReason));
|
|
166
|
+
if (this.current)
|
|
167
|
+
return Promise.reject(new GateError("Une commande est déjà en cours dans ce shell."));
|
|
168
|
+
const token = randomBytes(8).toString("hex");
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
this.current = { parser: new CommandOutputParser(token, onOutput), resolve, reject };
|
|
171
|
+
this.channel.write(wrapCommand(command, token));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** Vérifie la syntaxe avec `bash -n`, sans rien exécuter ; renvoie le message d'erreur, ou null. */
|
|
175
|
+
checkSyntax(command) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
this.client.exec("bash -n", (err, channel) => {
|
|
178
|
+
if (err)
|
|
179
|
+
return reject(new GateError(`Vérification de syntaxe impossible : ${err.message}`));
|
|
180
|
+
let output = "";
|
|
181
|
+
channel.on("data", (chunk) => (output += chunk.toString("utf8")));
|
|
182
|
+
channel.stderr.on("data", (chunk) => (output += chunk.toString("utf8")));
|
|
183
|
+
channel.on("close", (code) => resolve(code === 0 ? null : output.trim() || `bash -n a renvoyé le code ${code}.`));
|
|
184
|
+
channel.end(`${command}\n`);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Envoie SIGINT aux processus lancés par le shell, comme un Ctrl+C. Échoue
|
|
190
|
+
* quand aucun processus n'a reçu le signal : une boucle exécutée par le shell
|
|
191
|
+
* lui-même n'a pas de processus fils à interrompre.
|
|
192
|
+
*/
|
|
193
|
+
interrupt() {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
this.client.exec(`pkill -INT -P ${this.shellPid}`, (err, channel) => {
|
|
196
|
+
if (err)
|
|
197
|
+
return reject(new GateError(`Interruption impossible : ${err.message}`));
|
|
198
|
+
let output = "";
|
|
199
|
+
channel.on("data", (chunk) => (output += chunk.toString("utf8")));
|
|
200
|
+
channel.stderr.on("data", (chunk) => (output += chunk.toString("utf8")));
|
|
201
|
+
channel.on("close", (code) => {
|
|
202
|
+
if (code === 0)
|
|
203
|
+
return resolve();
|
|
204
|
+
if (code === 1)
|
|
205
|
+
return reject(new GateError("Aucun processus à interrompre : la commande s'exécute dans le shell lui-même. Ferme le shell pour l'arrêter."));
|
|
206
|
+
reject(new GateError(`Interruption impossible : pkill a renvoyé le code ${code}${output.trim() ? ` (${output.trim()})` : ""}.`));
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
close() {
|
|
212
|
+
this.handleClose("Déconnecté.");
|
|
213
|
+
}
|
|
214
|
+
receive(text) {
|
|
215
|
+
const current = this.current;
|
|
216
|
+
if (!current || !text)
|
|
217
|
+
return;
|
|
218
|
+
let result;
|
|
219
|
+
try {
|
|
220
|
+
result = current.parser.push(text);
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
this.current = null;
|
|
224
|
+
current.reject(err);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!result)
|
|
228
|
+
return;
|
|
229
|
+
this.current = null;
|
|
230
|
+
this.cwd = result.cwd;
|
|
231
|
+
current.resolve(result);
|
|
232
|
+
}
|
|
233
|
+
handleClose(reason) {
|
|
234
|
+
if (this.closedReason)
|
|
235
|
+
return;
|
|
236
|
+
this.closedReason = reason;
|
|
237
|
+
const current = this.current;
|
|
238
|
+
this.current = null;
|
|
239
|
+
current?.reject(new GateError(reason));
|
|
240
|
+
this.client.end();
|
|
241
|
+
this.onClose(reason);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Nouvelle clé de coffre : 32 octets aléatoires, en base64. */
|
|
2
|
+
export declare function generateVaultKey(): string;
|
|
3
|
+
/** Décode VAULT_KEY ; lève une erreur qui dit comment en obtenir une valide. */
|
|
4
|
+
export declare function parseVaultKey(raw: string | undefined): Buffer;
|
|
5
|
+
/** Chiffre en AES-256-GCM : `v1:<iv>:<tag>:<chiffré>`, chaque partie en base64. */
|
|
6
|
+
export declare function encryptSecret(plaintext: string, key: Buffer): string;
|
|
7
|
+
/** Déchiffre un secret produit par `encryptSecret` ; échoue si la clé n'est pas la bonne. */
|
|
8
|
+
export declare function decryptSecret(payload: string, key: Buffer): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import { GateError } from "./errors.js";
|
|
3
|
+
const KEY_BYTES = 32;
|
|
4
|
+
const IV_BYTES = 12;
|
|
5
|
+
const FORMAT_VERSION = "v1";
|
|
6
|
+
/** Nouvelle clé de coffre : 32 octets aléatoires, en base64. */
|
|
7
|
+
export function generateVaultKey() {
|
|
8
|
+
return randomBytes(KEY_BYTES).toString("base64");
|
|
9
|
+
}
|
|
10
|
+
/** Décode VAULT_KEY ; lève une erreur qui dit comment en obtenir une valide. */
|
|
11
|
+
export function parseVaultKey(raw) {
|
|
12
|
+
const value = raw?.trim();
|
|
13
|
+
if (!value) {
|
|
14
|
+
throw new GateError("VAULT_KEY is not set. It encrypts the SSH private keys: generate one with `npx -y @imenam/mcp-ssh-gate --generate-vault-key` and set it as a secret variable of the MCP.");
|
|
15
|
+
}
|
|
16
|
+
const key = Buffer.from(value, "base64");
|
|
17
|
+
if (key.length !== KEY_BYTES || key.toString("base64") !== value) {
|
|
18
|
+
throw new GateError(`VAULT_KEY is not a valid key: expected ${KEY_BYTES} bytes encoded in base64. Generate one with \`npx -y @imenam/mcp-ssh-gate --generate-vault-key\`.`);
|
|
19
|
+
}
|
|
20
|
+
return key;
|
|
21
|
+
}
|
|
22
|
+
/** Chiffre en AES-256-GCM : `v1:<iv>:<tag>:<chiffré>`, chaque partie en base64. */
|
|
23
|
+
export function encryptSecret(plaintext, key) {
|
|
24
|
+
const iv = randomBytes(IV_BYTES);
|
|
25
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
26
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
27
|
+
const tag = cipher.getAuthTag();
|
|
28
|
+
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), encrypted.toString("base64")].join(":");
|
|
29
|
+
}
|
|
30
|
+
/** Déchiffre un secret produit par `encryptSecret` ; échoue si la clé n'est pas la bonne. */
|
|
31
|
+
export function decryptSecret(payload, key) {
|
|
32
|
+
const parts = payload.split(":");
|
|
33
|
+
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
34
|
+
throw new GateError("Encrypted value has an unknown format.");
|
|
35
|
+
}
|
|
36
|
+
const [, iv, tag, encrypted] = parts;
|
|
37
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
|
|
38
|
+
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
|
39
|
+
try {
|
|
40
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, "base64")), decipher.final()]).toString("utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new GateError("VAULT_KEY does not decrypt this value: it is not the key that was used to encrypt it.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|