From d3faa2514fb4f4c7bb53fc615aec8cacb1b434af Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 23 May 2026 16:17:34 +0100 Subject: [PATCH] feat(backup): SSH key card in the sftp location editor When a location uses SSH key auth, show a key card: paste an existing private key, or 'Generate keypair', then the card displays the public key to copy into the remote server's authorized_keys (with Copy/Delete). Wires to the ssh-key-set/generate/delete CLI; key mutations refresh locations.json so the card reflects state immediately. applySshAuthVisibility toggles the card vs the password field by auth mode. Private key only ever flows in (base64); only the public key is ever shown. Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- .../libreportal/frontend/css/backup.css | 65 +++++++++++++++ .../js/components/backup/backup-page.js | 83 ++++++++++++++++++- scripts/backup/locations/location_ssh.sh | 11 +++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/containers/libreportal/frontend/css/backup.css b/containers/libreportal/frontend/css/backup.css index 4212c5d..5eb2ad0 100755 --- a/containers/libreportal/frontend/css/backup.css +++ b/containers/libreportal/frontend/css/backup.css @@ -739,6 +739,71 @@ padding: 20px 22px; } +/* SSH key card (sftp locations). LibrePortal holds the private key; only the + public key is shown — that's what goes in the remote's authorized_keys. */ +.backup-ssh-key-card { + margin-top: 14px; + padding: 14px; + border: 1px solid rgba(var(--text-rgb), 0.10); + border-radius: 10px; + background: rgba(var(--text-rgb), 0.03); +} + +.backup-ssh-key-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.backup-ssh-key-title { + font-weight: 600; + font-size: 0.95rem; +} + +.backup-ssh-key-status { + font-size: 0.8rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; +} + +.backup-ssh-key-status.ok { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.12); +} + +.backup-ssh-key-status.none { + color: rgba(var(--text-rgb), 0.6); + background: rgba(var(--text-rgb), 0.08); +} + +.backup-ssh-pubkey, +.backup-ssh-keyinput { + width: 100%; + box-sizing: border-box; + font-family: var(--font-mono, monospace); + font-size: 0.8rem; + line-height: 1.4; + padding: 8px 10px; + border: 1px solid rgba(var(--text-rgb), 0.12); + border-radius: 8px; + background: var(--card-bg); + color: var(--text-primary); + resize: vertical; +} + +.backup-ssh-pubkey { + word-break: break-all; +} + +.backup-ssh-key-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + .backup-form-footer { display: flex; justify-content: flex-end; diff --git a/containers/libreportal/frontend/js/components/backup/backup-page.js b/containers/libreportal/frontend/js/components/backup/backup-page.js index 67648a2..27e7dd8 100644 --- a/containers/libreportal/frontend/js/components/backup/backup-page.js +++ b/containers/libreportal/frontend/js/components/backup/backup-page.js @@ -209,6 +209,15 @@ class BackupPage { return; } + const sshSave = e.target.closest('[data-action="ssh-key-save"]'); + if (sshSave) { this.saveBackupSshKey(parseInt(sshSave.dataset.loc, 10)); return; } + const sshGen = e.target.closest('[data-action="ssh-key-generate"]'); + if (sshGen) { this.generateBackupSshKey(parseInt(sshGen.dataset.loc, 10)); return; } + const sshDel = e.target.closest('[data-action="ssh-key-delete"]'); + if (sshDel) { this.deleteBackupSshKey(parseInt(sshDel.dataset.loc, 10)); return; } + const sshCopy = e.target.closest('[data-action="ssh-key-copy"]'); + if (sshCopy) { this.copyBackupSshKey(parseInt(sshCopy.dataset.loc, 10)); return; } + const locTab = e.target.closest('[data-action="loc-tab"]'); if (locTab) { const tabIdx = locTab.dataset.loc; @@ -704,7 +713,7 @@ class BackupPage {
- ${this.renderLocFields(idx, groups.connection, l)} + ${this.renderConnectionInner(idx, l.type, l, groups.connection)}
@@ -778,7 +787,7 @@ class BackupPage { const conn = document.getElementById(`backup-location-${idx}-connection`); if (conn) { - conn.innerHTML = this.renderLocFields(idx, groups.connection, { ...loc, type }); + conn.innerHTML = this.renderConnectionInner(idx, type, { ...loc, type }, groups.connection); this.tagFieldsForSave(conn); } @@ -807,10 +816,13 @@ class BackupPage { applySshAuthVisibility(scope) { const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]'); if (!authSelect) return; + const isPassword = authSelect.value === 'password'; const passInput = scope.querySelector('input[name$="_SSH_PASS"]'); const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement; - if (!passGroup) return; - passGroup.style.display = authSelect.value === 'password' ? '' : 'none'; + if (passGroup) passGroup.style.display = isPassword ? '' : 'none'; + // SSH key card is the counterpart: shown for key auth, hidden for password. + const keyCard = scope.querySelector('.backup-ssh-key-card'); + if (keyCard) keyCard.style.display = isPassword ? 'none' : ''; } /* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */ @@ -1520,6 +1532,69 @@ class BackupPage { return { connection, advanced }; } + /* Connection-tab body: the generic fields plus, for sftp, the SSH key card. + Used both on first render and when the Type select changes. */ + renderConnectionInner(idx, type, loc, connectionSuffixes) { + let html = this.renderLocFields(idx, connectionSuffixes, loc); + if (type === 'sftp') html += this.renderBackupSshKeyCard(loc); + return html; + } + + /* SSH key card for an sftp location. LibrePortal holds the private key in + the location's ssh.key file; only the public key is shown — that's what + you paste into the remote server's authorized_keys. Hidden by + applySshAuthVisibility when SSH auth = password. */ + renderBackupSshKeyCard(l) { + const idx = l.idx; + const hasKey = l.ssh_key_exists === true; + const pub = l.ssh_public_key || ''; + const body = hasKey ? ` +

Add this public key to the remote server's ~/.ssh/authorized_keys:

+ +
+ + +
` : ` +

Paste an existing private key, or generate one and we'll show the public key to add on the remote.

+ +
+ + +
`; + return ` +
+
+ SSH key + ${hasKey ? '✓ Key configured' : 'No key yet'} +
+ ${body} +
`; + } + + async saveBackupSshKey(idx) { + const card = document.querySelector(`.backup-ssh-key-card[data-loc="${idx}"]`); + const key = (card?.querySelector('.backup-ssh-keyinput')?.value || '').trim(); + if (!key) { this.notify('Paste a private key first', 'error'); return; } + const b64 = btoa(unescape(encodeURIComponent(key + '\n'))); + await this.runTask(`libreportal backup location ssh-key-set ${idx} ${b64}`, 'backup', null); + } + + async generateBackupSshKey(idx) { + await this.runTask(`libreportal backup location ssh-key-generate ${idx}`, 'backup', null); + } + + async deleteBackupSshKey(idx) { + if (!confirm("Delete this location's SSH key? Backups here will fail until a new key is set and added on the remote.")) return; + await this.runTask(`libreportal backup location ssh-key-delete ${idx}`, 'backup', null); + } + + async copyBackupSshKey(idx) { + const loc = (this.locations?.locations || []).find(l => l.idx === idx); + const pub = loc?.ssh_public_key || ''; + try { await navigator.clipboard.writeText(pub); this.notify('Public key copied', 'success'); } + catch { this.notify('Copy failed — select the text and copy manually', 'error'); } + } + tagFieldsForSave(container) { container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => { if (!el.hasAttribute('data-backup-field')) { diff --git a/scripts/backup/locations/location_ssh.sh b/scripts/backup/locations/location_ssh.sh index 4909c05..c54997b 100644 --- a/scripts/backup/locations/location_ssh.sh +++ b/scripts/backup/locations/location_ssh.sh @@ -6,6 +6,14 @@ # separately. The CFG_BACKUP_LOC__SSH_AUTH selector (key|password) # is the only ssh-related value in the location.config file. +# Refresh locations.json so the editor reflects new key state (public key / +# "key set" status) right after a mutation. No-op if the generator isn't loaded. +backupSshKeyRefreshUi() +{ + declare -f webuiGenerateBackupLocations >/dev/null 2>&1 && webuiGenerateBackupLocations >/dev/null 2>&1 + return 0 +} + backupSshKeyFile() { local idx="$1" @@ -54,6 +62,7 @@ backupSshKeySet() fi isSuccessful "SSH key saved for location $idx" + backupSshKeyRefreshUi } backupSshKeyGenerate() @@ -81,6 +90,7 @@ backupSshKeyGenerate() isSuccessful "Generated ed25519 keypair for location $idx" isNotice "Public key (paste into the remote host's ~/.ssh/authorized_keys):" backupSshKeyPublic "$idx" + backupSshKeyRefreshUi } backupSshKeyPublic() @@ -99,4 +109,5 @@ backupSshKeyDelete() key_file=$(backupSshKeyFile "$idx") [[ -f "$key_file" ]] && sudo rm -f "$key_file" "${key_file}.pub" isSuccessful "SSH key removed for location $idx" + backupSshKeyRefreshUi }