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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ Todas as mudanças relevantes do MultTerm. O formato segue o

## [Não lançado]

### Adicionado

- **Arquivo → Importar → Hosts do Zabbix…**: conecta no Zabbix (6.0 ou mais novo) por API token
ou usuário e senha, lista os grupos e 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
Expand Down
40 changes: 39 additions & 1 deletion 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** (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 @@ -306,6 +307,43 @@ com as sessões existentes (mesma pasta + nome não é sobrescrito):
`identityFile` e `type=ssh|rdp`) preenchem a sessão; as do host valem sobre as do grupo.
Faixas como `web[01:03]` são expandidas.

### 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)

1. PowerShell como administrador:
Expand Down
158 changes: 158 additions & 0 deletions 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 @@ -1384,6 +1387,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();
Expand Down Expand Up @@ -1962,6 +2118,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; }
2 changes: 2 additions & 0 deletions src/desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { exportVpnSites, importVpnSites, registerVpnIpc } from './vpn.ts';
import { exportServerConfigs, importServerConfigs, registerServersIpc } from './servers.ts';
import { agentLaunch, exportAgentPrefs, importAgentPrefs, registerAgentIpc } from './agent.ts';
import { closeRdpOf, registerRdpIpc } from './rdp.ts';
import { registerZabbixIpc } from './zabbix.ts';
import { buildAppMenu } from './menu.ts';
import { BACKUP_EXTENSION, backupFileName, parseBackup, serializeBackup } from './backup.ts';
import { localShellCommand, type LocalShell } from './local-terminal.ts';
Expand Down Expand Up @@ -500,6 +501,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) => {
Expand Down
4 changes: 3 additions & 1 deletion src/desktop/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -42,6 +42,8 @@ export function buildAppMenu({ send, openProject, about }: MenuActions): Menu {
label: 'Importar',
submenu: [
cmd('import-sessions', 'Sessões (YAML ou inventário INI)…'),
cmd('import-zabbix', 'Hosts do Zabbix…'),
cmd('zabbix-sync', 'Sincronizar com o Zabbix'),
cmd('import-vpn', 'Site de VPN (.vpn)…'),
sep,
cmd('import-backup', 'Backup completo…'),
Expand Down
Loading
Loading