Skip to main content

manta_server/server/handlers/
migrate.rs

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