347 lines
20 KiB
JavaScript
347 lines
20 KiB
JavaScript
// 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 };
|