Files
MrTerm/src/main/netconf.js
T

347 lines
20 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-Konfiguration als einheitliches Modell – Lesen/Schreiben für
// Ubuntu: netplan (YAML, hier als JSON verarbeitet; JSON ist gültiges YAML)
// Debian/Proxmox: ifupdown / ifupdown2 (/etc/network/interfaces + interfaces.d)
// Modell pro Schnittstelle:
// { name, kind: ethernet|bond|bridge|vlan, method4: dhcp|static|none, addresses[], gateway, dns[], search[], mtu,
// method6: auto|dhcp|static|none, addresses6[], gateway6,
// bond: { members[], mode, lacpRate, hashPolicy, miimon }, bridge: { members[], stp }, vlan: { id, link }, file }
// Reine Funktionen ohne Seiteneffekte (lokal testbar).
const RX = {
name: /^[a-zA-Z0-9_.:-]{1,15}$/,
cidr4: /^(\d{1,3})(\.\d{1,3}){3}\/\d{1,2}$/,
cidr6: /^[0-9a-fA-F:]+(%\w+)?\/\d{1,3}$/,
ip4: /^\d{1,3}(\.\d{1,3}){3}$/,
ip6: /^[0-9a-fA-F:]+$/,
domain: /^[a-zA-Z0-9.-]{1,253}$/,
};
const BOND_MODES = ['balance-rr', 'active-backup', 'balance-xor', 'broadcast', '802.3ad', 'balance-tlb', 'balance-alb'];
const HASH = ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4'];
function fail(msg) { const e = new Error(msg); e.validation = true; throw e; }
// Eingaben prüfen, bevor daraus Konfigurationsdateien entstehen
function validate(m, t = (s, v) => (v ? s.replace(/\{(\w+)\}/g, (x, k) => v[k]) : s)) {
const chk = (ok, field, value) => { if (!ok) fail(t('Invalid value for {field}: {value}', { field, value })); };
chk(RX.name.test(m.name || ''), 'name', m.name);
chk(['ethernet', 'bond', 'bridge', 'vlan'].includes(m.kind), 'kind', m.kind);
chk(['dhcp', 'static', 'none'].includes(m.method4), 'IPv4', m.method4);
chk(['auto', 'dhcp', 'static', 'none'].includes(m.method6), 'IPv6', m.method6);
for (const a of m.addresses || []) chk(RX.cidr4.test(a), 'IPv4 address', a);
for (const a of m.addresses6 || []) chk(RX.cidr6.test(a), 'IPv6 address', a);
if (m.method4 === 'static' && !(m.addresses || []).length) fail(t('A static configuration needs at least one IPv4 address (e.g. 192.168.1.10/24).'));
if (m.method6 === 'static' && !(m.addresses6 || []).length) fail(t('A static IPv6 configuration needs at least one IPv6 address.'));
if (m.gateway) chk(RX.ip4.test(m.gateway), 'gateway', m.gateway);
if (m.gateway6) chk(RX.ip6.test(m.gateway6), 'IPv6 gateway', m.gateway6);
for (const d of m.dns || []) chk(RX.ip4.test(d) || RX.ip6.test(d), 'DNS', d);
for (const d of m.search || []) chk(RX.domain.test(d), 'search domain', d);
if (m.mtu !== '' && m.mtu != null) chk(Number(m.mtu) >= 68 && Number(m.mtu) <= 65535, 'MTU', m.mtu);
if (m.kind === 'bond') {
chk(BOND_MODES.includes(m.bond?.mode), 'bond mode', m.bond?.mode);
for (const x of m.bond.members || []) chk(RX.name.test(x), 'member', x);
if (m.bond.lacpRate) chk(['slow', 'fast'].includes(m.bond.lacpRate), 'LACP rate', m.bond.lacpRate);
if (m.bond.hashPolicy) chk(HASH.includes(m.bond.hashPolicy), 'hash policy', m.bond.hashPolicy);
if (m.bond.miimon !== '' && m.bond.miimon != null) chk(/^\d{1,5}$/.test(String(m.bond.miimon)), 'miimon', m.bond.miimon);
}
if (m.kind === 'bridge') for (const x of m.bridge?.members || []) chk(RX.name.test(x), 'port', x);
if (m.kind === 'vlan') {
chk(Number(m.vlan?.id) >= 1 && Number(m.vlan?.id) <= 4094, 'VLAN ID', m.vlan?.id);
chk(RX.name.test(m.vlan?.link || ''), 'VLAN parent', m.vlan?.link);
}
return m;
}
const blank = (name, kind = 'ethernet') => ({
name, kind, method4: 'none', addresses: [], gateway: '', dns: [], search: [], mtu: '',
method6: 'auto', addresses6: [], gateway6: '',
bond: kind === 'bond' ? { members: [], mode: '802.3ad', lacpRate: 'fast', hashPolicy: 'layer3+4', miimon: 100 } : undefined,
bridge: kind === 'bridge' ? { members: [], stp: false } : undefined,
vlan: kind === 'vlan' ? { id: '', link: '' } : undefined,
});
// ======================================================================= netplan
const NP_SECT = { ethernet: 'ethernets', bond: 'bonds', bridge: 'bridges', vlan: 'vlans' };
const DEFAULT_ROUTE = (r) => ['default', '0.0.0.0/0', '::/0'].includes(r?.to);
const addrOf = (a) => (typeof a === 'string' ? a : Object.keys(a || {})[0] || '');
function fromNetplan(cfg) {
const net = cfg?.network || {};
const out = [];
for (const [kind, sect] of Object.entries(NP_SECT)) {
for (const [name, c] of Object.entries(net[sect] || {})) {
const m = blank(name, kind);
const addrs = (c.addresses || []).map(addrOf);
m.addresses = addrs.filter((a) => !a.includes(':'));
m.addresses6 = addrs.filter((a) => a.includes(':'));
const routes = c.routes || [];
m.gateway = c.gateway4 || routes.find((r) => DEFAULT_ROUTE(r) && !String(r.via).includes(':'))?.via || '';
m.gateway6 = c.gateway6 || routes.find((r) => DEFAULT_ROUTE(r) && String(r.via).includes(':'))?.via || '';
m.method4 = c.dhcp4 === true || c.dhcp4 === 'yes' || c.dhcp4 === 'true' ? 'dhcp' : m.addresses.length ? 'static' : 'none';
m.method6 = c.dhcp6 === true || c.dhcp6 === 'yes' ? 'dhcp' : m.addresses6.length ? 'static' : c['accept-ra'] === false ? 'none' : 'auto';
m.dns = c.nameservers?.addresses || [];
m.search = c.nameservers?.search || [];
m.mtu = c.mtu ?? '';
const p = c.parameters || {};
if (kind === 'bond') m.bond = { members: c.interfaces || [], mode: p.mode || 'balance-rr', lacpRate: p['lacp-rate'] || '', hashPolicy: p['transmit-hash-policy'] || '', miimon: p['mii-monitor-interval'] ?? '' };
if (kind === 'bridge') m.bridge = { members: c.interfaces || [], stp: p.stp === true };
if (kind === 'vlan') m.vlan = { id: c.id ?? '', link: c.link || '' };
out.push(m);
}
}
return out;
}
// Mitglieder aus allen Bonds/Bridges außer "keep" entfernen
function npDetach(net, members, keepSect, keepName) {
for (const sect of ['bonds', 'bridges']) {
for (const [n, c] of Object.entries(net[sect] || {})) {
if (sect === keepSect && n === keepName) continue;
if (Array.isArray(c.interfaces)) c.interfaces = c.interfaces.filter((x) => !members.includes(x));
}
}
}
// Modell m in die netplan-Konfiguration übernehmen (liefert neue Konfiguration)
function applyNetplan(cfg, m, { remove = false } = {}) {
const out = structuredClone(cfg || {});
const net = (out.network ||= { version: 2 });
net.version ||= 2;
const sect = NP_SECT[m.kind];
if (remove) {
if (net[sect]) delete net[sect][m.name];
npDetach(net, [m.name]);
return out;
}
const c = ((net[sect] ||= {})[m.name] ||= {});
delete c.gateway4; delete c.gateway6;
c.dhcp4 = m.method4 === 'dhcp';
if (m.method6 === 'dhcp') c.dhcp6 = true; else delete c.dhcp6;
if (m.method6 === 'none') { c['accept-ra'] = false; c['link-local'] = ['ipv4']; }
else { if (c['accept-ra'] === false) delete c['accept-ra']; if (Array.isArray(c['link-local']) && !c['link-local'].includes('ipv6')) delete c['link-local']; }
const addrs = [...(m.method4 === 'static' ? m.addresses : []), ...(m.method6 === 'static' ? m.addresses6 : [])];
if (addrs.length) c.addresses = addrs; else delete c.addresses;
const routes = (c.routes || []).filter((r) => !DEFAULT_ROUTE(r));
if (m.method4 === 'static' && m.gateway) routes.push({ to: 'default', via: m.gateway });
if (m.method6 === 'static' && m.gateway6) routes.push({ to: '::/0', via: m.gateway6 });
if (routes.length) c.routes = routes; else delete c.routes;
if ((m.dns || []).length || (m.search || []).length) c.nameservers = { ...((m.dns || []).length ? { addresses: m.dns } : {}), ...((m.search || []).length ? { search: m.search } : {}) };
else delete c.nameservers;
if (m.mtu !== '' && m.mtu != null) c.mtu = Number(m.mtu); else delete c.mtu;
const members = m.kind === 'bond' ? m.bond.members : m.kind === 'bridge' ? m.bridge.members : [];
if (m.kind === 'bond' || m.kind === 'bridge') {
c.interfaces = [...members];
npDetach(net, members, sect, m.name);
// Mitglieder dürfen selbst keine IP-Konfiguration haben
for (const x of members) {
const sub = Object.values(NP_SECT).map((s) => net[s]?.[x]).find(Boolean) || ((net.ethernets ||= {})[x] = {});
for (const k of ['addresses', 'routes', 'nameservers', 'gateway4', 'gateway6', 'dhcp6']) delete sub[k];
sub.dhcp4 = false;
}
}
if (m.kind === 'bond') {
const p = { ...(c.parameters || {}), mode: m.bond.mode };
if (m.bond.mode === '802.3ad' && m.bond.lacpRate) p['lacp-rate'] = m.bond.lacpRate; else delete p['lacp-rate'];
if (m.bond.hashPolicy && ['802.3ad', 'balance-xor', 'balance-tlb', 'balance-alb'].includes(m.bond.mode)) p['transmit-hash-policy'] = m.bond.hashPolicy; else delete p['transmit-hash-policy'];
if (m.bond.miimon !== '' && m.bond.miimon != null) p['mii-monitor-interval'] = Number(m.bond.miimon); else delete p['mii-monitor-interval'];
c.parameters = p;
}
if (m.kind === 'bridge') c.parameters = { ...(c.parameters || {}), stp: !!m.bridge.stp };
if (m.kind === 'vlan') { c.id = Number(m.vlan.id); c.link = m.vlan.link; }
return out;
}
// ======================================================================= ifupdown
const KEYWORDS = /^(iface|auto|allow-[\w-]+|mapping|source|source-directory|rename|no-auto-down|no-scripts)\b/;
// Optionen, die MrTerm selbst schreibt; alle anderen bleiben unverändert erhalten
const MANAGED = new Set(['address', 'netmask', 'gateway', 'dns-nameservers', 'dns-search', 'mtu',
'bond-slaves', 'bond_slaves', 'slaves', 'bond-mode', 'bond_mode', 'bond-miimon', 'bond_miimon', 'bond-lacp-rate', 'bond_lacp_rate',
'bond-xmit-hash-policy', 'bond_xmit_hash_policy', 'bridge-ports', 'bridge_ports', 'bridge-stp', 'bridge_stp', 'vlan-raw-device', 'vlan-id', 'bond-master']);
// IP-Optionen, die ein Bond-/Bridge-Mitglied nicht haben darf
const IPKEYS = new Set(['address', 'netmask', 'gateway', 'dns-nameservers', 'dns-search']);
function parseInterfaces(text, file) {
const blocks = [];
let cur = null;
for (const line of String(text).split('\n')) {
const t = line.trim();
if (KEYWORDS.test(t)) {
const [kw, ...rest] = t.split(/\s+/);
if (kw === 'iface') {
cur = { type: 'iface', name: rest[0], family: rest[1] || 'inet', method: rest[2] || 'manual', options: [], file };
blocks.push(cur);
continue;
}
cur = null;
if (kw === 'auto' || kw.startsWith('allow-')) { blocks.push({ type: 'auto', kw, names: rest, file }); continue; }
blocks.push({ type: 'raw', line, file });
continue;
}
if (cur && t && !t.startsWith('#')) {
const [key, ...v] = t.split(/\s+/);
cur.options.push({ key, value: v.join(' ') });
continue;
}
if (cur && t.startsWith('#')) { cur.options.push({ comment: line }); continue; }
if (!t) cur = null;
blocks.push({ type: 'raw', line, file });
}
return blocks;
}
const maskToPrefix = (mask) => String(mask).split('.').reduce((n, o) => n + (Number(o) >>> 0).toString(2).split('').filter((b) => b === '1').length, 0);
function fromIfupdown(blocks) {
const byName = new Map();
const opt = (b, ...keys) => b?.options.filter((o) => keys.includes(o.key)).map((o) => o.value) || [];
for (const b of blocks.filter((x) => x.type === 'iface')) {
if (b.method === 'loopback' || b.name === 'lo') continue;
const e = byName.get(b.name) || { inet: null, inet6: null };
e[b.family === 'inet6' ? 'inet6' : 'inet'] = b;
byName.set(b.name, e);
}
// Mitglieder, die per bond-master auf einen Bond zeigen
const bondMasters = {};
for (const [name, e] of byName) { const bm = opt(e.inet, 'bond-master')[0]; if (bm) (bondMasters[bm] ||= []).push(name); }
const out = [];
for (const [name, { inet, inet6 }] of byName) {
const b = inet || inet6;
const slaves = opt(inet, 'bond-slaves', 'bond_slaves', 'slaves')[0];
const bridgePorts = opt(inet, 'bridge-ports', 'bridge_ports')[0];
const vlanDev = opt(inet, 'vlan-raw-device')[0] || opt(inet6, 'vlan-raw-device')[0];
const vm = name.match(/^(.+)\.(\d+)$/);
const kind = slaves !== undefined || opt(inet, 'bond-mode', 'bond_mode').length ? 'bond' : bridgePorts !== undefined ? 'bridge' : vlanDev || vm || /^vlan\d+$/.test(name) ? 'vlan' : 'ethernet';
const m = blank(name, kind);
m.file = b.file;
if (inet) {
m.method4 = inet.method === 'dhcp' ? 'dhcp' : inet.method === 'static' ? 'static' : 'none';
const mask = opt(inet, 'netmask')[0];
m.addresses = opt(inet, 'address').map((a) => (a.includes('/') ? a : `${a}/${mask ? maskToPrefix(mask) : 24}`));
m.gateway = opt(inet, 'gateway')[0] || '';
m.dns = (opt(inet, 'dns-nameservers')[0] || '').split(/\s+/).filter(Boolean);
m.search = (opt(inet, 'dns-search')[0] || '').split(/\s+/).filter(Boolean);
m.mtu = opt(inet, 'mtu')[0] || '';
}
if (inet6) {
m.method6 = inet6.method === 'dhcp' ? 'dhcp' : inet6.method === 'static' ? 'static' : inet6.method === 'auto' ? 'auto' : 'none';
m.addresses6 = opt(inet6, 'address').map((a) => (a.includes('/') ? a : `${a}/${opt(inet6, 'netmask')[0] || 64}`));
m.gateway6 = opt(inet6, 'gateway')[0] || '';
} else m.method6 = 'auto';
if (kind === 'bond') {
const members = slaves && slaves !== 'none' ? slaves.split(/\s+/) : bondMasters[name] || [];
m.bond = { members, mode: opt(inet, 'bond-mode', 'bond_mode')[0] || 'balance-rr', lacpRate: opt(inet, 'bond-lacp-rate', 'bond_lacp_rate')[0] || '',
hashPolicy: opt(inet, 'bond-xmit-hash-policy', 'bond_xmit_hash_policy')[0] || '', miimon: opt(inet, 'bond-miimon', 'bond_miimon')[0] || '' };
if (m.bond.lacpRate === '1') m.bond.lacpRate = 'fast';
if (m.bond.lacpRate === '0') m.bond.lacpRate = 'slow';
if (m.bond.mode === '4') m.bond.mode = '802.3ad';
}
if (kind === 'bridge') m.bridge = { members: bridgePorts && bridgePorts !== 'none' ? bridgePorts.split(/\s+/) : [], stp: /^(on|yes)$/.test(opt(inet, 'bridge-stp', 'bridge_stp')[0] || '') };
if (kind === 'vlan') m.vlan = { id: opt(inet, 'vlan-id')[0] || vm?.[2] || (name.match(/^vlan(\d+)$/) || [])[1] || '', link: vlanDev || vm?.[1] || '' };
out.push(m);
}
return out;
}
function ifaceLines(m, keep4 = [], keep6 = [], had6 = false) {
const L = [];
const o = (k, v) => L.push(` ${k} ${v}`);
L.push(`iface ${m.name} inet ${m.method4 === 'dhcp' ? 'dhcp' : m.method4 === 'static' ? 'static' : 'manual'}`);
if (m.method4 === 'static') { m.addresses.forEach((a) => o('address', a)); if (m.gateway) o('gateway', m.gateway); }
if ((m.dns || []).length) o('dns-nameservers', m.dns.join(' '));
if ((m.search || []).length) o('dns-search', m.search.join(' '));
if (m.mtu !== '' && m.mtu != null) o('mtu', m.mtu);
if (m.kind === 'bond') {
o('bond-slaves', m.bond.members.length ? m.bond.members.join(' ') : 'none');
o('bond-mode', m.bond.mode);
if (m.bond.miimon !== '' && m.bond.miimon != null) o('bond-miimon', m.bond.miimon);
if (m.bond.mode === '802.3ad' && m.bond.lacpRate) o('bond-lacp-rate', m.bond.lacpRate);
if (m.bond.hashPolicy && ['802.3ad', 'balance-xor', 'balance-tlb', 'balance-alb'].includes(m.bond.mode)) o('bond-xmit-hash-policy', m.bond.hashPolicy);
}
if (m.kind === 'bridge') { o('bridge-ports', m.bridge.members.length ? m.bridge.members.join(' ') : 'none'); o('bridge-stp', m.bridge.stp ? 'on' : 'off'); }
if (m.kind === 'vlan' && !/^.+\.\d+$/.test(m.name)) { o('vlan-raw-device', m.vlan.link); o('vlan-id', m.vlan.id); }
keep4.forEach((x) => L.push(x.comment ?? ` ${x.key} ${x.value}`));
// inet6: dhcp/static immer; "auto" nur, wenn der Block vorher schon existierte (sonst Kernel-Standard SLAAC)
if (m.method6 === 'dhcp' || m.method6 === 'static' || (m.method6 === 'auto' && had6)) {
L.push('');
L.push(`iface ${m.name} inet6 ${m.method6}`);
if (m.method6 === 'static') { m.addresses6.forEach((a) => o('address', a)); if (m.gateway6) o('gateway', m.gateway6); }
keep6.forEach((x) => L.push(x.comment ?? ` ${x.key} ${x.value}`));
}
return L;
}
// Modell in die Blöcke übernehmen; liefert { files: { pfad: inhalt } } für alle geänderten Dateien
function applyIfupdown(blocks, m, { remove = false, mainFile = '/etc/network/interfaces' } = {}) {
let bl = blocks.map((b) => ({ ...b, options: b.options ? [...b.options] : undefined, names: b.names ? [...b.names] : undefined }));
const changed = new Set();
const file = bl.find((b) => b.type === 'iface' && b.name === m.name)?.file || m.file || mainFile;
const members = m.kind === 'bond' ? m.bond.members : m.kind === 'bridge' ? m.bridge.members : [];
// bestehende Blöcke der Schnittstelle entfernen (Position merken)
const old = bl.filter((b) => b.type === 'iface' && b.name === m.name);
old.forEach((b) => changed.add(b.file));
const keep4 = (old.find((b) => b.family !== 'inet6')?.options || []).filter((x) => x.comment || !MANAGED.has(x.key));
const keep6 = (old.find((b) => b.family === 'inet6')?.options || []).filter((x) => x.comment || !MANAGED.has(x.key));
let pos = bl.findIndex((b) => b.type === 'iface' && b.name === m.name);
bl = bl.filter((b) => !(b.type === 'iface' && b.name === m.name));
if (remove) {
bl.forEach((b) => { if (b.type === 'auto' && b.names.includes(m.name)) { b.names = b.names.filter((n) => n !== m.name); changed.add(b.file); } });
bl = bl.filter((b) => !(b.type === 'auto' && !b.names.length));
} else {
changed.add(file);
if (pos < 0) { bl.push({ type: 'raw', line: '', file }); pos = bl.length; }
const hasAuto = bl.some((b) => b.type === 'auto' && b.names.includes(m.name));
const nb = [];
if (!hasAuto) nb.push({ type: 'auto', kw: 'auto', names: [m.name], file });
nb.push({ type: 'text', lines: ifaceLines(m, keep4, keep6, old.some((b) => b.family === 'inet6')), file });
bl.splice(pos, 0, ...nb);
}
// Mitglieder: aus anderen Bonds/Bridges lösen, selbst "inet manual" ohne IP
if (!remove && members.length) {
for (const b of bl) {
if (b.type !== 'iface' || b.name === m.name || b.family === 'inet6') continue;
for (const o of b.options) {
if (['bond-slaves', 'bond_slaves', 'slaves', 'bridge-ports', 'bridge_ports'].includes(o.key)) {
const v = o.value.split(/\s+/).filter((x) => x !== 'none' && !members.includes(x));
if (v.join(' ') !== o.value) { o.value = v.length ? v.join(' ') : 'none'; changed.add(b.file); }
}
}
}
for (const x of members) {
const idx = bl.findIndex((b) => b.type === 'iface' && b.name === x && b.family !== 'inet6');
const cur = bl[idx];
// Bereits "manual" ohne IP (z. B. Bond als Bridge-Port): unverändert lassen
if (cur && cur.method === 'manual' && !cur.options.some((o) => IPKEYS.has(o.key))) continue;
const others = (cur?.options || []).filter((o) => o.comment || !IPKEYS.has(o.key));
bl = bl.filter((b) => !(b.type === 'iface' && b.name === x));
const f = cur?.file || file;
changed.add(f);
const lines = [`iface ${x} inet manual`, ...others.map((o) => o.comment ?? ` ${o.key} ${o.value}`)];
if (cur) { bl.splice(Math.min(idx, bl.length), 0, { type: 'text', lines, file: f }); continue; }
// Neues Mitglied vor dem Block der Schnittstelle (inkl. deren auto-Zeile) einfügen
let at = bl.findIndex((b) => b.type === 'text' && b.lines[0].startsWith(`iface ${m.name} `));
if (at > 0 && bl[at - 1].type === 'auto' && bl[at - 1].names.includes(m.name)) at--;
bl.splice(at < 0 ? bl.length : at, 0, { type: 'auto', kw: 'auto', names: [x], file: f }, { type: 'text', lines, file: f }, { type: 'raw', line: '', file: f });
}
}
const files = {};
for (const f of changed) {
const lines = [];
for (const b of bl.filter((x) => x.file === f)) {
if (b.type === 'raw') lines.push(b.line);
else if (b.type === 'auto') lines.push(`${b.kw} ${b.names.join(' ')}`);
else if (b.type === 'text') lines.push(...b.lines);
else if (b.type === 'iface') {
lines.push(`iface ${b.name} ${b.family} ${b.method}`);
b.options.forEach((o) => lines.push(o.comment ?? ` ${o.key} ${o.value}`));
}
}
files[f] = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '\n');
}
return { files };
}
module.exports = { validate, blank, fromNetplan, applyNetplan, parseInterfaces, fromIfupdown, applyIfupdown, BOND_MODES, HASH };