From f77f3d77c0cf5feb17164243d64c2842faa52952 Mon Sep 17 00:00:00 2001 From: m1saka Date: Sat, 17 Oct 2026 01:36:21 +0800 Subject: [PATCH] feat: add full snapshot downloads and safe manual restore --- API.md | 17 +- OPERATIONS.md | 18 ++ internal/api/backup_management.go | 12 + internal/api/config.go | 18 +- internal/api/snapshot_download.go | 236 +++++++++++++++ internal/api/snapshot_download_test.go | 273 ++++++++++++++++++ internal/api/snapshot_restore.go | 381 +++++++++++++++++++++++++ internal/api/snapshot_restore_test.go | 220 ++++++++++++++ internal/api/upgrade.go | 4 + web/src/BackupPanel.test.tsx | 78 ++++- web/src/BackupPanel.tsx | 66 ++++- web/src/api.ts | 2 +- 12 files changed, 1318 insertions(+), 7 deletions(-) create mode 100644 internal/api/snapshot_download.go create mode 100644 internal/api/snapshot_download_test.go create mode 100644 internal/api/snapshot_restore.go create mode 100644 internal/api/snapshot_restore_test.go diff --git a/API.md b/API.md index f87647e..1c0cc66 100644 --- a/API.md +++ b/API.md @@ -13,9 +13,19 @@ ## 实例备份与快照管理 +### 完整 home 快照恢复 + +`GET /api/instances/:id/snapshots` 的每项另含 `restorable:boolean`、`restore_reason:string`、可选 `source_version` 与 `source_image`。来源记录在独立的 `snapshot_metadata` 表中,不会从当前实例推断旧快照版本。已知镜像或版本不一致时不可恢复;来源未知时 `restorable:true` 但 `restore_reason` 为 `snapshot source image/version unknown; explicit acknowledgement required`,客户端必须显示风险警告并要求额外确认。保护状态及大小读取错误时不可恢复。列表只是预检提示,最终恢复还会检查实时配额、容器布局、镜像准入与读者占用。 + +`POST /api/instances/:id/snapshots` 接收 `{id,password,confirm:true,allow_unknown_version?:true}`(JSON 最大4096字节,在获取锁前读取)。`password` 是当前登录密码,不是旧备份密码;需正常登录、同源 Origin 与 CSRF。来源未知必须明确传入 `allow_unknown_version:true`;此开关不能绕过已知不兼容。成功返回 `200 {ok:true,snapshot_id:"pre-restore-<32hex>"}`,源快照保持不变。 + +恢复替换整个 home,不是仅覆盖配置。仅支持经核验的模板7独立 /workspace bind 挂载与当前实例镜像;拒绝旧共享布局、被阻止镜像、恢复保护状态及活跃文件读者。先进行配额和保留数量检查,再使用 rooted 路径验证复制到同文件系统暂存目录;外部/绝对/悬空符号链接及特殊文件拒绝。停止运行时后重新检查配额,将当前 home 重命名保留为 pre-restore 快照,再安装暂存目录并验证就绪;原来停止的实例会临时启动验证后再次停止。失败时独立超时上下文执行回滚,失败的新 home 保留为 failed-restore 快照;无法安全回滚时保持 error,要求管理员恢复。 + +非法请求或不安全内容400,当前密码错误403,目标不存在/所有权不符404,保护/读者占用/不兼容/缺少未知版本确认/保留上限409,请求或配额超限413,存储/数据库/回滚故障500,容器检查/停止失败502。正常恢复前会记录 snapshot.restore.started,完成或回滚分别记录 snapshot.restore.completed / snapshot.restore.rolled_back。此接口不改变实例镜像。 + - `DELETE /api/instances/:id/backups?id=<32hex>` 删除当前用户实例的加密 `.dshbak` 包及其索引,记录 `backup.delete` 审计。ID 为32位小写十六进制;支持无索引的旧包,以及文件已不存在的残留索引。 - `GET /api/instances/:id/snapshots` 返回数组 `{id,size,created_at,protected,protection_reason,size_error?}`。`size` 为普通文件逻辑字节总和(与配额口径相同,硬链接逐路径计数,不跟随符号链接);扫描失败为 `-1` 并提供 `size_error`,不能显示为零。`created_at` 为目录修改时间的 RFC3339 字符串,不保证真实创建时间。未保护时 `protection_reason` 是空字符串。 -- `DELETE /api/instances/:id/snapshots?id=` 删除选定快照。先持久化 `snapshot.delete.started`,删除成功后记录 `snapshot.delete.completed`,删除失败则记录 `snapshot.delete.failed`;target 为实例 ID,detail 为快照 ID。初始审计失败时不会删除,终态审计失败返回500并保留 started 记录。不提供快照下载。 +- `DELETE /api/instances/:id/snapshots?id=` 删除选定快照。先持久化 `snapshot.delete.started`,删除成功后记录 `snapshot.delete.completed`,删除失败则记录 `snapshot.delete.failed`;target 为实例 ID,detail 为快照 ID。初始审计失败时不会删除,终态审计失败返回500并保留 started 记录。快照另支持 GET 下载,见下方接口清单及 OPERATIONS.md 的归档限制。 仅识别实例目录直属的 `pre-restore-<32hex>`、`failed-restore-<32hex>`、`upgrade-<32hex>-snapshot`、`failed-home-<32hex>` 真实目录;不列出或删除 `home`、其它命名或符号链接目录。删除成功返回 `200 {ok:true}`,非法ID/不安全路径400,不存在/所有权不符404,恢复保护409,存储/索引/审计错误500。不支持的方法405。删除仍要求同源 Origin 与 CSRF,不要求备份密码,不调用 Docker inspect/stop/restart。 @@ -141,6 +151,11 @@ GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id - POST /api/instances/{id}/backups multipart file,password(导入外部包,password为原包密码;不立即覆盖home) - GET /api/instances/{id}/backups - GET /api/instances/{id}/backups?download={backup_id} +- DELETE /api/instances/{id}/backups?id={backup_id} → {ok:true} +- GET /api/instances/{id}/snapshots → [{id,created_at,size,protected,protection_reason,restorable,restore_reason,source_version?,source_image?,size_error?}] +- DELETE /api/instances/{id}/snapshots?id={snapshot_id} → {ok:true} +- GET /api/instances/{id}/snapshots?download={snapshot_id} → 完整 home 的 `.tar.gz` 附件(未加密,可能含凭据) +- POST /api/instances/{id}/snapshots {id,password,confirm:true,allow_unknown_version?} → {ok:true,snapshot_id}(当前登录密码;snapshot_id 为切换前 home 的保留快照) - POST /api/instances/{id}/restore {backup_id,password}(原备份密码;旧平台key包可留空) - POST /api/instances/{id}/upgrade {image_id,password}(当前登录密码,加密pre-upgrade备份) diff --git a/OPERATIONS.md b/OPERATIONS.md index 13e55d5..dd1ef08 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -130,6 +130,24 @@ Office 文档预览的运行时接口 `/api/officeToPdf/render`(面板路径 文件删除与 SQLite 提交不具备跨系统原子性。若加密包已删除而索引提交失败,接口明确返回500;重试相同删除会清理残留索引。快照删除先持久化 `snapshot.delete.started`,失败则不触碰目录;递归删除期间不占用 SQL 连接或事务,仍保留实例锁和用户锁。随后分别记录 `snapshot.delete.completed` 或 `snapshot.delete.failed`。终态审计写入失败返回500;只有 started 的记录也可能表示进程中断或操作仍在进行,不代表删除成功,应由管理员核实目录和审计记录。目录删除中途遇到存储错误可能部分完成,排除故障后再重试。明确不安全路径返回400,权限或 I/O 故障返回500,缺失快照删除返回404(列表为空)。 +## 完整快照下载与手动恢复 + +实例的备份面板提供完整快照下载和恢复入口。下载的是未加密 `.tar.gz`,不同于仅包含配置资源的加密 `.dshbak`;可能包含密钥、会话和依赖,请妥善保管。归档保留权限及安全的相对符号链接,不跟随链接读取外部文件,遇到特殊文件或逃逸链接整体拒绝。下载先在系统临时目录完成归档,再释放实例锁传输;临时文件在请求结束时清理,需预留临时磁盘空间。文件内容总量上限1 GiB、100000条目、128层,另有限额容纳归档头及压缩开销;归档和传输分别有两分钟时限,共用最多4个目录归档下载槽。 + +恢复针对服务器保留的快照,不支持上传任意 tar 包恢复。需当前登录密码和明确确认;源快照保留不动,先复制到暂存目录,再停机并保留当前 home,切换后启动检查,失败尝试回退。原先停止的实例检查后仍停止。恢复需要额外容纳一份所选快照的配额和磁盘空间;已有恢复快照达到3个时先清理。已知源镜像或版本与当前实例不一致时禁止恢复;历史快照缺少来源信息时须额外确认风险。新升级、配置恢复和完整快照恢复会记录来源信息。 + +error 状态、未完成升级、活跃文件操作、禁用镜像或不兼容挂载布局会阻止恢复。恢复中断或回退失败保留 error 状态及数据,需管理员结合 `snapshot.restore.*` 审计核查 home、pre-restore、failed-restore 和暂存目录;不要直接把状态改成 running。快照只恢复 home,不回滚镜像、外部服务或启动产生的外部副作用。 + +### 完整快照恢复安全说明 + +快照恢复会替换整个实例 home,必须由所有者重新输入当前登录密码并明确确认。仅恢复到相同的已知镜像与版本;历史快照没有可靠来源记录时必须显示未知版本风险并额外确认,不得把当前实例版本当作历史来源。旧升级日志的 image_id 是目标镜像,不是旧 home 的可靠来源。新快照的来源写入 SQLite snapshot_metadata 表,升级回滚留下的 failed-home 使用尝试升级的目标镜像来源。 + +源快照不会被消耗。操作将源复制到实例目录下的 .snapshot-restore-<32hex>,确认 Docker 已停止后,把原 home 重命名为 pre-restore-<32hex>,再以同文件系统重命名安装副本并 fsync 父目录。运行时启动并通过就绪验证后恢复原运行/停止状态。原来停止的实例也会临时启动验证,因此应把恢复视为可执行该快照内容的操作,仅恢复可信数据。 + +开始停止/切换前实例状态持久化为 error;中途进程退出不会自动清除此状态。错误恢复时先停止容器,核对 snapshot.restore.started 审计中的源、pre-restore、failed-restore ID,以及 home 和暂存目录。不要盲目删除或覆盖这些目录;先确认哪一个保存原 home,保留全部恢复证据与源快照。只有核对目录、挂载和运行时就绪后才解除恢复状态。无法停止、重命名、同步或回滚就绪时接口保持 error 并要求人工处理。 + +恢复前按源副本大小检查用户配额,停止运行时后再次核验总用量;原 home 通过重命名保留,不重复复制。已有 pre-restore / failed-restore 数量达到3时拒绝,需显式清理不再需要的快照。没有自动保留清理;进程异常退出的隐藏暂存副本须在维护窗口核验后清理。文件复制期间不持有数据库事务。此流程仍需部署环境提供同文件系统原子重命名及 fsync 持久性,不能替代异机备份。 + ## 会话有效期 在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。 diff --git a/internal/api/backup_management.go b/internal/api/backup_management.go index 786f24f..4f831ba 100644 --- a/internal/api/backup_management.go +++ b/internal/api/backup_management.go @@ -33,6 +33,10 @@ type snapshotInfo struct { Protected bool `json:"protected"` ProtectionReason string `json:"protection_reason"` SizeError string `json:"size_error,omitempty"` + Restorable bool `json:"restorable"` + RestoreReason string `json:"restore_reason"` + SourceVersion string `json:"source_version,omitempty"` + SourceImage string `json:"source_image,omitempty"` } func managementRoot(base string, parts ...string) (*os.Root, error) { @@ -190,10 +194,18 @@ func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.I return } s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason} + s.SourceVersion, s.SourceImage, s.RestoreReason = a.snapshotCompatibility(i, s.ID) + s.Restorable = s.RestoreReason == "" || s.RestoreReason == snapshotUnknownReason + if reason != "" { + s.Restorable = false + s.RestoreReason = reason + } s.Size, err = snapshotBytes(r.Context(), root, entry.Name()) if err != nil { s.Size = -1 s.SizeError = "snapshot size unavailable: " + err.Error() + s.Restorable = false + s.RestoreReason = "snapshot size unavailable" } out = append(out, s) } diff --git a/internal/api/config.go b/internal/api/config.go index 62ccffe..bcd0d5a 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -23,6 +23,14 @@ var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$") var configBodySlots = make(chan struct{}, 4) func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) { + if action == "snapshots" && r.Method == http.MethodGet && r.URL.Query().Get("download") != "" { + a.snapshotDownload(w, r, i) + return + } + if action == "snapshots" && r.Method == http.MethodPost { + a.snapshotRestore(w, r, i) + return + } if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) { a.backupManagement(w, r, i, action) return @@ -472,6 +480,15 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a return } snapshot := "pre-restore-" + store.ID() + failed := "failed-restore-" + store.ID() + if e = a.recordSnapshot(i, snapshot, i.Version, i.ImageID); e != nil { + fail(w, 500, "snapshot metadata unavailable") + return + } + if e = a.recordSnapshot(i, failed, i.Version, i.ImageID); e != nil { + fail(w, 500, "snapshot metadata unavailable") + return + } if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil { fail(w, 413, "quota exceeded or snapshot size unavailable") return @@ -540,7 +557,6 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a fail(w, 500, "restore rollback requires runtime stop") return } - failed := "failed-restore-" + store.ID() if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil { a.s.SetInstance(i.ID, "error") fail(w, 500, "restore rollback requires administrator recovery") diff --git a/internal/api/snapshot_download.go b/internal/api/snapshot_download.go new file mode 100644 index 0000000..e34f44e --- /dev/null +++ b/internal/api/snapshot_download.go @@ -0,0 +1,236 @@ +package api + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "syscall" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +var errSnapshotDownloadLimit = errors.New("snapshot archive exceeds size, entry or depth limit") +var errUnsafeSnapshotArchive = errors.New("unsafe snapshot link or unsupported file type") + +type snapshotArchiveWriter struct { + ctx context.Context + w io.Writer + remaining int64 +} + +func (w *snapshotArchiveWriter) Write(p []byte) (int, error) { + if err := w.ctx.Err(); err != nil { + return 0, err + } + if int64(len(p)) > w.remaining { + return 0, errSnapshotDownloadLimit + } + n, err := w.w.Write(p) + w.remaining -= int64(n) + return n, err +} + +func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit int64) error { + // Bound headers, padding and gzip expansion independently of file content. + gz := gzip.NewWriter(&snapshotArchiveWriter{ctx, out, limit + (256 << 20)}) + tw := tar.NewWriter(&snapshotArchiveWriter{ctx, gz, limit + (128 << 20)}) + remaining := limit + entries := 100000 + var walk func(*os.Root, string, string, int) error + walk = func(parent *os.Root, name, rel string, depth int) error { + if err := ctx.Err(); err != nil { + return err + } + entries-- + if entries < 0 || depth > 128 { + return errSnapshotDownloadLimit + } + st, err := parent.Lstat(name) + if err != nil { + return err + } + link := "" + if st.Mode()&os.ModeSymlink != 0 { + link, err = parent.Readlink(name) + if err != nil { + return err + } + if filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) { + return errUnsafeSnapshotArchive + } + // Root resolution also catches escapes through chains of relative links. + if _, err = root.Stat(rel); err != nil && !errors.Is(err, os.ErrNotExist) { + return errUnsafeSnapshotArchive + } + } else if !st.IsDir() && !st.Mode().IsRegular() { + return errUnsafeSnapshotArchive + } + if st.Mode().IsRegular() && st.Size() > remaining { + return errSnapshotDownloadLimit + } + if st.Mode().IsRegular() { + remaining -= st.Size() + } + h, err := tar.FileInfoHeader(st, link) + if err != nil { + return err + } + h.Name = filepath.ToSlash(rel) + if st.IsDir() { + h.Name += "/" + } + if err = tw.WriteHeader(h); err != nil { + return err + } + if st.Mode()&os.ModeSymlink != 0 { + return nil + } + if st.IsDir() { + child, err := parent.OpenRoot(name) + if err != nil { + return err + } + defer child.Close() + actual, err := child.Stat(".") + if err != nil || !os.SameFile(st, actual) { + return errors.New("snapshot directory changed") + } + f, err := child.Open(".") + if err != nil { + return err + } + defer f.Close() + for { + children, err := f.ReadDir(128) + if err != nil && err != io.EOF { + return err + } + for _, entry := range children { + if err := walk(child, entry.Name(), filepath.Join(rel, entry.Name()), depth+1); err != nil { + return err + } + } + if err == io.EOF { + return nil + } + } + } + f, err := parent.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + defer f.Close() + actual, err := f.Stat() + if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) || actual.Size() != st.Size() { + return errors.New("snapshot file changed") + } + if _, err = io.CopyN(tw, f, st.Size()); err != nil { + return err + } + after, err := f.Stat() + if err != nil || after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) { + return errors.New("snapshot file changed") + } + return nil + } + if err := walk(root, ".", ".", 0); err != nil { + return err + } + if err := tw.Close(); err != nil { + return err + } + return gz.Close() +} + +func (a *API) snapshotDownload(w http.ResponseWriter, r *http.Request, i store.Instance) { + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + ids := r.URL.Query()["download"] + if len(ids) != 1 || !snapshotIDPattern.MatchString(ids[0]) { + fail(w, 400, "invalid snapshot ID") + return + } + id := ids[0] + select { + case zipSlots <- struct{}{}: + defer func() { <-zipSlots }() + default: + fail(w, 429, "too many archive downloads") + return + } + ctx, cancel := context.WithTimeout(r.Context(), zipTimeout) + defer cancel() + archive, err := os.CreateTemp("", "dsh-snapshot-*.tar.gz") + if err != nil { + fail(w, 500, "temporary archive unavailable") + return + } + defer os.Remove(archive.Name()) + defer archive.Close() + code := 500 + err = func() error { + lock := a.instanceLocks.Acquire(i.ID) + defer lock.Release() + if err := ctx.Err(); err != nil { + return err + } + current, err := a.s.GetInstance(i.ID) + if err != nil || current.UID != i.UID { + code = 404 + return errors.New("not found") + } + root, err := managementRoot(a.root, "workspaces", current.UID, "instances", current.ID, id) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + code = 404 + } + if errors.Is(err, errUnsafeManagementPath) { + code = 400 + } + return errors.New("snapshot unavailable or unsafe path") + } + defer root.Close() + return snapshotArchive(ctx, root, archive, upgradeSnapshotLimit) + }() + if err != nil { + if errors.Is(err, errUnsafeSnapshotArchive) { + code = 400 + } + if errors.Is(err, errSnapshotDownloadLimit) { + code = 413 + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + code = 408 + } + fail(w, code, err.Error()) + return + } + if ctx.Err() != nil { + fail(w, 408, "snapshot download timed out or canceled") + return + } + if _, err = archive.Seek(0, io.SeekStart); err != nil { + fail(w, 500, "archive unavailable") + return + } + controller := http.NewResponseController(w) + if err = controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) { + fail(w, 500, "download unavailable") + return + } + defer controller.SetWriteDeadline(time.Time{}) + a.s.AddAudit(i.UID, "snapshot.download", i.ID, id) + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": id + ".tar.gz"})) + http.ServeContent(w, r, id+".tar.gz", time.Time{}, archive) +} diff --git a/internal/api/snapshot_download_test.go b/internal/api/snapshot_download_test.go new file mode 100644 index 0000000..bcecb6d --- /dev/null +++ b/internal/api/snapshot_download_test.go @@ -0,0 +1,273 @@ +package api + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "errors" + "io" + "mime" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +type snapshotTransferWriter struct { + *httptest.ResponseRecorder + check func() +} + +func (w *snapshotTransferWriter) WriteHeader(code int) { + w.check() + w.ResponseRecorder.WriteHeader(code) +} + +func TestSnapshotDownloadTransferUnlockedAndOversize(t *testing.T) { + a, i, home := configTestAPI(t, false) + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + id := "failed-home-" + strings.Repeat("c", 32) + dir := filepath.Join(filepath.Dir(home), id) + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(dir, "data"), "saved") + checked := false + w := &snapshotTransferWriter{httptest.NewRecorder(), func() { + checked = true + if a.instanceLocks.held() { + t.Fatal("instance locked during transfer") + } + files, err := os.ReadDir(tmp) + if err != nil || len(files) != 1 { + t.Fatal(files, err) + } + st, err := files[0].Info() + if err != nil || st.Mode().Perm() != 0600 { + t.Fatal(st, err) + } + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + }} + a.snapshotDownload(w, httptest.NewRequest(http.MethodGet, "/?download="+id, nil), i) + if !checked || w.Code != 200 { + t.Fatal(checked, w.Code) + } + gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatal(err) + } + defer gz.Close() + if _, err := io.Copy(io.Discard, gz); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + f, err := os.Create(filepath.Join(dir, "huge")) + if err != nil { + t.Fatal(err) + } + err = f.Truncate(upgradeSnapshotLimit + 1) + f.Close() + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + a.snapshotDownload(rec, httptest.NewRequest("GET", "/?download="+id, nil), i) + if rec.Code != 413 || !strings.Contains(rec.Body.String(), "limit") { + t.Fatal(rec.Code, rec.Body.String()) + } + files, err := os.ReadDir(tmp) + if err != nil || len(files) != 0 { + t.Fatal(files, err) + } +} + +func TestSnapshotDownloadContentsAndCleanup(t *testing.T) { + a, i, home := configTestAPI(t, true) + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + id := "pre-restore-" + strings.Repeat("a", 32) + dir := filepath.Join(filepath.Dir(home), id) + if err := os.MkdirAll(filepath.Join(dir, "node_modules/.pnpm/pkg"), 0750); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), "#!/bin/sh") + if err := os.Chmod(filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), 0751); err != nil { + t.Fatal(err) + } + if err := os.Symlink(".pnpm/pkg", filepath.Join(dir, "node_modules/pkg")); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(dir, ".credentials.yaml"), "secret") + if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + a.config(w, httptest.NewRequest("GET", "/?download="+id, nil), i, "snapshots") + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition")) + if err != nil || params["filename"] != id+".tar.gz" { + t.Fatal(params, err) + } + gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatal(err) + } + defer gz.Close() + tr := tar.NewReader(gz) + seen := map[string]bool{} + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + seen[h.Name] = true + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + switch h.Name { + case "node_modules/.pnpm/pkg/bin": + if string(b) != "#!/bin/sh" || h.Mode != 0751 { + t.Fatal(h, string(b)) + } + case "node_modules/pkg": + if h.Typeflag != tar.TypeSymlink || h.Linkname != ".pnpm/pkg" { + t.Fatal(h) + } + case ".credentials.yaml": + if string(b) != "secret" { + t.Fatal(string(b)) + } + } + } + if len(seen) != 7 || !seen["node_modules/pkg"] || seen["node_modules/pkg/bin"] { + t.Fatal(seen) + } + files, err := os.ReadDir(tmp) + if err != nil || len(files) != 0 { + t.Fatal(files, err) + } + r := httptest.NewRequest("GET", "/?download="+id, nil) + r.Header.Set("Range", "bytes=0-9") + w = httptest.NewRecorder() + a.snapshotDownload(w, r, i) + if w.Code != 206 || w.Body.Len() != 10 { + t.Fatal(w.Code, w.Body.Len()) + } +} + +func TestSnapshotDownloadRejectsUnsafeAndForeign(t *testing.T) { + a, i, home := configTestAPI(t, false) + id := "pre-restore-" + strings.Repeat("b", 32) + dir := filepath.Join(filepath.Dir(home), id) + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + for _, query := range []string{"", "../home", "/home", id + "/../home", "pre-restore-ABC"} { + w := httptest.NewRecorder() + a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+url.QueryEscape(query), nil), i) + if w.Code != 400 { + t.Fatal(query, w.Code) + } + } + foreign := i + foreign.UID = "someone-else" + w := httptest.NewRecorder() + a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), foreign) + if w.Code != 404 { + t.Fatal(w.Code) + } + for _, target := range []string{"../home", "/etc/passwd", "../../elsewhere"} { + if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i) + if w.Code != 400 || w.Header().Get("Content-Disposition") != "" { + t.Fatal(target, w.Code) + } + if err := os.Remove(filepath.Join(dir, "link")); err != nil { + t.Fatal(err) + } + } + if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0600); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i) + if w.Code != 400 { + t.Fatal(w.Code) + } + if err := os.Remove(filepath.Join(dir, "fifo")); err != nil { + t.Fatal(err) + } + if err := os.Remove(dir); err != nil { + t.Fatal(err) + } + if err := os.Symlink(home, dir); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i) + if w.Code != 400 { + t.Fatal(w.Code) + } +} + +func TestSnapshotArchiveLimitsAndCancellation(t *testing.T) { + dir := t.TempDir() + configWrite(t, filepath.Join(dir, "file"), strings.Repeat("x", 2048)) + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := snapshotArchive(context.Background(), root, io.Discard, 1024); !errors.Is(err, errSnapshotDownloadLimit) { + t.Fatal(err) + } + if err := snapshotArchive(context.Background(), root, io.Discard, 2048); err != nil { + t.Fatal("exact content limit should allow archive overhead", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := snapshotArchive(ctx, root, io.Discard, upgradeSnapshotLimit); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + a, i, _ := configTestAPI(t, false) + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + r := httptest.NewRequest("GET", "/?download=pre-restore-"+strings.Repeat("a", 32), nil).WithContext(ctx) + w := httptest.NewRecorder() + a.snapshotDownload(w, r, i) + if w.Code != 408 { + t.Fatal(w.Code) + } + files, err := os.ReadDir(tmp) + if err != nil || len(files) != 0 { + t.Fatal(files, err) + } + if len(zipSlots) != 0 || a.instanceLocks.held() { + t.Fatal("resource leak") + } + t.Setenv("TMPDIR", filepath.Join(tmp, "missing")) + w = httptest.NewRecorder() + a.snapshotDownload(w, httptest.NewRequest("GET", r.URL.String(), nil), i) + if w.Code != 500 { + t.Fatal(w.Code) + } +} diff --git a/internal/api/snapshot_restore.go b/internal/api/snapshot_restore.go new file mode 100644 index 0000000..eba2db1 --- /dev/null +++ b/internal/api/snapshot_restore.go @@ -0,0 +1,381 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const snapshotUnknownReason = "snapshot source image/version unknown; explicit acknowledgement required" + +func snapshotRuntimeLayout(config, host map[string]json.RawMessage, mounts []struct{ Type, Source, Destination string }, home string) bool { + workspace := 0 + for _, mount := range mounts { + if mount.Type == "tmpfs" && mount.Destination == "/tmp" { + continue + } + if mount.Type != "bind" || mount.Source != home || mount.Destination != "/workspace" { + return false + } + workspace++ + } + if workspace != 1 { + return false + } + var env []string + if json.Unmarshal(config["Env"], &env) != nil { + return false + } + values := map[string]string{} + for _, entry := range env { + key, value, _ := strings.Cut(entry, "=") + values[key] = value + } + for key, want := range map[string]string{"DSH_HOME": "/workspace", "HOME": "/workspace", "XDG_DATA_HOME": "/workspace/.local/share", "XDG_CONFIG_HOME": "/workspace/.config"} { + if values[key] != want { + return false + } + } + for index, raw := range []json.RawMessage{host["Tmpfs"], config["Volumes"]} { + if len(raw) == 0 { + continue + } + var mounts map[string]json.RawMessage + if json.Unmarshal(raw, &mounts) != nil { + return false + } + if index == 0 { + delete(mounts, "/tmp") + } + if len(mounts) != 0 { + return false + } + } + return true +} + +func (a *API) recordSnapshot(i store.Instance, id, version, image string) error { + _, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS snapshot_metadata (instance_id TEXT NOT NULL, id TEXT NOT NULL, source_version TEXT NOT NULL, source_image TEXT NOT NULL, PRIMARY KEY(instance_id,id))") + if err != nil { + return err + } + _, err = a.s.DB.Exec("INSERT INTO snapshot_metadata(instance_id,id,source_version,source_image) VALUES(?,?,?,?)", i.ID, id, version, image) + return err +} + +func (a *API) snapshotCompatibility(i store.Instance, id string) (version, image, reason string) { + var exists int + if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='snapshot_metadata'").Scan(&exists); err != nil { + return "", "", "snapshot metadata unavailable" + } + if exists != 0 { + err := a.s.DB.QueryRow("SELECT source_version,source_image FROM snapshot_metadata WHERE instance_id=? AND id=?", i.ID, id).Scan(&version, &image) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", "", "snapshot metadata unavailable" + } + } + if (version != "" && version != i.Version) || (image != "" && image != i.ImageID) { + return version, image, "snapshot source image/version is incompatible" + } + if version == "" || image == "" { + return version, image, snapshotUnknownReason + } + return version, image, "" +} + +func (a *API) snapshotRestore(w http.ResponseWriter, r *http.Request, i store.Instance) { + controller := http.NewResponseController(w) + w, flush := deferJSONResponse(w) + defer flush() + select { + case configBodySlots <- struct{}{}: + defer func() { r.Body = http.NoBody; <-configBodySlots }() + default: + fail(w, 503, "configuration uploads busy; retry later") + return + } + if !configReadBody(w, r, controller, 4096) { + return + } + var in struct { + ID string + Password string + Confirm bool + AllowUnknown bool `json:"allow_unknown_version"` + } + dec := json.NewDecoder(r.Body) + if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !snapshotIDPattern.MatchString(in.ID) || !in.Confirm { + fail(w, 400, "valid snapshot ID and explicit confirmation required") + return + } + lock := a.instanceLocks.Acquire(i.ID) + defer lock.Release() + a.readersMu.Lock() + readers := a.workspaceReaders[i.ID] + a.readersMu.Unlock() + if readers > 0 { + fail(w, 409, "instance files busy; retry later") + return + } + user := a.userLocks.Acquire(i.UID) + defer user.Release() + current, err := a.s.GetInstance(i.ID) + if err != nil || current.UID != i.UID { + fail(w, 404, "not found") + return + } + i = current + if !a.reauthenticate(w, i.UID, in.Password) { + return + } + if reason := a.backupProtection(i); reason != "" { + fail(w, 409, reason) + return + } + admissionMu.RLock() + defer admissionMu.RUnlock() + buildQueue.Lock() + err = a.ensureBuilds() + buildQueue.Unlock() + if err != nil { + fail(w, 500, "database error") + return + } + var blocked int + if err = a.s.DB.QueryRowContext(r.Context(), "SELECT count(*) FROM image_admissions WHERE image_id=? AND state='blocked'", i.ImageID).Scan(&blocked); err != nil { + fail(w, 500, "database error") + return + } + if blocked > 0 { + fail(w, 409, "image is blocked; restore denied") + return + } + version, image, reason := a.snapshotCompatibility(i, in.ID) + if reason != "" && !(reason == snapshotUnknownReason && in.AllowUnknown) { + fail(w, 409, reason) + return + } + root, err := managementRoot(a.root, "workspaces", i.UID, "instances", i.ID) + if errors.Is(err, os.ErrNotExist) { + fail(w, 404, "home or snapshot not found") + return + } + if err != nil { + managementPathError(w, err) + return + } + defer root.Close() + for _, name := range []string{"home", in.ID} { + st, e := root.Lstat(name) + if errors.Is(e, os.ErrNotExist) { + fail(w, 404, "home or snapshot not found") + return + } + if e != nil { + managementPathError(w, e) + return + } + if !st.IsDir() { + fail(w, 400, "unsafe home or snapshot") + return + } + } + f, err := root.Open(".") + if err != nil { + fail(w, 500, "snapshot storage unavailable") + return + } + entries, err := f.ReadDir(-1) + f.Close() + count := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "pre-restore-") || strings.HasPrefix(entry.Name(), "failed-restore-") { + count++ + } + } + if err != nil { + fail(w, 500, "snapshot listing failed") + return + } + if count >= 3 { + fail(w, 409, "restore snapshot limit reached; cleanup required") + return + } + size, err := quotaTreeBytes(root, in.ID) + var pathError *os.PathError + if errors.As(err, &pathError) { + managementPathError(w, err) + return + } + if err != nil || a.quotaGrowth(i.UID, size) != nil { + fail(w, 413, "quota exceeded or snapshot size unavailable") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + var container struct { + Image string + Config, HostConfig map[string]json.RawMessage + Mounts []struct{ Type, Source, Destination string } + State struct{ Running, Paused, Restarting bool } + } + err = a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &container) + if err != nil { + fail(w, 502, "container inspection failed") + return + } + if container.State.Paused || container.State.Restarting { + fail(w, 409, "unsupported container state") + return + } + var runtimeImage struct { + ID string `json:"Id"` + Config struct{ Labels map[string]string } + } + hostRoot := os.Getenv("HOST_DATA_ROOT") + if hostRoot == "" { + hostRoot = a.root + } + homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home")) + if homeErr != nil || !snapshotRuntimeLayout(container.Config, container.HostConfig, container.Mounts, homeSource) { + fail(w, 409, "incompatible effective runtime workspace layout") + return + } + if i.ImageID == "" || container.Image != i.ImageID || container.Config == nil || container.HostConfig == nil || homeErr != nil || a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(i.ImageID)+"/json", nil, &runtimeImage) != nil || runtimeImage.ID != i.ImageID || runtimeImage.Config.Labels["dsh-mgr.version"] != i.Version || upgradeWorkspace(container.Config, container.HostConfig, runtimeImage.Config.Labels["dsh-mgr.recipe"], homeSource) != nil { + fail(w, 409, "incompatible runtime image or workspace layout") + return + } + wasRunning := container.State.Running + op := store.ID() + staging := ".snapshot-restore-" + op + previous := "pre-restore-" + op + failed := "failed-restore-" + op + if err = upgradeCopy(ctx, root, in.ID, staging, size); err != nil { + if root.RemoveAll(staging) != nil { + a.s.SetInstance(i.ID, "error") + fail(w, 500, "staging cleanup requires administrator recovery") + return + } + if errors.As(err, &pathError) { + managementPathError(w, err) + return + } + fail(w, 400, "unsafe or oversized snapshot") + return + } + defer func() { + if root.RemoveAll(staging) != nil { + a.s.SetInstance(i.ID, "error") + } + }() + if err = a.recordSnapshot(i, previous, i.Version, i.ImageID); err != nil { + fail(w, 500, "snapshot metadata unavailable") + return + } + if err = a.recordSnapshot(i, failed, version, image); err != nil { + fail(w, 500, "snapshot metadata unavailable") + return + } + if _, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil { + fail(w, 500, "cannot record restore state") + return + } + if _, err = a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.restore.started", i.ID, in.ID+" "+previous+" "+failed, time.Now().Unix()); err != nil { + fail(w, 500, "restore audit unavailable; recovery required") + return + } + if err = a.configStop(ctx, i.ContainerID); err != nil { + fail(w, 502, "runtime stop failed; recovery required") + return + } + moved, installed := false, false + var owner store.User + owner.ID = i.UID + err = a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", i.UID).Scan(&owner.Quota) + if err == nil { + var used int64 + used, err = a.userUsage(owner) + if err == nil && used > owner.Quota { + err = errors.New("quota exceeded after runtime stop") + } + } + if err == nil { + err = root.Rename("home", previous) + } + if err == nil { + moved = true + err = upgradeSync(root) + } + if err == nil { + err = root.Rename(staging, "home") + installed = err == nil + } + if err == nil { + err = upgradeSync(root) + } + startReady := func(c context.Context) error { + runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + if e := a.docker.Do(c, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil); e != nil { + return e + } + return a.upgradeReady(c, i.ContainerID, i.ID, a.runtimeHost(r)) + } + if err == nil { + err = startReady(ctx) + } + if err == nil && !wasRunning { + err = a.configStop(ctx, i.ContainerID) + } + rejected := err != nil + if rejected { + rollback, done := context.WithTimeout(context.Background(), 90*time.Second) + defer done() + if a.configStop(rollback, i.ContainerID) != nil { + fail(w, 500, "restore rollback requires runtime stop; recovery required") + return + } + if installed { + if root.Rename("home", failed) != nil || upgradeSync(root) != nil { + fail(w, 500, "restore rollback requires administrator recovery") + return + } + } + if moved { + if root.Rename(previous, "home") != nil || upgradeSync(root) != nil { + fail(w, 500, "restore rollback requires administrator recovery") + return + } + } + if wasRunning { + if startReady(rollback) != nil { + fail(w, 500, "restore rollback readiness failed; recovery required") + return + } + } + } + status := "exited" + if wasRunning { + status = "running" + } + if _, err = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); err != nil { + fail(w, 500, "restore state requires administrator recovery") + return + } + runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + if rejected { + a.s.AddAudit(i.UID, "snapshot.restore.rolled_back", i.ID, in.ID) + fail(w, 400, "restore rejected; previous home restored") + return + } + a.s.AddAudit(i.UID, "snapshot.restore.completed", i.ID, in.ID) + writeJSON(w, map[string]any{"ok": true, "snapshot_id": previous}) +} diff --git a/internal/api/snapshot_restore_test.go b/internal/api/snapshot_restore_test.go new file mode 100644 index 0000000..301d38c --- /dev/null +++ b/internal/api/snapshot_restore_test.go @@ -0,0 +1,220 @@ +package api + +import ( + "encoding/json" + "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func snapshotRestoreFixture(t *testing.T, running bool, failure string) (*API, store.Instance, string, string) { + t.Helper() + a, i, home := configTestAPI(t, running, failure) + i.ImageID = "sha256:" + strings.Repeat("a", 64) + i.Version = "1.0" + if _, err := a.s.DB.Exec("UPDATE instances SET image_id=?,version=? WHERE id=?", i.ImageID, i.Version, i.ID); err != nil { + t.Fatal(err) + } + original := a.docker + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/images/") { + writeJSON(w, map[string]any{"Id": i.ImageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.recipe": "7", "dsh-mgr.version": i.Version}}}) + return + } + if strings.HasSuffix(r.URL.Path, "/json") { + var data map[string]any + if err := original.Do(r.Context(), "GET", r.URL.RequestURI(), nil, &data); err != nil { + t.Error(err) + w.WriteHeader(500) + return + } + data["Image"] = i.ImageID + data["Config"] = map[string]any{"Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}} + data["Mounts"] = []map[string]string{{"Type": "bind", "Source": home, "Destination": "/workspace"}} + data["HostConfig"] = map[string]any{"Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}}} + data["HostConfig"].(map[string]any)["Tmpfs"] = map[string]string{"/tmp": "rw,noexec,nosuid,size=64m"} + writeJSON(w, data) + return + } + resp, err := original.Stream(r.Context(), r.Method, r.URL.RequestURI(), nil, "") + if err != nil { + t.Error(err) + w.WriteHeader(500) + return + } + defer resp.Body.Close() + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + })) + t.Cleanup(proxy.Close) + a.docker = docker.New(proxy.URL) + id := "upgrade-" + strings.Repeat("b", 32) + "-snapshot" + if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(filepath.Dir(home), id, "data"), "snapshot") + configWrite(t, filepath.Join(home, "data"), "current") + return a, i, home, id +} + +func snapshotRestoreBody(id string, unknown bool) string { + return fmt.Sprintf(`{"id":%q,"password":"original-password","confirm":true,"allow_unknown_version":%t}`, id, unknown) +} + +func TestSnapshotRuntimeLayout(t *testing.T) { + for _, test := range []string{"valid", "env", "tmpfs", "volume", "effective"} { + t.Run(test, func(t *testing.T) { + env := []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"} + if test == "env" { + env = append(env, "DSH_HOME=/old-shared") + } + raw, _ := json.Marshal(env) + config := map[string]json.RawMessage{"Env": raw} + host := map[string]json.RawMessage{"Tmpfs": json.RawMessage(`{"/tmp":"rw"}`)} + mounts := []struct{ Type, Source, Destination string }{{"bind", "/home", "/workspace"}} + mounts = append(mounts, struct{ Type, Source, Destination string }{"tmpfs", "", "/tmp"}) + if test == "tmpfs" { + host["Tmpfs"] = json.RawMessage(`{"/workspace/data":"rw"}`) + } + if test == "volume" { + config["Volumes"] = json.RawMessage(`{"/workspace/data":{}}`) + } + if test == "effective" { + mounts = append(mounts, struct{ Type, Source, Destination string }{"volume", "/other", "/workspace/data"}) + } + if snapshotRuntimeLayout(config, host, mounts, "/home") != (test == "valid") { + t.Fatal(test) + } + }) + } +} + +func TestSnapshotListProvenance(t *testing.T) { + a, i, _, id := snapshotRestoreFixture(t, false, "") + for _, known := range []bool{false, true} { + if known { + if err := a.recordSnapshot(i, id, "old-version", i.ImageID); err != nil { + t.Fatal(err) + } + } + w := configCall(a, i, "GET", "snapshots", "") + var list []snapshotInfo + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 { + t.Fatal(w.Code, w.Body.String()) + } + if list[0].Restorable == known || list[0].RestoreReason == "" { + t.Fatal(list) + } + if !known && (list[0].SourceVersion != "" || list[0].SourceImage != "") { + t.Fatal("inferred provenance", list) + } + } +} + +func TestSnapshotFullRestore(t *testing.T) { + for _, running := range []bool{false, true} { + t.Run(fmt.Sprint(running), func(t *testing.T) { + a, i, home, id := snapshotRestoreFixture(t, running, "") + if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil { + t.Fatal(err) + } + w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, false)) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var result struct { + SnapshotID string `json:"snapshot_id"` + } + json.Unmarshal(w.Body.Bytes(), &result) + for path, want := range map[string]string{filepath.Join(home, "data"): "snapshot", filepath.Join(filepath.Dir(home), id, "data"): "snapshot", filepath.Join(filepath.Dir(home), result.SnapshotID, "data"): "current"} { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatal(path, string(got), err) + } + } + current, _ := a.s.GetInstance(i.ID) + want := "exited" + if running { + want = "running" + } + if current.Status != want { + t.Fatal(current.Status) + } + }) + } +} + +func TestSnapshotRestoreRollback(t *testing.T) { + a, i, home, id := snapshotRestoreFixture(t, false, "reconcile") + w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true)) + if w.Code != 400 || !strings.Contains(w.Body.String(), "previous home restored") { + t.Fatal(w.Code, w.Body.String()) + } + for path, want := range map[string]string{filepath.Join(home, "data"): "current", filepath.Join(filepath.Dir(home), id, "data"): "snapshot"} { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatal(path, string(got), err) + } + } + matches, _ := filepath.Glob(filepath.Join(filepath.Dir(home), "failed-restore-*", "data")) + if len(matches) != 1 { + t.Fatal(matches) + } +} + +func TestSnapshotRestoreGuards(t *testing.T) { + for _, test := range []string{"unknown", "mismatch", "password", "confirm", "reader", "quota", "error", "unsafe", "foreign", "oversized"} { + t.Run(test, func(t *testing.T) { + a, i, home, id := snapshotRestoreFixture(t, false, "") + body := snapshotRestoreBody(id, true) + want := 409 + switch test { + case "unknown": + body = snapshotRestoreBody(id, false) + case "mismatch": + if err := a.recordSnapshot(i, id, "different", i.ImageID); err != nil { + t.Fatal(err) + } + case "password": + body = strings.ReplaceAll(body, "original-password", "wrong") + want = 403 + case "confirm": + body = strings.ReplaceAll(body, `"confirm":true`, `"confirm":false`) + want = 400 + case "reader": + a.workspaceReaders = map[string]int{i.ID: 1} + case "quota": + a.s.DB.Exec("UPDATE users SET quota=1 WHERE id=?", i.UID) + want = 413 + case "error": + a.s.SetInstance(i.ID, "error") + case "unsafe": + if err := os.Symlink("/etc/passwd", filepath.Join(filepath.Dir(home), id, "unsafe")); err != nil { + t.Fatal(err) + } + want = 400 + case "foreign": + i.UID = "someone-else" + want = 404 + case "oversized": + body = strings.Repeat("x", 4097) + want = 413 + } + w := configCall(a, i, "POST", "snapshots", body) + if w.Code != want { + t.Fatal(w.Code, w.Body.String()) + } + got, err := os.ReadFile(filepath.Join(home, "data")) + if err != nil || string(got) != "current" { + t.Fatal(string(got), err) + } + }) + } +} diff --git a/internal/api/upgrade.go b/internal/api/upgrade.go index c86e1b3..91e0a9e 100644 --- a/internal/api/upgrade.go +++ b/internal/api/upgrade.go @@ -340,6 +340,10 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance) } op := store.ID() snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op + if a.recordSnapshot(i, snapshot, i.Version, i.ImageID) != nil || a.recordSnapshot(i, failedHome, image.Config.Labels["dsh-mgr.version"], image.ID) != nil { + fail(w, 500, "snapshot metadata unavailable") + return + } newName := runtimeNetwork(i.ID) + "-upgrade-" + op _, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`) if err != nil { diff --git a/web/src/BackupPanel.test.tsx b/web/src/BackupPanel.test.tsx index ba2c685..0f4fd1c 100644 --- a/web/src/BackupPanel.test.tsx +++ b/web/src/BackupPanel.test.tsx @@ -26,7 +26,7 @@ function editor() { const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0]; return child.type(child.props); } -beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); refresh.mockClear(); onRestored.mockClear();}); +beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); refresh.mockClear(); onRestored.mockClear();}); it('retains restore password and shows failure inside the owning modal after confirmation', async () => { const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied')); nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick(); @@ -108,3 +108,79 @@ it('only a successful restore invokes the profile reload callback', async () => expect(onRestored).toHaveBeenCalledOnce(); expect(refresh).not.toHaveBeenCalled(); }); + +const snapshot = {id: 'pre-restore/a b', size: 1024, created_at: '', protected: false, restorable: true, restore_reason: '', source_image: 'image', source_version: '1.2.3'}; +function snapshotEditor() { + const child = nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)[0]; + return child.type(child.props); +} +const submitSnapshot = () => nodes(snapshotEditor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()}); +const snapshotPassword = () => nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'password')[0]; +it('warns about plaintext keys before download, cancels safely, and navigates only after confirmation', async () => { + state.snapshots = [snapshot]; + const assign = vi.fn(); + vi.stubGlobal('window', {location: {assign}}); + button('下载快照').props.onClick(); + const confirmation = nodes(render(), n => n.type === ConfirmDialog)[0]; + expect(confirmation.props.request.options.body).toContain('未加密'); + expect(confirmation.props.request.options.body).toContain('密钥'); + expect(confirmation.props.request.options.body).toContain('明文'); + expect(assign).not.toHaveBeenCalled(); + confirmation.props.onClose(false); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(assign).not.toHaveBeenCalled(); + button('下载快照').props.onClick(); + nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(assign).toHaveBeenCalledExactlyOnceWith('/api/instances/one/snapshots?download=pre-restore%2Fa%20b'); +}); +it('restores a known snapshot with the current password and reloads the profile only on success', async () => { + state.snapshots = [snapshot]; + const request = vi.spyOn(api, 'request').mockResolvedValue({ok: true}); + expect(JSON.stringify(render())).toContain('1.2.3'); + button('恢复快照').props.onClick(); + await submitSnapshot(); + expect(request).not.toHaveBeenCalled(); + expect(nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0); + const text = JSON.stringify(snapshotEditor()); + for (const warning of ['覆盖完整 home', '失败回滚副本', '保留原始快照', '临时停止', '就绪验证', '不会自动降级']) expect(text).toContain(warning); + snapshotPassword().props.onChange({target: {value: 'current-password'}}); + await submitSnapshot(); + expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: false}); + expect(onRestored).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); + expect(state.refreshStorage).toHaveBeenCalledTimes(2); + expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0); +}); +it.each(['source_image', 'source_version'])('requires unknown compatibility acknowledgement when %s is missing, clears password and refreshes after failure', async missing => { + state.snapshots = [{...snapshot, [missing]: undefined}]; + const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('snapshot restore denied')); + button('恢复快照').props.onClick(); + snapshotPassword().props.onChange({target: {value: 'current-password'}}); + await submitSnapshot(); + expect(request).not.toHaveBeenCalled(); + expect(nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '确认覆盖并恢复快照')[0].props.disabled).toBe(true); + nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')[0].props.onChange({target: {checked: true}}); + await submitSnapshot(); + expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: true}); + expect(snapshotPassword().props.value).toBe(''); + expect(JSON.stringify(nodes(snapshotEditor(), n => n.type === DialogContent)[0])).toContain('snapshot restore denied'); + expect(onRestored).not.toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledOnce(); + expect(state.refreshStorage).toHaveBeenCalledTimes(2); +}); +it.each([{protected: true, restorable: true}, {protected: false, restorable: false}])('disables protected or incompatible snapshot restoration (%j)', flags => { + state.snapshots = [{...snapshot, ...flags, restore_reason: 'incompatible home', protection_reason: 'recovery required'}]; + expect(button('恢复快照').props.disabled).toBe(true); + expect(JSON.stringify(render())).toContain('incompatible home'); + expect(button('下载快照').props.disabled).toBe(false); +}); +it('cancels snapshot restoration without requests or profile reloads', () => { + state.snapshots = [snapshot]; + const request = vi.spyOn(api, 'request'); + button('恢复快照').props.onClick(); + nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '取消')[0].props.onClick(); + expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0); + expect(request).not.toHaveBeenCalled(); + expect(onRestored).not.toHaveBeenCalled(); +}); diff --git a/web/src/BackupPanel.tsx b/web/src/BackupPanel.tsx index 80f83ca..1bd03d2 100644 --- a/web/src/BackupPanel.tsx +++ b/web/src/BackupPanel.tsx @@ -18,6 +18,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance const [password, setPassword] = useState(''); const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState(); const [restoreTarget, setRestoreTarget] = useState(); + const [snapshotTarget, setSnapshotTarget] = useState(); async function refreshStorage() { await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]); } @@ -33,6 +34,23 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance }, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除'); } + async function downloadSnapshot(snapshot: Snapshot) { + if (!await confirm.ask({ + title: '下载完整 home 快照', danger: true, confirmLabel: '确认下载', + body: '下载的是未加密的 tar.gz 完整 home 归档,可能包含密钥、密码及其他明文敏感数据。请妥善保管,不要公开分享。', + })) return; + window.location.assign('/api' + base + '/snapshots?download=' + encodeURIComponent(snapshot.id)); + } + async function restoreSnapshot(snapshot: Snapshot, password: string, allowUnknownVersion: boolean) { + if (snapshot.protected || !snapshot.restorable || !password || ((!snapshot.source_image || !snapshot.source_version) && !allowUnknownVersion)) return false; + return action.run(async () => { + try { + await api.request(base + '/snapshots', 'POST', {id: snapshot.id, password, confirm: true, allow_unknown_version: allowUnknownVersion}); + await onRestored(); + } finally { await refreshStorage(); } + }, '完整 home 快照恢复及就绪验证完成,已恢复原运行状态。'); + } + function createBackup(e: FormEvent) { e.preventDefault(); void action.run(async () => { @@ -88,7 +106,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance - {!restoreTarget && action.feedback} + {!restoreTarget && !snapshotTarget && action.feedback}

