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 @@