manta_server/server/handlers/
migrate.rs1use 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
29fn confine_to_root(
43 user_path: &str,
44 backup_root: &Path,
45) -> Result<PathBuf, BackendError> {
46 let candidate = Path::new(user_path);
47
48 if !candidate.is_absolute() {
51 return Err(BackendError::BadRequest(format!(
52 "migrate path '{user_path}' must be absolute"
53 )));
54 }
55
56 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
88fn 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
107fn 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#[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 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#[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 service::authorization::require_admin(&ctx.token)
221 .map_err(to_handler_error)?;
222
223 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#[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 service::authorization::require_admin(&ctx.token)
279 .map_err(to_handler_error)?;
280
281 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 use std::fs;
332
333 fn tmp_root() -> tempfile::TempDir {
334 tempfile::tempdir().expect("tempdir")
335 }
336
337 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}