// Package backup архивирует миры и конфиги Project Zomboid и восстанавливает // их обратно. Каждый архив относится к одному профилю сервера. package backup import ( "archive/tar" "compress/gzip" "context" "fmt" "io" "os" "path" "path/filepath" "regexp" "sort" "strings" "time" "github.com/mrleo1nid/pzmanager/internal/config" ) // Archive — один файл бэкапа на диске. type Archive struct { Name string `json:"name"` Profile string `json:"profile"` SizeBytes int64 `json:"size_bytes"` CreatedAt int64 `json:"created_at"` } // nameRe разбирает имена архивов: имя приходит от клиента и используется как // путь, поэтому набор символов жёстко ограничен. Часть с профилем // необязательна — так читаются архивы, снятые до появления профилей. var nameRe = regexp.MustCompile(`^pz-(?:([A-Za-z0-9_-]+)-)?[0-9]{8}-[0-9]{6}(?:-[a-zA-Z0-9_-]+)?\.tar\.gz$`) // Manager владеет директорией с бэкапами. type Manager struct { cfg *config.Config } // New создаёт менеджер бэкапов. func New(cfg *config.Config) *Manager { return &Manager{cfg: cfg} } // Dir возвращает директорию с архивами. func (m *Manager) Dir() string { return m.cfg.Backup.Dir } // List возвращает архивы, свежие в начале списка. func (m *Manager) List() ([]Archive, error) { entries, err := os.ReadDir(m.cfg.Backup.Dir) if os.IsNotExist(err) { return []Archive{}, nil } if err != nil { return nil, err } out := make([]Archive, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } match := nameRe.FindStringSubmatch(e.Name()) if match == nil { continue } info, err := e.Info() if err != nil { continue } out = append(out, Archive{ Name: e.Name(), Profile: match[1], SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix(), }) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) return out, nil } // ListForProfile отбирает архивы одного профиля. func (m *Manager) ListForProfile(profileID string) ([]Archive, error) { all, err := m.List() if err != nil { return nil, err } out := make([]Archive, 0, len(all)) for _, a := range all { if a.Profile == profileID { out = append(out, a) } } return out, nil } // Path возвращает полный путь к архиву, проверив имя. func (m *Manager) Path(name string) (string, error) { if !nameRe.MatchString(name) { return "", fmt.Errorf("недопустимое имя архива: %q", name) } return filepath.Join(m.cfg.Backup.Dir, name), nil } // ProfileOf возвращает профиль, которому принадлежит архив. Для архивов, // снятых до появления профилей, возвращает пустую строку. func (m *Manager) ProfileOf(name string) string { match := nameRe.FindStringSubmatch(name) if match == nil { return "" } return match[1] } // Create собирает архив мира профиля и его серверных конфигов. Суффикс note // попадает в имя файла, чтобы ручные бэкапы было легко отличить. func (m *Manager) Create(ctx context.Context, log func(string), profileID, note string) (Archive, error) { if err := config.ValidateProfileID(profileID); err != nil { return Archive{}, err } if err := os.MkdirAll(m.cfg.Backup.Dir, 0o755); err != nil { return Archive{}, err } name := fmt.Sprintf("pz-%s-%s", profileID, time.Now().Format("20060102-150405")) if note = sanitizeNote(note); note != "" { name += "-" + note } name += ".tar.gz" target := filepath.Join(m.cfg.Backup.Dir, name) log(fmt.Sprintf("Создаю архив %s (профиль %s)", name, profileID)) // Пишем во временный файл: недописанный архив не должен попасть в список. tmp := target + ".part" f, err := os.Create(tmp) if err != nil { return Archive{}, err } gz := gzip.NewWriter(f) tw := tar.NewWriter(gz) // abort закрывает всё начатое и убирает недописанный файл. abort := func(err error) (Archive, error) { tw.Close() gz.Close() f.Close() os.Remove(tmp) return Archive{}, err } var written int64 if world := m.cfg.WorldDirFor(profileID); dirExists(world) { n, err := addTree(ctx, tw, m.cfg.ZomboidDir, world, log) written += n if err != nil { return abort(err) } } else { log("Мир ещё не создан — архивирую только конфиги") } configs, err := profileConfigFiles(m.cfg.ServerDirPath(), profileID) if err != nil { return abort(err) } for _, file := range configs { if err := addFile(tw, m.cfg.ZomboidDir, file); err != nil { return abort(err) } written++ } if err := tw.Close(); err != nil { gz.Close() f.Close() os.Remove(tmp) return Archive{}, err } if err := gz.Close(); err != nil { f.Close() os.Remove(tmp) return Archive{}, err } if err := f.Close(); err != nil { os.Remove(tmp) return Archive{}, err } if err := os.Rename(tmp, target); err != nil { return Archive{}, err } info, err := os.Stat(target) if err != nil { return Archive{}, err } log(fmt.Sprintf("Архив готов: %s (%s, файлов: %d)", name, humanSize(info.Size()), written)) if err := m.Prune(profileID, m.cfg.Backup.Keep, log); err != nil { log("Ротация не удалась: " + err.Error()) } return Archive{ Name: name, Profile: profileID, SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix(), }, nil } // profileConfigFiles находит серверные файлы профиля: .ini, // _SandboxVars.lua, _spawnpoints.lua и прочие, которые создаёт игра. // Разделитель после префикса проверяется явно: иначе профиль "test" утащил бы // файлы профиля "test2". func profileConfigFiles(serverDir, profileID string) ([]string, error) { entries, err := os.ReadDir(serverDir) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, err } var out []string for _, e := range entries { if e.IsDir() { continue } name := e.Name() if strings.HasSuffix(name, ".bak") || strings.HasSuffix(name, ".tmp") { continue } rest, ok := strings.CutPrefix(name, profileID) if !ok || rest == "" { continue } if rest[0] != '.' && rest[0] != '_' { continue } out = append(out, filepath.Join(serverDir, name)) } return out, nil } // addFile кладёт один файл в архив, сохраняя путь относительно root. func addFile(tw *tar.Writer, root, filePath string) error { info, err := os.Stat(filePath) if err != nil { return err } rel, err := filepath.Rel(root, filePath) if err != nil { return err } hdr, err := tar.FileInfoHeader(info, "") if err != nil { return err } hdr.Name = filepath.ToSlash(rel) if err := tw.WriteHeader(hdr); err != nil { return err } f, err := os.Open(filePath) if err != nil { return err } defer f.Close() _, err = io.Copy(tw, f) return err } // addTree кладёт директорию в архив, сохраняя пути относительно root. func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(string)) (int64, error) { var count int64 err := filepath.Walk(dir, func(filePath string, info os.FileInfo, err error) error { if err != nil { // Файл мог исчезнуть, пока идёт обход, — это не повод падать. log("Пропускаю " + filePath + ": " + err.Error()) return nil } select { case <-ctx.Done(): return ctx.Err() default: } if !info.Mode().IsRegular() && !info.IsDir() { return nil } rel, err := filepath.Rel(root, filePath) if err != nil { return err } hdr, err := tar.FileInfoHeader(info, "") if err != nil { return err } hdr.Name = filepath.ToSlash(rel) if info.IsDir() { hdr.Name += "/" } if err := tw.WriteHeader(hdr); err != nil { return err } if info.IsDir() { return nil } f, err := os.Open(filePath) if err != nil { // Блокировка файла сервером не должна ронять весь бэкап. log("Не прочитан " + rel + ": " + err.Error()) return nil } defer f.Close() if _, err := io.Copy(tw, f); err != nil { return err } count++ return nil }) return count, err } // Restore распаковывает архив поверх ~/Zomboid. Пути внутри архива уже // содержат имя профиля, поэтому восстановление не зависит от того, какой // профиль сейчас активен. Сервер должен быть остановлен — это проверяет // вызывающий код. func (m *Manager) Restore(ctx context.Context, log func(string), name string) error { archivePath, err := m.Path(name) if err != nil { return err } f, err := os.Open(archivePath) if err != nil { return err } defer f.Close() gz, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("архив повреждён: %w", err) } defer gz.Close() log("Восстанавливаю " + name + " в " + m.cfg.ZomboidDir) tr := tar.NewReader(gz) var restored int for { select { case <-ctx.Done(): return ctx.Err() default: } hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return err } target, err := safeJoin(m.cfg.ZomboidDir, hdr.Name) if err != nil { return err } switch hdr.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, 0o755); err != nil { return err } case tar.TypeReg: if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)) if err != nil { return err } if _, err := io.Copy(out, tr); err != nil { out.Close() return err } out.Close() restored++ } } log(fmt.Sprintf("Восстановлено файлов: %d", restored)) return nil } // Delete удаляет архив. func (m *Manager) Delete(name string) error { archivePath, err := m.Path(name) if err != nil { return err } return os.Remove(archivePath) } // Prune оставляет только keep самых свежих архивов профиля (keep <= 0 — не // трогать). Ротация идёт по профилям отдельно: иначе частые бэкапы активного // сервера вытеснили бы архивы остальных. func (m *Manager) Prune(profileID string, keep int, log func(string)) error { if keep <= 0 { return nil } archives, err := m.ListForProfile(profileID) if err != nil { return err } if len(archives) <= keep { return nil } for _, a := range archives[keep:] { if err := m.Delete(a.Name); err != nil { return err } log("Удалён старый архив " + a.Name) } return nil } // safeJoin не даёт архиву с путями вида ../.. вылезти за пределы root. // Сегменты проверяются до нормализации: path.Clean схлопнул бы "a/../../b" в // безобидный на вид путь и скрыл попытку выхода. Такие пути отвергаются, а не // подгоняются под корень — они означают либо испорченный, либо специально // собранный архив. func safeJoin(root, name string) (string, error) { slashed := filepath.ToSlash(name) if path.IsAbs(slashed) { return "", fmt.Errorf("абсолютный путь в архиве: %s", name) } for _, segment := range strings.Split(slashed, "/") { if segment == ".." { return "", fmt.Errorf("небезопасный путь в архиве: %s", name) } } return filepath.Join(root, filepath.FromSlash(path.Clean(slashed))), nil } // sanitizeNote оставляет от пометки только безопасные для имени файла символы. func sanitizeNote(note string) string { var b strings.Builder for _, r := range note { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) case r == ' ': b.WriteRune('_') } if b.Len() >= 32 { break } } return strings.Trim(b.String(), "_-") } func dirExists(p string) bool { info, err := os.Stat(p) return err == nil && info.IsDir() } func humanSize(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d Б", n) } prefixes := []string{"К", "М", "Г", "Т"} div, exp := int64(unit), 0 for size := n / unit; size >= unit && exp < len(prefixes)-1; size /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %sБ", float64(n)/float64(div), prefixes[exp]) }