From fd155fe2dbbd1b3a81e334e3cdbbce015e169a5b Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 16 Oct 2026 15:58:18 +0200 Subject: [PATCH] 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 --- src/core/backend.js | 11 + src/i18n.js | 344 +++++++++++++++ src/main/admin.js | 112 +++++ src/main/certs.js | 180 ++++++++ src/main/main.js | 20 +- src/main/samba.js | 347 +++++++++++++++ src/main/store.js | 3 + src/main/webserver.js | 288 ++++++++++++ src/renderer/admin-tabs.js | 875 +++++++++++++++++++++++++++++++++++++ src/renderer/app.js | 29 +- src/renderer/index.html | 3 + src/renderer/styles.css | 91 ++++ src/renderer/webconf.js | 296 +++++++++++++ 13 files changed, 2585 insertions(+), 14 deletions(-) create mode 100644 src/main/admin.js create mode 100644 src/main/certs.js create mode 100644 src/main/samba.js create mode 100644 src/main/webserver.js create mode 100644 src/renderer/admin-tabs.js create mode 100644 src/renderer/webconf.js diff --git a/src/core/backend.js b/src/core/backend.js index 3b4ac92..527569d 100644 --- a/src/core/backend.js +++ b/src/core/backend.js @@ -11,6 +11,9 @@ const { SshManager } = require('../main/ssh'); const { DockerManager } = require('../main/docker'); const { FirewallManager } = require('../main/firewall'); const { NetworkConfigManager } = require('../main/network'); +const { WebServerManager } = require('../main/webserver'); +const { CertManager, probe: probeTls } = require('../main/certs'); +const { SambaManager } = require('../main/samba'); const { AiAssistant } = require('../main/ai'); const i18n = require('../i18n'); const backup = require('./backup'); @@ -35,6 +38,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa const docker = new DockerManager(ssh); const firewall = new FirewallManager(ssh); const network = new NetworkConfigManager(ssh); + const admins = { web: new WebServerManager(ssh), certs: new CertManager(ssh), samba: new SambaManager(ssh) }; const ai = new AiAssistant(store, send); let sync = null; if (withSync) { @@ -207,6 +211,13 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa handle('network:readFile', (id, path) => network.readFile(id, path)); handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify)); handle('network:close', (id) => network.close(id)); + for (const [p, m] of Object.entries(admins)) { + handle(`${p}:open`, (id, ref) => m.open(id, hostWithOverrides(ref), () => send(`${p}:closed`, id))); + handle(`${p}:read`, (id) => m.read(id)); + handle(`${p}:op`, (id, op, ...args) => m.op(id, op, ...args)); + handle(`${p}:close`, (id) => m.close(id)); + } + handle('certs:probe', (host, port) => probeTls(host, port)); // ---------- Support-Assistent (Ollama) ---------- handle('ai:models', (url) => ai.models(url)); diff --git a/src/i18n.js b/src/i18n.js index 1feb7cb..871f7af 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -816,6 +816,350 @@ '~/.ssh/config not found': '~/.ssh/config nicht gefunden', 'Host not found': 'Host 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/.crt and .key. Browsers show a warning for self-signed certificates.': 'Wird unter /etc/ssl/mrterm/.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} ': 'Lege die Passwortdatei auf dem Server an mit: htpasswd -c {file} ', + '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/)": 'Let’s Encrypt (certbot, /etc/letsencrypt/live/)', + '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 launch failed: {msg}': 'RDP-Start fehlgeschlagen: {msg}', 'xfreerdp3 missing (sudo pacman -S freerdp)': 'xfreerdp3 fehlt (sudo pacman -S freerdp)', diff --git a/src/main/admin.js b/src/main/admin.js new file mode 100644 index 0000000..a02fcfc --- /dev/null +++ b/src/main/admin.js @@ -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 }; diff --git a/src/main/certs.js b/src/main/certs.js new file mode 100644 index 0000000..b6f6224 --- /dev/null +++ b/src/main/certs.js @@ -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/.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 }; diff --git a/src/main/main.js b/src/main/main.js index 815ba1c..6a4726d 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -15,6 +15,9 @@ const { VpnManager } = require('./vpn'); const { DockerManager } = require('./docker'); const { FirewallManager } = require('./firewall'); const { NetworkConfigManager } = require('./network'); +const { WebServerManager } = require('./webserver'); +const { CertManager, probe: probeTls } = require('./certs'); +const { SambaManager } = require('./samba'); const { SyncService } = require('./sync'); const backup = require('../core/backup'); const composeTemplates = require('./compose-templates'); @@ -89,6 +92,8 @@ const vpn = new VpnManager(store, app.getPath('userData')); const docker = new DockerManager(ssh); const firewall = new FirewallManager(ssh); const network = new NetworkConfigManager(ssh); +// Verwaltungs-Tabs mit gemeinsamen Kanälen :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 @@ -153,7 +158,7 @@ function openTabWindow(data, pos) { for (const [id, o] of sessionOwner) { if (o !== w) continue; sessionOwner.delete(id); - for (const m of [ssh, docker, firewall, network]) { try { m.close(id); } catch {} } + for (const m of [ssh, docker, firewall, network, ...Object.values(admins)]) { try { m.close(id); } catch {} } } }); } @@ -321,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: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 ---------- handle('sync:status', () => sync.status()); handle('sync:enable', (on, name) => sync.setEnabled(on, name)); diff --git a/src/main/samba.js b/src/main/samba.js new file mode 100644 index 0000000..a4036ea --- /dev/null +++ b/src/main/samba.js @@ -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 }; diff --git a/src/main/store.js b/src/main/store.js index accaf63..fe6e735 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -45,6 +45,9 @@ const DEFAULTS = { dockerTemplatesUrl: '', aiUrl: 'http://localhost:11434', aiModel: '', + // Feature-Menüs: { docker: false, … } blendet einzelne aus, fehlende Einträge = an + features: {}, + homeAnimation: true, }, }; diff --git a/src/main/webserver.js b/src/main/webserver.js new file mode 100644 index 0000000..751ad45 --- /dev/null +++ b/src/main/webserver.js @@ -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 '/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 = `\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\n`; + const dir = ` DocumentRoot ${o.root}\n \n Options -Indexes +FollowSymLinks\n AllowOverride All\n Require all granted\n \n`; + if (o.type === 'php') return `${head}${dir} DirectoryIndex index.php index.html\n \n SetHandler "proxy:unix:${o.sock}|fcgi://localhost"\n \n\n`; + return `${head}${dir}\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' '${title}

