Compare commits

...
2 Commits
9 changed files with 470 additions and 7 deletions
+11
View File
@@ -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 - **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 - **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 - **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) - **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 - **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 - **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 <user>`). If a password is saved for the host, MrTerm falls back to `sudo` automatically. 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 <user>`). 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 ## 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. 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.7.0", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.7.0", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.7.0", "version": "0.8.0",
"description": "Moderner SSH-, SFTP- und RDP-Client", "description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js", "main": "src/main/main.js",
"author": "MrBlake", "author": "MrBlake",
+58
View File
@@ -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.', '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}', '{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 // Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host', 'Unknown host': 'Unbekannter Host',
+1 -1
View File
@@ -111,4 +111,4 @@ class DockerManager {
} }
} }
module.exports = { DockerManager }; module.exports = { DockerManager, execOn };
+180
View File
@@ -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 };
+13
View File
@@ -13,6 +13,7 @@ const i18n = require('../i18n');
const fido = require('./fido'); const fido = require('./fido');
const { VpnManager } = require('./vpn'); const { VpnManager } = require('./vpn');
const { DockerManager } = require('./docker'); const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; let win;
@@ -63,6 +64,7 @@ const ssh = new SshManager(store, confirmHostKey, askSecret);
const updater = new Updater(store, send); const updater = new Updater(store, send);
const vpn = new VpnManager(store, app.getPath('userData')); const vpn = new VpnManager(store, app.getPath('userData'));
const docker = new DockerManager(ssh); const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
function createWindow() { function createWindow() {
win = new BrowserWindow({ 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:command', (id, kind, cid) => docker.command(id, kind, cid));
handle('docker:close', (id) => docker.close(id)); 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 ---------- // ---------- VPN ----------
handle('vpn:status', () => vpn.status()); handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id)); handle('vpn:up', (id) => vpn.up(id));
+188 -3
View File
@@ -41,6 +41,7 @@ const ICONS = {
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>', download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>', upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>', docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>', logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
}; };
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100']; const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
@@ -389,7 +390,7 @@ function hostMenu(hst) {
return [ return [
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, { 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 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('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(); } }, { 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.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.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ 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() }] : []), ...(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('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) }, '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
@@ -1207,7 +1208,7 @@ class TerminalSession {
this.el = h(`<div class="session"> this.el = h(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div> <div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button> <button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button>`} ${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button>`}
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button> <button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button> <button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
</div> </div>
@@ -1266,6 +1267,7 @@ class TerminalSession {
const a = e.target.closest('[data-a]')?.dataset.a; const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'sftp') openSftp(this.host); if (a === 'sftp') openSftp(this.host);
if (a === 'docker') openDocker(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 === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
if (a === 'snipnew') editSnippet(); if (a === 'snipnew') editSnippet();
if (a === 'find') this.toggleFind(); if (a === 'find') this.toggleFind();
@@ -1787,6 +1789,188 @@ class DockerSession {
} }
function openDocker(host) { return new DockerSession(host); } 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(`<div class="session firewall">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<div class="seg sm backends"></div>
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
</div>
<div class="docker-body fw-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
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(`<button data-b="${id}">${l}</button>`); 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 = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center">${esc(msg)}</div></div>`;
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
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(`<div class="fw-head"><span class="status-pill ${on ? 'on' : ''}">${on ? T('active') : T('inactive')}</span></div>`);
const tgl = h(`<button class="btn sm ${on ? '' : 'primary'}">${on ? T('Disable') : T('Enable')}</button>`);
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(`<label class="fw-def">${dir === 'incoming' ? T('Incoming') : T('Outgoing')}<select>${['allow', 'deny', 'reject'].map((p) => `<option value="${p}" ${d.defaults[dir] === p ? 'selected' : ''}>${{ allow: T('Allow'), deny: T('Deny'), reject: T('Reject') }[p]}</option>`).join('')}</select></label>`);
$('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(`<div class="pane-empty" style="height:auto;padding:40px"><div style="color:var(--muted)">${T('No rules yet.')}</div></div>`)); return; }
const t = h(`<table class="docker-table fw-table"><thead><tr><th>#</th><th>${T('To')}</th><th>${T('Action')}</th><th>${T('From')}</th><th>${T('Comment')}</th><th></th></tr></thead><tbody></tbody></table>`);
for (const r of d.rules) {
const tr = h(`<tr><td class="num">${r.num}</td><td><b>${esc(r.to)}</b></td><td><span class="fw-act ${r.action.toLowerCase()}">${esc(r.action)} ${esc(r.dir)}</span></td><td class="muted">${esc(r.from)}</td><td class="muted">${esc(r.comment)}</td>
<td class="acts"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
$('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(`<div class="fw-note">${T('Changes apply immediately but are lost after a reboot unless you click “Save permanently”.')}</div>`));
for (const c of chains) {
const sec = h(`<div class="fw-chain"><div class="fw-chain-head"><b>${esc(c.name)}</b><span class="muted">${c.rules.length} ${T('rules')}</span></div></div>`);
if (c.policy && builtin.includes(c.name)) {
const sel = h(`<label class="fw-def">${T('Policy')}<select>${['ACCEPT', 'DROP'].map((p) => `<option ${c.policy === p ? 'selected' : ''}>${p}</option>`).join('')}</select></label>`);
$('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(`<table class="docker-table fw-table"><tbody></tbody></table>`);
for (const r of c.rules) {
const tgt = (r.spec.match(/-j (\S+)/) || [])[1] || '';
const tr = h(`<tr><td class="num" style="width:36px">${r.num}</td><td class="mono spec">${esc(r.spec)}</td><td style="width:90px"><span class="fw-act ${tgt === 'ACCEPT' ? 'allow' : /DROP|REJECT/.test(tgt) ? 'deny' : ''}">${esc(tgt)}</span></td>
<td class="acts" style="width:50px"><button class="btn ghost sm" title="${esc(T('Delete'))}">${ICONS.trash}</button></td></tr>`);
$('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: `
<div class="row"><div class="field"><label>${T('Action')}</label><select name="action"><option value="allow">${T('Allow')}</option><option value="deny">${T('Deny')}</option><option value="reject">${T('Reject')}</option><option value="limit">${T('Limit (rate-limit)')}</option></select></div>
<div class="field"><label>${T('Direction')}</label><select name="dir"><option value="in">${T('Incoming')}</option><option value="out">${T('Outgoing')}</option></select></div></div>
<div class="row"><div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div>
<div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="any">${T('Any')}</option><option value="tcp">TCP</option><option value="udp">UDP</option></select></div></div>
<div class="field"><label>${T('From (IP or network)')}</label><input name="from" placeholder="any, 192.168.0.0/24"/></div>
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
<label class="check"><input type="checkbox" name="top"/>${T('Insert at the top (highest priority)')}</label>`,
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: `
<div class="row"><div class="field"><label>${T('Chain')}</label><select name="chain">${chains.map((c) => `<option ${c === 'INPUT' ? 'selected' : ''}>${esc(c)}</option>`).join('')}</select></div>
<div class="field"><label>${T('Target')}</label><select name="target"><option>ACCEPT</option><option>DROP</option><option>REJECT</option><option>LOG</option><option>RETURN</option></select></div></div>
<div class="row"><div class="field small"><label>${T('Protocol')}</label><select name="proto"><option value="tcp">TCP</option><option value="udp">UDP</option><option value="${fam === 6 ? 'icmpv6' : 'icmp'}">ICMP</option><option value="all">${T('Any')}</option></select></div>
<div class="field"><label>${T('Port')}</label><input name="port" placeholder="22, 80,443, 6000:6010"/></div></div>
<div class="row"><div class="field"><label>${T('Source (IP or network)')}</label><input name="source" placeholder="any, ${fam === 6 ? 'fd00::/8' : '192.168.0.0/24'}"/></div>
<div class="field small"><label>${T('Interface')}</label><input name="iface" placeholder="eth0"/></div></div>
<div class="field"><label>${T('Connection state')}</label><select name="state"><option value="">${T('Any')}</option><option value="NEW">NEW</option><option value="ESTABLISHED,RELATED">ESTABLISHED,RELATED</option></select></div>
<div class="field"><label>${T('Comment')}</label><input name="comment" placeholder="${esc(T('optional'))}"/></div>
<label class="check"><input type="checkbox" name="top" checked/>${T('Insert at the top (highest priority)')}</label>`,
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 // ============================================================ Command Palette / Quick Connect
function openPalette() { function openPalette() {
if ($('.palette')) return; 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) }); 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: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.wall}</div>`, 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: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) })); S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, 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()]] [[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
+16
View File
@@ -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 { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.docker-table .dot.on { background: var(--green); } .docker-table .dot.on { background: var(--green); }
.docker-table .dot.wait { background: var(--orange); } .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; }