fix: preserve snapshot symlinks and guard incomplete upgrade copies

This commit is contained in:
2026-10-18 10:12:53 +08:00
parent 1c244e135f
commit ed72985e7b
8 changed files with 192 additions and 45 deletions
+3 -1
View File
@@ -49,7 +49,9 @@
升级到本版本后应在维护窗口逐个访问现有实例,完成首次认证持久化。若在首次成功连接前,启动token已被Docker日志轮转删除,本版本无法凭空恢复;先保存工作,再由管理员安排重启相关实例并访问验证。不要为此删除或重建实例。初次日志扫描上限64 MiB,常规每文件10 MiB/3份保留在此范围内。
- 实例升级选择已经启动验证且准入的本地模板7镜像并提供当前登录密码,面板自动停机、创建密码配置备份与home快照、启动验证及失败回滚,无需先手动停止。home快照最大1 GiB;允许受约束的内部相对链接以支持pnpm,拒绝不安全链接/特殊文件。模板7的 `/workspace` 是实例home,包含在home回滚内;旧共享挂载布局会在升级前被拒绝。跨DSH版本历史验证范围见 ACCEPTANCE.md。
- 实例升级选择已经启动验证且准入的本地模板7镜像并提供当前登录密码,面板自动停机、创建密码配置备份与home快照、启动验证及失败回滚,无需先手动停止。home快照最大1 GiB;软链接按链接本身保存,保留相对、绝对、失效及循环链接的目标文本,不读取或备份链接指向的外部内容;仍拒绝 socket、FIFO 等特殊文件。模板7的 `/workspace` 是实例home,包含在home回滚内;旧共享挂载布局会在升级前被拒绝。跨DSH版本历史验证范围见 ACCEPTANCE.md。
- 升级快照先复制到 `.upgrade-<ID>-snapshot-partial`,完成后才发布正式目录并记录完成状态。复制失败会清理临时目录;清理失败会报告并记录 `upgrade.snapshot.cleanup_failed`,管理员核实后处理。进程中断也可能留下临时目录,不会作为可恢复快照列出。已发布但未记录完成的快照会显示不可恢复。旧版残留没有可靠的完成记录,本版本不会自动判定其完整或自动删除,建议保留证据并重新生成快照。
- 完整快照恢复和下载同样保留软链接原文;失效链接不会自动修复,外部目标不属于恢复范围。下载的 tar.gz 可能包含绝对或目录外链接,应在隔离目录使用安全解压工具,不要跟随链接写入宿主机。独立的加密配置备份仍只接受白名单普通文件,配置路径和资源中的软链接仍会被拒绝。
- Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。
- 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
@@ -30,7 +31,7 @@ func TestRestoreSnapshotCopyFailureDoesNotConsumeRetention(t *testing.T) {
}
configWrite(t, filepath.Join(base, name, "keep"), name)
}
if err := os.Symlink("missing-target", filepath.Join(home, "dangling")); err != nil {
if err := syscall.Mkfifo(filepath.Join(home, "dangling"), 0600); err != nil {
t.Fatal(err)
}
body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID)
+1 -7
View File
@@ -62,13 +62,7 @@ func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit in
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
}
// Archive link text only; do not read or traverse its target.
} else if !st.IsDir() && !st.Mode().IsRegular() {
return errUnsafeSnapshotArchive
}
+26 -2
View File
@@ -192,15 +192,39 @@ func TestSnapshotDownloadRejectsUnsafeAndForeign(t *testing.T) {
if w.Code != 404 {
t.Fatal(w.Code)
}
for _, target := range []string{"../home", "/etc/passwd", "../../elsewhere"} {
for _, target := range []string{"../home", "/workspace/example", "../../elsewhere", "missing", "link"} {
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") != "" {
if w.Code != 200 || w.Header().Get("Content-Disposition") == "" {
t.Fatal(target, w.Code)
}
gz, err := gzip.NewReader(w.Body)
if err != nil {
t.Fatal(err)
}
tr := tar.NewReader(gz)
found := false
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if h.Name == "link" {
found = h.Typeflag == tar.TypeSymlink && h.Linkname == target && h.Size == 0
} else if h.Name != "./" {
t.Fatalf("unexpected archived entry: %s", h.Name)
}
}
gz.Close()
if !found {
t.Fatal("link text was not preserved")
}
if err := os.Remove(filepath.Join(dir, "link")); err != nil {
t.Fatal(err)
}
+13
View File
@@ -74,6 +74,19 @@ func (a *API) recordSnapshot(i store.Instance, id, version, image string) error
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_copy_state'").Scan(&exists); err != nil {
return "", "", "snapshot completion metadata unavailable"
}
if exists != 0 {
var ready int
err := a.s.DB.QueryRow("SELECT ready FROM snapshot_copy_state WHERE instance_id=? AND id=?", i.ID, id).Scan(&ready)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", "", "snapshot completion metadata unavailable"
}
if err == nil && ready != 1 {
return "", "", "snapshot copy incomplete"
}
}
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"
}
+48 -1
View File
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
@@ -122,6 +123,12 @@ 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, "")
links := map[string]string{"absolute": "/workspace/example", "dangling": "missing", "loop": "loop", "relative": "data"}
for name, target := range links {
if err := os.Symlink(target, filepath.Join(filepath.Dir(home), id, name)); err != nil {
t.Fatal(err)
}
}
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
t.Fatal(err)
}
@@ -140,6 +147,12 @@ func TestSnapshotFullRestore(t *testing.T) {
}
}
current, _ := a.s.GetInstance(i.ID)
for name, target := range links {
got, err := os.Readlink(filepath.Join(home, name))
if err != nil || got != target {
t.Fatalf("restored link %s: %q %v", name, got, err)
}
}
want := "exited"
if running {
want = "running"
@@ -169,6 +182,40 @@ func TestSnapshotRestoreRollback(t *testing.T) {
}
}
func TestIncompleteSnapshotNotRestorable(t *testing.T) {
a, i, _, id := snapshotRestoreFixture(t, false, "")
if _, err := a.s.DB.Exec("CREATE TABLE snapshot_copy_state (instance_id TEXT, id TEXT, ready INTEGER)"); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("INSERT INTO snapshot_copy_state VALUES(?,?,0)", i.ID, id); 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 || list[0].Restorable || list[0].RestoreReason != "snapshot copy incomplete" {
t.Fatal(w.Code, w.Body.String())
}
w = configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true))
if w.Code != 409 || !strings.Contains(w.Body.String(), "snapshot copy incomplete") {
t.Fatal(w.Code, w.Body.String())
}
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
t.Fatal(err)
}
if _, err := a.s.DB.Exec("UPDATE snapshot_copy_state SET ready=1"); err != nil {
t.Fatal(err)
}
if _, _, reason := a.snapshotCompatibility(i, id); reason != "" {
t.Fatal(reason)
}
if _, err := a.s.DB.Exec("ALTER TABLE snapshot_copy_state RENAME COLUMN ready TO unavailable"); err != nil {
t.Fatal(err)
}
if _, _, reason := a.snapshotCompatibility(i, id); reason != "snapshot completion metadata unavailable" {
t.Fatal(reason)
}
}
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) {
@@ -196,7 +243,7 @@ func TestSnapshotRestoreGuards(t *testing.T) {
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 {
if err := syscall.Mkfifo(filepath.Join(filepath.Dir(home), id, "unsafe"), 0600); err != nil {
t.Fatal(err)
}
want = 400
+45 -20
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -100,9 +101,17 @@ func upgradeSync(r *os.Root) error {
func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit int64) error {
remaining, entries := limit, 100000
var links []string
var copyTree func(string, string, int) error
copyTree = func(src, dst string, depth int) error {
copyTree = func(src, dst string, depth int) (copyErr error) {
defer func() {
if copyErr != nil {
if pathErr, ok := copyErr.(*os.PathError); ok {
copyErr = fmt.Errorf("snapshot copy: %w", &os.PathError{Op: pathErr.Op, Path: src, Err: pathErr.Err})
return
}
copyErr = fmt.Errorf("%s: %w", src, copyErr)
}
}()
if err := ctx.Err(); err != nil {
return err
}
@@ -155,14 +164,11 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i
if err != nil {
return err
}
rel, err := filepath.Rel(source, src)
if err != nil || filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
return errors.New("unsafe snapshot link")
}
// Preserve the link itself, including dangling and container-absolute
// targets. Never resolve it against the panel's filesystem.
if err = r.Symlink(link, dst); err != nil {
return err
}
links = append(links, rel)
return r.Lchown(dst, 1000, 1000)
}
if !st.Mode().IsRegular() || st.Size() > remaining {
@@ -201,17 +207,6 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i
if err := copyTree(source, target, 0); err != nil {
return err
}
snapshot, err := r.OpenRoot(target)
if err != nil {
return err
}
defer snapshot.Close()
// Resolve only within the completed snapshot, including intermediate symlinks.
for _, link := range links {
if _, err := snapshot.Stat(link); err != nil {
return errors.New("unsafe or unresolved snapshot link")
}
}
return upgradeSync(r)
}
@@ -460,8 +455,38 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
fail(w, 413, "quota exceeded or snapshot size unavailable")
return
}
if upgradeCopy(ctx, root, "home", snapshot, snapshotBytes) != nil {
fail(w, 500, "home snapshot failed")
// Only publish a completed copy under a name the snapshot API recognizes.
staging := "." + snapshot + "-partial"
if _, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS snapshot_copy_state (instance_id TEXT NOT NULL, id TEXT NOT NULL, ready INTEGER NOT NULL, PRIMARY KEY(instance_id,id))"); err != nil {
fail(w, 500, "snapshot completion metadata unavailable")
return
}
if _, err := a.s.DB.Exec("INSERT INTO snapshot_copy_state VALUES(?,?,0)", i.ID, snapshot); err != nil {
fail(w, 500, "snapshot completion metadata unavailable")
return
}
if copyErr := upgradeCopy(ctx, root, "home", staging, snapshotBytes); copyErr != nil {
if cleanupErr := root.RemoveAll(staging); cleanupErr != nil {
a.s.AddAudit(i.UID, "upgrade.snapshot.cleanup_failed", i.ID, staging)
fail(w, 500, "home snapshot failed: "+copyErr.Error()+"; partial snapshot cleanup required")
return
}
fail(w, 500, "home snapshot failed: "+copyErr.Error())
return
}
if err := root.Rename(staging, snapshot); err != nil {
if root.RemoveAll(staging) != nil {
a.s.AddAudit(i.UID, "upgrade.snapshot.cleanup_failed", i.ID, staging)
}
fail(w, 500, "home snapshot publication failed")
return
}
if upgradeSync(root) != nil {
fail(w, 500, "home snapshot publication sync failed")
return
}
if _, err := a.s.DB.Exec("UPDATE snapshot_copy_state SET ready=1 WHERE instance_id=? AND id=?", i.ID, snapshot); err != nil {
fail(w, 500, "snapshot completion metadata unavailable")
return
}
snapReady = true
+54 -13
View File
@@ -100,7 +100,7 @@ func TestUpgradeLifecycle(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, mode := range []string{"success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
for _, mode := range []string{"snapshot-fifo", "snapshot-dangling-link", "success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
t.Run(mode, func(t *testing.T) {
image := "sha256:" + strings.Repeat("b", 64)
var a *API
@@ -260,6 +260,16 @@ func TestUpgradeLifecycle(t *testing.T) {
if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("original"), 0600); err != nil {
t.Fatal(err)
}
if mode == "snapshot-dangling-link" {
if err := os.Symlink("missing", filepath.Join(home, "dangling")); err != nil {
t.Fatal(err)
}
}
if mode == "snapshot-fifo" {
if err := syscall.Mkfifo(filepath.Join(home, "pipe"), 0600); err != nil {
t.Fatal(err)
}
}
i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", ImageID: "old-image", Version: "v1", Status: "exited", Created: time.Now()}
if err := a.s.AddInstance(i); err != nil {
t.Fatal(err)
@@ -297,6 +307,38 @@ func TestUpgradeLifecycle(t *testing.T) {
if err := a.s.DB.QueryRow("SELECT phase,snapshot,failed_home FROM upgrade_journal WHERE instance_id='one'").Scan(&phase, &snapshot, &failed); err != nil {
t.Fatal(err)
}
if mode == "snapshot-fifo" {
if w.Code != 500 || !strings.Contains(w.Body.String(), "home/pipe") || phase != "rolled_back" || got.ContainerID != "old" {
t.Fatalf("%d %s phase=%s", w.Code, w.Body.String(), phase)
}
entries, err := os.ReadDir(base)
if err != nil || len(entries) != 1 || entries[0].Name() != "home" {
t.Fatalf("partial snapshot leaked: %v %v", entries, err)
}
listed := configCall(a, got, "GET", "snapshots", "")
if listed.Code != 200 || strings.TrimSpace(listed.Body.String()) != "[]" {
t.Fatal(listed.Code, listed.Body.String())
}
return
}
if mode == "snapshot-dangling-link" {
if w.Code != 200 || phase != "complete" || got.ContainerID != "new" {
t.Fatalf("unexpected result: %d %s phase=%s instance=%+v", w.Code, w.Body.String(), phase, got)
}
saved, err := os.ReadFile(filepath.Join(base, snapshot, ".config", "state"))
if err != nil || string(saved) != "original" {
t.Fatalf("copied data missing: %q %v", saved, err)
}
link, err := os.Readlink(filepath.Join(base, snapshot, "dangling"))
if err != nil || link != "missing" {
t.Fatalf("copied link missing: %q %v", link, err)
}
listed := configCall(a, got, "GET", "snapshots", "")
if listed.Code != 200 || !strings.Contains(listed.Body.String(), snapshot) {
t.Fatalf("snapshot not listed: %d %s", listed.Code, listed.Body.String())
}
return
}
data, err := os.ReadFile(filepath.Join(home, ".config", "state"))
if err != nil {
t.Fatal(err)
@@ -375,7 +417,7 @@ func TestUpgradeLifecycle(t *testing.T) {
}
func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
for _, kind := range []string{"symlink", "fifo", "oversize", "directory-symlink"} {
for _, kind := range []string{"fifo", "oversize"} {
t.Run(kind, func(t *testing.T) {
base := t.TempDir()
if err := os.Mkdir(filepath.Join(base, "home"), 0700); err != nil {
@@ -384,14 +426,10 @@ func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
p := filepath.Join(base, "home", "bad")
var err error
switch kind {
case "symlink":
err = os.Symlink("/etc/passwd", p)
case "fifo":
err = syscall.Mkfifo(p, 0600)
case "oversize":
err = os.WriteFile(p, []byte("12345"), 0600)
case "directory-symlink":
err = os.Symlink("/tmp", p)
}
if err != nil {
t.Fatal(err)
@@ -412,7 +450,7 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, mode := range []string{"pnpm", "escape", "chain-escape", "loop", "dangling"} {
for _, mode := range []string{"pnpm", "absolute", "escape", "chain-escape", "loop", "dangling"} {
t.Run(mode, func(t *testing.T) {
base := t.TempDir()
home := filepath.Join(base, "home")
@@ -425,6 +463,8 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
}
link := ".pnpm/pkg/node_modules/pkg"
switch mode {
case "absolute":
link = "/workspace/example"
case "escape":
link = "../../outside"
case "chain-escape":
@@ -446,12 +486,6 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
}
defer root.Close()
err = upgradeCopy(context.Background(), root, "home", "snapshot", 1024)
if mode != "pnpm" {
if err == nil {
t.Fatal("unsafe link accepted")
}
return
}
if err != nil {
t.Fatal(err)
}
@@ -465,6 +499,13 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
if err := root.Rename("snapshot", "home"); err != nil {
t.Fatal(err)
}
if mode != "pnpm" {
got, err := root.Readlink("home/node_modules/pkg")
if err != nil || got != link {
t.Fatalf("restored link changed: %q %v", got, err)
}
return
}
data, err := os.ReadFile(filepath.Join(home, "node_modules/pkg/index.js"))
if err != nil || string(data) != "original" {
t.Fatalf("rollback link broken: %q %v", data, err)