Add network management over SSH for Ubuntu (netplan) and Debian/Proxmox (ifupdown): interfaces, IPs, DHCP, gateway, DNS, bonds with LACP, bridges, VLANs, hostname and config files, applied with automatic rollback; UFW shows a hint when disabled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ const fido = require('./fido');
|
||||
const { VpnManager } = require('./vpn');
|
||||
const { DockerManager } = require('./docker');
|
||||
const { FirewallManager } = require('./firewall');
|
||||
const { NetworkConfigManager } = require('./network');
|
||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||
|
||||
let win;
|
||||
@@ -65,6 +66,7 @@ const updater = new Updater(store, send);
|
||||
const vpn = new VpnManager(store, app.getPath('userData'));
|
||||
const docker = new DockerManager(ssh);
|
||||
const firewall = new FirewallManager(ssh);
|
||||
const network = new NetworkConfigManager(ssh);
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
@@ -221,6 +223,21 @@ handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
|
||||
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
|
||||
handle('firewall:close', (id) => firewall.close(id));
|
||||
|
||||
// ---------- Netzwerk ----------
|
||||
handle('network:open', async (id, hostRef) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
return network.open(id, host, () => send('network:closed', id));
|
||||
});
|
||||
handle('network:read', (id) => network.read(id));
|
||||
handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
|
||||
handle('network:remove', (id, model) => network.removeInterface(id, model));
|
||||
handle('network:hostname', (id, name) => network.setHostname(id, name));
|
||||
handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
|
||||
handle('network:readFile', (id, path) => network.readFile(id, path));
|
||||
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
|
||||
handle('network:close', (id) => network.close(id));
|
||||
|
||||
// ---------- VPN ----------
|
||||
handle('vpn:status', () => vpn.status());
|
||||
handle('vpn:up', (id) => vpn.up(id));
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,323 @@
|
||||
// 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) {
|
||||
s.conn.on('close', () => { if (this.sessions.get(id) === s && !s.replacing) { this.close(id); s.onClose(); } });
|
||||
s.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}/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; 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);
|
||||
const c = await this.root(s, `rm -f ${STATE}/pending`, conn);
|
||||
if (c.code) { conn.end(); throw new Error(lastLine(c.err)); }
|
||||
// Neue Verbindung übernimmt die Sitzung
|
||||
s.replacing = true;
|
||||
try { s.conn.end(); } catch {}
|
||||
s.jumps?.forEach((x) => { try { x.end(); } catch {} });
|
||||
Object.assign(s, { conn, jumps, replacing: false });
|
||||
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 };
|
||||
Reference in New Issue
Block a user