v0.5.5: SFTP-over-SSH remote shares (russh, pure-Rust, ring backend)
Adds SFTP as a third remote ISO-library protocol alongside SMB and NFS. Pure-Rust russh + russh-sftp on the ring crypto backend — no kernel mount, no subprocess, no OpenSSL, no new C deps. Like NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests because SFTP opens a seekable file handle. - iso-store: SftpShareManager (connect/auth/READDIR/seekable stream), IsoSource::Sftp, password OR SSH-key auth, trust-on-first-use host-key pinning, 0600 credential sidecar with a restart-safe derived path. - http-api: /api/sftp-shares routes, Range-aware ISO dispatch arm, status/metrics counts, /api/docs entry, `sftp` terminal commands. - webui: "SFTP (SSH)" protocol option with a password/key auth toggle, host-key fingerprint display, dashboard tile, updated copy. SCP was deliberately rejected: sequential-only (no Range) and its crates wrap libssh2 (C + OpenSSL), which would break the static-musl build. russh is pinned to =0.55.0: russh 0.61 needs the stable RustCrypto generation (pkcs8 0.11), which is API-incompatible with the release- candidate crates bergshamra-crypto pins (pkcs8 =0.11.0-rc.11). 0.55 is the newest russh on the prior generation (pkcs8 0.7) that coexists. Do not bump past 0.55 until bergshamra adopts stable RustCrypto. 252 tests pass, clippy clean, static musl x86_64 binary (ring already present via rustls + bergshamra, so no new crypto/C deps). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
674a69f93b
commit
44a2212abe
+124
-13
@@ -291,11 +291,11 @@
|
||||
el('div', {class: 'trend'},
|
||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
||||
// v0.4.67: count both protocols. Label generically since
|
||||
// operators may be using one, the other, or both.
|
||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) +
|
||||
// v0.4.67+v0.5.5: count all remote-share protocols. Label
|
||||
// generically since operators may use any mix of SMB/NFS/SFTP.
|
||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
|
||||
' remote share' +
|
||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')),
|
||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
@@ -508,10 +508,11 @@
|
||||
// v0.4.67: NFSv3 added back as an in-process Rust client
|
||||
// (nfs3_client crate). Both protocols available side-by-side;
|
||||
// operators pick whichever their NAS prefers.
|
||||
const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([
|
||||
const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'),
|
||||
getJSON('/api/smb-shares'),
|
||||
getJSON('/api/nfs-shares'),
|
||||
getJSON('/api/sftp-shares'),
|
||||
getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
})),
|
||||
@@ -519,6 +520,7 @@
|
||||
]);
|
||||
const shares = smbRes.shares || [];
|
||||
const nfsShares = nfsRes.shares || [];
|
||||
const sftpShares = sftpRes.shares || [];
|
||||
const unattendedFiles = unattRes.files || [];
|
||||
|
||||
// ── Upload card ──
|
||||
@@ -829,6 +831,7 @@
|
||||
const protoSelect = el('select', {}, [
|
||||
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
|
||||
el('option', {value:'smb'}, 'SMB / CIFS'),
|
||||
el('option', {value:'sftp'}, 'SFTP (SSH)'),
|
||||
]);
|
||||
|
||||
// SMB inputs.
|
||||
@@ -893,11 +896,58 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
// SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so
|
||||
// SFTP-sourced ISOs support HTTP Range like NFS. Auth is password
|
||||
// OR an SSH private key (PEM, optional passphrase); the server's
|
||||
// host key is pinned trust-on-first-use on the first connect.
|
||||
const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||
const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||
const sftpUserIn = el('input', {type:'text', placeholder:'root'});
|
||||
const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'});
|
||||
const sftpAuthMode = el('select', {}, [
|
||||
el('option', {value:'password'}, 'Password'),
|
||||
el('option', {value:'key'}, 'SSH private key'),
|
||||
]);
|
||||
const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'});
|
||||
const sftpKeyIn = el('textarea', {rows:'4',
|
||||
placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----',
|
||||
style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'});
|
||||
const sftpPassphraseIn = el('input', {type:'password',
|
||||
placeholder:'(only if the private key is encrypted)'});
|
||||
const sftpPassBlock = el('label', {class:'field'},
|
||||
[el('span', {class:'name'}, 'Password'), sftpPassIn]);
|
||||
const sftpKeyBlock = el('div', {}, [
|
||||
el('label', {class:'field'},
|
||||
[el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]),
|
||||
el('label', {class:'field', style:'margin-top:10px'},
|
||||
[el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]),
|
||||
]);
|
||||
const syncSftpAuth = () => {
|
||||
const key = sftpAuthMode.value === 'key';
|
||||
sftpPassBlock.style.display = key ? 'none' : '';
|
||||
sftpKeyBlock.style.display = key ? '' : 'none';
|
||||
};
|
||||
sftpAuthMode.addEventListener('change', syncSftpAuth);
|
||||
syncSftpAuth();
|
||||
const sftpFields = el('div', {}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]),
|
||||
]),
|
||||
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]),
|
||||
]),
|
||||
el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]),
|
||||
]);
|
||||
|
||||
// Swap the visible field block + clear any stale message.
|
||||
const syncProto = () => {
|
||||
const nfs = protoSelect.value === 'nfs';
|
||||
smbFields.style.display = nfs ? 'none' : '';
|
||||
nfsFields.style.display = nfs ? '' : 'none';
|
||||
const p = protoSelect.value;
|
||||
smbFields.style.display = p === 'smb' ? '' : 'none';
|
||||
nfsFields.style.display = p === 'nfs' ? '' : 'none';
|
||||
sftpFields.style.display = p === 'sftp' ? '' : 'none';
|
||||
shareMsg.replaceChildren();
|
||||
shareMsg.className = 'msg';
|
||||
};
|
||||
@@ -924,6 +974,40 @@
|
||||
shareMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else { await showShareError(r); }
|
||||
} else if (protoSelect.value === 'sftp') {
|
||||
if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.'));
|
||||
shareMsg.className = 'msg err'; return;
|
||||
}
|
||||
const useKey = sftpAuthMode.value === 'key';
|
||||
if (useKey && !sftpKeyIn.value.trim()) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.'));
|
||||
shareMsg.className = 'msg err'; return;
|
||||
}
|
||||
if (!useKey && !sftpPassIn.value) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.'));
|
||||
shareMsg.className = 'msg err'; return;
|
||||
}
|
||||
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||
shareMsg.className = 'msg';
|
||||
const body = {
|
||||
server: sftpServerIn.value,
|
||||
export: sftpExportIn.value,
|
||||
username: sftpUserIn.value,
|
||||
};
|
||||
if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); }
|
||||
if (useKey) {
|
||||
body.private_key = sftpKeyIn.value;
|
||||
if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; }
|
||||
} else {
|
||||
body.password = sftpPassIn.value;
|
||||
}
|
||||
const r = await postJSON('/api/sftp-shares', body);
|
||||
if (r.ok) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||
shareMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else { await showShareError(r); }
|
||||
} else {
|
||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
@@ -962,9 +1046,36 @@
|
||||
el('span'),
|
||||
]));
|
||||
|
||||
const totalShares = shares.length + nfsShares.length;
|
||||
const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'),
|
||||
document.createTextNode(m.username + '@' + m.server + ':' + m.export)]),
|
||||
el('div', {class:'meta'},
|
||||
'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' +
|
||||
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||
m.host_key_fingerprint
|
||||
? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'},
|
||||
'host key ' + m.host_key_fingerprint)
|
||||
: null,
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||
]),
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||||
if (r.ok) render('storage');
|
||||
}}, 'Re-scan'),
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
|
||||
await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Remove'),
|
||||
el('span'),
|
||||
]));
|
||||
|
||||
const totalShares = shares.length + nfsShares.length + sftpShares.length;
|
||||
const remoteRows = totalShares
|
||||
? [...smbRowEls, ...nfsRowEls]
|
||||
? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
|
||||
: [el('div', {class:'empty'}, 'No remote shares configured.')];
|
||||
syncProto();
|
||||
|
||||
@@ -1071,13 +1182,13 @@
|
||||
]),
|
||||
el('span'),
|
||||
]),
|
||||
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]),
|
||||
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
|
||||
addShare, shareMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'Remote .iso libraries are read on demand — no local cache to ' +
|
||||
'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' +
|
||||
'the hosts IP address is provisioned.'),
|
||||
'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
|
||||
'Ensure that the hosts IP address is provisioned.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
|
||||
Reference in New Issue
Block a user