librelad 4fd043a852 refactor(webui): fold SSH Access into an Admin area
Rename the Config top-nav to 'Admin' and move SSH Access into its sidebar
under a 'Tools' group, instead of a separate top-level nav item. SSH Access is
rendered by SshPage into the config main pane via a renderConfig('ssh-access')
special case; the sidebar item (config-sidebar.js) routes there. SshPage now
mounts into any container (defaults to #config-section). /ssh redirects to
/config?=ssh-access for old links; the standalone ssh-content.html is removed.

Declutters the top bar and gives system/admin features one home that scales
(updates, users, Connect settings can become sidebar entries later).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 17:31:26 +01:00

146 lines
6.8 KiB
JavaScript

// SSH Access — inbound admin SSH to this host. Lives in the Admin area (a
// sidebar item on the Config/Admin page) and renders into whatever container
// it's given (defaults to #config-section). Authorize public keys (paste to
// grant access), remove them, and toggle password login behind the backend's
// lockout guard. Reads /data/ssh/access.json; mutations run as
// `libreportal ssh ...` tasks. LibrePortal never handles a private key here.
class SshPage {
constructor(rootId = 'config-section') {
this.rootId = rootId;
this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null;
this.data = null;
this._bound = false;
}
root() { return document.getElementById(this.rootId); }
async init() {
const r = this.root();
if (r) r.innerHTML = '<div class="ssh-page"><div class="backup-empty-state">Loading…</div></div>';
this.bindEvents();
await this.refresh();
this.render();
}
async refresh() {
this.data = await this.fetchJson(`/data/ssh/access.json?t=${Date.now()}`);
}
async fetchJson(url) {
try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); }
catch { return null; }
}
escape(s) {
return String(s ?? '').replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
notify(msg, type) {
if (window.notificationSystem) window.notificationSystem.show(msg, type || 'info');
else console.log(`[ssh ${type || 'info'}] ${msg}`);
}
bindEvents() {
if (this._bound) return;
this._bound = true;
document.addEventListener('click', (e) => {
if (e.target.closest('[data-action="ssh-add-key"]')) { this.addKey(); return; }
const rm = e.target.closest('[data-action="ssh-remove-key"]');
if (rm) { this.removeKey(rm.dataset.fp); return; }
const tog = e.target.closest('[data-action="ssh-toggle-password"]');
if (tog) { this.togglePassword(tog.dataset.next); return; }
});
}
render() {
const root = this.root();
if (!root) return;
if (!this.data) {
root.innerHTML = `<div class="ssh-page"><div class="backup-empty-state">Couldn't load SSH access data.</div></div>`;
return;
}
const d = this.data;
const pwOn = d.password_auth === true;
const keys = Array.isArray(d.keys) ? d.keys : [];
const keysHtml = keys.length ? keys.map(k => `
<div class="ssh-key-row">
<div class="ssh-key-row-main">
<span class="ssh-key-type">${this.escape(k.type)}</span>
<span class="ssh-key-comment">${this.escape(k.comment || '(no comment)')}</span>
<span class="ssh-key-fp">${this.escape(k.fingerprint)}</span>
</div>
<button type="button" class="backup-danger-btn" data-action="ssh-remove-key" data-fp="${this.escape(k.fingerprint)}">Remove</button>
</div>`).join('') : `<div class="backup-empty-state">No keys authorized yet — add one below to allow key-based login.</div>`;
root.innerHTML = `
<div class="ssh-page">
<div class="ssh-page-header">
<h1>SSH Access</h1>
<p class="ssh-page-sub">Control who can SSH into this server. Grant access by adding a public key, and optionally require key-only login.</p>
</div>
<div class="backup-ssh-key-card">
<div class="backup-ssh-key-head">
<span class="backup-ssh-key-title">Login</span>
<span class="backup-ssh-key-status ${pwOn ? 'none' : 'ok'}">${pwOn ? 'Password login: ON' : 'Key-only login'}</span>
</div>
<p class="backup-card-hint">Logging in as <code>${this.escape(d.user)}</code> · ${keys.length} authorized key${keys.length === 1 ? '' : 's'}.</p>
<div class="backup-ssh-key-actions">
${pwOn
? `<button type="button" class="backup-secondary-btn" data-action="ssh-toggle-password" data-next="off">Require key-only login</button>`
: `<button type="button" class="backup-secondary-btn" data-action="ssh-toggle-password" data-next="on">Re-enable password login</button>`}
</div>
${pwOn ? '' : `<p class="backup-card-hint" style="margin-top:8px">Password login is disabled — only the keys below can connect.</p>`}
</div>
<div class="backup-ssh-key-card">
<div class="backup-ssh-key-head"><span class="backup-ssh-key-title">Add a key</span></div>
<p class="backup-card-hint">Paste a <strong>public</strong> key (the <code>.pub</code> from your machine) to grant it SSH access:</p>
<textarea class="backup-ssh-keyinput" id="ssh-add-key-input" rows="3" spellcheck="false" placeholder="ssh-ed25519 AAAA... you@laptop"></textarea>
<div class="backup-ssh-key-actions">
<button type="button" class="backup-primary-btn" data-action="ssh-add-key">Authorize key</button>
</div>
</div>
<div class="backup-ssh-key-card">
<div class="backup-ssh-key-head"><span class="backup-ssh-key-title">Authorized keys</span></div>
<div class="ssh-key-list">${keysHtml}</div>
</div>
</div>
`;
}
async runTask(command) {
if (!this.taskManager) { this.notify('Task system unavailable', 'error'); return; }
try {
await this.taskManager.createTask(command, 'ssh', null);
setTimeout(() => this.refresh().then(() => this.render()), 1500);
} catch (e) {
this.notify(`Failed to queue task: ${e.message || e}`, 'error');
}
}
async addKey() {
const key = (document.getElementById('ssh-add-key-input')?.value || '').trim();
if (!key) { this.notify('Paste a public key first', 'error'); return; }
const b64 = btoa(unescape(encodeURIComponent(key)));
await this.runTask(`libreportal ssh key-add ${b64}`);
}
async removeKey(fp) {
if (!fp) return;
if (!confirm('Remove this key? That machine will no longer be able to SSH in.')) return;
await this.runTask(`libreportal ssh key-remove ${fp}`);
}
async togglePassword(next) {
if (next === 'off' && !confirm('Disable password login? Make sure a key above works first — otherwise you could lock yourself out.')) return;
await this.runTask(`libreportal ssh password-auth ${next}`);
}
}
window.SshPage = SshPage;