${title}

Served by MrTerm

' > ${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 }; diff --git a/src/renderer/admin-tabs.js b/src/renderer/admin-tabs.js new file mode 100644 index 0000000..09a62f1 --- /dev/null +++ b/src/renderer/admin-tabs.js @@ -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: '', + cert: '', + users: '', +}); + +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(`

${T('Feature menus')}

${T('Choose which management tabs are offered for SSH hosts (host menu, terminal toolbar and quick search).')}

`); + for (const f of FEATURES) { + const el = h(``); + $('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 `${esc(label)}`; +}; +const daysLeft = (t) => (t ? Math.floor((t - Date.now()) / 86400000) : null); +function expiryHtml(t) { + if (!t) return '–'; + const d = daysLeft(t); + const cls = d < 0 ? 'err' : d < 14 ? 'err' : d < 30 ? 'warn' : 'ok'; + return `${esc(fmtDate(t))}${d < 0 ? esc(T('expired')) : esc(T('{n} days', { n: d }))}`; +} +const spinnerHtml = (text) => `
${esc(text)}
`; + +// 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(`
+
${avatar(host, 22)}${esc(hostSub(host))}
+
${views.map(([v, l]) => ``).join('')}
+ + +
+
${spinnerHtml(T('Connecting to {host} …', { host: host.address }))}
`); + 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 = `
${esc(msg)}
`; + const b = h(``); + 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('
'); + this.body.append(wrap); + this[`draw_${this.view}`](wrap); + this.body.scrollTop = scroll; + } + + section(title, actions = []) { + const sec = h(`

