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::service;
23
24pub use manta_shared::types::api::migrate::{
25 MigrateBackupRequest, MigrateNodesRequest, MigrateRestoreRequest,
26};
27
28fn confine_to_root(
42 user_path: &str,
43 backup_root: &Path,
44) -> Result<PathBuf, BackendError> {
45 let candidate = Path::new(user_path);
46
47 if !candidate.is_absolute() {
50 return Err(BackendError::BadRequest(format!(
51 "migrate path '{user_path}' must be absolute"
52 )));
53 }
54
55 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
87fn 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
106fn 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#[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 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#[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 service::authorization::require_admin(&ctx.token)
202 .map_err(to_handler_error)?;
203
204 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#[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 service::authorization::require_admin(&ctx.token)
250 .map_err(to_handler_error)?;
251
252 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 use std::fs;
303
304 fn tmp_root() -> tempfile::TempDir {
305 tempfile::tempdir().expect("tempdir")
306 }
307
308 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}