- Updated the CallWindow component to include connection quality states and reconnection attempts, improving user experience during calls. - Enhanced the ChatWindow to allow starting audio and video calls in a new window, providing users with more flexibility. - Integrated accelerometer and gyroscope data collection in the RegistroPonto component, enabling validation of point registration authenticity. - Improved error handling and user feedback for sensor permissions and data validation, ensuring a smoother registration process. - Updated backend logic to validate sensor data and adjust confidence scores for point registration, enhancing security against spoofing.
433 lines
12 KiB
TypeScript
433 lines
12 KiB
TypeScript
"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}`,
|
|
};
|
|
}
|
|
},
|
|
});
|
|
|