From d3c6172ab4bed21f1e4f4cc3d353265f83606128 Mon Sep 17 00:00:00 2001 From: acacio90 <49293808+acacio90@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:14:48 -0300 Subject: [PATCH 1/3] feat(sessions): importa hosts e grupos do Zabbix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arquivo → Importar → Hosts do Zabbix: conecta na API (6.0+, token ou usuário/senha), lista os grupos e importa os hosts dos escolhidos; cada grupo vira pasta e cada host uma sessão, com o hostid guardado em zabbixHostId. Credencial cifrada pelo safeStorage. --- CHANGELOG.md | 6 ++ README.md | 27 +++++- desktop/app.js | 118 ++++++++++++++++++++++++ desktop/index.html | 29 ++++++ desktop/preload.cjs | 4 + desktop/styles.css | 23 +++++ src/desktop/main.ts | 5 + src/desktop/menu.ts | 3 +- src/desktop/zabbix.ts | 126 ++++++++++++++++++++++++++ src/sessions/import-zabbix.ts | 110 ++++++++++++++++++++++ src/sessions/model.ts | 7 ++ src/sessions/store.ts | 3 +- src/zabbix/client.ts | 166 ++++++++++++++++++++++++++++++++++ test-ui/zabbix.spec.ts | 73 +++++++++++++++ test/import-zabbix.test.ts | 82 +++++++++++++++++ test/zabbix-client.test.ts | 89 ++++++++++++++++++ test/zabbix-fake.ts | 96 ++++++++++++++++++++ 17 files changed, 964 insertions(+), 3 deletions(-) create mode 100644 src/desktop/zabbix.ts create mode 100644 src/sessions/import-zabbix.ts create mode 100644 src/zabbix/client.ts create mode 100644 test-ui/zabbix.spec.ts create mode 100644 test/import-zabbix.test.ts create mode 100644 test/zabbix-client.test.ts create mode 100644 test/zabbix-fake.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 50fcb7f..602345d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Todas as mudanças relevantes do MultTerm. O formato segue o ## [Não lançado] +### Adicionado + +- **Arquivo → Importar → Hosts do Zabbix…**: conecta no Zabbix (6.0 ou mais novo) por API token + ou usuário e senha, lista os grupos e importa os hosts dos escolhidos; cada grupo vira uma pasta + e cada host uma sessão (Windows vira RDP). A conexão fica lembrada, com a credencial cifrada. (#103) + ### Alterado - Importação de inventário (INI/YAML): as variáveis agora têm os nomes dos campos da sessão diff --git a/README.md b/README.md index 5384857..70461ae 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ As sessões ficam num arquivo YAML legível e versionável, **sem limite de quan - **Terminal SSH embutido em abas** (xterm.js + node-pty), usando o OpenSSH do sistema - Login por chave via agente SSH (ssh-agent do Windows, KeePassXC) - **RDP numa aba** (cliente FreeRDP embutido), ou no cliente do Windows (`mstsc`) pelo menu -- Importação de sessões de outro arquivo YAML ou de um **inventário** (INI/YAML) +- Importação de sessões de outro arquivo YAML, de um **inventário** (INI/YAML) ou dos hosts e + grupos do **Zabbix** - Exportação das sessões (todas ou uma pasta), dos sites de VPN ou de um **backup completo** para levar tudo a outra máquina - **Ferramentas de rede**: ping, traceroute, consulta DNS, consulta SNMP (v1/v2c/v3), scanner de @@ -306,6 +307,30 @@ com as sessões existentes (mesma pasta + nome não é sobrescrito): `identityFile` e `type=ssh|rdp`) preenchem a sessão; as do host valem sobre as do grupo. Faixas como `web[01:03]` são expandidas. +### Importar do Zabbix + +**Arquivo → Importar → Hosts do Zabbix…** busca os hosts de um Zabbix 6.0 ou mais novo pela API: + +1. Informe o endereço (`https://zabbix.exemplo.com`; o `/api_jsonrpc.php` é completado) e um + **API token** (Usuários → API tokens) ou usuário e senha. Para certificado autoassinado, marque + **Aceitar certificado autoassinado**. +2. **Conectar e listar grupos** mostra os grupos com a quantidade de hosts; marque os que quer. +3. **Importar**: cada grupo vira uma pasta dentro da pasta de destino (padrão `Zabbix`; um `/` no + nome do grupo cria subpastas) e cada host uma sessão. Um host em vários grupos marcados entra em + cada pasta. Sessões que já existem (mesma pasta + nome) são mantidas. + +Cada host vira uma sessão assim: + +- **Endereço**: interface principal do agente (IP ou DNS, conforme configurado no host); sem + agente, a interface SNMP. Hosts sem nenhuma das duas ficam de fora, com aviso. +- **Tipo**: a tag `multterm.type` (`ssh` ou `rdp`); sem ela, Windows no inventário (`os`) ou num + template vira RDP, o resto SSH. +- **Usuário e porta**: tags opcionais `multterm.user` e `multterm.port`. +- **MAC** (Wake-on-LAN): campo `macaddress_a` do inventário. + +O endereço, os grupos e a pasta ficam lembrados; o token e a senha ficam cifrados pelo Windows +e não precisam ser digitados de novo. A sessão guarda o `zabbixHostId` do host de origem. + ### Login sem senha (agente SSH) 1. PowerShell como administrador: diff --git a/desktop/app.js b/desktop/app.js index 27452c7..66ada40 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -280,6 +280,9 @@ async function save(event) { event.preventDefault(); clearMessages(); const session = readForm(); + // Campos que o formulário não mostra (origem no Zabbix) seguem com a sessão editada. + const edited = state.sessions.find((s) => keyOf(s) === state.editing); + if (edited?.zabbixHostId) session.zabbixHostId = edited.zabbixHostId; const others = state.sessions.filter((s) => keyOf(s) !== state.editing); try { apply(await api.save([...others, session])); @@ -1384,6 +1387,120 @@ async function importSessions() { } } +/** + * Importar hosts do Zabbix: conecta na API, lista os grupos (com a quantidade de hosts) e importa + * os escolhidos. Token e senha em branco usam os guardados; eles nunca voltam do processo principal. + */ +async function importZabbix() { + const dialog = $('zabbix-dialog'); + const zform = $('zabbix-form'); + const box = $('zabbix-groups-box'); + const list = $('zabbix-groups'); + const error = $('zabbix-error'); + const importBtn = $('zabbix-import'); + let config; + try { + config = await api.zabbixConfig(); + } catch (err) { + showEditor(); + clearMessages(); + message(cleanError(err), 'error'); + return; + } + zform.url.value = config.url; + zform.authType.value = config.authType; + zform.user.value = config.user; + zform.token.value = ''; + zform.password.value = ''; + zform.token.placeholder = config.hasToken ? 'guardado (em branco = manter)' : ''; + zform.password.placeholder = config.hasPassword ? 'guardada (em branco = manter)' : ''; + zform.insecure.checked = config.insecure; + zform.rootFolder.value = config.rootFolder; + $('zabbix-filter').value = ''; + list.replaceChildren(); + box.hidden = true; + importBtn.disabled = true; + error.textContent = ''; + + const updateAuth = () => { + const password = zform.authType.value === 'password'; + for (const el of zform.querySelectorAll('.zabbix-token')) el.hidden = password; + for (const el of zform.querySelectorAll('.zabbix-password')) el.hidden = !password; + }; + const updateImport = () => { + importBtn.disabled = !list.querySelector('input:checked'); + }; + updateAuth(); + + const connectZabbix = async () => { + error.textContent = ''; + $('zabbix-connect').disabled = true; + try { + const { version, groups } = await api.zabbixConnect({ + url: zform.url.value, + authType: zform.authType.value, + token: zform.token.value, + user: zform.user.value, + password: zform.password.value, + insecure: zform.insecure.checked, + }); + const chosen = new Set(config.groupIds); + list.replaceChildren(...groups.map((g) => { + const label = document.createElement('label'); + const check = Object.assign(document.createElement('input'), { type: 'checkbox', value: g.groupid, checked: chosen.has(g.groupid) }); + const count = Object.assign(document.createElement('span'), { className: 'count', textContent: `(${g.hosts})` }); + label.append(check, ` ${g.name} `, count); + label.dataset.name = g.name.toLowerCase(); + return label; + })); + $('zabbix-version').textContent = `Zabbix ${version}: ${groups.length} grupo(s) com hosts. Um host em vários grupos marcados entra em cada pasta.`; + box.hidden = false; + updateImport(); + } catch (err) { + error.textContent = cleanError(err); + } finally { + $('zabbix-connect').disabled = false; + } + }; + + const done = () => { + zform.onsubmit = null; + zform.authType.onchange = null; + list.onchange = null; + $('zabbix-filter').oninput = null; + $('zabbix-connect').onclick = null; + $('zabbix-cancel').onclick = null; + dialog.close(); + }; + zform.authType.onchange = updateAuth; + list.onchange = updateImport; + $('zabbix-filter').oninput = (e) => { + const q = e.target.value.trim().toLowerCase(); + for (const label of list.children) label.hidden = !!q && !label.dataset.name.includes(q); + }; + $('zabbix-connect').onclick = connectZabbix; + $('zabbix-cancel').onclick = done; + zform.onsubmit = async (e) => { + e.preventDefault(); + error.textContent = ''; + importBtn.disabled = true; + try { + const groupIds = [...list.querySelectorAll('input:checked')].map((el) => el.value); + const result = await api.zabbixImport({ groupIds, rootFolder: zform.rootFolder.value }); + done(); + showEditor(); + clearMessages(); + apply(result); + message(`${result.added} sessões importadas do Zabbix (${result.hosts} hosts), ${result.skipped} já existiam.`); + for (const w of result.warnings) message(w, 'warn'); + } catch (err) { + error.textContent = cleanError(err); + updateImport(); + } + }; + dialog.showModal(); +} + /** Troca o arquivo de sessões em uso (ex.: um sessions.yaml versionado no git). */ async function chooseSessionsFile() { showEditor(); @@ -1962,6 +2079,7 @@ const MENU_COMMANDS = { reload: () => load(), 'default-key': editDefaultKey, 'import-sessions': importSessions, + 'import-zabbix': importZabbix, 'import-vpn': importVpnSite, 'import-backup': importBackup, 'export-sessions': () => exportSessions(), diff --git a/desktop/index.html b/desktop/index.html index eeff247..31648e3 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -128,6 +128,35 @@

