manta_server/server/handlers/
migrate.rs

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