Files
MrTerm/src/renderer/admin-tabs.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

876 lines
64 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.
/* global S, T, h, $, $$, esc, ICONS, api, call, toast, modal, confirmBox, promptBox, uid, avatar, hostSub, hostRef, addTab, closeTab, setTabState, openTerminal, settings, fmtDate, relTime, openDocker, openFirewall, openNetwork */
'use strict';
// Feature-Menüs (Docker, Firewall, Netzwerk, Webserver, Zertifikate, Samba/AD): Liste, Ein-/Ausschalten in den
// Einstellungen und die Verwaltungs-Tabs Webserver, Zertifikate und Samba/AD (Backend: src/main/admin.js & Co.).
// Wird nach app.js geladen und nutzt dessen Hilfsfunktionen.
Object.assign(ICONS, {
globe: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.7 3.8 5.7 3.8 9s-1.3 6.3-3.8 9c-2.5-2.7-3.8-5.7-3.8-9s1.3-6.3 3.8-9z"/></svg>',
cert: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M7 8h10M7 11h6"/><circle cx="16" cy="17" r="3"/><path d="M14.5 19.5L14 22l2-1 2 1-.5-2.5"/></svg>',
users: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5"/><circle cx="17" cy="9" r="2.8"/><path d="M16.5 14.6c2.6.2 4.5 1.9 5 5"/></svg>',
});
const FEATURES = [
{ id: 'docker', label: () => 'Docker', title: () => T('Docker containers'), icon: 'docker', color: 'var(--accent-2)', open: (x) => openDocker(x) },
{ id: 'firewall', label: () => T('Firewall'), icon: 'wall', color: 'var(--orange)', open: (x) => openFirewall(x) },
{ id: 'network', label: () => T('Network'), icon: 'network', color: 'var(--accent)', open: (x) => openNetwork(x) },
{ id: 'webserver', label: () => T('Web server'), icon: 'globe', color: 'var(--green)', open: (x) => new WebServerSession(x) },
{ id: 'certs', label: () => T('Certificates'), icon: 'cert', color: 'var(--accent-3)', open: (x) => new CertSession(x) },
{ id: 'samba', label: () => 'Samba / AD', icon: 'users', color: 'var(--folder)', open: (x) => new SambaSession(x) },
];
const featureOn = (id) => settings().features?.[id] !== false;
const enabledFeatures = () => FEATURES.filter((f) => featureOn(f.id));
const featureOpeners = () => Object.fromEntries(FEATURES.map((f) => [f.id, f.open]));
const featureTitle = (f) => (f.title ? f.title() : f.label());
// Einstellungen: Feature-Menüs ein-/ausschalten
function featuresCard(save) {
const card = h(`<div class="settings-card"><h3>${T('Feature menus')}</h3><p style="color:var(--muted);margin-top:0">${T('Choose which management tabs are offered for SSH hosts (host menu, terminal toolbar and quick search).')}</p><div class="feature-list"></div></div>`);
for (const f of FEATURES) {
const el = h(`<label class="check feature-it"><input type="checkbox" ${featureOn(f.id) ? 'checked' : ''}/><span class="fi" style="color:${f.color}">${ICONS[f.icon]}</span>${esc(featureTitle(f))}</label>`);
$('input', el).onchange = (e) => save({ features: { ...(settings().features || {}), [f.id]: e.target.checked } });
$('.feature-list', card).append(el);
}
return card;
}
const pill = (state) => {
const on = state === 'active';
const label = { active: T('active'), inactive: T('inactive'), failed: T('failed'), activating: T('starting') }[state] || state || T('unknown');
return `<span class="status-pill ${on ? 'on' : state === 'failed' ? 'err' : ''}">${esc(label)}</span>`;
};
const daysLeft = (t) => (t ? Math.floor((t - Date.now()) / 86400000) : null);
function expiryHtml(t) {
if (!t) return '<span class="muted">–</span>';
const d = daysLeft(t);
const cls = d < 0 ? 'err' : d < 14 ? 'err' : d < 30 ? 'warn' : 'ok';
return `<span class="exp ${cls}">${esc(fmtDate(t))}<small>${d < 0 ? esc(T('expired')) : esc(T('{n} days', { n: d }))}</small></span>`;
}
const spinnerHtml = (text) => `<div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(text)}</div></div>`;
// Formular-Dialog mit Logik nach dem Öffnen (z. B. Felder je nach Auswahl ein-/ausblenden)
function formModal(opts, wire) {
const p = modal(opts);
const m = $$('#modalRoot .modal-bg').pop();
if (m && wire) wire(m);
return p;
}
// ============================================================ Basis für Verwaltungs-Tabs
class AdminSession {
constructor(host, { kind, ch, title, views }) {
this.kind = kind; this.ch = ch; this.id = uid(); this.host = host;
this.title = `${title} · ${host.label || host.address}`;
this.views = views; this.view = views[0][0]; this.data = null;
this.el = h(`<div class="session admin ${kind}">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<div class="seg sm views">${views.map(([v, l]) => `<button data-v="${v}" class="${v === this.view ? 'active' : ''}">${esc(l)}</button>`).join('')}</div>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
</div>
<div class="docker-body admin-body">${spinnerHtml(T('Connecting to {host} …', { host: host.address }))}</div></div>`);
this.body = $('.admin-body', this.el);
this.el.addEventListener('click', (e) => {
const v = e.target.closest('.views [data-v]')?.dataset.v;
if (v && this.data) this.setView(v);
if (e.target.closest('[data-a=refresh]') && this.data) this.refresh();
});
this.unsub = api.on(`${ch}:closed`, (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this);
this.open();
}
async open() {
try {
this.data = await api.call(`${this.ch}:open`, this.id, hostRef(this.host));
setTabState(this, 'on');
this.draw();
} catch (e) {
setTabState(this, 'err');
this.error(e.message);
}
}
error(msg) {
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:560px;text-align:center;white-space:pre-wrap">${esc(msg)}</div></div>`;
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
b.onclick = () => { closeTab(this.id); featureOpeners()[this.kind](this.host); };
$('.pane-empty', this.body).append(b);
}
setView(v) {
this.view = v;
$$('.views [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v));
this.body.scrollTop = 0;
this.draw();
}
async refresh() {
try { this.data = await api.call(`${this.ch}:read`, this.id); this.draw(); } catch (e) { toast(e.message, 'error'); }
}
// Operation im Backend; Fehler werden als Toast gezeigt und weitergeworfen
op(name, ...args) { return call(`${this.ch}:op`, this.id, name, ...args); }
// Operation + Neuladen; true bei Erfolg
async act(name, args, okMsg) {
try { await this.op(name, ...args); } catch { return false; }
if (okMsg) toast(okMsg, 'ok');
await this.refresh();
return true;
}
// Befehl vom Backend im Terminal-Tab ausführen (Live-Ausgabe, Rückfragen möglich)
async term(name, args, label) {
try { openTerminal({ ...this.host, execCommand: await this.op(name, ...args), label: `${label} · ${this.host.label || this.host.address}` }); } catch {}
}
draw() {
if (!this.data || this.editing) return;
const scroll = this.body.scrollTop;
this.body.innerHTML = '';
const wrap = h('<div class="admin-wrap"></div>');
this.body.append(wrap);
this[`draw_${this.view}`](wrap);
this.body.scrollTop = scroll;
}
section(title, actions = []) {
const sec = h(`<div class="admin-sec"><div class="admin-sec-head"><h4>${esc(title)}</h4><span class="grow"></span></div></div>`);
for (const a of actions) {
const b = h(`<button class="btn ${a.cls || 'ghost'} sm" ${a.title ? `title="${esc(a.title)}"` : ''}>${a.icon ? ICONS[a.icon] : ''}${esc(a.label)}</button>`);
b.onclick = a.run;
$('.admin-sec-head', sec).append(b);
}
return sec;
}
// Tabelle: cols = [[Titel, (zeile) => html, klasse?]], acts = (zeile) => [{ icon, title, run, danger }]
table(rows, cols, acts) {
const t = h(`<table class="docker-table admin-table"><thead><tr>${cols.map(([c, , cls]) => `<th class="${cls || ''}">${esc(c)}</th>`).join('')}${acts ? '<th></th>' : ''}</tr></thead><tbody></tbody></table>`);
for (const r of rows) {
const tr = h(`<tr>${cols.map(([, f, cls]) => `<td class="${cls || ''}">${f(r)}</td>`).join('')}${acts ? '<td class="acts"></td>' : ''}</tr>`);
if (acts) for (const a of acts(r).filter(Boolean)) {
const b = h(`<button class="btn ghost sm ${a.danger ? 'danger' : ''}" title="${esc(a.title)}">${a.icon ? ICONS[a.icon] : ''}${a.label ? esc(a.label) : ''}</button>`);
b.onclick = a.run;
$('.acts', tr).append(b);
}
$('tbody', t).append(tr);
}
return t;
}
empty(text) { return h(`<div class="admin-empty">${esc(text)}</div>`); }
// Dienst-Zeile mit Start/Stopp/Neustart/Neu laden und Autostart
serviceButtons(u, opName = 'service', withReload = true) {
const box = h('<span class="svc-btns"></span>');
const run = (action, msg) => this.act(opName, [u.unit, action], msg);
const btn = (icon, title, fn, cls = '') => { const b = h(`<button class="btn ghost sm ${cls}" title="${esc(title)}">${ICONS[icon]}</button>`); b.onclick = fn; box.append(b); };
if (u.active === 'active') btn('stop', T('Stop'), async () => { if (await confirmBox(T('Stop {name}?', { name: u.unit }), T('The service will stop serving requests.'), T('Stop'))) run('stop', T('{name} stopped', { name: u.unit })); });
else btn('play', T('Start'), () => run('start', T('{name} started', { name: u.unit })));
btn('refresh', T('Restart'), () => run('restart', T('{name} restarted', { name: u.unit })));
if (withReload && u.active === 'active') btn('download', T('Reload configuration'), () => run('reload', T('Configuration reloaded')));
const en = h(`<label class="check svc-auto" title="${esc(T('Start automatically at boot'))}"><input type="checkbox" ${u.enabled === 'enabled' ? 'checked' : ''}/>${T('Autostart')}</label>`);
$('input', en).onchange = (e) => run(e.target.checked ? 'enable' : 'disable');
box.append(en);
return box;
}
// Datei im Tab bearbeiten; save(content) → [Operation, ...Argumente]; Ergebnis: Ausgabe der Prüfung oder { path, output }
// onSaved(ergebnis) z. B. um nach dem ersten Speichern einer neuen Datei auf "überschreiben" umzustellen
async editFile(path, { read, save, note, onSaved }) {
let content;
try { content = await read(); } catch { return; }
this.editing = path;
this.body.innerHTML = '';
const ed = h(`<div class="stack-editor"><div class="se-head"><b class="mono">${esc(path)}</b><span style="flex:1"></span>
<button class="btn ghost sm" data-e="cancel">${T('Back')}</button><button class="btn primary sm" data-e="save">${ICONS.download}${T('Save & apply')}</button></div>
${note ? `<div class="se-note">${esc(note)}</div>` : ''}<div class="se-out" style="display:none"></div>
<div class="se-grid" style="grid-template-columns:1fr"><div class="se-col"><textarea class="mono" spellcheck="false"></textarea></div></div></div>`);
const ta = $('textarea', ed);
ta.value = content;
ta.addEventListener('keydown', (e) => {
if (e.key === 'Tab') { e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); }
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $('[data-e=save]', ed).click(); }
});
const out = $('.se-out', ed);
$('[data-e=cancel]', ed).onclick = () => { this.editing = null; this.refresh(); };
$('[data-e=save]', ed).onclick = async (e) => {
const b = e.currentTarget; b.disabled = true;
try {
const r = await api.call(`${this.ch}:op`, this.id, ...save(ta.value));
onSaved?.(r);
if (r?.path) $('.se-head b', ed).textContent = r.path;
const msg = typeof r === 'string' ? r : r?.output;
out.style.display = ''; out.className = 'se-out ok'; out.textContent = (typeof msg === 'string' && msg.trim()) || T('Saved and applied.');
toast(T('Saved'), 'ok');
} catch (err) {
out.style.display = ''; out.className = 'se-out err'; out.textContent = err.message;
}
b.disabled = false;
};
this.body.append(ed);
ta.focus();
}
aiContext() { return this.body.innerText; }
dispose() { this.unsub(); api.call(`${this.ch}:close`, this.id).catch(() => {}); }
}
// ============================================================ Webserver (nginx, Apache, Caddy, PHP-FPM)
const SRV_LABEL = { nginx: 'nginx', apache: 'Apache', caddy: 'Caddy', php: 'PHP-FPM' };
class WebServerSession extends AdminSession {
constructor(host) {
super(host, { kind: 'webserver', ch: 'web', title: T('Web server'), views: [['overview', T('Overview')], ['sites', T('Sites')], ['generator', T('Generator')], ['files', T('Configuration')]] });
}
// ---------- Konfigurations-Generator: alle Optionen durchsuchbar, Live-Vorschau, als Site speichern oder im Editor öffnen
draw_generator(w) {
const W = window.WEBCONF;
const inst = this.installed().map((x) => x.id);
const g = this.gen ||= { server: inst[0] || 'nginx', o: W.defaults(), q: '' };
const nginxVersion = (this.data.servers.find((x) => x.id === 'nginx')?.version.match(/nginx\/([\d.]+)/) || [])[1];
const box = h(`<div class="gen">
<div class="gen-left">
<div class="gen-top"><select class="gen-preset"><option value="">${T('Start from a template …')}</option>${W.PRESETS.map(([id, l]) => `<option value="${id}">${esc(T(l))}</option>`).join('')}</select>
<label class="search sm">${ICONS.search}<input class="gen-q" placeholder="${esc(T('Search settings (e.g. upload, websocket, hsts) …'))}" value="${esc(g.q)}"/></label></div>
<div class="gen-opts"></div>
</div>
<div class="gen-right">
<div class="gen-bar"><div class="seg sm gen-srv">${['nginx', 'apache', 'caddy'].map((x) => `<button data-s="${x}" class="${g.server === x ? 'active' : ''}">${SRV_LABEL[x]}${inst.includes(x) ? '' : ' <small>·</small>'}</button>`).join('')}</div><span style="flex:1"></span>
<button class="btn ghost sm" data-g="copy" title="${esc(T('Copy'))}">${ICONS.copy}</button>
<button class="btn sm" data-g="edit">${ICONS.edit}${T('Open in editor')}</button>
<button class="btn primary sm" data-g="save">${ICONS.download}${T('Save as site')}</button></div>
<div class="gen-note"></div>
<pre class="gen-preview mono"></pre>
</div></div>`);
w.append(box);
w.classList.add('gen-wrap');
const optsBox = $('.gen-opts', box);
const rows = [];
for (const [gid, glabel] of W.GROUPS) {
const sec = h(`<div class="gen-group" data-group="${gid}"><h4>${esc(T(glabel))}</h4></div>`);
for (const opt of W.OPTIONS.filter((x) => x.group === gid)) {
const v = g.o[opt.id];
let input;
if (opt.type === 'bool') input = `<input type="checkbox" data-o="${opt.id}" ${v ? 'checked' : ''}/>`;
else if (opt.type === 'select') input = `<select data-o="${opt.id}">${opt.options.map(([k, l]) => `<option value="${esc(k)}" ${String(v) === k ? 'selected' : ''}>${esc(T(l))}</option>`).join('')}</select>`;
else if (opt.type === 'textarea') input = `<textarea data-o="${opt.id}" rows="4" class="mono" spellcheck="false">${esc(v)}</textarea>`;
else input = `<input type="${opt.type === 'number' ? 'number' : 'text'}" data-o="${opt.id}" value="${esc(v)}" placeholder="${esc(opt.placeholder ? T(opt.placeholder) : '')}" spellcheck="false"/>`;
const only = opt.only ? `<span class="badge">${esc(opt.only.map((x) => SRV_LABEL[x]).join(', '))}</span>` : '';
const row = h(`<div class="gen-row ${opt.type === 'bool' ? 'bool' : ''} ${opt.type === 'textarea' ? 'wide' : ''}"><label><span class="gl">${esc(T(opt.label))}${only}</span>${opt.desc ? `<small>${esc(T(opt.desc))}</small>` : ''}</label><div class="gc">${input}</div></div>`);
row._opt = opt;
row._text = [opt.id, opt.label, T(opt.label), opt.desc || '', opt.desc ? T(opt.desc) : '', opt.kw || '', glabel, T(glabel)].join(' ').toLowerCase();
rows.push(row);
sec.append(row);
}
optsBox.append(sec);
}
const gen = () => W.generate(g.server, g.o, { nginxVersion });
const update = () => {
const q = g.q.trim().toLowerCase();
for (const row of rows) {
const opt = row._opt;
const relevant = !opt.show || opt.show(g.o);
const supported = !opt.only || opt.only.includes(g.server);
// Bei einer Suche alle passenden Optionen zeigen (auch gerade nicht relevante), sonst nur relevante
row.style.display = q ? (q.split(/\s+/).every((t) => row._text.includes(t)) ? '' : 'none') : (relevant ? '' : 'none');
row.classList.toggle('off', !supported || !relevant);
}
$$('.gen-group', optsBox).forEach((sec) => { sec.style.display = $$('.gen-row', sec).some((r) => r.style.display !== 'none') ? '' : 'none'; });
$('.gen-preview', box).textContent = gen().text;
const notes = [];
if (!inst.includes(g.server)) notes.push(T('{server} is not installed on this host – you can still generate and copy the configuration.', { server: SRV_LABEL[g.server] }));
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'letsencrypt') notes.push(T('The certificate must exist before saving: request it in the “Certificates” tab first (e.g. via webroot), or save without HTTPS first.'));
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'auto') notes.push(T('Automatic certificates are only available with Caddy.'));
if (g.o.basicAuth) notes.push(T('Create the password file on the server with: htpasswd -c {file} <user>', { file: g.o.authFile }));
$('.gen-note', box).innerHTML = notes.map((n) => `<div>${esc(n)}</div>`).join('');
$('.gen-note', box).style.display = notes.length ? '' : 'none';
};
optsBox.addEventListener('input', (e) => {
const id = e.target.dataset.o; if (!id) return;
const opt = W.OPTIONS.find((x) => x.id === id);
g.o[id] = opt.type === 'bool' ? e.target.checked : opt.type === 'number' ? Number(e.target.value) : e.target.value;
// Dokumentenverzeichnis folgt der ersten Domain, bis es von Hand geändert wurde
if (id === 'domains' && !g.rootTouched) { const d = W.splitList(g.o.domains)[0]; g.o.root = d ? `/var/www/${d}` : ''; const r = $('[data-o=root]', optsBox); if (r) r.value = g.o.root; }
if (id === 'root') g.rootTouched = true;
update();
});
$('.gen-q', box).oninput = (e) => { g.q = e.target.value; update(); };
$('.gen-preset', box).onchange = (e) => {
const p = W.PRESETS.find(([id]) => id === e.target.value);
if (!p) return;
// Vorlage über die aktuellen Werte legen; Domains/Wurzel bleiben erhalten
g.o = { ...W.defaults(), domains: g.o.domains, root: g.o.root, ...(p[0] === 'secure' ? g.o : {}), ...p[2] };
this.draw();
};
$('.gen-srv', box).onclick = (e) => { const sv = e.target.closest('[data-s]')?.dataset.s; if (sv) { g.server = sv; $$('.gen-srv button', box).forEach((b) => b.classList.toggle('active', b.dataset.s === sv)); update(); } };
const siteArgs = (content) => {
const d = W.splitList(g.o.domains)[0];
const needsRoot = ['static', 'spa', 'php'].includes(g.o.type) && g.o.createRoot;
return { name: g.o.name || d || 'site', content, modules: gen().modules, mkRoot: needsRoot ? g.o.root || (d ? `/var/www/${d}` : '') : '', title: d };
};
box.addEventListener('click', async (e) => {
const a = e.target.closest('[data-g]')?.dataset.g;
if (a === 'copy') { await api.call('clipboard:write', gen().text); toast(T('Copied')); }
if (a === 'save') {
if (!W.splitList(g.o.domains).length && g.server !== 'caddy') return toast(T('Please enter at least one domain.'), 'error');
try { const r = await this.op('saveSite', g.server, siteArgs(gen().text)); toast(T('Site saved: {path}', { path: r.path }), 'ok'); await this.refresh(); this.setView('sites'); } catch {}
}
if (a === 'edit') {
const st = { path: null };
const text = gen().text;
this.editFile(T('New site ({server})', { server: SRV_LABEL[g.server] }), {
read: async () => text,
save: (c) => (st.path ? ['writeFile', st.path, c] : ['saveSite', g.server, siteArgs(c)]),
onSaved: (r) => { if (r?.path) st.path = r.path; },
note: T('Edit the generated configuration freely. Saving creates the site, checks it and reloads the web server; if the check fails, nothing is changed.'),
});
}
});
update();
}
installed() { return this.data.servers.filter((x) => x.installed); }
draw_overview(w) {
const d = this.data;
const grid = h('<div class="ws-grid"></div>');
for (const srv of d.servers) {
const card = h(`<div class="ws-card ${srv.installed ? '' : 'absent'}"><div class="nh"><span class="ws-ico">${ICONS.globe}</span><b>${esc(srv.label)}</b><span class="grow"></span>${srv.installed ? pill(srv.active) : `<span class="muted">${T('not installed')}</span>`}</div>
${srv.installed ? `<div class="muted small">${esc(srv.version)} · ${esc(srv.unit)}</div>` : ''}<div class="ws-acts"></div></div>`);
const acts = $('.ws-acts', card);
if (srv.installed) {
acts.append(this.serviceButtons(srv));
const test = h(`<button class="btn sm">${ICONS.shield}${T('Test configuration')}</button>`);
test.onclick = async () => { try { const r = await this.op('test', srv.id); modal({ title: `${srv.label}: ${r.ok ? T('Configuration OK') : T('Configuration has errors')}`, body: `<pre class="fw-pre mono" style="max-height:50vh;overflow:auto">${esc(r.output || 'OK')}</pre>`, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] }); } catch {} };
const logs = h(`<button class="btn sm">${ICONS.logs}${T('Logs')}</button>`);
logs.onclick = () => this.term('logsCommand', [srv.id], `${T('Logs')} ${srv.label}`);
acts.append(test, logs);
} else {
const inst = h(`<button class="btn sm">${ICONS.download}${T('Install')}</button>`);
inst.onclick = () => this.term('installCommand', [srv.id], `${T('Install')} ${srv.label}`);
acts.append(inst);
}
grid.append(card);
}
w.append(grid);
const running = d.servers.filter((x) => x.active === 'active');
if (running.length > 1) w.append(h(`<div class="net-banner warn">${ICONS.wall}<div>${T('Several web servers are running ({list}). Only one of them can use port 80/443 – make sure they listen on different ports.', { list: running.map((x) => x.label).join(', ') })}</div></div>`));
const php = this.section('PHP', d.php.pools.length ? [] : [{ label: T('Install PHP-FPM'), icon: 'download', run: () => this.term('installCommand', ['php'], `${T('Install')} PHP-FPM`) }]);
if (d.php.version) php.append(h(`<div class="muted small" style="margin:0 0 8px">${esc(d.php.version)}</div>`));
if (d.php.pools.length) php.append(this.table(d.php.pools, [[T('Service'), (u) => `<b>${esc(u.unit)}</b>`], [T('Status'), (u) => pill(u.active)]], (u) => [{ icon: 'logs', title: T('Logs'), run: () => this.term('logsCommand', ['php'], `${T('Logs')} ${u.unit}`) }]));
else php.append(this.empty(T('No PHP-FPM service found.')));
if (d.php.pools.length) {
$$('tbody tr', php).forEach((tr, i) => { $('.acts', tr).prepend(this.serviceButtons(d.php.pools[i])); });
}
if (d.php.sockets.length) php.append(h(`<div class="muted small" style="margin-top:8px">${T('Sockets')}: <span class="mono">${d.php.sockets.map(esc).join(', ')}</span></div>`));
w.append(php);
}
draw_sites(w) {
const d = this.data;
const inst = this.installed();
const sec = this.section(T('Sites'), inst.length ? [{ label: T('Generator'), icon: 'code', run: () => this.setView('generator') }, { label: T('New site'), icon: 'plus', cls: 'primary', run: () => this.newSite() }] : []);
if (!inst.length) { sec.append(this.empty(T('No web server installed. Install nginx, Apache or Caddy in the overview.'))); w.append(sec); return; }
if (!d.sites.length) sec.append(this.empty(T('No sites configured yet.')));
else sec.append(this.table(d.sites, [
['', (x) => `<span class="dot ${x.enabled ? 'on' : 'none'}"></span>`, 'w-dot'],
[T('Server'), (x) => esc(SRV_LABEL[x.server])],
[T('Site'), (x) => `<b>${esc(x.name)}</b><div class="muted small mono">${esc(x.path)}${x.line ? `:${x.line}` : ''}</div>`, 'name'],
[T('Domains'), (x) => x.domains.map((n) => `<span class="tag">${esc(n)}</span>`).join(' ') || '<span class="muted">–</span>'],
], (x) => [
x.server !== 'caddy' && { icon: x.enabled ? 'stop' : 'play', title: x.enabled ? T('Disable') : T('Enable'), run: () => this.act('setSiteEnabled', [x.server, x.path, !x.enabled], x.enabled ? T('Site disabled') : T('Site enabled')) },
{ icon: 'edit', title: T('Edit'), run: () => this.editConfig(x.path) },
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete site?'), x.server === 'caddy' ? T('The block “{name}” will be removed from the Caddyfile.', { name: x.name }) : T('{path} will be deleted (a copy is kept in /var/backups/mrterm).', { path: x.path }))) this.act('deleteSite', [x.server, x.path, x.name], T('Site deleted')); } },
]));
w.append(sec);
w.append(h(`<div class="fw-note" style="border:0;padding:0 2px">${T('Every change is checked with the configuration test of the web server before reloading. If the test fails, the previous state is restored.')}</div>`));
}
draw_files(w) {
const d = this.data;
const files = [...new Set([...d.files, ...d.sites.map((x) => x.path)])].sort();
const sec = this.section(T('Configuration files'));
if (!files.length) sec.append(this.empty(T('No configuration files found.')));
else {
const list = h('<div class="file-list"></div>');
for (const f of files) { const b = h(`<button class="file-it">${ICONS.file}<span class="mono">${esc(f)}</span></button>`); b.onclick = () => this.editConfig(f); list.append(b); }
sec.append(list);
}
w.append(sec);
}
editConfig(path) {
this.editFile(path, { read: () => this.op('readFile', path), save: (c) => ['writeFile', path, c], note: T('Saving checks the configuration and reloads the service. If the check fails, the previous version is restored.') });
}
async newSite() {
const d = this.data;
const inst = this.installed();
const sock = d.php.sockets[0] || '/run/php/php-fpm.sock';
const r = await formModal({ title: T('New site'), body: `
<div class="row"><div class="field"><label>${T('Web server')}</label><select name="server">${inst.map((x) => `<option value="${x.id}">${esc(x.label)}</option>`).join('')}</select></div>
<div class="field"><label>${T('Type')}</label><select name="type"><option value="static">${T('Static website')}</option><option value="proxy">${T('Reverse proxy')}</option><option value="php">PHP</option></select></div></div>
<div class="field"><label>${T('Domains')}</label><input name="domains" placeholder="example.com www.example.com"/></div>
<div class="field" data-f="name"><label>${T('Name of the configuration file')}</label><input name="name" placeholder="${esc(T('optional – default: first domain'))}"/></div>
<div class="field" data-f="root"><label>${T('Document root')}</label><input name="root" placeholder="/var/www/example.com"/></div>
<div class="field" data-f="upstream" style="display:none"><label>${T('Target (upstream)')}</label><input name="upstream" placeholder="http://127.0.0.1:3000"/></div>
<div class="field" data-f="sock" style="display:none"><label>${T('PHP-FPM socket')}</label>${d.php.sockets.length > 1 ? `<select name="sock">${d.php.sockets.map((x) => `<option>${esc(x)}</option>`).join('')}</select>` : `<input name="sock" value="${esc(sock)}"/>`}</div>
<label class="check" data-f="createRoot"><input type="checkbox" name="createRoot" checked/>${T('Create the folder with a placeholder page if it does not exist')}</label>
<div class="hint" data-f="caddy" style="display:none;color:var(--muted);font-size:12px;margin-top:8px">${T('Caddy requests HTTPS certificates for public domains automatically.')}</div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] }, (m) => {
const upd = () => {
const type = $('[name=type]', m).value, server = $('[name=server]', m).value;
const show = (f, on) => { const el = $(`[data-f=${f}]`, m); if (el) el.style.display = on ? '' : 'none'; };
show('root', type !== 'proxy'); show('createRoot', type !== 'proxy'); show('upstream', type === 'proxy'); show('sock', type === 'php');
show('name', server !== 'caddy'); show('caddy', server === 'caddy');
};
$('[name=type]', m).onchange = upd; $('[name=server]', m).onchange = upd;
$('[name=domains]', m).oninput = (e) => { const r0 = $('[name=root]', m); if (!r0.dataset.touched) r0.value = `/var/www/${e.target.value.trim().split(/[\s,]+/)[0] || ''}`; };
$('[name=root]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
upd();
});
if (!r) return;
const ok = await this.act('createSite', [r.server, r], T('Site created'));
if (ok && r.server !== 'caddy' && featureOn('certs')) toast(T('Tip: request an HTTPS certificate in the “Certificates” tab.'));
}
}
// ============================================================ Zertifikate (Let's Encrypt / certbot, acme.sh)
class CertSession extends AdminSession {
constructor(host) {
super(host, { kind: 'certs', ch: 'certs', title: T('Certificates'), views: [['certs', T('Certificates')], ['check', T('Check endpoint')]] });
}
draw_certs(w) {
const d = this.data;
const cb = d.certbot;
const head = h('<div class="cert-head"></div>');
head.append(h(`<div><label>certbot</label><div>${cb.version ? esc(cb.version) : `<span class="muted">${T('not installed')}</span>`}</div></div>`));
const timer = d.timers.find((t) => t.unit.endsWith('.timer'));
const cron = d.timers.find((t) => t.unit === 'cron');
const renew = h(`<div><label>${T('Automatic renewal')}</label><div class="v"></div></div>`);
if (timer?.enabled === 'enabled' || cron) $('.v', renew).innerHTML = `${pill(timer?.active || 'active')} <span class="muted small">${esc(timer?.unit || '/etc/cron.d/certbot')}</span>`;
else if (cb.version) {
$('.v', renew).innerHTML = `<span class="exp warn">${T('not set up')}</span> `;
const b = h(`<button class="btn sm">${T('Enable')}</button>`);
b.onclick = () => this.act('enableAutoRenew', [timer?.unit || 'certbot.timer'], T('Automatic renewal enabled'));
$('.v', renew).append(b);
} else $('.v', renew).innerHTML = '<span class="muted">–</span>';
head.append(renew);
const acts = h('<div class="cert-acts"></div>');
const btn = (label, icon, run, cls = '') => { const b = h(`<button class="btn sm ${cls}">${ICONS[icon]}${esc(label)}</button>`); b.onclick = run; acts.append(b); };
if (cb.version) {
btn(T('Request certificate'), 'plus', () => this.issue(), 'primary');
btn(T('Renew all'), 'refresh', () => this.term('renewCommand', ['', false], T('Renew certificates')));
btn(T('Test renewal'), 'shield', () => this.term('renewCommand', ['', true], T('Test renewal')));
} else btn(T('Install certbot'), 'download', () => this.term('installCommand', [], `${T('Install')} certbot`), 'primary');
btn(T('Self-signed …'), 'key', () => this.selfSigned());
head.append(acts);
w.append(head);
const sec = this.section("Let's Encrypt (certbot)");
if (!cb.version) sec.append(this.empty(T('certbot is not installed. Install it to request free certificates from Let\'s Encrypt.')));
else if (!cb.certs.length) sec.append(this.empty(T('No certificates yet.')));
else sec.append(this.table(cb.certs, [
[T('Name'), (c) => `<b>${esc(c.name)}</b><div class="muted small">${esc(c.keyType || '')}</div>`, 'name'],
[T('Domains'), (c) => c.domains.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
[T('Expires'), (c) => expiryHtml(c.expires)],
], (c) => [
{ icon: 'refresh', title: T('Renew'), run: () => this.term('renewCommand', [c.name, false], `${T('Renew')} ${c.name}`) },
{ icon: 'eye', title: T('Details'), run: () => this.details(c.certPath) },
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete certificate?'), T('“{name}” and its files will be deleted. Web server configurations that still use it will fail.', { name: c.name }))) this.act('deleteCert', [c.name], T('Certificate deleted')); } },
]));
w.append(sec);
if (d.acme.dir) {
const a = this.section('acme.sh');
if (!d.acme.certs.length) a.append(this.empty(T('No certificates yet.')));
else a.append(this.table(d.acme.certs, [
[T('Domain'), (c) => `<b>${esc(c.domain)}</b>`, 'name'], ['SAN', (c) => c.sans.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
['CA', (c) => esc(c.ca)], [T('Next renewal'), (c) => esc(c.renew)],
], (c) => [
{ icon: 'refresh', title: T('Renew'), run: () => this.term('acmeRenewCommand', [c.domain], `acme.sh ${c.domain}`) },
{ icon: 'trash', title: T('Remove'), danger: true, run: async () => { if (await confirmBox(T('Remove certificate?'), T('acme.sh will no longer renew “{name}”.', { name: c.domain }), T('Remove'))) this.act('acmeRemove', [c.domain], T('Removed')); } },
]));
w.append(a);
}
const f = this.section(T('Certificate files'));
const files = d.files.filter((x) => !x.path.startsWith('/etc/letsencrypt/'));
if (!files.length) f.append(this.empty(T('No other certificate files found (searched /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).')));
else f.append(this.table(files, [
[T('File'), (c) => `<span class="mono small">${esc(c.path)}</span><div class="muted small">${esc(c.issuer)}</div>`, 'name'],
[T('Domains'), (c) => (c.sans.length ? c.sans : [c.subject]).map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')],
[T('Expires'), (c) => expiryHtml(c.expires)],
], (c) => [{ icon: 'eye', title: T('Details'), run: () => this.details(c.path) }]));
w.append(f);
}
async details(path) {
try {
const text = await this.op('details', path);
modal({ title: path, body: `<pre class="fw-pre mono" style="max-height:60vh;overflow:auto;font-size:11.5px">${esc(text)}</pre>`, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] });
} catch {}
}
async issue() {
const d = this.data;
const plug = d.certbot.plugins;
const methods = [
...(plug.includes('nginx') ? [['nginx', T('nginx (configures HTTPS automatically)')]] : []),
...(plug.includes('apache') ? [['apache', T('Apache (configures HTTPS automatically)')]] : []),
['webroot', T('Webroot (running web server, files in .well-known)')],
['standalone', T('Standalone (port 80 must be free)')],
['dns', T('DNS challenge (manual TXT record, also for wildcards)')],
];
const r = await formModal({ title: T('Request certificate'), body: `
<div class="field"><label>${T('Domains')}</label><input name="domains" placeholder="example.com www.example.com"/></div>
<div class="field"><label>${T('Method')}</label><select name="method">${methods.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join('')}</select></div>
<div class="field" data-f="webroot" style="display:none"><label>${T('Webroot')}</label><input name="webroot" placeholder="/var/www/example.com"/></div>
<div class="field"><label>${T('E-mail for expiry notices')}</label><input name="email" placeholder="${esc(T('optional'))}"/></div>
<div class="row"><div class="field"><label>${T('Key type')}</label><select name="keyType"><option value="ecdsa">ECDSA</option><option value="rsa">RSA 4096</option></select></div>
<div class="field"><label>${T('Certificate name')}</label><input name="name" placeholder="${esc(T('optional'))}"/></div></div>
<label class="check" data-f="redirect"><input type="checkbox" name="redirect" checked/>${T('Redirect HTTP to HTTPS')}</label>
<label class="check"><input type="checkbox" name="staging"/>${T('Test certificate (Let\'s Encrypt staging, no rate limits)')}</label>
<div class="hint" style="color:var(--muted);font-size:12px;margin-top:8px">${T('certbot runs in a terminal tab so you can follow the output and answer questions.')}</div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Request'), value: 'form', cls: 'primary' }] }, (m) => {
const upd = () => { const v = $('[name=method]', m).value; $('[data-f=webroot]', m).style.display = v === 'webroot' ? '' : 'none'; $('[data-f=redirect]', m).style.display = v === 'nginx' || v === 'apache' ? '' : 'none'; };
$('[name=method]', m).onchange = upd; upd();
});
if (r) this.term('issueCommand', [r], 'certbot');
}
async selfSigned() {
const r = await modal({ title: T('Self-signed certificate'), body: `
<div class="row"><div class="field"><label>${T('Name')}</label><input name="name" placeholder="intranet"/></div><div class="field small"><label>${T('Valid (days)')}</label><input name="days" type="number" value="825"/></div></div>
<div class="field"><label>${T('Common name (CN)')}</label><input name="cn" placeholder="intranet.local"/></div>
<div class="field"><label>${T('Additional names / IPs')}</label><input name="sans" placeholder="server.local 192.168.1.10"/></div>
<div class="field"><label>${T('Key type')}</label><select name="keyType"><option value="ec">ECDSA P-256</option><option value="rsa">RSA 4096</option></select></div>
<div class="hint" style="color:var(--muted);font-size:12px">${T('Stored in /etc/ssl/mrterm/<name>.crt and .key. Browsers show a warning for self-signed certificates.')}</div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
if (!r) return;
try {
const res = await this.op('selfSigned', r);
toast(T('Created: {path}', { path: res.cert }), 'ok');
await this.refresh();
} catch {}
}
draw_check(w) {
const sec = this.section(T('Check TLS endpoint'));
const form = h(`<div class="row cert-probe"><div class="field"><label>${T('Host')}</label><input name="host" value="${esc(this.probeHost || this.host.address)}"/></div><div class="field small"><label>Port</label><input name="port" type="number" value="${esc(this.probePort || 443)}"/></div></div>`);
const go = h(`<button class="btn primary">${ICONS.search}${T('Check')}</button>`);
const out = h('<div class="probe-out"></div>');
go.onclick = async () => {
this.probeHost = $('[name=host]', form).value.trim(); this.probePort = $('[name=port]', form).value;
out.innerHTML = spinnerHtml(T('Checking …'));
try {
const r = await api.call('certs:probe', this.probeHost, Number(this.probePort) || 443);
out.innerHTML = `<div class="net-sys"><div><label>${T('Status')}</label><div class="v">${r.authorized ? `<span class="exp ok">${T('trusted')}</span>` : `<span class="exp err">${esc(r.error || T('not trusted'))}</span>`}</div></div>
<div><label>${T('Expires')}</label><div class="v">${expiryHtml(r.expires)}</div></div><div><label>${T('Issuer')}</label><div class="v">${esc(r.issuer)}</div></div>
<div><label>${T('Subject')}</label><div class="v">${esc(r.subject)}</div></div><div><label>${T('Protocol')}</label><div class="v">${esc(r.protocol)}</div></div>
<div style="grid-column:1/-1"><label>${T('Names')}</label><div class="v">${r.sans.map((x) => `<span class="tag">${esc(x)}</span>`).join(' ')}</div></div>
<div style="grid-column:1/-1"><label>SHA-256</label><div class="v mono small" style="word-break:break-all">${esc(r.fingerprint)}</div></div></div>`;
} catch (e) { out.innerHTML = `<div class="admin-empty" style="color:var(--red)">${esc(e.message)}</div>`; }
};
form.append(go);
sec.append(form, h(`<div class="muted small" style="margin:4px 0 12px">${T('The connection is made from this computer – useful to check which certificate a web server actually delivers.')}</div>`), out);
w.append(sec);
}
}
// ============================================================ Samba / Active Directory
const YES = (v) => /^(yes|true|1)$/i.test(String(v));
class SambaSession extends AdminSession {
constructor(host) {
super(host, { kind: 'samba', ch: 'samba', title: 'Samba', views: [['shares', T('Shares')], ['users', T('Users')], ['status', T('Status')], ['domain', T('Domain')]] });
this.adView = 'users';
}
notInstalled(w) {
const box = h(`<div class="pane-empty" style="height:auto;padding:48px 20px"><div class="fw-off">${ICONS.users}<b>${T('Samba is not installed')}</b><span>${T('Install Samba to share folders with Windows clients or to run a domain controller.')}</span></div></div>`);
const b = h(`<button class="btn primary">${ICONS.download}${T('Install Samba')}</button>`);
b.onclick = () => this.term('installCommand', ['samba'], `${T('Install')} Samba`);
$('.fw-off', box).append(b);
w.append(box);
}
summary(w) {
const d = this.data;
const g = d.global;
w.append(h(`<div class="net-sys"><div><label>${T('Version')}</label><div class="v">${esc(d.version || '–')}</div></div>
<div><label>${T('Server role')}</label><div class="v">${esc(d.role || '–')}</div></div>
<div><label>${T('Workgroup')}</label><div class="v">${esc(g.workgroup || '–')}</div></div>
${g.realm ? `<div><label>Realm</label><div class="v">${esc(g.realm)}</div></div>` : ''}</div>`));
}
draw_shares(w) {
const d = this.data;
if (!d.installed) return this.notInstalled(w);
this.summary(w);
const sec = this.section(T('Shares'), [
{ label: T('Edit smb.conf'), icon: 'edit', run: () => this.editConf() },
{ label: T('New share'), icon: 'plus', cls: 'primary', run: () => this.editShare(null) },
]);
if (!d.shares.length) sec.append(this.empty(T('No shares yet.')));
else sec.append(this.table(d.shares, [
[T('Share'), (s) => `<b>${esc(s.name)}</b>${s.comment ? `<div class="muted small">${esc(s.comment)}</div>` : ''}`, 'name'],
[T('Path'), (s) => `<span class="mono small">${esc(s.path || '–')}</span>`],
[T('Access'), (s) => `<span class="fw-act ${YES(s.readOnly) ? '' : 'allow'}">${YES(s.readOnly) ? T('read only') : T('read/write')}</span>${YES(s.guestOk) ? ` <span class="fw-act limit">${T('guest')}</span>` : ''}${YES(s.browseable) ? '' : ` <span class="fw-act">${T('hidden')}</span>`}`],
[T('Users'), (s) => esc(s.validUsers || T('all'))],
], (s) => [
{ icon: 'edit', title: T('Edit'), run: () => (s.special ? this.editConf() : this.editShare(s)) },
!s.special && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete share?'), T('The share “{name}” will be removed from smb.conf. The files in {path} are kept.', { name: s.name, path: s.path || '–' }))) this.act('deleteShare', [s.name], T('Share deleted')); } },
]));
w.append(sec);
}
editConf() {
this.editFile('/etc/samba/smb.conf', { read: () => this.op('readConf'), save: (c) => ['writeConf', c], note: T('Saving checks the file with testparm and reloads Samba. If the check fails, the previous version is restored.') });
}
async editShare(s) {
const v = s || { readOnly: 'no', browseable: 'yes', guestOk: 'no' };
const r = await modal({ title: s ? T('Edit share') : T('New share'), body: `
<div class="row"><div class="field"><label>${T('Name')}</label><input name="name" value="${esc(v.name || '')}" placeholder="daten"/></div>
<div class="field"><label>${T('Path')}</label><input name="path" value="${esc(v.path || '')}" placeholder="/srv/samba/daten"/></div></div>
<div class="field"><label>${T('Comment')}</label><input name="comment" value="${esc(v.comment || '')}" placeholder="${esc(T('optional'))}"/></div>
<div class="row"><label class="check"><input type="checkbox" name="readOnly" ${YES(v.readOnly) ? 'checked' : ''}/>${T('Read only')}</label>
<label class="check"><input type="checkbox" name="browseable" ${YES(v.browseable) ? 'checked' : ''}/>${T('Visible in the network')}</label>
<label class="check"><input type="checkbox" name="guestOk" ${YES(v.guestOk) ? 'checked' : ''}/>${T('Guest access')}</label></div>
<div class="field"><label>${T('Allowed users / groups')}</label><input name="validUsers" value="${esc(v.validUsers || '')}" placeholder="${esc(T('empty = all; e.g. anna, @staff'))}"/></div>
<div class="field"><label>${T('Users with write access')}</label><input name="writeList" value="${esc(v.writeList || '')}" placeholder="${esc(T('optional; e.g. @admins'))}"/></div>
<div class="row"><div class="field"><label>${T('File permissions (create mask)')}</label><input name="createMask" value="${esc(v.createMask || '')}" placeholder="0664"/></div>
<div class="field"><label>${T('Folder permissions (directory mask)')}</label><input name="directoryMask" value="${esc(v.directoryMask || '')}" placeholder="2775"/></div></div>
<label class="check"><input type="checkbox" name="createDir" ${s ? '' : 'checked'}/>${T('Create the folder if it does not exist')}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
const yn = (b) => (b ? 'yes' : 'no');
const known = ['path', 'comment', 'read only', 'browseable', 'guest ok', 'valid users', 'write list', 'create mask', 'directory mask'];
const extra = s ? Object.fromEntries(Object.entries(s.params || {}).filter(([k]) => !known.includes(k))) : {};
this.act('saveShare', [{ ...r, readOnly: yn(r.readOnly), browseable: yn(r.browseable), guestOk: yn(r.guestOk), extra }, s?.name || null, r.createDir], T('Share saved'));
}
draw_users(w) {
const d = this.data;
if (!d.installed) return this.notInstalled(w);
if (d.dc) {
w.append(h(`<div class="net-banner">${ICONS.users}<div>${T('This server is an Active Directory domain controller. Users are managed in the “Domain” view.')}</div></div>`));
return;
}
const sec = this.section(T('Samba users'), [{ label: T('Add user'), icon: 'plus', cls: 'primary', run: () => this.addUser() }]);
if (!d.users.length) sec.append(this.empty(T('No Samba users yet. Samba users need a Linux account and their own Samba password.')));
else sec.append(this.table(d.users, [
[T('User'), (u) => `<b>${esc(u.user)}</b>${u.fullName ? `<div class="muted small">${esc(u.fullName)}</div>` : ''}`, 'name'],
[T('Status'), (u) => (u.disabled ? `<span class="fw-act deny">${T('disabled')}</span>` : `<span class="fw-act allow">${T('active')}</span>`)],
[T('Flags'), (u) => `<span class="mono small">${esc(u.flags)}</span>`],
], (u) => [
{ icon: 'key', title: T('Set password'), run: () => this.password((pw) => this.act('setPassword', [u.user, pw], T('Password changed')), u.user) },
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => this.act('setUserEnabled', [u.user, u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete Samba user?'), T('“{name}” can no longer access shares. The Linux account is kept.', { name: u.user }))) this.act('deleteUser', [u.user], T('User deleted')); } },
]));
w.append(sec);
}
async password(run, name) {
const r = await modal({ title: T('Set password'), text: name, body: `<div class="field"><label>${T('New password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
run(r.pw, r);
}
async addUser() {
const r = await modal({ title: T('Add Samba user'), body: `
<div class="field"><label>${T('Username')}</label><input name="user" autocomplete="off"/></div>
<div class="row"><div class="field"><label>${T('Password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div></div>
<label class="check"><input type="checkbox" name="createUnix" checked/>${T('Create a Linux account without login if it does not exist')}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
this.act('addUser', [r.user.trim(), r.pw, r.createUnix], T('User added'));
}
draw_status(w) {
const d = this.data;
if (!d.installed) return this.notInstalled(w);
const svc = this.section(T('Services'));
if (!d.units.length) svc.append(this.empty(T('No Samba services found.')));
else {
svc.append(this.table(d.units, [[T('Service'), (u) => `<b>${esc(u.unit)}</b>`], [T('Status'), (u) => pill(u.active)]], () => []));
$$('tbody tr', svc).forEach((tr, i) => $('.acts', tr).append(this.serviceButtons(d.units[i], 'service', false)));
}
w.append(svc);
const st = d.status;
const con = this.section(T('Connected clients'));
if (st.raw !== undefined) con.append(h(`<pre class="fw-pre mono">${esc(st.raw || T('No connections.'))}</pre>`));
else if (!st.sessions.length) con.append(this.empty(T('No connections.')));
else con.append(this.table(st.sessions, [[T('User'), (x) => `<b>${esc(x.user)}</b>`], [T('Computer'), (x) => esc(x.machine || x.host)], [T('Protocol'), (x) => esc(x.protocol)], [T('Encryption'), (x) => esc(x.encryption || '–')],
[T('Shares'), (x) => st.shares.filter((t) => t.session === x.id).map((t) => `<span class="tag">${esc(t.service)}</span>`).join(' ')]]));
w.append(con);
if (st.files?.length) {
const f = this.section(T('Open files'));
f.append(this.table(st.files, [[T('File'), (x) => `<span class="mono small">${esc(x.path)}</span>`]]));
w.append(f);
}
}
async draw_domain(w) {
const d = this.data;
if (!d.dc) return this.drawMembership(w);
const seg = h(`<div class="seg sm ad-seg">${[['users', T('Users')], ['groups', T('Groups')], ['computers', T('Computers')], ['info', T('Domain info')]].map(([v, l]) => `<button data-ad="${v}" class="${this.adView === v ? 'active' : ''}">${esc(l)}</button>`).join('')}</div>`);
seg.onclick = (e) => { const v = e.target.closest('[data-ad]')?.dataset.ad; if (v) { this.adView = v; this.draw(); } };
w.append(seg);
const box = h(`<div>${spinnerHtml(T('Loading …'))}</div>`);
w.append(box);
try { if (!this.ad || this.adStale) { this.ad = await this.op('adRead'); this.adStale = false; } } catch { box.innerHTML = ''; return; }
box.innerHTML = '';
const ad = this.ad;
if (ad.limited) box.append(h(`<div class="net-banner warn">${ICONS.wall}<div>${T('ldbsearch is missing (package ldb-tools), so only names are shown.')}</div></div>`));
const reloadAd = async (name, args, msg) => { this.adStale = true; return this.act(name, args, msg); };
if (this.adView === 'users') {
const sec = this.section(T('Domain users'), [{ label: T('New user'), icon: 'plus', cls: 'primary', run: () => this.adNewUser(reloadAd) }]);
sec.append(this.table(ad.users, [
[T('User'), (u) => `<b>${esc(u.name)}</b>${u.display ? `<div class="muted small">${esc(u.display)}</div>` : ''}`, 'name'],
[T('E-mail'), (u) => esc(u.mail || '')],
[T('Groups'), (u) => (u.groups || []).slice(0, 4).map((g) => `<span class="tag">${esc(g)}</span>`).join(' ') + ((u.groups || []).length > 4 ? ` <span class="muted">+${u.groups.length - 4}</span>` : '')],
[T('Last logon'), (u) => (u.lastLogon ? esc(relTime(u.lastLogon)) : '<span class="muted">–</span>')],
[T('Status'), (u) => (u.disabled ? `<span class="fw-act deny">${T('disabled')}</span>` : `<span class="fw-act allow">${T('active')}</span>`)],
], (u) => [
{ icon: 'key', title: T('Set password'), run: () => this.adPassword(u.name, reloadAd) },
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => reloadAd('adUserEnable', [u.name, !!u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
!/^(Administrator|krbtgt|Guest)$/i.test(u.name) && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete domain user?'), T('“{name}” will be permanently deleted from the domain.', { name: u.name }))) reloadAd('adUserDelete', [u.name], T('User deleted')); } },
]));
box.append(sec);
} else if (this.adView === 'groups') {
const sec = this.section(T('Groups'), [{ label: T('New group'), icon: 'plus', cls: 'primary', run: async () => {
const r = await modal({ title: T('New group'), body: `<div class="field"><label>${T('Name')}</label><input name="name"/></div><div class="field"><label>${T('Description')}</label><input name="description" placeholder="${esc(T('optional'))}"/></div>`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
if (r) reloadAd('adGroupCreate', [r.name, r.description], T('Group created'));
} }]);
sec.append(this.table(ad.groups, [
[T('Group'), (g) => `<b>${esc(g.name)}</b>${g.description ? `<div class="muted small">${esc(g.description)}</div>` : ''}`, 'name'],
[T('Members'), (g) => `${(g.members || []).length}`, 'num'],
], (g) => [
{ icon: 'users', title: T('Members'), run: () => this.adMembers(g, reloadAd) },
!g.builtin && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('The group “{name}” will be deleted.', { name: g.name }))) reloadAd('adGroupDelete', [g.name], T('Group deleted')); } },
]));
box.append(sec);
} else if (this.adView === 'computers') {
const sec = this.section(T('Computers'));
sec.append(this.table(ad.computers, [
[T('Computer'), (c) => `<b>${esc(c.name)}</b>${c.dns ? `<div class="muted small">${esc(c.dns)}</div>` : ''}`, 'name'],
[T('Operating system'), (c) => esc(c.os || '')],
[T('Last logon'), (c) => (c.lastLogon ? esc(relTime(c.lastLogon)) : '<span class="muted">–</span>')],
], (c) => [{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete computer account?'), T('“{name}” will be removed from the domain and must join again.', { name: c.name }))) reloadAd('adComputerDelete', [c.name], T('Computer deleted')); } }]));
box.append(sec);
} else {
box.append(h(`<pre class="fw-pre mono">${esc([ad.info, ad.level, ad.fsmo].filter(Boolean).join('\n\n'))}</pre>`));
}
}
// Kein Domänencontroller: Mitgliedschaft anzeigen, beitreten/verlassen oder eine neue Domäne erstellen
drawMembership(w) {
const d = this.data;
const tools = new Set(d.tools);
const joined = (d.realm.match(/^(\S+)\s*$/m) || [])[1];
const btn = (label, icon, run, cls = '') => { const b = h(`<button class="btn sm ${cls}">${ICONS[icon]}${esc(label)}</button>`); b.onclick = run; return b; };
const mem = this.section(T('Domain membership'));
if (joined) {
mem.append(h(`<div class="net-banner">${ICONS.users}<div>${T('This server is a member of the domain {domain}.', { domain: `<b>${esc(joined)}</b>` })}</div></div>`));
mem.append(h(`<pre class="fw-pre mono" style="margin-top:10px">${esc(d.realm)}</pre>`));
const acts = h('<div class="cert-acts"></div>');
acts.append(btn(T('Leave domain'), 'close', async () => { if (await confirmBox(T('Leave domain?'), T('Domain users can no longer log in on this server. The computer account in the domain is kept.'), T('Leave'))) this.act('leaveDomain', [joined], T('Left the domain')); }, 'danger'));
mem.append(acts);
} else {
mem.append(this.empty(T('This server is not a member of a domain.')));
if (d.winbind) mem.append(h(`<div class="muted small">winbind: ${esc(d.winbind)}</div>`));
const acts = h('<div class="cert-acts"></div>');
if (tools.has('realm')) acts.append(btn(T('Join domain …'), 'plus', () => this.joinDialog(), 'primary'));
else acts.append(btn(T('Install domain join tools'), 'download', () => this.term('installCommand', ['join'], `${T('Install')} realmd/sssd`), 'primary'));
mem.append(acts);
}
w.append(mem);
if (!joined) {
const dc = this.section(T('Create a new domain'));
dc.append(h(`<div class="muted" style="font-size:13px;margin-bottom:10px">${T('Turns this server into the first domain controller of a new Active Directory domain. Windows and Linux computers can then join it, and you manage users, groups and computers right here.')}</div>`));
const acts = h('<div class="cert-acts" style="margin-left:0"></div>');
if (tools.has('samba-tool')) acts.append(btn(T('Create domain …'), 'plus', () => this.provisionDialog(), 'primary'));
acts.append(btn(T('Install domain controller packages'), 'download', () => this.term('installCommand', ['dc'], `${T('Install')} Samba AD DC`)));
dc.append(acts);
w.append(dc);
}
}
async joinDialog() {
const r = await modal({ title: T('Join domain'), body: `
<div class="field"><label>${T('Domain')}</label><input name="domain" placeholder="corp.example.com"/></div>
<div class="row"><div class="field"><label>${T('Domain administrator')}</label><input name="admin" value="Administrator"/></div><div class="field"><label>${T('Password')}</label><input name="password" type="password" autocomplete="off"/></div></div>
<div class="hint" style="color:var(--muted);font-size:12px">${T('The server must resolve the domain via DNS (the domain controller as DNS server). Logins of domain users are then handled by sssd.')}</div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Join'), value: 'form', cls: 'primary' }] });
if (!r) return;
this.body.innerHTML = spinnerHtml(T('Joining {domain} …', { domain: r.domain }));
if (!(await this.act('joinDomain', [r.domain, r.admin, r.password], T('Joined the domain')))) this.draw();
}
async provisionDialog() {
const host = (this.host.address || '').includes('.') && !/^\d/.test(this.host.address) ? this.host.address.split('.').slice(1).join('.') : '';
const r = await formModal({ title: T('Create a new domain'), body: `
<div class="row"><div class="field"><label>${T('DNS domain (realm)')}</label><input name="realm" placeholder="corp.example.com" value="${esc(host)}"/></div>
<div class="field small" style="flex:0 0 150px"><label>${T('NetBIOS name')}</label><input name="domain" placeholder="CORP" maxlength="15"/></div></div>
<div class="row"><div class="field"><label>${T('Password for Administrator')}</label><input name="password" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="password2" type="password" autocomplete="new-password"/></div></div>
<div class="field"><label>${T('DNS forwarder')}</label><input name="forwarder" placeholder="1.1.1.1"/><div class="hint">${T('Queries for other domains are forwarded to this DNS server.')}</div></div>
<label class="check"><input type="checkbox" name="useAsDns" checked/>${T('Use this domain controller as DNS server of the host (resolv.conf)')}</label>
<label class="check"><input type="checkbox" name="rfc2307"/>${T('Store Unix attributes (UID/GID) in the directory (RFC 2307)')}</label>
<div class="net-banner warn" style="margin-top:10px">${ICONS.wall}<div>${T('The file server services (smbd/nmbd) are replaced by samba-ad-dc; the previous smb.conf is kept as smb.conf.mrterm-before-dc. The server needs a fixed IP address and a hostname that is not the domain name. The password must be complex (upper/lower case, digits, 8+ characters).')}</div></div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create domain'), value: 'form', cls: 'primary' }] }, (m) => {
$('[name=realm]', m).oninput = (e) => { const n = $('[name=domain]', m); if (!n.dataset.touched) n.value = (e.target.value.split('.')[0] || '').toUpperCase().slice(0, 15); };
$('[name=domain]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
$('[name=realm]', m).dispatchEvent(new Event('input'));
});
if (!r) return;
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
this.body.innerHTML = spinnerHtml(T('Creating the domain {realm} … this takes about a minute.', { realm: r.realm.toUpperCase() }));
this.ad = null;
if (await this.act('provisionDomain', [r], T('Domain {realm} created', { realm: r.realm.toUpperCase() }))) { this.adView = 'info'; this.draw(); } else this.draw();
}
async adNewUser(reloadAd) {
const r = await modal({ title: T('New domain user'), body: `
<div class="field"><label>${T('Username')}</label><input name="name" autocomplete="off"/></div>
<div class="row"><div class="field"><label>${T('First name')}</label><input name="given"/></div><div class="field"><label>${T('Last name')}</label><input name="surname"/></div></div>
<div class="field"><label>${T('E-mail')}</label><input name="mail" placeholder="${esc(T('optional'))}"/></div>
<div class="row"><div class="field"><label>${T('Password')}</label><input name="password" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="password2" type="password" autocomplete="new-password"/></div></div>
<label class="check"><input type="checkbox" name="mustChange" checked/>${T('User must change the password at next logon')}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
reloadAd('adUserCreate', [r], T('User created'));
}
async adPassword(name, reloadAd) {
const r = await modal({ title: T('Set password'), text: name, body: `<div class="field"><label>${T('New password')}</label><input name="pw" type="password" autocomplete="new-password"/></div><div class="field"><label>${T('Repeat password')}</label><input name="pw2" type="password" autocomplete="new-password"/></div><label class="check"><input type="checkbox" name="mustChange"/>${T('User must change the password at next logon')}</label>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
reloadAd('adSetPassword', [name, r.pw, r.mustChange], T('Password changed'));
}
async adMembers(g, reloadAd) {
const users = (this.ad.users || []).map((u) => u.name);
const r = await modal({ title: T('Members of {name}', { name: g.name }), body: `<div class="field"><label>${T('One member per line (users, groups or computers)')}</label><textarea name="members" rows="10" class="mono" spellcheck="false">${esc((g.members || []).join('\n'))}</textarea></div>
${users.length ? `<div class="hint" style="color:var(--muted);font-size:12px">${T('Users')}: ${users.slice(0, 40).map(esc).join(', ')}${users.length > 40 ? ' …' : ''}</div>` : ''}`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
const now = r.members.split('\n').map((x) => x.trim()).filter(Boolean);
const before = g.members || [];
const add = now.filter((x) => !before.includes(x)), remove = before.filter((x) => !now.includes(x));
if (add.length || remove.length) reloadAd('adGroupMembers', [g.name, add, remove], T('Members updated'));
}
}