Compare commits

...
25 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 809227e8f1 Version 0.23.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:24 +02:00
MrBlakeandClaude Opus 5.5 fd155fe2db Add web server, certificates and Samba/AD management tabs, a web server config generator, toggleable feature menus and an animated start page background
- Web server tab (nginx, Apache, Caddy, PHP-FPM): service control with autostart, config test and live logs, sites list with enable/disable/edit/delete, new site wizard (static, reverse proxy, PHP) and a code editor for all config files; every change is checked (nginx -t, apachectl configtest, caddy validate, php-fpm -t) before reloading and rolled back if the check fails
- Config generator: all options grouped and searchable (reverse proxy with load balancing and WebSockets, HTTPS/TLS, HSTS, security headers, IP allow list, basic auth, rate limit, gzip, caching, upload size, PHP, redirects, logs, custom directives), presets, live preview for nginx/Apache/Caddy; save as a site (needed Apache modules are enabled) or open in the editor first
- Certificates tab: Let's Encrypt via certbot (request with nginx/Apache/webroot/standalone/DNS challenge, renew, dry run, delete) in a terminal tab, acme.sh certificates, expiry of all found certificate files, auto-renew timer, self-signed certificates and a TLS endpoint check from this computer
- Samba/AD tab: shares (smb.conf edited section by section, checked with testparm), Samba users, services and connected clients; create a new AD domain (samba-tool domain provision), join or leave a domain (realmd/sssd) and manage domain users, groups, members and computers; passwords are passed via stdin
- Settings: feature menus (Docker, firewall, network, web server, certificates, Samba/AD) can be enabled or disabled for the host menu, terminal toolbar and quick search
- Start page: slowly drifting, blurred colour glows in the theme colours behind the vault (can be turned off in the design settings, respects reduced motion)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:18 +02:00
MrBlakeandClaude Opus 5.5 29d3747859 App icon: cut out the dark square background behind the MrTerm emblem (transparent, edge pixels un-blended so no dark fringe remains); make-icons.js now does the cut-out itself
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:17 +02:00
MrBlake ea8d73dd60 Version 0.22.0 2026-10-14 21:54:01 +02:00
MrBlakeandClaude Opus 5.5 d87feb63af Tabs: reorder by dragging; detach tabs into their own window (drag out of the window or "Open in new window") and move them back (drag into another window's tab bar or "Back to main window"); terminal tabs keep their SSH connection and output, SFTP/Docker/firewall/network reconnect; events go to all windows, password prompts to the window showing the session; closing a detached window ends its sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:54:00 +02:00
MrBlake ec5ca37356 Version 0.21.1 2026-10-14 21:45:08 +02:00
MrBlakeandClaude Opus 5.5 5c974f31ce Title bar: center tabs vertically as rounded pills (were bottom-aligned while logo, + and buttons were centered), frame the active tab, draw + as an icon and center all title bar icons at the same size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:45:05 +02:00
MrBlake 25115f8747 Version 0.21.0 2026-10-14 21:36:00 +02:00
MrBlakeandClaude Opus 5.5 c3a4d06310 sudo for firewall, network and Docker: try passwordless sudo first, then the saved host password, otherwise ask for the sudo password (up to 3 attempts, kept only for the open tab) instead of failing with "root privileges required"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:35:59 +02:00
MrBlake b4eedc77bb Version 0.20.0 2026-10-14 21:29:50 +02:00
MrBlakeandClaude Opus 5.5 93123c5c60 SFTP: upload button on the server pane (desktop: system file dialog with multi-select, uploads into the current folder); fix the backend switch (UFW/iptables) sitting too high in the firewall tab bar (.seg bottom margin was only reset for Docker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:29:49 +02:00
MrBlake 3714205dab Version 0.19.1 2026-10-13 21:53:46 +02:00
MrBlake 4818523b29 Version 0.18.1 2026-10-13 21:50:56 +02:00
MrBlakeandClaude Opus 5.5 6f52873b69 Support assistant: fix the delete-chat confirmation opening twice (panel click handler was added again on every reload)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:50:55 +02:00
MrBlakeandClaude Opus 5.5 51bd769d6e Version 0.19.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:34 +02:00
MrBlakeandClaude Opus 5.5 136e4b4e2c RDP tabs on Windows: no visible mstsc windows and resolution follows the tab size. mstsc is started with /v: instead of an unsigned .rdp file (avoids the mandatory "unknown publisher" security warning; a file is still used when the host needs drive redirection, clipboard/audio off or scaling); a WinEvent hook adopts the session window while it is still hidden (borderless, parked off-screen until logged in), keeps the "connecting" progress dialog invisible and shows real prompts (certificate, errors) centered over the tab; servers without dynamic resolution (mstsc clamps the window to the session size) are detected and reconnected with the new size after resizing, re-confirming a certificate the user already accepted for this session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:30 +02:00
MrBlakeandClaude Opus 5.5 6b02dc078a Version 0.18.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:12:10 +02:00
MrBlake ecaf579ccd Version 0.18.0 2026-10-01 22:44:35 +02:00
MrBlakeandClaude Opus 5.5 4176cc0a01 Support assistant across all tabs: global panel (title bar button and in terminal, Docker, firewall and network tabs) that uses the active tab as context (terminal output, container/stack view, compose editor with masked .env values, firewall rules, network config); chats are stored in the vault and can be switched, started anew and deleted; stop button while answering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:44:35 +02:00
MrBlake 20506ee889 Version 0.17.1 2026-10-01 22:12:38 +02:00
MrBlakeandClaude Opus 5.5 6ebdc5126f Docker stacks: show .env variables when editing a stack whose .env was written via sudo (root-owned, mode 600); reading now retries with sudo instead of silently showing an empty .env, which also deleted the file on the next save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:12:37 +02:00
MrBlake 08c6d65e29 Version 0.17.0 2026-10-01 21:42:02 +02:00
MrBlakeandClaude Opus 5.5 8c8dc0d4c0 Add support assistant via Ollama: chat panel in terminal tabs with the latest terminal output as context, 'explain last error', copy/insert suggested commands (never auto-executed), Ollama server/model in settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:41:56 +02:00
MrBlake a279ef88b2 Version 0.16.0 2026-09-27 14:58:50 +02:00
MrBlakeandClaude Opus 5.5 6efa74aa7e Docker: add a Compose stack manager (Containers | Stacks toggle): list stacks in a stacks folder (default /opt/stacks) plus projects found via compose ls, create/edit compose.yaml and .env in an editor (validated with compose config), deploy/stop/restart/down/pull/logs/delete in terminal tabs, clone stacks from Git and git pull + redeploy, app templates in Portainer format (default Lissy93/portainer-templates: container templates are converted to compose, stack templates loaded from GitHub), "Set up folder" makes the stacks folder writable for the SSH user
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:58:50 +02:00
29 changed files with 3974 additions and 107 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 222 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.15.0", "version": "0.23.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.15.0", "version": "0.23.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.15.0", "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/**"
+19
View File
@@ -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
View File
@@ -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());
+26
View File
@@ -11,8 +11,13 @@ const { SshManager } = require('../main/ssh');
const { DockerManager } = require('../main/docker'); const { DockerManager } = require('../main/docker');
const { FirewallManager } = require('../main/firewall'); const { FirewallManager } = require('../main/firewall');
const { NetworkConfigManager } = require('../main/network'); 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 i18n = require('../i18n');
const backup = require('./backup'); const backup = require('./backup');
const composeTemplates = require('../main/compose-templates');
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) { function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode) // Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
@@ -33,6 +38,8 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
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);
const admins = { web: new WebServerManager(ssh), certs: new CertManager(ssh), samba: new SambaManager(ssh) };
const ai = new AiAssistant(store, send);
let sync = null; let sync = null;
if (withSync) { if (withSync) {
const { SyncService } = require('../main/sync'); const { SyncService } = require('../main/sync');
@@ -184,6 +191,12 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
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));
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', 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:list', (id, backend) => firewall.list(id, backend));
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args)); handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
@@ -198,6 +211,18 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
handle('network:readFile', (id, path) => network.readFile(id, path)); 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));
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 ---------- // ---------- Synchronisation ----------
if (sync) { if (sync) {
@@ -214,6 +239,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
function close() { function close() {
ssh.closeAll?.(); ssh.closeAll?.();
ai.stopAll();
sync?.stop(); sync?.stop();
for (const r of pending.values()) r(null); for (const r of pending.values()) r(null);
pending.clear(); pending.clear();
+431 -1
View File
@@ -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',
@@ -367,6 +404,56 @@
'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.', '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', 'Replace': 'Ersetzen',
'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets', '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', 'Templates': 'Vorlagen',
'Run': 'Ausführen', 'Run': 'Ausführen',
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten', 'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
@@ -498,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).',
@@ -730,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)',
+112
View File
@@ -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 };
+93
View File
@@ -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 };
+180
View File
@@ -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 };
+75
View File
@@ -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 };
+146 -3
View File
@@ -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;
+4 -3
View File
@@ -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);
+125 -20
View File
@@ -15,8 +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 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;
@@ -41,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) {
@@ -76,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();
@@ -88,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,
@@ -105,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) {
@@ -130,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) =>
@@ -233,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) => {
@@ -260,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));
@@ -271,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));
@@ -394,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) => {
@@ -459,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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+347
View File
@@ -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 };
+9
View File
@@ -16,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)
@@ -39,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,
}, },
}; };
+25
View File
@@ -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 };
+288
View File
@@ -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 };
+6
View File
@@ -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),
+875
View File
@@ -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'));
}
}
+564 -22
View File
@@ -46,6 +46,7 @@ const ICONS = {
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>', docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>', wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>', network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
ai: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg>',
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>', logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
}; };
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100']; const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
@@ -150,6 +151,7 @@ async function reload() {
S.vault = await call('vault:get'); S.vault = await call('vault:get');
I18N.setLanguage(settings().language, navigator.language); I18N.setLanguage(settings().language, navigator.language);
applyStatic(); applyStatic();
initAi();
const b = $('#vaultBadge'); const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`; b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).'); b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
@@ -157,6 +159,12 @@ async function reload() {
S.activeForwards = new Set(await call('fw:active')); S.activeForwards = new Set(await call('fw:active'));
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({})); if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
render(); render();
// Abgelöstes Fenster: übergebenen Tab erst nach dem Entsperren übernehmen (vorher fehlen Tresor und Einstellungen)
if (DETACHED && !S.handoffDone) {
S.handoffDone = true;
const d = await api.win.handoff(DETACHED);
if (d) adoptTab(d); else api.win.close();
}
} }
// Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title) // Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title)
@@ -394,7 +402,7 @@ function hostMenu(hst) {
return [ return [
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, { label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []), ...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }, { label: T('Network'), icon: 'network', run: () => openNetwork(hst) }] : []), ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, ...enabledFeatures().map((f) => ({ label: featureTitle(f), icon: f.icon, run: () => f.open(hst) }))] : []),
'-', '-',
{ label: T('Edit'), icon: 'edit', run: () => editHost(hst) }, { label: T('Edit'), icon: 'edit', run: () => editHost(hst) },
{ label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } }, { label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } },
@@ -1026,6 +1034,11 @@ async function viewSettings(page) {
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); }; b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b); $('.accent-colors', designCard).append(b);
}); });
const anim = check(T('Animated background on the start page'), 'homeAnimation', st.homeAnimation !== false);
anim.style.marginTop = '14px';
$('input', anim).onchange = async (e) => { await save({ homeAnimation: e.target.checked }); applyAppTheme(); };
designCard.append(anim);
const featCard = featuresCard(save);
const themeCard = h(`<div class="settings-card"><h3>${T('Terminal color scheme')}</h3><div class="theme-list"></div></div>`); const themeCard = h(`<div class="settings-card"><h3>${T('Terminal color scheme')}</h3><div class="theme-list"></div></div>`);
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term]; const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
@@ -1051,6 +1064,28 @@ async function viewSettings(page) {
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 }); save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
}); });
// ---- Support-Assistent (Ollama)
const aiCard = h(`<div class="settings-card"><h3>${T('Support assistant')}</h3><p style="color:var(--muted);margin-top:0">${T('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.')}</p></div>`);
const aiUrl = field(T('Ollama server'), 'aiUrl', st.aiUrl || 'http://localhost:11434', { placeholder: 'http://localhost:11434' });
const aiModel = field(T('Model'), 'aiModel', st.aiModel, { type: 'select', options: [[st.aiModel || '', st.aiModel || T('– select –')]] });
const loadModels = async () => {
const sel = $('select', aiModel);
try {
const list = await api.call('ai:models', $('input', aiUrl).value.trim());
sel.innerHTML = '';
if (!list.length) sel.append(new Option(T('No models installed (ollama pull …)'), ''));
list.forEach((m) => sel.append(new Option(m, m)));
sel.value = list.includes(st.aiModel) ? st.aiModel : list[0] || '';
if (sel.value !== (settings().aiModel || '')) save({ aiModel: sel.value });
} catch (e) { toast(e.message, 'error'); }
};
const aiReload = h(`<button class="btn">${ICONS.refresh}${T('Load models')}</button>`);
aiReload.onclick = loadModels;
$('input', aiUrl).onchange = () => save({ aiUrl: $('input', aiUrl).value.trim() }).then(loadModels);
$('select', aiModel).onchange = () => save({ aiModel: $('select', aiModel).value });
aiCard.append(aiUrl, aiModel, aiReload);
if (st.aiModel) loadModels();
const rdpCard = h(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`); const rdpCard = h(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false); const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
emb.onchange = async () => { emb.onchange = async () => {
@@ -1069,6 +1104,15 @@ async function viewSettings(page) {
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)'; if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
}).catch(() => {}); }).catch(() => {});
const dockerCard = h(`<div class="settings-card"><h3>Docker Compose</h3></div>`);
const dockerFields = h('<div></div>');
dockerFields.append(
field(T('Stacks folder'), 'dockerStacksDir', st.dockerStacksDir || '/opt/stacks', { hint: T('Folder on the host in which each stack gets its own subfolder') }),
field(T('Template source'), 'dockerTemplatesUrl', st.dockerTemplatesUrl || '', { placeholder: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', hint: T('URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.') }),
);
dockerFields.addEventListener('change', () => save(formValues(dockerFields)));
dockerCard.append(dockerFields);
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`); const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
const btns = [ const btns = [
...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]), ...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]),
@@ -1123,8 +1167,8 @@ async function viewSettings(page) {
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span> <span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span> <span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`); <span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, importCard, dataCard, keysCard); if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, featCard, aiCard, dockerCard, importCard, dataCard, keysCard);
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard); else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, featCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
} }
// ============================================================ App-Sperre // ============================================================ App-Sperre
@@ -1389,6 +1433,7 @@ function applyAppTheme(st = settings()) {
const root = document.documentElement; const root = document.documentElement;
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme; if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent'); if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
document.body.classList.toggle('home-anim', st.homeAnimation !== false);
S.tabs.forEach((t) => t.applySettings?.()); S.tabs.forEach((t) => t.applySettings?.());
} }
@@ -1511,13 +1556,17 @@ function addTab(session) {
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); }; tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); }; tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []), ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, ...featureOpeners() }[session.kind] || openTerminal)(session.host) }] : []),
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []), ...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, { label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
...(DETACHABLE.has(session.kind) ? ['-', { label: T('Open in new window'), icon: 'dup', run: () => detachTab(session) },
...(DETACHED ? [{ label: T('Back to main window'), icon: 'home', run: () => attachTab(session) }] : [])] : []),
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) }, '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
{ label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) }, { label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) },
]); ]);
session.tabEl = tab; session.tabEl = tab;
tabDrag(tab, session);
if (!WEB) api.win.claim(session.id);
$('#tabs').append(tab); $('#tabs').append(tab);
$('#sessions').append(session.el); $('#sessions').append(session.el);
activateTab(session.id); activateTab(session.id);
@@ -1536,6 +1585,7 @@ function activateTab(id) {
}); });
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); $('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
renderTree(); renderTree();
if ($('#aiPanel').classList.contains('open')) aiUpdateCtx();
} }
function closeTab(id) { function closeTab(id) {
const i = S.tabs.findIndex((t) => t.id === id); const i = S.tabs.findIndex((t) => t.id === id);
@@ -1544,22 +1594,107 @@ function closeTab(id) {
t.dispose(); t.dispose();
t.el.remove(); t.tabEl.remove(); t.el.remove(); t.tabEl.remove();
S.tabs.splice(i, 1); S.tabs.splice(i, 1);
if (DETACHED && !S.tabs.length) return api.win.close();
if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home'); if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home');
} }
$('.tab.home').onclick = () => activateTab('home'); $('.tab.home').onclick = () => activateTab('home');
// ============================================================ Tabs verschieben, lösen und zurückholen (Desktop)
// Ziehen in der Tableiste = Reihenfolge ändern; aus dem Fenster ziehen = eigenes Fenster;
// in die Tableiste eines anderen MrTerm-Fensters ziehen = dorthin verschieben. Terminals behalten dabei ihre SSH-Verbindung.
const DETACHED = !WEB && new URLSearchParams(location.search).get('detached');
const DETACHABLE = new Set(WEB ? [] : ['ssh', 'sftp', 'docker', 'firewall', 'network', 'webserver', 'certs', 'samba']);
const TAB_MIME = 'application/x-mrterm-tab';
function tabHandoff(t) {
const d = { kind: t.kind, host: t.host, id: t.id, title: t.title };
if (t.kind === 'ssh') Object.assign(d, { text: t.bufferText(), alive: t.alive });
return d;
}
// Tab hier entfernen, ohne die SSH-Verbindung zu trennen (andere Tabarten verbinden im neuen Fenster neu)
function releaseTab(t) { t.keepAlive = true; closeTab(t.id); }
function adoptTab(d) {
if (d.kind === 'ssh') return new TerminalSession(d.host, d);
return ({ sftp: openSftp, ...featureOpeners() }[d.kind])?.(d.host);
}
async function detachTab(t, pos) { await api.win.detach(tabHandoff(t), pos); releaseTab(t); }
async function attachTab(t) { await api.win.attach(tabHandoff(t)); releaseTab(t); }
function tabDrag(tab, session) {
tab.draggable = true;
tab.addEventListener('dragstart', (e) => {
S.dragTab = session;
e.dataTransfer.effectAllowed = 'move';
if (DETACHABLE.has(session.kind)) e.dataTransfer.setData(TAB_MIME, JSON.stringify(tabHandoff(session)));
else e.dataTransfer.setData('text/plain', session.title);
});
tab.addEventListener('dragend', (e) => {
const t = S.dragTab; S.dragTab = null;
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
if (!t || !DETACHABLE.has(t.kind) || e.dataTransfer.dropEffect !== 'none') return;
// Außerhalb des Fensters losgelassen → eigenes Fenster an dieser Stelle
const out = e.screenX < window.screenX || e.screenX > window.screenX + window.outerWidth || e.screenY < window.screenY || e.screenY > window.screenY + window.outerHeight;
if (out) detachTab(t, { x: e.screenX, y: e.screenY });
});
}
function tabDropTarget(e) {
const tabs = $$('#tabs .tab:not(.home)');
const over = tabs.find((x) => { const r = x.getBoundingClientRect(); return e.clientX >= r.left && e.clientX <= r.right; });
if (!over) return null;
const r = over.getBoundingClientRect();
return { el: over, after: e.clientX > r.left + r.width / 2 };
}
const tabsBar = $('#tabs');
tabsBar.addEventListener('dragover', (e) => {
const local = !!S.dragTab, foreign = !local && [...e.dataTransfer.types].includes(TAB_MIME);
if (!local && !foreign) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
const t = tabDropTarget(e);
if (t && t.el !== S.dragTab?.tabEl) t.el.classList.add(t.after ? 'drop-after' : 'drop-before');
});
tabsBar.addEventListener('dragleave', (e) => { if (!tabsBar.contains(e.relatedTarget)) $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); });
tabsBar.addEventListener('drop', (e) => {
const target = tabDropTarget(e);
$$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
let moved = S.dragTab;
if (!moved) {
// Tab aus einem anderen Fenster
const raw = e.dataTransfer.getData(TAB_MIME);
if (!raw) return;
e.preventDefault();
const d = JSON.parse(raw);
api.win.released(d.id);
moved = adoptTab(d);
if (!moved) return;
} else e.preventDefault();
if (target && target.el !== moved.tabEl) target.el.insertAdjacentElement(target.after ? 'afterend' : 'beforebegin', moved.tabEl);
else if (!target) tabsBar.append(moved.tabEl);
// Reihenfolge für Strg+Tab / Strg+1..9 übernehmen
const order = $$('#tabs .tab[data-tab]').map((x) => x.dataset.tab);
S.tabs.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
activateTab(moved.id);
});
if (!WEB) {
api.on('win:adopt', (d) => { adoptTab(d); });
api.on('win:released', (id) => { const t = S.tabs.find((x) => x.id === id); if (t) releaseTab(t); });
}
// ============================================================ Terminal-Session // ============================================================ Terminal-Session
class TerminalSession { class TerminalSession {
constructor(host) { // attach: { id, title, text, alive } – bestehende SSH-Sitzung aus einem anderen Fenster übernehmen
constructor(host, attach) {
this.kind = 'ssh'; this.kind = 'ssh';
this.id = uid(); this.id = attach?.id || uid();
this.host = host; this.host = host;
this.title = host.label || host.address; this.title = attach?.title || host.label || host.address;
this.el = h(`<div class="session"> this.el = h(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div> <div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button> <button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</button>`} ${host.execCommand ? '' : enabledFeatures().map((f) => `<button class="btn ghost sm" data-feature="${f.id}" title="${esc(featureTitle(f))}">${ICONS[f.icon]}${esc(f.label())}</button>`).join('')}
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button> <button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}${T('Assistant')}</button>
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button> <button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
</div> </div>
<div class="term-wrap"><div class="term"></div> <div class="term-wrap"><div class="term"></div>
@@ -1572,7 +1707,21 @@ class TerminalSession {
this.bindUi(); this.bindUi();
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); }); this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this); addTab(this);
this.connect(); if (attach) this.adopt(attach); else this.connect();
}
adopt({ text, alive }) {
if (text) this.term.write(text.replace(/\r?\n/g, '\r\n'));
this.alive = !!alive; this.ended = !alive;
setTabState(this, alive ? 'on' : '');
requestAnimationFrame(() => { this.doFit(); if (this.alive) api.ssh.resize(this.id, this.term.cols, this.term.rows); });
}
// Gesamter Puffer als Text (beim Lösen in ein anderes Fenster; Farben gehen dabei verloren)
bufferText() {
const b = this.term.buffer.active, out = [];
for (let i = 0; i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');
while (out.length && !out[out.length - 1]) out.pop();
return out.join('\n');
} }
initTerm() { initTerm() {
@@ -1616,9 +1765,8 @@ class TerminalSession {
this.el.addEventListener('click', (e) => { this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a; const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'sftp') openSftp(this.host); if (a === 'sftp') openSftp(this.host);
if (a === 'docker') openDocker(this.host); const fid = e.target.closest('[data-feature]')?.dataset.feature;
if (a === 'firewall') openFirewall(this.host); if (fid) featureOpeners()[fid](this.host);
if (a === 'network') openNetwork(this.host);
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); } if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
if (a === 'snipnew') editSnippet(); if (a === 'snipnew') editSnippet();
if (a === 'find') this.toggleFind(); if (a === 'find') this.toggleFind();
@@ -1646,6 +1794,13 @@ class TerminalSession {
box.append(templateList(this)); box.append(templateList(this));
} }
// Letzte Zeilen des Terminals als Kontext für den Assistenten
aiContext(lines = 120) {
const b = this.term.buffer.active, out = [];
for (let i = Math.max(0, b.length - lines); i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
toggleFind(open) { toggleFind(open) {
const fb = $('.findbar', this.el); const fb = $('.findbar', this.el);
const o = open ?? !fb.classList.contains('open'); const o = open ?? !fb.classList.contains('open');
@@ -1718,10 +1873,161 @@ class TerminalSession {
this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`);
} }
} }
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); } dispose() { this.unsub(); this.ro.disconnect(); if (!this.keepAlive) api.ssh.close(this.id); this.term.dispose(); }
}
// Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
function aiMarkdown(md) {
return md.split(/```/).map((part, i) => {
if (i % 2) {
const code = part.replace(/^[\w-]*\n/, '');
return `<div class="code"><div class="acts"><button class="btn ghost sm" data-cmd="copy">${T('Copy')}</button><button class="btn ghost sm" data-cmd="paste" title="${T('Insert into terminal (not executed)')}">${T('Insert')}</button></div><pre><code>${esc(code)}</code></pre></div>`;
}
return esc(part).replace(/`([^`\n]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>');
}).join('');
} }
function openTerminal(host) { return new TerminalSession(host); } function openTerminal(host) { return new TerminalSession(host); }
// ============================================================ Support-Assistent (tabübergreifend, Chats im Tresor)
const AI = { chat: null, unsub: null, busy: false };
const aiChats = () => (S.vault.aiChats ||= []);
const activeSession = () => S.tabs.find((t) => t.id === S.active);
function aiToggle(open) {
const p = $('#aiPanel'), o = open ?? !p.classList.contains('open');
p.classList.toggle('open', o);
if (o) { aiRender(); $('textarea', p).focus(); }
}
// Kontext aus dem aktiven Tab: Terminalausgabe, Docker-/Firewall-/Netzwerkansicht …
function aiContext() {
const t = activeSession();
if (!t) return '';
const host = t.host ? `, host ${t.host.username ? t.host.username + '@' : ''}${t.host.address}` : '';
const body = String((t.aiContext ? t.aiContext() : t.el.innerText) || '').trim();
return body ? `Active MrTerm tab: ${t.title} (${t.kind}${host})\n\n${body.slice(-12000)}` : '';
}
function aiUpdateCtx() {
const t = activeSession();
$('#aiPanel .ctx-name').textContent = t ? t.title : T('Vault (no context)');
}
function aiRender() {
const p = $('#aiPanel'), sel = $('.chats', p), box = $('.msgs', p);
sel.innerHTML = '';
sel.append(new Option(T('New chat'), ''));
[...aiChats()].sort((a, b) => b.updatedAt - a.updatedAt).forEach((c) => sel.append(new Option(c.title, c.id)));
sel.value = AI.chat?.id && aiChats().includes(AI.chat) ? AI.chat.id : '';
box.innerHTML = '';
if (!AI.chat?.messages.length) box.append(h(`<div class="hint">${T('Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.')}</div>`));
for (const m of AI.chat?.messages || []) {
if (m.role === 'user') box.append(h(`<div class="m user">${m.tab ? `<small>${esc(m.tab)}</small>` : ''}${esc(m.content)}</div>`));
else { const d = h('<div class="m bot"></div>'); d.innerHTML = aiMarkdown(m.content); box.append(d); }
}
$('[data-ai=del]', p).disabled = !AI.chat?.id || !aiChats().includes(AI.chat);
aiUpdateCtx();
box.scrollTop = box.scrollHeight;
}
async function aiSave() {
const c = AI.chat;
if (!c) return;
if (!aiChats().includes(c)) aiChats().push(c);
c.updatedAt = Date.now();
try { await call('vault:upsert', 'aiChats', c); } catch {}
}
function aiSetBusy(b) {
AI.busy = b;
const btn = $('#aiPanel [type=submit]');
btn.textContent = b ? T('Stop') : T('Send');
btn.classList.toggle('danger', b);
}
async function aiAsk(text) {
const p = $('#aiPanel');
if (!p.classList.contains('open')) aiToggle(true);
if (AI.busy) return;
if (!settings().aiModel) { toast(T('Please select an Ollama model in the settings first.'), 'error'); return; }
if (!AI.chat) AI.chat = { id: uid(), title: text.replace(/\s+/g, ' ').slice(0, 60), messages: [] };
const chat = AI.chat, t = activeSession();
chat.messages.push({ role: 'user', content: text, tab: t?.title || '' });
aiRender();
const box = $('.msgs', p);
$('.hint', box)?.remove();
const out = h('<div class="m bot"><div class="spinner"></div></div>'); box.append(out);
box.scrollTop = box.scrollHeight;
const reply = { role: 'assistant', content: '' };
aiSetBusy(true);
const finish = async (err) => {
AI.unsub?.(); AI.unsub = null; aiSetBusy(false);
if (err) { out.classList.add('err'); out.textContent = err; chat.messages.pop(); }
else if (reply.content) { chat.messages.push(reply); await aiSave(); if (AI.chat === chat) aiRender(); }
else out.remove();
};
AI.unsub = api.on('ai:event', (cid, type, payload) => {
if (cid !== chat.id) return;
if (type === 'data') {
reply.content += payload;
if (AI.chat !== chat) return;
const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
out.innerHTML = aiMarkdown(reply.content);
if (stick) box.scrollTop = box.scrollHeight;
} else finish(type === 'error' ? payload : null);
});
const ctx = $('[name=ctx]', p).checked ? aiContext() : '';
try { await api.call('ai:chat', chat.id, chat.messages.slice(-20).map(({ role, content }) => ({ role, content })), ctx); }
catch (e) { finish(e.message); }
}
function initAi() {
const p = $('#aiPanel');
p.innerHTML = `<div class="head"><span>${ICONS.ai}${T('Support assistant')}</span>
<span><button class="btn ghost sm" data-ai="new" title="${T('New chat')}">${ICONS.plus}</button><button class="btn ghost sm" data-ai="del" title="${T('Delete chat')}">${ICONS.trash}</button><button class="btn ghost sm" data-ai-toggle title="${T('Close')}">${ICONS.close}</button></span></div>
<div class="bar"><select class="chats" title="${T('Saved chats')}"></select></div>
<div class="msgs"></div>
<form class="ask"><label class="check" title="${T('The content of the active tab is sent to the Ollama server.')}"><input type="checkbox" name="ctx" checked/>${T('Context:')}&nbsp;<b class="ctx-name"></b></label>
<textarea rows="3" placeholder="${T('Describe the problem … (Enter = send)')}"></textarea>
<div class="row"><button type="button" class="btn sm" data-ai="explain">${T('Explain this tab')}</button><button class="btn primary sm" type="submit">${T('Send')}</button></div></form>`;
const ta = $('textarea', p);
$('form', p).onsubmit = (e) => {
e.preventDefault();
if (AI.busy) { api.call('ai:stop', AI.chat?.id); return; }
if (ta.value.trim()) { aiAsk(ta.value.trim()); ta.value = ''; }
};
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!AI.busy) $('form', p).requestSubmit(); } });
$('.chats', p).onchange = (e) => {
if (AI.busy) api.call('ai:stop', AI.chat?.id);
AI.chat = aiChats().find((c) => c.id === e.target.value) || null;
aiRender();
};
// onclick statt addEventListener: initAi() läuft bei jedem reload(), sonst doppelte Handler (z. B. zwei Löschdialoge)
p.onclick = async (e) => {
const a = e.target.closest('[data-ai]')?.dataset.ai;
if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); }
if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) {
if (AI.busy) api.call('ai:stop', AI.chat.id);
S.vault.aiChats = aiChats().filter((c) => c !== AI.chat);
await call('vault:remove', 'aiChats', AI.chat.id).catch(() => {});
AI.chat = null; aiRender();
}
if (a === 'explain') {
const t = activeSession();
aiAsk(t?.kind === 'ssh' ? T('Explain the last error in the terminal output and how to fix it.') : T('Look at the active tab, explain what you see and point out problems.'));
}
const b = e.target.closest('[data-cmd]');
if (!b) return;
const code = b.closest('.code').querySelector('code').textContent.replace(/\n+$/, '');
const t = activeSession();
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); }
else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); }
};
if ($('#aiPanel').classList.contains('open')) aiRender();
if (AI.init) return;
AI.init = true;
document.addEventListener('click', (e) => { if (e.target.closest('[data-ai-toggle]')) aiToggle(); });
}
// ============================================================ RDP-Session (eingebettet im Tab) // ============================================================ RDP-Session (eingebettet im Tab)
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML. // Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist. // Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
@@ -1802,6 +2108,10 @@ class RdpSession {
this.overlay?.remove(); this.overlay = null; this.overlay?.remove(); this.overlay = null;
setTabState(this, 'on'); setTabState(this, 'on');
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id); if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
} else if (type === 'resizing') {
this.attached = false;
setTabState(this, 'wait');
this.showOverlay(T('Adjusting resolution …'));
} else if (type === 'exit') { } else if (type === 'exit') {
this.attached = false; this.exited = true; this.attached = false; this.exited = true;
setTabState(this, payload?.code ? 'err' : ''); setTabState(this, payload?.code ? 'err' : '');
@@ -1845,7 +2155,7 @@ class FilePane {
<input class="path" spellcheck="false"/> <input class="path" spellcheck="false"/>
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button> <button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button> <button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div> ${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `${side === 'remote' ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload files …')}">${ICONS.upload}</button>` : ''}<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div>
<div class="files"></div></div>`); <div class="files"></div></div>`);
this.el.addEventListener('click', (e) => { this.el.addEventListener('click', (e) => {
const p = e.target.closest('[data-p]')?.dataset.p; const p = e.target.closest('[data-p]')?.dataset.p;
@@ -1872,7 +2182,14 @@ class FilePane {
}); });
} }
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; } get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
pickUpload() { async pickUpload() {
if (!WEB) {
// Desktop: Dateiauswahl des Systems, Upload in den aktuellen Server-Ordner
const files = await call('sftp:pickUpload');
for (const f of files) await this.sftp.transfer('upload', f.path, await this.join(this.cwd, f.name), f.name);
if (files.length) this.go(this.cwd);
return;
}
const inp = h('<input type="file" multiple style="display:none"/>'); const inp = h('<input type="file" multiple style="display:none"/>');
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); }; inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
document.body.append(inp); document.body.append(inp);
@@ -2086,14 +2403,26 @@ class DockerSession {
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true; this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
this.el = h(`<div class="session docker"> this.el = h(`<div class="session docker">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div> <div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search containers …'))}"/></label> <div class="seg sm"><button class="active" data-v="containers">${T('Containers')}</button><button data-v="stacks">Stacks</button></div>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label> <label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search …'))}"/></label>
<label class="check stopped-opt" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
<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> <button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
</div> </div>
<div class="docker-body"><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(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`); <div class="docker-body"><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(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
this.body = $('.docker-body', this.el); this.body = $('.docker-body', this.el);
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); }; $('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); }); this.view = 'containers'; this.stackInfo = null; this.editing = null;
this.el.addEventListener('click', (e) => {
if (e.target.closest('[data-a=refresh]')) this.refresh(true);
const v = e.target.closest('.seg [data-v]')?.dataset.v;
if (v && v !== this.view) {
this.view = v; this.editing = null;
$$('.seg [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v));
$('.stopped-opt', this.el).style.display = v === 'containers' ? '' : 'none';
if (v === 'stacks') this.loadStacks(true); else this.draw();
}
});
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); }; $('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } }); this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this); addTab(this);
@@ -2128,7 +2457,11 @@ class DockerSession {
async refresh(withStats) { async refresh(withStats) {
if (this.busy) return; if (this.busy) return;
this.busy = true; this.busy = true;
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); } try {
this.apply(await api.call('docker:list', this.id));
if (withStats) this.loadStats();
if (this.view === 'stacks' && !this.editing) await this.loadStacks();
} catch (e) { if (withStats) toast(e.message, 'error'); }
this.busy = false; this.busy = false;
} }
@@ -2137,6 +2470,7 @@ class DockerSession {
} }
draw() { draw() {
if (this.view === 'stacks') return this.editing ? null : this.drawStacks();
const running = (c) => c.state === 'running'; const running = (c) => c.state === 'running';
const list = this.containers const list = this.containers
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter)))) .filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
@@ -2189,6 +2523,212 @@ class DockerSession {
await this.refresh(true); await this.refresh(true);
} }
// ---------- Compose-Stacks
get stacksDir() { return settings().dockerStacksDir || '/opt/stacks'; }
async loadStacks(showErrors) {
try { this.stackInfo = await api.call('docker:stacks', this.id, this.stacksDir); this.stackError = ''; }
catch (e) { this.stackError = e.message; if (showErrors) this.stackInfo = null; }
if (this.view === 'stacks' && !this.editing) this.drawStacks();
}
drawStacks() {
const info = this.stackInfo;
if (!info) {
this.body.innerHTML = `<div class="pane-empty">${this.stackError ? `<div style="color:var(--red);max-width:520px;text-align:center">${esc(this.stackError)}</div>` : '<span class="spinner sm"></span>'}</div>`;
return;
}
const list = info.stacks.filter((x) => !this.filter || [x.name, x.file, typeof x.git === 'string' ? x.git : ''].some((f) => String(f).toLowerCase().includes(this.filter)));
const scroll = this.body.scrollTop;
const needSetup = !info.exists || !info.writable;
this.body.innerHTML = `<div class="stack-bar">
<span class="muted">${T('Folder')}: <code>${esc(info.dir)}</code></span>
<button class="btn ghost sm" data-s="dir" title="${T('Change folder')}">${ICONS.edit}</button>
${needSetup ? `<button class="btn sm" data-s="setup" title="${esc(T('Creates the folder with sudo and makes it writable for {user}.', { user: this.host.username || '$USER' }))}">${ICONS.newdir}${T('Set up folder')}</button>` : ''}
<span style="flex:1"></span>
<button class="btn sm" data-s="templates">${ICONS.download}${T('Templates')}</button>
<button class="btn sm" data-s="git">${ICONS.code}${T('From Git')}</button>
<button class="btn primary sm" data-s="new">${ICONS.plus}${T('New stack')}</button>
</div>
${list.length ? `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Compose file')}</th><th></th></tr></thead><tbody>
${list.map((x, i) => {
const on = /running/.test(x.status);
return `<tr data-i="${i}"><td><span class="dot ${on ? 'on' : x.status ? '' : 'none'}"></span></td>
<td class="name"><div><b>${esc(x.name)}${x.git ? ` <span class="badge" title="${esc(typeof x.git === 'string' ? x.git : 'git')}">git</span>` : ''}${x.managed ? '' : ` <span class="badge" title="${esc(T('Found via docker compose ls, outside the stacks folder'))}">${T('external')}</span>`}</b></div></td>
<td class="muted">${esc(x.status || T('not deployed'))}</td><td class="muted ports" title="${esc(x.file)}">${esc(x.file || T('no compose file found'))}</td>
<td class="acts">${x.file ? `
<button class="btn ghost sm" data-k="edit" title="${T('Edit')}">${ICONS.edit}</button>
<button class="btn ghost sm" data-k="up" title="${T('Deploy (up -d)')}">${ICONS.play}</button>
${on ? `<button class="btn ghost sm" data-k="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-k="stop" title="${T('Stop')}">${ICONS.stop}</button>` : ''}
<button class="btn ghost sm" data-k="logs" title="${T('Logs')}">${ICONS.logs}</button>` : ''}
${x.managed ? `<button class="btn ghost sm" data-k="delete" title="${T('Delete')}">${ICONS.trash}</button>` : ''}
</td></tr>`;
}).join('')}</tbody></table>`
: `<div class="pane-empty" style="height:auto;padding:60px 0"><div style="color:var(--muted)">${this.filter ? T('No matches.') : T('No stacks yet. Create one, clone it from Git or start from a template.')}</div></div>`}`;
this.body.scrollTop = scroll;
$$('[data-s]', this.body).forEach((b) => { b.onclick = () => this.stackBar(b.dataset.s); });
$$('tr[data-i]', this.body).forEach((tr) => {
const x = list[+tr.dataset.i];
tr.onclick = (e) => { const k = e.target.closest('[data-k]')?.dataset.k; if (k) this.stackAct(k, x); };
tr.ondblclick = (e) => { if (!e.target.closest('[data-k]') && x.file) this.stackAct('edit', x); };
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(x.file ? [
{ label: T('Edit'), icon: 'edit', run: () => this.stackAct('edit', x) },
{ label: T('Deploy (up -d)'), icon: 'play', run: () => this.stackAct('up', x) },
{ label: T('Update images (pull + up)'), icon: 'download', run: () => this.stackAct('pull', x) },
...(x.git ? [{ label: T('Git pull + redeploy'), icon: 'code', run: () => this.stackAct('gitpull', x) }] : []),
{ label: T('Restart'), icon: 'refresh', run: () => this.stackAct('restart', x) },
{ label: T('Stop'), icon: 'stop', run: () => this.stackAct('stop', x) },
{ label: T('Down (remove containers)'), icon: 'close', run: () => this.stackAct('down', x) },
{ label: T('Logs'), icon: 'logs', run: () => this.stackAct('logs', x) },
'-', { label: T('Copy path'), icon: 'copy', run: () => api.call('clipboard:write', x.file) },
] : []),
...(x.managed ? ['-', { label: T('Delete'), icon: 'trash', danger: true, run: () => this.stackAct('delete', x) }] : []),
]);
});
}
// Befehl in einem Terminal-Tab ausführen (Live-Ausgabe, sudo/git-Abfragen möglich)
async stackTerminal(action, st, opts, label) {
const cmd = await call('docker:stackCommand', this.id, action, st, opts);
openTerminal({ ...this.host, execCommand: cmd, label: `${label} · ${st.name || st.dir}` });
}
async stackBar(a) {
if (a === 'new') return this.editStack({ name: '', compose: 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n', env: '' });
if (a === 'dir') {
const d = await promptBox(T('Stacks folder'), T('Folder on the host in which each stack gets its own subfolder'), this.stacksDir);
if (!d || !d.startsWith('/')) return;
await call('vault:settings', { dockerStacksDir: d.replace(/\/+$/, '') || '/' });
S.vault.settings.dockerStacksDir = d.replace(/\/+$/, '') || '/';
return this.loadStacks(true);
}
if (a === 'setup') return this.stackTerminal('setup', { dir: this.stacksDir }, {}, T('Set up folder'));
if (a === 'git') {
const r = await modal({
title: T('Stack from Git'),
body: `${field(T('Repository URL'), 'url', '', { placeholder: 'https://github.com/user/repo.git' }).outerHTML}
${row(field(T('Stack name'), 'name', '', { placeholder: 'my-app' }), field(T('Branch (optional)'), 'branch', '')).outerHTML}
${field(T('Compose file in the repository (optional)'), 'path', '', { placeholder: 'docker-compose.yml', hint: T('If empty, the first compose file found is used.') }).outerHTML}
${check(T('Deploy right after cloning'), 'deploy', true).outerHTML}
<div class="hint" style="color:var(--muted);font-size:12px">${T('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.')}</div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Clone'), value: 'form', cls: 'primary' }],
});
if (!r) return;
const name = (r.name || r.url.split('/').pop().replace(/\.git$/, '')).toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return this.stackTerminal('clone', { name, stacksDir: this.stacksDir }, { url: r.url.trim(), branch: r.branch.trim(), path: r.path.trim(), deploy: r.deploy }, 'git clone').catch(() => {});
}
if (a === 'templates') return this.pickTemplate();
}
async pickTemplate() {
const tplUrl = settings().dockerTemplatesUrl || '';
const done = modal({
title: T('App templates'),
body: `<div class="tpl-top"><label class="search sm" style="flex:1">${ICONS.search}<input class="tpl-q" placeholder="${esc(T('Search templates …'))}"/></label><select class="tpl-cat"></select></div>
<div class="tpl-list"><div class="pane-empty" style="padding:40px 0"><span class="spinner sm"></span></div></div>
<div class="hint" style="color:var(--faint);font-size:11px;margin-top:6px">${T('Source: {url} (Portainer template format, changeable in the settings).', { url: esc(tplUrl || 'Lissy93/portainer-templates') })}</div>`,
buttons: [{ label: T('Close'), value: null, cls: 'ghost' }],
noEnter: true,
});
const root = $$('#modalRoot .modal-bg').pop();
$('.modal', root).classList.add('wide');
let list = [];
try { list = await api.call('docker:templates', tplUrl); } catch (e) { $('.tpl-list', root).innerHTML = `<div style="color:var(--red);padding:20px">${esc(e.message)}</div>`; return done; }
const cats = [...new Set(list.flatMap((t) => t.categories))].sort();
$('.tpl-cat', root).innerHTML = `<option value="">${T('All categories')}</option>${cats.map((c) => `<option>${esc(c)}</option>`).join('')}`;
const render = () => {
const q = $('.tpl-q', root).value.toLowerCase(), cat = $('.tpl-cat', root).value;
const f = list.filter((t) => (!cat || t.categories.includes(cat)) && (!q || `${t.title} ${t.description}`.toLowerCase().includes(q))).slice(0, 200);
$('.tpl-list', root).innerHTML = f.map((t) => `<div class="tpl-it" data-i="${t.i}"><div><b>${esc(t.title)}</b> <span class="badge">${t.kind === 'stack' ? 'Stack' : 'Container'}</span></div><div class="d">${esc(t.description)}</div></div>`).join('') || `<div style="color:var(--muted);padding:20px">${T('No matches.')}</div>`;
};
$('.tpl-q', root).oninput = render;
$('.tpl-cat', root).onchange = render;
$('.tpl-list', root).onclick = async (e) => {
const it = e.target.closest('.tpl-it');
if (!it) return;
it.style.opacity = '.5';
try {
const t = await api.call('docker:template', +it.dataset.i, tplUrl);
$('.mfoot .btn', root).click();
this.editStack({ name: t.name, compose: t.compose, env: t.env, note: t.note });
} catch (err) { it.style.opacity = ''; toast(err.message, 'error'); }
};
render();
$('.tpl-q', root).focus();
return done;
}
async stackAct(k, x) {
try {
if (k === 'edit') {
const r = await call('docker:stackRead', this.id, x.file);
return this.editStack({ ...x, ...r });
}
if (k === 'delete') {
const r = await modal({
title: T('Delete stack?'),
text: T('The stack “{name}” is stopped (down) and its folder {dir} is deleted.', { name: x.name, dir: x.dir }),
body: check(T('Also delete named volumes (data!)'), 'volumes', false).outerHTML,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Delete'), value: 'form', cls: 'danger' }],
});
if (!r) return;
return await this.stackTerminal('delete', x, { volumes: r.volumes }, T('Delete'));
}
if (k === 'down' && !(await confirmBox(T('Remove containers?'), T('docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.', { name: x.name }), T('Down')))) return;
const labels = { up: 'up', pull: 'pull', gitpull: 'git pull', restart: 'restart', stop: 'stop', down: 'down', logs: T('Logs') };
await this.stackTerminal(k, x, {}, labels[k] || k);
} catch {}
}
aiContext() {
if (!this.editing || !$('.se-compose', this.body)) return this.body.innerText;
const env = $('.se-env', this.body).value.replace(/^(\s*[^#=\s]+\s*=).*$/gm, '$1***');
return `Editing compose stack "${this.editing.name || ''}"\n\ncompose.yaml:\n${$('.se-compose', this.body).value}\n\n.env (values hidden):\n${env}`;
}
// Editor für compose.yaml und .env; st.file fehlt bei neuen Stacks
editStack(st) {
this.editing = st;
const isNew = !st.file;
this.body.innerHTML = `<div class="stack-editor">
<div class="se-head">
<button class="btn ghost sm" data-e="back">← ${T('Back')}</button>
${isNew ? `<input class="se-name" placeholder="${esc(T('Stack name'))}" value="${esc(st.name || '')}" spellcheck="false"/>` : `<b>${esc(st.name)}</b><span class="muted mono" style="font-size:12px">${esc(st.file)}</span>`}
<span style="flex:1"></span>
<button class="btn sm" data-e="save">${T('Save')}</button>
<button class="btn primary sm" data-e="deploy">${ICONS.play}${T('Save & deploy')}</button>
</div>
${st.note ? `<div class="se-note">${esc(st.note)}</div>` : ''}
<div class="se-grid">
<div class="se-col"><label>${isNew ? 'compose.yaml' : esc(st.file.split('/').pop())}</label><textarea class="se-compose mono" spellcheck="false"></textarea></div>
<div class="se-col env"><label>.env <span class="muted">${T('(variables, used as ${NAME} in the compose file)')}</span></label><textarea class="se-env mono" spellcheck="false" placeholder="KEY=value"></textarea></div>
</div></div>`;
const ta = $('.se-compose', this.body), env = $('.se-env', this.body);
ta.value = st.compose || ''; env.value = st.env || '';
for (const t of [ta, env]) t.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); t.setRangeText(' ', t.selectionStart, t.selectionEnd, 'end'); }
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(false); }
});
const save = async (deploy) => {
const name = isNew ? $('.se-name', this.body).value.trim() : st.name;
const btns = $$('[data-e]', this.body); btns.forEach((b) => { b.disabled = true; });
try {
const r = await call('docker:stackSave', this.id, { stacksDir: this.stacksDir, name, file: st.file, compose: ta.value, env: env.value });
if (r.warning) toast(T('Saved, but the compose file has an error: {msg}', { msg: r.warning }), 'error');
else toast(T('Saved'), 'ok');
Object.assign(st, { file: r.file, name, dir: r.file.replace(/\/[^/]*$/, ''), managed: true, compose: ta.value, env: env.value });
if (deploy && !r.warning) await this.stackTerminal('up', st, {}, 'up');
if (isNew || deploy) { this.editing = null; await this.loadStacks(true); return; }
} catch {}
btns.forEach((b) => { b.disabled = false; });
};
$('[data-e=back]', this.body).onclick = () => { this.editing = null; this.loadStacks(true); };
$('[data-e=save]', this.body).onclick = () => save(false);
$('[data-e=deploy]', this.body).onclick = () => save(true);
(isNew && !st.name ? $('.se-name', this.body) : ta).focus();
}
onShow() { if (this.containers.length) this.refresh(); } onShow() { if (this.containers.length) this.refresh(); }
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); } dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
} }
@@ -2204,6 +2744,7 @@ class FirewallSession {
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div> <div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<div class="seg sm backends"></div> <div class="seg sm backends"></div>
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button> <button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
<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> <button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button> <button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
</div> </div>
@@ -2397,6 +2938,7 @@ class NetworkSession {
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div> <div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label> <label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button> <button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
<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> <button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button> <button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
</div> </div>
@@ -2661,9 +3203,7 @@ function openPalette() {
hosts.slice(0, 12).forEach((x) => { hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) }); items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) }); if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) }); if (x.protocol !== 'rdp' && q) enabledFeatures().forEach((f) => items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:${f.color}">${ICONS[f.icon]}</div>`, label: `${f.label()}: ${x.label || x.address}`, run: () => f.open(x) }));
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.wall}</div>`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent)">${ICONS.network}</div>`, label: `${T('Network')}: ${x.label || x.address}`, run: () => openNetwork(x) });
}); });
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) })); S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]] [[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
@@ -2748,5 +3288,7 @@ api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch
api.on('toast', (m, t) => toast(m, t)); api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; }); api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
// Abgelöstes Fenster: nur Tabs, ohne Seitenleiste und Tresor-Tab; den übergebenen Tab übernehmen
if (DETACHED) document.body.classList.add('detached');
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo); $('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
+6 -1
View File
@@ -17,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>
@@ -48,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>
@@ -66,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

+152 -6
View File
@@ -24,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; }
@@ -198,6 +205,31 @@ body.in-session #sidebar { display: none; }
.snip-tpl .it:hover { background: var(--card-hover); } .snip-tpl .it:hover { background: var(--card-hover); }
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; } .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; }
@@ -292,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; }
@@ -336,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); }
@@ -415,3 +470,94 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
/* Web-Version (Browser): Desktop-Funktionen ausblenden */ /* 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; } .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; }
+296
View File
@@ -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);