diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index d247c23..86e55d7 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -28,4 +28,4 @@ jobs: with: persist-credentials: false - - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + - uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce6d0d..f3cd0d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ Todas as mudanças relevantes do MultTerm. O formato segue o ## [Não lançado] +## [0.10.0] - 2026-10-27 + +### 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 monta a árvore com os hosts dos escolhidos; cada grupo + vira uma pasta e cada host uma sessão (Windows vira RDP; hosts com endereço local, como + `127.0.0.1`, ficam de fora). **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 + +- Importação de inventário (INI/YAML): as variáveis agora têm os nomes dos campos da sessão + (`host`, `port`, `user`, `identityFile` e `type=ssh|rdp`); variáveis `ansible_*` são ignoradas. + O menu passa a ser **Importar → Sessões (YAML ou inventário INI)…**. (#101) + +### Corrigido + +- Colar com o botão direito em programas de terminal que usam o mouse não duplica mais o texto: + o clique direito cola pelo MultTerm e não chega ao programa como clique do mouse. (#99) +- Mensagens do SSH com acentos (ex.: "Este host não é conhecido.") aparecem com os acentos, e não + como códigos `\343`/`\351`, nas abas SSH e no status do agente SSH. (#100) + ## [0.9.0] - 2026-10-21 ### Adicionado @@ -257,7 +281,8 @@ Primeira versão. - Instalador para Windows x64 (NSIS, instalação por usuário). - CLI `multterm` (`list`, `validate`, `import`). -[Não lançado]: https://github.com/acacio90/MultTerm/compare/v0.9.0...develop +[Não lançado]: https://github.com/acacio90/MultTerm/compare/v0.10.0...develop +[0.10.0]: https://github.com/acacio90/MultTerm/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/acacio90/MultTerm/compare/v0.8.1...v0.9.0 [0.8.1]: https://github.com/acacio90/MultTerm/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/acacio90/MultTerm/compare/v0.7.0...v0.8.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bfb4d3..a32547f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Outros comandos úteis: npm run icon # regenera desktop/icon.png a partir de desktop/icon.svg npm run multterm -- list npm run multterm -- import outro.yaml --dry-run -npm run multterm -- import inventario.ini --folder Ansible --dry-run +npm run multterm -- import inventario.ini --folder Inventario --dry-run ``` O `wfreerdp.exe` é compilado pelo workflow `.github/workflows/freerdp.yml` a partir do diff --git a/README.md b/README.md index 99bd823..bc5ab8f 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 do Ansible** (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 @@ -301,11 +302,47 @@ sessões a outra máquina. Não há senhas no arquivo (o app não guarda senhas) com as sessões existentes (mesma pasta + nome não é sobrescrito): - **Sessões do MultTerm** (YAML com `sessions:`). -- **Inventário do Ansible** (INI ou YAML): cada grupo vira uma pasta (grupos filhos viram - subpastas) e cada host uma sessão SSH, usando `ansible_host`, `ansible_port`, - `ansible_user` e `ansible_ssh_private_key_file` (variáveis do host valem sobre as do grupo). - Hosts com `ansible_connection=winrm`/`psrp` viram sessões RDP; faixas como `web[01:03]` - são expandidas; valores com template Jinja são ignorados com aviso. +- **Inventário** (INI ou YAML): cada grupo vira uma pasta (grupos filhos viram subpastas) e + cada host uma sessão. Variáveis com os nomes dos campos da sessão (`host`, `port`, `user`, + `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. + +### Sincronizar com o Zabbix + +**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. **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: + +- **Endereço**: interface principal do agente (IP ou DNS, conforme configurado no host); sem + agente, a interface SNMP. Hosts sem nenhuma das duas, ou com endereço local (`localhost`, + `127.x.x.x`, `::1`, como o próprio servidor do Zabbix), 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 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 4c0e3e7..8bdb2ad 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])); @@ -882,6 +885,13 @@ async function openTerminalTab({ title, tooltip, open, spec, adopt, endedText = e.preventDefault(); pasteInto(tab); }); + // O botão direito é do MultTerm (colar): não chega ao programa mesmo que ele use o mouse. Senão + // programas que também colam no clique direito (Claude Code) recebem o texto duas vezes. + for (const type of ['mousedown', 'mouseup', 'pointerdown', 'pointerup']) { + tab.panel.addEventListener(type, (e) => { + if (e.button === 2) e.stopPropagation(); + }, true); + } tab.term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown' || !e.ctrlKey || !e.shiftKey) return true; if (e.code === 'KeyV') { @@ -1376,7 +1386,7 @@ async function importSessions() { const result = await api.importSessions(); if (!result) return; apply(result); - const origem = result.format === 'ansible' ? ' do inventário do Ansible' : ''; + const origem = result.format === 'inventory' ? ' do inventário' : ''; message(`${result.added} sessões importadas${origem}, ${result.skipped} já existiam.`); for (const w of result.warnings) message(w, 'warn'); } catch (err) { @@ -1384,6 +1394,159 @@ async function importSessions() { } } +/** + * 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'); + 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; grupos desmarcados saem da árvore.`; + 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; + const req = { + groupIds: [...list.querySelectorAll('input:checked')].map((el) => el.value), + rootFolder: zform.rootFolder.value, + }; + let preview; + try { + 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(); @@ -1962,6 +2125,8 @@ const MENU_COMMANDS = { reload: () => load(), '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 eeff247..81d51b8 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -128,6 +128,35 @@

Chave SSH padrão

+ +
+

Hosts do Zabbix

+ + + + + + +
+ +

+
+ + +
+
+
+

Confirmar

