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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user