All files / src/services ssh.js

6.41% Statements 5/78
0% Branches 0/21
0% Functions 0/21
7.35% Lines 5/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215                                1x 1x 1x     1x                                                                                                                                                                                                                                                                                                                                                                                               1x  
/**
 * Pool de connexions SSH persistantes.
 *
 * Architecture :
 *   - Une connexion SSH est maintenue par paire (userId, serverId)
 *   - Reconnexion automatique en cas de perte de connexion
 *   - Keepalive toutes les 30 secondes pour maintenir la connexion ouverte
 *
 * Sécurité :
 *   - Les clés SSH sont déchiffrées en mémoire uniquement lors de la connexion
 *   - Les connexions sont fermées à la suppression d'un serveur
 *   - Timeout sur chaque commande (30s par défaut) pour éviter les blocages
 *
 * @module services/ssh
 */
 
const { Client } = require('ssh2');
const { decrypt } = require('./crypto');
const { pool: db } = require('../db');
 
// Map de connexions actives : `${userId}:${serverId}` → { client, ready }
const connections = new Map();
 
/**
 * Retourne (ou crée) une connexion SSH pour ce serveur.
 * La connexion est mise en cache et réutilisée pour les requêtes suivantes.
 *
 * @param {string} userId — ID de l'utilisateur authentifié
 * @param {string} serverId — ID du serveur cible
 * @returns {Promise<import('ssh2').Client>}
 * @throws {Error} Si le serveur est introuvable ou la connexion échoue
 */
async function getConnection(userId, serverId) {
  const key = `${userId}:${serverId}`;
  const existing = connections.get(key);
  if (existing?.ready) return existing.client;
 
  // Charger les infos du serveur + la clé SSH depuis la base
  // En mode groupe, le serveur appartient au owner — on cherche aussi par owner
  const result = await db.query(
    `SELECT s.host, s.port, s.ssh_user,
            k.encrypted_private_key, k.passphrase_encrypted
     FROM servers s
     LEFT JOIN ssh_keys k ON k.id = s.ssh_key_id
     WHERE s.id = $1 AND (s.user_id = $2 OR s.id IN (
       SELECT ags.server_id FROM access_group_servers ags
       JOIN access_group_members agm ON agm.group_id = ags.group_id
       WHERE agm.user_id = $2 AND agm.status = 'active'
     ))`,
    [serverId, userId],
  );
 
  if (result.rows.length === 0) {
    throw new Error('Serveur introuvable.');
  }
 
  const server = result.rows[0];
 
  // Déchiffrer la clé privée (en mémoire uniquement, jamais stockée en clair)
  const privateKey = decrypt(server.encrypted_private_key);
  const passphrase = server.passphrase_encrypted
    ? decrypt(server.passphrase_encrypted)
    : undefined;
 
  return new Promise((resolve, reject) => {
    const client = new Client();
 
    client.on('ready', () => {
      const entry = connections.get(key);
      if (entry) entry.ready = true;
      resolve(client);
    });
 
    client.on('error', (err) => {
      const entry = connections.get(key);
      if (entry) entry.ready = false;
      console.error(`[ssh] Erreur connexion ${key} :`, err.message);
    });
 
    client.on('close', () => {
      const entry = connections.get(key);
      if (entry) entry.ready = false;
      console.log(`[ssh] Connexion fermée ${key}`);
    });
 
    connections.set(key, { client, ready: false });
 
    client.connect({
      host: server.host,
      port: server.port,
      username: server.ssh_user,
      privateKey,
      passphrase,
      readyTimeout: 15000,       // 15s max pour la connexion
      keepaliveInterval: 30000,  // Keepalive toutes les 30s
      keepaliveCountMax: 5,      // 5 keepalive ratés → déconnexion
    });
  });
}
 
/**
 * Exécute une commande sur le serveur distant et retourne le résultat.
 *
 * @param {string} userId — ID de l'utilisateur
 * @param {string} serverId — ID du serveur
 * @param {string} command — Commande bash à exécuter
 * @param {number} [timeout=30000] — Timeout en ms
 * @returns {Promise<{ stdout: string, stderr: string, code: number }>}
 * @throws {Error} En cas de timeout ou d'erreur SSH
 */
async function exec(userId, serverId, command, timeout = 30000) {
  const client = await getConnection(userId, serverId);
 
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('Timeout SSH exec')), timeout);
 
    client.exec(command, (err, stream) => {
      if (err) {
        clearTimeout(timer);
        return reject(err);
      }
 
      let stdout = '';
      let stderr = '';
 
      stream.on('data', (data) => { stdout += data.toString(); });
      stream.stderr.on('data', (data) => { stderr += data.toString(); });
      stream.on('close', (code) => {
        clearTimeout(timer);
        resolve({ stdout, stderr, code });
      });
    });
  });
}
 
/**
 * Ferme et supprime la connexion SSH d'un serveur.
 * Appelé lors de la suppression ou modification d'un serveur.
 *
 * @param {string} userId
 * @param {string} serverId
 */
function closeConnection(userId, serverId) {
  const key = `${userId}:${serverId}`;
  const entry = connections.get(key);
  if (entry) {
    try { entry.client.end(); } catch { /* Ignorer les erreurs de fermeture */ }
    connections.delete(key);
  }
}
 
/**
 * Teste la connectivité SSH sans utiliser le pool de connexions.
 * Utilisé à la création d'un serveur pour vérifier que les paramètres sont corrects.
 *
 * @param {{ host: string, port: number, ssh_user: string, privateKey: string, passphrase?: string }} params
 * @returns {Promise<void>} — Résout si OK, rejette sinon
 */
function testConnection({ host, port, ssh_user, privateKey, passphrase }) {
  return new Promise((resolve, reject) => {
    const client = new Client();
    const timer = setTimeout(() => {
      client.destroy();
      reject(new Error('Timeout de connexion SSH'));
    }, 15000);
 
    client.on('ready', () => {
      clearTimeout(timer);
      client.end();
      resolve();
    });
 
    client.on('error', (err) => {
      clearTimeout(timer);
      reject(err);
    });
 
    client.connect({
      host,
      port,
      username: ssh_user,
      privateKey,
      passphrase,
      readyTimeout: 10000,
    });
  });
}
 
/**
 * Ouvre un shell interactif sur le serveur distant.
 * Retourne le stream SSH (stdin/stdout bidirectionnel).
 *
 * @param {string} userId
 * @param {string} serverId
 * @param {{ cols: number, rows: number }} [pty] — Taille du terminal
 * @returns {Promise<import('ssh2').ClientChannel>}
 */
async function shell(userId, serverId, pty) {
  if (!pty) pty = { cols: 80, rows: 24 };
  const client = await getConnection(userId, serverId);
 
  return new Promise((resolve, reject) => {
    client.shell({
      term: 'xterm-256color',
      cols: pty.cols,
      rows: pty.rows,
    }, (err, stream) => {
      if (err) return reject(err);
      resolve(stream);
    });
  });
}
 
module.exports = { getConnection, exec, closeConnection, testConnection, shell };