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
+136
-4
@@ -37,8 +37,8 @@ use openpxe_core::{
|
||||
};
|
||||
use openpxe_ipxe_assets::asset_bytes;
|
||||
use openpxe_iso_store::{
|
||||
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, SmbState,
|
||||
UnattendedKind, UnattendedMeta,
|
||||
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
|
||||
SmbState, UnattendedKind, UnattendedMeta,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -188,6 +188,15 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
|
||||
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
|
||||
// v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client).
|
||||
// Parallel to SMB/NFS so the UI reuses the same form/error/hint
|
||||
// rendering. Like NFS, SFTP-sourced ISOs support Range requests.
|
||||
.route(
|
||||
"/api/sftp-shares",
|
||||
get(api_sftp_shares_list).post(api_sftp_shares_add),
|
||||
)
|
||||
.route("/api/sftp-shares/:id", delete(api_sftp_shares_remove))
|
||||
.route("/api/sftp-shares/:id/scan", post(api_sftp_shares_scan))
|
||||
// Phase 4: Network info (read-only) + DNS edit.
|
||||
.route("/api/network", get(api_network).put(api_network_put))
|
||||
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
||||
@@ -856,6 +865,57 @@ async fn iso_raw(
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
IsoSource::Sftp {
|
||||
share_id,
|
||||
relative_path,
|
||||
} => {
|
||||
// v0.5.5: SFTP sources support Range requests because SFTP
|
||||
// opens a seekable file handle (seek to offset, then bounded
|
||||
// reads). Identical handling to the NFS arm above.
|
||||
let total = meta.size_bytes;
|
||||
let range = match parse_range(headers.get(header::RANGE), total) {
|
||||
Some(triple) => triple,
|
||||
None if headers.get(header::RANGE).is_some() => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
// No Range header — serve the whole file.
|
||||
None => (0, total.saturating_sub(1), false),
|
||||
};
|
||||
let (start, end, partial) = range;
|
||||
let len = if total == 0 { 0 } else { end - start + 1 };
|
||||
let max_len = if total == 0 { None } else { Some(len) };
|
||||
match state
|
||||
.sftp_shares
|
||||
.stream_iso(share_id, relative_path, start, max_len)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
let body = Body::from_stream(stream);
|
||||
let status = if partial {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, len);
|
||||
if partial {
|
||||
builder = builder.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {start}-{end}/{total}"),
|
||||
);
|
||||
}
|
||||
builder.body(body).unwrap()
|
||||
}
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,6 +1550,19 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
"summary": "Re-list a share for new ISOs."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "SFTP shares",
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/sftp-shares",
|
||||
"summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
|
||||
{"method": "POST", "path": "/api/sftp-shares",
|
||||
"summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."},
|
||||
{"method": "DELETE", "path": "/api/sftp-shares/:id",
|
||||
"summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."},
|
||||
{"method": "POST", "path": "/api/sftp-shares/:id/scan",
|
||||
"summary": "Re-list a share for new ISOs."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Network",
|
||||
"endpoints": [
|
||||
@@ -1963,6 +2036,8 @@ struct StatusResponse {
|
||||
smb_share_reachable: usize,
|
||||
nfs_share_count: usize,
|
||||
nfs_share_reachable: usize,
|
||||
sftp_share_count: usize,
|
||||
sftp_share_reachable: usize,
|
||||
host_bindings: usize,
|
||||
custom_logo: bool,
|
||||
branding: BrandingStatus,
|
||||
@@ -1983,6 +2058,9 @@ async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
|
||||
let nfs_shares = state.nfs_shares.list();
|
||||
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
|
||||
// v0.5.5: SFTP shares fold into the same "reachable shares" tile.
|
||||
let sftp_shares = state.sftp_shares.list();
|
||||
let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
|
||||
let isos = state.iso_store.list();
|
||||
let clients = state.clients.list();
|
||||
let queue_entries = state.queue.list();
|
||||
@@ -2003,7 +2081,7 @@ async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||||
state
|
||||
.metrics
|
||||
.set_nfs_active((smb_reachable + nfs_reachable) as u64);
|
||||
.set_nfs_active((smb_reachable + nfs_reachable + sftp_reachable) as u64);
|
||||
state.metrics.record_http(openpxe_core::HttpRoute::Api);
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
||||
@@ -2025,6 +2103,9 @@ async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
// metric works regardless of protocol mix.
|
||||
nfs_share_count: nfs_shares.len(),
|
||||
nfs_share_reachable: nfs_reachable,
|
||||
// v0.5.5: SFTP share counts, summed into the same dashboard tile.
|
||||
sftp_share_count: sftp_shares.len(),
|
||||
sftp_share_reachable: sftp_reachable,
|
||||
host_bindings: state.hosts.len(),
|
||||
custom_logo: state.branding.has_any_web_logo(),
|
||||
branding: BrandingStatus {
|
||||
@@ -2348,6 +2429,48 @@ async fn api_nfs_shares_scan(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SFTP share API (v0.5.5) ───────────────────────────────────────────────
|
||||
//
|
||||
// Parallel to the NFS shares API. The pure-Rust `russh` + `russh-sftp`
|
||||
// client gives us in-process listing and streaming, no subprocess. Like
|
||||
// NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests —
|
||||
// SFTP opens a seekable file handle. Auth is password OR SSH private
|
||||
// key; the server's host key is pinned trust-on-first-use.
|
||||
|
||||
async fn api_sftp_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "shares": state.sftp_shares.list() }))
|
||||
}
|
||||
|
||||
async fn api_sftp_shares_add(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SftpAddRequest>,
|
||||
) -> Response {
|
||||
match state.sftp_shares.add(req).await {
|
||||
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
|
||||
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_sftp_shares_remove(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.sftp_shares.remove(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_sftp_shares_scan(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.sftp_shares.rescan(&id).await {
|
||||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Network info API ──────────────────────────────────────────────────────
|
||||
|
||||
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
@@ -2715,7 +2838,16 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
|
||||
.iter()
|
||||
.filter(|m| m.reachable)
|
||||
.count();
|
||||
state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64);
|
||||
// v0.5.5: SFTP shares fold into the same reachable-shares gauge.
|
||||
let sftp_ok = state
|
||||
.sftp_shares
|
||||
.list()
|
||||
.iter()
|
||||
.filter(|m| m.reachable)
|
||||
.count();
|
||||
state
|
||||
.metrics
|
||||
.set_nfs_active((smb_ok + nfs_ok + sftp_ok) as u64);
|
||||
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
|
||||
|
||||
Reference in New Issue
Block a user