${esc(title)}

`); + for (const a of actions) { + const b = h(``); + 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(`${cols.map(([c, , cls]) => ``).join('')}${acts ? '' : ''}
${esc(c)}
`); + for (const r of rows) { + const tr = h(`${cols.map(([, f, cls]) => `${f(r)}`).join('')}${acts ? '' : ''}`); + if (acts) for (const a of acts(r).filter(Boolean)) { + const b = h(``); + b.onclick = a.run; + $('.acts', tr).append(b); + } + $('tbody', t).append(tr); + } + return t; + } + + empty(text) { return h(`
${esc(text)}
`); } + + // Dienst-Zeile mit Start/Stopp/Neustart/Neu laden und Autostart + serviceButtons(u, opName = 'service', withReload = true) { + const box = h(''); + const run = (action, msg) => this.act(opName, [u.unit, action], msg); + const btn = (icon, title, fn, cls = '') => { const b = h(``); 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(``); + $('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(`
${esc(path)} +
+ ${note ? `
${esc(note)}
` : ''} +
`); + 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(`
+
+
+
+
+
+
+
${['nginx', 'apache', 'caddy'].map((x) => ``).join('')}
+ + +
+
+

+      
`); + 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(`

${esc(T(glabel))}

`); + for (const opt of W.OPTIONS.filter((x) => x.group === gid)) { + const v = g.o[opt.id]; + let input; + if (opt.type === 'bool') input = ``; + else if (opt.type === 'select') input = ``; + else if (opt.type === 'textarea') input = ``; + else input = ``; + const only = opt.only ? `${esc(opt.only.map((x) => SRV_LABEL[x]).join(', '))}` : ''; + const row = h(`
${input}
`); + 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} ', { file: g.o.authFile })); + $('.gen-note', box).innerHTML = notes.map((n) => `
${esc(n)}
`).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('
'); + for (const srv of d.servers) { + const card = h(`
${ICONS.globe}${esc(srv.label)}${srv.installed ? pill(srv.active) : `${T('not installed')}`}
+ ${srv.installed ? `
${esc(srv.version)} · ${esc(srv.unit)}
` : ''}
`); + const acts = $('.ws-acts', card); + if (srv.installed) { + acts.append(this.serviceButtons(srv)); + const test = h(``); + 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: `
${esc(r.output || 'OK')}
`, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] }); } catch {} }; + const logs = h(``); + logs.onclick = () => this.term('logsCommand', [srv.id], `${T('Logs')} ${srv.label}`); + acts.append(test, logs); + } else { + const inst = h(``); + 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(`
${ICONS.wall}
${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(', ') })}
`)); + + 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(`
${esc(d.php.version)}
`)); + if (d.php.pools.length) php.append(this.table(d.php.pools, [[T('Service'), (u) => `${esc(u.unit)}`], [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(`
${T('Sockets')}: ${d.php.sockets.map(esc).join(', ')}
`)); + 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) => ``, 'w-dot'], + [T('Server'), (x) => esc(SRV_LABEL[x.server])], + [T('Site'), (x) => `${esc(x.name)}
${esc(x.path)}${x.line ? `:${x.line}` : ''}
`, 'name'], + [T('Domains'), (x) => x.domains.map((n) => `${esc(n)}`).join(' ') || '–'], + ], (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(`
${T('Every change is checked with the configuration test of the web server before reloading. If the test fails, the previous state is restored.')}
`)); + } + + 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('
'); + for (const f of files) { const b = h(``); 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: ` +
+
+
+
+
+ + + + `, + 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('
'); + head.append(h(`
${cb.version ? esc(cb.version) : `${T('not installed')}`}
`)); + const timer = d.timers.find((t) => t.unit.endsWith('.timer')); + const cron = d.timers.find((t) => t.unit === 'cron'); + const renew = h(`
`); + if (timer?.enabled === 'enabled' || cron) $('.v', renew).innerHTML = `${pill(timer?.active || 'active')} ${esc(timer?.unit || '/etc/cron.d/certbot')}`; + else if (cb.version) { + $('.v', renew).innerHTML = `${T('not set up')} `; + const b = h(``); + b.onclick = () => this.act('enableAutoRenew', [timer?.unit || 'certbot.timer'], T('Automatic renewal enabled')); + $('.v', renew).append(b); + } else $('.v', renew).innerHTML = '–'; + head.append(renew); + const acts = h('
'); + const btn = (label, icon, run, cls = '') => { const b = h(``); 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) => `${esc(c.name)}
${esc(c.keyType || '')}
`, 'name'], + [T('Domains'), (c) => c.domains.map((x) => `${esc(x)}`).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) => `${esc(c.domain)}`, 'name'], ['SAN', (c) => c.sans.map((x) => `${esc(x)}`).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) => `${esc(c.path)}
${esc(c.issuer)}
`, 'name'], + [T('Domains'), (c) => (c.sans.length ? c.sans : [c.subject]).map((x) => `${esc(x)}`).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: `
${esc(text)}
`, 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: ` +
+
+ +
+
+
+ + +
${T('certbot runs in a terminal tab so you can follow the output and answer questions.')}
`, + 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: ` +
+
+
+
+
${T('Stored in /etc/ssl/mrterm/.crt and .key. Browsers show a warning for self-signed certificates.')}
`, + 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(`
`); + const go = h(``); + const out = h('
'); + 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 = `
${r.authorized ? `${T('trusted')}` : `${esc(r.error || T('not trusted'))}`}
+
${expiryHtml(r.expires)}
${esc(r.issuer)}
+
${esc(r.subject)}
${esc(r.protocol)}
+
${r.sans.map((x) => `${esc(x)}`).join(' ')}
+
${esc(r.fingerprint)}
`; + } catch (e) { out.innerHTML = `
${esc(e.message)}
`; } + }; + form.append(go); + sec.append(form, h(`
${T('The connection is made from this computer – useful to check which certificate a web server actually delivers.')}
`), 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(`
${ICONS.users}${T('Samba is not installed')}${T('Install Samba to share folders with Windows clients or to run a domain controller.')}
`); + const b = h(``); + 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(`
${esc(d.version || '–')}
+
${esc(d.role || '–')}
+
${esc(g.workgroup || '–')}
+ ${g.realm ? `
${esc(g.realm)}
` : ''}
`)); + } + + 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) => `${esc(s.name)}${s.comment ? `
${esc(s.comment)}
` : ''}`, 'name'], + [T('Path'), (s) => `${esc(s.path || '–')}`], + [T('Access'), (s) => `${YES(s.readOnly) ? T('read only') : T('read/write')}${YES(s.guestOk) ? ` ${T('guest')}` : ''}${YES(s.browseable) ? '' : ` ${T('hidden')}`}`], + [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: ` +
+
+
+
+ +
+
+
+
+
+ `, + 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(`
${ICONS.users}
${T('This server is an Active Directory domain controller. Users are managed in the “Domain” view.')}
`)); + 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) => `${esc(u.user)}${u.fullName ? `
${esc(u.fullName)}
` : ''}`, 'name'], + [T('Status'), (u) => (u.disabled ? `${T('disabled')}` : `${T('active')}`)], + [T('Flags'), (u) => `${esc(u.flags)}`], + ], (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: `
`, + 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: ` +
+
+ `, + 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) => `${esc(u.unit)}`], [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(`
${esc(st.raw || T('No connections.'))}
`)); + else if (!st.sessions.length) con.append(this.empty(T('No connections.'))); + else con.append(this.table(st.sessions, [[T('User'), (x) => `${esc(x.user)}`], [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) => `${esc(t.service)}`).join(' ')]])); + w.append(con); + if (st.files?.length) { + const f = this.section(T('Open files')); + f.append(this.table(st.files, [[T('File'), (x) => `${esc(x.path)}`]])); + w.append(f); + } + } + + async draw_domain(w) { + const d = this.data; + if (!d.dc) return this.drawMembership(w); + const seg = h(`
${[['users', T('Users')], ['groups', T('Groups')], ['computers', T('Computers')], ['info', T('Domain info')]].map(([v, l]) => ``).join('')}
`); + 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(`
${spinnerHtml(T('Loading …'))}
`); + 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(`
${ICONS.wall}
${T('ldbsearch is missing (package ldb-tools), so only names are shown.')}
`)); + 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) => `${esc(u.name)}${u.display ? `
${esc(u.display)}
` : ''}`, 'name'], + [T('E-mail'), (u) => esc(u.mail || '')], + [T('Groups'), (u) => (u.groups || []).slice(0, 4).map((g) => `${esc(g)}`).join(' ') + ((u.groups || []).length > 4 ? ` +${u.groups.length - 4}` : '')], + [T('Last logon'), (u) => (u.lastLogon ? esc(relTime(u.lastLogon)) : '–')], + [T('Status'), (u) => (u.disabled ? `${T('disabled')}` : `${T('active')}`)], + ], (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: `
`, 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) => `${esc(g.name)}${g.description ? `
${esc(g.description)}
` : ''}`, '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) => `${esc(c.name)}${c.dns ? `
${esc(c.dns)}
` : ''}`, 'name'], + [T('Operating system'), (c) => esc(c.os || '')], + [T('Last logon'), (c) => (c.lastLogon ? esc(relTime(c.lastLogon)) : '–')], + ], (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(`
${esc([ad.info, ad.level, ad.fsmo].filter(Boolean).join('\n\n'))}
`)); + } + } + + // 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(``); b.onclick = run; return b; }; + + const mem = this.section(T('Domain membership')); + if (joined) { + mem.append(h(`
${ICONS.users}
${T('This server is a member of the domain {domain}.', { domain: `${esc(joined)}` })}
`)); + mem.append(h(`
${esc(d.realm)}
`)); + const acts = h('
'); + 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(`
winbind: ${esc(d.winbind)}
`)); + const acts = h('
'); + 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(`
${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.')}
`)); + const acts = h('
'); + 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: ` +
+
+
${T('The server must resolve the domain via DNS (the domain controller as DNS server). Logins of domain users are then handled by sssd.')}
`, + 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: ` +
+
+
+
${T('Queries for other domains are forwarded to this DNS server.')}
+ + +
${ICONS.wall}
${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).')}
`, + 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: ` +
+
+
+
+ `, + 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: `
`, + 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: `
+ ${users.length ? `
${T('Users')}: ${users.slice(0, 40).map(esc).join(', ')}${users.length > 40 ? ' …' : ''}
` : ''}`, + 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')); + } +} diff --git a/src/renderer/app.js b/src/renderer/app.js index c1b4c0f..3a731e1 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -402,7 +402,7 @@ function hostMenu(hst) { return [ { label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, ...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []), - ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }, { label: T('Firewall'), icon: 'wall', run: () => openFirewall(hst) }, { 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('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(); } }, @@ -1034,6 +1034,11 @@ async function viewSettings(page) { b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); }; $('.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(`

${T('Terminal color scheme')}

`); const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term]; @@ -1162,8 +1167,8 @@ async function viewSettings(page) { ${C}+Shift+F${T('Search in terminal')} ${C}++/−/0${T('Font size')} ${C}+Shift+L${T('Lock now')}`); - if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard); - else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, 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, featCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard); } // ============================================================ App-Sperre @@ -1428,6 +1433,7 @@ function applyAppTheme(st = settings()) { const root = document.documentElement; 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'); + document.body.classList.toggle('home-anim', st.homeAnimation !== false); S.tabs.forEach((t) => t.applySettings?.()); } @@ -1550,7 +1556,7 @@ function addTab(session) { tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); }; tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); }; tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ - ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, 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() }] : []), { 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) }, @@ -1597,7 +1603,7 @@ $('.tab.home').onclick = () => activateTab('home'); // 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']); +const DETACHABLE = new Set(WEB ? [] : ['ssh', 'sftp', 'docker', 'firewall', 'network', 'webserver', 'certs', 'samba']); const TAB_MIME = 'application/x-mrterm-tab'; function tabHandoff(t) { @@ -1609,7 +1615,7 @@ function tabHandoff(t) { 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, docker: openDocker, firewall: openFirewall, network: openNetwork }[d.kind])?.(d.host); + 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); } @@ -1686,7 +1692,7 @@ class TerminalSession { this.el = h(`
${avatar(host, 22).replace('SSH', '')}${esc(hostSub(host))}
- ${host.execCommand ? '' : ``} + ${host.execCommand ? '' : enabledFeatures().map((f) => ``).join('')} @@ -1759,9 +1765,8 @@ class TerminalSession { this.el.addEventListener('click', (e) => { const a = e.target.closest('[data-a]')?.dataset.a; if (a === 'sftp') openSftp(this.host); - if (a === 'docker') openDocker(this.host); - if (a === 'firewall') openFirewall(this.host); - if (a === 'network') openNetwork(this.host); + const fid = e.target.closest('[data-feature]')?.dataset.feature; + if (fid) featureOpeners()[fid](this.host); if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); } if (a === 'snipnew') editSnippet(); if (a === 'find') this.toggleFind(); @@ -3198,9 +3203,7 @@ function openPalette() { hosts.slice(0, 12).forEach((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: `
${ICONS.folder}
`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) }); - if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.docker}
`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) }); - if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.wall}
`, label: `${T('Firewall')}: ${x.label || x.address}`, run: () => openFirewall(x) }); - if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.network}
`, label: `${T('Network')}: ${x.label || x.address}`, run: () => openNetwork(x) }); + if (x.protocol !== 'rdp' && q) enabledFeatures().forEach((f) => items.push({ grp: 'Hosts', icon: `
${ICONS[f.icon]}
`, label: `${f.label()}: ${x.label || x.address}`, run: () => f.open(x) })); }); S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `
${ICONS.code}
`, label: s.label, sub: s.command, run: () => runSnippet(s) })); [[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]] diff --git a/src/renderer/index.html b/src/renderer/index.html index 1bb67bc..8ad2871 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -49,6 +49,7 @@
+
@@ -68,6 +69,8 @@ + + diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 3dbc6f3..5be27b9 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -470,3 +470,94 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi /* Web-Version (Browser): Desktop-Funktionen ausblenden */ .web .win-ctrls, .web #lockBtn, .web .nav[data-view='vpns'], .web .nav[data-view='forwards'], .web #updateBadge { display: none !important; } + +/* ---------- Verwaltungs-Tabs: Webserver, Zertifikate, Samba/AD ---------- */ +.admin-wrap { padding: 16px; display: flex; flex-direction: column; gap: 14px; } +.admin-sec { background: var(--card); border-radius: 12px; padding: 12px 14px; } +.admin-sec-head { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } +.admin-sec-head h4 { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); } +.admin-sec-head .grow, .ws-card .grow { flex: 1; } +.admin-table th { background: var(--card); } +.admin-table td { white-space: normal; } +.admin-table td.name { width: auto; max-width: none; } +.admin-table td.acts { white-space: nowrap; width: 1%; } +.admin-table .w-dot { width: 18px; } +.admin-empty { color: var(--muted); padding: 14px 4px; font-size: 13px; } +.muted { color: var(--muted); } +.small { font-size: 11.5px; } +.status-pill.err { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } +.svc-btns { display: inline-flex; align-items: center; gap: 2px; flex-wrap: wrap; } +.svc-auto { margin: 0 0 0 6px; font-size: 12px; color: var(--muted); } +.ws-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } +.ws-card { background: var(--card); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; } +.ws-card.absent { opacity: .75; border: 1px dashed var(--border); background: transparent; } +.ws-card .nh { display: flex; align-items: center; gap: 8px; } +.ws-card .ws-ico svg { width: 18px; height: 18px; color: var(--green); } +.ws-acts { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.file-list { display: flex; flex-direction: column; gap: 2px; } +.file-it { display: flex; align-items: center; gap: 8px; text-align: left; background: transparent; border: 0; border-radius: 8px; padding: 7px 8px; color: var(--text); } +.file-it:hover { background: var(--card-hover); } +.file-it svg { width: 15px; height: 15px; color: var(--faint); flex: none; } +.stack-editor .se-out { padding: 8px 12px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; white-space: pre-wrap; border-bottom: 1px solid var(--border); max-height: 140px; overflow: auto; } +.stack-editor .se-out.ok { color: var(--green); } +.stack-editor .se-out.err { color: var(--red); } +.cert-head { display: flex; gap: 24px; align-items: flex-end; flex-wrap: wrap; background: var(--card); border-radius: 12px; padding: 12px 14px; } +.cert-head label { font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--faint); font-weight: 600; } +.cert-head .v { display: flex; align-items: center; gap: 6px; margin-top: 4px; } +.cert-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; } +.exp { display: inline-flex; flex-direction: column; font-size: 12.5px; } +.exp small { font-size: 11px; color: var(--faint); } +.exp.ok { color: var(--green); } +.exp.warn { color: var(--orange); } +.exp.err { color: var(--red); } +.cert-probe { align-items: flex-end; } +.cert-probe .btn { margin-bottom: 14px; } +.ad-seg { align-self: flex-start; } +.feature-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 4px 12px; } +.feature-it { margin: 4px 0; } +.feature-it .fi svg { width: 16px; height: 16px; vertical-align: -3px; margin-right: 2px; } + +/* ---------- Animierter Hintergrund der Startseite: langsam treibende, weichgezeichnete Farbflächen ---------- */ +.home-bg { position: absolute; inset: 0; overflow: hidden; pointer-events: none; display: none; z-index: 0; } +body.home-anim #main:has(> #homeView.active) .home-bg { display: block; } +body.home-anim #homeView { z-index: 1; } +body.home-anim #homeView .toolbar { background: transparent; -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); } +.home-bg i { position: absolute; width: 62vmax; height: 62vmax; border-radius: 50%; filter: blur(80px); opacity: .34; will-change: transform; mix-blend-mode: screen; } +.home-bg i:nth-child(1) { background: radial-gradient(circle, var(--accent) 0, transparent 68%); left: -18vmax; top: -24vmax; animation: hb1 46s ease-in-out infinite alternate; } +.home-bg i:nth-child(2) { background: radial-gradient(circle, var(--accent-3) 0, transparent 68%); right: -22vmax; top: -10vmax; animation: hb2 58s ease-in-out infinite alternate; } +.home-bg i:nth-child(3) { background: radial-gradient(circle, var(--folder) 0, transparent 68%); left: 10vmax; bottom: -34vmax; animation: hb3 52s ease-in-out infinite alternate; opacity: .24; } +.home-bg i:nth-child(4) { background: radial-gradient(circle, var(--orange) 0, transparent 70%); right: -10vmax; bottom: -30vmax; width: 44vmax; height: 44vmax; animation: hb4 64s ease-in-out infinite alternate; opacity: .18; } +/* feines Rauschen gegen Farbstufen im Verlauf */ +.home-bg::after { content: ''; position: absolute; inset: 0; opacity: .05; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } +[data-theme='light'] .home-bg i, [data-theme='win11'] .home-bg i, [data-theme='xp'] .home-bg i { mix-blend-mode: multiply; opacity: .22; } +[data-theme='macos'] .home-bg { display: none !important; } +@keyframes hb1 { 0% { transform: translate(0, 0) scale(1); } 50% { transform: translate(22vmax, 14vmax) scale(1.15); } 100% { transform: translate(8vmax, 26vmax) scale(.92); } } +@keyframes hb2 { 0% { transform: translate(0, 0) scale(1.05); } 50% { transform: translate(-26vmax, 18vmax) scale(.9); } 100% { transform: translate(-10vmax, 30vmax) scale(1.12); } } +@keyframes hb3 { 0% { transform: translate(0, 0) scale(.95); } 50% { transform: translate(24vmax, -16vmax) scale(1.1); } 100% { transform: translate(-12vmax, -24vmax) scale(1); } } +@keyframes hb4 { 0% { transform: translate(0, 0); } 50% { transform: translate(-30vmax, -12vmax); } 100% { transform: translate(-14vmax, -28vmax); } } +@media (prefers-reduced-motion: reduce) { .home-bg i { animation: none !important; } } +/* Konfigurations-Generator */ +.admin-wrap.gen-wrap { height: 100%; box-sizing: border-box; } +.gen { display: grid; grid-template-columns: minmax(340px, 1fr) minmax(360px, 1.1fr); gap: 14px; flex: 1; min-height: 0; height: 100%; } +.gen-left, .gen-right { display: flex; flex-direction: column; min-height: 0; background: var(--card); border-radius: 12px; overflow: hidden; } +.gen-top { display: flex; gap: 8px; padding: 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } +.gen-top select { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 0 8px; height: 30px; max-width: 230px; } +.gen-top .search { margin: 0; height: 30px; flex: 1; min-width: 180px; } +.gen-opts { overflow: auto; padding: 4px 12px 16px; } +.gen-group h4 { margin: 14px 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--accent-2); } +.gen-row { display: grid; grid-template-columns: 1fr minmax(150px, 46%); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--row-line); } +.gen-row.bool { grid-template-columns: 1fr auto; } +.gen-row.wide { grid-template-columns: 1fr; } +.gen-row.off { opacity: .45; } +.gen-row label { display: flex; flex-direction: column; gap: 2px; font-size: 13px; } +.gen-row label small { color: var(--faint); font-size: 11px; } +.gen-row .badge { font-size: 9.5px; font-weight: 600; padding: 1px 5px; border-radius: 5px; background: var(--icon-bg); color: var(--muted); margin-left: 6px; vertical-align: 1px; } +.gen-row .gc input:not([type=checkbox]), .gen-row .gc select, .gen-row .gc textarea { width: 100%; box-sizing: border-box; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; font: inherit; font-size: 12.5px; } +.gen-row .gc textarea { font-family: 'Cascadia Code', Consolas, monospace; resize: vertical; } +.gen-row .gc input:focus, .gen-row .gc select:focus, .gen-row .gc textarea:focus { border-color: var(--accent); outline: none; } +.gen-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); } +.gen-bar .seg small { opacity: .5; } +.gen-note { padding: 8px 12px; font-size: 12px; color: var(--orange); border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 4px; } +.gen-preview { flex: 1; margin: 0; padding: 12px 14px; overflow: auto; white-space: pre; font-size: 12px; line-height: 1.5; background: var(--panel); color: var(--text); user-select: text; } +@media (max-width: 900px) { .gen { grid-template-columns: 1fr; height: auto; } .gen-preview { max-height: 50vh; } } +.session.admin .views button { white-space: nowrap; } \ No newline at end of file diff --git a/src/renderer/webconf.js b/src/renderer/webconf.js new file mode 100644 index 0000000..ee78557 --- /dev/null +++ b/src/renderer/webconf.js @@ -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/)"], ['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'}`, ``, indent(dir.join('\n'), 4), ''); + } else if (splitList(o.allowIps).length) { + body.push('', ` Require ip ${splitList(o.allowIps).join(' ')}`, ''); + } + 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('', '', ' AuthType Basic', ` AuthName "${o.authRealm || 'Restricted'}"`, ` AuthUserFile ${o.authFile || '/etc/apache2/.htpasswd'}`, ' Require valid-user', ''); + + 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(``, ...ups.map((u) => ` BalancerMember ${u.replace(/\/$/, '')}${flags ? ` ${flags}` : ''}`), ` ProxySet lbmethod=${o.lb === 'least_conn' ? 'bybusyness' : 'byrequests'}`, ''); + 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('', '', ` SetHandler "proxy:unix:${o.phpSocket || '/run/php/php-fpm.sock'}|fcgi://localhost"`, ''); } + if (num(o.cacheDays)) { mods.add('expires'); body.push('', '', ' 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"`), ''); } + if (o.denyHidden) body.push('', '', ' Require all denied', ''); + } + 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) => [``, indent([...names, '', ...lines].join('\n'), 4), '']; + 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);