diff --git a/desktop/preload.cjs b/desktop/preload.cjs index 1744fd3..e0c0adc 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), + zabbixSync: (req) => ipcRenderer.invoke('zabbix:sync', 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/package-lock.json b/package-lock.json index 7740e1b..951087a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "multterm", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multterm", - "version": "0.9.0", + "version": "0.10.0", "license": "GPL-3.0-only", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "electron-updater": "^6.8.9", + "electron-updater": "^6.8.10", "koffi": "^3.3.3", "net-snmp": "^3.29.2", "node-pty": "^1.1.0", @@ -22,12 +22,12 @@ "multterm": "src/cli.ts" }, "devDependencies": { - "@playwright/test": "^1.64.0", + "@playwright/test": "^1.64.1", "@types/net-snmp": "^3.23.0", "@types/node": "^26.6.5", "@types/ssh2": "^1.15.6", - "electron": "^44.7.0", - "electron-builder": "^26.15.3", + "electron": "^44.9.0", + "electron-builder": "^26.18.0", "typescript": "^7.0.2" }, "engines": { @@ -727,13 +727,13 @@ } }, "node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -795,13 +795,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", - "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.64.0" + "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -1396,9 +1396,9 @@ } }, "node_modules/app-builder-lib": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", - "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.18.0.tgz", + "integrity": "sha512-lx+KkuCSZHcbECf4R3IAcC9VOFk0+cMUwljO63s5y8yqS1Lw0sRGmjPaWsPwP3zv3ohZYbMhUvjXeiQXXZhJXA==", "dev": true, "license": "MIT", "dependencies": { @@ -1407,16 +1407,16 @@ "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.4", + "@electron/rebuild": "^4.2.1", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", - "@noble/hashes": "^2.2.0", + "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", @@ -1424,7 +1424,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.15.3", + "electron-publish": "26.18.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -1448,8 +1448,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.15.3", - "electron-builder-squirrel-windows": "26.15.3" + "dmg-builder": "26.18.0", + "electron-builder-squirrel-windows": "26.18.0" } }, "node_modules/app-builder-lib/node_modules/@electron/get": { @@ -1580,14 +1580,14 @@ "license": "MIT" }, "node_modules/asn1js": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", + "integrity": "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", + "pvutils": "^1.2.0", "tslib": "^2.8.1" }, "engines": { @@ -1721,9 +1721,9 @@ } }, "node_modules/builder-util": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", - "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.16.0.tgz", + "integrity": "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==", "dev": true, "license": "MIT", "dependencies": { @@ -2173,14 +2173,14 @@ } }, "node_modules/dmg-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", - "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.18.0.tgz", + "integrity": "sha512-ozCGC54wi2C1RkS2v69y9lQxvoQ1Coh+iT8CmWiyY8y1ZuOKVvuNson2Hd+HEOs9+EZNNsOBbzKgQZ4SNqWd0w==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } @@ -2256,9 +2256,9 @@ } }, "node_modules/electron": { - "version": "44.7.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-44.7.0.tgz", - "integrity": "sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==", + "version": "44.9.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.9.0.tgz", + "integrity": "sha512-boP8zA5OIoMGYe3uWP4IqB1VJNoEzdHYgcBaVYnd1DBv5TogwXkbM4yJGSfNmYCzalVQ5J3TAIbaKOY+ILTnyg==", "dev": true, "license": "MIT", "dependencies": { @@ -2275,18 +2275,18 @@ } }, "node_modules/electron-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", - "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.18.0.tgz", + "integrity": "sha512-ASgIuQnOq4RuD8jMhS9hI0LS3EMRT1I/M5R6utqifB/BHN80IcCjBdt+DED+NmNPvmBgEtrlExXZitNdA7kXOg==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.15.3", + "dmg-builder": "26.18.0", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -2301,28 +2301,28 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", - "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.18.0.tgz", + "integrity": "sha512-a2elgTipboXNIsxqS1HOmHMGp4Pd3LJgIZOdIFTOskBSawvxKyZWYWQ1d7KKA4CtINSchYCYEKzhxRqiKZev9Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.18.0", + "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, "node_modules/electron-publish": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", - "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "version": "26.18.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.18.0.tgz", + "integrity": "sha512-MagnqaZSALSWjcdKu5HEVhYUZD/2BDL3q7XzrqWwub6gf/UfD3ItMgmthVDv9w5y9E0gH1C3JWqKaXwf6f4ROg==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", @@ -2332,9 +2332,9 @@ } }, "node_modules/electron-updater": { - "version": "6.8.9", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", - "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "version": "6.8.10", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.10.tgz", + "integrity": "sha512-6ZSwTUoBNtI1f9Pp8y6zCigD8LKV18Rb1+1A7sZzvKvA17o2sqSqKmm0t6uvKeF+HZqA4vsmw4qq0JSS241USQ==", "license": "MIT", "dependencies": { "builder-util-runtime": "9.7.0", @@ -2569,9 +2569,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -3423,9 +3423,9 @@ } }, "node_modules/node-abi": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.37.0.tgz", - "integrity": "sha512-O9XXMXLeRHQtvh43NCah3y9N1K69tecOmu8Y5p2ay4iXvtpbQr0VaroJF9EmRVp3vkdoj3qn4IFfX+ZYtC94fw==", + "version": "4.38.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.38.0.tgz", + "integrity": "sha512-suSdznUPfabdaeLiNERA0LHod14RhcEn8wMPUZEQ6bKvajUCZ+aizY4pIvlLU+gImeLKp2bFDHhOc9yonISsXw==", "dev": true, "license": "MIT", "dependencies": { @@ -3497,9 +3497,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "dev": true, "license": "MIT", "engines": { @@ -3671,9 +3671,9 @@ } }, "node_modules/pkijs": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", - "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.2.tgz", + "integrity": "sha512-LIy5RqLEzlliax2ANQNWvDLvhJ2CtlpJahiSvKtXnpMdKHhXXoLdpdnWqae/uaTyF3U5l5ARweW0kZp9B0s2VA==", "dev": true, "license": "BSD-3-Clause", "workspaces": [ @@ -3691,27 +3691,14 @@ "node": ">=16.0.0" } }, - "node_modules/pkijs/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/playwright": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", - "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -3721,9 +3708,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", - "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index f1b8736..4e6cce2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "multterm", "productName": "MultTerm", - "version": "0.9.0", + "version": "0.10.0", "private": true, "description": "Gerenciador de sessões SSH/RDP com terminal em abas", "author": "acacio90", @@ -90,7 +90,7 @@ "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "electron-updater": "^6.8.9", + "electron-updater": "^6.8.10", "koffi": "^3.3.3", "net-snmp": "^3.29.2", "node-pty": "^1.1.0", @@ -98,12 +98,12 @@ "yaml": "^2.9.1" }, "devDependencies": { - "@playwright/test": "^1.64.0", + "@playwright/test": "^1.64.1", "@types/net-snmp": "^3.23.0", "@types/node": "^26.6.5", "@types/ssh2": "^1.15.6", - "electron": "^44.7.0", - "electron-builder": "^26.15.3", + "electron": "^44.9.0", + "electron-builder": "^26.18.0", "typescript": "^7.0.2" } } diff --git a/src/cli.ts b/src/cli.ts index 15bd416..353bbac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,7 +9,7 @@ const USAGE = `uso: multterm [opções] comandos: list lista as sessões agrupadas por pasta validate valida o arquivo de sessões - import importa sessões YAML ou inventário do Ansible (INI/YAML), mantendo as existentes + import importa sessões YAML ou inventário (INI/YAML), mantendo as existentes opções: --file arquivo de sessões (padrão: $MULTTERM_SESSIONS ou ./sessions.yaml) @@ -60,11 +60,11 @@ async function main(argv: string[]): Promise { case 'import': { const source = rest[0]; if (!source) { - process.stderr.write('informe o arquivo a importar (sessões YAML ou inventário do Ansible)\n'); + process.stderr.write('informe o arquivo a importar (sessões YAML ou inventário INI/YAML)\n'); return 1; } const { format, sessions: imported, warnings } = await readImportFile(source, values.folder ?? ''); - console.error(`formato: ${format === 'ansible' ? 'inventário do Ansible' : 'sessões do MultTerm'}`); + console.error(`formato: ${format === 'inventory' ? 'inventário' : 'sessões do MultTerm'}`); for (const w of warnings) console.warn(`aviso: ${w}`); const existing = await loadSessionFile(file); diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 3750773..d2f623d 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -25,12 +25,14 @@ 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'; import { wintunDllPath } from './native-vpn.ts'; import { HELPER_FLAG, runVpnHelper } from '../vpn/helper.ts'; import { readImportFile } from '../sessions/import.ts'; +import { sshTextStream } from '../sessions/ssh-text.ts'; import { exportFileName, loadSessionDefaults, loadSessionFile, mergeSessions, saveSessionFile, sessionsInFolder, validateDefaults, validateSessions, withDefaults, } from '../sessions/store.ts'; @@ -181,7 +183,7 @@ function registerIpc(): void { title: 'Importar sessões', defaultPath: dirname(sessionsPath), filters: [ - { name: 'Sessões ou inventário do Ansible', extensions: ['yaml', 'yml', 'ini', 'cfg', 'hosts'] }, + { name: 'Sessões ou inventário', extensions: ['yaml', 'yml', 'ini', 'cfg', 'hosts'] }, { name: 'Todos os arquivos', extensions: ['*'] }, ], properties: ['openFile'], @@ -299,7 +301,7 @@ function registerIpc(): void { sshAlgorithms: session.legacySsh ? await sshAlgorithms() : undefined, }); const size = (n: unknown, fallback: number) => (Number.isInteger(n) && (n as number) > 0 ? (n as number) : fallback); - openTerminal(e.sender, id, file, args, { cols: size(cols, 80), rows: size(rows, 24), cwd: homedir() }); + openTerminal(e.sender, id, file, args, { cols: size(cols, 80), rows: size(rows, 24), cwd: homedir() }, sshTextStream()); // Avisos vão para a faixa da aba: o ConPTY limpa a tela ao iniciar e apagaria texto escrito antes. return { warnings }; }); @@ -500,6 +502,7 @@ if (!vpnHelper) app.whenReady().then(async () => { registerAgentIpc(); registerDetachIpc(); registerRdpIpc(); + registerZabbixIpc({ load: () => loadSessionFile(sessionsPath), save: (sessions) => persist(sessions) }); 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 213245e..f195b96 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' | 'zabbix-sync' | 'import-vpn' | 'import-backup' | 'export-sessions' | 'export-vpn' | 'export-backup' | 'connect' | 'external' | 'wol' | 'edit' | 'delete' | 'local-powershell' | 'local-cmd' | 'local-wsl' @@ -41,7 +41,9 @@ export function buildAppMenu({ send, openProject, about }: MenuActions): Menu { { label: 'Importar', submenu: [ - cmd('import-sessions', 'Sessões (YAML ou inventário do Ansible)…'), + cmd('import-sessions', 'Sessões (YAML ou inventário INI)…'), + cmd('import-zabbix', 'Hosts do Zabbix…'), + cmd('zabbix-sync', 'Sincronizar com o Zabbix'), cmd('import-vpn', 'Site de VPN (.vpn)…'), sep, cmd('import-backup', 'Backup completo…'), diff --git a/src/desktop/terminals.ts b/src/desktop/terminals.ts index 3c76663..32fe2e2 100644 --- a/src/desktop/terminals.ts +++ b/src/desktop/terminals.ts @@ -26,6 +26,19 @@ const terminals = new Map(); const keyOf = (owner: WebContents, id: number) => `${owner.id}:${id}`; +/** + * Ajuste da saída antes de ir para a aba e para o agente (ex.: acentos escapados pelo ssh). Pode + * guardar o fim de um pedaço para juntar com o próximo; o que ficou guardado sai no `flush`. + */ +export interface OutputFilter { + push(chunk: string): string; + flush(): string; + readonly pending: boolean; +} + +// O que o filtro guardou sai se nada mais vier logo (ex.: uma "\" digitada no shell remoto). +const FILTER_WAIT_MS = 20; + function validId(id: unknown): id is number { return Number.isInteger(id) && (id as number) > 0; } @@ -44,6 +57,7 @@ export function openTerminal( file: string, args: string[], size: { cols: number; rows: number; cwd: string }, + filter?: OutputFilter, ): void { if (!validId(id)) throw new Error('id de terminal inválido'); const key = keyOf(owner, id); @@ -59,13 +73,23 @@ export function openTerminal( terminals.set(key, entry); if (!outputs.has(key)) outputs.set(key, new OutputBuffer()); // Dono e id lidos na hora: a aba pode ter mudado de janela. - proc.onData((data) => { + const deliver = (data: string) => { + if (!data) return; const k = keyOf(entry.owner, entry.id); outputs.get(k)?.push(data); for (const l of dataListeners) l(k); if (!entry.owner.isDestroyed()) entry.owner.send('term:data', entry.id, data); + }; + let held: NodeJS.Timeout | undefined; + proc.onData((raw) => { + if (!filter) return deliver(raw); + clearTimeout(held); + deliver(filter.push(raw)); + if (filter.pending) held = setTimeout(() => deliver(filter.flush()), FILTER_WAIT_MS); }); proc.onExit(({ exitCode }) => { + clearTimeout(held); + if (filter) deliver(filter.flush()); const k = keyOf(entry.owner, entry.id); if (terminals.get(k) === entry) terminals.delete(k); if (!entry.owner.isDestroyed()) entry.owner.send('term:exit', entry.id, exitCode); diff --git a/src/desktop/zabbix.ts b/src/desktop/zabbix.ts new file mode 100644 index 0000000..05ce4fd --- /dev/null +++ b/src/desktop/zabbix.ts @@ -0,0 +1,147 @@ +import { app, ipcMain, safeStorage } from 'electron'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { planZabbixSync, zabbixToSessions } from '../sessions/import-zabbix.ts'; +import { normalizeFolder, sessionKey, 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 sincroniza as sessões + * com 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() : ''); + +/** Acesso ao arquivo de sessões; "save" devolve o novo estado para a interface. */ +export interface SessionsAccess { + load(): Promise; + save(sessions: Session[]): Promise>; +} + +export function registerZabbixIpc(sessions: SessionsAccess): 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(); + } + }); + + /* + * Sincroniza com os grupos e a pasta informados (ou os guardados, no "Sincronizar com o Zabbix" + * do menu). Sem "apply" só devolve a prévia; com "apply" grava e lembra a escolha. + */ + ipcMain.handle('zabbix:sync', async (_e, raw: Record) => { + const config = await loadConfig(); + if (!config.url) throw new ZabbixError('configure a conexão em Arquivo → Importar → Hosts do Zabbix'); + const groupIds = Array.isArray(raw?.groupIds) + ? raw.groupIds.filter((id): id is string => typeof id === 'string' && /^\d+$/.test(id)) + : config.groupIds ?? []; + if (!groupIds.length) throw new ZabbixError('escolha ao menos um grupo'); + const rootFolder = typeof raw?.rootFolder === 'string' ? normalizeFolder(raw.rootFolder) : config.rootFolder ?? DEFAULT_ROOT; + 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(); + } + const { sessions: incoming, warnings } = zabbixToSessions(hosts, groupIds, rootFolder); + const plan = planZabbixSync(await sessions.load(), incoming); + const summary = { + hosts: hosts.length, + added: plan.added.length, + updated: plan.updated.length, + removed: plan.removed.map(sessionKey), + warnings: [ + ...warnings, + ...plan.conflicts.map((s) => `${sessionKey(s)}: já existe uma sessão com esse nome criada à mão; mantida`), + ], + }; + if (raw?.apply !== true) return summary; + await saveConfig({ ...config, groupIds, rootFolder }); + return { ...(await sessions.save(plan.sessions)), ...summary }; + }); +} diff --git a/src/sessions/import-ansible.ts b/src/sessions/import-inventory.ts similarity index 86% rename from src/sessions/import-ansible.ts rename to src/sessions/import-inventory.ts index 8d431c1..4a36bc7 100644 --- a/src/sessions/import-ansible.ts +++ b/src/sessions/import-inventory.ts @@ -2,11 +2,11 @@ import { parse } from 'yaml'; import { normalizeFolder, SessionError, sessionKey, validateSession, type Session } from './model.ts'; /* - * Inventário do Ansible (INI ou YAML) → sessões. + * Inventário (INI ou YAML) → sessões. * - Cada grupo vira uma pasta; grupos filhos (":children" / "children:") viram subpastas. * - Um host aparece em cada grupo onde é listado diretamente. * - Variáveis: host > grupo > grupos pais > all. - * - ansible_connection=winrm/psrp → sessão RDP (a porta do WinRM não é usada). + * - Variáveis com os nomes dos campos da sessão: host, port, user, identityFile e type (ssh|rdp; padrão ssh). */ type Vars = Record; @@ -37,7 +37,7 @@ function group(inv: Inventory, name: string): Group { return g; } -/** Expande faixas de host do Ansible: web[01:03] → web01, web02, web03; db[a:c] → dba, dbb, dbc. */ +/** Expande faixas de host: web[01:03] → web01, web02, web03; db[a:c] → dba, dbb, dbc. */ export function expandHostPattern(pattern: string): string[] { const m = /^(.*?)\[([0-9a-z]+):([0-9a-z]+)(?::(\d+))?\](.*)$/i.exec(pattern); if (!m) return [pattern]; @@ -144,7 +144,7 @@ export function parseYamlInventory(doc: unknown): Inventory { return inv; } -/** Um YAML é inventário do Ansible se os grupos de topo têm hosts/children/vars. */ +/** Um YAML é inventário se os grupos de topo têm hosts/children/vars. */ export function looksLikeYamlInventory(doc: unknown): boolean { return isObject(doc) && !('sessions' in doc) && Object.values(doc).some((g) => isObject(g) && ('hosts' in g || 'children' in g || 'vars' in g)); @@ -192,7 +192,7 @@ export function inventoryToSessions(inv: Inventory, rootFolder = ''): ImportResu if (v === undefined || v === null || v === '') continue; const s = String(v); if (TEMPLATE.test(s)) { - warnings.push(`${label}: ${k} usa template Jinja (${s}); campo ignorado`); + warnings.push(`${label}: ${k} usa template (${s}); campo ignorado`); return undefined; } return s; @@ -200,24 +200,16 @@ export function inventoryToSessions(inv: Inventory, rootFolder = ''): ImportResu return undefined; }; - const connection = pick('ansible_connection'); - if (connection === 'local') { - warnings.push(`${label}: ansible_connection=local, ignorado`); - continue; - } - const windows = connection === 'winrm' || connection === 'psrp'; + const type = pick('type') ?? 'ssh'; const raw: Record = { name: host.replace(/\//g, '-'), folder: normalizeFolder([rootFolder, folder].filter(Boolean).join('/')), - type: windows ? 'rdp' : 'ssh', - host: pick('ansible_host', 'ansible_ssh_host') ?? host, - user: pick('ansible_user', 'ansible_ssh_user'), + type, + host: pick('host') ?? host, + port: pick('port'), + user: pick('user'), }; - if (!windows) { - const port = pick('ansible_port', 'ansible_ssh_port'); - if (port) raw.port = Number(port); - raw.identityFile = pick('ansible_ssh_private_key_file', 'ansible_private_key_file'); - } + if (type === 'ssh') raw.identityFile = pick('identityFile'); let session: Session; try { @@ -237,7 +229,7 @@ export function inventoryToSessions(inv: Inventory, rootFolder = ''): ImportResu } /** Lê um inventário em texto (INI ou YAML). */ -export function parseAnsibleInventory(text: string, rootFolder = ''): ImportResult { +export function parseInventory(text: string, rootFolder = ''): ImportResult { let doc: unknown; try { doc = parse(text); diff --git a/src/sessions/import-zabbix.ts b/src/sessions/import-zabbix.ts new file mode 100644 index 0000000..5619af6 --- /dev/null +++ b/src/sessions/import-zabbix.ts @@ -0,0 +1,186 @@ +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, e a sincronização delas com o arquivo. + * - 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. + * Hosts com endereço local (localhost, 127.x.x.x, ::1) ficam de fora. + * - 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; +} + +/** localhost, 127.x.x.x e ::1: o próprio servidor do Zabbix, que não é alcançável por esse endereço. */ +export function isLoopback(address: string): boolean { + const a = address.toLowerCase().replace(/^\[|\]$/g, ''); + return a === 'localhost' || a.endsWith('.localhost') || /^127\.\d+\.\d+\.\d+$/.test(a) || a === '::1' || a === '0:0:0:0:0:0:0:1'; +} + +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; + } + if (isLoopback(address)) { + warnings.push(`${label}: endereço local (${address}), 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 }; +} + +export interface ZabbixSyncPlan { + /** O arquivo inteiro depois da sincronização. */ + sessions: Session[]; + added: Session[]; + updated: Session[]; + removed: Session[]; + /** Vindas do Zabbix com a mesma pasta/nome de uma sessão criada à mão (que é mantida). */ + conflicts: Session[]; +} + +/** Mesmos campos e valores, em qualquer ordem. */ +function sameSession(a: Session, b: Session): boolean { + const norm = (s: Session) => JSON.stringify(Object.entries(s).filter(([, v]) => v !== undefined).sort(([x], [y]) => x.localeCompare(y))); + return norm(a) === norm(b); +} + +/** + * Sessão existente atualizada pelo Zabbix: nome, pasta e endereço vêm sempre dele; usuário, porta + * e MAC só quando o Zabbix tem valor; tipo, chave, VPN, opções e tags ficam como editados aqui. + */ +function updateFromZabbix(prev: Session, inc: Session): Session { + const next: Session = { ...prev, name: inc.name, folder: inc.folder, host: inc.host, zabbixHostId: inc.zabbixHostId }; + if (inc.user !== undefined) next.user = inc.user; + if (inc.port !== undefined) next.port = inc.port; + if (inc.mac !== undefined) next.mac = inc.mac; + return next; +} + +/** + * Sincroniza as sessões do Zabbix (as que têm zabbixHostId) com o que veio da API: novas entram, + * existentes são atualizadas e as que não vieram (host removido ou grupo desmarcado) saem. + * Sessões sem zabbixHostId nunca são alteradas. + */ +export function planZabbixSync(existing: Session[], incoming: Session[]): ZabbixSyncPlan { + const managed = existing.filter((s) => s.zabbixHostId); + const others = existing.filter((s) => !s.zabbixHostId); + const otherKeys = new Set(others.map(sessionKey)); + const byKey = new Map(managed.map((s) => [sessionKey(s), s])); + const used = new Set(); + const plan: ZabbixSyncPlan = { sessions: [...others], added: [], updated: [], removed: [], conflicts: [] }; + + for (const inc of incoming) { + if (otherKeys.has(sessionKey(inc))) { + plan.conflicts.push(inc); + continue; + } + // Mesmo lugar primeiro; senão, outra sessão do mesmo host (grupo ou nome mudou no Zabbix). + const same = byKey.get(sessionKey(inc)); + const prev = same && same.zabbixHostId === inc.zabbixHostId && !used.has(same) + ? same + : managed.find((s) => s.zabbixHostId === inc.zabbixHostId && !used.has(s)); + if (!prev) { + plan.added.push(inc); + plan.sessions.push(inc); + continue; + } + used.add(prev); + const next = updateFromZabbix(prev, inc); + plan.sessions.push(next); + if (!sameSession(prev, next)) plan.updated.push(next); + } + plan.removed = managed.filter((s) => !used.has(s)); + return plan; +} diff --git a/src/sessions/import.ts b/src/sessions/import.ts index 78d4d37..f4bb927 100644 --- a/src/sessions/import.ts +++ b/src/sessions/import.ts @@ -1,16 +1,16 @@ import { readFile } from 'node:fs/promises'; import { parse } from 'yaml'; -import { parseAnsibleInventory } from './import-ansible.ts'; +import { parseInventory } from './import-inventory.ts'; import type { Session } from './model.ts'; import { parseSessions } from './store.ts'; export interface ImportFile { - format: 'multterm' | 'ansible'; + format: 'multterm' | 'inventory'; sessions: Session[]; warnings: string[]; } -/** Lê um arquivo para importar: sessões do MultTerm (YAML) ou inventário do Ansible (INI/YAML). */ +/** Lê um arquivo para importar: sessões do MultTerm (YAML) ou inventário (INI/YAML). */ export async function readImportFile(path: string, rootFolder = ''): Promise { const text = await readFile(path, 'utf8'); let doc: unknown; @@ -22,5 +22,5 @@ export async function readImportFile(path: string, rootFolder = ''): Promise l.trim()).filter(Boolean) }; } if (exitCode === 1) return { available: true, keys: [] }; - return { available: false, message: (stderr || stdout).trim() || 'agente SSH indisponível' }; + return { available: false, message: unescapeSshText((stderr || stdout).trim()) || 'agente SSH indisponível' }; } /** 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/ssh-text.ts b/src/sessions/ssh-text.ts new file mode 100644 index 0000000..c5e9754 --- /dev/null +++ b/src/sessions/ssh-text.ts @@ -0,0 +1,53 @@ +/* + * O OpenSSH escapa em octal (\343) os bytes que não são UTF-8 válido nas próprias mensagens. No + * Windows em português o texto do sistema vem em cp1252: "Este host n\343o \351 conhecido." + * Aqui os escapes voltam a ser os acentos (UTF-8 quando a sequência for UTF-8 válido, senão cp1252). + * Só vira texto o que decodifica em letras: um "\351" que não é acento fica como está. + */ + +const utf8 = new TextDecoder('utf-8', { fatal: true }); +const cp1252 = new TextDecoder('windows-1252'); + +// Sequências de bytes altos (\200-\377) que não vêm depois de outra barra ("\\351" é uma barra literal). +const ESCAPES = /(? { + const bytes = Uint8Array.from(run.slice(1).split('\\'), (octal) => parseInt(octal, 8)); + let decoded: string; + try { + decoded = utf8.decode(bytes); + } catch { + decoded = cp1252.decode(bytes); + } + return /^[\p{L}\p{M}]+$/u.test(decoded) ? decoded : run; + }); +} + +/** + * Para a saída do terminal, que chega em pedaços: o fim de um pedaço que pode continuar no próximo + * fica guardado e sai junto com ele (ou no `flush`), para um "\343" cortado ao meio não escapar. + */ +export function sshTextStream(): { push(chunk: string): string; flush(): string; readonly pending: boolean } { + let held = ''; + return { + push(chunk) { + const text = held + chunk; + // Sem casamento quando o texto termina numa barra literal ("\\"): nada a guardar. + held = TAIL.exec(text)?.[0] ?? ''; + return unescapeSshText(text.slice(0, text.length - held.length)); + }, + flush() { + const text = held; + held = ''; + return unescapeSshText(text); + }, + get pending() { + return held !== ''; + }, + }; +} 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/app.spec.ts b/test-ui/app.spec.ts index e1f62d7..8d4b064 100644 --- a/test-ui/app.spec.ts +++ b/test-ui/app.spec.ts @@ -116,15 +116,15 @@ test('F1 abre a ajuda com versão, agente SSH e arquivo de sessões', async () = await expect(help).toBeHidden(); }); -test('importa inventário do Ansible pelo botão (diálogo substituído no teste)', async () => { +test('importa inventário pelo botão (diálogo substituído no teste)', async () => { const inventory = join(dir, 'hosts.ini'); - await writeFile(inventory, '[web]\nweb[01:02].example.org\n\n[win]\nad02 ansible_connection=winrm\n'); + await writeFile(inventory, '[web]\nweb[01:02].example.org\n\n[win]\nad02 type=rdp\n'); await app.evaluate(({ dialog }, path) => { dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [path] })) as typeof dialog.showOpenDialog; }, inventory); await clickMenu(app, 'import-sessions'); - await expect(page.locator('#messages')).toContainText('3 sessões importadas do inventário do Ansible'); + await expect(page.locator('#messages')).toContainText('3 sessões importadas do inventário'); await expect(page.locator('#status-count')).toHaveText('6 sessões · sem limite'); await expect(session('web02.example.org')).toHaveCount(1); await expect(session('ad02')).toContainText('rdp'); diff --git a/test-ui/detach.spec.ts b/test-ui/detach.spec.ts index 98f83b8..bbd12e5 100644 --- a/test-ui/detach.spec.ts +++ b/test-ui/detach.spec.ts @@ -121,11 +121,13 @@ test('fechar a janela principal encerra o app, inclusive as janelas destacadas', const opened = ctx.app.waitForEvent('window'); await main.locator('#tabs .tab', { hasText: 'PowerShell' }).click({ button: 'right' }); await main.locator('.tab-menu button', { hasText: 'Abrir em janela separada' }).click(); - await opened; + const detached = await opened; + await expect(detached.locator('#tabs .tab', { hasText: 'PowerShell' })).toBeVisible(); expect(ctx.app.windows()).toHaveLength(2); const exited = ctx.app.waitForEvent('close'); + // A principal pela URL, não pelo título: enquanto carrega, a destacada também se chama "MultTerm". await ctx.app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows().find((w) => w.getTitle() === 'MultTerm')?.close(); + BrowserWindow.getAllWindows().find((w) => !w.webContents.getURL().includes('detached=1'))?.close(); }); await exited; }); diff --git a/test-ui/fixtures/mouse-echo.cjs b/test-ui/fixtures/mouse-echo.cjs new file mode 100644 index 0000000..77d5269 --- /dev/null +++ b/test-ui/fixtures/mouse-echo.cjs @@ -0,0 +1,13 @@ +// Programa de teste que liga o mouse no terminal (como o Claude Code) e mostra cada entrada recebida. +process.stdin.setRawMode(true); +process.stdin.resume(); +process.stdout.write('\x1b[?1000h\x1b[?1006h'); +process.stdout.write('PRONTO\r\n'); +process.stdin.on('data', (data) => { + const text = data.toString('utf8'); + if (text === '\x03') { + process.stdout.write('\x1b[?1000l\x1b[?1006l'); + process.exit(0); + } + process.stdout.write(`RECEBIDO ${JSON.stringify(text)}\r\n`); +}); diff --git a/test-ui/paste.spec.ts b/test-ui/paste.spec.ts new file mode 100644 index 0000000..843ddf9 --- /dev/null +++ b/test-ui/paste.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { join } from 'node:path'; +import { launchApp, type LaunchedApp } from './fixture.ts'; + +let ctx: LaunchedApp; + +test.beforeEach(async () => { + ctx = await launchApp('version: 1\nsessions: []\n'); +}); + +test.afterEach(async () => { + await ctx?.close(); +}); + +test('botão direito cola uma vez só, mesmo num programa que usa o mouse (Claude Code)', async () => { + const { app, page } = ctx; + await app.evaluate(({ clipboard }) => clipboard.writeText('colado-123')); + await page.click('#btn-tools'); + await page.click('#tools-menu [data-tool="local-powershell"]'); + const rows = page.locator('.term-panel:not([hidden]) .xterm-rows'); + await expect(rows).toContainText('PS ', { timeout: 20_000 }); + + const script = join(import.meta.dirname, 'fixtures', 'mouse-echo.cjs'); + await page.locator('.term-panel:not([hidden]) .xterm-helper-textarea').focus(); + await page.keyboard.type(`& "${process.execPath}" "${script}"\r`); + await expect(rows).toContainText('PRONTO', { timeout: 20_000 }); + + await page.locator('.term-panel:not([hidden]) .xterm-screen').click({ button: 'right' }); + await expect(rows).toContainText('RECEBIDO "colado-123"'); + // Nada além da colagem: nem a segunda cópia nem o clique direito como evento de mouse. + await page.waitForTimeout(500); + await expect(rows.locator('div', { hasText: 'RECEBIDO' })).toHaveCount(1); +}); diff --git a/test-ui/zabbix.spec.ts b/test-ui/zabbix.spec.ts new file mode 100644 index 0000000..4e3b2a8 --- /dev/null +++ b/test-ui/zabbix.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { clickMenu, launchApp, type LaunchedApp } from './fixture.ts'; +import { startFakeZabbix, type FakeZabbix, type FakeZabbixOptions } from '../test/zabbix-fake.ts'; + +const LINUX = { groupid: '2', name: 'Linux servers' }; +const ACME = { groupid: '11', name: 'Clientes/ACME' }; +const agent = (ip: string) => [{ type: '1', main: '1', useip: '1', ip, dns: '' }]; + +let ctx: LaunchedApp; +let zabbix: FakeZabbix; +let options: FakeZabbixOptions; + +test.beforeEach(async () => { + options = { + version: '7.0.5', + token: 'token-123', + groups: [LINUX, ACME], + 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: [ACME] }, + { hostid: '103', host: 'db01', interfaces: agent('10.0.0.3'), groups: [LINUX, ACME] }, + ], + }; + zabbix = await startFakeZabbix(options); + ctx = await launchApp('version: 1\nsessions:\n - { name: roteador, folder: Rede, host: 10.0.0.254 }\n'); +}); + +test.afterEach(async () => { + await ctx?.close(); + await zabbix?.close(); +}); + +test('sincroniza com o Zabbix: prévia, grava, mantém o editado e remove o que sumiu', async () => { + const { app, page, sessionsFile } = ctx; + const dialog = page.locator('#zabbix-dialog'); + const confirm = page.locator('#confirm-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(); + + // Primeira sincronização: prévia com 2 novas, nada gravado antes de confirmar. + await page.locator('#zabbix-groups label', { hasText: 'Clientes/ACME' }).locator('input').check(); + await page.locator('#zabbix-import').click(); + await expect(dialog).toBeHidden(); + await expect(confirm).toContainText('2 hosts no Zabbix: 2 sessão(ões) nova(s), 0 atualizada(s) e 0 removida(s).'); + expect(await readFile(sessionsFile, 'utf8')).not.toContain('zabbixHostId'); + await page.locator('#confirm-ok').click(); + await expect(page.locator('#messages')).toContainText('Zabbix sincronizado: 2 nova(s), 0 atualizada(s), 0 removida(s).'); + await expect(page.locator('#status-count')).toHaveText('3 sessões · sem limite'); + + let saved = await readFile(sessionsFile, 'utf8'); + 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. + expect(await readFile(join(ctx.dir, 'userdata', 'zabbix.json'), 'utf8')).not.toContain('token-123'); + + // Edição à mão (usuário) e mudanças no Zabbix: db01 muda de IP, ad01 sai do grupo. + await writeFile(sessionsFile, saved.replace('host: 10.0.0.3', 'host: 10.0.0.3\n user: pedro')); + await clickMenu(app, 'reload'); + options.hosts[2].interfaces = agent('10.0.0.33'); + options.hosts[1].groups = [LINUX]; + + // Sincronizar pelo menu usa a conexão e os grupos guardados. + await clickMenu(app, 'zabbix-sync'); + await expect(confirm).toContainText('0 sessão(ões) nova(s), 1 atualizada(s) e 1 removida(s).'); + await expect(confirm).toContainText('remover: Zabbix/Clientes/ACME/ad01'); + await page.locator('#confirm-ok').click(); + await expect(page.locator('#messages')).toContainText('Zabbix sincronizado: 0 nova(s), 1 atualizada(s), 1 removida(s).'); + + saved = await readFile(sessionsFile, 'utf8'); + expect(saved).not.toContain('ad01'); + expect(saved).toMatch(/host: 10\.0\.0\.33\n\s+user: pedro/); + expect(saved).toContain('name: roteador'); // sessão criada à mão nunca é tocada + + // Cancelar a prévia não grava nada. + options.hosts.pop(); + await clickMenu(app, 'zabbix-sync'); + await expect(confirm).toContainText('1 removida(s)'); + await page.locator('#confirm-cancel').click(); + expect(await readFile(sessionsFile, 'utf8')).toBe(saved); + + // Sem mudanças: não pergunta. + options.hosts.push({ hostid: '103', host: 'db01', interfaces: agent('10.0.0.33'), groups: [LINUX, ACME] }); + await clickMenu(app, 'zabbix-sync'); + await expect(page.locator('#messages')).toContainText('Zabbix sincronizado: nada mudou.'); +}); diff --git a/test/import-ansible.test.ts b/test/import-inventory.test.ts similarity index 70% rename from test/import-ansible.test.ts rename to test/import-inventory.test.ts index 0dfe32b..7ff36b2 100644 --- a/test/import-ansible.test.ts +++ b/test/import-inventory.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { expandHostPattern, parseAnsibleInventory } from '../src/sessions/import-ansible.ts'; +import { expandHostPattern, parseInventory } from '../src/sessions/import-inventory.ts'; import { readImportFile } from '../src/sessions/import.ts'; import { saveSessionFile } from '../src/sessions/store.ts'; @@ -12,26 +12,26 @@ const INI = ` solto.example.org [web] -web[01:02].example.org ansible_user=deploy -lb.example.org ansible_host=10.0.0.5 ansible_port=2222 ansible_ssh_private_key_file="~/.ssh/chave lb" +web[01:02].example.org user=deploy +lb.example.org host=10.0.0.5 port=2222 identityFile="~/.ssh/chave lb" [db] -db1 ansible_host=10.0.0.10 +db1 host=10.0.0.10 [producao:children] web db [producao:vars] -ansible_user = admin +user = admin [windows] -ad01 ansible_host=10.0.0.20 ansible_connection=winrm ansible_port=5986 ansible_user=administrador +ad01 host=10.0.0.20 type=rdp port=3390 user=administrador [outros] -local ansible_connection=local -tpl ansible_host="{{ lookup('env', 'X') }}" -mal ansible_host=-oProxyCommand=calc +errado type=telnet +tpl host="{{ lookup('env', 'X') }}" +mal host=-oProxyCommand=calc `; test('expande faixas numéricas (com zeros) e alfabéticas', () => { @@ -42,11 +42,11 @@ test('expande faixas numéricas (com zeros) e alfabéticas', () => { }); test('INI: grupos viram pastas (filhos aninhados) e variáveis seguem a precedência', () => { - const { sessions } = parseAnsibleInventory(INI); + const { sessions } = parseInventory(INI); const by = (folder: string, name: string) => sessions.find((s) => s.folder === folder && s.name === name); assert.deepEqual(by('', 'solto.example.org'), { name: 'solto.example.org', folder: '', type: 'ssh', host: 'solto.example.org' }); - // host > grupo > grupo pai: web01 tem ansible_user próprio; db1 herda "admin" de producao:vars + // host > grupo > grupo pai: web01 tem user próprio; db1 herda "admin" de producao:vars assert.equal(by('producao/web', 'web01.example.org')?.user, 'deploy'); assert.equal(by('producao/web', 'web02.example.org')?.user, 'deploy'); assert.equal(by('producao/db', 'db1')?.user, 'admin'); @@ -57,16 +57,16 @@ test('INI: grupos viram pastas (filhos aninhados) e variáveis seguem a precedê }); }); -test('INI: winrm vira RDP sem a porta do WinRM; local, template e host inválido geram aviso', () => { - const { sessions, warnings } = parseAnsibleInventory(INI); +test('INI: type=rdp vira RDP; type desconhecido, template e host inválido geram aviso', () => { + const { sessions, warnings } = parseInventory(INI); assert.deepEqual(sessions.find((s) => s.name === 'ad01'), { - name: 'ad01', folder: 'windows', type: 'rdp', host: '10.0.0.20', user: 'administrador', + name: 'ad01', folder: 'windows', type: 'rdp', host: '10.0.0.20', port: 3390, user: 'administrador', }); - assert.equal(sessions.some((s) => s.name === 'local'), false); + assert.equal(sessions.some((s) => s.name === 'errado'), false); assert.equal(sessions.find((s) => s.name === 'tpl')?.host, 'tpl'); assert.equal(sessions.some((s) => s.name === 'mal'), false); - assert.ok(warnings.some((w) => /local, ignorado/.test(w))); - assert.ok(warnings.some((w) => /template Jinja/.test(w))); + assert.ok(warnings.some((w) => /"type" deve ser/.test(w))); + assert.ok(warnings.some((w) => /usa template/.test(w))); assert.ok(warnings.some((w) => /"host" inválido/.test(w))); }); @@ -74,22 +74,22 @@ test('YAML: all → children → hosts, com vars de grupo e pasta raiz opcional' const yaml = ` all: vars: - ansible_user: ops + user: ops children: rede: children: switches: hosts: sw[1:2]: - ansible_host: 10.1.0.1 + host: 10.1.0.1 servidores: hosts: app01: - ansible_port: 2200 + port: 2200 `; - const { sessions } = parseAnsibleInventory(yaml, 'Ansible'); + const { sessions } = parseInventory(yaml, 'Inventario'); assert.deepEqual(sessions.map((s) => `${s.folder}/${s.name}`).sort(), [ - 'Ansible/rede/switches/sw1', 'Ansible/rede/switches/sw2', 'Ansible/servidores/app01', + 'Inventario/rede/switches/sw1', 'Inventario/rede/switches/sw2', 'Inventario/servidores/app01', ]); assert.equal(sessions.find((s) => s.name === 'app01')?.port, 2200); assert.ok(sessions.every((s) => s.user === 'ops')); @@ -105,7 +105,7 @@ test('readImportFile detecta o formato do arquivo', async () => { const ini = join(dir, 'hosts.ini'); await writeFile(ini, INI); const r = await readImportFile(ini); - assert.equal(r.format, 'ansible'); + assert.equal(r.format, 'inventory'); assert.ok(r.sessions.length >= 6); await assert.rejects(readImportFile(join(dir, 'nao-existe.yaml')), /ENOENT/); diff --git a/test/import-zabbix.test.ts b/test/import-zabbix.test.ts new file mode 100644 index 0000000..3a89546 --- /dev/null +++ b/test/import-zabbix.test.ts @@ -0,0 +1,148 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hostAddress, isLoopback, planZabbixSync, zabbixToSessions, type ZabbixHost } from '../src/sessions/import-zabbix.ts'; +import type { Session } from '../src/sessions/model.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('endereço local (localhost, 127.x, ::1) fica de fora com aviso', () => { + for (const a of ['localhost', 'LOCALHOST', 'app.localhost', '127.0.0.1', '127.1.2.3', '::1', '[::1]']) assert.ok(isLoopback(a), a); + for (const a of ['10.0.0.1', '127.example.org', 'localhost.acme.com', '::2']) assert.equal(isLoopback(a), false, a); + + const server = { groupid: '4', name: 'Zabbix servers' }; + const hosts: ZabbixHost[] = [ + { hostid: '10084', host: 'Zabbix server', interfaces: [agent('127.0.0.1')], hostgroups: [server] }, + { hostid: '10085', host: 'proxy', interfaces: [agent('', 'localhost', '0')], hostgroups: [server] }, + { hostid: '10086', host: 'web', interfaces: [agent('10.0.0.8')], hostgroups: [server] }, + ]; + const { sessions, warnings } = zabbixToSessions(hosts, ['4'], 'Zabbix'); + assert.deepEqual(sessions.map((s) => s.name), ['web']); + assert.deepEqual(warnings, [ + 'Zabbix server: endereço local (127.0.0.1), ignorado', + 'proxy: endereço local (localhost), ignorado', + ]); +}); + +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/); +}); + +describe('sincronização', () => { + const web: Session = { name: 'web01', folder: 'Zabbix/Linux', type: 'ssh', host: '10.0.0.1', zabbixHostId: '1' }; + const db: Session = { name: 'db01', folder: 'Zabbix/Linux', type: 'ssh', host: '10.0.0.2', zabbixHostId: '2' }; + const manual: Session = { name: 'roteador', folder: 'Rede', type: 'ssh', host: '10.0.0.254' }; + + test('novas entram, iguais não contam como mudança, as que sumiram saem; manuais ficam', () => { + const plan = planZabbixSync([web, db, manual], [web, { ...db, name: 'db02', zabbixHostId: '3' }]); + assert.deepEqual(plan.added.map((s) => s.name), ['db02']); + assert.deepEqual(plan.updated, []); + assert.deepEqual(plan.removed.map((s) => s.name), ['db01']); + assert.deepEqual(plan.sessions.map((s) => s.name).sort(), ['db02', 'roteador', 'web01']); + }); + + test('atualiza endereço, nome e pasta do Zabbix e mantém o que foi editado aqui', () => { + const edited: Session = { ...web, type: 'rdp', user: 'pedro', identityFile: '~/.ssh/id', vpn: 'campus', tags: ['x'] }; + const plan = planZabbixSync([edited], [{ ...web, name: 'Web 01', folder: 'Zabbix/Servidores', host: '10.0.0.10' }]); + assert.deepEqual(plan.updated, [{ + name: 'Web 01', folder: 'Zabbix/Servidores', type: 'rdp', host: '10.0.0.10', user: 'pedro', + identityFile: '~/.ssh/id', vpn: 'campus', tags: ['x'], zabbixHostId: '1', + }]); + assert.deepEqual(plan.removed, []); + assert.equal(plan.sessions.length, 1); + }); + + test('usuário, porta e MAC do Zabbix só substituem quando existem', () => { + const edited = { ...web, user: 'pedro', port: 2200 }; + assert.equal(planZabbixSync([edited], [web]).sessions[0].user, 'pedro'); + const plan = planZabbixSync([edited], [{ ...web, user: 'deploy', mac: 'aa:bb:cc:dd:ee:ff' }]); + assert.equal(plan.sessions[0].user, 'deploy'); + assert.equal(plan.sessions[0].port, 2200); + assert.equal(plan.sessions[0].mac, 'aa:bb:cc:dd:ee:ff'); + }); + + test('host em dois grupos: cada sessão casa com a sua pasta', () => { + const other = { ...web, folder: 'Zabbix/Produção', user: 'prod' }; + const plan = planZabbixSync([web, other], [web, { ...web, folder: 'Zabbix/Produção' }]); + assert.deepEqual(plan.updated, []); + assert.equal(plan.sessions.find((s) => s.folder === 'Zabbix/Produção')?.user, 'prod'); + }); + + test('mesma pasta/nome de uma sessão manual: a manual fica e o conflito é avisado', () => { + const plan = planZabbixSync([manual], [{ ...manual, zabbixHostId: '9' }]); + assert.deepEqual(plan.sessions, [manual]); + assert.deepEqual(plan.conflicts.map((s) => s.name), ['roteador']); + }); +}); diff --git a/test/release-notes.test.ts b/test/release-notes.test.ts index 69c58e4..6f69fa9 100644 --- a/test/release-notes.test.ts +++ b/test/release-notes.test.ts @@ -5,7 +5,7 @@ import { htmlToText, releaseNotesText } from '../src/desktop/release-notes.ts'; // Trecho no formato em que o GitHub entrega as notas da release. const HTML = `

Novidades

    -
  • Importação (#4): reconhece ansible_host & faixas.
  • +
  • Importação (#4): reconhece host & faixas.
  • Ícone próprio na janela.

Instalar

@@ -14,7 +14,7 @@ const HTML = `

Novidades

test('converte as notas em HTML para texto legível', () => { assert.equal(htmlToText(HTML), [ 'Novidades', - '• Importação (#4): reconhece ansible_host & faixas.', + '• Importação (#4): reconhece host & faixas.', '• Ícone próprio na janela.', 'Instalar', 'Baixe o instalador — Mais informações → Executar — ok!', diff --git a/test/ssh-text.test.ts b/test/ssh-text.test.ts new file mode 100644 index 0000000..60c666c --- /dev/null +++ b/test/ssh-text.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sshTextStream, unescapeSshText } from '../src/sessions/ssh-text.ts'; +import { parseAgentList } from '../src/sessions/launch.ts'; + +test('mensagens do Windows em cp1252 escapadas pelo ssh voltam a ter acentos', () => { + // Saída real do ssh.exe num Windows em português. + assert.equal( + unescapeSshText('ssh: Could not resolve hostname x.invalid: Este host n\\343o \\351 conhecido.\r\n'), + 'ssh: Could not resolve hostname x.invalid: Este host n\u00e3o \u00e9 conhecido.\r\n', + ); + assert.equal(unescapeSshText('read: Uma conex\\343o existente foi for\\347ada'), 'read: Uma conex\u00e3o existente foi for\u00e7ada'); + assert.equal(unescapeSshText('\\301rea e c\\363digo'), '\u00c1rea e c\u00f3digo'); +}); + +test('sequência UTF-8 escapada vira o caractere UTF-8', () => { + assert.equal(unescapeSshText('caf\\303\\251'), 'caf\u00e9'); +}); + +test('o que não é acento fica como está', () => { + const kept = [ + 'sem escapes', + 'C:\\Users\\Pedro', + 'byte de controle \\200', + 'símbolo \\327', // × não é letra + 'barra literal \\\\351', + 'octal baixo \\101', + ]; + for (const text of kept) assert.equal(unescapeSshText(text), text, text); +}); + +test('escape cortado entre dois pedaços da saída também vira acento', () => { + const message = 'Este host n\\343o \\351 conhecido. caf\\303\\251\r\n'; + // Todo ponto de corte possível, inclusive no meio de "\343" e entre os bytes UTF-8 de "é". + for (let i = 1; i < message.length; i++) { + const stream = sshTextStream(); + const out = stream.push(message.slice(0, i)) + stream.push(message.slice(i)) + stream.flush(); + assert.equal(out, 'Este host n\u00e3o \u00e9 conhecido. caf\u00e9\r\n', `corte em ${i}`); + assert.equal(stream.pending, false); + } +}); + +test('o stream só guarda o que pode continuar no próximo pedaço', () => { + const stream = sshTextStream(); + assert.equal(stream.push('texto comum\r\n'), 'texto comum\r\n'); + assert.equal(stream.pending, false); + assert.equal(stream.push('C:\\Users\\'), 'C:\\Users'); + assert.equal(stream.pending, true); + assert.equal(stream.flush(), '\\'); + assert.equal(stream.push('barra literal \\\\'), 'barra literal \\\\'); + assert.equal(stream.pending, false); +}); + +test('mensagem do agente SSH (ssh-add) também volta a ter acentos', () => { + assert.deepEqual( + parseAgentList(2, '', 'Error connecting to agent: O sistema n\\343o pode encontrar o arquivo especificado.\r\n'), + { available: false, message: 'Error connecting to agent: O sistema n\u00e3o pode encontrar o arquivo especificado.' }, + ); +}); diff --git a/test/terminals.test.ts b/test/terminals.test.ts index ae78985..a5b4c5a 100644 --- a/test/terminals.test.ts +++ b/test/terminals.test.ts @@ -4,6 +4,7 @@ import type { WebContents } from 'electron'; import { closeTerminalsOf, moveTerminal, onTerminalMoved, openTerminal, terminalKey, terminalOutput, terminalRunning, writeTerminal, } from '../src/desktop/terminals.ts'; +import { sshTextStream } from '../src/sessions/ssh-text.ts'; /** Janela falsa: só o que terminals.ts usa. */ function fakeWindow(id: number) { @@ -58,3 +59,39 @@ test('moveTerminal passa o processo para outra janela sem reiniciá-lo', async ( closeTerminalsOf(a.wc); assert.equal(terminalRunning(terminalKey(b.wc, 7)), true); }); + +test('filtro ajusta a saída antes da aba e do agente (acentos escapados pelo ssh)', async (t) => { + const w = fakeWindow(903); + t.after(() => closeTerminalsOf(w.wc)); + openTerminal(w.wc, 1, 'cmd.exe', ['/q', '/k', 'echo n\\343o'], { cols: 80, rows: 24, cwd: process.cwd() }, sshTextStream()); + await until(() => dataOf(w.sent, 1).includes('não')); + assert.doesNotMatch(dataOf(w.sent, 1), /\\343/); + // Saída bruta guardada para o agente: o tail() aplica \r e cursor de forma simplificada, e o + // redesenho do ConPTY (no CI) apaga o texto nele. + const saved = terminalOutput(terminalKey(w.wc, 1))!.since(0); + assert.match(saved, /não/); + assert.doesNotMatch(saved, /\\343/); +}); + +test('o que o filtro guarda sai sozinho se nada mais vier', async (t) => { + const w = fakeWindow(904); + t.after(() => closeTerminalsOf(w.wc)); + // Guarda tudo: a saída só chega pelo flush do prazo. + let held = ''; + const filter = { + push(chunk: string) { + held += chunk; + return ''; + }, + flush() { + const text = held; + held = ''; + return text; + }, + get pending() { + return held !== ''; + }, + }; + openTerminal(w.wc, 1, 'cmd.exe', ['/q', '/k', 'echo guardado'], { cols: 80, rows: 24, cwd: process.cwd() }, filter); + await until(() => dataOf(w.sent, 1).includes('guardado')); +}); 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())), + }; +}