From 352a887a67df1e9d8034de528b0f99c0c1b23b56 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 25 Sep 2026 22:40:58 +0200 Subject: [PATCH] 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 --- README.md | 21 +++ src/i18n.js | 74 +++++++++ src/main/main.js | 17 ++ src/main/netconf.js | 346 ++++++++++++++++++++++++++++++++++++++++ src/main/network.js | 323 +++++++++++++++++++++++++++++++++++++ src/renderer/app.js | 271 ++++++++++++++++++++++++++++++- src/renderer/styles.css | 27 ++++ 7 files changed, 1076 insertions(+), 3 deletions(-) create mode 100644 src/main/netconf.js create mode 100644 src/main/network.js diff --git a/README.md b/README.md index 9d1805b..74da76b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on - **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key - **Docker & Podman**: list a host's containers, open a shell inside a container, follow logs, and start, stop, restart or remove containers, all over SSH - **Firewall**: view and edit UFW and iptables/ip6tables rules on your servers +- **Network**: configure interfaces, IP addresses, DHCP, gateway, DNS, bonds (LACP), bridges, VLANs and the hostname on Ubuntu and Debian/Proxmox servers, with automatic rollback - **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D) - **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host - **Snippets**: save frequently used commands and send them to a terminal with one click @@ -87,8 +88,28 @@ Right-click an SSH host and choose **Firewall**, or click **Firewall** in the to - **iptables**: all chains with their rules, the policy of INPUT, FORWARD and OUTPUT, and adding or deleting rules. iptables changes are lost on reboot unless you click **Save permanently** (uses `netfilter-persistent` on Debian/Ubuntu or `/etc/iptables/*.rules` on Arch). - **Lockout protection**: if you enable UFW without a rule that allows SSH, MrTerm warns you and offers to allow SSH first. Switching a default policy to blocking asks for confirmation. +UFW rules can only be viewed and added while UFW is enabled. + This needs root privileges. Either log in as root, or save the password of a user with sudo rights on the host. +## Network + +Right-click an SSH host and choose **Network**, or click **Network** in the terminal toolbar. MrTerm shows every interface with its state, MAC address, MTU and IP addresses. Bonds also show their mode, LACP rate and the status of each member. The default gateway, DNS servers and hostname appear at the top. + +On **Ubuntu (netplan)** and **Debian/Proxmox (ifupdown)** you can also edit the configuration: + +- **Per interface**: DHCP or static IPv4 addresses, gateway, DNS servers and search domains, IPv6 (SLAAC, DHCPv6, static or disabled) and MTU +- **Bonds** with any mode, including **802.3ad (LACP)** with LACP rate, hash policy and MII monitoring +- **Bridges** (e.g. Proxmox `vmbr`) and **VLANs**, which you can also create and delete +- **Hostname** and, if not managed by systemd-resolved, `/etc/resolv.conf` +- **Config files**: edit the netplan files, `/etc/network/interfaces` or `/etc/hosts` directly + +**Automatic rollback:** before applying a change, MrTerm backs up the configuration and checks the new one. After applying it, MrTerm opens a new SSH connection to confirm the server is still reachable. If that doesn't work within 90 seconds, the server restores the previous configuration by itself, so a wrong IP address won't lock you out. If your change affects the address MrTerm connects to, enter the new address in the confirmation dialog. + +On Ubuntu, MrTerm writes the complete netplan configuration to `/etc/netplan/90-mrterm.yaml` and renames the previous files to `*.yaml.mrterm-off`. On Debian/Proxmox, only the changed interfaces are rewritten, and all other lines (such as `post-up` or `bridge-fd`) are kept. + +Root privileges are required, the same as for the firewall. + ## VPN Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host. diff --git a/src/i18n.js b/src/i18n.js index baf6dc5..02c4223 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -468,6 +468,80 @@ 'A port needs the protocol TCP or UDP.': 'Für einen Port ist das Protokoll TCP oder UDP nötig.', 'No known way to save iptables rules on this host (e.g. install iptables-persistent).': 'Keine bekannte Möglichkeit, iptables-Regeln auf diesem Host zu speichern (z. B. iptables-persistent installieren).', + 'UFW is disabled': 'UFW ist deaktiviert', + 'Enable UFW to view or add rules.': 'Aktiviere UFW, um Regeln einzusehen oder anzulegen.', + // Netzwerk + 'Network': 'Netzwerk', + 'Show virtual interfaces': 'Virtuelle Schnittstellen anzeigen', + 'Config files': 'Konfigurationsdateien', + 'New interface': 'Neue Schnittstelle', + 'New bond': 'Neuer Bond', + 'New bridge': 'Neue Bridge', + 'New VLAN': 'Neues VLAN', + 'The last network change was rolled back automatically ({time}) because MrTerm could not reconnect.': 'Die letzte Netzwerkänderung wurde automatisch zurückgenommen ({time}), weil MrTerm sich nicht erneut verbinden konnte.', + 'Editing is supported for Ubuntu (netplan) and Debian/Proxmox (ifupdown). This host uses {backend}, so interfaces are shown read-only. Config files can still be edited.': 'Bearbeiten wird für Ubuntu (netplan) und Debian/Proxmox (ifupdown) unterstützt. Dieser Host nutzt {backend}, daher werden die Schnittstellen nur angezeigt. Konfigurationsdateien lassen sich trotzdem bearbeiten.', + 'none': 'keins', + 'Hostname': 'Hostname', + 'Default gateway': 'Standard-Gateway', + 'DNS servers': 'DNS-Server', + 'Search': 'Suche', + 'systemd-resolved is used: set DNS servers per interface.': 'systemd-resolved ist aktiv: DNS-Server pro Schnittstelle festlegen.', + 'Not configured': 'Nicht konfiguriert', + 'Static': 'Statisch', + 'No IPv4': 'Kein IPv4', + 'Mode': 'Modus', + 'Configured, but not present': 'Konfiguriert, aber nicht vorhanden', + 'No address': 'Keine Adresse', + 'Configured': 'Konfiguriert', + 'MrTerm reconnects to confirm the change. Without confirmation the server restores the previous configuration after 90 seconds.': 'MrTerm verbindet sich neu, um die Änderung zu bestätigen. Ohne Bestätigung stellt der Server nach 90 Sekunden die vorherige Konfiguration wieder her.', + 'Saved': 'Gespeichert', + 'Network change applied and confirmed': 'Netzwerkänderung angewendet und bestätigt', + 'MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.': 'MrTerm konnte sich nach der Änderung nicht erneut verbinden. Der Server stellt die vorherige Konfiguration innerhalb von 90 Sekunden automatisch wieder her.', + 'Apply network change?': 'Netzwerkänderung anwenden?', + 'The change is applied immediately. If MrTerm cannot reconnect within 90 seconds, the server restores the previous configuration automatically.': 'Die Änderung wird sofort angewendet. Kann MrTerm sich nicht innerhalb von 90 Sekunden erneut verbinden, stellt der Server die vorherige Konfiguration automatisch wieder her.', + 'Reconnect via': 'Neu verbinden über', + 'Change this if the change affects the address MrTerm uses to connect.': 'Ändern, wenn die Änderung die Adresse betrifft, über die MrTerm sich verbindet.', + 'Apply': 'Anwenden', + 'No suitable interfaces found.': 'Keine passenden Schnittstellen gefunden.', + 'Usually ., e.g. eno1.100': 'Üblich: ., z. B. eno1.100', + 'Bond': 'Bond', + 'Members': 'Mitglieder', + 'LACP rate': 'LACP-Rate', + 'Hash policy': 'Hash-Richtlinie', + 'MII monitoring (ms)': 'MII-Überwachung (ms)', + 'Bridge': 'Bridge', + 'Spanning Tree (STP)': 'Spanning Tree (STP)', + 'VLAN ID': 'VLAN-ID', + 'Parent interface': 'Übergeordnete Schnittstelle', + 'IPv4 addresses (one per line, CIDR)': 'IPv4-Adressen (eine pro Zeile, CIDR)', + 'Gateway': 'Gateway', + 'IPv6 addresses (one per line, CIDR)': 'IPv6-Adressen (eine pro Zeile, CIDR)', + 'IPv6 gateway': 'IPv6-Gateway', + 'Method': 'Methode', + 'No IPv4 address': 'Keine IPv4-Adresse', + 'Search domains': 'Suchdomänen', + 'Automatic (SLAAC)': 'Automatisch (SLAAC)', + 'Disabled': 'Deaktiviert', + 'New {kind}': 'Neue(r) {kind}', + 'Select at least one member.': 'Wähle mindestens ein Mitglied aus.', + 'Select the parent interface.': 'Wähle die übergeordnete Schnittstelle aus.', + 'An interface with this name already exists.': 'Eine Schnittstelle mit diesem Namen gibt es bereits.', + 'Applying network configuration …': 'Netzwerkkonfiguration wird angewendet …', + 'Delete {kind} {name}?': '{kind} {name} löschen?', + 'The interface is removed from the configuration. Members keep their current settings.': 'Die Schnittstelle wird aus der Konfiguration entfernt. Mitglieder behalten ihre aktuellen Einstellungen.', + 'Change hostname': 'Hostname ändern', + 'Hostname changed': 'Hostname geändert', + 'Written to /etc/resolv.conf.': 'Wird in /etc/resolv.conf geschrieben.', + 'Content': 'Inhalt', + 'Saving applies the file with automatic rollback.': 'Beim Speichern wird die Datei mit automatischem Rollback angewendet.', + 'Saving …': 'Speichere …', + 'Network session not found': 'Netzwerk-Sitzung nicht gefunden', + 'netplan configuration could not be read ({err}).': 'netplan-Konfiguration konnte nicht gelesen werden ({err}).', + 'Editing is not supported for this network configuration ({backend}).': 'Bearbeiten wird für diese Netzwerkkonfiguration ({backend}) nicht unterstützt.', + 'The new configuration is invalid and was not applied: {err}': 'Die neue Konfiguration ist ungültig und wurde nicht angewendet: {err}', + 'A static configuration needs at least one IPv4 address (e.g. 192.168.1.10/24).': 'Eine statische Konfiguration braucht mindestens eine IPv4-Adresse (z. B. 192.168.1.10/24).', + 'A static IPv6 configuration needs at least one IPv6 address.': 'Eine statische IPv6-Konfiguration braucht mindestens eine IPv6-Adresse.', + // Main-Prozess 'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Unknown host': 'Unbekannter Host', diff --git a/src/main/main.js b/src/main/main.js index 8450873..c4575b0 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -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)); diff --git a/src/main/netconf.js b/src/main/netconf.js new file mode 100644 index 0000000..7c71f43 --- /dev/null +++ b/src/main/netconf.js @@ -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 }; diff --git a/src/main/network.js b/src/main/network.js new file mode 100644 index 0000000..f86682a --- /dev/null +++ b/src/main/network.js @@ -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 }; diff --git a/src/renderer/app.js b/src/renderer/app.js index d88cfac..07ef39e 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -42,6 +42,7 @@ const ICONS = { upload: '', docker: '', wall: '', + network: '', logs: '', }; const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100']; @@ -390,7 +391,7 @@ function hostMenu(hst) { return [ { label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, ...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []), - ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }] : []), + ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }, { label: T('Network'), icon: 'network', run: () => openNetwork(hst) }] : []), '-', { label: T('Edit'), icon: 'edit', run: () => editHost(hst) }, { label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } }, @@ -1161,7 +1162,7 @@ function addTab(session) { tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); }; tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); }; tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ - ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall }[session.kind] || openTerminal)(session.host) }] : []), + ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []), ...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []), { label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) }, @@ -1208,7 +1209,7 @@ class TerminalSession { this.el = h(`
${avatar(host, 22).replace('SSH', '')}${esc(hostSub(host))}
- ${host.execCommand ? '' : ``} + ${host.execCommand ? '' : ``}
@@ -1268,6 +1269,7 @@ class TerminalSession { if (a === 'sftp') openSftp(this.host); if (a === 'docker') openDocker(this.host); if (a === 'firewall') openFirewall(this.host); + if (a === 'network') openNetwork(this.host); if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); } if (a === 'snipnew') editSnippet(); if (a === 'find') this.toggleFind(); @@ -1842,6 +1844,7 @@ class FirewallSession { this.backend = b; $$('.backends button', this.el).forEach((x) => x.classList.toggle('active', x.dataset.b === b)); $('[data-a=save]', this.el).style.display = b === 'ufw' ? 'none' : ''; + $('[data-a=add]', this.el).disabled = false; await this.refresh(); } @@ -1894,6 +1897,11 @@ class FirewallSession { } } this.body.append(head); + $('[data-a=add]', this.el).disabled = !on; + if (!on) { + this.body.append(h(`
${ICONS.wall}${T('UFW is disabled')}${T('Enable UFW to view or add rules.')}
`)); + return; + } if (!d.rules.length) { this.body.append(h(`
${T('No rules yet.')}
`)); return; } const t = h(`
#${T('To')}${T('Action')}${T('From')}${T('Comment')}
`); for (const r of d.rules) { @@ -1971,6 +1979,262 @@ class FirewallSession { } function openFirewall(host) { return new FirewallSession(host); } +// ============================================================ Netzwerk (Ubuntu netplan / Debian ifupdown über SSH) +const NET_KINDS = { ethernet: 'Ethernet', bond: 'Bond', bridge: 'Bridge', vlan: 'VLAN' }; +const BOND_MODES = [['802.3ad', '802.3ad (LACP)'], ['active-backup', 'active-backup'], ['balance-rr', 'balance-rr'], ['balance-xor', 'balance-xor'], ['balance-tlb', 'balance-tlb'], ['balance-alb', 'balance-alb'], ['broadcast', 'broadcast']]; +const VIRTUAL_IF = /^(veth|docker\d|br-[0-9a-f]{12}|tap\d|fwbr|fwpr|fwln|vnet|virbr|cali|flannel|cni|kube|tun|wg|tailscale|zt)/; +const splitList = (v) => String(v || '').split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean); + +class NetworkSession { + constructor(host) { + this.kind = 'network'; this.id = uid(); this.host = host; + this.title = `${T('Network')} · ${host.label || host.address}`; + this.data = null; this.showVirtual = false; + this.el = h(`
+
${avatar(host, 22)}${esc(hostSub(host))}
+ + + + +
+
${esc(T('Connecting to {host} …', { host: host.address }))}
`); + this.body = $('.net-body', this.el); + this.el.addEventListener('click', (e) => { + const a = e.target.closest('[data-a]')?.dataset.a; + if (a === 'refresh') this.refresh(); + if (a === 'files') this.files(); + if (a === 'new') ctxMenu(e.clientX, e.clientY, [ + { label: T('New bond'), icon: 'plus', run: () => this.edit(null, 'bond') }, + { label: T('New bridge'), icon: 'plus', run: () => this.edit(null, 'bridge') }, + { label: T('New VLAN'), icon: 'plus', run: () => this.edit(null, 'vlan') }, + ]); + }); + $('[data-a=virtual]', this.el).onchange = (e) => { this.showVirtual = e.target.checked; this.draw(); }; + this.unsub = api.on('network:closed', (sid) => { if (sid === this.id && !this.applying) { setTabState(this, 'err'); this.error(T('Connection closed.')); } }); + addTab(this); + this.open(); + } + + async open() { + try { + this.apply(await api.call('network:open', this.id, hostRef(this.host))); + setTabState(this, 'on'); + } catch (e) { setTabState(this, 'err'); this.error(e.message); } + } + + error(msg) { + this.body.innerHTML = `
${esc(msg)}
`; + const b = h(``); + b.onclick = () => { closeTab(this.id); openNetwork(this.host); }; + $('.pane-empty', this.body).append(b); + } + + apply(d) { + this.data = d; + const be = { netplan: 'netplan', ifupdown: 'ifupdown', networkmanager: 'NetworkManager', networkd: 'systemd-networkd' }[d.backend] || d.backend; + $('.rt', this.el).textContent = ` · ${d.os || ''} · ${be}`; + $('[data-a=new]', this.el).disabled = !d.editable; + this.draw(); + } + + async refresh() { + try { this.apply(await api.call('network:read', this.id)); } catch (e) { toast(e.message, 'error'); } + } + + cfg(name) { return this.data.config.find((c) => c.name === name); } + + draw() { + const d = this.data; + const scroll = this.body.scrollTop; + this.body.innerHTML = ''; + const wrap = h('
'); + if (d.rolledBack) wrap.append(h(`
${ICONS.refresh}${esc(T('The last network change was rolled back automatically ({time}) because MrTerm could not reconnect.', { time: d.rolledBack }))}
`)); + if (!d.editable) wrap.append(h(`
${ICONS.network}${esc(d.note || T('Editing is supported for Ubuntu (netplan) and Debian/Proxmox (ifupdown). This host uses {backend}, so interfaces are shown read-only. Config files can still be edited.', { backend: d.backend }))}
`)); + + // System: Hostname, Gateway, DNS + const gw = d.routes.map((r) => `${esc(r.via || '—')} (${esc(r.dev)})`).join('
') || `${T('none')}`; + const sys = h(`
+
${esc(d.hostname)}
+
${gw}
+
${esc(d.dns.servers.join(', ') || '—')}${d.dns.search.length ? `
${T('Search')}: ${esc(d.dns.search.join(' '))}` : ''}${d.dns.editable && !d.dns.servers.includes('127.0.0.53') ? `` : ''}
+ ${d.dns.servers.includes('127.0.0.53') ? `
${T('systemd-resolved is used: set DNS servers per interface.')}
` : ''}
`); + sys.onclick = (e) => { const a = e.target.closest('[data-s]')?.dataset.s; if (a === 'host') this.hostname(); if (a === 'dns') this.resolv(); }; + wrap.append(sys); + + // Schnittstellen: live + nur konfigurierte + const live = d.interfaces.filter((i) => this.showVirtual || !VIRTUAL_IF.test(i.name)); + const names = new Set(live.map((i) => i.name)); + const cfgOnly = d.config.filter((c) => !d.interfaces.some((i) => i.name === c.name)).map((c) => ({ name: c.name, kind: c.kind, state: 'ABSENT', addrs: [], master: '' })); + const order = { bond: 1, bridge: 2, vlan: 3, ethernet: 0 }; + const list = [...live, ...cfgOnly.filter((c) => !names.has(c.name))].sort((a, b) => ((order[this.kindOf(a)] ?? 4) - (order[this.kindOf(b)] ?? 4)) || a.name.localeCompare(b.name, undefined, { numeric: true })); + const grid = h('
'); + for (const i of list) grid.append(this.card(i)); + wrap.append(grid); + this.body.append(wrap); + this.body.scrollTop = scroll; + } + + kindOf(i) { return this.cfg(i.name)?.kind || (['bond', 'bridge', 'vlan'].includes(i.kind) ? i.kind : 'ethernet'); } + + card(i) { + const c = this.cfg(i.name); + const kind = this.kindOf(i); + const up = i.state === 'UP' || i.state === 'UNKNOWN'; + const summary = !c ? `${T('Not configured')}` + : c.method4 === 'dhcp' ? 'DHCP' : c.method4 === 'static' ? `${T('Static')} ${esc(c.addresses.join(', '))}${c.gateway ? ` → ${esc(c.gateway)}` : ''}` : `${T('No IPv4')}`; + const b = i.bonding; + const bondInfo = b ? `
${T('Mode')}: ${esc(b.mode || '')}${b.lacpRate ? ` · LACP ${esc(b.lacpRate)}` : ''}${b.hashPolicy ? ` · ${esc(b.hashPolicy)}` : ''}
+ ${b.slaves.map((s) => `
${esc(s.name)} ${esc(s.speed || '')}${s.aggregator ? ` · Agg ${esc(s.aggregator)}` : ''}
`).join('')}
` : ''; + const el = h(`
+
${esc(i.name)}${NET_KINDS[kind] || esc(i.kind || '')} + ${i.master ? `→ ${esc(i.master)}` : ''} + ${this.data.editable ? `` : ''} + ${this.data.editable && c && kind !== 'ethernet' ? `` : ''}
+
${i.mac ? esc(i.mac) : ''}${i.mtu ? ` · MTU ${i.mtu}` : ''}${i.state === 'ABSENT' ? T('Configured, but not present') : ''}
+
${i.addrs.map((a) => `
${esc(a.addr)}${a.dynamic ? ' DHCP' : ''}
`).join('') || `
${T('No address')}
`}
+ ${bondInfo} +
${T('Configured')}: ${summary}
`); + el.onclick = (e) => { + const a = e.target.closest('[data-n]')?.dataset.n; + if (a === 'edit') this.edit(i.name, kind); + if (a === 'del') this.remove(c); + }; + return el; + } + + // Anwenden mit Rollback-Anzeige + async run(label, fn) { + this.applying = true; + const ov = h(`
${esc(label)}
${T('MrTerm reconnects to confirm the change. Without confirmation the server restores the previous configuration after 90 seconds.')}
`); + this.el.append(ov); + try { + const r = await fn(); + ov.remove(); + if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(); } + else { + setTabState(this, 'err'); + this.body.innerHTML = `
${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}
`; + const b = h(``); + b.onclick = () => { closeTab(this.id); openNetwork(this.host); }; + $('.pane-empty', this.body).append(b); + } + } catch (e) { ov.remove(); toast(e.message, 'error'); } + this.applying = false; + } + + async confirmApply(what) { + const r = await modal({ title: T('Apply network change?'), text: what, + body: `

${T('The change is applied immediately. If MrTerm cannot reconnect within 90 seconds, the server restores the previous configuration automatically.')}

+
${T('Change this if the change affects the address MrTerm uses to connect.')}
`, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Apply'), value: 'form', cls: 'primary' }] }); + return r ? (r.verify.trim() === this.host.address ? '' : r.verify.trim()) : null; + } + + edit(name, kind) { + const d = this.data; + const isNew = !name; + const m = structuredClone(this.cfg(name) || { name: 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 }); + if (!this.cfg(name) && name) { const live = d.interfaces.find((i) => i.name === name); if (live?.addrs.some((a) => a.dynamic)) m.method4 = 'dhcp'; } + const phys = [...new Set([...d.interfaces.filter((i) => !VIRTUAL_IF.test(i.name) && (i.kind === 'ethernet' || i.kind === 'bond' || i.kind === 'vlan')).map((i) => i.name), ...d.config.filter((c) => c.kind !== 'bridge').map((c) => c.name)])].filter((x) => x !== m.name).sort(); + const memberBox = (sel, filter) => { + const box = h('
'); + phys.filter(filter).forEach((x) => box.append(h(``))); + if (!box.children.length) box.append(h(`
${T('No suitable interfaces found.')}
`)); + return box; + }; + const fields = []; + if (isNew) fields.push(field(T('Name'), 'name', kind === 'bond' ? 'bond0' : kind === 'bridge' ? (d.backend === 'ifupdown' ? 'vmbr1' : 'br0') : '', { placeholder: kind === 'vlan' ? 'eno1.100' : '', hint: kind === 'vlan' ? T('Usually ., e.g. eno1.100') : '' })); + let members = null; + if (m.kind === 'bond') { + members = memberBox(m.bond.members, (x) => this.kindOf({ name: x, kind: d.interfaces.find((i) => i.name === x)?.kind }) === 'ethernet'); + fields.push(heading(T('Bond')), h(``), members, + field(T('Mode'), 'bmode', m.bond.mode, { type: 'select', options: BOND_MODES }), + row(field(T('LACP rate'), 'lacp', m.bond.lacpRate || 'slow', { type: 'select', options: [['fast', 'fast (1 s)'], ['slow', 'slow (30 s)']] }), + field(T('Hash policy'), 'hash', m.bond.hashPolicy || 'layer2', { type: 'select', options: ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4'].map((x) => [x, x]) })), + field(T('MII monitoring (ms)'), 'miimon', m.bond.miimon ?? 100, { type: 'number' })); + } + if (m.kind === 'bridge') { + members = memberBox(m.bridge.members, () => true); + fields.push(heading(T('Bridge')), h(``), members, check(T('Spanning Tree (STP)'), 'stp', m.bridge.stp)); + } + if (m.kind === 'vlan') fields.push(heading('VLAN'), row(field(T('VLAN ID'), 'vid', m.vlan.id, { type: 'number', placeholder: '100' }), field(T('Parent interface'), 'vlink', m.vlan.link, { type: 'select', options: [['', '—'], ...phys.map((x) => [x, x])] }))); + const v4 = h('
'); + v4.append(field(T('IPv4 addresses (one per line, CIDR)'), 'addresses', m.addresses.join('\n'), { type: 'textarea', placeholder: '192.168.1.10/24' }), field(T('Gateway'), 'gateway', m.gateway, { placeholder: '192.168.1.1' })); + const v6 = h('
'); + v6.append(field(T('IPv6 addresses (one per line, CIDR)'), 'addresses6', m.addresses6.join('\n'), { type: 'textarea', placeholder: '2001:db8::10/64' }), field(T('IPv6 gateway'), 'gateway6', m.gateway6)); + fields.push(heading('IPv4'), field(T('Method'), 'method4', m.method4, { type: 'select', options: [['dhcp', 'DHCP'], ['static', T('Static')], ['none', T('No IPv4 address')]] }), v4, + heading('DNS'), row(field(T('DNS servers'), 'dns', m.dns.join(', '), { placeholder: '1.1.1.1, 9.9.9.9' }), field(T('Search domains'), 'search', m.search.join(' '), { placeholder: 'example.lan' })), + heading('IPv6'), field(T('Method'), 'method6', m.method6, { type: 'select', options: [['auto', T('Automatic (SLAAC)')], ['dhcp', 'DHCPv6'], ['static', T('Static')], ['none', T('Disabled')]] }), v6, + heading(T('Advanced')), field('MTU', 'mtu', m.mtu, { type: 'number', placeholder: '1500' })); + const form = openDrawer(isNew ? T('New {kind}', { kind: NET_KINDS[kind] }) : `${NET_KINDS[m.kind]} ${m.name}`, fields, async () => { + const f = formValues(form); + const out = { ...m, name: isNew ? f.name.trim() : m.name, method4: f.method4, addresses: f.method4 === 'static' ? splitList(f.addresses) : [], gateway: f.method4 === 'static' ? f.gateway.trim() : '', + dns: splitList(f.dns), search: splitList(f.search), mtu: f.mtu, method6: f.method6, addresses6: f.method6 === 'static' ? splitList(f.addresses6) : [], gateway6: f.method6 === 'static' ? f.gateway6.trim() : '' }; + const sel = members ? $$('[data-m]', members).filter((x) => x.checked).map((x) => x.dataset.m) : []; + if (m.kind === 'bond') { if (!sel.length) throw new Error(T('Select at least one member.')); out.bond = { members: sel, mode: f.bmode, lacpRate: f.bmode === '802.3ad' ? f.lacp : '', hashPolicy: f.hash, miimon: f.miimon }; } + if (m.kind === 'bridge') out.bridge = { members: sel, stp: f.stp }; + if (m.kind === 'vlan') { out.vlan = { id: f.vid, link: f.vlink }; if (!f.vlink) throw new Error(T('Select the parent interface.')); } + if (isNew && d.config.some((c) => c.name === out.name)) throw new Error(T('An interface with this name already exists.')); + const verify = await this.confirmApply(`${NET_KINDS[out.kind]} ${out.name}`); + if (verify === null) return false; + closeDrawer(); + this.run(T('Applying network configuration …'), () => api.call('network:save', this.id, out, verify)); + return false; + }); + const sync = () => { + v4.style.display = $('[name=method4]', form).value === 'static' ? '' : 'none'; + v6.style.display = $('[name=method6]', form).value === 'static' ? '' : 'none'; + const bm = $('[name=bmode]', form); + if (bm) $('[name=lacp]', form).closest('.field').style.display = bm.value === '802.3ad' ? '' : 'none'; + }; + form.addEventListener('change', sync); + sync(); + } + + async remove(c) { + if (!(await confirmBox(T('Delete {kind} {name}?', { kind: NET_KINDS[c.kind], name: c.name }), T('The interface is removed from the configuration. Members keep their current settings.')))) return; + const verify = await this.confirmApply(`${T('Delete')} ${c.name}`); + if (verify === null) return; + this.run(T('Applying network configuration …'), () => api.call('network:remove', this.id, c, verify)); + } + + async hostname() { + const n = await promptBox(T('Change hostname'), T('Hostname'), this.data.hostname); + if (!n || n === this.data.hostname) return; + try { await call('network:hostname', this.id, n.trim()); toast(T('Hostname changed'), 'ok'); this.refresh(); } catch {} + } + + async resolv() { + const r = await modal({ title: T('DNS servers'), body: `
${T('Written to /etc/resolv.conf.')}
`, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] }); + if (!r) return; + try { await call('network:resolv', this.id, splitList(r.s), splitList(r.d)); toast(T('Saved'), 'ok'); this.refresh(); } catch {} + } + + async files() { + const list = [...new Set([...this.data.files, '/etc/hosts', ...(this.data.dns.editable ? ['/etc/resolv.conf'] : [])])]; + const f = await new Promise((res) => ctxMenu(innerWidth - 320, 90, list.map((p) => ({ label: p, icon: 'file', run: () => res(p) })))); + let content; + try { content = await call('network:readFile', this.id, f); } catch { return; } + const net = !/^\/etc\/(hosts|resolv\.conf)$/.test(f); + const form = openDrawer(f, [field(T('Content'), 'content', content, { type: 'textarea', hint: net ? T('Saving applies the file with automatic rollback.') : '' })], async () => { + const v = formValues(form).content; + if (!net) { this.run(T('Saving …'), () => api.call('network:writeFile', this.id, f, v, '')); return; } + const verify = await this.confirmApply(f); + if (verify === null) return false; + closeDrawer(); + this.run(T('Applying network configuration …'), () => api.call('network:writeFile', this.id, f, v, verify)); + return false; + }); + const ta = $('textarea', form); ta.classList.add('mono'); ta.style.minHeight = '60vh'; + } + + dispose() { this.unsub(); api.call('network:close', this.id).catch(() => {}); } +} +function openNetwork(host) { return new NetworkSession(host); } + // ============================================================ Command Palette / Quick Connect function openPalette() { if ($('.palette')) return; @@ -1991,6 +2255,7 @@ function openPalette() { if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.folder}
`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.docker}
`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.wall}
`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) }); + if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.network}
`, label: `${T('Network')}: ${x.label || x.address}`, run: () => openNetwork(x) }); }); S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `
${ICONS.code}
`, label: s.label, sub: s.command, run: () => runSnippet(s) })); [[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]] diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 99d65ae..64b0475 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -402,3 +402,30 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi .fw-act.deny, .fw-act.reject { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } .fw-act.limit { background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); } .fw-table th:first-child, .fw-table td:first-child { width: 44px; text-align: left; padding-left: 16px; } +.fw-off { display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--muted); text-align: center; } +.fw-off svg { width: 40px; height: 40px; color: var(--faint); margin-bottom: 6px; } +.fw-off b { color: var(--text); font-size: 15px; } + +/* Netzwerk */ +.net-wrap { padding: 16px; display: flex; flex-direction: column; gap: 14px; } +.net-banner { display: flex; gap: 10px; align-items: center; padding: 10px 14px; border-radius: 10px; background: var(--panel); border: 1px solid var(--border); color: var(--muted); font-size: 13px; } +.net-banner.warn { border-color: color-mix(in srgb, var(--orange) 50%, transparent); color: var(--orange); } +.net-sys { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; background: var(--card); border-radius: 12px; padding: 14px 16px; } +.net-sys label { font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--faint); font-weight: 600; } +.net-sys .v { margin-top: 4px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.net-sys .hint { font-size: 11px; color: var(--faint); margin-top: 4px; } +.net-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; } +.net-card { background: var(--card); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; } +.net-card.absent { opacity: .7; border: 1px dashed var(--border); } +.net-card .nh { display: flex; align-items: center; gap: 8px; } +.net-card .nh .grow { flex: 1; } +.net-card .kind { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; padding: 2px 6px; border-radius: 6px; background: rgb(var(--tint) / 0.063); color: var(--muted); } +.net-card .nm, .net-card .ncfg { font-size: 12px; } +.net-card .na { font-family: var(--mono, monospace); font-size: 12.5px; display: flex; flex-direction: column; gap: 2px; } +.net-card .net-bond { font-size: 12px; border-top: 1px solid var(--row-line); padding-top: 6px; display: flex; flex-direction: column; gap: 3px; } +.net-card .slave { display: flex; align-items: center; gap: 6px; } +.net-card .muted, .net-sys .muted { color: var(--faint); } +.network .dot, .net-card .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; } +.net-card .dot.on { background: var(--green); } +.net-card .dot.err { background: var(--red); } +.flabel { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); font-weight: 600; margin: 4px 0 6px; }