Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 43 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
167 changes: 166 additions & 1 deletion desktop/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -1376,14 +1386,167 @@ 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) {
message(cleanError(err), 'error');
}
}

/**
* 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();
Expand Down Expand Up @@ -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(),
Expand Down
29 changes: 29 additions & 0 deletions desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,35 @@ <h2 id="key-title">Chave SSH padrão</h2>
</form>
</dialog>

<dialog id="zabbix-dialog" aria-labelledby="zabbix-title">
<form id="zabbix-form" method="dialog" class="small-dialog">
<h2 id="zabbix-title">Hosts do Zabbix</h2>
<label>Endereço <input name="url" required autocomplete="off" spellcheck="false" placeholder="https://zabbix.exemplo.com"></label>
<label>Autenticação
<select name="authType">
<option value="token">API token</option>
<option value="password">Usuário e senha</option>
</select>
</label>
<label class="zabbix-token">API token <input name="token" type="password" autocomplete="off"></label>
<label class="zabbix-password">Usuário <input name="user" autocomplete="off" spellcheck="false"></label>
<label class="zabbix-password">Senha <input name="password" type="password" autocomplete="off"></label>
<label class="check"><input type="checkbox" name="insecure"> Aceitar certificado autoassinado</label>
<div class="actions"><button type="button" id="zabbix-connect">Conectar e listar grupos</button></div>
<div id="zabbix-groups-box" class="zabbix-groups-box" hidden>
<p id="zabbix-version" class="muted"></p>
<input id="zabbix-filter" type="search" placeholder="Filtrar grupos" autocomplete="off">
<div id="zabbix-groups" class="zabbix-groups" role="group" aria-label="Grupos do Zabbix"></div>
<label>Pasta de destino <input name="rootFolder" autocomplete="off" placeholder="vazio = raiz"></label>
</div>
<p id="zabbix-error" class="form-error" aria-live="polite"></p>
<div class="actions">
<button type="submit" id="zabbix-import" class="primary" disabled>Sincronizar…</button>
<button type="button" id="zabbix-cancel">Cancelar</button>
</div>
</form>
</dialog>

<dialog id="confirm-dialog" aria-labelledby="confirm-title">
<form id="confirm-form" method="dialog" class="small-dialog">
<h2 id="confirm-title">Confirmar</h2>
Expand Down
4 changes: 4 additions & 0 deletions desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
23 changes: 23 additions & 0 deletions desktop/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Loading
Loading