manta_server/server/handlers/
migrate.rs

1//! Migration handlers (node moves + cluster backup/restore).
2//!
3//! - `POST /api/v1/migrate/nodes`   → [`migrate_nodes`] — bulk node
4//!   moves between HSM groups.
5//! - `POST /api/v1/migrate/backup`  → [`migrate_backup`] — dump the
6//!   site's configuration state to a server-local file.
7//! - `POST /api/v1/migrate/restore` → [`migrate_restore`] — load a
8//!   previously-written backup.
9//!
10//! The two backup endpoints are gated by
11//! [`crate::server::ServerState::migrate_backup_root`]: when `None`,
12//! both return 501. When `Some(root)`, every filesystem path
13//! supplied by the caller is run through [`confine_to_root`] so a
14//! relative or escaping path cannot read/write outside `root`.
15
16use std::path::{Path, PathBuf};
17
18use axum::{Json, http::StatusCode, response::IntoResponse};
19use manta_backend_dispatcher::error::Error as BackendError;
20
21use super::{ErrorResponse, RequestCtx, SiteHeader, to_handler_error};
22use crate::service;
23
24pub use manta_shared::types::api::migrate::{
25  MigrateBackupRequest, MigrateNodesRequest, MigrateRestoreRequest,
26};
27
28/// Resolve a user-supplied filesystem path against the configured
29/// `migrate_backup_root` and reject anything that escapes it.
30///
31/// Used by both migrate-backup (where the path is a `destination` to
32/// be written) and migrate-restore (where it is a file to be read).
33/// For backup the destination may not exist yet, so we canonicalise
34/// the nearest existing ancestor and append the not-yet-existing
35/// suffix back. Symlinks are followed by `canonicalize`, so a
36/// symlink pointing outside the root fails the `starts_with` check.
37///
38/// Returns the canonicalised path on success — callers should forward
39/// THAT instead of the original user input to close the
40/// canonicalise-then-open TOCTOU window.
41fn confine_to_root(
42  user_path: &str,
43  backup_root: &Path,
44) -> Result<PathBuf, BackendError> {
45  let candidate = Path::new(user_path);
46
47  // Reject relative paths up-front: they would resolve against the
48  // server's CWD, which is unrelated to backup_root.
49  if !candidate.is_absolute() {
50    return Err(BackendError::BadRequest(format!(
51      "migrate path '{user_path}' must be absolute"
52    )));
53  }
54
55  // Walk up until we find an existing prefix; lets the destination
56  // file/dir not exist yet for backup writes.
57  let mut existing: &Path = candidate;
58  while !existing.exists() {
59    existing = existing.parent().ok_or_else(|| {
60      BackendError::BadRequest(format!(
61        "migrate path '{user_path}' has no existing ancestor"
62      ))
63    })?;
64  }
65
66  let resolved_existing = existing.canonicalize().map_err(|e| {
67    BackendError::BadRequest(format!(
68      "could not resolve migrate path '{}': {e}",
69      existing.display()
70    ))
71  })?;
72
73  if !resolved_existing.starts_with(backup_root) {
74    return Err(BackendError::BadRequest(format!(
75      "migrate path '{user_path}' resolves outside the configured \
76       migrate_backup_root '{}'",
77      backup_root.display()
78    )));
79  }
80
81  let suffix = candidate
82    .strip_prefix(existing)
83    .expect("existing is a prefix of candidate by construction");
84  Ok(resolved_existing.join(suffix))
85}
86
87/// Validate every `Some(path)` in `paths` against `backup_root`.
88/// Returns the canonicalised paths in the same order so the caller
89/// can forward those instead of the original user input.
90fn confine_all(
91  paths: &[Option<&str>],
92  backup_root: &Path,
93) -> Result<Vec<Option<String>>, BackendError> {
94  paths
95    .iter()
96    .map(|p| {
97      p.map(|raw| {
98        confine_to_root(raw, backup_root)
99          .map(|pb| pb.to_string_lossy().into_owned())
100      })
101      .transpose()
102    })
103    .collect()
104}
105
106/// Resolve `state.migrate_backup_root` or reject with `BadRequest`.
107/// Operators must opt in to server-side filesystem writes — there is
108/// no built-in default root.
109fn require_backup_root(ctx: &RequestCtx) -> Result<&Path, BackendError> {
110  ctx.state.migrate_backup_root.as_deref().ok_or_else(|| {
111    BackendError::BadRequest(
112      "migrate endpoints disabled: server has no [server] migrate_backup_root \
113         configured. Set it to an absolute, existing directory and restart."
114        .to_string(),
115    )
116  })
117}
118
119/// `POST /api/v1/migrate/nodes` — move nodes between HSM groups.
120#[utoipa::path(post, path = "/migrate/nodes", tag = "migrate",
121  params(SiteHeader),
122  request_body = MigrateNodesRequest,
123  security(("bearerAuth" = [])),
124  responses(
125    (status = 200, description = "Migration result", body = manta_shared::types::api::responses::MigrateNodesResponse),
126    (status = 400, description = "Bad request",      body = ErrorResponse),
127    (status = 401, description = "Unauthorized",     body = ErrorResponse),
128    (status = 500, description = "Internal error",   body = ErrorResponse),
129  )
130)]
131#[tracing::instrument(skip_all)]
132pub async fn migrate_nodes(
133  ctx: RequestCtx,
134  Json(body): Json<MigrateNodesRequest>,
135) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
136  tracing::info!("migrate_nodes dry_run={}", body.dry_run);
137  let infra = ctx.infra();
138
139  // Authorization: every named group on both sides must be accessible.
140  // Collect all names into one Vec and validate in a single backend
141  // round-trip instead of N+M serial calls.
142  let all_groups: Vec<String> = body
143    .target_hsm_names
144    .iter()
145    .chain(body.parent_hsm_names.iter())
146    .cloned()
147    .collect();
148  service::authorization::validate_user_group_vec_access(
149    &infra,
150    &ctx.token,
151    &all_groups,
152  )
153  .await
154  .map_err(to_handler_error)?;
155
156  let (xnames, results) = service::migrate::migrate_nodes(
157    &infra,
158    &ctx.token,
159    &body.target_hsm_names,
160    &body.parent_hsm_names,
161    &body.hosts_expression,
162    body.dry_run,
163    body.create_hsm_group,
164  )
165  .await
166  .map_err(to_handler_error)?;
167
168  Ok(Json(serde_json::json!({
169    "xnames": xnames,
170    "results": results,
171  })))
172}
173
174// ---------------------------------------------------------------------------
175// POST /api/v1/migrate/backup — Backup BOS session templates
176// ---------------------------------------------------------------------------
177
178/// `POST /api/v1/migrate/backup` — export BOS session templates to backup files.
179#[utoipa::path(post, path = "/migrate/backup", tag = "migrate",
180  params(SiteHeader),
181  request_body = MigrateBackupRequest,
182  security(("bearerAuth" = [])),
183  responses(
184    (status = 200, description = "Backup completed",      body = manta_shared::types::api::responses::CompletedResponse),
185    (status = 401, description = "Unauthorized",          body = ErrorResponse),
186    (status = 500, description = "Internal error",        body = ErrorResponse),
187  )
188)]
189#[tracing::instrument(skip_all)]
190pub async fn migrate_backup(
191  ctx: RequestCtx,
192  Json(body): Json<MigrateBackupRequest>,
193) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
194  tracing::info!("migrate_backup");
195  let infra = ctx.infra();
196
197  // Authorization: backup writes to a server-side filesystem path
198  // chosen by the caller. Restrict to admin to prevent
199  // non-privileged users from triggering arbitrary writes via the
200  // server process's UID.
201  service::authorization::require_admin(&ctx.token)
202    .map_err(to_handler_error)?;
203
204  // Confine the destination to `[server] migrate_backup_root`. Even
205  // admin tokens can't write outside that directory.
206  let backup_root = require_backup_root(&ctx).map_err(to_handler_error)?;
207  let confined = confine_all(&[body.destination.as_deref()], backup_root)
208    .map_err(to_handler_error)?;
209  let destination = confined.into_iter().next().flatten();
210
211  service::migrate::backup(
212    &infra,
213    &ctx.token,
214    body.bos.as_deref(),
215    destination.as_deref(),
216  )
217  .await
218  .map_err(to_handler_error)?;
219
220  Ok(Json(serde_json::json!({ "completed": true })))
221}
222
223// ---------------------------------------------------------------------------
224// POST /api/v1/migrate/restore — Restore from backup files
225// ---------------------------------------------------------------------------
226
227/// `POST /api/v1/migrate/restore` — import BOS session templates and related artifacts from backup.
228#[utoipa::path(post, path = "/migrate/restore", tag = "migrate",
229  params(SiteHeader),
230  request_body = MigrateRestoreRequest,
231  security(("bearerAuth" = [])),
232  responses(
233    (status = 200, description = "Restore completed",  body = manta_shared::types::api::responses::CompletedResponse),
234    (status = 401, description = "Unauthorized",       body = ErrorResponse),
235    (status = 500, description = "Internal error",     body = ErrorResponse),
236  )
237)]
238#[tracing::instrument(skip_all)]
239pub async fn migrate_restore(
240  ctx: RequestCtx,
241  Json(body): Json<MigrateRestoreRequest>,
242) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
243  tracing::info!("migrate_restore overwrite={}", body.overwrite);
244  let infra = ctx.infra();
245
246  // Authorization: restore reads from server-side filesystem paths
247  // chosen by the caller and rewrites CFS/HSM/IMS state — high
248  // blast radius. Restrict to admin.
249  service::authorization::require_admin(&ctx.token)
250    .map_err(to_handler_error)?;
251
252  // Confine every supplied file path to `[server] migrate_backup_root`.
253  // The five paths are independent (some restores omit subsets), so
254  // we validate them through a uniform helper that preserves
255  // None-ness.
256  let backup_root = require_backup_root(&ctx).map_err(to_handler_error)?;
257  let confined = confine_all(
258    &[
259      body.bos_file.as_deref(),
260      body.cfs_file.as_deref(),
261      body.hsm_file.as_deref(),
262      body.ims_file.as_deref(),
263      body.image_dir.as_deref(),
264    ],
265    backup_root,
266  )
267  .map_err(to_handler_error)?;
268  let mut iter = confined.into_iter();
269  let bos_file = iter.next().flatten();
270  let cfs_file = iter.next().flatten();
271  let hsm_file = iter.next().flatten();
272  let ims_file = iter.next().flatten();
273  let image_dir = iter.next().flatten();
274
275  service::migrate::restore(
276    &infra,
277    &ctx.token,
278    bos_file.as_deref(),
279    cfs_file.as_deref(),
280    hsm_file.as_deref(),
281    ims_file.as_deref(),
282    image_dir.as_deref(),
283    body.overwrite,
284    body.overwrite,
285    body.overwrite,
286    body.overwrite,
287  )
288  .await
289  .map_err(to_handler_error)?;
290
291  Ok(Json(serde_json::json!({ "completed": true })))
292}
293
294#[cfg(test)]
295mod tests {
296  use super::*;
297
298  // Pin the four lines of defence `confine_to_root` enforces. The
299  // helper is the only thing standing between an admin token and the
300  // server process's full filesystem write capability, so regressions
301  // here would re-open the migrate-backup arbitrary-write surface.
302  use std::fs;
303
304  fn tmp_root() -> tempfile::TempDir {
305    tempfile::tempdir().expect("tempdir")
306  }
307
308  /// Production code canonicalises `migrate_backup_root` once at
309  /// startup (see `main.rs`) — the helper assumes that contract.
310  /// On macOS the tempdir lives under `/var/folders/...` which is
311  /// itself a symlink, so tests must canonicalise too.
312  fn canonical(dir: &tempfile::TempDir) -> std::path::PathBuf {
313    dir.path().canonicalize().expect("canonical tempdir")
314  }
315
316  #[test]
317  fn accepts_existing_file_under_root() {
318    let root = tmp_root();
319    let canon = canonical(&root);
320    let file = canon.join("bos.yaml");
321    fs::write(&file, "").unwrap();
322    let resolved = confine_to_root(file.to_str().unwrap(), &canon).unwrap();
323    assert!(resolved.starts_with(&canon));
324  }
325
326  #[test]
327  fn accepts_yet_to_exist_destination_when_parent_is_under_root() {
328    let root = tmp_root();
329    let canon = canonical(&root);
330    let dest = canon.join("new-subdir").join("dest.tar");
331    let resolved = confine_to_root(dest.to_str().unwrap(), &canon).unwrap();
332    assert!(resolved.starts_with(&canon));
333    assert!(resolved.ends_with("dest.tar"));
334  }
335
336  #[test]
337  fn rejects_relative_path() {
338    let root = tmp_root();
339    let canon = canonical(&root);
340    let err = confine_to_root("relative/path.yaml", &canon).unwrap_err();
341    assert!(matches!(err, BackendError::BadRequest(_)));
342  }
343
344  #[test]
345  fn rejects_path_outside_root() {
346    let root = tmp_root();
347    let other = tmp_root();
348    let canon = canonical(&root);
349    let file = canonical(&other).join("hsm.yaml");
350    fs::write(&file, "").unwrap();
351    let err = confine_to_root(file.to_str().unwrap(), &canon).unwrap_err();
352    assert!(matches!(err, BackendError::BadRequest(_)));
353  }
354
355  #[test]
356  fn rejects_symlink_that_escapes_root() {
357    let root = tmp_root();
358    let outside = tmp_root();
359    let canon = canonical(&root);
360    let target = canonical(&outside).join("secret.yaml");
361    fs::write(&target, "").unwrap();
362    let link = canon.join("link.yaml");
363    #[cfg(unix)]
364    std::os::unix::fs::symlink(&target, &link).unwrap();
365    #[cfg(windows)]
366    std::os::windows::fs::symlink_file(&target, &link).unwrap();
367    let err = confine_to_root(link.to_str().unwrap(), &canon).unwrap_err();
368    assert!(matches!(err, BackendError::BadRequest(_)));
369  }
370
371  #[test]
372  fn rejects_dotdot_traversal() {
373    let root = tmp_root();
374    let canon = canonical(&root);
375    let escape = format!("{}/../escape", canon.display());
376    let err = confine_to_root(&escape, &canon).unwrap_err();
377    assert!(matches!(err, BackendError::BadRequest(_)));
378  }
379}