Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4396db3c8 | ||
|
|
98d8cfa993 | ||
|
|
1517764cca | ||
|
|
eba3e38620 | ||
|
|
6a63e0e80d | ||
|
|
41717b2d07 | ||
|
|
ae5309f693 | ||
|
|
487ed069fc | ||
|
|
7a9b70e645 | ||
|
|
352a887a67 | ||
|
|
90f0907430 | ||
|
|
128ca98030 | ||
|
|
a608948823 | ||
|
|
70a15eddbc | ||
|
|
039d89b9f7 | ||
|
|
b38837d4e0 | ||
|
|
b716ce3415 | ||
|
|
b33af709aa | ||
|
|
618f5ad010 | ||
|
|
29fef7cb33 | ||
|
|
74ce2c20be | ||
|
|
e8fefe4ebe | ||
|
|
797722c510 | ||
|
|
c589033964 | ||
|
|
0cefd5a21f |
@@ -32,14 +32,20 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
|
|||||||
- **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display
|
- **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display
|
||||||
- **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer
|
- **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer
|
||||||
- **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key
|
- **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)
|
- **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
|
- **Snippets**: save frequently used commands and send them to a terminal with one click
|
||||||
- **Known hosts**: MrTerm warns you if a server's host key changes
|
- **Known hosts**: MrTerm warns you if a server's host key changes
|
||||||
- **History** of recent connections
|
- **History** of recent connections
|
||||||
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
|
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
|
||||||
- **Backup** export and import
|
- **Backup** export and import
|
||||||
|
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
|
||||||
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
|
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
|
||||||
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
|
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
|
||||||
|
- **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive
|
||||||
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
|
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
@@ -61,9 +67,94 @@ For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `r
|
|||||||
| `Ctrl+Shift+C` / `Ctrl+Shift+V` | Copy / paste in the terminal |
|
| `Ctrl+Shift+C` / `Ctrl+Shift+V` | Copy / paste in the terminal |
|
||||||
| `Ctrl+Shift+F` | Search in the terminal |
|
| `Ctrl+Shift+F` | Search in the terminal |
|
||||||
| `Ctrl` + `+` / `-` / `0` | Font size |
|
| `Ctrl` + `+` / `-` / `0` | Font size |
|
||||||
|
| `Ctrl+Shift+L` | Lock MrTerm |
|
||||||
|
|
||||||
`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual.
|
`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual.
|
||||||
|
|
||||||
|
## Synchronization between devices
|
||||||
|
|
||||||
|
Under **Settings → Synchronization**, you can keep several MrTerm installations in sync, for example your desktop and laptop. Devices talk to each other directly in your local network. There is no server or cloud involved.
|
||||||
|
|
||||||
|
1. Turn on **LAN synchronization** on both devices.
|
||||||
|
2. Click **Pair new device** on both devices and select the other one.
|
||||||
|
3. Both devices show a 6-digit code. If the codes match, click **Codes match** on both devices.
|
||||||
|
4. Choose which **SSH keys, host passwords and VPN configurations** this device may share. Nothing secret is shared unless you select it, and you can change the selection at any time. When you add a new key, password or VPN later, MrTerm asks whether to share it.
|
||||||
|
|
||||||
|
From then on, hosts, groups, snippets, port forwards, VPNs and known hosts are synchronized automatically whenever both devices are running on the same network. Deletions are synchronized too. If a change was made on both devices, the newest one wins. Device-specific settings such as theme, language and app lock stay local.
|
||||||
|
|
||||||
|
**Security:** pairing uses an X25519 key exchange confirmed by the matching code, so another device on the network can't intercept it. Every sync connection is mutually authenticated and encrypted with AES-256-GCM, using a new key for each session.
|
||||||
|
|
||||||
|
**Firewall:** devices find each other on UDP port 47811 and sync on TCP port 47812. **CachyOS enables the UFW firewall by default**, so run this once on CachyOS (and on any other Linux with UFW enabled):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo ufw allow 47811/udp
|
||||||
|
sudo ufw allow 47812/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
MrTerm shows these commands automatically when it detects CachyOS, UFW or firewalld. Windows asks for permission the first time. If devices can't find each other, you can also add one by its IP address.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Right-click an SSH host and choose **Docker containers**, or click **Docker** in the toolbar of an open terminal. MrTerm connects over SSH and shows all containers on that host, with status, ports, CPU and memory.
|
||||||
|
|
||||||
|
- **Open shell**: opens a terminal tab inside the container (bash if available, otherwise sh). You can also double-click a running container.
|
||||||
|
- **Logs**: follows the container's logs live in a terminal tab.
|
||||||
|
- **Start, stop, restart, delete** from the row buttons or the right-click menu.
|
||||||
|
|
||||||
|
Nothing needs to be installed on the server. MrTerm uses the `docker` command (or `podman` if Docker isn't installed). Your SSH user needs permission to run it, which usually means membership in the `docker` group (`sudo usermod -aG docker <user>`). If a password is saved for the host, MrTerm falls back to `sudo` automatically.
|
||||||
|
|
||||||
|
## Firewall
|
||||||
|
|
||||||
|
Right-click an SSH host and choose **Firewall**, or click **Firewall** in the toolbar of an open terminal. MrTerm supports **UFW** and **iptables/ip6tables**. If a server has both, you can switch between them at the top.
|
||||||
|
|
||||||
|
- **UFW**: turn the firewall on or off, change the default policies for incoming and outgoing traffic, and add or delete rules (allow, deny, reject, limit, with port, protocol, source and comment).
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
When you open an assigned host (terminal, SFTP, RDP or port forwarding), MrTerm connects the VPN first if it isn't already connected. You can also connect and disconnect manually. By default, MrTerm disconnects the VPNs it started when you close it.
|
||||||
|
|
||||||
|
- **Linux**: uses NetworkManager, so no root password is needed. For OpenVPN, install the plugin with `sudo pacman -S networkmanager-openvpn`.
|
||||||
|
- **Windows**: WireGuard requires [WireGuard for Windows](https://www.wireguard.com/install/) and asks for administrator permission when connecting. OpenVPN requires the [OpenVPN GUI](https://openvpn.net/community-downloads/).
|
||||||
|
- OpenVPN certificates and keys must be embedded in the `.ovpn` file.
|
||||||
|
|
||||||
|
## App lock
|
||||||
|
|
||||||
|
Under **Settings → App lock** you can protect MrTerm with a password, one or more FIDO2 security keys (e.g. YubiKey), or both. Once a method is set up:
|
||||||
|
|
||||||
|
- MrTerm starts locked, and your hosts, keys and passwords stay encrypted until you unlock it.
|
||||||
|
- Lock it any time with the lock icon in the title bar or `Ctrl+Shift+L`, or let it lock automatically after a period of inactivity.
|
||||||
|
- Open sessions keep running in the background while MrTerm is locked.
|
||||||
|
|
||||||
|
Security keys need to support the *hmac-secret* (PRF) extension, which YubiKey 5 and most current FIDO2 keys do. Touching the key is enough, no PIN is needed. On Linux, the key must be accessible to your user. This is the default on Arch/CachyOS.
|
||||||
|
|
||||||
|
**Keep in mind:** if you forget the password and lose all registered security keys, your vault cannot be recovered. Setting up a second unlock method is a good idea.
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
MrTerm looks for updates on startup. You can also check manually under **Settings → Updates**. When a new version is available, click **Install now** and MrTerm will download the right package for your system and restart.
|
MrTerm looks for updates on startup. You can also check manually under **Settings → Updates**. When a new version is available, click **Install now** and MrTerm will download the right package for your system and restart.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.4.0",
|
"version": "0.10.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.4.0",
|
"version": "0.10.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.4.0",
|
"version": "0.10.2",
|
||||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||||
"main": "src/main/main.js",
|
"main": "src/main/main.js",
|
||||||
"author": "MrBlake",
|
"author": "MrBlake",
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ if [[ "$version" != "$current" ]]; then
|
|||||||
git add package.json package-lock.json
|
git add package.json package-lock.json
|
||||||
git commit -m "Version ${version}"
|
git commit -m "Version ${version}"
|
||||||
fi
|
fi
|
||||||
git push origin HEAD
|
# Push mit dem Gitea-Token (Basic-Auth, Benutzer aus GITEA_USER, Standard MrBlake)
|
||||||
|
auth="$(printf '%s:%s' "${GITEA_USER:-MrBlake}" "$GITEA_TOKEN" | base64 -w0)"
|
||||||
|
git -c http.extraHeader="Authorization: Basic ${auth}" push origin HEAD
|
||||||
|
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
npm run dist:linux
|
npm run dist:linux
|
||||||
|
|||||||
+286
@@ -311,6 +311,292 @@
|
|||||||
'Actions': 'Aktionen',
|
'Actions': 'Aktionen',
|
||||||
'Log in': 'Anmelden',
|
'Log in': 'Anmelden',
|
||||||
|
|
||||||
|
// App-Sperre
|
||||||
|
'App lock': 'App-Sperre',
|
||||||
|
'Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.': 'MrTerm mit Passwort und/oder FIDO2-Sicherheitsschlüssel (z. B. YubiKey) sperren. Der Vault wird dann zusätzlich verschlüsselt und lässt sich nur mit einer dieser Methoden öffnen.',
|
||||||
|
'MrTerm is locked': 'MrTerm ist gesperrt',
|
||||||
|
'MrTerm is locked.': 'MrTerm ist gesperrt.',
|
||||||
|
'Unlock': 'Entsperren',
|
||||||
|
'Unlock with security key': 'Mit Sicherheitsschlüssel entsperren',
|
||||||
|
'Disable app lock?': 'App-Sperre deaktivieren?',
|
||||||
|
'This is the last unlock method. MrTerm will no longer be locked.': 'Das ist die letzte Entsperrmethode. MrTerm wird danach nicht mehr gesperrt.',
|
||||||
|
'Disable': 'Deaktivieren',
|
||||||
|
'Set': 'Festgelegt',
|
||||||
|
'Not set': 'Nicht festgelegt',
|
||||||
|
'Change password': 'Passwort ändern',
|
||||||
|
'Set password': 'Passwort festlegen',
|
||||||
|
'New password': 'Neues Passwort',
|
||||||
|
'Repeat password': 'Passwort wiederholen',
|
||||||
|
'The passwords do not match.': 'Die Passwörter stimmen nicht überein.',
|
||||||
|
'Password saved': 'Passwort gespeichert',
|
||||||
|
'Security key': 'Sicherheitsschlüssel',
|
||||||
|
'Security key (FIDO2)': 'Sicherheitsschlüssel (FIDO2)',
|
||||||
|
'Remove security key?': 'Sicherheitsschlüssel entfernen?',
|
||||||
|
'Add security key': 'Sicherheitsschlüssel hinzufügen',
|
||||||
|
'Security key added': 'Sicherheitsschlüssel hinzugefügt',
|
||||||
|
'Lock now': 'Jetzt sperren',
|
||||||
|
'Lock (Ctrl+Shift+L)': 'Sperren (Strg+Shift+L)',
|
||||||
|
'Lock automatically after inactivity': 'Automatisch sperren bei Inaktivität',
|
||||||
|
'Never': 'Nie',
|
||||||
|
'{n} minutes': '{n} Minuten',
|
||||||
|
'If you forget the password and lose all security keys, the vault cannot be recovered.': 'Wenn du das Passwort vergisst und alle Sicherheitsschlüssel verlierst, lässt sich der Vault nicht wiederherstellen.',
|
||||||
|
'No password set.': 'Kein Passwort festgelegt.',
|
||||||
|
'Wrong password.': 'Falsches Passwort.',
|
||||||
|
'No security key registered.': 'Kein Sicherheitsschlüssel registriert.',
|
||||||
|
'Unknown security key.': 'Unbekannter Sicherheitsschlüssel.',
|
||||||
|
'This security key could not unlock the vault.': 'Dieser Sicherheitsschlüssel konnte den Vault nicht entsperren.',
|
||||||
|
'The password must be at least 6 characters long.': 'Das Passwort muss mindestens 6 Zeichen lang sein.',
|
||||||
|
'Touch your security key to register it.': 'Berühre deinen Sicherheitsschlüssel, um ihn zu registrieren.',
|
||||||
|
'Touch your security key again to finish.': 'Berühre deinen Sicherheitsschlüssel noch einmal zum Abschließen.',
|
||||||
|
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
||||||
|
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
||||||
|
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||||
|
|
||||||
|
// VPN
|
||||||
|
'— No VPN —': '— Kein VPN —',
|
||||||
|
'Connected automatically before connecting to this host.': 'Wird vor dem Verbinden mit diesem Host automatisch aufgebaut.',
|
||||||
|
'Search VPNs …': 'VPNs suchen …',
|
||||||
|
'New VPN': 'Neues VPN',
|
||||||
|
'No VPNs': 'Keine VPNs',
|
||||||
|
'Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.': 'Füge WireGuard- oder OpenVPN-Konfigurationen hinzu und ordne sie Hosts zu. MrTerm baut das VPN automatisch auf, wenn du einen solchen Host öffnest.',
|
||||||
|
'connected': 'verbunden',
|
||||||
|
'disconnected': 'getrennt',
|
||||||
|
'Disconnect': 'Trennen',
|
||||||
|
'Connecting …': 'Verbinde …',
|
||||||
|
'VPN “{name}” connected': 'VPN „{name}“ verbunden',
|
||||||
|
'Delete VPN?': 'VPN löschen?',
|
||||||
|
'“{name}” will be removed. Assigned hosts will connect without VPN.': '„{name}“ wird entfernt. Zugeordnete Hosts verbinden sich dann ohne VPN.',
|
||||||
|
'Configuration': 'Konfiguration',
|
||||||
|
'Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).': 'Zertifikate und Schlüssel müssen in der .ovpn-Datei eingebettet sein (<ca>, <cert>, <key> …).',
|
||||||
|
'Contents of a WireGuard .conf file.': 'Inhalt einer WireGuard-.conf-Datei.',
|
||||||
|
'Edit VPN': 'VPN bearbeiten',
|
||||||
|
'Office VPN': 'Büro-VPN',
|
||||||
|
'Disconnect when MrTerm closes': 'Beim Beenden von MrTerm trennen',
|
||||||
|
'Assigned hosts': 'Zugeordnete Hosts',
|
||||||
|
'Configuration is missing.': 'Konfiguration fehlt.',
|
||||||
|
'This does not look like a WireGuard configuration.': 'Das sieht nicht nach einer WireGuard-Konfiguration aus.',
|
||||||
|
'VPN configuration': 'VPN-Konfiguration',
|
||||||
|
'NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.': 'NetworkManager (nmcli) nicht gefunden. MrTerm nutzt NetworkManager für VPN-Verbindungen.',
|
||||||
|
'OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn': 'OpenVPN-Unterstützung für NetworkManager fehlt. Installieren mit: sudo pacman -S networkmanager-openvpn',
|
||||||
|
'VPN configuration could not be imported: {err}': 'VPN-Konfiguration konnte nicht importiert werden: {err}',
|
||||||
|
'VPN “{name}” could not be connected: {err}': 'VPN „{name}“ konnte nicht verbunden werden: {err}',
|
||||||
|
'VPN not found': 'VPN nicht gefunden',
|
||||||
|
'OpenVPN GUI not found. Install OpenVPN from openvpn.net.': 'OpenVPN GUI nicht gefunden. Installiere OpenVPN von openvpn.net.',
|
||||||
|
'WireGuard for Windows not found. Install it from wireguard.com.': 'WireGuard für Windows nicht gefunden. Installiere es von wireguard.com.',
|
||||||
|
'timeout': 'Zeitüberschreitung',
|
||||||
|
'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …',
|
||||||
|
|
||||||
|
// Docker
|
||||||
|
'Docker containers': 'Docker-Container',
|
||||||
|
'Search containers …': 'Container suchen …',
|
||||||
|
'Show stopped': 'Gestoppte anzeigen',
|
||||||
|
'Connection closed.': 'Verbindung beendet.',
|
||||||
|
'No containers on this host.': 'Keine Container auf diesem Host.',
|
||||||
|
'Ports': 'Ports',
|
||||||
|
'Memory': 'Speicher',
|
||||||
|
'Open shell': 'Shell öffnen',
|
||||||
|
'Logs': 'Logs',
|
||||||
|
'Restart': 'Neu starten',
|
||||||
|
'Copy ID': 'ID kopieren',
|
||||||
|
'Delete container?': 'Container löschen?',
|
||||||
|
'“{name}” will be removed including its writable layer. Volumes are kept.': '„{name}“ wird samt beschreibbarer Ebene entfernt. Volumes bleiben erhalten.',
|
||||||
|
'Stop container?': 'Container stoppen?',
|
||||||
|
'Started': 'Gestartet',
|
||||||
|
'Stopped': 'Gestoppt',
|
||||||
|
'Restarted': 'Neu gestartet',
|
||||||
|
'Deleted': 'Gelöscht',
|
||||||
|
'Docker session not found': 'Docker-Sitzung nicht gefunden',
|
||||||
|
'Neither Docker nor Podman was found on this host.': 'Auf diesem Host wurde weder Docker noch Podman gefunden.',
|
||||||
|
'No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.': 'Keine Berechtigung für {runtime}. Füge den Benutzer der Gruppe „docker“ hinzu (sudo usermod -aG docker {user}) oder hinterlege das Host-Passwort, damit MrTerm sudo verwenden kann.',
|
||||||
|
'{runtime} exited with code {code}': '{runtime} beendet mit Code {code}',
|
||||||
|
|
||||||
|
// Firewall
|
||||||
|
'Firewall': 'Firewall',
|
||||||
|
'Save the current iptables rules so they survive a reboot': 'Aktuelle iptables-Regeln speichern, damit sie einen Neustart überstehen',
|
||||||
|
'Save permanently': 'Dauerhaft speichern',
|
||||||
|
'Add rule': 'Regel hinzufügen',
|
||||||
|
'Rules saved permanently': 'Regeln dauerhaft gespeichert',
|
||||||
|
'Enable': 'Aktivieren',
|
||||||
|
'Disable firewall?': 'Firewall deaktivieren?',
|
||||||
|
'All UFW rules will stop filtering traffic.': 'Keine UFW-Regel filtert dann mehr den Datenverkehr.',
|
||||||
|
'Enable firewall?': 'Firewall aktivieren?',
|
||||||
|
'There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.': 'Es gibt keine Regel, die SSH (Port {port}) erlaubt. Wenn du UFW aktivierst, könntest du dich von diesem Server aussperren.',
|
||||||
|
'Enable anyway': 'Trotzdem aktivieren',
|
||||||
|
'Allow SSH and enable': 'SSH erlauben und aktivieren',
|
||||||
|
'Firewall enabled': 'Firewall aktiviert',
|
||||||
|
'Incoming': 'Eingehend',
|
||||||
|
'Outgoing': 'Ausgehend',
|
||||||
|
'Allow': 'Erlauben',
|
||||||
|
'Deny': 'Verweigern',
|
||||||
|
'Reject': 'Abweisen',
|
||||||
|
'Change default policy?': 'Standardrichtlinie ändern?',
|
||||||
|
'Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.': 'Eingehende Verbindungen ohne passende Regel werden dann blockiert. Stelle sicher, dass SSH erlaubt ist.',
|
||||||
|
'Change': 'Ändern',
|
||||||
|
'No rules yet.': 'Noch keine Regeln.',
|
||||||
|
'To': 'Ziel',
|
||||||
|
'Action': 'Aktion',
|
||||||
|
'From': 'Quelle',
|
||||||
|
'Comment': 'Kommentar',
|
||||||
|
'Rule deleted': 'Regel gelöscht',
|
||||||
|
'Changes apply immediately but are lost after a reboot unless you click “Save permanently”.': 'Änderungen gelten sofort, gehen nach einem Neustart aber verloren, wenn du nicht auf „Dauerhaft speichern“ klickst.',
|
||||||
|
'rules': 'Regeln',
|
||||||
|
'Policy': 'Richtlinie',
|
||||||
|
'Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.': 'Datenverkehr ohne passende ACCEPT-Regel wird dann verworfen. Stelle sicher, dass SSH erlaubt ist, sonst sperrst du dich eventuell aus.',
|
||||||
|
'Add UFW rule': 'UFW-Regel hinzufügen',
|
||||||
|
'Limit (rate-limit)': 'Begrenzen (Rate-Limit)',
|
||||||
|
'Direction': 'Richtung',
|
||||||
|
'Port': 'Port',
|
||||||
|
'Protocol': 'Protokoll',
|
||||||
|
'Any': 'Beliebig',
|
||||||
|
'From (IP or network)': 'Quelle (IP oder Netz)',
|
||||||
|
'Insert at the top (highest priority)': 'Ganz oben einfügen (höchste Priorität)',
|
||||||
|
'Add': 'Hinzufügen',
|
||||||
|
'Rule added': 'Regel hinzugefügt',
|
||||||
|
'Add {bin} rule': '{bin}-Regel hinzufügen',
|
||||||
|
'Chain': 'Kette',
|
||||||
|
'Target': 'Ziel',
|
||||||
|
'Source (IP or network)': 'Quelle (IP oder Netz)',
|
||||||
|
'Interface': 'Schnittstelle',
|
||||||
|
'Connection state': 'Verbindungsstatus',
|
||||||
|
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
|
||||||
|
'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
|
||||||
|
'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}',
|
||||||
|
'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.',
|
||||||
|
'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.',
|
||||||
|
'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.',
|
||||||
|
'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).',
|
||||||
|
'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 <parent>.<VLAN ID>, e.g. eno1.100': 'Üblich: <Eltern>.<VLAN-ID>, 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.',
|
||||||
|
|
||||||
|
'Authentication failed for {user}. Check the username, password or key.': 'Anmeldung für {user} fehlgeschlagen. Prüfe Benutzername, Passwort oder Schlüssel.',
|
||||||
|
// Synchronisation
|
||||||
|
'Synchronization': 'Synchronisation',
|
||||||
|
'Synchronize hosts, groups, snippets, port forwards, VPNs and known hosts directly between MrTerm devices in your local network. The connection is end-to-end encrypted; no server or cloud is involved.': 'Hosts, Gruppen, Snippets, Port-Weiterleitungen, VPNs und Known Hosts direkt zwischen MrTerm-Geräten im lokalen Netz abgleichen. Die Verbindung ist Ende-zu-Ende verschlüsselt, ohne Server oder Cloud.',
|
||||||
|
'Enable LAN synchronization': 'LAN-Synchronisation aktivieren',
|
||||||
|
'Device name': 'Gerätename',
|
||||||
|
'No paired devices yet.': 'Noch keine gekoppelten Geräte.',
|
||||||
|
'online': 'online',
|
||||||
|
'offline': 'offline',
|
||||||
|
'last sync {time}': 'zuletzt {time}',
|
||||||
|
'Unpair': 'Entkoppeln',
|
||||||
|
'Unpair device?': 'Gerät entkoppeln?',
|
||||||
|
'“{name}” will no longer synchronize with this device.': '„{name}“ synchronisiert dann nicht mehr mit diesem Gerät.',
|
||||||
|
'Shared secrets: {keys} SSH keys, {pw} host passwords, {vpn} VPN configurations': 'Geteilte Geheimnisse: {keys} SSH-Schlüssel, {pw} Host-Passwörter, {vpn} VPN-Konfigurationen',
|
||||||
|
'Pair new device': 'Neues Gerät koppeln',
|
||||||
|
'Sync now': 'Jetzt synchronisieren',
|
||||||
|
'Synchronized with {n} device(s)': 'Mit {n} Gerät(en) synchronisiert',
|
||||||
|
'Choose shared secrets': 'Geteilte Geheimnisse auswählen',
|
||||||
|
'Devices find each other via UDP port 47811 and synchronize via TCP port 47812. If a firewall is active, allow these ports in your local network (with UFW: sudo ufw allow 47811/udp and sudo ufw allow 47812/tcp).': 'Geräte finden sich über UDP-Port 47811 und synchronisieren über TCP-Port 47812. Ist eine Firewall aktiv, erlaube diese Ports im lokalen Netz (mit UFW: sudo ufw allow 47811/udp und sudo ufw allow 47812/tcp).',
|
||||||
|
'Open “Pair new device” on the other device too, then select it here. Both devices show a code that you confirm on both sides.': 'Öffne „Neues Gerät koppeln“ auch auf dem anderen Gerät und wähle es dann hier aus. Beide Geräte zeigen einen Code, den du auf beiden Seiten bestätigst.',
|
||||||
|
'Or enter an IP address, e.g. 192.168.0.25': 'Oder IP-Adresse eingeben, z. B. 192.168.0.25',
|
||||||
|
'Searching for devices …': 'Suche nach Geräten …',
|
||||||
|
'not ready for pairing': 'nicht bereit zum Koppeln',
|
||||||
|
'Pair': 'Koppeln',
|
||||||
|
'Pairing …': 'Kopple …',
|
||||||
|
'Device found': 'Gerät gefunden',
|
||||||
|
'Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.': 'Wähle, welche Geheimnisse dieses Gerät an gekoppelte Geräte senden darf. Alles andere (Hosts, Gruppen, Snippets …) wird ohne Passwörter und private Schlüssel synchronisiert.',
|
||||||
|
'SSH keys': 'SSH-Schlüssel',
|
||||||
|
'Host passwords': 'Host-Passwörter',
|
||||||
|
'VPN configurations': 'VPN-Konfigurationen',
|
||||||
|
'There are no secrets on this device yet.': 'Auf diesem Gerät gibt es noch keine Geheimnisse.',
|
||||||
|
'Share nothing': 'Nichts teilen',
|
||||||
|
'Select all': 'Alle auswählen',
|
||||||
|
'Paired with {name}': 'Mit {name} gekoppelt',
|
||||||
|
'Confirm pairing': 'Kopplung bestätigen',
|
||||||
|
'Pairing with “{name}”. Does the other device show the same code?': 'Kopplung mit „{name}“. Zeigt das andere Gerät denselben Code?',
|
||||||
|
'Codes differ': 'Codes unterschiedlich',
|
||||||
|
'Codes match': 'Codes stimmen überein',
|
||||||
|
'Pairing was rejected on this device.': 'Die Kopplung wurde auf diesem Gerät abgelehnt.',
|
||||||
|
'Pairing was rejected on the other device.': 'Die Kopplung wurde auf dem anderen Gerät abgelehnt.',
|
||||||
|
'Pairing verification failed.': 'Die Überprüfung der Kopplung ist fehlgeschlagen.',
|
||||||
|
'Device is not ready for pairing': 'Das Gerät ist nicht zum Koppeln bereit',
|
||||||
|
|
||||||
|
'CachyOS: allow the sync ports in the firewall': 'CachyOS: Sync-Ports in der Firewall freigeben',
|
||||||
|
'Firewall active: allow the sync ports': 'Firewall aktiv: Sync-Ports freigeben',
|
||||||
|
'Otherwise other devices cannot find or reach this device. Run these commands once in a terminal:': 'Sonst können andere Geräte dieses Gerät weder finden noch erreichen. Führe diese Befehle einmal in einem Terminal aus:',
|
||||||
|
'Copy commands': 'Befehle kopieren',
|
||||||
|
'Already done, hide': 'Erledigt, ausblenden',
|
||||||
|
'CachyOS enables the UFW firewall by default. It blocks the ports MrTerm needs to find and reach other devices (UDP 47811, TCP 47812). Run these commands once in a terminal:': 'CachyOS aktiviert standardmäßig die Firewall UFW. Sie blockiert die Ports, über die MrTerm andere Geräte findet und erreicht (UDP 47811, TCP 47812). Führe diese Befehle einmal in einem Terminal aus:',
|
||||||
|
'No devices found? The firewall on this device may block them:': 'Keine Geräte gefunden? Die Firewall dieses Geräts blockiert sie eventuell:',
|
||||||
|
'Host password': 'Host-Passwort',
|
||||||
|
'Share with paired devices?': 'Mit gekoppelten Geräten teilen?',
|
||||||
|
'Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.': 'Soll dieses Geheimnis mit deinen gekoppelten Geräten ({names}) synchronisiert werden? Du kannst das später unter Einstellungen → Synchronisation ändern.',
|
||||||
|
'Don\'t share': 'Nicht teilen',
|
||||||
|
'Share': 'Teilen',
|
||||||
// Main-Prozess
|
// Main-Prozess
|
||||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||||
'Unknown host': 'Unbekannter Host',
|
'Unknown host': 'Unbekannter Host',
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Docker/Podman auf entfernten Hosts über die bestehende SSH-Verbindung (CLI per exec).
|
||||||
|
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
|
||||||
|
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
|
||||||
|
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
|
||||||
|
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
|
||||||
|
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
|
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
||||||
|
|
||||||
|
function execOn(conn, cmd, stdin) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
conn.exec(cmd, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
let out = '', errOut = '';
|
||||||
|
stream.on('data', (d) => { out += d; });
|
||||||
|
stream.stderr.on('data', (d) => { errOut += d; });
|
||||||
|
stream.on('close', (code) => resolve({ code: code ?? 0, out, err: errOut }));
|
||||||
|
stream.end(stdin ?? '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DockerManager {
|
||||||
|
constructor(ssh) {
|
||||||
|
this.ssh = ssh;
|
||||||
|
this.sessions = new Map(); // id -> { conn, jumps, host, runtime, sudo }
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(id, host, onClose) {
|
||||||
|
const { conn, jumps } = await this.ssh.connect(host, id);
|
||||||
|
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false });
|
||||||
|
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
||||||
|
conn.on('error', () => {});
|
||||||
|
return this.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) throw new Error(i18n.t('Docker session not found'));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
run(s, args) {
|
||||||
|
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||||
|
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(id) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (!s.runtime) {
|
||||||
|
const r = await execOn(s.conn, 'for c in docker podman; do if command -v $c >/dev/null 2>&1; then echo $c; exit 0; fi; done; exit 127');
|
||||||
|
if (r.code) throw new Error(i18n.t('Neither Docker nor Podman was found on this host.'));
|
||||||
|
s.runtime = r.out.trim();
|
||||||
|
}
|
||||||
|
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||||
|
if (r.code && denied(r.err) && !s.sudo) {
|
||||||
|
s.sudo = true;
|
||||||
|
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||||
|
if (r.code) s.sudo = false;
|
||||||
|
}
|
||||||
|
if (r.code) {
|
||||||
|
if (denied(r.err) || /sudo/i.test(r.err)) {
|
||||||
|
throw new Error(i18n.t('No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.', { runtime: s.runtime, user: s.host.username || '$USER' }));
|
||||||
|
}
|
||||||
|
throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
|
||||||
|
}
|
||||||
|
const containers = r.out.split('\n').filter(Boolean).map((l) => {
|
||||||
|
const [cid, name, image, state, status, ports] = l.split('\t');
|
||||||
|
return { id: cid.slice(0, 12), name, image, state: (state || '').toLowerCase(), status, ports };
|
||||||
|
});
|
||||||
|
return { runtime: s.runtime, sudo: s.sudo, containers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU/RAM der laufenden Container (dauert ~2 s)
|
||||||
|
async stats(id) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const r = await this.run(s, `stats --no-stream --format ${STATS_FMT}`);
|
||||||
|
if (r.code) return {};
|
||||||
|
return Object.fromEntries(r.out.split('\n').filter(Boolean).map((l) => {
|
||||||
|
const [cid, cpu, mem] = l.split('\t');
|
||||||
|
return [cid.slice(0, 12), { cpu, mem }];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async action(id, action, cid) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (!ACTIONS[action] || !SAFE_ID.test(cid)) throw new Error('Invalid action');
|
||||||
|
const r = await this.run(s, `${ACTIONS[action]} ${cid}`);
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehl für einen Terminal-Tab (läuft mit PTY; sudo fragt dort ggf. selbst nach dem Passwort)
|
||||||
|
command(id, kind, cid) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (!SAFE_ID.test(cid)) throw new Error('Invalid container');
|
||||||
|
const pre = `${s.sudo ? 'sudo ' : ''}${s.runtime}`;
|
||||||
|
if (kind === 'logs') return `${pre} logs -f --tail 500 ${cid}`;
|
||||||
|
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = { DockerManager, execOn };
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// FIDO2/WebAuthn (YubiKey & Co.) für die App-Sperre.
|
||||||
|
// Chromium erlaubt WebAuthn nur auf HTTPS oder http://localhost – nicht unter file://. Deshalb läuft die
|
||||||
|
// Abfrage in einem kleinen eigenen Fenster, dessen http://localhost-Seite über eine eigene Session
|
||||||
|
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
||||||
|
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
|
||||||
|
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
||||||
|
const { BrowserWindow, session } = require('electron');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||||
|
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
||||||
|
body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;border:1px solid #2c3142;box-sizing:border-box;text-align:center;padding:16px}
|
||||||
|
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
||||||
|
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
|
||||||
|
button:hover{background:#343a4f}
|
||||||
|
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`;
|
||||||
|
|
||||||
|
let fidoSession;
|
||||||
|
function getSession() {
|
||||||
|
if (fidoSession) return fidoSession;
|
||||||
|
fidoSession = session.fromPartition('mrterm-fido');
|
||||||
|
fidoSession.protocol.handle('http', () => new Response(PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
||||||
|
return fidoSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemeinsame Hilfsfunktionen im Fenster (base64url <-> Bytes)
|
||||||
|
const HELPERS = `
|
||||||
|
const b64 = (u) => btoa(String.fromCharCode(...new Uint8Array(u))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
||||||
|
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function ceremony(parent, text, script) {
|
||||||
|
const w = new BrowserWindow({
|
||||||
|
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
|
||||||
|
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await w.loadURL('http://localhost/');
|
||||||
|
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
||||||
|
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
||||||
|
w.show();
|
||||||
|
w.focus();
|
||||||
|
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
|
||||||
|
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
|
||||||
|
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
|
if (r?.error) throw new Error(r.error);
|
||||||
|
return r;
|
||||||
|
} finally {
|
||||||
|
if (!w.isDestroyed()) w.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
||||||
|
async function register(parent) {
|
||||||
|
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
||||||
|
const cred = await navigator.credentials.create({ publicKey: {
|
||||||
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
|
rp: { name: 'MrTerm', id: 'localhost' },
|
||||||
|
user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'MrTerm', displayName: 'MrTerm' },
|
||||||
|
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -8 }, { type: 'public-key', alg: -257 }],
|
||||||
|
authenticatorSelection: { userVerification: 'discouraged', residentKey: 'discouraged' },
|
||||||
|
timeout: 60000, extensions: { prf: {} },
|
||||||
|
} });
|
||||||
|
return { credId: b64(cred.rawId), prf: cred.getClientExtensionResults().prf?.enabled };`);
|
||||||
|
if (r.prf === false) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
return r.credId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
||||||
|
// Liefert { credId, secret: Buffer(32) }
|
||||||
|
async function derive(parent, creds, text) {
|
||||||
|
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
||||||
|
const creds = ${JSON.stringify(creds)};
|
||||||
|
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
||||||
|
const a = await navigator.credentials.get({ publicKey: {
|
||||||
|
challenge: crypto.getRandomValues(new Uint8Array(32)), rpId: 'localhost', timeout: 60000, userVerification: 'discouraged',
|
||||||
|
allowCredentials: creds.map((c) => ({ type: 'public-key', id: unb64(c.credId) })),
|
||||||
|
extensions: { prf: { evalByCredential } },
|
||||||
|
} });
|
||||||
|
const first = a.getClientExtensionResults().prf?.results?.first;
|
||||||
|
return { credId: b64(a.rawId), secret: first ? b64(first) : null };`);
|
||||||
|
if (!r.secret) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
return { credId: r.credId, secret: Buffer.from(r.secret, 'base64url') };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register, derive };
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Firewall entfernter Hosts über SSH einsehen und bearbeiten: UFW sowie iptables/ip6tables (Tabelle filter).
|
||||||
|
// Braucht root: entweder als root angemeldet oder sudo mit dem gespeicherten Host-Passwort (über stdin).
|
||||||
|
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const { execOn } = require('./docker');
|
||||||
|
|
||||||
|
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
|
const RX = {
|
||||||
|
port: /^\d{1,5}([:,]\d{1,5})*$/,
|
||||||
|
addr: /^(any|[0-9a-fA-F.:]+(\/\d{1,3})?)$/,
|
||||||
|
comment: /^[^'"\\`$\n]{0,80}$/,
|
||||||
|
chain: /^[A-Za-z0-9_.-]{1,40}$/,
|
||||||
|
iface: /^[A-Za-z0-9_.@-]{1,15}\+?$/,
|
||||||
|
};
|
||||||
|
const check = (v, rx, what) => { if (!rx.test(String(v))) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: what, value: v })); return String(v); };
|
||||||
|
|
||||||
|
// UFW-Regeln aus `ufw status numbered`
|
||||||
|
function parseUfw(out) {
|
||||||
|
const status = /Status:\s*active/i.test(out) ? 'active' : 'inactive';
|
||||||
|
const rules = [];
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
const m = line.match(/^\[\s*(\d+)\]\s+(.+?)\s{2,}(ALLOW|DENY|REJECT|LIMIT)(?:\s+(IN|OUT|FWD))?\s+(.+?)\s*$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let [, num, to, action, dir, from] = m;
|
||||||
|
let comment = '';
|
||||||
|
const c = from.match(/^(.*?)\s+#\s*(.*)$/);
|
||||||
|
if (c) { from = c[1].trim(); comment = c[2]; }
|
||||||
|
rules.push({ num: Number(num), to: to.trim(), action, dir: dir || 'IN', from: from.trim(), comment, v6: /\(v6\)/.test(to + from) });
|
||||||
|
}
|
||||||
|
return { status, rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
// iptables -S: Richtlinien, Ketten und Regeln (Nummer = Position in der Kette)
|
||||||
|
function parseIptables(out) {
|
||||||
|
const chains = new Map();
|
||||||
|
const get = (n) => { if (!chains.has(n)) chains.set(n, { name: n, policy: null, rules: [] }); return chains.get(n); };
|
||||||
|
for (const line of out.split('\n')) {
|
||||||
|
let m;
|
||||||
|
if ((m = line.match(/^-P (\S+) (\S+)/))) get(m[1]).policy = m[2];
|
||||||
|
else if ((m = line.match(/^-N (\S+)/))) get(m[1]);
|
||||||
|
else if ((m = line.match(/^-A (\S+) (.*)$/))) { const c = get(m[1]); c.rules.push({ num: c.rules.length + 1, spec: m[2] }); }
|
||||||
|
}
|
||||||
|
return [...chains.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
class FirewallManager {
|
||||||
|
constructor(ssh) {
|
||||||
|
this.ssh = ssh;
|
||||||
|
this.sessions = new Map(); // id -> { conn, jumps, host, sudo, tools }
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) throw new Error(i18n.t('Firewall session not found'));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehl mit root-Rechten ausführen
|
||||||
|
run(s, cmd) {
|
||||||
|
if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`);
|
||||||
|
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
|
||||||
|
}
|
||||||
|
async runOk(s, cmd) {
|
||||||
|
const r = await this.run(s, cmd);
|
||||||
|
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, tools: [] };
|
||||||
|
this.sessions.set(id, s);
|
||||||
|
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
||||||
|
conn.on('error', () => {});
|
||||||
|
const uid = (await execOn(conn, 'id -u')).out.trim();
|
||||||
|
if (uid !== '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.'));
|
||||||
|
}
|
||||||
|
const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\'');
|
||||||
|
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||||
|
if (!s.tools.includes('ufw') && !s.tools.includes('iptables')) {
|
||||||
|
throw new Error(s.tools.includes('firewall-cmd') ? i18n.t('This host uses firewalld, which is not supported yet.') : i18n.t('Neither UFW nor iptables was found on this host.'));
|
||||||
|
}
|
||||||
|
return { tools: s.tools, sudo: s.sudo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(id, backend) {
|
||||||
|
const s = this.get(id);
|
||||||
|
if (backend === 'ufw') {
|
||||||
|
const out = await this.runOk(s, 'ufw status numbered');
|
||||||
|
const verbose = await this.runOk(s, 'ufw status verbose');
|
||||||
|
const def = verbose.match(/Default:\s*(\w+)\s*\(incoming\),\s*(\w+)\s*\(outgoing\)(?:,\s*(\w+)\s*\(routed\))?/i);
|
||||||
|
return { ...parseUfw(out), defaults: def ? { incoming: def[1], outgoing: def[2], routed: def[3] || '' } : null };
|
||||||
|
}
|
||||||
|
const bin = backend === 'ip6tables' ? 'ip6tables' : 'iptables';
|
||||||
|
return { chains: parseIptables(await this.runOk(s, `${bin} -S`)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async ufw(id, op, a = {}) {
|
||||||
|
const s = this.get(id);
|
||||||
|
switch (op) {
|
||||||
|
case 'enable': return this.runOk(s, 'ufw --force enable');
|
||||||
|
case 'disable': return this.runOk(s, 'ufw disable');
|
||||||
|
case 'delete': return this.runOk(s, `ufw --force delete ${Number(a.num)}`);
|
||||||
|
case 'default': {
|
||||||
|
const pol = check(a.policy, /^(allow|deny|reject)$/, 'policy');
|
||||||
|
const dir = check(a.dir, /^(incoming|outgoing|routed)$/, 'direction');
|
||||||
|
return this.runOk(s, `ufw default ${pol} ${dir}`);
|
||||||
|
}
|
||||||
|
case 'add': {
|
||||||
|
const action = check(a.action, /^(allow|deny|reject|limit)$/, 'action');
|
||||||
|
const dir = check(a.dir || 'in', /^(in|out)$/, 'direction');
|
||||||
|
const from = check(a.from || 'any', RX.addr, 'from');
|
||||||
|
const to = check(a.to || 'any', RX.addr, 'to');
|
||||||
|
const parts = ['ufw', a.top ? 'insert 1' : '', action, dir];
|
||||||
|
if (a.port) {
|
||||||
|
check(a.port, RX.port, 'port');
|
||||||
|
const proto = check(a.proto || 'any', /^(any|tcp|udp)$/, 'protocol');
|
||||||
|
// Portbereiche/-listen verlangen bei UFW ein Protokoll
|
||||||
|
if (/[:,]/.test(a.port) && proto === 'any') throw new Error(i18n.t('Port ranges and lists need a protocol (TCP or UDP).'));
|
||||||
|
if (proto !== 'any') parts.push('proto', proto);
|
||||||
|
parts.push('from', from, 'to', to, 'port', a.port);
|
||||||
|
} else parts.push('from', from, 'to', to);
|
||||||
|
if (a.comment) parts.push('comment', `'${check(a.comment, RX.comment, 'comment')}'`);
|
||||||
|
return this.runOk(s, parts.filter(Boolean).join(' '));
|
||||||
|
}
|
||||||
|
default: throw new Error('Invalid operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ipt(id, op, a = {}) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const bin = a.family === 6 ? 'ip6tables' : 'iptables';
|
||||||
|
switch (op) {
|
||||||
|
case 'delete': return this.runOk(s, `${bin} -D ${check(a.chain, RX.chain, 'chain')} ${Number(a.num)}`);
|
||||||
|
case 'policy': return this.runOk(s, `${bin} -P ${check(a.chain, /^(INPUT|OUTPUT|FORWARD)$/, 'chain')} ${check(a.policy, /^(ACCEPT|DROP)$/, 'policy')}`);
|
||||||
|
case 'add': {
|
||||||
|
const parts = [bin, a.top ? '-I' : '-A', check(a.chain, RX.chain, 'chain')];
|
||||||
|
const proto = check(a.proto || 'all', /^(all|tcp|udp|icmp|icmpv6)$/, 'protocol');
|
||||||
|
if (proto !== 'all') parts.push('-p', proto);
|
||||||
|
if (a.source && a.source !== 'any') parts.push('-s', check(a.source, RX.addr, 'source'));
|
||||||
|
if (a.iface) parts.push(a.chain === 'OUTPUT' ? '-o' : '-i', check(a.iface, RX.iface, 'interface'));
|
||||||
|
if (a.port) {
|
||||||
|
check(a.port, RX.port, 'port');
|
||||||
|
if (proto !== 'tcp' && proto !== 'udp') throw new Error(i18n.t('A port needs the protocol TCP or UDP.'));
|
||||||
|
if (a.port.includes(',')) parts.push('-m', 'multiport', '--dports', a.port);
|
||||||
|
else parts.push('--dport', a.port);
|
||||||
|
}
|
||||||
|
if (a.state) parts.push('-m', 'conntrack', '--ctstate', check(a.state, /^[A-Z,]+$/, 'state'));
|
||||||
|
if (a.comment) parts.push('-m', 'comment', '--comment', `'${check(a.comment, RX.comment, 'comment')}'`);
|
||||||
|
parts.push('-j', check(a.target, /^(ACCEPT|DROP|REJECT|LOG|RETURN)$/, 'target'));
|
||||||
|
return this.runOk(s, parts.join(' '));
|
||||||
|
}
|
||||||
|
case 'save': {
|
||||||
|
// Dauerhaft speichern: Debian/Ubuntu (netfilter-persistent bzw. rules.v4/v6) oder Arch (iptables.rules)
|
||||||
|
const script = 'if command -v netfilter-persistent >/dev/null 2>&1; then netfilter-persistent save; '
|
||||||
|
+ 'elif [ -f /etc/debian_version ] && [ -d /etc/iptables ]; then iptables-save > /etc/iptables/rules.v4 && ip6tables-save > /etc/iptables/rules.v6; '
|
||||||
|
+ 'elif [ -d /etc/iptables ]; then iptables-save > /etc/iptables/iptables.rules && ip6tables-save > /etc/iptables/ip6tables.rules; '
|
||||||
|
+ 'else exit 3; fi';
|
||||||
|
const r = await this.run(s, `sh -c '${script}'`);
|
||||||
|
if (r.code === 3) throw new Error(i18n.t('No known way to save iptables rules on this host (e.g. install iptables-persistent).'));
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default: throw new Error('Invalid operation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = { FirewallManager, parseUfw, parseIptables };
|
||||||
+202
-14
@@ -10,6 +10,12 @@ const rdp = require('./rdp');
|
|||||||
const { Updater } = require('./updater');
|
const { Updater } = require('./updater');
|
||||||
const { createEmbed, embedSupported } = require('./rdp-embed');
|
const { createEmbed, embedSupported } = require('./rdp-embed');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
|
const fido = require('./fido');
|
||||||
|
const { VpnManager } = require('./vpn');
|
||||||
|
const { DockerManager } = require('./docker');
|
||||||
|
const { FirewallManager } = require('./firewall');
|
||||||
|
const { NetworkConfigManager } = require('./network');
|
||||||
|
const { SyncService } = require('./sync');
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
@@ -27,6 +33,13 @@ if (process.platform === 'linux' && readLaunch().x11 !== false && !process.argv.
|
|||||||
}
|
}
|
||||||
const pendingSecrets = new Map();
|
const pendingSecrets = new Map();
|
||||||
|
|
||||||
|
// Sicherheitsnetz: unerwartete Fehler (z. B. aus Netzwerkbibliotheken) als Meldung anzeigen statt den Hauptprozess abstürzen zu lassen
|
||||||
|
process.on('uncaughtException', (e) => {
|
||||||
|
console.error('Uncaught exception:', e);
|
||||||
|
send('toast', e?.message || String(e), 'error');
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||||
|
|
||||||
function send(channel, ...args) {
|
function send(channel, ...args) {
|
||||||
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args);
|
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args);
|
||||||
}
|
}
|
||||||
@@ -58,6 +71,21 @@ function askSecret(sessionId, req) {
|
|||||||
|
|
||||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||||
const updater = new Updater(store, send);
|
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);
|
||||||
|
|
||||||
|
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
|
||||||
|
const pairReplies = new Map();
|
||||||
|
const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
|
||||||
|
const reqId = crypto.randomUUID();
|
||||||
|
pairReplies.set(reqId, resolve);
|
||||||
|
send('sync:pairPrompt', { reqId, ...req });
|
||||||
|
setTimeout(() => { if (pairReplies.delete(reqId)) resolve(false); }, 115000);
|
||||||
|
}));
|
||||||
|
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
|
||||||
|
store.onChange = () => sync.schedule();
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -88,9 +116,14 @@ function createWindow() {
|
|||||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
|
||||||
|
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||||
function handle(channel, fn) {
|
function handle(channel, fn) {
|
||||||
ipcMain.handle(channel, async (_e, ...args) => {
|
ipcMain.handle(channel, async (_e, ...args) => {
|
||||||
try { return { ok: true, value: await fn(...args) }; }
|
try {
|
||||||
|
if (store.locked && !channel.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(channel)) throw new Error(i18n.t('MrTerm is locked.'));
|
||||||
|
return { ok: true, value: await fn(...args) };
|
||||||
|
}
|
||||||
catch (e) { return { ok: false, error: e.message || String(e) }; }
|
catch (e) { return { ok: false, error: e.message || String(e) }; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -100,27 +133,164 @@ ipcMain.on('win:min', () => win.minimize());
|
|||||||
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
||||||
ipcMain.on('win:close', () => win.close());
|
ipcMain.on('win:close', () => win.close());
|
||||||
|
|
||||||
|
// ---------- App-Sperre (Passwort / FIDO2) ----------
|
||||||
|
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||||
|
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||||||
|
const fidoKek = (secret) => Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.alloc(0), 'mrterm-fido-kek', 32));
|
||||||
|
function lockStatus() {
|
||||||
|
const { language, appTheme, accent } = store.get().settings;
|
||||||
|
return {
|
||||||
|
enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password,
|
||||||
|
fido: (store.lock?.fido || []).map(({ id, label }) => ({ id, label })), meta: { language, appTheme, accent },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }
|
||||||
|
const ensureLock = () => (store.lock = store.lock || { password: null, fido: [] });
|
||||||
|
function afterUnlock() { applyLanguage(); scheduleUpdateCheck(); sync.start().catch(() => {}); }
|
||||||
|
|
||||||
|
handle('lock:status', lockStatus);
|
||||||
|
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
|
||||||
|
handle('lock:unlockPassword', async (pw) => {
|
||||||
|
const p = store.lock?.password;
|
||||||
|
if (!p) throw new Error(i18n.t('No password set.'));
|
||||||
|
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
|
||||||
|
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
|
||||||
|
afterUnlock();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
handle('lock:unlockFido', async () => {
|
||||||
|
const list = store.lock?.fido || [];
|
||||||
|
if (!list.length) throw new Error(i18n.t('No security key registered.'));
|
||||||
|
const r = await fido.derive(win, list.map(({ credId, prfSalt }) => ({ credId, prfSalt })));
|
||||||
|
const entry = list.find((f) => f.credId === r.credId);
|
||||||
|
if (!entry) throw new Error(i18n.t('Unknown security key.'));
|
||||||
|
try { store.unlockWith(fidoKek(r.secret), entry.wrap); } catch { throw new Error(i18n.t('This security key could not unlock the vault.')); }
|
||||||
|
afterUnlock();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
handle('lock:setPassword', async (pw) => {
|
||||||
|
requireUnlocked();
|
||||||
|
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
|
||||||
|
const salt = crypto.randomBytes(16), N = 2 ** 15;
|
||||||
|
const kek = await kdf(pw, salt, N);
|
||||||
|
ensureLock().password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
|
||||||
|
store.save();
|
||||||
|
return lockStatus();
|
||||||
|
});
|
||||||
|
handle('lock:removePassword', () => {
|
||||||
|
requireUnlocked();
|
||||||
|
if (store.lock) store.lock.password = null;
|
||||||
|
store.dropLockIfEmpty();
|
||||||
|
store.save();
|
||||||
|
return lockStatus();
|
||||||
|
});
|
||||||
|
handle('lock:addFido', async (label) => {
|
||||||
|
requireUnlocked();
|
||||||
|
const credId = await fido.register(win);
|
||||||
|
const prfSalt = crypto.randomBytes(32).toString('base64url');
|
||||||
|
const r = await fido.derive(win, [{ credId, prfSalt }], i18n.t('Touch your security key again to finish.'));
|
||||||
|
ensureLock().fido.push({ id: crypto.randomUUID(), label: label || i18n.t('Security key'), credId, prfSalt, wrap: store.wrapDek(fidoKek(r.secret)) });
|
||||||
|
store.save();
|
||||||
|
return lockStatus();
|
||||||
|
});
|
||||||
|
handle('lock:removeFido', (id) => {
|
||||||
|
requireUnlocked();
|
||||||
|
if (store.lock) store.lock.fido = store.lock.fido.filter((f) => f.id !== id);
|
||||||
|
store.dropLockIfEmpty();
|
||||||
|
store.save();
|
||||||
|
return lockStatus();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Vault ----------
|
// ---------- Vault ----------
|
||||||
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
|
// Kopplungsschlüssel und Löschvermerke bleiben im Hauptprozess
|
||||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
|
||||||
handle('vault:remove', (col, id) => store.remove(col, id));
|
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: process.platform }));
|
||||||
|
handle('vault:upsert', async (col, item) => {
|
||||||
|
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
|
||||||
|
if (col === 'vpns' && item.id) {
|
||||||
|
const old = store.get().vpns.find((v) => v.id === item.id);
|
||||||
|
if (old && ['type', 'config', 'username', 'password'].some((k) => (old[k] || '') !== (item[k] || ''))) await vpn.forget(old);
|
||||||
|
}
|
||||||
|
return store.upsert(col, item);
|
||||||
|
});
|
||||||
|
handle('vault:remove', async (col, id) => {
|
||||||
|
if (col === 'vpns') {
|
||||||
|
await vpn.forget(store.get().vpns.find((v) => v.id === id));
|
||||||
|
store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
|
||||||
|
}
|
||||||
|
return store.remove(col, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Docker ----------
|
||||||
|
handle('docker:open', async (id, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return docker.open(id, host, () => send('docker:closed', id));
|
||||||
|
});
|
||||||
|
handle('docker:list', (id) => docker.list(id));
|
||||||
|
handle('docker:stats', (id) => docker.stats(id));
|
||||||
|
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
|
handle('docker:close', (id) => docker.close(id));
|
||||||
|
|
||||||
|
// ---------- Firewall ----------
|
||||||
|
handle('firewall:open', async (id, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return firewall.open(id, host, () => send('firewall:closed', id));
|
||||||
|
});
|
||||||
|
handle('firewall:list', (id, backend) => firewall.list(id, backend));
|
||||||
|
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));
|
||||||
|
|
||||||
|
// ---------- Synchronisation ----------
|
||||||
|
handle('sync:status', () => sync.status());
|
||||||
|
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
|
||||||
|
handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
|
||||||
|
handle('sync:pair', (id) => sync.pair(id));
|
||||||
|
handle('sync:unpair', (id) => sync.unpair(id));
|
||||||
|
handle('sync:now', () => sync.syncAll());
|
||||||
|
handle('sync:share', (sel) => sync.setShare(sel));
|
||||||
|
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||||
|
handle('sync:probe', (address) => sync.probe(address));
|
||||||
|
|
||||||
|
// ---------- VPN ----------
|
||||||
|
handle('vpn:status', () => vpn.status());
|
||||||
|
handle('vpn:up', (id) => vpn.up(id));
|
||||||
|
handle('vpn:down', (id) => vpn.down(id));
|
||||||
handle('vault:settings', (s) => {
|
handle('vault:settings', (s) => {
|
||||||
store.setSettings(s);
|
store.setSettings(s);
|
||||||
if ('language' in s) applyLanguage();
|
if ('language' in s) applyLanguage();
|
||||||
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
|
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
|
||||||
});
|
});
|
||||||
handle('vault:forgetHost', (id) => { delete store.get().knownHosts[id]; store.save(); });
|
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||||
handle('vault:export', async () => {
|
handle('vault:export', async () => {
|
||||||
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
|
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
|
||||||
if (r.canceled) return false;
|
if (r.canceled) return false;
|
||||||
fs.writeFileSync(r.filePath, JSON.stringify(store.get(), null, 2), { mode: 0o600 });
|
fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 });
|
||||||
return r.filePath;
|
return r.filePath;
|
||||||
});
|
});
|
||||||
handle('vault:import', async () => {
|
handle('vault:import', async () => {
|
||||||
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
|
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
|
||||||
if (r.canceled) return false;
|
if (r.canceled) return false;
|
||||||
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8'));
|
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8'));
|
||||||
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards'])
|
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
|
||||||
for (const item of data[col] || []) store.upsert(col, item);
|
for (const item of data[col] || []) store.upsert(col, item);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -194,12 +364,16 @@ function hostWithOverrides(hostOrId) {
|
|||||||
if (!h) throw new Error(i18n.t('Host not found'));
|
if (!h) throw new Error(i18n.t('Host not found'));
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
// Gespeicherter Host mit Zusätzen, z. B. { ref, execCommand } für Container-Shells
|
||||||
|
if (hostOrId?.ref) return { ...hostWithOverrides(hostOrId.ref), execCommand: hostOrId.execCommand, label: hostOrId.label };
|
||||||
return hostOrId; // Quick-Connect: temporärer Host
|
return hostOrId; // Quick-Connect: temporärer Host
|
||||||
}
|
}
|
||||||
|
|
||||||
handle('ssh:open', async (sessionId, hostRef, size) => {
|
handle('ssh:open', async (sessionId, hostRef, size) => {
|
||||||
const host = hostWithOverrides(hostRef);
|
const host = hostWithOverrides(hostRef);
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
|
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
|
||||||
|
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
|
||||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -212,7 +386,11 @@ ipcMain.on('secret:reply', (_e, reqId, value) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- SFTP ----------
|
// ---------- SFTP ----------
|
||||||
handle('sftp:open', (sessionId, hostRef) => ssh.openSftp(sessionId, hostWithOverrides(hostRef)));
|
handle('sftp:open', async (sessionId, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return ssh.openSftp(sessionId, host);
|
||||||
|
});
|
||||||
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||||
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||||
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
||||||
@@ -246,9 +424,10 @@ handle('local:op', (op, a, b) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Port-Forwarding ----------
|
// ---------- Port-Forwarding ----------
|
||||||
handle('fw:start', (id) => {
|
handle('fw:start', async (id) => {
|
||||||
const fw = store.get().forwards.find((f) => f.id === id);
|
const fw = store.get().forwards.find((f) => f.id === id);
|
||||||
if (!fw) throw new Error(i18n.t('Rule not found'));
|
if (!fw) throw new Error(i18n.t('Rule not found'));
|
||||||
|
if (await vpn.ensureForHost(store.resolveHost(fw.hostId))) send('vpn:changed');
|
||||||
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
|
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
|
||||||
});
|
});
|
||||||
handle('fw:stop', (id) => ssh.stopForward(id));
|
handle('fw:stop', (id) => ssh.stopForward(id));
|
||||||
@@ -256,8 +435,9 @@ handle('fw:active', () => ssh.activeForwards());
|
|||||||
|
|
||||||
// ---------- RDP ----------
|
// ---------- RDP ----------
|
||||||
handle('rdp:detect', () => rdp.detect(store.get().settings));
|
handle('rdp:detect', () => rdp.detect(store.get().settings));
|
||||||
handle('rdp:launch', (hostId) => {
|
handle('rdp:launch', async (hostId) => {
|
||||||
const host = hostWithOverrides(hostId);
|
const host = hostWithOverrides(hostId);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
const r = rdp.launch(host, store.get().settings);
|
const r = rdp.launch(host, store.get().settings);
|
||||||
if (r.process) {
|
if (r.process) {
|
||||||
@@ -277,6 +457,7 @@ handle('rdp:embedSupported', () => {
|
|||||||
handle('rdp:open', async (id, hostRef, bounds) => {
|
handle('rdp:open', async (id, hostRef, bounds) => {
|
||||||
const host = hostWithOverrides(hostRef);
|
const host = hostWithOverrides(hostRef);
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
const emb = createEmbed(win.getNativeWindowHandle());
|
const emb = createEmbed(win.getNativeWindowHandle());
|
||||||
const sess = { emb, cancelled: false };
|
const sess = { emb, cancelled: false };
|
||||||
rdpSessions.set(id, sess);
|
rdpSessions.set(id, sess);
|
||||||
@@ -369,11 +550,18 @@ app.whenReady().then(() => {
|
|||||||
store.load();
|
store.load();
|
||||||
applyLanguage();
|
applyLanguage();
|
||||||
createWindow();
|
createWindow();
|
||||||
if (store.get().settings.updateAutoCheck && app.isPackaged) {
|
sync.start().catch(() => {});
|
||||||
|
scheduleUpdateCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatische Update-Prüfung – bei gesperrtem Vault erst nach dem Entsperren (Einstellungen sind verschlüsselt)
|
||||||
|
let updateScheduled = false;
|
||||||
|
function scheduleUpdateCheck() {
|
||||||
|
if (updateScheduled || store.sealed || !store.get().settings.updateAutoCheck || !app.isPackaged) return;
|
||||||
|
updateScheduled = true;
|
||||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
||||||
}
|
}
|
||||||
});
|
app.on('window-all-closed', async () => { ssh.closeAll(); sync.stop(); await vpn.downOnQuit(); app.quit(); });
|
||||||
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
|
|
||||||
|
|
||||||
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
||||||
if (process.env.MRTERM_SMOKE) {
|
if (process.env.MRTERM_SMOKE) {
|
||||||
|
|||||||
@@ -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,326 @@
|
|||||||
|
// 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) {
|
||||||
|
const conn = s.conn;
|
||||||
|
// Nur die aktuelle Verbindung darf die Sitzung beenden (nach apply wird sie ersetzt)
|
||||||
|
conn.on('close', () => { if (this.sessions.get(id) === s && s.conn === conn) { this.close(id); s.onClose(); } });
|
||||||
|
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}/applied ${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; touch ${STATE}/applied; 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);
|
||||||
|
// Erst bestätigen, wenn das Anwenden fertig ist: sonst reißt z. B. ein Bond-Neustart die neue Verbindung wieder ab
|
||||||
|
const c = await this.root(s, `[ -f ${STATE}/applied ] || exit 7; rm -f ${STATE}/pending`, conn);
|
||||||
|
if (c.code) { conn.on('error', () => {}); conn.end(); jumps?.forEach((x) => { try { x.end(); } catch {} }); throw new Error(lastLine(c.err) || 'not applied yet'); }
|
||||||
|
// Neue Verbindung übernimmt die Sitzung
|
||||||
|
const old = { conn: s.conn, jumps: s.jumps };
|
||||||
|
Object.assign(s, { conn, jumps });
|
||||||
|
// Alte Verbindung ist nach dem IP-Wechsel meist tot: Fehler (z. B. Keepalive-Timeout) still verwerfen
|
||||||
|
for (const c of [old.conn, ...(old.jumps || [])]) { c.on('error', () => {}); try { c.end(); } catch {} }
|
||||||
|
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 };
|
||||||
+24
-8
@@ -9,7 +9,11 @@ const i18n = require('../i18n');
|
|||||||
|
|
||||||
function defaultAgent() {
|
function defaultAgent() {
|
||||||
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
|
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
|
||||||
if (process.platform === 'win32') return '\\\\.\\pipe\\openssh-ssh-agent';
|
if (process.platform === 'win32') {
|
||||||
|
// Nur verwenden, wenn der Windows-OpenSSH-Agent läuft (sonst schlägt die Agent-Anmeldung unnötig fehl)
|
||||||
|
const pipe = '\\\\.\\pipe\\openssh-ssh-agent';
|
||||||
|
try { return fs.existsSync(pipe) ? pipe : undefined; } catch { return undefined; }
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +115,16 @@ class SshManager {
|
|||||||
}
|
}
|
||||||
finish(answers);
|
finish(answers);
|
||||||
});
|
});
|
||||||
conn.once('ready', () => resolve(conn));
|
// ssh2 kann mehrere 'error'-Ereignisse senden (z. B. Anmeldefehler und danach Handshake-Timeout).
|
||||||
conn.once('error', async (err) => {
|
// Ein dauerhafter Listener verhindert, dass ein späterer Fehler den Hauptprozess abstürzen lässt;
|
||||||
|
// Fehler nach dem Verbindungsaufbau melden die Sitzungen selbst (close/error).
|
||||||
|
let settled = false;
|
||||||
|
conn.on('error', () => {});
|
||||||
|
conn.once('ready', () => { settled = true; resolve(conn); });
|
||||||
|
conn.on('error', async (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try { conn.end(); } catch {}
|
||||||
// Kein Passwort hinterlegt und alle Methoden schlugen fehl -> nach Passwort fragen und neu versuchen
|
// Kein Passwort hinterlegt und alle Methoden schlugen fehl -> nach Passwort fragen und neu versuchen
|
||||||
if (err.level === 'client-authentication' && !host.password && !host._retried) {
|
if (err.level === 'client-authentication' && !host.password && !host._retried) {
|
||||||
const pw = await this.askSecret(sessionId, { title: i18n.t('Password'), prompt: i18n.t('Password for {user}', { user: `${cfg.username}@${host.address}` }), echo: false, host: host.label || host.address });
|
const pw = await this.askSecret(sessionId, { title: i18n.t('Password'), prompt: i18n.t('Password for {user}', { user: `${cfg.username}@${host.address}` }), echo: false, host: host.label || host.address });
|
||||||
@@ -121,9 +133,9 @@ class SshManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reject(err);
|
reject(err.level === 'client-authentication' ? new Error(i18n.t('Authentication failed for {user}. Check the username, password or key.', { user: `${cfg.username}@${host.address}` })) : err);
|
||||||
});
|
});
|
||||||
try { conn.connect(cfg); } catch (e) { reject(e); }
|
try { conn.connect(cfg); } catch (e) { settled = true; reject(e); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,15 +149,19 @@ class SshManager {
|
|||||||
|
|
||||||
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
|
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
|
||||||
await new Promise((res, rej) => {
|
await new Promise((res, rej) => {
|
||||||
conn.shell({ term: 'xterm-256color', cols, rows }, { env }, (err, stream) => {
|
const pty = { term: 'xterm-256color', cols, rows };
|
||||||
|
const onStream = (err, stream) => {
|
||||||
if (err) return rej(err);
|
if (err) return rej(err);
|
||||||
sess.stream = stream;
|
sess.stream = stream;
|
||||||
stream.on('data', (d) => send('data', d.toString('utf8')));
|
stream.on('data', (d) => send('data', d.toString('utf8')));
|
||||||
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
||||||
stream.on('close', () => conn.end());
|
stream.on('close', () => conn.end());
|
||||||
if (host.startupCommand) stream.write(host.startupCommand + '\n');
|
if (host.startupCommand && !host.execCommand) stream.write(host.startupCommand + '\n');
|
||||||
res();
|
res();
|
||||||
});
|
};
|
||||||
|
// execCommand: statt Login-Shell einen Befehl mit PTY starten (z. B. docker exec -it …)
|
||||||
|
if (host.execCommand) conn.exec(host.execCommand, { pty, env }, onStream);
|
||||||
|
else conn.shell(pty, { env }, onStream);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+80
-3
@@ -12,8 +12,13 @@ const DEFAULTS = {
|
|||||||
keys: [],
|
keys: [],
|
||||||
snippets: [],
|
snippets: [],
|
||||||
forwards: [],
|
forwards: [],
|
||||||
|
vpns: [],
|
||||||
knownHosts: {},
|
knownHosts: {},
|
||||||
history: [],
|
history: [],
|
||||||
|
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
||||||
|
tombstones: [],
|
||||||
|
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
||||||
|
sync: { enabled: false, deviceId: '', deviceName: '', peers: [], share: { keys: [], hosts: [], vpns: [] }, asked: false },
|
||||||
settings: {
|
settings: {
|
||||||
terminalTheme: 'mrterm',
|
terminalTheme: 'mrterm',
|
||||||
fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace',
|
fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace',
|
||||||
@@ -31,16 +36,63 @@ const DEFAULTS = {
|
|||||||
updateToken: '',
|
updateToken: '',
|
||||||
updateAutoCheck: true,
|
updateAutoCheck: true,
|
||||||
updatePrerelease: false,
|
updatePrerelease: false,
|
||||||
|
autoLock: 0,
|
||||||
language: 'auto',
|
language: 'auto',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const merge = (parsed) => ({ ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } });
|
||||||
|
|
||||||
|
// AES-256-GCM; Ergebnis als base64-Felder für JSON
|
||||||
|
function box(key, plain) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const ct = Buffer.concat([c.update(plain), c.final()]);
|
||||||
|
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
||||||
|
}
|
||||||
|
function unbox(key, b) {
|
||||||
|
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64'));
|
||||||
|
d.setAuthTag(Buffer.from(b.tag, 'base64'));
|
||||||
|
return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dir = app.getPath('userData');
|
this.dir = app.getPath('userData');
|
||||||
this.file = path.join(this.dir, 'vault.dat');
|
this.file = path.join(this.dir, 'vault.dat');
|
||||||
this.data = structuredClone(DEFAULTS);
|
this.data = structuredClone(DEFAULTS);
|
||||||
this.encrypted = false;
|
this.encrypted = false;
|
||||||
|
// App-Sperre: Inhalt zusätzlich mit zufälligem Datenschlüssel (DEK, AES-256-GCM) verschlüsselt.
|
||||||
|
// Der DEK liegt je Entsperrmethode verpackt vor: Passwort (scrypt) und/oder FIDO2-Schlüssel (PRF/hmac-secret).
|
||||||
|
this.lock = null; // { password: { salt, N, wrap } | null, fido: [{ id, label, credId, prfSalt, wrap }] }
|
||||||
|
this.dek = null;
|
||||||
|
this.sealed = null; // verschlüsselter Inhalt, solange nach dem Start noch nicht entsperrt
|
||||||
|
this.locked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lockEnabled() { return !!(this.lock && (this.lock.password || this.lock.fido.length)); }
|
||||||
|
|
||||||
|
// Entsperren mit einem Schlüssel, der den DEK verpackt hat (wirft bei falschem Schlüssel)
|
||||||
|
unlockWith(kek, wrap) {
|
||||||
|
const dek = unbox(kek, wrap);
|
||||||
|
if (this.sealed) {
|
||||||
|
const parsed = JSON.parse(unbox(dek, this.sealed).toString('utf8'));
|
||||||
|
this.data = merge(parsed);
|
||||||
|
this.sealed = null;
|
||||||
|
}
|
||||||
|
this.dek = dek;
|
||||||
|
this.locked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Entsperrmethode: verpackt den (ggf. neu erzeugten) DEK mit kek
|
||||||
|
wrapDek(kek) {
|
||||||
|
if (!this.dek) this.dek = crypto.randomBytes(32);
|
||||||
|
return box(kek, this.dek);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzte Methode entfernt → Sperre aus, Inhalt wieder nur per Betriebssystem verschlüsselt
|
||||||
|
dropLockIfEmpty() {
|
||||||
|
if (!this.lockEnabled) { this.lock = null; this.dek = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
canEncrypt() {
|
canEncrypt() {
|
||||||
@@ -64,12 +116,24 @@ class Store {
|
|||||||
json = raw.toString('utf8');
|
json = raw.toString('utf8');
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(json);
|
const parsed = JSON.parse(json);
|
||||||
this.data = { ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } };
|
if (parsed.mrtermLock) {
|
||||||
|
// Gesperrt: nur Darstellungs-Einstellungen (meta) sind bis zum Entsperren bekannt
|
||||||
|
this.lock = parsed.lock;
|
||||||
|
this.sealed = parsed.data;
|
||||||
|
this.locked = true;
|
||||||
|
this.data = merge({ settings: parsed.meta || {} });
|
||||||
|
} else this.data = merge(parsed);
|
||||||
return this.data;
|
return this.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
const json = JSON.stringify(this.data, null, 2);
|
if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben
|
||||||
|
if (!this.muted) this.onChange?.();
|
||||||
|
let json = JSON.stringify(this.data, null, 2);
|
||||||
|
if (this.lockEnabled && this.dek) {
|
||||||
|
const { language, appTheme, accent } = this.data.settings;
|
||||||
|
json = JSON.stringify({ mrtermLock: 1, meta: { language, appTheme, accent }, lock: this.lock, data: box(this.dek, Buffer.from(json)) });
|
||||||
|
}
|
||||||
const tmp = this.file + '.tmp';
|
const tmp = this.file + '.tmp';
|
||||||
if (this.canEncrypt()) {
|
if (this.canEncrypt()) {
|
||||||
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)]));
|
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)]));
|
||||||
@@ -97,7 +161,20 @@ class Store {
|
|||||||
|
|
||||||
remove(collection, id) {
|
remove(collection, id) {
|
||||||
this.data[collection] = this.data[collection].filter((x) => x.id !== id);
|
this.data[collection] = this.data[collection].filter((x) => x.id !== id);
|
||||||
if (collection === 'groups') this.data.hosts.forEach((h) => { if (h.groupId === id) h.groupId = null; });
|
if (collection === 'groups') this.data.hosts.forEach((h) => { if (h.groupId === id) { h.groupId = null; h.updatedAt = Date.now(); } });
|
||||||
|
this.tombstone(collection, id);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Löschung für andere Geräte vermerken (180 Tage aufbewahren)
|
||||||
|
tombstone(c, id) {
|
||||||
|
const now = Date.now();
|
||||||
|
this.data.tombstones = [...(this.data.tombstones || []).filter((t) => !(t.c === c && t.id === id) && now - t.at < 180 * 864e5), { c, id, at: now }];
|
||||||
|
}
|
||||||
|
|
||||||
|
forgetKnownHost(id) {
|
||||||
|
delete this.data.knownHosts[id];
|
||||||
|
this.tombstone('knownHosts', id);
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
// LAN-Synchronisation zwischen MrTerm-Clients (Peer-to-Peer, Ende-zu-Ende verschlüsselt).
|
||||||
|
//
|
||||||
|
// Finden: UDP-Broadcast auf Port 47811 ("Beacon" mit Geräte-ID, Name, TCP-Port, koppelbar ja/nein).
|
||||||
|
// Koppeln: X25519-Schlüsselaustausch mit Commitment (der Initiator legt sich per Hash auf seinen Schlüssel fest,
|
||||||
|
// bevor er den des anderen kennt). Beide Geräte zeigen denselben 6-stelligen Vergleichscode (SAS);
|
||||||
|
// der Nutzer bestätigt auf beiden Geräten. Ergebnis: dauerhafter Kopplungsschlüssel (32 Byte).
|
||||||
|
// Sitzung: Gegenseitige Authentisierung per HMAC mit dem Kopplungsschlüssel, frische X25519-Schlüssel je Sitzung
|
||||||
|
// (Forward Secrecy), danach alle Nachrichten AES-256-GCM-verschlüsselt.
|
||||||
|
// Abgleich: Beide Seiten senden einen Schnappschuss; pro Eintrag gewinnt die neueste Änderung (updatedAt),
|
||||||
|
// Löschvermerke (tombstones) entfernen Einträge. Geheimnisse (SSH-Schlüssel, Passwörter, VPN-Konfigurationen)
|
||||||
|
// werden nur übertragen, wenn der Nutzer sie freigegeben hat.
|
||||||
|
const dgram = require('dgram');
|
||||||
|
const net = require('net');
|
||||||
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
// Lokale Firewall erkennen, die Broadcasts/eingehende Verbindungen blockieren könnte (Linux).
|
||||||
|
// Ob die Ports bereits freigegeben sind, lässt sich ohne root nicht prüfen (UFW-Regeln sind nur für root lesbar).
|
||||||
|
function localFirewall() {
|
||||||
|
if (process.platform !== 'linux') return null;
|
||||||
|
const read = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return ''; } };
|
||||||
|
const active = (unit) => { try { return execFileSync('systemctl', ['is-active', unit], { timeout: 3000 }).toString().trim() === 'active'; } catch { return false; } };
|
||||||
|
const os = (read('/etc/os-release').match(/^ID=(.*)$/m) || [])[1]?.replace(/"/g, '') || '';
|
||||||
|
const ufw = /^ENABLED=yes/m.test(read('/etc/ufw/ufw.conf')) || active('ufw');
|
||||||
|
const firewalld = active('firewalld');
|
||||||
|
if (!ufw && !firewalld && os !== 'cachyos') return null;
|
||||||
|
return { os, ufw, firewalld };
|
||||||
|
}
|
||||||
|
|
||||||
|
const UDP_PORT = 47811;
|
||||||
|
const TCP_PORT = 47812;
|
||||||
|
const PROTO = 1;
|
||||||
|
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
|
||||||
|
const SECRETS = { keys: ['privateKey', 'passphrase'], hosts: ['password'], vpns: ['config', 'password'] };
|
||||||
|
const SPKI_X25519 = Buffer.from('302a300506032b656e032100', 'hex');
|
||||||
|
const MAX_FRAME = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
const sha = (...parts) => crypto.createHash('sha256').update(Buffer.concat(parts.map((p) => Buffer.from(p)))).digest();
|
||||||
|
const hmac = (key, ...parts) => crypto.createHmac('sha256', key).update(Buffer.concat(parts.map((p) => Buffer.from(p)))).digest();
|
||||||
|
const hkdf = (ikm, salt, info) => Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, 32));
|
||||||
|
const ts = (x) => x?.updatedAt || x?.createdAt || 0;
|
||||||
|
|
||||||
|
function x25519() {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
||||||
|
return { privateKey, pub: publicKey.export({ type: 'spki', format: 'der' }).subarray(-32) };
|
||||||
|
}
|
||||||
|
function dh(privateKey, peerPub) {
|
||||||
|
const pk = crypto.createPublicKey({ key: Buffer.concat([SPKI_X25519, Buffer.from(peerPub)]), format: 'der', type: 'spki' });
|
||||||
|
return crypto.diffieHellman({ privateKey, publicKey: pk });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Framing (4 Byte Länge + Nutzlast)
|
||||||
|
class Channel {
|
||||||
|
constructor(sock) {
|
||||||
|
this.sock = sock;
|
||||||
|
this.buf = Buffer.alloc(0);
|
||||||
|
this.queue = [];
|
||||||
|
this.waiters = [];
|
||||||
|
this.keys = null; // { send, recv } nach dem Handshake
|
||||||
|
sock.on('data', (d) => this.onData(d));
|
||||||
|
const fail = (e) => { this.closed = e || new Error('closed'); this.waiters.splice(0).forEach((w) => w.reject(this.closed)); };
|
||||||
|
sock.on('close', () => fail());
|
||||||
|
sock.on('error', (e) => fail(e));
|
||||||
|
sock.setTimeout(180000, () => sock.destroy(new Error('timeout')));
|
||||||
|
}
|
||||||
|
onData(d) {
|
||||||
|
this.buf = Buffer.concat([this.buf, d]);
|
||||||
|
while (this.buf.length >= 4) {
|
||||||
|
const len = this.buf.readUInt32BE(0);
|
||||||
|
if (len > MAX_FRAME) { this.sock.destroy(new Error('frame too large')); return; }
|
||||||
|
if (this.buf.length < 4 + len) break;
|
||||||
|
const frame = this.buf.subarray(4, 4 + len);
|
||||||
|
this.buf = this.buf.subarray(4 + len);
|
||||||
|
let msg;
|
||||||
|
try { msg = this.decode(frame); } catch (e) { this.sock.destroy(e); return; }
|
||||||
|
const w = this.waiters.shift();
|
||||||
|
if (w) w.resolve(msg); else this.queue.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encode(obj) {
|
||||||
|
const plain = Buffer.from(JSON.stringify(obj));
|
||||||
|
if (!this.keys) return plain;
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv('aes-256-gcm', this.keys.send, iv);
|
||||||
|
return Buffer.concat([iv, c.update(plain), c.final(), c.getAuthTag()]);
|
||||||
|
}
|
||||||
|
decode(frame) {
|
||||||
|
if (!this.keys) return JSON.parse(frame.toString('utf8'));
|
||||||
|
const d = crypto.createDecipheriv('aes-256-gcm', this.keys.recv, frame.subarray(0, 12));
|
||||||
|
d.setAuthTag(frame.subarray(frame.length - 16));
|
||||||
|
return JSON.parse(Buffer.concat([d.update(frame.subarray(12, frame.length - 16)), d.final()]).toString('utf8'));
|
||||||
|
}
|
||||||
|
send(obj) {
|
||||||
|
const p = this.encode(obj);
|
||||||
|
const h = Buffer.alloc(4); h.writeUInt32BE(p.length);
|
||||||
|
this.sock.write(Buffer.concat([h, p]));
|
||||||
|
}
|
||||||
|
recv(type, timeout = 30000) {
|
||||||
|
const msg = this.queue.length ? Promise.resolve(this.queue.shift()) : this.closed ? Promise.reject(this.closed) : new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
||||||
|
let t;
|
||||||
|
const to = new Promise((_, rej) => { t = setTimeout(() => rej(new Error('timeout')), timeout); });
|
||||||
|
return Promise.race([msg, to]).finally(() => clearTimeout(t)).then((m) => {
|
||||||
|
if (m?.t === 'error') throw new Error(m.msg || 'remote error');
|
||||||
|
if (type && m?.t !== type) throw new Error(`unexpected message ${m?.t}`);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
close() { try { this.sock.end(); } catch {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Zusammenführen
|
||||||
|
// Schnappschuss für ein Gerät; nicht freigegebene Geheimnisse werden entfernt
|
||||||
|
function snapshot(data) {
|
||||||
|
const share = data.sync?.share || {};
|
||||||
|
const out = { tombstones: data.tombstones || [], knownHosts: data.knownHosts || {} };
|
||||||
|
for (const c of COLLECTIONS) {
|
||||||
|
out[c] = (data[c] || []).map((x) => {
|
||||||
|
const fields = SECRETS[c];
|
||||||
|
if (!fields || (share[c] || []).includes(x.id)) return x;
|
||||||
|
const y = { ...x, _noSecret: true };
|
||||||
|
fields.forEach((f) => delete y[f]);
|
||||||
|
return y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eingehenden Schnappschuss übernehmen; liefert true, wenn sich etwas geändert hat
|
||||||
|
function merge(data, incoming) {
|
||||||
|
let changed = false;
|
||||||
|
const tombs = new Map((data.tombstones || []).map((t) => [`${t.c}|${t.id}`, t]));
|
||||||
|
for (const t of incoming.tombstones || []) {
|
||||||
|
const k = `${t.c}|${t.id}`;
|
||||||
|
if (!tombs.has(k) || tombs.get(k).at < t.at) { tombs.set(k, t); changed = true; }
|
||||||
|
}
|
||||||
|
data.tombstones = [...tombs.values()];
|
||||||
|
for (const c of COLLECTIONS) {
|
||||||
|
const list = data[c] || (data[c] = []);
|
||||||
|
for (const inc of incoming[c] || []) {
|
||||||
|
if (!inc?.id) continue;
|
||||||
|
const tomb = tombs.get(`${c}|${inc.id}`);
|
||||||
|
if (tomb && tomb.at >= ts(inc)) continue;
|
||||||
|
const i = list.findIndex((x) => x.id === inc.id);
|
||||||
|
const local = list[i];
|
||||||
|
if (local && ts(local) >= ts(inc)) continue;
|
||||||
|
const { _noSecret, ...item } = inc;
|
||||||
|
// Nicht freigegebene Geheimnisse: lokale Werte behalten
|
||||||
|
if (_noSecret && local) (SECRETS[c] || []).forEach((f) => { if (local[f] !== undefined) item[f] = local[f]; });
|
||||||
|
if (i >= 0) list[i] = item; else list.push(item);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Löschvermerke anwenden
|
||||||
|
const before = list.length;
|
||||||
|
data[c] = list.filter((x) => { const t = tombs.get(`${c}|${x.id}`); return !t || t.at < ts(x); });
|
||||||
|
if (data[c].length !== before) changed = true;
|
||||||
|
}
|
||||||
|
for (const [id, kh] of Object.entries(incoming.knownHosts || {})) {
|
||||||
|
const t = tombs.get(`knownHosts|${id}`);
|
||||||
|
if (t && t.at >= (kh.addedAt || 0)) continue;
|
||||||
|
const local = data.knownHosts[id];
|
||||||
|
if (!local || (kh.addedAt || 0) > (local.addedAt || 0)) { data.knownHosts[id] = kh; changed = true; }
|
||||||
|
}
|
||||||
|
for (const t of tombs.values()) {
|
||||||
|
if (t.c === 'knownHosts' && data.knownHosts[t.id] && (data.knownHosts[t.id].addedAt || 0) <= t.at) { delete data.knownHosts[t.id]; changed = true; }
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Dienst
|
||||||
|
class SyncService {
|
||||||
|
/**
|
||||||
|
* @param {import('./store').Store} store
|
||||||
|
* @param {(event: string, payload?: any) => void} emit Ereignisse an die Oberfläche
|
||||||
|
* @param {(req: { code, name, id }) => Promise<boolean>} confirmPair Vergleichscode anzeigen und Bestätigung abwarten
|
||||||
|
*/
|
||||||
|
constructor(store, emit, confirmPair) {
|
||||||
|
this.store = store;
|
||||||
|
this.emit = emit;
|
||||||
|
this.confirmPair = confirmPair;
|
||||||
|
this.seen = new Map(); // id -> { id, name, address, port, pairable, at }
|
||||||
|
this.pairable = false;
|
||||||
|
this.running = false;
|
||||||
|
this.lastError = '';
|
||||||
|
this.syncing = new Set();
|
||||||
|
this.firewall = localFirewall();
|
||||||
|
}
|
||||||
|
|
||||||
|
get cfg() {
|
||||||
|
const d = this.store.get();
|
||||||
|
d.sync ||= { enabled: false, peers: [], share: { keys: [], hosts: [], vpns: [] } };
|
||||||
|
const s = d.sync;
|
||||||
|
if (!s.deviceId) s.deviceId = crypto.randomUUID();
|
||||||
|
if (!s.deviceName) s.deviceName = os.hostname();
|
||||||
|
s.peers ||= []; s.share ||= { keys: [], hosts: [], vpns: [] };
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ Start/Stop
|
||||||
|
async start() {
|
||||||
|
if (this.running || this.store.sealed || !this.cfg.enabled) return;
|
||||||
|
this.running = true;
|
||||||
|
this.server = net.createServer((sock) => this.accept(sock).catch(() => sock.destroy()));
|
||||||
|
this.server.on('error', () => {});
|
||||||
|
await new Promise((res) => {
|
||||||
|
this.server.once('error', () => this.server.listen(0, res));
|
||||||
|
this.server.listen(TCP_PORT, res);
|
||||||
|
});
|
||||||
|
this.port = this.server.address().port;
|
||||||
|
this.udp = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
||||||
|
this.udp.on('error', () => {});
|
||||||
|
this.udp.on('message', (msg, rinfo) => this.onBeacon(msg, rinfo));
|
||||||
|
await new Promise((res) => this.udp.bind(UDP_PORT, () => { try { this.udp.setBroadcast(true); } catch {} res(); }));
|
||||||
|
this.beaconTimer = setInterval(() => this.beacon(), 5000);
|
||||||
|
this.syncTimer = setInterval(() => this.syncAll(), 120000);
|
||||||
|
this.beacon();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.running) return;
|
||||||
|
this.running = false;
|
||||||
|
clearInterval(this.beaconTimer); clearInterval(this.syncTimer); clearTimeout(this.debounce);
|
||||||
|
try { this.server.close(); } catch {}
|
||||||
|
try { this.udp.close(); } catch {}
|
||||||
|
this.seen.clear();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(on, name) {
|
||||||
|
const c = this.cfg;
|
||||||
|
c.enabled = !!on;
|
||||||
|
if (name) c.deviceName = String(name).slice(0, 60);
|
||||||
|
this.store.save();
|
||||||
|
if (on) this.start(); else this.stop();
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ Finden
|
||||||
|
broadcastAddrs() {
|
||||||
|
const out = new Set(['255.255.255.255']);
|
||||||
|
for (const list of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const a of list || []) {
|
||||||
|
if (a.family !== 'IPv4' || a.internal) continue;
|
||||||
|
const ip = a.address.split('.').map(Number), mask = a.netmask.split('.').map(Number);
|
||||||
|
out.add(ip.map((o, i) => (o & mask[i]) | (~mask[i] & 255)).join('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
beacon() {
|
||||||
|
if (!this.running) return;
|
||||||
|
const c = this.cfg;
|
||||||
|
const msg = Buffer.from(JSON.stringify({ mrterm: PROTO, id: c.deviceId, name: c.deviceName, port: this.port, pairable: this.pairable }));
|
||||||
|
for (const addr of this.broadcastAddrs()) this.udp.send(msg, UDP_PORT, addr, () => {});
|
||||||
|
// Einträge älter als 20 s gelten als offline
|
||||||
|
for (const [id, p] of this.seen) if (Date.now() - p.at > 20000) { this.seen.delete(id); this.emitState(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeacon(buf, rinfo) {
|
||||||
|
let b;
|
||||||
|
try { b = JSON.parse(buf.toString('utf8')); } catch { return; }
|
||||||
|
if (b?.mrterm !== PROTO || !b.id || b.id === this.cfg.deviceId || !Number.isInteger(b.port)) return;
|
||||||
|
const known = this.seen.get(b.id);
|
||||||
|
this.seen.set(b.id, { id: b.id, name: String(b.name || '').slice(0, 60), address: rinfo.address, port: b.port, pairable: !!b.pairable, at: Date.now() });
|
||||||
|
const peer = this.cfg.peers.find((p) => p.id === b.id);
|
||||||
|
if (peer && peer.address !== rinfo.address) { peer.address = rinfo.address; peer.port = b.port; }
|
||||||
|
if (!known) { this.emitState(); if (peer) this.syncWith(peer).catch(() => {}); }
|
||||||
|
else if (known.pairable !== !!b.pairable) this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPairable(on) {
|
||||||
|
this.pairable = !!on;
|
||||||
|
clearTimeout(this.pairTimer);
|
||||||
|
if (on) this.pairTimer = setTimeout(() => this.setPairable(false), 180000);
|
||||||
|
this.beacon();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ Status für die Oberfläche
|
||||||
|
status() {
|
||||||
|
const c = this.cfg;
|
||||||
|
return {
|
||||||
|
enabled: c.enabled, running: this.running, deviceId: c.deviceId, deviceName: c.deviceName, pairable: this.pairable, port: this.port,
|
||||||
|
peers: c.peers.map((p) => ({ id: p.id, name: p.name, lastSync: p.lastSync || 0, online: this.seen.has(p.id), address: p.address, error: p.error || '' })),
|
||||||
|
nearby: [...this.seen.values()].filter((p) => !c.peers.some((x) => x.id === p.id)).map(({ id, name, address, pairable }) => ({ id, name, address, pairable })),
|
||||||
|
share: c.share, asked: !!c.asked, sealed: !!this.store.sealed, firewall: this.firewall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
emitState() { this.emit('sync:state', this.status()); }
|
||||||
|
|
||||||
|
// ------------------------------------------------ Verbindungen
|
||||||
|
connect(address, port) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = net.connect({ host: address, port, timeout: 8000 });
|
||||||
|
sock.once('connect', () => { sock.setTimeout(0); resolve(new Channel(sock)); });
|
||||||
|
sock.once('timeout', () => { sock.destroy(); reject(new Error('timeout')); });
|
||||||
|
sock.once('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eingehende Verbindung: Kopplung oder Synchronisation
|
||||||
|
async accept(sock) {
|
||||||
|
const ch = new Channel(sock);
|
||||||
|
try {
|
||||||
|
const first = await ch.recv(null, 10000);
|
||||||
|
if (first.t === 'pair') await this.pairResponder(ch, first);
|
||||||
|
else if (first.t === 'hello') await this.sessionResponder(ch, first);
|
||||||
|
} catch (e) {
|
||||||
|
try { ch.send({ t: 'error', msg: e.message }); } catch {}
|
||||||
|
} finally { ch.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ Kopplung
|
||||||
|
// Initiator (Gerät, auf dem der Nutzer ein anderes Gerät ausgewählt hat)
|
||||||
|
async pair(id) {
|
||||||
|
const target = this.seen.get(id);
|
||||||
|
if (!target) throw new Error('Device not found');
|
||||||
|
const ch = await this.connect(target.address, target.port);
|
||||||
|
try {
|
||||||
|
const me = x25519();
|
||||||
|
const nonce = crypto.randomBytes(16);
|
||||||
|
const c = this.cfg;
|
||||||
|
ch.send({ t: 'pair', v: PROTO, id: c.deviceId, name: c.deviceName, commit: sha(me.pub, nonce).toString('base64') });
|
||||||
|
const r = await ch.recv('pair-pub');
|
||||||
|
ch.send({ t: 'pair-reveal', pub: me.pub.toString('base64'), nonce: nonce.toString('base64') });
|
||||||
|
const peerPub = Buffer.from(r.pub, 'base64');
|
||||||
|
const { code, key } = this.derivePair(dh(me.privateKey, peerPub), peerPub, me.pub, r.id, c.deviceId);
|
||||||
|
return await this.finishPair(ch, { id: r.id, name: r.name, address: target.address, port: target.port }, code, key, 'initiator');
|
||||||
|
} finally { ch.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenseite: nur solange "koppelbar" aktiv ist
|
||||||
|
async pairResponder(ch, msg) {
|
||||||
|
if (!this.pairable) throw new Error(i18n.t('Device is not ready for pairing'));
|
||||||
|
const me = x25519();
|
||||||
|
const c = this.cfg;
|
||||||
|
ch.send({ t: 'pair-pub', id: c.deviceId, name: c.deviceName, pub: me.pub.toString('base64') });
|
||||||
|
const rev = await ch.recv('pair-reveal');
|
||||||
|
const peerPub = Buffer.from(rev.pub, 'base64');
|
||||||
|
if (!sha(peerPub, Buffer.from(rev.nonce, 'base64')).equals(Buffer.from(msg.commit, 'base64'))) throw new Error('Commitment mismatch');
|
||||||
|
const { code, key } = this.derivePair(dh(me.privateKey, peerPub), me.pub, peerPub, c.deviceId, msg.id);
|
||||||
|
const addr = ch.sock.remoteAddress?.replace(/^::ffff:/, '');
|
||||||
|
await this.finishPair(ch, { id: msg.id, name: String(msg.name || '').slice(0, 60), address: addr, port: this.seen.get(msg.id)?.port || TCP_PORT }, code, key, 'responder');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vergleichscode und Kopplungsschlüssel aus dem Schlüsselaustausch (Reihenfolge: Responder, Initiator)
|
||||||
|
derivePair(shared, pubR, pubI, idR, idI) {
|
||||||
|
const transcript = Buffer.concat([pubR, pubI, Buffer.from(`${idR}|${idI}`)]);
|
||||||
|
const code = String(sha('mrterm-sas', transcript).readUInt32BE(0) % 1000000).padStart(6, '0');
|
||||||
|
const key = hkdf(shared, transcript, 'mrterm-pair-key');
|
||||||
|
return { code, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
async finishPair(ch, peer, code, key, role) {
|
||||||
|
const ok = await this.confirmPair({ code, name: peer.name, id: peer.id, role });
|
||||||
|
ch.send({ t: 'pair-confirm', ok, mac: ok ? hmac(key, 'confirm', role).toString('base64') : '' });
|
||||||
|
const r = await ch.recv('pair-confirm', 120000);
|
||||||
|
const other = role === 'initiator' ? 'responder' : 'initiator';
|
||||||
|
if (!ok) throw new Error(i18n.t('Pairing was rejected on this device.'));
|
||||||
|
if (!r.ok) throw new Error(i18n.t('Pairing was rejected on the other device.'));
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(r.mac, 'base64'), hmac(key, 'confirm', other))) throw new Error(i18n.t('Pairing verification failed.'));
|
||||||
|
const c = this.cfg;
|
||||||
|
c.peers = [...c.peers.filter((p) => p.id !== peer.id), { ...peer, key: key.toString('base64'), pairedAt: Date.now() }];
|
||||||
|
this.store.save();
|
||||||
|
this.setPairable(false);
|
||||||
|
this.emit('sync:paired', { id: peer.id, name: peer.name });
|
||||||
|
this.emitState();
|
||||||
|
if (role === 'initiator') setTimeout(() => this.syncWith(c.peers.find((p) => p.id === peer.id)).catch(() => {}), 1500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
unpair(id) {
|
||||||
|
const c = this.cfg;
|
||||||
|
c.peers = c.peers.filter((p) => p.id !== id);
|
||||||
|
this.store.save();
|
||||||
|
this.emitState();
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------ Sitzung (authentisiert + verschlüsselt)
|
||||||
|
sessionKeys(pairKey, shared, transcript, initiator) {
|
||||||
|
const k1 = hkdf(Buffer.concat([pairKey, shared]), transcript, 'mrterm-i2r');
|
||||||
|
const k2 = hkdf(Buffer.concat([pairKey, shared]), transcript, 'mrterm-r2i');
|
||||||
|
return initiator ? { send: k1, recv: k2 } : { send: k2, recv: k1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncWith(peer) {
|
||||||
|
if (!peer || !this.running || this.store.sealed || this.syncing.has(peer.id)) return;
|
||||||
|
const live = this.seen.get(peer.id);
|
||||||
|
const address = live?.address || peer.address;
|
||||||
|
const port = live?.port || peer.port || TCP_PORT;
|
||||||
|
if (!address) return;
|
||||||
|
this.syncing.add(peer.id);
|
||||||
|
let ch;
|
||||||
|
try {
|
||||||
|
ch = await this.connect(address, port);
|
||||||
|
const key = Buffer.from(peer.key, 'base64');
|
||||||
|
const me = x25519();
|
||||||
|
const nI = crypto.randomBytes(16);
|
||||||
|
const c = this.cfg;
|
||||||
|
ch.send({ t: 'hello', v: PROTO, id: c.deviceId, eph: me.pub.toString('base64'), nonce: nI.toString('base64') });
|
||||||
|
const r = await ch.recv('hello');
|
||||||
|
if (r.id !== peer.id) throw new Error('Unexpected device');
|
||||||
|
const transcript = Buffer.concat([Buffer.from(c.deviceId), Buffer.from(peer.id), me.pub, Buffer.from(r.eph, 'base64'), nI, Buffer.from(r.nonce, 'base64')]);
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(r.mac, 'base64'), hmac(key, 'responder', transcript))) throw new Error('Authentication failed');
|
||||||
|
ch.send({ t: 'auth', mac: hmac(key, 'initiator', transcript).toString('base64') });
|
||||||
|
ch.keys = this.sessionKeys(key, dh(me.privateKey, Buffer.from(r.eph, 'base64')), transcript, true);
|
||||||
|
ch.send({ t: 'state', data: snapshot(this.store.get()) });
|
||||||
|
const theirs = await ch.recv('state');
|
||||||
|
this.applyRemote(theirs.data);
|
||||||
|
Object.assign(peer, { lastSync: Date.now(), error: '', address, port });
|
||||||
|
this.store.muted = true; this.store.save(); this.store.muted = false;
|
||||||
|
} catch (e) {
|
||||||
|
peer.error = e.message;
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
this.syncing.delete(peer.id);
|
||||||
|
ch?.close();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sessionResponder(ch, msg) {
|
||||||
|
const c = this.cfg;
|
||||||
|
const peer = c.peers.find((p) => p.id === msg.id);
|
||||||
|
if (!peer || this.store.sealed) throw new Error('Unknown device');
|
||||||
|
const key = Buffer.from(peer.key, 'base64');
|
||||||
|
const me = x25519();
|
||||||
|
const nR = crypto.randomBytes(16);
|
||||||
|
const transcript = Buffer.concat([Buffer.from(msg.id), Buffer.from(c.deviceId), Buffer.from(msg.eph, 'base64'), me.pub, Buffer.from(msg.nonce, 'base64'), nR]);
|
||||||
|
ch.send({ t: 'hello', id: c.deviceId, eph: me.pub.toString('base64'), nonce: nR.toString('base64'), mac: hmac(key, 'responder', transcript).toString('base64') });
|
||||||
|
const a = await ch.recv('auth');
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(a.mac, 'base64'), hmac(key, 'initiator', transcript))) throw new Error('Authentication failed');
|
||||||
|
ch.keys = this.sessionKeys(key, dh(me.privateKey, Buffer.from(msg.eph, 'base64')), transcript, false);
|
||||||
|
const theirs = await ch.recv('state');
|
||||||
|
ch.send({ t: 'state', data: snapshot(this.store.get()) });
|
||||||
|
this.applyRemote(theirs.data);
|
||||||
|
Object.assign(peer, { lastSync: Date.now(), error: '', address: ch.sock.remoteAddress?.replace(/^::ffff:/, '') || peer.address });
|
||||||
|
this.store.muted = true; this.store.save(); this.store.muted = false;
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRemote(data) {
|
||||||
|
if (merge(this.store.get(), data || {})) {
|
||||||
|
this.store.muted = true; this.store.save(); this.store.muted = false;
|
||||||
|
this.emit('sync:changed');
|
||||||
|
// Änderungen an weitere gekoppelte Geräte weitergeben
|
||||||
|
this.schedule(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach lokalen Änderungen (entprellt) mit allen erreichbaren Geräten abgleichen
|
||||||
|
schedule(delay = 2000) {
|
||||||
|
if (!this.running) return;
|
||||||
|
clearTimeout(this.debounce);
|
||||||
|
this.debounce = setTimeout(() => this.syncAll(), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAll() {
|
||||||
|
const results = await Promise.allSettled(this.cfg.peers.filter((p) => this.seen.has(p.id) || p.address).map((p) => this.syncWith(p)));
|
||||||
|
return results.filter((r) => r.status === 'fulfilled').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShare(share) {
|
||||||
|
const clean = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []);
|
||||||
|
const c = this.cfg;
|
||||||
|
c.share = { keys: clean(share?.keys), hosts: clean(share?.hosts), vpns: clean(share?.vpns), declined: { keys: [], hosts: [], vpns: [] } };
|
||||||
|
c.asked = true;
|
||||||
|
this.store.save();
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelnes neues Geheimnis teilen oder ablehnen (Ablehnung wird gemerkt, damit nicht erneut gefragt wird)
|
||||||
|
shareItem(c, id, yes) {
|
||||||
|
if (!SECRETS[c] || typeof id !== 'string') throw new Error('Invalid');
|
||||||
|
const sh = this.cfg.share;
|
||||||
|
sh.declined ||= { keys: [], hosts: [], vpns: [] };
|
||||||
|
sh[c] = (sh[c] || []).filter((x) => x !== id);
|
||||||
|
sh.declined[c] = (sh.declined[c] || []).filter((x) => x !== id);
|
||||||
|
(yes ? sh[c] : sh.declined[c]).push(id);
|
||||||
|
this.store.save();
|
||||||
|
if (yes) this.schedule(500);
|
||||||
|
return this.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerät per IP hinzufügen (wenn Broadcasts im Netz blockiert sind)
|
||||||
|
async probe(address, port = TCP_PORT) {
|
||||||
|
if (!/^[\w.:-]+$/.test(address)) throw new Error('Invalid address');
|
||||||
|
// Kurzer Test, ob dort ein MrTerm lauscht; das Gerät erscheint danach unter "In der Nähe"
|
||||||
|
const ch = await this.connect(address, Number(port) || TCP_PORT);
|
||||||
|
ch.close();
|
||||||
|
this.seen.set(`manual:${address}`, { id: `manual:${address}`, name: address, address, port: Number(port) || TCP_PORT, pairable: true, at: Date.now() + 600000 });
|
||||||
|
this.emitState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SyncService, snapshot, merge, Channel };
|
||||||
+52
-5
@@ -7,6 +7,52 @@ const os = require('os');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
// Startet ein Programm und liefert den Exit-Code ('ENOENT', falls es nicht existiert).
|
||||||
|
// Electron/Chromium setzt unter Linux PR_SET_NO_NEW_PRIVS, das alle Kindprozesse erben – dann
|
||||||
|
// funktionieren weder pkexec noch sudo. Deshalb über systemd-run --user starten: Elternprozess ist
|
||||||
|
// dann der systemd-User-Manager, ohne diesen Schalter.
|
||||||
|
const GUI_ENV = ['DISPLAY', 'WAYLAND_DISPLAY', 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS'];
|
||||||
|
function spawnCode(cmd, args) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const p = spawn(cmd, args, { stdio: 'ignore' });
|
||||||
|
p.on('error', (e) => resolve(e.code === 'ENOENT' ? 'ENOENT' : e.message));
|
||||||
|
p.on('exit', (c) => resolve(c));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let hasSystemdRun;
|
||||||
|
async function run(cmd, args) {
|
||||||
|
if (hasSystemdRun === undefined) hasSystemdRun = (await spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', 'true'])) === 0;
|
||||||
|
if (!hasSystemdRun) return spawnCode(cmd, args);
|
||||||
|
const inPath = cmd.includes('/') ? fs.existsSync(cmd) : (process.env.PATH || '').split(':').some((d) => d && fs.existsSync(path.join(d, cmd)));
|
||||||
|
if (!inPath) return 'ENOENT';
|
||||||
|
const env = GUI_ENV.filter((k) => process.env[k]).flatMap((k) => ['-E', `${k}=${process.env[k]}`]);
|
||||||
|
return spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', ...env, '--', cmd, ...args]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback ohne Polkit-Agent: pacman -U per sudo in einem Terminalfenster; wartet, bis das Fenster geschlossen ist.
|
||||||
|
// Der Erfolg wird über eine Marker-Datei erkannt, da nicht jedes Terminal den Exit-Code durchreicht.
|
||||||
|
async function installInTerminal(file) {
|
||||||
|
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||||
|
const marker = file + '.ok';
|
||||||
|
try { fs.unlinkSync(marker); } catch {}
|
||||||
|
const script = `sudo /usr/bin/pacman -U --noconfirm ${q(file)} && touch ${q(marker)} || { echo; read -rp "[Enter]"; }`;
|
||||||
|
const terms = [
|
||||||
|
['konsole', ['--nofork', '-e', 'bash', '-c', script]],
|
||||||
|
['gnome-terminal', ['--wait', '--', 'bash', '-c', script]],
|
||||||
|
['kitty', ['bash', '-c', script]],
|
||||||
|
['alacritty', ['-e', 'bash', '-c', script]],
|
||||||
|
['foot', ['bash', '-c', script]],
|
||||||
|
['xfce4-terminal', ['--disable-server', '-x', 'bash', '-c', script]],
|
||||||
|
['xterm', ['-e', 'bash', '-c', script]],
|
||||||
|
];
|
||||||
|
for (const [cmd, args] of terms) {
|
||||||
|
const c = await run(cmd, args);
|
||||||
|
if (c === 'ENOENT') continue;
|
||||||
|
return fs.existsSync(marker) ? 0 : c || 1;
|
||||||
|
}
|
||||||
|
return 'ENOENT';
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_REPO ='https://git.mrblake.cc/MrBlake/MrTerm';
|
const DEFAULT_REPO ='https://git.mrblake.cc/MrBlake/MrTerm';
|
||||||
|
|
||||||
function parseRepo(url) {
|
function parseRepo(url) {
|
||||||
@@ -134,11 +180,12 @@ class Updater {
|
|||||||
app.relaunch({ execPath: target });
|
app.relaunch({ execPath: target });
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
} else if (kind === 'pacman' && /\.(pacman|pkg\.tar\.zst)$/.test(file)) {
|
} else if (kind === 'pacman' && /\.(pacman|pkg\.tar\.zst)$/.test(file)) {
|
||||||
await new Promise((resolve, reject) => {
|
// 1. pkexec (grafische Passwortabfrage über den Polkit-Agent)
|
||||||
const p = spawn('pkexec', ['pacman', '-U', '--noconfirm', file], { stdio: 'ignore' });
|
let code = await run('pkexec', ['/usr/bin/pacman', '-U', '--noconfirm', file]);
|
||||||
p.on('error', reject);
|
// 126/127 = Abfrage abgebrochen bzw. kein Polkit-Agent → Terminal mit sudo öffnen
|
||||||
p.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(i18n.t('pacman exited with code {code}', { code: c })))));
|
if (code === 126 || code === 127 || code === 'ENOENT') code = await installInTerminal(file);
|
||||||
});
|
if (code === 'ENOENT') throw new Error(i18n.t('Automatic installation is not possible here. The file is located at: {file}', { file }));
|
||||||
|
if (code !== 0) throw new Error(i18n.t('pacman exited with code {code}', { code }));
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
// VPN-Verbindungen (WireGuard / OpenVPN), die vor dem Verbinden zu zugeordneten Hosts automatisch aufgebaut werden.
|
||||||
|
// Linux: NetworkManager (nmcli) – kein root nötig. MrTerm legt pro VPN eine Verbindung "mrterm-<id>" an
|
||||||
|
// (autoconnect aus). OpenVPN braucht das Plugin networkmanager-openvpn.
|
||||||
|
// Windows: WireGuard als Tunnel-Dienst über wireguard.exe (UAC-Abfrage), OpenVPN über die OpenVPN GUI.
|
||||||
|
const { execFile, spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
function exec(cmd, args) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(cmd, args, { windowsHide: true, timeout: 90000 }, (err, stdout, stderr) => {
|
||||||
|
resolve({ code: err ? (typeof err.code === 'number' ? err.code : err.code === 'ENOENT' ? 'ENOENT' : 1) : 0, stdout: String(stdout || ''), stderr: String(stderr || err?.message || '') });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const lastLine = (s) => s.split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
class VpnManager {
|
||||||
|
constructor(store, userDir) {
|
||||||
|
this.store = store;
|
||||||
|
this.dir = path.join(userDir, 'vpn');
|
||||||
|
this.busy = new Map(); // id -> laufendes up() (parallele Verbindungen zum selben VPN nur einmal aufbauen)
|
||||||
|
this.started = new Set(); // von MrTerm aufgebaute VPNs
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id) { return this.store.get().vpns.find((v) => v.id === id); }
|
||||||
|
nmName(v) { return 'mrterm-' + v.id.slice(0, 8); }
|
||||||
|
// Interface-/Tunnelname: max. 15 Zeichen (Linux), nur [a-z0-9]
|
||||||
|
ifName(v) { return 'mt' + v.id.replace(/-/g, '').slice(0, 10); }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Linux (NetworkManager)
|
||||||
|
async nm(args) {
|
||||||
|
const r = await exec('nmcli', args);
|
||||||
|
if (r.code === 'ENOENT') throw new Error(i18n.t('NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.'));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
async nmExists(v) {
|
||||||
|
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show']);
|
||||||
|
return r.stdout.split('\n').includes(this.nmName(v));
|
||||||
|
}
|
||||||
|
async nmImport(v) {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mrterm-vpn-'));
|
||||||
|
const file = path.join(tmp, this.ifName(v) + (v.type === 'openvpn' ? '.ovpn' : '.conf'));
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(file, v.config || '', { mode: 0o600 });
|
||||||
|
const r = await this.nm(['connection', 'import', 'type', v.type === 'openvpn' ? 'openvpn' : 'wireguard', 'file', file]);
|
||||||
|
if (r.code) {
|
||||||
|
const err = lastLine(r.stderr);
|
||||||
|
if (v.type === 'openvpn' && /plugin|openvpn/i.test(err)) throw new Error(i18n.t('OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn'));
|
||||||
|
throw new Error(i18n.t('VPN configuration could not be imported: {err}', { err }));
|
||||||
|
}
|
||||||
|
const uuid = (r.stdout.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) || [])[0];
|
||||||
|
const mod = ['connection', 'modify', uuid || this.ifName(v), 'connection.id', this.nmName(v), 'connection.autoconnect', 'no'];
|
||||||
|
if (v.type === 'openvpn' && v.username) mod.push('+vpn.data', `username=${v.username}`);
|
||||||
|
if (v.type === 'openvpn' && v.password) mod.push('+vpn.data', 'password-flags=0', 'vpn.secrets', `password=${v.password}`);
|
||||||
|
const m = await this.nm(mod);
|
||||||
|
if (m.code) throw new Error(lastLine(m.stderr));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Windows
|
||||||
|
get wgExe() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'WireGuard', 'wireguard.exe'); }
|
||||||
|
get ovpnGui() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'OpenVPN', 'bin', 'openvpn-gui.exe'); }
|
||||||
|
// Programm mit Administratorrechten starten (UAC) und warten
|
||||||
|
async elevate(exe, args) {
|
||||||
|
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
||||||
|
const cmd = `$p = Start-Process -FilePath ${q(exe)} -ArgumentList @(${args.map((a) => q(`"${a}"`)).join(',')}) -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode`;
|
||||||
|
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', cmd]);
|
||||||
|
if (r.code) throw new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: path.basename(exe), err: lastLine(r.stderr) || r.code }));
|
||||||
|
}
|
||||||
|
winOvpnName(v) { return this.ifName(v) + '.ovpn'; }
|
||||||
|
async winOpenvpnUp() {
|
||||||
|
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
||||||
|
"@(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.InterfaceDescription -match 'TAP-Windows|Wintun|OpenVPN|ovpn-dco' }).Count"]);
|
||||||
|
return Number(r.stdout.trim()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Gemeinsame API
|
||||||
|
async isUp(v) {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (v.type === 'openvpn') return this.winOpenvpnUp();
|
||||||
|
const r = await exec('sc', ['query', `WireGuardTunnel$${this.ifName(v)}`]);
|
||||||
|
return /RUNNING/.test(r.stdout);
|
||||||
|
}
|
||||||
|
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show', '--active']);
|
||||||
|
return r.stdout.split('\n').includes(this.nmName(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
async status() {
|
||||||
|
const out = {};
|
||||||
|
for (const v of this.store.get().vpns) { try { out[v.id] = await this.isUp(v); } catch { out[v.id] = false; } }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
up(id) {
|
||||||
|
if (this.busy.has(id)) return this.busy.get(id);
|
||||||
|
const p = this._up(id).finally(() => this.busy.delete(id));
|
||||||
|
this.busy.set(id, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _up(id) {
|
||||||
|
const v = this.get(id);
|
||||||
|
if (!v) throw new Error(i18n.t('VPN not found'));
|
||||||
|
if (await this.isUp(v)) return;
|
||||||
|
const fail = (err) => new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: v.label, err }));
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
fs.mkdirSync(this.dir, { recursive: true });
|
||||||
|
if (v.type === 'openvpn') {
|
||||||
|
if (!fs.existsSync(this.ovpnGui)) throw new Error(i18n.t('OpenVPN GUI not found. Install OpenVPN from openvpn.net.'));
|
||||||
|
const dir = path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v));
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, this.winOvpnName(v)), v.config || '');
|
||||||
|
spawn(this.ovpnGui, ['--connect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
|
||||||
|
} else {
|
||||||
|
if (!fs.existsSync(this.wgExe)) throw new Error(i18n.t('WireGuard for Windows not found. Install it from wireguard.com.'));
|
||||||
|
const file = path.join(this.dir, this.ifName(v) + '.conf');
|
||||||
|
fs.writeFileSync(file, v.config || '');
|
||||||
|
await this.elevate(this.wgExe, ['/installtunnelservice', file]);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 60; i++) { if (await this.isUp(v)) { this.started.add(id); return; } await sleep(500); }
|
||||||
|
throw fail(i18n.t('timeout'));
|
||||||
|
}
|
||||||
|
if (!(await this.nmExists(v))) await this.nmImport(v);
|
||||||
|
const r = await this.nm(['--wait', '45', 'connection', 'up', 'id', this.nmName(v)]);
|
||||||
|
if (r.code) throw fail(lastLine(r.stderr));
|
||||||
|
this.started.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Beenden: von MrTerm aufgebaute VPNs mit Option "beim Beenden trennen" wieder abbauen
|
||||||
|
async downOnQuit() {
|
||||||
|
for (const id of this.started) {
|
||||||
|
const v = this.get(id);
|
||||||
|
if (v && v.disconnectOnQuit !== false) await this.down(id).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(id) {
|
||||||
|
const v = this.get(id);
|
||||||
|
if (!v) return;
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (v.type === 'openvpn') spawn(this.ovpnGui, ['--command', 'disconnect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
|
||||||
|
else if (await this.isUp(v)) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.nm(['connection', 'down', 'id', this.nmName(v)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Änderung/Löschen: im System hinterlegte Verbindung entfernen, beim nächsten Verbinden wird neu importiert
|
||||||
|
async forget(v) {
|
||||||
|
if (!v) return;
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (v.type !== 'openvpn' && (await this.isUp(v))) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
|
||||||
|
fs.rmSync(path.join(this.dir, this.ifName(v) + '.conf'), { force: true });
|
||||||
|
fs.rmSync(path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v)), { recursive: true, force: true });
|
||||||
|
} else if (await this.nmExists(v)) await this.nm(['connection', 'delete', 'id', this.nmName(v)]);
|
||||||
|
} catch { /* VPN-Werkzeuge fehlen – nichts aufzuräumen */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle VPNs eines Hosts (inkl. Jump-Host-Kette) aufbauen, bevor verbunden wird
|
||||||
|
async ensureForHost(host, onStatus = () => {}) {
|
||||||
|
const ids = [];
|
||||||
|
for (let h = host, n = 0; h && n < 10; h = h.jumpHostId ? this.store.resolveHost(h.jumpHostId) : null, n++) {
|
||||||
|
if (h.vpnId && !ids.includes(h.vpnId)) ids.push(h.vpnId);
|
||||||
|
}
|
||||||
|
for (const id of ids.reverse()) {
|
||||||
|
const v = this.get(id);
|
||||||
|
if (!v) continue;
|
||||||
|
if (await this.isUp(v)) continue;
|
||||||
|
onStatus(i18n.t('Connecting VPN “{name}” …', { name: v.label }));
|
||||||
|
await this.up(id);
|
||||||
|
}
|
||||||
|
return ids.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VpnManager };
|
||||||
@@ -34,4 +34,5 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
close: (id) => ipcRenderer.send('rdp:close', id),
|
close: (id) => ipcRenderer.send('rdp:close', id),
|
||||||
},
|
},
|
||||||
replySecret: (reqId, v) => ipcRenderer.send('secret:reply', reqId, v),
|
replySecret: (reqId, v) => ipcRenderer.send('secret:reply', reqId, v),
|
||||||
|
pairReply: (reqId, ok) => ipcRenderer.send('sync:pairReply', reqId, ok),
|
||||||
});
|
});
|
||||||
|
|||||||
+965
-18
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
||||||
<div class="drag-fill"></div>
|
<div class="drag-fill"></div>
|
||||||
|
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
|
||||||
<div class="win-ctrls">
|
<div class="win-ctrls">
|
||||||
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
|
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
|
||||||
<button id="winMax" title="Maximize" data-i18n-title="Maximize"><svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg></button>
|
<button id="winMax" title="Maximize" data-i18n-title="Maximize"><svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg></button>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
<button class="nav" data-view="sftp"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>SFTP</span></button>
|
<button class="nav" data-view="sftp"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>SFTP</span></button>
|
||||||
<button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button>
|
<button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button>
|
||||||
<button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button>
|
<button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button>
|
||||||
|
<button class="nav" data-view="vpns"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12h6M12 9v6"/></svg><span>VPN</span></button>
|
||||||
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
|
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
|
||||||
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
|
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
|
||||||
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span data-i18n="History">History</span></button>
|
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span data-i18n="History">History</span></button>
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ body.in-session #sidebar { display: none; }
|
|||||||
.session .term { flex: 1; min-width: 0; min-height: 0; overflow: hidden; }
|
.session .term { flex: 1; min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
/* Innenabstand am .xterm-Element: FitAddon zieht nur dessen Padding ab (am Elternelement würde es mitgezählt) */
|
/* Innenabstand am .xterm-Element: FitAddon zieht nur dessen Padding ab (am Elternelement würde es mitgezählt) */
|
||||||
.session .term .xterm { height: 100%; padding: 8px 0 4px 10px; }
|
.session .term .xterm { height: 100%; padding: 8px 0 4px 10px; }
|
||||||
|
.session .term .xterm-viewport { background-color: transparent !important; } /* sonst schwarzer Rand im Innenabstand */
|
||||||
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: var(--overlay); z-index: 3; }
|
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: var(--overlay); z-index: 3; }
|
||||||
.session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; }
|
.session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; }
|
||||||
.session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; }
|
.session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; }
|
||||||
@@ -342,3 +343,103 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
#sidebar.nav-collapsed .nav-menu { flex-direction: row; flex-wrap: wrap; gap: 2px; }
|
#sidebar.nav-collapsed .nav-menu { flex-direction: row; flex-wrap: wrap; gap: 2px; }
|
||||||
#sidebar.nav-collapsed .nav-menu .nav { padding: 7px; flex: 0 0 auto; }
|
#sidebar.nav-collapsed .nav-menu .nav { padding: 7px; flex: 0 0 auto; }
|
||||||
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
||||||
|
|
||||||
|
/* App-Sperre */
|
||||||
|
.lock-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; }
|
||||||
|
.lock-screen { position: fixed; inset: 0; top: var(--titlebar); z-index: 200; background: var(--bg); display: flex; align-items: center; justify-content: center; animation: fade .15s; }
|
||||||
|
.lock-box { width: 340px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
|
||||||
|
.lock-box h2 { margin: 0 0 6px; font-size: 18px; }
|
||||||
|
.lock-logo { width: 64px; height: 64px; }
|
||||||
|
.lock-pw { display: flex; gap: 8px; width: 100%; }
|
||||||
|
.lock-pw input { flex: 1; min-width: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; color: var(--text); outline: none; }
|
||||||
|
.lock-pw input:focus { border-color: var(--accent); }
|
||||||
|
.lock-fido { width: 100%; justify-content: center; }
|
||||||
|
.lock-err { color: var(--red); min-height: 18px; font-size: 13px; }
|
||||||
|
.lock-row { display: flex; align-items: center; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.lock-row .grow { flex: 1; min-width: 0; }
|
||||||
|
.lock-row b { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.lock-row b svg { width: 14px; height: 14px; }
|
||||||
|
.lock-row .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* VPN */
|
||||||
|
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
|
||||||
|
.spinner.sm { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin-right: 6px; vertical-align: -2px; }
|
||||||
|
|
||||||
|
/* Docker */
|
||||||
|
.session.docker .sbar .search.sm { flex: 0 1 260px; height: 28px; margin: 0; }
|
||||||
|
.session.docker .rt { color: var(--faint); }
|
||||||
|
.docker-body { flex: 1; overflow: auto; position: relative; }
|
||||||
|
.docker-body .pane-empty { height: 100%; display: flex; flex-direction: column; gap: 14px; align-items: center; justify-content: center; }
|
||||||
|
.docker-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.docker-table th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 8px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); z-index: 1; }
|
||||||
|
.docker-table td { padding: 7px 10px; border-bottom: 1px solid var(--row-line); white-space: nowrap; vertical-align: middle; }
|
||||||
|
.docker-table tr:hover td { background: rgb(var(--tint) / 0.024); }
|
||||||
|
.docker-table td.name { width: 30%; max-width: 0; }
|
||||||
|
.docker-table td.name > div { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.docker-table td.name b, .docker-table td.name .img { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.docker-table td.name .img { color: var(--faint); font-size: 11px; }
|
||||||
|
.docker-table td.muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.docker-table td.ports { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.docker-table .num { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||||
|
.docker-table td.acts { text-align: right; }
|
||||||
|
.docker-table td.acts .btn { padding: 4px 6px; }
|
||||||
|
.docker-table .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||||||
|
.docker-table .dot.on { background: var(--green); }
|
||||||
|
.docker-table .dot.wait { background: var(--orange); }
|
||||||
|
|
||||||
|
/* Firewall */
|
||||||
|
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
.fw-def { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin-left: 8px; }
|
||||||
|
.fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }
|
||||||
|
.fw-note { padding: 10px 16px; color: var(--faint); font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.fw-chain-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px 8px; }
|
||||||
|
.fw-chain-head .muted { color: var(--faint); font-size: 12px; }
|
||||||
|
.fw-chain-head .fw-def { margin-left: auto; }
|
||||||
|
.fw-table td.spec { font-size: 12px; white-space: normal; word-break: break-all; }
|
||||||
|
.fw-act { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; background: rgb(var(--tint) / 0.063); color: var(--muted); }
|
||||||
|
.fw-act.allow, .fw-act.accept { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Synchronisation */
|
||||||
|
.sync-peers .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
||||||
|
.sync-peers .dot.on { background: var(--green); }
|
||||||
|
.pair-code { font-size: 34px; font-weight: 700; letter-spacing: 6px; text-align: center; padding: 14px 0 6px; font-variant-numeric: tabular-nums; color: var(--accent); }
|
||||||
|
.pair-list { max-height: 260px; overflow: auto; }
|
||||||
|
.share-list { max-height: 380px; overflow: auto; }
|
||||||
|
.share-list h4 { margin: 12px 0 6px; }
|
||||||
|
.fw-hint { align-items: flex-start; margin: 4px 0 14px; }
|
||||||
|
.fw-hint svg { width: 22px; height: 22px; }
|
||||||
|
.fw-hint .grow { flex: 1; min-width: 0; }
|
||||||
|
.fw-hint b { color: var(--text); }
|
||||||
|
.fw-hint p { margin: 4px 0 8px; color: var(--muted); }
|
||||||
|
.fw-hint pre, .fw-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 0 0 10px; color: var(--text); white-space: pre-wrap; user-select: text; }
|
||||||
|
|||||||
Reference in New Issue
Block a user