Compare commits

...

2 Commits

Author SHA1 Message Date
librelad
4078468a97 Merge claude/1 2026-05-23 16:17:34 +01:00
librelad
d3faa2514f 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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 16:17:34 +01:00
3 changed files with 155 additions and 4 deletions

View File

@ -739,6 +739,71 @@
padding: 20px 22px; 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 { .backup-form-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@ -209,6 +209,15 @@ class BackupPage {
return; 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"]'); const locTab = e.target.closest('[data-action="loc-tab"]');
if (locTab) { if (locTab) {
const tabIdx = locTab.dataset.loc; const tabIdx = locTab.dataset.loc;
@ -704,7 +713,7 @@ class BackupPage {
<div class="tabs-content"> <div class="tabs-content">
<div class="tab-panel active" data-tab-panel="connection" data-loc="${idx}"> <div class="tab-panel active" data-tab-panel="connection" data-loc="${idx}">
<div class="backup-location-connection-fields" id="backup-location-${idx}-connection"> <div class="backup-location-connection-fields" id="backup-location-${idx}-connection">
${this.renderLocFields(idx, groups.connection, l)} ${this.renderConnectionInner(idx, l.type, l, groups.connection)}
</div> </div>
</div> </div>
<div class="tab-panel" data-tab-panel="retention" data-loc="${idx}"> <div class="tab-panel" data-tab-panel="retention" data-loc="${idx}">
@ -778,7 +787,7 @@ class BackupPage {
const conn = document.getElementById(`backup-location-${idx}-connection`); const conn = document.getElementById(`backup-location-${idx}-connection`);
if (conn) { if (conn) {
conn.innerHTML = this.renderLocFields(idx, groups.connection, { ...loc, type }); conn.innerHTML = this.renderConnectionInner(idx, type, { ...loc, type }, groups.connection);
this.tagFieldsForSave(conn); this.tagFieldsForSave(conn);
} }
@ -807,10 +816,13 @@ class BackupPage {
applySshAuthVisibility(scope) { applySshAuthVisibility(scope) {
const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]'); const authSelect = scope.querySelector('select[name$="_SSH_AUTH"]');
if (!authSelect) return; if (!authSelect) return;
const isPassword = authSelect.value === 'password';
const passInput = scope.querySelector('input[name$="_SSH_PASS"]'); const passInput = scope.querySelector('input[name$="_SSH_PASS"]');
const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement; const passGroup = passInput?.closest('.field-group') || passInput?.closest('.password-mode-wrapper')?.parentElement;
if (!passGroup) return; if (passGroup) passGroup.style.display = isPassword ? '' : 'none';
passGroup.style.display = authSelect.value === 'password' ? '' : '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. */ /* Hide the custom PATH input when PATH_MODE=auto, show when =custom. */
@ -1520,6 +1532,69 @@ class BackupPage {
return { connection, advanced }; 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 ? `
<p class="backup-card-hint">Add this public key to the remote server's <code>~/.ssh/authorized_keys</code>:</p>
<textarea class="backup-ssh-pubkey" readonly rows="2" spellcheck="false">${this.escape(pub)}</textarea>
<div class="backup-ssh-key-actions">
<button type="button" class="backup-secondary-btn" data-action="ssh-key-copy" data-loc="${idx}">Copy public key</button>
<button type="button" class="backup-danger-btn" data-action="ssh-key-delete" data-loc="${idx}">Delete key</button>
</div>` : `
<p class="backup-card-hint">Paste an existing private key, or generate one and we'll show the public key to add on the remote.</p>
<textarea class="backup-ssh-keyinput" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
<div class="backup-ssh-key-actions">
<button type="button" class="backup-primary-btn" data-action="ssh-key-save" data-loc="${idx}">Save key</button>
<button type="button" class="backup-secondary-btn" data-action="ssh-key-generate" data-loc="${idx}">Generate keypair</button>
</div>`;
return `
<div class="backup-ssh-key-card" data-loc="${idx}">
<div class="backup-ssh-key-head">
<span class="backup-ssh-key-title">SSH key</span>
<span class="backup-ssh-key-status ${hasKey ? 'ok' : 'none'}">${hasKey ? '✓ Key configured' : 'No key yet'}</span>
</div>
${body}
</div>`;
}
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) { tagFieldsForSave(container) {
container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => { container.querySelectorAll('input[name], select[name], textarea[name]').forEach(el => {
if (!el.hasAttribute('data-backup-field')) { if (!el.hasAttribute('data-backup-field')) {

View File

@ -6,6 +6,14 @@
# separately. The CFG_BACKUP_LOC_<idx>_SSH_AUTH selector (key|password) # separately. The CFG_BACKUP_LOC_<idx>_SSH_AUTH selector (key|password)
# is the only ssh-related value in the location.config file. # 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() backupSshKeyFile()
{ {
local idx="$1" local idx="$1"
@ -54,6 +62,7 @@ backupSshKeySet()
fi fi
isSuccessful "SSH key saved for location $idx" isSuccessful "SSH key saved for location $idx"
backupSshKeyRefreshUi
} }
backupSshKeyGenerate() backupSshKeyGenerate()
@ -81,6 +90,7 @@ backupSshKeyGenerate()
isSuccessful "Generated ed25519 keypair for location $idx" isSuccessful "Generated ed25519 keypair for location $idx"
isNotice "Public key (paste into the remote host's ~/.ssh/authorized_keys):" isNotice "Public key (paste into the remote host's ~/.ssh/authorized_keys):"
backupSshKeyPublic "$idx" backupSshKeyPublic "$idx"
backupSshKeyRefreshUi
} }
backupSshKeyPublic() backupSshKeyPublic()
@ -99,4 +109,5 @@ backupSshKeyDelete()
key_file=$(backupSshKeyFile "$idx") key_file=$(backupSshKeyFile "$idx")
[[ -f "$key_file" ]] && sudo rm -f "$key_file" "${key_file}.pub" [[ -f "$key_file" ]] && sudo rm -f "$key_file" "${key_file}.pub"
isSuccessful "SSH key removed for location $idx" isSuccessful "SSH key removed for location $idx"
backupSshKeyRefreshUi
} }