Files
MrTerm/src/main/network.js
T

327 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };