// 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 };