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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:18 +02:00

205 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt.
// Im Web-Server gibt es kein Electron: dort übergibt der Aufrufer Verzeichnis und Verschlüsselung (opts)
const electron = (() => { try { const e = require('electron'); return typeof e === 'object' ? e : {}; } catch { return {}; } })();
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DEFAULTS = {
version: 1,
groups: [],
hosts: [],
keys: [],
snippets: [],
forwards: [],
vpns: [],
knownHosts: {},
history: [],
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
aiChats: [],
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
tombstones: [],
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
sync: { enabled: false, deviceId: '', deviceName: '', peers: [], share: { keys: [], hosts: [], vpns: [] }, asked: false },
settings: {
terminalTheme: 'mrterm',
fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace',
fontSize: 14,
cursorStyle: 'block',
cursorBlink: true,
scrollback: 10000,
copyOnSelect: true,
keepAlive: 30,
rdpClientLinux: 'auto',
rdpEmbed: true,
appTheme: 'midnight',
accent: '',
updateUrl: 'https://git.mrblake.cc/MrBlake/MrTerm',
updateToken: '',
updateAutoCheck: true,
updatePrerelease: false,
autoLock: 0,
language: 'auto',
dockerStacksDir: '/opt/stacks',
dockerTemplatesUrl: '',
aiUrl: 'http://localhost:11434',
aiModel: '',
// Feature-Menüs: { docker: false, … } blendet einzelne aus, fehlende Einträge = an
features: {},
homeAnimation: true,
},
};
const merge = (parsed) => ({ ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } });
// AES-256-GCM; Ergebnis als base64-Felder für JSON
function box(key, plain) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([c.update(plain), c.final()]);
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
}
function unbox(key, b) {
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64'));
d.setAuthTag(Buffer.from(b.tag, 'base64'));
return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]);
}
class Store {
// opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage
constructor(opts = {}) {
this.dir = opts.dir || electron.app.getPath('userData');
this.crypto = opts.crypto || electron.safeStorage;
this.file = path.join(this.dir, 'vault.dat');
this.data = structuredClone(DEFAULTS);
this.encrypted = false;
// App-Sperre: Inhalt zusätzlich mit zufälligem Datenschlüssel (DEK, AES-256-GCM) verschlüsselt.
// Der DEK liegt je Entsperrmethode verpackt vor: Passwort (scrypt) und/oder FIDO2-Schlüssel (PRF/hmac-secret).
this.lock = null; // { password: { salt, N, wrap } | null, fido: [{ id, label, credId, prfSalt, wrap }] }
this.dek = null;
this.sealed = null; // verschlüsselter Inhalt, solange nach dem Start noch nicht entsperrt
this.locked = false;
}
get lockEnabled() { return !!(this.lock && (this.lock.password || this.lock.fido.length)); }
// Entsperren mit einem Schlüssel, der den DEK verpackt hat (wirft bei falschem Schlüssel)
unlockWith(kek, wrap) {
const dek = unbox(kek, wrap);
if (this.sealed) {
const parsed = JSON.parse(unbox(dek, this.sealed).toString('utf8'));
this.data = merge(parsed);
this.sealed = null;
}
this.dek = dek;
this.locked = false;
}
// Neue Entsperrmethode: verpackt den (ggf. neu erzeugten) DEK mit kek
wrapDek(kek) {
if (!this.dek) this.dek = crypto.randomBytes(32);
return box(kek, this.dek);
}
// Letzte Methode entfernt → Sperre aus, Inhalt wieder nur per Betriebssystem verschlüsselt
dropLockIfEmpty() {
if (!this.lockEnabled) { this.lock = null; this.dek = null; }
}
canEncrypt() {
try {
if (!this.crypto.isEncryptionAvailable()) return false;
// Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung.
if (process.platform === 'linux' && this.crypto.getSelectedStorageBackend?.() === 'basic_text') return false;
return true;
} catch { return false; }
}
load() {
fs.mkdirSync(this.dir, { recursive: true });
if (!fs.existsSync(this.file)) { this.save(); return this.data; }
const raw = fs.readFileSync(this.file);
let json;
if (raw.slice(0, 4).toString() === 'ENC1') {
json = this.crypto.decryptString(raw.slice(4));
this.encrypted = true;
} else {
json = raw.toString('utf8');
}
const parsed = JSON.parse(json);
if (parsed.mrtermLock) {
// Gesperrt: nur Darstellungs-Einstellungen (meta) sind bis zum Entsperren bekannt
this.lock = parsed.lock;
this.sealed = parsed.data;
this.locked = true;
this.data = merge({ settings: parsed.meta || {} });
} else this.data = merge(parsed);
return this.data;
}
save() {
if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben
if (!this.muted) this.onChange?.();
let json = JSON.stringify(this.data, null, 2);
if (this.lockEnabled && this.dek) {
const { language, appTheme, accent } = this.data.settings;
json = JSON.stringify({ mrtermLock: 1, meta: { language, appTheme, accent }, lock: this.lock, data: box(this.dek, Buffer.from(json)) });
}
const tmp = this.file + '.tmp';
if (this.canEncrypt()) {
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), this.crypto.encryptString(json)]));
this.encrypted = true;
} else {
fs.writeFileSync(tmp, json, { mode: 0o600 });
this.encrypted = false;
}
fs.renameSync(tmp, this.file);
}
get() { return this.data; }
// Generisches Upsert für Collections (hosts, groups, keys, snippets, forwards)
upsert(collection, item) {
const list = this.data[collection];
if (!Array.isArray(list)) throw new Error('Unbekannte Collection: ' + collection);
if (!item.id) item.id = crypto.randomUUID();
const i = list.findIndex((x) => x.id === item.id);
if (i >= 0) list[i] = { ...list[i], ...item, updatedAt: Date.now() };
else list.push({ ...item, createdAt: Date.now(), updatedAt: Date.now() });
this.save();
return item;
}
remove(collection, id) {
this.data[collection] = this.data[collection].filter((x) => x.id !== id);
if (collection === 'groups') this.data.hosts.forEach((h) => { if (h.groupId === id) { h.groupId = null; h.updatedAt = Date.now(); } });
this.tombstone(collection, id);
this.save();
}
// Löschung für andere Geräte vermerken (180 Tage aufbewahren)
tombstone(c, id) {
const now = Date.now();
this.data.tombstones = [...(this.data.tombstones || []).filter((t) => !(t.c === c && t.id === id) && now - t.at < 180 * 864e5), { c, id, at: now }];
}
forgetKnownHost(id) {
delete this.data.knownHosts[id];
this.tombstone('knownHosts', id);
this.save();
}
setSettings(s) { this.data.settings = { ...this.data.settings, ...s }; this.save(); }
addHistory(entry) {
this.data.history = [entry, ...this.data.history.filter((h) => h.hostId !== entry.hostId)].slice(0, 30);
this.save();
}
resolveHost(id) { return this.data.hosts.find((h) => h.id === id); }
resolveKey(id) { return this.data.keys.find((k) => k.id === id); }
}
module.exports = { Store };