Chave SSH padrão

+ +
+

Importar hosts do Zabbix

+ + + + + + +
+ +

+
+ + +
+
+
+

Confirmar

diff --git a/desktop/preload.cjs b/desktop/preload.cjs index 1744fd3..367a5b0 100644 --- a/desktop/preload.cjs +++ b/desktop/preload.cjs @@ -17,6 +17,10 @@ contextBridge.exposeInMainWorld('multterm', { chooseKeyFile: (current) => ipcRenderer.invoke('sessions:choose-key', current), setDefaults: (defaults) => ipcRenderer.invoke('sessions:set-defaults', defaults), importSessions: () => ipcRenderer.invoke('sessions:import'), + // Importar hosts do Zabbix (token e senha ficam no processo principal). + zabbixConfig: () => ipcRenderer.invoke('zabbix:config'), + zabbixConnect: (req) => ipcRenderer.invoke('zabbix:connect', req), + zabbixImport: (req) => ipcRenderer.invoke('zabbix:import', req), exportSessions: (folder) => ipcRenderer.invoke('sessions:export', folder), exportBackup: () => ipcRenderer.invoke('backup:export'), importBackup: () => ipcRenderer.invoke('backup:import'), diff --git a/desktop/styles.css b/desktop/styles.css index 8c9fea3..50d1e70 100644 --- a/desktop/styles.css +++ b/desktop/styles.css @@ -416,3 +416,26 @@ dialog#name-dialog::backdrop { background: rgb(0 0 0 / 45%); } /* Caixa de seleção no formulário de sessão (ex.: TLS antigo nas sessões RDP). */ #form label.check { flex-direction: row; align-items: center; gap: 6px; } #form label.check input { width: auto; } + +/* ---------- importar do Zabbix ---------- */ + +dialog#zabbix-dialog { + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + color: var(--text); +} +dialog#zabbix-dialog::backdrop { background: rgb(0 0 0 / 45%); } +#zabbix-form { width: 440px; } +#zabbix-form label[hidden] { display: none; } +#zabbix-form label.check { flex-direction: row; align-items: center; gap: 6px; } +#zabbix-form label.check input { width: auto; } +#zabbix-form .actions { margin-top: 0; } +.zabbix-groups-box { display: flex; flex-direction: column; gap: 8px; } +.zabbix-groups-box[hidden] { display: none; } +.zabbix-groups-box .muted { margin: 0; font-size: 12px; color: var(--muted); } +.zabbix-groups { max-height: 240px; overflow: auto; border: 1px solid var(--border); border-radius: 4px; padding: 4px 8px; } +.zabbix-groups label { flex-direction: row; align-items: center; gap: 6px; color: var(--text); padding: 2px 0; } +.zabbix-groups label input { width: auto; } +.zabbix-groups label .count { color: var(--muted); font-size: 12px; } diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 1022c55..67f75dc 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -25,6 +25,7 @@ import { exportVpnSites, importVpnSites, registerVpnIpc } from './vpn.ts'; import { exportServerConfigs, importServerConfigs, registerServersIpc } from './servers.ts'; import { agentLaunch, exportAgentPrefs, importAgentPrefs, registerAgentIpc } from './agent.ts'; import { closeRdpOf, registerRdpIpc } from './rdp.ts'; +import { registerZabbixIpc } from './zabbix.ts'; import { buildAppMenu } from './menu.ts'; import { BACKUP_EXTENSION, backupFileName, parseBackup, serializeBackup } from './backup.ts'; import { localShellCommand, type LocalShell } from './local-terminal.ts'; @@ -500,6 +501,10 @@ if (!vpnHelper) app.whenReady().then(async () => { registerAgentIpc(); registerDetachIpc(); registerRdpIpc(); + registerZabbixIpc(async (imported) => { + const { merged, added, skipped } = mergeSessions(await loadSessionFile(sessionsPath), imported); + return { ...(await persist(merged)), added: added.length, skipped: skipped.length }; + }); Menu.setApplicationMenu(buildAppMenu({ // Os comandos agem na janela principal (abre de novo se só restaram janelas destacadas). send: async (command) => { diff --git a/src/desktop/menu.ts b/src/desktop/menu.ts index a2d8d3d..980f366 100644 --- a/src/desktop/menu.ts +++ b/src/desktop/menu.ts @@ -8,7 +8,7 @@ import { app, Menu, type MenuItemConstructorOptions } from 'electron'; */ export type MenuCommand = | 'new-session' | 'new-folder' | 'open-file' | 'reload' | 'default-key' - | 'import-sessions' | 'import-vpn' | 'import-backup' + | 'import-sessions' | 'import-zabbix' | 'import-vpn' | 'import-backup' | 'export-sessions' | 'export-vpn' | 'export-backup' | 'connect' | 'external' | 'wol' | 'edit' | 'delete' | 'local-powershell' | 'local-cmd' | 'local-wsl' @@ -42,6 +42,7 @@ export function buildAppMenu({ send, openProject, about }: MenuActions): Menu { label: 'Importar', submenu: [ cmd('import-sessions', 'Sessões (YAML ou inventário INI)…'), + cmd('import-zabbix', 'Hosts do Zabbix…'), cmd('import-vpn', 'Site de VPN (.vpn)…'), sep, cmd('import-backup', 'Backup completo…'), diff --git a/src/desktop/zabbix.ts b/src/desktop/zabbix.ts new file mode 100644 index 0000000..b1e0c98 --- /dev/null +++ b/src/desktop/zabbix.ts @@ -0,0 +1,126 @@ +import { app, ipcMain, safeStorage } from 'electron'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { zabbixToSessions } from '../sessions/import-zabbix.ts'; +import { normalizeFolder, type Session } from '../sessions/model.ts'; +import { apiUrl, ZabbixClient, ZabbixError, type ZabbixAuth } from '../zabbix/client.ts'; + +/* + * Arquivo → Importar → Hosts do Zabbix: conecta na API, lista os grupos e importa os hosts dos + * grupos escolhidos. Endereço, grupos e pasta ficam lembrados em zabbix.json; o token e a senha + * ficam cifrados pelo Windows (safeStorage) e nunca voltam para a interface. + */ + +type AuthType = 'token' | 'password'; + +interface Config { + url?: string; + authType?: AuthType; + user?: string; + tokenSealed?: string; + passwordSealed?: string; + insecure?: boolean; + rootFolder?: string; + groupIds?: string[]; +} + +const DEFAULT_ROOT = 'Zabbix'; +const configFile = () => join(app.getPath('userData'), 'zabbix.json'); + +async function loadConfig(): Promise { + try { + return JSON.parse(await readFile(configFile(), 'utf8')) as Config; + } catch { + return {}; + } +} + +async function saveConfig(config: Config): Promise { + await mkdir(app.getPath('userData'), { recursive: true }); + await writeFile(configFile(), JSON.stringify(config, null, 2)); +} + +const seal = (text: string) => { + if (!safeStorage.isEncryptionAvailable()) throw new ZabbixError('o Windows não liberou a cifra para guardar a credencial'); + return safeStorage.encryptString(text).toString('base64'); +}; +const unseal = (b64: string | undefined) => { + if (!b64) return ''; + try { + return safeStorage.decryptString(Buffer.from(b64, 'base64')); + } catch { + return ''; + } +}; + +function authOf(config: Config): ZabbixAuth { + if (config.authType === 'password') { + const password = unseal(config.passwordSealed); + if (!config.user || !password) throw new ZabbixError('informe usuário e senha do Zabbix'); + return { user: config.user, password }; + } + const token = unseal(config.tokenSealed); + if (!token) throw new ZabbixError('informe o API token do Zabbix'); + return { token }; +} + +const text = (v: unknown) => (typeof v === 'string' ? v.trim() : ''); + +/** Adiciona as sessões importadas ao arquivo e devolve o resultado para a interface. */ +export type AddSessions = (sessions: Session[]) => Promise>; + +export function registerZabbixIpc(addSessions: AddSessions): void { + ipcMain.handle('zabbix:config', async () => { + const c = await loadConfig(); + return { + url: c.url ?? '', + authType: c.authType ?? 'token', + user: c.user ?? '', + insecure: c.insecure === true, + rootFolder: c.rootFolder ?? DEFAULT_ROOT, + groupIds: c.groupIds ?? [], + hasToken: !!c.tokenSealed, + hasPassword: !!c.passwordSealed, + }; + }); + + // Conecta (token/senha em branco = o guardado), lembra a conexão e devolve os grupos. + ipcMain.handle('zabbix:connect', async (_e, raw: Record) => { + const config = await loadConfig(); + const url = apiUrl(text(raw?.url)).href; + const authType: AuthType = raw?.authType === 'password' ? 'password' : 'token'; + const next: Config = { ...config, url, authType, insecure: raw?.insecure === true }; + if (authType === 'password') { + next.user = text(raw?.user); + if (typeof raw?.password === 'string' && raw.password) next.passwordSealed = seal(raw.password); + } else if (text(raw?.token)) { + next.tokenSealed = seal(text(raw?.token)); + } + const client = await ZabbixClient.connect({ url, auth: authOf(next), insecure: next.insecure }); + try { + const groups = await client.groups(); + await saveConfig(next); + return { version: client.version, groups }; + } finally { + await client.close(); + } + }); + + ipcMain.handle('zabbix:import', async (_e, raw: Record) => { + const config = await loadConfig(); + if (!config.url) throw new ZabbixError('conecte ao Zabbix primeiro'); + const groupIds = Array.isArray(raw?.groupIds) ? raw.groupIds.filter((id): id is string => typeof id === 'string' && /^\d+$/.test(id)) : []; + if (!groupIds.length) throw new ZabbixError('escolha ao menos um grupo'); + const rootFolder = normalizeFolder(text(raw?.rootFolder)); + const client = await ZabbixClient.connect({ url: config.url, auth: authOf(config), insecure: config.insecure }); + let hosts; + try { + hosts = await client.hosts(groupIds); + } finally { + await client.close(); + } + await saveConfig({ ...config, groupIds, rootFolder }); + const { sessions, warnings } = zabbixToSessions(hosts, groupIds, rootFolder); + return { ...(await addSessions(sessions)), hosts: hosts.length, warnings }; + }); +} diff --git a/src/sessions/import-zabbix.ts b/src/sessions/import-zabbix.ts new file mode 100644 index 0000000..3bdfdef --- /dev/null +++ b/src/sessions/import-zabbix.ts @@ -0,0 +1,110 @@ +import { normalizeFolder, normalizeMac, SessionError, sessionKey, validateSession, type Session } from './model.ts'; +import type { ImportResult } from './import-inventory.ts'; + +/* + * Hosts do Zabbix (resposta do host.get) → sessões. + * - Cada grupo escolhido vira uma pasta ("/" no nome do grupo cria subpastas); um host em vários + * grupos escolhidos aparece em cada um. + * - Endereço: interface principal do agente (IP ou DNS, conforme "useip"); sem agente, a SNMP. + * - Tipo: tag "multterm.type" (ssh|rdp); senão Windows no inventário ou nos templates vira RDP. + * - Tags opcionais "multterm.user" e "multterm.port"; MAC do inventário (macaddress_a). + */ + +export interface ZabbixGroup { + groupid: string; + name: string; +} + +export interface ZabbixInterface { + type: string; + main: string; + useip: string; + ip: string; + dns: string; +} + +export interface ZabbixHost { + hostid: string; + host: string; + name?: string; + interfaces?: ZabbixInterface[]; + /** O Zabbix devolve uma lista vazia quando o host não tem inventário. */ + inventory?: { os?: string; macaddress_a?: string } | unknown[]; + tags?: { tag: string; value: string }[]; + /** selectHostGroups (6.2+) ou selectGroups (6.0). */ + hostgroups?: ZabbixGroup[]; + groups?: ZabbixGroup[]; + parentTemplates?: { name: string }[]; +} + +const AGENT = '1'; +const SNMP = '2'; + +/** Endereço da interface principal do agente; sem agente, da SNMP. */ +export function hostAddress(host: ZabbixHost): string | undefined { + const interfaces = host.interfaces ?? []; + const main = (type: string) => interfaces.find((i) => i.type === type && i.main === '1'); + const iface = main(AGENT) ?? main(SNMP); + if (!iface) return undefined; + const preferred = iface.useip === '1' ? iface.ip : iface.dns; + return (preferred || iface.ip || iface.dns || '').trim() || undefined; +} + +function tag(host: ZabbixHost, name: string): string | undefined { + return host.tags?.find((t) => t.tag.toLowerCase() === name)?.value.trim() || undefined; +} + +function inventory(host: ZabbixHost): { os?: string; macaddress_a?: string } { + return host.inventory && !Array.isArray(host.inventory) ? host.inventory : {}; +} + +function sessionType(host: ZabbixHost): string { + const explicit = tag(host, 'multterm.type'); + if (explicit) return explicit.toLowerCase(); + const windows = /windows/i.test(inventory(host).os ?? '') || (host.parentTemplates ?? []).some((t) => /windows/i.test(t.name)); + return windows ? 'rdp' : 'ssh'; +} + +export function zabbixToSessions(hosts: ZabbixHost[], groupIds: Iterable, rootFolder = ''): ImportResult { + const chosen = new Set(groupIds); + const sessions: Session[] = []; + const warnings: string[] = []; + const seen = new Set(); + + for (const host of hosts) { + const label = host.name || host.host; + const address = hostAddress(host); + if (!address) { + warnings.push(`${label}: sem interface do agente ou SNMP, ignorado`); + continue; + } + const groups = (host.hostgroups ?? host.groups ?? []).filter((g) => chosen.has(g.groupid)); + const type = sessionType(host); + const mac = normalizeMac(inventory(host).macaddress_a ?? ''); + for (const group of groups) { + const raw: Record = { + name: label.replace(/\//g, '-'), + folder: normalizeFolder([rootFolder, group.name].filter(Boolean).join('/')), + type, + host: address, + port: tag(host, 'multterm.port'), + user: tag(host, 'multterm.user'), + mac, + zabbixHostId: host.hostid, + }; + let session: Session; + try { + session = validateSession(raw, `${group.name}/${label}`); + } catch (err) { + if (!(err instanceof SessionError)) throw err; + warnings.push(`${err.message}, ignorado`); + continue; + } + const key = sessionKey(session); + if (seen.has(key)) continue; + seen.add(key); + sessions.push(session); + } + } + return { sessions, warnings }; +} diff --git a/src/sessions/model.ts b/src/sessions/model.ts index 4ee5ded..bea0516 100644 --- a/src/sessions/model.ts +++ b/src/sessions/model.ts @@ -26,6 +26,8 @@ export interface Session { /** SSH: aceita algoritmos antigos (troca de chaves SHA-1, ssh-rsa, CBC). Escolha explícita por sessão. */ legacySsh?: boolean; tags?: string[]; + /** hostid do host no Zabbix de onde a sessão foi importada. */ + zabbixHostId?: string; } /** Aceita aa:bb:cc:dd:ee:ff, AA-BB-…, aabb.ccdd.eeff (Cisco) ou 12 dígitos hex; devolve "aa:bb:cc:dd:ee:ff". */ @@ -129,6 +131,10 @@ export function validateSession(raw: unknown, where: string): Session { if (macText !== undefined && !mac) throw new SessionError(`${where}: "mac" inválido (${macText})`); const vpn = optionalString(obj, 'vpn', where)?.trim() || undefined; if (vpn !== undefined && !isSafeSiteName(vpn)) throw new SessionError(`${where}: "vpn" inválido (${vpn})`); + const zabbixHostId = obj.zabbixHostId === undefined || obj.zabbixHostId === null ? undefined : String(obj.zabbixHostId); + if (zabbixHostId !== undefined && !/^\d+$/.test(zabbixHostId)) { + throw new SessionError(`${where}: "zabbixHostId" inválido (${zabbixHostId})`); + } for (const flag of ['legacyTls', 'legacySsh', 'multimon']) { if (obj[flag] !== undefined && obj[flag] !== null && typeof obj[flag] !== 'boolean') { throw new SessionError(`${where}: "${flag}" deve ser true ou false`); @@ -144,5 +150,6 @@ export function validateSession(raw: unknown, where: string): Session { if (obj.legacySsh === true && session.type === 'ssh') session.legacySsh = true; if (obj.multimon === true && session.type === 'rdp') session.multimon = true; if (tags !== undefined) session.tags = tags; + if (zabbixHostId !== undefined) session.zabbixHostId = zabbixHostId; return session; } diff --git a/src/sessions/store.ts b/src/sessions/store.ts index 395818a..793144d 100644 --- a/src/sessions/store.ts +++ b/src/sessions/store.ts @@ -5,7 +5,7 @@ import { DEFAULT_PORTS, normalizeFolder, SessionError, sessionKey, validateSessi export const FILE_VERSION = 1; const HEADER = `# Sessões do MultTerm — arquivo versionável, sem limite de sessões. -# Campos: name, folder ("Pasta/Subpasta"), type (ssh|rdp), host, port, user, identityFile, mac, vpn, legacyTls, legacySsh, multimon, tags +# Campos: name, folder ("Pasta/Subpasta"), type (ssh|rdp), host, port, user, identityFile, mac, vpn, legacyTls, legacySsh, multimon, tags, zabbixHostId # defaults.identityFile: chave das sessões SSH sem identityFile própria `; @@ -91,6 +91,7 @@ export function serializeSessions(sessions: Session[], defaults: SessionDefaults if (s.legacySsh) out.legacySsh = true; if (s.multimon) out.multimon = true; if (s.tags?.length) out.tags = s.tags; + if (s.zabbixHostId) out.zabbixHostId = s.zabbixHostId; return out; }); const doc: Record = { version: FILE_VERSION }; diff --git a/src/zabbix/client.ts b/src/zabbix/client.ts new file mode 100644 index 0000000..230fd15 --- /dev/null +++ b/src/zabbix/client.ts @@ -0,0 +1,166 @@ +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import type { ZabbixGroup, ZabbixHost } from '../sessions/import-zabbix.ts'; + +/* + * Cliente da API JSON-RPC do Zabbix (6.0 em diante). A versão do servidor decide os parâmetros: + * - 6.4+: credencial no cabeçalho "Authorization: Bearer"; antes, no campo "auth" do pedido. + * - 6.2+: hostgroup.get com "with_hosts" e host.get com "selectHostGroups"; antes, "real_hosts" e "selectGroups". + */ + +export type ZabbixAuth = { token: string } | { user: string; password: string }; + +export interface ZabbixConnection { + url: string; + auth: ZabbixAuth; + /** Aceita certificado autoassinado ou de outra autoridade (HTTPS). */ + insecure?: boolean; + timeoutMs?: number; +} + +export interface ZabbixGroupInfo extends ZabbixGroup { + hosts: number; +} + +export class ZabbixError extends Error { + override name = 'ZabbixError'; +} + +const MAX_RESPONSE = 64 * 1024 * 1024; + +/** Aceita "https://zabbix", ".../zabbix/" ou o endereço completo do api_jsonrpc.php. */ +export function apiUrl(text: string): URL { + let url: URL; + try { + url = new URL(text.trim()); + } catch { + throw new ZabbixError(`endereço inválido: ${text}`); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new ZabbixError('o endereço deve começar com http:// ou https://'); + if (!url.pathname.endsWith('/api_jsonrpc.php')) url.pathname = `${url.pathname.replace(/\/+$/, '')}/api_jsonrpc.php`; + url.search = ''; + url.hash = ''; + return url; +} + +/** "7.0.5" → [7, 0]. */ +export function parseVersion(version: string): [number, number] { + const [major, minor] = version.split('.').map(Number); + return [major || 0, minor || 0]; +} + +function atLeast(version: string, major: number, minor: number): boolean { + const [a, b] = parseVersion(version); + return a > major || (a === major && b >= minor); +} + +function post(conn: ZabbixConnection, body: unknown, bearer?: string): Promise { + const url = apiUrl(conn.url); + const data = Buffer.from(JSON.stringify(body)); + const headers: Record = { 'Content-Type': 'application/json-rpc', 'Content-Length': data.length }; + if (bearer) headers.Authorization = `Bearer ${bearer}`; + const request = url.protocol === 'https:' ? httpsRequest : httpRequest; + return new Promise((resolve, reject) => { + const req = request(url, { method: 'POST', headers, timeout: conn.timeoutMs ?? 30_000, rejectUnauthorized: !conn.insecure }, (res) => { + const chunks: Buffer[] = []; + let size = 0; + res.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_RESPONSE) req.destroy(new ZabbixError('resposta grande demais')); + else chunks.push(chunk); + }); + res.on('end', () => { + if (res.statusCode !== 200) return reject(new ZabbixError(`o servidor respondeu HTTP ${res.statusCode}`)); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new ZabbixError('a resposta não é da API do Zabbix (confira o endereço)')); + } + }); + res.on('error', reject); + }); + req.on('timeout', () => req.destroy(new ZabbixError('o servidor não respondeu a tempo'))); + req.on('error', (err: NodeJS.ErrnoException) => { + if (err instanceof ZabbixError) return reject(err); + const tls = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/.test(err.code ?? ''); + reject(new ZabbixError(tls ? `certificado não confiável (${err.code}); marque "aceitar certificado" se o servidor for seu` : err.message)); + }); + req.end(data); + }); +} + +let nextId = 1; + +async function call(conn: ZabbixConnection, method: string, params: unknown, credential?: { value: string; bearer: boolean }): Promise { + const body: Record = { jsonrpc: '2.0', method, params, id: nextId++ }; + if (credential && !credential.bearer) body.auth = credential.value; + const res = await post(conn, body, credential?.bearer ? credential.value : undefined) as { result?: T; error?: { message?: string; data?: string } }; + if (res?.error) throw new ZabbixError([res.error.message, res.error.data].filter(Boolean).join(' ')); + if (!res || !('result' in res)) throw new ZabbixError('a resposta não é da API do Zabbix (confira o endereço)'); + return res.result as T; +} + +export class ZabbixClient { + readonly version: string; + private readonly conn: ZabbixConnection; + private readonly credential: { value: string; bearer: boolean }; + private readonly loggedIn: boolean; + + private constructor(conn: ZabbixConnection, version: string, credential: { value: string; bearer: boolean }, loggedIn: boolean) { + this.conn = conn; + this.version = version; + this.credential = credential; + this.loggedIn = loggedIn; + } + + /** Descobre a versão e autentica (token ou usuário/senha). */ + static async connect(conn: ZabbixConnection): Promise { + const version = await call(conn, 'apiinfo.version', []); + if (!atLeast(version, 6, 0)) throw new ZabbixError(`Zabbix ${version} não é suportado (precisa ser 6.0 ou mais novo)`); + const bearer = atLeast(version, 6, 4); + if ('token' in conn.auth) { + const client = new ZabbixClient(conn, version, { value: conn.auth.token, bearer }, false); + await client.groups(); // confere o token já na conexão + return client; + } + const session = await call(conn, 'user.login', { username: conn.auth.user, password: conn.auth.password }); + return new ZabbixClient(conn, version, { value: session, bearer }, true); + } + + private call(method: string, params: unknown): Promise { + return call(this.conn, method, params, this.credential); + } + + /** Grupos com hosts, em ordem alfabética, com a quantidade de hosts de cada um. */ + async groups(): Promise { + const filter = atLeast(this.version, 6, 2) ? 'with_hosts' : 'real_hosts'; + const groups = await this.call<(ZabbixGroup & { hosts?: string })[]>('hostgroup.get', { + output: ['groupid', 'name'], + selectHosts: 'count', + [filter]: true, + sortfield: 'name', + }); + return groups.map((g) => ({ groupid: g.groupid, name: g.name, hosts: Number(g.hosts ?? 0) })); + } + + /** Hosts dos grupos, com interfaces, inventário, tags, grupos e templates. */ + hosts(groupIds: string[]): Promise { + if (!groupIds.length) return Promise.resolve([]); + const selectGroups = atLeast(this.version, 6, 2) ? 'selectHostGroups' : 'selectGroups'; + return this.call('host.get', { + output: ['hostid', 'host', 'name'], + groupids: groupIds, + selectInterfaces: ['type', 'main', 'useip', 'ip', 'dns'], + selectInventory: ['os', 'macaddress_a'], + selectTags: ['tag', 'value'], + [selectGroups]: ['groupid', 'name'], + selectParentTemplates: ['name'], + }); + } + + /** Encerra a sessão aberta por usuário/senha (token não precisa). */ + async close(): Promise { + if (!this.loggedIn) return; + await this.call('user.logout', []).catch(() => undefined); + } +} diff --git a/test-ui/zabbix.spec.ts b/test-ui/zabbix.spec.ts new file mode 100644 index 0000000..0a65b6a --- /dev/null +++ b/test-ui/zabbix.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { clickMenu, launchApp, type LaunchedApp } from './fixture.ts'; +import { startFakeZabbix, type FakeZabbix } from '../test/zabbix-fake.ts'; + +const LINUX = { groupid: '2', name: 'Linux servers' }; +const WIN = { groupid: '11', name: 'Clientes/ACME' }; +const agent = (ip: string) => [{ type: '1', main: '1', useip: '1', ip, dns: '' }]; + +let ctx: LaunchedApp; +let zabbix: FakeZabbix; + +test.beforeEach(async () => { + zabbix = await startFakeZabbix({ + version: '7.0.5', + token: 'token-123', + groups: [LINUX, WIN], + hosts: [ + { hostid: '101', host: 'web01', interfaces: agent('10.0.0.1'), groups: [LINUX] }, + { hostid: '102', host: 'ad01', interfaces: agent('10.0.0.2'), inventory: { os: 'Windows Server 2022' }, groups: [WIN] }, + { hostid: '103', host: 'db01', interfaces: agent('10.0.0.3'), groups: [LINUX, WIN] }, + ], + }); + ctx = await launchApp('version: 1\nsessions: []\n'); +}); + +test.afterEach(async () => { + await ctx?.close(); + await zabbix?.close(); +}); + +test('importa hosts do Zabbix: conecta com token, escolhe o grupo e monta a árvore', async () => { + const { app, page, sessionsFile } = ctx; + const dialog = page.locator('#zabbix-dialog'); + await clickMenu(app, 'import-zabbix'); + await expect(dialog).toBeVisible(); + + // Token errado: erro no próprio diálogo. + await dialog.locator('input[name="url"]').fill(zabbix.url); + await dialog.locator('input[name="token"]').fill('errado'); + await page.locator('#zabbix-connect').click(); + await expect(page.locator('#zabbix-error')).toContainText('Not authorised'); + + await dialog.locator('input[name="token"]').fill('token-123'); + await page.locator('#zabbix-connect').click(); + await expect(page.locator('#zabbix-version')).toContainText('Zabbix 7.0.5: 2 grupo(s)'); + await expect(page.locator('#zabbix-groups label')).toHaveText(['Linux servers (2)', 'Clientes/ACME (2)']); + await expect(page.locator('#zabbix-import')).toBeDisabled(); + + await page.locator('#zabbix-groups label', { hasText: 'Clientes/ACME' }).locator('input').check(); + await page.locator('#zabbix-import').click(); + await expect(dialog).toBeHidden(); + await expect(page.locator('#messages')).toContainText('2 sessões importadas do Zabbix (2 hosts), 0 já existiam.'); + await expect(page.locator('#status-count')).toHaveText('2 sessões · sem limite'); + + const saved = await readFile(sessionsFile, 'utf8'); + expect(saved).toContain('folder: Zabbix/Clientes/ACME'); + expect(saved).toMatch(/name: ad01\n\s+folder: Zabbix\/Clientes\/ACME\n\s+type: rdp/); + expect(saved).toContain('zabbixHostId: "103"'); + + // O token fica cifrado: não aparece em texto puro no zabbix.json. + const config = await readFile(join(ctx.dir, 'userdata', 'zabbix.json'), 'utf8'); + expect(config).not.toContain('token-123'); + + // Reabrir lembra a conexão: token em branco usa o guardado e o grupo escolhido vem marcado. + await clickMenu(app, 'import-zabbix'); + await expect(dialog.locator('input[name="token"]')).toHaveAttribute('placeholder', /guardado/); + await page.locator('#zabbix-connect').click(); + await expect(page.locator('#zabbix-groups label', { hasText: 'Clientes/ACME' }).locator('input')).toBeChecked(); + await page.locator('#zabbix-import').click(); + await expect(page.locator('#messages')).toContainText('0 sessões importadas do Zabbix (2 hosts), 2 já existiam.'); +}); diff --git a/test/import-zabbix.test.ts b/test/import-zabbix.test.ts new file mode 100644 index 0000000..f45c739 --- /dev/null +++ b/test/import-zabbix.test.ts @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hostAddress, zabbixToSessions, type ZabbixHost } from '../src/sessions/import-zabbix.ts'; +import { parseSessions, serializeSessions } from '../src/sessions/store.ts'; + +const LINUX = { groupid: '2', name: 'Linux servers' }; +const PROD = { groupid: '10', name: 'Clientes/ACME/Produção' }; +const WIN = { groupid: '11', name: 'Windows' }; + +const agent = (ip: string, dns = '', useip = '1') => ({ type: '1', main: '1', useip, ip, dns }); + +const HOSTS: ZabbixHost[] = [ + { + hostid: '10501', host: 'web01', name: 'Web 01', interfaces: [agent('10.0.0.1')], + inventory: { os: 'Ubuntu 24.04', macaddress_a: 'AA-BB-CC-DD-EE-01' }, + tags: [{ tag: 'multterm.user', value: 'deploy' }, { tag: 'multterm.port', value: '2222' }], + hostgroups: [LINUX, PROD], + }, + { + hostid: '10502', host: 'ad01', name: 'AD/01', interfaces: [agent('', 'ad01.acme.local', '0')], + inventory: [], hostgroups: [WIN], parentTemplates: [{ name: 'Windows by Zabbix agent' }], + }, + { + hostid: '10503', host: 'sw01', interfaces: [{ type: '2', main: '1', useip: '1', ip: '10.0.9.1', dns: '' }], + tags: [{ tag: 'multterm.type', value: 'SSH' }], groups: [PROD], + }, + { hostid: '10504', host: 'sem-interface', interfaces: [], hostgroups: [LINUX] }, + { + hostid: '10505', host: 'tipo-ruim', interfaces: [agent('10.0.0.5')], + tags: [{ tag: 'multterm.type', value: 'telnet' }], hostgroups: [LINUX], + }, +]; + +test('endereço: agente principal (IP ou DNS conforme useip); sem agente, SNMP', () => { + assert.equal(hostAddress(HOSTS[0]), '10.0.0.1'); + assert.equal(hostAddress(HOSTS[1]), 'ad01.acme.local'); + assert.equal(hostAddress(HOSTS[2]), '10.0.9.1'); + assert.equal(hostAddress(HOSTS[3]), undefined); + // useip=1 sem IP cai para o DNS + assert.equal(hostAddress({ hostid: '1', host: 'x', interfaces: [agent('', 'x.local')] }), 'x.local'); +}); + +test('cada grupo escolhido vira pasta; host em vários grupos entra em cada um', () => { + const { sessions } = zabbixToSessions(HOSTS, ['2', '10', '11'], 'Zabbix'); + const keys = sessions.map((s) => `${s.folder}/${s.name}`).sort(); + assert.deepEqual(keys, [ + 'Zabbix/Clientes/ACME/Produção/Web 01', + 'Zabbix/Clientes/ACME/Produção/sw01', + 'Zabbix/Linux servers/Web 01', + 'Zabbix/Windows/AD-01', + ]); +}); + +test('só os grupos escolhidos entram', () => { + const { sessions } = zabbixToSessions(HOSTS, ['11'], ''); + assert.deepEqual(sessions.map((s) => `${s.folder}/${s.name}`), ['Windows/AD-01']); +}); + +test('campos: tags multterm.*, MAC do inventário, Windows vira RDP e hostid guardado', () => { + const { sessions } = zabbixToSessions(HOSTS, ['2', '10', '11'], 'Zabbix'); + assert.deepEqual(sessions.find((s) => s.name === 'Web 01' && s.folder === 'Zabbix/Linux servers'), { + name: 'Web 01', folder: 'Zabbix/Linux servers', type: 'ssh', host: '10.0.0.1', port: 2222, user: 'deploy', + mac: 'aa:bb:cc:dd:ee:01', zabbixHostId: '10501', + }); + assert.equal(sessions.find((s) => s.name === 'AD-01')?.type, 'rdp'); + assert.equal(sessions.find((s) => s.name === 'sw01')?.type, 'ssh'); +}); + +test('host sem interface ou com tipo inválido gera aviso e fica de fora', () => { + const { sessions, warnings } = zabbixToSessions(HOSTS, ['2'], 'Zabbix'); + assert.equal(sessions.some((s) => s.zabbixHostId === '10504' || s.zabbixHostId === '10505'), false); + assert.ok(warnings.some((w) => /sem-interface: sem interface/.test(w))); + assert.ok(warnings.some((w) => /tipo-ruim.*"type" deve ser/.test(w))); +}); + +test('zabbixHostId sobrevive à gravação e à leitura do arquivo', () => { + const { sessions } = zabbixToSessions(HOSTS, ['11'], 'Zabbix'); + const text = serializeSessions(sessions); + assert.match(text, /zabbixHostId: "10502"/); + assert.equal(parseSessions(text)[0].zabbixHostId, '10502'); + assert.throws(() => parseSessions('sessions:\n - { name: a, host: a, zabbixHostId: abc }'), /zabbixHostId/); +}); diff --git a/test/zabbix-client.test.ts b/test/zabbix-client.test.ts new file mode 100644 index 0000000..2524a65 --- /dev/null +++ b/test/zabbix-client.test.ts @@ -0,0 +1,89 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { apiUrl, ZabbixClient, ZabbixError } from '../src/zabbix/client.ts'; +import { startFakeZabbix, type FakeZabbixOptions } from './zabbix-fake.ts'; + +const LINUX = { groupid: '2', name: 'Linux servers' }; +const WIN = { groupid: '11', name: 'Windows' }; + +const base = (version: string): FakeZabbixOptions => ({ + version, + token: 'token-123', + user: 'Admin', + password: 'zabbix', + groups: [LINUX, WIN], + hosts: [ + { hostid: '1', host: 'web01', interfaces: [{ type: '1', main: '1', useip: '1', ip: '10.0.0.1', dns: '' }], groups: [LINUX] }, + { hostid: '2', host: 'ad01', interfaces: [{ type: '1', main: '1', useip: '1', ip: '10.0.0.2', dns: '' }], groups: [WIN] }, + ], +}); + +test('apiUrl completa o caminho do api_jsonrpc.php e recusa outros protocolos', () => { + assert.equal(apiUrl('https://zabbix.acme.com').href, 'https://zabbix.acme.com/api_jsonrpc.php'); + assert.equal(apiUrl('https://acme.com/zabbix/').href, 'https://acme.com/zabbix/api_jsonrpc.php'); + assert.equal(apiUrl('http://acme.com/zabbix/api_jsonrpc.php?x=1').href, 'http://acme.com/zabbix/api_jsonrpc.php'); + assert.throws(() => apiUrl('ftp://acme.com'), ZabbixError); + assert.throws(() => apiUrl('zabbix'), /endereço inválido/); +}); + +// O servidor falso só aceita a credencial no lugar certo para a versão ("auth" até 6.2, Bearer na 6.4+). +for (const [version, groupsKey, filter] of [ + ['6.0.30', 'selectGroups', 'real_hosts'], + ['7.0.5', 'selectHostGroups', 'with_hosts'], +] as const) { + test(`Zabbix ${version}: token, grupos e hosts com os parâmetros da versão`, async () => { + const fake = await startFakeZabbix(base(version)); + try { + const client = await ZabbixClient.connect({ url: fake.url, auth: { token: 'token-123' } }); + assert.equal(client.version, version); + assert.deepEqual(await client.groups(), [{ ...LINUX, hosts: 1 }, { ...WIN, hosts: 1 }]); + const hosts = await client.hosts(['11']); + assert.deepEqual(hosts.map((h) => h.host), ['ad01']); + assert.deepEqual(hosts[0].hostgroups ?? hosts[0].groups, [WIN]); + + const groupCall = fake.calls.find((c) => c.method === 'hostgroup.get')!; + assert.equal(groupCall.params[filter], true); + const hostCall = fake.calls.find((c) => c.method === 'host.get')!; + assert.ok(groupsKey in hostCall.params); + assert.ok(fake.calls.filter((c) => c.method !== 'apiinfo.version').every((c) => c.credential === 'token-123')); + await client.close(); + assert.equal(fake.calls.some((c) => c.method === 'user.logout'), false); + } finally { + await fake.close(); + } + }); +} + +test('usuário e senha: faz login, usa a sessão e sai no fim', async () => { + const fake = await startFakeZabbix(base('7.0.5')); + try { + const client = await ZabbixClient.connect({ url: fake.url, auth: { user: 'Admin', password: 'zabbix' } }); + await client.groups(); + await client.close(); + assert.deepEqual(fake.calls.map((c) => c.method), ['apiinfo.version', 'user.login', 'hostgroup.get', 'user.logout']); + assert.equal(fake.calls[2].credential, 'sessao-de-teste'); + } finally { + await fake.close(); + } +}); + +test('erros: token errado, senha errada, versão antiga e endereço que não é a API', async () => { + const fake = await startFakeZabbix(base('6.4.0')); + const old = await startFakeZabbix(base('5.0.40')); + try { + await assert.rejects(ZabbixClient.connect({ url: fake.url, auth: { token: 'errado' } }), /Not authorised/); + await assert.rejects(ZabbixClient.connect({ url: fake.url, auth: { user: 'Admin', password: 'x' } }), /Incorrect user name/); + await assert.rejects(ZabbixClient.connect({ url: old.url, auth: { token: 'token-123' } }), /5\.0\.40 não é suportado/); + await assert.rejects(ZabbixClient.connect({ url: fake.url.replace('/zabbix', '/outro'), auth: { token: 'token-123' } }), /HTTP 404/); + } finally { + await fake.close(); + await old.close(); + } +}); + +test('erro de conexão vira mensagem legível', async () => { + await assert.rejects( + ZabbixClient.connect({ url: 'http://127.0.0.1:1/zabbix', auth: { token: 'x' }, timeoutMs: 2000 }), + (err: Error) => err instanceof ZabbixError && /ECONNREFUSED/.test(err.message), + ); +}); diff --git a/test/zabbix-fake.ts b/test/zabbix-fake.ts new file mode 100644 index 0000000..2302631 --- /dev/null +++ b/test/zabbix-fake.ts @@ -0,0 +1,96 @@ +import { createServer, type IncomingMessage } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { ZabbixHost } from '../src/sessions/import-zabbix.ts'; + +/* + * Servidor JSON-RPC que imita a API do Zabbix nas versões 6.0 e 6.4+: credencial no campo + * "auth" (até 6.2) ou no cabeçalho Bearer (6.4+), real_hosts/with_hosts e selectGroups/selectHostGroups. + */ + +export interface FakeZabbixOptions { + version: string; + token?: string; + user?: string; + password?: string; + groups: { groupid: string; name: string }[]; + /** Hosts com "groups" (o servidor devolve no campo certo para a versão). */ + hosts: (Omit & { groups: { groupid: string; name: string }[] })[]; +} + +export interface FakeCall { + method: string; + params: Record; + credential?: string; +} + +export interface FakeZabbix { + url: string; + calls: FakeCall[]; + close(): Promise; +} + +const SESSION = 'sessao-de-teste'; + +function atLeast(version: string, minor: number): boolean { + const [a, b] = version.split('.').map(Number); + return a > 6 || (a === 6 && b >= minor); +} + +async function body(req: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +export async function startFakeZabbix(opts: FakeZabbixOptions): Promise { + const calls: FakeCall[] = []; + const bearerAuth = atLeast(opts.version, 4); + const newGroups = atLeast(opts.version, 2); + + const server = createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/zabbix/api_jsonrpc.php') { + res.writeHead(404).end('not found'); + return; + } + const rpc = await body(req); + const params = (rpc.params ?? {}) as Record; + const header = /^Bearer (.+)$/.exec(req.headers.authorization ?? '')?.[1]; + const credential = bearerAuth ? header : (rpc.auth as string | undefined); + calls.push({ method: String(rpc.method), params, credential }); + + const reply = (result: unknown) => res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', result, id: rpc.id })); + const fail = (message: string, data: string) => res.writeHead(200, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32602, message, data }, id: rpc.id })); + + if (rpc.method === 'apiinfo.version') return reply(opts.version); + if (rpc.method === 'user.login') { + return params.username === opts.user && params.password === opts.password + ? reply(SESSION) + : fail('Invalid params.', 'Incorrect user name or password or account is temporarily blocked.'); + } + if (!credential || (credential !== opts.token && credential !== SESSION)) return fail('Invalid params.', 'Not authorised.'); + if (rpc.method === 'user.logout') return reply(true); + if (rpc.method === 'hostgroup.get') { + return reply(opts.groups.map((g) => ({ + ...g, + hosts: String(opts.hosts.filter((h) => h.groups.some((hg) => hg.groupid === g.groupid)).length), + }))); + } + if (rpc.method === 'host.get') { + const ids = new Set(params.groupids as string[]); + const key = newGroups ? 'hostgroups' : 'groups'; + return reply(opts.hosts + .filter((h) => h.groups.some((g) => ids.has(g.groupid))) + .map(({ groups, ...h }) => ({ ...h, [key]: groups }))); + } + return fail('Method not found.', `Incorrect API "${String(rpc.method)}".`); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/zabbix`, + calls, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} From 8d95c8f08ec1ce5788218509dc07d28381bbe7dc Mon Sep 17 00:00:00 2001 From: acacio90 <49293808+acacio90@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:28:24 -0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(sessions):=20sincroniza=20as=20sess?= =?UTF-8?q?=C3=B5es=20com=20o=20Zabbix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A importação do Zabbix passa a sincronizar: hosts novos entram, removidos (ou de grupos desmarcados) saem e os existentes são atualizados pelo zabbixHostId, mantendo o que foi editado no MultTerm. Prévia antes de gravar e item "Sincronizar com o Zabbix" no menu. --- CHANGELOG.md | 6 ++- README.md | 26 ++++++++---- desktop/app.js | 62 +++++++++++++++++++++++------ desktop/index.html | 4 +- desktop/preload.cjs | 2 +- src/desktop/main.ts | 5 +-- src/desktop/menu.ts | 3 +- src/desktop/zabbix.ts | 49 ++++++++++++++++------- src/sessions/import-zabbix.ts | 67 ++++++++++++++++++++++++++++++- test-ui/zabbix.spec.ts | 74 +++++++++++++++++++++++------------ test/import-zabbix.test.ts | 52 +++++++++++++++++++++++- 11 files changed, 281 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 602345d..189386a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ Todas as mudanças relevantes do MultTerm. O formato segue o ### Adicionado - **Arquivo → Importar → Hosts do Zabbix…**: conecta no Zabbix (6.0 ou mais novo) por API token - ou usuário e senha, lista os grupos e importa os hosts dos escolhidos; cada grupo vira uma pasta - e cada host uma sessão (Windows vira RDP). A conexão fica lembrada, com a credencial cifrada. (#103) + ou usuário e senha, lista os grupos e monta a árvore com os hosts dos escolhidos; cada grupo + vira uma pasta e cada host uma sessão (Windows vira RDP). **Sincronizar com o Zabbix** mantém a + árvore em dia (hosts novos entram, removidos saem), com prévia antes de gravar e sem perder o que + foi editado no MultTerm. A conexão fica lembrada, com a credencial cifrada. (#103) ### Alterado diff --git a/README.md b/README.md index 70461ae..7f4ecaa 100644 --- a/README.md +++ b/README.md @@ -307,17 +307,30 @@ com as sessões existentes (mesma pasta + nome não é sobrescrito): `identityFile` e `type=ssh|rdp`) preenchem a sessão; as do host valem sobre as do grupo. Faixas como `web[01:03]` são expandidas. -### Importar do Zabbix +### Sincronizar com o Zabbix -**Arquivo → Importar → Hosts do Zabbix…** busca os hosts de um Zabbix 6.0 ou mais novo pela API: +**Arquivo → Importar → Hosts do Zabbix…** monta a árvore com os hosts de um Zabbix 6.0 ou mais +novo, pela API, e a mantém em dia: 1. Informe o endereço (`https://zabbix.exemplo.com`; o `/api_jsonrpc.php` é completado) e um **API token** (Usuários → API tokens) ou usuário e senha. Para certificado autoassinado, marque **Aceitar certificado autoassinado**. 2. **Conectar e listar grupos** mostra os grupos com a quantidade de hosts; marque os que quer. -3. **Importar**: cada grupo vira uma pasta dentro da pasta de destino (padrão `Zabbix`; um `/` no - nome do grupo cria subpastas) e cada host uma sessão. Um host em vários grupos marcados entra em - cada pasta. Sessões que já existem (mesma pasta + nome) são mantidas. +3. **Sincronizar…** mostra a prévia (sessões novas, atualizadas e removidas) e só grava se você + confirmar. Cada grupo vira uma pasta dentro da pasta de destino (padrão `Zabbix`; um `/` no + nome do grupo cria subpastas) e cada host uma sessão; um host em vários grupos marcados entra + em cada pasta. + +Depois, **Arquivo → Importar → Sincronizar com o Zabbix** repete com a conexão, os grupos e a +pasta guardados. A sincronização só mexe nas sessões que vieram do Zabbix (as que têm +`zabbixHostId`): + +- hosts novos entram; hosts removidos do Zabbix, ou de grupos desmarcados, saem; +- **endereço, nome e pasta** vêm sempre do Zabbix (uma sessão movida volta para a pasta do grupo); +- **usuário, porta e MAC** só mudam quando o Zabbix tem o valor (tags ou inventário); +- **tipo, chave, VPN, opções e tags** editados no MultTerm são mantidos; +- sessões criadas à mão nunca são alteradas; se uma tiver a mesma pasta e nome de um host, ela + fica e o host é avisado. Cada host vira uma sessão assim: @@ -328,8 +341,7 @@ Cada host vira uma sessão assim: - **Usuário e porta**: tags opcionais `multterm.user` e `multterm.port`. - **MAC** (Wake-on-LAN): campo `macaddress_a` do inventário. -O endereço, os grupos e a pasta ficam lembrados; o token e a senha ficam cifrados pelo Windows -e não precisam ser digitados de novo. A sessão guarda o `zabbixHostId` do host de origem. +O token e a senha ficam cifrados pelo Windows e não precisam ser digitados de novo. ### Login sem senha (agente SSH) diff --git a/desktop/app.js b/desktop/app.js index 66ada40..9ec6fe6 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -1388,8 +1388,8 @@ async function importSessions() { } /** - * Importar hosts do Zabbix: conecta na API, lista os grupos (com a quantidade de hosts) e importa - * os escolhidos. Token e senha em branco usam os guardados; eles nunca voltam do processo principal. + * Hosts do Zabbix: conecta na API, lista os grupos (com a quantidade de hosts) e sincroniza os + * escolhidos. Token e senha em branco usam os guardados; eles nunca voltam do processo principal. */ async function importZabbix() { const dialog = $('zabbix-dialog'); @@ -1453,7 +1453,7 @@ async function importZabbix() { label.dataset.name = g.name.toLowerCase(); return label; })); - $('zabbix-version').textContent = `Zabbix ${version}: ${groups.length} grupo(s) com hosts. Um host em vários grupos marcados entra em cada pasta.`; + $('zabbix-version').textContent = `Zabbix ${version}: ${groups.length} grupo(s) com hosts. Um host em vários grupos marcados entra em cada pasta; grupos desmarcados saem da árvore.`; box.hidden = false; updateImport(); } catch (err) { @@ -1484,23 +1484,62 @@ async function importZabbix() { e.preventDefault(); error.textContent = ''; importBtn.disabled = true; + const req = { + groupIds: [...list.querySelectorAll('input:checked')].map((el) => el.value), + rootFolder: zform.rootFolder.value, + }; + let preview; try { - const groupIds = [...list.querySelectorAll('input:checked')].map((el) => el.value); - const result = await api.zabbixImport({ groupIds, rootFolder: zform.rootFolder.value }); - done(); - showEditor(); - clearMessages(); - apply(result); - message(`${result.added} sessões importadas do Zabbix (${result.hosts} hosts), ${result.skipped} já existiam.`); - for (const w of result.warnings) message(w, 'warn'); + preview = await api.zabbixSync(req); } catch (err) { error.textContent = cleanError(err); updateImport(); + return; } + done(); + await confirmZabbixSync(req, preview); }; dialog.showModal(); } +/** Mostra a prévia (novas, atualizadas, removidas) e grava se o usuário confirmar. */ +async function confirmZabbixSync(req, preview) { + showEditor(); + clearMessages(); + const changes = preview.added + preview.updated + preview.removed.length; + if (changes) { + const ok = await askConfirm({ + title: 'Sincronizar com o Zabbix', + text: `${preview.hosts} hosts no Zabbix: ${preview.added} sessão(ões) nova(s), ${preview.updated} atualizada(s) e ${preview.removed.length} removida(s).`, + items: preview.removed.map((key) => `remover: ${key}`), + note: 'Endereço, nome e pasta vêm do Zabbix; tipo, chave, VPN e opções editados no MultTerm são mantidos. Sessões criadas à mão não são alteradas.', + confirmLabel: 'Sincronizar', + }); + if (!ok) return; + } + try { + const result = await api.zabbixSync({ ...req, apply: true }); + apply(result); + message(changes + ? `Zabbix sincronizado: ${result.added} nova(s), ${result.updated} atualizada(s), ${result.removed.length} removida(s).` + : 'Zabbix sincronizado: nada mudou.'); + for (const w of result.warnings) message(w, 'warn'); + } catch (err) { + message(cleanError(err), 'error'); + } +} + +/** Arquivo → Importar → Sincronizar com o Zabbix: usa a conexão, os grupos e a pasta guardados. */ +async function syncZabbix() { + showEditor(); + clearMessages(); + try { + await confirmZabbixSync({}, await api.zabbixSync({})); + } catch (err) { + message(cleanError(err), 'error'); + } +} + /** Troca o arquivo de sessões em uso (ex.: um sessions.yaml versionado no git). */ async function chooseSessionsFile() { showEditor(); @@ -2080,6 +2119,7 @@ const MENU_COMMANDS = { 'default-key': editDefaultKey, 'import-sessions': importSessions, 'import-zabbix': importZabbix, + 'zabbix-sync': syncZabbix, 'import-vpn': importVpnSite, 'import-backup': importBackup, 'export-sessions': () => exportSessions(), diff --git a/desktop/index.html b/desktop/index.html index 31648e3..81d51b8 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -130,7 +130,7 @@

Chave SSH padrão

-

Importar hosts do Zabbix

+

Hosts do Zabbix