manta_server/server/handlers/
migrate.rs1use 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
17fn confine_to_root(
31 user_path: &str,
32 backup_root: &Path,
33) -> Result<PathBuf, BackendError> {
34 let candidate = Path::new(user_path);
35
36 if !candidate.is_absolute() {
39 return Err(BackendError::BadRequest(format!(
40 "migrate path '{user_path}' must be absolute"
41 )));
42 }
43
44 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
76fn 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
95fn 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#[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 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#[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 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 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#[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 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 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 use std::fs;
292
293 fn tmp_root() -> tempfile::TempDir {
294 tempfile::tempdir().expect("tempdir")
295 }
296
297 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}