配置备份(.dshbak)

仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。

@@ -112,7 +130,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance

完整 home 快照

- 升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。 + 升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。可下载未加密的 tar.gz 归档或恢复完整 home,请妥善保管其中的密钥与明文数据。删除不可撤销,待恢复实例的快照受保护。 {snapshots.data && !snapshots.error &&

快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}

} {snapshots.data?.length === 0 && 暂无完整 home 快照} @@ -121,8 +139,14 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance

{s.id}

{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}

{s.protected &&

受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}

} +

来源版本:{s.source_version || '未知'}{!s.source_image && ' · 来源镜像未知'}

+ {s.restore_reason &&

{s.restore_reason}

} + +
+ + +
- )} @@ -130,9 +154,45 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance const ok = await restore(restoreTarget, password); if (ok) setRestoreTarget(undefined); }} />} + {snapshotTarget && setSnapshotTarget(undefined)} onSubmit={async (password, allowUnknownVersion) => { + const ok = await restoreSnapshot(snapshotTarget, password, allowUnknownVersion); + if (ok) setSnapshotTarget(undefined); + }} />} ; } +function SnapshotRestoreDialog({snapshot, busy, feedback, onClose, onSubmit}: {snapshot: Snapshot; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string, allowUnknownVersion: boolean) => Promise}) { + const [password, setPassword] = useState(''); + const [allowUnknownVersion, setAllowUnknownVersion] = useState(false); + const unknownVersion = !snapshot.source_image || !snapshot.source_version; + const disabled = busy || !password || snapshot.protected || !snapshot.restorable || (unknownVersion && !allowUnknownVersion); + async function submit(e: FormEvent) { + e.preventDefault(); + if (disabled) return; + try { await onSubmit(password, unknownVersion && allowUnknownVersion); } + finally { setPassword(''); } + } + return { if (!open && !busy) onClose(); }}> + +
+ {feedback} + 此操作将覆盖完整 home,包括配置、依赖及工作文件,未保存的编辑将丢失。服务器会先保留当前 home 作为失败回滚副本,并保留原始快照;按需临时停止实例,完成就绪验证后恢复原运行状态。不会自动降级或切换当前镜像。 +

来源版本:{snapshot.source_version || '未知'}

+ {unknownVersion && } + + + + + +
+
+
; +} + function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise}) { const [password, setPassword] = useState(''); return { if (!open && !busy) onClose(); }}> diff --git a/web/src/api.ts b/web/src/api.ts index 9ea6951..4b755e0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -13,7 +13,7 @@ export interface Entry { name: string; path?: string; is_dir: boolean; size: num export interface Usage { used_bytes: number; quota_bytes: number } export interface TextFile { content: string; version: string } export interface Backup { id: string; created_at: string; size: number } -export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string } +export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string; restorable: boolean; restore_reason: string; source_version?: string; source_image?: string } export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string } export function createApi(fetcher: typeof fetch = fetch) { let csrf = '';