@@ -21066,7 +21066,10 @@ pub async fn zfs_status(req: HttpRequest, state: web::Data<AppState>) -> HttpRes
2106621066 let pools = zfs_get_pools();
2106721067 HttpResponse::Ok().json(serde_json::json!({
2106821068 "available": true,
21069- "pools": pools
21069+ "pools": pools,
21070+ // e.g. "zfs-2.4.2-1~bpo13+1" — surfaced so admins can see what their
21071+ // installed OpenZFS supports (and why AnyRaid isn't offered yet).
21072+ "version": zfs_version_string(),
2107021073 }))
2107121074}
2107221075
@@ -21371,6 +21374,270 @@ pub async fn zfs_pool_iostat(
2137121374 }
2137221375}
2137321376
21377+ /// First line of `zfs version` (e.g. "zfs-2.4.2-1~bpo13+1"), or None if it
21378+ /// can't be read. Shown in the UI so admins know what their installed OpenZFS
21379+ /// can and can't do (klasSponsor 2026-07-22 asked for AnyRaid management —
21380+ /// AnyRaid is not in any RELEASED OpenZFS as of 2026-07, so the UI shows the
21381+ /// version plus an honest "pending upstream" note instead of unusable options).
21382+ fn zfs_version_string() -> Option<String> {
21383+ let out = std::process::Command::new("zfs").arg("version").output().ok()?;
21384+ if !out.status.success() {
21385+ return None;
21386+ }
21387+ String::from_utf8_lossy(&out.stdout)
21388+ .lines()
21389+ .next()
21390+ .map(|l| l.trim().to_string())
21391+ }
21392+
21393+ /// Valid ZFS pool name: begins with a letter; letters/digits/-_.: after; not a
21394+ /// reserved vdev keyword (zpoolconcepts(7): mirror/raidz/draid/spare/log).
21395+ fn valid_zpool_name(name: &str) -> bool {
21396+ let mut chars = name.chars();
21397+ let first_ok = chars.next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false);
21398+ let rest_ok = name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'));
21399+ let reserved = ["mirror", "raidz", "draid", "spare", "log"];
21400+ let not_reserved = !reserved.iter().any(|r| name == *r || name.starts_with(&format!("{}0", r)) || name.starts_with(&format!("{}1", r)) || name.starts_with(&format!("{}2", r)) || name.starts_with(&format!("{}3", r)));
21401+ first_ok && rest_ok && not_reserved
21402+ }
21403+
21404+ /// GET /api/storage/zfs/importable — pools visible to `zpool import` (exported
21405+ /// or foreign, e.g. a disk shelf pulled from a TrueNAS box — TrueNAS pools are
21406+ /// plain OpenZFS, so migrating to WolfStack is a straight import).
21407+ pub async fn zfs_importable(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
21408+ if let Err(e) = require_auth(&req, &state) { return e; }
21409+ // `zpool import` scans every device; seconds, not millis — off the runtime.
21410+ let output = web::block(|| std::process::Command::new("zpool").arg("import").output()).await;
21411+ let output = match output {
21412+ Ok(Ok(o)) => o,
21413+ _ => return HttpResponse::InternalServerError().json(serde_json::json!({ "error": "zpool import failed to run" })),
21414+ };
21415+ // Exit status is non-zero when there is nothing to import — that's an
21416+ // empty list, not an error.
21417+ let text = format!(
21418+ "{}\n{}",
21419+ String::from_utf8_lossy(&output.stdout),
21420+ String::from_utf8_lossy(&output.stderr)
21421+ );
21422+ let mut pools: Vec<serde_json::Value> = Vec::new();
21423+ let mut current: Option<(String, String, String)> = None; // name, id, state
21424+ for line in text.lines() {
21425+ let t = line.trim();
21426+ if let Some(rest) = t.strip_prefix("pool:") {
21427+ if let Some((n, i, s)) = current.take() {
21428+ pools.push(serde_json::json!({ "name": n, "id": i, "state": s }));
21429+ }
21430+ current = Some((rest.trim().to_string(), String::new(), String::new()));
21431+ } else if let Some(rest) = t.strip_prefix("id:")
21432+ && let Some(c) = current.as_mut() { c.1 = rest.trim().to_string(); }
21433+ else if let Some(rest) = t.strip_prefix("state:")
21434+ && let Some(c) = current.as_mut() { c.2 = rest.trim().to_string(); }
21435+ }
21436+ if let Some((n, i, s)) = current.take() {
21437+ pools.push(serde_json::json!({ "name": n, "id": i, "state": s }));
21438+ }
21439+ HttpResponse::Ok().json(serde_json::json!({ "pools": pools }))
21440+ }
21441+
21442+ /// POST /api/storage/zfs/pool/import — {pool, force?} import by name or id.
21443+ /// `force` maps to `zpool import -f` (pool last used on another system — the
21444+ /// normal case for a TrueNAS migration, where the pool was never exported).
21445+ pub async fn zfs_pool_import(
21446+ req: HttpRequest,
21447+ state: web::Data<AppState>,
21448+ body: web::Json<serde_json::Value>,
21449+ ) -> HttpResponse {
21450+ if let Err(e) = require_auth(&req, &state) { return e; }
21451+ let pool = body.get("pool").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21452+ let force = body.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
21453+ // Accept a pool name or a numeric pool id.
21454+ let is_id = !pool.is_empty() && pool.chars().all(|c| c.is_ascii_digit());
21455+ if pool.is_empty() || (!is_id && !valid_zpool_name(&pool)) {
21456+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid pool name" }));
21457+ }
21458+ let output = web::block(move || {
21459+ let mut args: Vec<&str> = vec!["import"];
21460+ if force { args.push("-f"); }
21461+ args.push(&pool);
21462+ std::process::Command::new("zpool").args(&args).output()
21463+ .map(|o| (o, pool.clone()))
21464+ })
21465+ .await;
21466+ match output {
21467+ Ok(Ok((out, pool))) if out.status.success() => {
21468+ HttpResponse::Ok().json(serde_json::json!({ "message": format!("Pool '{}' imported", pool) }))
21469+ }
21470+ Ok(Ok((out, _))) => {
21471+ let err = String::from_utf8_lossy(&out.stderr).to_string();
21472+ HttpResponse::InternalServerError().json(serde_json::json!({ "error": err.trim() }))
21473+ }
21474+ _ => HttpResponse::InternalServerError().json(serde_json::json!({ "error": "zpool import failed to run" })),
21475+ }
21476+ }
21477+
21478+ /// POST /api/storage/zfs/pool/create — {name, layout, disks[], mountpoint?, force?}.
21479+ ///
21480+ /// Layouts are the RELEASED OpenZFS vdev types only (stripe/mirror/raidz1-3).
21481+ /// AnyRaid/anymirror (mixed-size vdevs) is deliberately NOT offered: it is not
21482+ /// in any released OpenZFS (still in review upstream as of 2026-07) and its
21483+ /// final CLI syntax is not frozen — offering it would ship guessed commands.
21484+ /// The UI explains this; support lands here the release after OpenZFS ships it.
21485+ ///
21486+ /// DESTRUCTIVE: creating a pool takes ownership of the member disks. Every
21487+ /// disk must pass the same paranoid in-use gate as dedicate-disk
21488+ /// (mounted/LVM/mdraid/LUKS/swap/ZFS/bcache/Ceph anywhere on the device) —
21489+ /// `force` does NOT bypass that gate, it only maps to `zpool create -f`
21490+ /// (which clears foreign non-active labels). A disk that is part of the
21491+ /// running system can never be pulled into a pool through this endpoint.
21492+ pub async fn zfs_pool_create(
21493+ req: HttpRequest,
21494+ state: web::Data<AppState>,
21495+ body: web::Json<serde_json::Value>,
21496+ ) -> HttpResponse {
21497+ if let Err(e) = require_auth(&req, &state) { return e; }
21498+ let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21499+ let layout = body.get("layout").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21500+ let force = body.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
21501+ let mountpoint = body.get("mountpoint").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21502+ let disks: Vec<String> = body.get("disks").and_then(|v| v.as_array())
21503+ .map(|a| a.iter().filter_map(|d| d.as_str()).map(|s| s.trim().to_string()).collect())
21504+ .unwrap_or_default();
21505+
21506+ if !valid_zpool_name(&name) {
21507+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid pool name (must start with a letter; letters, digits, - _ . : only)" }));
21508+ }
21509+ // (vdev keyword, minimum devices) per zpoolconcepts(7).
21510+ let (vdev_kw, min_disks): (Option<&str>, usize) = match layout.as_str() {
21511+ "stripe" => (None, 1),
21512+ "mirror" => (Some("mirror"), 2),
21513+ "raidz1" => (Some("raidz1"), 2),
21514+ "raidz2" => (Some("raidz2"), 3),
21515+ "raidz3" => (Some("raidz3"), 4),
21516+ _ => return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid layout (stripe, mirror, raidz1, raidz2, raidz3)" })),
21517+ };
21518+ if disks.len() < min_disks {
21519+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Layout '{}' needs at least {} disks", layout, min_disks) }));
21520+ }
21521+ if !mountpoint.is_empty() && (!mountpoint.starts_with('/') || mountpoint.contains("..")) {
21522+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Mountpoint must be an absolute path" }));
21523+ }
21524+ for d in &disks {
21525+ if !d.starts_with("/dev/") || d.contains("..") || d.chars().any(|c| c.is_whitespace()) {
21526+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Invalid device path: {}", d) }));
21527+ }
21528+ if !std::path::Path::new(d).exists() {
21529+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Device not found: {}", d) }));
21530+ }
21531+ }
21532+ let mut seen = std::collections::HashSet::new();
21533+ if !disks.iter().all(|d| seen.insert(d.clone())) {
21534+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Duplicate device in disk list" }));
21535+ }
21536+
21537+ let result = web::block(move || -> Result<String, String> {
21538+ // Safety gates — never overridable from the API (matches dedicate-disk).
21539+ for d in &disks {
21540+ if storage::is_protected_device(d)? {
21541+ return Err(format!("{} holds a protected system mountpoint — refusing", d));
21542+ }
21543+ if storage::device_or_children_in_use(d)? {
21544+ return Err(format!(
21545+ "{} is in use (mounted, or holds LVM/mdraid/LUKS/swap/ZFS/bcache/Ceph data) — \
21546+ clear it first if it really is expendable", d
21547+ ));
21548+ }
21549+ }
21550+ let mut args: Vec<String> = vec!["create".into()];
21551+ if force { args.push("-f".into()); }
21552+ if !mountpoint.is_empty() {
21553+ args.push("-m".into());
21554+ args.push(mountpoint.clone());
21555+ }
21556+ args.push(name.clone());
21557+ if let Some(kw) = vdev_kw { args.push(kw.into()); }
21558+ args.extend(disks.iter().cloned());
21559+ let out = std::process::Command::new("zpool").args(&args).output()
21560+ .map_err(|e| format!("zpool: {}", e))?;
21561+ if out.status.success() {
21562+ Ok(format!("Pool '{}' created ({} on {} disks)", name, layout, disks.len()))
21563+ } else {
21564+ Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
21565+ }
21566+ })
21567+ .await;
21568+
21569+ match result {
21570+ Ok(Ok(msg)) => HttpResponse::Ok().json(serde_json::json!({ "message": msg })),
21571+ Ok(Err(e)) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
21572+ Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("{}", e) })),
21573+ }
21574+ }
21575+
21576+ /// DELETE /api/storage/zfs/pool — {pool, confirm} destroy a pool. `confirm`
21577+ /// must EXACTLY equal the pool name (the UI makes the admin type it) — this is
21578+ /// an irreversible, whole-pool data loss operation.
21579+ pub async fn zfs_pool_destroy(
21580+ req: HttpRequest,
21581+ state: web::Data<AppState>,
21582+ body: web::Json<serde_json::Value>,
21583+ ) -> HttpResponse {
21584+ if let Err(e) = require_auth(&req, &state) { return e; }
21585+ let pool = body.get("pool").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21586+ let confirm = body.get("confirm").and_then(|v| v.as_str()).unwrap_or("").trim();
21587+ if !valid_zpool_name(&pool) {
21588+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid pool name" }));
21589+ }
21590+ if confirm != pool {
21591+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Confirmation text does not match the pool name" }));
21592+ }
21593+ let output = web::block(move || {
21594+ std::process::Command::new("zpool").args(["destroy", &pool]).output()
21595+ .map(|o| (o, pool.clone()))
21596+ })
21597+ .await;
21598+ match output {
21599+ Ok(Ok((out, pool))) if out.status.success() => {
21600+ HttpResponse::Ok().json(serde_json::json!({ "message": format!("Pool '{}' destroyed", pool) }))
21601+ }
21602+ Ok(Ok((out, _))) => {
21603+ let err = String::from_utf8_lossy(&out.stderr).to_string();
21604+ HttpResponse::InternalServerError().json(serde_json::json!({ "error": err.trim() }))
21605+ }
21606+ _ => HttpResponse::InternalServerError().json(serde_json::json!({ "error": "zpool destroy failed to run" })),
21607+ }
21608+ }
21609+
21610+ /// POST /api/storage/zfs/dataset — {name} create a dataset (name must be
21611+ /// pool/path, validated to the zfs charset).
21612+ pub async fn zfs_dataset_create(
21613+ req: HttpRequest,
21614+ state: web::Data<AppState>,
21615+ body: web::Json<serde_json::Value>,
21616+ ) -> HttpResponse {
21617+ if let Err(e) = require_auth(&req, &state) { return e; }
21618+ let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
21619+ let valid = !name.is_empty()
21620+ && name.contains('/')
21621+ && !name.contains("..")
21622+ && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':'))
21623+ && !name.starts_with('/')
21624+ && !name.ends_with('/');
21625+ if !valid {
21626+ return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Invalid dataset name (pool/path)" }));
21627+ }
21628+ let output = std::process::Command::new("zfs").args(["create", "-p", &name]).output();
21629+ match output {
21630+ Ok(out) if out.status.success() => {
21631+ HttpResponse::Ok().json(serde_json::json!({ "message": format!("Dataset '{}' created", name) }))
21632+ }
21633+ Ok(out) => {
21634+ let err = String::from_utf8_lossy(&out.stderr).to_string();
21635+ HttpResponse::InternalServerError().json(serde_json::json!({ "error": err.trim() }))
21636+ }
21637+ Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("{}", e) })),
21638+ }
21639+ }
21640+
2137421641// ─── File Manager ───
2137521642
2137621643/// Validate and normalize a file path — prevent traversal attacks
@@ -40863,6 +41130,11 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
4086341130 .route("/api/storage/zfs/pool/scrub", web::post().to(zfs_pool_scrub))
4086441131 .route("/api/storage/zfs/pool/status", web::get().to(zfs_pool_status))
4086541132 .route("/api/storage/zfs/pool/iostat", web::get().to(zfs_pool_iostat))
41133+ .route("/api/storage/zfs/importable", web::get().to(zfs_importable))
41134+ .route("/api/storage/zfs/pool/import", web::post().to(zfs_pool_import))
41135+ .route("/api/storage/zfs/pool/create", web::post().to(zfs_pool_create))
41136+ .route("/api/storage/zfs/pool", web::delete().to(zfs_pool_destroy))
41137+ .route("/api/storage/zfs/dataset", web::post().to(zfs_dataset_create))
4086641138 // File Manager
4086741139 .route("/api/files/browse", web::get().to(files_browse))
4086841140 .route("/api/files/mkdir", web::post().to(files_mkdir))
0 commit comments