Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
809227e8f1 | ||
|
|
fd155fe2db | ||
|
|
29d3747859 | ||
|
|
ea8d73dd60 | ||
|
|
d87feb63af | ||
|
|
ec5ca37356 | ||
|
|
5c974f31ce | ||
|
|
25115f8747 | ||
|
|
c3a4d06310 | ||
|
|
b4eedc77bb | ||
|
|
93123c5c60 | ||
|
|
3714205dab | ||
|
|
4818523b29 | ||
|
|
6f52873b69 | ||
|
|
51bd769d6e | ||
|
|
136e4b4e2c | ||
|
|
6b02dc078a | ||
|
|
ecaf579ccd | ||
|
|
4176cc0a01 | ||
|
|
20506ee889 | ||
|
|
6ebdc5126f | ||
|
|
08c6d65e29 | ||
|
|
8c8dc0d4c0 | ||
|
|
a279ef88b2 | ||
|
|
6efa74aa7e | ||
|
|
0b43182019 | ||
|
|
93d7130bfa | ||
|
|
408b4bd9d0 | ||
|
|
7d4991c99c | ||
|
|
10acdaeb0b | ||
|
|
daff240aa6 | ||
|
|
66d4350221 | ||
|
|
6c6e25df36 | ||
|
|
32bf502ffa | ||
|
|
a2a0893b8c | ||
|
|
45b0be8557 | ||
|
|
c143053319 | ||
|
|
58709e09f2 | ||
|
|
27091ab5d1 | ||
|
|
6a6971e1cb |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
mobile
|
||||||
|
build
|
||||||
|
docs
|
||||||
|
.git
|
||||||
|
.gitea-token
|
||||||
|
web-data
|
||||||
@@ -8,3 +8,4 @@ mobile/vendor/
|
|||||||
mobile/www/
|
mobile/www/
|
||||||
mobile/android/keystore.properties
|
mobile/android/keystore.properties
|
||||||
mobile/android/*.jks
|
mobile/android/*.jks
|
||||||
|
web-data/
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# MrTerm Web – die MrTerm-Oberfläche im Browser, selbst gehostet.
|
||||||
|
# docker compose up -d → http://<server>:8080 (beim ersten Aufruf Admin-Konto anlegen)
|
||||||
|
|
||||||
|
# ---- Build: Server samt ssh2/ws in eine Datei bündeln
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
COPY src ./src
|
||||||
|
RUN npx esbuild src/web/server.js --bundle --platform=node --target=node22 --minify --legal-comments=none \
|
||||||
|
--external:electron --external:cpu-features --external:bufferutil --external:utf-8-validate \
|
||||||
|
--outfile=dist/server.js
|
||||||
|
|
||||||
|
# ---- Laufzeit: nur Node, gebündelter Server und die Dateien der Oberfläche
|
||||||
|
FROM node:22-alpine
|
||||||
|
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data MRTERM_ROOT=/app
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
COPY --from=build /src/dist/server.js dist/server.js
|
||||||
|
COPY src/i18n.js src/i18n.js
|
||||||
|
COPY src/renderer src/renderer
|
||||||
|
COPY src/web/public src/web/public
|
||||||
|
COPY --from=build /src/node_modules/@xterm node_modules/@xterm
|
||||||
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
USER node
|
||||||
|
VOLUME /data
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
@@ -62,7 +62,8 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app.
|
|||||||
- **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** of everything (hosts, keys, passwords, snippets, forwards, VPNs, known hosts, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import can merge or replace
|
||||||
|
- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker)
|
||||||
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
|
- **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)
|
||||||
@@ -82,6 +83,39 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app.
|
|||||||
|  | |
|
|  | |
|
||||||
| **Settings**: language, app lock, LAN sync and themes | |
|
| **Settings**: language, app lock, LAN sync and themes | |
|
||||||
|
|
||||||
|
## Web version (Docker)
|
||||||
|
|
||||||
|
MrTerm also runs as a self-hosted web app, similar to Termix: you use the familiar MrTerm interface in the browser, from any computer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O https://git.mrblake.cc/MrBlake/MrTerm/raw/branch/main/docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://<your-server>:8080` and create the administrator account. As an administrator you can add more users under **Settings → Account**.
|
||||||
|
|
||||||
|
- **Included:** hosts and groups, SSH terminal tabs, SFTP file manager (upload by button or drag & drop, download to your computer), keys, snippets, known hosts, Docker, firewall and network management, themes.
|
||||||
|
- **Not included:** RDP, VPN, port forwarding, LAN sync and the app lock. These need direct access to your computer.
|
||||||
|
- **Security:** every user has their own vault, encrypted with a key that only their login password can unlock. The server stores no readable passwords or keys. If a user forgets their password, their vault cannot be recovered.
|
||||||
|
- **HTTPS:** put MrTerm behind a reverse proxy with HTTPS (Traefik, Caddy, nginx …) before you use it over the internet, and set `TRUST_PROXY=1` in `docker-compose.yml`. The proxy must forward WebSockets.
|
||||||
|
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
|
||||||
|
- **SSH connections** start from the server, so the server must be able to reach your hosts.
|
||||||
|
|
||||||
|
### Updating the web version
|
||||||
|
|
||||||
|
Run these commands in the folder that contains your `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --pull --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This fetches the latest MrTerm from the repository, rebuilds the image and restarts the container. Your users and vaults stay in the `mrterm-data` volume and are kept. Everyone has to sign in again after the restart.
|
||||||
|
|
||||||
|
To check which version is running, look at the bottom of the sign-in page. If a release note mentions changes to `docker-compose.yml`, download it again first (`curl -O …` as above) and copy over your own changes, such as ports or `TRUST_PROXY`.
|
||||||
|
|
||||||
|
To remove images left over from earlier builds: `docker image prune`.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
1. Click **New host**, enter the address, username and password or key, and click **Save**.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 222 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
# MrTerm Web
|
||||||
|
# docker compose up -d
|
||||||
|
# Danach http://<server>:8080 öffnen und das Admin-Konto anlegen.
|
||||||
|
# Hinter einem Reverse-Proxy mit HTTPS (empfohlen): TRUST_PROXY=1 setzen und den Port nur lokal freigeben.
|
||||||
|
services:
|
||||||
|
mrterm:
|
||||||
|
build:
|
||||||
|
# Baut direkt aus dem Repository – kein Klonen nötig. Alternativ "context: ." in einem Klon.
|
||||||
|
context: https://git.mrblake.cc/MrBlake/MrTerm.git
|
||||||
|
image: mrterm-web
|
||||||
|
container_name: mrterm
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Berlin
|
||||||
|
# - TRUST_PROXY=1
|
||||||
|
volumes:
|
||||||
|
- mrterm-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mrterm-data:
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:windowSoftInputMode="adjustNothing"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package de.mrterm.app;
|
package de.mrterm.app;
|
||||||
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
|
||||||
import android.webkit.WebView;
|
import android.webkit.WebView;
|
||||||
|
|
||||||
import androidx.activity.OnBackPressedCallback;
|
import androidx.activity.OnBackPressedCallback;
|
||||||
@@ -28,24 +26,20 @@ public class MainActivity extends BridgeActivity {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edge-to-Edge: Abstände für Statusleiste, Navigationsleiste und Tastatur setzt allein diese Ansicht.
|
// Edge-to-Edge: Ränder für Status- und Navigationsleiste setzt diese Ansicht. Den Platz für die Tastatur
|
||||||
// Die Insets werden danach verbraucht, damit WebView/System nicht ein zweites Mal verkleinern.
|
// schafft Android/WebView selbst – hier nur melden, ob sie offen ist (sonst würde doppelt verkleinert).
|
||||||
View parent = (View) web.getParent();
|
View parent = (View) web.getParent();
|
||||||
parent.setBackgroundColor(0xFF14161D);
|
parent.setBackgroundColor(0xFF14161D);
|
||||||
final boolean[] imeOpen = { false };
|
final boolean[] imeOpen = { false };
|
||||||
// Vor Android 11 kommen Tastatur-Insets nicht zuverlässig: dort verkleinert das System das Fenster
|
|
||||||
final boolean legacyIme = Build.VERSION.SDK_INT < Build.VERSION_CODES.R;
|
|
||||||
if (legacyIme) getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(parent, (v, insets) -> {
|
ViewCompat.setOnApplyWindowInsetsListener(parent, (v, insets) -> {
|
||||||
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
|
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
|
||||||
int ime = legacyIme ? 0 : insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;
|
boolean open = insets.isVisible(WindowInsetsCompat.Type.ime());
|
||||||
v.setPadding(bars.left, bars.top, bars.right, Math.max(ime, bars.bottom));
|
v.setPadding(bars.left, bars.top, bars.right, open ? 0 : bars.bottom);
|
||||||
boolean open = ime > 0;
|
|
||||||
if (open != imeOpen[0]) {
|
if (open != imeOpen[0]) {
|
||||||
imeOpen[0] = open;
|
imeOpen[0] = open;
|
||||||
web.evaluateJavascript("window.mrtermIme && window.mrtermIme(" + open + ")", null);
|
web.evaluateJavascript("window.mrtermIme && window.mrtermIme(" + open + ")", null);
|
||||||
}
|
}
|
||||||
return WindowInsetsCompat.CONSUMED;
|
return insets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,23 @@ public class MrTermNative extends Plugin {
|
|||||||
call.resolve();
|
call.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Design: Hintergrund hinter Status-/Navigationsleiste und dunkle bzw. helle Symbole
|
||||||
|
@PluginMethod
|
||||||
|
public void bars(PluginCall call) {
|
||||||
|
final boolean light = Boolean.TRUE.equals(call.getBoolean("light", false));
|
||||||
|
final String color = call.getString("color", "#14161d");
|
||||||
|
getActivity().runOnUiThread(() -> {
|
||||||
|
try {
|
||||||
|
android.view.View parent = (android.view.View) getBridge().getWebView().getParent();
|
||||||
|
parent.setBackgroundColor(android.graphics.Color.parseColor(color));
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat c = androidx.core.view.WindowCompat.getInsetsController(getActivity().getWindow(), parent);
|
||||||
|
c.setAppearanceLightStatusBars(light);
|
||||||
|
c.setAppearanceLightNavigationBars(light);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
});
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
public void background(PluginCall call) {
|
public void background(PluginCall call) {
|
||||||
getActivity().runOnUiThread(() -> getActivity().moveTaskToBack(true));
|
getActivity().runOnUiThread(() -> getActivity().moveTaskToBack(true));
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function buildWeb() {
|
|||||||
copy(path.join(__dirname, 'node_modules', '@capacitor', 'core', 'dist', 'capacitor.js'), path.join(WWW, 'lib', 'capacitor.js'));
|
copy(path.join(__dirname, 'node_modules', '@capacitor', 'core', 'dist', 'capacitor.js'), path.join(WWW, 'lib', 'capacitor.js'));
|
||||||
copy(path.join(ROOT, 'src', 'i18n.js'), path.join(WWW, 'lib', 'i18n.js'));
|
copy(path.join(ROOT, 'src', 'i18n.js'), path.join(WWW, 'lib', 'i18n.js'));
|
||||||
copy(path.join(ROOT, 'src', 'renderer', 'themes.js'), path.join(WWW, 'lib', 'themes.js'));
|
copy(path.join(ROOT, 'src', 'renderer', 'themes.js'), path.join(WWW, 'lib', 'themes.js'));
|
||||||
|
copy(path.join(ROOT, 'src', 'renderer', 'app-themes.css'), path.join(WWW, 'lib', 'app-themes.css'));
|
||||||
copy(path.join(ROOT, 'src', 'renderer', 'logo.png'), path.join(WWW, 'logo.png'));
|
copy(path.join(ROOT, 'src', 'renderer', 'logo.png'), path.join(WWW, 'logo.png'));
|
||||||
|
|
||||||
// Node-Backend inkl. ssh2 in eine Datei bündeln (nodejs-mobile = Node 18)
|
// Node-Backend inkl. ssh2 in eine Datei bündeln (nodejs-mobile = Node 18)
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ contextBridge.exposeInMainWorld('DevBridge', {
|
|||||||
send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
|
send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
|
||||||
addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
|
addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
|
||||||
},
|
},
|
||||||
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background'].map((m) => [m, native(m)])),
|
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background', 'bars'].map((m) => [m, native(m)])),
|
||||||
});
|
});
|
||||||
|
|||||||
+17
-168
@@ -1,16 +1,11 @@
|
|||||||
// Node-Backend der Android-App. Nutzt dieselben Module wie der Electron-Hauptprozess
|
// Node-Backend der Android-App. Die Kanäle kommen aus src/core/backend.js (gemeinsam mit der Web-Version);
|
||||||
// (Tresor, SSH, LAN-Sync, Docker, Firewall, Netzwerk) und spricht über die Capacitor-Bridge mit der Oberfläche.
|
// hier nur, was Android betrifft: Bridge-Anbindung, Entsperren per Fingerabdruck, Pause/Fortsetzen.
|
||||||
// Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente];
|
// Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente];
|
||||||
// Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente].
|
// Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente].
|
||||||
const { channel } = require('bridge');
|
const { channel } = require('bridge');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { utils: sshUtils } = require('ssh2');
|
|
||||||
const { Store } = require('../../src/main/store');
|
const { Store } = require('../../src/main/store');
|
||||||
const { SshManager } = require('../../src/main/ssh');
|
const { createBackend } = require('../../src/core/backend');
|
||||||
const { DockerManager } = require('../../src/main/docker');
|
|
||||||
const { FirewallManager } = require('../../src/main/firewall');
|
|
||||||
const { NetworkConfigManager } = require('../../src/main/network');
|
|
||||||
const { SyncService } = require('../../src/main/sync');
|
|
||||||
const i18n = require('../../src/i18n');
|
const i18n = require('../../src/i18n');
|
||||||
|
|
||||||
const send = (ch, ...args) => channel.send('evt', ch, args);
|
const send = (ch, ...args) => channel.send('evt', ch, args);
|
||||||
@@ -18,182 +13,36 @@ process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.mess
|
|||||||
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||||
|
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, process.env.MRTERM_LOCALE || 'en');
|
const applyLanguage = (lang = store.get().settings.language) => i18n.setLanguage(lang, process.env.MRTERM_LOCALE || 'en');
|
||||||
|
const be = createBackend({ store, send, platform: 'android', version: process.env.MRTERM_VERSION || '0.0.0', withSync: true, onLanguage: applyLanguage });
|
||||||
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
|
||||||
const pending = new Map();
|
|
||||||
function ask(ch, req, timeout = 0) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const reqId = crypto.randomUUID();
|
|
||||||
pending.set(reqId, resolve);
|
|
||||||
send(ch, { reqId, ...req });
|
|
||||||
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
|
|
||||||
|
|
||||||
const confirmHostKey = async (target, fingerprint, known) =>
|
|
||||||
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
|
|
||||||
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
|
|
||||||
|
|
||||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
|
||||||
const docker = new DockerManager(ssh);
|
|
||||||
const firewall = new FirewallManager(ssh);
|
|
||||||
const network = new NetworkConfigManager(ssh);
|
|
||||||
const sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
|
|
||||||
store.onChange = () => sync.schedule();
|
|
||||||
|
|
||||||
const handlers = new Map();
|
|
||||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
|
||||||
const handle = (ch, fn) => handlers.set(ch, fn);
|
|
||||||
|
|
||||||
channel.addListener('call', async (reqId, ch, args = []) => {
|
channel.addListener('call', async (reqId, ch, args = []) => {
|
||||||
try {
|
try { channel.send('reply', reqId, true, await be.call(ch, args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
|
||||||
const fn = handlers.get(ch);
|
|
||||||
if (!fn) throw new Error(`Unknown channel ${ch}`);
|
|
||||||
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
|
|
||||||
channel.send('reply', reqId, true, await fn(...args));
|
|
||||||
} catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
|
|
||||||
});
|
});
|
||||||
const listeners = {
|
channel.addListener('send', (ch, args = []) => be.notify(ch, args));
|
||||||
'ssh:write': (id, d) => ssh.write(id, d),
|
|
||||||
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
|
|
||||||
'ssh:close': (id) => ssh.close(id),
|
|
||||||
'ask:reply': answer,
|
|
||||||
};
|
|
||||||
channel.addListener('send', (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } });
|
|
||||||
|
|
||||||
// ---------- App-Sperre: Passwort, zusätzlich Fingerabdruck (Geheimnis aus dem Android Keystore) ----------
|
// ---------- Fingerabdruck (Geheimnis aus dem Android Keystore, nur zusätzlich zum Passwort) ----------
|
||||||
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 bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32));
|
const bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32));
|
||||||
function lockStatus() {
|
be.handle('lock:unlockBio', (secret) => {
|
||||||
const { language, appTheme, accent } = store.get().settings;
|
|
||||||
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, meta: { language, appTheme, accent } };
|
|
||||||
}
|
|
||||||
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
|
|
||||||
const afterUnlock = () => { applyLanguage(); 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:unlockBio', (secret) => {
|
|
||||||
if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.'));
|
if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.'));
|
||||||
try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your password.')); }
|
try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your password.')); }
|
||||||
afterUnlock();
|
applyLanguage();
|
||||||
|
be.sync.start().catch(() => {});
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
handle('lock:setPassword', async (pw) => {
|
be.handle('lock:setBio', (secret) => {
|
||||||
requireUnlocked();
|
be.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);
|
|
||||||
store.lock = store.lock || { password: null, fido: [] };
|
|
||||||
store.lock.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.lock.bio = null; } // Fingerabdruck nur zusätzlich zum Passwort
|
|
||||||
store.dropLockIfEmpty();
|
|
||||||
store.save();
|
|
||||||
return lockStatus();
|
|
||||||
});
|
|
||||||
handle('lock:setBio', (secret) => {
|
|
||||||
requireUnlocked();
|
|
||||||
if (!store.lock?.password) throw new Error(i18n.t('Set a password first.'));
|
if (!store.lock?.password) throw new Error(i18n.t('Set a password first.'));
|
||||||
store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null;
|
store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null;
|
||||||
store.save();
|
store.save();
|
||||||
return lockStatus();
|
return be.lockStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Tresor ----------
|
|
||||||
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
|
|
||||||
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: 'android' }));
|
|
||||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
|
||||||
handle('vault:remove', (col, id) => {
|
|
||||||
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
|
|
||||||
return store.remove(col, id);
|
|
||||||
});
|
|
||||||
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) applyLanguage(); });
|
|
||||||
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
|
||||||
handle('app:version', () => process.env.MRTERM_VERSION || '0.0.0');
|
|
||||||
|
|
||||||
// ---------- Schlüssel ----------
|
|
||||||
handle('key:generate', ({ type, bits, comment, passphrase }) => {
|
|
||||||
const opts = { comment: comment || 'mrterm@android' };
|
|
||||||
if (type === 'rsa') opts.bits = Number(bits) || 4096;
|
|
||||||
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
|
|
||||||
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
|
|
||||||
return { privateKey: k.private, publicKey: k.public };
|
|
||||||
});
|
|
||||||
handle('key:parse', ({ privateKey, passphrase }) => {
|
|
||||||
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
|
|
||||||
if (k instanceof Error) throw k;
|
|
||||||
const key = Array.isArray(k) ? k[0] : k;
|
|
||||||
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------- SSH-Terminal ----------
|
|
||||||
function hostWithOverrides(ref) {
|
|
||||||
if (typeof ref === 'string') {
|
|
||||||
const h = store.resolveHost(ref);
|
|
||||||
if (!h) throw new Error(i18n.t('Host not found'));
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
handle('ssh:open', async (sessionId, ref, size) => {
|
|
||||||
const host = hostWithOverrides(ref);
|
|
||||||
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
|
||||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------- Docker / Firewall / Netzwerk (gleiche Module wie am Desktop) ----------
|
|
||||||
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => 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));
|
|
||||||
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => 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));
|
|
||||||
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => 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: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));
|
|
||||||
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
|
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
|
||||||
handle('app:pause', () => sync.stop());
|
be.handle('app:pause', () => be.sync.stop());
|
||||||
handle('app:resume', () => sync.start().catch(() => {}));
|
be.handle('app:resume', () => be.sync.start().catch(() => {}));
|
||||||
|
|
||||||
store.load();
|
store.load();
|
||||||
applyLanguage();
|
applyLanguage();
|
||||||
sync.start().catch(() => {});
|
be.sync.start().catch(() => {});
|
||||||
channel.send('ready');
|
channel.send('ready');
|
||||||
|
|||||||
+51
-10
@@ -193,10 +193,12 @@ async function reload() {
|
|||||||
S.lock = await api.call('lock:status');
|
S.lock = await api.call('lock:status');
|
||||||
I18N.setLanguage(S.lock.meta.language || 'auto', S.info.locale);
|
I18N.setLanguage(S.lock.meta.language || 'auto', S.info.locale);
|
||||||
applyStatic();
|
applyStatic();
|
||||||
|
applyTheme(S.lock.meta);
|
||||||
if (S.lock.locked) return showLock();
|
if (S.lock.locked) return showLock();
|
||||||
S.vault = await api.call('vault:get');
|
S.vault = await api.call('vault:get');
|
||||||
I18N.setLanguage(settings().language || 'auto', S.info.locale);
|
I18N.setLanguage(settings().language || 'auto', S.info.locale);
|
||||||
applyStatic();
|
applyStatic();
|
||||||
|
applyTheme();
|
||||||
S.sync = await api.call('sync:status').catch(() => null);
|
S.sync = await api.call('sync:status').catch(() => null);
|
||||||
$('#main').hidden = false;
|
$('#main').hidden = false;
|
||||||
render();
|
render();
|
||||||
@@ -410,7 +412,33 @@ const KEYS = [
|
|||||||
];
|
];
|
||||||
const mods = { ctrl: 0, alt: 0 }; // 0 aus, 1 einmal, 2 festgestellt
|
const mods = { ctrl: 0, alt: 0 }; // 0 aus, 1 einmal, 2 festgestellt
|
||||||
|
|
||||||
function termTheme() { return TERM_THEMES[settings().terminalTheme] || TERM_THEMES.mrterm; }
|
function termTheme() {
|
||||||
|
const id = settings().terminalTheme;
|
||||||
|
if (!id || id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
|
||||||
|
return TERM_THEMES[id] || TERM_THEMES.mrterm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App-Design (gemeinsam mit dem Desktop: lib/app-themes.css + APP_THEMES)
|
||||||
|
function applyTheme(st = settings()) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const t = APP_THEMES[st.appTheme] || APP_THEMES.midnight;
|
||||||
|
if (st.appTheme && st.appTheme !== 'midnight' && APP_THEMES[st.appTheme]) root.dataset.theme = st.appTheme; else delete root.dataset.theme;
|
||||||
|
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
|
||||||
|
root.classList.toggle('light', !!t.light);
|
||||||
|
S.sessions?.forEach((x) => { x.term.options.theme = termTheme(); });
|
||||||
|
document.documentElement.style.setProperty('--term-bg', termTheme().background);
|
||||||
|
syncBars();
|
||||||
|
}
|
||||||
|
// Statusleiste passend zur sichtbaren Fläche (Terminal oder App)
|
||||||
|
function syncBars() {
|
||||||
|
const t = APP_THEMES[settings().appTheme] || APP_THEMES.midnight;
|
||||||
|
const inTerm = !$('#termScreen').hidden;
|
||||||
|
const bg = inTerm ? getComputedStyle(document.querySelector('.term-head')).backgroundColor : getComputedStyle(document.body).backgroundColor;
|
||||||
|
const rgb = bg.match(/\d+/g)?.map(Number) || [20, 22, 29];
|
||||||
|
const hex = '#' + rgb.slice(0, 3).map((n) => n.toString(16).padStart(2, '0')).join('');
|
||||||
|
const light = inTerm ? (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 > 150 : !!t.light;
|
||||||
|
Native.bars?.({ light, color: hex }).catch(() => {});
|
||||||
|
}
|
||||||
const fontSize = () => Number(settings().mobileFontSize) || 12;
|
const fontSize = () => Number(settings().mobileFontSize) || 12;
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
@@ -537,6 +565,7 @@ function showSession(s) {
|
|||||||
$('#termScreen').hidden = false;
|
$('#termScreen').hidden = false;
|
||||||
$('#main').hidden = true;
|
$('#main').hidden = true;
|
||||||
document.documentElement.style.setProperty('--term-bg', termTheme().background);
|
document.documentElement.style.setProperty('--term-bg', termTheme().background);
|
||||||
|
syncBars();
|
||||||
updateTermHead();
|
updateTermHead();
|
||||||
drawKeybar();
|
drawKeybar();
|
||||||
requestAnimationFrame(() => { s.refit(); if (s.alive) s.term.focus(); });
|
requestAnimationFrame(() => { s.refit(); if (s.alive) s.term.focus(); });
|
||||||
@@ -548,6 +577,7 @@ function hideTerminal() {
|
|||||||
S.active?.term.blur();
|
S.active?.term.blur();
|
||||||
S.active = null;
|
S.active = null;
|
||||||
render();
|
render();
|
||||||
|
syncBars();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTermHead() {
|
function updateTermHead() {
|
||||||
@@ -723,20 +753,31 @@ function viewSettings(v) {
|
|||||||
v.append(h(`<div class="top"><h1>${esc(T('Settings'))}</h1></div>`));
|
v.append(h(`<div class="top"><h1>${esc(T('Settings'))}</h1></div>`));
|
||||||
const st = settings();
|
const st = settings();
|
||||||
|
|
||||||
// Sprache & Terminal
|
// Sprache, Design & Terminal
|
||||||
const gen = h(`<div class="card"><h3>${esc(T('General'))}</h3>
|
const gen = h(`<div class="card"><h3>${esc(T('General'))}</h3>
|
||||||
${field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System language')], ...Object.entries(I18N.LANGUAGES)] })}
|
${field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System language')], ...Object.entries(I18N.LANGUAGES)] })}
|
||||||
${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'mrterm', { type: 'select', options: Object.entries(TERM_THEMES).map(([id, t]) => [id, t.name]) })}
|
<div class="field"><label>Design</label><div class="theme-grid"></div></div>
|
||||||
|
<div class="field"><label>${esc(T('Accent color'))}</label><div class="colors"></div></div>
|
||||||
|
${field(T('Terminal color scheme'), 'terminalTheme', st.terminalTheme || 'auto', { type: 'select', options: [['auto', T('Match theme')], ...Object.entries(TERM_THEMES).map(([id, t]) => [id, t.name])] })}
|
||||||
${field(T('Font size'), 'mobileFontSize', fontSize(), { type: 'select', options: [8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20].map((n) => [n, `${n} px`]) })}
|
${field(T('Font size'), 'mobileFontSize', fontSize(), { type: 'select', options: [8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20].map((n) => [n, `${n} px`]) })}
|
||||||
</div>`);
|
</div>`);
|
||||||
$('[name=language]', gen).onchange = async (e) => { await call('vault:settings', { language: e.target.value }); await reload(); };
|
const save = async (patch) => { await call('vault:settings', patch); Object.assign(S.vault.settings, patch); };
|
||||||
$('[name=terminalTheme]', gen).onchange = async (e) => {
|
$('[name=language]', gen).onchange = async (e) => { await save({ language: e.target.value }); await reload(); };
|
||||||
await call('vault:settings', { terminalTheme: e.target.value }); S.vault.settings.terminalTheme = e.target.value;
|
for (const [id, t] of Object.entries(APP_THEMES)) {
|
||||||
S.sessions.forEach((s) => { s.term.options.theme = termTheme(); });
|
const p = h(`<button class="theme-prev ${(st.appTheme || 'midnight') === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
|
||||||
};
|
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div>${esc(T(t.name))}</button>`);
|
||||||
|
p.onclick = async () => { await save({ appTheme: id }); applyTheme(); render(); };
|
||||||
|
$('.theme-grid', gen).append(p);
|
||||||
|
}
|
||||||
|
['', ...COLORS.slice(0, 6), '#21c7a8', '#cba6f7', '#fcee0a'].forEach((col) => {
|
||||||
|
const b = h(`<button title="${esc(col || T('Theme default'))}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
|
||||||
|
b.onclick = async () => { await save({ accent: col }); applyTheme(); render(); };
|
||||||
|
$('.colors', gen).append(b);
|
||||||
|
});
|
||||||
|
$('[name=terminalTheme]', gen).onchange = async (e) => { await save({ terminalTheme: e.target.value }); applyTheme(); };
|
||||||
$('[name=mobileFontSize]', gen).onchange = async (e) => {
|
$('[name=mobileFontSize]', gen).onchange = async (e) => {
|
||||||
const n = Number(e.target.value); await call('vault:settings', { mobileFontSize: n }); S.vault.settings.mobileFontSize = n;
|
const n = Number(e.target.value); await save({ mobileFontSize: n });
|
||||||
S.sessions.forEach((s) => { s.term.options.fontSize = n; });
|
S.sessions.forEach((x) => { x.term.options.fontSize = n; });
|
||||||
};
|
};
|
||||||
v.append(gen);
|
v.append(gen);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<title>MrTerm</title>
|
<title>MrTerm</title>
|
||||||
<link rel="stylesheet" href="lib/xterm.css" />
|
<link rel="stylesheet" href="lib/xterm.css" />
|
||||||
|
<link rel="stylesheet" href="lib/app-themes.css" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+56
-24
@@ -1,26 +1,19 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #14161d;
|
/* Farben kommen aus lib/app-themes.css (gemeinsam mit dem Desktop, Auswahl über data-theme) */
|
||||||
--surface: #1c1f29;
|
--surface: var(--card);
|
||||||
--surface2: #252936;
|
--surface2: var(--card-hover);
|
||||||
--line: #2c3040;
|
--line: var(--border);
|
||||||
--text: #e6e8ef;
|
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
--muted: #9aa0b4;
|
--r: calc(var(--r) + 4px);
|
||||||
--faint: #5d6377;
|
/* Status-/Navigationsleiste rechnet MainActivity als Rand um den WebView heraus */
|
||||||
--accent: #6e7bff;
|
|
||||||
--accent-soft: #6e7bff26;
|
|
||||||
--green: #3ecf8e;
|
|
||||||
--red: #ff5f6d;
|
|
||||||
--orange: #ffb347;
|
|
||||||
--radius: 14px;
|
|
||||||
/* Capacitor setzt --safe-area-inset-* bei Edge-to-Edge; env() als Rückfall */
|
|
||||||
/* Status-/Navigationsleiste und Tastatur rechnet MainActivity als Rand um den WebView heraus */
|
|
||||||
--top: 0px;
|
--top: 0px;
|
||||||
--bottom: 0px;
|
--bottom: 0px;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
:root.light { color-scheme: light; }
|
||||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden; }
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden; }
|
||||||
body { font: 15px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
|
body { font: 15px/1.4 var(--font); font-family: var(--font), system-ui, Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
||||||
input, select, textarea { font: inherit; color: var(--text); -webkit-user-select: text; user-select: text; }
|
input, select, textarea { font: inherit; color: var(--text); -webkit-user-select: text; user-select: text; }
|
||||||
@@ -55,7 +48,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
|
.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
|
||||||
.section svg { width: 16px; height: 16px; }
|
.section svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
.list { background: var(--surface); border-radius: var(--radius); overflow: hidden; }
|
.list { background: var(--surface); border-radius: var(--r); overflow: hidden; }
|
||||||
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 62px; position: relative; }
|
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 62px; position: relative; }
|
||||||
.item + .item::before { content: ''; position: absolute; top: 0; left: 64px; right: 0; border-top: 1px solid var(--line); }
|
.item + .item::before { content: ''; position: absolute; top: 0; left: 64px; right: 0; border-top: 1px solid var(--line); }
|
||||||
.item:active { background: var(--surface2); }
|
.item:active { background: var(--surface2); }
|
||||||
@@ -80,11 +73,11 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
.empty .avatar svg { width: 28px; height: 28px; }
|
.empty .avatar svg { width: 28px; height: 28px; }
|
||||||
.empty h3 { color: var(--text); margin: 0 0 6px; }
|
.empty h3 { color: var(--text); margin: 0 0 6px; }
|
||||||
|
|
||||||
.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: 18px; background: var(--accent); color: #fff; display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
|
.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: calc(var(--radius) + 8px); background: var(--accent); color: var(--on-accent); display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
|
||||||
.fab svg { width: 26px; height: 26px; }
|
.fab svg { width: 26px; height: 26px; }
|
||||||
|
|
||||||
/* ---------- Karten / Einstellungen ---------- */
|
/* ---------- Karten / Einstellungen ---------- */
|
||||||
.card { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 14px; }
|
.card { background: var(--surface); border-radius: var(--r); padding: 14px 16px; margin-bottom: 14px; }
|
||||||
.card h3 { margin: 0 0 4px; font-size: 16px; }
|
.card h3 { margin: 0 0 4px; font-size: 16px; }
|
||||||
.card p, .hint { color: var(--muted); font-size: 13.5px; margin: 4px 0 10px; }
|
.card p, .hint { color: var(--muted); font-size: 13.5px; margin: 4px 0 10px; }
|
||||||
.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
|
.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
|
||||||
@@ -93,9 +86,9 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
.row .sub { color: var(--muted); font-size: 13px; }
|
.row .sub { color: var(--muted); font-size: 13px; }
|
||||||
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||||
|
|
||||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); font-weight: 600; }
|
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); font-weight: 600; }
|
||||||
.btn svg { width: 18px; height: 18px; }
|
.btn svg { width: 18px; height: 18px; }
|
||||||
.btn.primary { background: var(--accent); color: #fff; }
|
.btn.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||||
.btn.danger { color: var(--red); }
|
.btn.danger { color: var(--red); }
|
||||||
.btn.block { width: 100%; }
|
.btn.block { width: 100%; }
|
||||||
.btn:disabled { opacity: .5; }
|
.btn:disabled { opacity: .5; }
|
||||||
@@ -117,7 +110,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
|
.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
|
||||||
.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
|
.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
|
||||||
.switch input:checked + span { background: var(--accent); }
|
.switch input:checked + span { background: var(--accent); }
|
||||||
.switch input:checked + span::after { transform: translateX(20px); background: #fff; }
|
.switch input:checked + span::after { transform: translateX(20px); background: var(--on-accent); }
|
||||||
|
|
||||||
.warn-box { border: 1px solid #ffb34755; background: #ffb34712; border-radius: 12px; padding: 10px 12px; color: var(--orange); font-size: 13px; margin: 8px 0; }
|
.warn-box { border: 1px solid #ffb34755; background: #ffb34712; border-radius: 12px; padding: 10px 12px; color: var(--orange); font-size: 13px; margin: 8px 0; }
|
||||||
|
|
||||||
@@ -185,7 +178,46 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
#keybar::-webkit-scrollbar { display: none; }
|
#keybar::-webkit-scrollbar { display: none; }
|
||||||
#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
|
#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
|
||||||
#keybar button svg { width: 18px; height: 18px; }
|
#keybar button svg { width: 18px; height: 18px; }
|
||||||
#keybar button.on { background: var(--accent); color: #fff; }
|
#keybar button.on { background: var(--accent); color: var(--on-accent); }
|
||||||
#keybar button.lock { box-shadow: inset 0 0 0 2px #fff; }
|
#keybar button.lock { box-shadow: inset 0 0 0 2px var(--on-accent); }
|
||||||
body.kbd-open #keybar { padding-bottom: 6px; }
|
body.kbd-open #keybar { padding-bottom: 6px; }
|
||||||
body.kbd-open #tabs, body.kbd-open .fab { display: none; }
|
body.kbd-open #tabs, body.kbd-open .fab { display: none; }
|
||||||
|
|
||||||
|
/* Design-Extras */
|
||||||
|
[data-theme='xp'] .page-head, [data-theme='xp'] .term-head { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; border-color: #0a3f9f; }
|
||||||
|
[data-theme='xp'] .page-head .icon, [data-theme='xp'] .term-head .icon, [data-theme='xp'] .term-title svg { color: #fff; }
|
||||||
|
[data-theme='xp'] #tabs { background: linear-gradient(#3168d5, #1d4fbf); border-color: #0a3f9f; }
|
||||||
|
[data-theme='xp'] #tabs button { color: #c7d8ff; }
|
||||||
|
[data-theme='xp'] #tabs button.active { color: #fff; }
|
||||||
|
[data-theme='xp'] .btn.primary, [data-theme='xp'] .fab { background: linear-gradient(#3d86e8, #2159b8); }
|
||||||
|
[data-theme='cyberpunk'] .btn.primary, [data-theme='cyberpunk'] .fab { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 12px) 100%, 0 100%); }
|
||||||
|
[data-theme='cyberpunk'] .btn.primary { text-transform: uppercase; letter-spacing: .6px; }
|
||||||
|
[data-theme='cyberpunk'] .top h1 { text-transform: uppercase; letter-spacing: 1px; text-shadow: 2px 0 #ff003c88, -2px 0 #00f0ff88; }
|
||||||
|
[data-theme='cyberpunk'] .list, [data-theme='cyberpunk'] .card { border-left: 2px solid var(--accent); }
|
||||||
|
[data-theme='cyberpunk'] #tabs button.active { color: var(--accent); text-shadow: 0 0 8px #fcee0a88; }
|
||||||
|
.theme-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 6px 0 14px; }
|
||||||
|
.theme-prev { border-radius: 12px; padding: 8px; border: 2px solid var(--line); font-size: 12px; font-weight: 600; text-align: left; }
|
||||||
|
.theme-prev.active { border-color: var(--accent); }
|
||||||
|
.theme-prev .dp { display: flex; gap: 4px; height: 40px; margin-bottom: 6px; }
|
||||||
|
.theme-prev .dp i { width: 12px; border-radius: 3px; }
|
||||||
|
.theme-prev .dp div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.theme-prev .dp b { flex: 1; border-radius: 3px; position: relative; }
|
||||||
|
.theme-prev .dp s { position: absolute; left: 4px; top: 4px; width: 14px; height: 6px; border-radius: 2px; }
|
||||||
|
.colors { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.colors button { width: 34px; height: 34px; border-radius: 50%; border: 3px solid transparent; }
|
||||||
|
.colors button.active { border-color: var(--text); }
|
||||||
|
|
||||||
|
/* macOS Liquid Glass */
|
||||||
|
[data-theme='macos'] #main, [data-theme='macos'] #view { background: transparent; }
|
||||||
|
[data-theme='macos'] :is(.list, .card, .search, #tabs, .page-head, .term-head, .sheet, .dialog, .toast, .chip, .btn, .fab, .field input, .field select, .field textarea, #keybar) {
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
[data-theme='macos'] :is(.list, .card, .search, .sheet, .dialog, .toast, .chip) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
|
||||||
|
[data-theme='macos'] .page, [data-theme='macos'] #lockScreen { background: var(--wallpaper); }
|
||||||
|
[data-theme='macos'] .page-body { background: transparent; }
|
||||||
|
[data-theme='macos'] #tabs, [data-theme='macos'] .page-head { background: rgba(255, 255, 255, .42); border-color: rgba(255, 255, 255, .7); }
|
||||||
|
[data-theme='macos'] .btn { border-radius: 999px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9); }
|
||||||
|
[data-theme='macos'] .btn.primary, [data-theme='macos'] .fab { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 6px 18px rgba(0, 122, 255, .3); }
|
||||||
|
[data-theme='macos'] .fab { border-radius: 50%; }
|
||||||
|
[data-theme='macos'] .scrim { background: rgba(30, 40, 70, .18); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
|
||||||
|
[data-theme='macos'] .sheet, [data-theme='macos'] .dialog { background: rgba(255, 255, 255, .62); }
|
||||||
|
|||||||
Generated
+511
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.11.3",
|
"version": "0.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.11.3",
|
"version": "0.23.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
"electron-builder": "^26.15.3"
|
"electron-builder": "^26.15.3",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@electron-internal/extract-zip": {
|
"node_modules/@electron-internal/extract-zip": {
|
||||||
@@ -325,6 +327,448 @@
|
|||||||
"node": ">=14.14"
|
"node": ">=14.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/fs-minipass": {
|
"node_modules/@isaacs/fs-minipass": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||||
@@ -2071,6 +2515,48 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -3992,6 +4478,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
+7
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.11.3",
|
"version": "0.23.0",
|
||||||
"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",
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
},
|
},
|
||||||
|
"beforePack": "scripts/ensure-koffi.js",
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/koffi/**",
|
"node_modules/koffi/**",
|
||||||
"node_modules/@koromix/**"
|
"node_modules/@koromix/**"
|
||||||
@@ -68,12 +69,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
"electron-builder": "^26.15.3"
|
"electron-builder": "^26.15.3",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"electron@44.4.5": true,
|
"electron@44.4.5": true,
|
||||||
"electron-winstaller@5.4.0": true,
|
"electron-winstaller@5.4.0": true,
|
||||||
"ssh2@1.17.0": true,
|
"ssh2@1.17.0": true,
|
||||||
"koffi@3.3.1": true
|
"koffi@3.3.1": true,
|
||||||
|
"esbuild@0.28.2": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// electron-builder beforePack: stellt sicher, dass die native koffi-Binary der Zielplattform in node_modules liegt.
|
||||||
|
// npm installiert nur die Binary der Build-Plattform – beim Cross-Build (z. B. Windows-Paket auf Linux) fehlt sie sonst,
|
||||||
|
// und RDP im Tab fällt mit "koffi nicht verfügbar" auf ein externes Fenster zurück.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const { Arch } = require('electron-builder');
|
||||||
|
|
||||||
|
module.exports = async (context) => {
|
||||||
|
const platform = context.electronPlatformName;
|
||||||
|
const arch = Arch[context.arch];
|
||||||
|
const pkg = `@koromix/koffi-${platform}-${arch}`;
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
if (fs.existsSync(path.join(root, 'node_modules', pkg))) return;
|
||||||
|
const { version } = require(path.join(root, 'node_modules', 'koffi', 'package.json'));
|
||||||
|
console.log(` • koffi-Binary für ${platform}-${arch} fehlt – installiere ${pkg}@${version}`);
|
||||||
|
execSync(`npm install --no-save --force ${pkg}@${version}`, { cwd: root, stdio: 'inherit' });
|
||||||
|
if (!fs.existsSync(path.join(root, 'node_modules', pkg))) throw new Error(`${pkg} konnte nicht installiert werden`);
|
||||||
|
};
|
||||||
+34
-1
@@ -1,12 +1,45 @@
|
|||||||
// Erzeugt App-Icons aus assets/logo-full.png: npx electron scripts/make-icons.js
|
// Erzeugt App-Icons aus assets/logo-full.png: npx electron scripts/make-icons.js
|
||||||
|
// Der dunkle Hintergrund um das Emblem wird freigestellt (transparent), das Gesicht innerhalb der Kontur bleibt.
|
||||||
const { app, nativeImage } = require('electron');
|
const { app, nativeImage } = require('electron');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Von den Bildrändern aus fluten: Pixel ähnlich der Hintergrundfarbe werden transparent, der Kantensaum (max. 3 px)
|
||||||
|
// bekommt Transparenz aus seiner Helligkeit und wird gegen den Hintergrund "entmischt", damit kein dunkler Rand bleibt.
|
||||||
|
function cutOut(img) {
|
||||||
|
const { width: w, height: h } = img.getSize();
|
||||||
|
const src = img.toBitmap(); // BGRA
|
||||||
|
const out = Buffer.from(src);
|
||||||
|
const at = (x, y) => (y * w + x) * 4;
|
||||||
|
const bgI = at(w >> 1, 1);
|
||||||
|
const bg = [src[bgI + 2], src[bgI + 1], src[bgI]];
|
||||||
|
const seen = new Uint8Array(w * h);
|
||||||
|
const q = [];
|
||||||
|
for (let x = 0; x < w; x++) q.push([x, 0, 0], [x, h - 1, 0]);
|
||||||
|
for (let y = 0; y < h; y++) q.push([0, y, 0], [w - 1, y, 0]);
|
||||||
|
while (q.length) {
|
||||||
|
const [x, y, depth] = q.pop();
|
||||||
|
if (x < 0 || y < 0 || x >= w || y >= h || seen[y * w + x]) continue;
|
||||||
|
const i = at(x, y), r = src[i + 2], g = src[i + 1], b = src[i];
|
||||||
|
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
const bgLike = Math.hypot(r - bg[0], g - bg[1], b - bg[2]) < 34;
|
||||||
|
if (!bgLike && (lum >= 215 || depth >= 3)) continue;
|
||||||
|
seen[y * w + x] = 1;
|
||||||
|
const a = bgLike ? 0 : Math.max(0, Math.min(1, (lum - 40) / 195));
|
||||||
|
const un = (v, c) => Math.max(0, Math.min(255, Math.round((v - (1 - a) * c) / a)));
|
||||||
|
if (a <= 0) out.fill(0, i, i + 4);
|
||||||
|
else { out[i + 2] = un(r, bg[0]); out[i + 1] = un(g, bg[1]); out[i] = un(b, bg[2]); out[i + 3] = Math.round(a * 255); }
|
||||||
|
const nd = bgLike ? 0 : depth + 1;
|
||||||
|
q.push([x + 1, y, nd], [x - 1, y, nd], [x, y + 1, nd], [x, y - 1, nd]);
|
||||||
|
}
|
||||||
|
return nativeImage.createFromBitmap(out, { width: w, height: h });
|
||||||
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
const src = nativeImage.createFromPath('assets/logo-full.png');
|
const src = nativeImage.createFromPath('assets/logo-full.png');
|
||||||
const { width } = src.getSize();
|
const { width } = src.getSize();
|
||||||
const k = width / 1254;
|
const k = width / 1254;
|
||||||
// Ausschnitt: Kopf mit Hut, ohne Schriftzug
|
// Ausschnitt: Kopf mit Hut, ohne Schriftzug
|
||||||
const head = src.crop({ x: Math.round(282 * k), y: Math.round(172 * k), width: Math.round(706 * k), height: Math.round(706 * k) });
|
const head = cutOut(src.crop({ x: Math.round(282 * k), y: Math.round(172 * k), width: Math.round(706 * k), height: Math.round(706 * k) }));
|
||||||
fs.writeFileSync('build/icon.png', head.resize({ width: 512, height: 512, quality: 'best' }).toPNG());
|
fs.writeFileSync('build/icon.png', head.resize({ width: 512, height: 512, quality: 'best' }).toPNG());
|
||||||
fs.writeFileSync('src/renderer/logo.png', head.resize({ width: 96, height: 96, quality: 'best' }).toPNG());
|
fs.writeFileSync('src/renderer/logo.png', head.resize({ width: 96, height: 96, quality: 'best' }).toPNG());
|
||||||
fs.writeFileSync('assets/icon.png', head.resize({ width: 256, height: 256, quality: 'best' }).toPNG());
|
fs.writeFileSync('assets/icon.png', head.resize({ width: 256, height: 256, quality: 'best' }).toPNG());
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Gemeinsame Backend-Kanäle für die Android-App und die Web-Version (Docker).
|
||||||
|
// Der Electron-Hauptprozess (src/main/main.js) registriert seine Kanäle selbst, nutzt aber dieselben Module.
|
||||||
|
//
|
||||||
|
// createBackend({ store, send, platform, version, withSync, onLanguage })
|
||||||
|
// store Store-Instanz (bereits geladen)
|
||||||
|
// send (kanal, ...args) → Ereignis an die Oberfläche
|
||||||
|
// → { call(kanal, args), notify(kanal, args), close(), ssh, sync, handle }
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { utils: sshUtils } = require('ssh2');
|
||||||
|
const { SshManager } = require('../main/ssh');
|
||||||
|
const { DockerManager } = require('../main/docker');
|
||||||
|
const { FirewallManager } = require('../main/firewall');
|
||||||
|
const { NetworkConfigManager } = require('../main/network');
|
||||||
|
const { WebServerManager } = require('../main/webserver');
|
||||||
|
const { CertManager, probe: probeTls } = require('../main/certs');
|
||||||
|
const { SambaManager } = require('../main/samba');
|
||||||
|
const { AiAssistant } = require('../main/ai');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const backup = require('./backup');
|
||||||
|
const composeTemplates = require('../main/compose-templates');
|
||||||
|
|
||||||
|
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
|
||||||
|
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
||||||
|
const pending = new Map();
|
||||||
|
const ask = (ch, req, timeout = 0) => new Promise((resolve) => {
|
||||||
|
const reqId = crypto.randomUUID();
|
||||||
|
pending.set(reqId, resolve);
|
||||||
|
send(ch, { reqId, ...req });
|
||||||
|
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
|
||||||
|
});
|
||||||
|
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
|
||||||
|
|
||||||
|
const confirmHostKey = async (target, fingerprint, known) =>
|
||||||
|
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
|
||||||
|
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
|
||||||
|
|
||||||
|
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||||
|
const docker = new DockerManager(ssh);
|
||||||
|
const firewall = new FirewallManager(ssh);
|
||||||
|
const network = new NetworkConfigManager(ssh);
|
||||||
|
const admins = { web: new WebServerManager(ssh), certs: new CertManager(ssh), samba: new SambaManager(ssh) };
|
||||||
|
const ai = new AiAssistant(store, send);
|
||||||
|
let sync = null;
|
||||||
|
if (withSync) {
|
||||||
|
const { SyncService } = require('../main/sync');
|
||||||
|
sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
|
||||||
|
store.onChange = () => sync.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = new Map();
|
||||||
|
const handle = (ch, fn) => handlers.set(ch, fn);
|
||||||
|
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||||
|
|
||||||
|
async function call(ch, args = []) {
|
||||||
|
const fn = handlers.get(ch);
|
||||||
|
if (!fn) throw new Error(`Unknown channel ${ch}`);
|
||||||
|
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
|
||||||
|
return fn(...args);
|
||||||
|
}
|
||||||
|
const listeners = {
|
||||||
|
'ssh:write': (id, d) => ssh.write(id, d),
|
||||||
|
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
|
||||||
|
'ssh:close': (id) => ssh.close(id),
|
||||||
|
'ask:reply': answer,
|
||||||
|
// Namen der Desktop-Vorlage (preload.js)
|
||||||
|
'secret:reply': answer,
|
||||||
|
'sync:pairReply': answer,
|
||||||
|
};
|
||||||
|
const notify = (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } };
|
||||||
|
|
||||||
|
// ---------- App-Sperre (Passwort) ----------
|
||||||
|
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 lockStatus = () => {
|
||||||
|
const { language, appTheme, accent } = store.get().settings;
|
||||||
|
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, fido: [], meta: { language, appTheme, accent } };
|
||||||
|
};
|
||||||
|
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
|
||||||
|
const afterUnlock = () => { onLanguage(store.get().settings.language); 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: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);
|
||||||
|
store.lock = store.lock || { password: null, fido: [] };
|
||||||
|
store.lock.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.lock.bio = null; }
|
||||||
|
store.dropLockIfEmpty();
|
||||||
|
store.save();
|
||||||
|
return lockStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Tresor ----------
|
||||||
|
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
|
||||||
|
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform }));
|
||||||
|
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
||||||
|
handle('vault:remove', (col, id) => {
|
||||||
|
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
|
||||||
|
return store.remove(col, id);
|
||||||
|
});
|
||||||
|
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
|
||||||
|
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||||
|
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version }));
|
||||||
|
handle('backup:inspect', (content) => backup.inspectBackup(content));
|
||||||
|
handle('backup:import', (content, password, opts) => {
|
||||||
|
const r = backup.importBackup(store, content, password, opts);
|
||||||
|
if (opts?.settings) onLanguage(store.get().settings.language);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
|
||||||
|
handle('vault:bulkImport', (entries) => {
|
||||||
|
const groups = store.get().groups;
|
||||||
|
const ensure = (parts) => {
|
||||||
|
let parent = null;
|
||||||
|
for (const label of parts) {
|
||||||
|
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
|
||||||
|
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
|
||||||
|
parent = g.id;
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
};
|
||||||
|
let hosts = 0, skipped = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
const groupId = ensure(e.folder || []);
|
||||||
|
if (!e.host) continue;
|
||||||
|
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
|
||||||
|
if (dup) { skipped++; continue; }
|
||||||
|
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
|
||||||
|
hosts++;
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
return { hosts, skipped, groups: groups.length };
|
||||||
|
});
|
||||||
|
handle('app:version', () => version);
|
||||||
|
|
||||||
|
// ---------- Schlüssel ----------
|
||||||
|
handle('key:generate', ({ type, bits, comment, passphrase }) => {
|
||||||
|
const opts = { comment: comment || `mrterm@${platform}` };
|
||||||
|
if (type === 'rsa') opts.bits = Number(bits) || 4096;
|
||||||
|
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
|
||||||
|
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
|
||||||
|
return { privateKey: k.private, publicKey: k.public };
|
||||||
|
});
|
||||||
|
handle('key:parse', ({ privateKey, passphrase }) => {
|
||||||
|
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
|
||||||
|
if (k instanceof Error) throw k;
|
||||||
|
const key = Array.isArray(k) ? k[0] : k;
|
||||||
|
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- SSH-Terminal & SFTP ----------
|
||||||
|
function hostWithOverrides(ref) {
|
||||||
|
if (typeof ref === 'string') {
|
||||||
|
const h = store.resolveHost(ref);
|
||||||
|
if (!h) throw new Error(i18n.t('Host not found'));
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
handle('ssh:open', async (sessionId, ref, size) => {
|
||||||
|
const host = hostWithOverrides(ref);
|
||||||
|
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
|
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
handle('sftp:open', (id, ref) => ssh.openSftp(id, hostWithOverrides(ref)));
|
||||||
|
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||||
|
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||||
|
handle('sftp:close', (id) => ssh.close(id));
|
||||||
|
|
||||||
|
// ---------- Docker / Firewall / Netzwerk ----------
|
||||||
|
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => 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));
|
||||||
|
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
|
||||||
|
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
|
||||||
|
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
|
||||||
|
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
|
||||||
|
handle('docker:templates', (url) => composeTemplates.list(url));
|
||||||
|
handle('docker:template', (index, url) => composeTemplates.build(index, url));
|
||||||
|
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => 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));
|
||||||
|
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => 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));
|
||||||
|
for (const [p, m] of Object.entries(admins)) {
|
||||||
|
handle(`${p}:open`, (id, ref) => m.open(id, hostWithOverrides(ref), () => send(`${p}:closed`, id)));
|
||||||
|
handle(`${p}:read`, (id) => m.read(id));
|
||||||
|
handle(`${p}:op`, (id, op, ...args) => m.op(id, op, ...args));
|
||||||
|
handle(`${p}:close`, (id) => m.close(id));
|
||||||
|
}
|
||||||
|
handle('certs:probe', (host, port) => probeTls(host, port));
|
||||||
|
|
||||||
|
// ---------- Support-Assistent (Ollama) ----------
|
||||||
|
handle('ai:models', (url) => ai.models(url));
|
||||||
|
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
|
||||||
|
handle('ai:stop', (id) => ai.stop(id));
|
||||||
|
|
||||||
|
// ---------- Synchronisation ----------
|
||||||
|
if (sync) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
ssh.closeAll?.();
|
||||||
|
ai.stopAll();
|
||||||
|
sync?.stop();
|
||||||
|
for (const r of pending.values()) r(null);
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { call, notify, close, handle, ssh, sync, store, kdf, requireUnlocked, lockStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createBackend };
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Vollständiges Backup (Desktop, Web, Android): alle Collections, bekannte Hosts, Verlauf und Einstellungen.
|
||||||
|
// Optional mit Passwort verschlüsselt: scrypt (N=2^16, r=8, p=1) → AES-256-GCM.
|
||||||
|
// Gerätebezogenes (LAN-Sync-Kopplungen, Löschvermerke, App-Sperre) wird nicht exportiert.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const FORMAT = 'mrterm-backup';
|
||||||
|
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
|
||||||
|
const KDF = { N: 1 << 16, r: 8, p: 1 };
|
||||||
|
const scrypt = (password, salt, k) => crypto.scryptSync(String(password).normalize('NFC'), salt, 32, { ...k, maxmem: 256 * 1024 * 1024 });
|
||||||
|
|
||||||
|
function snapshot(store, { settings = true } = {}) {
|
||||||
|
const d = store.get();
|
||||||
|
const out = { knownHosts: d.knownHosts || {}, history: d.history || [] };
|
||||||
|
for (const c of COLLECTIONS) out[c] = d[c] || [];
|
||||||
|
if (settings) out.settings = d.settings;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert den Dateiinhalt (JSON-Text)
|
||||||
|
function createBackup(store, { password = '', settings = true, version = '' } = {}) {
|
||||||
|
const data = snapshot(store, { settings });
|
||||||
|
const head = { format: FORMAT, version: 2, app: version, createdAt: new Date().toISOString() };
|
||||||
|
if (!password) return JSON.stringify({ ...head, encrypted: false, data }, null, 2);
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const key = scrypt(password, salt, KDF);
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
c.setAAD(Buffer.from(FORMAT));
|
||||||
|
const ct = Buffer.concat([c.update(JSON.stringify(data)), c.final()]);
|
||||||
|
return JSON.stringify({
|
||||||
|
...head, encrypted: true,
|
||||||
|
kdf: { name: 'scrypt', salt: salt.toString('base64'), ...KDF },
|
||||||
|
cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64') },
|
||||||
|
ct: ct.toString('base64'),
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(content) {
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(String(content).replace(/^/, '')); } catch { throw new Error('INVALID'); }
|
||||||
|
if (!j || typeof j !== 'object') throw new Error('INVALID');
|
||||||
|
// Altes Format (bis 0.14): unverschlüsseltes JSON mit den Collections auf oberster Ebene
|
||||||
|
if (j.format !== FORMAT) {
|
||||||
|
if (!COLLECTIONS.some((c) => Array.isArray(j[c]))) throw new Error('INVALID');
|
||||||
|
return { encrypted: false, data: j, createdAt: null, app: '' };
|
||||||
|
}
|
||||||
|
return { encrypted: !!j.encrypted, raw: j, data: j.encrypted ? null : j.data, createdAt: j.createdAt, app: j.app };
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = (data) => Object.fromEntries([...COLLECTIONS.map((c) => [c, (data[c] || []).length]), ['knownHosts', Object.keys(data.knownHosts || {}).length], ['settings', data.settings ? 1 : 0]]);
|
||||||
|
|
||||||
|
// Vorabinfo für die Oberfläche (ohne Passwort)
|
||||||
|
function inspectBackup(content) {
|
||||||
|
const b = parse(content);
|
||||||
|
return { encrypted: b.encrypted, createdAt: b.createdAt, app: b.app, counts: b.data ? counts(b.data) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(content, password) {
|
||||||
|
const b = parse(content);
|
||||||
|
if (!b.encrypted) return b.data;
|
||||||
|
if (!password) throw new Error('PASSWORD_REQUIRED');
|
||||||
|
const { kdf, cipher, ct } = b.raw;
|
||||||
|
const key = scrypt(password, Buffer.from(kdf.salt, 'base64'), { N: kdf.N, r: kdf.r, p: kdf.p });
|
||||||
|
try {
|
||||||
|
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'base64'));
|
||||||
|
d.setAAD(Buffer.from(FORMAT));
|
||||||
|
d.setAuthTag(Buffer.from(cipher.tag, 'base64'));
|
||||||
|
return JSON.parse(Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8'));
|
||||||
|
} catch { throw new Error('WRONG_PASSWORD'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// opts: { replace: vorhandene Einträge entfernen, die nicht im Backup sind; settings: Einstellungen übernehmen }
|
||||||
|
function importBackup(store, content, password, { replace = false, settings = false } = {}) {
|
||||||
|
const data = decrypt(content, password);
|
||||||
|
const d = store.get();
|
||||||
|
store.muted = true;
|
||||||
|
try {
|
||||||
|
for (const c of COLLECTIONS) {
|
||||||
|
const items = Array.isArray(data[c]) ? data[c].filter((x) => x && typeof x === 'object') : [];
|
||||||
|
if (replace) {
|
||||||
|
const keep = new Set(items.map((x) => x.id).filter(Boolean));
|
||||||
|
for (const x of [...(d[c] || [])]) if (!keep.has(x.id)) store.remove(c, x.id);
|
||||||
|
}
|
||||||
|
for (const item of items) store.upsert(c, { ...item });
|
||||||
|
}
|
||||||
|
if (data.knownHosts && typeof data.knownHosts === 'object') d.knownHosts = replace ? { ...data.knownHosts } : { ...d.knownHosts, ...data.knownHosts };
|
||||||
|
if (Array.isArray(data.history)) {
|
||||||
|
const seen = new Set();
|
||||||
|
d.history = [...(replace ? [] : d.history || []), ...data.history].filter((h) => h && !seen.has(h.hostId) && seen.add(h.hostId)).slice(0, 30);
|
||||||
|
}
|
||||||
|
if (settings && data.settings && typeof data.settings === 'object') d.settings = { ...d.settings, ...data.settings };
|
||||||
|
} finally {
|
||||||
|
store.muted = false;
|
||||||
|
}
|
||||||
|
store.save();
|
||||||
|
return counts(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createBackup, inspectBackup, importBackup };
|
||||||
+500
-1
@@ -151,6 +151,42 @@
|
|||||||
'Send Enter automatically': 'Enter automatisch senden',
|
'Send Enter automatically': 'Enter automatisch senden',
|
||||||
'Command is missing.': 'Befehl fehlt.',
|
'Command is missing.': 'Befehl fehlt.',
|
||||||
'No open terminal': 'Kein offenes Terminal',
|
'No open terminal': 'Kein offenes Terminal',
|
||||||
|
'Upload files …': 'Dateien hochladen …',
|
||||||
|
'Root privileges are required. Log in as root or enter the password of a user with sudo rights.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder gib das Passwort eines Benutzers mit sudo-Rechten ein.',
|
||||||
|
'sudo password': 'sudo-Passwort',
|
||||||
|
'sudo password for {user}': 'sudo-Passwort für {user}',
|
||||||
|
'Wrong password. sudo password for {user}': 'Falsches Passwort. sudo-Passwort für {user}',
|
||||||
|
'Open in new window': 'In neuem Fenster öffnen',
|
||||||
|
'Back to main window': 'Zurück ins Hauptfenster',
|
||||||
|
// Support-Assistent
|
||||||
|
'Delete chat': 'Chat löschen',
|
||||||
|
'Delete chat?': 'Chat löschen?',
|
||||||
|
'Saved chats': 'Gespeicherte Chats',
|
||||||
|
'Context:': 'Kontext:',
|
||||||
|
'Vault (no context)': 'Tresor (kein Kontext)',
|
||||||
|
'Explain this tab': 'Diesen Tab erklären',
|
||||||
|
'Look at the active tab, explain what you see and point out problems.': 'Sieh dir den aktiven Tab an, erkläre, was du siehst, und weise auf Probleme hin.',
|
||||||
|
'The content of the active tab is sent to the Ollama server.': 'Der Inhalt des aktiven Tabs wird an den Ollama-Server gesendet.',
|
||||||
|
'Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.': 'Frag nach einem Fehler oder lass dir erklären, was du siehst. Der Inhalt des aktiven Tabs wird als Kontext mitgesendet.',
|
||||||
|
'Copied – no terminal tab is active': 'Kopiert – kein Terminal-Tab aktiv',
|
||||||
|
'Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.': 'Hilft bei Fehlern in Linux-Konsole, Docker, Firewall und Netzwerk über eine lokale <b>Ollama</b>-Instanz. Öffnen über den Chat-Button in der Titelleiste oder im Tab. Der Inhalt des aktiven Tabs wird als Kontext an den Ollama-Server gesendet. Chats werden im Tresor gespeichert.',
|
||||||
|
'Support assistant (Ollama)': 'Support-Assistent (Ollama)',
|
||||||
|
'Assistant': 'Assistent',
|
||||||
|
'Support assistant': 'Support-Assistent',
|
||||||
|
'New chat': 'Neuer Chat',
|
||||||
|
'Include terminal output': 'Terminalausgabe mitsenden',
|
||||||
|
'Describe the problem … (Enter = send)': 'Problem beschreiben … (Enter = senden)',
|
||||||
|
'Explain last error': 'Letzten Fehler erklären',
|
||||||
|
'Send': 'Senden',
|
||||||
|
'Explain the last error in the terminal output and how to fix it.': 'Erkläre den letzten Fehler in der Terminalausgabe und wie ich ihn behebe.',
|
||||||
|
'Please select an Ollama model in the settings first.': 'Bitte zuerst in den Einstellungen ein Ollama-Modell auswählen.',
|
||||||
|
'Insert into terminal (not executed)': 'Ins Terminal einfügen (wird nicht ausgeführt)',
|
||||||
|
'Insert': 'Einfügen',
|
||||||
|
'Ollama server': 'Ollama-Server',
|
||||||
|
'Model': 'Modell',
|
||||||
|
'– select –': '– auswählen –',
|
||||||
|
'No models installed (ollama pull …)': 'Keine Modelle installiert (ollama pull …)',
|
||||||
|
'Load models': 'Modelle laden',
|
||||||
'No snippets yet.': 'Noch keine Snippets.',
|
'No snippets yet.': 'Noch keine Snippets.',
|
||||||
|
|
||||||
// Port Forwarding
|
// Port Forwarding
|
||||||
@@ -277,6 +313,7 @@
|
|||||||
'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.',
|
'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.',
|
||||||
'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
|
'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
|
||||||
'RDP session ended.': 'RDP-Sitzung beendet.',
|
'RDP session ended.': 'RDP-Sitzung beendet.',
|
||||||
|
'Adjusting resolution …': 'Auflösung wird angepasst …',
|
||||||
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
|
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
|
||||||
'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter',
|
'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter',
|
||||||
|
|
||||||
@@ -351,6 +388,93 @@
|
|||||||
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
'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.',
|
'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.',
|
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||||
|
'This file is not a MrTerm backup.': 'Diese Datei ist kein MrTerm-Backup.',
|
||||||
|
'This backup is encrypted. Please enter the password.': 'Dieses Backup ist verschlüsselt. Bitte gib das Passwort ein.',
|
||||||
|
'Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.': 'Exportiert alle Hosts, Gruppen, SSH-Keys, Passwörter, Snippets, Portweiterleitungen, VPNs, bekannten Hosts und Einstellungen.',
|
||||||
|
'Recommended. Without a password, keys and passwords are stored in plain text in the file.': 'Empfohlen. Ohne Passwort stehen Schlüssel und Passwörter im Klartext in der Datei.',
|
||||||
|
'Include settings': 'Einstellungen einschließen',
|
||||||
|
'Export': 'Exportieren',
|
||||||
|
'Export without password?': 'Ohne Passwort exportieren?',
|
||||||
|
'Private keys and passwords will be readable by anyone who gets the file.': 'Private Schlüssel und Passwörter sind dann für jeden lesbar, der die Datei erhält.',
|
||||||
|
'{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs': '{hosts} Hosts, {keys} Schlüssel, {snippets} Snippets, {vpns} VPNs',
|
||||||
|
'Contents are encrypted.': 'Inhalt ist verschlüsselt.',
|
||||||
|
'Import settings': 'Einstellungen übernehmen',
|
||||||
|
'Replace existing data (entries not contained in the backup are deleted)': 'Vorhandene Daten ersetzen (Einträge, die nicht im Backup sind, werden gelöscht)',
|
||||||
|
'Replace existing data?': 'Vorhandene Daten ersetzen?',
|
||||||
|
'Hosts, keys, snippets and other entries that are not in the backup will be deleted.': 'Hosts, Schlüssel, Snippets und andere Einträge, die nicht im Backup sind, werden gelöscht.',
|
||||||
|
'Replace': 'Ersetzen',
|
||||||
|
'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets',
|
||||||
|
'Stacks folder': 'Stack-Ordner',
|
||||||
|
'Folder on the host in which each stack gets its own subfolder': 'Ordner auf dem Host, in dem jeder Stack einen eigenen Unterordner bekommt',
|
||||||
|
'Template source': 'Vorlagen-Quelle',
|
||||||
|
'URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.': 'URL einer templates.json im Portainer-Format. Leer = Lissy93/portainer-templates.',
|
||||||
|
'Containers': 'Container',
|
||||||
|
'Folder': 'Ordner',
|
||||||
|
'Change folder': 'Ordner ändern',
|
||||||
|
'Creates the folder with sudo and makes it writable for {user}.': 'Legt den Ordner per sudo an und macht ihn für {user} beschreibbar.',
|
||||||
|
'Set up folder': 'Ordner einrichten',
|
||||||
|
'From Git': 'Aus Git',
|
||||||
|
'New stack': 'Neuer Stack',
|
||||||
|
'Compose file': 'Compose-Datei',
|
||||||
|
'Found via docker compose ls, outside the stacks folder': 'Über docker compose ls gefunden, außerhalb des Stack-Ordners',
|
||||||
|
'external': 'extern',
|
||||||
|
'not deployed': 'nicht gestartet',
|
||||||
|
'no compose file found': 'keine Compose-Datei gefunden',
|
||||||
|
'Deploy (up -d)': 'Starten (up -d)',
|
||||||
|
'No matches.': 'Keine Treffer.',
|
||||||
|
'No stacks yet. Create one, clone it from Git or start from a template.': 'Noch keine Stacks. Lege einen an, klone ihn aus Git oder starte mit einer Vorlage.',
|
||||||
|
'Update images (pull + up)': 'Images aktualisieren (pull + up)',
|
||||||
|
'Git pull + redeploy': 'Git pull + neu starten',
|
||||||
|
'Down (remove containers)': 'Down (Container entfernen)',
|
||||||
|
'Stack from Git': 'Stack aus Git',
|
||||||
|
'Repository URL': 'Repository-URL',
|
||||||
|
'Stack name': 'Stack-Name',
|
||||||
|
'Branch (optional)': 'Branch (optional)',
|
||||||
|
'Compose file in the repository (optional)': 'Compose-Datei im Repository (optional)',
|
||||||
|
'If empty, the first compose file found is used.': 'Leer = die erste gefundene Compose-Datei wird verwendet.',
|
||||||
|
'Deploy right after cloning': 'Nach dem Klonen direkt starten',
|
||||||
|
'The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.': 'Das Repository wird auf dem Host in den Stack-Ordner geklont. Git fragt Zugangsdaten bei Bedarf im Terminal ab; für private Repos funktioniert auch ein SSH-Deploy-Key oder ein Token in der URL.',
|
||||||
|
'Clone': 'Klonen',
|
||||||
|
'App templates': 'App-Vorlagen',
|
||||||
|
'Search templates …': 'Vorlagen durchsuchen …',
|
||||||
|
'Source: {url} (Portainer template format, changeable in the settings).': 'Quelle: {url} (Portainer-Vorlagenformat, in den Einstellungen änderbar).',
|
||||||
|
'All categories': 'Alle Kategorien',
|
||||||
|
'Delete stack?': 'Stack löschen?',
|
||||||
|
'The stack “{name}” is stopped (down) and its folder {dir} is deleted.': 'Der Stack „{name}“ wird gestoppt (down) und sein Ordner {dir} gelöscht.',
|
||||||
|
'Also delete named volumes (data!)': 'Auch benannte Volumes löschen (Daten!)',
|
||||||
|
'Remove containers?': 'Container entfernen?',
|
||||||
|
'docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.': 'docker compose down stoppt und entfernt die Container von „{name}“. Volumes und Dateien bleiben erhalten.',
|
||||||
|
'Down': 'Down',
|
||||||
|
'Back': 'Zurück',
|
||||||
|
'Save & deploy': 'Speichern & starten',
|
||||||
|
'(variables, used as ${NAME} in the compose file)': '(Variablen, in der Compose-Datei als ${NAME} nutzbar)',
|
||||||
|
'Saved, but the compose file has an error: {msg}': 'Gespeichert, aber die Compose-Datei enthält einen Fehler: {msg}',
|
||||||
|
'Docker Compose was not found on this host (docker compose plugin or docker-compose).': 'Docker Compose wurde auf diesem Host nicht gefunden (docker-compose-Plugin oder docker-compose).',
|
||||||
|
'Could not read {file}': '{file} konnte nicht gelesen werden',
|
||||||
|
'Stack names may only contain lowercase letters, digits, - and _.': 'Stack-Namen dürfen nur Kleinbuchstaben, Ziffern, - und _ enthalten.',
|
||||||
|
'A stack named “{name}” already exists.': 'Ein Stack namens „{name}“ existiert bereits.',
|
||||||
|
'No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.': 'Keine Schreibrechte in {dir}. Nutze „Ordner einrichten“ oder speichere das Host-Passwort, damit MrTerm sudo verwenden kann.',
|
||||||
|
'Templates': 'Vorlagen',
|
||||||
|
'Run': 'Ausführen',
|
||||||
|
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
|
||||||
|
'Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.': 'Trägt einen öffentlichen Schlüssel aus dem Schlüsselbund in ~/.ssh/authorized_keys des Benutzers ein.',
|
||||||
|
'Key': 'Schlüssel',
|
||||||
|
'Public key is missing.': 'Öffentlicher Schlüssel fehlt.',
|
||||||
|
'User is missing.': 'Benutzer fehlt.',
|
||||||
|
'Install QEMU guest agent': 'QEMU Guest Agent installieren',
|
||||||
|
'Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).': 'Installiert und startet qemu-guest-agent (apt, dnf, yum, pacman, zypper oder apk).',
|
||||||
|
'Use APT cache server': 'APT-Cache-Server verwenden',
|
||||||
|
'Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).': 'Leitet APT-Downloads über einen Cache-Proxy wie apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).',
|
||||||
|
'Proxy URL': 'Proxy-URL',
|
||||||
|
'Invalid URL.': 'Ungültige URL.',
|
||||||
|
'Remove APT cache server': 'APT-Cache-Server entfernen',
|
||||||
|
'Removes the APT proxy setting again.': 'Entfernt die APT-Proxy-Einstellung wieder.',
|
||||||
|
'Updates all packages with the system package manager.': 'Aktualisiert alle Pakete mit dem Paketmanager des Systems.',
|
||||||
|
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
|
||||||
|
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
|
||||||
|
'Wrong PIN.': 'Falsche PIN.',
|
||||||
|
'{n} attempts left.': 'Noch {n} Versuche.',
|
||||||
|
'The PIN of this security key is blocked. Remove and reinsert the key, or reset it.': 'Die PIN dieses Sicherheitsschlüssels ist gesperrt. Ziehe den Schlüssel ab und stecke ihn neu ein oder setze ihn zurück.',
|
||||||
|
|
||||||
// VPN
|
// VPN
|
||||||
'— No VPN —': '— Kein VPN —',
|
'— No VPN —': '— Kein VPN —',
|
||||||
@@ -461,7 +585,6 @@
|
|||||||
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
|
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
|
||||||
'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
|
'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
|
||||||
'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}',
|
'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.',
|
'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.',
|
'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).',
|
'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).',
|
||||||
@@ -650,6 +773,38 @@
|
|||||||
'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.',
|
'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.',
|
||||||
'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.',
|
'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.',
|
||||||
'Set a password first.': 'Lege zuerst ein Passwort fest.',
|
'Set a password first.': 'Lege zuerst ein Passwort fest.',
|
||||||
|
// Web-Version
|
||||||
|
'RDP is not available in the web version.': 'RDP ist in der Web-Version nicht verfügbar.',
|
||||||
|
'Account': 'Konto',
|
||||||
|
'Signed in as {name}': 'Angemeldet als {name}',
|
||||||
|
'Administrator': 'Administrator',
|
||||||
|
'Current password': 'Aktuelles Passwort',
|
||||||
|
'Sign out': 'Abmelden',
|
||||||
|
'Users': 'Benutzer',
|
||||||
|
'User': 'Benutzer',
|
||||||
|
'Delete user?': 'Benutzer löschen?',
|
||||||
|
'“{name}” and their vault will be permanently deleted.': '„{name}“ und der zugehörige Tresor werden endgültig gelöscht.',
|
||||||
|
'Add user': 'Benutzer hinzufügen',
|
||||||
|
'Your vault is stored on the server, encrypted with a key that only your login password can unlock.': 'Dein Tresor liegt auf dem Server, verschlüsselt mit einem Schlüssel, den nur dein Login-Passwort entsperren kann.',
|
||||||
|
'Folders cannot be downloaded in the browser: {name}': 'Ordner können im Browser nicht heruntergeladen werden: {name}',
|
||||||
|
'Welcome! Create the administrator account for this MrTerm server.': 'Willkommen! Lege das Administrator-Konto für diesen MrTerm-Server an.',
|
||||||
|
'Sign in to your MrTerm server.': 'Melde dich an deinem MrTerm-Server an.',
|
||||||
|
'Create account': 'Konto anlegen',
|
||||||
|
'Sign in': 'Anmelden',
|
||||||
|
'Administrators only.': 'Nur für Administratoren.',
|
||||||
|
'Forbidden': 'Nicht erlaubt',
|
||||||
|
'Not signed in': 'Nicht angemeldet',
|
||||||
|
'Setup is already complete.': 'Die Einrichtung ist bereits abgeschlossen.',
|
||||||
|
'The current password is wrong.': 'Das aktuelle Passwort ist falsch.',
|
||||||
|
'The last administrator cannot be deleted.': 'Der letzte Administrator kann nicht gelöscht werden.',
|
||||||
|
'Too many failed attempts. Try again in 15 minutes.': 'Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.',
|
||||||
|
'User not found.': 'Benutzer nicht gefunden.',
|
||||||
|
'Wrong username or password.': 'Benutzername oder Passwort falsch.',
|
||||||
|
'You cannot delete yourself.': 'Du kannst dich nicht selbst löschen.',
|
||||||
|
'The password must be at least 8 characters long.': 'Das Passwort muss mindestens 8 Zeichen lang sein.',
|
||||||
|
'This username is already taken.': 'Dieser Benutzername ist bereits vergeben.',
|
||||||
|
'Usernames may contain letters, digits and . _ @ - (max. 64).': 'Benutzernamen dürfen Buchstaben, Ziffern und . _ @ - enthalten (max. 64).',
|
||||||
|
'Connection lost – reconnecting …': 'Verbindung verloren – verbinde neu …',
|
||||||
// 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',
|
||||||
@@ -661,6 +816,350 @@
|
|||||||
'~/.ssh/config not found': '~/.ssh/config nicht gefunden',
|
'~/.ssh/config not found': '~/.ssh/config nicht gefunden',
|
||||||
'Host not found': 'Host nicht gefunden',
|
'Host not found': 'Host nicht gefunden',
|
||||||
'Rule not found': 'Regel nicht gefunden',
|
'Rule not found': 'Regel nicht gefunden',
|
||||||
|
|
||||||
|
// Feature-Menüs & Verwaltungs-Tabs (gemeinsam)
|
||||||
|
'Web server': 'Webserver',
|
||||||
|
'Certificates': 'Zertifikate',
|
||||||
|
'Feature menus': 'Feature-Menüs',
|
||||||
|
'Choose which management tabs are offered for SSH hosts (host menu, terminal toolbar and quick search).': 'Lege fest, welche Verwaltungs-Tabs für SSH-Hosts angeboten werden (Host-Menü, Terminal-Leiste und Schnellsuche).',
|
||||||
|
'Animated background on the start page': 'Animierter Hintergrund auf der Startseite',
|
||||||
|
'failed': 'fehlgeschlagen',
|
||||||
|
'starting': 'startet',
|
||||||
|
'unknown': 'unbekannt',
|
||||||
|
'expired': 'abgelaufen',
|
||||||
|
'{n} days': '{n} Tage',
|
||||||
|
'Stop {name}?': '{name} stoppen?',
|
||||||
|
'The service will stop serving requests.': 'Der Dienst beantwortet dann keine Anfragen mehr.',
|
||||||
|
'{name} stopped': '{name} gestoppt',
|
||||||
|
'{name} started': '{name} gestartet',
|
||||||
|
'{name} restarted': '{name} neu gestartet',
|
||||||
|
'Reload configuration': 'Konfiguration neu laden',
|
||||||
|
'Configuration reloaded': 'Konfiguration neu geladen',
|
||||||
|
'Start automatically at boot': 'Beim Systemstart automatisch starten',
|
||||||
|
'Autostart': 'Autostart',
|
||||||
|
'Save & apply': 'Speichern & anwenden',
|
||||||
|
'Saved and applied.': 'Gespeichert und angewendet.',
|
||||||
|
'not installed': 'nicht installiert',
|
||||||
|
'Install': 'Installieren',
|
||||||
|
'Service': 'Dienst',
|
||||||
|
'Services': 'Dienste',
|
||||||
|
'Path': 'Pfad',
|
||||||
|
'File': 'Datei',
|
||||||
|
'Description': 'Beschreibung',
|
||||||
|
'Create': 'Erstellen',
|
||||||
|
'Details': 'Details',
|
||||||
|
'Loading …': 'Lade …',
|
||||||
|
'Session not found': 'Sitzung nicht gefunden',
|
||||||
|
'The configuration is invalid, the previous version was restored: {err}': 'Die Konfiguration ist ungültig, der vorherige Stand wurde wiederhergestellt: {err}',
|
||||||
|
'Saved, but reloading failed: {err}': 'Gespeichert, aber das Neuladen ist fehlgeschlagen: {err}',
|
||||||
|
'The configuration is invalid: {err}': 'Die Konfiguration ist ungültig: {err}',
|
||||||
|
'No known package manager on this host.': 'Auf diesem Host wurde keine bekannte Paketverwaltung gefunden.',
|
||||||
|
'Please enter at least one domain.': 'Bitte mindestens eine Domain eingeben.',
|
||||||
|
'Please enter a password.': 'Bitte ein Passwort eingeben.',
|
||||||
|
'Timeout': 'Zeitüberschreitung',
|
||||||
|
|
||||||
|
// Webserver
|
||||||
|
'Overview': 'Übersicht',
|
||||||
|
'Sites': 'Sites',
|
||||||
|
'Site': 'Site',
|
||||||
|
'Server': 'Server',
|
||||||
|
'Domains': 'Domains',
|
||||||
|
'Test configuration': 'Konfiguration testen',
|
||||||
|
'Configuration OK': 'Konfiguration in Ordnung',
|
||||||
|
'Configuration has errors': 'Konfiguration fehlerhaft',
|
||||||
|
'Several web servers are running ({list}). Only one of them can use port 80/443 – make sure they listen on different ports.': 'Es laufen mehrere Webserver ({list}). Nur einer davon kann Port 80/443 nutzen – achte darauf, dass sie auf unterschiedlichen Ports lauschen.',
|
||||||
|
'Install PHP-FPM': 'PHP-FPM installieren',
|
||||||
|
'No PHP-FPM service found.': 'Kein PHP-FPM-Dienst gefunden.',
|
||||||
|
'Sockets': 'Sockets',
|
||||||
|
'New site': 'Neue Site',
|
||||||
|
'No web server installed. Install nginx, Apache or Caddy in the overview.': 'Kein Webserver installiert. Installiere nginx, Apache oder Caddy in der Übersicht.',
|
||||||
|
'No sites configured yet.': 'Noch keine Sites eingerichtet.',
|
||||||
|
'Site disabled': 'Site deaktiviert',
|
||||||
|
'Site enabled': 'Site aktiviert',
|
||||||
|
'Delete site?': 'Site löschen?',
|
||||||
|
'The block “{name}” will be removed from the Caddyfile.': 'Der Block „{name}“ wird aus dem Caddyfile entfernt.',
|
||||||
|
'{path} will be deleted (a copy is kept in /var/backups/mrterm).': '{path} wird gelöscht (eine Kopie bleibt in /var/backups/mrterm).',
|
||||||
|
'Site deleted': 'Site gelöscht',
|
||||||
|
'Every change is checked with the configuration test of the web server before reloading. If the test fails, the previous state is restored.': 'Jede Änderung wird vor dem Neuladen mit dem Konfigurationstest des Webservers geprüft. Schlägt der Test fehl, wird der vorherige Stand wiederhergestellt.',
|
||||||
|
'Configuration files': 'Konfigurationsdateien',
|
||||||
|
'No configuration files found.': 'Keine Konfigurationsdateien gefunden.',
|
||||||
|
'Saving checks the configuration and reloads the service. If the check fails, the previous version is restored.': 'Beim Speichern wird die Konfiguration geprüft und der Dienst neu geladen. Schlägt die Prüfung fehl, wird der vorherige Stand wiederhergestellt.',
|
||||||
|
'Static website': 'Statische Website',
|
||||||
|
'Reverse proxy': 'Reverse Proxy',
|
||||||
|
'Name of the configuration file': 'Name der Konfigurationsdatei',
|
||||||
|
'optional – default: first domain': 'optional – Standard: erste Domain',
|
||||||
|
'Document root': 'Dokumentenverzeichnis',
|
||||||
|
'Target (upstream)': 'Ziel (Upstream)',
|
||||||
|
'PHP-FPM socket': 'PHP-FPM-Socket',
|
||||||
|
'Create the folder with a placeholder page if it does not exist': 'Ordner mit Platzhalterseite anlegen, falls er fehlt',
|
||||||
|
'Caddy requests HTTPS certificates for public domains automatically.': 'Caddy holt sich für öffentliche Domains automatisch HTTPS-Zertifikate.',
|
||||||
|
'Site created': 'Site angelegt',
|
||||||
|
'Tip: request an HTTPS certificate in the “Certificates” tab.': 'Tipp: Ein HTTPS-Zertifikat bekommst du im Tab „Zertifikate“.',
|
||||||
|
'A site named “{name}” already exists.': 'Eine Site namens „{name}“ gibt es bereits.',
|
||||||
|
'Site not found': 'Site nicht gefunden',
|
||||||
|
|
||||||
|
// Zertifikate
|
||||||
|
'Check endpoint': 'Endpunkt prüfen',
|
||||||
|
'Automatic renewal': 'Automatische Erneuerung',
|
||||||
|
'not set up': 'nicht eingerichtet',
|
||||||
|
'Automatic renewal enabled': 'Automatische Erneuerung aktiviert',
|
||||||
|
'Request certificate': 'Zertifikat anfordern',
|
||||||
|
'Renew all': 'Alle erneuern',
|
||||||
|
'Renew certificates': 'Zertifikate erneuern',
|
||||||
|
'Test renewal': 'Erneuerung testen',
|
||||||
|
'Install certbot': 'certbot installieren',
|
||||||
|
'Self-signed …': 'Selbstsigniert …',
|
||||||
|
"certbot is not installed. Install it to request free certificates from Let's Encrypt.": 'certbot ist nicht installiert. Installiere es, um kostenlose Zertifikate von Let’s Encrypt anzufordern.',
|
||||||
|
'No certificates yet.': 'Noch keine Zertifikate.',
|
||||||
|
'Expires': 'Läuft ab',
|
||||||
|
'Renew': 'Erneuern',
|
||||||
|
'Delete certificate?': 'Zertifikat löschen?',
|
||||||
|
'“{name}” and its files will be deleted. Web server configurations that still use it will fail.': '„{name}“ und seine Dateien werden gelöscht. Webserver-Konfigurationen, die es noch verwenden, schlagen dann fehl.',
|
||||||
|
'Certificate deleted': 'Zertifikat gelöscht',
|
||||||
|
'Next renewal': 'Nächste Erneuerung',
|
||||||
|
'Remove certificate?': 'Zertifikat entfernen?',
|
||||||
|
'acme.sh will no longer renew “{name}”.': 'acme.sh erneuert „{name}“ dann nicht mehr.',
|
||||||
|
'Removed': 'Entfernt',
|
||||||
|
'Certificate files': 'Zertifikatsdateien',
|
||||||
|
'No other certificate files found (searched /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).': 'Keine weiteren Zertifikatsdateien gefunden (durchsucht: /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).',
|
||||||
|
'nginx (configures HTTPS automatically)': 'nginx (richtet HTTPS automatisch ein)',
|
||||||
|
'Apache (configures HTTPS automatically)': 'Apache (richtet HTTPS automatisch ein)',
|
||||||
|
'Webroot (running web server, files in .well-known)': 'Webroot (laufender Webserver, Dateien in .well-known)',
|
||||||
|
'Standalone (port 80 must be free)': 'Standalone (Port 80 muss frei sein)',
|
||||||
|
'DNS challenge (manual TXT record, also for wildcards)': 'DNS-Challenge (TXT-Eintrag von Hand, auch für Wildcards)',
|
||||||
|
'Webroot': 'Webroot',
|
||||||
|
'E-mail for expiry notices': 'E-Mail für Ablaufhinweise',
|
||||||
|
'Key type': 'Schlüsseltyp',
|
||||||
|
'Certificate name': 'Zertifikatsname',
|
||||||
|
'Redirect HTTP to HTTPS': 'HTTP auf HTTPS umleiten',
|
||||||
|
"Test certificate (Let's Encrypt staging, no rate limits)": 'Testzertifikat (Let’s Encrypt Staging, ohne Ratenlimits)',
|
||||||
|
'certbot runs in a terminal tab so you can follow the output and answer questions.': 'certbot läuft in einem Terminal-Tab, damit du die Ausgabe verfolgen und Rückfragen beantworten kannst.',
|
||||||
|
'Request': 'Anfordern',
|
||||||
|
'Self-signed certificate': 'Selbstsigniertes Zertifikat',
|
||||||
|
'Valid (days)': 'Gültig (Tage)',
|
||||||
|
'Common name (CN)': 'Common Name (CN)',
|
||||||
|
'Additional names / IPs': 'Weitere Namen / IPs',
|
||||||
|
'Stored in /etc/ssl/mrterm/<name>.crt and .key. Browsers show a warning for self-signed certificates.': 'Wird unter /etc/ssl/mrterm/<name>.crt und .key abgelegt. Browser zeigen bei selbstsignierten Zertifikaten eine Warnung.',
|
||||||
|
'Created: {path}': 'Erstellt: {path}',
|
||||||
|
'Check TLS endpoint': 'TLS-Endpunkt prüfen',
|
||||||
|
'Host': 'Host',
|
||||||
|
'Check': 'Prüfen',
|
||||||
|
'trusted': 'vertrauenswürdig',
|
||||||
|
'not trusted': 'nicht vertrauenswürdig',
|
||||||
|
'Issuer': 'Aussteller',
|
||||||
|
'Subject': 'Inhaber',
|
||||||
|
'Names': 'Namen',
|
||||||
|
'The connection is made from this computer – useful to check which certificate a web server actually delivers.': 'Die Verbindung wird von diesem Rechner aus aufgebaut – praktisch, um zu prüfen, welches Zertifikat ein Webserver tatsächlich ausliefert.',
|
||||||
|
'Wildcard certificates need the DNS challenge.': 'Wildcard-Zertifikate benötigen die DNS-Challenge.',
|
||||||
|
|
||||||
|
// Samba / AD
|
||||||
|
'Shares': 'Freigaben',
|
||||||
|
'Samba is not installed': 'Samba ist nicht installiert',
|
||||||
|
'Install Samba to share folders with Windows clients or to run a domain controller.': 'Installiere Samba, um Ordner für Windows-Clients freizugeben oder einen Domänencontroller zu betreiben.',
|
||||||
|
'Install Samba': 'Samba installieren',
|
||||||
|
'Server role': 'Serverrolle',
|
||||||
|
'Workgroup': 'Arbeitsgruppe',
|
||||||
|
'Edit smb.conf': 'smb.conf bearbeiten',
|
||||||
|
'New share': 'Neue Freigabe',
|
||||||
|
'No shares yet.': 'Noch keine Freigaben.',
|
||||||
|
'Access': 'Zugriff',
|
||||||
|
'read only': 'nur lesen',
|
||||||
|
'read/write': 'lesen/schreiben',
|
||||||
|
'guest': 'Gast',
|
||||||
|
'hidden': 'versteckt',
|
||||||
|
'all': 'alle',
|
||||||
|
'Delete share?': 'Freigabe löschen?',
|
||||||
|
'The share “{name}” will be removed from smb.conf. The files in {path} are kept.': 'Die Freigabe „{name}“ wird aus smb.conf entfernt. Die Dateien in {path} bleiben erhalten.',
|
||||||
|
'Share deleted': 'Freigabe gelöscht',
|
||||||
|
'Saving checks the file with testparm and reloads Samba. If the check fails, the previous version is restored.': 'Beim Speichern wird die Datei mit testparm geprüft und Samba neu geladen. Schlägt die Prüfung fehl, wird der vorherige Stand wiederhergestellt.',
|
||||||
|
'Edit share': 'Freigabe bearbeiten',
|
||||||
|
'Read only': 'Nur lesen',
|
||||||
|
'Visible in the network': 'Im Netzwerk sichtbar',
|
||||||
|
'Guest access': 'Gastzugriff',
|
||||||
|
'Allowed users / groups': 'Erlaubte Benutzer / Gruppen',
|
||||||
|
'empty = all; e.g. anna, @staff': 'leer = alle; z. B. anna, @staff',
|
||||||
|
'Users with write access': 'Benutzer mit Schreibrecht',
|
||||||
|
'optional; e.g. @admins': 'optional; z. B. @admins',
|
||||||
|
'File permissions (create mask)': 'Dateirechte (create mask)',
|
||||||
|
'Folder permissions (directory mask)': 'Ordnerrechte (directory mask)',
|
||||||
|
'Create the folder if it does not exist': 'Ordner anlegen, falls er fehlt',
|
||||||
|
'Share saved': 'Freigabe gespeichert',
|
||||||
|
'A share named “{name}” already exists.': 'Eine Freigabe namens „{name}“ gibt es bereits.',
|
||||||
|
'This server is an Active Directory domain controller. Users are managed in the “Domain” view.': 'Dieser Server ist ein Active-Directory-Domänencontroller. Benutzer werden in der Ansicht „Domäne“ verwaltet.',
|
||||||
|
'Samba users': 'Samba-Benutzer',
|
||||||
|
'No Samba users yet. Samba users need a Linux account and their own Samba password.': 'Noch keine Samba-Benutzer. Samba-Benutzer brauchen ein Linux-Konto und ein eigenes Samba-Passwort.',
|
||||||
|
'disabled': 'deaktiviert',
|
||||||
|
'Flags': 'Flags',
|
||||||
|
'Password changed': 'Passwort geändert',
|
||||||
|
'User enabled': 'Benutzer aktiviert',
|
||||||
|
'User disabled': 'Benutzer deaktiviert',
|
||||||
|
'Delete Samba user?': 'Samba-Benutzer löschen?',
|
||||||
|
'“{name}” can no longer access shares. The Linux account is kept.': '„{name}“ kann dann nicht mehr auf Freigaben zugreifen. Das Linux-Konto bleibt erhalten.',
|
||||||
|
'User deleted': 'Benutzer gelöscht',
|
||||||
|
'Add Samba user': 'Samba-Benutzer hinzufügen',
|
||||||
|
'Create a Linux account without login if it does not exist': 'Linux-Konto ohne Anmeldung anlegen, falls es fehlt',
|
||||||
|
'User added': 'Benutzer hinzugefügt',
|
||||||
|
'Linux user {user} does not exist.': 'Den Linux-Benutzer {user} gibt es nicht.',
|
||||||
|
'No Samba services found.': 'Keine Samba-Dienste gefunden.',
|
||||||
|
'Connected clients': 'Verbundene Clients',
|
||||||
|
'No connections.': 'Keine Verbindungen.',
|
||||||
|
'Computer': 'Computer',
|
||||||
|
'Encryption': 'Verschlüsselung',
|
||||||
|
'Open files': 'Geöffnete Dateien',
|
||||||
|
'Computers': 'Computer',
|
||||||
|
'Domain info': 'Domäneninfo',
|
||||||
|
'ldbsearch is missing (package ldb-tools), so only names are shown.': 'ldbsearch fehlt (Paket ldb-tools), daher werden nur Namen angezeigt.',
|
||||||
|
'Domain users': 'Domänenbenutzer',
|
||||||
|
'New user': 'Neuer Benutzer',
|
||||||
|
'E-mail': 'E-Mail',
|
||||||
|
'Last logon': 'Letzte Anmeldung',
|
||||||
|
'Delete domain user?': 'Domänenbenutzer löschen?',
|
||||||
|
'“{name}” will be permanently deleted from the domain.': '„{name}“ wird endgültig aus der Domäne gelöscht.',
|
||||||
|
'Group created': 'Gruppe angelegt',
|
||||||
|
'The group “{name}” will be deleted.': 'Die Gruppe „{name}“ wird gelöscht.',
|
||||||
|
'Group deleted': 'Gruppe gelöscht',
|
||||||
|
'Operating system': 'Betriebssystem',
|
||||||
|
'Delete computer account?': 'Computerkonto löschen?',
|
||||||
|
'“{name}” will be removed from the domain and must join again.': '„{name}“ wird aus der Domäne entfernt und muss erneut beitreten.',
|
||||||
|
'Computer deleted': 'Computer gelöscht',
|
||||||
|
'Domain membership': 'Domänenmitgliedschaft',
|
||||||
|
'This server is not a member of a domain (or realmd is not installed).': 'Dieser Server ist kein Mitglied einer Domäne (oder realmd ist nicht installiert).',
|
||||||
|
'Join domain …': 'Domäne beitreten …',
|
||||||
|
'Join domain': 'Domäne beitreten',
|
||||||
|
'Domain administrator': 'Domänen-Administrator',
|
||||||
|
'Runs in a terminal tab and asks for the password of the domain administrator.': 'Läuft in einem Terminal-Tab und fragt nach dem Passwort des Domänen-Administrators.',
|
||||||
|
'Join': 'Beitreten',
|
||||||
|
'Install domain join tools': 'Werkzeuge für den Domänenbeitritt installieren',
|
||||||
|
'To manage users, groups and computers here, provision this server as a Samba AD domain controller (samba-tool domain provision).': 'Um hier Benutzer, Gruppen und Computer zu verwalten, richte diesen Server als Samba-AD-Domänencontroller ein (samba-tool domain provision).',
|
||||||
|
'New domain user': 'Neuer Domänenbenutzer',
|
||||||
|
'First name': 'Vorname',
|
||||||
|
'Last name': 'Nachname',
|
||||||
|
'User must change the password at next logon': 'Benutzer muss das Passwort bei der nächsten Anmeldung ändern',
|
||||||
|
'User created': 'Benutzer angelegt',
|
||||||
|
'Members of {name}': 'Mitglieder von {name}',
|
||||||
|
'One member per line (users, groups or computers)': 'Ein Mitglied pro Zeile (Benutzer, Gruppen oder Computer)',
|
||||||
|
'Members updated': 'Mitglieder aktualisiert',
|
||||||
|
'This host is not a Samba AD domain controller.': 'Dieser Host ist kein Samba-AD-Domänencontroller.',
|
||||||
|
'This server is a member of the domain {domain}.': 'Dieser Server ist Mitglied der Domäne {domain}.',
|
||||||
|
'Leave domain': 'Domäne verlassen',
|
||||||
|
'Leave domain?': 'Domäne verlassen?',
|
||||||
|
'Domain users can no longer log in on this server. The computer account in the domain is kept.': 'Domänenbenutzer können sich dann nicht mehr auf diesem Server anmelden. Das Computerkonto in der Domäne bleibt erhalten.',
|
||||||
|
'Leave': 'Verlassen',
|
||||||
|
'Left the domain': 'Domäne verlassen',
|
||||||
|
'This server is not a member of a domain.': 'Dieser Server ist kein Mitglied einer Domäne.',
|
||||||
|
'Create a new domain': 'Neue Domäne erstellen',
|
||||||
|
'Turns this server into the first domain controller of a new Active Directory domain. Windows and Linux computers can then join it, and you manage users, groups and computers right here.': 'Macht diesen Server zum ersten Domänencontroller einer neuen Active-Directory-Domäne. Windows- und Linux-Rechner können ihr dann beitreten, und du verwaltest Benutzer, Gruppen und Computer direkt hier.',
|
||||||
|
'Create domain …': 'Domäne erstellen …',
|
||||||
|
'Create domain': 'Domäne erstellen',
|
||||||
|
'Install domain controller packages': 'Pakete für den Domänencontroller installieren',
|
||||||
|
'The server must resolve the domain via DNS (the domain controller as DNS server). Logins of domain users are then handled by sssd.': 'Der Server muss die Domäne per DNS auflösen können (Domänencontroller als DNS-Server). Anmeldungen von Domänenbenutzern übernimmt danach sssd.',
|
||||||
|
'Joining {domain} …': 'Trete {domain} bei …',
|
||||||
|
'Joined the domain': 'Der Domäne beigetreten',
|
||||||
|
'DNS domain (realm)': 'DNS-Domäne (Realm)',
|
||||||
|
'NetBIOS name': 'NetBIOS-Name',
|
||||||
|
'Password for Administrator': 'Passwort für Administrator',
|
||||||
|
'DNS forwarder': 'DNS-Weiterleitung',
|
||||||
|
'Queries for other domains are forwarded to this DNS server.': 'Anfragen für andere Domains werden an diesen DNS-Server weitergeleitet.',
|
||||||
|
'Use this domain controller as DNS server of the host (resolv.conf)': 'Diesen Domänencontroller als DNS-Server des Hosts verwenden (resolv.conf)',
|
||||||
|
'Store Unix attributes (UID/GID) in the directory (RFC 2307)': 'Unix-Attribute (UID/GID) im Verzeichnis speichern (RFC 2307)',
|
||||||
|
'The file server services (smbd/nmbd) are replaced by samba-ad-dc; the previous smb.conf is kept as smb.conf.mrterm-before-dc. The server needs a fixed IP address and a hostname that is not the domain name. The password must be complex (upper/lower case, digits, 8+ characters).': 'Die Dateiserver-Dienste (smbd/nmbd) werden durch samba-ad-dc ersetzt; die bisherige smb.conf bleibt als smb.conf.mrterm-before-dc erhalten. Der Server braucht eine feste IP-Adresse und einen Hostnamen, der nicht dem Domänennamen entspricht. Das Passwort muss komplex sein (Groß-/Kleinbuchstaben, Ziffern, mind. 8 Zeichen).',
|
||||||
|
'Creating the domain {realm} … this takes about a minute.': 'Erstelle die Domäne {realm} … das dauert etwa eine Minute.',
|
||||||
|
'Domain {realm} created': 'Domäne {realm} erstellt',
|
||||||
|
'samba-tool is missing. Install the domain controller packages first.': 'samba-tool fehlt. Installiere zuerst die Pakete für den Domänencontroller.',
|
||||||
|
'This server already contains an AD domain database (/var/lib/samba/private/sam.ldb).': 'Auf diesem Server gibt es bereits eine AD-Domänendatenbank (/var/lib/samba/private/sam.ldb).',
|
||||||
|
'Creating the domain failed: {err}': 'Das Erstellen der Domäne ist fehlgeschlagen: {err}',
|
||||||
|
'The domain was created, but samba-ad-dc could not be started: {err}': 'Die Domäne wurde erstellt, aber samba-ad-dc ließ sich nicht starten: {err}',
|
||||||
|
'realmd is not installed. Install the domain join tools first.': 'realmd ist nicht installiert. Installiere zuerst die Werkzeuge für den Domänenbeitritt.',
|
||||||
|
'Joining the domain failed: {err}': 'Der Domänenbeitritt ist fehlgeschlagen: {err}',
|
||||||
|
'Samba from Fedora/RHEL packages cannot run an AD domain controller.': 'Samba aus den Fedora-/RHEL-Paketen kann keinen AD-Domänencontroller betreiben.',
|
||||||
|
|
||||||
|
// Konfigurations-Generator
|
||||||
|
'Generator': 'Generator',
|
||||||
|
'Start from a template …': 'Mit Vorlage beginnen …',
|
||||||
|
'Search settings (e.g. upload, websocket, hsts) …': 'Einstellungen durchsuchen (z. B. upload, websocket, hsts) …',
|
||||||
|
'Open in editor': 'Im Editor öffnen',
|
||||||
|
'Save as site': 'Als Site speichern',
|
||||||
|
'{server} is not installed on this host – you can still generate and copy the configuration.': '{server} ist auf diesem Host nicht installiert – du kannst die Konfiguration trotzdem erzeugen und kopieren.',
|
||||||
|
'The certificate must exist before saving: request it in the “Certificates” tab first (e.g. via webroot), or save without HTTPS first.': 'Das Zertifikat muss vor dem Speichern vorhanden sein: fordere es zuerst im Tab „Zertifikate“ an (z. B. per Webroot) oder speichere die Site erst ohne HTTPS.',
|
||||||
|
'Automatic certificates are only available with Caddy.': 'Automatische Zertifikate gibt es nur mit Caddy.',
|
||||||
|
'Create the password file on the server with: htpasswd -c {file} <user>': 'Lege die Passwortdatei auf dem Server an mit: htpasswd -c {file} <benutzer>',
|
||||||
|
'Site saved: {path}': 'Site gespeichert: {path}',
|
||||||
|
'New site ({server})': 'Neue Site ({server})',
|
||||||
|
'Edit the generated configuration freely. Saving creates the site, checks it and reloads the web server; if the check fails, nothing is changed.': 'Bearbeite die erzeugte Konfiguration nach Belieben. Beim Speichern wird die Site angelegt, geprüft und der Webserver neu geladen; schlägt die Prüfung fehl, bleibt alles unverändert.',
|
||||||
|
'Basics': 'Grundlagen',
|
||||||
|
'HTTPS / TLS': 'HTTPS / TLS',
|
||||||
|
'Security': 'Sicherheit',
|
||||||
|
'Access control': 'Zugriffskontrolle',
|
||||||
|
'Performance & caching': 'Leistung & Caching',
|
||||||
|
'Redirect': 'Weiterleitung',
|
||||||
|
'Logging': 'Protokollierung',
|
||||||
|
'Custom directives': 'Eigene Direktiven',
|
||||||
|
'Reverse proxy (Node, Docker, …)': 'Reverse Proxy (Node, Docker, …)',
|
||||||
|
'Load balancer': 'Load Balancer',
|
||||||
|
'Single-page app (React, Vue, …)': 'Single-Page-App (React, Vue, …)',
|
||||||
|
'PHP app (WordPress, Laravel, …)': 'PHP-App (WordPress, Laravel, …)',
|
||||||
|
'Redirect / domain move': 'Weiterleitung / Domainumzug',
|
||||||
|
'Hardened HTTPS site': 'Gehärtete HTTPS-Site',
|
||||||
|
'Site type': 'Art der Site',
|
||||||
|
'Single-page app (index.html fallback)': 'Single-Page-App (Fallback auf index.html)',
|
||||||
|
'PHP application': 'PHP-Anwendung',
|
||||||
|
'Redirect to another URL': 'Weiterleitung auf eine andere URL',
|
||||||
|
'Separated by spaces. The first one is the main name.': 'Durch Leerzeichen getrennt. Der erste ist der Hauptname.',
|
||||||
|
'HTTP port': 'HTTP-Port',
|
||||||
|
'Index files': 'Indexdateien',
|
||||||
|
'Target servers (upstream)': 'Zielserver (Upstream)',
|
||||||
|
'One or more, separated by spaces. Several targets = load balancing.': 'Einer oder mehrere, durch Leerzeichen getrennt. Mehrere Ziele = Lastverteilung.',
|
||||||
|
'Only requests below this path are forwarded.': 'Nur Anfragen unterhalb dieses Pfads werden weitergeleitet.',
|
||||||
|
'Load balancing method': 'Verfahren der Lastverteilung',
|
||||||
|
'Round robin': 'Reihum (Round Robin)',
|
||||||
|
'Least connections': 'Wenigste Verbindungen',
|
||||||
|
'Same client → same server (IP hash)': 'Gleicher Client → gleicher Server (IP-Hash)',
|
||||||
|
'WebSockets': 'WebSockets',
|
||||||
|
'Forward Upgrade/Connection headers.': 'Upgrade-/Connection-Header weitergeben.',
|
||||||
|
'Pass the original Host header': 'Ursprünglichen Host-Header weitergeben',
|
||||||
|
'X-Forwarded-For / -Proto / X-Real-IP': 'X-Forwarded-For / -Proto / X-Real-IP',
|
||||||
|
'Timeout (seconds)': 'Timeout (Sekunden)',
|
||||||
|
'Read timeout for slow backends. 0 = default.': 'Lese-Timeout für langsame Backends. 0 = Standard.',
|
||||||
|
'Buffer responses': 'Antworten puffern',
|
||||||
|
'Turn off for streaming (Server-Sent Events, long polling).': 'Für Streaming ausschalten (Server-Sent Events, Long Polling).',
|
||||||
|
'Do not verify the HTTPS certificate of the target': 'HTTPS-Zertifikat des Ziels nicht prüfen',
|
||||||
|
'Enable HTTPS': 'HTTPS aktivieren',
|
||||||
|
'HTTPS port': 'HTTPS-Port',
|
||||||
|
'Certificate': 'Zertifikat',
|
||||||
|
"Let's Encrypt (certbot, /etc/letsencrypt/live/<domain>)": 'Let’s Encrypt (certbot, /etc/letsencrypt/live/<domain>)',
|
||||||
|
'Automatic (Caddy)': 'Automatisch (Caddy)',
|
||||||
|
'Own files': 'Eigene Dateien',
|
||||||
|
'Certificate file': 'Zertifikatsdatei',
|
||||||
|
'Key file': 'Schlüsseldatei',
|
||||||
|
'Only TLS 1.2 and 1.3': 'Nur TLS 1.2 und 1.3',
|
||||||
|
'HSTS (seconds, 0 = off)': 'HSTS (Sekunden, 0 = aus)',
|
||||||
|
'Browsers then only use HTTPS for this domain. 31536000 = one year.': 'Browser verwenden für diese Domain dann nur noch HTTPS. 31536000 = ein Jahr.',
|
||||||
|
'Hide server version': 'Serverversion verbergen',
|
||||||
|
'X-Frame-Options (clickjacking protection)': 'X-Frame-Options (Schutz vor Clickjacking)',
|
||||||
|
'off': 'aus',
|
||||||
|
'Block hidden files (.git, .env, .htaccess)': 'Versteckte Dateien sperren (.git, .env, .htaccess)',
|
||||||
|
'Allowed IPs / networks': 'Erlaubte IPs / Netze',
|
||||||
|
'Empty = everyone. Others get 403.': 'Leer = alle. Andere erhalten 403.',
|
||||||
|
'Password protection (basic auth)': 'Passwortschutz (Basic Auth)',
|
||||||
|
'Password file (htpasswd)': 'Passwortdatei (htpasswd)',
|
||||||
|
'Create it with: htpasswd -c /etc/nginx/.htpasswd user': 'Anlegen mit: htpasswd -c /etc/nginx/.htpasswd benutzer',
|
||||||
|
'Realm (text in the login dialog)': 'Realm (Text im Anmeldedialog)',
|
||||||
|
'Rate limit (requests per second per IP, 0 = off)': 'Ratenlimit (Anfragen pro Sekunde je IP, 0 = aus)',
|
||||||
|
'Compression (gzip)': 'Komprimierung (gzip)',
|
||||||
|
'Browser cache for assets (days, 0 = off)': 'Browser-Cache für Assets (Tage, 0 = aus)',
|
||||||
|
'CSS, JS, images and fonts.': 'CSS, JS, Bilder und Schriften.',
|
||||||
|
'Max. upload size (MB, 0 = default)': 'Max. Upload-Größe (MB, 0 = Standard)',
|
||||||
|
'Target URL': 'Ziel-URL',
|
||||||
|
'Status code': 'Statuscode',
|
||||||
|
'301 (permanent)': '301 (dauerhaft)',
|
||||||
|
'302 (temporary)': '302 (vorübergehend)',
|
||||||
|
'Keep the path': 'Pfad beibehalten',
|
||||||
|
'example.org/a → target/a': 'example.org/a → ziel/a',
|
||||||
|
'Access log': 'Zugriffsprotokoll',
|
||||||
|
'Empty = default of the server, "off" = no access log.': 'Leer = Standard des Servers, „off“ = kein Zugriffsprotokoll.',
|
||||||
|
'Error log': 'Fehlerprotokoll',
|
||||||
|
'Additional directives': 'Zusätzliche Direktiven',
|
||||||
|
'Inserted as-is into the server block.': 'Wird unverändert in den Server-Block eingefügt.',
|
||||||
'RDP exited (code {code}): {err}': 'RDP beendet (Code {code}): {err}',
|
'RDP exited (code {code}): {err}': 'RDP beendet (Code {code}): {err}',
|
||||||
'RDP launch failed: {msg}': 'RDP-Start fehlgeschlagen: {msg}',
|
'RDP launch failed: {msg}': 'RDP-Start fehlgeschlagen: {msg}',
|
||||||
'xfreerdp3 missing (sudo pacman -S freerdp)': 'xfreerdp3 fehlt (sudo pacman -S freerdp)',
|
'xfreerdp3 missing (sudo pacman -S freerdp)': 'xfreerdp3 fehlt (sudo pacman -S freerdp)',
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Gemeinsame Basis für Verwaltungs-Tabs über SSH (Webserver, Zertifikate, Samba/AD): Verbindung, root-Rechte
|
||||||
|
// (direkt als root oder per sudo), Skripte als root, Konfigurationsdateien mit Backup, Prüfung und automatischem Zurückrollen.
|
||||||
|
// Unterklassen setzen NAME (für Fehlermeldungen), OPS (aufrufbare Operationen) und implementieren read(id).
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const { execOn } = require('./docker');
|
||||||
|
const { acquireSudo } = require('./sudo');
|
||||||
|
|
||||||
|
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 q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // Shell-Quoting in einfachen Anführungszeichen
|
||||||
|
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); };
|
||||||
|
|
||||||
|
// Ausgabe mit @@ABSCHNITT-Markern in { ABSCHNITT: text } zerlegen
|
||||||
|
function sections(out) {
|
||||||
|
const res = {};
|
||||||
|
let cur = null;
|
||||||
|
for (const line of String(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()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paketverwaltung des Hosts erkennen (für Installationsbefehle im Terminal)
|
||||||
|
const PKG_DETECT = 'if command -v apt-get >/dev/null 2>&1; then echo apt; elif command -v dnf >/dev/null 2>&1; then echo dnf; elif command -v yum >/dev/null 2>&1; then echo yum; elif command -v pacman >/dev/null 2>&1; then echo pacman; elif command -v zypper >/dev/null 2>&1; then echo zypper; elif command -v apk >/dev/null 2>&1; then echo apk; fi';
|
||||||
|
function installCmd(pkg, packages) {
|
||||||
|
const p = packages.join(' ');
|
||||||
|
return { apt: `apt-get update && apt-get install -y ${p}`, dnf: `dnf install -y ${p}`, yum: `yum install -y ${p}`, pacman: `pacman -S --needed --noconfirm ${p}`, zypper: `zypper install -y ${p}`, apk: `apk add ${p}` }[pkg] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminManager {
|
||||||
|
constructor(ssh) {
|
||||||
|
this.ssh = ssh;
|
||||||
|
this.sessions = new Map(); // id -> { conn, jumps, host, sudo, sudoPw, ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
const s = this.sessions.get(id);
|
||||||
|
if (!s) throw new Error(i18n.t('Session not found'));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell-Skript als root ausführen (base64-kodiert, damit kein Quoting-Problem entsteht).
|
||||||
|
// stdin: weitere Eingabe für das Skript (z. B. Passwörter, die nicht in der Kommandozeile stehen sollen)
|
||||||
|
root(s, script, stdin = '') {
|
||||||
|
const cmd = `sh -c "$(printf %s ${b64(`export PATH=$PATH:/usr/sbin:/sbin:/usr/local/sbin\n${script}`)} | base64 -d)"`;
|
||||||
|
if (!s.sudo) return execOn(s.conn, cmd, stdin);
|
||||||
|
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n${stdin}`);
|
||||||
|
}
|
||||||
|
async rootOk(s, script, stdin) {
|
||||||
|
const r = await this.root(s, script, stdin);
|
||||||
|
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 };
|
||||||
|
this.sessions.set(id, s);
|
||||||
|
conn.on('close', () => { if (this.sessions.get(id) === s) { this.close(id); onClose(); } });
|
||||||
|
conn.on('error', () => {});
|
||||||
|
if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
|
||||||
|
s.sudo = true;
|
||||||
|
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
|
||||||
|
if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
|
||||||
|
}
|
||||||
|
s.pkg = (await this.root(s, PKG_DETECT)).out.trim();
|
||||||
|
return this.read(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Präfix für Befehle, die im Terminal-Tab laufen (dort fragt sudo selbst nach dem Passwort)
|
||||||
|
termPrefix(s) { return s.sudo ? 'sudo ' : ''; }
|
||||||
|
termCmd(s, cmd) { return s.sudo ? `sudo sh -c ${q(cmd)}` : cmd; }
|
||||||
|
|
||||||
|
// Datei schreiben mit Backup; validate (Shell) muss erfolgreich sein, sonst wird der alte Stand wiederhergestellt.
|
||||||
|
// after: Befehl nach erfolgreicher Prüfung (z. B. reload). Liefert die Ausgabe der Prüfung.
|
||||||
|
async writeChecked(s, path, content, { validate = 'true', after = '', mode = '' } = {}) {
|
||||||
|
const r = await this.root(s, `f=${q(path)}
|
||||||
|
had=0; [ -e "$f" ] && { cp -a "$f" "$f.mrterm-bak" || exit 1; had=1; }
|
||||||
|
mkdir -p "$(dirname "$f")" || exit 1
|
||||||
|
printf %s ${b64(content)} | base64 -d > "$f" || exit 1
|
||||||
|
${mode ? `chmod ${mode} "$f"` : ''}
|
||||||
|
if ! out=$( (${validate}) 2>&1 ); then
|
||||||
|
if [ $had = 1 ]; then mv -f "$f.mrterm-bak" "$f"; else rm -f "$f"; fi
|
||||||
|
printf '%s\\n' "$out" >&2; exit 4
|
||||||
|
fi
|
||||||
|
printf '%s\\n' "$out"
|
||||||
|
${after ? `(${after}) 2>&1 || exit 6` : ''}
|
||||||
|
exit 0`);
|
||||||
|
if (r.code === 4) throw new Error(i18n.t('The configuration is invalid, the previous version was restored: {err}', { err: r.err.trim().split('\n').slice(-6).join('\n') }));
|
||||||
|
if (r.code === 6) throw new Error(i18n.t('Saved, but reloading failed: {err}', { err: lastLine(r.out) }));
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
|
||||||
|
return r.out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generischer Einstieg für die Oberfläche: nur Operationen aus OPS sind erlaubt
|
||||||
|
op(id, name, ...args) {
|
||||||
|
if (!this.constructor.OPS.includes(name)) throw new Error('Invalid operation');
|
||||||
|
return this[name](this.get(id), ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = { AdminManager, sections, lastLine, b64, q, check, installCmd };
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Support-Assistent über eine Ollama-Instanz (lokal oder im Netz).
|
||||||
|
// Antworten werden gestreamt: send('ai:event', chatId, 'data' | 'done' | 'error', payload)
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are the MrTerm support assistant, an experienced Linux system administrator.
|
||||||
|
The user manages Linux servers (typically Ubuntu, Debian or Proxmox) with MrTerm: SSH terminals, Docker containers
|
||||||
|
and compose stacks, firewall (UFW/iptables), network configuration and SFTP.
|
||||||
|
You may receive the content of the user's active MrTerm tab as context (terminal output, container list, compose file,
|
||||||
|
firewall rules, …). Your job:
|
||||||
|
- Explain errors briefly and clearly, name the most likely cause.
|
||||||
|
- Suggest concrete, copy-pasteable shell commands in \`\`\`bash code blocks, one step at a time.
|
||||||
|
- Warn explicitly before destructive commands (rm -rf, dd, mkfs, firewall changes that may lock out SSH, network changes).
|
||||||
|
- If information is missing, say which command would provide it.
|
||||||
|
Answer in the language of the user's question. Be concise.`;
|
||||||
|
|
||||||
|
function request(base, path, { method = 'GET', body, signal } = {}) {
|
||||||
|
const url = new URL(path, base.endsWith('/') ? base : base + '/');
|
||||||
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = lib.request(url, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, signal, timeout: 15000 }, (res) => {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
let t = ''; res.on('data', (c) => (t += c));
|
||||||
|
res.on('end', () => { let m = t; try { m = JSON.parse(t).error || t; } catch {} reject(new Error(`Ollama: ${m || res.statusCode}`)); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(res);
|
||||||
|
});
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Ollama: timeout')));
|
||||||
|
req.on('error', (e) => reject(e.code === 'ECONNREFUSED' ? new Error(`Ollama not reachable at ${base}`) : e));
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiAssistant {
|
||||||
|
constructor(store, send) {
|
||||||
|
this.store = store;
|
||||||
|
this.send = send;
|
||||||
|
this.running = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
get cfg() {
|
||||||
|
const s = this.store.get().settings;
|
||||||
|
return { url: s.aiUrl || 'http://localhost:11434', model: s.aiModel || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async models(url) {
|
||||||
|
const res = await request(url || this.cfg.url, 'api/tags');
|
||||||
|
let t = ''; for await (const c of res) t += c;
|
||||||
|
return (JSON.parse(t).models || []).map((m) => m.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages: [{ role: 'user' | 'assistant', content }], context: letzte Terminalausgabe
|
||||||
|
async chat(chatId, messages, context = '') {
|
||||||
|
const { url, model } = this.cfg;
|
||||||
|
if (!model) throw new Error('No Ollama model selected (Settings → Support assistant).');
|
||||||
|
this.stop(chatId);
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
this.running.set(chatId, ctrl);
|
||||||
|
const sys = SYSTEM_PROMPT + (context ? `\n\nContent of the active tab:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : '');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } });
|
||||||
|
res.setTimeout(0);
|
||||||
|
let buf = '';
|
||||||
|
for await (const chunk of res) {
|
||||||
|
buf += chunk;
|
||||||
|
let i;
|
||||||
|
while ((i = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
const m = JSON.parse(line);
|
||||||
|
if (m.error) throw new Error(`Ollama: ${m.error}`);
|
||||||
|
if (m.message?.content) this.send('ai:event', chatId, 'data', m.message.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.send('ai:event', chatId, 'done');
|
||||||
|
} catch (e) {
|
||||||
|
if (ctrl.signal.aborted) this.send('ai:event', chatId, 'done');
|
||||||
|
else this.send('ai:event', chatId, 'error', e.message || String(e));
|
||||||
|
} finally {
|
||||||
|
if (this.running.get(chatId) === ctrl) this.running.delete(chatId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(chatId) { this.running.get(chatId)?.abort(); this.running.delete(chatId); }
|
||||||
|
stopAll() { for (const c of this.running.values()) c.abort(); this.running.clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AiAssistant };
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Zertifikate-Tab: Let's Encrypt über certbot oder acme.sh, Ablaufdaten aller gefundenen Zertifikate,
|
||||||
|
// automatische Erneuerung, selbstsignierte Zertifikate und TLS-Prüfung eines Endpunkts.
|
||||||
|
// Ausstellen/Erneuern läuft in einem Terminal-Tab, damit Rückfragen (DNS-Challenge, E-Mail) beantwortet werden können.
|
||||||
|
const tls = require('tls');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const { AdminManager, sections, q, check, installCmd } = require('./admin');
|
||||||
|
|
||||||
|
const RX = {
|
||||||
|
name: /^[\w.@-]{1,120}$/,
|
||||||
|
domain: /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$|^localhost$/,
|
||||||
|
email: /^[^\s@'"`$\\]+@[^\s@'"`$\\]+\.[^\s@'"`$\\]+$/,
|
||||||
|
webroot: /^\/[\w./@+-]*$/,
|
||||||
|
cert: /^\/(etc|var\/lib|opt|srv)\/[\w./@*+-]+\.(pem|crt|cer)$/,
|
||||||
|
};
|
||||||
|
const noDots = (p) => { if (String(p).split('/').includes('..')) throw new Error('Invalid path'); return p; };
|
||||||
|
|
||||||
|
// Zertifikatsdateien außerhalb von certbot/acme.sh, deren Ablauf angezeigt wird
|
||||||
|
const SCAN = '/etc/ssl/mrterm/*.crt /etc/nginx/ssl/*.crt /etc/nginx/ssl/*.pem /etc/apache2/ssl/*.crt /etc/apache2/ssl/*.pem /etc/pki/tls/certs/localhost.crt /var/lib/caddy/.local/share/caddy/certificates/*/*/*.crt';
|
||||||
|
|
||||||
|
const GATHER = `echo "@@CERTBOT"; command -v certbot >/dev/null 2>&1 && certbot --version 2>&1 | head -1
|
||||||
|
echo "@@PLUGINS"; command -v certbot >/dev/null 2>&1 && certbot plugins 2>/dev/null | sed -n 's/^\\* //p'
|
||||||
|
echo "@@CERTS"; command -v certbot >/dev/null 2>&1 && certbot certificates 2>/dev/null
|
||||||
|
echo "@@TIMER"; for u in certbot.timer snap.certbot.renew.timer certbot-renew.timer; do s=$(systemctl is-enabled "$u" 2>/dev/null) && echo "$u|$s|$(systemctl is-active "$u" 2>/dev/null)"; done; [ -f /etc/cron.d/certbot ] && echo "cron|/etc/cron.d/certbot|"
|
||||||
|
echo "@@ACME"; for d in /root/.acme.sh "$HOME/.acme.sh"; do [ -x "$d/acme.sh" ] && { echo "DIR $d"; "$d/acme.sh" --list --listraw 2>/dev/null; break; }; done
|
||||||
|
echo "@@FILES"; for f in /etc/letsencrypt/live/*/cert.pem ${SCAN}; do [ -f "$f" ] || continue; echo "==F $f"; openssl x509 -noout -subject -issuer -enddate -ext subjectAltName -in "$f" 2>/dev/null; done
|
||||||
|
echo "@@WEB"; command -v nginx >/dev/null 2>&1 && echo nginx; { command -v apache2 >/dev/null 2>&1 || command -v httpd >/dev/null 2>&1; } && echo apache
|
||||||
|
echo "@@END"`;
|
||||||
|
|
||||||
|
// Ausgabe von "certbot certificates"
|
||||||
|
function parseCertbot(text) {
|
||||||
|
const out = [];
|
||||||
|
let cur = null;
|
||||||
|
for (const line of String(text).split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
let m;
|
||||||
|
if ((m = t.match(/^Certificate Name:\s*(.+)$/))) { cur = { name: m[1], domains: [] }; out.push(cur); }
|
||||||
|
else if (!cur) continue;
|
||||||
|
else if ((m = t.match(/^Domains:\s*(.+)$/))) cur.domains = m[1].split(/\s+/);
|
||||||
|
else if ((m = t.match(/^Expiry Date:\s*(\S+ \S+)\s*\((.+)\)/))) { cur.expires = Date.parse(m[1].replace(' ', 'T').replace(/\+00:00$/, 'Z')) || null; cur.state = m[2]; }
|
||||||
|
else if ((m = t.match(/^Key Type:\s*(.+)$/))) cur.keyType = m[1];
|
||||||
|
else if ((m = t.match(/^Certificate Path:\s*(.+)$/))) cur.certPath = m[1];
|
||||||
|
else if ((m = t.match(/^Private Key Path:\s*(.+)$/))) cur.keyPath = m[1];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// acme.sh --list --listraw: Main_Domain|KeyLength|SAN_Domains|Profile?|CA|Created|Renew
|
||||||
|
function parseAcme(text) {
|
||||||
|
const lines = String(text).split('\n');
|
||||||
|
const dir = (lines.find((l) => l.startsWith('DIR ')) || '').slice(4);
|
||||||
|
const rows = lines.filter((l) => l.includes('|') && !/^Main_Domain/.test(l));
|
||||||
|
const head = (lines.find((l) => /^Main_Domain/.test(l)) || 'Main_Domain|KeyLength|SAN_Domains|CA|Created|Renew').split('|');
|
||||||
|
return { dir, certs: rows.map((r) => { const v = r.split('|'); const o = Object.fromEntries(head.map((k, i) => [k, v[i] || ''])); return { domain: o.Main_Domain, keyLength: o.KeyLength.replace(/"/g, ''), sans: o.SAN_Domains === 'no' ? [] : o.SAN_Domains.split(','), ca: o.CA, created: o.Created, renew: o.Renew }; }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// openssl x509 -subject -issuer -enddate -ext subjectAltName je Datei
|
||||||
|
function parseFiles(text) {
|
||||||
|
return String(text).split(/^==F /m).filter(Boolean).map((part) => {
|
||||||
|
const [path, ...rest] = part.split('\n');
|
||||||
|
const body = rest.join('\n');
|
||||||
|
const end = body.match(/notAfter=(.+)/)?.[1];
|
||||||
|
const sans = (body.match(/DNS:[^,\s]+/g) || []).map((x) => x.slice(4));
|
||||||
|
return { path: path.trim(), subject: (body.match(/subject=\s*(.+)/)?.[1] || '').trim(), issuer: (body.match(/issuer=\s*(.+)/)?.[1] || '').trim(), expires: end ? Date.parse(end) : null, sans };
|
||||||
|
}).filter((x) => x.subject || x.expires);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CertManager extends AdminManager {
|
||||||
|
static OPS = ['issueCommand', 'renewCommand', 'deleteCert', 'details', 'selfSigned', 'enableAutoRenew', 'installCommand', 'acmeRenewCommand', 'acmeRemove'];
|
||||||
|
|
||||||
|
async read(id) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const sec = sections(await this.rootOk(s, GATHER));
|
||||||
|
const acme = parseAcme(sec.ACME || '');
|
||||||
|
s.acmeDir = acme.dir;
|
||||||
|
return {
|
||||||
|
pkg: s.pkg,
|
||||||
|
certbot: { version: (sec.CERTBOT || '').trim(), plugins: (sec.PLUGINS || '').split('\n').map((x) => x.trim()).filter(Boolean), certs: parseCertbot(sec.CERTS || '') },
|
||||||
|
timers: (sec.TIMER || '').split('\n').filter(Boolean).map((l) => { const [unit, enabled, active] = l.split('|'); return { unit, enabled, active }; }),
|
||||||
|
acme,
|
||||||
|
files: parseFiles(sec.FILES || ''),
|
||||||
|
web: (sec.WEB || '').split('\n').filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// certbot-Befehl zum Ausstellen; method: nginx | apache | webroot | standalone | dns
|
||||||
|
issueCommand(s, o) {
|
||||||
|
const method = check(o.method, /^(nginx|apache|webroot|standalone|dns)$/, 'method');
|
||||||
|
const domains = String(o.domains || '').split(/[\s,]+/).filter(Boolean).map((d) => check(d, RX.domain, 'domain'));
|
||||||
|
if (!domains.length) throw new Error(i18n.t('Please enter at least one domain.'));
|
||||||
|
if (domains.some((d) => d.startsWith('*.')) && method !== 'dns') throw new Error(i18n.t('Wildcard certificates need the DNS challenge.'));
|
||||||
|
const args = ['certbot'];
|
||||||
|
if (method === 'nginx' || method === 'apache') args.push(`--${method}`);
|
||||||
|
else args.push('certonly');
|
||||||
|
if (method === 'webroot') args.push('--webroot', '-w', q(check(noDots(String(o.webroot || '').trim()), RX.webroot, 'webroot')));
|
||||||
|
if (method === 'standalone') args.push('--standalone');
|
||||||
|
if (method === 'dns') args.push('--manual', '--preferred-challenges', 'dns');
|
||||||
|
domains.forEach((d) => args.push('-d', q(d)));
|
||||||
|
if (o.email) args.push('--email', q(check(String(o.email).trim(), RX.email, 'email')), '--agree-tos', '--no-eff-email');
|
||||||
|
else args.push('--register-unsafely-without-email', '--agree-tos');
|
||||||
|
if (o.keyType === 'rsa') args.push('--key-type', 'rsa', '--rsa-key-size', '4096'); else args.push('--key-type', 'ecdsa');
|
||||||
|
if (o.redirect && (method === 'nginx' || method === 'apache')) args.push('--redirect');
|
||||||
|
if (o.staging) args.push('--staging');
|
||||||
|
if (o.name) args.push('--cert-name', q(check(String(o.name).trim(), RX.name, 'name')));
|
||||||
|
return this.termCmd(s, args.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
renewCommand(s, name, dryRun) {
|
||||||
|
const args = ['certbot', 'renew'];
|
||||||
|
if (name) args.push('--cert-name', q(check(name, RX.name, 'name')));
|
||||||
|
if (dryRun) args.push('--dry-run');
|
||||||
|
return this.termCmd(s, args.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCert(s, name) {
|
||||||
|
await this.rootOk(s, `certbot delete --non-interactive --cert-name ${q(check(name, RX.name, 'name'))}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
details(s, path) {
|
||||||
|
check(noDots(path), RX.cert, 'file');
|
||||||
|
return this.rootOk(s, `openssl x509 -noout -text -in ${q(path)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selbstsigniertes Zertifikat unter /etc/ssl/mrterm/<name>.crt/.key
|
||||||
|
async selfSigned(s, o) {
|
||||||
|
const name = check(String(o.name || '').trim(), /^[\w.-]{1,80}$/, 'name');
|
||||||
|
const cn = check(String(o.cn || '').trim(), RX.domain, 'common name');
|
||||||
|
const sans = [cn, ...String(o.sans || '').split(/[\s,]+/).filter(Boolean).map((d) => check(d, /^[\w.*-]+$|^\d{1,3}(\.\d{1,3}){3}$/, 'SAN'))];
|
||||||
|
const days = Math.max(1, Math.min(3650, Number(o.days) || 365));
|
||||||
|
const san = [...new Set(sans)].map((x) => (/^\d{1,3}(\.\d{1,3}){3}$/.test(x) ? `IP:${x}` : `DNS:${x}`)).join(',');
|
||||||
|
const key = o.keyType === 'rsa' ? '-newkey rsa:4096' : '-newkey ec -pkeyopt ec_paramgen_curve:prime256v1';
|
||||||
|
await this.rootOk(s, `mkdir -p /etc/ssl/mrterm && chmod 755 /etc/ssl/mrterm
|
||||||
|
[ -e ${q(`/etc/ssl/mrterm/${name}.crt`)} ] && { echo 'exists' >&2; exit 3; }
|
||||||
|
openssl req -x509 ${key} -nodes -days ${days} -subj ${q(`/CN=${cn}`)} -addext ${q(`subjectAltName=${san}`)} -keyout ${q(`/etc/ssl/mrterm/${name}.key`)} -out ${q(`/etc/ssl/mrterm/${name}.crt`)} 2>&1 && chmod 600 ${q(`/etc/ssl/mrterm/${name}.key`)}`);
|
||||||
|
return { cert: `/etc/ssl/mrterm/${name}.crt`, key: `/etc/ssl/mrterm/${name}.key` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async enableAutoRenew(s, unit) {
|
||||||
|
await this.rootOk(s, `systemctl enable --now ${check(unit || 'certbot.timer', /^[\w.@-]+\.timer$/, 'timer')}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
installCommand(s) {
|
||||||
|
const pkgs = { apt: ['certbot', 'python3-certbot-nginx', 'python3-certbot-apache'], dnf: ['certbot', 'python3-certbot-nginx', 'python3-certbot-apache'], yum: ['certbot'], pacman: ['certbot', 'certbot-nginx', 'certbot-apache'], zypper: ['python3-certbot'], apk: ['certbot'] }[s.pkg];
|
||||||
|
if (!pkgs) throw new Error(i18n.t('No known package manager on this host.'));
|
||||||
|
return this.termCmd(s, installCmd(s.pkg, pkgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
acmeRenewCommand(s, domain) {
|
||||||
|
if (!s.acmeDir) throw new Error('acme.sh not found');
|
||||||
|
return this.termCmd(s, `${q(`${s.acmeDir}/acme.sh`)} --renew -d ${q(check(domain, RX.domain, 'domain'))} --force`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acmeRemove(s, domain) {
|
||||||
|
if (!s.acmeDir) throw new Error('acme.sh not found');
|
||||||
|
await this.rootOk(s, `${q(`${s.acmeDir}/acme.sh`)} --remove -d ${q(check(domain, RX.domain, 'domain'))}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS-Endpunkt von diesem Rechner aus prüfen (z. B. ob der Webserver das neue Zertifikat ausliefert)
|
||||||
|
function probe(host, port = 443) {
|
||||||
|
check(host, /^[\w.-]+$|^\[?[0-9a-fA-F:]+\]?$/, 'host');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sock = tls.connect({ host, port: Number(port) || 443, servername: /^[\d.]+$|:/.test(host) ? undefined : host, rejectUnauthorized: false, timeout: 8000 }, () => {
|
||||||
|
const c = sock.getPeerCertificate();
|
||||||
|
resolve({
|
||||||
|
authorized: sock.authorized, error: sock.authorizationError || '', protocol: sock.getProtocol(),
|
||||||
|
subject: c.subject?.CN || '', issuer: [c.issuer?.O, c.issuer?.CN].filter(Boolean).join(' · '),
|
||||||
|
validFrom: Date.parse(c.valid_from), expires: Date.parse(c.valid_to),
|
||||||
|
sans: (c.subjectaltname || '').split(/,\s*/).map((x) => x.replace(/^DNS:/, '')).filter(Boolean), fingerprint: c.fingerprint256,
|
||||||
|
});
|
||||||
|
sock.end();
|
||||||
|
});
|
||||||
|
sock.on('timeout', () => { sock.destroy(); reject(new Error(i18n.t('Timeout'))); });
|
||||||
|
sock.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CertManager, parseCertbot, parseAcme, parseFiles, probe };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// App-Vorlagen im Portainer-Format (v2/v3), z. B. https://github.com/Lissy93/portainer-templates.
|
||||||
|
// Container-Vorlagen (type 1) werden in eine compose.yaml umgewandelt; Stack-Vorlagen (type 2/3) laden die
|
||||||
|
// Compose-Datei aus dem angegebenen GitHub-Repository. Umgebungsvariablen landen mit Standardwerten in der .env.
|
||||||
|
const DEFAULT_URL = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json';
|
||||||
|
|
||||||
|
let cache = { url: '', at: 0, list: [] };
|
||||||
|
|
||||||
|
const y = (v) => JSON.stringify(String(v)); // JSON-Strings sind gültiges YAML
|
||||||
|
const slug = (s) => String(s || 'app').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 40) || 'app';
|
||||||
|
|
||||||
|
async function list(url = DEFAULT_URL) {
|
||||||
|
url = url || DEFAULT_URL;
|
||||||
|
if (cache.url === url && Date.now() - cache.at < 3600e3) return cache.list;
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const j = await res.json();
|
||||||
|
const items = (Array.isArray(j) ? j : j.templates || []).filter((t) => t && t.title && (t.image || t.repository?.stackfile));
|
||||||
|
const listed = items.map((t, i) => ({
|
||||||
|
i, title: t.title, description: String(t.description || '').slice(0, 400), categories: t.categories || [],
|
||||||
|
kind: t.image ? 'container' : 'stack', note: t.note || '', t,
|
||||||
|
}));
|
||||||
|
cache = { url, at: Date.now(), list: listed };
|
||||||
|
return listed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für die Oberfläche ohne Rohdaten
|
||||||
|
const summary = (l) => l.map(({ t: _t, ...rest }) => rest);
|
||||||
|
|
||||||
|
function envFile(env) {
|
||||||
|
return (env || []).filter((e) => e?.name).map((e) => {
|
||||||
|
const def = e.select ? (e.select.find((x) => x.default) || e.select[0] || {}).value : e.default;
|
||||||
|
const hint = [e.label && e.label !== e.name ? e.label : '', e.description || '', e.select ? `(${e.select.map((x) => x.value).join(' | ')})` : ''].filter(Boolean).join(' – ');
|
||||||
|
return `${hint ? `# ${hint.replace(/\n/g, ' ')}\n` : ''}${e.name}=${def ?? ''}`;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerCompose(t, name) {
|
||||||
|
const L = ['services:', ` ${name}:`, ` image: ${y(t.image)}`, ` container_name: ${y(name)}`, ` restart: ${y(t.restart_policy || 'unless-stopped')}`];
|
||||||
|
if (t.hostname) L.push(` hostname: ${y(t.hostname)}`);
|
||||||
|
if (t.command) L.push(` command: ${y(t.command)}`);
|
||||||
|
if (t.privileged) L.push(' privileged: true');
|
||||||
|
if (t.interactive) L.push(' stdin_open: true', ' tty: true');
|
||||||
|
if (t.network === 'host') L.push(' network_mode: host');
|
||||||
|
if (t.ports?.length && t.network !== 'host') L.push(' ports:', ...t.ports.map((p) => ` - ${y(p)}`));
|
||||||
|
if (t.env?.length) L.push(' environment:', ...t.env.filter((e) => e?.name).map((e) => ` - ${y(`${e.name}=\${${e.name}}`)}`));
|
||||||
|
if (t.volumes?.length) {
|
||||||
|
L.push(' volumes:', ...t.volumes.filter((v) => v?.container).map((v) => {
|
||||||
|
const host = v.bind ? v.bind.replace(/^\/portainer\/Files\/AppData\/[^/]+\/?/, './data/') : `./data${v.container}`;
|
||||||
|
return ` - ${y(`${host.replace(/\/$/, '') || './data'}:${v.container}${v.readonly ? ':ro' : ''}`)}`;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (t.labels?.length) L.push(' labels:', ...t.labels.filter((l) => l?.name).map((l) => ` ${y(l.name)}: ${y(l.value ?? '')}`));
|
||||||
|
return L.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert { name, compose, env, note }
|
||||||
|
async function build(index, url) {
|
||||||
|
const l = await list(url);
|
||||||
|
const item = l.find((x) => x.i === index);
|
||||||
|
if (!item) throw new Error('Template not found');
|
||||||
|
const t = item.t;
|
||||||
|
const name = slug(t.name || t.title);
|
||||||
|
let compose;
|
||||||
|
if (t.image) compose = containerCompose(t, name);
|
||||||
|
else {
|
||||||
|
const m = /^https:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(t.repository.url || '');
|
||||||
|
if (!m) throw new Error(`Only GitHub repositories are supported (${t.repository.url}). Use “Clone from Git” instead.`);
|
||||||
|
const res = await fetch(`https://raw.githubusercontent.com/${m[1]}/${m[2]}/HEAD/${t.repository.stackfile.replace(/^\/+/, '')}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${t.repository.stackfile}`);
|
||||||
|
compose = await res.text();
|
||||||
|
}
|
||||||
|
return { name, compose, env: envFile(t.env), note: String(t.note || '').replace(/<[^>]+>/g, '') };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { list: async (url) => summary(await list(url)), build, DEFAULT_URL };
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Minimaler CTAP2-Client über Linux-hidraw (ohne native Module).
|
||||||
|
// Nötig, weil Chromium/Electron keine PIN-Eingabe für WebAuthn hat: Schlüssel mit gesetzter FIDO2-PIN
|
||||||
|
// (z. B. YubiKey 5 ohne makeCredUvNotRqd) verlangen die PIN beim Registrieren, und Chromium bricht dann
|
||||||
|
// nach dem Berühren mit NotAllowedError ab. Hier läuft makeCredential direkt, mit PIN-Token (Protokoll 1).
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// ---- CBOR (nur was CTAP2 braucht; Map-Schlüssel kanonisch sortiert) ----
|
||||||
|
function head(major, n) {
|
||||||
|
if (n < 24) return Buffer.from([(major << 5) | n]);
|
||||||
|
if (n < 0x100) return Buffer.from([(major << 5) | 24, n]);
|
||||||
|
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b; }
|
||||||
|
const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b;
|
||||||
|
}
|
||||||
|
function enc(v) {
|
||||||
|
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
|
||||||
|
if (typeof v === 'boolean') return Buffer.from([v ? 0xf5 : 0xf4]);
|
||||||
|
if (typeof v === 'string') { const s = Buffer.from(v, 'utf8'); return Buffer.concat([head(3, s.length), s]); }
|
||||||
|
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.concat([head(2, v.length), Buffer.from(v)]);
|
||||||
|
if (Array.isArray(v)) return Buffer.concat([head(4, v.length), ...v.map(enc)]);
|
||||||
|
if (v instanceof Map) {
|
||||||
|
const items = [...v].map(([k, x]) => [enc(k), enc(x)])
|
||||||
|
.sort(([a], [b]) => a.length - b.length || Buffer.compare(a, b));
|
||||||
|
return Buffer.concat([head(5, items.length), ...items.flat()]);
|
||||||
|
}
|
||||||
|
throw new Error('CBOR: unsupported value');
|
||||||
|
}
|
||||||
|
function dec(buf, pos = 0) {
|
||||||
|
const ib = buf[pos++], major = ib >> 5, ai = ib & 31;
|
||||||
|
let n = ai;
|
||||||
|
if (ai === 24) n = buf[pos++];
|
||||||
|
else if (ai === 25) { n = buf.readUInt16BE(pos); pos += 2; }
|
||||||
|
else if (ai === 26) { n = buf.readUInt32BE(pos); pos += 4; }
|
||||||
|
else if (ai === 27) { n = Number(buf.readBigUInt64BE(pos)); pos += 8; }
|
||||||
|
switch (major) {
|
||||||
|
case 0: return [n, pos];
|
||||||
|
case 1: return [-1 - n, pos];
|
||||||
|
case 2: return [buf.subarray(pos, pos + n), pos + n];
|
||||||
|
case 3: return [buf.toString('utf8', pos, pos + n), pos + n];
|
||||||
|
case 4: { const a = []; for (let i = 0; i < n; i++) { let x; [x, pos] = dec(buf, pos); a.push(x); } return [a, pos]; }
|
||||||
|
case 5: { const m = new Map(); for (let i = 0; i < n; i++) { let k, x; [k, pos] = dec(buf, pos); [x, pos] = dec(buf, pos); m.set(k, x); } return [m, pos]; }
|
||||||
|
case 6: return dec(buf, pos);
|
||||||
|
default: return [ai === 20 ? false : ai === 21 ? true : null, pos];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CTAPHID ----
|
||||||
|
const CMD = { CBOR: 0x10, INIT: 0x06, CANCEL: 0x11, KEEPALIVE: 0x3b, ERROR: 0x3f };
|
||||||
|
|
||||||
|
class CtapError extends Error {
|
||||||
|
constructor(code) { super(`CTAP2 error 0x${code.toString(16).padStart(2, '0')}`); this.code = code; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDevice() {
|
||||||
|
const base = '/sys/class/hidraw';
|
||||||
|
let names = [];
|
||||||
|
try { names = fs.readdirSync(base); } catch { return null; }
|
||||||
|
for (const n of names) {
|
||||||
|
try {
|
||||||
|
const rd = fs.readFileSync(path.join(base, n, 'device', 'report_descriptor'));
|
||||||
|
if (rd.includes(Buffer.from([0x06, 0xd0, 0xf1]))) return '/dev/' + n; // Usage Page 0xF1D0 (FIDO)
|
||||||
|
} catch { /* ignorieren */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Device {
|
||||||
|
static open() {
|
||||||
|
const p = findDevice();
|
||||||
|
if (!p) return null;
|
||||||
|
const d = new Device();
|
||||||
|
d.fd = fs.openSync(p, 'r+');
|
||||||
|
d.cid = Buffer.from([0xff, 0xff, 0xff, 0xff]);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
try { fs.closeSync(this.fd); } catch { /* ignorieren */ }
|
||||||
|
}
|
||||||
|
// Nur lesen, während eine Antwort erwartet wird – so hängt nach close() kein blockierender Read im Threadpool
|
||||||
|
readPacket() {
|
||||||
|
const b = Buffer.alloc(64);
|
||||||
|
return new Promise((resolve, reject) => fs.read(this.fd, b, 0, 64, null, (e, n) => (e ? reject(e) : resolve(b.subarray(0, n)))));
|
||||||
|
}
|
||||||
|
write(cmd, data) {
|
||||||
|
const pkt = (b) => { const r = Buffer.alloc(65); b.copy(r, 1); fs.writeSync(this.fd, r); };
|
||||||
|
const first = Buffer.alloc(64);
|
||||||
|
this.cid.copy(first, 0); first[4] = 0x80 | cmd; first.writeUInt16BE(data.length, 5);
|
||||||
|
data.copy(first, 7, 0, 57); pkt(first);
|
||||||
|
for (let off = 57, seq = 0; off < data.length; off += 59, seq++) {
|
||||||
|
const c = Buffer.alloc(64);
|
||||||
|
this.cid.copy(c, 0); c[4] = seq; data.copy(c, 5, off, off + 59); pkt(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async transact(cmd, data) {
|
||||||
|
this.write(cmd, data);
|
||||||
|
for (;;) {
|
||||||
|
const r = await this.readPacket();
|
||||||
|
if (!r.subarray(0, 4).equals(this.cid)) continue;
|
||||||
|
const rcmd = r[4] & 0x7f;
|
||||||
|
if (rcmd === CMD.KEEPALIVE) continue;
|
||||||
|
const len = r.readUInt16BE(5);
|
||||||
|
const parts = [r.subarray(7)];
|
||||||
|
let got = r.length - 7;
|
||||||
|
while (got < len) { const c = await this.readPacket(); parts.push(c.subarray(5)); got += c.length - 5; }
|
||||||
|
const body = Buffer.concat(parts).subarray(0, len);
|
||||||
|
if (rcmd === CMD.ERROR) throw new Error(`CTAPHID error 0x${body[0].toString(16)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
const nonce = crypto.randomBytes(8);
|
||||||
|
for (;;) {
|
||||||
|
const r = await this.transact(CMD.INIT, nonce);
|
||||||
|
if (r.subarray(0, 8).equals(nonce)) { this.cid = Buffer.from(r.subarray(8, 12)); return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel() { try { if (!this.closed) this.write(CMD.CANCEL, Buffer.alloc(0)); } catch { /* ignorieren */ } }
|
||||||
|
async cbor(cmd, params) {
|
||||||
|
const r = await this.transact(CMD.CBOR, Buffer.concat([Buffer.from([cmd]), params ? enc(params) : Buffer.alloc(0)]));
|
||||||
|
if (r[0] !== 0) throw new CtapError(r[0]);
|
||||||
|
return r.length > 1 ? dec(r, 1)[0] : new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PIN-Protokoll 1 ----
|
||||||
|
const aes = (mode, key, data) => {
|
||||||
|
const c = mode === 'enc' ? crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16)) : crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
|
||||||
|
c.setAutoPadding(false);
|
||||||
|
return Buffer.concat([c.update(data), c.final()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sharedSecret(dev) {
|
||||||
|
const ka = (await dev.cbor(0x06, new Map([[1, 1], [2, 2]]))).get(1);
|
||||||
|
const ecdh = crypto.createECDH('prime256v1');
|
||||||
|
ecdh.generateKeys();
|
||||||
|
const z = ecdh.computeSecret(Buffer.concat([Buffer.from([4]), ka.get(-2), ka.get(-3)]));
|
||||||
|
const pub = ecdh.getPublicKey();
|
||||||
|
const platformKey = new Map([[1, 2], [3, -25], [-1, 1], [-2, pub.subarray(1, 33)], [-3, pub.subarray(33, 65)]]);
|
||||||
|
return { key: crypto.createHash('sha256').update(z).digest(), platformKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinToken(dev, pin) {
|
||||||
|
const { key, platformKey } = await sharedSecret(dev);
|
||||||
|
const pinHash = crypto.createHash('sha256').update(pin, 'utf8').digest().subarray(0, 16);
|
||||||
|
const r = await dev.cbor(0x06, new Map([[1, 1], [2, 5], [3, platformKey], [6, aes('enc', key, pinHash)]]));
|
||||||
|
return aes('dec', key, r.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinRetries(dev) {
|
||||||
|
try { return (await dev.cbor(0x06, new Map([[1, 1], [2, 1]]))).get(3); } catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-Codes, die für "abgebrochen / nicht bestätigt / Zeit abgelaufen" stehen
|
||||||
|
const CANCEL_CODES = new Set([0x27, 0x2d, 0x2f, 0x3a]);
|
||||||
|
const PIN = { INVALID: 0x31, BLOCKED: 0x32, AUTH_BLOCKED: 0x34 };
|
||||||
|
|
||||||
|
// Registriert ein nicht-residentes Credential mit hmac-secret für rpId "localhost" (kompatibel zur
|
||||||
|
// WebAuthn-PRF-Abfrage in fido.js). ui: { askPin(retries, wrong) -> Promise<string|null>, touch() }
|
||||||
|
// Liefert die Credential-ID als Buffer, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
|
||||||
|
async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
|
||||||
|
let dev;
|
||||||
|
try { dev = Device.open(); } catch { return null; }
|
||||||
|
if (!dev) return null;
|
||||||
|
let timer;
|
||||||
|
const abort = () => dev.cancel();
|
||||||
|
try {
|
||||||
|
await dev.init();
|
||||||
|
const info = await dev.cbor(0x04);
|
||||||
|
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
|
||||||
|
if (!(info.get(2) || []).includes('hmac-secret')) throw new CtapError(-1);
|
||||||
|
const opts = info.get(4) || new Map();
|
||||||
|
|
||||||
|
const params = new Map([
|
||||||
|
[2, new Map([['id', 'localhost'], ['name', 'MrTerm']])],
|
||||||
|
[3, new Map([['id', crypto.randomBytes(16)], ['name', 'MrTerm'], ['displayName', 'MrTerm']])],
|
||||||
|
[4, [-7, -8, -257].map((alg) => new Map([['alg', alg], ['type', 'public-key']]))],
|
||||||
|
[6, new Map([['hmac-secret', true]])],
|
||||||
|
]);
|
||||||
|
const cdh = crypto.randomBytes(32);
|
||||||
|
params.set(1, cdh);
|
||||||
|
|
||||||
|
if (opts.get('clientPin') === true && opts.get('makeCredUvNotRqd') !== true) {
|
||||||
|
let wrong = false;
|
||||||
|
for (;;) {
|
||||||
|
const pin = await ui.askPin(await pinRetries(dev), wrong);
|
||||||
|
if (pin == null) throw new CtapError(0x2d);
|
||||||
|
try {
|
||||||
|
const tok = await pinToken(dev, pin);
|
||||||
|
params.set(8, crypto.createHmac('sha256', tok).update(cdh).digest().subarray(0, 16));
|
||||||
|
params.set(9, 1);
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === PIN.INVALID) { wrong = true; continue; }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.touch(abort);
|
||||||
|
timer = setTimeout(abort, timeoutMs);
|
||||||
|
const r = await dev.cbor(0x01, params);
|
||||||
|
const authData = r.get(2);
|
||||||
|
const idLen = authData.readUInt16BE(53);
|
||||||
|
return Buffer.from(authData.subarray(55, 55 + idLen));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
dev.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit
|
||||||
|
// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert.
|
||||||
|
// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
|
||||||
|
async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) {
|
||||||
|
let dev;
|
||||||
|
try { dev = Device.open(); } catch { return null; }
|
||||||
|
if (!dev) return null;
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
await dev.init();
|
||||||
|
const info = await dev.cbor(0x04);
|
||||||
|
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
|
||||||
|
const rpId = 'localhost';
|
||||||
|
const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]);
|
||||||
|
// Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false)
|
||||||
|
let cred = null;
|
||||||
|
for (const c of creds) {
|
||||||
|
try {
|
||||||
|
await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]]));
|
||||||
|
cred = c; break;
|
||||||
|
} catch (e) { if (e.code !== 0x2e) throw e; }
|
||||||
|
}
|
||||||
|
if (!cred) throw new CtapError(0x2e);
|
||||||
|
const { key, platformKey } = await sharedSecret(dev);
|
||||||
|
const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest();
|
||||||
|
const saltEnc = aes('enc', key, salt);
|
||||||
|
const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16);
|
||||||
|
ui.touch(() => dev.cancel());
|
||||||
|
timer = setTimeout(() => dev.cancel(), timeoutMs);
|
||||||
|
const r = await dev.cbor(0x02, new Map([
|
||||||
|
[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]],
|
||||||
|
[4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])],
|
||||||
|
]));
|
||||||
|
const authData = r.get(2);
|
||||||
|
if (!(authData[32] & 0x80)) throw new CtapError(-1);
|
||||||
|
const ext = dec(authData, 37)[0].get('hmac-secret');
|
||||||
|
if (!ext) throw new CtapError(-1);
|
||||||
|
return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
dev.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN };
|
||||||
+146
-3
@@ -2,12 +2,19 @@
|
|||||||
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
|
// 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.
|
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
|
const { acquireSudo } = require('./sudo');
|
||||||
|
|
||||||
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
|
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 LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
|
||||||
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
|
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 lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
|
const q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
|
||||||
|
const STACK_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
||||||
|
const COMPOSE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||||
|
const absPath = (p) => { if (typeof p !== 'string' || !p.startsWith('/') || /[\n\0]/.test(p) || p.split('/').includes('..')) throw new Error('Invalid path'); return p; };
|
||||||
|
const dirname = (p) => p.replace(/\/[^/]*$/, '') || '/';
|
||||||
|
const b64 = (t) => Buffer.from(String(t), 'utf8').toString('base64');
|
||||||
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
||||||
|
|
||||||
function execOn(conn, cmd, stdin) {
|
function execOn(conn, cmd, stdin) {
|
||||||
@@ -31,12 +38,18 @@ class DockerManager {
|
|||||||
|
|
||||||
async open(id, host, onClose) {
|
async open(id, host, onClose) {
|
||||||
const { conn, jumps } = await this.ssh.connect(host, id);
|
const { conn, jumps } = await this.ssh.connect(host, id);
|
||||||
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false });
|
this.sessions.set(id, { id, conn, jumps, host, runtime: null, sudo: false });
|
||||||
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
||||||
conn.on('error', () => {});
|
conn.on('error', () => {});
|
||||||
return this.list(id);
|
return this.list(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sudo-Passwort einmal pro Sitzung ermitteln (ohne Passwort, gespeichertes Host-Passwort oder Nachfrage)
|
||||||
|
async sudo(id, s) {
|
||||||
|
if (s.sudoPw === undefined) s.sudoPw = await acquireSudo(this.ssh, id, s.host, (c, stdin) => execOn(s.conn, c, stdin));
|
||||||
|
return s.sudoPw != null;
|
||||||
|
}
|
||||||
|
|
||||||
get(id) {
|
get(id) {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
if (!s) throw new Error(i18n.t('Docker session not found'));
|
if (!s) throw new Error(i18n.t('Docker session not found'));
|
||||||
@@ -45,7 +58,7 @@ class DockerManager {
|
|||||||
|
|
||||||
run(s, args) {
|
run(s, args) {
|
||||||
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||||
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
|
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(id) {
|
async list(id) {
|
||||||
@@ -56,7 +69,7 @@ class DockerManager {
|
|||||||
s.runtime = r.out.trim();
|
s.runtime = r.out.trim();
|
||||||
}
|
}
|
||||||
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||||
if (r.code && denied(r.err) && !s.sudo) {
|
if (r.code && denied(r.err) && !s.sudo && await this.sudo(id, s)) {
|
||||||
s.sudo = true;
|
s.sudo = true;
|
||||||
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||||
if (r.code) s.sudo = false;
|
if (r.code) s.sudo = false;
|
||||||
@@ -102,6 +115,136 @@ class DockerManager {
|
|||||||
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
|
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Compose-Stacks ----------
|
||||||
|
// Stacks liegen je in einem Unterordner von stacksDir (wie Dockge); zusätzlich werden per `compose ls`
|
||||||
|
// gefundene Projekte außerhalb davon angezeigt. Dateien werden ohne sudo geschrieben, sonst per sudo mit Host-Passwort.
|
||||||
|
async composeCmd(s) {
|
||||||
|
if (s.compose) return s.compose;
|
||||||
|
const cands = s.runtime === 'podman' ? ['podman compose', 'podman-compose'] : ['docker compose', 'docker-compose'];
|
||||||
|
for (const c of cands) {
|
||||||
|
const r = await execOn(s.conn, `${c} version >/dev/null 2>&1`);
|
||||||
|
if (!r.code) return (s.compose = c);
|
||||||
|
}
|
||||||
|
throw new Error(i18n.t('Docker Compose was not found on this host (docker compose plugin or docker-compose).'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort)
|
||||||
|
async sh(s, script) {
|
||||||
|
let r = await execOn(s.conn, `sh -c ${q(script)}`);
|
||||||
|
if (r.code && /permission denied|operation not permitted/i.test(r.err) && await this.sudo(s.id, s)) {
|
||||||
|
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.sudoPw}\n`);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stacks(id, stacksDir) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const dir = absPath(stacksDir);
|
||||||
|
const compose = await this.composeCmd(s);
|
||||||
|
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
|
||||||
|
const scan = `d=${q(dir)}; [ -d "$d" ] && echo "#exists"; [ -w "$d" ] && echo "#writable"; for p in "$d"/*/; do [ -d "$p" ] || continue; `
|
||||||
|
+ `f=""; for c in ${COMPOSE_NAMES.join(' ')}; do [ -f "$p$c" ] && { f="$p$c"; break; }; done; `
|
||||||
|
+ `[ -n "$f" ] || f=$(find "$p" -maxdepth 3 \\( ${find} \\) 2>/dev/null | head -n1); `
|
||||||
|
+ `g=""; [ -d "$p.git" ] && g="git:$(git -C "$p" remote get-url origin 2>/dev/null)"; `
|
||||||
|
+ `printf '%s\\t%s\\t%s\\n' "$(basename "$p")" "$f" "$g"; done`;
|
||||||
|
const [r, ls] = await Promise.all([execOn(s.conn, `sh -c ${q(scan)}`), this.runCompose(s, compose, 'ls -a --format json')]);
|
||||||
|
const lines = r.out.split('\n').filter(Boolean);
|
||||||
|
let running = [];
|
||||||
|
try { running = JSON.parse(ls.out.trim() || '[]'); } catch { /* podman-compose kennt kein ls */ }
|
||||||
|
const status = new Map(running.map((x) => [x.Name, { status: x.Status, files: String(x.ConfigFiles || '').split(',').filter(Boolean) }]));
|
||||||
|
const list = lines.filter((l) => !l.startsWith('#')).map((l) => {
|
||||||
|
const [name, file, git] = l.split('\t');
|
||||||
|
return { name, dir: `${dir.replace(/\/$/, '')}/${name}`, file: file ? file.replace(/\/\//g, '/') : '', git: git ? git.slice(4) || true : false, managed: true, status: status.get(name)?.status || '' };
|
||||||
|
});
|
||||||
|
for (const [name, st] of status) {
|
||||||
|
if (list.some((x) => x.name === name) || !st.files[0]) continue;
|
||||||
|
list.push({ name, dir: dirname(st.files[0]), file: st.files[0], git: false, managed: false, status: st.status });
|
||||||
|
}
|
||||||
|
return { dir, exists: lines.includes('#exists'), writable: lines.includes('#writable'), compose, sudo: s.sudo, stacks: list.sort((a, b) => a.name.localeCompare(b.name)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
runCompose(s, compose, args) {
|
||||||
|
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||||
|
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async readStack(id, file) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const f = absPath(file);
|
||||||
|
// .env wird mit umask 077 geschrieben – beim Speichern per sudo gehört sie root. Dann scheitert cat mit
|
||||||
|
// „Permission denied“ und sh() wiederholt das Lesen per sudo (vorher wurde der Fehler verschluckt).
|
||||||
|
const env = q(dirname(f) + '/.env');
|
||||||
|
const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { [ ! -e ${env} ] || cat ${env}; }`);
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Could not read {file}', { file: f }));
|
||||||
|
const i = r.out.lastIndexOf('\n\0');
|
||||||
|
return { compose: r.out.slice(0, i), env: r.out.slice(i + 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// { stacksDir, name, file?, compose, env } – ohne file wird ein neuer Stack angelegt. Liefert { file, warning }
|
||||||
|
async saveStack(id, { stacksDir, name, file, compose, env }) {
|
||||||
|
const s = this.get(id);
|
||||||
|
let f;
|
||||||
|
if (file) f = absPath(file);
|
||||||
|
else {
|
||||||
|
if (!STACK_NAME.test(name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
|
||||||
|
f = `${absPath(stacksDir).replace(/\/$/, '')}/${name}/compose.yaml`;
|
||||||
|
}
|
||||||
|
const d = dirname(f);
|
||||||
|
const envFile = `${d}/.env`;
|
||||||
|
const script = `${file ? '' : `[ -e ${q(d)} ] && { echo "exists" >&2; exit 3; }; `}mkdir -p ${q(d)} && echo ${b64(compose)} | base64 -d > ${q(f)} && `
|
||||||
|
+ (String(env || '').trim() ? `(umask 077; echo ${b64(env.endsWith('\n') ? env : env + '\n')} | base64 -d > ${q(envFile)})` : `rm -f ${q(envFile)}`);
|
||||||
|
const r = await this.sh(s, script);
|
||||||
|
if (r.code === 3) throw new Error(i18n.t('A stack named “{name}” already exists.', { name }));
|
||||||
|
if (r.code) {
|
||||||
|
if (/permission denied/i.test(r.err)) throw new Error(i18n.t('No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.', { dir: d }));
|
||||||
|
throw new Error(lastLine(r.err) || 'Error');
|
||||||
|
}
|
||||||
|
const compose2 = await this.composeCmd(s);
|
||||||
|
const v = await this.runCompose(s, compose2, `--project-directory ${q(d)} -f ${q(f)} config -q`);
|
||||||
|
return { file: f, warning: v.code ? lastLine(v.err) || 'compose config failed' : '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehl für einen Terminal-Tab (Ausgabe live; sudo/git fragen dort selbst nach Passwörtern)
|
||||||
|
async stackCommand(id, action, st, opts = {}) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const compose = `${s.sudo ? 'sudo ' : ''}${await this.composeCmd(s)}`;
|
||||||
|
const S = (dir) => `S=$([ -w ${q(dir)} ] || echo sudo); `;
|
||||||
|
if (action === 'setup') {
|
||||||
|
const d = absPath(st.dir);
|
||||||
|
return `sudo mkdir -p ${q(d)} && sudo chown "$(id -u):$(id -g)" ${q(d)} && echo OK: ${q(d)}`;
|
||||||
|
}
|
||||||
|
if (action === 'clone') {
|
||||||
|
if (!STACK_NAME.test(st.name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
|
||||||
|
if (!/^(https?:\/\/|git@|ssh:\/\/)\S+$/.test(opts.url || '')) throw new Error(i18n.t('Invalid repository URL: {url}', { url: opts.url || '' }));
|
||||||
|
const parent = absPath(st.stacksDir).replace(/\/$/, '');
|
||||||
|
const d = `${parent}/${st.name}`;
|
||||||
|
const sub = opts.path ? absPath('/' + opts.path.replace(/^\/+/, '')).slice(1) : '';
|
||||||
|
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
|
||||||
|
const locate = sub ? `f=${q(`${d}/${sub}`)}` : `f=$(find ${q(d)} -maxdepth 3 \\( ${find} \\) | head -n1)`;
|
||||||
|
return `${S(parent)}command -v git >/dev/null || { echo "git is not installed"; exit 1; }; $S git clone ${opts.branch ? `--branch ${q(opts.branch)} ` : ''}${q(opts.url)} ${q(d)} && `
|
||||||
|
+ `{ [ "$S" ] && $S chown -R "$(id -u):$(id -g)" ${q(d)}; true; } && ${locate} && [ -f "$f" ] && echo "Compose: $f"`
|
||||||
|
+ (opts.deploy ? ` && cd "$(dirname "$f")" && ${compose} -p ${q(st.name)} up -d` : '');
|
||||||
|
}
|
||||||
|
const f = absPath(st.file);
|
||||||
|
const cd = `cd ${q(dirname(f))} && `;
|
||||||
|
const p = `${compose} -p ${q(st.name)} -f ${q(f)}`;
|
||||||
|
switch (action) {
|
||||||
|
case 'up': return `${cd}${p} up -d --remove-orphans`;
|
||||||
|
case 'stop': return `${cd}${p} stop`;
|
||||||
|
case 'restart': return `${cd}${p} restart`;
|
||||||
|
case 'down': return `${cd}${p} down`;
|
||||||
|
case 'pull': return `${cd}${p} pull && ${p} up -d --remove-orphans`;
|
||||||
|
case 'logs': return `${cd}${p} logs -f --tail 200`;
|
||||||
|
case 'gitpull': return `${cd}${S(dirname(f))}$S git pull --ff-only && ${p} up -d --remove-orphans`;
|
||||||
|
case 'delete': {
|
||||||
|
if (!st.managed) throw new Error('Only stacks in the stacks folder can be deleted');
|
||||||
|
const d = absPath(st.dir);
|
||||||
|
return `${cd}${p} down${opts.volumes ? ' -v' : ''}; cd / && ${S(dirname(d))}$S rm -rf ${q(d)} && echo "Deleted: ${d.replace(/"/g, '')}"`;
|
||||||
|
}
|
||||||
|
default: throw new Error('Invalid action');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close(id) {
|
close(id) {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
|||||||
+87
-8
@@ -4,8 +4,11 @@
|
|||||||
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
// 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
|
// 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).
|
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
||||||
|
// Registrieren läuft unter Linux direkt per CTAP2 (ctap2.js), weil Electron keine PIN-Abfrage kennt und
|
||||||
|
// Schlüssel mit gesetzter PIN sonst nach dem Berühren mit NotAllowedError scheitern.
|
||||||
const { BrowserWindow, session } = require('electron');
|
const { BrowserWindow, session } = require('electron');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
|
const ctap2 = require('./ctap2');
|
||||||
|
|
||||||
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
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}
|
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
||||||
@@ -13,7 +16,10 @@ const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
|||||||
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
.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{-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}
|
button:hover{background:#343a4f}
|
||||||
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`;
|
form{display:flex;gap:8px;-webkit-app-region:no-drag} form[hidden]{display:none}
|
||||||
|
input{background:#10131b;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 10px;font:inherit;width:170px}
|
||||||
|
.row{display:flex;gap:8px}
|
||||||
|
</style></head><body><div class="ic">🔑</div><div id="t"></div><form id="f" hidden><input id="p" type="password" autocomplete="off"><button id="ok"></button></form><div class="row"><button id="c"></button></div></body></html>`;
|
||||||
|
|
||||||
let fidoSession;
|
let fidoSession;
|
||||||
function getSession() {
|
function getSession() {
|
||||||
@@ -29,17 +35,23 @@ const HELPERS = `
|
|||||||
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function ceremony(parent, text, script) {
|
async function openWindow(parent, text) {
|
||||||
const w = new BrowserWindow({
|
const w = new BrowserWindow({
|
||||||
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
|
parent, modal: !!parent, width: 420, height: 230, frame: false, resizable: false, show: false,
|
||||||
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
||||||
});
|
});
|
||||||
|
await w.loadURL('http://localhost/');
|
||||||
|
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
||||||
|
document.getElementById('ok').textContent = ${JSON.stringify(i18n.t('OK'))};
|
||||||
|
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
||||||
|
w.show();
|
||||||
|
w.focus();
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ceremony(parent, text, script) {
|
||||||
|
const w = await openWindow(parent, text);
|
||||||
try {
|
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 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]);
|
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 === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
@@ -50,8 +62,71 @@ async function ceremony(parent, text, script) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registrierung direkt per CTAP2 (Linux). Liefert die Credential-ID (base64url) oder null, wenn kein
|
||||||
|
// FIDO2-Gerät per hidraw erreichbar ist (dann übernimmt Chromium).
|
||||||
|
async function registerNative(parent) {
|
||||||
|
const w = await openWindow(parent, i18n.t('Connecting to security key …'));
|
||||||
|
let abort = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
w.once('closed', () => { cancelled = true; abort(); });
|
||||||
|
const js = (code) => (w.isDestroyed() ? Promise.resolve(null) : w.webContents.executeJavaScript(code).catch(() => null));
|
||||||
|
const setText = (t) => js(`document.getElementById('t').textContent = ${JSON.stringify(t)}; 0;`);
|
||||||
|
const ui = {
|
||||||
|
async askPin(retries, wrong) {
|
||||||
|
let t = i18n.t('Enter the PIN of your security key.');
|
||||||
|
if (wrong) t = i18n.t('Wrong PIN.') + ' ' + t;
|
||||||
|
if (retries != null) t += ' ' + i18n.t('{n} attempts left.', { n: retries });
|
||||||
|
await setText(t);
|
||||||
|
const closed = new Promise((resolve) => w.once('closed', () => resolve(null)));
|
||||||
|
return Promise.race([closed, js(`new Promise((resolve) => {
|
||||||
|
const f = document.getElementById('f'), p = document.getElementById('p');
|
||||||
|
f.hidden = false; p.value = ''; p.focus();
|
||||||
|
f.onsubmit = (e) => { e.preventDefault(); f.hidden = true; resolve(p.value); };
|
||||||
|
})`)]);
|
||||||
|
},
|
||||||
|
touch(a) { abort = a; if (cancelled) a(); setText(i18n.t('Touch your security key to register it.')); },
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const id = await ctap2.makeCredential(ui);
|
||||||
|
return id && b64url(id);
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
|
if (e.code === ctap2.PIN.BLOCKED || e.code === ctap2.PIN.AUTH_BLOCKED) throw new Error(i18n.t('The PIN of this security key is blocked. Remove and reinsert the key, or reset it.'));
|
||||||
|
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (!w.isDestroyed()) w.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist
|
||||||
|
async function deriveNative(parent, creds, text) {
|
||||||
|
const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.'));
|
||||||
|
let abort = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
w.once('closed', () => { cancelled = true; abort(); });
|
||||||
|
try {
|
||||||
|
const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } },
|
||||||
|
creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') })));
|
||||||
|
return r && { credId: b64url(r.credId), secret: r.secret };
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||||
|
if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.'));
|
||||||
|
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
if (!w.isDestroyed()) w.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64url = (buf) => Buffer.from(buf).toString('base64url');
|
||||||
|
|
||||||
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
||||||
async function register(parent) {
|
async function register(parent) {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const id = await registerNative(parent);
|
||||||
|
if (id) return id;
|
||||||
|
}
|
||||||
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
||||||
const cred = await navigator.credentials.create({ publicKey: {
|
const cred = await navigator.credentials.create({ publicKey: {
|
||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
@@ -69,6 +144,10 @@ async function register(parent) {
|
|||||||
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
||||||
// Liefert { credId, secret: Buffer(32) }
|
// Liefert { credId, secret: Buffer(32) }
|
||||||
async function derive(parent, creds, text) {
|
async function derive(parent, creds, text) {
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const r = await deriveNative(parent, creds, text);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
||||||
const creds = ${JSON.stringify(creds)};
|
const creds = ${JSON.stringify(creds)};
|
||||||
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
|
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
const { execOn } = require('./docker');
|
const { execOn } = require('./docker');
|
||||||
|
const { acquireSudo } = require('./sudo');
|
||||||
|
|
||||||
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||||
const RX = {
|
const RX = {
|
||||||
@@ -58,7 +59,7 @@ class FirewallManager {
|
|||||||
// Befehl mit root-Rechten ausführen
|
// Befehl mit root-Rechten ausführen
|
||||||
run(s, cmd) {
|
run(s, cmd) {
|
||||||
if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${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`);
|
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
|
||||||
}
|
}
|
||||||
async runOk(s, cmd) {
|
async runOk(s, cmd) {
|
||||||
const r = await this.run(s, cmd);
|
const r = await this.run(s, cmd);
|
||||||
@@ -75,8 +76,8 @@ class FirewallManager {
|
|||||||
const uid = (await execOn(conn, 'id -u')).out.trim();
|
const uid = (await execOn(conn, 'id -u')).out.trim();
|
||||||
if (uid !== '0') {
|
if (uid !== '0') {
|
||||||
s.sudo = true;
|
s.sudo = true;
|
||||||
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
|
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
|
||||||
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.'));
|
if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
|
||||||
}
|
}
|
||||||
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\'');
|
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);
|
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||||
|
|||||||
+137
-31
@@ -15,7 +15,13 @@ const { VpnManager } = require('./vpn');
|
|||||||
const { DockerManager } = require('./docker');
|
const { DockerManager } = require('./docker');
|
||||||
const { FirewallManager } = require('./firewall');
|
const { FirewallManager } = require('./firewall');
|
||||||
const { NetworkConfigManager } = require('./network');
|
const { NetworkConfigManager } = require('./network');
|
||||||
|
const { WebServerManager } = require('./webserver');
|
||||||
|
const { CertManager, probe: probeTls } = require('./certs');
|
||||||
|
const { SambaManager } = require('./samba');
|
||||||
const { SyncService } = require('./sync');
|
const { SyncService } = require('./sync');
|
||||||
|
const backup = require('../core/backup');
|
||||||
|
const composeTemplates = require('./compose-templates');
|
||||||
|
const { AiAssistant } = require('./ai');
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
@@ -40,8 +46,19 @@ process.on('uncaughtException', (e) => {
|
|||||||
});
|
});
|
||||||
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||||
|
|
||||||
|
// Abgelöste Tabs laufen in eigenen Fenstern. Ereignisse gehen an alle Fenster (die Oberfläche filtert nach Sitzungs-ID),
|
||||||
|
// Passwortabfragen nur an das Fenster, das die Sitzung zeigt; Kopplung/Updates nur an das Hauptfenster.
|
||||||
|
const tabWins = new Set();
|
||||||
|
const sessionOwner = new Map(); // Sitzungs-ID -> BrowserWindow (nur für abgelöste Fenster)
|
||||||
|
const MAIN_ONLY = new Set(['sync:pairPrompt', 'update:available']);
|
||||||
|
const alive = (w) => w && !w.isDestroyed();
|
||||||
function send(channel, ...args) {
|
function send(channel, ...args) {
|
||||||
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args);
|
if (channel === 'secret:request') {
|
||||||
|
const w = sessionOwner.get(args[0]?.sessionId);
|
||||||
|
if (alive(w)) return w.webContents.send(channel, ...args);
|
||||||
|
}
|
||||||
|
if (alive(win)) win.webContents.send(channel, ...args);
|
||||||
|
if (!MAIN_ONLY.has(channel)) for (const w of tabWins) if (alive(w)) w.webContents.send(channel, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmHostKey(target, fingerprint, known) {
|
async function confirmHostKey(target, fingerprint, known) {
|
||||||
@@ -75,6 +92,9 @@ const vpn = new VpnManager(store, app.getPath('userData'));
|
|||||||
const docker = new DockerManager(ssh);
|
const docker = new DockerManager(ssh);
|
||||||
const firewall = new FirewallManager(ssh);
|
const firewall = new FirewallManager(ssh);
|
||||||
const network = new NetworkConfigManager(ssh);
|
const network = new NetworkConfigManager(ssh);
|
||||||
|
// Verwaltungs-Tabs mit gemeinsamen Kanälen <prefix>:open/read/op/close (siehe admin.js)
|
||||||
|
const admins = { web: new WebServerManager(ssh), certs: new CertManager(ssh), samba: new SambaManager(ssh) };
|
||||||
|
const ai = new AiAssistant(store, send);
|
||||||
|
|
||||||
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
|
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
|
||||||
const pairReplies = new Map();
|
const pairReplies = new Map();
|
||||||
@@ -87,12 +107,13 @@ const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
|
|||||||
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
|
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
|
||||||
store.onChange = () => sync.schedule();
|
store.onChange = () => sync.schedule();
|
||||||
|
|
||||||
function createWindow() {
|
function newWindow(opts = {}) {
|
||||||
win = new BrowserWindow({
|
const w = new BrowserWindow({
|
||||||
width: 1360,
|
width: 1360,
|
||||||
height: 860,
|
height: 860,
|
||||||
minWidth: 900,
|
minWidth: 600,
|
||||||
minHeight: 560,
|
minHeight: 400,
|
||||||
|
...opts,
|
||||||
backgroundColor: '#14161d',
|
backgroundColor: '#14161d',
|
||||||
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
|
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
|
||||||
frame: false,
|
frame: false,
|
||||||
@@ -104,18 +125,57 @@ function createWindow() {
|
|||||||
sandbox: true,
|
sandbox: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
w.on('maximize', () => w.webContents.send('win:state', true));
|
||||||
|
w.on('unmaximize', () => w.webContents.send('win:state', false));
|
||||||
|
w.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
win = newWindow({ minWidth: 900, minHeight: 560 });
|
||||||
Menu.setApplicationMenu(null);
|
Menu.setApplicationMenu(null);
|
||||||
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
|
||||||
win.on('maximize', () => send('win:state', true));
|
// Hauptfenster zu = MrTerm beenden, auch abgelöste Tab-Fenster schließen
|
||||||
|
win.on('closed', () => { for (const w of tabWins) if (alive(w)) w.close(); });
|
||||||
// Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen
|
// Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen
|
||||||
const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } };
|
const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } };
|
||||||
win.on('move', followRdp);
|
win.on('move', followRdp);
|
||||||
win.on('resize', followRdp);
|
win.on('resize', followRdp);
|
||||||
win.on('restore', followRdp);
|
win.on('restore', followRdp);
|
||||||
win.on('unmaximize', () => send('win:state', false));
|
|
||||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab in ein eigenes Fenster lösen: data = { kind, host, id, title, … } wird vom neuen Fenster per win:handoff abgeholt
|
||||||
|
const handoffs = new Map();
|
||||||
|
function openTabWindow(data, pos) {
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
handoffs.set(token, data);
|
||||||
|
const w = newWindow({ width: 1000, height: 650, ...(pos ? { x: Math.round(pos.x - 120), y: Math.round(pos.y - 20) } : {}) });
|
||||||
|
tabWins.add(w);
|
||||||
|
w.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'), { query: { detached: token } });
|
||||||
|
// Fenster zu: dort noch offene Sitzungen beenden
|
||||||
|
w.on('closed', () => {
|
||||||
|
tabWins.delete(w);
|
||||||
|
for (const [id, o] of sessionOwner) {
|
||||||
|
if (o !== w) continue;
|
||||||
|
sessionOwner.delete(id);
|
||||||
|
for (const m of [ssh, docker, firewall, network, ...Object.values(admins)]) { try { m.close(id); } catch {} }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ipcMain.handle('win:detach', (_e, data, pos) => { openTabWindow(data, pos); return true; });
|
||||||
|
ipcMain.handle('win:handoff', (_e, token) => { const d = handoffs.get(token); handoffs.delete(token); return d || null; });
|
||||||
|
// Sitzung gehört (jetzt) dem aufrufenden Fenster; im Hauptfenster gilt keine Zuordnung
|
||||||
|
ipcMain.on('win:claim', (e, id) => {
|
||||||
|
const w = BrowserWindow.fromWebContents(e.sender);
|
||||||
|
if (w === win) sessionOwner.delete(id); else sessionOwner.set(id, w);
|
||||||
|
});
|
||||||
|
// Tab ins Hauptfenster zurück
|
||||||
|
ipcMain.handle('win:attach', (_e, data) => { if (alive(win)) { win.webContents.send('win:adopt', data); win.focus(); } return true; });
|
||||||
|
// Ein anderes Fenster hat den Tab übernommen (Drag & Drop): beim bisherigen Fenster ohne Trennen entfernen
|
||||||
|
ipcMain.on('win:released', (e, id) => {
|
||||||
|
for (const w of [win, ...tabWins]) if (alive(w) && w.webContents !== e.sender) w.webContents.send('win:released', id);
|
||||||
|
});
|
||||||
|
|
||||||
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
|
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
|
||||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||||
function handle(channel, fn) {
|
function handle(channel, fn) {
|
||||||
@@ -129,9 +189,10 @@ function handle(channel, fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Fenster ----------
|
// ---------- Fenster ----------
|
||||||
ipcMain.on('win:min', () => win.minimize());
|
const senderWin = (e) => BrowserWindow.fromWebContents(e.sender);
|
||||||
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
ipcMain.on('win:min', (e) => senderWin(e)?.minimize());
|
||||||
ipcMain.on('win:close', () => win.close());
|
ipcMain.on('win:max', (e) => { const w = senderWin(e); if (w) w.isMaximized() ? w.unmaximize() : w.maximize(); });
|
||||||
|
ipcMain.on('win:close', (e) => senderWin(e)?.close());
|
||||||
|
|
||||||
// ---------- App-Sperre (Passwort / FIDO2) ----------
|
// ---------- App-Sperre (Passwort / FIDO2) ----------
|
||||||
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||||
@@ -232,6 +293,12 @@ handle('docker:stats', (id) => docker.stats(id));
|
|||||||
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
handle('docker:close', (id) => docker.close(id));
|
handle('docker:close', (id) => docker.close(id));
|
||||||
|
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
|
||||||
|
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
|
||||||
|
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
|
||||||
|
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
|
||||||
|
handle('docker:templates', (url) => composeTemplates.list(url));
|
||||||
|
handle('docker:template', (index, url) => composeTemplates.build(index, url));
|
||||||
|
|
||||||
// ---------- Firewall ----------
|
// ---------- Firewall ----------
|
||||||
handle('firewall:open', async (id, hostRef) => {
|
handle('firewall:open', async (id, hostRef) => {
|
||||||
@@ -259,6 +326,19 @@ handle('network:readFile', (id, path) => network.readFile(id, path));
|
|||||||
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
|
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
|
||||||
handle('network:close', (id) => network.close(id));
|
handle('network:close', (id) => network.close(id));
|
||||||
|
|
||||||
|
// ---------- Webserver / Zertifikate / Samba-AD ----------
|
||||||
|
for (const [p, m] of Object.entries(admins)) {
|
||||||
|
handle(`${p}:open`, async (id, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return m.open(id, host, () => send(`${p}:closed`, id));
|
||||||
|
});
|
||||||
|
handle(`${p}:read`, (id) => m.read(id));
|
||||||
|
handle(`${p}:op`, (id, op, ...args) => m.op(id, op, ...args));
|
||||||
|
handle(`${p}:close`, (id) => m.close(id));
|
||||||
|
}
|
||||||
|
handle('certs:probe', (host, port) => probeTls(host, port));
|
||||||
|
|
||||||
// ---------- Synchronisation ----------
|
// ---------- Synchronisation ----------
|
||||||
handle('sync:status', () => sync.status());
|
handle('sync:status', () => sync.status());
|
||||||
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
|
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
|
||||||
@@ -270,6 +350,11 @@ handle('sync:share', (sel) => sync.setShare(sel));
|
|||||||
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||||
handle('sync:probe', (address) => sync.probe(address));
|
handle('sync:probe', (address) => sync.probe(address));
|
||||||
|
|
||||||
|
// ---------- Support-Assistent (Ollama) ----------
|
||||||
|
handle('ai:models', (url) => ai.models(url));
|
||||||
|
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
|
||||||
|
handle('ai:stop', (id) => ai.stop(id));
|
||||||
|
|
||||||
// ---------- VPN ----------
|
// ---------- VPN ----------
|
||||||
handle('vpn:status', () => vpn.status());
|
handle('vpn:status', () => vpn.status());
|
||||||
handle('vpn:up', (id) => vpn.up(id));
|
handle('vpn:up', (id) => vpn.up(id));
|
||||||
@@ -280,19 +365,19 @@ handle('vault:settings', (s) => {
|
|||||||
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) => store.forgetKnownHost(id));
|
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
|
||||||
handle('vault:export', async () => {
|
// Backup: vollständig, optional per Passwort verschlüsselt (src/core/backup.js)
|
||||||
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
|
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version: app.getVersion() }));
|
||||||
if (r.canceled) return false;
|
handle('backup:inspect', (content) => backup.inspectBackup(content));
|
||||||
fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 });
|
handle('backup:import', (content, password, opts) => {
|
||||||
return r.filePath;
|
const r = backup.importBackup(store, content, password, opts);
|
||||||
|
if (opts?.settings) applyLanguage();
|
||||||
|
return r;
|
||||||
});
|
});
|
||||||
handle('vault:import', async () => {
|
handle('backup:saveFile', async (content, name) => {
|
||||||
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
|
const r = await dialog.showSaveDialog(win, { defaultPath: name, filters: [{ name: 'MrTerm Backup', extensions: ['json'] }] });
|
||||||
if (r.canceled) return false;
|
if (r.canceled) return false;
|
||||||
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8'));
|
fs.writeFileSync(r.filePath, content, { mode: 0o600 });
|
||||||
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
|
return r.filePath;
|
||||||
for (const item of data[col] || []) store.upsert(col, item);
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import aus ~/.ssh/config
|
// Import aus ~/.ssh/config
|
||||||
@@ -393,6 +478,11 @@ handle('sftp:open', async (sessionId, hostRef) => {
|
|||||||
});
|
});
|
||||||
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));
|
||||||
|
// Dateien für den SFTP-Upload auswählen (Mehrfachauswahl)
|
||||||
|
handle('sftp:pickUpload', async () => {
|
||||||
|
const r = await dialog.showOpenDialog(win, { title: i18n.t('Upload'), properties: ['openFile', 'multiSelections', 'showHiddenFiles'] });
|
||||||
|
return r.canceled ? [] : r.filePaths.map((p) => ({ path: p, name: path.basename(p) }));
|
||||||
|
});
|
||||||
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
||||||
let last = 0;
|
let last = 0;
|
||||||
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
|
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
|
||||||
@@ -458,30 +548,46 @@ 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');
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
const emb = createEmbed(win.getNativeWindowHandle());
|
const sess = { cancelled: false };
|
||||||
const sess = { emb, cancelled: false };
|
|
||||||
rdpSessions.set(id, sess);
|
rdpSessions.set(id, sess);
|
||||||
|
return startRdpEmbed(id, sess, host, bounds);
|
||||||
|
});
|
||||||
|
async function startRdpEmbed(id, sess, host, bounds, acceptCert = false) {
|
||||||
|
const emb = createEmbed(win.getNativeWindowHandle());
|
||||||
|
emb.acceptCert = acceptCert;
|
||||||
|
sess.emb = emb;
|
||||||
const size = { width: bounds.width, height: bounds.height };
|
const size = { width: bounds.width, height: bounds.height };
|
||||||
let proc;
|
let proc;
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const file = rdp.writeRdpFile(host, size);
|
const args = rdp.mstscArgs(host, size);
|
||||||
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ rdpFile: file }))));
|
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ args }))));
|
||||||
} else {
|
} else {
|
||||||
proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password });
|
proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password });
|
||||||
}
|
}
|
||||||
proc.on('error', (e) => send('rdp:event', id, 'exit', { code: -1, message: e.message }));
|
const current = () => rdpSessions.get(id) === sess && sess.emb === emb;
|
||||||
|
proc.on('error', (e) => { if (current()) send('rdp:event', id, 'exit', { code: -1, message: e.message }); });
|
||||||
proc.on('exit', (code) => {
|
proc.on('exit', (code) => {
|
||||||
|
if (!current()) return; // durch Neuverbindung ersetzt
|
||||||
const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || '';
|
const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || '';
|
||||||
if (rdpSessions.get(id) === sess) send('rdp:event', id, 'exit', { code, message: err });
|
send('rdp:event', id, 'exit', { code, message: err });
|
||||||
rdpSessions.delete(id);
|
rdpSessions.delete(id);
|
||||||
});
|
});
|
||||||
const ok = await emb.attach(bounds, () => sess.cancelled);
|
// Server ohne dynamische Auflösung: mit neuer Größe neu verbinden (die Sitzung auf dem Server bleibt erhalten)
|
||||||
if (ok) {
|
emb.onResizeNeeded = (b) => {
|
||||||
|
if (!current()) return;
|
||||||
|
send('rdp:event', id, 'resizing');
|
||||||
|
emb.kill();
|
||||||
|
startRdpEmbed(id, sess, host, b, emb.certAccepted).then((ok) => {
|
||||||
|
if (!ok && rdpSessions.get(id) === sess && !sess.cancelled) send('rdp:event', id, 'exit', { code: -1, message: '' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const ok = await emb.attach(bounds, () => sess.cancelled || !current());
|
||||||
|
if (ok && current()) {
|
||||||
if (sess.hidden) emb.hide();
|
if (sess.hidden) emb.hide();
|
||||||
send('rdp:event', id, 'attached');
|
send('rdp:event', id, 'attached');
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
});
|
}
|
||||||
ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} });
|
ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} });
|
||||||
ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } });
|
ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } });
|
||||||
ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
|
ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
|
||||||
|
|||||||
+4
-3
@@ -4,6 +4,7 @@
|
|||||||
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
|
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
const { execOn } = require('./docker');
|
const { execOn } = require('./docker');
|
||||||
|
const { acquireSudo } = require('./sudo');
|
||||||
const nc = require('./netconf');
|
const nc = require('./netconf');
|
||||||
|
|
||||||
const STATE = '/var/lib/mrterm-net';
|
const STATE = '/var/lib/mrterm-net';
|
||||||
@@ -127,7 +128,7 @@ class NetworkConfigManager {
|
|||||||
root(s, script, conn = s.conn) {
|
root(s, script, conn = s.conn) {
|
||||||
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
|
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
|
||||||
if (!s.sudo) return execOn(conn, cmd);
|
if (!s.sudo) return execOn(conn, cmd);
|
||||||
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
|
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
|
||||||
}
|
}
|
||||||
async rootOk(s, script) {
|
async rootOk(s, script) {
|
||||||
const r = await this.root(s, script);
|
const r = await this.root(s, script);
|
||||||
@@ -142,8 +143,8 @@ class NetworkConfigManager {
|
|||||||
this.watch(id, s);
|
this.watch(id, s);
|
||||||
if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
|
if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
|
||||||
s.sudo = true;
|
s.sudo = true;
|
||||||
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
|
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
|
||||||
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.'));
|
if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
|
||||||
}
|
}
|
||||||
return this.read(id);
|
return this.read(id);
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-43
@@ -1,5 +1,5 @@
|
|||||||
// Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab).
|
// Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab).
|
||||||
// Windows: mstsc.exe wird gestartet, sein Hauptfenster (TscShellContainerClass) per SetParent eingehängt.
|
// Windows: mstsc.exe wird gestartet, sein Sitzungsfenster (TscShellContainerClass) rahmenlos über den Tab gelegt.
|
||||||
// Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland).
|
// Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland).
|
||||||
// Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters.
|
// Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters.
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
@@ -12,13 +12,25 @@ const i18n = require('../i18n');
|
|||||||
// Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes
|
// Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes
|
||||||
// mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window"
|
// mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window"
|
||||||
// von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag.
|
// von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag.
|
||||||
|
// Mit "dynamic resolution" passt mstsc die Auflösung der Sitzung an, sobald sich die Fenstergröße ändert.
|
||||||
function winApi() {
|
function winApi() {
|
||||||
if (winApi.api) return winApi.api;
|
if (winApi.api) return winApi.api;
|
||||||
const u = koffi.load('user32.dll');
|
const u = koffi.load('user32.dll');
|
||||||
const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)');
|
const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)');
|
||||||
|
const WinEventProc = koffi.proto('void __stdcall MrTermWinEventProc(intptr hook, uint32 event, intptr hwnd, long idObject, long idChild, uint32 thread, uint32 time)');
|
||||||
const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' });
|
const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' });
|
||||||
|
const RECT = koffi.struct('MrTermRECT', { left: 'int', top: 'int', right: 'int', bottom: 'int' });
|
||||||
winApi.api = {
|
winApi.api = {
|
||||||
EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'),
|
EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'),
|
||||||
|
EnumChildWindows: u.func('bool __stdcall EnumChildWindows(intptr hwnd, MrTermEnumProc *cb, intptr lParam)'),
|
||||||
|
SetWinEventHook: u.func('intptr __stdcall SetWinEventHook(uint32 min, uint32 max, intptr mod, MrTermWinEventProc *cb, uint32 pid, uint32 tid, uint32 flags)'),
|
||||||
|
UnhookWinEvent: u.func('bool __stdcall UnhookWinEvent(intptr hook)'),
|
||||||
|
GetDlgItem: u.func('intptr __stdcall GetDlgItem(intptr hwnd, int id)'),
|
||||||
|
PostMessageW: u.func('bool __stdcall PostMessageW(intptr hwnd, uint32 msg, uintptr w, intptr l)'),
|
||||||
|
GetAncestor: u.func('intptr __stdcall GetAncestor(intptr hwnd, uint32 flags)'),
|
||||||
|
GetWindowRect: u.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ MrTermRECT *rect)'),
|
||||||
|
SetLayeredWindowAttributes: u.func('bool __stdcall SetLayeredWindowAttributes(intptr hwnd, uint32 key, uint8 alpha, uint32 flags)'),
|
||||||
|
WinEventProc,
|
||||||
GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'),
|
GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'),
|
||||||
GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'),
|
GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'),
|
||||||
IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'),
|
IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'),
|
||||||
@@ -34,58 +46,143 @@ function winApi() {
|
|||||||
return winApi.api;
|
return winApi.api;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findWindowsOfPid(pid, cls) {
|
const buf = Buffer.alloc(512);
|
||||||
const api = winApi();
|
const className = (hwnd) => buf.toString('utf16le', 0, winApi().GetClassNameW(hwnd, buf, 256) * 2);
|
||||||
const out = [];
|
function childClasses(hwnd) {
|
||||||
const buf = Buffer.alloc(512);
|
const out = new Set();
|
||||||
api.EnumWindows((hwnd) => {
|
winApi().EnumChildWindows(hwnd, (c) => { out.add(className(c)); return true; }, 0);
|
||||||
const p = [0];
|
|
||||||
api.GetWindowThreadProcessId(hwnd, p);
|
|
||||||
if (p[0] === pid && api.IsWindowVisible(hwnd)) {
|
|
||||||
const n = api.GetClassNameW(hwnd, buf, 256);
|
|
||||||
if (!cls || buf.toString('utf16le', 0, n * 2) === cls) out.push(hwnd);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, 0);
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8;
|
const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8;
|
||||||
const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000;
|
const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000;
|
||||||
const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000;
|
const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000;
|
||||||
const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080;
|
const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080, WS_EX_LAYERED = 0x00080000;
|
||||||
const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
|
const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
|
||||||
const SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40;
|
const SWP_NOSIZE = 0x1, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40;
|
||||||
const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5;
|
const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5;
|
||||||
|
const EVENT_OBJECT_CREATE = 0x8000, EVENT_OBJECT_SHOW = 0x8002, WINEVENT_OUTOFCONTEXT = 0, GA_ROOT = 2, LWA_ALPHA = 2;
|
||||||
|
const BM_CLICK = 0xf5, ID_CERT_YES = 14004, ID_CERT_VIEW = 13443; // Zertifikatswarnung von mstsc: "Ja", "Zertifikat anzeigen"
|
||||||
|
|
||||||
class WinEmbed {
|
class WinEmbed {
|
||||||
constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.cls = 'TscShellContainerClass'; this.visible = true; }
|
constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.visible = true; }
|
||||||
|
|
||||||
start({ rdpFile }) {
|
// args: mstsc-Kommandozeile. Mit /v:… statt .rdp-Datei zeigt mstsc keine Sicherheitswarnung "Unbekannter Herausgeber".
|
||||||
this.proc = spawn('mstsc.exe', rdpFile ? [rdpFile] : [], { stdio: 'ignore' });
|
start({ args }) {
|
||||||
|
this.proc = spawn('mstsc.exe', args, { stdio: 'ignore' });
|
||||||
|
this.hookWindows();
|
||||||
|
this.proc.on('exit', () => this.unhook());
|
||||||
return this.proc;
|
return this.proc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wartet, bis mstsc sein Sitzungsfenster geöffnet hat (Anmelde-/Zertifikatsdialoge davor bleiben eigene Fenster)
|
// Fängt die Fenster von mstsc ab, sobald sie entstehen (läuft über die Nachrichtenschleife des Electron-Hauptthreads):
|
||||||
async attach(bounds, isCancelled) {
|
// Sitzungsfenster -> noch unsichtbar rahmenlos über den Tab legen, damit nie ein normales mstsc-Fenster aufblitzt
|
||||||
const t0 = Date.now();
|
// "Verbindung wird hergestellt" (Dialog mit Fortschrittsbalken) -> unsichtbar lassen, der Tab zeigt den Status selbst
|
||||||
while (Date.now() - t0 < 120000) {
|
// andere Dialoge (Zertifikat, Fehler) -> brauchen eine Entscheidung, daher mittig über dem Tab einblenden
|
||||||
if (isCancelled() || this.proc.exitCode !== null) return false;
|
hookWindows() {
|
||||||
const [hwnd] = findWindowsOfPid(this.proc.pid, this.cls);
|
const api = winApi();
|
||||||
if (hwnd) {
|
this.cb = koffi.register((_hook, event, hwnd, idObject, idChild) => {
|
||||||
const api = winApi();
|
try {
|
||||||
const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0;
|
if (idObject !== 0 || idChild !== 0 || Number(api.GetAncestor(hwnd, GA_ROOT)) !== Number(hwnd)) return;
|
||||||
api.SetWindowLongPtrW(hwnd, GWL_STYLE, style);
|
const cls = className(hwnd);
|
||||||
const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0);
|
if (cls === 'TscShellContainerClass') {
|
||||||
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
|
if (event === EVENT_OBJECT_CREATE || !this.hwnd) this.adopt(hwnd);
|
||||||
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent); // Besitzer = MrTerm
|
if (event === EVENT_OBJECT_SHOW) this.onSessionShown();
|
||||||
this.hwnd = hwnd;
|
} else if (cls === '#32770') {
|
||||||
this.setBounds(bounds);
|
const isCert = () => api.GetDlgItem(hwnd, ID_CERT_YES) && api.GetDlgItem(hwnd, ID_CERT_VIEW);
|
||||||
return true;
|
if (event === EVENT_OBJECT_CREATE) this.setAlpha(hwnd, 0);
|
||||||
}
|
else if (childClasses(hwnd).has('msctls_progress32')) api.ShowWindow(hwnd, SW_HIDE);
|
||||||
await new Promise((r) => setTimeout(r, 250));
|
else if (this.acceptCert && isCert()) api.PostMessageW(api.GetDlgItem(hwnd, ID_CERT_YES), BM_CLICK, 0, 0);
|
||||||
}
|
else { if (isCert()) this.certPrompted = true; this.presentDialog(hwnd); }
|
||||||
return false;
|
}
|
||||||
|
} catch { /* Fenster kann bereits wieder zu sein */ }
|
||||||
|
}, koffi.pointer(api.WinEventProc));
|
||||||
|
this.hook = api.SetWinEventHook(EVENT_OBJECT_CREATE, EVENT_OBJECT_SHOW, 0, this.cb, this.proc.pid, 0, WINEVENT_OUTOFCONTEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
unhook() {
|
||||||
|
if (this.hook) { winApi().UnhookWinEvent(this.hook); this.hook = null; }
|
||||||
|
if (this.cb) { koffi.unregister(this.cb); this.cb = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlpha(hwnd, alpha) {
|
||||||
|
const api = winApi();
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) | WS_EX_LAYERED) >>> 0);
|
||||||
|
api.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
presentDialog(hwnd) {
|
||||||
|
const api = winApi();
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
|
||||||
|
const r = {};
|
||||||
|
api.GetWindowRect(hwnd, r);
|
||||||
|
const b = this.bounds || { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
const pt = { x: b.x + Math.round((b.width - (r.right - r.left)) / 2), y: b.y + Math.round((b.height - (r.bottom - r.top)) / 2) };
|
||||||
|
api.ClientToScreen(this.parent, pt);
|
||||||
|
api.SetWindowPos(hwnd, 0, pt.x, pt.y, 0, 0, SWP_NOSIZE | SWP_NOZORDER);
|
||||||
|
this.setAlpha(hwnd, 255);
|
||||||
|
api.SetForegroundWindow(hwnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sitzungsfenster rahmenlos und ohne Taskleisten-Eintrag als "owned window" von MrTerm
|
||||||
|
adopt(hwnd) {
|
||||||
|
const api = winApi();
|
||||||
|
this.hwnd = hwnd;
|
||||||
|
const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0;
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWL_STYLE, style);
|
||||||
|
const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0);
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
|
||||||
|
// Bis zur Anmeldung außerhalb des Bildschirms parken: mstsc zentriert seine Dialoge über diesem Fenster,
|
||||||
|
// so bleibt auch "Verbindung wird hergestellt" unsichtbar, bevor der Hook ihn ausblenden kann.
|
||||||
|
if (!this.connected) api.SetWindowPos(hwnd, 0, -32000, -32000, this.bounds?.width || 800, this.bounds?.height || 600, SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mstsc blendet das Sitzungsfenster erst nach erfolgreicher Anmeldung ein und setzt dabei Rahmen und Größe neu
|
||||||
|
onSessionShown() {
|
||||||
|
this.connected = true;
|
||||||
|
this.adopt(this.hwnd);
|
||||||
|
if (!this.visible) winApi().ShowWindow(this.hwnd, SW_HIDE);
|
||||||
|
this.setBounds();
|
||||||
|
this.detectFixedSize();
|
||||||
|
this.setBounds();
|
||||||
|
this.onConnected?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne dynamische Auflösung (Server zu alt, xrdp < 0.10 …) begrenzt mstsc das Fenster auf die Sitzungsgröße –
|
||||||
|
// erkennbar daran, dass es sich nicht vergrößern lässt. Dann hilft nur Neuverbinden mit der neuen Größe.
|
||||||
|
detectFixedSize() {
|
||||||
|
const api = winApi();
|
||||||
|
const r = {};
|
||||||
|
api.GetWindowRect(this.hwnd, r);
|
||||||
|
api.SetWindowPos(this.hwnd, 0, r.left, r.top, r.right - r.left + 32, r.bottom - r.top + 32, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
const g = {};
|
||||||
|
api.GetWindowRect(this.hwnd, g);
|
||||||
|
this.fixedSize = g.right - g.left < r.right - r.left + 32 ? { width: r.right - r.left, height: r.bottom - r.top } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zertifikat wurde für diese Sitzung bestätigt (Rückfrage gezeigt und danach verbunden) – gilt für Neuverbindungen beim Größenändern
|
||||||
|
get certAccepted() { return this.acceptCert || (this.certPrompted && this.connected); }
|
||||||
|
|
||||||
|
checkFixedSize() {
|
||||||
|
clearTimeout(this.resizeTimer);
|
||||||
|
const b = this.bounds;
|
||||||
|
if (!this.fixedSize || !this.visible || (Math.abs(b.width - this.fixedSize.width) < 8 && Math.abs(b.height - this.fixedSize.height) < 8)) return;
|
||||||
|
this.resizeTimer = setTimeout(() => this.onResizeNeeded?.(this.bounds), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wartet, bis die Sitzung steht (Sitzungsfenster sichtbar)
|
||||||
|
attach(bounds, isCancelled) {
|
||||||
|
this.bounds = bounds;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const done = (ok) => { clearInterval(iv); this.onConnected = null; resolve(ok); };
|
||||||
|
this.onConnected = () => done(true);
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
if (this.connected) done(true);
|
||||||
|
else if (isCancelled() || this.proc.exitCode !== null || Date.now() - t0 > 120000) done(false);
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten
|
// b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten
|
||||||
@@ -96,15 +193,17 @@ class WinEmbed {
|
|||||||
const pt = { x: this.bounds.x, y: this.bounds.y };
|
const pt = { x: this.bounds.x, y: this.bounds.y };
|
||||||
api.ClientToScreen(this.parent, pt);
|
api.ClientToScreen(this.parent, pt);
|
||||||
api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height),
|
api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height),
|
||||||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible ? SWP_SHOWWINDOW : 0));
|
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible && this.connected ? SWP_SHOWWINDOW : 0));
|
||||||
|
if (this.connected) this.checkFixedSize();
|
||||||
}
|
}
|
||||||
reposition() { this.setBounds(); }
|
reposition() { this.setBounds(); }
|
||||||
show() { this.visible = true; if (this.hwnd) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
|
show() { this.visible = true; if (this.hwnd && this.connected) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
|
||||||
hide() { this.visible = false; if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); }
|
hide() { this.visible = false; clearTimeout(this.resizeTimer); if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); }
|
||||||
focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); }
|
focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); }
|
||||||
// Aus MrTerm lösen und als normales Fenster weiterlaufen lassen
|
// Aus MrTerm lösen und als normales Fenster weiterlaufen lassen
|
||||||
detach() {
|
detach() {
|
||||||
if (!this.hwnd) return;
|
if (!this.hwnd) return;
|
||||||
|
this.unhook();
|
||||||
const api = winApi();
|
const api = winApi();
|
||||||
api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0);
|
api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0);
|
||||||
const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0);
|
const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0);
|
||||||
@@ -115,7 +214,7 @@ class WinEmbed {
|
|||||||
api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
|
api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
|
||||||
this.hwnd = null;
|
this.hwnd = null;
|
||||||
}
|
}
|
||||||
kill() { try { this.proc?.kill(); } catch {} }
|
kill() { clearTimeout(this.resizeTimer); this.unhook(); try { this.proc?.kill(); } catch {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Linux (X11)
|
// ------------------------------------------------------------------ Linux (X11)
|
||||||
|
|||||||
+11
-1
@@ -63,6 +63,16 @@ function writeRdpFile(host, size) {
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mstsc-Kommandozeile für eingebettete Sitzungen. Seit 2026 zeigt mstsc bei jeder unsignierten .rdp-Datei eine
|
||||||
|
// Sicherheitswarnung – mit /v:… startet es ohne. Die übrigen Optionen kommen dann aus Default.rdp (Zwischenablage an,
|
||||||
|
// Audio lokal, keine Laufwerke, dynamische Auflösung); weicht der Host davon ab, geht es nur über eine .rdp-Datei.
|
||||||
|
function mstscArgs(host, size) {
|
||||||
|
const port = Number(host.port) || 3389;
|
||||||
|
const needsFile = host.rdpDrives || host.rdpClipboard === false || host.rdpAudio === false || host.rdpResize === 'scale';
|
||||||
|
if (needsFile) return [writeRdpFile(host, size)];
|
||||||
|
return [`/v:${port === 3389 ? host.address : `${host.address}:${port}`}`, `/w:${size.width}`, `/h:${size.height}`];
|
||||||
|
}
|
||||||
|
|
||||||
// Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet.
|
// Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet.
|
||||||
function withWinCredentials(host, run) {
|
function withWinCredentials(host, run) {
|
||||||
const user = userOf(host);
|
const user = userOf(host);
|
||||||
@@ -110,4 +120,4 @@ function launch(host, settings) {
|
|||||||
return { client, process: p, getErr: () => err };
|
return { client, process: p, getErr: () => err };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { detect, launch, linuxClient, writeRdpFile, withWinCredentials, freerdpArgs };
|
module.exports = { detect, launch, linuxClient, writeRdpFile, mstscArgs, withWinCredentials, freerdpArgs };
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// Samba-/AD-Tab: Freigaben (smb.conf), Samba-Benutzer, aktive Verbindungen, Dienste und – auf einem
|
||||||
|
// Samba-AD-Domänencontroller – Benutzer, Gruppen und Computer der Domäne (samba-tool).
|
||||||
|
// Passwörter gehen nur über stdin an smbpasswd/samba-tool, nie über die Kommandozeile.
|
||||||
|
// smb.conf wird abschnittsweise geändert (Kommentare bleiben erhalten), mit testparm geprüft und sonst zurückgerollt.
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const { AdminManager, sections, q, check, installCmd } = require('./admin');
|
||||||
|
|
||||||
|
const SMB_CONF = '/etc/samba/smb.conf';
|
||||||
|
const RX = {
|
||||||
|
share: /^[\w$][\w $.-]{0,79}$/,
|
||||||
|
user: /^[a-zA-Z0-9_][\w.-]{0,63}\$?$/,
|
||||||
|
path: /^\/[\w./@+ -]*$/,
|
||||||
|
users: /^[\w@%+.\\, -]{0,400}$/,
|
||||||
|
mask: /^0?[0-7]{3,4}$/,
|
||||||
|
text: /^[^\n\r'"`$\\]{0,200}$/,
|
||||||
|
group: /^[\w][\w .-]{0,63}$/,
|
||||||
|
};
|
||||||
|
const noDots = (p) => { if (String(p).split('/').includes('..')) throw new Error('Invalid path'); return p; };
|
||||||
|
const BOOL = /^(yes|no)$/;
|
||||||
|
// Felder des Freigabe-Formulars → smb.conf-Parameter
|
||||||
|
const SHARE_FIELDS = { path: 'path', comment: 'comment', readOnly: 'read only', browseable: 'browseable', guestOk: 'guest ok', validUsers: 'valid users', writeList: 'write list', createMask: 'create mask', directoryMask: 'directory mask' };
|
||||||
|
|
||||||
|
const GATHER = `echo "@@VERSION"; command -v smbd >/dev/null 2>&1 && smbd --version 2>/dev/null
|
||||||
|
echo "@@TOOLS"; for c in smbd samba samba-tool pdbedit smbpasswd smbstatus testparm wbinfo realm net ldbsearch; do command -v $c >/dev/null 2>&1 && echo $c; done
|
||||||
|
echo "@@UNITS"; for u in smbd nmbd winbind samba samba-ad-dc sssd; do s=$(systemctl is-enabled "$u" 2>/dev/null); case "$s" in ''|not-found) continue;; esac; echo "$u|$(systemctl is-active "$u" 2>/dev/null)|$s"; done
|
||||||
|
echo "@@ROLE"; command -v testparm >/dev/null 2>&1 && testparm -s --parameter-name='server role' 2>/dev/null
|
||||||
|
echo "@@CONF"; command -v testparm >/dev/null 2>&1 && testparm -s 2>/dev/null
|
||||||
|
echo "@@USERS"; command -v pdbedit >/dev/null 2>&1 && pdbedit -L -v 2>/dev/null
|
||||||
|
echo "@@STATUS"; command -v smbstatus >/dev/null 2>&1 && { smbstatus -j 2>/dev/null || { echo NOJSON; smbstatus 2>/dev/null; }; }
|
||||||
|
echo "@@REALM"; command -v realm >/dev/null 2>&1 && realm list 2>/dev/null
|
||||||
|
echo "@@WBINFO"; command -v wbinfo >/dev/null 2>&1 && wbinfo --online-status 2>/dev/null
|
||||||
|
echo "@@END"`;
|
||||||
|
|
||||||
|
// testparm -s: [abschnitt] und "param = wert"
|
||||||
|
function parseConf(text) {
|
||||||
|
const out = [];
|
||||||
|
let cur = null;
|
||||||
|
for (const line of String(text).split('\n')) {
|
||||||
|
let m;
|
||||||
|
if ((m = line.match(/^\s*\[(.+)\]\s*$/))) { cur = { name: m[1], params: {} }; out.push(cur); }
|
||||||
|
else if (cur && (m = line.match(/^\s*([^=]+?)\s*=\s*(.*)$/))) cur.params[m[1].toLowerCase()] = m[2];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePdbedit(text) {
|
||||||
|
return String(text).split(/^-{5,}$/m).map((block) => {
|
||||||
|
const get = (k) => (block.match(new RegExp(`^${k}:[ \\t]*(.*)$`, 'm')) || [])[1]?.trim() || '';
|
||||||
|
const user = get('Unix username');
|
||||||
|
if (!user) return null;
|
||||||
|
const flags = get('Account Flags');
|
||||||
|
return { user, fullName: get('Full Name'), flags, disabled: /D/.test(flags.replace(/[[\]\s]/g, '')), lastLogon: get('Logon time') };
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// smbstatus -j (Samba ≥ 4.16) oder Text
|
||||||
|
function parseStatus(text) {
|
||||||
|
if (String(text).startsWith('NOJSON')) return { raw: text.replace(/^NOJSON\n?/, ''), sessions: [], shares: [], files: [] };
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(text); } catch { return { raw: text, sessions: [], shares: [], files: [] }; }
|
||||||
|
const sessions = Object.values(j.sessions || {}).map((x) => ({ id: x.session_id, user: x.username, group: x.groupname, machine: x.remote_machine, host: x.hostname, protocol: x.session_dialect, encryption: x.encryption?.cipher || '', signing: x.signing?.cipher || '' }));
|
||||||
|
const shares = Object.values(j.tcons || {}).map((x) => ({ service: x.service, machine: x.machine, connected: x.connected_at, session: x.session_id }));
|
||||||
|
const files = Object.values(j.open_files || {}).map((x) => ({ path: `${x.service_path || ''}/${x.filename || ''}`.replace(/\/+/g, '/'), share: x.service_path, opens: Object.keys(x.opens || {}).length }));
|
||||||
|
return { sessions, shares, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDIF von ldbsearch → Objekte (mehrwertige Attribute als Array)
|
||||||
|
function parseLdif(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const block of String(text).replace(/\n /g, '').split(/\n(?=# record|dn: )/)) {
|
||||||
|
if (!/^dn: /m.test(block)) continue;
|
||||||
|
const o = {};
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
const m = line.match(/^(\w+)(::?) (.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const v = m[2] === '::' ? Buffer.from(m[3], 'base64').toString() : m[3];
|
||||||
|
const k = m[1];
|
||||||
|
if (o[k] === undefined) o[k] = v; else o[k] = [].concat(o[k], v);
|
||||||
|
}
|
||||||
|
out.push(o);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const arr = (v) => (v === undefined ? [] : [].concat(v));
|
||||||
|
const cn = (dn) => (String(dn).match(/^CN=([^,]+)/i) || [])[1] || dn;
|
||||||
|
// Windows FILETIME (100 ns seit 1601) → ms
|
||||||
|
const fileTime = (v) => { const n = Number(v); return n > 0 && n < 9e18 ? Math.round(n / 1e4 - 11644473600000) : null; };
|
||||||
|
|
||||||
|
class SambaManager extends AdminManager {
|
||||||
|
static OPS = ['service', 'readConf', 'writeConf', 'saveShare', 'deleteShare', 'addUser', 'setPassword', 'setUserEnabled', 'deleteUser',
|
||||||
|
'adUserCreate', 'adUserDelete', 'adUserEnable', 'adSetPassword', 'adGroupCreate', 'adGroupDelete', 'adGroupMembers', 'adComputerDelete', 'adRead', 'provisionDomain', 'joinDomain', 'leaveDomain', 'joinCommand', 'installCommand'];
|
||||||
|
|
||||||
|
async read(id) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const sec = sections(await this.rootOk(s, GATHER));
|
||||||
|
const tools = new Set((sec.TOOLS || '').split('\n').filter(Boolean));
|
||||||
|
const units = (sec.UNITS || '').split('\n').filter(Boolean).map((l) => { const [unit, active, enabled] = l.split('|'); return { unit, active, enabled }; });
|
||||||
|
const role = (sec.ROLE || '').trim();
|
||||||
|
s.tools = tools;
|
||||||
|
s.units = new Set(units.map((u) => u.unit));
|
||||||
|
s.dc = /active directory domain controller/i.test(role);
|
||||||
|
const conf = parseConf(sec.CONF || '');
|
||||||
|
const global = conf.find((c) => c.name === 'global')?.params || {};
|
||||||
|
return {
|
||||||
|
pkg: s.pkg,
|
||||||
|
version: (sec.VERSION || '').replace(/^Version\s*/, ''),
|
||||||
|
installed: tools.has('smbd') || tools.has('samba'),
|
||||||
|
tools: [...tools], units, role, dc: s.dc,
|
||||||
|
global: { workgroup: global.workgroup || '', realm: global.realm || '', netbios: global['netbios name'] || '', security: global.security || '' },
|
||||||
|
shares: conf.filter((c) => c.name.toLowerCase() !== 'global').map((c) => ({
|
||||||
|
name: c.name, path: c.params.path || '', comment: c.params.comment || '', readOnly: c.params['read only'] ?? (c.params.writeable === 'yes' || c.params.writable === 'yes' ? 'no' : 'yes'),
|
||||||
|
browseable: c.params.browseable ?? c.params.browsable ?? 'yes', guestOk: c.params['guest ok'] ?? c.params.public ?? 'no', validUsers: c.params['valid users'] || '', writeList: c.params['write list'] || '',
|
||||||
|
createMask: c.params['create mask'] || '', directoryMask: c.params['directory mask'] || '', special: ['printers', 'print$', 'homes', 'netlogon', 'sysvol'].includes(c.name.toLowerCase()), params: c.params,
|
||||||
|
})),
|
||||||
|
users: parsePdbedit(sec.USERS || ''),
|
||||||
|
status: parseStatus(sec.STATUS || ''),
|
||||||
|
realm: (sec.REALM || '').trim(),
|
||||||
|
winbind: (sec.WBINFO || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abschnitt [name] in smb.conf ersetzen (lines = null → löschen); Kommentare anderer Abschnitte bleiben unverändert
|
||||||
|
static replaceSection(text, name, lines) {
|
||||||
|
const src = String(text).split('\n');
|
||||||
|
const isHead = (l) => /^\s*\[.+\]\s*$/.test(l);
|
||||||
|
const start = src.findIndex((l) => isHead(l) && l.trim().slice(1, -1).trim().toLowerCase() === name.toLowerCase());
|
||||||
|
const block = lines ? [`[${name}]`, ...lines.map((l) => ` ${l}`), ''] : [];
|
||||||
|
if (start < 0) { if (!lines) return text; return `${src.join('\n').replace(/\s*$/, '\n')}\n${block.join('\n')}`; }
|
||||||
|
let end = src.length;
|
||||||
|
for (let i = start + 1; i < src.length; i++) if (isHead(src[i])) { end = i; break; }
|
||||||
|
// Kommentarzeilen direkt vor dem nächsten Abschnitt gehören zu diesem
|
||||||
|
while (end - 1 > start && /^\s*[#;]/.test(src[end - 1])) end--;
|
||||||
|
// Beim Löschen auch den Kommentar direkt über dem eigenen Abschnitt entfernen
|
||||||
|
let from = start;
|
||||||
|
if (!lines) while (from > 0 && /^\s*[#;]/.test(src[from - 1])) from--;
|
||||||
|
src.splice(from, end - from, ...block);
|
||||||
|
return src.join('\n').replace(/\n{3,}/g, '\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() { return 'if command -v smbcontrol >/dev/null 2>&1; then smbcontrol all reload-config >/dev/null 2>&1 || true; fi'; }
|
||||||
|
|
||||||
|
async service(s, unit, action) {
|
||||||
|
if (!s.units?.has(unit)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'service', value: unit }));
|
||||||
|
check(action, /^(start|stop|restart|enable|disable)$/, 'action');
|
||||||
|
await this.rootOk(s, action === 'enable' || action === 'disable' ? `systemctl ${action} --now ${unit}` : `systemctl ${action} ${unit}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
readConf(s) { return this.rootOk(s, `cat ${SMB_CONF}`); }
|
||||||
|
writeConf(s, content) { return this.writeChecked(s, SMB_CONF, content, { validate: `testparm -s ${SMB_CONF} >/dev/null`, after: this.reload() }); }
|
||||||
|
|
||||||
|
async saveShare(s, share, oldName, createDir) {
|
||||||
|
const name = check(String(share.name || '').trim(), RX.share, 'name');
|
||||||
|
if (name.toLowerCase() === 'global') throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'name', value: name }));
|
||||||
|
const path = check(noDots(String(share.path || '').trim()), RX.path, 'path');
|
||||||
|
const lines = [];
|
||||||
|
for (const [k, param] of Object.entries(SHARE_FIELDS)) {
|
||||||
|
const v = String(share[k] ?? '').trim();
|
||||||
|
if (!v) continue;
|
||||||
|
if (['readOnly', 'browseable', 'guestOk'].includes(k)) check(v, BOOL, param);
|
||||||
|
else if (k === 'createMask' || k === 'directoryMask') check(v, RX.mask, param);
|
||||||
|
else if (k === 'validUsers' || k === 'writeList') check(v, RX.users, param);
|
||||||
|
else if (k === 'comment') check(v, RX.text, param);
|
||||||
|
else if (k === 'path') check(v, RX.path, param);
|
||||||
|
lines.push(`${param} = ${k === 'path' ? path : v}`);
|
||||||
|
}
|
||||||
|
// Weitere Parameter einer bestehenden Freigabe übernehmen (z. B. vfs objects), die das Formular nicht kennt
|
||||||
|
for (const [k, v] of Object.entries(share.extra || {})) {
|
||||||
|
if (Object.values(SHARE_FIELDS).includes(k) || ['writeable', 'writable', 'browsable', 'public'].includes(k)) continue;
|
||||||
|
lines.push(`${check(k, /^[a-z][a-z0-9 :_-]{0,60}$/, 'parameter')} = ${check(v, /^[^\n\r]{0,400}$/, k)}`);
|
||||||
|
}
|
||||||
|
let text = await this.readConf(s);
|
||||||
|
if (oldName && oldName.toLowerCase() !== name.toLowerCase()) text = SambaManager.replaceSection(text, check(oldName, RX.share, 'name'), null);
|
||||||
|
else if (!oldName && parseConf(text).some((c) => c.name.toLowerCase() === name.toLowerCase())) throw new Error(i18n.t('A share named “{name}” already exists.', { name }));
|
||||||
|
text = SambaManager.replaceSection(text, name, lines);
|
||||||
|
if (createDir) await this.rootOk(s, `mkdir -p ${q(path)}`);
|
||||||
|
return this.writeConf(s, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteShare(s, name) {
|
||||||
|
const text = await this.readConf(s);
|
||||||
|
return this.writeConf(s, SambaManager.replaceSection(text, check(name, RX.share, 'name'), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Samba-Benutzer anlegen; optional fehlenden Linux-Benutzer ohne Login-Shell mit anlegen
|
||||||
|
async addUser(s, user, password, createUnix) {
|
||||||
|
const u = check(user, RX.user, 'user');
|
||||||
|
if (!password) throw new Error(i18n.t('Please enter a password.'));
|
||||||
|
await this.rootOk(s, `id ${q(u)} >/dev/null 2>&1 || { ${createUnix ? `useradd -M -s /usr/sbin/nologin ${q(u)} 2>/dev/null || useradd -M -s /sbin/nologin ${q(u)}` : `echo ${q(i18n.t('Linux user {user} does not exist.', { user: u }))} >&2; exit 3`}; } || exit 1
|
||||||
|
IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | smbpasswd -s -a ${q(u)}`, `${password}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(s, user, password) {
|
||||||
|
if (!password) throw new Error(i18n.t('Please enter a password.'));
|
||||||
|
await this.rootOk(s, `IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | smbpasswd -s ${q(check(user, RX.user, 'user'))}`, `${password}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setUserEnabled(s, user, on) {
|
||||||
|
await this.rootOk(s, `smbpasswd ${on ? '-e' : '-d'} ${q(check(user, RX.user, 'user'))}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(s, user) {
|
||||||
|
await this.rootOk(s, `smbpasswd -x ${q(check(user, RX.user, 'user'))}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Samba-AD-Domänencontroller ----------
|
||||||
|
requireDc(s) { if (!s.dc) throw new Error(i18n.t('This host is not a Samba AD domain controller.')); }
|
||||||
|
|
||||||
|
async adRead(s) {
|
||||||
|
this.requireDc(s);
|
||||||
|
const info = await this.root(s, 'samba-tool domain info 127.0.0.1 2>&1; echo "@@LEVEL"; samba-tool domain level show 2>&1 | grep -E "level|Forest|Domain"; echo "@@FSMO"; samba-tool fsmo show 2>&1');
|
||||||
|
const sec = sections(`@@INFO\n${info.out}`);
|
||||||
|
let users = [], groups = [], computers = [];
|
||||||
|
if (s.tools.has('ldbsearch')) {
|
||||||
|
const ldb = (filter, attrs) => this.rootOk(s, `ldbsearch -H "$(testparm -s --parameter-name='private dir' 2>/dev/null)/sam.ldb" ${q(filter)} ${attrs} 2>/dev/null`).then(parseLdif);
|
||||||
|
const rawUsers = await ldb('(&(objectCategory=person)(objectClass=user))', 'sAMAccountName displayName mail userAccountControl memberOf lastLogonTimestamp description');
|
||||||
|
const rawGroups = await ldb('(objectClass=group)', 'sAMAccountName description member groupType');
|
||||||
|
const rawComputers = await ldb('(objectClass=computer)', 'sAMAccountName dNSHostName operatingSystem operatingSystemVersion lastLogonTimestamp');
|
||||||
|
// DN → Kontoname, denn samba-tool erwartet bei Mitgliedschaften sAMAccountName statt Anzeigename
|
||||||
|
const sam = new Map([...rawUsers, ...rawGroups, ...rawComputers].map((o) => [String(o.dn).toLowerCase(), o.sAMAccountName]));
|
||||||
|
const acct = (dn) => sam.get(String(dn).toLowerCase()) || cn(dn);
|
||||||
|
users = rawUsers.map((o) => ({
|
||||||
|
name: o.sAMAccountName, display: o.displayName || '', mail: o.mail || '', disabled: !!(Number(o.userAccountControl) & 2), groups: arr(o.memberOf).map(acct), lastLogon: fileTime(o.lastLogonTimestamp), description: o.description || '',
|
||||||
|
}));
|
||||||
|
groups = rawGroups.map((o) => ({
|
||||||
|
name: o.sAMAccountName, description: o.description || '', members: arr(o.member).map(acct), builtin: (Number(o.groupType) & 1) === 1,
|
||||||
|
}));
|
||||||
|
computers = rawComputers.map((o) => ({
|
||||||
|
name: String(o.sAMAccountName || '').replace(/\$$/, ''), dns: o.dNSHostName || '', os: [o.operatingSystem, o.operatingSystemVersion].filter(Boolean).join(' '), lastLogon: fileTime(o.lastLogonTimestamp),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const list = async (c) => (await this.rootOk(s, `samba-tool ${c} list 2>/dev/null`)).split('\n').map((x) => x.trim()).filter(Boolean);
|
||||||
|
users = (await list('user')).map((name) => ({ name, groups: [] }));
|
||||||
|
groups = (await list('group')).map((name) => ({ name, members: [] }));
|
||||||
|
computers = (await list('computer')).map((name) => ({ name: name.replace(/\$$/, '') }));
|
||||||
|
}
|
||||||
|
const sort = (a, b) => String(a.name).localeCompare(String(b.name));
|
||||||
|
return { info: sec.INFO || '', level: sec.LEVEL || '', fsmo: sec.FSMO || '', users: users.sort(sort), groups: groups.sort(sort), computers: computers.sort(sort), limited: !s.tools.has('ldbsearch') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// samba-tool fragt Passwörter mit getpass ab; ohne Terminal liest es sie von stdin
|
||||||
|
async adUserCreate(s, o) {
|
||||||
|
this.requireDc(s);
|
||||||
|
const u = check(String(o.name || '').trim(), RX.user, 'user');
|
||||||
|
if (!o.password) throw new Error(i18n.t('Please enter a password.'));
|
||||||
|
const opt = (flag, v, rx) => (String(v || '').trim() ? ` ${flag}=${q(check(String(v).trim(), rx, flag))}` : '');
|
||||||
|
await this.rootOk(s, `IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | samba-tool user create ${q(u)}${opt('--given-name', o.given, RX.text)}${opt('--surname', o.surname, RX.text)}${opt('--mail-address', o.mail, /^[^\s'"`$\\]+@[^\s'"`$\\]+$/)}${opt('--description', o.description, RX.text)}${o.mustChange ? ' --must-change-at-next-login' : ''} 2>&1`, `${o.password}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async adUserDelete(s, name) { this.requireDc(s); await this.rootOk(s, `samba-tool user delete ${q(check(name, RX.user, 'user'))} 2>&1`); return true; }
|
||||||
|
async adUserEnable(s, name, on) { this.requireDc(s); await this.rootOk(s, `samba-tool user ${on ? 'enable' : 'disable'} ${q(check(name, RX.user, 'user'))} 2>&1`); return true; }
|
||||||
|
async adSetPassword(s, name, password, mustChange) {
|
||||||
|
this.requireDc(s);
|
||||||
|
if (!password) throw new Error(i18n.t('Please enter a password.'));
|
||||||
|
await this.rootOk(s, `IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | samba-tool user setpassword ${q(check(name, RX.user, 'user'))}${mustChange ? ' --must-change-at-next-login' : ''} 2>&1`, `${password}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async adGroupCreate(s, name, description) {
|
||||||
|
this.requireDc(s);
|
||||||
|
await this.rootOk(s, `samba-tool group add ${q(check(String(name).trim(), RX.group, 'group'))}${description ? ` --description=${q(check(description, RX.text, 'description'))}` : ''} 2>&1`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async adGroupDelete(s, name) { this.requireDc(s); await this.rootOk(s, `samba-tool group delete ${q(check(name, RX.group, 'group'))} 2>&1`); return true; }
|
||||||
|
async adGroupMembers(s, group, add, remove) {
|
||||||
|
this.requireDc(s);
|
||||||
|
const g = q(check(group, RX.group, 'group'));
|
||||||
|
const list = (a) => a.map((m) => check(m, /^[\w][\w .$-]{0,63}$/, 'member')).join(',');
|
||||||
|
if (add?.length) await this.rootOk(s, `samba-tool group addmembers ${g} ${q(list(add))} 2>&1`);
|
||||||
|
if (remove?.length) await this.rootOk(s, `samba-tool group removemembers ${g} ${q(list(remove))} 2>&1`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async adComputerDelete(s, name) { this.requireDc(s); await this.rootOk(s, `samba-tool computer delete ${q(check(name, RX.user, 'computer'))} 2>&1`); return true; }
|
||||||
|
|
||||||
|
// Neue AD-Domäne anlegen: dieser Server wird Samba-AD-Domänencontroller.
|
||||||
|
// smbd/nmbd/winbind werden abgeschaltet (der DC bringt eigene Dienste mit), die alte smb.conf wird gesichert.
|
||||||
|
// samba-tool nimmt das Admin-Passwort nur als Option an; es kommt per stdin in das Skript und steht nur kurz in dessen Prozessliste.
|
||||||
|
async provisionDomain(s, o) {
|
||||||
|
const realm = check(String(o.realm || '').trim().toUpperCase(), /^[A-Z0-9]([A-Z0-9-]*[A-Z0-9])?(\.[A-Z0-9]([A-Z0-9-]*[A-Z0-9])?)+$/, 'realm');
|
||||||
|
const domain = check(String(o.domain || realm.split('.')[0]).trim().toUpperCase(), /^[A-Z0-9][A-Z0-9-]{0,14}$/, 'NetBIOS domain');
|
||||||
|
if (!o.password) throw new Error(i18n.t('Please enter a password.'));
|
||||||
|
const fwd = String(o.forwarder || '').trim();
|
||||||
|
if (fwd) check(fwd, /^[\d.]+$|^[0-9a-fA-F:]+$/, 'DNS forwarder');
|
||||||
|
const r = await this.root(s, `command -v samba-tool >/dev/null 2>&1 || exit 3
|
||||||
|
[ -f /var/lib/samba/private/sam.ldb ] && exit 5
|
||||||
|
IFS= read -r PW
|
||||||
|
for u in smbd nmbd winbind; do systemctl stop $u 2>/dev/null; systemctl disable $u 2>/dev/null; done
|
||||||
|
[ -f /etc/samba/smb.conf ] && mv -f /etc/samba/smb.conf /etc/samba/smb.conf.mrterm-before-dc
|
||||||
|
if ! samba-tool domain provision --realm=${q(realm)} --domain=${q(domain)} --server-role=dc --dns-backend=SAMBA_INTERNAL --adminpass="$PW"${o.rfc2307 ? ' --use-rfc2307' : ''}${fwd ? ` --option=${q(`dns forwarder = ${fwd}`)}` : ''} 2>&1; then
|
||||||
|
[ -f /etc/samba/smb.conf.mrterm-before-dc ] && mv -f /etc/samba/smb.conf.mrterm-before-dc /etc/samba/smb.conf
|
||||||
|
for u in smbd nmbd; do systemctl enable --now $u 2>/dev/null; done
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
cp -f /var/lib/samba/private/krb5.conf /etc/krb5.conf
|
||||||
|
${o.useAsDns ? `if [ ! -L /etc/resolv.conf ]; then cp -a /etc/resolv.conf /etc/resolv.conf.mrterm-before-dc; printf 'search %s\\nnameserver 127.0.0.1\\n' ${q(realm.toLowerCase())} > /etc/resolv.conf; fi` : ''}
|
||||||
|
systemctl unmask samba-ad-dc 2>/dev/null; systemctl enable samba-ad-dc 2>/dev/null; systemctl restart samba-ad-dc || exit 6
|
||||||
|
echo provisioned`, `${o.password}\n`);
|
||||||
|
if (r.code === 3) throw new Error(i18n.t('samba-tool is missing. Install the domain controller packages first.'));
|
||||||
|
if (r.code === 5) throw new Error(i18n.t('This server already contains an AD domain database (/var/lib/samba/private/sam.ldb).'));
|
||||||
|
if (r.code === 4) throw new Error(i18n.t('Creating the domain failed: {err}', { err: (r.out + r.err).trim().split('\n').filter((l) => !/^WARNING: Using passwords on command line/.test(l)).slice(-4).join('\n') }));
|
||||||
|
if (r.code === 6) throw new Error(i18n.t('The domain was created, but samba-ad-dc could not be started: {err}', { err: (r.out + r.err).trim().split('\n').slice(-3).join('\n') }));
|
||||||
|
if (r.code) throw new Error((r.out + r.err).trim().split('\n').slice(-3).join('\n') || i18n.t('Command failed with code {code}', { code: r.code }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestehender Domäne beitreten (realmd + sssd); das Admin-Passwort liest realm von stdin
|
||||||
|
async joinDomain(s, domain, admin, password) {
|
||||||
|
const d = check(String(domain).trim(), /^[a-zA-Z0-9.-]+$/, 'domain');
|
||||||
|
const a = check(String(admin || 'Administrator').trim(), RX.user, 'user');
|
||||||
|
if (!password) throw new Error(i18n.t('Please enter a password.'));
|
||||||
|
if (!s.tools.has('realm')) throw new Error(i18n.t('realmd is not installed. Install the domain join tools first.'));
|
||||||
|
const r = await this.root(s, `IFS= read -r PW; printf '%s\\n' "$PW" | realm join -v -U ${q(a)} ${q(d)} 2>&1`, `${password}\n`);
|
||||||
|
if (r.code) throw new Error(i18n.t('Joining the domain failed: {err}', { err: (r.out + r.err).trim().split('\n').filter((l) => !/^\s*\*/.test(l)).slice(-3).join('\n') || r.code }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveDomain(s, domain) {
|
||||||
|
await this.rootOk(s, `realm leave -v ${q(check(String(domain).trim(), /^[a-zA-Z0-9.-]+$/, 'domain'))} 2>&1`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domäne beitreten (fragt das Passwort des Domänen-Admins) – im Terminal
|
||||||
|
joinCommand(s, domain, admin) {
|
||||||
|
const d = check(String(domain).trim(), /^[a-zA-Z0-9.-]+$/, 'domain');
|
||||||
|
const a = check(String(admin || 'Administrator').trim(), RX.user, 'user');
|
||||||
|
if (s.tools.has('realm')) return this.termCmd(s, `realm join -v -U ${q(a)} ${q(d)}`);
|
||||||
|
return this.termCmd(s, `net ads join -U ${q(a)} -S ${q(d)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
installCommand(s, kind) {
|
||||||
|
const pkgs = kind === 'join'
|
||||||
|
? { apt: ['realmd', 'sssd', 'sssd-tools', 'adcli', 'samba-common-bin', 'packagekit'], dnf: ['realmd', 'sssd', 'adcli', 'samba-common-tools'], yum: ['realmd', 'sssd', 'adcli', 'samba-common-tools'], pacman: ['realmd', 'sssd', 'adcli', 'samba'], zypper: ['realmd', 'sssd', 'adcli', 'samba-client'] }[s.pkg]
|
||||||
|
: kind === 'dc'
|
||||||
|
// Fedora/RHEL bauen Samba ohne AD-DC (MIT-Kerberos), daher nur Debian/Ubuntu, Arch und openSUSE
|
||||||
|
? { apt: ['samba', 'winbind', 'krb5-user', 'smbclient', 'ldb-tools', 'dnsutils'], pacman: ['samba', 'krb5', 'ldb'], zypper: ['samba-ad-dc', 'samba-client', 'krb5-client'] }[s.pkg]
|
||||||
|
: { apt: ['samba'], dnf: ['samba'], yum: ['samba'], pacman: ['samba'], zypper: ['samba'], apk: ['samba'] }[s.pkg];
|
||||||
|
if (!pkgs) throw new Error(kind === 'dc' && /dnf|yum/.test(s.pkg) ? i18n.t('Samba from Fedora/RHEL packages cannot run an AD domain controller.') : i18n.t('No known package manager on this host.'));
|
||||||
|
// krb5-user fragt sonst interaktiv nach dem Standard-Realm; samba-ad-dc ist ab Debian 13 / Ubuntu 24.04 ein eigenes Paket
|
||||||
|
const extra = kind === 'dc' && s.pkg === 'apt' ? ' && { ! apt-cache show samba-ad-dc >/dev/null 2>&1 || apt-get install -y samba-ad-dc; }' : '';
|
||||||
|
return this.termCmd(s, `${s.pkg === 'apt' ? 'export DEBIAN_FRONTEND=noninteractive; ' : ''}${installCmd(s.pkg, pkgs)}${extra}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SambaManager, parseConf, parsePdbedit, parseStatus, parseLdif };
|
||||||
+19
-7
@@ -1,6 +1,7 @@
|
|||||||
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
|
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
|
||||||
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
|
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
|
||||||
const { app, safeStorage } = require('electron');
|
// Im Web-Server gibt es kein Electron: dort übergibt der Aufrufer Verzeichnis und Verschlüsselung (opts)
|
||||||
|
const electron = (() => { try { const e = require('electron'); return typeof e === 'object' ? e : {}; } catch { return {}; } })();
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -15,6 +16,8 @@ const DEFAULTS = {
|
|||||||
vpns: [],
|
vpns: [],
|
||||||
knownHosts: {},
|
knownHosts: {},
|
||||||
history: [],
|
history: [],
|
||||||
|
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
|
||||||
|
aiChats: [],
|
||||||
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
||||||
tombstones: [],
|
tombstones: [],
|
||||||
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
||||||
@@ -38,6 +41,13 @@ const DEFAULTS = {
|
|||||||
updatePrerelease: false,
|
updatePrerelease: false,
|
||||||
autoLock: 0,
|
autoLock: 0,
|
||||||
language: 'auto',
|
language: 'auto',
|
||||||
|
dockerStacksDir: '/opt/stacks',
|
||||||
|
dockerTemplatesUrl: '',
|
||||||
|
aiUrl: 'http://localhost:11434',
|
||||||
|
aiModel: '',
|
||||||
|
// Feature-Menüs: { docker: false, … } blendet einzelne aus, fehlende Einträge = an
|
||||||
|
features: {},
|
||||||
|
homeAnimation: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,8 +67,10 @@ function unbox(key, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
constructor() {
|
// opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage
|
||||||
this.dir = app.getPath('userData');
|
constructor(opts = {}) {
|
||||||
|
this.dir = opts.dir || electron.app.getPath('userData');
|
||||||
|
this.crypto = opts.crypto || electron.safeStorage;
|
||||||
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;
|
||||||
@@ -97,9 +109,9 @@ class Store {
|
|||||||
|
|
||||||
canEncrypt() {
|
canEncrypt() {
|
||||||
try {
|
try {
|
||||||
if (!safeStorage.isEncryptionAvailable()) return false;
|
if (!this.crypto.isEncryptionAvailable()) return false;
|
||||||
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
|
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
|
||||||
if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend?.() === 'basic_text') return false;
|
if (process.platform === 'linux' && this.crypto.getSelectedStorageBackend?.() === 'basic_text') return false;
|
||||||
return true;
|
return true;
|
||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
@@ -110,7 +122,7 @@ class Store {
|
|||||||
const raw = fs.readFileSync(this.file);
|
const raw = fs.readFileSync(this.file);
|
||||||
let json;
|
let json;
|
||||||
if (raw.slice(0, 4).toString() === 'ENC1') {
|
if (raw.slice(0, 4).toString() === 'ENC1') {
|
||||||
json = safeStorage.decryptString(raw.slice(4));
|
json = this.crypto.decryptString(raw.slice(4));
|
||||||
this.encrypted = true;
|
this.encrypted = true;
|
||||||
} else {
|
} else {
|
||||||
json = raw.toString('utf8');
|
json = raw.toString('utf8');
|
||||||
@@ -136,7 +148,7 @@ class Store {
|
|||||||
}
|
}
|
||||||
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'), this.crypto.encryptString(json)]));
|
||||||
this.encrypted = true;
|
this.encrypted = true;
|
||||||
} else {
|
} else {
|
||||||
fs.writeFileSync(tmp, json, { mode: 0o600 });
|
fs.writeFileSync(tmp, json, { mode: 0o600 });
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// sudo für Firewall, Netzwerk und Docker: erst ohne Passwort (NOPASSWD), dann mit dem am Host gespeicherten Passwort,
|
||||||
|
// sonst den Nutzer fragen (bis zu 3 Versuche). Das Passwort gilt nur für die offene Sitzung.
|
||||||
|
// run(cmd, stdin) → Promise<{ code, out, err }>
|
||||||
|
// → Passwort ('' bei NOPASSWD) oder null, wenn sudo nicht möglich ist bzw. abgebrochen wurde
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
async function acquireSudo(ssh, sessionId, host, run) {
|
||||||
|
if ((await run('sudo -n true')).code === 0) return '';
|
||||||
|
const works = async (pw) => (await run("sudo -S -p '' -v", `${pw}\n`)).code === 0;
|
||||||
|
if (host.password && await works(host.password)) return host.password;
|
||||||
|
const user = `${host.username || ''}@${host.address}`.replace(/^@/, '');
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const pw = await ssh.askSecret(sessionId, {
|
||||||
|
title: i18n.t('sudo password'),
|
||||||
|
prompt: i ? i18n.t('Wrong password. sudo password for {user}', { user }) : i18n.t('sudo password for {user}', { user }),
|
||||||
|
echo: false,
|
||||||
|
host: host.label || host.address,
|
||||||
|
});
|
||||||
|
if (pw == null) return null;
|
||||||
|
if (await works(pw)) return pw;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { acquireSudo };
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// Webserver-Tab: nginx, Apache (apache2/httpd), Caddy und PHP-FPM auf entfernten Hosts.
|
||||||
|
// Dienste steuern, Sites anlegen/aktivieren/löschen, Konfiguration bearbeiten – jede Änderung wird vor dem Neuladen
|
||||||
|
// geprüft (nginx -t, apachectl configtest, caddy validate, php-fpm -t) und bei einem Fehler zurückgerollt.
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
const { AdminManager, sections, lastLine, q, check, installCmd } = require('./admin');
|
||||||
|
|
||||||
|
const RX = {
|
||||||
|
name: /^[a-zA-Z0-9][\w.-]{0,80}$/,
|
||||||
|
domain: /^(_|(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*)$/,
|
||||||
|
caddyAddr: /^(https?:\/\/)?((\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?)?(:\d{1,5})?$/,
|
||||||
|
root: /^\/[\w./@+-]*$/,
|
||||||
|
upstream: /^https?:\/\/[\w.\-[\]:]+(:\d{1,5})?(\/[\w./%-]*)?$/,
|
||||||
|
sock: /^\/[\w./@-]+\.sock$/,
|
||||||
|
port: /^\d{1,5}$/,
|
||||||
|
// Nur Konfigurationsdateien der Webserver und von PHP
|
||||||
|
file: /^\/etc\/(nginx|apache2|httpd|caddy|php|php-fpm\.d|php\d*)\/[\w./@+-]+$|^\/etc\/(php\.ini|php-fpm\.conf)$/,
|
||||||
|
};
|
||||||
|
const noDots = (p) => { if (String(p).split('/').includes('..')) throw new Error('Invalid path'); return p; };
|
||||||
|
|
||||||
|
const SERVERS = {
|
||||||
|
nginx: { label: 'nginx', units: ['nginx'], test: 'nginx -t', pkgs: { apt: ['nginx'], dnf: ['nginx'], yum: ['nginx'], pacman: ['nginx'], zypper: ['nginx'], apk: ['nginx'] } },
|
||||||
|
apache: { label: 'Apache', units: ['apache2', 'httpd'], test: 'if command -v apache2ctl >/dev/null 2>&1; then apache2ctl configtest; elif command -v apachectl >/dev/null 2>&1; then apachectl configtest; else httpd -t; fi', pkgs: { apt: ['apache2'], dnf: ['httpd'], yum: ['httpd'], pacman: ['apache'], zypper: ['apache2'], apk: ['apache2'] } },
|
||||||
|
caddy: { label: 'Caddy', units: ['caddy'], test: 'caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile', pkgs: { apt: ['caddy'], dnf: ['caddy'], yum: ['caddy'], pacman: ['caddy'], zypper: ['caddy'], apk: ['caddy'] } },
|
||||||
|
php: { label: 'PHP-FPM', units: [], pkgs: { apt: ['php-fpm'], dnf: ['php-fpm'], yum: ['php-fpm'], pacman: ['php-fpm'], zypper: ['php8-fpm'], apk: ['php83-fpm'] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const GATHER = `unit() { u=$1; a=$(systemctl is-active "$u" 2>/dev/null); e=$(systemctl is-enabled "$u" 2>/dev/null); echo "$u|$a|$e"; }
|
||||||
|
echo "@@NGINX"; if command -v nginx >/dev/null 2>&1; then nginx -v 2>&1 | head -1; unit nginx; fi
|
||||||
|
echo "@@APACHE"; if command -v apache2 >/dev/null 2>&1; then apache2 -v 2>&1 | head -1; unit apache2; elif command -v httpd >/dev/null 2>&1; then httpd -v 2>&1 | head -1; unit httpd; fi
|
||||||
|
echo "@@CADDY"; if command -v caddy >/dev/null 2>&1; then caddy version 2>&1 | head -1; unit caddy; fi
|
||||||
|
echo "@@PHP"; command -v php >/dev/null 2>&1 && php -v 2>/dev/null | head -1
|
||||||
|
for u in $(systemctl list-unit-files --type=service --no-legend 2>/dev/null | awk '{print $1}' | grep -E '^php[0-9.]*-fpm\\.service$|^php-fpm[0-9.]*\\.service$'); do unit "\${u%.service}"; done
|
||||||
|
echo "@@SOCKS"; ls -1 /run/php/*.sock /run/php-fpm/*.sock /var/run/php-fpm/*.sock 2>/dev/null
|
||||||
|
echo "@@NGINXSITES"
|
||||||
|
if [ -d /etc/nginx/sites-available ]; then for f in /etc/nginx/sites-available/*; do [ -f "$f" ] || continue; n=\${f##*/}; e=0; [ -e "/etc/nginx/sites-enabled/$n" ] && e=1; echo "F|$f|$n|$e|$(grep -hoE '^[[:space:]]*server_name[[:space:]]+[^;]+' "$f" | sed -E 's/^[[:space:]]*server_name[[:space:]]+//' | tr '\\n' ' ')"; done; fi
|
||||||
|
for f in /etc/nginx/conf.d/*.conf /etc/nginx/conf.d/*.conf.disabled; do [ -f "$f" ] || continue; n=\${f##*/}; e=1; case "$n" in *.disabled) e=0;; esac; echo "C|$f|$n|$e|$(grep -hoE '^[[:space:]]*server_name[[:space:]]+[^;]+' "$f" | sed -E 's/^[[:space:]]*server_name[[:space:]]+//' | tr '\\n' ' ')"; done
|
||||||
|
echo "@@APACHESITES"
|
||||||
|
if [ -d /etc/apache2/sites-available ]; then for f in /etc/apache2/sites-available/*.conf; do [ -f "$f" ] || continue; n=\${f##*/}; e=0; [ -e "/etc/apache2/sites-enabled/$n" ] && e=1; echo "F|$f|$n|$e|$(grep -hoiE '^[[:space:]]*(ServerName|ServerAlias)[[:space:]]+.+' "$f" | sed -E 's/^[[:space:]]*[A-Za-z]+[[:space:]]+//' | tr '\\n' ' ')"; done
|
||||||
|
elif [ -d /etc/httpd/conf.d ]; then for f in /etc/httpd/conf.d/*.conf /etc/httpd/conf.d/*.conf.disabled; do [ -f "$f" ] || continue; grep -qi '<VirtualHost' "$f" || continue; n=\${f##*/}; e=1; case "$n" in *.disabled) e=0;; esac; echo "C|$f|$n|$e|$(grep -hoiE '^[[:space:]]*(ServerName|ServerAlias)[[:space:]]+.+' "$f" | sed -E 's/^[[:space:]]*[A-Za-z]+[[:space:]]+//' | tr '\\n' ' ')"; done; fi
|
||||||
|
echo "@@CADDYFILE"; [ -f /etc/caddy/Caddyfile ] && cat /etc/caddy/Caddyfile
|
||||||
|
echo "@@FILES"
|
||||||
|
ls -1d /etc/nginx/nginx.conf /etc/apache2/apache2.conf /etc/apache2/ports.conf /etc/httpd/conf/httpd.conf /etc/caddy/Caddyfile /etc/php/*/fpm/php.ini /etc/php/*/fpm/pool.d/*.conf /etc/php.ini /etc/php-fpm.conf /etc/php-fpm.d/*.conf /etc/php/php.ini /etc/php/php-fpm.d/*.conf 2>/dev/null
|
||||||
|
echo "@@END"`;
|
||||||
|
|
||||||
|
const units = (text) => text.split('\n').filter((l) => /^[\w@.-]+\|/.test(l)).map((l) => { const [unit, active, enabled] = l.split('|'); return { unit, active, enabled }; });
|
||||||
|
|
||||||
|
// Top-Level-Blöcke im Caddyfile (Site-Adressen); der globale Optionsblock "{ … }" wird übersprungen
|
||||||
|
function caddySites(text) {
|
||||||
|
const out = [];
|
||||||
|
let depth = 0;
|
||||||
|
text.split('\n').forEach((line, i) => {
|
||||||
|
const t = line.replace(/#.*$/, '').trim();
|
||||||
|
if (!depth && t.endsWith('{') && t !== '{' && !t.startsWith('(')) out.push({ name: t.slice(0, -1).trim(), line: i + 1 });
|
||||||
|
depth += (t.match(/\{/g) || []).length - (t.match(/\}/g) || []).length;
|
||||||
|
if (depth < 0) depth = 0;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templates(server, o) {
|
||||||
|
const domains = o.domains.join(' ');
|
||||||
|
if (server === 'nginx') {
|
||||||
|
const head = `server {\n listen 80;\n listen [::]:80;\n server_name ${domains};\n`;
|
||||||
|
if (o.type === 'proxy') return `${head}\n location / {\n proxy_pass ${o.upstream};\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection "upgrade";\n }\n}\n`;
|
||||||
|
if (o.type === 'php') return `${head} root ${o.root};\n index index.php index.html;\n\n location / {\n try_files $uri $uri/ /index.php?$query_string;\n }\n\n location ~ \\.php$ {\n include fastcgi_params;\n fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n fastcgi_pass unix:${o.sock};\n }\n\n location ~ /\\.ht {\n deny all;\n }\n}\n`;
|
||||||
|
return `${head} root ${o.root};\n index index.html index.htm;\n\n location / {\n try_files $uri $uri/ =404;\n }\n}\n`;
|
||||||
|
}
|
||||||
|
if (server === 'apache') {
|
||||||
|
const [first, ...rest] = o.domains;
|
||||||
|
const head = `<VirtualHost *:80>\n ServerName ${first}\n${rest.length ? ` ServerAlias ${rest.join(' ')}\n` : ''}`;
|
||||||
|
if (o.type === 'proxy') return `${head} ProxyPreserveHost On\n ProxyPass / ${o.upstream.replace(/\/?$/, '/')}\n ProxyPassReverse / ${o.upstream.replace(/\/?$/, '/')}\n</VirtualHost>\n`;
|
||||||
|
const dir = ` DocumentRoot ${o.root}\n <Directory ${o.root}>\n Options -Indexes +FollowSymLinks\n AllowOverride All\n Require all granted\n </Directory>\n`;
|
||||||
|
if (o.type === 'php') return `${head}${dir} DirectoryIndex index.php index.html\n <FilesMatch "\\.php$">\n SetHandler "proxy:unix:${o.sock}|fcgi://localhost"\n </FilesMatch>\n</VirtualHost>\n`;
|
||||||
|
return `${head}${dir}</VirtualHost>\n`;
|
||||||
|
}
|
||||||
|
// Caddy holt sich für öffentliche Domains automatisch Zertifikate (Let's Encrypt / ZeroSSL)
|
||||||
|
const addr = o.domains.join(', ');
|
||||||
|
if (o.type === 'proxy') return `\n${addr} {\n reverse_proxy ${o.upstream.replace(/^https?:\/\//, (m) => (m === 'https://' ? m : ''))}\n}\n`;
|
||||||
|
if (o.type === 'php') return `\n${addr} {\n root * ${o.root}\n php_fastcgi unix/${o.sock}\n file_server\n}\n`;
|
||||||
|
return `\n${addr} {\n root * ${o.root}\n file_server\n}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebServerManager extends AdminManager {
|
||||||
|
static OPS = ['service', 'readFile', 'writeFile', 'setSiteEnabled', 'createSite', 'saveSite', 'deleteSite', 'logsCommand', 'installCommand', 'test'];
|
||||||
|
|
||||||
|
async read(id) {
|
||||||
|
const s = this.get(id);
|
||||||
|
const sec = sections(await this.rootOk(s, GATHER));
|
||||||
|
const server = (key, text) => {
|
||||||
|
const lines = (text || '').split('\n').filter(Boolean);
|
||||||
|
const [unit] = units(text || '');
|
||||||
|
return { id: key, label: SERVERS[key].label, installed: !!lines.length, version: (lines[0] || '').replace(/^Server version:\s*/i, ''), ...(unit || { unit: SERVERS[key].units[0], active: 'unknown', enabled: '' }) };
|
||||||
|
};
|
||||||
|
const sites = (text, srv) => (text || '').split('\n').filter((l) => /^[FC]\|/.test(l)).map((l) => {
|
||||||
|
const [layout, path, name, enabled, domains] = l.split('|');
|
||||||
|
return { server: srv, path, name, enabled: enabled === '1', layout, domains: (domains || '').trim().split(/\s+/).filter(Boolean) };
|
||||||
|
});
|
||||||
|
const caddy = sec.CADDYFILE || '';
|
||||||
|
const phpLines = (sec.PHP || '').split('\n');
|
||||||
|
s.units = new Set();
|
||||||
|
const res = {
|
||||||
|
pkg: s.pkg,
|
||||||
|
servers: [server('nginx', sec.NGINX), server('apache', sec.APACHE), server('caddy', sec.CADDY)],
|
||||||
|
php: { version: phpLines[0] && !/\|/.test(phpLines[0]) ? phpLines[0] : '', pools: units(sec.PHP || ''), sockets: (sec.SOCKS || '').split('\n').filter(Boolean) },
|
||||||
|
sites: [...sites(sec.NGINXSITES, 'nginx'), ...sites(sec.APACHESITES, 'apache'), ...caddySites(caddy).map((x) => ({ server: 'caddy', path: '/etc/caddy/Caddyfile', name: x.name, enabled: true, layout: 'caddy', domains: x.name.split(/[\s,]+/).filter(Boolean), line: x.line }))],
|
||||||
|
files: (sec.FILES || '').split('\n').filter(Boolean),
|
||||||
|
};
|
||||||
|
res.servers.forEach((x) => x.installed && s.units.add(x.unit));
|
||||||
|
res.php.pools.forEach((p) => s.units.add(p.unit));
|
||||||
|
s.layout = { nginx: res.sites.find((x) => x.server === 'nginx')?.layout, apache: res.sites.find((x) => x.server === 'apache')?.layout };
|
||||||
|
s.info = res;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfbefehl passend zur Datei
|
||||||
|
testFor(path) {
|
||||||
|
if (path.startsWith('/etc/nginx/')) return SERVERS.nginx.test;
|
||||||
|
if (/^\/etc\/(apache2|httpd)\//.test(path)) return SERVERS.apache.test;
|
||||||
|
if (path.startsWith('/etc/caddy/')) return path === '/etc/caddy/Caddyfile' ? SERVERS.caddy.test : 'true';
|
||||||
|
const v = path.match(/^\/etc\/php\/(\d+\.\d+)\//)?.[1];
|
||||||
|
if (v) return `php-fpm${v} -t`;
|
||||||
|
return 'if command -v php-fpm >/dev/null 2>&1; then php-fpm -t; else true; fi';
|
||||||
|
}
|
||||||
|
reloadFor(s, path) {
|
||||||
|
const unit = path.startsWith('/etc/nginx/') ? 'nginx' : /^\/etc\/apache2\//.test(path) ? 'apache2' : /^\/etc\/httpd\//.test(path) ? 'httpd' : path.startsWith('/etc/caddy/') ? 'caddy'
|
||||||
|
: [...(s.units || [])].find((u) => /php/.test(u) && (!path.match(/\/etc\/php\/(\d+\.\d+)\//) || u.includes(path.match(/\/etc\/php\/(\d+\.\d+)\//)[1])));
|
||||||
|
return unit ? `if systemctl is-active -q ${unit}; then systemctl reload ${unit} 2>/dev/null || systemctl restart ${unit}; fi` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(s, server) {
|
||||||
|
const t = SERVERS[check(server, /^(nginx|apache|caddy)$/, 'server')].test;
|
||||||
|
const r = await this.root(s, `${t} 2>&1`);
|
||||||
|
return { ok: !r.code, output: (r.out + r.err).trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async service(s, unit, action) {
|
||||||
|
if (!s.units?.has(unit)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'service', value: unit }));
|
||||||
|
check(action, /^(start|stop|restart|reload|enable|disable)$/, 'action');
|
||||||
|
if (action === 'reload' || action === 'restart' || action === 'start') {
|
||||||
|
const srv = Object.entries(SERVERS).find(([, x]) => x.units.includes(unit));
|
||||||
|
if (srv?.[1].test) { const t = await this.root(s, `${srv[1].test} 2>&1`); if (t.code) throw new Error(i18n.t('The configuration is invalid: {err}', { err: (t.out + t.err).trim().split('\n').slice(-4).join('\n') })); }
|
||||||
|
}
|
||||||
|
await this.rootOk(s, action === 'enable' || action === 'disable' ? `systemctl ${action} --now ${unit}` : `systemctl ${action} ${unit}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
readFile(s, path) {
|
||||||
|
check(noDots(path), RX.file, 'file');
|
||||||
|
return this.rootOk(s, `cat ${q(path)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(s, path, content) {
|
||||||
|
check(noDots(path), RX.file, 'file');
|
||||||
|
return this.writeChecked(s, path, content, { validate: this.testFor(path), after: this.reloadFor(s, path) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site aktivieren/deaktivieren: Debian-Layout per Symlink bzw. a2ensite, sonst Umbenennen nach *.disabled
|
||||||
|
async setSiteEnabled(s, server, path, on) {
|
||||||
|
check(server, /^(nginx|apache)$/, 'server');
|
||||||
|
check(noDots(path), RX.file, 'file');
|
||||||
|
const name = path.split('/').pop();
|
||||||
|
const test = SERVERS[server].test;
|
||||||
|
let change, undo;
|
||||||
|
if (server === 'nginx' && path.startsWith('/etc/nginx/sites-available/')) {
|
||||||
|
change = on ? `ln -sf ${q(path)} ${q(`/etc/nginx/sites-enabled/${name}`)}` : `rm -f ${q(`/etc/nginx/sites-enabled/${name}`)}`;
|
||||||
|
undo = on ? `rm -f ${q(`/etc/nginx/sites-enabled/${name}`)}` : `ln -sf ${q(path)} ${q(`/etc/nginx/sites-enabled/${name}`)}`;
|
||||||
|
} else if (server === 'apache' && path.startsWith('/etc/apache2/sites-available/')) {
|
||||||
|
const site = name.replace(/\.conf$/, '');
|
||||||
|
change = `${on ? 'a2ensite' : 'a2dissite'} -q ${q(site)}`;
|
||||||
|
undo = `${on ? 'a2dissite' : 'a2ensite'} -q ${q(site)}`;
|
||||||
|
} else {
|
||||||
|
const enabledPath = path.replace(/\.disabled$/, '');
|
||||||
|
const disabledPath = `${enabledPath}.disabled`;
|
||||||
|
change = on ? `mv -f ${q(disabledPath)} ${q(enabledPath)}` : `mv -f ${q(enabledPath)} ${q(disabledPath)}`;
|
||||||
|
undo = on ? `mv -f ${q(enabledPath)} ${q(disabledPath)}` : `mv -f ${q(disabledPath)} ${q(enabledPath)}`;
|
||||||
|
}
|
||||||
|
const unit = server === 'nginx' ? 'nginx' : [...(s.units || [])].find((u) => u === 'apache2' || u === 'httpd') || 'apache2';
|
||||||
|
const r = await this.root(s, `${change} || exit 1
|
||||||
|
if ! out=$( (${test}) 2>&1 ); then ${undo}; printf '%s\\n' "$out" >&2; exit 4; fi
|
||||||
|
if systemctl is-active -q ${unit}; then systemctl reload ${unit}; fi`);
|
||||||
|
if (r.code === 4) throw new Error(i18n.t('The configuration is invalid, the previous version was restored: {err}', { err: r.err.trim().split('\n').slice(-6).join('\n') }));
|
||||||
|
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSite(s, server, o) {
|
||||||
|
check(server, /^(nginx|apache|caddy)$/, 'server');
|
||||||
|
const opts = { type: check(o.type, /^(static|proxy|php)$/, 'type') };
|
||||||
|
// Caddy-Adressen dürfen auch Port/Schema enthalten (":8080", "http://intern.lan", "example.com:8443")
|
||||||
|
opts.domains = String(o.domains || '').split(/[\s,]+/).filter(Boolean).map((d) => check(d, server === 'caddy' ? RX.caddyAddr : RX.domain, 'domain'));
|
||||||
|
if (!opts.domains.length) throw new Error(i18n.t('Please enter at least one domain.'));
|
||||||
|
if (opts.type === 'proxy') opts.upstream = check(String(o.upstream || '').trim(), RX.upstream, 'upstream');
|
||||||
|
else opts.root = check(noDots(String(o.root || '').trim()), RX.root, 'root');
|
||||||
|
if (opts.type === 'php') opts.sock = check(String(o.sock || '').trim(), RX.sock, 'PHP-FPM socket');
|
||||||
|
const modules = opts.type === 'proxy' ? ['proxy', 'proxy_http'] : opts.type === 'php' ? ['proxy', 'proxy_fcgi'] : [];
|
||||||
|
return (await this.saveSite(s, server, { name: o.name || opts.domains[0], content: templates(server, opts), modules, mkRoot: o.createRoot ? opts.root : '', title: opts.domains[0] })).output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fertige Konfiguration als neue Site speichern (aus "Neue Site" oder dem Generator):
|
||||||
|
// schreiben, aktivieren, prüfen – bei Fehler wieder entfernen. Caddy: Block ans Caddyfile anhängen.
|
||||||
|
// o: { name, content, modules (Apache), mkRoot (Ordner mit Platzhalterseite), title }
|
||||||
|
async saveSite(s, server, o) {
|
||||||
|
check(server, /^(nginx|apache|caddy)$/, 'server');
|
||||||
|
const content = String(o.content || '');
|
||||||
|
if (!content.trim() || content.length > 200000) throw new Error('Invalid content');
|
||||||
|
let mkRoot = '';
|
||||||
|
if (o.mkRoot) {
|
||||||
|
const r = check(noDots(String(o.mkRoot).trim()), RX.root, 'root');
|
||||||
|
const title = String(o.title || '').replace(/[^\w.-]/g, '') || 'MrTerm';
|
||||||
|
mkRoot = `mkdir -p ${q(r)}; [ -e ${q(`${r}/index.html`)} ] || [ -e ${q(`${r}/index.php`)} ] || printf '%s\\n' '<!doctype html><title>${title}</title><h1>${title}</h1><p>Served by MrTerm</p>' > ${q(`${r}/index.html`)}`;
|
||||||
|
}
|
||||||
|
if (server === 'caddy') {
|
||||||
|
const cur = (await this.root(s, 'cat /etc/caddy/Caddyfile 2>/dev/null')).out;
|
||||||
|
if (mkRoot) await this.rootOk(s, mkRoot);
|
||||||
|
const output = await this.writeChecked(s, '/etc/caddy/Caddyfile', `${cur.replace(/\s*$/, '\n')}\n${content.replace(/^\s+/, '')}`, { validate: SERVERS.caddy.test, after: this.reloadFor(s, '/etc/caddy/Caddyfile') });
|
||||||
|
return { path: '/etc/caddy/Caddyfile', output };
|
||||||
|
}
|
||||||
|
const name = check(String(o.name || '').trim().replace(/^\*\./, 'wildcard.'), RX.name, 'name');
|
||||||
|
let path, enable = '';
|
||||||
|
if (server === 'nginx') {
|
||||||
|
const debian = (await this.root(s, '[ -d /etc/nginx/sites-available ] && [ -d /etc/nginx/sites-enabled ]')).code === 0;
|
||||||
|
path = debian ? `/etc/nginx/sites-available/${name}` : `/etc/nginx/conf.d/${name}.conf`;
|
||||||
|
if (debian) enable = `ln -sf ${q(path)} ${q(`/etc/nginx/sites-enabled/${name}`)}`;
|
||||||
|
} else {
|
||||||
|
const debian = (await this.root(s, '[ -d /etc/apache2/sites-available ]')).code === 0;
|
||||||
|
path = debian ? `/etc/apache2/sites-available/${name}.conf` : `/etc/httpd/conf.d/${name}.conf`;
|
||||||
|
const mods = (o.modules || []).map((m) => check(m, /^[a-z0-9_]{2,30}$/, 'module'));
|
||||||
|
if (debian) enable = `${mods.length ? `a2enmod -q ${mods.join(' ')} && ` : ''}a2ensite -q ${q(name)}`;
|
||||||
|
}
|
||||||
|
if ((await this.root(s, `[ -e ${q(path)} ]`)).code === 0) throw new Error(i18n.t('A site named “{name}” already exists.', { name }));
|
||||||
|
if (mkRoot) await this.rootOk(s, mkRoot);
|
||||||
|
// Erst schreiben + aktivieren, dann prüfen; bei Fehler Site wieder entfernen.
|
||||||
|
// Neu aktivierte Apache-Module brauchen einen Neustart statt reload.
|
||||||
|
const unit = server === 'nginx' ? 'nginx' : [...(s.units || [])].find((u) => u === 'apache2' || u === 'httpd') || 'apache2';
|
||||||
|
const disable = server === 'nginx' ? `rm -f ${q(`/etc/nginx/sites-enabled/${name}`)}` : `command -v a2dissite >/dev/null 2>&1 && a2dissite -q ${q(name)}`;
|
||||||
|
const output = await this.writeChecked(s, path, content, {
|
||||||
|
validate: `${enable || 'true'} && ${SERVERS[server].test} || { ${disable} >/dev/null 2>&1; false; }`,
|
||||||
|
after: `if systemctl is-active -q ${unit}; then systemctl ${server === 'apache' && o.modules?.length ? 'restart' : 'reload'} ${unit}; fi`,
|
||||||
|
});
|
||||||
|
return { path, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSite(s, server, path, name) {
|
||||||
|
check(server, /^(nginx|apache|caddy)$/, 'server');
|
||||||
|
if (server === 'caddy') {
|
||||||
|
// Block ab der Adresszeile bis zur passenden schließenden Klammer entfernen
|
||||||
|
const cur = (await this.rootOk(s, 'cat /etc/caddy/Caddyfile'));
|
||||||
|
const lines = cur.split('\n');
|
||||||
|
const start = lines.findIndex((l) => l.replace(/#.*$/, '').trim() === `${name} {`);
|
||||||
|
if (start < 0) throw new Error(i18n.t('Site not found'));
|
||||||
|
let depth = 0, end = start;
|
||||||
|
for (let i = start; i < lines.length; i++) {
|
||||||
|
const t = lines[i].replace(/#.*$/, '');
|
||||||
|
depth += (t.match(/\{/g) || []).length - (t.match(/\}/g) || []).length;
|
||||||
|
if (depth <= 0) { end = i; break; }
|
||||||
|
}
|
||||||
|
const from = start > 0 && lines[start - 1].trim() === '# Generated by MrTerm' ? start - 1 : start;
|
||||||
|
lines.splice(from, end - from + 1);
|
||||||
|
return this.writeChecked(s, '/etc/caddy/Caddyfile', lines.join('\n').replace(/\n{3,}/g, '\n\n'), { validate: SERVERS.caddy.test, after: this.reloadFor(s, '/etc/caddy/Caddyfile') });
|
||||||
|
}
|
||||||
|
check(noDots(path), RX.file, 'file');
|
||||||
|
const n = path.split('/').pop();
|
||||||
|
const links = server === 'nginx' ? `rm -f ${q(`/etc/nginx/sites-enabled/${n}`)}` : `[ -e ${q(`/etc/apache2/sites-enabled/${n}`)} ] && a2dissite -q ${q(n.replace(/\.conf$/, ''))}`;
|
||||||
|
const unit = server === 'nginx' ? 'nginx' : [...(s.units || [])].find((u) => u === 'apache2' || u === 'httpd') || 'apache2';
|
||||||
|
await this.rootOk(s, `${links}; mkdir -p /var/backups/mrterm && cp -a ${q(path)} /var/backups/mrterm/ && rm -f ${q(path)} || exit 1
|
||||||
|
if ${SERVERS[server].test} >/dev/null 2>&1 && systemctl is-active -q ${unit}; then systemctl reload ${unit}; fi`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Befehle für Terminal-Tabs (Live-Ausgabe, sudo fragt dort selbst)
|
||||||
|
logsCommand(s, server) {
|
||||||
|
const id = check(server, /^(nginx|apache|caddy|php)$/, 'server');
|
||||||
|
const logs = { nginx: '/var/log/nginx/error.log /var/log/nginx/access.log', apache: '/var/log/apache2/error.log /var/log/apache2/access.log /var/log/httpd/error_log /var/log/httpd/access_log' }[id];
|
||||||
|
const unit = id === 'php' ? [...(s.units || [])].find((u) => /php/.test(u)) : id === 'apache' ? [...(s.units || [])].find((u) => u === 'apache2' || u === 'httpd') : id;
|
||||||
|
const cmd = logs ? `tail -n 100 -F $(ls ${logs} 2>/dev/null)` : `journalctl -u ${unit || id} -n 200 -f`;
|
||||||
|
return this.termCmd(s, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
installCommand(s, server) {
|
||||||
|
const id = check(server, /^(nginx|apache|caddy|php)$/, 'server');
|
||||||
|
const pkgs = SERVERS[id].pkgs[s.pkg];
|
||||||
|
const cmd = pkgs && installCmd(s.pkg, pkgs);
|
||||||
|
if (!cmd) throw new Error(i18n.t('No known package manager on this host.'));
|
||||||
|
return this.termCmd(s, cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { WebServerManager, caddySites, templates };
|
||||||
@@ -20,6 +20,12 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
min: () => ipcRenderer.send('win:min'),
|
min: () => ipcRenderer.send('win:min'),
|
||||||
max: () => ipcRenderer.send('win:max'),
|
max: () => ipcRenderer.send('win:max'),
|
||||||
close: () => ipcRenderer.send('win:close'),
|
close: () => ipcRenderer.send('win:close'),
|
||||||
|
// Tabs lösen / zurückholen
|
||||||
|
detach: (data, pos) => ipcRenderer.invoke('win:detach', data, pos),
|
||||||
|
attach: (data) => ipcRenderer.invoke('win:attach', data),
|
||||||
|
handoff: (token) => ipcRenderer.invoke('win:handoff', token),
|
||||||
|
claim: (id) => ipcRenderer.send('win:claim', id),
|
||||||
|
released: (id) => ipcRenderer.send('win:released', id),
|
||||||
},
|
},
|
||||||
ssh: {
|
ssh: {
|
||||||
write: (id, d) => ipcRenderer.send('ssh:write', id, d),
|
write: (id, d) => ipcRenderer.send('ssh:write', id, d),
|
||||||
|
|||||||
@@ -0,0 +1,875 @@
|
|||||||
|
/* global S, T, h, $, $$, esc, ICONS, api, call, toast, modal, confirmBox, promptBox, uid, avatar, hostSub, hostRef, addTab, closeTab, setTabState, openTerminal, settings, fmtDate, relTime, openDocker, openFirewall, openNetwork */
|
||||||
|
'use strict';
|
||||||
|
// Feature-Menüs (Docker, Firewall, Netzwerk, Webserver, Zertifikate, Samba/AD): Liste, Ein-/Ausschalten in den
|
||||||
|
// Einstellungen und die Verwaltungs-Tabs Webserver, Zertifikate und Samba/AD (Backend: src/main/admin.js & Co.).
|
||||||
|
// Wird nach app.js geladen und nutzt dessen Hilfsfunktionen.
|
||||||
|
|
||||||
|
Object.assign(ICONS, {
|
||||||
|
globe: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 3.8 5.7 3.8 9s-1.3 6.3-3.8 9c-2.5-2.7-3.8-5.7-3.8-9s1.3-6.3 3.8-9z"/></svg>',
|
||||||
|
cert: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M7 8h10M7 11h6"/><circle cx="16" cy="17" r="3"/><path d="M14.5 19.5L14 22l2-1 2 1-.5-2.5"/></svg>',
|
||||||
|
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5"/><circle cx="17" cy="9" r="2.8"/><path d="M16.5 14.6c2.6.2 4.5 1.9 5 5"/></svg>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{ id: 'docker', label: () => 'Docker', title: () => T('Docker containers'), icon: 'docker', color: 'var(--accent-2)', open: (x) => openDocker(x) },
|
||||||
|
{ id: 'firewall', label: () => T('Firewall'), icon: 'wall', color: 'var(--orange)', open: (x) => openFirewall(x) },
|
||||||
|
{ id: 'network', label: () => T('Network'), icon: 'network', color: 'var(--accent)', open: (x) => openNetwork(x) },
|
||||||
|
{ id: 'webserver', label: () => T('Web server'), icon: 'globe', color: 'var(--green)', open: (x) => new WebServerSession(x) },
|
||||||
|
{ id: 'certs', label: () => T('Certificates'), icon: 'cert', color: 'var(--accent-3)', open: (x) => new CertSession(x) },
|
||||||
|
{ id: 'samba', label: () => 'Samba / AD', icon: 'users', color: 'var(--folder)', open: (x) => new SambaSession(x) },
|
||||||
|
];
|
||||||
|
const featureOn = (id) => settings().features?.[id] !== false;
|
||||||
|
const enabledFeatures = () => FEATURES.filter((f) => featureOn(f.id));
|
||||||
|
const featureOpeners = () => Object.fromEntries(FEATURES.map((f) => [f.id, f.open]));
|
||||||
|
const featureTitle = (f) => (f.title ? f.title() : f.label());
|
||||||
|
|
||||||
|
// Einstellungen: Feature-Menüs ein-/ausschalten
|
||||||
|
function featuresCard(save) {
|
||||||
|
const card = h(`<div class="settings-card"><h3>${T('Feature menus')}</h3><p style="color:var(--muted);margin-top:0">${T('Choose which management tabs are offered for SSH hosts (host menu, terminal toolbar and quick search).')}</p><div class="feature-list"></div></div>`);
|
||||||
|
for (const f of FEATURES) {
|
||||||
|
const el = h(`<label class="check feature-it"><input type="checkbox" ${featureOn(f.id) ? 'checked' : ''}/><span class="fi" style="color:${f.color}">${ICONS[f.icon]}</span>${esc(featureTitle(f))}</label>`);
|
||||||
|
$('input', el).onchange = (e) => save({ features: { ...(settings().features || {}), [f.id]: e.target.checked } });
|
||||||
|
$('.feature-list', card).append(el);
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pill = (state) => {
|
||||||
|
const on = state === 'active';
|
||||||
|
const label = { active: T('active'), inactive: T('inactive'), failed: T('failed'), activating: T('starting') }[state] || state || T('unknown');
|
||||||
|
return `<span class="status-pill ${on ? 'on' : state === 'failed' ? 'err' : ''}">${esc(label)}</span>`;
|
||||||
|
};
|
||||||
|
const daysLeft = (t) => (t ? Math.floor((t - Date.now()) / 86400000) : null);
|
||||||
|
function expiryHtml(t) {
|
||||||
|
if (!t) return '<span class="muted">–</span>';
|
||||||
|
const d = daysLeft(t);
|
||||||
|
const cls = d < 0 ? 'err' : d < 14 ? 'err' : d < 30 ? 'warn' : 'ok';
|
||||||
|
return `<span class="exp ${cls}">${esc(fmtDate(t))}<small>${d < 0 ? esc(T('expired')) : esc(T('{n} days', { n: d }))}</small></span>`;
|
||||||
|
}
|
||||||
|
const spinnerHtml = (text) => `<div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(text)}</div></div>`;
|
||||||
|
|
||||||
|
// Formular-Dialog mit Logik nach dem Öffnen (z. B. Felder je nach Auswahl ein-/ausblenden)
|
||||||
|
function formModal(opts, wire) {
|
||||||
|
const p = modal(opts);
|
||||||
|
const m = $$('#modalRoot .modal-bg').pop();
|
||||||
|
if (m && wire) wire(m);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ Basis für Verwaltungs-Tabs
|
||||||
|
class AdminSession {
|
||||||
|
constructor(host, { kind, ch, title, views }) {
|
||||||
|
this.kind = kind; this.ch = ch; this.id = uid(); this.host = host;
|
||||||
|
this.title = `${title} · ${host.label || host.address}`;
|
||||||
|
this.views = views; this.view = views[0][0]; this.data = null;
|
||||||
|
this.el = h(`<div class="session admin ${kind}">
|
||||||
|
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||||
|
<div class="seg sm views">${views.map(([v, l]) => `<button data-v="${v}" class="${v === this.view ? 'active' : ''}">${esc(l)}</button>`).join('')}</div>
|
||||||
|
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
|
||||||
|
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||||
|
</div>
|
||||||
|
<div class="docker-body admin-body">${spinnerHtml(T('Connecting to {host} …', { host: host.address }))}</div></div>`);
|
||||||
|
this.body = $('.admin-body', this.el);
|
||||||
|
this.el.addEventListener('click', (e) => {
|
||||||
|
const v = e.target.closest('.views [data-v]')?.dataset.v;
|
||||||
|
if (v && this.data) this.setView(v);
|
||||||
|
if (e.target.closest('[data-a=refresh]') && this.data) this.refresh();
|
||||||
|
});
|
||||||
|
this.unsub = api.on(`${ch}:closed`, (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||||
|
addTab(this);
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
this.data = await api.call(`${this.ch}:open`, this.id, hostRef(this.host));
|
||||||
|
setTabState(this, 'on');
|
||||||
|
this.draw();
|
||||||
|
} catch (e) {
|
||||||
|
setTabState(this, 'err');
|
||||||
|
this.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error(msg) {
|
||||||
|
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center;white-space:pre-wrap">${esc(msg)}</div></div>`;
|
||||||
|
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||||||
|
b.onclick = () => { closeTab(this.id); featureOpeners()[this.kind](this.host); };
|
||||||
|
$('.pane-empty', this.body).append(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(v) {
|
||||||
|
this.view = v;
|
||||||
|
$$('.views [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v));
|
||||||
|
this.body.scrollTop = 0;
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
try { this.data = await api.call(`${this.ch}:read`, this.id); this.draw(); } catch (e) { toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation im Backend; Fehler werden als Toast gezeigt und weitergeworfen
|
||||||
|
op(name, ...args) { return call(`${this.ch}:op`, this.id, name, ...args); }
|
||||||
|
// Operation + Neuladen; true bei Erfolg
|
||||||
|
async act(name, args, okMsg) {
|
||||||
|
try { await this.op(name, ...args); } catch { return false; }
|
||||||
|
if (okMsg) toast(okMsg, 'ok');
|
||||||
|
await this.refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Befehl vom Backend im Terminal-Tab ausführen (Live-Ausgabe, Rückfragen möglich)
|
||||||
|
async term(name, args, label) {
|
||||||
|
try { openTerminal({ ...this.host, execCommand: await this.op(name, ...args), label: `${label} · ${this.host.label || this.host.address}` }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (!this.data || this.editing) return;
|
||||||
|
const scroll = this.body.scrollTop;
|
||||||
|
this.body.innerHTML = '';
|
||||||
|
const wrap = h('<div class="admin-wrap"></div>');
|
||||||
|
this.body.append(wrap);
|
||||||
|
this[`draw_${this.view}`](wrap);
|
||||||
|
this.body.scrollTop = scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
section(title, actions = []) {
|
||||||
|
const sec = h(`<div class="admin-sec"><div class="admin-sec-head"><h4>${esc(title)}</h4><span class="grow"></span></div></div>`);
|
||||||
|
for (const a of actions) {
|
||||||
|
const b = h(`<button class="btn ${a.cls || 'ghost'} sm" ${a.title ? `title="${esc(a.title)}"` : ''}>${a.icon ? ICONS[a.icon] : ''}${esc(a.label)}</button>`);
|
||||||
|
b.onclick = a.run;
|
||||||
|
$('.admin-sec-head', sec).append(b);
|
||||||
|
}
|
||||||
|
return sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabelle: cols = [[Titel, (zeile) => html, klasse?]], acts = (zeile) => [{ icon, title, run, danger }]
|
||||||
|
table(rows, cols, acts) {
|
||||||
|
const t = h(`<table class="docker-table admin-table"><thead><tr>${cols.map(([c, , cls]) => `<th class="${cls || ''}">${esc(c)}</th>`).join('')}${acts ? '<th></th>' : ''}</tr></thead><tbody></tbody></table>`);
|
||||||
|
for (const r of rows) {
|
||||||
|
const tr = h(`<tr>${cols.map(([, f, cls]) => `<td class="${cls || ''}">${f(r)}</td>`).join('')}${acts ? '<td class="acts"></td>' : ''}</tr>`);
|
||||||
|
if (acts) for (const a of acts(r).filter(Boolean)) {
|
||||||
|
const b = h(`<button class="btn ghost sm ${a.danger ? 'danger' : ''}" title="${esc(a.title)}">${a.icon ? ICONS[a.icon] : ''}${a.label ? esc(a.label) : ''}</button>`);
|
||||||
|
b.onclick = a.run;
|
||||||
|
$('.acts', tr).append(b);
|
||||||
|
}
|
||||||
|
$('tbody', t).append(tr);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty(text) { return h(`<div class="admin-empty">${esc(text)}</div>`); }
|
||||||
|
|
||||||
|
// Dienst-Zeile mit Start/Stopp/Neustart/Neu laden und Autostart
|
||||||
|
serviceButtons(u, opName = 'service', withReload = true) {
|
||||||
|
const box = h('<span class="svc-btns"></span>');
|
||||||
|
const run = (action, msg) => this.act(opName, [u.unit, action], msg);
|
||||||
|
const btn = (icon, title, fn, cls = '') => { const b = h(`<button class="btn ghost sm ${cls}" title="${esc(title)}">${ICONS[icon]}</button>`); b.onclick = fn; box.append(b); };
|
||||||
|
if (u.active === 'active') btn('stop', T('Stop'), async () => { if (await confirmBox(T('Stop {name}?', { name: u.unit }), T('The service will stop serving requests.'), T('Stop'))) run('stop', T('{name} stopped', { name: u.unit })); });
|
||||||
|
else btn('play', T('Start'), () => run('start', T('{name} started', { name: u.unit })));
|
||||||
|
btn('refresh', T('Restart'), () => run('restart', T('{name} restarted', { name: u.unit })));
|
||||||
|
if (withReload && u.active === 'active') btn('download', T('Reload configuration'), () => run('reload', T('Configuration reloaded')));
|
||||||
|
const en = h(`<label class="check svc-auto" title="${esc(T('Start automatically at boot'))}"><input type="checkbox" ${u.enabled === 'enabled' ? 'checked' : ''}/>${T('Autostart')}</label>`);
|
||||||
|
$('input', en).onchange = (e) => run(e.target.checked ? 'enable' : 'disable');
|
||||||
|
box.append(en);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei im Tab bearbeiten; save(content) → [Operation, ...Argumente]; Ergebnis: Ausgabe der Prüfung oder { path, output }
|
||||||
|
// onSaved(ergebnis) z. B. um nach dem ersten Speichern einer neuen Datei auf "überschreiben" umzustellen
|
||||||
|
async editFile(path, { read, save, note, onSaved }) {
|
||||||
|
let content;
|
||||||
|
try { content = await read(); } catch { return; }
|
||||||
|
this.editing = path;
|
||||||
|
this.body.innerHTML = '';
|
||||||
|
const ed = h(`<div class="stack-editor"><div class="se-head"><b class="mono">${esc(path)}</b><span style="flex:1"></span>
|
||||||
|
<button class="btn ghost sm" data-e="cancel">${T('Back')}</button><button class="btn primary sm" data-e="save">${ICONS.download}${T('Save & apply')}</button></div>
|
||||||
|
${note ? `<div class="se-note">${esc(note)}</div>` : ''}<div class="se-out" style="display:none"></div>
|
||||||
|
<div class="se-grid" style="grid-template-columns:1fr"><div class="se-col"><textarea class="mono" spellcheck="false"></textarea></div></div></div>`);
|
||||||
|
const ta = $('textarea', ed);
|
||||||
|
ta.value = content;
|
||||||
|
ta.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Tab') { e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); }
|
||||||
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $('[data-e=save]', ed).click(); }
|
||||||
|
});
|
||||||
|
const out = $('.se-out', ed);
|
||||||
|
$('[data-e=cancel]', ed).onclick = () => { this.editing = null; this.refresh(); };
|
||||||
|
$('[data-e=save]', ed).onclick = async (e) => {
|
||||||
|
const b = e.currentTarget; b.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api.call(`${this.ch}:op`, this.id, ...save(ta.value));
|
||||||
|
onSaved?.(r);
|
||||||
|
if (r?.path) $('.se-head b', ed).textContent = r.path;
|
||||||
|
const msg = typeof r === 'string' ? r : r?.output;
|
||||||
|
out.style.display = ''; out.className = 'se-out ok'; out.textContent = (typeof msg === 'string' && msg.trim()) || T('Saved and applied.');
|
||||||
|
toast(T('Saved'), 'ok');
|
||||||
|
} catch (err) {
|
||||||
|
out.style.display = ''; out.className = 'se-out err'; out.textContent = err.message;
|
||||||
|
}
|
||||||
|
b.disabled = false;
|
||||||
|
};
|
||||||
|
this.body.append(ed);
|
||||||
|
ta.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
aiContext() { return this.body.innerText; }
|
||||||
|
dispose() { this.unsub(); api.call(`${this.ch}:close`, this.id).catch(() => {}); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ Webserver (nginx, Apache, Caddy, PHP-FPM)
|
||||||
|
const SRV_LABEL = { nginx: 'nginx', apache: 'Apache', caddy: 'Caddy', php: 'PHP-FPM' };
|
||||||
|
|
||||||
|
class WebServerSession extends AdminSession {
|
||||||
|
constructor(host) {
|
||||||
|
super(host, { kind: 'webserver', ch: 'web', title: T('Web server'), views: [['overview', T('Overview')], ['sites', T('Sites')], ['generator', T('Generator')], ['files', T('Configuration')]] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Konfigurations-Generator: alle Optionen durchsuchbar, Live-Vorschau, als Site speichern oder im Editor öffnen
|
||||||
|
draw_generator(w) {
|
||||||
|
const W = window.WEBCONF;
|
||||||
|
const inst = this.installed().map((x) => x.id);
|
||||||
|
const g = this.gen ||= { server: inst[0] || 'nginx', o: W.defaults(), q: '' };
|
||||||
|
const nginxVersion = (this.data.servers.find((x) => x.id === 'nginx')?.version.match(/nginx\/([\d.]+)/) || [])[1];
|
||||||
|
const box = h(`<div class="gen">
|
||||||
|
<div class="gen-left">
|
||||||
|
<div class="gen-top"><select class="gen-preset"><option value="">${T('Start from a template …')}</option>${W.PRESETS.map(([id, l]) => `<option value="${id}">${esc(T(l))}</option>`).join('')}</select>
|
||||||
|
<label class="search sm">${ICONS.search}<input class="gen-q" placeholder="${esc(T('Search settings (e.g. upload, websocket, hsts) …'))}" value="${esc(g.q)}"/></label></div>
|
||||||
|
<div class="gen-opts"></div>
|
||||||
|
</div>
|
||||||
|
<div class="gen-right">
|
||||||
|
<div class="gen-bar"><div class="seg sm gen-srv">${['nginx', 'apache', 'caddy'].map((x) => `<button data-s="${x}" class="${g.server === x ? 'active' : ''}">${SRV_LABEL[x]}${inst.includes(x) ? '' : ' <small>·</small>'}</button>`).join('')}</div><span style="flex:1"></span>
|
||||||
|
<button class="btn ghost sm" data-g="copy" title="${esc(T('Copy'))}">${ICONS.copy}</button>
|
||||||
|
<button class="btn sm" data-g="edit">${ICONS.edit}${T('Open in editor')}</button>
|
||||||
|
<button class="btn primary sm" data-g="save">${ICONS.download}${T('Save as site')}</button></div>
|
||||||
|
<div class="gen-note"></div>
|
||||||
|
<pre class="gen-preview mono"></pre>
|
||||||
|
</div></div>`);
|
||||||
|
w.append(box);
|
||||||
|
w.classList.add('gen-wrap');
|
||||||
|
const optsBox = $('.gen-opts', box);
|
||||||
|
const rows = [];
|
||||||
|
for (const [gid, glabel] of W.GROUPS) {
|
||||||
|
const sec = h(`<div class="gen-group" data-group="${gid}"><h4>${esc(T(glabel))}</h4></div>`);
|
||||||
|
for (const opt of W.OPTIONS.filter((x) => x.group === gid)) {
|
||||||
|
const v = g.o[opt.id];
|
||||||
|
let input;
|
||||||
|
if (opt.type === 'bool') input = `<input type="checkbox" data-o="${opt.id}" ${v ? 'checked' : ''}/>`;
|
||||||
|
else if (opt.type === 'select') input = `<select data-o="${opt.id}">${opt.options.map(([k, l]) => `<option value="${esc(k)}" ${String(v) === k ? 'selected' : ''}>${esc(T(l))}</option>`).join('')}</select>`;
|
||||||
|
else if (opt.type === 'textarea') input = `<textarea data-o="${opt.id}" rows="4" class="mono" spellcheck="false">${esc(v)}</textarea>`;
|
||||||
|
else input = `<input type="${opt.type === 'number' ? 'number' : 'text'}" data-o="${opt.id}" value="${esc(v)}" placeholder="${esc(opt.placeholder ? T(opt.placeholder) : '')}" spellcheck="false"/>`;
|
||||||
|
const only = opt.only ? `<span class="badge">${esc(opt.only.map((x) => SRV_LABEL[x]).join(', '))}</span>` : '';
|
||||||
|
const row = h(`<div class="gen-row ${opt.type === 'bool' ? 'bool' : ''} ${opt.type === 'textarea' ? 'wide' : ''}"><label><span class="gl">${esc(T(opt.label))}${only}</span>${opt.desc ? `<small>${esc(T(opt.desc))}</small>` : ''}</label><div class="gc">${input}</div></div>`);
|
||||||
|
row._opt = opt;
|
||||||
|
row._text = [opt.id, opt.label, T(opt.label), opt.desc || '', opt.desc ? T(opt.desc) : '', opt.kw || '', glabel, T(glabel)].join(' ').toLowerCase();
|
||||||
|
rows.push(row);
|
||||||
|
sec.append(row);
|
||||||
|
}
|
||||||
|
optsBox.append(sec);
|
||||||
|
}
|
||||||
|
const gen = () => W.generate(g.server, g.o, { nginxVersion });
|
||||||
|
const update = () => {
|
||||||
|
const q = g.q.trim().toLowerCase();
|
||||||
|
for (const row of rows) {
|
||||||
|
const opt = row._opt;
|
||||||
|
const relevant = !opt.show || opt.show(g.o);
|
||||||
|
const supported = !opt.only || opt.only.includes(g.server);
|
||||||
|
// Bei einer Suche alle passenden Optionen zeigen (auch gerade nicht relevante), sonst nur relevante
|
||||||
|
row.style.display = q ? (q.split(/\s+/).every((t) => row._text.includes(t)) ? '' : 'none') : (relevant ? '' : 'none');
|
||||||
|
row.classList.toggle('off', !supported || !relevant);
|
||||||
|
}
|
||||||
|
$$('.gen-group', optsBox).forEach((sec) => { sec.style.display = $$('.gen-row', sec).some((r) => r.style.display !== 'none') ? '' : 'none'; });
|
||||||
|
$('.gen-preview', box).textContent = gen().text;
|
||||||
|
const notes = [];
|
||||||
|
if (!inst.includes(g.server)) notes.push(T('{server} is not installed on this host – you can still generate and copy the configuration.', { server: SRV_LABEL[g.server] }));
|
||||||
|
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'letsencrypt') notes.push(T('The certificate must exist before saving: request it in the “Certificates” tab first (e.g. via webroot), or save without HTTPS first.'));
|
||||||
|
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'auto') notes.push(T('Automatic certificates are only available with Caddy.'));
|
||||||
|
if (g.o.basicAuth) notes.push(T('Create the password file on the server with: htpasswd -c {file} <user>', { file: g.o.authFile }));
|
||||||
|
$('.gen-note', box).innerHTML = notes.map((n) => `<div>${esc(n)}</div>`).join('');
|
||||||
|
$('.gen-note', box).style.display = notes.length ? '' : 'none';
|
||||||
|
};
|
||||||
|
optsBox.addEventListener('input', (e) => {
|
||||||
|
const id = e.target.dataset.o; if (!id) return;
|
||||||
|
const opt = W.OPTIONS.find((x) => x.id === id);
|
||||||
|
g.o[id] = opt.type === 'bool' ? e.target.checked : opt.type === 'number' ? Number(e.target.value) : e.target.value;
|
||||||
|
// Dokumentenverzeichnis folgt der ersten Domain, bis es von Hand geändert wurde
|
||||||
|
if (id === 'domains' && !g.rootTouched) { const d = W.splitList(g.o.domains)[0]; g.o.root = d ? `/var/www/${d}` : ''; const r = $('[data-o=root]', optsBox); if (r) r.value = g.o.root; }
|
||||||
|
if (id === 'root') g.rootTouched = true;
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
$('.gen-q', box).oninput = (e) => { g.q = e.target.value; update(); };
|
||||||
|
$('.gen-preset', box).onchange = (e) => {
|
||||||
|
const p = W.PRESETS.find(([id]) => id === e.target.value);
|
||||||
|
if (!p) return;
|
||||||
|
// Vorlage über die aktuellen Werte legen; Domains/Wurzel bleiben erhalten
|
||||||
|
g.o = { ...W.defaults(), domains: g.o.domains, root: g.o.root, ...(p[0] === 'secure' ? g.o : {}), ...p[2] };
|
||||||
|
this.draw();
|
||||||
|
};
|
||||||
|
$('.gen-srv', box).onclick = (e) => { const sv = e.target.closest('[data-s]')?.dataset.s; if (sv) { g.server = sv; $$('.gen-srv button', box).forEach((b) => b.classList.toggle('active', b.dataset.s === sv)); update(); } };
|
||||||
|
const siteArgs = (content) => {
|
||||||
|
const d = W.splitList(g.o.domains)[0];
|
||||||
|
const needsRoot = ['static', 'spa', 'php'].includes(g.o.type) && g.o.createRoot;
|
||||||
|
return { name: g.o.name || d || 'site', content, modules: gen().modules, mkRoot: needsRoot ? g.o.root || (d ? `/var/www/${d}` : '') : '', title: d };
|
||||||
|
};
|
||||||
|
box.addEventListener('click', async (e) => {
|
||||||
|
const a = e.target.closest('[data-g]')?.dataset.g;
|
||||||
|
if (a === 'copy') { await api.call('clipboard:write', gen().text); toast(T('Copied')); }
|
||||||
|
if (a === 'save') {
|
||||||
|
if (!W.splitList(g.o.domains).length && g.server !== 'caddy') return toast(T('Please enter at least one domain.'), 'error');
|
||||||
|
try { const r = await this.op('saveSite', g.server, siteArgs(gen().text)); toast(T('Site saved: {path}', { path: r.path }), 'ok'); await this.refresh(); this.setView('sites'); } catch {}
|
||||||
|
}
|
||||||
|
if (a === 'edit') {
|
||||||
|
const st = { path: null };
|
||||||
|
const text = gen().text;
|
||||||
|
this.editFile(T('New site ({server})', { server: SRV_LABEL[g.server] }), {
|
||||||
|
read: async () => text,
|
||||||
|
save: (c) => (st.path ? ['writeFile', st.path, c] : ['saveSite', g.server, siteArgs(c)]),
|
||||||
|
onSaved: (r) => { if (r?.path) st.path = r.path; },
|
||||||
|
note: T('Edit the generated configuration freely. Saving creates the site, checks it and reloads the web server; if the check fails, nothing is changed.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
installed() { return this.data.servers.filter((x) => x.installed); }
|
||||||
|
|
||||||
|
draw_overview(w) {
|
||||||
|
const d = this.data;
|
||||||
|
const grid = h('<div class="ws-grid"></div>');
|
||||||
|
for (const srv of d.servers) {
|
||||||
|
const card = h(`<div class="ws-card ${srv.installed ? '' : 'absent'}"><div class="nh"><span class="ws-ico">${ICONS.globe}</span><b>${esc(srv.label)}</b><span class="grow"></span>${srv.installed ? pill(srv.active) : `<span class="muted">${T('not installed')}</span>`}</div>
|
||||||
|
${srv.installed ? `<div class="muted small">${esc(srv.version)} · ${esc(srv.unit)}</div>` : ''}<div class="ws-acts"></div></div>`);
|
||||||
|
const acts = $('.ws-acts', card);
|
||||||
|
if (srv.installed) {
|
||||||
|
acts.append(this.serviceButtons(srv));
|
||||||
|
const test = h(`<button class="btn sm">${ICONS.shield}${T('Test configuration')}</button>`);
|
||||||
|
test.onclick = async () => { try { const r = await this.op('test', srv.id); modal({ title: `${srv.label}: ${r.ok ? T('Configuration OK') : T('Configuration has errors')}`, body: `<pre class="fw-pre mono" style="max-height:50vh;overflow:auto">${esc(r.output || 'OK')}</pre>`, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] }); } catch {} };
|
||||||
|
const logs = h(`<button class="btn sm">${ICONS.logs}${T('Logs')}</button>`);
|
||||||
|
logs.onclick = () => this.term('logsCommand', [srv.id], `${T('Logs')} ${srv.label}`);
|
||||||
|
acts.append(test, logs);
|
||||||
|
} else {
|
||||||
|
const inst = h(`<button class="btn sm">${ICONS.download}${T('Install')}</button>`);
|
||||||
|
inst.onclick = () => this.term('installCommand', [srv.id], `${T('Install')} ${srv.label}`);
|
||||||
|
acts.append(inst);
|
||||||
|
}
|
||||||
|
grid.append(card);
|
||||||
|
}
|
||||||
|
w.append(grid);
|
||||||
|
const running = d.servers.filter((x) => x.active === 'active');
|
||||||
|
if (running.length > 1) w.append(h(`<div class="net-banner warn">${ICONS.wall}<div>${T('Several web servers are running ({list}). Only one of them can use port 80/443 – make sure they listen on different ports.', { list: running.map((x) => x.label).join(', ') })}</div></div>`));
|
||||||
|
|
||||||
|
const php = this.section('PHP', d.php.pools.length ? [] : [{ label: T('Install PHP-FPM'), icon: 'download', run: () => this.term('installCommand', ['php'], `${T('Install')} PHP-FPM`) }]);
|
||||||
|
if (d.php.version) php.append(h(`<div class="muted small" style="margin:0 0 8px">${esc(d.php.version)}</div>`));
|
||||||
|
if (d.php.pools.length) php.append(this.table(d.php.pools, [[T('Service'), (u) => `<b>${esc(u.unit)}</b>`], [T('Status'), (u) => pill(u.active)]], (u) => [{ icon: 'logs', title: T('Logs'), run: () => this.term('logsCommand', ['php'], `${T('Logs')} ${u.unit}`) }]));
|
||||||
|
else php.append(this.empty(T('No PHP-FPM service found.')));
|
||||||
|
if (d.php.pools.length) {
|
||||||
|
$$('tbody tr', php).forEach((tr, i) => { $('.acts', tr).prepend(this.serviceButtons(d.php.pools[i])); });
|
||||||
|
}
|
||||||
|
if (d.php.sockets.length) php.append(h(`<div class="muted small" style="margin-top:8px">${T('Sockets')}: <span class="mono">${d.php.sockets.map(esc).join(', ')}</span></div>`));
|
||||||
|
w.append(php);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_sites(w) {
|
||||||
|
const d = this.data;
|
||||||
|
const inst = this.installed();
|
||||||
|
const sec = this.section(T('Sites'), inst.length ? [{ label: T('Generator'), icon: 'code', run: () => this.setView('generator') }, { label: T('New site'), icon: 'plus', cls: 'primary', run: () => this.newSite() }] : []);
|
||||||
|
if (!inst.length) { sec.append(this.empty(T('No web server installed. Install nginx, Apache or Caddy in the overview.'))); w.append(sec); return; }
|
||||||
|
if (!d.sites.length) sec.append(this.empty(T('No sites configured yet.')));
|
||||||
|
else sec.append(this.table(d.sites, [
|
||||||
|
['', (x) => `<span class="dot ${x.enabled ? 'on' : 'none'}"></span>`, 'w-dot'],
|
||||||
|
[T('Server'), (x) => esc(SRV_LABEL[x.server])],
|
||||||
|
[T('Site'), (x) => `<b>${esc(x.name)}</b><div class="muted small mono">${esc(x.path)}${x.line ? `:${x.line}` : ''}</div>`, 'name'],
|
||||||
|
[T('Domains'), (x) => x.domains.map((n) => `<span class="tag">${esc(n)}</span>`).join(' ') || '<span class="muted">–</span>'],
|
||||||
|
], (x) => [
|
||||||
|
x.server !== 'caddy' && { icon: x.enabled ? 'stop' : 'play', title: x.enabled ? T('Disable') : T('Enable'), run: () => this.act('setSiteEnabled', [x.server, x.path, !x.enabled], x.enabled ? T('Site disabled') : T('Site enabled')) },
|
||||||
|
{ icon: 'edit', title: T('Edit'), run: () => this.editConfig(x.path) },
|
||||||
|
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete site?'), x.server === 'caddy' ? T('The block “{name}” will be removed from the Caddyfile.', { name: x.name }) : T('{path} will be deleted (a copy is kept in /var/backups/mrterm).', { path: x.path }))) this.act('deleteSite', [x.server, x.path, x.name], T('Site deleted')); } },
|
||||||
|
]));
|
||||||
|
w.append(sec);
|
||||||
|
w.append(h(`<div class="fw-note" style="border:0;padding:0 2px">${T('Every change is checked with the configuration test of the web server before reloading. If the test fails, the previous state is restored.')}</div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_files(w) {
|
||||||
|
const d = this.data;
|
||||||
|
const files = [...new Set([...d.files, ...d.sites.map((x) => x.path)])].sort();
|
||||||
|
const sec = this.section(T('Configuration files'));
|
||||||
|
if (!files.length) sec.append(this.empty(T('No configuration files found.')));
|
||||||
|
else {
|
||||||
|
const list = h('<div class="file-list"></div>');
|
||||||
|
for (const f of files) { const b = h(`<button class="file-it">${ICONS.file}<span class="mono">${esc(f)}</span></button>`); b.onclick = () => this.editConfig(f); list.append(b); }
|
||||||
|
sec.append(list);
|
||||||
|
}
|
||||||
|
w.append(sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
editConfig(path) {
|
||||||
|
this.editFile(path, { read: () => this.op('readFile', path), save: (c) => ['writeFile', path, c], note: T('Saving checks the configuration and reloads the service. If the check fails, the previous version is restored.') });
|
||||||
|
}
|
||||||
|
|
||||||
|
async newSite() {
|
||||||
|
const d = this.data;
|
||||||
|
const inst = this.installed();
|
||||||
|
const sock = d.php.sockets[0] || '/run/php/php-fpm.sock';
|
||||||
|
const r = await formModal({ title: T('New site'), body: `
|
||||||
|
<div class="row"><div class="field"><label>${T('Web server')}</label><select name="server">${inst.map((x) => `<option value="${x.id}">${esc(x.label)}</option>`).join('')}</select></div>
|
||||||
|
<div class="field"><label>${T('Type')}</label><select name="type"><option value="static">${T('Static website')}</option><option value="proxy">${T('Reverse proxy')}</option><option value="php">PHP</option></select></div></div>
|
||||||
|
<div class="field"><label>${T('Domains')}</label><input name="domains" placeholder="example.com www.example.com"/></div>
|
||||||
|
<div class="field" data-f="name"><label>${T('Name of the configuration file')}</label><input name="name" placeholder="${esc(T('optional – default: first domain'))}"/></div>
|
||||||
|
<div class="field" data-f="root"><label>${T('Document root')}</label><input name="root" placeholder="/var/www/example.com"/></div>
|
||||||
|
<div class="field" data-f="upstream" style="display:none"><label>${T('Target (upstream)')}</label><input name="upstream" placeholder="http://127.0.0.1:3000"/></div>
|
||||||
|
<div class="field" data-f="sock" style="display:none"><label>${T('PHP-FPM socket')}</label>${d.php.sockets.length > 1 ? `<select name="sock">${d.php.sockets.map((x) => `<option>${esc(x)}</option>`).join('')}</select>` : `<input name="sock" value="${esc(sock)}"/>`}</div>
|
||||||
|
<label class="check" data-f="createRoot"><input type="checkbox" name="createRoot" checked/>${T('Create the folder with a placeholder page if it does not exist')}</label>
|
||||||
|
<div class="hint" data-f="caddy" style="display:none;color:var(--muted);font-size:12px;margin-top:8px">${T('Caddy requests HTTPS certificates for public domains automatically.')}</div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] }, (m) => {
|
||||||
|
const upd = () => {
|
||||||
|
const type = $('[name=type]', m).value, server = $('[name=server]', m).value;
|
||||||
|
const show = (f, on) => { const el = $(`[data-f=${f}]`, m); if (el) el.style.display = on ? '' : 'none'; };
|
||||||
|
show('root', type !== 'proxy'); show('createRoot', type !== 'proxy'); show('upstream', type === 'proxy'); show('sock', type === 'php');
|
||||||
|
show('name', server !== 'caddy'); show('caddy', server === 'caddy');
|
||||||
|
};
|
||||||
|
$('[name=type]', m).onchange = upd; $('[name=server]', m).onchange = upd;
|
||||||
|
$('[name=domains]', m).oninput = (e) => { const r0 = $('[name=root]', m); if (!r0.dataset.touched) r0.value = `/var/www/${e.target.value.trim().split(/[\s,]+/)[0] || ''}`; };
|
||||||
|
$('[name=root]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
|
||||||
|
upd();
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
const ok = await this.act('createSite', [r.server, r], T('Site created'));
|
||||||
|
if (ok && r.server !== 'caddy' && featureOn('certs')) toast(T('Tip: request an HTTPS certificate in the “Certificates” tab.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ Zertifikate (Let's Encrypt / certbot, acme.sh)
|
||||||
|
class CertSession extends AdminSession {
|
||||||
|
constructor(host) {
|
||||||
|
super(host, { kind: 'certs', ch: 'certs', title: T('Certificates'), views: [['certs', T('Certificates')], ['check', T('Check endpoint')]] });
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_certs(w) {
|
||||||
|
const d = this.data;
|
||||||
|
const cb = d.certbot;
|
||||||
|
const head = h('<div class="cert-head"></div>');
|
||||||
|
head.append(h(`<div><label>certbot</label><div>${cb.version ? esc(cb.version) : `<span class="muted">${T('not installed')}</span>`}</div></div>`));
|
||||||
|
const timer = d.timers.find((t) => t.unit.endsWith('.timer'));
|
||||||
|
const cron = d.timers.find((t) => t.unit === 'cron');
|
||||||
|
const renew = h(`<div><label>${T('Automatic renewal')}</label><div class="v"></div></div>`);
|
||||||
|
if (timer?.enabled === 'enabled' || cron) $('.v', renew).innerHTML = `${pill(timer?.active || 'active')} <span class="muted small">${esc(timer?.unit || '/etc/cron.d/certbot')}</span>`;
|
||||||
|
else if (cb.version) {
|
||||||
|
$('.v', renew).innerHTML = `<span class="exp warn">${T('not set up')}</span> `;
|
||||||
|
const b = h(`<button class="btn sm">${T('Enable')}</button>`);
|
||||||
|
b.onclick = () => this.act('enableAutoRenew', [timer?.unit || 'certbot.timer'], T('Automatic renewal enabled'));
|
||||||
|
$('.v', renew).append(b);
|
||||||
|
} else $('.v', renew).innerHTML = '<span class="muted">–</span>';
|
||||||
|
head.append(renew);
|
||||||
|
const acts = h('<div class="cert-acts"></div>');
|
||||||
|
const btn = (label, icon, run, cls = '') => { const b = h(`<button class="btn sm ${cls}">${ICONS[icon]}${esc(label)}</button>`); b.onclick = run; acts.append(b); };
|
||||||
|
if (cb.version) {
|
||||||
|
btn(T('Request certificate'), 'plus', () => this.issue(), 'primary');
|
||||||
|
btn(T('Renew all'), 'refresh', () => this.term('renewCommand', ['', false], T('Renew certificates')));
|
||||||
|
btn(T('Test renewal'), 'shield', () => this.term('renewCommand', ['', true], T('Test renewal')));
|
||||||
|
} else btn(T('Install certbot'), 'download', () => this.term('installCommand', [], `${T('Install')} certbot`), 'primary');
|
||||||
|
btn(T('Self-signed …'), 'key', () => this.selfSigned());
|
||||||
|
head.append(acts);
|
||||||
|
w.append(head);
|
||||||
|
|
||||||
|
const sec = this.section("Let's Encrypt (certbot)");
|
||||||
|
if (!cb.version) sec.append(this.empty(T('certbot is not installed. Install it to request free certificates from Let\'s Encrypt.')));
|
||||||
|
else if (!cb.certs.length) sec.append(this.empty(T('No certificates yet.')));
|
||||||
|
else sec.append(this.table(cb.certs, [
|
||||||
|
[T('Name'), (c) => `<b>${esc(c.name)}</b><div class="muted small">${esc(c.keyType || '')}</div>`, 'name'],
|
||||||
|
[T('Domains'), (c) => c.domains.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
|
||||||
|
[T('Expires'), (c) => expiryHtml(c.expires)],
|
||||||
|
], (c) => [
|
||||||
|
{ icon: 'refresh', title: T('Renew'), run: () => this.term('renewCommand', [c.name, false], `${T('Renew')} ${c.name}`) },
|
||||||
|
{ icon: 'eye', title: T('Details'), run: () => this.details(c.certPath) },
|
||||||
|
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete certificate?'), T('“{name}” and its files will be deleted. Web server configurations that still use it will fail.', { name: c.name }))) this.act('deleteCert', [c.name], T('Certificate deleted')); } },
|
||||||
|
]));
|
||||||
|
w.append(sec);
|
||||||
|
|
||||||
|
if (d.acme.dir) {
|
||||||
|
const a = this.section('acme.sh');
|
||||||
|
if (!d.acme.certs.length) a.append(this.empty(T('No certificates yet.')));
|
||||||
|
else a.append(this.table(d.acme.certs, [
|
||||||
|
[T('Domain'), (c) => `<b>${esc(c.domain)}</b>`, 'name'], ['SAN', (c) => c.sans.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
|
||||||
|
['CA', (c) => esc(c.ca)], [T('Next renewal'), (c) => esc(c.renew)],
|
||||||
|
], (c) => [
|
||||||
|
{ icon: 'refresh', title: T('Renew'), run: () => this.term('acmeRenewCommand', [c.domain], `acme.sh ${c.domain}`) },
|
||||||
|
{ icon: 'trash', title: T('Remove'), danger: true, run: async () => { if (await confirmBox(T('Remove certificate?'), T('acme.sh will no longer renew “{name}”.', { name: c.domain }), T('Remove'))) this.act('acmeRemove', [c.domain], T('Removed')); } },
|
||||||
|
]));
|
||||||
|
w.append(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = this.section(T('Certificate files'));
|
||||||
|
const files = d.files.filter((x) => !x.path.startsWith('/etc/letsencrypt/'));
|
||||||
|
if (!files.length) f.append(this.empty(T('No other certificate files found (searched /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).')));
|
||||||
|
else f.append(this.table(files, [
|
||||||
|
[T('File'), (c) => `<span class="mono small">${esc(c.path)}</span><div class="muted small">${esc(c.issuer)}</div>`, 'name'],
|
||||||
|
[T('Domains'), (c) => (c.sans.length ? c.sans : [c.subject]).map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
|
||||||
|
[T('Expires'), (c) => expiryHtml(c.expires)],
|
||||||
|
], (c) => [{ icon: 'eye', title: T('Details'), run: () => this.details(c.path) }]));
|
||||||
|
w.append(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
async details(path) {
|
||||||
|
try {
|
||||||
|
const text = await this.op('details', path);
|
||||||
|
modal({ title: path, body: `<pre class="fw-pre mono" style="max-height:60vh;overflow:auto;font-size:11.5px">${esc(text)}</pre>`, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async issue() {
|
||||||
|
const d = this.data;
|
||||||
|
const plug = d.certbot.plugins;
|
||||||
|
const methods = [
|
||||||
|
...(plug.includes('nginx') ? [['nginx', T('nginx (configures HTTPS automatically)')]] : []),
|
||||||
|
...(plug.includes('apache') ? [['apache', T('Apache (configures HTTPS automatically)')]] : []),
|
||||||
|
['webroot', T('Webroot (running web server, files in .well-known)')],
|
||||||
|
['standalone', T('Standalone (port 80 must be free)')],
|
||||||
|
['dns', T('DNS challenge (manual TXT record, also for wildcards)')],
|
||||||
|
];
|
||||||
|
const r = await formModal({ title: T('Request certificate'), body: `
|
||||||
|
<div class="field"><label>${T('Domains')}</label><input name="domains" placeholder="example.com www.example.com"/></div>
|
||||||
|
<div class="field"><label>${T('Method')}</label><select name="method">${methods.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select></div>
|
||||||
|
<div class="field" data-f="webroot" style="display:none"><label>${T('Webroot')}</label><input name="webroot" placeholder="/var/www/example.com"/></div>
|
||||||
|
<div class="field"><label>${T('E-mail for expiry notices')}</label><input name="email" placeholder="${esc(T('optional'))}"/></div>
|
||||||
|
<div class="row"><div class="field"><label>${T('Key type')}</label><select name="keyType"><option value="ecdsa">ECDSA</option><option value="rsa">RSA 4096</option></select></div>
|
||||||
|
<div class="field"><label>${T('Certificate name')}</label><input name="name" placeholder="${esc(T('optional'))}"/></div></div>
|
||||||
|
<label class="check" data-f="redirect"><input type="checkbox" name="redirect" checked/>${T('Redirect HTTP to HTTPS')}</label>
|
||||||
|
<label class="check"><input type="checkbox" name="staging"/>${T('Test certificate (Let\'s Encrypt staging, no rate limits)')}</label>
|
||||||
|
<div class="hint" style="color:var(--muted);font-size:12px;margin-top:8px">${T('certbot runs in a terminal tab so you can follow the output and answer questions.')}</div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Request'), value: 'form', cls: 'primary' }] }, (m) => {
|
||||||
|
const upd = () => { const v = $('[name=method]', m).value; $('[data-f=webroot]', m).style.display = v === 'webroot' ? '' : 'none'; $('[data-f=redirect]', m).style.display = v === 'nginx' || v === 'apache' ? '' : 'none'; };
|
||||||
|
$('[name=method]', m).onchange = upd; upd();
|
||||||
|
});
|
||||||
|
if (r) this.term('issueCommand', [r], 'certbot');
|
||||||
|
}
|
||||||
|
|
||||||
|
async selfSigned() {
|
||||||
|
const r = await modal({ title: T('Self-signed certificate'), body: `
|
||||||
|
<div class="row"><div class="field"><label>${T('Name')}</label><input name="name" placeholder="intranet"/></div><div class="field small"><label>${T('Valid (days)')}</label><input name="days" type="number" value="825"/></div></div>
|
||||||
|
<div class="field"><label>${T('Common name (CN)')}</label><input name="cn" placeholder="intranet.local"/></div>
|
||||||
|
<div class="field"><label>${T('Additional names / IPs')}</label><input name="sans" placeholder="server.local 192.168.1.10"/></div>
|
||||||
|
<div class="field"><label>${T('Key type')}</label><select name="keyType"><option value="ec">ECDSA P-256</option><option value="rsa">RSA 4096</option></select></div>
|
||||||
|
<div class="hint" style="color:var(--muted);font-size:12px">${T('Stored in /etc/ssl/mrterm/<name>.crt and .key. Browsers show a warning for self-signed certificates.')}</div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
try {
|
||||||
|
const res = await this.op('selfSigned', r);
|
||||||
|
toast(T('Created: {path}', { path: res.cert }), 'ok');
|
||||||
|
await this.refresh();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_check(w) {
|
||||||
|
const sec = this.section(T('Check TLS endpoint'));
|
||||||
|
const form = h(`<div class="row cert-probe"><div class="field"><label>${T('Host')}</label><input name="host" value="${esc(this.probeHost || this.host.address)}"/></div><div class="field small"><label>Port</label><input name="port" type="number" value="${esc(this.probePort || 443)}"/></div></div>`);
|
||||||
|
const go = h(`<button class="btn primary">${ICONS.search}${T('Check')}</button>`);
|
||||||
|
const out = h('<div class="probe-out"></div>');
|
||||||
|
go.onclick = async () => {
|
||||||
|
this.probeHost = $('[name=host]', form).value.trim(); this.probePort = $('[name=port]', form).value;
|
||||||
|
out.innerHTML = spinnerHtml(T('Checking …'));
|
||||||
|
try {
|
||||||
|
const r = await api.call('certs:probe', this.probeHost, Number(this.probePort) || 443);
|
||||||
|
out.innerHTML = `<div class="net-sys"><div><label>${T('Status')}</label><div class="v">${r.authorized ? `<span class="exp ok">${T('trusted')}</span>` : `<span class="exp err">${esc(r.error || T('not trusted'))}</span>`}</div></div>
|
||||||
|
<div><label>${T('Expires')}</label><div class="v">${expiryHtml(r.expires)}</div></div><div><label>${T('Issuer')}</label><div class="v">${esc(r.issuer)}</div></div>
|
||||||
|
<div><label>${T('Subject')}</label><div class="v">${esc(r.subject)}</div></div><div><label>${T('Protocol')}</label><div class="v">${esc(r.protocol)}</div></div>
|
||||||
|
<div style="grid-column:1/-1"><label>${T('Names')}</label><div class="v">${r.sans.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')}</div></div>
|
||||||
|
<div style="grid-column:1/-1"><label>SHA-256</label><div class="v mono small" style="word-break:break-all">${esc(r.fingerprint)}</div></div></div>`;
|
||||||
|
} catch (e) { out.innerHTML = `<div class="admin-empty" style="color:var(--red)">${esc(e.message)}</div>`; }
|
||||||
|
};
|
||||||
|
form.append(go);
|
||||||
|
sec.append(form, h(`<div class="muted small" style="margin:4px 0 12px">${T('The connection is made from this computer – useful to check which certificate a web server actually delivers.')}</div>`), out);
|
||||||
|
w.append(sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ Samba / Active Directory
|
||||||
|
const YES = (v) => /^(yes|true|1)$/i.test(String(v));
|
||||||
|
|
||||||
|
class SambaSession extends AdminSession {
|
||||||
|
constructor(host) {
|
||||||
|
super(host, { kind: 'samba', ch: 'samba', title: 'Samba', views: [['shares', T('Shares')], ['users', T('Users')], ['status', T('Status')], ['domain', T('Domain')]] });
|
||||||
|
this.adView = 'users';
|
||||||
|
}
|
||||||
|
|
||||||
|
notInstalled(w) {
|
||||||
|
const box = h(`<div class="pane-empty" style="height:auto;padding:48px 20px"><div class="fw-off">${ICONS.users}<b>${T('Samba is not installed')}</b><span>${T('Install Samba to share folders with Windows clients or to run a domain controller.')}</span></div></div>`);
|
||||||
|
const b = h(`<button class="btn primary">${ICONS.download}${T('Install Samba')}</button>`);
|
||||||
|
b.onclick = () => this.term('installCommand', ['samba'], `${T('Install')} Samba`);
|
||||||
|
$('.fw-off', box).append(b);
|
||||||
|
w.append(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary(w) {
|
||||||
|
const d = this.data;
|
||||||
|
const g = d.global;
|
||||||
|
w.append(h(`<div class="net-sys"><div><label>${T('Version')}</label><div class="v">${esc(d.version || '–')}</div></div>
|
||||||
|
<div><label>${T('Server role')}</label><div class="v">${esc(d.role || '–')}</div></div>
|
||||||
|
<div><label>${T('Workgroup')}</label><div class="v">${esc(g.workgroup || '–')}</div></div>
|
||||||
|
${g.realm ? `<div><label>Realm</label><div class="v">${esc(g.realm)}</div></div>` : ''}</div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_shares(w) {
|
||||||
|
const d = this.data;
|
||||||
|
if (!d.installed) return this.notInstalled(w);
|
||||||
|
this.summary(w);
|
||||||
|
const sec = this.section(T('Shares'), [
|
||||||
|
{ label: T('Edit smb.conf'), icon: 'edit', run: () => this.editConf() },
|
||||||
|
{ label: T('New share'), icon: 'plus', cls: 'primary', run: () => this.editShare(null) },
|
||||||
|
]);
|
||||||
|
if (!d.shares.length) sec.append(this.empty(T('No shares yet.')));
|
||||||
|
else sec.append(this.table(d.shares, [
|
||||||
|
[T('Share'), (s) => `<b>${esc(s.name)}</b>${s.comment ? `<div class="muted small">${esc(s.comment)}</div>` : ''}`, 'name'],
|
||||||
|
[T('Path'), (s) => `<span class="mono small">${esc(s.path || '–')}</span>`],
|
||||||
|
[T('Access'), (s) => `<span class="fw-act ${YES(s.readOnly) ? '' : 'allow'}">${YES(s.readOnly) ? T('read only') : T('read/write')}</span>${YES(s.guestOk) ? ` <span class="fw-act limit">${T('guest')}</span>` : ''}${YES(s.browseable) ? '' : ` <span class="fw-act">${T('hidden')}</span>`}`],
|
||||||
|
[T('Users'), (s) => esc(s.validUsers || T('all'))],
|
||||||
|
], (s) => [
|
||||||
|
{ icon: 'edit', title: T('Edit'), run: () => (s.special ? this.editConf() : this.editShare(s)) },
|
||||||
|
!s.special && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete share?'), T('The share “{name}” will be removed from smb.conf. The files in {path} are kept.', { name: s.name, path: s.path || '–' }))) this.act('deleteShare', [s.name], T('Share deleted')); } },
|
||||||
|
]));
|
||||||
|
w.append(sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
editConf() {
|
||||||
|
this.editFile('/etc/samba/smb.conf', { read: () => this.op('readConf'), save: (c) => ['writeConf', c], note: T('Saving checks the file with testparm and reloads Samba. If the check fails, the previous version is restored.') });
|
||||||
|
}
|
||||||
|
|
||||||
|
async editShare(s) {
|
||||||
|
const v = s || { readOnly: 'no', browseable: 'yes', guestOk: 'no' };
|
||||||
|
const r = await modal({ title: s ? T('Edit share') : T('New share'), body: `
|
||||||
|
<div class="row"><div class="field"><label>${T('Name')}</label><input name="name" value="${esc(v.name || '')}" placeholder="daten"/></div>
|
||||||
|
<div class="field"><label>${T('Path')}</label><input name="path" value="${esc(v.path || '')}" placeholder="/srv/samba/daten"/></div></div>
|
||||||
|
<div class="field"><label>${T('Comment')}</label><input name="comment" value="${esc(v.comment || '')}" placeholder="${esc(T('optional'))}"/></div>
|
||||||
|
<div class="row"><label class="check"><input type="checkbox" name="readOnly" ${YES(v.readOnly) ? 'checked' : ''}/>${T('Read only')}</label>
|
||||||
|
<label class="check"><input type="checkbox" name="browseable" ${YES(v.browseable) ? 'checked' : ''}/>${T('Visible in the network')}</label>
|
||||||
|
<label class="check"><input type="checkbox" name="guestOk" ${YES(v.guestOk) ? 'checked' : ''}/>${T('Guest access')}</label></div>
|
||||||
|
<div class="field"><label>${T('Allowed users / groups')}</label><input name="validUsers" value="${esc(v.validUsers || '')}" placeholder="${esc(T('empty = all; e.g. anna, @staff'))}"/></div>
|
||||||
|
<div class="field"><label>${T('Users with write access')}</label><input name="writeList" value="${esc(v.writeList || '')}" placeholder="${esc(T('optional; e.g. @admins'))}"/></div>
|
||||||
|
<div class="row"><div class="field"><label>${T('File permissions (create mask)')}</label><input name="createMask" value="${esc(v.createMask || '')}" placeholder="0664"/></div>
|
||||||
|
<div class="field"><label>${T('Folder permissions (directory mask)')}</label><input name="directoryMask" value="${esc(v.directoryMask || '')}" placeholder="2775"/></div></div>
|
||||||
|
<label class="check"><input type="checkbox" name="createDir" ${s ? '' : 'checked'}/>${T('Create the folder if it does not exist')}</label>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
const yn = (b) => (b ? 'yes' : 'no');
|
||||||
|
const known = ['path', 'comment', 'read only', 'browseable', 'guest ok', 'valid users', 'write list', 'create mask', 'directory mask'];
|
||||||
|
const extra = s ? Object.fromEntries(Object.entries(s.params || {}).filter(([k]) => !known.includes(k))) : {};
|
||||||
|
this.act('saveShare', [{ ...r, readOnly: yn(r.readOnly), browseable: yn(r.browseable), guestOk: yn(r.guestOk), extra }, s?.name || null, r.createDir], T('Share saved'));
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_users(w) {
|
||||||
|
const d = this.data;
|
||||||
|
if (!d.installed) return this.notInstalled(w);
|
||||||
|
if (d.dc) {
|
||||||
|
w.append(h(`<div class="net-banner">${ICONS.users}<div>${T('This server is an Active Directory domain controller. Users are managed in the “Domain” view.')}</div></div>`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sec = this.section(T('Samba users'), [{ label: T('Add user'), icon: 'plus', cls: 'primary', run: () => this.addUser() }]);
|
||||||
|
if (!d.users.length) sec.append(this.empty(T('No Samba users yet. Samba users need a Linux account and their own Samba password.')));
|
||||||
|
else sec.append(this.table(d.users, [
|
||||||
|
[T('User'), (u) => `<b>${esc(u.user)}</b>${u.fullName ? `<div class="muted small">${esc(u.fullName)}</div>` : ''}`, 'name'],
|
||||||
|
[T('Status'), (u) => (u.disabled ? `<span class="fw-act deny">${T('disabled')}</span>` : `<span class="fw-act allow">${T('active')}</span>`)],
|
||||||
|
[T('Flags'), (u) => `<span class="mono small">${esc(u.flags)}</span>`],
|
||||||
|
], (u) => [
|
||||||
|
{ icon: 'key', title: T('Set password'), run: () => this.password((pw) => this.act('setPassword', [u.user, pw], T('Password changed')), u.user) },
|
||||||
|
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => this.act('setUserEnabled', [u.user, u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
|
||||||
|
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete Samba user?'), T('“{name}” can no longer access shares. The Linux account is kept.', { name: u.user }))) this.act('deleteUser', [u.user], T('User deleted')); } },
|
||||||
|
]));
|
||||||
|
w.append(sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
async password(run, name) {
|
||||||
|
const r = await modal({ title: T('Set password'), text: name, body: `<div class="field"><label>${T('New password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
|
||||||
|
run(r.pw, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUser() {
|
||||||
|
const r = await modal({ title: T('Add Samba user'), body: `
|
||||||
|
<div class="field"><label>${T('Username')}</label><input name="user" autocomplete="off"/></div>
|
||||||
|
<div class="row"><div class="field"><label>${T('Password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div></div>
|
||||||
|
<label class="check"><input type="checkbox" name="createUnix" checked/>${T('Create a Linux account without login if it does not exist')}</label>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
|
||||||
|
this.act('addUser', [r.user.trim(), r.pw, r.createUnix], T('User added'));
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_status(w) {
|
||||||
|
const d = this.data;
|
||||||
|
if (!d.installed) return this.notInstalled(w);
|
||||||
|
const svc = this.section(T('Services'));
|
||||||
|
if (!d.units.length) svc.append(this.empty(T('No Samba services found.')));
|
||||||
|
else {
|
||||||
|
svc.append(this.table(d.units, [[T('Service'), (u) => `<b>${esc(u.unit)}</b>`], [T('Status'), (u) => pill(u.active)]], () => []));
|
||||||
|
$$('tbody tr', svc).forEach((tr, i) => $('.acts', tr).append(this.serviceButtons(d.units[i], 'service', false)));
|
||||||
|
}
|
||||||
|
w.append(svc);
|
||||||
|
const st = d.status;
|
||||||
|
const con = this.section(T('Connected clients'));
|
||||||
|
if (st.raw !== undefined) con.append(h(`<pre class="fw-pre mono">${esc(st.raw || T('No connections.'))}</pre>`));
|
||||||
|
else if (!st.sessions.length) con.append(this.empty(T('No connections.')));
|
||||||
|
else con.append(this.table(st.sessions, [[T('User'), (x) => `<b>${esc(x.user)}</b>`], [T('Computer'), (x) => esc(x.machine || x.host)], [T('Protocol'), (x) => esc(x.protocol)], [T('Encryption'), (x) => esc(x.encryption || '–')],
|
||||||
|
[T('Shares'), (x) => st.shares.filter((t) => t.session === x.id).map((t) => `<span class="tag">${esc(t.service)}</span>`).join(' ')]]));
|
||||||
|
w.append(con);
|
||||||
|
if (st.files?.length) {
|
||||||
|
const f = this.section(T('Open files'));
|
||||||
|
f.append(this.table(st.files, [[T('File'), (x) => `<span class="mono small">${esc(x.path)}</span>`]]));
|
||||||
|
w.append(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async draw_domain(w) {
|
||||||
|
const d = this.data;
|
||||||
|
if (!d.dc) return this.drawMembership(w);
|
||||||
|
const seg = h(`<div class="seg sm ad-seg">${[['users', T('Users')], ['groups', T('Groups')], ['computers', T('Computers')], ['info', T('Domain info')]].map(([v, l]) => `<button data-ad="${v}" class="${this.adView === v ? 'active' : ''}">${esc(l)}</button>`).join('')}</div>`);
|
||||||
|
seg.onclick = (e) => { const v = e.target.closest('[data-ad]')?.dataset.ad; if (v) { this.adView = v; this.draw(); } };
|
||||||
|
w.append(seg);
|
||||||
|
const box = h(`<div>${spinnerHtml(T('Loading …'))}</div>`);
|
||||||
|
w.append(box);
|
||||||
|
try { if (!this.ad || this.adStale) { this.ad = await this.op('adRead'); this.adStale = false; } } catch { box.innerHTML = ''; return; }
|
||||||
|
box.innerHTML = '';
|
||||||
|
const ad = this.ad;
|
||||||
|
if (ad.limited) box.append(h(`<div class="net-banner warn">${ICONS.wall}<div>${T('ldbsearch is missing (package ldb-tools), so only names are shown.')}</div></div>`));
|
||||||
|
const reloadAd = async (name, args, msg) => { this.adStale = true; return this.act(name, args, msg); };
|
||||||
|
if (this.adView === 'users') {
|
||||||
|
const sec = this.section(T('Domain users'), [{ label: T('New user'), icon: 'plus', cls: 'primary', run: () => this.adNewUser(reloadAd) }]);
|
||||||
|
sec.append(this.table(ad.users, [
|
||||||
|
[T('User'), (u) => `<b>${esc(u.name)}</b>${u.display ? `<div class="muted small">${esc(u.display)}</div>` : ''}`, 'name'],
|
||||||
|
[T('E-mail'), (u) => esc(u.mail || '')],
|
||||||
|
[T('Groups'), (u) => (u.groups || []).slice(0, 4).map((g) => `<span class="tag">${esc(g)}</span>`).join(' ') + ((u.groups || []).length > 4 ? ` <span class="muted">+${u.groups.length - 4}</span>` : '')],
|
||||||
|
[T('Last logon'), (u) => (u.lastLogon ? esc(relTime(u.lastLogon)) : '<span class="muted">–</span>')],
|
||||||
|
[T('Status'), (u) => (u.disabled ? `<span class="fw-act deny">${T('disabled')}</span>` : `<span class="fw-act allow">${T('active')}</span>`)],
|
||||||
|
], (u) => [
|
||||||
|
{ icon: 'key', title: T('Set password'), run: () => this.adPassword(u.name, reloadAd) },
|
||||||
|
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => reloadAd('adUserEnable', [u.name, !!u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
|
||||||
|
!/^(Administrator|krbtgt|Guest)$/i.test(u.name) && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete domain user?'), T('“{name}” will be permanently deleted from the domain.', { name: u.name }))) reloadAd('adUserDelete', [u.name], T('User deleted')); } },
|
||||||
|
]));
|
||||||
|
box.append(sec);
|
||||||
|
} else if (this.adView === 'groups') {
|
||||||
|
const sec = this.section(T('Groups'), [{ label: T('New group'), icon: 'plus', cls: 'primary', run: async () => {
|
||||||
|
const r = await modal({ title: T('New group'), body: `<div class="field"><label>${T('Name')}</label><input name="name"/></div><div class="field"><label>${T('Description')}</label><input name="description" placeholder="${esc(T('optional'))}"/></div>`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (r) reloadAd('adGroupCreate', [r.name, r.description], T('Group created'));
|
||||||
|
} }]);
|
||||||
|
sec.append(this.table(ad.groups, [
|
||||||
|
[T('Group'), (g) => `<b>${esc(g.name)}</b>${g.description ? `<div class="muted small">${esc(g.description)}</div>` : ''}`, 'name'],
|
||||||
|
[T('Members'), (g) => `${(g.members || []).length}`, 'num'],
|
||||||
|
], (g) => [
|
||||||
|
{ icon: 'users', title: T('Members'), run: () => this.adMembers(g, reloadAd) },
|
||||||
|
!g.builtin && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('The group “{name}” will be deleted.', { name: g.name }))) reloadAd('adGroupDelete', [g.name], T('Group deleted')); } },
|
||||||
|
]));
|
||||||
|
box.append(sec);
|
||||||
|
} else if (this.adView === 'computers') {
|
||||||
|
const sec = this.section(T('Computers'));
|
||||||
|
sec.append(this.table(ad.computers, [
|
||||||
|
[T('Computer'), (c) => `<b>${esc(c.name)}</b>${c.dns ? `<div class="muted small">${esc(c.dns)}</div>` : ''}`, 'name'],
|
||||||
|
[T('Operating system'), (c) => esc(c.os || '')],
|
||||||
|
[T('Last logon'), (c) => (c.lastLogon ? esc(relTime(c.lastLogon)) : '<span class="muted">–</span>')],
|
||||||
|
], (c) => [{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete computer account?'), T('“{name}” will be removed from the domain and must join again.', { name: c.name }))) reloadAd('adComputerDelete', [c.name], T('Computer deleted')); } }]));
|
||||||
|
box.append(sec);
|
||||||
|
} else {
|
||||||
|
box.append(h(`<pre class="fw-pre mono">${esc([ad.info, ad.level, ad.fsmo].filter(Boolean).join('\n\n'))}</pre>`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein Domänencontroller: Mitgliedschaft anzeigen, beitreten/verlassen oder eine neue Domäne erstellen
|
||||||
|
drawMembership(w) {
|
||||||
|
const d = this.data;
|
||||||
|
const tools = new Set(d.tools);
|
||||||
|
const joined = (d.realm.match(/^(\S+)\s*$/m) || [])[1];
|
||||||
|
const btn = (label, icon, run, cls = '') => { const b = h(`<button class="btn sm ${cls}">${ICONS[icon]}${esc(label)}</button>`); b.onclick = run; return b; };
|
||||||
|
|
||||||
|
const mem = this.section(T('Domain membership'));
|
||||||
|
if (joined) {
|
||||||
|
mem.append(h(`<div class="net-banner">${ICONS.users}<div>${T('This server is a member of the domain {domain}.', { domain: `<b>${esc(joined)}</b>` })}</div></div>`));
|
||||||
|
mem.append(h(`<pre class="fw-pre mono" style="margin-top:10px">${esc(d.realm)}</pre>`));
|
||||||
|
const acts = h('<div class="cert-acts"></div>');
|
||||||
|
acts.append(btn(T('Leave domain'), 'close', async () => { if (await confirmBox(T('Leave domain?'), T('Domain users can no longer log in on this server. The computer account in the domain is kept.'), T('Leave'))) this.act('leaveDomain', [joined], T('Left the domain')); }, 'danger'));
|
||||||
|
mem.append(acts);
|
||||||
|
} else {
|
||||||
|
mem.append(this.empty(T('This server is not a member of a domain.')));
|
||||||
|
if (d.winbind) mem.append(h(`<div class="muted small">winbind: ${esc(d.winbind)}</div>`));
|
||||||
|
const acts = h('<div class="cert-acts"></div>');
|
||||||
|
if (tools.has('realm')) acts.append(btn(T('Join domain …'), 'plus', () => this.joinDialog(), 'primary'));
|
||||||
|
else acts.append(btn(T('Install domain join tools'), 'download', () => this.term('installCommand', ['join'], `${T('Install')} realmd/sssd`), 'primary'));
|
||||||
|
mem.append(acts);
|
||||||
|
}
|
||||||
|
w.append(mem);
|
||||||
|
|
||||||
|
if (!joined) {
|
||||||
|
const dc = this.section(T('Create a new domain'));
|
||||||
|
dc.append(h(`<div class="muted" style="font-size:13px;margin-bottom:10px">${T('Turns this server into the first domain controller of a new Active Directory domain. Windows and Linux computers can then join it, and you manage users, groups and computers right here.')}</div>`));
|
||||||
|
const acts = h('<div class="cert-acts" style="margin-left:0"></div>');
|
||||||
|
if (tools.has('samba-tool')) acts.append(btn(T('Create domain …'), 'plus', () => this.provisionDialog(), 'primary'));
|
||||||
|
acts.append(btn(T('Install domain controller packages'), 'download', () => this.term('installCommand', ['dc'], `${T('Install')} Samba AD DC`)));
|
||||||
|
dc.append(acts);
|
||||||
|
w.append(dc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinDialog() {
|
||||||
|
const r = await modal({ title: T('Join domain'), body: `
|
||||||
|
<div class="field"><label>${T('Domain')}</label><input name="domain" placeholder="corp.example.com"/></div>
|
||||||
|
<div class="row"><div class="field"><label>${T('Domain administrator')}</label><input name="admin" value="Administrator"/></div><div class="field"><label>${T('Password')}</label><input name="password" type="password" autocomplete="off"/></div></div>
|
||||||
|
<div class="hint" style="color:var(--muted);font-size:12px">${T('The server must resolve the domain via DNS (the domain controller as DNS server). Logins of domain users are then handled by sssd.')}</div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Join'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
this.body.innerHTML = spinnerHtml(T('Joining {domain} …', { domain: r.domain }));
|
||||||
|
if (!(await this.act('joinDomain', [r.domain, r.admin, r.password], T('Joined the domain')))) this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async provisionDialog() {
|
||||||
|
const host = (this.host.address || '').includes('.') && !/^\d/.test(this.host.address) ? this.host.address.split('.').slice(1).join('.') : '';
|
||||||
|
const r = await formModal({ title: T('Create a new domain'), body: `
|
||||||
|
<div class="row"><div class="field"><label>${T('DNS domain (realm)')}</label><input name="realm" placeholder="corp.example.com" value="${esc(host)}"/></div>
|
||||||
|
<div class="field small" style="flex:0 0 150px"><label>${T('NetBIOS name')}</label><input name="domain" placeholder="CORP" maxlength="15"/></div></div>
|
||||||
|
<div class="row"><div class="field"><label>${T('Password for Administrator')}</label><input name="password" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="password2" type="password" autocomplete="new-password"/></div></div>
|
||||||
|
<div class="field"><label>${T('DNS forwarder')}</label><input name="forwarder" placeholder="1.1.1.1"/><div class="hint">${T('Queries for other domains are forwarded to this DNS server.')}</div></div>
|
||||||
|
<label class="check"><input type="checkbox" name="useAsDns" checked/>${T('Use this domain controller as DNS server of the host (resolv.conf)')}</label>
|
||||||
|
<label class="check"><input type="checkbox" name="rfc2307"/>${T('Store Unix attributes (UID/GID) in the directory (RFC 2307)')}</label>
|
||||||
|
<div class="net-banner warn" style="margin-top:10px">${ICONS.wall}<div>${T('The file server services (smbd/nmbd) are replaced by samba-ad-dc; the previous smb.conf is kept as smb.conf.mrterm-before-dc. The server needs a fixed IP address and a hostname that is not the domain name. The password must be complex (upper/lower case, digits, 8+ characters).')}</div></div>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create domain'), value: 'form', cls: 'primary' }] }, (m) => {
|
||||||
|
$('[name=realm]', m).oninput = (e) => { const n = $('[name=domain]', m); if (!n.dataset.touched) n.value = (e.target.value.split('.')[0] || '').toUpperCase().slice(0, 15); };
|
||||||
|
$('[name=domain]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
|
||||||
|
$('[name=realm]', m).dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
|
||||||
|
this.body.innerHTML = spinnerHtml(T('Creating the domain {realm} … this takes about a minute.', { realm: r.realm.toUpperCase() }));
|
||||||
|
this.ad = null;
|
||||||
|
if (await this.act('provisionDomain', [r], T('Domain {realm} created', { realm: r.realm.toUpperCase() }))) { this.adView = 'info'; this.draw(); } else this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async adNewUser(reloadAd) {
|
||||||
|
const r = await modal({ title: T('New domain user'), body: `
|
||||||
|
<div class="field"><label>${T('Username')}</label><input name="name" autocomplete="off"/></div>
|
||||||
|
<div class="row"><div class="field"><label>${T('First name')}</label><input name="given"/></div><div class="field"><label>${T('Last name')}</label><input name="surname"/></div></div>
|
||||||
|
<div class="field"><label>${T('E-mail')}</label><input name="mail" placeholder="${esc(T('optional'))}"/></div>
|
||||||
|
<div class="row"><div class="field"><label>${T('Password')}</label><input name="password" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="password2" type="password" autocomplete="new-password"/></div></div>
|
||||||
|
<label class="check"><input type="checkbox" name="mustChange" checked/>${T('User must change the password at next logon')}</label>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
|
||||||
|
reloadAd('adUserCreate', [r], T('User created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async adPassword(name, reloadAd) {
|
||||||
|
const r = await modal({ title: T('Set password'), text: name, body: `<div class="field"><label>${T('New password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div><label class="check"><input type="checkbox" name="mustChange"/>${T('User must change the password at next logon')}</label>`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
|
||||||
|
reloadAd('adSetPassword', [name, r.pw, r.mustChange], T('Password changed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async adMembers(g, reloadAd) {
|
||||||
|
const users = (this.ad.users || []).map((u) => u.name);
|
||||||
|
const r = await modal({ title: T('Members of {name}', { name: g.name }), body: `<div class="field"><label>${T('One member per line (users, groups or computers)')}</label><textarea name="members" rows="10" class="mono" spellcheck="false">${esc((g.members || []).join('\n'))}</textarea></div>
|
||||||
|
${users.length ? `<div class="hint" style="color:var(--muted);font-size:12px">${T('Users')}: ${users.slice(0, 40).map(esc).join(', ')}${users.length > 40 ? ' …' : ''}</div>` : ''}`,
|
||||||
|
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||||
|
if (!r) return;
|
||||||
|
const now = r.members.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||||
|
const before = g.members || [];
|
||||||
|
const add = now.filter((x) => !before.includes(x)), remove = before.filter((x) => !now.includes(x));
|
||||||
|
if (add.length || remove.length) reloadAd('adGroupMembers', [g.name, add, remove], T('Members updated'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* App-Designs: gemeinsame Farbvariablen für Desktop (src/renderer) und Android-App (mobile/web) */
|
||||||
|
:root {
|
||||||
|
--bg: #13151c;
|
||||||
|
--bg-2: #181b24;
|
||||||
|
--panel: #1c1f2a;
|
||||||
|
--card: #212533;
|
||||||
|
--card-hover: #282d3d;
|
||||||
|
--border: #2b3040;
|
||||||
|
--text: #e6e8ef;
|
||||||
|
--muted: #8b91a5;
|
||||||
|
--faint: #5d6377;
|
||||||
|
--accent: #6e7bff;
|
||||||
|
--accent-2: #8b95ff;
|
||||||
|
--green: #3ecf8e;
|
||||||
|
--red: #ff5f6d;
|
||||||
|
--orange: #ffb454;
|
||||||
|
--accent-3: #b36bff;
|
||||||
|
--chrome: #0f1117;
|
||||||
|
--chrome-line: #1f2330;
|
||||||
|
--hover: #1a1d27;
|
||||||
|
--nav-active: #1f2233;
|
||||||
|
--icon-bg: #2a2f45;
|
||||||
|
--folder: #6ea8ff;
|
||||||
|
--row-line: #1c1f29;
|
||||||
|
--overlay: #13151ce6;
|
||||||
|
--tint: 255 255 255;
|
||||||
|
--radius: 10px;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--font: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
|
||||||
|
}
|
||||||
|
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
|
||||||
|
[data-theme='navy'] {
|
||||||
|
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
|
||||||
|
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
|
||||||
|
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
|
||||||
|
}
|
||||||
|
[data-theme='nord'] {
|
||||||
|
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
|
||||||
|
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
|
||||||
|
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
|
||||||
|
}
|
||||||
|
[data-theme='dracula'] {
|
||||||
|
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
|
||||||
|
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
|
||||||
|
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
|
||||||
|
}
|
||||||
|
[data-theme='mocha'] {
|
||||||
|
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
|
||||||
|
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
|
||||||
|
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
|
||||||
|
}
|
||||||
|
[data-theme='forest'] {
|
||||||
|
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
|
||||||
|
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
|
||||||
|
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
|
||||||
|
}
|
||||||
|
[data-theme='light'] {
|
||||||
|
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
|
||||||
|
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
|
||||||
|
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
|
||||||
|
--tint: 0 0 0;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Windows XP (Luna) */
|
||||||
|
[data-theme='xp'] {
|
||||||
|
--bg: #ece9d8; --bg-2: #e3dfcb; --panel: #ffffff; --card: #ffffff; --card-hover: #f1efe2; --border: #aca899;
|
||||||
|
--text: #000000; --muted: #4a4a4a; --faint: #7f7f7f; --accent: #316ac5; --accent-3: #3a9a3a; --green: #2b8a2b; --red: #c81e1e; --orange: #d87400;
|
||||||
|
--chrome: #d6dff7; --chrome-line: #7a96df; --hover: #e8eefc; --nav-active: #c1d2ee; --icon-bg: #dde7f8; --folder: #e0a92a; --row-line: #ece9d8; --overlay: #ece9d8e6;
|
||||||
|
--tint: 0 0 0; --radius: 3px; --font: Tahoma, Verdana, 'Segoe UI', sans-serif;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
}
|
||||||
|
/* Windows 11 (hell, Mica) */
|
||||||
|
[data-theme='win11'] {
|
||||||
|
--bg: #f3f3f3; --bg-2: #eeeeee; --panel: #fbfbfb; --card: #ffffff; --card-hover: #f6f6f6; --border: #e5e5e5;
|
||||||
|
--text: #1b1b1b; --muted: #5f5f5f; --faint: #8a8a8a; --accent: #0067c0; --accent-3: #8764b8; --green: #0f7b0f; --red: #c42b1c; --orange: #9d5d00;
|
||||||
|
--chrome: #ebebeb; --chrome-line: #e0e0e0; --hover: #eaeaea; --nav-active: #e0e0e0; --icon-bg: #e6eef8; --folder: #e8a33d; --row-line: #efefef; --overlay: #f3f3f3e6;
|
||||||
|
--tint: 0 0 0; --radius: 8px; --font: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
}
|
||||||
|
/* macOS 26 „Liquid Glass“: milchige, durchscheinende Flächen über einem farbigen Hintergrund */
|
||||||
|
[data-theme='macos'] {
|
||||||
|
--bg: rgba(255, 255, 255, .34); --bg-2: rgba(255, 255, 255, .28); --panel: rgba(255, 255, 255, .55); --card: rgba(255, 255, 255, .48);
|
||||||
|
--card-hover: rgba(255, 255, 255, .68); --border: rgba(255, 255, 255, .7);
|
||||||
|
--text: #1d1d1f; --muted: #55555c; --faint: #8a8a92; --accent: #007aff; --accent-3: #af52de; --green: #248a3d; --red: #e0352b; --orange: #c96b00;
|
||||||
|
--chrome: rgba(255, 255, 255, .3); --chrome-line: rgba(255, 255, 255, .55); --hover: rgba(255, 255, 255, .45); --nav-active: rgba(255, 255, 255, .72);
|
||||||
|
--icon-bg: rgba(0, 122, 255, .12); --folder: #1a8cff; --row-line: rgba(255, 255, 255, .5); --overlay: rgba(240, 242, 250, .55);
|
||||||
|
--tint: 0 0 0; --radius: 14px; --font: -apple-system, 'SF Pro Text', 'Helvetica Neue', 'Inter', system-ui, sans-serif;
|
||||||
|
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
||||||
|
--glass-blur: blur(26px) saturate(185%);
|
||||||
|
--glass-edge: inset 0 1px 0 rgba(255, 255, 255, .95), inset 0 -1px 0 rgba(255, 255, 255, .3), 0 10px 30px rgba(30, 40, 80, .10);
|
||||||
|
}
|
||||||
|
/* Hintergrund, durch den das Glas schimmert */
|
||||||
|
[data-theme='macos'] {
|
||||||
|
--wallpaper:
|
||||||
|
radial-gradient(at 12% 18%, #9cc2ff 0, transparent 46%),
|
||||||
|
radial-gradient(at 88% 12%, #f7b6dd 0, transparent 42%),
|
||||||
|
radial-gradient(at 72% 88%, #98e2d2 0, transparent 46%),
|
||||||
|
radial-gradient(at 18% 92%, #ffd6a0 0, transparent 42%),
|
||||||
|
#eef1f8;
|
||||||
|
}
|
||||||
|
html[data-theme='macos'], html[data-theme='macos'] body { background: var(--wallpaper); background-attachment: fixed; }
|
||||||
|
/* Cyberpunk 2077 */
|
||||||
|
[data-theme='cyberpunk'] {
|
||||||
|
--bg: #0a0a0f; --bg-2: #0e0e15; --panel: #111119; --card: #15151f; --card-hover: #1e1c10; --border: #3d3a14;
|
||||||
|
--text: #eafcff; --muted: #8fb8c8; --faint: #5a7680; --accent: #fcee0a; --accent-3: #00f0ff; --green: #00ff9f; --red: #ff003c; --orange: #ff9e00;
|
||||||
|
--chrome: #07070b; --chrome-line: #2a2808; --hover: #17161b; --nav-active: #232008; --icon-bg: #26240b; --folder: #00f0ff; --row-line: #17161f; --overlay: #0a0a0fe6;
|
||||||
|
--radius: 2px; --on-accent: #0a0a0f; --font: 'Rajdhani', 'Orbitron', 'Bahnschrift', 'DIN Alternate', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--accent-2: #fff45c;
|
||||||
|
}
|
||||||
+843
-33
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" />
|
||||||
<title>MrTerm</title>
|
<title>MrTerm</title>
|
||||||
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" />
|
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" />
|
||||||
|
<link rel="stylesheet" href="app-themes.css" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -16,8 +17,9 @@
|
|||||||
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
|
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
|
||||||
</button>
|
</button>
|
||||||
</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)"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button>
|
||||||
<div class="drag-fill"></div>
|
<div class="drag-fill"></div>
|
||||||
|
<button id="aiBtn" class="icon-btn" data-ai-toggle title="Support assistant (Ollama)" data-i18n-title="Support assistant (Ollama)"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg></button>
|
||||||
<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>
|
<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>
|
||||||
@@ -47,10 +49,12 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
|
<div class="home-bg" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||||
<section id="homeView" class="page active"></section>
|
<section id="homeView" class="page active"></section>
|
||||||
<div id="sessions"></div>
|
<div id="sessions"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<aside id="aiPanel" class="ai-panel"></aside>
|
||||||
<aside id="drawer" class="drawer"></aside>
|
<aside id="drawer" class="drawer"></aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,6 +69,8 @@
|
|||||||
<script src="../i18n.js"></script>
|
<script src="../i18n.js"></script>
|
||||||
<script src="themes.js"></script>
|
<script src="themes.js"></script>
|
||||||
<script src="rdm-import.js"></script>
|
<script src="rdm-import.js"></script>
|
||||||
|
<script src="webconf.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
<script src="admin-tabs.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
+189
-71
@@ -1,71 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #13151c;
|
|
||||||
--bg-2: #181b24;
|
|
||||||
--panel: #1c1f2a;
|
|
||||||
--card: #212533;
|
|
||||||
--card-hover: #282d3d;
|
|
||||||
--border: #2b3040;
|
|
||||||
--text: #e6e8ef;
|
|
||||||
--muted: #8b91a5;
|
|
||||||
--faint: #5d6377;
|
|
||||||
--accent: #6e7bff;
|
|
||||||
--accent-2: #8b95ff;
|
|
||||||
--green: #3ecf8e;
|
|
||||||
--red: #ff5f6d;
|
|
||||||
--orange: #ffb454;
|
|
||||||
--accent-3: #b36bff;
|
|
||||||
--chrome: #0f1117;
|
|
||||||
--chrome-line: #1f2330;
|
|
||||||
--hover: #1a1d27;
|
|
||||||
--nav-active: #1f2233;
|
|
||||||
--icon-bg: #2a2f45;
|
|
||||||
--folder: #6ea8ff;
|
|
||||||
--row-line: #1c1f29;
|
|
||||||
--overlay: #13151ce6;
|
|
||||||
--tint: 255 255 255;
|
|
||||||
--radius: 10px;
|
|
||||||
--titlebar: 42px;
|
--titlebar: 42px;
|
||||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
/* ---------- App-Designs ---------- */
|
[data-theme='light'] .card, [data-theme='xp'] .card, [data-theme='win11'] .card, [data-theme='macos'] .card { border-color: var(--border); }
|
||||||
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
|
[data-theme='light'] .avatar .proto, [data-theme='xp'] .avatar .proto, [data-theme='win11'] .avatar .proto, [data-theme='macos'] .avatar .proto { background: var(--panel); }
|
||||||
[data-theme='navy'] {
|
[data-theme='light'] ::-webkit-scrollbar-thumb, [data-theme='xp'] ::-webkit-scrollbar-thumb, [data-theme='win11'] ::-webkit-scrollbar-thumb, [data-theme='macos'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
|
||||||
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
|
|
||||||
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
|
|
||||||
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
|
|
||||||
}
|
|
||||||
[data-theme='nord'] {
|
|
||||||
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
|
|
||||||
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
|
|
||||||
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
|
|
||||||
}
|
|
||||||
[data-theme='dracula'] {
|
|
||||||
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
|
|
||||||
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
|
|
||||||
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
|
|
||||||
}
|
|
||||||
[data-theme='mocha'] {
|
|
||||||
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
|
|
||||||
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
|
|
||||||
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
|
|
||||||
}
|
|
||||||
[data-theme='forest'] {
|
|
||||||
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
|
|
||||||
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
|
|
||||||
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
|
|
||||||
}
|
|
||||||
[data-theme='light'] {
|
|
||||||
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
|
|
||||||
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
|
|
||||||
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
|
|
||||||
--tint: 0 0 0;
|
|
||||||
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
|
|
||||||
}
|
|
||||||
[data-theme='light'] .card { border-color: var(--border); }
|
|
||||||
[data-theme='light'] .avatar .proto { background: var(--panel); }
|
|
||||||
[data-theme='light'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
||||||
button { font: inherit; color: inherit; cursor: pointer; }
|
button { font: inherit; color: inherit; cursor: pointer; }
|
||||||
@@ -83,23 +24,30 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
|
|||||||
.logo { width: 24px; height: 24px; border-radius: 6px; }
|
.logo { width: 24px; height: 24px; border-radius: 6px; }
|
||||||
.brand { gap: 8px !important; }
|
.brand { gap: 8px !important; }
|
||||||
.brand-term { color: #5ef0c4; margin-left: -8px; }
|
.brand-term { color: #5ef0c4; margin-left: -8px; }
|
||||||
#tabs { display: flex; gap: 4px; height: 100%; align-items: flex-end; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; }
|
#tabs { display: flex; gap: 4px; height: 100%; align-items: center; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; }
|
||||||
#tabs::-webkit-scrollbar { height: 0; }
|
#tabs::-webkit-scrollbar { height: 0; }
|
||||||
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 12px; border: none; background: transparent; border-radius: 8px 8px 0 0; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
|
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px 0 12px; border: none; background: transparent; border-radius: 8px; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
|
||||||
.tab.home { min-width: 0; }
|
.tab.home { min-width: 0; }
|
||||||
|
/* Tabs verschieben / lösen */
|
||||||
|
.tab.drop-before { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.tab.drop-after { box-shadow: inset -3px 0 0 var(--accent); }
|
||||||
|
body.detached #sidebar, body.detached .tab.home, body.detached #homeView { display: none !important; }
|
||||||
|
body.detached .brand { width: auto; padding-right: 18px; }
|
||||||
.tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
|
.tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
|
||||||
.tab:hover { background: var(--hover); color: var(--text); }
|
.tab:hover { background: var(--hover); color: var(--text); }
|
||||||
.tab.active { background: var(--bg); color: var(--text); }
|
.tab.active { background: var(--bg); color: var(--text); box-shadow: 0 0 0 1px var(--chrome-line); }
|
||||||
.tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
.tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
||||||
.tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
.tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
.tab .dot.err { background: var(--red); }
|
.tab .dot.err { background: var(--red); }
|
||||||
.tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; }
|
.tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; }
|
||||||
.tab .x { opacity: 0; border: none; background: none; padding: 2px; border-radius: 4px; color: var(--muted); display: flex; }
|
.tab .x { opacity: 0; border: none; background: none; padding: 3px; border-radius: 4px; color: var(--muted); display: flex; }
|
||||||
.tab:hover .x, .tab.active .x { opacity: 1; }
|
.tab:hover .x, .tab.active .x { opacity: 1; }
|
||||||
.tab .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); }
|
.tab .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); }
|
||||||
.tab .x svg { width: 12px; height: 12px; }
|
.tab .x svg { width: 12px; height: 12px; }
|
||||||
@keyframes pulse { 50% { opacity: .35; } }
|
@keyframes pulse { 50% { opacity: .35; } }
|
||||||
.icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; font-size: 18px; margin-left: 4px; flex: none; }
|
.icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; margin-left: 4px; flex: none; display: flex; align-items: center; justify-content: center; padding: 0; }
|
||||||
|
/* Alle Titelleisten-Symbole gleich groß und mittig (vorher: „+“ als Text mit eigener Grundlinie) */
|
||||||
|
.icon-btn svg { width: 16px; height: 16px; }
|
||||||
.icon-btn:hover { background: var(--chrome-line); color: var(--text); }
|
.icon-btn:hover { background: var(--chrome-line); color: var(--text); }
|
||||||
.drag-fill { flex: 1; min-width: 40px; height: 100%; }
|
.drag-fill { flex: 1; min-width: 40px; height: 100%; }
|
||||||
.win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; }
|
.win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; }
|
||||||
@@ -154,7 +102,7 @@ body.in-session #sidebar { display: none; }
|
|||||||
.search svg { color: var(--faint); }
|
.search svg { color: var(--faint); }
|
||||||
.btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); font-weight: 600; white-space: nowrap; }
|
.btn { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); font-weight: 600; white-space: nowrap; }
|
||||||
.btn:hover { background: var(--card-hover); }
|
.btn:hover { background: var(--card-hover); }
|
||||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||||
.btn.primary:hover { background: var(--accent-2); }
|
.btn.primary:hover { background: var(--accent-2); }
|
||||||
.btn.danger { color: var(--red); }
|
.btn.danger { color: var(--red); }
|
||||||
.btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
.btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
||||||
@@ -221,7 +169,7 @@ body.in-session #sidebar { display: none; }
|
|||||||
.form h4 { margin: 20px 0 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent-2); }
|
.form h4 { margin: 20px 0 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--accent-2); }
|
||||||
.seg { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; margin-bottom: 14px; }
|
.seg { display: flex; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; margin-bottom: 14px; }
|
||||||
.seg button { flex: 1; border: none; background: none; padding: 7px; border-radius: 6px; color: var(--muted); font-weight: 600; }
|
.seg button { flex: 1; border: none; background: none; padding: 7px; border-radius: 6px; color: var(--muted); font-weight: 600; }
|
||||||
.seg button.active { background: var(--accent); color: white; }
|
.seg button.active { background: var(--accent); color: var(--on-accent); }
|
||||||
.check { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; cursor: pointer; }
|
.check { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; cursor: pointer; }
|
||||||
.check input { accent-color: var(--accent); width: 16px; height: 16px; }
|
.check input { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||||
.colors { display: flex; gap: 8px; flex-wrap: wrap; }
|
.colors { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
@@ -250,7 +198,38 @@ body.in-session #sidebar { display: none; }
|
|||||||
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
||||||
.snip-panel .it:hover { background: var(--card-hover); }
|
.snip-panel .it:hover { background: var(--card-hover); }
|
||||||
|
.snip-tpl { margin-top: 10px; }
|
||||||
|
.snip-tpl summary { cursor: pointer; color: var(--muted); font-weight: 600; padding: 6px 2px; user-select: none; }
|
||||||
|
.snip-tpl .tpl-items { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
|
||||||
|
.snip-tpl .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
|
||||||
|
.snip-tpl .it:hover { background: var(--card-hover); }
|
||||||
|
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
||||||
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
||||||
|
/* Support-Assistent (rechts neben allen Tabs) */
|
||||||
|
.ai-panel { width: 400px; border-left: 1px solid var(--border); background: var(--bg-2); display: none; flex-direction: column; flex: none; min-height: 0; }
|
||||||
|
.ai-panel.open { display: flex; }
|
||||||
|
.ai-panel .head { padding: 8px 12px; font-weight: 600; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.ai-panel .head > span:first-child { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.ai-panel .head svg { width: 16px; height: 16px; }
|
||||||
|
.ai-panel .bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.ai-panel .bar select { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; color: inherit; }
|
||||||
|
.ai-panel .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; }
|
||||||
|
.ai-panel .msgs .hint { color: var(--muted); padding: 8px; }
|
||||||
|
.ai-panel .m { border-radius: 8px; padding: 8px 10px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
|
.ai-panel .m.user { background: var(--accent); color: #fff; align-self: flex-end; max-width: 90%; white-space: pre-wrap; }
|
||||||
|
.ai-panel .m.user small { display: block; opacity: .75; font-size: 10px; margin-bottom: 2px; }
|
||||||
|
.ai-panel .m.bot { background: var(--card); }
|
||||||
|
.ai-panel .m.err { color: var(--red, #ff6b6b); }
|
||||||
|
.ai-panel .m code { font-family: var(--mono, monospace); font-size: 12px; background: var(--bg); padding: 1px 4px; border-radius: 4px; }
|
||||||
|
.ai-panel .code { margin: 6px 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
.ai-panel .code .acts { display: flex; justify-content: flex-end; gap: 2px; padding: 2px; border-bottom: 1px solid var(--border); }
|
||||||
|
.ai-panel .code pre { margin: 0; padding: 8px; overflow: auto; }
|
||||||
|
.ai-panel .code pre code { background: none; padding: 0; }
|
||||||
|
.ai-panel .ask { border-top: 1px solid var(--border); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ai-panel .ask .check { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||||
|
.ai-panel .ask textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; resize: vertical; color: inherit; font: inherit; outline: none; }
|
||||||
|
.ai-panel .ask .row { justify-content: space-between; }
|
||||||
|
.ai-panel .spinner { width: 16px; height: 16px; }
|
||||||
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
|
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
|
||||||
.findbar.open { display: flex; }
|
.findbar.open { display: flex; }
|
||||||
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
||||||
@@ -345,7 +324,7 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
||||||
|
|
||||||
/* App-Sperre */
|
/* App-Sperre */
|
||||||
.lock-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; }
|
.lock-btn svg { 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-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 { 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-box h2 { margin: 0 0 6px; font-size: 18px; }
|
||||||
@@ -389,6 +368,29 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
|
|
||||||
/* Firewall */
|
/* Firewall */
|
||||||
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.session .sbar .seg { margin: 0 6px 0 0; flex: none; }
|
||||||
|
.docker-table .dot.none { background: transparent; border: 1px solid var(--faint); }
|
||||||
|
.docker-table .badge, .tpl-it .badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--icon-bg); color: var(--muted); margin-left: 4px; vertical-align: middle; }
|
||||||
|
.stack-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; position: sticky; top: 0; background: var(--bg); z-index: 2; }
|
||||||
|
.stack-bar .muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.stack-bar + .docker-table th { top: 45px; }
|
||||||
|
.stack-editor { display: flex; flex-direction: column; height: 100%; }
|
||||||
|
.stack-editor .se-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
.stack-editor .se-name { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 5px 8px; width: 200px; font: inherit; }
|
||||||
|
.stack-editor .se-note { padding: 8px 12px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--border); max-height: 80px; overflow: auto; }
|
||||||
|
.stack-editor .se-grid { flex: 1; display: grid; grid-template-columns: 2fr 1fr; gap: 10px; padding: 10px; min-height: 0; }
|
||||||
|
.stack-editor .se-col { display: flex; flex-direction: column; min-height: 0; gap: 6px; }
|
||||||
|
.stack-editor .se-col label { font-size: 12px; color: var(--muted); font-weight: 600; }
|
||||||
|
.stack-editor textarea { flex: 1; resize: none; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 13px; line-height: 1.45; tab-size: 2; white-space: pre; min-height: 200px; }
|
||||||
|
@media (max-width: 800px) { .stack-editor .se-grid { grid-template-columns: 1fr; } }
|
||||||
|
.modal.wide { width: min(760px, 94vw); max-width: none; }
|
||||||
|
.tpl-top { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.tpl-top .search { margin: 0; }
|
||||||
|
.tpl-top select { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 0 8px; }
|
||||||
|
.tpl-list { max-height: 55vh; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.tpl-it { background: var(--card); border-radius: 8px; padding: 9px 11px; cursor: pointer; }
|
||||||
|
.tpl-it:hover { background: var(--card-hover); }
|
||||||
|
.tpl-it .d { color: var(--muted); font-size: 12px; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
.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 { 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-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }
|
||||||
@@ -443,3 +445,119 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
.fw-hint b { color: var(--text); }
|
.fw-hint b { color: var(--text); }
|
||||||
.fw-hint p { margin: 4px 0 8px; color: var(--muted); }
|
.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; }
|
.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; }
|
||||||
|
|
||||||
|
/* Design-Extras: XP-Titelleiste, Cyberpunk-Neon */
|
||||||
|
[data-theme='xp'] #titlebar { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; }
|
||||||
|
[data-theme='xp'] #titlebar .brand, [data-theme='xp'] #titlebar .win-ctrls button, [data-theme='xp'] #titlebar .tab:not(.active) { color: #fff; }
|
||||||
|
[data-theme='xp'] #titlebar .tab.active { color: var(--text); }
|
||||||
|
[data-theme='xp'] .btn.primary { background: linear-gradient(#3d86e8, #2159b8); border-color: #1b4a9a; }
|
||||||
|
[data-theme='cyberpunk'] .btn.primary { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 10px) 100%, 0 100%); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
[data-theme='cyberpunk'] #titlebar { border-bottom: 1px solid #fcee0a55; }
|
||||||
|
[data-theme='cyberpunk'] .card:hover { box-shadow: inset 2px 0 0 var(--accent-3); }
|
||||||
|
[data-theme='xp'] .brand-term, [data-theme='win11'] .brand-term, [data-theme='macos'] .brand-term { color: #14a37a; }
|
||||||
|
/* macOS Liquid Glass */
|
||||||
|
[data-theme='macos'] #main, [data-theme='macos'] .pane { background: transparent; }
|
||||||
|
[data-theme='macos'] :is(#titlebar, #sidebar, .toolbar, .card, .settings-card, .modal, .drawer, .ctx, .palette, .search, .toast, .net-card, .findbar, .pane th, .tab.active, .btn, .seg, .field input, .field select, .field textarea) {
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
|
||||||
|
}
|
||||||
|
[data-theme='macos'] :is(.card, .settings-card, .modal, .drawer, .ctx, .palette, .toast, .net-card, .search) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
|
||||||
|
[data-theme='macos'] #sidebar { border-right: 1px solid var(--chrome-line); box-shadow: inset -1px 0 0 rgba(255, 255, 255, .6); }
|
||||||
|
[data-theme='macos'] #titlebar { border-bottom: 1px solid var(--chrome-line); }
|
||||||
|
[data-theme='macos'] .btn { border-radius: 999px; border-color: rgba(255, 255, 255, .75); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
|
||||||
|
[data-theme='macos'] .btn.primary { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); border-color: rgba(255, 255, 255, .45); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 4px 14px rgba(0, 122, 255, .3); }
|
||||||
|
[data-theme='macos'] .modal-bg { -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
||||||
|
[data-theme='macos'] .nav.active { box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); }
|
||||||
|
|
||||||
|
/* Web-Version (Browser): Desktop-Funktionen ausblenden */
|
||||||
|
.web .win-ctrls, .web #lockBtn, .web .nav[data-view='vpns'], .web .nav[data-view='forwards'], .web #updateBadge { display: none !important; }
|
||||||
|
|
||||||
|
/* ---------- Verwaltungs-Tabs: Webserver, Zertifikate, Samba/AD ---------- */
|
||||||
|
.admin-wrap { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.admin-sec { background: var(--card); border-radius: 12px; padding: 12px 14px; }
|
||||||
|
.admin-sec-head { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.admin-sec-head h4 { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); }
|
||||||
|
.admin-sec-head .grow, .ws-card .grow { flex: 1; }
|
||||||
|
.admin-table th { background: var(--card); }
|
||||||
|
.admin-table td { white-space: normal; }
|
||||||
|
.admin-table td.name { width: auto; max-width: none; }
|
||||||
|
.admin-table td.acts { white-space: nowrap; width: 1%; }
|
||||||
|
.admin-table .w-dot { width: 18px; }
|
||||||
|
.admin-empty { color: var(--muted); padding: 14px 4px; font-size: 13px; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 11.5px; }
|
||||||
|
.status-pill.err { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); }
|
||||||
|
.svc-btns { display: inline-flex; align-items: center; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.svc-auto { margin: 0 0 0 6px; font-size: 12px; color: var(--muted); }
|
||||||
|
.ws-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||||
|
.ws-card { background: var(--card); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.ws-card.absent { opacity: .75; border: 1px dashed var(--border); background: transparent; }
|
||||||
|
.ws-card .nh { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.ws-card .ws-ico svg { width: 18px; height: 18px; color: var(--green); }
|
||||||
|
.ws-acts { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.file-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.file-it { display: flex; align-items: center; gap: 8px; text-align: left; background: transparent; border: 0; border-radius: 8px; padding: 7px 8px; color: var(--text); }
|
||||||
|
.file-it:hover { background: var(--card-hover); }
|
||||||
|
.file-it svg { width: 15px; height: 15px; color: var(--faint); flex: none; }
|
||||||
|
.stack-editor .se-out { padding: 8px 12px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; white-space: pre-wrap; border-bottom: 1px solid var(--border); max-height: 140px; overflow: auto; }
|
||||||
|
.stack-editor .se-out.ok { color: var(--green); }
|
||||||
|
.stack-editor .se-out.err { color: var(--red); }
|
||||||
|
.cert-head { display: flex; gap: 24px; align-items: flex-end; flex-wrap: wrap; background: var(--card); border-radius: 12px; padding: 12px 14px; }
|
||||||
|
.cert-head label { font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--faint); font-weight: 600; }
|
||||||
|
.cert-head .v { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
||||||
|
.cert-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
|
||||||
|
.exp { display: inline-flex; flex-direction: column; font-size: 12.5px; }
|
||||||
|
.exp small { font-size: 11px; color: var(--faint); }
|
||||||
|
.exp.ok { color: var(--green); }
|
||||||
|
.exp.warn { color: var(--orange); }
|
||||||
|
.exp.err { color: var(--red); }
|
||||||
|
.cert-probe { align-items: flex-end; }
|
||||||
|
.cert-probe .btn { margin-bottom: 14px; }
|
||||||
|
.ad-seg { align-self: flex-start; }
|
||||||
|
.feature-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 4px 12px; }
|
||||||
|
.feature-it { margin: 4px 0; }
|
||||||
|
.feature-it .fi svg { width: 16px; height: 16px; vertical-align: -3px; margin-right: 2px; }
|
||||||
|
|
||||||
|
/* ---------- Animierter Hintergrund der Startseite: langsam treibende, weichgezeichnete Farbflächen ---------- */
|
||||||
|
.home-bg { position: absolute; inset: 0; overflow: hidden; pointer-events: none; display: none; z-index: 0; }
|
||||||
|
body.home-anim #main:has(> #homeView.active) .home-bg { display: block; }
|
||||||
|
body.home-anim #homeView { z-index: 1; }
|
||||||
|
body.home-anim #homeView .toolbar { background: transparent; -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); }
|
||||||
|
.home-bg i { position: absolute; width: 62vmax; height: 62vmax; border-radius: 50%; filter: blur(80px); opacity: .34; will-change: transform; mix-blend-mode: screen; }
|
||||||
|
.home-bg i:nth-child(1) { background: radial-gradient(circle, var(--accent) 0, transparent 68%); left: -18vmax; top: -24vmax; animation: hb1 46s ease-in-out infinite alternate; }
|
||||||
|
.home-bg i:nth-child(2) { background: radial-gradient(circle, var(--accent-3) 0, transparent 68%); right: -22vmax; top: -10vmax; animation: hb2 58s ease-in-out infinite alternate; }
|
||||||
|
.home-bg i:nth-child(3) { background: radial-gradient(circle, var(--folder) 0, transparent 68%); left: 10vmax; bottom: -34vmax; animation: hb3 52s ease-in-out infinite alternate; opacity: .24; }
|
||||||
|
.home-bg i:nth-child(4) { background: radial-gradient(circle, var(--orange) 0, transparent 70%); right: -10vmax; bottom: -30vmax; width: 44vmax; height: 44vmax; animation: hb4 64s ease-in-out infinite alternate; opacity: .18; }
|
||||||
|
/* feines Rauschen gegen Farbstufen im Verlauf */
|
||||||
|
.home-bg::after { content: ''; position: absolute; inset: 0; opacity: .05; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
|
||||||
|
[data-theme='light'] .home-bg i, [data-theme='win11'] .home-bg i, [data-theme='xp'] .home-bg i { mix-blend-mode: multiply; opacity: .22; }
|
||||||
|
[data-theme='macos'] .home-bg { display: none !important; }
|
||||||
|
@keyframes hb1 { 0% { transform: translate(0, 0) scale(1); } 50% { transform: translate(22vmax, 14vmax) scale(1.15); } 100% { transform: translate(8vmax, 26vmax) scale(.92); } }
|
||||||
|
@keyframes hb2 { 0% { transform: translate(0, 0) scale(1.05); } 50% { transform: translate(-26vmax, 18vmax) scale(.9); } 100% { transform: translate(-10vmax, 30vmax) scale(1.12); } }
|
||||||
|
@keyframes hb3 { 0% { transform: translate(0, 0) scale(.95); } 50% { transform: translate(24vmax, -16vmax) scale(1.1); } 100% { transform: translate(-12vmax, -24vmax) scale(1); } }
|
||||||
|
@keyframes hb4 { 0% { transform: translate(0, 0); } 50% { transform: translate(-30vmax, -12vmax); } 100% { transform: translate(-14vmax, -28vmax); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .home-bg i { animation: none !important; } }
|
||||||
|
/* Konfigurations-Generator */
|
||||||
|
.admin-wrap.gen-wrap { height: 100%; box-sizing: border-box; }
|
||||||
|
.gen { display: grid; grid-template-columns: minmax(340px, 1fr) minmax(360px, 1.1fr); gap: 14px; flex: 1; min-height: 0; height: 100%; }
|
||||||
|
.gen-left, .gen-right { display: flex; flex-direction: column; min-height: 0; background: var(--card); border-radius: 12px; overflow: hidden; }
|
||||||
|
.gen-top { display: flex; gap: 8px; padding: 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
.gen-top select { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 0 8px; height: 30px; max-width: 230px; }
|
||||||
|
.gen-top .search { margin: 0; height: 30px; flex: 1; min-width: 180px; }
|
||||||
|
.gen-opts { overflow: auto; padding: 4px 12px 16px; }
|
||||||
|
.gen-group h4 { margin: 14px 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--accent-2); }
|
||||||
|
.gen-row { display: grid; grid-template-columns: 1fr minmax(150px, 46%); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--row-line); }
|
||||||
|
.gen-row.bool { grid-template-columns: 1fr auto; }
|
||||||
|
.gen-row.wide { grid-template-columns: 1fr; }
|
||||||
|
.gen-row.off { opacity: .45; }
|
||||||
|
.gen-row label { display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
|
||||||
|
.gen-row label small { color: var(--faint); font-size: 11px; }
|
||||||
|
.gen-row .badge { font-size: 9.5px; font-weight: 600; padding: 1px 5px; border-radius: 5px; background: var(--icon-bg); color: var(--muted); margin-left: 6px; vertical-align: 1px; }
|
||||||
|
.gen-row .gc input:not([type=checkbox]), .gen-row .gc select, .gen-row .gc textarea { width: 100%; box-sizing: border-box; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; font: inherit; font-size: 12.5px; }
|
||||||
|
.gen-row .gc textarea { font-family: 'Cascadia Code', Consolas, monospace; resize: vertical; }
|
||||||
|
.gen-row .gc input:focus, .gen-row .gc select:focus, .gen-row .gc textarea:focus { border-color: var(--accent); outline: none; }
|
||||||
|
.gen-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
.gen-bar .seg small { opacity: .5; }
|
||||||
|
.gen-note { padding: 8px 12px; font-size: 12px; color: var(--orange); border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.gen-preview { flex: 1; margin: 0; padding: 12px 14px; overflow: auto; white-space: pre; font-size: 12px; line-height: 1.5; background: var(--panel); color: var(--text); user-select: text; }
|
||||||
|
@media (max-width: 900px) { .gen { grid-template-columns: 1fr; height: auto; } .gen-preview { max-height: 50vh; } }
|
||||||
|
.session.admin .views button { white-space: nowrap; }
|
||||||
+19
-1
@@ -29,6 +29,20 @@ TERM_THEMES.mocha = { name: 'Catppuccin Mocha', background: '#1e1e2e', foregroun
|
|||||||
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
|
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
|
||||||
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
|
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
|
||||||
|
|
||||||
|
// Terminal-Schemata zu den System-Designs
|
||||||
|
TERM_THEMES.xp = { name: 'Windows XP (cmd.exe)', background: '#000000', foreground: '#c0c0c0', cursor: '#c0c0c0', cursorAccent: '#000000', selectionBackground: '#c0c0c066',
|
||||||
|
black: '#000000', red: '#800000', green: '#008000', yellow: '#808000', blue: '#000080', magenta: '#800080', cyan: '#008080', white: '#c0c0c0',
|
||||||
|
brightBlack: '#808080', brightRed: '#ff0000', brightGreen: '#00ff00', brightYellow: '#ffff00', brightBlue: '#0000ff', brightMagenta: '#ff00ff', brightCyan: '#00ffff', brightWhite: '#ffffff' };
|
||||||
|
TERM_THEMES.campbell = { name: 'Windows 11 (Campbell)', background: '#0c0c0c', foreground: '#cccccc', cursor: '#ffffff', cursorAccent: '#0c0c0c', selectionBackground: '#ffffff40',
|
||||||
|
black: '#0c0c0c', red: '#c50f1f', green: '#13a10e', yellow: '#c19c00', blue: '#0037da', magenta: '#881798', cyan: '#3a96dd', white: '#cccccc',
|
||||||
|
brightBlack: '#767676', brightRed: '#e74856', brightGreen: '#16c60c', brightYellow: '#f9f1a5', brightBlue: '#3b78ff', brightMagenta: '#b4009e', brightCyan: '#61d6d6', brightWhite: '#f2f2f2' };
|
||||||
|
TERM_THEMES.macos = { name: 'macOS Terminal', background: '#ffffff', foreground: '#1d1d1f', cursor: '#7f7f7f', cursorAccent: '#ffffff', selectionBackground: '#b4d7ff',
|
||||||
|
black: '#000000', red: '#c23621', green: '#25bc24', yellow: '#adad27', blue: '#492ee1', magenta: '#d338d3', cyan: '#33bbc8', white: '#cbcccd',
|
||||||
|
brightBlack: '#818383', brightRed: '#fc391f', brightGreen: '#31e722', brightYellow: '#eaec23', brightBlue: '#5833ff', brightMagenta: '#f935f8', brightCyan: '#14f0f0', brightWhite: '#e9ebeb' };
|
||||||
|
TERM_THEMES.cyberpunk = { name: 'Cyberpunk 2077', background: '#0a0a0f', foreground: '#eafcff', cursor: '#fcee0a', cursorAccent: '#0a0a0f', selectionBackground: '#00f0ff44',
|
||||||
|
black: '#15151f', red: '#ff003c', green: '#00ff9f', yellow: '#fcee0a', blue: '#00b8ff', magenta: '#ff2bd6', cyan: '#00f0ff', white: '#d7e6ee',
|
||||||
|
brightBlack: '#5a7680', brightRed: '#ff4f70', brightGreen: '#5cffc0', brightYellow: '#fff45c', brightBlue: '#5cd3ff', brightMagenta: '#ff6be4', brightCyan: '#6bf7ff', brightWhite: '#ffffff' };
|
||||||
|
|
||||||
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
|
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
|
||||||
window.APP_THEMES = {
|
window.APP_THEMES = {
|
||||||
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
|
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
|
||||||
@@ -37,5 +51,9 @@ window.APP_THEMES = {
|
|||||||
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
|
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
|
||||||
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
|
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
|
||||||
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
|
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
|
||||||
light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' },
|
light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light', light: true },
|
||||||
|
xp: { name: 'Windows XP', bg: '#ece9d8', side: '#0a5fdc', card: '#ffffff', accent: '#316ac5', text: '#000000', term: 'xp', light: true },
|
||||||
|
win11: { name: 'Windows 11', bg: '#f3f3f3', side: '#ebebeb', card: '#ffffff', accent: '#0067c0', text: '#1b1b1b', term: 'campbell', light: true },
|
||||||
|
macos: { name: 'macOS', bg: '#ffffff', side: '#e8e8ed', card: '#f5f5f7', accent: '#007aff', text: '#1d1d1f', term: 'macos', light: true },
|
||||||
|
cyberpunk: { name: 'Cyberpunk 2077', bg: '#0a0a0f', side: '#07070b', card: '#15151f', accent: '#fcee0a', text: '#eafcff', term: 'cyberpunk' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
// Konfigurations-Generator für Webserver-Sites (nginx, Apache, Caddy): Optionsschema, Vorlagen und Erzeugung.
|
||||||
|
// Reine Funktionen ohne DOM – im Renderer global (WEBCONF), in Node per require (Tests).
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Optionsschema. type: bool | text | number | select | list | textarea
|
||||||
|
// only: Server, die die Option unterstützen; kw: Suchbegriffe (z. B. Direktiven); show: Sichtbarkeit abhängig von anderen Werten
|
||||||
|
const GROUPS = [
|
||||||
|
['basics', 'Basics'], ['proxy', 'Reverse proxy'], ['tls', 'HTTPS / TLS'], ['security', 'Security'],
|
||||||
|
['access', 'Access control'], ['performance', 'Performance & caching'], ['php', 'PHP'], ['redirect', 'Redirect'], ['logging', 'Logging'], ['custom', 'Custom directives'],
|
||||||
|
];
|
||||||
|
const isType = (...t) => (o) => t.includes(o.type);
|
||||||
|
const OPTIONS = [
|
||||||
|
{ id: 'type', group: 'basics', label: 'Site type', type: 'select', def: 'proxy', options: [['proxy', 'Reverse proxy'], ['static', 'Static website'], ['spa', 'Single-page app (index.html fallback)'], ['php', 'PHP application'], ['redirect', 'Redirect to another URL']], kw: 'mode kind' },
|
||||||
|
{ id: 'domains', group: 'basics', label: 'Domains', desc: 'Separated by spaces. The first one is the main name.', type: 'text', def: '', placeholder: 'example.com www.example.com', kw: 'server_name ServerName ServerAlias host' },
|
||||||
|
{ id: 'name', group: 'basics', label: 'Name of the configuration file', type: 'text', def: '', placeholder: 'optional – default: first domain', only: ['nginx', 'apache'] },
|
||||||
|
{ id: 'httpPort', group: 'basics', label: 'HTTP port', type: 'number', def: 80, kw: 'listen' },
|
||||||
|
{ id: 'root', group: 'basics', label: 'Document root', type: 'text', def: '', placeholder: '/var/www/example.com', show: isType('static', 'spa', 'php'), kw: 'root DocumentRoot' },
|
||||||
|
{ id: 'createRoot', group: 'basics', label: 'Create the folder with a placeholder page if it does not exist', type: 'bool', def: true, show: isType('static', 'spa', 'php') },
|
||||||
|
{ id: 'index', group: 'basics', label: 'Index files', type: 'text', def: 'index.html index.htm', show: isType('static', 'spa', 'php'), kw: 'index DirectoryIndex' },
|
||||||
|
|
||||||
|
{ id: 'upstreams', group: 'proxy', label: 'Target servers (upstream)', desc: 'One or more, separated by spaces. Several targets = load balancing.', type: 'text', def: 'http://127.0.0.1:3000', show: isType('proxy'), kw: 'proxy_pass ProxyPass reverse_proxy backend balancer' },
|
||||||
|
{ id: 'proxyPath', group: 'proxy', label: 'Path', desc: 'Only requests below this path are forwarded.', type: 'text', def: '/', show: isType('proxy'), kw: 'location' },
|
||||||
|
{ id: 'lb', group: 'proxy', label: 'Load balancing method', type: 'select', def: 'round_robin', options: [['round_robin', 'Round robin'], ['least_conn', 'Least connections'], ['ip_hash', 'Same client → same server (IP hash)']], show: (o) => o.type === 'proxy' && splitList(o.upstreams).length > 1, kw: 'least_conn ip_hash lbmethod lb_policy' },
|
||||||
|
{ id: 'websockets', group: 'proxy', label: 'WebSockets', desc: 'Forward Upgrade/Connection headers.', type: 'bool', def: true, show: isType('proxy'), kw: 'upgrade ws wss' },
|
||||||
|
{ id: 'preserveHost', group: 'proxy', label: 'Pass the original Host header', type: 'bool', def: true, show: isType('proxy'), kw: 'ProxyPreserveHost header_up Host' },
|
||||||
|
{ id: 'forwardHeaders', group: 'proxy', label: 'X-Forwarded-For / -Proto / X-Real-IP', type: 'bool', def: true, show: isType('proxy'), only: ['nginx', 'apache'], kw: 'X-Forwarded-For real ip' },
|
||||||
|
{ id: 'proxyTimeout', group: 'proxy', label: 'Timeout (seconds)', desc: 'Read timeout for slow backends. 0 = default.', type: 'number', def: 60, show: isType('proxy'), kw: 'proxy_read_timeout ProxyTimeout' },
|
||||||
|
{ id: 'proxyBuffering', group: 'proxy', label: 'Buffer responses', desc: 'Turn off for streaming (Server-Sent Events, long polling).', type: 'bool', def: true, show: isType('proxy'), kw: 'proxy_buffering flush_interval sse' },
|
||||||
|
{ id: 'upstreamInsecure', group: 'proxy', label: 'Do not verify the HTTPS certificate of the target', type: 'bool', def: false, show: (o) => o.type === 'proxy' && /https:/.test(o.upstreams), kw: 'proxy_ssl_verify SSLProxyVerify tls_insecure_skip_verify' },
|
||||||
|
|
||||||
|
{ id: 'tls', group: 'tls', label: 'Enable HTTPS', type: 'bool', def: false, kw: 'ssl https certificate 443' },
|
||||||
|
{ id: 'httpsPort', group: 'tls', label: 'HTTPS port', type: 'number', def: 443, show: (o) => o.tls, kw: 'listen 443' },
|
||||||
|
{ id: 'certSource', group: 'tls', label: 'Certificate', type: 'select', def: 'letsencrypt', options: [['letsencrypt', "Let's Encrypt (certbot, /etc/letsencrypt/live/<domain>)"], ['auto', 'Automatic (Caddy)'], ['custom', 'Own files']], show: (o) => o.tls, kw: 'ssl_certificate SSLCertificateFile' },
|
||||||
|
{ id: 'certFile', group: 'tls', label: 'Certificate file', type: 'text', def: '', placeholder: '/etc/ssl/mrterm/site.crt', show: (o) => o.tls && o.certSource === 'custom', kw: 'fullchain crt pem' },
|
||||||
|
{ id: 'keyFile', group: 'tls', label: 'Key file', type: 'text', def: '', placeholder: '/etc/ssl/mrterm/site.key', show: (o) => o.tls && o.certSource === 'custom', kw: 'privkey key' },
|
||||||
|
{ id: 'redirectHttp', group: 'tls', label: 'Redirect HTTP to HTTPS', type: 'bool', def: true, show: (o) => o.tls, kw: '301 force https' },
|
||||||
|
{ id: 'http2', group: 'tls', label: 'HTTP/2', type: 'bool', def: true, show: (o) => o.tls, only: ['nginx', 'apache'], kw: 'h2 Protocols' },
|
||||||
|
{ id: 'tlsModern', group: 'tls', label: 'Only TLS 1.2 and 1.3', type: 'bool', def: true, show: (o) => o.tls, only: ['nginx', 'apache'], kw: 'ssl_protocols SSLProtocol' },
|
||||||
|
{ id: 'hsts', group: 'tls', label: 'HSTS (seconds, 0 = off)', desc: 'Browsers then only use HTTPS for this domain. 31536000 = one year.', type: 'number', def: 0, show: (o) => o.tls, kw: 'Strict-Transport-Security' },
|
||||||
|
|
||||||
|
{ id: 'hideVersion', group: 'security', label: 'Hide server version', type: 'bool', def: true, kw: 'server_tokens ServerSignature Server header' },
|
||||||
|
{ id: 'xFrame', group: 'security', label: 'X-Frame-Options (clickjacking protection)', type: 'select', def: 'SAMEORIGIN', options: [['', 'off'], ['SAMEORIGIN', 'SAMEORIGIN'], ['DENY', 'DENY']], kw: 'iframe frame' },
|
||||||
|
{ id: 'noSniff', group: 'security', label: 'X-Content-Type-Options: nosniff', type: 'bool', def: true, kw: 'mime sniffing' },
|
||||||
|
{ id: 'referrer', group: 'security', label: 'Referrer-Policy', type: 'select', def: 'strict-origin-when-cross-origin', options: [['', 'off'], ['strict-origin-when-cross-origin', 'strict-origin-when-cross-origin'], ['no-referrer', 'no-referrer'], ['same-origin', 'same-origin']], kw: 'referer' },
|
||||||
|
{ id: 'csp', group: 'security', label: 'Content-Security-Policy', type: 'text', def: '', placeholder: "default-src 'self'", kw: 'csp script-src' },
|
||||||
|
{ id: 'permissions', group: 'security', label: 'Permissions-Policy', type: 'text', def: '', placeholder: 'camera=(), microphone=(), geolocation=()', kw: 'feature policy' },
|
||||||
|
{ id: 'denyHidden', group: 'security', label: 'Block hidden files (.git, .env, .htaccess)', type: 'bool', def: true, show: isType('static', 'spa', 'php'), kw: 'dotfiles deny' },
|
||||||
|
|
||||||
|
{ id: 'allowIps', group: 'access', label: 'Allowed IPs / networks', desc: 'Empty = everyone. Others get 403.', type: 'text', def: '', placeholder: '192.168.0.0/24 10.0.0.5', kw: 'allow deny Require ip remote_ip whitelist' },
|
||||||
|
{ id: 'basicAuth', group: 'access', label: 'Password protection (basic auth)', type: 'bool', def: false, only: ['nginx', 'apache'], kw: 'auth_basic AuthType htpasswd login' },
|
||||||
|
{ id: 'authFile', group: 'access', label: 'Password file (htpasswd)', desc: 'Create it with: htpasswd -c /etc/nginx/.htpasswd user', type: 'text', def: '/etc/nginx/.htpasswd', show: (o) => o.basicAuth, only: ['nginx', 'apache'], kw: 'auth_basic_user_file AuthUserFile' },
|
||||||
|
{ id: 'authRealm', group: 'access', label: 'Realm (text in the login dialog)', type: 'text', def: 'Restricted', show: (o) => o.basicAuth, only: ['nginx', 'apache'], kw: 'AuthName' },
|
||||||
|
{ id: 'rateLimit', group: 'access', label: 'Rate limit (requests per second per IP, 0 = off)', type: 'number', def: 0, only: ['nginx'], kw: 'limit_req throttle ddos' },
|
||||||
|
{ id: 'rateBurst', group: 'access', label: 'Burst', type: 'number', def: 20, show: (o) => Number(o.rateLimit) > 0, only: ['nginx'], kw: 'limit_req burst' },
|
||||||
|
|
||||||
|
{ id: 'gzip', group: 'performance', label: 'Compression (gzip)', type: 'bool', def: true, kw: 'deflate encode zstd brotli' },
|
||||||
|
{ id: 'cacheDays', group: 'performance', label: 'Browser cache for assets (days, 0 = off)', desc: 'CSS, JS, images and fonts.', type: 'number', def: 30, show: isType('static', 'spa', 'php'), kw: 'expires Cache-Control max-age' },
|
||||||
|
{ id: 'maxBody', group: 'performance', label: 'Max. upload size (MB, 0 = default)', type: 'number', def: 0, kw: 'client_max_body_size LimitRequestBody request_body max_size upload' },
|
||||||
|
|
||||||
|
{ id: 'phpSocket', group: 'php', label: 'PHP-FPM socket', type: 'text', def: '/run/php/php-fpm.sock', show: isType('php'), kw: 'fastcgi_pass php_fastcgi SetHandler fpm' },
|
||||||
|
|
||||||
|
{ id: 'redirectTo', group: 'redirect', label: 'Target URL', type: 'text', def: 'https://example.com', show: isType('redirect'), kw: 'return redir Redirect' },
|
||||||
|
{ id: 'redirectCode', group: 'redirect', label: 'Status code', type: 'select', def: '301', options: [['301', '301 (permanent)'], ['302', '302 (temporary)'], ['307', '307'], ['308', '308']], show: isType('redirect') },
|
||||||
|
{ id: 'redirectPath', group: 'redirect', label: 'Keep the path', desc: 'example.org/a → target/a', type: 'bool', def: true, show: isType('redirect'), kw: 'request_uri' },
|
||||||
|
|
||||||
|
{ id: 'accessLog', group: 'logging', label: 'Access log', desc: 'Empty = default of the server, "off" = no access log.', type: 'text', def: '', placeholder: '/var/log/nginx/example.access.log', kw: 'access_log CustomLog log' },
|
||||||
|
{ id: 'errorLog', group: 'logging', label: 'Error log', type: 'text', def: '', placeholder: '/var/log/nginx/example.error.log', only: ['nginx', 'apache'], kw: 'error_log ErrorLog' },
|
||||||
|
|
||||||
|
{ id: 'extra', group: 'custom', label: 'Additional directives', desc: 'Inserted as-is into the server block.', type: 'textarea', def: '', kw: 'raw custom snippet' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
['proxy', 'Reverse proxy (Node, Docker, …)', { type: 'proxy', upstreams: 'http://127.0.0.1:3000', websockets: true }],
|
||||||
|
['lb', 'Load balancer', { type: 'proxy', upstreams: 'http://10.0.0.11:8080 http://10.0.0.12:8080', lb: 'least_conn' }],
|
||||||
|
['static', 'Static website', { type: 'static', cacheDays: 30 }],
|
||||||
|
['spa', 'Single-page app (React, Vue, …)', { type: 'spa', cacheDays: 365 }],
|
||||||
|
['php', 'PHP app (WordPress, Laravel, …)', { type: 'php', index: 'index.php index.html', maxBody: 64 }],
|
||||||
|
['redirect', 'Redirect / domain move', { type: 'redirect' }],
|
||||||
|
['secure', 'Hardened HTTPS site', { tls: true, redirectHttp: true, hsts: 31536000, xFrame: 'DENY', csp: "default-src 'self'", permissions: 'camera=(), microphone=(), geolocation=()' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
function defaults() { return Object.fromEntries(OPTIONS.map((o) => [o.id, o.def])); }
|
||||||
|
function splitList(v) { return String(v || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean); }
|
||||||
|
const visible = (opt, o, server) => (!opt.only || opt.only.includes(server)) && (!opt.show || opt.show(o));
|
||||||
|
const num = (v) => Math.max(0, Number(v) || 0);
|
||||||
|
const indent = (text, n) => text.split('\n').map((l) => (l ? ' '.repeat(n) + l : l)).join('\n');
|
||||||
|
const hostPort = (u) => u.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||||
|
|
||||||
|
function certPaths(o, domain) {
|
||||||
|
if (o.certSource === 'custom') return { cert: o.certFile || '/etc/ssl/certs/site.crt', key: o.keyFile || '/etc/ssl/private/site.key' };
|
||||||
|
return { cert: `/etc/letsencrypt/live/${domain}/fullchain.pem`, key: `/etc/letsencrypt/live/${domain}/privkey.pem` };
|
||||||
|
}
|
||||||
|
const headerList = (o) => [
|
||||||
|
o.xFrame && ['X-Frame-Options', o.xFrame],
|
||||||
|
o.noSniff && ['X-Content-Type-Options', 'nosniff'],
|
||||||
|
o.referrer && ['Referrer-Policy', o.referrer],
|
||||||
|
o.csp && ['Content-Security-Policy', o.csp],
|
||||||
|
o.permissions && ['Permissions-Policy', o.permissions],
|
||||||
|
o.tls && num(o.hsts) && ['Strict-Transport-Security', `max-age=${num(o.hsts)}; includeSubDomains`],
|
||||||
|
].filter(Boolean);
|
||||||
|
const ASSET_EXT = 'css|js|mjs|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot';
|
||||||
|
const cmpVersion = (a, b) => { const x = String(a).split('.').map(Number), y = String(b).split('.').map(Number); for (let i = 0; i < 3; i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0); } return 0; };
|
||||||
|
const zoneName = (o) => `mrterm_${(splitList(o.domains)[0] || 'site').replace(/[^a-z0-9]/gi, '_')}`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- nginx
|
||||||
|
function nginx(o, env = {}) {
|
||||||
|
const domains = splitList(o.domains);
|
||||||
|
const main = domains[0] || 'example.com';
|
||||||
|
const ups = splitList(o.upstreams);
|
||||||
|
const upName = `${zoneName(o).replace(/^mrterm_/, '')}_backend`;
|
||||||
|
const out = ['# Generated by MrTerm'];
|
||||||
|
if (o.type === 'proxy' && ups.length > 1) {
|
||||||
|
out.push(`upstream ${upName} {`, ...(o.lb === 'least_conn' ? [' least_conn;'] : o.lb === 'ip_hash' ? [' ip_hash;'] : []), ...ups.map((u) => ` server ${hostPort(u)};`), '}', '');
|
||||||
|
}
|
||||||
|
if (num(o.rateLimit)) out.push(`limit_req_zone $binary_remote_addr zone=${zoneName(o)}:10m rate=${num(o.rateLimit)}r/s;`, '');
|
||||||
|
|
||||||
|
const body = [];
|
||||||
|
if (o.type !== 'redirect' && o.type !== 'proxy') body.push(`root ${o.root || `/var/www/${main}`};`, `index ${o.index || 'index.html'};`);
|
||||||
|
if (o.hideVersion) body.push('server_tokens off;');
|
||||||
|
if (num(o.maxBody)) body.push(`client_max_body_size ${num(o.maxBody)}m;`);
|
||||||
|
if (o.accessLog) body.push(o.accessLog === 'off' ? 'access_log off;' : `access_log ${o.accessLog};`);
|
||||||
|
if (o.errorLog) body.push(`error_log ${o.errorLog};`);
|
||||||
|
const hdr = headerList(o);
|
||||||
|
if (hdr.length) body.push('', ...hdr.map(([k, v]) => `add_header ${k} "${v.replace(/"/g, '\\"')}" always;`));
|
||||||
|
if (o.gzip) body.push('', 'gzip on;', 'gzip_vary on;', 'gzip_proxied any;', 'gzip_comp_level 5;', 'gzip_types text/plain text/css text/xml application/json application/javascript application/xml image/svg+xml;');
|
||||||
|
const ips = splitList(o.allowIps);
|
||||||
|
if (ips.length) body.push('', ...ips.map((ip) => `allow ${ip};`), 'deny all;');
|
||||||
|
if (o.basicAuth) body.push('', `auth_basic "${o.authRealm || 'Restricted'}";`, `auth_basic_user_file ${o.authFile || '/etc/nginx/.htpasswd'};`);
|
||||||
|
const limit = num(o.rateLimit) ? [`limit_req zone=${zoneName(o)} burst=${num(o.rateBurst) || 20} nodelay;`] : [];
|
||||||
|
|
||||||
|
if (o.type === 'redirect') {
|
||||||
|
body.push('', `return ${o.redirectCode || 301} ${(o.redirectTo || 'https://example.com').replace(/\/$/, '')}${o.redirectPath ? '$request_uri' : ''};`);
|
||||||
|
} else if (o.type === 'proxy') {
|
||||||
|
const target = ups.length > 1 ? `${/^https:/.test(ups[0]) ? 'https' : 'http'}://${upName}` : (ups[0] || 'http://127.0.0.1:3000');
|
||||||
|
const loc = [...limit, `proxy_pass ${target};`];
|
||||||
|
if (o.websockets || o.preserveHost || o.forwardHeaders) loc.push('proxy_http_version 1.1;');
|
||||||
|
if (o.preserveHost) loc.push('proxy_set_header Host $host;');
|
||||||
|
if (o.forwardHeaders) loc.push('proxy_set_header X-Real-IP $remote_addr;', 'proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;', 'proxy_set_header X-Forwarded-Proto $scheme;', 'proxy_set_header X-Forwarded-Host $host;');
|
||||||
|
if (o.websockets) loc.push('proxy_set_header Upgrade $http_upgrade;', `proxy_set_header Connection $${zoneName(o)}_upgrade;`);
|
||||||
|
if (num(o.proxyTimeout) && num(o.proxyTimeout) !== 60) loc.push(`proxy_connect_timeout ${Math.min(num(o.proxyTimeout), 75)}s;`, `proxy_send_timeout ${num(o.proxyTimeout)}s;`, `proxy_read_timeout ${num(o.proxyTimeout)}s;`);
|
||||||
|
if (!o.proxyBuffering) loc.push('proxy_buffering off;', 'proxy_cache off;');
|
||||||
|
if (o.upstreamInsecure) loc.push('proxy_ssl_verify off;');
|
||||||
|
if (/^https:/.test(target)) loc.push('proxy_ssl_server_name on;');
|
||||||
|
body.push('', `location ${o.proxyPath || '/'} {`, indent(loc.join('\n'), 4), '}');
|
||||||
|
if (o.websockets) out.push('# WebSocket: Connection header matching the Upgrade header', `map $http_upgrade $${zoneName(o)}_upgrade {`, ' default upgrade;', " '' close;", '}', '');
|
||||||
|
} else {
|
||||||
|
const fallback = o.type === 'spa' ? '/index.html' : o.type === 'php' ? '/index.php?$query_string' : '=404';
|
||||||
|
body.push('', 'location / {', indent([...limit, `try_files $uri $uri/ ${fallback};`].join('\n'), 4), '}');
|
||||||
|
if (o.type === 'php') body.push('', 'location ~ \\.php$ {', ' try_files $uri =404;', ' include fastcgi_params;', ' fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;', ` fastcgi_pass unix:${o.phpSocket || '/run/php/php-fpm.sock'};`, '}');
|
||||||
|
if (num(o.cacheDays)) body.push('', `location ~* \\.(${ASSET_EXT})$ {`, ` expires ${num(o.cacheDays)}d;`, ' access_log off;', ' try_files $uri =404;', '}');
|
||||||
|
if (o.denyHidden) body.push('', 'location ~ /\\.(?!well-known) {', ' deny all;', '}');
|
||||||
|
}
|
||||||
|
if (o.extra?.trim()) body.push('', '# Custom', o.extra.trim());
|
||||||
|
|
||||||
|
const listen = (port, extra = '') => [`listen ${port}${extra};`, `listen [::]:${port}${extra};`];
|
||||||
|
if (o.tls) {
|
||||||
|
const c = certPaths(o, main);
|
||||||
|
if (o.redirectHttp) out.push('server {', indent([...listen(num(o.httpPort) || 80), `server_name ${domains.join(' ') || main};`, 'return 301 https://$host$request_uri;'].join('\n'), 4), '}', '');
|
||||||
|
const tls = [`ssl_certificate ${c.cert};`, `ssl_certificate_key ${c.key};`];
|
||||||
|
if (o.tlsModern) tls.push('ssl_protocols TLSv1.2 TLSv1.3;', 'ssl_prefer_server_ciphers off;');
|
||||||
|
tls.push('ssl_session_cache shared:SSL:10m;', 'ssl_session_timeout 1d;');
|
||||||
|
// `http2 on;` gibt es erst ab nginx 1.25.1, davor gehört http2 an listen
|
||||||
|
const newH2 = !env.nginxVersion || cmpVersion(env.nginxVersion, '1.25.1') >= 0;
|
||||||
|
out.push('server {', indent([...listen(num(o.httpsPort) || 443, o.http2 && !newH2 ? ' ssl http2' : ' ssl'), ...(o.http2 && newH2 ? ['http2 on;'] : []), `server_name ${domains.join(' ') || main};`, '', ...tls, '', ...body].join('\n'), 4), '}');
|
||||||
|
if (!o.redirectHttp) out.push('', 'server {', indent([...listen(num(o.httpPort) || 80), `server_name ${domains.join(' ') || main};`, '', ...body].join('\n'), 4), '}');
|
||||||
|
} else {
|
||||||
|
out.push('server {', indent([...listen(num(o.httpPort) || 80), `server_name ${domains.join(' ') || main};`, '', ...body].join('\n'), 4), '}');
|
||||||
|
}
|
||||||
|
return { text: `${out.join('\n')}\n`, modules: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Apache
|
||||||
|
function apache(o) {
|
||||||
|
const domains = splitList(o.domains);
|
||||||
|
const main = domains[0] || 'example.com';
|
||||||
|
const ups = splitList(o.upstreams);
|
||||||
|
const mods = new Set();
|
||||||
|
const balancer = `balancer://${zoneName(o).replace(/^mrterm_/, '')}`;
|
||||||
|
const pre = [];
|
||||||
|
const body = [];
|
||||||
|
if (o.type !== 'proxy' && o.type !== 'redirect') {
|
||||||
|
const root0 = o.root || `/var/www/${main}`;
|
||||||
|
const dir = ['Options -Indexes +FollowSymLinks', 'AllowOverride All'];
|
||||||
|
const ips = splitList(o.allowIps);
|
||||||
|
dir.push(ips.length ? `Require ip ${ips.join(' ')}` : 'Require all granted');
|
||||||
|
if (o.type === 'spa') dir.push('FallbackResource /index.html');
|
||||||
|
body.push(`DocumentRoot ${root0}`, `DirectoryIndex ${o.index || 'index.html'}`, `<Directory ${root0}>`, indent(dir.join('\n'), 4), '</Directory>');
|
||||||
|
} else if (splitList(o.allowIps).length) {
|
||||||
|
body.push('<Location />', ` Require ip ${splitList(o.allowIps).join(' ')}`, '</Location>');
|
||||||
|
}
|
||||||
|
if (o.hideVersion) body.push('ServerSignature Off');
|
||||||
|
if (num(o.maxBody)) body.push(`LimitRequestBody ${num(o.maxBody) * 1024 * 1024}`);
|
||||||
|
if (o.errorLog) body.push(`ErrorLog ${o.errorLog}`);
|
||||||
|
if (o.accessLog && o.accessLog !== 'off') body.push(`CustomLog ${o.accessLog} combined`);
|
||||||
|
const hdr = headerList(o);
|
||||||
|
if (hdr.length) { mods.add('headers'); body.push('', ...hdr.map(([k, v]) => `Header always set ${k} "${v.replace(/"/g, '\\"')}"`)); }
|
||||||
|
if (o.gzip) { mods.add('deflate'); body.push('', 'AddOutputFilterByType DEFLATE text/plain text/html text/css text/xml application/json application/javascript application/xml image/svg+xml'); }
|
||||||
|
if (o.basicAuth) body.push('', '<Location />', ' AuthType Basic', ` AuthName "${o.authRealm || 'Restricted'}"`, ` AuthUserFile ${o.authFile || '/etc/apache2/.htpasswd'}`, ' Require valid-user', '</Location>');
|
||||||
|
|
||||||
|
if (o.type === 'redirect') {
|
||||||
|
mods.add('alias');
|
||||||
|
body.push('', o.redirectPath ? `Redirect ${o.redirectCode || 301} / ${(o.redirectTo || 'https://example.com').replace(/\/?$/, '/')}` : `RedirectMatch ${o.redirectCode || 301} ^ ${o.redirectTo || 'https://example.com'}`);
|
||||||
|
} else if (o.type === 'proxy') {
|
||||||
|
mods.add('proxy'); mods.add('proxy_http');
|
||||||
|
const p = (o.proxyPath || '/').replace(/\/?$/, '/');
|
||||||
|
// Worker-Parameter: bei balancer:// gehören sie an die einzelnen BalancerMember, nicht an ProxyPass
|
||||||
|
const flags = [o.websockets ? 'upgrade=websocket' : '', o.proxyBuffering ? '' : 'flushpackets=on'].filter(Boolean).join(' ');
|
||||||
|
let target;
|
||||||
|
if (ups.length > 1) {
|
||||||
|
mods.add('proxy_balancer'); mods.add('slotmem_shm'); mods.add(o.lb === 'least_conn' ? 'lbmethod_bybusyness' : 'lbmethod_byrequests');
|
||||||
|
pre.push(`<Proxy ${balancer}>`, ...ups.map((u) => ` BalancerMember ${u.replace(/\/$/, '')}${flags ? ` ${flags}` : ''}`), ` ProxySet lbmethod=${o.lb === 'least_conn' ? 'bybusyness' : 'byrequests'}`, '</Proxy>');
|
||||||
|
target = `${balancer}/`;
|
||||||
|
} else target = (ups[0] || 'http://127.0.0.1:3000').replace(/\/?$/, '/');
|
||||||
|
if (o.preserveHost) body.push('', 'ProxyPreserveHost On');
|
||||||
|
if (o.forwardHeaders) { mods.add('headers'); body.push('RequestHeader set X-Forwarded-Proto "expr=%{REQUEST_SCHEME}"'); }
|
||||||
|
if (num(o.proxyTimeout) && num(o.proxyTimeout) !== 60) body.push(`ProxyTimeout ${num(o.proxyTimeout)}`);
|
||||||
|
if (/^https:/.test(target) || ups.some((u) => /^https:/.test(u))) { mods.add('ssl'); body.push('SSLProxyEngine On'); if (o.upstreamInsecure) body.push('SSLProxyVerify none', 'SSLProxyCheckPeerName off', 'SSLProxyCheckPeerExpire off'); }
|
||||||
|
if (pre.length) body.push(...pre, '');
|
||||||
|
body.push(`ProxyPass ${p} ${target}${flags && ups.length <= 1 ? ` ${flags}` : ''}`, `ProxyPassReverse ${p} ${target}`);
|
||||||
|
} else {
|
||||||
|
if (o.type === 'php') { mods.add('proxy'); mods.add('proxy_fcgi'); body.push('', '<FilesMatch "\\.php$">', ` SetHandler "proxy:unix:${o.phpSocket || '/run/php/php-fpm.sock'}|fcgi://localhost"`, '</FilesMatch>'); }
|
||||||
|
if (num(o.cacheDays)) { mods.add('expires'); body.push('', '<IfModule mod_expires.c>', ' ExpiresActive On', ...['text/css', 'application/javascript', 'image/png', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp', 'font/woff2'].map((t) => ` ExpiresByType ${t} "access plus ${num(o.cacheDays)} days"`), '</IfModule>'); }
|
||||||
|
if (o.denyHidden) body.push('', '<DirectoryMatch "/\\.(?!well-known)">', ' Require all denied', '</DirectoryMatch>');
|
||||||
|
}
|
||||||
|
if (o.extra?.trim()) body.push('', '# Custom', o.extra.trim());
|
||||||
|
|
||||||
|
const names = [`ServerName ${main}`, ...(domains.length > 1 ? [`ServerAlias ${domains.slice(1).join(' ')}`] : [])];
|
||||||
|
const out = ['# Generated by MrTerm'];
|
||||||
|
const vhost = (port, lines) => [`<VirtualHost *:${port}>`, indent([...names, '', ...lines].join('\n'), 4), '</VirtualHost>'];
|
||||||
|
if (o.tls) {
|
||||||
|
mods.add('ssl');
|
||||||
|
const c = certPaths(o, main);
|
||||||
|
const tls = ['SSLEngine on', `SSLCertificateFile ${c.cert}`, `SSLCertificateKeyFile ${c.key}`];
|
||||||
|
if (o.tlsModern) tls.push('SSLProtocol -all +TLSv1.2 +TLSv1.3');
|
||||||
|
if (o.http2) { mods.add('http2'); tls.push('Protocols h2 http/1.1'); }
|
||||||
|
out.push(...vhost(num(o.httpPort) || 80, o.redirectHttp ? [`Redirect permanent / https://${main}/`] : body), '');
|
||||||
|
out.push(...vhost(num(o.httpsPort) || 443, [...tls, '', ...body]));
|
||||||
|
} else out.push(...vhost(num(o.httpPort) || 80, body));
|
||||||
|
return { text: `${out.join('\n')}\n`, modules: [...mods] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Caddy
|
||||||
|
function caddy(o) {
|
||||||
|
const domains = splitList(o.domains);
|
||||||
|
const port = num(o.httpPort) || 80;
|
||||||
|
const sport = num(o.httpsPort) || 443;
|
||||||
|
// Ohne HTTPS: Adressen mit http:// (sonst holt Caddy für Domains automatisch Zertifikate)
|
||||||
|
const addrs = (domains.length ? domains : [`:${port}`]).map((d) => {
|
||||||
|
if (/^(https?:\/\/|:)/.test(d) || /:\d+$/.test(d)) return d;
|
||||||
|
if (o.tls) return sport === 443 ? d : `${d}:${sport}`;
|
||||||
|
return `http://${d}${port !== 80 ? `:${port}` : ''}`;
|
||||||
|
});
|
||||||
|
const b = [];
|
||||||
|
// Caddy verwaltet Zertifikate selbst; eigene Dateien nur auf ausdrücklichen Wunsch
|
||||||
|
if (o.tls && o.certSource === 'custom') { const c = certPaths(o, domains[0] || 'example.com'); b.push(`tls ${c.cert} ${c.key}`); }
|
||||||
|
if (o.gzip) b.push('encode zstd gzip');
|
||||||
|
const hdr = headerList(o);
|
||||||
|
if (hdr.length || o.hideVersion) b.push('header {', ...hdr.map(([k, v]) => ` ${k} "${v.replace(/"/g, '\\"')}"`), ...(o.hideVersion ? [' -Server'] : []), '}');
|
||||||
|
if (num(o.maxBody)) b.push('request_body {', ` max_size ${num(o.maxBody)}MB`, '}');
|
||||||
|
const ips = splitList(o.allowIps);
|
||||||
|
if (ips.length) b.push(`@blocked not remote_ip ${ips.join(' ')}`, 'respond @blocked 403');
|
||||||
|
if (o.accessLog && o.accessLog !== 'off') b.push('log {', ` output file ${o.accessLog}`, '}');
|
||||||
|
if (o.type === 'redirect') {
|
||||||
|
b.push(`redir ${(o.redirectTo || 'https://example.com').replace(/\/$/, '')}${o.redirectPath ? '{uri}' : ''} ${o.redirectCode || 301}`);
|
||||||
|
} else if (o.type === 'proxy') {
|
||||||
|
const ups = splitList(o.upstreams);
|
||||||
|
const p = o.proxyPath && o.proxyPath !== '/' ? `${o.proxyPath.replace(/\/?$/, '/')}* ` : '';
|
||||||
|
const sub = [];
|
||||||
|
if (ups.length > 1) sub.push(`lb_policy ${o.lb === 'least_conn' ? 'least_conn' : o.lb === 'ip_hash' ? 'ip_hash' : 'round_robin'}`);
|
||||||
|
if (!o.preserveHost) sub.push('header_up Host {upstream_hostport}');
|
||||||
|
if (!o.proxyBuffering) sub.push('flush_interval -1');
|
||||||
|
const https = ups.some((u) => /^https:/.test(u));
|
||||||
|
if ((num(o.proxyTimeout) && num(o.proxyTimeout) !== 60) || (https && o.upstreamInsecure)) {
|
||||||
|
sub.push('transport http {', ...(num(o.proxyTimeout) && num(o.proxyTimeout) !== 60 ? [` read_timeout ${num(o.proxyTimeout)}s`] : []), ...(https && o.upstreamInsecure ? [' tls_insecure_skip_verify'] : []), '}');
|
||||||
|
}
|
||||||
|
const target = (ups.length ? ups : ['127.0.0.1:3000']).map((u) => u.replace(/^http:\/\//, '')).join(' ');
|
||||||
|
b.push(sub.length ? `reverse_proxy ${p}${target} {\n${indent(sub.join('\n'), 4)}\n}` : `reverse_proxy ${p}${target}`);
|
||||||
|
} else {
|
||||||
|
b.push(`root * ${o.root || `/var/www/${domains[0] || 'site'}`}`);
|
||||||
|
if (o.denyHidden) b.push('@hidden path */.*', 'respond @hidden 404');
|
||||||
|
if (num(o.cacheDays)) b.push(`@assets path_regexp \\.(${ASSET_EXT})$`, `header @assets Cache-Control "public, max-age=${num(o.cacheDays) * 86400}"`);
|
||||||
|
if (o.type === 'php') b.push(`php_fastcgi unix/${o.phpSocket || '/run/php/php-fpm.sock'}`);
|
||||||
|
if (o.type === 'spa') b.push('try_files {path} /index.html');
|
||||||
|
b.push('file_server');
|
||||||
|
}
|
||||||
|
if (o.extra?.trim()) b.push('# Custom', o.extra.trim());
|
||||||
|
return { text: `# Generated by MrTerm\n${addrs.join(', ')} {\n${indent(b.join('\n'), 4)}\n}\n`, modules: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// env: Eigenschaften des Zielhosts, z. B. { nginxVersion: '1.22.1' }
|
||||||
|
const generate = (server, o, env = {}) => ({ nginx, apache, caddy }[server] || nginx)({ ...defaults(), ...o }, env);
|
||||||
|
const api = { GROUPS, OPTIONS, PRESETS, defaults, visible, splitList, generate };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.WEBCONF = api;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; min-height: 100%; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: var(--font); display: grid; place-items: center; min-height: 100vh; padding: 16px; }
|
||||||
|
.box { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 32px 28px 22px; text-align: center; box-shadow: 0 20px 60px #0006; }
|
||||||
|
.logo { width: 72px; height: 72px; border-radius: 18px; }
|
||||||
|
h1 { margin: 10px 0 4px; font-size: 26px; }
|
||||||
|
h1 span { color: var(--green); }
|
||||||
|
p { color: var(--muted); margin: 0 0 22px; font-size: 14px; }
|
||||||
|
form { display: flex; flex-direction: column; gap: 14px; text-align: left; }
|
||||||
|
label span { display: block; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
input { width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; font: inherit; outline: none; }
|
||||||
|
input:focus { border-color: var(--accent); }
|
||||||
|
button { margin-top: 6px; background: var(--accent); color: var(--on-accent); border: 0; border-radius: 10px; padding: 12px; font: 600 15px var(--font); cursor: pointer; }
|
||||||
|
button:disabled { opacity: .6; }
|
||||||
|
.err { color: var(--red); font-size: 13px; }
|
||||||
|
.ver { margin-top: 18px; color: var(--faint); font-size: 11px; }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>MrTerm</title>
|
||||||
|
<link rel="icon" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/app/src/renderer/app-themes.css" />
|
||||||
|
<link rel="stylesheet" href="/web/login.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="box">
|
||||||
|
<img src="/favicon.png" alt="" class="logo" />
|
||||||
|
<h1>Mr<span>Term</span></h1>
|
||||||
|
<p id="intro"></p>
|
||||||
|
<form id="form" autocomplete="on">
|
||||||
|
<label><span data-t="Username">Username</span><input name="name" autocomplete="username" required autofocus /></label>
|
||||||
|
<label><span data-t="Password">Password</span><input name="password" type="password" autocomplete="current-password" required /></label>
|
||||||
|
<label id="repeatRow" hidden><span data-t="Repeat password">Repeat password</span><input name="repeat" type="password" autocomplete="new-password" /></label>
|
||||||
|
<div id="err" class="err" hidden></div>
|
||||||
|
<button id="submit" type="submit"></button>
|
||||||
|
</form>
|
||||||
|
<div class="ver" id="ver"></div>
|
||||||
|
</main>
|
||||||
|
<script src="/app/src/i18n.js"></script>
|
||||||
|
<script src="/web/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Anmeldung bzw. Ersteinrichtung (Admin-Konto) der Web-Version
|
||||||
|
(async () => {
|
||||||
|
'use strict';
|
||||||
|
I18N.setLanguage('auto', navigator.language);
|
||||||
|
const T = I18N.t;
|
||||||
|
const $ = (s) => document.querySelector(s);
|
||||||
|
document.querySelectorAll('[data-t]').forEach((e) => { e.textContent = T(e.dataset.t); });
|
||||||
|
|
||||||
|
const st = await fetch('/api/state').then((r) => r.json());
|
||||||
|
if (st.user) { location.href = '/app/src/renderer/index.html'; return; }
|
||||||
|
const setup = st.setup;
|
||||||
|
$('#ver').textContent = `MrTerm Web ${st.version}`;
|
||||||
|
$('#intro').textContent = setup ? T('Welcome! Create the administrator account for this MrTerm server.') : T('Sign in to your MrTerm server.');
|
||||||
|
$('#submit').textContent = setup ? T('Create account') : T('Sign in');
|
||||||
|
$('#repeatRow').hidden = !setup;
|
||||||
|
if (setup) $('[name=password]').autocomplete = 'new-password';
|
||||||
|
|
||||||
|
$('#form').onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = Object.fromEntries(new FormData(e.target));
|
||||||
|
const err = $('#err');
|
||||||
|
err.hidden = true;
|
||||||
|
if (setup && f.password !== f.repeat) { err.textContent = T('The passwords do not match.'); err.hidden = false; return; }
|
||||||
|
$('#submit').disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(setup ? '/api/setup' : '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: JSON.stringify({ name: f.name, password: f.password }) });
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||||
|
location.href = '/app/src/renderer/index.html';
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = T(ex.message);
|
||||||
|
err.hidden = false;
|
||||||
|
$('#submit').disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// window.api für die Web-Version: gleiche Schnittstelle wie src/preload.js (Electron),
|
||||||
|
// aber über einen WebSocket zum MrTerm-Web-Server. Dateidialoge, Zwischenablage und Links laufen im Browser.
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const listeners = new Map();
|
||||||
|
const waits = new Map();
|
||||||
|
let ws, seq = 0, queue = [], retry = 0, banner;
|
||||||
|
const emit = (ch, args) => listeners.get(ch)?.forEach((fn) => { try { fn(...args); } catch (e) { console.error(e); } });
|
||||||
|
|
||||||
|
function showBanner(text) {
|
||||||
|
if (!document.body) return;
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.style.cssText = 'position:fixed;left:50%;top:10px;transform:translateX(-50%);z-index:9999;background:var(--card,#222);color:var(--text,#eee);border:1px solid var(--border,#444);border-radius:10px;padding:8px 14px;font:13px system-ui;box-shadow:0 6px 24px #0006';
|
||||||
|
document.body.append(banner);
|
||||||
|
}
|
||||||
|
banner.textContent = text;
|
||||||
|
banner.hidden = !text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
|
||||||
|
ws.onopen = () => { retry = 0; showBanner(''); for (const m of queue) ws.send(m); queue = []; };
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const m = JSON.parse(e.data);
|
||||||
|
if (m.t === 'reply') {
|
||||||
|
const w = waits.get(m.id);
|
||||||
|
if (!w) return;
|
||||||
|
waits.delete(m.id);
|
||||||
|
m.ok ? w.resolve(m.v) : w.reject(new Error(m.v));
|
||||||
|
} else if (m.t === 'evt') emit(m.ch, m.args || []);
|
||||||
|
};
|
||||||
|
ws.onclose = (e) => {
|
||||||
|
for (const w of waits.values()) w.reject(new Error('Connection to the MrTerm server lost.'));
|
||||||
|
waits.clear();
|
||||||
|
if (e.code === 4001) { location.href = '/'; return; }
|
||||||
|
// Sitzung abgelaufen? Dann zur Anmeldung, sonst neu verbinden
|
||||||
|
fetch('/api/me', { headers: { 'X-MrTerm': '1' } }).then((r) => {
|
||||||
|
if (r.status === 401) { location.href = '/'; return; }
|
||||||
|
showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …');
|
||||||
|
setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000));
|
||||||
|
}).catch(() => { showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …'); setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)); });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const post = (m) => { const s = JSON.stringify(m); if (ws.readyState === 1) ws.send(s); else queue.push(s); };
|
||||||
|
const remote = (ch, args) => new Promise((resolve, reject) => { const id = ++seq; waits.set(id, { resolve, reject }); post({ t: 'call', id, ch, args }); });
|
||||||
|
|
||||||
|
// Datei im Browser auswählen und als Text lesen
|
||||||
|
function pickFile(accept) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'file';
|
||||||
|
if (accept) inp.accept = accept;
|
||||||
|
inp.style.display = 'none';
|
||||||
|
inp.onchange = async () => { const f = inp.files[0]; inp.remove(); resolve(f ? { name: f.name, content: await f.text() } : null); };
|
||||||
|
inp.addEventListener('cancel', () => { inp.remove(); resolve(null); });
|
||||||
|
document.body.append(inp);
|
||||||
|
inp.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function downloadText(name, text) {
|
||||||
|
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||||
|
const a = Object.assign(document.createElement('a'), { href: url, download: name });
|
||||||
|
document.body.append(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kanäle, die im Browser selbst erledigt werden
|
||||||
|
const local = {
|
||||||
|
// Ohne HTTPS gibt es navigator.clipboard nicht: Kopieren per execCommand, Einfügen dann nur per Strg+V
|
||||||
|
'clipboard:write': (t) => {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(String(t));
|
||||||
|
const ta = Object.assign(document.createElement('textarea'), { value: String(t) });
|
||||||
|
ta.style.cssText = 'position:fixed;opacity:0';
|
||||||
|
document.body.append(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
||||||
|
},
|
||||||
|
'clipboard:read': () => {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.readText();
|
||||||
|
throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.');
|
||||||
|
},
|
||||||
|
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
|
||||||
|
'backup:saveFile': (content, name) => { downloadText(name, content); return name; },
|
||||||
|
'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; },
|
||||||
|
'import:pickFile': async () => pickFile(),
|
||||||
|
'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); },
|
||||||
|
};
|
||||||
|
|
||||||
|
window.api = {
|
||||||
|
platform: 'web',
|
||||||
|
call: (ch, ...args) => (local[ch] ? Promise.resolve().then(() => local[ch](...args)) : remote(ch, args)),
|
||||||
|
on(ch, fn) {
|
||||||
|
if (!listeners.has(ch)) listeners.set(ch, new Set());
|
||||||
|
listeners.get(ch).add(fn);
|
||||||
|
return () => listeners.get(ch).delete(fn);
|
||||||
|
},
|
||||||
|
win: { min() {}, max() {}, close() {} },
|
||||||
|
ssh: {
|
||||||
|
write: (id, d) => post({ t: 'send', ch: 'ssh:write', args: [id, d] }),
|
||||||
|
resize: (id, c, r) => post({ t: 'send', ch: 'ssh:resize', args: [id, c, r] }),
|
||||||
|
close: (id) => post({ t: 'send', ch: 'ssh:close', args: [id] }),
|
||||||
|
},
|
||||||
|
rdp: { bounds() {}, show() {}, hide() {}, focus() {}, close() {} },
|
||||||
|
replySecret: (reqId, v) => post({ t: 'send', ch: 'secret:reply', args: [reqId, v] }),
|
||||||
|
pairReply: (reqId, ok) => post({ t: 'send', ch: 'sync:pairReply', args: [reqId, ok] }),
|
||||||
|
};
|
||||||
|
document.documentElement.classList.add('web');
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// MrTerm Web: die Desktop-Oberfläche im Browser, selbst gehostet (Docker).
|
||||||
|
// - Benutzerkonten; jeder Tresor ist mit einem eigenen Datenschlüssel (DEK) verschlüsselt,
|
||||||
|
// den nur das Login-Passwort des Benutzers entpackt (scrypt → AES-256-GCM).
|
||||||
|
// - Oberfläche ↔ Server über WebSocket (/ws), gleiche Kanäle wie Electron/Android (src/core/backend.js).
|
||||||
|
// - SFTP-Up-/Downloads über HTTP (/web/upload, /web/download).
|
||||||
|
//
|
||||||
|
// Umgebung: PORT (8080), DATA_DIR (/data), TRUST_PROXY=1 (X-Forwarded-Proto/-For auswerten, z. B. hinter Traefik)
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { Store } = require('../main/store');
|
||||||
|
const { createBackend } = require('../core/backend');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
const ROOT = process.env.MRTERM_ROOT || path.resolve(__dirname, '..', '..');
|
||||||
|
const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'web-data'));
|
||||||
|
const PORT = Number(process.env.PORT) || 8080;
|
||||||
|
const TRUST_PROXY = process.env.TRUST_PROXY === '1';
|
||||||
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { return '0.0.0'; } })();
|
||||||
|
const SESSION_IDLE = 12 * 3600e3;
|
||||||
|
const SESSION_MAX = 7 * 24 * 3600e3;
|
||||||
|
const COOKIE = 'mrterm_sid';
|
||||||
|
|
||||||
|
process.on('uncaughtException', (e) => console.error('Uncaught exception:', e));
|
||||||
|
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
|
||||||
|
fs.mkdirSync(path.join(DATA, 'users'), { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
// ============================================================ Krypto-Helfer
|
||||||
|
const scrypt = (pw, salt, N = 2 ** 15) => 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))));
|
||||||
|
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()]);
|
||||||
|
}
|
||||||
|
// Verschlüsselung des Tresors mit dem DEK (Schnittstelle wie Electrons safeStorage)
|
||||||
|
const vaultCrypto = (dek) => ({
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString(s) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const c = crypto.createCipheriv('aes-256-gcm', dek, iv);
|
||||||
|
const ct = Buffer.concat([c.update(String(s), 'utf8'), c.final()]);
|
||||||
|
return Buffer.concat([iv, c.getAuthTag(), ct]);
|
||||||
|
},
|
||||||
|
decryptString(buf) {
|
||||||
|
const d = crypto.createDecipheriv('aes-256-gcm', dek, buf.subarray(0, 12));
|
||||||
|
d.setAuthTag(buf.subarray(12, 28));
|
||||||
|
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================ Benutzer
|
||||||
|
const USERS_FILE = path.join(DATA, 'users.json');
|
||||||
|
let users = (() => { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')).users || []; } catch { return []; } })();
|
||||||
|
function saveUsers() {
|
||||||
|
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify({ users }, null, 2), { mode: 0o600 });
|
||||||
|
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
|
||||||
|
}
|
||||||
|
const validName = (n) => /^[A-Za-z0-9._@-]{1,64}$/.test(String(n || ''));
|
||||||
|
const validPw = (p) => typeof p === 'string' && p.length >= 8 && p.length <= 1024;
|
||||||
|
const publicUser = (u) => ({ id: u.id, name: u.name, admin: !!u.admin });
|
||||||
|
|
||||||
|
async function createUser(name, password, admin) {
|
||||||
|
if (!validName(name)) throw new Error('Usernames may contain letters, digits and . _ @ - (max. 64).');
|
||||||
|
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
|
||||||
|
if (users.some((u) => u.name.toLowerCase() === name.toLowerCase())) throw new Error('This username is already taken.');
|
||||||
|
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
|
||||||
|
const dek = crypto.randomBytes(32);
|
||||||
|
const u = {
|
||||||
|
id: crypto.randomUUID(), name, admin: !!admin, createdAt: Date.now(),
|
||||||
|
auth: { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') },
|
||||||
|
kek: { salt: kekSalt.toString('base64') },
|
||||||
|
dek: box(await scrypt(password, kekSalt), dek),
|
||||||
|
};
|
||||||
|
users.push(u);
|
||||||
|
saveUsers();
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
// Prüft das Passwort und liefert den entpackten DEK (oder null)
|
||||||
|
async function verify(u, password) {
|
||||||
|
const hash = await scrypt(password, Buffer.from(u.auth.salt, 'base64'));
|
||||||
|
if (!crypto.timingSafeEqual(hash, Buffer.from(u.auth.hash, 'base64'))) return null;
|
||||||
|
try { return unbox(await scrypt(password, Buffer.from(u.kek.salt, 'base64')), u.dek); } catch { return null; }
|
||||||
|
}
|
||||||
|
async function setPassword(u, dek, password) {
|
||||||
|
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
|
||||||
|
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
|
||||||
|
u.auth = { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') };
|
||||||
|
u.kek = { salt: kekSalt.toString('base64') };
|
||||||
|
u.dek = box(await scrypt(password, kekSalt), dek);
|
||||||
|
saveUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ Sitzungen
|
||||||
|
const sessions = new Map(); // token -> { userId, dek, created, last }
|
||||||
|
function newSession(u, dek) {
|
||||||
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
|
sessions.set(token, { userId: u.id, dek, created: Date.now(), last: Date.now() });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
function sessionOf(req) {
|
||||||
|
const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`));
|
||||||
|
const s = m && sessions.get(m[1]);
|
||||||
|
if (!s) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - s.last > SESSION_IDLE || now - s.created > SESSION_MAX || !users.some((u) => u.id === s.userId)) { sessions.delete(m[1]); return null; }
|
||||||
|
s.last = now;
|
||||||
|
return { token: m[1], ...s, user: users.find((u) => u.id === s.userId) };
|
||||||
|
}
|
||||||
|
setInterval(() => { for (const [t, s] of sessions) if (Date.now() - s.last > SESSION_IDLE || Date.now() - s.created > SESSION_MAX) sessions.delete(t); }, 600e3).unref();
|
||||||
|
|
||||||
|
const secure = (req) => req.socket.encrypted || (TRUST_PROXY && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
|
||||||
|
const cookie = (req, token, maxAge) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? '; Secure' : ''}`;
|
||||||
|
const clientIp = (req) => (TRUST_PROXY && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || '';
|
||||||
|
|
||||||
|
// Anmeldeversuche begrenzen: 10 Fehlversuche je IP in 15 Minuten
|
||||||
|
const failures = new Map();
|
||||||
|
const blocked = (ip) => { const f = failures.get(ip); return f && f.n >= 10 && Date.now() - f.first < 900e3; };
|
||||||
|
const fail = (ip) => { const f = failures.get(ip); if (!f || Date.now() - f.first > 900e3) failures.set(ip, { n: 1, first: Date.now() }); else f.n++; };
|
||||||
|
|
||||||
|
// ============================================================ Laufzeit je Benutzer (Tresor + Backend)
|
||||||
|
const runtimes = new Map(); // userId -> { store, be, sockets, timer }
|
||||||
|
function runtimeFor(sess) {
|
||||||
|
let rt = runtimes.get(sess.userId);
|
||||||
|
if (rt) { clearTimeout(rt.timer); return rt; }
|
||||||
|
const dir = path.join(DATA, 'users', sess.userId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
const store = new Store({ dir, crypto: vaultCrypto(sess.dek) });
|
||||||
|
store.load();
|
||||||
|
const sockets = new Set();
|
||||||
|
const send = (ch, ...args) => { const msg = JSON.stringify({ t: 'evt', ch, args }); for (const ws of sockets) if (ws.readyState === 1) ws.send(msg); };
|
||||||
|
const be = createBackend({ store, send, platform: 'web', version: VERSION });
|
||||||
|
// Desktop-Funktionen, die es im Browser nicht gibt: neutrale Antworten, damit die Oberfläche nicht stolpert
|
||||||
|
be.handle('vpn:status', () => ({}));
|
||||||
|
be.handle('fw:active', () => []);
|
||||||
|
be.handle('rdp:detect', () => ({}));
|
||||||
|
be.handle('rdp:embedSupported', () => ({ ok: false, reason: 'web' }));
|
||||||
|
be.handle('update:check', () => ({ available: false }));
|
||||||
|
be.handle('sync:status', () => ({ enabled: false, running: false, peers: [], nearby: [], share: { keys: [], hosts: [], vpns: [] } }));
|
||||||
|
rt = { store, be, sockets, timer: null };
|
||||||
|
runtimes.set(sess.userId, rt);
|
||||||
|
return rt;
|
||||||
|
}
|
||||||
|
// Letzter Browser-Tab zu: SSH-Verbindungen nach kurzer Zeit schließen und den Tresor aus dem Speicher nehmen
|
||||||
|
function release(userId) {
|
||||||
|
const rt = runtimes.get(userId);
|
||||||
|
if (!rt || rt.sockets.size) return;
|
||||||
|
rt.timer = setTimeout(() => { if (!rt.sockets.size) { rt.be.close(); runtimes.delete(userId); } }, 60e3);
|
||||||
|
}
|
||||||
|
function dropUser(userId) {
|
||||||
|
for (const [t, s] of sessions) if (s.userId === userId) sessions.delete(t);
|
||||||
|
const rt = runtimes.get(userId);
|
||||||
|
if (rt) { for (const ws of rt.sockets) ws.close(4001, 'signed out'); rt.be.close(); runtimes.delete(userId); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================ HTTP
|
||||||
|
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2' };
|
||||||
|
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
|
||||||
|
function headers(extra = {}) {
|
||||||
|
return { 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store', ...extra };
|
||||||
|
}
|
||||||
|
const json = (res, code, obj, extra = {}) => { res.writeHead(code, headers({ 'Content-Type': 'application/json', ...extra })); res.end(JSON.stringify(obj)); };
|
||||||
|
const redirect = (res, to) => { res.writeHead(302, headers({ Location: to })); res.end(); };
|
||||||
|
|
||||||
|
// Freigegebene statische Dateien: URL-Präfix → Verzeichnis
|
||||||
|
const STATIC = [
|
||||||
|
['/app/src/renderer/', path.join(ROOT, 'src', 'renderer')],
|
||||||
|
['/app/src/i18n.js', path.join(ROOT, 'src', 'i18n.js')],
|
||||||
|
['/app/node_modules/@xterm/', path.join(ROOT, 'node_modules', '@xterm')],
|
||||||
|
['/web/', path.join(ROOT, 'src', 'web', 'public')],
|
||||||
|
];
|
||||||
|
function staticFile(urlPath) {
|
||||||
|
for (const [prefix, dir] of STATIC) {
|
||||||
|
if (urlPath === prefix && !prefix.endsWith('/')) return dir;
|
||||||
|
if (!prefix.endsWith('/') || !urlPath.startsWith(prefix)) continue;
|
||||||
|
const file = path.resolve(dir, '.' + path.posix.normalize('/' + decodeURIComponent(urlPath.slice(prefix.length))));
|
||||||
|
if (!file.startsWith(dir + path.sep)) return null;
|
||||||
|
if (dir.endsWith('@xterm') && !/[\\/](lib|css)[\\/][^\\/]+$/.test(file)) return null;
|
||||||
|
if (dir.endsWith('renderer') && /\.(rdm|md)$/.test(file)) return null;
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function serveFile(res, file, transform) {
|
||||||
|
fs.stat(file, (err, st) => {
|
||||||
|
if (err || !st.isFile()) return json(res, 404, { error: 'Not found' });
|
||||||
|
const type = MIME[path.extname(file)] || 'application/octet-stream';
|
||||||
|
const cache = type.startsWith('text/html') ? 'no-store' : 'private, max-age=300';
|
||||||
|
if (transform) {
|
||||||
|
const body = transform(fs.readFileSync(file, 'utf8'));
|
||||||
|
res.writeHead(200, headers({ 'Content-Type': type, 'Cache-Control': cache }));
|
||||||
|
return res.end(body);
|
||||||
|
}
|
||||||
|
res.writeHead(200, headers({ 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': cache }));
|
||||||
|
fs.createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readJson(req, limit = 64 * 1024) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let size = 0; const parts = [];
|
||||||
|
req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('Request too large')); req.destroy(); } else parts.push(c); });
|
||||||
|
req.on('end', () => { try { resolve(parts.length ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); } catch { reject(new Error('Invalid JSON')); } });
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const APP_URL = '/app/src/renderer/index.html';
|
||||||
|
|
||||||
|
async function api(req, res, url, sess) {
|
||||||
|
const p = url.pathname;
|
||||||
|
// Schreibende Anfragen nur mit eigenem Header (Browser senden ihn nicht seitenübergreifend ohne CORS-Freigabe)
|
||||||
|
if (req.method === 'POST' && req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
|
||||||
|
if (p === '/api/state' && req.method === 'GET') return json(res, 200, { setup: !users.length, user: sess ? publicUser(sess.user) : null, version: VERSION });
|
||||||
|
if (p === '/api/setup' && req.method === 'POST') {
|
||||||
|
if (users.length) return json(res, 409, { error: 'Setup is already complete.' });
|
||||||
|
const b = await readJson(req);
|
||||||
|
const u = await createUser(b.name, b.password, true);
|
||||||
|
const dek = await verify(u, b.password);
|
||||||
|
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
|
||||||
|
}
|
||||||
|
if (p === '/api/login' && req.method === 'POST') {
|
||||||
|
const ip = clientIp(req);
|
||||||
|
if (blocked(ip)) return json(res, 429, { error: 'Too many failed attempts. Try again in 15 minutes.' });
|
||||||
|
const b = await readJson(req);
|
||||||
|
const u = users.find((x) => x.name.toLowerCase() === String(b.name || '').toLowerCase());
|
||||||
|
const dek = u ? await verify(u, String(b.password || '')) : (await scrypt('x', crypto.randomBytes(16)), null);
|
||||||
|
if (!dek) { fail(ip); return json(res, 401, { error: 'Wrong username or password.' }); }
|
||||||
|
failures.delete(ip);
|
||||||
|
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
|
||||||
|
}
|
||||||
|
if (!sess) return json(res, 401, { error: 'Not signed in' });
|
||||||
|
if (p === '/api/me') return json(res, 200, publicUser(sess.user));
|
||||||
|
if (p === '/api/logout' && req.method === 'POST') {
|
||||||
|
sessions.delete(sess.token);
|
||||||
|
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, '', 0) });
|
||||||
|
}
|
||||||
|
if (p === '/api/password' && req.method === 'POST') {
|
||||||
|
const b = await readJson(req);
|
||||||
|
if (!(await verify(sess.user, String(b.old || '')))) return json(res, 400, { error: 'The current password is wrong.' });
|
||||||
|
await setPassword(sess.user, sess.dek, b.password);
|
||||||
|
for (const [t, s] of sessions) if (s.userId === sess.userId && t !== sess.token) sessions.delete(t);
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if (p.startsWith('/api/users')) {
|
||||||
|
if (!sess.user.admin) return json(res, 403, { error: 'Administrators only.' });
|
||||||
|
if (p === '/api/users' && req.method === 'GET') return json(res, 200, { users: users.map(publicUser) });
|
||||||
|
if (p === '/api/users' && req.method === 'POST') {
|
||||||
|
const b = await readJson(req);
|
||||||
|
return json(res, 200, publicUser(await createUser(b.name, b.password, !!b.admin)));
|
||||||
|
}
|
||||||
|
if (p === '/api/users/delete' && req.method === 'POST') {
|
||||||
|
const b = await readJson(req);
|
||||||
|
const u = users.find((x) => x.id === b.id);
|
||||||
|
if (!u) return json(res, 404, { error: 'User not found.' });
|
||||||
|
if (u.id === sess.userId) return json(res, 400, { error: 'You cannot delete yourself.' });
|
||||||
|
if (u.admin && users.filter((x) => x.admin).length === 1) return json(res, 400, { error: 'The last administrator cannot be deleted.' });
|
||||||
|
dropUser(u.id);
|
||||||
|
users = users.filter((x) => x.id !== u.id);
|
||||||
|
saveUsers();
|
||||||
|
fs.rmSync(path.join(DATA, 'users', u.id), { recursive: true, force: true });
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFTP-Dateien des angemeldeten Benutzers streamen
|
||||||
|
function sftpFor(sess, url) {
|
||||||
|
const rt = runtimes.get(sess.userId);
|
||||||
|
const sid = url.searchParams.get('sid') || '';
|
||||||
|
const file = url.searchParams.get('path') || '';
|
||||||
|
if (!rt || !file.startsWith('/')) throw new Error('Invalid request');
|
||||||
|
return { sftp: rt.be.ssh.sftpOf(sid), file };
|
||||||
|
}
|
||||||
|
function download(req, res, sess, url) {
|
||||||
|
let t;
|
||||||
|
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
|
||||||
|
t.sftp.stat(t.file, (err, st) => {
|
||||||
|
if (err) return json(res, 404, { error: err.message });
|
||||||
|
const name = path.posix.basename(t.file);
|
||||||
|
res.writeHead(200, headers({
|
||||||
|
'Content-Type': 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': 'no-store',
|
||||||
|
'Content-Disposition': `attachment; filename="${name.replace(/[^\x20-\x7e]|["\\]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
||||||
|
}));
|
||||||
|
const rs = t.sftp.createReadStream(t.file);
|
||||||
|
rs.on('error', () => res.destroy());
|
||||||
|
rs.pipe(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function upload(req, res, sess, url) {
|
||||||
|
let t;
|
||||||
|
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
|
||||||
|
const ws = t.sftp.createWriteStream(t.file);
|
||||||
|
let done = false;
|
||||||
|
const finish = (err) => { if (done) return; done = true; err ? json(res, 500, { error: err.message }) : json(res, 200, { ok: true }); };
|
||||||
|
ws.on('error', finish);
|
||||||
|
ws.on('close', () => finish());
|
||||||
|
req.on('error', finish);
|
||||||
|
req.pipe(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, 'http://x');
|
||||||
|
const p = url.pathname;
|
||||||
|
const sess = sessionOf(req);
|
||||||
|
if (p === '/healthz') return json(res, 200, { ok: true });
|
||||||
|
if (p.startsWith('/api/')) return await api(req, res, url, sess);
|
||||||
|
if (p === '/web/download' && req.method === 'GET') return sess ? download(req, res, sess, url) : json(res, 401, { error: 'Not signed in' });
|
||||||
|
if (p === '/web/upload' && req.method === 'POST') {
|
||||||
|
if (!sess) return json(res, 401, { error: 'Not signed in' });
|
||||||
|
if (req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
|
||||||
|
return upload(req, res, sess, url);
|
||||||
|
}
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed' });
|
||||||
|
if (p === '/' || p === '/login') return sess && p === '/' ? redirect(res, APP_URL) : serveFile(res, path.join(ROOT, 'src', 'web', 'public', 'login.html'));
|
||||||
|
if (p === '/favicon.ico' || p === '/favicon.png') return serveFile(res, path.join(ROOT, 'src', 'renderer', 'logo.png'));
|
||||||
|
if (p === APP_URL) {
|
||||||
|
if (!sess) return redirect(res, '/');
|
||||||
|
// Browser-Anbindung (window.api) vor den übrigen Skripten laden
|
||||||
|
return serveFile(res, staticFile(p), (html) => html.replace('<script ', '<script src="/web/web-api.js"></script>\n <script '));
|
||||||
|
}
|
||||||
|
const file = staticFile(p);
|
||||||
|
if (!file) return json(res, 404, { error: 'Not found' });
|
||||||
|
// Nur die Anmeldeseite und ihre Dateien sind ohne Sitzung erreichbar
|
||||||
|
const PUBLIC = ['/web/login.js', '/web/login.css', '/app/src/i18n.js', '/app/src/renderer/app-themes.css', '/app/src/renderer/logo.png'];
|
||||||
|
if (!sess && !PUBLIC.includes(p)) return json(res, 401, { error: 'Not signed in' });
|
||||||
|
return serveFile(res, file);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (!res.headersSent) json(res, 500, { error: e.message || 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================ WebSocket
|
||||||
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
const url = new URL(req.url, 'http://x');
|
||||||
|
const sess = sessionOf(req);
|
||||||
|
// Nur Verbindungen von der eigenen Seite (Schutz vor Cross-Site-WebSocket-Hijacking)
|
||||||
|
const origin = req.headers.origin ? new URL(req.headers.origin).host : '';
|
||||||
|
const host = (TRUST_PROXY && req.headers['x-forwarded-host']) || req.headers.host;
|
||||||
|
if (url.pathname !== '/ws' || !sess || origin !== host) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); return socket.destroy(); }
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
const rt = runtimeFor(sess);
|
||||||
|
rt.sockets.add(ws);
|
||||||
|
const lang = String(req.headers['accept-language'] || 'en').split(',')[0];
|
||||||
|
ws.on('message', async (raw) => {
|
||||||
|
let m;
|
||||||
|
try { m = JSON.parse(raw.toString('utf8')); } catch { return; }
|
||||||
|
if (!sessions.has(sess.token)) return ws.close(4001, 'signed out');
|
||||||
|
if (m.t === 'send') return rt.be.notify(String(m.ch), Array.isArray(m.args) ? m.args : []);
|
||||||
|
if (m.t !== 'call') return;
|
||||||
|
try {
|
||||||
|
i18n.setLanguage(rt.store.get().settings.language, lang);
|
||||||
|
const v = await rt.be.call(String(m.ch), Array.isArray(m.args) ? m.args : []);
|
||||||
|
ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: true, v: v === undefined ? null : v }));
|
||||||
|
} catch (e) {
|
||||||
|
if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: false, v: e?.message || String(e) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('close', () => { rt.sockets.delete(ws); release(sess.userId); });
|
||||||
|
ws.send(JSON.stringify({ t: 'hello', user: publicUser(sess.user), version: VERSION }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (e) => { console.error(`Cannot listen on port ${PORT}: ${e.message}`); process.exit(1); });
|
||||||
|
server.listen(PORT, () => console.log(`MrTerm Web ${VERSION} on port ${PORT} (data: ${DATA})${users.length ? '' : ' – open it in the browser to create the admin account'}`));
|
||||||
Reference in New Issue
Block a user