"use node"; import { action } from "../_generated/server"; import { v } from "convex/values"; import { api, internal } from "../_generated/api"; import { Client } from "ssh2"; import { readFileSync } from "fs"; import { decryptSMTPPasswordNode } from "./utils/nodeCrypto"; /** * Interface para configuração SSH */ interface SSHConfig { host: string; port: number; username: string; password?: string; keyPath?: string; } /** * Executar comando via SSH */ async function executarComandoSSH( config: SSHConfig, comando: string ): Promise<{ sucesso: boolean; output: string; erro?: string }> { return new Promise((resolve) => { const conn = new Client(); let output = ""; let errorOutput = ""; conn.on("ready", () => { conn.exec(comando, (err, stream) => { if (err) { conn.end(); resolve({ sucesso: false, output: "", erro: err.message }); return; } stream .on("close", (code: number | null, signal: string | null) => { conn.end(); if (code === 0) { resolve({ sucesso: true, output: output.trim() }); } else { resolve({ sucesso: false, output: output.trim(), erro: `Comando retornou código ${code}${signal ? ` (signal: ${signal})` : ""}. ${errorOutput}`, }); } }) .on("data", (data: Buffer) => { output += data.toString(); }) .stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); }); }); }).on("error", (err) => { resolve({ sucesso: false, output: "", erro: err.message }); }).connect({ host: config.host, port: config.port, username: config.username, password: config.password, privateKey: config.keyPath ? readFileSync(config.keyPath) : undefined, readyTimeout: 10000, }); }); } /** * Ler arquivo via SSH */ async function lerArquivoSSH( config: SSHConfig, caminho: string ): Promise<{ sucesso: boolean; conteudo?: string; erro?: string }> { const comando = `cat "${caminho}" 2>&1`; const resultado = await executarComandoSSH(config, comando); if (!resultado.sucesso) { return { sucesso: false, erro: resultado.erro || "Erro ao ler arquivo" }; } return { sucesso: true, conteudo: resultado.output }; } /** * Escrever arquivo via SSH */ async function escreverArquivoSSH( config: SSHConfig, caminho: string, conteudo: string ): Promise<{ sucesso: boolean; erro?: string }> { // Escapar conteúdo para shell const conteudoEscapado = conteudo .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\$/g, "\\$") .replace(/`/g, "\\`"); const comando = `cat > "${caminho}" << 'JITSI_CONFIG_EOF' ${conteudo} JITSI_CONFIG_EOF`; const resultado = await executarComandoSSH(config, comando); if (!resultado.sucesso) { return { sucesso: false, erro: resultado.erro || "Erro ao escrever arquivo" }; } return { sucesso: true }; } /** * Aplicar configurações do Jitsi no servidor Docker via SSH */ export const aplicarConfiguracaoServidor = action({ args: { configId: v.id("configuracaoJitsi"), sshPassword: v.optional(v.string()), // Senha SSH (se não usar chave) }, returns: v.union( v.object({ sucesso: v.literal(true), mensagem: v.string(), detalhes: v.optional(v.string()), }), v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args): Promise< | { sucesso: true; mensagem: string; detalhes?: string } | { sucesso: false; erro: string } > => { try { // Buscar configuração const config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); if (!config || config._id !== args.configId) { return { sucesso: false as const, erro: "Configuração não encontrada" }; } // Verificar se tem configurações SSH const configFull = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiCompleta, { configId: args.configId, }); if (!configFull || !configFull.sshHost) { return { sucesso: false as const, erro: "Configurações SSH não estão definidas. Configure o servidor SSH primeiro.", }; } // Configurar SSH let sshPasswordDecrypted: string | undefined = undefined; // Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada if (args.sshPassword) { sshPasswordDecrypted = args.sshPassword; } else if (configFull.sshPasswordHash && configFull.sshPasswordHash !== "********") { // Tentar descriptografar senha armazenada try { sshPasswordDecrypted = await decryptSMTPPasswordNode(configFull.sshPasswordHash); } catch (error) { return { sucesso: false as const, erro: "Não foi possível descriptografar a senha SSH armazenada. Forneça a senha novamente.", }; } } const sshConfig: SSHConfig = { host: configFull.sshHost, port: configFull.sshPort || 22, username: configFull.sshUsername || "", password: sshPasswordDecrypted, keyPath: configFull.sshKeyPath || undefined, }; if (!sshConfig.username) { return { sucesso: false as const, erro: "Usuário SSH não configurado" }; } if (!sshConfig.password && !sshConfig.keyPath) { return { sucesso: false as const, erro: "Senha SSH ou caminho da chave deve ser fornecido", }; } const basePath = configFull.jitsiConfigPath || "~/.jitsi-meet-cfg"; const dockerComposePath = configFull.dockerComposePath || "."; // Extrair host e porta do domain const [host, portStr] = configFull.domain.split(":"); const port = portStr ? parseInt(portStr, 10) : configFull.useHttps ? 443 : 80; const protocol = configFull.useHttps ? "https" : "http"; const detalhes: string[] = []; // 1. Atualizar arquivo .env do docker-compose if (dockerComposePath) { const envContent = `# Configuração Jitsi - Atualizada automaticamente pelo SGSE CONFIG=${basePath} TZ=America/Recife ENABLE_LETSENCRYPT=0 HTTP_PORT=${protocol === "https" ? 8000 : port} HTTPS_PORT=${configFull.useHttps ? port : 8443} PUBLIC_URL=${protocol}://${host}${portStr ? `:${port}` : ""} DOMAIN=${host} ENABLE_AUTH=0 ENABLE_GUESTS=1 ENABLE_TRANSCRIPTION=0 ENABLE_RECORDING=0 ENABLE_PREJOIN_PAGE=0 START_AUDIO_MUTED=0 START_VIDEO_MUTED=0 ENABLE_XMPP_WEBSOCKET=0 ENABLE_P2P=1 MAX_NUMBER_OF_PARTICIPANTS=10 RESOLUTION_WIDTH=1280 RESOLUTION_HEIGHT=720 JWT_APP_ID=${configFull.appId} JWT_APP_SECRET= `; const envPath = `${dockerComposePath}/.env`; const resultadoEnv = await escreverArquivoSSH(sshConfig, envPath, envContent); if (!resultadoEnv.sucesso) { return { sucesso: false as const, erro: `Erro ao atualizar .env: ${resultadoEnv.erro}`, }; } detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`); } // 2. Atualizar configuração do Prosody (conforme documentação oficial) const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`; const prosodyContent = `-- Configuração Prosody para ${host} -- Gerada automaticamente pelo SGSE -- Baseado na documentação oficial do Jitsi Meet VirtualHost "${host}" authentication = "anonymous" modules_enabled = { "bosh"; "websocket"; "ping"; "speakerstats"; "turncredentials"; "presence"; "conference_duration"; "stats"; } c2s_require_encryption = false allow_anonymous_s2s = false bosh_max_inactivity = 60 bosh_max_polling = 5 bosh_max_stanzas = 5 Component "conference.${host}" "muc" storage = "memory" muc_room_locking = false muc_room_default_public_jids = true muc_room_cache_size = 1000 muc_log_presences = true Component "jitsi-videobridge.${host}" component_secret = "" Component "focus.${host}" component_secret = "" `; const resultadoProsody = await escreverArquivoSSH(sshConfig, prosodyConfigPath, prosodyContent); if (!resultadoProsody.sucesso) { return { sucesso: false as const, erro: `Erro ao atualizar Prosody: ${resultadoProsody.erro}`, }; } detalhes.push(`✓ Configuração Prosody atualizada: ${prosodyConfigPath}`); // 3. Atualizar configuração do Jicofo const jicofoConfigPath = `${basePath}/jicofo/sip-communicator.properties`; const jicofoContent = `# Configuração Jicofo # Gerada automaticamente pelo SGSE org.jitsi.jicofo.BRIDGE_MUC=JvbBrewery@internal.${host} org.jitsi.jicofo.jid=XMPP_USER@${host} org.jitsi.jicofo.BRIDGE_MUC_JID=MUC_BRIDGE_JID@internal.${host} org.jitsi.jicofo.app.ID=${configFull.appId} `; const resultadoJicofo = await escreverArquivoSSH(sshConfig, jicofoConfigPath, jicofoContent); if (!resultadoJicofo.sucesso) { return { sucesso: false as const, erro: `Erro ao atualizar Jicofo: ${resultadoJicofo.erro}`, }; } detalhes.push(`✓ Configuração Jicofo atualizada: ${jicofoConfigPath}`); // 4. Atualizar configuração do JVB const jvbConfigPath = `${basePath}/jvb/sip-communicator.properties`; const jvbContent = `# Configuração JVB (Jitsi Video Bridge) # Gerada automaticamente pelo SGSE org.jitsi.videobridge.AUTHORIZED_SOURCE_REGEXP=.*@${host}/.* org.jitsi.videobridge.xmpp.user.shard.HOSTNAME=${host} org.jitsi.videobridge.xmpp.user.shard.DOMAIN=auth.${host} org.jitsi.videobridge.xmpp.user.shard.USERNAME=jvb org.jitsi.videobridge.xmpp.user.shard.MUC_JIDS=JvbBrewery@internal.${host} `; const resultadoJvb = await escreverArquivoSSH(sshConfig, jvbConfigPath, jvbContent); if (!resultadoJvb.sucesso) { return { sucesso: false as const, erro: `Erro ao atualizar JVB: ${resultadoJvb.erro}`, }; } detalhes.push(`✓ Configuração JVB atualizada: ${jvbConfigPath}`); // 5. Reiniciar containers Docker if (dockerComposePath) { const resultadoRestart = await executarComandoSSH( sshConfig, `cd "${dockerComposePath}" && docker-compose restart 2>&1 || docker compose restart 2>&1` ); if (!resultadoRestart.sucesso) { return { sucesso: false as const, erro: `Erro ao reiniciar containers: ${resultadoRestart.erro}`, }; } detalhes.push(`✓ Containers Docker reiniciados`); } // Atualizar timestamp de configuração no servidor await ctx.runMutation(internal.configuracaoJitsi.marcarConfiguradoNoServidor, { configId: args.configId, }); return { sucesso: true as const, mensagem: "Configurações aplicadas com sucesso no servidor Jitsi", detalhes: detalhes.join("\n"), }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); return { sucesso: false as const, erro: `Erro ao aplicar configurações: ${errorMessage}`, }; } }, }); /** * Testar conexão SSH */ export const testarConexaoSSH = action({ args: { sshHost: v.string(), sshPort: v.optional(v.number()), sshUsername: v.string(), sshPassword: v.optional(v.string()), sshKeyPath: v.optional(v.string()), }, returns: v.union( v.object({ sucesso: v.literal(true), mensagem: v.string() }), v.object({ sucesso: v.literal(false), erro: v.string() }) ), handler: async (ctx, args): Promise< | { sucesso: true; mensagem: string } | { sucesso: false; erro: string } > => { try { if (!args.sshPassword && !args.sshKeyPath) { return { sucesso: false as const, erro: "Senha SSH ou caminho da chave deve ser fornecido", }; } const sshConfig: SSHConfig = { host: args.sshHost, port: args.sshPort || 22, username: args.sshUsername, password: args.sshPassword || undefined, keyPath: args.sshKeyPath || undefined, }; // Tentar executar um comando simples const resultado = await executarComandoSSH(sshConfig, "echo 'SSH_OK'"); if (resultado.sucesso && resultado.output.includes("SSH_OK")) { return { sucesso: true as const, mensagem: "Conexão SSH estabelecida com sucesso", }; } return { sucesso: false as const, erro: resultado.erro || "Falha ao estabelecer conexão SSH", }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); return { sucesso: false as const, erro: `Erro ao testar SSH: ${errorMessage}`, }; } }, });