327 lines
17 KiB
JavaScript
327 lines
17 KiB
JavaScript
// 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 };
|