diff --git a/containers/libreportal/frontend/css/ssh.css b/containers/libreportal/frontend/css/ssh.css new file mode 100644 index 0000000..078f268 --- /dev/null +++ b/containers/libreportal/frontend/css/ssh.css @@ -0,0 +1,73 @@ +/* SSH Access page. Reuses the .backup-ssh-key-card / button styles from + backup.css for the cards; this file only adds page chrome + the key list. */ + +.ssh-page { + max-width: 860px; + margin: 0 auto; + padding: 24px 20px 40px; +} + +.ssh-page-header { + margin-bottom: 18px; +} + +.ssh-page-header h1 { + margin: 0 0 6px; + font-size: 1.5rem; +} + +.ssh-page-sub { + margin: 0; + color: rgba(var(--text-rgb), 0.65); + font-size: 0.92rem; + line-height: 1.45; +} + +.ssh-page .backup-ssh-key-card { + margin-top: 16px; +} + +.ssh-key-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ssh-key-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 8px; + background: var(--card-bg); +} + +.ssh-key-row-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.ssh-key-type { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent); +} + +.ssh-key-comment { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); +} + +.ssh-key-fp { + font-family: var(--font-mono, monospace); + font-size: 0.74rem; + color: rgba(var(--text-rgb), 0.6); + word-break: break-all; +} diff --git a/containers/libreportal/frontend/html/ssh-content.html b/containers/libreportal/frontend/html/ssh-content.html new file mode 100644 index 0000000..1ba6ee1 --- /dev/null +++ b/containers/libreportal/frontend/html/ssh-content.html @@ -0,0 +1,9 @@ +
+
+

SSH Access

+

Control who can SSH into this server. Grant access by adding a public key, and optionally require key-only login.

+
+
+
Loading…
+
+
diff --git a/containers/libreportal/frontend/html/topbar.html b/containers/libreportal/frontend/html/topbar.html index 3a1380a..2bd09e2 100755 --- a/containers/libreportal/frontend/html/topbar.html +++ b/containers/libreportal/frontend/html/topbar.html @@ -51,6 +51,15 @@ Backups + + + + + + + + SSH Access +
diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html index 65172ac..d3d5499 100755 --- a/containers/libreportal/frontend/index.html +++ b/containers/libreportal/frontend/index.html @@ -17,6 +17,7 @@ + @@ -97,6 +98,7 @@ + diff --git a/containers/libreportal/frontend/js/components/ssh/ssh-page.js b/containers/libreportal/frontend/js/components/ssh/ssh-page.js new file mode 100644 index 0000000..3f0afe4 --- /dev/null +++ b/containers/libreportal/frontend/js/components/ssh/ssh-page.js @@ -0,0 +1,132 @@ +// SSH Access page — manage inbound admin SSH to this host: authorize public +// keys (paste to grant access), remove them, and toggle password login behind +// the backend's lockout guard. Reads /data/ssh/access.json; all mutations run +// as `libreportal ssh ...` tasks. LibrePortal never handles a private key here. +class SshPage { + constructor() { + this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; + this.data = null; + this._bound = false; + } + + async init() { + 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 => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = document.getElementById('ssh-page-root'); + if (!root) return; + if (!this.data) { + root.innerHTML = `
Couldn't load SSH access data.
`; + 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 => ` +
+
+ ${this.escape(k.type)} + ${this.escape(k.comment || '(no comment)')} + ${this.escape(k.fingerprint)} +
+ +
`).join('') : `
No keys authorized yet — add one below to allow key-based login.
`; + + root.innerHTML = ` +
+
+ Login + ${pwOn ? 'Password login: ON' : 'Key-only login'} +
+

Logging in as ${this.escape(d.user)} · ${keys.length} authorized key${keys.length === 1 ? '' : 's'}.

+
+ ${pwOn + ? `` + : ``} +
+ ${pwOn ? '' : `

Password login is disabled — only the keys below can connect.

`} +
+ +
+
Add a key
+

Paste a public key (the .pub from your machine) to grant it SSH access:

+ +
+ +
+
+ +
+
Authorized keys
+
${keysHtml}
+
+ `; + } + + 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 input = document.getElementById('ssh-add-key-input'); + const 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; diff --git a/containers/libreportal/frontend/js/components/topbar.js b/containers/libreportal/frontend/js/components/topbar.js index bae0e45..05092e4 100755 --- a/containers/libreportal/frontend/js/components/topbar.js +++ b/containers/libreportal/frontend/js/components/topbar.js @@ -272,6 +272,8 @@ class TopbarComponent { activeNavId = 'nav-tasks'; } else if (path.startsWith('/backup')) { activeNavId = 'nav-backup'; + } else if (path.startsWith('/ssh')) { + activeNavId = 'nav-ssh'; } else if (path === '/' || path === '/dashboard') { activeNavId = 'nav-dashboard'; } else { diff --git a/containers/libreportal/frontend/js/spa.js b/containers/libreportal/frontend/js/spa.js index 6d6d4b4..b77813c 100755 --- a/containers/libreportal/frontend/js/spa.js +++ b/containers/libreportal/frontend/js/spa.js @@ -74,6 +74,8 @@ class LibrePortalSPAClean { this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query this.routes.set('/backup', () => this.handleBackup()); this.routes.set('/backup*', () => this.handleBackup()); + this.routes.set('/ssh', () => this.handleSsh()); + this.routes.set('/ssh*', () => this.handleSsh()); //console.log('📍 Routes registered:', Array.from(this.routes.keys())); } @@ -267,6 +269,22 @@ class LibrePortalSPAClean { } } + async handleSsh() { + try { + const html = await this.fetchContent('/html/ssh-content.html'); + this.loadContent(html, 'SSH Access'); + if (typeof SshPage !== 'undefined') { + window.sshPage = new SshPage(); + await window.sshPage.init(); + } else { + console.error('SshPage class not loaded'); + } + } catch (error) { + console.error('❌ SSH page load error:', error); + this.showError('Failed to load SSH page'); + } + } + async handleApps() { //console.log('📱 Loading apps...');