feat(ssh): WebUI SSH Access page
New /ssh page (topbar nav + SPA route + SshPage controller + ssh-content.html + ssh.css). Reads data/ssh/access.json and lets the admin: paste a public key to authorize a machine, remove keys, and toggle key-only login — all via 'libreportal ssh ...' tasks through the backend's lockout guards. Reuses the backup key-card styles for a consistent look. This is the inbound counterpart to the backup location key card (outbound): same paste-a-key model, opposite direction. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
parent
44ad449e21
commit
e75f10618d
73
containers/libreportal/frontend/css/ssh.css
Normal file
73
containers/libreportal/frontend/css/ssh.css
Normal file
@ -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;
|
||||
}
|
||||
9
containers/libreportal/frontend/html/ssh-content.html
Normal file
9
containers/libreportal/frontend/html/ssh-content.html
Normal file
@ -0,0 +1,9 @@
|
||||
<div class="container 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 id="ssh-page-root">
|
||||
<div class="backup-empty-state">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -51,6 +51,15 @@
|
||||
</svg>
|
||||
Backups
|
||||
</a>
|
||||
<a href="/ssh" class="nav-item" id="nav-ssh">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4.5"></circle>
|
||||
<path d="M10.7 12.3 19 4"></path>
|
||||
<path d="M17 6l2 2"></path>
|
||||
<path d="M15 8l2 2"></path>
|
||||
</svg>
|
||||
SSH Access
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mobile-drawer-page-section" id="mobile-drawer-page-section"></div>
|
||||
<div class="topbar-controls">
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<link rel="stylesheet" href="/css/ip-whitelist.css">
|
||||
<link rel="stylesheet" href="/css/port-manager.css">
|
||||
<link rel="stylesheet" href="/css/backup.css">
|
||||
<link rel="stylesheet" href="/css/ssh.css">
|
||||
<link rel="stylesheet" href="/css/services.css">
|
||||
<link rel="stylesheet" href="/css/modal.css">
|
||||
<link rel="stylesheet" href="/css/tools.css">
|
||||
@ -97,6 +98,7 @@
|
||||
<script src="/js/system/system-orchestrator.js"></script>
|
||||
<script src="/js/components/backup/backup-page.js"></script>
|
||||
<script src="/js/components/backup/backup-app-card.js"></script>
|
||||
<script src="/js/components/ssh/ssh-page.js"></script>
|
||||
<script src="/js/spa.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
132
containers/libreportal/frontend/js/components/ssh/ssh-page.js
Normal file
132
containers/libreportal/frontend/js/components/ssh/ssh-page.js
Normal file
@ -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 = `<div class="backup-empty-state">Couldn't load SSH access data.</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="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>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
@ -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 {
|
||||
|
||||
@ -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...');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user