From 128ca98030ac6dffa5b46a376af9a1beea9a2d9e Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 25 Sep 2026 22:21:04 +0200 Subject: [PATCH] Add firewall management over SSH for UFW and iptables/ip6tables: view and edit rules and default policies, persist iptables rules, SSH lockout protection Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++ src/i18n.js | 58 ++++++++++++ src/main/docker.js | 2 +- src/main/firewall.js | 180 +++++++++++++++++++++++++++++++++++++ src/main/main.js | 13 +++ src/renderer/app.js | 191 +++++++++++++++++++++++++++++++++++++++- src/renderer/styles.css | 16 ++++ 7 files changed, 467 insertions(+), 4 deletions(-) create mode 100644 src/main/firewall.js diff --git a/README.md b/README.md index db88d22..9d1805b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on - **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer - **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key - **Docker & Podman**: list a host's containers, open a shell inside a container, follow logs, and start, stop, restart or remove containers, all over SSH +- **Firewall**: view and edit UFW and iptables/ip6tables rules on your servers - **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D) - **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host - **Snippets**: save frequently used commands and send them to a terminal with one click @@ -78,6 +79,16 @@ Right-click an SSH host and choose **Docker containers**, or click **Docker** in Nothing needs to be installed on the server. MrTerm uses the `docker` command (or `podman` if Docker isn't installed). Your SSH user needs permission to run it, which usually means membership in the `docker` group (`sudo usermod -aG docker `). If a password is saved for the host, MrTerm falls back to `sudo` automatically. +## Firewall + +Right-click an SSH host and choose **Firewall**, or click **Firewall** in the toolbar of an open terminal. MrTerm supports **UFW** and **iptables/ip6tables**. If a server has both, you can switch between them at the top. + +- **UFW**: turn the firewall on or off, change the default policies for incoming and outgoing traffic, and add or delete rules (allow, deny, reject, limit, with port, protocol, source and comment). +- **iptables**: all chains with their rules, the policy of INPUT, FORWARD and OUTPUT, and adding or deleting rules. iptables changes are lost on reboot unless you click **Save permanently** (uses `netfilter-persistent` on Debian/Ubuntu or `/etc/iptables/*.rules` on Arch). +- **Lockout protection**: if you enable UFW without a rule that allows SSH, MrTerm warns you and offers to allow SSH first. Switching a default policy to blocking asks for confirmation. + +This needs root privileges. Either log in as root, or save the password of a user with sudo rights on the host. + ## VPN Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host. diff --git a/src/i18n.js b/src/i18n.js index 5b10eaf..baf6dc5 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -410,6 +410,64 @@ 'No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.': 'Keine Berechtigung für {runtime}. Füge den Benutzer der Gruppe „docker“ hinzu (sudo usermod -aG docker {user}) oder hinterlege das Host-Passwort, damit MrTerm sudo verwenden kann.', '{runtime} exited with code {code}': '{runtime} beendet mit Code {code}', + // Firewall + 'Firewall': 'Firewall', + 'Save the current iptables rules so they survive a reboot': 'Aktuelle iptables-Regeln speichern, damit sie einen Neustart überstehen', + 'Save permanently': 'Dauerhaft speichern', + 'Add rule': 'Regel hinzufügen', + 'Rules saved permanently': 'Regeln dauerhaft gespeichert', + 'Enable': 'Aktivieren', + 'Disable firewall?': 'Firewall deaktivieren?', + 'All UFW rules will stop filtering traffic.': 'Keine UFW-Regel filtert dann mehr den Datenverkehr.', + 'Enable firewall?': 'Firewall aktivieren?', + 'There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.': 'Es gibt keine Regel, die SSH (Port {port}) erlaubt. Wenn du UFW aktivierst, könntest du dich von diesem Server aussperren.', + 'Enable anyway': 'Trotzdem aktivieren', + 'Allow SSH and enable': 'SSH erlauben und aktivieren', + 'Firewall enabled': 'Firewall aktiviert', + 'Incoming': 'Eingehend', + 'Outgoing': 'Ausgehend', + 'Allow': 'Erlauben', + 'Deny': 'Verweigern', + 'Reject': 'Abweisen', + 'Change default policy?': 'Standardrichtlinie ändern?', + 'Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.': 'Eingehende Verbindungen ohne passende Regel werden dann blockiert. Stelle sicher, dass SSH erlaubt ist.', + 'Change': 'Ändern', + 'No rules yet.': 'Noch keine Regeln.', + 'To': 'Ziel', + 'Action': 'Aktion', + 'From': 'Quelle', + 'Comment': 'Kommentar', + 'Rule deleted': 'Regel gelöscht', + 'Changes apply immediately but are lost after a reboot unless you click “Save permanently”.': 'Änderungen gelten sofort, gehen nach einem Neustart aber verloren, wenn du nicht auf „Dauerhaft speichern“ klickst.', + 'rules': 'Regeln', + 'Policy': 'Richtlinie', + 'Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.': 'Datenverkehr ohne passende ACCEPT-Regel wird dann verworfen. Stelle sicher, dass SSH erlaubt ist, sonst sperrst du dich eventuell aus.', + 'Add UFW rule': 'UFW-Regel hinzufügen', + 'Limit (rate-limit)': 'Begrenzen (Rate-Limit)', + 'Direction': 'Richtung', + 'Port': 'Port', + 'Protocol': 'Protokoll', + 'Any': 'Beliebig', + 'From (IP or network)': 'Quelle (IP oder Netz)', + 'Insert at the top (highest priority)': 'Ganz oben einfügen (höchste Priorität)', + 'Add': 'Hinzufügen', + 'Rule added': 'Regel hinzugefügt', + 'Add {bin} rule': '{bin}-Regel hinzufügen', + 'Chain': 'Kette', + 'Target': 'Ziel', + 'Source (IP or network)': 'Quelle (IP oder Netz)', + 'Interface': 'Schnittstelle', + 'Connection state': 'Verbindungsstatus', + 'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}', + 'Firewall session not found': 'Firewall-Sitzung nicht gefunden', + 'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}', + 'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.', + 'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.', + 'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.', + 'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).', + 'A port needs the protocol TCP or UDP.': 'Für einen Port ist das Protokoll TCP oder UDP nötig.', + 'No known way to save iptables rules on this host (e.g. install iptables-persistent).': 'Keine bekannte Möglichkeit, iptables-Regeln auf diesem Host zu speichern (z. B. iptables-persistent installieren).', + // Main-Prozess 'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Unknown host': 'Unbekannter Host', diff --git a/src/main/docker.js b/src/main/docker.js index 7121c52..cd327d0 100644 --- a/src/main/docker.js +++ b/src/main/docker.js @@ -111,4 +111,4 @@ class DockerManager { } } -module.exports = { DockerManager }; +module.exports = { DockerManager, execOn }; diff --git a/src/main/firewall.js b/src/main/firewall.js new file mode 100644 index 0000000..5ae0b62 --- /dev/null +++ b/src/main/firewall.js @@ -0,0 +1,180 @@ +// Firewall entfernter Hosts über SSH einsehen und bearbeiten: UFW sowie iptables/ip6tables (Tabelle filter). +// Braucht root: entweder als root angemeldet oder sudo mit dem gespeicherten Host-Passwort (über stdin). +// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen. +const i18n = require('../i18n'); +const { execOn } = require('./docker'); + +const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || ''; +const RX = { + port: /^\d{1,5}([:,]\d{1,5})*$/, + addr: /^(any|[0-9a-fA-F.:]+(\/\d{1,3})?)$/, + comment: /^[^'"\\`$\n]{0,80}$/, + chain: /^[A-Za-z0-9_.-]{1,40}$/, + iface: /^[A-Za-z0-9_.@-]{1,15}\+?$/, +}; +const check = (v, rx, what) => { if (!rx.test(String(v))) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: what, value: v })); return String(v); }; + +// UFW-Regeln aus `ufw status numbered` +function parseUfw(out) { + const status = /Status:\s*active/i.test(out) ? 'active' : 'inactive'; + const rules = []; + for (const line of out.split('\n')) { + const m = line.match(/^\[\s*(\d+)\]\s+(.+?)\s{2,}(ALLOW|DENY|REJECT|LIMIT)(?:\s+(IN|OUT|FWD))?\s+(.+?)\s*$/); + if (!m) continue; + let [, num, to, action, dir, from] = m; + let comment = ''; + const c = from.match(/^(.*?)\s+#\s*(.*)$/); + if (c) { from = c[1].trim(); comment = c[2]; } + rules.push({ num: Number(num), to: to.trim(), action, dir: dir || 'IN', from: from.trim(), comment, v6: /\(v6\)/.test(to + from) }); + } + return { status, rules }; +} + +// iptables -S: Richtlinien, Ketten und Regeln (Nummer = Position in der Kette) +function parseIptables(out) { + const chains = new Map(); + const get = (n) => { if (!chains.has(n)) chains.set(n, { name: n, policy: null, rules: [] }); return chains.get(n); }; + for (const line of out.split('\n')) { + let m; + if ((m = line.match(/^-P (\S+) (\S+)/))) get(m[1]).policy = m[2]; + else if ((m = line.match(/^-N (\S+)/))) get(m[1]); + else if ((m = line.match(/^-A (\S+) (.*)$/))) { const c = get(m[1]); c.rules.push({ num: c.rules.length + 1, spec: m[2] }); } + } + return [...chains.values()]; +} + +class FirewallManager { + constructor(ssh) { + this.ssh = ssh; + this.sessions = new Map(); // id -> { conn, jumps, host, sudo, tools } + } + + get(id) { + const s = this.sessions.get(id); + if (!s) throw new Error(i18n.t('Firewall session not found')); + return s; + } + + // Befehl mit root-Rechten ausführen + run(s, cmd) { + if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`); + return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`); + } + async runOk(s, cmd) { + const r = await this.run(s, cmd); + if (r.code) throw new Error(lastLine(r.err) || lastLine(r.out) || i18n.t('Command failed with code {code}', { code: r.code })); + return r.out; + } + + async open(id, host, onClose) { + const { conn, jumps } = await this.ssh.connect(host, id); + const s = { conn, jumps, host, sudo: false, tools: [] }; + this.sessions.set(id, s); + conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } }); + conn.on('error', () => {}); + const uid = (await execOn(conn, 'id -u')).out.trim(); + if (uid !== '0') { + s.sudo = true; + const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`); + if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.')); + } + const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\''); + s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean); + if (!s.tools.includes('ufw') && !s.tools.includes('iptables')) { + throw new Error(s.tools.includes('firewall-cmd') ? i18n.t('This host uses firewalld, which is not supported yet.') : i18n.t('Neither UFW nor iptables was found on this host.')); + } + return { tools: s.tools, sudo: s.sudo }; + } + + async list(id, backend) { + const s = this.get(id); + if (backend === 'ufw') { + const out = await this.runOk(s, 'ufw status numbered'); + const verbose = await this.runOk(s, 'ufw status verbose'); + const def = verbose.match(/Default:\s*(\w+)\s*\(incoming\),\s*(\w+)\s*\(outgoing\)(?:,\s*(\w+)\s*\(routed\))?/i); + return { ...parseUfw(out), defaults: def ? { incoming: def[1], outgoing: def[2], routed: def[3] || '' } : null }; + } + const bin = backend === 'ip6tables' ? 'ip6tables' : 'iptables'; + return { chains: parseIptables(await this.runOk(s, `${bin} -S`)) }; + } + + async ufw(id, op, a = {}) { + const s = this.get(id); + switch (op) { + case 'enable': return this.runOk(s, 'ufw --force enable'); + case 'disable': return this.runOk(s, 'ufw disable'); + case 'delete': return this.runOk(s, `ufw --force delete ${Number(a.num)}`); + case 'default': { + const pol = check(a.policy, /^(allow|deny|reject)$/, 'policy'); + const dir = check(a.dir, /^(incoming|outgoing|routed)$/, 'direction'); + return this.runOk(s, `ufw default ${pol} ${dir}`); + } + case 'add': { + const action = check(a.action, /^(allow|deny|reject|limit)$/, 'action'); + const dir = check(a.dir || 'in', /^(in|out)$/, 'direction'); + const from = check(a.from || 'any', RX.addr, 'from'); + const to = check(a.to || 'any', RX.addr, 'to'); + const parts = ['ufw', a.top ? 'insert 1' : '', action, dir]; + if (a.port) { + check(a.port, RX.port, 'port'); + const proto = check(a.proto || 'any', /^(any|tcp|udp)$/, 'protocol'); + // Portbereiche/-listen verlangen bei UFW ein Protokoll + if (/[:,]/.test(a.port) && proto === 'any') throw new Error(i18n.t('Port ranges and lists need a protocol (TCP or UDP).')); + if (proto !== 'any') parts.push('proto', proto); + parts.push('from', from, 'to', to, 'port', a.port); + } else parts.push('from', from, 'to', to); + if (a.comment) parts.push('comment', `'${check(a.comment, RX.comment, 'comment')}'`); + return this.runOk(s, parts.filter(Boolean).join(' ')); + } + default: throw new Error('Invalid operation'); + } + } + + async ipt(id, op, a = {}) { + const s = this.get(id); + const bin = a.family === 6 ? 'ip6tables' : 'iptables'; + switch (op) { + case 'delete': return this.runOk(s, `${bin} -D ${check(a.chain, RX.chain, 'chain')} ${Number(a.num)}`); + case 'policy': return this.runOk(s, `${bin} -P ${check(a.chain, /^(INPUT|OUTPUT|FORWARD)$/, 'chain')} ${check(a.policy, /^(ACCEPT|DROP)$/, 'policy')}`); + case 'add': { + const parts = [bin, a.top ? '-I' : '-A', check(a.chain, RX.chain, 'chain')]; + const proto = check(a.proto || 'all', /^(all|tcp|udp|icmp|icmpv6)$/, 'protocol'); + if (proto !== 'all') parts.push('-p', proto); + if (a.source && a.source !== 'any') parts.push('-s', check(a.source, RX.addr, 'source')); + if (a.iface) parts.push(a.chain === 'OUTPUT' ? '-o' : '-i', check(a.iface, RX.iface, 'interface')); + if (a.port) { + check(a.port, RX.port, 'port'); + if (proto !== 'tcp' && proto !== 'udp') throw new Error(i18n.t('A port needs the protocol TCP or UDP.')); + if (a.port.includes(',')) parts.push('-m', 'multiport', '--dports', a.port); + else parts.push('--dport', a.port); + } + if (a.state) parts.push('-m', 'conntrack', '--ctstate', check(a.state, /^[A-Z,]+$/, 'state')); + if (a.comment) parts.push('-m', 'comment', '--comment', `'${check(a.comment, RX.comment, 'comment')}'`); + parts.push('-j', check(a.target, /^(ACCEPT|DROP|REJECT|LOG|RETURN)$/, 'target')); + return this.runOk(s, parts.join(' ')); + } + case 'save': { + // Dauerhaft speichern: Debian/Ubuntu (netfilter-persistent bzw. rules.v4/v6) oder Arch (iptables.rules) + const script = 'if command -v netfilter-persistent >/dev/null 2>&1; then netfilter-persistent save; ' + + 'elif [ -f /etc/debian_version ] && [ -d /etc/iptables ]; then iptables-save > /etc/iptables/rules.v4 && ip6tables-save > /etc/iptables/rules.v6; ' + + 'elif [ -d /etc/iptables ]; then iptables-save > /etc/iptables/iptables.rules && ip6tables-save > /etc/iptables/ip6tables.rules; ' + + 'else exit 3; fi'; + const r = await this.run(s, `sh -c '${script}'`); + if (r.code === 3) throw new Error(i18n.t('No known way to save iptables rules on this host (e.g. install iptables-persistent).')); + if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code })); + return true; + } + default: throw new Error('Invalid operation'); + } + } + + close(id) { + const s = this.sessions.get(id); + if (!s) return; + this.sessions.delete(id); + try { s.conn.end(); } catch {} + s.jumps?.forEach((c) => { try { c.end(); } catch {} }); + } +} + +module.exports = { FirewallManager, parseUfw, parseIptables }; diff --git a/src/main/main.js b/src/main/main.js index 6b88da8..8450873 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -13,6 +13,7 @@ const i18n = require('../i18n'); const fido = require('./fido'); const { VpnManager } = require('./vpn'); const { DockerManager } = require('./docker'); +const { FirewallManager } = require('./firewall'); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); let win; @@ -63,6 +64,7 @@ const ssh = new SshManager(store, confirmHostKey, askSecret); const updater = new Updater(store, send); const vpn = new VpnManager(store, app.getPath('userData')); const docker = new DockerManager(ssh); +const firewall = new FirewallManager(ssh); function createWindow() { win = new BrowserWindow({ @@ -208,6 +210,17 @@ handle('docker:action', (id, action, cid) => docker.action(id, action, cid)); handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid)); handle('docker:close', (id) => docker.close(id)); +// ---------- Firewall ---------- +handle('firewall:open', async (id, hostRef) => { + const host = hostWithOverrides(hostRef); + if (await vpn.ensureForHost(host)) send('vpn:changed'); + return firewall.open(id, host, () => send('firewall:closed', id)); +}); +handle('firewall:list', (id, backend) => firewall.list(id, backend)); +handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args)); +handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args)); +handle('firewall:close', (id) => firewall.close(id)); + // ---------- VPN ---------- handle('vpn:status', () => vpn.status()); handle('vpn:up', (id) => vpn.up(id)); diff --git a/src/renderer/app.js b/src/renderer/app.js index 89ca3c2..d88cfac 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -41,6 +41,7 @@ const ICONS = { download: '', upload: '', docker: '', + wall: '', logs: '', }; const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100']; @@ -389,7 +390,7 @@ function hostMenu(hst) { return [ { label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, ...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []), - ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }] : []), + ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }] : []), '-', { label: T('Edit'), icon: 'edit', run: () => editHost(hst) }, { label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } }, @@ -1160,7 +1161,7 @@ function addTab(session) { tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); }; tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); }; tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ - ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker }[session.kind] || openTerminal)(session.host) }] : []), + ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall }[session.kind] || openTerminal)(session.host) }] : []), ...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []), { label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) }, @@ -1207,7 +1208,7 @@ class TerminalSession { this.el = h(`
${avatar(host, 22).replace('SSH', '')}${esc(hostSub(host))}
- ${host.execCommand ? '' : ``} + ${host.execCommand ? '' : ``}
@@ -1266,6 +1267,7 @@ class TerminalSession { const a = e.target.closest('[data-a]')?.dataset.a; if (a === 'sftp') openSftp(this.host); if (a === 'docker') openDocker(this.host); + if (a === 'firewall') openFirewall(this.host); if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); } if (a === 'snipnew') editSnippet(); if (a === 'find') this.toggleFind(); @@ -1787,6 +1789,188 @@ class DockerSession { } function openDocker(host) { return new DockerSession(host); } +// ============================================================ Firewall (UFW / iptables über SSH) +class FirewallSession { + constructor(host) { + this.kind = 'firewall'; this.id = uid(); this.host = host; + this.title = `${T('Firewall')} · ${host.label || host.address}`; + this.backend = null; this.data = null; + this.el = h(`
+
${avatar(host, 22)}${esc(hostSub(host))}
+
+ + + +
+
${esc(T('Connecting to {host} …', { host: host.address }))}
`); + this.body = $('.fw-body', this.el); + this.el.addEventListener('click', (e) => { + const a = e.target.closest('[data-a]')?.dataset.a; + if (a === 'refresh') this.refresh(); + if (a === 'add') this.backend === 'ufw' ? this.addUfw() : this.addIpt(); + if (a === 'save') this.op('ipt', 'save', {}, T('Rules saved permanently')); + }); + this.unsub = api.on('firewall:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } }); + addTab(this); + this.open(); + } + + async open() { + try { + const r = await api.call('firewall:open', this.id, hostRef(this.host)); + $('.rt', this.el).textContent = r.sudo ? ' · sudo' : ' · root'; + const opts = [...(r.tools.includes('ufw') ? [['ufw', 'UFW']] : []), ...(r.tools.includes('iptables') ? [['iptables', 'iptables']] : []), ...(r.tools.includes('ip6tables') ? [['ip6tables', 'ip6tables']] : [])]; + const seg = $('.backends', this.el); + opts.forEach(([id, l]) => { const b = h(``); b.onclick = () => this.switchTo(id); seg.append(b); }); + setTabState(this, 'on'); + $('[data-a=add]', this.el).disabled = false; + await this.switchTo(opts[0][0]); + } catch (e) { + setTabState(this, 'err'); + this.error(e.message); + } + } + + error(msg) { + this.body.innerHTML = `
${esc(msg)}
`; + const b = h(``); + b.onclick = () => { closeTab(this.id); openFirewall(this.host); }; + $('.pane-empty', this.body).append(b); + } + + async switchTo(b) { + this.backend = b; + $$('.backends button', this.el).forEach((x) => x.classList.toggle('active', x.dataset.b === b)); + $('[data-a=save]', this.el).style.display = b === 'ufw' ? 'none' : ''; + await this.refresh(); + } + + async refresh() { + try { this.data = await api.call('firewall:list', this.id, this.backend); this.draw(); } catch (e) { toast(e.message, 'error'); } + } + + // Aktion ausführen, danach neu laden + async op(kind, op, args, okMsg) { + try { await call(kind === 'ufw' ? 'firewall:ufw' : 'firewall:ipt', this.id, op, args); if (okMsg) toast(okMsg, 'ok'); } catch { return false; } + await this.refresh(); + return true; + } + + sshPort() { return String(this.host.port || 22); } + + draw() { + const scroll = this.body.scrollTop; + this.body.innerHTML = ''; + if (this.backend === 'ufw') this.drawUfw(); else this.drawIpt(); + this.body.scrollTop = scroll; + } + + drawUfw() { + const d = this.data; + const on = d.status === 'active'; + const head = h(`
${on ? T('active') : T('inactive')}
`); + const tgl = h(``); + tgl.onclick = async () => { + if (on) { if (await confirmBox(T('Disable firewall?'), T('All UFW rules will stop filtering traffic.'), T('Disable'))) this.op('ufw', 'disable', {}); return; } + const sshOk = d.rules.some((r) => r.action !== 'DENY' && r.action !== 'REJECT' && r.dir === 'IN' && new RegExp(`(^|[^0-9])${this.sshPort()}(/tcp)?($|[^0-9])|OpenSSH|ssh`, 'i').test(r.to)); + if (!sshOk) { + const r = await modal({ title: T('Enable firewall?'), text: T('There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.', { port: this.sshPort() }), + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Enable anyway'), value: 'force', cls: 'danger' }, { label: T('Allow SSH and enable'), value: 'ssh', cls: 'primary' }] }); + if (!r) return; + if (r === 'ssh' && !(await this.op('ufw', 'add', { action: 'allow', dir: 'in', port: this.sshPort(), proto: 'tcp', comment: 'SSH (MrTerm)' }))) return; + } + this.op('ufw', 'enable', {}, T('Firewall enabled')); + }; + head.append(tgl); + if (d.defaults) { + for (const dir of ['incoming', 'outgoing']) { + const sel = h(``); + $('select', sel).onchange = async (e) => { + const pol = e.target.value; + if (dir === 'incoming' && pol !== 'allow' && on && !(await confirmBox(T('Change default policy?'), T('Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.'), T('Change')))) return this.draw(); + this.op('ufw', 'default', { policy: pol, dir }); + }; + head.append(sel); + } + } + this.body.append(head); + if (!d.rules.length) { this.body.append(h(`
${T('No rules yet.')}
`)); return; } + const t = h(`
#${T('To')}${T('Action')}${T('From')}${T('Comment')}
`); + for (const r of d.rules) { + const tr = h(`${r.num}${esc(r.to)}${esc(r.action)} ${esc(r.dir)}${esc(r.from)}${esc(r.comment)} + `); + $('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${r.to} · ${r.action} ${r.dir} · ${r.from}`)) this.op('ufw', 'delete', { num: r.num }, T('Rule deleted')); }; + $('tbody', t).append(tr); + } + this.body.append(t); + } + + drawIpt() { + const fam = this.backend === 'ip6tables' ? 6 : 4; + const builtin = ['INPUT', 'FORWARD', 'OUTPUT']; + const chains = [...this.data.chains].sort((a, b) => ((builtin.indexOf(a.name) + 1 || 99) - (builtin.indexOf(b.name) + 1 || 99))); + this.body.append(h(`
${T('Changes apply immediately but are lost after a reboot unless you click “Save permanently”.')}
`)); + for (const c of chains) { + const sec = h(`
${esc(c.name)}${c.rules.length} ${T('rules')}
`); + if (c.policy && builtin.includes(c.name)) { + const sel = h(``); + $('select', sel).onchange = async (e) => { + if (e.target.value === 'DROP' && c.name !== 'FORWARD' && !(await confirmBox(T('Change default policy?'), T('Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.'), T('Change')))) return this.draw(); + this.op('ipt', 'policy', { family: fam, chain: c.name, policy: e.target.value }); + }; + $('.fw-chain-head', sec).append(sel); + } + if (c.rules.length) { + const t = h(`
`); + for (const r of c.rules) { + const tgt = (r.spec.match(/-j (\S+)/) || [])[1] || ''; + const tr = h(`${r.num}${esc(r.spec)}${esc(tgt)} + `); + $('button', tr).onclick = async () => { if (await confirmBox(T('Delete rule?'), `${c.name} #${r.num}: ${r.spec}`)) this.op('ipt', 'delete', { family: fam, chain: c.name, num: r.num }, T('Rule deleted')); }; + $('tbody', t).append(tr); + } + sec.append(t); + } + this.body.append(sec); + } + } + + async addUfw() { + const r = await modal({ title: T('Add UFW rule'), body: ` +
+
+
+
+
+
+ `, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] }); + if (!r) return; + this.op('ufw', 'add', { ...r, port: r.port.replace(/\s/g, ''), from: r.from.trim() || 'any', comment: r.comment.trim() }, T('Rule added')); + } + + async addIpt() { + const fam = this.backend === 'ip6tables' ? 6 : 4; + const chains = this.data.chains.map((c) => c.name); + const r = await modal({ title: T('Add {bin} rule', { bin: this.backend }), body: ` +
+
+
+
+
+
+
+
+ `, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] }); + if (!r) return; + this.op('ipt', 'add', { ...r, family: fam, port: r.port.replace(/\s/g, ''), source: r.source.trim(), iface: r.iface.trim(), comment: r.comment.trim() }, T('Rule added')); + } + + dispose() { this.unsub(); api.call('firewall:close', this.id).catch(() => {}); } +} +function openFirewall(host) { return new FirewallSession(host); } + // ============================================================ Command Palette / Quick Connect function openPalette() { if ($('.palette')) return; @@ -1806,6 +1990,7 @@ function openPalette() { items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.folder}
`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.docker}
`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) }); + if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.wall}
`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) }); }); S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `
${ICONS.code}
`, label: s.label, sub: s.command, run: () => runSnippet(s) })); [[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]] diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 987f3b6..99d65ae 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -386,3 +386,19 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi .docker-table .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); } .docker-table .dot.on { background: var(--green); } .docker-table .dot.wait { background: var(--orange); } + +/* Firewall */ +.seg.sm button { padding: 4px 10px; font-size: 12px; } +.fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } +.fw-def { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin-left: 8px; } +.fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); } +.fw-note { padding: 10px 16px; color: var(--faint); font-size: 12px; border-bottom: 1px solid var(--border); } +.fw-chain-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px 8px; } +.fw-chain-head .muted { color: var(--faint); font-size: 12px; } +.fw-chain-head .fw-def { margin-left: auto; } +.fw-table td.spec { font-size: 12px; white-space: normal; word-break: break-all; } +.fw-act { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; background: rgb(var(--tint) / 0.063); color: var(--muted); } +.fw-act.allow, .fw-act.accept { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); } +.fw-act.deny, .fw-act.reject { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } +.fw-act.limit { background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); } +.fw-table th:first-child, .fw-table td:first-child { width: 44px; text-align: left; padding-left: 16px; }