Files
bluebird/internal/app/backup.go
T

212 lines
4.3 KiB
Go

package app
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Backup is an offline operation: Open has already acquired the exclusive service lock.
func (a *App) Backup(configPath, target string) error {
abs, e := filepath.Abs(target)
if e != nil {
return e
}
data, e := filepath.Abs(a.cfg.DataDir)
if e != nil {
return e
}
if abs == data || strings.HasPrefix(abs, data+string(os.PathSeparator)) {
return errors.New("备份目标必须在数据目录之外")
}
if e = a.db.Close(); e != nil {
return e
}
a.db = nil
out, e := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
return e
}
success := false
defer func() {
out.Close()
if !success {
os.Remove(abs)
}
}()
gz := gzip.NewWriter(out)
tw := tar.NewWriter(gz)
write := func(name string, f *os.File, st os.FileInfo) error {
h := &tar.Header{Name: name, Mode: 0600, Size: st.Size(), ModTime: st.ModTime(), Typeflag: tar.TypeReg}
if e := tw.WriteHeader(h); e != nil {
return e
}
_, e := io.Copy(tw, f)
return e
}
cf, e := os.Open(configPath)
if e != nil {
return e
}
st, e := cf.Stat()
if e == nil {
e = write("config.toml", cf, st)
}
cf.Close()
if e != nil {
return e
}
e = fs.WalkDir(a.root.FS(), ".", func(path string, d fs.DirEntry, e error) error {
if e != nil {
return e
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("拒绝备份符号链接: %s", path)
}
if d.IsDir() || path == "service.lock" {
return nil
}
if strings.HasPrefix(path, "tmp/") {
return nil
}
st, e := d.Info()
if e != nil {
return e
}
if !st.Mode().IsRegular() {
return errors.New("数据目录包含特殊文件")
}
f, e := a.root.Open(path)
if e != nil {
return e
}
defer f.Close()
return write("var/"+path, f, st)
})
if e != nil {
return e
}
if e = tw.Close(); e != nil {
return e
}
if e = gz.Close(); e != nil {
return e
}
if e = out.Sync(); e != nil {
return e
}
success = true
return nil
}
func Restore(c Config, source string) error {
if e := c.Validate(); e != nil {
return e
}
if e := os.MkdirAll(c.DataDir, 0700); e != nil {
return e
}
entries, e := os.ReadDir(c.DataDir)
if e != nil {
return e
}
if len(entries) != 0 {
return errors.New("恢复数据目录必须为空")
}
root, e := os.OpenRoot(c.DataDir)
if e != nil {
return e
}
defer root.Close()
if e = root.WriteFile("restore.incomplete", []byte("restoring"), 0600); e != nil {
return e
}
input, e := os.Open(source)
if e != nil {
return e
}
defer input.Close()
gz, e := gzip.NewReader(input)
if e != nil {
return e
}
defer gz.Close()
tr := tar.NewReader(gz)
var total int64
var count int
for {
h, e := tr.Next()
if errors.Is(e, io.EOF) {
break
}
if e != nil {
return e
}
if h.Typeflag != tar.TypeReg || !safeRelative(h.Name) {
return errors.New("备份包含非法路径或文件类型")
}
count++
if count > c.MaxRecords+32 {
return errors.New("备份文件数量超出恢复限制")
}
name := ""
if h.Name == "config.toml" {
name = "restored-config.toml"
} else if strings.HasPrefix(h.Name, "var/") {
name = strings.TrimPrefix(h.Name, "var/")
} else {
return errors.New("未知备份路径")
}
if !safeRelative(name) || h.Size < 0 || h.Size > c.DataBudget || total > c.DataBudget-h.Size {
return errors.New("备份超过恢复容量")
}
total += h.Size
if e = root.MkdirAll(filepath.Dir(name), 0700); e != nil {
return e
}
f, e := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if e != nil {
return e
}
_, e = io.CopyN(f, tr, h.Size)
if e == nil {
e = f.Sync()
}
ce := f.Close()
if e == nil {
e = ce
}
if e != nil {
return e
}
}
if _, e = io.Copy(io.Discard, gz); e != nil {
return e
}
// Invalidate before Open runs lifecycle cleanup, leaving payloads retained for admin review.
dbPath := filepath.Join(c.DataDir, "db", "bluebird.db")
if _, e = os.Stat(dbPath); e != nil {
return errors.New("备份缺少数据库")
}
a, e := OpenForRestore(c)
if e != nil {
return e
}
defer a.Close()
if e = a.Check(); e != nil {
return e
}
if e = a.InvalidateRestored(); e != nil {
return e
}
if e = root.Remove("restore.incomplete"); e != nil {
return e
}
return syncDir(root, ".")
}