Add network management over SSH for Ubuntu (netplan) and Debian/Proxmox (ifupdown): interfaces, IPs, DHCP, gateway, DNS, bonds with LACP, bridges, VLANs, hostname and config files, applied with automatic rollback; UFW shows a hint when disabled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 22:40:58 +02:00
co-authored by Claude Opus 5.5
parent 90f0907430
commit 352a887a67
7 changed files with 1076 additions and 3 deletions
+323
View File
@@ -0,0 +1,323 @@
// Netzwerk-Tab: Schnittstellen, IPs, Bonds (LACP), Bridges, VLANs, Gateway, DNS und Hostname entfernter Hosts.
// Bearbeiten für Ubuntu (netplan) und Debian/Proxmox (ifupdown/ifupdown2); sonst Übersicht + Datei-Editor.
// Änderungen werden mit automatischem Rollback angewendet: Der Server stellt die vorherige Konfiguration
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
const i18n = require('../i18n');
const { execOn } = require('./docker');
const nc = require('./netconf');
const STATE = '/var/lib/mrterm-net';
const ROLLBACK_SECONDS = 90;
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const b64 = (s) => Buffer.from(String(s)).toString('base64');
const HOSTNAME = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const EDITABLE_PATH = /^(\/etc\/netplan\/[\w.-]+\.yaml|\/etc\/network\/interfaces(\.d\/[\w.-]+)?|\/etc\/systemd\/network\/[\w.-]+|\/etc\/resolv\.conf|\/etc\/hosts)$/;
const BACKENDS = {
netplan: {
paths: ['etc/netplan'],
restore: `rm -rf /etc/netplan && mkdir -p /etc/netplan && tar xzf ${STATE}/backup.tgz -C /`,
validate: 'netplan generate',
apply: 'netplan apply',
},
ifupdown: {
paths: ['etc/network/interfaces', 'etc/network/interfaces.d'],
restore: `tar xzf ${STATE}/backup.tgz -C /`,
validate: 'if command -v ifreload >/dev/null 2>&1; then ifquery -a >/dev/null; else ifup --no-act -a >/dev/null; fi',
apply: 'if command -v ifreload >/dev/null 2>&1; then ifreload -a; else systemctl restart networking; fi',
},
networkd: {
paths: ['etc/systemd/network'],
restore: `rm -rf /etc/systemd/network && mkdir -p /etc/systemd/network && tar xzf ${STATE}/backup.tgz -C /`,
validate: 'true',
apply: 'networkctl reload 2>/dev/null || systemctl restart systemd-networkd',
},
};
// Merged netplan-Konfiguration (alle /etc/netplan/*.yaml) als JSON – netplan bringt python3-yaml mit
const NETPLAN_PY = `import sys,json,glob,os
try:
import yaml
except Exception:
print(json.dumps({"error":"python3-yaml missing"})); sys.exit(0)
def merge(a,b):
for k,v in (b or {}).items():
if isinstance(v,dict) and isinstance(a.get(k),dict): merge(a[k],v)
else: a[k]=v
return a
files=sorted(glob.glob("/etc/netplan/*.yaml"), key=os.path.basename)
out={}
for f in files:
merge(out, yaml.safe_load(open(f)) or {})
print(json.dumps({"files":files,"config":out}))`;
const GATHER = `export PATH=$PATH:/usr/sbin:/sbin
echo "@@HOSTNAME"; hostname
echo "@@OS"; (. /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")
echo "@@BACKEND"
if ls /etc/netplan/*.yaml >/dev/null 2>&1 && command -v netplan >/dev/null 2>&1; then echo netplan
elif [ -f /etc/network/interfaces ] && { command -v ifreload >/dev/null 2>&1 || command -v ifup >/dev/null 2>&1; }; then echo ifupdown
elif systemctl is-active -q NetworkManager 2>/dev/null; then echo networkmanager
elif systemctl is-active -q systemd-networkd 2>/dev/null; then echo networkd
else echo unknown; fi
echo "@@LINK"; ip -j -d link show
echo "@@ADDR"; ip -j addr show
echo "@@ROUTE"; ip -j route show default
echo "@@ROUTE6"; ip -j -6 route show default
echo "@@RESOLVTYPE"; if [ -L /etc/resolv.conf ]; then echo symlink; else echo file; fi
echo "@@RESOLV"; cat /etc/resolv.conf 2>/dev/null
echo "@@RESOLVECTL"; resolvectl dns 2>/dev/null
echo "@@BONDING"; for f in /proc/net/bonding/*; do [ -f "$f" ] && { echo "== \${f##*/}"; cat "$f"; }; done
echo "@@NETPLAN"; if command -v netplan >/dev/null 2>&1; then python3 -c '${NETPLAN_PY}' 2>&1; fi
echo "@@IFUPDOWN"; for f in /etc/network/interfaces /etc/network/interfaces.d/*; do [ -f "$f" ] && { echo "==FILE $f"; cat "$f"; echo; }; done
echo "@@FILES"; ls -1d /etc/netplan/*.yaml /etc/network/interfaces /etc/network/interfaces.d/* /etc/systemd/network/* 2>/dev/null
echo "@@PENDING"; [ -f ${STATE}/pending ] && echo pending; [ -f ${STATE}/rolled-back ] && cat ${STATE}/rolled-back
echo "@@END"`;
function sections(out) {
const res = {};
let cur = null;
for (const line of out.split('\n')) {
const m = line.match(/^@@(\w+)$/);
if (m) { cur = m[1]; res[cur] = []; continue; }
if (cur) res[cur].push(line);
}
return Object.fromEntries(Object.entries(res).map(([k, v]) => [k, v.join('\n').trim()]));
}
const json = (s, def) => { try { return JSON.parse(s); } catch { return def; } };
function parseBonding(text) {
const out = {};
for (const part of text.split(/^== /m).filter(Boolean)) {
const [name, ...lines] = part.split('\n');
const b = { slaves: [] };
let slave = null;
for (const l of lines) {
const [k, ...v] = l.split(':');
const val = v.join(':').trim();
if (k === 'Bonding Mode') b.mode = val;
else if (k === 'Transmit Hash Policy') b.hashPolicy = val.replace(/\s*\(\d+\)$/, '');
else if (k === 'LACP rate') b.lacpRate = val;
else if (k === 'MII Polling Interval (ms)') b.miimon = val;
else if (k === 'Slave Interface') { slave = { name: val }; b.slaves.push(slave); }
else if (slave && k === 'MII Status') slave.mii = val;
else if (slave && k === 'Speed') slave.speed = val;
else if (slave && k === 'Aggregator ID') slave.aggregator = val;
else if (!slave && k === 'MII Status') b.mii = val;
else if (k.trim() === 'Partner Mac Address' && !b.partnerMac) b.partnerMac = val;
}
out[name.trim()] = b;
}
return out;
}
class NetworkConfigManager {
constructor(ssh) {
this.ssh = ssh;
this.sessions = new Map(); // id -> { conn, jumps, host, sudo, backend, blocks, netplan }
}
get(id) {
const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Network session not found'));
return s;
}
// Shell-Skript als root ausführen (Skript base64-kodiert, damit kein Quoting-Problem entsteht)
root(s, script, conn = s.conn) {
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
if (!s.sudo) return execOn(conn, cmd);
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
}
async rootOk(s, script) {
const r = await this.root(s, script);
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, onClose };
this.sessions.set(id, s);
this.watch(id, s);
if ((await execOn(conn, 'id -u')).out.trim() !== '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.'));
}
return this.read(id);
}
watch(id, s) {
s.conn.on('close', () => { if (this.sessions.get(id) === s && !s.replacing) { this.close(id); s.onClose(); } });
s.conn.on('error', () => {});
}
async read(id) {
const s = this.get(id);
const sec = sections(await this.rootOk(s, GATHER));
s.backend = sec.BACKEND || 'unknown';
const links = json(sec.LINK, []);
const addrs = json(sec.ADDR, []);
const bonding = parseBonding(sec.BONDING || '');
let config = [];
let note = '';
if (s.backend === 'netplan') {
const np = json(sec.NETPLAN, {});
if (np.error || !np.config) { note = i18n.t('netplan configuration could not be read ({err}).', { err: np.error || lastLine(sec.NETPLAN) }); s.netplan = null; }
else { s.netplan = np; config = nc.fromNetplan(np.config); }
} else if (s.backend === 'ifupdown') {
s.blocks = [];
for (const part of (sec.IFUPDOWN || '').split(/^==FILE /m).filter(Boolean)) {
const nl = part.indexOf('\n');
s.blocks.push(...nc.parseInterfaces(part.slice(nl + 1), part.slice(0, nl).trim()));
}
config = nc.fromIfupdown(s.blocks);
}
const interfaces = links.filter((l) => l.ifname !== 'lo').map((l) => {
const a = addrs.find((x) => x.ifname === l.ifname) || {};
return {
name: l.ifname, mac: l.address, mtu: l.mtu, state: l.operstate, master: l.master || '',
kind: l.linkinfo?.info_kind || (l.link_type === 'ether' ? 'ethernet' : l.link_type), slaveKind: l.linkinfo?.info_slave_kind || '',
vlanId: l.linkinfo?.info_data?.id, link: l.link || '',
addrs: (a.addr_info || []).filter((x) => x.scope !== 'link').map((x) => ({ addr: `${x.local}/${x.prefixlen}`, family: x.family, dynamic: !!x.dynamic })),
bonding: bonding[l.ifname] || null,
};
});
const resolv = sec.RESOLV || '';
return {
hostname: sec.HOSTNAME, os: sec.OS, backend: s.backend, editable: ['netplan', 'ifupdown'].includes(s.backend) && !note, note,
interfaces, config,
routes: [...json(sec.ROUTE, []), ...json(sec.ROUTE6, [])].map((r) => ({ via: r.gateway, dev: r.dev, metric: r.metric })),
dns: { servers: (resolv.match(/^nameserver\s+(\S+)/gm) || []).map((l) => l.split(/\s+/)[1]), search: ((resolv.match(/^search\s+(.+)$/m) || [])[1] || '').split(/\s+/).filter(Boolean), resolved: sec.RESOLVECTL || '', editable: sec.RESOLVTYPE === 'file' },
files: (sec.FILES || '').split('\n').filter(Boolean),
rolledBack: /\d/.test(sec.PENDING || '') ? sec.PENDING.split('\n').filter((x) => /\d/.test(x)).pop() : '',
};
}
// Schreibbefehle für eine Modelländerung erzeugen
plan(s, model, remove) {
const m = nc.validate(model, i18n.t);
if (s.backend === 'netplan') {
const cfg = nc.applyNetplan(s.netplan.config, m, { remove });
// Gesamte Konfiguration in eine Datei; bisherige Dateien werden deaktiviert (im Backup enthalten)
const others = s.netplan.files.filter((f) => f !== '/etc/netplan/90-mrterm.yaml');
return [
...others.map((f) => `mv '${f}' '${f}.mrterm-off'`),
`printf %s ${b64(`# Managed by MrTerm – previous files were renamed to *.yaml.mrterm-off\n${JSON.stringify(cfg, null, 2)}\n`)} | base64 -d > /etc/netplan/90-mrterm.yaml`,
'chmod 600 /etc/netplan/90-mrterm.yaml',
// cloud-init soll die Netzwerkkonfiguration beim nächsten Start nicht neu erzeugen
...(others.some((f) => /cloud-init/.test(f)) ? ["[ -d /etc/cloud/cloud.cfg.d ] && echo 'network: {config: disabled}' > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg || true"] : []),
];
}
if (s.backend === 'ifupdown') {
const { files } = nc.applyIfupdown(s.blocks, m, { remove });
return Object.entries(files).map(([f, content]) => {
if (!/^\/etc\/network\/interfaces(\.d\/[\w.-]+)?$/.test(f)) throw new Error('Invalid file');
return `printf %s ${b64(content)} | base64 -d > '${f}'`;
});
}
throw new Error(i18n.t('Editing is not supported for this network configuration ({backend}).', { backend: s.backend }));
}
// Änderung anwenden: Backup, schreiben, prüfen, im Hintergrund anwenden, Rollback-Wächter starten, neu verbinden und bestätigen
async apply(id, writes, verifyAddress, backendName) {
const s = this.get(id);
const be = BACKENDS[backendName || s.backend];
if (!be) throw new Error(i18n.t('Editing is not supported for this network configuration ({backend}).', { backend: s.backend }));
const script = `export PATH=$PATH:/usr/sbin:/sbin
mkdir -p ${STATE} || exit 1; rm -f ${STATE}/pending ${STATE}/rolled-back
cd / && tar czf ${STATE}/backup.tgz $(for p in ${be.paths.join(' ')}; do [ -e "$p" ] && echo "$p"; done) || exit 1
# Schreiben in einer Subshell: bei einem Fehler sofort den alten Stand wiederherstellen
if ! ( set -e
${writes.join('\n')}
) >${STATE}/write.log 2>&1; then ${be.restore}; cat ${STATE}/write.log >&2; exit 5; fi
if ! (${be.validate}) >${STATE}/validate.log 2>&1; then ${be.restore}; cat ${STATE}/validate.log >&2; exit 4; fi
touch ${STATE}/pending
nohup setsid sh -c 'export PATH=$PATH:/usr/sbin:/sbin; sleep 2; (${be.apply}) >${STATE}/apply.log 2>&1; sleep ${ROLLBACK_SECONDS}; if [ -f ${STATE}/pending ]; then ${be.restore}; (${be.apply}) >>${STATE}/apply.log 2>&1; rm -f ${STATE}/pending; date "+%Y-%m-%d %H:%M:%S" > ${STATE}/rolled-back; fi' >/dev/null 2>&1 &
echo started`;
const r = await this.root(s, script);
if (r.code === 4) throw new Error(i18n.t('The new configuration is invalid and was not applied: {err}', { err: lastLine(r.err) }));
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
const deadline = Date.now() + (ROLLBACK_SECONDS - 12) * 1000;
await new Promise((res) => setTimeout(res, 6000));
const target = { ...s.host, address: verifyAddress || s.host.address };
while (Date.now() < deadline) {
try {
const { conn, jumps } = await this.ssh.connect(target, id);
const c = await this.root(s, `rm -f ${STATE}/pending`, conn);
if (c.code) { conn.end(); throw new Error(lastLine(c.err)); }
// Neue Verbindung übernimmt die Sitzung
s.replacing = true;
try { s.conn.end(); } catch {}
s.jumps?.forEach((x) => { try { x.end(); } catch {} });
Object.assign(s, { conn, jumps, replacing: false });
if (verifyAddress) s.host = target;
this.watch(id, s);
return { confirmed: true };
} catch {
await new Promise((res) => setTimeout(res, 4000));
}
}
return { confirmed: false, rollbackSeconds: ROLLBACK_SECONDS };
}
saveInterface(id, model, verifyAddress) {
const s = this.get(id);
return this.apply(id, this.plan(s, model, false), verifyAddress);
}
removeInterface(id, model) {
const s = this.get(id);
return this.apply(id, this.plan(s, model, true));
}
async setHostname(id, name) {
const s = this.get(id);
if (!HOSTNAME.test(name)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'hostname', value: name }));
const old = (await this.rootOk(s, 'hostname')).trim();
const esc = (x) => x.replace(/\./g, '\\.');
const short = name.split('.')[0];
await this.rootOk(s, `export PATH=$PATH:/usr/sbin:/sbin
hostnamectl set-hostname '${name}' 2>/dev/null || { echo '${name}' > /etc/hostname; hostname '${name}'; }
${HOSTNAME.test(old) ? `sed -i -E 's/(^|[[:space:]])${esc(old)}([[:space:]]|$)/\\1${name}\\2/g; s/(^|[[:space:]])${esc(old.split('.')[0])}([[:space:]]|$)/\\1${short}\\2/g' /etc/hosts` : ''}
grep -qE '[[:space:]]${esc(short)}([[:space:]]|$)' /etc/hosts || echo '127.0.1.1 ${name}${name !== short ? ` ${short}` : ''}' >> /etc/hosts`);
return true;
}
async setResolv(id, servers, search) {
const s = this.get(id);
const ip = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-fA-F:]+)$/;
servers.forEach((x) => { if (!ip.test(x)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'DNS', value: x })); });
search.forEach((x) => { if (!/^[a-zA-Z0-9.-]+$/.test(x)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'search', value: x })); });
const content = `# Written by MrTerm\n${search.length ? `search ${search.join(' ')}\n` : ''}${servers.map((x) => `nameserver ${x}`).join('\n')}\n`;
await this.rootOk(s, `[ -L /etc/resolv.conf ] && exit 5; printf %s ${b64(content)} | base64 -d > /etc/resolv.conf`);
return true;
}
async readFile(id, path) {
const s = this.get(id);
if (!EDITABLE_PATH.test(path)) throw new Error('Invalid file');
return this.rootOk(s, `cat '${path}'`);
}
// Datei direkt bearbeiten; Netzwerkdateien mit Rollback, /etc/hosts und resolv.conf direkt
async writeFile(id, path, content, verifyAddress) {
const s = this.get(id);
if (!EDITABLE_PATH.test(path)) throw new Error('Invalid file');
const write = `printf %s ${b64(content)} | base64 -d > '${path}'`;
if (/^\/etc\/(hosts|resolv\.conf)$/.test(path)) { await this.rootOk(s, write); return { confirmed: true, direct: true }; }
const backend = path.startsWith('/etc/netplan/') ? 'netplan' : path.startsWith('/etc/network/') ? 'ifupdown' : 'networkd';
return this.apply(id, [write], verifyAddress, backend);
}
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 = { NetworkConfigManager, parseBonding, sections };