/* 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')); } }