// 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) { const conn = s.conn; // Nur die aktuelle Verbindung darf die Sitzung beenden (nach apply wird sie ersetzt) conn.on('close', () => { if (this.sessions.get(id) === s && s.conn === conn) { this.close(id); s.onClose(); } }); 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}/applied ${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; touch ${STATE}/applied; 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); // Erst bestätigen, wenn das Anwenden fertig ist: sonst reißt z. B. ein Bond-Neustart die neue Verbindung wieder ab const c = await this.root(s, `[ -f ${STATE}/applied ] || exit 7; rm -f ${STATE}/pending`, conn); if (c.code) { conn.on('error', () => {}); conn.end(); jumps?.forEach((x) => { try { x.end(); } catch {} }); throw new Error(lastLine(c.err) || 'not applied yet'); } // Neue Verbindung übernimmt die Sitzung const old = { conn: s.conn, jumps: s.jumps }; Object.assign(s, { conn, jumps }); // Alte Verbindung ist nach dem IP-Wechsel meist tot: Fehler (z. B. Keepalive-Timeout) still verwerfen for (const c of [old.conn, ...(old.jumps || [])]) { c.on('error', () => {}); try { c.end(); } catch {} } 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 };