v0.4.66: ship smbclient in the runtime image

v0.4.65 added the SmbShareManager but the Dockerfile only installed
the `samba` package — in Debian 12 that ships the SERVER (smbd) only,
not the `smbclient` CLI the new manager shells out to. Every "Add
share" attempt surfaced:

    could not exec smbclient: No such file or directory (os error 2)

Fix is two lines: add `smbclient` to the runtime apt install, drop
the leftover `nfs-common` (no kernel-mount NFS anymore so the helpers
aren't needed).

While in the area, harden the manager so future stripped-down runtime
images get a useful error instead of a bare exec failure:

- `list_isos` and `stream_iso` both detect `ErrorKind::NotFound` on
  spawn and emit "smbclient binary not found on $PATH".
- `hint_for` translates the missing-binary pattern into an actionable
  hint: "pull OpenPXE v0.4.66+ or add the Debian `smbclient` package
  to your runtime stage." So even on a custom build the UI still
  surfaces a clear remediation.

Tests: 150 passing (+1 for the new hint). clippy clean.

The image is still ~98 MB — `smbclient` adds <1 MB on top of the
already-installed samba server.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-28 11:58:51 -04:00
co-authored by Claude Opus 4.7
parent 900b65b3ec
commit 9f66c269c4
4 changed files with 94 additions and 20 deletions
+65 -3
View File
@@ -400,7 +400,20 @@ impl SmbShareManager {
if share.guest {
cmd.arg("-N");
}
let mut child = cmd.spawn().map_err(|e| Error::Other(e.into()))?;
// v0.4.66: surface a useful error if smbclient isn't on the
// PATH. Shouldn't happen on the stock image but custom
// builds may strip it.
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::Invalid(
"smbclient binary not found on $PATH — install the \
Debian `smbclient` package or pull OpenPXE v0.4.66+"
.into(),
)
} else {
Error::Other(e.into())
}
})?;
let stdout = child
.stdout
.take()
@@ -517,9 +530,22 @@ impl SmbShareManager {
let output = match cmd.output().await {
Ok(o) => o,
Err(e) => {
// v0.4.66: distinguish ENOENT (missing binary) from
// other exec failures and pre-fill the hint so the
// UI shows a clear remediation instead of the bare
// "No such file or directory (os error 2)". This
// shouldn't fire on the stock image — the Dockerfile
// installs the `smbclient` package — but is a useful
// breadcrumb for anyone running OpenPXE in a stripped
// base image.
let stderr = if e.kind() == std::io::ErrorKind::NotFound {
"smbclient binary not found on $PATH".to_string()
} else {
String::new()
};
return Err((
format!("could not exec smbclient: {e}"),
String::new(),
stderr,
));
}
};
@@ -762,7 +788,25 @@ async fn tcp_probe(
/// hints. Returns `None` when we don't have a translation.
fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
if s.contains("smbclient binary not found")
|| s.contains("smbclient: no such file")
|| (s.contains("could not exec smbclient") && s.contains("os error 2"))
{
// v0.4.66: this only fires on a stripped / custom runtime
// image — the stock OpenPXE container ships `smbclient` from
// the Debian `smbclient` package. The error surfaced on
// v0.4.65 specifically because that release's Dockerfile
// installed `samba` (the server) but not `smbclient` (the
// client CLI). Operators on the stock image should never see
// this; if they do, the fix is to upgrade.
Some(
"smbclient isn't installed in this container. Pull the \
official OpenPXE image v0.4.66 or newer — the stock image \
ships smbclient. If you're running a custom build, add the \
Debian `smbclient` package to your runtime stage."
.into(),
)
} else if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
Some(
"the server rejected the credentials. Double-check the username \
and password — many NAS appliances use a separate SMB account \
@@ -880,6 +924,24 @@ mod tests {
assert!(hint_for("some unrelated error text").is_none());
}
#[test]
fn hint_for_missing_smbclient_calls_out_upgrade() {
// The exec-side path sets `stderr` to "smbclient binary not
// found on $PATH" when ENOENT lands.
let h = hint_for("smbclient binary not found on $PATH").unwrap();
assert!(
h.contains("smbclient") && h.to_lowercase().contains("install"),
"expected upgrade/install guidance, got: {h}"
);
// The raw error path on the API side passes the verbatim
// exec error in `error` plus an empty `stderr`. The
// SmbShareError constructor's hint_for fallback checks error
// too, so this pattern needs to translate as well.
let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)")
.unwrap();
assert!(h2.contains("smbclient"));
}
#[test]
fn parse_ls_iso_finds_one_iso_and_skips_directories() {
let out = "\