backup-master: сервер бэкапов, агент и восстановление VPS

Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-06 21:52:40 +03:00
commit 1d44d067e9
53 files changed
+7278

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
package agent
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
)
// item — что положить в архив: обход Src с именами Name(path) и исключениями Skip.
type item struct {
Src string
Name func(p string) string
Skip *archive.Matcher
}
// Plan — результат сбора: манифест, содержимое частей и временные файлы дампов.
type Plan struct {
Manifest manifest.Manifest
Parts map[string][]item // имя части → элементы; порядок частей — Manifest.Parts
}
type collector struct {
ctx context.Context
cat *catalog.Config
spool string
heavyDue bool
global *archive.Matcher
plan *Plan
}
func (c *collector) warn(format string, a ...any) {
msg := fmt.Sprintf(format, a...)
c.plan.Manifest.Warnings = append(c.plan.Manifest.Warnings, msg)
}
func (c *collector) add(part string, it item) {
if _, ok := c.plan.Parts[part]; !ok {
c.plan.Manifest.Parts = append(c.plan.Manifest.Parts, part)
}
c.plan.Parts[part] = append(c.plan.Parts[part], it)
}
func filesItem(cat, src string, skip *archive.Matcher) item {
prefix := manifest.FilesPrefix(cat)
return item{Src: src, Skip: skip, Name: func(p string) string {
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
}}
}
func binItem(cat, src string) item {
prefix := manifest.BinPrefix(cat)
return item{Src: src, Name: func(p string) string {
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
}}
}
func dumpItem(cat, src, name string) item {
n := manifest.DumpsPrefix(cat) + name
return item{Src: src, Name: func(string) string { return n }}
}
func treeItem(src, prefix string, skip *archive.Matcher) item {
return item{Src: src, Skip: skip, Name: func(p string) string {
rel, err := filepath.Rel(src, p)
if err != nil || rel == "." {
return strings.TrimSuffix(prefix, "/")
}
return prefix + filepath.ToSlash(rel)
}}
}
// Collect собирает план бэкапа по каталогу: выполняет дампы в spool, определяет версии.
func Collect(ctx context.Context, cat *catalog.Config, spool string, heavyDue bool) (*Plan, error) {
c := &collector{
ctx: ctx,
cat: cat,
spool: spool,
heavyDue: heavyDue,
global: archive.NewMatcher(cat.Exclude),
plan: &Plan{Parts: map[string][]item{}},
}
c.plan.Parts[manifest.MainPart] = nil
c.plan.Manifest.Parts = []string{manifest.MainPart}
c.fillSystem()
var composeSpecs []catalog.Spec
for _, s := range cat.Categories {
if s.Disabled {
continue
}
if s.Type == catalog.TypeCompose {
composeSpecs = append(composeSpecs, s)
continue
}
mc, err := c.collectSpec(s)
if err != nil {
return nil, fmt.Errorf("категория %s: %w", s.Name, err)
}
if mc != nil {
c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc)
}
}
if err := c.collectCompose(composeSpecs); err != nil {
return nil, err
}
c.checkCron()
return c.plan, nil
}
func (c *collector) fillSystem() {
m := &c.plan.Manifest
m.FormatVersion = manifest.FormatVersion
m.CreatedAt = time.Now()
if h, err := sysx.Output(c.ctx, "hostnamectl", "hostname"); err == nil && h != "" {
m.Hostname = h
} else {
m.Hostname, _ = os.Hostname()
}
osr := sysx.OSRelease()
m.OS = manifest.OSInfo{ID: osr["ID"], VersionID: osr["VERSION_ID"], PrettyName: osr["PRETTY_NAME"], Arch: sysx.DpkgArch(c.ctx)}
m.Packages = map[string]string{}
for _, p := range []string{"caddy", "telegraf", "docker-ce", "docker-ce-cli", "containerd.io", "docker-compose-plugin", "docker-buildx-plugin"} {
if v := sysx.DpkgVersion(c.ctx, p); v != "" {
m.Packages[p] = v
}
}
m.Features = manifest.Features{
Blocklist: sysx.Exists("/etc/cron.d/ban-blacklist"),
SSHHardened: sysx.Exists("/etc/ssh/sshd_config.d/00-hardening.conf"),
}
m.SudoUsers = sysx.GroupMembers("sudo")
}
// collectSpec обрабатывает категории files/systemd/xui. nil — категория неактивна.
func (c *collector) collectSpec(s catalog.Spec) (*manifest.Category, error) {
if len(s.Detect) > 0 && !anyExists(s.Detect) {
return nil, nil
}
mc := &manifest.Category{
Name: s.Name,
Type: s.Type,
Install: s.Install,
EnsureDirs: s.EnsureDirs,
PostRestore: s.PostRestore,
RestoreAfter: s.RestoreAfter,
}
skip := archive.NewMatcher(c.cat.Exclude, s.Exclude)
for _, p := range s.Paths {
if !sysx.Exists(p) {
continue
}
mc.Paths = append(mc.Paths, p)
c.add(manifest.MainPart, filesItem(s.Name, p, skip))
}
if s.Type == catalog.TypeXUI {
if err := c.snapshotXUI(mc); err != nil {
return nil, err
}
}
for _, d := range s.Dumps {
if err := c.cmdDump(s.Name, d, mc); err != nil {
return nil, err
}
}
if len(mc.Paths) == 0 && len(mc.Dumps) == 0 {
return nil, nil
}
for _, b := range s.KeepBinaries {
if sysx.Exists(b) {
mc.Binaries = append(mc.Binaries, b)
c.add(manifest.MainPart, binItem(s.Name, b))
}
}
if len(s.VersionCmd) > 0 && sysx.Exists(s.VersionCmd[0]) {
mc.Version = c.version(s)
}
if s.Service != "" && sysx.UnitLoaded(c.ctx, s.Service) {
en, ac := sysx.UnitState(c.ctx, s.Service)
mc.Service = &manifest.ServiceState{Unit: s.Service, Enabled: en, Active: ac}
}
return mc, nil
}
var defaultVersionRe = regexp.MustCompile(`v?(\d+\.\d+(?:\.\d+)?[\w.+-]*)`)
func (c *collector) version(s catalog.Spec) string {
ctx, cancel := context.WithTimeout(c.ctx, 15*time.Second)
defer cancel()
out, err := sysx.Output(ctx, s.VersionCmd[0], s.VersionCmd[1:]...)
if err != nil {
c.warn("%s: не удалось определить версию: %v", s.Name, err)
return ""
}
re := defaultVersionRe
if s.VersionRegex != "" {
if r, err := regexp.Compile(s.VersionRegex); err == nil {
re = r
}
}
if m := re.FindStringSubmatch(out); len(m) > 1 {
return m[1]
}
c.warn("%s: версия не распознана в выводе %q", s.Name, out)
return ""
}
func (c *collector) spoolDir(cat string) (string, error) {
d := filepath.Join(c.spool, cat)
return d, os.MkdirAll(d, 0o700)
}
func (c *collector) cmdDump(cat string, d catalog.CmdDump, mc *manifest.Category) error {
dir, err := c.spoolDir(cat)
if err != nil {
return err
}
out := filepath.Join(dir, d.Name)
if err := sysx.ToFile(c.ctx, out, "bash", "-o", "pipefail", "-c", d.Cmd); err != nil {
c.warn("%s: дамп %s не удался: %v", cat, d.Name, err)
return nil
}
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: d.Name, Kind: "cmd", RestoreCmd: d.RestoreCmd})
c.add(manifest.MainPart, dumpItem(cat, out, d.Name))
return nil
}
func anyExists(paths []string) bool {
for _, p := range paths {
if sysx.Exists(p) {
return true
}
}
return false
}
// treeSize считает размер дерева с учётом исключений (для plan).
func treeSize(src string, skip *archive.Matcher) (int64, int) {
var size int64
var n int
_ = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if skip.Match(filepath.ToSlash(p)) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if d.Type().IsRegular() {
if fi, err := d.Info(); err == nil {
size += fi.Size()
n++
}
}
return nil
})
return size, n
}
+462
View File
@@ -0,0 +1,462 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
)
type lsEntry struct {
Name string
Status string
ConfigFiles string
}
func (e lsEntry) files() []string {
var out []string
for _, f := range strings.Split(e.ConfigFiles, ",") {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
func (e lsEntry) dir() string {
if fs := e.files(); len(fs) > 0 {
return filepath.Dir(fs[0])
}
return ""
}
type composeConfig struct {
Name string `json:"name"`
Services map[string]composeService `json:"services"`
Volumes map[string]struct {
Name string `json:"name"`
External bool `json:"external"`
} `json:"volumes"`
Networks map[string]struct {
Name string `json:"name"`
External bool `json:"external"`
} `json:"networks"`
}
type composeService struct {
Image string `json:"image"`
Build json.RawMessage `json:"build"`
Volumes []struct {
Type string `json:"type"`
Source string `json:"source"`
Target string `json:"target"`
} `json:"volumes"`
}
type container struct {
ID string
Service string
Running bool
}
// Пути, которые не имеет смысла бэкапить как bind-mount.
var systemMounts = []string{"/run", "/var/run", "/proc", "/sys", "/dev", "/etc/localtime", "/etc/timezone",
"/var/lib/docker", "/usr", "/lib", "/bin", "/sbin", "/tmp"}
var composeFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"}
var overrideFileNames = []string{"compose.override.yaml", "compose.override.yml", "docker-compose.override.yaml", "docker-compose.override.yml"}
func findComposeFiles(dir string) []string {
var out []string
for _, group := range [][]string{composeFileNames, overrideFileNames} {
for _, n := range group {
if p := filepath.Join(dir, n); sysx.Exists(p) {
out = append(out, p)
break
}
}
}
if len(out) > 0 && !slices.Contains(composeFileNames, filepath.Base(out[0])) {
return nil // только override без основного файла
}
return out
}
func (c *collector) composeLS() []lsEntry {
if !sysx.HasCommand("docker") {
return nil
}
out, err := sysx.Output(c.ctx, "docker", "compose", "ls", "-a", "--format", "json")
if err != nil {
c.warn("docker compose ls: %v", err)
return nil
}
var entries []lsEntry
if err := json.Unmarshal([]byte(out), &entries); err != nil {
c.warn("docker compose ls: %v", err)
}
return entries
}
var nonNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
// collectCompose обрабатывает compose-категории каталога и автонайденные проекты.
func (c *collector) collectCompose(specs []catalog.Spec) error {
entries := c.composeLS()
used := map[string]bool{}
taken := map[string]bool{}
for _, mc := range c.plan.Manifest.Categories {
taken[mc.Name] = true
}
// отключённые категории тоже «занимают» свои проекты, чтобы не попасть в авто
for _, s := range c.cat.Categories {
if s.Type != catalog.TypeCompose || !s.Disabled {
continue
}
for _, e := range entries {
if e.Name == s.Project || (s.Dir != "" && e.dir() == filepath.Clean(s.Dir)) {
used[e.Name] = true
}
}
}
type job struct {
spec catalog.Spec
entry *lsEntry
auto bool
}
var jobs []job
for _, s := range specs {
var match *lsEntry
for i, e := range entries {
if (s.Project != "" && e.Name == s.Project) || (s.Dir != "" && e.dir() == filepath.Clean(s.Dir)) {
match = &entries[i]
used[e.Name] = true
break
}
}
if match == nil && (s.Dir == "" || len(findComposeFiles(s.Dir)) == 0) {
continue // проекта на этом сервере нет
}
jobs = append(jobs, job{spec: s, entry: match})
}
if c.cat.AutoComposeEnabled() {
for i, e := range entries {
if used[e.Name] || e.dir() == "" {
continue
}
name := nonNameRe.ReplaceAllString(e.Name, "-")
if taken[name] {
name = "compose-" + name
}
jobs = append(jobs, job{spec: catalog.Spec{Name: name, Type: catalog.TypeCompose, Dir: e.dir(), Project: e.Name}, entry: &entries[i], auto: true})
}
}
for _, j := range jobs {
mc, err := c.composeProject(j.spec, j.entry)
if err != nil {
return fmt.Errorf("compose %s: %w", j.spec.Name, err)
}
mc.Auto = j.auto
c.plan.Manifest.Categories = append(c.plan.Manifest.Categories, *mc)
}
return nil
}
func (c *collector) composeProject(s catalog.Spec, e *lsEntry) (*manifest.Category, error) {
mc := &manifest.Category{Name: s.Name, Type: catalog.TypeCompose, PostRestore: s.PostRestore, RestoreAfter: s.RestoreAfter}
info := &manifest.Compose{Dir: filepath.Clean(s.Dir), Project: s.Project}
if e != nil {
info.Project = e.Name
info.ConfigFiles = e.files()
if info.Dir == "." || info.Dir == "" {
info.Dir = e.dir()
}
} else {
info.ConfigFiles = findComposeFiles(info.Dir)
}
mc.Compose = info
dir := info.Dir
dirSlash := filepath.ToSlash(dir)
skip := archive.NewMatcher(c.cat.Exclude)
for _, p := range s.Exclude {
skip.Add(absUnder(dirSlash, p))
}
var heavy []string
for _, p := range s.HeavyPaths {
h := absUnder(dirSlash, p)
skip.Add(h) // heavy никогда не попадает в main
if sysx.Exists(h) {
heavy = append(heavy, h)
}
}
cfg, err := c.composeConfig(info)
if err != nil {
c.warn("%s: docker compose config не удался, бэкаплю только каталог: %v", s.Name, err)
}
var containers map[string]container
if cfg != nil {
if info.Project == "" {
info.Project = cfg.Name
}
containers = c.composeContainers(info.Project)
for _, ct := range containers {
if ct.Running {
info.Running = true
}
}
for _, n := range cfg.Networks {
if n.External && n.Name != "" {
info.ExternalNetworks = append(info.ExternalNetworks, n.Name)
}
}
slices.Sort(info.ExternalNetworks)
info.Images = map[string]manifest.Image{}
names := make([]string, 0, len(cfg.Services))
for name := range cfg.Services {
names = append(names, name)
}
slices.Sort(names)
var extraBinds []string
for _, name := range names {
svc := cfg.Services[name]
ct, hasCt := containers[name]
img := manifest.Image{Ref: svc.Image, Built: len(svc.Build) > 0 && string(svc.Build) != "null"}
if hasCt && !img.Built {
img.Digest = c.imageDigest(ct.ID)
}
info.Images[name] = img
kind := dbKind(svc.Image)
skipData := slices.Contains(s.SkipServices, name) || imageName(svc.Image) == "rabbitmq"
dumped := false
if kind != "" {
if hasCt && ct.Running {
if err := c.dbDump(mc, name, kind, ct.ID); err != nil {
return nil, err
}
dumped = true
} else {
c.warn("%s: контейнер БД %s не запущен — каталог данных копируется как есть", s.Name, name)
}
}
for _, v := range svc.Volumes {
switch v.Type {
case "bind":
src := filepath.ToSlash(v.Source)
if dumped || skipData {
skip.Add(src)
continue
}
if !archive.Covers([]string{dirSlash}, src) && !archive.Covers(systemMounts, src) && !strings.HasSuffix(src, "docker.sock") {
extraBinds = append(extraBinds, v.Source)
}
case "volume":
if dumped || skipData || v.Source == "" {
continue
}
vol := cfg.Volumes[v.Source]
real := vol.Name
if real == "" {
real = info.Project + "_" + v.Source
}
key := v.Source
if vol.External {
key = ""
}
c.addVolume(mc, real, key, skip)
}
}
}
for _, b := range extraBinds {
if sysx.Exists(b) && !archive.Covers(mc.Paths, filepath.ToSlash(b)) {
mc.Paths = append(mc.Paths, b)
c.add(manifest.MainPart, filesItem(s.Name, b, skip))
}
}
}
mc.Paths = append([]string{dir}, mc.Paths...)
c.add(manifest.MainPart, filesItem(s.Name, dir, skip))
if len(heavy) > 0 {
mc.Heavy = heavy
if c.heavyDue {
for _, h := range heavy {
c.add(s.Name, filesItem(s.Name, h, archive.NewMatcher(c.cat.Exclude)))
}
} else {
mc.HeavySkipped = true
}
}
info.Git = c.gitInfo(dir)
return mc, nil
}
func absUnder(dir, p string) string {
p = filepath.ToSlash(p)
if strings.HasPrefix(p, "/") {
return p
}
return path.Join(dir, p)
}
func (c *collector) composeArgs(info *manifest.Compose) []string {
args := []string{"compose", "--project-directory", info.Dir}
if info.Project != "" {
args = append(args, "-p", info.Project)
}
for _, f := range info.ConfigFiles {
args = append(args, "-f", f)
}
return args
}
func (c *collector) composeConfig(info *manifest.Compose) (*composeConfig, error) {
if !sysx.HasCommand("docker") {
return nil, fmt.Errorf("docker не установлен")
}
if len(info.ConfigFiles) == 0 {
return nil, fmt.Errorf("в %s нет compose-файла", info.Dir)
}
out, err := sysx.Output(c.ctx, "docker", append(c.composeArgs(info), "config", "--format", "json")...)
if err != nil {
return nil, err
}
var cfg composeConfig
if err := json.Unmarshal([]byte(out), &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *collector) composeContainers(project string) map[string]container {
out, err := sysx.Output(c.ctx, "docker", "ps", "-a", "--filter", "label=com.docker.compose.project="+project,
"--format", `{{.ID}} {{.Label "com.docker.compose.service"}} {{.State}}`)
res := map[string]container{}
if err != nil {
c.warn("docker ps %s: %v", project, err)
return res
}
for _, line := range strings.Split(out, "\n") {
f := strings.Split(line, "\t")
if len(f) != 3 || f[1] == "" {
continue
}
ct := container{ID: f[0], Service: f[1], Running: f[2] == "running"}
// при нескольких репликах важнее запущенная
if prev, ok := res[ct.Service]; !ok || (!prev.Running && ct.Running) {
res[ct.Service] = ct
}
}
return res
}
func (c *collector) imageDigest(containerID string) string {
imgID, err := sysx.Output(c.ctx, "docker", "inspect", "-f", "{{.Image}}", containerID)
if err != nil {
return ""
}
out, err := sysx.Output(c.ctx, "docker", "image", "inspect", "-f", "{{json .RepoDigests}}", imgID)
if err != nil {
return ""
}
var digests []string
if json.Unmarshal([]byte(out), &digests) != nil || len(digests) == 0 {
return ""
}
return digests[0]
}
func (c *collector) addVolume(mc *manifest.Category, name, key string, skip *archive.Matcher) {
for _, v := range mc.Volumes {
if v.Name == name {
return
}
}
mp, err := sysx.Output(c.ctx, "docker", "volume", "inspect", "-f", "{{.Mountpoint}}", name)
if err != nil || mp == "" {
c.warn("%s: том %s не найден", mc.Name, name)
return
}
mc.Volumes = append(mc.Volumes, manifest.Volume{Name: name, Key: key})
c.add(manifest.MainPart, treeItem(mp, manifest.VolumesPrefix(mc.Name)+name+"/", skip))
}
// imageName — последний компонент имени образа без тега и digest: docker.io/library/mongo:7 → mongo.
func imageName(image string) string {
image, _, _ = strings.Cut(image, "@")
if i := strings.LastIndex(image, "/"); i >= 0 {
image = image[i+1:]
}
image, _, _ = strings.Cut(image, ":")
return strings.ToLower(image)
}
func dbKind(image string) string {
switch imageName(image) {
case "postgres", "postgresql", "postgis":
return "postgres"
case "mongo", "mongodb":
return "mongo"
case "mysql":
return "mysql"
case "mariadb":
return "mariadb"
}
return ""
}
// Команды дампа выполняются внутри контейнера: учётные данные берутся из его окружения.
var dumpCmds = map[string]struct{ ext, cmd string }{
"postgres": {"pgdump", `exec pg_dump -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" -Fc`},
"mongo": {"mongo.archive.gz", `if [ -n "$MONGO_INITDB_ROOT_USERNAME" ]; then exec mongodump --quiet --archive --gzip -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else exec mongodump --quiet --archive --gzip; fi`},
"mysql": {"sql", `exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction --routines --events --triggers`},
"mariadb": {"sql", `D=$(command -v mariadb-dump || command -v mysqldump); exec "$D" -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --all-databases --single-transaction --routines --events --triggers`},
}
func (c *collector) dbDump(mc *manifest.Category, service, kind, containerID string) error {
dc := dumpCmds[kind]
dir, err := c.spoolDir(mc.Name)
if err != nil {
return err
}
name := service + "." + dc.ext
out := filepath.Join(dir, name)
if err := sysx.ToFile(c.ctx, out, "docker", "exec", containerID, "sh", "-c", dc.cmd); err != nil {
return fmt.Errorf("дамп %s (%s): %w", service, kind, err)
}
if fi, err := os.Stat(out); err != nil || fi.Size() == 0 {
return fmt.Errorf("дамп %s (%s) пустой", service, kind)
}
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: name, Kind: kind, Service: service})
c.add(manifest.MainPart, dumpItem(mc.Name, out, name))
return nil
}
func (c *collector) gitInfo(dir string) *manifest.GitInfo {
if !sysx.Exists(filepath.Join(dir, ".git")) || !sysx.HasCommand("git") {
return nil
}
git := func(args ...string) string {
out, _ := sysx.Output(c.ctx, "git", append([]string{"-c", "safe.directory=*", "-C", dir}, args...)...)
return out
}
commit := git("rev-parse", "HEAD")
if commit == "" {
return nil
}
return &manifest.GitInfo{Remote: git("remote", "get-url", "origin"), Commit: commit, Dirty: git("status", "--porcelain") != ""}
}
+35
View File
@@ -0,0 +1,35 @@
// Package agent собирает бэкап на клиенте и отправляет его на сервер.
package agent
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
const DefaultConfigPath = "/etc/backup-agent/agent.yaml"
type Config struct {
Server string `yaml:"server"`
Token string `yaml:"token"`
SpoolDir string `yaml:"spool_dir"`
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if c.Server == "" || c.Token == "" {
return nil, fmt.Errorf("%s: нужны server и token", path)
}
if c.SpoolDir == "" {
c.SpoolDir = "/var/tmp/backup-agent"
}
return &c, nil
}
+87
View File
@@ -0,0 +1,87 @@
package agent
import (
"os"
"path/filepath"
"strings"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
)
// Каталоги, скрипты из которых ставятся пакетами и в бэкапе не нужны.
var systemBinDirs = []string{"/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/lib", "/usr/share", "/lib", "/etc/cron.daily",
"/etc/cron.hourly", "/etc/cron.weekly", "/etc/cron.monthly", "/usr/libexec"}
// checkCron ищет в crontab упоминания скриптов, не покрытых ни одной категорией.
func (c *collector) checkCron() {
var files []string
for _, pattern := range []string{"/var/spool/cron/crontabs/*", "/etc/cron.d/*", "/etc/crontab"} {
m, _ := filepath.Glob(pattern)
files = append(files, m...)
}
var covered []string
for _, mc := range c.plan.Manifest.Categories {
for _, p := range mc.Paths {
covered = append(covered, filepath.ToSlash(p))
}
}
reported := map[string]bool{}
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
continue
}
for _, p := range cronPaths(string(data)) {
if reported[p] || archive.Covers(covered, p) || archive.Covers(systemBinDirs, p) {
continue
}
fi, err := os.Stat(p)
if err != nil || !fi.Mode().IsRegular() {
continue
}
reported[p] = true
c.warn("cron (%s) запускает %s, но он не попадает в бэкап — добавьте путь в категорию", f, p)
}
}
}
// cronPaths извлекает абсолютные пути из команд crontab, кроме целей перенаправления вывода.
func cronPaths(text string) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) > 0 && strings.Contains(fields[0], "=") && !strings.HasPrefix(fields[0], "/") {
// присваивание переменной (SHELL=/bin/bash) в отдельной строке
if len(fields) == 1 {
continue
}
}
redirect := false
for _, tok := range fields {
if strings.HasSuffix(tok, ">") || strings.HasSuffix(tok, "<") {
redirect = true
continue
}
if redirect {
redirect = false
continue
}
// "2>/dev/null", ">>file" — перенаправление слитно с целью
if i := strings.IndexAny(tok, "<>"); i >= 0 {
continue
}
if _, v, ok := strings.Cut(tok, "="); ok {
tok = v
}
tok = strings.Trim(tok, `"';&|()`)
if strings.HasPrefix(tok, "/") && !strings.ContainsAny(tok, "*$") {
out = append(out, tok)
}
}
}
return out
}
+44
View File
@@ -0,0 +1,44 @@
package agent
import (
"slices"
"testing"
)
func TestCronPaths(t *testing.T) {
text := `# m h dom mon dow command
SHELL=/bin/bash
17 4 * * * RESTART_CMD='x-ui restart' /usr/local/bin/update-geosite.sh >> /var/log/ipcheck-update.log 2>&1
0 5 * * 1 /var/scripts/update-geodata.sh >> /var/log/update-geodata.log 2>&1
@reboot sleep 60 && bash /usr/local/sbin/ban.sh
0 */6 * * * bash /usr/local/sbin/ban.sh >/dev/null 2>&1
`
got := cronPaths(text)
for _, want := range []string{"/usr/local/bin/update-geosite.sh", "/var/scripts/update-geodata.sh", "/usr/local/sbin/ban.sh"} {
if !slices.Contains(got, want) {
t.Errorf("нет %s в %v", want, got)
}
}
for _, bad := range []string{"/var/log/ipcheck-update.log", "/var/log/update-geodata.log", "/dev/null", "/bin/bash"} {
if slices.Contains(got, bad) {
t.Errorf("лишний путь %s в %v", bad, got)
}
}
}
func TestDBKind(t *testing.T) {
cases := map[string]string{
"postgres:17-alpine": "postgres",
"docker.io/mongo": "mongo",
"docker.io/library/mongo@sha256:abc": "mongo",
"mariadb:11": "mariadb",
"mongo-express": "",
"ghcr.io/stoatchat/api:v0.15.5": "",
"valkey/valkey:9-alpine": "",
}
for img, want := range cases {
if got := dbKind(img); got != want {
t.Errorf("dbKind(%q) = %q, want %q", img, got, want)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
//go:build linux
package agent
import (
"fmt"
"os"
"syscall"
)
// lock не даёт запустить два бэкапа одновременно (таймер + ручной запуск).
func lock(path string) (func(), error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, fmt.Errorf("бэкап уже выполняется (%s)", path)
}
return func() { f.Close() }, nil
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !linux
package agent
func lock(string) (func(), error) { return func() {}, nil }
+356
View File
@@ -0,0 +1,356 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
type Options struct {
ConfigPath string
AgentVersion string
// Output — записать части локально в каталог вместо отправки (для отладки).
Output string
// ForceHeavy — включить heavy-части независимо от расписания.
ForceHeavy bool
}
// prepare загружает конфиг, получает переопределения с сервера и собирает план.
func prepare(ctx context.Context, opts Options, spool string) (*Plan, *api.Client, error) {
cfg, err := LoadConfig(opts.ConfigPath)
if err != nil {
return nil, nil, err
}
client := api.NewClient(cfg.Server, cfg.Token)
remote, err := client.AgentConfig(ctx)
if err != nil {
return nil, nil, fmt.Errorf("конфиг с сервера: %w", err)
}
cat, err := catalog.Default()
if err != nil {
return nil, nil, err
}
if strings.TrimSpace(remote.ConfigYAML) != "" {
over, err := catalog.Parse([]byte(remote.ConfigYAML))
if err != nil {
return nil, nil, fmt.Errorf("конфиг клиента с сервера: %w", err)
}
cat = catalog.Merge(cat, over)
}
if err := cat.Validate(); err != nil {
return nil, nil, err
}
if spool == "" {
spool = cfg.SpoolDir
}
plan, err := Collect(ctx, cat, spool, remote.HeavyDue || opts.ForceHeavy)
if err != nil {
return nil, nil, err
}
plan.Manifest.Domain = remote.Domain
plan.Manifest.AgentVersion = opts.AgentVersion
return plan, client, nil
}
func newSpool(base string) (string, error) {
dir := filepath.Join(base, time.Now().Format("20060102-150405"))
return dir, os.MkdirAll(dir, 0o700)
}
// PrintPlan выводит, что попадёт в бэкап. Дампы БД при этом реально выполняются (во временный каталог).
func PrintPlan(ctx context.Context, opts Options, w io.Writer) error {
cfg, err := LoadConfig(opts.ConfigPath)
if err != nil {
return err
}
spool, err := newSpool(cfg.SpoolDir)
if err != nil {
return err
}
defer os.RemoveAll(spool)
plan, _, err := prepare(ctx, opts, spool)
if err != nil {
return err
}
m := &plan.Manifest
fmt.Fprintf(w, "Домен: %s, hostname: %s, %s (%s)\n", m.Domain, m.Hostname, m.OS.PrettyName, m.OS.Arch)
if len(m.Packages) > 0 {
fmt.Fprintf(w, "Пакеты:")
for k, v := range m.Packages {
fmt.Fprintf(w, " %s=%s", k, v)
}
fmt.Fprintln(w)
}
for _, c := range m.Categories {
line := fmt.Sprintf("\n[%s] %s", c.Name, c.Type)
if c.Auto {
line += " (найден автоматически)"
}
if c.Version != "" {
line += ", версия " + c.Version
}
if c.Service != nil {
line += fmt.Sprintf(", сервис %s enabled=%v active=%v", c.Service.Unit, c.Service.Enabled, c.Service.Active)
}
fmt.Fprintln(w, line)
if c.Compose != nil {
fmt.Fprintf(w, " проект %s, запущен=%v\n", c.Compose.Project, c.Compose.Running)
for svc, img := range c.Compose.Images {
ref := img.Digest
if img.Built {
ref = "собирается из исходников"
} else if ref == "" {
ref = img.Ref + " (digest не определён)"
}
fmt.Fprintf(w, " образ %s: %s\n", svc, ref)
}
}
for _, d := range c.Dumps {
fmt.Fprintf(w, " дамп %s %s\n", d.Kind, d.Name)
}
for _, v := range c.Volumes {
fmt.Fprintf(w, " том %s\n", v.Name)
}
for _, h := range c.Heavy {
note := "в этот раз"
if c.HeavySkipped {
note = "по расписанию, не в этот раз"
}
fmt.Fprintf(w, " heavy %s (%s)\n", h, note)
}
}
fmt.Fprintln(w, "\nСодержимое частей:")
for _, part := range m.Parts {
var total int64
var files int
for _, it := range plan.Parts[part] {
size, n := treeSize(it.Src, it.Skip)
total += size
files += n
}
fmt.Fprintf(w, " %s: %d файлов, %s до сжатия\n", part, files, human(total))
}
if len(m.Warnings) > 0 {
fmt.Fprintln(w, "\nПредупреждения:")
for _, wr := range m.Warnings {
fmt.Fprintln(w, " ! "+wr)
}
}
return nil
}
// Backup выполняет полный цикл: сбор, отправка частей, подтверждение.
func Backup(ctx context.Context, opts Options) (err error) {
unlock, err := lock("/run/backup-agent.lock")
if err != nil {
return err
}
defer unlock()
cfg, err := LoadConfig(opts.ConfigPath)
if err != nil {
return err
}
spool, err := newSpool(cfg.SpoolDir)
if err != nil {
return err
}
defer os.RemoveAll(spool)
start := time.Now()
plan, client, err := prepare(ctx, opts, spool)
if err != nil {
return err
}
m := &plan.Manifest
log.Printf("собрано: %d категорий, части %v, предупреждений %d", len(m.Categories), m.Parts, len(m.Warnings))
for _, w := range m.Warnings {
log.Printf("! %s", w)
}
if len(m.Categories) == 0 {
// пустой «успешный» бэкап хуже ошибки: сервер решит, что всё в порядке
return errors.New("ни одна категория не найдена на сервере — проверьте каталог (backupctl plan)")
}
if opts.Output != "" {
return writeLocal(plan, opts.Output)
}
id, err := client.CreateBackup(ctx, m)
if err != nil {
return fmt.Errorf("создание бэкапа на сервере: %w", err)
}
defer func() {
if err != nil {
fctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if ferr := client.Fail(fctx, id, err.Error()); ferr != nil {
log.Printf("не удалось сообщить серверу об ошибке: %v", ferr)
}
}
}()
var done api.CompleteRequest
for _, part := range m.Parts {
info, warns, err := uploadWithRetry(ctx, client, id, plan, part)
if err != nil {
return fmt.Errorf("часть %s: %w", part, err)
}
done.Parts = append(done.Parts, *info)
done.Warnings = append(done.Warnings, warns...)
log.Printf("часть %s отправлена: %s", part, human(info.Size))
}
for _, w := range done.Warnings {
log.Printf("! %s", w)
}
if err := client.Complete(ctx, id, done); err != nil {
return fmt.Errorf("подтверждение: %w", err)
}
log.Printf("бэкап #%d готов за %s", id, time.Since(start).Round(time.Second))
return nil
}
func uploadWithRetry(ctx context.Context, client *api.Client, id int64, plan *Plan, part string) (*api.PartInfo, []string, error) {
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
if attempt > 1 {
wait := time.Duration(attempt*attempt) * 20 * time.Second
log.Printf("часть %s: попытка %d через %s (%v)", part, attempt, wait, lastErr)
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(wait):
}
}
info, warns, err := uploadPart(ctx, client, id, plan, part)
if err == nil {
return info, warns, nil
}
lastErr = err
var apiErr *api.Error
if errors.As(err, &apiErr) && apiErr.Status >= 400 && apiErr.Status < 500 && apiErr.Status != 408 && apiErr.Status != 429 {
break // повтор не поможет
}
}
return nil, nil, lastErr
}
// uploadPart архивирует часть прямо в тело PUT-запроса, параллельно считая sha256.
func uploadPart(ctx context.Context, client *api.Client, id int64, plan *Plan, part string) (*api.PartInfo, []string, error) {
pr, pw := io.Pipe()
h := sha256.New()
cw := &countWriter{w: io.MultiWriter(pw, h)}
type result struct {
warns []string
err error
}
done := make(chan result, 1)
go func() {
warns, err := writePart(cw, plan, part)
pw.CloseWithError(err)
done <- result{warns, err}
}()
info, err := client.UploadPart(ctx, id, part, pr)
pr.CloseWithError(errors.New("загрузка прервана")) // освобождаем писателя, если сервер ответил раньше
res := <-done
if err != nil {
if res.err != nil && !errors.Is(res.err, io.ErrClosedPipe) && !strings.Contains(res.err.Error(), "загрузка прервана") {
return nil, nil, fmt.Errorf("архивация: %w", res.err)
}
return nil, nil, err
}
if res.err != nil {
return nil, nil, fmt.Errorf("архивация: %w", res.err)
}
warns := res.warns
sum := hex.EncodeToString(h.Sum(nil))
if info.SHA256 != sum || info.Size != cw.n {
return nil, nil, fmt.Errorf("сервер получил %d байт (%s), отправлено %d (%s)", info.Size, info.SHA256, cw.n, sum)
}
return info, warns, nil
}
func writePart(w io.Writer, plan *Plan, part string) ([]string, error) {
aw, err := archive.NewWriter(w)
if err != nil {
return nil, err
}
if part == manifest.MainPart {
data, err := json.MarshalIndent(plan.Manifest, "", " ")
if err != nil {
return nil, err
}
if err := aw.AddBytes(manifest.ManifestFile, data, 0o600); err != nil {
return nil, err
}
}
for _, it := range plan.Parts[part] {
if err := aw.AddTree(it.Src, it.Name, it.Skip); err != nil {
return aw.Warnings, err
}
}
return aw.Warnings, aw.Close()
}
func writeLocal(plan *Plan, dir string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
for _, part := range plan.Manifest.Parts {
p := filepath.Join(dir, part+".tar.zst")
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
warns, err := writePart(f, plan, part)
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return err
}
for _, w := range warns {
log.Printf("! %s", w)
}
log.Printf("записано %s", p)
}
return nil
}
type countWriter struct {
w io.Writer
n int64
}
func (c *countWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
c.n += int64(n)
return n, err
}
func human(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d Б", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp])
}
+47
View File
@@ -0,0 +1,47 @@
package agent
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
_ "modernc.org/sqlite"
)
const xuiDB = "/etc/x-ui/x-ui.db"
// snapshotXUI делает консистентную копию базы 3x-ui на горячую (VACUUM INTO),
// без логина в панель и без остановки сервиса.
func (c *collector) snapshotXUI(mc *manifest.Category) error {
if _, err := os.Stat(xuiDB); err != nil {
c.warn("xui: нет %s", xuiDB)
return nil
}
dir, err := c.spoolDir(mc.Name)
if err != nil {
return err
}
out := filepath.Join(dir, "x-ui.db")
if err := vacuumInto(xuiDB, out); err != nil {
c.warn("xui: снимок базы не удался: %v", err)
return nil
}
mc.Dumps = append(mc.Dumps, manifest.Dump{Name: "x-ui.db", Kind: "sqlite", Target: xuiDB})
c.add(manifest.MainPart, dumpItem(mc.Name, out, "x-ui.db"))
return nil
}
func vacuumInto(src, dst string) error {
_ = os.Remove(dst)
db, err := sql.Open("sqlite", "file:"+src+"?_pragma=busy_timeout(10000)")
if err != nil {
return err
}
defer db.Close()
_, err = db.Exec(fmt.Sprintf("VACUUM INTO '%s'", strings.ReplaceAll(dst, "'", "''")))
return err
}
+161
View File
@@ -0,0 +1,161 @@
// Package api — типы протокола сервер↔агент/restore и HTTP-клиент к нему.
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
type AgentConfig struct {
Domain string `json:"domain"`
ConfigYAML string `json:"config_yaml"`
HeavyDue bool `json:"heavy_due"`
}
type PartInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type CompleteRequest struct {
Parts []PartInfo `json:"parts"`
// Warnings — предупреждения, возникшие при архивации (после отправки манифеста).
Warnings []string `json:"warnings,omitempty"`
}
type RestoreManifest struct {
BackupID int64 `json:"backup_id"`
CreatedAt time.Time `json:"created_at"`
Manifest json.RawMessage `json:"manifest"`
Parts []PartInfo `json:"parts"` // только доступные (heavy могли удалить ротацией)
}
// Client — HTTP-клиент с Bearer-токеном.
type Client struct {
Server string
Token string
HTTP *http.Client
}
func NewClient(server, token string) *Client {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 10 * time.Minute, // complete на сервере может считать ретеншн
ForceAttemptHTTP2: true,
}
return &Client{Server: strings.TrimRight(server, "/"), Token: token, HTTP: &http.Client{Transport: tr}}
}
type Error struct {
Status int
Msg string
}
func (e *Error) Error() string {
return fmt.Sprintf("сервер ответил %d: %s", e.Status, e.Msg)
}
func (c *Client) do(ctx context.Context, method, path string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, c.Server+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil && method != http.MethodPut {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
var e struct{ Error string }
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if json.Unmarshal(data, &e) != nil || e.Error == "" {
e.Error = strings.TrimSpace(string(data))
}
return &Error{Status: resp.StatusCode, Msg: e.Error}
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func jsonBody(v any) io.Reader {
data, _ := json.Marshal(v)
return bytes.NewReader(data)
}
func (c *Client) AgentConfig(ctx context.Context) (*AgentConfig, error) {
var out AgentConfig
return &out, c.do(ctx, http.MethodGet, "/api/v1/agent/config", nil, &out)
}
func (c *Client) CreateBackup(ctx context.Context, m *manifest.Manifest) (int64, error) {
var out struct{ ID int64 }
err := c.do(ctx, http.MethodPost, "/api/v1/backups", jsonBody(m), &out)
return out.ID, err
}
func (c *Client) UploadPart(ctx context.Context, id int64, name string, body io.Reader) (*PartInfo, error) {
var out PartInfo
err := c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/backups/%d/parts/%s", id, name), body, &out)
return &out, err
}
func (c *Client) Complete(ctx context.Context, id int64, req CompleteRequest) error {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/complete", id), jsonBody(req), nil)
}
func (c *Client) Fail(ctx context.Context, id int64, msg string) error {
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/v1/backups/%d/fail", id), jsonBody(map[string]string{"error": msg}), nil)
}
func (c *Client) RestoreManifest(ctx context.Context, backupID int64) (*RestoreManifest, error) {
path := "/api/v1/restore/manifest"
if backupID != 0 {
path += fmt.Sprintf("?backup=%d", backupID)
}
var out RestoreManifest
return &out, c.do(ctx, http.MethodGet, path, nil, &out)
}
// DownloadPart открывает поток части начиная с offset (для докачки).
func (c *Client) DownloadPart(ctx context.Context, id int64, name string, offset int64) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/restore/%d/parts/%s", c.Server, id, name), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
want := http.StatusOK
if offset > 0 {
want = http.StatusPartialContent
}
if resp.StatusCode != want {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
return nil, &Error{Status: resp.StatusCode, Msg: strings.TrimSpace(string(data))}
}
return resp.Body, nil
}
+115
View File
@@ -0,0 +1,115 @@
package archive
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestMatcher(t *testing.T) {
m := NewMatcher([]string{"/opt/stoat/data/db", "/etc/x-ui/x-ui.db*", "*.swp", "/opt/p/pgdata/"})
cases := map[string]bool{
"/opt/stoat/data/db": true,
"/opt/stoat/data/db/WiredTiger": true,
"/opt/stoat/data/dbx": false,
"/etc/x-ui/x-ui.db": true,
"/etc/x-ui/x-ui.db-wal": true,
"/etc/x-ui/config.json": false,
"/var/scripts/.a.sh.swp": true,
"/opt/p/pgdata/base/1": true,
"/opt/p/pgdata2": false,
}
for p, want := range cases {
if got := m.Match(p); got != want {
t.Errorf("Match(%q) = %v, want %v", p, got, want)
}
}
}
func TestRoundtrip(t *testing.T) {
src := t.TempDir()
must(t, os.MkdirAll(filepath.Join(src, "a", "b"), 0o750))
must(t, os.WriteFile(filepath.Join(src, "a", "b", "f.txt"), []byte("hello"), 0o640))
must(t, os.WriteFile(filepath.Join(src, "a", "skip.swp"), []byte("x"), 0o644))
must(t, os.MkdirAll(filepath.Join(src, "a", "pgdata"), 0o700))
must(t, os.WriteFile(filepath.Join(src, "a", "pgdata", "PG_VERSION"), []byte("17"), 0o600))
symlinks := runtime.GOOS != "windows"
if symlinks {
must(t, os.Symlink("b/f.txt", filepath.Join(src, "a", "link")))
}
var buf bytes.Buffer
w, err := NewWriter(&buf)
must(t, err)
must(t, w.AddBytes("manifest.json", []byte(`{"domain":"x.example"}`), 0o644))
skip := NewMatcher([]string{"*.swp", filepath.ToSlash(filepath.Join(src, "a", "pgdata"))})
must(t, w.AddTree(filepath.Join(src, "a"), func(p string) string {
rel, _ := filepath.Rel(src, p)
return "cat/files/" + filepath.ToSlash(rel)
}, skip))
must(t, w.Close())
if len(w.Warnings) > 0 {
t.Fatalf("warnings: %v", w.Warnings)
}
var m struct{ Domain string }
must(t, ReadManifest(bytes.NewReader(buf.Bytes()), &m))
if m.Domain != "x.example" {
t.Fatalf("manifest domain = %q", m.Domain)
}
dst := t.TempDir()
e := &Extractor{Route: func(name string) (string, bool) {
rest, ok := strings.CutPrefix(name, "cat/files/")
if !ok {
return "", false
}
return filepath.Join(dst, filepath.FromSlash(rest)), true
}}
must(t, e.Extract(bytes.NewReader(buf.Bytes())))
data, err := os.ReadFile(filepath.Join(dst, "a", "b", "f.txt"))
must(t, err)
if string(data) != "hello" {
t.Fatalf("content = %q", data)
}
if _, err := os.Stat(filepath.Join(dst, "a", "skip.swp")); !os.IsNotExist(err) {
t.Fatalf("skip.swp должен быть исключён")
}
if _, err := os.Stat(filepath.Join(dst, "a", "pgdata")); !os.IsNotExist(err) {
t.Fatalf("pgdata должен быть исключён")
}
if runtime.GOOS != "windows" {
fi, err := os.Stat(filepath.Join(dst, "a", "b", "f.txt"))
must(t, err)
if fi.Mode().Perm() != 0o640 {
t.Fatalf("mode = %v", fi.Mode().Perm())
}
fi, err = os.Stat(filepath.Join(dst, "a", "b"))
must(t, err)
if fi.Mode().Perm() != 0o750 {
t.Fatalf("dir mode = %v", fi.Mode().Perm())
}
}
if symlinks {
target, err := os.Readlink(filepath.Join(dst, "a", "link"))
must(t, err)
if target != "b/f.txt" {
t.Fatalf("symlink = %q", target)
}
}
// повторная распаковка поверх существующих файлов
e2 := &Extractor{Route: e.Route}
must(t, e2.Extract(bytes.NewReader(buf.Bytes())))
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
+71
View File
@@ -0,0 +1,71 @@
package archive
import (
"path"
"strings"
)
// Matcher проверяет абсолютные пути (со слешами) по списку исключений.
//
// - шаблон без glob-символов — префикс: "/opt/x/pgdata" исключает и сам каталог, и всё внутри;
// - шаблон с "/" и glob-символами — сопоставляется с полным путём ("/etc/x-ui/x-ui.db*");
// - шаблон без "/" — с именем файла ("*.swp").
type Matcher struct {
patterns []string
}
func NewMatcher(patterns ...[]string) *Matcher {
m := &Matcher{}
for _, ps := range patterns {
for _, p := range ps {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if p != "/" {
p = strings.TrimSuffix(p, "/")
}
m.patterns = append(m.patterns, p)
}
}
return m
}
func (m *Matcher) Add(p ...string) {
m.patterns = append(m.patterns, NewMatcher(p).patterns...)
}
func (m *Matcher) Match(abs string) bool {
if m == nil {
return false
}
for _, p := range m.patterns {
if !strings.ContainsAny(p, "*?[") {
if abs == p || strings.HasPrefix(abs, p+"/") {
return true
}
continue
}
if strings.Contains(p, "/") {
if ok, _ := path.Match(p, abs); ok {
return true
}
continue
}
if ok, _ := path.Match(p, path.Base(abs)); ok {
return true
}
}
return false
}
// Covers сообщает, попадает ли abs под один из путей (точное совпадение или вложенность).
func Covers(paths []string, abs string) bool {
for _, p := range paths {
p = strings.TrimSuffix(p, "/")
if abs == p || strings.HasPrefix(abs, p+"/") {
return true
}
}
return false
}
+224
View File
@@ -0,0 +1,224 @@
package archive
import (
"archive/tar"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/klauspost/compress/zstd"
)
// Route отображает имя в архиве на путь назначения. ok=false — запись пропускается.
type Route func(name string) (dest string, ok bool)
type Extractor struct {
Route Route
Warnings []string
Files int
users map[string]int
groups map[string]int
dirs []dirMeta
}
type dirMeta struct {
path string
mode os.FileMode
mtime time.Time
}
// Extract распаковывает zstd-tar из r.
func (e *Extractor) Extract(r io.Reader) error {
zr, err := zstd.NewReader(r)
if err != nil {
return err
}
defer zr.Close()
tr := tar.NewReader(zr)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if strings.Contains(hdr.Name, "..") {
e.warn("пропущено подозрительное имя %q", hdr.Name)
continue
}
dest, ok := e.Route(hdr.Name)
if !ok {
continue
}
if err := e.extractOne(tr, hdr, filepath.Clean(dest)); err != nil {
return fmt.Errorf("%s: %w", hdr.Name, err)
}
}
// права и время каталогов — в конце, иначе запись файлов их перебьёт
for i := len(e.dirs) - 1; i >= 0; i-- {
d := e.dirs[i]
_ = os.Chmod(d.path, d.mode)
_ = os.Chtimes(d.path, d.mtime, d.mtime)
}
return nil
}
func (e *Extractor) warn(format string, a ...any) {
e.Warnings = append(e.Warnings, fmt.Sprintf(format, a...))
}
func (e *Extractor) extractOne(tr *tar.Reader, hdr *tar.Header, dest string) error {
mode := os.FileMode(hdr.Mode).Perm()
// tar хранит suid/sgid/sticky в битах c_ISUID/c_ISGID/c_ISVTX
if hdr.Mode&04000 != 0 {
mode |= os.ModeSetuid
}
if hdr.Mode&02000 != 0 {
mode |= os.ModeSetgid
}
if hdr.Mode&01000 != 0 {
mode |= os.ModeSticky
}
switch hdr.Typeflag {
case tar.TypeDir:
if fi, err := os.Lstat(dest); err == nil && !fi.IsDir() {
if err := os.Remove(dest); err != nil {
return err
}
}
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
e.chown(dest, hdr)
e.dirs = append(e.dirs, dirMeta{dest, mode, hdr.ModTime})
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
// пишем во временный файл и переименовываем: работает и для занятых бинарников
tmp := dest + ".bm-tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
e.chown(tmp, hdr)
if err := os.Chmod(tmp, mode); err != nil {
e.warn("%s: chmod: %v", dest, err)
}
_ = os.Chtimes(tmp, hdr.ModTime, hdr.ModTime)
if fi, err := os.Lstat(dest); err == nil && fi.IsDir() {
if err := os.RemoveAll(dest); err != nil {
os.Remove(tmp)
return err
}
}
if err := os.Rename(tmp, dest); err != nil {
os.Remove(tmp)
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if fi, err := os.Lstat(dest); err == nil {
if fi.IsDir() && fi.Mode()&os.ModeSymlink == 0 {
if err := os.RemoveAll(dest); err != nil {
return err
}
} else if err := os.Remove(dest); err != nil {
return err
}
}
if err := os.Symlink(hdr.Linkname, dest); err != nil {
return err
}
e.chown(dest, hdr)
default:
return nil
}
e.Files++
return nil
}
// chown выставляет владельца по имени из архива (uid на новом сервере может отличаться),
// при отсутствии такого пользователя — по числовому id.
func (e *Extractor) chown(p string, hdr *tar.Header) {
if runtime.GOOS == "windows" {
return
}
uid := e.lookup(&e.users, hdr.Uname, hdr.Uid, func(n string) (string, error) {
u, err := user.Lookup(n)
if err != nil {
return "", err
}
return u.Uid, nil
})
gid := e.lookup(&e.groups, hdr.Gname, hdr.Gid, func(n string) (string, error) {
g, err := user.LookupGroup(n)
if err != nil {
return "", err
}
return g.Gid, nil
})
if err := os.Lchown(p, uid, gid); err != nil {
e.warn("%s: chown: %v", p, err)
}
}
func (e *Extractor) lookup(cache *map[string]int, name string, fallback int, find func(string) (string, error)) int {
if name == "" {
return fallback
}
if *cache == nil {
*cache = map[string]int{}
}
if id, ok := (*cache)[name]; ok {
return id
}
id := fallback
if s, err := find(name); err == nil {
if n, err := strconv.Atoi(s); err == nil {
id = n
}
}
(*cache)[name] = id
return id
}
// ReadManifest читает manifest.json из начала архива (без распаковки остального).
func ReadManifest(r io.Reader, v any) error {
zr, err := zstd.NewReader(r)
if err != nil {
return err
}
defer zr.Close()
tr := tar.NewReader(zr)
hdr, err := tr.Next()
if err != nil {
return err
}
if hdr.Name != "manifest.json" {
return fmt.Errorf("первая запись архива %q, ожидался manifest.json", hdr.Name)
}
return json.NewDecoder(tr).Decode(v)
}
+167
View File
@@ -0,0 +1,167 @@
// Package archive пишет и читает архивы бэкапа: tar (PAX) поверх zstd.
package archive
import (
"archive/tar"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/klauspost/compress/zstd"
)
type Writer struct {
zw *zstd.Encoder
tw *tar.Writer
seen map[string]bool
Files int
Bytes int64
Warnings []string
}
func NewWriter(w io.Writer) (*Writer, error) {
zw, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.SpeedDefault))
if err != nil {
return nil, err
}
return &Writer{zw: zw, tw: tar.NewWriter(zw), seen: map[string]bool{}}, nil
}
func (w *Writer) warn(format string, a ...any) {
w.Warnings = append(w.Warnings, fmt.Sprintf(format, a...))
}
// AddBytes пишет в архив файл из памяти.
func (w *Writer) AddBytes(name string, data []byte, mode int64) error {
hdr := &tar.Header{
Name: name,
Mode: mode,
Size: int64(len(data)),
ModTime: time.Now(),
Typeflag: tar.TypeReg,
Format: tar.FormatPAX,
}
if err := w.tw.WriteHeader(hdr); err != nil {
return err
}
_, err := w.tw.Write(data)
w.seen[name] = true
w.Files++
w.Bytes += hdr.Size
return err
}
// AddFiles добавляет абсолютный путь src (файл или каталог) под именами prefix+<путь без ведущего />.
func (w *Writer) AddFiles(prefix, src string, skip *Matcher) error {
return w.AddTree(src, func(p string) string {
return prefix + strings.TrimPrefix(filepath.ToSlash(p), "/")
}, skip)
}
// AddTree обходит src (не следуя симлинкам) и пишет каждую запись под именем name(path).
// Ошибки отдельных файлов (нет доступа, исчез во время обхода) становятся предупреждениями.
func (w *Writer) AddTree(src string, name func(p string) string, skip *Matcher) error {
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if errors.Is(err, fs.ErrNotExist) && p != src {
return nil
}
w.warn("%s: %v", p, err)
if d != nil && d.IsDir() {
return fs.SkipDir
}
return nil
}
if skip.Match(filepath.ToSlash(p)) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
return w.addEntry(p, name(p))
})
}
func (w *Writer) addEntry(p, name string) error {
fi, err := os.Lstat(p)
if err != nil {
w.warn("%s: %v", p, err)
return nil
}
mode := fi.Mode()
if mode&(fs.ModeSocket|fs.ModeDevice|fs.ModeCharDevice|fs.ModeNamedPipe|fs.ModeIrregular) != 0 {
return nil
}
if fi.IsDir() {
name = strings.TrimSuffix(name, "/") + "/"
}
if w.seen[name] {
return nil
}
var link string
if mode&fs.ModeSymlink != 0 {
if link, err = os.Readlink(p); err != nil {
w.warn("%s: %v", p, err)
return nil
}
}
hdr, err := tar.FileInfoHeader(fi, link)
if err != nil {
w.warn("%s: %v", p, err)
return nil
}
hdr.Name = name
hdr.Format = tar.FormatPAX
var f *os.File
if hdr.Typeflag == tar.TypeReg {
// открываем до заголовка: недоступный файл просто пропускаем
if f, err = os.Open(p); err != nil {
w.warn("%s: %v", p, err)
return nil
}
defer f.Close()
}
if err := w.tw.WriteHeader(hdr); err != nil {
return err
}
w.seen[name] = true
w.Files++
if f == nil {
return nil
}
n, err := io.CopyN(w.tw, f, hdr.Size)
w.Bytes += n
if err != nil {
if !errors.Is(err, io.EOF) {
return fmt.Errorf("%s: %w", p, err)
}
// файл уменьшился во время чтения — добиваем нулями до размера из заголовка
w.warn("%s: файл изменился во время чтения (%d из %d байт)", p, n, hdr.Size)
if _, err := io.CopyN(w.tw, zeroReader{}, hdr.Size-n); err != nil {
return err
}
}
return nil
}
func (w *Writer) Close() error {
if err := w.tw.Close(); err != nil {
w.zw.Close()
return err
}
return w.zw.Close()
}
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
+150
View File
@@ -0,0 +1,150 @@
// Package catalog описывает категории бэкапа. Встроенный каталог (default.yaml)
// покрывает типовой набор софта, конфиг клиента с сервера переопределяет его по имени.
package catalog
import (
_ "embed"
"fmt"
"gopkg.in/yaml.v3"
)
//go:embed default.yaml
var defaultYAML []byte
const (
TypeFiles = "files"
TypeSystemd = "systemd"
TypeXUI = "xui"
TypeCompose = "compose"
)
type Config struct {
// AutoCompose — бэкапить compose-проекты, которых нет в каталоге.
AutoCompose *bool `yaml:"auto_compose,omitempty"`
Exclude []string `yaml:"exclude,omitempty"` // глобальные исключения
Categories []Spec `yaml:"categories"`
}
type Spec struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Disabled bool `yaml:"disabled,omitempty"`
Detect []string `yaml:"detect,omitempty"` // категория активна, если существует хоть один путь
Paths []string `yaml:"paths,omitempty"`
Exclude []string `yaml:"exclude,omitempty"`
Service string `yaml:"service,omitempty"`
VersionCmd []string `yaml:"version_cmd,omitempty"`
VersionRegex string `yaml:"version_regex,omitempty"`
Install string `yaml:"install,omitempty"` // recipe: nebula, xui, sing-box-extended
KeepBinaries []string `yaml:"keep_binaries,omitempty"` // fallback-копии бинарников
EnsureDirs []string `yaml:"ensure_dirs,omitempty"`
PostRestore []string `yaml:"post_restore,omitempty"`
Dumps []CmdDump `yaml:"dumps,omitempty"`
// compose
Dir string `yaml:"dir,omitempty"`
Project string `yaml:"project,omitempty"`
SkipServices []string `yaml:"skip_services,omitempty"`
HeavyPaths []string `yaml:"heavy_paths,omitempty"` // относительно dir (или абсолютные)
RestoreAfter []string `yaml:"restore_after,omitempty"`
}
// CmdDump — произвольный дамп: stdout команды cmd сохраняется в файл name.
type CmdDump struct {
Name string `yaml:"name"`
Cmd string `yaml:"cmd"`
RestoreCmd string `yaml:"restore_cmd,omitempty"`
}
func (c *Config) AutoComposeEnabled() bool { return c.AutoCompose == nil || *c.AutoCompose }
// Default возвращает встроенный каталог.
func Default() (*Config, error) {
var c Config
if err := yaml.Unmarshal(defaultYAML, &c); err != nil {
return nil, fmt.Errorf("встроенный каталог: %w", err)
}
return &c, c.Validate()
}
// Parse разбирает YAML-переопределения клиента.
func Parse(data []byte) (*Config, error) {
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, err
}
return &c, nil
}
// Merge накладывает override на base: категории с тем же именем заменяются целиком,
// новые добавляются в конец, глобальные исключения складываются.
func Merge(base, override *Config) *Config {
out := &Config{AutoCompose: base.AutoCompose}
out.Exclude = append(out.Exclude, base.Exclude...)
out.Categories = append(out.Categories, base.Categories...)
if override == nil {
return out
}
if override.AutoCompose != nil {
out.AutoCompose = override.AutoCompose
}
out.Exclude = append(out.Exclude, override.Exclude...)
for _, s := range override.Categories {
replaced := false
for i := range out.Categories {
if out.Categories[i].Name == s.Name {
// "name + disabled: true" — короткая форма отключения встроенной категории
if s.Disabled && s.Type == "" {
out.Categories[i].Disabled = true
} else {
out.Categories[i] = s
}
replaced = true
break
}
}
if !replaced {
out.Categories = append(out.Categories, s)
}
}
return out
}
func (c *Config) Validate() error {
seen := map[string]bool{}
for _, s := range c.Categories {
if s.Name == "" {
return fmt.Errorf("категория без имени")
}
if seen[s.Name] {
return fmt.Errorf("категория %q описана дважды", s.Name)
}
seen[s.Name] = true
if s.Disabled {
continue
}
switch s.Type {
case TypeFiles, TypeSystemd, TypeXUI:
case TypeCompose:
if s.Dir == "" && s.Project == "" {
return fmt.Errorf("категория %q: для compose нужен dir или project", s.Name)
}
default:
return fmt.Errorf("категория %q: неизвестный тип %q", s.Name, s.Type)
}
}
return nil
}
// Find возвращает категорию по имени.
func (c *Config) Find(name string) *Spec {
for i := range c.Categories {
if c.Categories[i].Name == name {
return &c.Categories[i]
}
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package catalog
import "testing"
func TestDefaultAndMerge(t *testing.T) {
base, err := Default()
if err != nil {
t.Fatal(err)
}
if base.Find("stoat") == nil || base.Find("xui") == nil {
t.Fatal("в каталоге нет stoat/xui")
}
over, err := Parse([]byte(`
categories:
- {name: scripts, disabled: true}
- name: extra
type: files
paths: [/srv/extra]
- name: telegraf
type: files
paths: [/etc/telegraf]
`))
if err != nil {
t.Fatal(err)
}
m := Merge(base, over)
if err := m.Validate(); err != nil {
t.Fatal(err)
}
if !m.Find("scripts").Disabled || len(m.Find("scripts").Paths) == 0 {
t.Error("короткая форма disabled должна сохранять остальные поля")
}
if m.Find("extra") == nil {
t.Error("новая категория не добавлена")
}
if m.Find("telegraf").Type != TypeFiles {
t.Error("категория не заменена")
}
}
+91
View File
@@ -0,0 +1,91 @@
# Встроенный каталог категорий. Переопределяется конфигом клиента в веб-морде:
# категория с тем же name заменяется целиком, `- {name: X, disabled: true}` отключает её.
auto_compose: true
exclude:
- "*.swp"
categories:
- name: system
type: files
paths:
- /etc/ufw/user.rules
- /etc/ufw/user6.rules
- /var/spool/cron/crontabs
- /etc/cron.d/ban-blacklist
- /usr/local/sbin/ban.sh
- /etc/sysctl.d
post_restore:
- "ufw reload || true"
- "sysctl --system >/dev/null || true"
- name: xui
type: xui
detect: [/usr/local/x-ui/x-ui]
paths: [/etc/x-ui, /root/cert]
exclude: ["/etc/x-ui/x-ui.db*"]
service: x-ui
version_cmd: [/usr/local/x-ui/x-ui, -v]
install: xui
keep_binaries: [/usr/local/x-ui, /usr/bin/x-ui, /etc/systemd/system/x-ui.service]
- name: nebula
type: systemd
detect: [/etc/nebula]
paths: [/etc/nebula, /etc/systemd/system/nebula.service]
service: nebula
version_cmd: [/usr/local/bin/nebula, -version]
install: nebula
keep_binaries: [/usr/local/bin/nebula, /usr/local/bin/nebula-cert]
- name: caddy
type: systemd
detect: [/etc/caddy/Caddyfile]
paths: [/etc/caddy, /var/www, /var/lib/caddy/.local/share/caddy]
service: caddy
- name: telegraf
type: systemd
detect: [/etc/telegraf]
paths: [/etc/telegraf, /etc/sudoers.d/telegraf]
service: telegraf
- name: sing-box-warp
type: systemd
detect: [/opt/sing-box-warp]
paths:
- /opt/sing-box-warp
- /etc/sing-box-warp
- /etc/systemd/system/sing-box-warp.service
- /etc/sysctl.d/99-sing-box-warp.conf
service: sing-box-warp
version_cmd: [/usr/local/bin/sing-box, version]
version_regex: 'version\s+(\S+)'
install: sing-box-extended
keep_binaries: [/usr/local/bin/sing-box]
ensure_dirs: [/var/cache/sing-box-warp]
- name: scripts
type: files
paths: [/var/scripts, /usr/local/bin/update-geosite.sh]
- name: pnvpanel
type: compose
dir: /opt/PnvPanel
- name: stoat
type: compose
dir: /opt/stoat
skip_services: [rabbit, redis]
heavy_paths: [data/minio]
- name: stoat-mbot
type: compose
dir: /opt/stoat-mbot
restore_after: [stoat]
- name: psiphon
type: compose
dir: /opt/psiphon
disabled: true
+110
View File
@@ -0,0 +1,110 @@
// Package manifest описывает метаданные бэкапа: что и откуда собрано, какие версии
// стояли на сервере. Манифест лежит первым файлом в main-архиве и отдельно хранится
// на сервере, чтобы restore мог спланировать восстановление до скачивания архива.
package manifest
import "time"
const FormatVersion = 1
// MainPart — имя основной части бэкапа. Остальные части — heavy-категории.
const MainPart = "main"
type Manifest struct {
FormatVersion int `json:"format_version"`
AgentVersion string `json:"agent_version"`
Domain string `json:"domain"`
Hostname string `json:"hostname"`
CreatedAt time.Time `json:"created_at"`
OS OSInfo `json:"os"`
Packages map[string]string `json:"packages,omitempty"` // apt-пакет → точная версия dpkg
Features Features `json:"features"`
SudoUsers []string `json:"sudo_users,omitempty"`
Categories []Category `json:"categories"`
Parts []string `json:"parts"` // main + heavy-части, вошедшие в этот бэкап
Warnings []string `json:"warnings,omitempty"`
}
type OSInfo struct {
ID string `json:"id"`
VersionID string `json:"version_id"`
PrettyName string `json:"pretty_name"`
Arch string `json:"arch"` // как dpkg --print-architecture: amd64, arm64
}
// Features — признаки базовой настройки vps_installer, которые нужно повторить.
type Features struct {
Blocklist bool `json:"blocklist"`
SSHHardened bool `json:"ssh_hardened"`
}
type Category struct {
Name string `json:"name"`
Type string `json:"type"`
Auto bool `json:"auto,omitempty"` // найдена автоматически, в каталоге не описана
Version string `json:"version,omitempty"`
Install string `json:"install,omitempty"` // recipe установки бинарника
Service *ServiceState `json:"service,omitempty"`
Paths []string `json:"paths,omitempty"` // абсолютные пути в <cat>/files/
Binaries []string `json:"binaries,omitempty"` // абсолютные пути в <cat>/bin/ (fallback)
Dumps []Dump `json:"dumps,omitempty"`
Volumes []Volume `json:"volumes,omitempty"`
Heavy []string `json:"heavy,omitempty"` // пути, ушедшие в отдельную часть <cat>
HeavySkipped bool `json:"heavy_skipped,omitempty"`
Compose *Compose `json:"compose,omitempty"`
EnsureDirs []string `json:"ensure_dirs,omitempty"`
PostRestore []string `json:"post_restore,omitempty"`
RestoreAfter []string `json:"restore_after,omitempty"`
}
type ServiceState struct {
Unit string `json:"unit"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
}
// Dump — файл в <cat>/dumps/. Kind: postgres, mongo, mysql, mariadb, sqlite, cmd.
type Dump struct {
Name string `json:"name"`
Kind string `json:"kind"`
Service string `json:"service,omitempty"` // compose-сервис
Target string `json:"target,omitempty"` // sqlite: куда положить файл при восстановлении
RestoreCmd string `json:"restore_cmd,omitempty"` // cmd: команда, получает DUMP_FILE
}
// Volume — именованный docker-том, содержимое в <cat>/volumes/<name>/.
type Volume struct {
Name string `json:"name"` // реальное имя тома в docker
Key string `json:"key"` // ключ в compose-файле
}
type Compose struct {
Project string `json:"project"`
Dir string `json:"dir"`
ConfigFiles []string `json:"config_files"` // абсолютные пути
Running bool `json:"running"`
Images map[string]Image `json:"images,omitempty"` // по имени сервиса
ExternalNetworks []string `json:"external_networks,omitempty"`
Git *GitInfo `json:"git,omitempty"`
}
type Image struct {
Ref string `json:"ref"`
Digest string `json:"digest,omitempty"` // repo@sha256:...
Built bool `json:"built,omitempty"`
}
type GitInfo struct {
Remote string `json:"remote,omitempty"`
Commit string `json:"commit"`
Dirty bool `json:"dirty,omitempty"`
}
// Archive-пути внутри tar.
func FilesPrefix(cat string) string { return cat + "/files/" }
func BinPrefix(cat string) string { return cat + "/bin/" }
func DumpsPrefix(cat string) string { return cat + "/dumps/" }
func VolumesPrefix(cat string) string { return cat + "/volumes/" }
const ManifestFile = "manifest.json"
+186
View File
@@ -0,0 +1,186 @@
package restore
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
)
const pinFile = "compose.restore.yml"
// restoreCompose поднимает compose-проект: закрепляет образы по digest, создаёт
// недостающие external-сети, заливает дампы БД и запускает проект.
func (r *restorer) restoreCompose(c manifest.Category) error {
info := c.Compose
if info == nil {
return fmt.Errorf("нет сведений о compose-проекте")
}
if !sysx.HasCommand("docker") {
return fmt.Errorf("docker не установлен")
}
if len(info.ConfigFiles) == 0 {
return fmt.Errorf("неизвестны compose-файлы проекта")
}
args := []string{"compose", "--project-directory", info.Dir, "-p", info.Project}
for _, f := range info.ConfigFiles {
args = append(args, "-f", f)
}
if pin, err := r.writePinFile(c); err != nil {
return err
} else if pin != "" {
args = append(args, "-f", pin)
r.todo = append(r.todo, fmt.Sprintf("%s: образы закреплены по digest в %s — добавляйте `-f %s` к docker compose, иначе подтянутся новые версии",
c.Name, filepath.Join(info.Dir, pinFile), pinFile))
}
compose := func(extra ...string) error {
return sysx.Run(r.ctx, "docker", append(slices.Clone(args), extra...)...)
}
for _, n := range info.ExternalNetworks {
if _, err := sysx.Output(r.ctx, "docker", "network", "inspect", n); err == nil {
continue
}
if _, err := sysx.Output(r.ctx, "docker", "network", "create", n); err != nil {
return fmt.Errorf("сеть %s: %w", n, err)
}
r.todo = append(r.todo, fmt.Sprintf("%s: external-сеть %s создана пустой — сервис, который её предоставлял, на этом сервере не восстановлен", c.Name, n))
}
if err := compose("pull", "--ignore-buildable", "--quiet"); err != nil {
r.warn("%s: pull: %v", c.Name, err)
}
if hasBuilt(info) {
if err := compose("build"); err != nil {
return fmt.Errorf("сборка образов: %w", err)
}
}
if err := compose("create"); err != nil {
return err
}
for _, d := range c.Dumps {
if d.Service == "" {
continue
}
if err := r.restoreDB(c, d, compose, args); err != nil {
return err
}
}
if info.Running {
if err := compose("up", "-d"); err != nil {
return err
}
} else {
r.warn("%s: на старом сервере проект был остановлен — не запускаю", c.Name)
}
return nil
}
func hasBuilt(info *manifest.Compose) bool {
for _, img := range info.Images {
if img.Built {
return true
}
}
return false
}
// writePinFile пишет compose.restore.yml с образами по digest. Пустая строка — закреплять нечего.
func (r *restorer) writePinFile(c manifest.Category) (string, error) {
var services []string
for name := range c.Compose.Images {
services = append(services, name)
}
slices.Sort(services)
var b strings.Builder
b.WriteString("# Создано backup-master при восстановлении: образы закреплены по digest со старого сервера.\n")
b.WriteString("services:\n")
n := 0
for _, svc := range services {
img := c.Compose.Images[svc]
if img.Built || img.Digest == "" {
continue
}
fmt.Fprintf(&b, " %q:\n image: %q\n", svc, img.Digest)
n++
}
if n == 0 {
return "", nil
}
p := filepath.Join(c.Compose.Dir, pinFile)
return p, os.WriteFile(p, []byte(b.String()), 0o644)
}
var dbReady = map[string]string{
"postgres": `pg_isready -q -h 127.0.0.1 -U "${POSTGRES_USER:-postgres}"`,
"mongo": `(command -v mongosh >/dev/null && mongosh --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok') || mongo --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok'`,
"mysql": `mysqladmin ping -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" --silent`,
"mariadb": `A=$(command -v mariadb-admin || command -v mysqladmin); "$A" ping -h 127.0.0.1 -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --silent`,
}
var dbRestore = map[string]string{
"postgres": `pg_restore -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" --clean --if-exists --no-owner`,
"mongo": `if [ -n "$MONGO_INITDB_ROOT_USERNAME" ]; then exec mongorestore --quiet --archive --gzip --drop -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else exec mongorestore --quiet --archive --gzip --drop; fi`,
"mysql": `exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"`,
"mariadb": `M=$(command -v mariadb || command -v mysql); exec "$M" -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}"`,
}
func (r *restorer) restoreDB(c manifest.Category, d manifest.Dump, compose func(...string) error, args []string) error {
file := filepath.Join(r.staging, c.Name, "dumps", d.Name)
if !exists(file) {
return fmt.Errorf("дамп %s не найден в архиве", d.Name)
}
info("БД %s (%s): запуск", d.Service, d.Kind)
if err := compose("up", "-d", d.Service); err != nil {
return err
}
cid, err := sysx.Output(r.ctx, "docker", append(slices.Clone(args), "ps", "-q", d.Service)...)
if err != nil || cid == "" {
return fmt.Errorf("контейнер %s не найден: %v", d.Service, err)
}
cid = strings.Fields(cid)[0]
if err := r.waitDB(cid, d.Kind); err != nil {
return fmt.Errorf("БД %s не поднялась: %w", d.Service, err)
}
info("БД %s: заливка дампа %s", d.Service, d.Name)
err = sysx.FromFile(r.ctx, file, "docker", "exec", "-i", cid, "sh", "-c", dbRestore[d.Kind])
if err != nil {
if d.Kind == "postgres" {
// pg_restore возвращает 1 и на некритичных предупреждениях — данные обычно на месте
r.warn("%s: pg_restore завершился с предупреждениями, проверьте вывод выше", c.Name)
return nil
}
return fmt.Errorf("заливка дампа %s: %w", d.Name, err)
}
return nil
}
// waitDB ждёт, пока БД стабильно принимает TCP-подключения: init-скрипты образов
// поднимают временный сервер без сети, поэтому проверка через 127.0.0.1.
func (r *restorer) waitDB(cid, kind string) error {
probe := dbReady[kind]
deadline := time.Now().Add(4 * time.Minute)
okInRow := 0
for time.Now().Before(deadline) {
if _, err := sysx.Output(r.ctx, "docker", "exec", cid, "sh", "-c", probe); err == nil {
if okInRow++; okInRow >= 3 {
return nil
}
} else {
okInRow = 0
}
select {
case <-r.ctx.Done():
return r.ctx.Err()
case <-time.After(2 * time.Second):
}
}
return fmt.Errorf("таймаут ожидания")
}
+44
View File
@@ -0,0 +1,44 @@
package restore
import "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
// order сортирует категории так, чтобы restore_after шли раньше зависящих от них.
// Порядок манифеста сохраняется, отсутствующие зависимости игнорируются, при цикле
// оставшиеся категории идут в исходном порядке.
func order(cats []manifest.Category) []manifest.Category {
idx := map[string]int{}
for i, c := range cats {
idx[c.Name] = i
}
done := make([]bool, len(cats))
var out []manifest.Category
for len(out) < len(cats) {
progressed := false
for i, c := range cats {
if done[i] {
continue
}
ready := true
for _, dep := range c.RestoreAfter {
if j, ok := idx[dep]; ok && !done[j] && j != i {
ready = false
break
}
}
if ready {
done[i] = true
out = append(out, c)
progressed = true
}
}
if !progressed {
for i, c := range cats {
if !done[i] {
done[i] = true
out = append(out, c)
}
}
}
}
return out
}
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env bash
#
# Базовая подготовка нового сервера перед восстановлением из backup-master.
# Основано на vps_installer/setup.sh (https://gitea.hsrv.site/mrleo1nid/vps_installer),
# но рассчитано на запуск из `backupctl restore`:
# - все параметры приходят через окружение (их выставляет restore по манифесту);
# - интерактивны только NEW_USER (по умолчанию DEFAULT_USER) и NEW_PASS;
# - apt-пакеты ставятся точных версий со старого сервера, если они есть в репозитории;
# - 3x-ui, Nebula, sing-box ставит restore (нужной версии), здесь их нет;
# - без перезагрузки — о ней напомнит restore в конце.
#
# Окружение:
# NEW_USER, NEW_PASS, DEFAULT_USER, NEW_HOSTNAME,
# INSTALL_BLOCKLIST, BLOCKLIST_URL, HARDEN_SSH (y/n),
# INSTALL_CADDY, CADDY_VERSION, INSTALL_TELEGRAF, TELEGRAF_VERSION,
# INSTALL_DOCKER, DOCKER_CE_VERSION, DOCKER_CLI_VERSION, COMPOSE_VERSION
set -Eeuo pipefail
REPO_RAW="https://gitea.hsrv.site/mrleo1nid/vps_installer/raw/branch/main"
STATUSPAGE_DIR="/var/www/statuspage"
LOG_FILE="/var/log/vps-setup.log"
DEFAULT_BLOCKLIST_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.txt"
BAN_SCRIPT_URL="https://raw.githubusercontent.com/jahlib/somebody-knocked-my-ssh/refs/heads/main/ban.sh"
# 00- — чтобы файл читался раньше 50-cloud-init.conf: sshd берёт ПЕРВОЕ найденное значение
SSHD_HARDENING="/etc/ssh/sshd_config.d/00-hardening.conf"
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
: "${INSTALL_BLOCKLIST:=y}" "${HARDEN_SSH:=y}" "${INSTALL_CADDY:=n}" "${INSTALL_TELEGRAF:=n}" "${INSTALL_DOCKER:=n}"
: "${BLOCKLIST_URL:=$DEFAULT_BLOCKLIST_URL}"
# ---------- вывод ----------
if [[ -t 1 ]]; then
C_RED=$'\e[31m' C_GRN=$'\e[32m' C_YLW=$'\e[33m' C_BLU=$'\e[1;34m' C_RST=$'\e[0m'
else
C_RED='' C_GRN='' C_YLW='' C_BLU='' C_RST=''
fi
step() { echo; echo "${C_BLU}==> $*${C_RST}"; }
info() { echo " $*"; }
ok() { echo "${C_GRN} ✓ $*${C_RST}"; }
warn() { echo "${C_YLW} ! $*${C_RST}"; }
die() { echo "${C_RED}ОШИБКА: $*${C_RST}" >&2; exit 1; }
trap 'echo "${C_RED}Скрипт упал на строке $LINENO: $BASH_COMMAND${C_RST}" >&2' ERR
# ---------- ввод (через /dev/tty) ----------
TTY=/dev/tty
have_tty() { [[ -r $TTY && -w $TTY ]] && { : <"$TTY"; } 2>/dev/null; }
ask() { # ask VAR "вопрос" [default]
local __var=$1 __q=$2 __def=${3:-} __ans
[[ -n ${!__var:-} ]] && return 0
have_tty || die "Нет терминала для вопроса «$__q». Задайте переменную $__var."
if [[ -n $__def ]]; then
read -r -p "$__q [$__def]: " __ans <"$TTY"
__ans=${__ans:-$__def}
else
read -r -p "$__q: " __ans <"$TTY"
fi
printf -v "$__var" '%s' "$__ans"
}
ask_password() { # ask_password VAR
local __var=$1 p1 p2
[[ -n ${!__var:-} ]] && return 0
have_tty || die "Нет терминала для ввода пароля. Задайте переменную $__var."
while true; do
read -r -s -p "Пароль для $NEW_USER: " p1 <"$TTY"; echo >"$TTY"
read -r -s -p "Повторите пароль: " p2 <"$TTY"; echo >"$TTY"
if [[ -z $p1 ]]; then echo "Пароль не может быть пустым." >"$TTY"
elif [[ $p1 != "$p2" ]]; then echo "Пароли не совпадают, ещё раз." >"$TTY"
elif (( ${#p1} < 8 )); then echo "Слишком короткий, минимум 8 символов." >"$TTY"
else break
fi
done
printf -v "$__var" '%s' "$p1"
}
apt_install() {
apt-get install -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@"
}
# pkg_spec PKG VERSION — "pkg=version", если такая версия есть в репозитории, иначе "pkg"
pkg_spec() {
local pkg=$1 ver=${2:-}
if [[ -n $ver ]] && apt-cache madison "$pkg" 2>/dev/null | awk -F'|' '{gsub(/ /,"",$2); print $2}' | grep -qxF "$ver"; then
echo "$pkg=$ver"
else
[[ -n $ver ]] && warn "$pkg $ver нет в репозитории — будет установлена последняя версия" >&2
echo "$pkg"
fi
}
# ---------- проверки ----------
[[ $EUID -eq 0 ]] || die "Нужен root"
. /etc/os-release
[[ ${ID:-} == ubuntu ]] || die "Скрипт рассчитан на Ubuntu (обнаружено: ${PRETTY_NAME:-?})"
[[ ${VERSION_ID:-} == 24.04 ]] || warn "Скрипт писался под Ubuntu 24.04, у вас ${PRETTY_NAME}. Продолжаю."
ARCH=$(dpkg --print-architecture)
CLIENT_IP=""
if [[ -n ${SSH_CLIENT:-} ]]; then CLIENT_IP=${SSH_CLIENT%% *}
else CLIENT_IP=$(who am i 2>/dev/null | sed -nE 's/.*\(([0-9a-fA-F.:]+)\).*/\1/p' || true)
fi
# ---------- параметры ----------
step "Параметры подготовки"
while true; do
ask NEW_USER "Логин пользователя с sudo" "${DEFAULT_USER:-}"
if [[ $NEW_USER =~ ^[a-z_][a-z0-9_-]{0,31}$ && $NEW_USER != root ]]; then break; fi
echo "Недопустимый логин (строчные латинские буквы, цифры, _ и -; не root)."
NEW_USER=""
done
USER_EXISTS=n
if id "$NEW_USER" &>/dev/null; then
USER_EXISTS=y
info "Пользователь $NEW_USER уже есть — пароль не меняю (задайте NEW_PASS, чтобы сменить)"
else
ask_password NEW_PASS
fi
[[ -n ${NEW_HOSTNAME:-} ]] || ask NEW_HOSTNAME "Имя машины (hostname)" "$(hostname)"
NEW_HOSTNAME=${NEW_HOSTNAME,,}
touch "$LOG_FILE" && chmod 600 "$LOG_FILE"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "===== $(date '+%F %T') backup-master provision start =====" >>"$LOG_FILE"
# ---------- 1. Обновление системы ----------
step "1. Обновление системы"
apt-get update -q
apt-get -y -q -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade
apt_install unattended-upgrades curl ca-certificates gnupg
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
ok "Система обновлена, автообновления безопасности включены"
# ---------- 2. Пользователь с sudo ----------
step "2. Пользователь $NEW_USER"
if [[ $USER_EXISTS == n ]]; then
useradd -m -s /bin/bash "$NEW_USER"
ok "Пользователь создан"
fi
if [[ -n ${NEW_PASS:-} ]]; then
echo "$NEW_USER:$NEW_PASS" | chpasswd
unset NEW_PASS
fi
usermod -aG sudo "$NEW_USER"
SUDOERS_FILE="/etc/sudoers.d/$NEW_USER"
echo "$NEW_USER ALL=(ALL) NOPASSWD:ALL" >"$SUDOERS_FILE.tmp"
chmod 440 "$SUDOERS_FILE.tmp"
if visudo -c -f "$SUDOERS_FILE.tmp" >/dev/null; then
mv "$SUDOERS_FILE.tmp" "$SUDOERS_FILE"
ok "sudo без пароля ($SUDOERS_FILE)"
else
rm -f "$SUDOERS_FILE.tmp"
die "visudo отверг файл sudoers"
fi
# ---------- 3. Имя машины ----------
step "3. Имя машины: $NEW_HOSTNAME"
SHORT_HOSTNAME=${NEW_HOSTNAME%%.*}
hostnamectl set-hostname "$NEW_HOSTNAME"
HOSTS_LINE="127.0.1.1 $NEW_HOSTNAME"
[[ $SHORT_HOSTNAME != "$NEW_HOSTNAME" ]] && HOSTS_LINE+=" $SHORT_HOSTNAME"
if grep -q '^127\.0\.1\.1[[:space:]]' /etc/hosts; then
sed -i "s/^127\.0\.1\.1[[:space:]].*/$HOSTS_LINE/" /etc/hosts
else
echo "$HOSTS_LINE" >>/etc/hosts
fi
if [[ -d /etc/cloud/cloud.cfg.d ]]; then
echo "preserve_hostname: true" >/etc/cloud/cloud.cfg.d/99-preserve-hostname.cfg
fi
ok "hostname: $(hostname)"
# ---------- 4. iptables legacy ----------
step "4. Переключение на iptables-legacy"
apt_install iptables ipset
for t in iptables ip6tables arptables ebtables; do
if [[ -x /usr/sbin/$t-legacy ]] && update-alternatives --list "$t" &>/dev/null; then
update-alternatives --set "$t" "/usr/sbin/$t-legacy" >/dev/null
fi
done
iptables --version | grep -q legacy || die "iptables не переключился на legacy"
ok "$(iptables --version)"
# ---------- 5. Fail2ban ----------
step "5. Fail2ban"
apt_install fail2ban python3-systemd
IGNORE_IP="127.0.0.1/8 ::1 192.168.0.0/16 10.0.0.0/8"
if [[ -n $CLIENT_IP ]]; then
IGNORE_IP+=" $CLIENT_IP"
info "Ваш текущий IP $CLIENT_IP добавлен в ignoreip"
fi
cat >/etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
# свои IP, чтобы не забанить себя
ignoreip = $IGNORE_IP
[sshd]
enabled = true
backend = systemd
EOF
systemctl enable fail2ban >/dev/null 2>&1
systemctl restart fail2ban
ok "Fail2ban включён"
# ---------- 6. UFW ----------
# правила со старого сервера восстановит категория system (ufw reload после распаковки)
step "6. Firewall (UFW)"
apt_install ufw
ufw default deny incoming >/dev/null
ufw default allow outgoing >/dev/null
ufw allow OpenSSH >/dev/null
mkdir -p /run/sshd
SSH_PORT=$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}' || true)
if [[ -n $SSH_PORT && $SSH_PORT != 22 ]]; then
ufw allow "$SSH_PORT/tcp" >/dev/null
fi
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ufw --force enable >/dev/null
ok "UFW включён: SSH, 80, 443"
# ---------- 7. Блокировка IP по списку ----------
if [[ $INSTALL_BLOCKLIST == y ]]; then
step "7. Блокировка IP по внешнему списку"
curl -fsSL "$BAN_SCRIPT_URL" -o /usr/local/sbin/ban.sh
chmod +x /usr/local/sbin/ban.sh
if [[ $BLOCKLIST_URL != "$DEFAULT_BLOCKLIST_URL" ]]; then
ESCAPED_URL=$(printf '%s' "$BLOCKLIST_URL" | sed 's/[&|\\]/\\&/g')
sed -i "s|^URL=.*|URL=\"$ESCAPED_URL\"|" /usr/local/sbin/ban.sh
fi
bash /usr/local/sbin/ban.sh || warn "ban.sh завершился с ошибкой"
cat >/etc/cron.d/ban-blacklist <<'EOF'
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@reboot root sleep 60 && bash /usr/local/sbin/ban.sh >/dev/null 2>&1
0 */6 * * * root bash /usr/local/sbin/ban.sh >/dev/null 2>&1
EOF
chmod 644 /etc/cron.d/ban-blacklist
ok "ipset blacklist настроен"
else
step "7. Блокировка IP — пропущено"
fi
# ---------- 8. Статус-страница ----------
step "8. Статус-страница $STATUSPAGE_DIR"
if [[ -f $STATUSPAGE_DIR/index.html ]]; then
info "уже есть — не трогаю"
else
install -d -m 755 "$STATUSPAGE_DIR"
curl -fsSL "$REPO_RAW/statuspage/index.html" -o "$STATUSPAGE_DIR/index.html" || warn "статус-страница не скачалась"
chmod 644 "$STATUSPAGE_DIR/index.html" 2>/dev/null || true
fi
# ---------- 9. Docker ----------
if [[ $INSTALL_DOCKER == y ]]; then
step "9. Docker + Docker Compose"
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
dpkg -s "$pkg" &>/dev/null && apt-get remove -y -q "$pkg"
done
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$ARCH signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $VERSION_CODENAME stable" \
>/etc/apt/sources.list.d/docker.list
apt-get update -q
apt_install --allow-downgrades \
"$(pkg_spec docker-ce "${DOCKER_CE_VERSION:-}")" \
"$(pkg_spec docker-ce-cli "${DOCKER_CLI_VERSION:-}")" \
containerd.io docker-buildx-plugin \
"$(pkg_spec docker-compose-plugin "${COMPOSE_VERSION:-}")"
systemctl enable --now docker.service containerd.service >/dev/null 2>&1
usermod -aG docker "$NEW_USER"
ok "$(docker --version), $(docker compose version | head -n1)"
else
step "9. Docker — пропущено"
fi
# ---------- 10. Telegraf ----------
if [[ $INSTALL_TELEGRAF == y ]]; then
step "10. Telegraf"
mkdir -p /etc/apt/keyrings
curl -fsSL https://repos.influxdata.com/influxdata-archive.key \
| gpg --batch --yes --dearmor -o /etc/apt/keyrings/influxdata.gpg
echo "deb [signed-by=/etc/apt/keyrings/influxdata.gpg] https://repos.influxdata.com/ubuntu stable main" \
>/etc/apt/sources.list.d/influxdata.list
apt-get update -q
apt_install --allow-downgrades "$(pkg_spec telegraf "${TELEGRAF_VERSION:-}")" influxdata-archive-keyring
# запуск — после восстановления конфига, по состоянию со старого сервера
systemctl disable --now telegraf >/dev/null 2>&1 || true
ok "$(telegraf --version) установлен"
else
step "10. Telegraf — пропущено"
fi
# ---------- 11. Caddy ----------
if [[ $INSTALL_CADDY == y ]]; then
step "11. Caddy"
apt_install debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
>/etc/apt/sources.list.d/caddy-stable.list
apt-get update -q
apt_install --allow-downgrades "$(pkg_spec caddy "${CADDY_VERSION:-}")"
ok "Caddy $(caddy version | awk '{print $1}') установлен"
else
step "11. Caddy — пропущено"
fi
# ---------- 12. Отключение доступа root ----------
if [[ $HARDEN_SSH == y ]]; then
step "12. Отключение доступа root"
cat >"$SSHD_HARDENING" <<'EOF'
PermitRootLogin no
PasswordAuthentication yes
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
mkdir -p /run/sshd
sshd -t || { rm -f "$SSHD_HARDENING"; die "sshd -t нашёл ошибку в конфигурации"; }
systemctl restart ssh
passwd -l root >/dev/null
ok "Вход root по SSH запрещён, пароль root заблокирован"
else
step "12. Отключение доступа root — пропущено"
fi
ok "Базовая подготовка завершена"
+240
View File
@@ -0,0 +1,240 @@
package restore
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// recipe ставит бинарник нужной версии под архитектуру arch (amd64/arm64).
type recipe func(ctx context.Context, version, arch string) error
var recipes = map[string]recipe{
"nebula": installNebula,
"xui": installXUI,
"sing-box-extended": installSingBoxExtended,
}
func installNebula(ctx context.Context, version, arch string) error {
v := strings.TrimPrefix(version, "v")
url := fmt.Sprintf("https://github.com/slackhq/nebula/releases/download/v%s/nebula-linux-%s.tar.gz", v, arch)
return fetchTarGz(ctx, url, func(name string) (string, bool) {
switch filepath.Base(name) {
case "nebula", "nebula-cert":
return "/usr/local/bin/" + filepath.Base(name), true
}
return "", false
})
}
// installXUI ставит 3x-ui из release-архива нужного тега — без интерактивного install.sh.
// Настройки панели (порт, путь, логин) приходят из восстановленной x-ui.db.
func installXUI(ctx context.Context, version, arch string) error {
v := version
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
url := fmt.Sprintf("https://github.com/MHSanaei/3x-ui/releases/download/%s/x-ui-linux-%s.tar.gz", v, arch)
_ = os.RemoveAll("/usr/local/x-ui")
if err := fetchTarGz(ctx, url, func(name string) (string, bool) {
name = strings.TrimPrefix(name, "./")
if !strings.HasPrefix(name, "x-ui/") && name != "x-ui" {
return "", false
}
return "/usr/local/" + name, true
}); err != nil {
return err
}
for _, p := range []string{"/usr/local/x-ui/x-ui"} {
_ = os.Chmod(p, 0o755)
}
if bins, _ := filepath.Glob("/usr/local/x-ui/bin/xray-linux-*"); len(bins) > 0 {
for _, b := range bins {
_ = os.Chmod(b, 0o755)
}
}
// в разных версиях юнит называется по-разному
var unit string
for _, n := range []string{"x-ui.service.debian", "x-ui.service"} {
if p := "/usr/local/x-ui/" + n; exists(p) {
unit = p
break
}
}
if unit == "" {
return errors.New("в архиве 3x-ui нет x-ui.service")
}
if err := copyFile(unit, "/etc/systemd/system/x-ui.service", 0o644); err != nil {
return err
}
// консольная утилита x-ui того же тега (не критично)
script := fmt.Sprintf("https://raw.githubusercontent.com/MHSanaei/3x-ui/%s/x-ui.sh", v)
if err := download(ctx, script, "/usr/bin/x-ui", 0o755); err != nil {
fmt.Printf(" ! x-ui.sh не скачан: %v\n", err)
}
return nil
}
func installSingBoxExtended(ctx context.Context, version, arch string) error {
v := strings.TrimPrefix(version, "v")
url := fmt.Sprintf("https://gitea.digitalforest.my/hesoyam/sing-box-extended-mirror/releases/download/v%s/sing-box-%s-linux-%s.tar.gz", v, v, arch)
return fetchTarGz(ctx, url, func(name string) (string, bool) {
if filepath.Base(name) == "sing-box" {
return "/usr/local/bin/sing-box", true
}
return "", false
})
}
var httpClient = &http.Client{Timeout: 10 * time.Minute}
func download(ctx context.Context, url, dest string, mode os.FileMode) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", url, resp.Status)
}
return writeAtomic(dest, resp.Body, mode)
}
// fetchTarGz скачивает tar.gz и распаковывает выбранные записи.
func fetchTarGz(ctx context.Context, url string, route func(name string) (string, bool)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", url, resp.Status)
}
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return fmt.Errorf("%s: %w", url, err)
}
tr := tar.NewReader(gz)
found := 0
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("%s: %w", url, err)
}
if strings.Contains(hdr.Name, "..") {
continue
}
dest, ok := route(hdr.Name)
if !ok {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := writeAtomic(dest, tr, os.FileMode(hdr.Mode).Perm()|0o400); err != nil {
return err
}
found++
case tar.TypeSymlink:
_ = os.Remove(dest)
if err := os.Symlink(hdr.Linkname, dest); err != nil {
return err
}
}
}
if found == 0 {
return fmt.Errorf("%s: нужные файлы в архиве не найдены", url)
}
return nil
}
func writeAtomic(dest string, r io.Reader, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
tmp := dest + ".bm-tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Chmod(tmp, mode); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dest)
}
func copyFile(src, dest string, mode os.FileMode) error {
f, err := os.Open(src)
if err != nil {
return err
}
defer f.Close()
return writeAtomic(dest, f, mode)
}
// copyTree копирует дерево src в dest с правами и симлинками (fallback-бинарники из бэкапа).
func copyTree(src, dest string) error {
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, p)
target := filepath.Join(dest, rel)
fi, err := os.Lstat(p)
if err != nil {
return err
}
switch {
case fi.IsDir():
return os.MkdirAll(target, fi.Mode().Perm()|0o700)
case fi.Mode()&os.ModeSymlink != 0:
link, err := os.Readlink(p)
if err != nil {
return err
}
_ = os.Remove(target)
return os.Symlink(link, target)
case fi.Mode().IsRegular():
return copyFile(p, target, fi.Mode().Perm())
}
return nil
})
}
func exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
+552
View File
@@ -0,0 +1,552 @@
// Package restore восстанавливает сервер из бэкапа: базовая подготовка, установка
// тех же версий софта, раскладка файлов и данных, запуск сервисов.
package restore
import (
"bufio"
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
)
//go:embed provision.sh
var provisionScript []byte
type Options struct {
Server string
Token string
BackupID int64
User string // пользователь с sudo на новом сервере
SkipProvision bool
SkipHeavy bool
Yes bool // не спрашивать подтверждение
StagingDir string
KeepStaging bool
}
type restorer struct {
ctx context.Context
opts Options
client *api.Client
m *manifest.Manifest
backupID int64
staging string
arch string
volumes map[string]string // имя тома → mountpoint
warnings []string
todo []string
}
func (r *restorer) warn(format string, a ...any) {
msg := fmt.Sprintf(format, a...)
r.warnings = append(r.warnings, msg)
fmt.Println(" ! " + msg)
}
func step(format string, a ...any) { fmt.Printf("\n==> "+format+"\n", a...) }
func info(format string, a ...any) { fmt.Printf(" "+format+"\n", a...) }
func Run(ctx context.Context, opts Options) error {
if os.Geteuid() != 0 {
return errors.New("restore нужно запускать от root")
}
if opts.Token == "" {
return errors.New("нужен --token (команда восстановления выдаётся в веб-морде)")
}
r := &restorer{ctx: ctx, opts: opts, client: api.NewClient(opts.Server, opts.Token), volumes: map[string]string{}}
rm, err := r.client.RestoreManifest(ctx, opts.BackupID)
if err != nil {
return fmt.Errorf("манифест: %w", err)
}
var m manifest.Manifest
if err := json.Unmarshal(rm.Manifest, &m); err != nil {
return fmt.Errorf("манифест: %w", err)
}
r.m, r.backupID = &m, rm.BackupID
r.arch = sysx.DpkgArch(ctx)
r.summary(rm)
if !opts.Yes && !confirm("Восстановить этот бэкап на ТЕКУЩИЙ сервер? Файлы будут перезаписаны") {
return errors.New("отменено")
}
r.staging = opts.StagingDir
if r.staging == "" {
r.staging = "/var/tmp/backup-restore"
}
r.staging = filepath.Join(r.staging, fmt.Sprint(rm.BackupID))
if err := os.MkdirAll(r.staging, 0o700); err != nil {
return err
}
if !opts.KeepStaging {
defer os.RemoveAll(r.staging) // там дампы баз и ключи
}
if !opts.SkipProvision {
if err := r.provision(); err != nil {
return fmt.Errorf("подготовка сервера: %w", err)
}
}
if err := r.prepareVolumes(); err != nil {
return err
}
parts := map[string]api.PartInfo{}
for _, p := range rm.Parts {
parts[p.Name] = p
}
main, ok := parts[manifest.MainPart]
if !ok {
return errors.New("в бэкапе нет основной части")
}
step("Распаковка основной части (%s)", human(main.Size))
if err := r.extractPart(main); err != nil {
return err
}
for _, name := range m.Parts {
if name == manifest.MainPart {
continue
}
p, ok := parts[name]
switch {
case !ok:
r.warn("heavy-часть %s удалена ротацией — данные категории %s останутся пустыми", name, name)
case opts.SkipHeavy:
r.warn("heavy-часть %s пропущена (--skip-heavy)", name)
default:
step("Распаковка heavy-части %s (%s)", name, human(p.Size))
if err := r.extractPart(p); err != nil {
r.warn("heavy-часть %s: %v", name, err)
}
}
}
// heavy-категории, не попавшие в этот бэкап
for _, c := range m.Categories {
if c.HeavySkipped {
r.warn("%s: heavy-данные (%s) в этот бэкап не входили — выберите бэкап с heavy-частью, если они нужны", c.Name, strings.Join(c.Heavy, ", "))
}
}
_ = sysx.Run(ctx, "systemctl", "daemon-reload")
var failed []string
for _, c := range order(m.Categories) {
step("Категория %s (%s)", c.Name, c.Type)
if err := r.restoreCategory(c); err != nil {
r.warn("%s: %v", c.Name, err)
failed = append(failed, c.Name)
}
}
r.finish(failed)
if len(failed) > 0 {
return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", "))
}
return nil
}
func (r *restorer) summary(rm *api.RestoreManifest) {
m := r.m
step("Бэкап #%d %s от %s", rm.BackupID, m.Domain, rm.CreatedAt.Local().Format("02.01.2006 15:04"))
info("старый сервер: %s, %s (%s)", m.Hostname, m.OS.PrettyName, m.OS.Arch)
osr := sysx.OSRelease()
info("этот сервер: %s (%s)", osr["PRETTY_NAME"], r.arch)
if m.OS.Arch != "" && r.arch != "" && m.OS.Arch != r.arch {
r.warn("архитектура отличается (%s → %s): fallback-бинарники из бэкапа не подойдут", m.OS.Arch, r.arch)
}
if osr["VERSION_ID"] != "" && m.OS.VersionID != "" && osr["VERSION_ID"] != m.OS.VersionID {
r.warn("версия ОС отличается: %s → %s", m.OS.VersionID, osr["VERSION_ID"])
}
for _, c := range m.Categories {
line := c.Name + " (" + c.Type
if c.Version != "" {
line += " " + c.Version
}
line += ")"
if c.Compose != nil {
line += " " + c.Compose.Dir
}
info("• %s", line)
}
var parts []string
for _, p := range rm.Parts {
parts = append(parts, fmt.Sprintf("%s %s", p.Name, human(p.Size)))
}
info("части: %s", strings.Join(parts, ", "))
}
func confirm(q string) bool {
fmt.Printf("\n%s? [y/N]: ", q)
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes", "д", "да":
return true
}
return false
}
func (r *restorer) hasCategory(name string) bool {
return slices.ContainsFunc(r.m.Categories, func(c manifest.Category) bool { return c.Name == name })
}
func yn(b bool) string {
if b {
return "y"
}
return "n"
}
func (r *restorer) provision() error {
step("Базовая подготовка сервера")
m := r.m
hasCompose := slices.ContainsFunc(m.Categories, func(c manifest.Category) bool { return c.Type == catalog.TypeCompose })
hostname := m.Hostname
if hostname == "" || hostname == "localhost" {
hostname = m.Domain
}
env := []string{
"NEW_HOSTNAME=" + hostname,
"INSTALL_BLOCKLIST=" + yn(m.Features.Blocklist),
"HARDEN_SSH=" + yn(m.Features.SSHHardened),
"INSTALL_DOCKER=" + yn(hasCompose || m.Packages["docker-ce"] != ""),
"DOCKER_CE_VERSION=" + m.Packages["docker-ce"],
"DOCKER_CLI_VERSION=" + m.Packages["docker-ce-cli"],
"COMPOSE_VERSION=" + m.Packages["docker-compose-plugin"],
"INSTALL_CADDY=" + yn(m.Packages["caddy"] != "" || r.hasCategory("caddy")),
"CADDY_VERSION=" + m.Packages["caddy"],
"INSTALL_TELEGRAF=" + yn(m.Packages["telegraf"] != "" || r.hasCategory("telegraf")),
"TELEGRAF_VERSION=" + m.Packages["telegraf"],
}
if len(m.SudoUsers) > 0 {
env = append(env, "DEFAULT_USER="+m.SudoUsers[0])
}
if r.opts.User != "" {
env = append(env, "NEW_USER="+r.opts.User)
}
script := filepath.Join(r.staging, "provision.sh")
if err := os.WriteFile(script, provisionScript, 0o700); err != nil {
return err
}
cmd := exec.CommandContext(r.ctx, "bash", script)
cmd.Env = append(os.Environ(), env...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}
// prepareVolumes создаёт docker-тома заранее (с метками compose), чтобы распаковать данные прямо в них.
func (r *restorer) prepareVolumes() error {
var need bool
for _, c := range r.m.Categories {
if len(c.Volumes) > 0 {
need = true
}
}
if !need {
return nil
}
if !sysx.HasCommand("docker") {
return errors.New("в бэкапе есть docker-тома, но docker не установлен (запустите без --skip-provision)")
}
step("Docker-тома")
for _, c := range r.m.Categories {
for _, v := range c.Volumes {
if _, err := sysx.Output(r.ctx, "docker", "volume", "inspect", v.Name); err != nil {
args := []string{"volume", "create"}
if v.Key != "" && c.Compose != nil {
args = append(args, "--label", "com.docker.compose.project="+c.Compose.Project,
"--label", "com.docker.compose.volume="+v.Key)
}
if _, err := sysx.Output(r.ctx, "docker", append(args, v.Name)...); err != nil {
return fmt.Errorf("том %s: %w", v.Name, err)
}
}
mp, err := sysx.Output(r.ctx, "docker", "volume", "inspect", "-f", "{{.Mountpoint}}", v.Name)
if err != nil {
return fmt.Errorf("том %s: %w", v.Name, err)
}
r.volumes[v.Name] = mp
info("%s → %s", v.Name, mp)
}
}
return nil
}
// route раскладывает записи архива: files → /, volumes → mountpoint тома, dumps и bin → staging.
func (r *restorer) route(name string) (string, bool) {
cat, rest, ok := strings.Cut(name, "/")
if !ok || cat == "" {
return "", false
}
kind, rest, ok := strings.Cut(rest, "/")
if !ok || rest == "" {
return "", false
}
switch kind {
case "files":
return "/" + rest, true
case "bin", "dumps":
return filepath.Join(r.staging, cat, kind, filepath.FromSlash(rest)), true
case "volumes":
vol, inner, _ := strings.Cut(rest, "/")
mp, ok := r.volumes[vol]
if !ok {
return filepath.Join(r.staging, cat, "volumes", filepath.FromSlash(rest)), true
}
return filepath.Join(mp, filepath.FromSlash(inner)), true
}
return "", false
}
func (r *restorer) extractPart(p api.PartInfo) error {
body := &resumableReader{ctx: r.ctx, client: r.client, backupID: r.backupID, part: p.Name, h: sha256.New()}
defer body.Close()
ex := &archive.Extractor{Route: r.route}
if err := ex.Extract(body); err != nil {
return err
}
// дочитываем хвост (zstd мог не запросить последние байты), чтобы сверить сумму
if _, err := io.Copy(io.Discard, body); err != nil {
return err
}
if sum := hex.EncodeToString(body.h.Sum(nil)); sum != p.SHA256 {
return fmt.Errorf("контрольная сумма части %s не совпала", p.Name)
}
for _, w := range ex.Warnings {
r.warn("%s", w)
}
info("распаковано записей: %d", ex.Files)
return nil
}
// resumableReader читает часть с сервера и при обрыве продолжает с того же байта (Range).
type resumableReader struct {
ctx context.Context
client *api.Client
backupID int64
part string
body io.ReadCloser
off int64
retries int
h hash.Hash
}
func (rr *resumableReader) Read(p []byte) (int, error) {
for {
if rr.body == nil {
b, err := rr.client.DownloadPart(rr.ctx, rr.backupID, rr.part, rr.off)
if err != nil {
if rr.retries++; rr.retries > 5 {
return 0, err
}
time.Sleep(time.Duration(rr.retries) * 5 * time.Second)
continue
}
rr.body = b
}
n, err := rr.body.Read(p)
rr.off += int64(n)
rr.h.Write(p[:n])
if err == nil || errors.Is(err, io.EOF) {
return n, err
}
rr.body.Close()
rr.body = nil
if rr.retries++; rr.retries > 5 {
return n, err
}
fmt.Printf(" ! обрыв загрузки на %s, продолжаю: %v\n", human(rr.off), err)
if n > 0 {
return n, nil
}
}
}
func (rr *resumableReader) Close() error {
if rr.body != nil {
return rr.body.Close()
}
return nil
}
func (r *restorer) restoreCategory(c manifest.Category) error {
for _, d := range c.EnsureDirs {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
switch c.Type {
case catalog.TypeSystemd, catalog.TypeXUI:
if err := r.installBinary(c); err != nil {
return err
}
if err := r.restoreSQLite(c); err != nil {
return err
}
case catalog.TypeCompose:
if err := r.restoreCompose(c); err != nil {
return err
}
}
if c.Name == "system" {
r.fixCrontabs()
}
for _, d := range c.Dumps {
if d.Kind != "cmd" {
continue
}
if d.RestoreCmd == "" {
r.warn("%s: для дампа %s не задан restore_cmd — файл не применён", c.Name, d.Name)
continue
}
file := filepath.Join(r.staging, c.Name, "dumps", d.Name)
if err := sysx.Shell(r.ctx, d.RestoreCmd, "DUMP_FILE="+file); err != nil {
return fmt.Errorf("дамп %s: %w", d.Name, err)
}
}
for _, cmd := range c.PostRestore {
if err := sysx.Shell(r.ctx, cmd); err != nil {
r.warn("%s: post_restore %q: %v", c.Name, cmd, err)
}
}
if c.Service != nil {
return r.applyService(c.Service)
}
return nil
}
// installBinary ставит версию со старого сервера, при неудаче — копию бинарника из бэкапа.
func (r *restorer) installBinary(c manifest.Category) error {
if c.Install != "" && c.Version != "" {
if rec, ok := recipes[c.Install]; ok {
info("установка %s %s", c.Install, c.Version)
err := rec(r.ctx, c.Version, r.arch)
if err == nil {
return nil
}
r.warn("%s: установка %s %s не удалась: %v", c.Name, c.Install, c.Version, err)
} else {
r.warn("%s: неизвестный recipe %q", c.Name, c.Install)
}
}
if len(c.Binaries) == 0 {
if c.Install != "" {
return fmt.Errorf("бинарник не установлен и копии в бэкапе нет")
}
return nil
}
if r.m.OS.Arch != "" && r.arch != "" && r.m.OS.Arch != r.arch {
return fmt.Errorf("копия бинарника из бэкапа под %s, а сервер %s", r.m.OS.Arch, r.arch)
}
info("использую копию бинарника из бэкапа")
return copyTree(filepath.Join(r.staging, c.Name, "bin"), "/")
}
func (r *restorer) restoreSQLite(c manifest.Category) error {
for _, d := range c.Dumps {
if d.Kind != "sqlite" || d.Target == "" {
continue
}
if c.Service != nil {
_ = sysx.Run(r.ctx, "systemctl", "stop", c.Service.Unit)
}
src := filepath.Join(r.staging, c.Name, "dumps", d.Name)
for _, suffix := range []string{"-wal", "-shm"} {
_ = os.Remove(d.Target + suffix)
}
if err := copyFile(src, d.Target, 0o600); err != nil {
return fmt.Errorf("база %s: %w", d.Target, err)
}
info("база %s восстановлена", d.Target)
}
return nil
}
func (r *restorer) applyService(s *manifest.ServiceState) error {
_ = sysx.Run(r.ctx, "systemctl", "daemon-reload")
if s.Enabled {
if err := sysx.Run(r.ctx, "systemctl", "enable", s.Unit); err != nil {
return err
}
} else {
_ = sysx.Run(r.ctx, "systemctl", "disable", s.Unit)
}
if s.Active {
if err := sysx.Run(r.ctx, "systemctl", "restart", s.Unit); err != nil {
return fmt.Errorf("%s не запустился (journalctl -u %s): %w", s.Unit, s.Unit, err)
}
info("%s запущен", s.Unit)
} else {
_ = sysx.Run(r.ctx, "systemctl", "stop", s.Unit)
info("%s оставлен остановленным, как на старом сервере", s.Unit)
}
return nil
}
// fixCrontabs выставляет crontab-файлам владельца и права, которые требует cron.
func (r *restorer) fixCrontabs() {
files, _ := filepath.Glob("/var/spool/cron/crontabs/*")
for _, f := range files {
user := filepath.Base(f)
if _, err := sysx.Output(r.ctx, "id", "-u", user); err != nil {
r.warn("crontab пользователя %s восстановлен, но такого пользователя нет — cron его не выполнит", user)
continue
}
_ = sysx.Run(r.ctx, "chown", user+":crontab", f)
_ = os.Chmod(f, 0o600)
}
if len(files) > 0 {
_ = sysx.Run(r.ctx, "systemctl", "restart", "cron")
}
}
func (r *restorer) finish(failed []string) {
step("Итог")
if len(failed) == 0 {
info("Все категории восстановлены.")
}
if len(r.warnings) > 0 {
info("Предупреждения:")
for _, w := range r.warnings {
info(" ! %s", w)
}
}
r.todo = append(r.todo,
fmt.Sprintf("Перевести DNS %s (и другие домены сервера) на IP этого сервера", r.m.Domain),
"Если это lighthouse Nebula — обновить static_host_map на остальных узлах",
"Перезагрузить сервер (iptables-legacy и обновление ядра): sudo reboot",
)
info("Осталось сделать:")
for _, t := range r.todo {
info(" • %s", t)
}
}
func human(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d Б", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp])
}
+51
View File
@@ -0,0 +1,51 @@
package restore
import (
"path/filepath"
"testing"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
func TestOrder(t *testing.T) {
cats := []manifest.Category{
{Name: "system"},
{Name: "stoat-mbot", RestoreAfter: []string{"stoat", "psiphon"}},
{Name: "pnvpanel"},
{Name: "stoat"},
}
var names []string
for _, c := range order(cats) {
names = append(names, c.Name)
}
want := []string{"system", "pnvpanel", "stoat", "stoat-mbot"}
for i := range want {
if names[i] != want[i] {
t.Fatalf("order = %v, want %v", names, want)
}
}
// цикл не зависает
cyc := order([]manifest.Category{{Name: "a", RestoreAfter: []string{"b"}}, {Name: "b", RestoreAfter: []string{"a"}}})
if len(cyc) != 2 {
t.Fatal("цикл потерял категории")
}
}
func TestRoute(t *testing.T) {
r := &restorer{staging: "/stage", volumes: map[string]string{"pnvpanel_ticket_uploads": "/var/lib/docker/volumes/pnvpanel_ticket_uploads/_data"}}
cases := map[string]string{
"pnvpanel/files/opt/PnvPanel/.env": "/opt/PnvPanel/.env",
"pnvpanel/dumps/db.pgdump": filepath.Join("/stage", "pnvpanel", "dumps", "db.pgdump"),
"pnvpanel/volumes/pnvpanel_ticket_uploads/a/b.png": filepath.Join("/var/lib/docker/volumes/pnvpanel_ticket_uploads/_data", "a", "b.png"),
"xui/bin/usr/local/x-ui/x-ui": filepath.Join("/stage", "xui", "bin", "usr", "local", "x-ui", "x-ui"),
}
for name, want := range cases {
got, ok := r.route(name)
if !ok || got != want {
t.Errorf("route(%q) = %q, %v; want %q", name, got, ok, want)
}
}
if _, ok := r.route("manifest.json"); ok {
t.Error("manifest.json не должен распаковываться")
}
}
+395
View File
@@ -0,0 +1,395 @@
package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
type ctxKey int
const clientKey ctxKey = 1
func clientFrom(r *http.Request) *Client { return r.Context().Value(clientKey).(*Client) }
func jsonResp(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
jsonResp(w, code, map[string]string{"error": msg})
}
// agentAuth — upload-токен клиента. Даёт только запись своих бэкапов.
func (s *Server) agentAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
jsonErr(w, http.StatusUnauthorized, "нужен токен")
return
}
c, err := s.st.ClientByToken(hashToken(tok))
if err != nil || c.Disabled {
jsonErr(w, http.StatusUnauthorized, "неверный токен или клиент отключён")
return
}
_ = s.st.TouchClient(c.ID)
h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c)))
}
}
// restoreAuth — временный restore-токен, даёт чтение бэкапов одного клиента.
func (s *Server) restoreAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
id, err := s.st.RestoreTokenClient(hashToken(tok))
if tok == "" || err != nil {
jsonErr(w, http.StatusUnauthorized, "неверный или просроченный restore-токен")
return
}
c, err := s.st.GetClient(id)
if err != nil {
jsonErr(w, http.StatusUnauthorized, "клиент удалён")
return
}
h(w, r.WithContext(context.WithValue(r.Context(), clientKey, c)))
}
}
type AgentConfigResponse = api.AgentConfig
func (s *Server) apiAgentConfig(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
due := c.HeavyEveryDays >= 0 &&
time.Since(time.Unix(c.LastHeavyAt, 0)) >= time.Duration(c.HeavyEveryDays)*24*time.Hour-time.Hour
jsonResp(w, http.StatusOK, AgentConfigResponse{Domain: c.Domain, ConfigYAML: c.ConfigYAML, HeavyDue: due})
}
var partNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
func (s *Server) apiCreateBackup(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
var m manifest.Manifest
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<20)).Decode(&m); err != nil {
jsonErr(w, http.StatusBadRequest, "манифест: "+err.Error())
return
}
if !slices.Contains(m.Parts, manifest.MainPart) {
jsonErr(w, http.StatusBadRequest, "в манифесте нет части main")
return
}
for _, p := range m.Parts {
if !partNameRe.MatchString(p) {
jsonErr(w, http.StatusBadRequest, "недопустимое имя части "+p)
return
}
}
m.Domain = c.Domain
now := time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
// предыдущая незавершённая загрузка этого клиента уже не продолжится
if old, err := s.st.UploadingBefore(now.Unix()+1, c.ID); err == nil {
for _, b := range old {
if !s.isUploading(b.ID) {
s.failBackup(b, "прервана новым бэкапом")
}
}
}
raw, _ := json.Marshal(m)
id, err := s.st.CreateBackup(c.ID, now.Unix(), string(raw))
if err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
dir := filepath.Join(c.Domain, fmt.Sprintf("%s-%d", now.Format("20060102-150405"), id))
if err := os.MkdirAll(filepath.Join(s.backupsDir(), dir), 0o700); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.st.SetBackupDir(id, dir); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
log.Printf("бэкап #%d (%s) начат, части %v", id, c.Domain, m.Parts)
jsonResp(w, http.StatusCreated, map[string]int64{"id": id})
}
// ownBackup загружает бэкап из пути и проверяет, что он принадлежит клиенту.
func (s *Server) ownBackup(w http.ResponseWriter, r *http.Request) (*Backup, *manifest.Manifest, bool) {
c := clientFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
jsonErr(w, http.StatusBadRequest, "id")
return nil, nil, false
}
b, err := s.st.GetBackup(id)
if err != nil || b.ClientID != c.ID {
jsonErr(w, http.StatusNotFound, "бэкап не найден")
return nil, nil, false
}
var m manifest.Manifest
if err := json.Unmarshal([]byte(b.Manifest), &m); err != nil {
jsonErr(w, http.StatusInternalServerError, "манифест: "+err.Error())
return nil, nil, false
}
return b, &m, true
}
func (s *Server) isUploading(id int64) bool {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
return s.uploads[id]
}
func (s *Server) apiUploadPart(w http.ResponseWriter, r *http.Request) {
b, m, ok := s.ownBackup(w, r)
if !ok {
return
}
name := r.PathValue("name")
if b.Status != StatusUploading {
jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status)
return
}
if !slices.Contains(m.Parts, name) {
jsonErr(w, http.StatusBadRequest, "части "+name+" нет в манифесте")
return
}
s.uploadMu.Lock()
if s.uploads[b.ID] {
s.uploadMu.Unlock()
jsonErr(w, http.StatusConflict, "загрузка уже идёт")
return
}
s.uploads[b.ID] = true
s.uploadMu.Unlock()
defer func() {
s.uploadMu.Lock()
delete(s.uploads, b.ID)
s.uploadMu.Unlock()
}()
tmp := s.partPath(b.Dir, name) + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), http.MaxBytesReader(w, r.Body, s.cfg.MaxUpload))
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(tmp)
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
jsonErr(w, http.StatusRequestEntityTooLarge, "превышен размер части")
return
}
jsonErr(w, http.StatusBadRequest, "загрузка прервана: "+err.Error())
return
}
sum := hex.EncodeToString(h.Sum(nil))
if err := s.st.UpsertPart(Part{BackupID: b.ID, Name: name, Size: n, SHA256: sum, Status: PartUploaded}); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
jsonResp(w, http.StatusOK, PartInfo{Name: name, Size: n, SHA256: sum})
}
type PartInfo = api.PartInfo
type CompleteRequest = api.CompleteRequest
func (s *Server) apiComplete(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
b, m, ok := s.ownBackup(w, r)
if !ok {
return
}
if b.Status != StatusUploading {
jsonErr(w, http.StatusConflict, "бэкап уже "+b.Status)
return
}
var req CompleteRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
claimed := map[string]PartInfo{}
for _, p := range req.Parts {
claimed[p.Name] = p
}
stored := map[string]Part{}
for _, p := range b.Parts {
stored[p.Name] = p
}
var total int64
for _, name := range m.Parts {
p, ok := stored[name]
cl := claimed[name]
if !ok {
jsonErr(w, http.StatusBadRequest, "часть "+name+" не загружена")
return
}
if cl.SHA256 != p.SHA256 || cl.Size != p.Size {
s.failBackup(b, fmt.Sprintf("часть %s: контрольная сумма не совпала", name))
jsonErr(w, http.StatusBadRequest, "контрольная сумма части "+name+" не совпала")
return
}
total += p.Size
}
for _, name := range m.Parts {
final := s.partPath(b.Dir, name)
if err := os.Rename(final+".part", final); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.st.SetPartStatus(b.ID, name, PartComplete); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
}
if len(req.Warnings) > 0 {
m.Warnings = append(m.Warnings, req.Warnings...)
raw, _ := json.Marshal(m)
b.Manifest = string(raw)
if err := s.st.SetBackupManifest(b.ID, b.Manifest); err != nil {
log.Printf("backup #%d: %v", b.ID, err)
}
}
_ = os.WriteFile(filepath.Join(s.backupsDir(), b.Dir, manifest.ManifestFile), []byte(b.Manifest), 0o600)
if err := s.st.SetBackupStatus(b.ID, StatusComplete, "", total); err != nil {
jsonErr(w, http.StatusInternalServerError, err.Error())
return
}
heavy := len(m.Parts) > 1
if err := s.st.MarkClientSuccess(c.ID, b.CreatedAt, heavy); err != nil {
log.Printf("client %d: %v", c.ID, err)
}
log.Printf("бэкап #%d (%s) завершён: %s", b.ID, c.Domain, humanBytes(total))
if s.st.Setting(settingNotifySuccess) == "1" {
msg := fmt.Sprintf("%s: %s, частей %d", c.Domain, humanBytes(total), len(m.Parts))
if len(m.Warnings) > 0 {
msg += fmt.Sprintf(", предупреждений %d", len(m.Warnings))
}
s.notify("Бэкап готов: "+c.Domain, msg, "low", "white_check_mark")
}
if c2, err := s.st.GetClient(c.ID); err == nil {
if err := s.applyRetention(c2); err != nil {
log.Printf("ретеншн %s: %v", c.Domain, err)
}
}
jsonResp(w, http.StatusOK, map[string]any{"id": b.ID, "size": total})
}
func (s *Server) apiFail(w http.ResponseWriter, r *http.Request) {
b, _, ok := s.ownBackup(w, r)
if !ok {
return
}
var req struct {
Error string `json:"error"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req)
if req.Error == "" {
req.Error = "агент сообщил об ошибке"
}
if b.Status == StatusUploading {
s.failBackup(b, req.Error)
}
jsonResp(w, http.StatusOK, map[string]string{"status": StatusFailed})
}
type RestoreManifestResponse = api.RestoreManifest
func (s *Server) apiRestoreManifest(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
var b *Backup
var err error
if v := r.URL.Query().Get("backup"); v != "" {
id, _ := strconv.ParseInt(v, 10, 64)
b, err = s.st.GetBackup(id)
if err == nil && (b.ClientID != c.ID || b.Status != StatusComplete) {
err = ErrNotFound
}
} else {
b, err = s.st.LatestComplete(c.ID)
}
if err != nil {
jsonErr(w, http.StatusNotFound, "нет подходящего бэкапа")
return
}
resp := RestoreManifestResponse{BackupID: b.ID, CreatedAt: time.Unix(b.CreatedAt, 0), Manifest: json.RawMessage(b.Manifest)}
for _, p := range b.Parts {
if p.Status == PartComplete {
resp.Parts = append(resp.Parts, PartInfo{Name: p.Name, Size: p.Size, SHA256: p.SHA256})
}
}
// heavy-части из более ранних бэкапов здесь не подмешиваются: restore может запросить
// их отдельно через ?backup=, если понадобится
jsonResp(w, http.StatusOK, resp)
}
func (s *Server) apiRestorePart(w http.ResponseWriter, r *http.Request) {
c := clientFrom(r)
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
b, err := s.st.GetBackup(id)
if err != nil || b.ClientID != c.ID || b.Status != StatusComplete {
jsonErr(w, http.StatusNotFound, "бэкап не найден")
return
}
s.servePart(w, r, b, r.PathValue("name"))
}
func (s *Server) servePart(w http.ResponseWriter, r *http.Request, b *Backup, name string) {
found := false
for _, p := range b.Parts {
if p.Name == name && p.Status == PartComplete {
found = true
w.Header().Set("X-Content-Sha256", p.SHA256)
}
}
if !found {
http.Error(w, "часть не найдена", http.StatusNotFound)
return
}
f, err := os.Open(s.partPath(b.Dir, name))
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/zstd")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%d-%s.tar.zst"`,
filepath.Base(filepath.Dir(b.Dir)), b.ID, name))
http.ServeContent(w, r, "", fi.ModTime(), f)
}
+231
View File
@@ -0,0 +1,231 @@
package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
func newTestServer(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
s, err := New(Config{DataDir: t.TempDir(), PublicURL: "https://backup.test"})
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() { ts.Close(); s.Close() })
return s, ts
}
func call(t *testing.T, method, url, token string, body io.Reader) *http.Response {
t.Helper()
req, err := http.NewRequest(method, url, body)
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
func decode(t *testing.T, resp *http.Response, v any) {
t.Helper()
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatal(err)
}
}
func sum(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func TestUploadAndRestoreFlow(t *testing.T) {
s, ts := newTestServer(t)
agentTok := newToken()
clientID, err := s.st.CreateClient("nl1", "nl1.example.com", hashToken(agentTok))
if err != nil {
t.Fatal(err)
}
// без токена — 401
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", "", nil); r.StatusCode != 401 {
t.Fatalf("без токена: %d", r.StatusCode)
}
var cfg AgentConfigResponse
r := call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil)
decode(t, r, &cfg)
if cfg.Domain != "nl1.example.com" || !cfg.HeavyDue {
t.Fatalf("config: %+v", cfg)
}
m := manifest.Manifest{FormatVersion: 1, Hostname: "nl1", Parts: []string{"main", "stoat"}}
raw, _ := json.Marshal(m)
r = call(t, "POST", ts.URL+"/api/v1/backups", agentTok, bytes.NewReader(raw))
if r.StatusCode != 201 {
t.Fatalf("create: %d", r.StatusCode)
}
var created struct{ ID int64 }
decode(t, r, &created)
mainData := []byte("main-archive-data")
heavyData := bytes.Repeat([]byte("minio"), 1000)
for name, data := range map[string][]byte{"main": mainData, "stoat": heavyData} {
r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/%s", ts.URL, created.ID, name), agentTok, bytes.NewReader(data))
if r.StatusCode != 200 {
t.Fatalf("upload %s: %d", name, r.StatusCode)
}
}
// часть вне манифеста
r = call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/other", ts.URL, created.ID), agentTok, strings.NewReader("x"))
if r.StatusCode != 400 {
t.Fatalf("чужая часть: %d", r.StatusCode)
}
done := CompleteRequest{Parts: []PartInfo{
{Name: "main", Size: int64(len(mainData)), SHA256: sum(mainData)},
{Name: "stoat", Size: int64(len(heavyData)), SHA256: sum(heavyData)},
}}
raw, _ = json.Marshal(done)
r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), agentTok, bytes.NewReader(raw))
if r.StatusCode != 200 {
body, _ := io.ReadAll(r.Body)
t.Fatalf("complete: %d %s", r.StatusCode, body)
}
c, _ := s.st.GetClient(clientID)
if c.LastBackupAt == 0 || c.LastHeavyAt == 0 {
t.Fatalf("клиент не отмечен: %+v", c)
}
r = call(t, "GET", ts.URL+"/api/v1/agent/config", agentTok, nil)
decode(t, r, &cfg)
if cfg.HeavyDue {
t.Fatal("heavy только что загружен")
}
// upload-токен не даёт читать
if r := call(t, "GET", ts.URL+"/api/v1/restore/manifest", agentTok, nil); r.StatusCode != 401 {
t.Fatalf("агентский токен читает бэкапы: %d", r.StatusCode)
}
restoreTok := newToken()
if err := s.st.CreateRestoreToken(hashToken(restoreTok), clientID, time.Hour); err != nil {
t.Fatal(err)
}
var rm RestoreManifestResponse
r = call(t, "GET", ts.URL+"/api/v1/restore/manifest", restoreTok, nil)
decode(t, r, &rm)
if rm.BackupID != created.ID || len(rm.Parts) != 2 {
t.Fatalf("restore manifest: %+v", rm)
}
var got manifest.Manifest
if err := json.Unmarshal(rm.Manifest, &got); err != nil || got.Domain != "nl1.example.com" {
t.Fatalf("manifest domain: %q %v", got.Domain, err)
}
// скачивание с Range (докачка)
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/restore/%d/parts/main", ts.URL, created.ID), nil)
req.Header.Set("Authorization", "Bearer "+restoreTok)
req.Header.Set("Range", "bytes=5-")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 206 || string(body) != string(mainData[5:]) {
t.Fatalf("range: %d %q", resp.StatusCode, body)
}
}
func TestChecksumMismatchFails(t *testing.T) {
s, ts := newTestServer(t)
tok := newToken()
if _, err := s.st.CreateClient("x", "x.example.com", hashToken(tok)); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(manifest.Manifest{Parts: []string{"main"}})
r := call(t, "POST", ts.URL+"/api/v1/backups", tok, bytes.NewReader(raw))
var created struct{ ID int64 }
decode(t, r, &created)
call(t, "PUT", fmt.Sprintf("%s/api/v1/backups/%d/parts/main", ts.URL, created.ID), tok, strings.NewReader("data"))
raw, _ = json.Marshal(CompleteRequest{Parts: []PartInfo{{Name: "main", Size: 4, SHA256: "bad"}}})
r = call(t, "POST", fmt.Sprintf("%s/api/v1/backups/%d/complete", ts.URL, created.ID), tok, bytes.NewReader(raw))
if r.StatusCode != 400 {
t.Fatalf("complete с неверной суммой: %d", r.StatusCode)
}
b, _ := s.st.GetBackup(created.ID)
if b.Status != StatusFailed {
t.Fatalf("статус %s", b.Status)
}
}
func TestWebLoginAndPages(t *testing.T) {
s, ts := newTestServer(t)
if err := s.st.SetAdmin("admin", "secret-pass"); err != nil {
t.Fatal(err)
}
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(ts.URL + "/")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 303 {
t.Fatalf("без сессии: %d", resp.StatusCode)
}
resp, err = client.PostForm(ts.URL+"/login", map[string][]string{"user": {"admin"}, "password": {"secret-pass"}})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
var session *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == sessionCookie {
session = c
}
}
if session == nil {
t.Fatal("нет cookie сессии")
}
for _, p := range []string{"/", "/clients/new", "/settings"} {
req, _ := http.NewRequest("GET", ts.URL+p, nil)
req.AddCookie(session)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("%s: %d", p, resp.StatusCode)
}
}
// POST без CSRF отклоняется
req, _ := http.NewRequest("POST", ts.URL+"/clients", strings.NewReader("domain=a.example.com"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(session)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 403 {
t.Fatalf("без CSRF: %d", resp.StatusCode)
}
}
+169
View File
@@ -0,0 +1,169 @@
package server
import (
"context"
"crypto/subtle"
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "bm_session"
sessionTTL = 7 * 24 * time.Hour
csrfKey ctxKey = 2
)
// SetAdmin задаёт логин и пароль единственного администратора и сбрасывает сессии.
func (s *Store) SetAdmin(user, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
if err := s.SetSetting(settingAdminUser, user); err != nil {
return err
}
if err := s.SetSetting(settingAdminHash, string(hash)); err != nil {
return err
}
return s.DeleteAllSessions()
}
func (s *Store) CheckAdmin(user, password string) bool {
hash := s.Setting(settingAdminHash)
if hash == "" {
return false
}
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(s.Setting(settingAdminUser))) == 1
passOK := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
return userOK && passOK
}
// admin пропускает только авторизованного админа; на POST дополнительно проверяет CSRF.
func (s *Server) admin(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ck, err := r.Cookie(sessionCookie)
var csrf string
if err == nil {
csrf, err = s.st.SessionCSRF(ck.Value)
}
if err != nil {
if r.Method == http.MethodGet {
http.Redirect(w, r, "/login", http.StatusSeeOther)
} else {
http.Error(w, "нужна авторизация", http.StatusUnauthorized)
}
return
}
if r.Method == http.MethodPost {
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrf)) != 1 {
http.Error(w, "неверный CSRF-токен, обновите страницу", http.StatusForbidden)
return
}
}
h(w, r.WithContext(context.WithValue(r.Context(), csrfKey, csrf)))
}
}
func csrfFrom(r *http.Request) string {
v, _ := r.Context().Value(csrfKey).(string)
return v
}
func (s *Server) secureCookies() bool { return strings.HasPrefix(s.cfg.PublicURL, "https://") }
func (s *Server) webLoginPage(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "login", map[string]any{"NoAdmin": s.st.Setting(settingAdminHash) == ""})
}
func (s *Server) webLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
if !s.login.allowed(ip) {
s.render(w, r, "login", map[string]any{"Error": "Слишком много попыток, подождите несколько минут"})
return
}
if !s.st.CheckAdmin(r.FormValue("user"), r.FormValue("password")) {
s.login.fail(ip)
time.Sleep(700 * time.Millisecond)
s.render(w, r, "login", map[string]any{"Error": "Неверный логин или пароль"})
return
}
s.login.reset(ip)
tok := newToken()
if err := s.st.CreateSession(tok, newToken(), sessionTTL); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, Secure: s.secureCookies(),
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) webLogout(w http.ResponseWriter, r *http.Request) {
if ck, err := r.Cookie(sessionCookie); err == nil {
_ = s.st.DeleteSession(ck.Value)
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if s.cfg.TrustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
}
return host
}
// loginLimiter: после 5 неудач подряд вход с IP блокируется на 10 минут.
type loginLimiter struct {
mu sync.Mutex
fails map[string]*loginState
}
type loginState struct {
n int
until time.Time
}
func newLoginLimiter() *loginLimiter { return &loginLimiter{fails: map[string]*loginState{}} }
func (l *loginLimiter) allowed(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
st := l.fails[ip]
return st == nil || time.Now().After(st.until)
}
func (l *loginLimiter) fail(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
st := l.fails[ip]
if st == nil {
st = &loginState{}
l.fails[ip] = st
}
st.n++
if st.n >= 5 {
st.n = 0
st.until = time.Now().Add(10 * time.Minute)
}
}
func (l *loginLimiter) reset(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.fails, ip)
}
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"context"
"fmt"
"log"
"mime"
"net/http"
"strings"
"time"
)
const (
settingAdminUser = "admin_user"
settingAdminHash = "admin_hash"
settingNtfyURL = "ntfy_url"
settingNtfyAuth = "ntfy_auth" // токен tk_... или "user:password"
settingNotifySuccess = "notify_success"
)
// notify отправляет уведомление в ntfy (асинхронно, ошибки только в лог).
func (s *Server) notify(title, msg, priority, tags string) {
url := s.st.Setting(settingNtfyURL)
if url == "" {
return
}
auth := s.st.Setting(settingNtfyAuth)
go func() {
if err := sendNtfy(url, auth, title, msg, priority, tags); err != nil {
log.Printf("ntfy: %v", err)
}
}()
}
func sendNtfy(url, auth, title, msg, priority, tags string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(msg))
if err != nil {
return err
}
// заголовки должны быть ASCII — кириллицу кодируем по RFC 2047, ntfy это понимает
req.Header.Set("Title", mime.BEncoding.Encode("UTF-8", title))
if priority != "" {
req.Header.Set("Priority", priority)
}
if tags != "" {
req.Header.Set("Tags", tags)
}
switch {
case auth == "":
case strings.Contains(auth, ":"):
u, p, _ := strings.Cut(auth, ":")
req.SetBasicAuth(u, p)
default:
req.Header.Set("Authorization", "Bearer "+auth)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("ответ %s", resp.Status)
}
return nil
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d Б", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp])
}
+112
View File
@@ -0,0 +1,112 @@
package server
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
// SelectGFS возвращает индексы бэкапов, которые нужно сохранить.
// times отсортированы от новых к старым. Последний бэкап сохраняется всегда,
// дальше — самый новый в каждом из daily последних дней, weekly недель (ISO), monthly месяцев.
func SelectGFS(times []time.Time, daily, weekly, monthly int) map[int]bool {
keep := map[int]bool{}
if len(times) == 0 {
return keep
}
keep[0] = true
bucket := func(n int, key func(time.Time) string) {
seen := map[string]bool{}
for i, t := range times {
if len(seen) >= n {
return
}
k := key(t)
if !seen[k] {
seen[k] = true
keep[i] = true
}
}
}
bucket(daily, func(t time.Time) string { return t.Format("2006-01-02") })
bucket(weekly, func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
})
bucket(monthly, func(t time.Time) string { return t.Format("2006-01") })
return keep
}
// applyRetention удаляет лишние бэкапы клиента и старые heavy-части.
func (s *Server) applyRetention(c *Client) error {
backups, err := s.st.ListBackups(c.ID, StatusComplete)
if err != nil {
return err
}
times := make([]time.Time, len(backups))
for i, b := range backups {
times[i] = time.Unix(b.CreatedAt, 0)
}
keep := SelectGFS(times, c.KeepDaily, c.KeepWeekly, c.KeepMonthly)
// heavy-части: по каждому имени — только heavy_keep последних.
// Бэкап с сохраняемой heavy-частью не удаляется целиком, даже если GFS его не выбрал.
type excess struct {
b *Backup
name string
}
var drop []excess
heavySeen := map[string]int{}
for i, b := range backups {
for _, p := range b.Parts {
if p.Name == manifest.MainPart || p.Status != PartComplete {
continue
}
heavySeen[p.Name]++
if heavySeen[p.Name] <= max(c.HeavyKeep, 0) {
keep[i] = true
} else {
drop = append(drop, excess{b, p.Name})
}
}
}
for _, d := range drop {
if err := s.deletePart(d.b, d.name); err != nil {
return err
}
}
for i, b := range backups {
if keep[i] {
continue
}
if err := s.deleteBackup(b); err != nil {
return err
}
log.Printf("ретеншн: %s удалён бэкап #%d от %s", c.Domain, b.ID, time.Unix(b.CreatedAt, 0).Format(time.DateTime))
}
return nil
}
func (s *Server) deletePart(b *Backup, name string) error {
if err := os.Remove(s.partPath(b.Dir, name)); err != nil && !os.IsNotExist(err) {
return err
}
if err := s.st.SetPartStatus(b.ID, name, PartDeleted); err != nil {
return err
}
return s.st.RecalcTotal(b.ID)
}
func (s *Server) deleteBackup(b *Backup) error {
if b.Dir != "" {
if err := os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir)); err != nil {
return err
}
}
return s.st.DeleteBackup(b.ID)
}
+41
View File
@@ -0,0 +1,41 @@
package server
import (
"testing"
"time"
)
func TestSelectGFS(t *testing.T) {
// 60 дней ежедневных бэкапов + второй бэкап в последний день
now := time.Date(2026, 10, 6, 3, 0, 0, 0, time.Local)
times := []time.Time{now.Add(time.Hour)}
for d := 0; d < 60; d++ {
times = append(times, now.AddDate(0, 0, -d))
}
keep := SelectGFS(times, 7, 4, 3)
if !keep[0] {
t.Fatal("последний бэкап должен сохраняться")
}
if keep[1] {
t.Error("второй бэкап того же дня не должен сохраняться")
}
for i := 2; i <= 7; i++ {
if !keep[i] {
t.Errorf("дневной бэкап %d должен сохраняться", i)
}
}
// 7 дневных (вкл. idx 0) + недели/месяцы частично пересекаются; верхняя граница 7+4+3
if n := len(keep); n < 7 || n > 14 {
t.Errorf("сохранено %d бэкапов", n)
}
if keep[len(times)-1] && len(keep) == len(times) {
t.Error("ничего не удалено")
}
if len(SelectGFS(nil, 7, 4, 6)) != 0 {
t.Error("пустой список")
}
if k := SelectGFS(times, 0, 0, 0); len(k) != 1 || !k[0] {
t.Errorf("при нулевых keep_* остаётся только последний: %v", k)
}
}
+170
View File
@@ -0,0 +1,170 @@
package server
import (
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"text/template"
)
var agentScript = template.Must(template.New("agent.sh").Parse(`#!/usr/bin/env bash
# Установка/обновление агента backup-master.
# curl -fsSL {{.URL}}/agent.sh | sudo bash -s -- --token <токен> [--now]
# Без --token обновляет бинарник и юниты, сохраняя существующий конфиг.
set -Eeuo pipefail
SERVER="{{.URL}}"
TOKEN=""
RUN_NOW=n
while [[ $# -gt 0 ]]; do
case $1 in
--token) TOKEN=$2; shift 2 ;;
--now) RUN_NOW=y; shift ;;
*) echo "Неизвестный аргумент: $1" >&2; exit 1 ;;
esac
done
[[ $EUID -eq 0 ]] || { echo "Запустите от root (sudo)" >&2; exit 1; }
case $(uname -m) in
x86_64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) echo "Архитектура $(uname -m) не поддерживается" >&2; exit 1 ;;
esac
CONF=/etc/backup-agent/agent.yaml
if [[ -z $TOKEN && ! -f $CONF ]]; then
echo "Нужен --token (выдаётся в веб-морде при добавлении клиента)" >&2; exit 1
fi
echo "==> Скачиваю backupctl ($ARCH)"
curl -fsSL "$SERVER/dl/backupctl-linux-$ARCH" -o /usr/local/bin/backupctl.new
chmod 755 /usr/local/bin/backupctl.new
mv -f /usr/local/bin/backupctl.new /usr/local/bin/backupctl
/usr/local/bin/backupctl version
if [[ -n $TOKEN ]]; then
echo "==> Конфиг $CONF"
install -d -m 700 /etc/backup-agent
umask 077
cat >"$CONF" <<EOF
server: $SERVER
token: $TOKEN
spool_dir: /var/tmp/backup-agent
EOF
fi
echo "==> systemd-юниты"
cat >/etc/systemd/system/backup-agent.service <<'EOF'
[Unit]
Description=backup-master agent
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backupctl backup
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=6h
EOF
cat >/etc/systemd/system/backup-agent.timer <<'EOF'
[Unit]
Description=Ежедневный бэкап backup-master
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now backup-agent.timer
echo "==> Что будет в бэкапе"
/usr/local/bin/backupctl plan || true
if [[ $RUN_NOW == y ]]; then
echo "==> Первый бэкап (журнал: journalctl -u backup-agent -f)"
systemctl start --no-block backup-agent.service
fi
echo "Готово. Расписание: systemctl list-timers backup-agent.timer"
`))
var restoreScript = template.Must(template.New("restore.sh").Parse(`#!/usr/bin/env bash
# Восстановление сервера из backup-master.
# curl -fsSL {{.URL}}/restore.sh | sudo bash -s -- --token <restore-токен> [--backup ID] [--skip-heavy] [--skip-provision]
set -Eeuo pipefail
SERVER="{{.URL}}"
[[ $EUID -eq 0 ]] || { echo "Запустите от root (sudo)" >&2; exit 1; }
case $(uname -m) in
x86_64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) echo "Архитектура $(uname -m) не поддерживается" >&2; exit 1 ;;
esac
command -v curl >/dev/null || { apt-get update -q && apt-get install -y -q curl; }
echo "==> Скачиваю backupctl ($ARCH)"
curl -fsSL "$SERVER/dl/backupctl-linux-$ARCH" -o /usr/local/bin/backupctl.new
chmod 755 /usr/local/bin/backupctl.new
mv -f /usr/local/bin/backupctl.new /usr/local/bin/backupctl
# stdin занят пайпом curl — вопросы задаём через терминал
if [[ -r /dev/tty ]]; then
exec /usr/local/bin/backupctl restore --server "$SERVER" "$@" </dev/tty
fi
exec /usr/local/bin/backupctl restore --server "$SERVER" "$@"
`))
func (s *Server) publicURL(r *http.Request) string {
if s.cfg.PublicURL != "" {
return s.cfg.PublicURL
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
func (s *Server) serveScript(w http.ResponseWriter, r *http.Request, t *template.Template) {
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
if err := t.Execute(w, map[string]string{"URL": s.publicURL(r)}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) serveAgentScript(w http.ResponseWriter, r *http.Request) {
s.serveScript(w, r, agentScript)
}
func (s *Server) serveRestoreScript(w http.ResponseWriter, r *http.Request) {
s.serveScript(w, r, restoreScript)
}
var binNameRe = regexp.MustCompile(`^backupctl-linux-(amd64|arm64)$`)
// serveBinary раздаёт backupctl для агентов: из --dist, а для своей архитектуры — сам себя.
func (s *Server) serveBinary(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
m := binNameRe.FindStringSubmatch(name)
if m == nil {
http.NotFound(w, r)
return
}
if s.cfg.DistDir != "" {
p := filepath.Join(s.cfg.DistDir, name)
if _, err := os.Stat(p); err == nil {
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, p)
return
}
}
if runtime.GOOS == "linux" && runtime.GOARCH == m[1] {
if self, err := os.Executable(); err == nil && !strings.HasSuffix(self, ".test") {
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, self)
return
}
}
http.Error(w, "бинарник "+name+" не найден: положите его в --dist", http.StatusNotFound)
}
+255
View File
@@ -0,0 +1,255 @@
// Package server — сервер бэкапов: API для агентов и restore, веб-морда, фоновые задачи.
package server
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type Config struct {
Listen string
DataDir string
PublicURL string // внешний адрес, подставляется в команды установки
DistDir string // бинарники backupctl-linux-<arch> для раздачи агентам
MaxUpload int64 // максимальный размер одной части, байт
// TrustProxy — брать IP клиента из X-Forwarded-For (за Caddy).
TrustProxy bool
}
type Server struct {
cfg Config
st *Store
pages map[string]*pageTemplate
login *loginLimiter
uploadMu sync.Mutex
uploads map[int64]bool // бэкапы, в которые сейчас идёт загрузка
}
func New(cfg Config) (*Server, error) {
if cfg.MaxUpload == 0 {
cfg.MaxUpload = 200 << 30
}
cfg.PublicURL = strings.TrimRight(cfg.PublicURL, "/")
if err := os.MkdirAll(filepath.Join(cfg.DataDir, "backups"), 0o700); err != nil {
return nil, err
}
st, err := OpenStore(filepath.Join(cfg.DataDir, "backup-master.db"))
if err != nil {
return nil, err
}
pages, err := loadPages()
if err != nil {
st.Close()
return nil, err
}
return &Server{cfg: cfg, st: st, pages: pages, login: newLoginLimiter(), uploads: map[int64]bool{}}, nil
}
func (s *Server) Close() error { return s.st.Close() }
func (s *Server) Store() *Store { return s.st }
func (s *Server) backupsDir() string { return filepath.Join(s.cfg.DataDir, "backups") }
func (s *Server) partPath(dir, name string) string {
return filepath.Join(s.backupsDir(), dir, name+".tar.zst")
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// API агента
mux.HandleFunc("GET /api/v1/agent/config", s.agentAuth(s.apiAgentConfig))
mux.HandleFunc("POST /api/v1/backups", s.agentAuth(s.apiCreateBackup))
mux.HandleFunc("PUT /api/v1/backups/{id}/parts/{name}", s.agentAuth(s.apiUploadPart))
mux.HandleFunc("POST /api/v1/backups/{id}/complete", s.agentAuth(s.apiComplete))
mux.HandleFunc("POST /api/v1/backups/{id}/fail", s.agentAuth(s.apiFail))
// API восстановления
mux.HandleFunc("GET /api/v1/restore/manifest", s.restoreAuth(s.apiRestoreManifest))
mux.HandleFunc("GET /api/v1/restore/{id}/parts/{name}", s.restoreAuth(s.apiRestorePart))
// публичное
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
mux.HandleFunc("GET /agent.sh", s.serveAgentScript)
mux.HandleFunc("GET /restore.sh", s.serveRestoreScript)
mux.HandleFunc("GET /dl/{file}", s.serveBinary)
mux.Handle("GET /static/", http.FileServerFS(staticFS))
// веб-морда
mux.HandleFunc("GET /login", s.webLoginPage)
mux.HandleFunc("POST /login", s.webLogin)
mux.HandleFunc("POST /logout", s.admin(s.webLogout))
mux.HandleFunc("GET /{$}", s.admin(s.webClients))
mux.HandleFunc("GET /clients/new", s.admin(s.webClientNew))
mux.HandleFunc("POST /clients", s.admin(s.webClientCreate))
mux.HandleFunc("GET /clients/{id}", s.admin(s.webClient))
mux.HandleFunc("POST /clients/{id}/settings", s.admin(s.webClientSettings))
mux.HandleFunc("POST /clients/{id}/token", s.admin(s.webClientRotateToken))
mux.HandleFunc("POST /clients/{id}/restore-token", s.admin(s.webClientRestoreToken))
mux.HandleFunc("POST /clients/{id}/delete", s.admin(s.webClientDelete))
mux.HandleFunc("GET /backups/{id}", s.admin(s.webBackup))
mux.HandleFunc("GET /backups/{id}/parts/{name}", s.admin(s.webBackupDownload))
mux.HandleFunc("POST /backups/{id}/delete", s.admin(s.webBackupDelete))
mux.HandleFunc("GET /settings", s.admin(s.webSettings))
mux.HandleFunc("POST /settings", s.admin(s.webSettingsSave))
return logRequests(mux)
}
// Run запускает HTTP-сервер и фоновые задачи до отмены ctx.
func (s *Server) Run(ctx context.Context) error {
if s.st.Setting(settingAdminHash) == "" {
log.Printf("ВНИМАНИЕ: админ не задан, выполните: backupctl admin-passwd <логин>")
}
srv := &http.Server{
Addr: s.cfg.Listen,
Handler: s.Handler(),
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 2 * time.Minute,
}
go s.background(ctx)
errc := make(chan error, 1)
go func() {
log.Printf("backup-master слушает %s (public url %s)", s.cfg.Listen, s.cfg.PublicURL)
errc <- srv.ListenAndServe()
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shCtx)
}
}
func (s *Server) background(ctx context.Context) {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
for {
s.tick()
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (s *Server) tick() {
if err := s.st.Cleanup(); err != nil {
log.Printf("cleanup: %v", err)
}
// зависшие загрузки
stuck, err := s.st.UploadingBefore(time.Now().Add(-6*time.Hour).Unix(), 0)
if err != nil {
log.Printf("stuck uploads: %v", err)
}
for _, b := range stuck {
s.failBackup(b, "загрузка не завершилась за 6 часов")
}
// клиенты без свежих бэкапов
clients, err := s.st.ListClients()
if err != nil {
log.Printf("clients: %v", err)
return
}
now := time.Now()
for _, c := range clients {
if c.Disabled || c.StaleNotified || c.StaleHours <= 0 {
continue
}
ref := c.LastBackupAt
if ref == 0 {
ref = c.CreatedAt
}
if now.Sub(time.Unix(ref, 0)) > time.Duration(c.StaleHours)*time.Hour {
msg := fmt.Sprintf("%s: нет успешного бэкапа больше %d ч", c.Domain, c.StaleHours)
if c.LastBackupAt == 0 {
msg = fmt.Sprintf("%s: от клиента ещё не пришло ни одного бэкапа", c.Domain)
}
s.notify("Бэкап просрочен: "+c.Domain, msg, "high", "warning")
_ = s.st.SetStaleNotified(c.ID)
}
}
}
func (s *Server) failBackup(b *Backup, msg string) {
if b.Dir != "" {
_ = os.RemoveAll(filepath.Join(s.backupsDir(), b.Dir))
}
if err := s.st.SetBackupStatus(b.ID, StatusFailed, msg, 0); err != nil {
log.Printf("backup #%d: %v", b.ID, err)
}
_ = s.st.MarkClientError(b.ClientID, msg)
domain := fmt.Sprintf("клиент #%d", b.ClientID)
if c, err := s.st.GetClient(b.ClientID); err == nil {
domain = c.Domain
}
log.Printf("бэкап #%d (%s) провален: %s", b.ID, domain, msg)
s.notify("Бэкап не удался: "+domain, msg, "high", "x")
}
// --- токены ---
const tokenPrefix = "bm_"
func newToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return tokenPrefix + base64.RawURLEncoding.EncodeToString(b)
}
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
}
func bearer(r *http.Request) string {
v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
return ""
}
return strings.TrimSpace(v)
}
// --- логирование ---
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func logRequests(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
h.ServeHTTP(sw, r)
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
return
}
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
})
}
+127
View File
@@ -0,0 +1,127 @@
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1c2128;
--muted: #6b7280;
--border: #dfe3e8;
--accent: #2f6fde;
--accent-text: #ffffff;
--ok: #1f8a4c;
--ok-bg: #e3f4ea;
--warn: #9a6400;
--warn-bg: #fdf1d8;
--fail: #c0362c;
--fail-bg: #fbe5e3;
--code-bg: #eef1f5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171c;
--panel: #1c2027;
--text: #e4e7eb;
--muted: #9aa3ae;
--border: #2e343d;
--accent: #5b8ff0;
--ok: #4cc27f;
--ok-bg: #18342a;
--warn: #e0ad4a;
--warn-bg: #3a2f17;
--fail: #f07167;
--fail-bg: #3d1f1d;
--code-bg: #232831;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
.top {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
}
.brand { font-weight: 600; color: var(--text); }
.top nav { display: flex; align-items: center; gap: 18px; }
.top nav a { color: var(--muted); }
.top nav a.active { color: var(--text); font-weight: 500; }
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 24px; margin: 0 0 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h2 { font-size: 18px; margin: 32px 0 12px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
.right { text-align: right; }
.empty { color: var(--muted); padding: 24px 0; }
.hint { color: var(--muted); font-size: 13px; margin: 4px 0 12px; }
.table-wrap { overflow-x: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-size: 13px; font-weight: 500; color: var(--muted); }
tr:last-child td { border-bottom: none; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.badge.ok { background: var(--ok-bg); color: var(--ok); }
.badge.stale, .badge.never { background: var(--warn-bg); color: var(--warn); }
.badge.fail { background: var(--fail-bg); color: var(--fail); }
.badge.off { background: var(--code-bg); color: var(--muted); }
.ok-text { color: var(--ok); }
.facts { display: flex; flex-wrap: wrap; gap: 12px 32px; margin: 16px 0; }
.facts dt { font-size: 12px; color: var(--muted); }
.facts dd { margin: 0; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; }
.card.narrow, .login { max-width: 520px; }
.card h2:first-of-type { margin-top: 0; }
label { display: block; margin-bottom: 12px; font-size: 14px; }
label.check { display: flex; align-items: center; gap: 8px; }
input, textarea {
display: block; width: 100%; margin-top: 4px; padding: 7px 10px;
background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: inherit;
}
input[type=checkbox] { display: inline; width: auto; margin: 0; }
textarea { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0 16px; }
button, .button {
display: inline-block; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--panel); color: var(--text); font: inherit; cursor: pointer;
}
button:hover, .button:hover { text-decoration: none; filter: brightness(0.97); }
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.link { background: none; border: none; padding: 0; color: var(--muted); }
button.danger { background: var(--fail-bg); border-color: var(--fail-bg); color: var(--fail); }
button.link.danger { background: none; }
form.inline { display: inline; }
.error { background: var(--fail-bg); color: var(--fail); padding: 10px 14px; border-radius: 6px; }
.warnings { background: var(--warn-bg); color: var(--warn); padding: 10px 14px; border-radius: 6px; margin: 12px 0; }
.warnings ul { margin: 8px 0 0; padding-left: 20px; font-size: 13px; }
.danger-zone { margin-top: 32px; color: var(--muted); }
.danger-zone form { margin-top: 12px; max-width: 420px; }
.danger-zone input { margin-bottom: 8px; }
.cmd { display: flex; gap: 8px; align-items: flex-start; margin: 16px 0; }
.cmd pre { flex: 1; margin: 0; padding: 12px; background: var(--code-bg); border-radius: 6px; white-space: pre-wrap; word-break: break-all; }
pre.json { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-x: auto; }
.login { margin: 10vh auto; }
.login form { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; }
@media (max-width: 640px) {
main { padding: 16px; }
.top { padding: 10px 16px; }
.cmd { flex-direction: column; }
}
+442
View File
@@ -0,0 +1,442 @@
package server
import (
"database/sql"
"errors"
"fmt"
"time"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
domain TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
config_yaml TEXT NOT NULL DEFAULT '',
keep_daily INTEGER NOT NULL DEFAULT 7,
keep_weekly INTEGER NOT NULL DEFAULT 4,
keep_monthly INTEGER NOT NULL DEFAULT 6,
heavy_keep INTEGER NOT NULL DEFAULT 1,
heavy_every_days INTEGER NOT NULL DEFAULT 7,
stale_hours INTEGER NOT NULL DEFAULT 26,
created_at INTEGER NOT NULL,
last_backup_at INTEGER NOT NULL DEFAULT 0,
last_heavy_at INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_error_at INTEGER NOT NULL DEFAULT 0,
stale_notified INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
status TEXT NOT NULL,
dir TEXT NOT NULL DEFAULT '',
manifest TEXT NOT NULL,
total_size INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS backups_client ON backups(client_id, created_at);
CREATE TABLE IF NOT EXISTS parts (
backup_id INTEGER NOT NULL REFERENCES backups(id) ON DELETE CASCADE,
name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
PRIMARY KEY (backup_id, name)
);
CREATE TABLE IF NOT EXISTS restore_tokens (
token_hash TEXT PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
csrf TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
const (
StatusUploading = "uploading"
StatusComplete = "complete"
StatusFailed = "failed"
PartUploaded = "uploaded"
PartComplete = "complete"
PartDeleted = "deleted" // heavy-часть удалена ротацией
)
var ErrNotFound = errors.New("не найдено")
type Store struct {
db *sql.DB
}
func OpenStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
// одно соединение: исключает SQLITE_BUSY, нагрузка у сервиса мизерная
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("миграция: %w", err)
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
type Client struct {
ID int64
Name string
Domain string
TokenHash string
ConfigYAML string
KeepDaily int
KeepWeekly int
KeepMonthly int
HeavyKeep int
HeavyEveryDays int
StaleHours int
CreatedAt int64
LastBackupAt int64
LastHeavyAt int64
LastSeenAt int64
LastError string
LastErrorAt int64
StaleNotified bool
Disabled bool
}
const clientCols = `id, name, domain, token_hash, config_yaml, keep_daily, keep_weekly, keep_monthly,
heavy_keep, heavy_every_days, stale_hours, created_at, last_backup_at, last_heavy_at, last_seen_at,
last_error, last_error_at, stale_notified, disabled`
func scanClient(row interface{ Scan(...any) error }) (*Client, error) {
var c Client
err := row.Scan(&c.ID, &c.Name, &c.Domain, &c.TokenHash, &c.ConfigYAML, &c.KeepDaily, &c.KeepWeekly,
&c.KeepMonthly, &c.HeavyKeep, &c.HeavyEveryDays, &c.StaleHours, &c.CreatedAt, &c.LastBackupAt,
&c.LastHeavyAt, &c.LastSeenAt, &c.LastError, &c.LastErrorAt, &c.StaleNotified, &c.Disabled)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return &c, err
}
func (s *Store) CreateClient(name, domain, tokenHash string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO clients (name, domain, token_hash, created_at) VALUES (?, ?, ?, ?)`,
name, domain, tokenHash, time.Now().Unix())
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) GetClient(id int64) (*Client, error) {
return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE id = ?`, id))
}
func (s *Store) ClientByToken(hash string) (*Client, error) {
return scanClient(s.db.QueryRow(`SELECT `+clientCols+` FROM clients WHERE token_hash = ?`, hash))
}
func (s *Store) ListClients() ([]*Client, error) {
rows, err := s.db.Query(`SELECT ` + clientCols + ` FROM clients ORDER BY domain`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Client
for rows.Next() {
c, err := scanClient(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) UpdateClientSettings(c *Client) error {
_, err := s.db.Exec(`UPDATE clients SET name = ?, config_yaml = ?, keep_daily = ?, keep_weekly = ?,
keep_monthly = ?, heavy_keep = ?, heavy_every_days = ?, stale_hours = ?, disabled = ? WHERE id = ?`,
c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays,
c.StaleHours, c.Disabled, c.ID)
return err
}
func (s *Store) SetClientToken(id int64, hash string) error {
_, err := s.db.Exec(`UPDATE clients SET token_hash = ? WHERE id = ?`, hash, id)
return err
}
func (s *Store) DeleteClient(id int64) error {
_, err := s.db.Exec(`DELETE FROM clients WHERE id = ?`, id)
return err
}
func (s *Store) TouchClient(id int64) error {
_, err := s.db.Exec(`UPDATE clients SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), id)
return err
}
func (s *Store) MarkClientSuccess(id int64, at int64, heavy bool) error {
q := `UPDATE clients SET last_backup_at = ?1, last_error = '', last_error_at = 0, stale_notified = 0`
if heavy {
q += `, last_heavy_at = ?1`
}
_, err := s.db.Exec(q+` WHERE id = ?2`, at, id)
return err
}
func (s *Store) MarkClientError(id int64, msg string) error {
_, err := s.db.Exec(`UPDATE clients SET last_error = ?, last_error_at = ? WHERE id = ?`, msg, time.Now().Unix(), id)
return err
}
func (s *Store) SetStaleNotified(id int64) error {
_, err := s.db.Exec(`UPDATE clients SET stale_notified = 1 WHERE id = ?`, id)
return err
}
type Backup struct {
ID int64
ClientID int64
CreatedAt int64
Status string
Dir string
Manifest string
TotalSize int64
Error string
Parts []Part
}
type Part struct {
BackupID int64
Name string
Size int64
SHA256 string
Status string
}
func (s *Store) CreateBackup(clientID int64, createdAt int64, manifest string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO backups (client_id, created_at, status, manifest) VALUES (?, ?, ?, ?)`,
clientID, createdAt, StatusUploading, manifest)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) SetBackupDir(id int64, dir string) error {
_, err := s.db.Exec(`UPDATE backups SET dir = ? WHERE id = ?`, dir, id)
return err
}
func (s *Store) SetBackupStatus(id int64, status, errMsg string, totalSize int64) error {
_, err := s.db.Exec(`UPDATE backups SET status = ?, error = ?, total_size = ? WHERE id = ?`, status, errMsg, totalSize, id)
return err
}
func (s *Store) SetBackupManifest(id int64, manifest string) error {
_, err := s.db.Exec(`UPDATE backups SET manifest = ? WHERE id = ?`, manifest, id)
return err
}
func (s *Store) DeleteBackup(id int64) error {
_, err := s.db.Exec(`DELETE FROM backups WHERE id = ?`, id)
return err
}
func (s *Store) GetBackup(id int64) (*Backup, error) {
var b Backup
err := s.db.QueryRow(`SELECT id, client_id, created_at, status, dir, manifest, total_size, error FROM backups WHERE id = ?`, id).
Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.Manifest, &b.TotalSize, &b.Error)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
b.Parts, err = s.parts(id)
return &b, err
}
func (s *Store) parts(backupID int64) ([]Part, error) {
rows, err := s.db.Query(`SELECT backup_id, name, size, sha256, status FROM parts WHERE backup_id = ? ORDER BY name = 'main' DESC, name`, backupID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Part
for rows.Next() {
var p Part
if err := rows.Scan(&p.BackupID, &p.Name, &p.Size, &p.SHA256, &p.Status); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// ListBackups возвращает бэкапы клиента от новых к старым (без манифеста), с частями.
func (s *Store) ListBackups(clientID int64, status string) ([]*Backup, error) {
q := `SELECT id, client_id, created_at, status, dir, total_size, error FROM backups WHERE client_id = ?`
args := []any{clientID}
if status != "" {
q += ` AND status = ?`
args = append(args, status)
}
rows, err := s.db.Query(q+` ORDER BY created_at DESC, id DESC`, args...)
if err != nil {
return nil, err
}
var out []*Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.ClientID, &b.CreatedAt, &b.Status, &b.Dir, &b.TotalSize, &b.Error); err != nil {
rows.Close()
return nil, err
}
out = append(out, &b)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
for _, b := range out {
if b.Parts, err = s.parts(b.ID); err != nil {
return nil, err
}
}
return out, nil
}
// LatestComplete возвращает последний успешный бэкап клиента.
func (s *Store) LatestComplete(clientID int64) (*Backup, error) {
var id int64
err := s.db.QueryRow(`SELECT id FROM backups WHERE client_id = ? AND status = ? ORDER BY created_at DESC, id DESC LIMIT 1`,
clientID, StatusComplete).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return s.GetBackup(id)
}
// UploadingBefore возвращает незавершённые загрузки старше ts (по всем клиентам или одному).
func (s *Store) UploadingBefore(ts int64, clientID int64) ([]*Backup, error) {
q := `SELECT id, client_id, dir FROM backups WHERE status = ? AND created_at < ?`
args := []any{StatusUploading, ts}
if clientID != 0 {
q += ` AND client_id = ?`
args = append(args, clientID)
}
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.ClientID, &b.Dir); err != nil {
return nil, err
}
out = append(out, &b)
}
return out, rows.Err()
}
func (s *Store) UpsertPart(p Part) error {
_, err := s.db.Exec(`INSERT INTO parts (backup_id, name, size, sha256, status) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (backup_id, name) DO UPDATE SET size = excluded.size, sha256 = excluded.sha256, status = excluded.status`,
p.BackupID, p.Name, p.Size, p.SHA256, p.Status)
return err
}
func (s *Store) SetPartStatus(backupID int64, name, status string) error {
_, err := s.db.Exec(`UPDATE parts SET status = ? WHERE backup_id = ? AND name = ?`, status, backupID, name)
return err
}
func (s *Store) RecalcTotal(backupID int64) error {
_, err := s.db.Exec(`UPDATE backups SET total_size = (SELECT COALESCE(SUM(size), 0) FROM parts
WHERE backup_id = ?1 AND status = ?2) WHERE id = ?1`, backupID, PartComplete)
return err
}
func (s *Store) CreateRestoreToken(hash string, clientID int64, ttl time.Duration) error {
now := time.Now()
_, err := s.db.Exec(`INSERT INTO restore_tokens (token_hash, client_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
hash, clientID, now.Unix(), now.Add(ttl).Unix())
return err
}
func (s *Store) RestoreTokenClient(hash string) (int64, error) {
var id int64
err := s.db.QueryRow(`SELECT client_id FROM restore_tokens WHERE token_hash = ? AND expires_at > ?`, hash, time.Now().Unix()).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrNotFound
}
return id, err
}
func (s *Store) CreateSession(token, csrf string, ttl time.Duration) error {
_, err := s.db.Exec(`INSERT INTO sessions (token, csrf, expires_at) VALUES (?, ?, ?)`, token, csrf, time.Now().Add(ttl).Unix())
return err
}
func (s *Store) SessionCSRF(token string) (string, error) {
var csrf string
err := s.db.QueryRow(`SELECT csrf FROM sessions WHERE token = ? AND expires_at > ?`, token, time.Now().Unix()).Scan(&csrf)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return csrf, err
}
func (s *Store) DeleteSession(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func (s *Store) DeleteAllSessions() error {
_, err := s.db.Exec(`DELETE FROM sessions`)
return err
}
func (s *Store) Cleanup() error {
now := time.Now().Unix()
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, now); err != nil {
return err
}
_, err := s.db.Exec(`DELETE FROM restore_tokens WHERE expires_at <= ?`, now)
return err
}
func (s *Store) Setting(key string) string {
var v string
_ = s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
return v
}
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
+30
View File
@@ -0,0 +1,30 @@
{{define "title"}}Бэкап #{{.Backup.ID}} — backup-master{{end}}
{{define "content"}}
{{$b := .Backup}}
<p><a href="/clients/{{.Client.ID}}">← {{.Client.Domain}}</a></p>
<div class="head">
<h1>Бэкап #{{.Backup.ID}} <span class="badge {{if eq .Backup.Status "complete"}}ok{{else if eq .Backup.Status "failed"}}fail{{else}}stale{{end}}">{{.Backup.Status}}</span></h1>
{{if eq .Backup.Status "complete"}}
<form method="post" action="/clients/{{.Client.ID}}/restore-token" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="backup" value="{{.Backup.ID}}">
<button class="primary">Восстановить из этого бэкапа</button>
</form>
{{end}}
</div>
{{with .Backup.Error}}<p class="error">{{.}}</p>{{end}}
<dl class="facts">
<div><dt>Создан</dt><dd>{{ts .Backup.CreatedAt}}</dd></div>
<div><dt>Размер</dt><dd>{{bytes .Backup.TotalSize}}</dd></div>
<div><dt>Агент</dt><dd>{{.M.AgentVersion}}</dd></div>
<div><dt>Части</dt><dd>{{range .Backup.Parts}}{{if eq .Status "complete"}}<a href="/backups/{{$b.ID}}/parts/{{.Name}}">{{.Name}}</a> {{bytes .Size}}<br>{{else}}<span class="muted">{{.Name}}: {{.Status}}</span><br>{{end}}{{end}}</dd></div>
</dl>
{{if .M.Warnings}}
<details class="warnings" open>
<summary>Предупреждения ({{len .M.Warnings}})</summary>
<ul>{{range .M.Warnings}}<li>{{.}}</li>{{end}}</ul>
</details>
{{end}}
<h2>Манифест</h2>
<pre class="json">{{.Raw}}</pre>
{{end}}
+132
View File
@@ -0,0 +1,132 @@
{{define "title"}}{{.Client.Domain}} — backup-master{{end}}
{{define "content"}}
{{$csrf := .CSRF}}
<div class="head">
<div>
<h1>{{.Client.Domain}} <span class="badge {{.StatusClass}}">{{.StatusText}}</span></h1>
{{if ne .Client.Name .Client.Domain}}<p class="muted">{{.Client.Name}}</p>{{end}}
</div>
<div class="actions">
<form method="post" action="/clients/{{.Client.ID}}/restore-token" class="inline">
<input type="hidden" name="csrf" value="{{$csrf}}">
<button class="primary" {{if not .Latest}}disabled title="Нет успешных бэкапов"{{end}}>Команда восстановления</button>
</form>
<form method="post" action="/clients/{{.Client.ID}}/token" class="inline" onsubmit="return confirm('Старый токен перестанет работать. Продолжить?')">
<input type="hidden" name="csrf" value="{{$csrf}}">
<button>Новый токен агента</button>
</form>
</div>
</div>
{{with .Error}}<p class="error">{{.}}</p>{{end}}
{{if .Client.LastError}}<p class="error">Последняя ошибка ({{ts .Client.LastErrorAt}}): {{.Client.LastError}}</p>{{end}}
<dl class="facts">
<div><dt>Последний бэкап</dt><dd title="{{ts .Client.LastBackupAt}}">{{ago .Client.LastBackupAt}}</dd></div>
<div><dt>Heavy-части</dt><dd title="{{ts .Client.LastHeavyAt}}">{{ago .Client.LastHeavyAt}}</dd></div>
<div><dt>Агент на связи</dt><dd title="{{ts .Client.LastSeenAt}}">{{ago .Client.LastSeenAt}}</dd></div>
{{with .Latest}}<div><dt>Система</dt><dd>{{.Hostname}}{{with .OS.PrettyName}} · {{.}}{{end}}{{with .OS.Arch}} · {{.}}{{end}}</dd></div>{{end}}
</dl>
{{with .Latest}}
<h2>Что бэкапится <span class="muted small">по последнему бэкапу</span></h2>
<div class="table-wrap">
<table>
<thead><tr><th>Категория</th><th>Тип</th><th>Версия</th><th>Сервис</th><th>Содержимое</th></tr></thead>
<tbody>
{{range .Categories}}
<tr>
<td>{{.Name}}{{if .Auto}} <span class="badge stale" title="Проект найден автоматически и не описан в каталоге — бэкапится с настройками по умолчанию">авто</span>{{end}}</td>
<td>{{.Type}}</td>
<td>{{.Version}}{{with .Compose}}{{with .Git}}<span class="muted small" title="{{.Remote}}">git {{printf "%.8s" .Commit}}{{if .Dirty}}*{{end}}</span>{{end}}{{end}}</td>
<td>{{with .Service}}{{.Unit}} {{if .Active}}<span class="ok-text">active</span>{{else}}<span class="muted">inactive</span>{{end}}{{if not .Enabled}} <span class="muted">(disabled)</span>{{end}}{{end}}</td>
<td class="small">
{{if .Paths}}{{join .Paths ", "}}{{end}}
{{range .Dumps}}<br>дамп {{.Kind}}{{with .Service}} ({{.}}){{end}}{{end}}
{{range .Volumes}}<br>том {{.Name}}{{end}}
{{if .Heavy}}<br>heavy: {{join .Heavy ", "}}{{if .HeavySkipped}} <span class="muted">(не в этот раз)</span>{{end}}{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ни одной категории не найдено.</td></tr>
{{end}}
</tbody>
</table>
</div>
{{if .Packages}}
<p class="small muted">Пакеты: {{range $k, $v := .Packages}}{{$k}}={{$v}} &nbsp; {{end}}</p>
{{end}}
{{if .Warnings}}
<details class="warnings" open>
<summary>Предупреждения ({{len .Warnings}})</summary>
<ul>{{range .Warnings}}<li>{{.}}</li>{{end}}</ul>
</details>
{{end}}
{{end}}
<h2>История</h2>
{{if .Backups}}
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>Дата</th><th>Статус</th><th class="num">Размер</th><th>Части</th><th></th></tr></thead>
<tbody>
{{range .Backups}}
{{$b := .}}
<tr>
<td><a href="/backups/{{.ID}}">{{.ID}}</a></td>
<td>{{ts .CreatedAt}}</td>
<td><span class="badge {{if eq .Status "complete"}}ok{{else if eq .Status "failed"}}fail{{else}}stale{{end}}">{{.Status}}</span>{{with .Error}} <span class="small muted">{{.}}</span>{{end}}</td>
<td class="num">{{if .TotalSize}}{{bytes .TotalSize}}{{end}}</td>
<td class="small">{{range .Parts}}{{if eq .Status "complete"}}<a href="/backups/{{$b.ID}}/parts/{{.Name}}">{{.Name}}</a> ({{bytes .Size}}) {{else if eq .Status "deleted"}}<span class="muted">{{.Name}} удалена</span> {{end}}{{end}}</td>
<td class="right">
<form method="post" action="/backups/{{.ID}}/delete" class="inline" onsubmit="return confirm('Удалить бэкап #{{.ID}}?')">
<input type="hidden" name="csrf" value="{{$csrf}}">
<button class="link danger">удалить</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">Бэкапов ещё нет.</p>
{{end}}
<h2>Настройки</h2>
<form method="post" action="/clients/{{.Client.ID}}/settings" class="card">
<input type="hidden" name="csrf" value="{{$csrf}}">
<label>Название <input name="name" value="{{.Client.Name}}"></label>
<div class="grid">
<label>Дневных копий <input type="number" min="0" name="keep_daily" value="{{.Client.KeepDaily}}"></label>
<label>Недельных <input type="number" min="0" name="keep_weekly" value="{{.Client.KeepWeekly}}"></label>
<label>Месячных <input type="number" min="0" name="keep_monthly" value="{{.Client.KeepMonthly}}"></label>
<label>Тревога через, ч <input type="number" min="0" name="stale_hours" value="{{.Client.StaleHours}}"></label>
<label>Heavy раз в, дней <input type="number" min="0" name="heavy_every_days" value="{{.Client.HeavyEveryDays}}"></label>
<label>Хранить heavy, шт <input type="number" min="0" name="heavy_keep" value="{{.Client.HeavyKeep}}"></label>
</div>
<label class="check"><input type="checkbox" name="disabled" value="1" {{if .Client.Disabled}}checked{{end}}> Клиент отключён (агент получает отказ)</label>
<label>Переопределения каталога (YAML)
<textarea name="config_yaml" rows="12" spellcheck="false" placeholder="categories:
- {name: scripts, disabled: true} # отключить встроенную категорию
- name: extra # добавить свою
type: files
paths: [/srv/extra]
dumps:
- name: full_dump.sql
cmd: mysqldump --all-databases
restore_cmd: mysql &lt; &quot;$DUMP_FILE&quot;">{{.Client.ConfigYAML}}</textarea>
</label>
<p class="hint">Категория с тем же <code>name</code>, что во встроенном каталоге, заменяет её целиком. Поля: type (files|systemd|xui|compose), paths, exclude, detect, service, version_cmd, install, keep_binaries, ensure_dirs, post_restore, dumps, dir, skip_services, heavy_paths, restore_after.</p>
<button class="primary">Сохранить</button>
</form>
<details class="danger-zone">
<summary>Удаление клиента</summary>
<form method="post" action="/clients/{{.Client.ID}}/delete">
<input type="hidden" name="csrf" value="{{$csrf}}">
<p>Будут удалены все бэкапы клиента. Введите домен для подтверждения:</p>
<input name="confirm" placeholder="{{.Client.Domain}}">
<button class="danger">Удалить клиента и бэкапы</button>
</form>
</details>
{{end}}
+12
View File
@@ -0,0 +1,12 @@
{{define "title"}}Новый клиент — backup-master{{end}}
{{define "content"}}
<h1>Новый клиент</h1>
{{with .Error}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/clients" class="card narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Домен <input name="domain" value="{{.Domain}}" placeholder="nl1.example.com" required autofocus></label>
<p class="hint">По домену бэкап находится при восстановлении. Он же станет hostname нового сервера, если на старом hostname не задан.</p>
<label>Название (необязательно) <input name="name" value="{{.Name}}"></label>
<button class="primary">Создать и получить токен</button>
</form>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "title"}}Клиенты — backup-master{{end}}
{{define "content"}}
<div class="head">
<h1>Клиенты</h1>
<a class="button primary" href="/clients/new">Добавить клиента</a>
</div>
{{if .Clients}}
<div class="table-wrap">
<table>
<thead><tr><th>Домен</th><th>Название</th><th>Статус</th><th>Последний бэкап</th><th class="num">Размер</th><th>Агент на связи</th></tr></thead>
<tbody>
{{range .Clients}}
<tr>
<td><a href="/clients/{{.ID}}">{{.Domain}}</a></td>
<td>{{if ne .Name .Domain}}{{.Name}}{{end}}</td>
<td><span class="badge {{.StatusClass}}">{{.StatusText}}</span>{{if and (eq .StatusClass "fail") .LastError}} <span class="muted small" title="{{.LastError}}">{{.LastError}}</span>{{end}}</td>
<td title="{{ts .LastBackupAt}}">{{ago .LastBackupAt}}</td>
<td class="num">{{if .LastSize}}{{bytes .LastSize}}{{end}}</td>
<td title="{{ts .LastSeenAt}}">{{ago .LastSeenAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">Клиентов пока нет. Добавьте первый — получите команду установки агента.</p>
{{end}}
{{end}}
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}backup-master{{end}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{{if ne .Page "login"}}
<header class="top">
<a class="brand" href="/">backup-master</a>
<nav>
<a href="/" {{if or (eq .Page "clients") (eq .Page "client") (eq .Page "backup")}}class="active"{{end}}>Клиенты</a>
<a href="/settings" {{if eq .Page "settings"}}class="active"{{end}}>Настройки</a>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="link">Выйти</button>
</form>
</nav>
</header>
{{end}}
<main>
{{template "content" .}}
</main>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{{define "title"}}Вход — backup-master{{end}}
{{define "content"}}
<div class="login">
<h1>backup-master</h1>
{{if .NoAdmin}}
<p class="error">Администратор не задан. На сервере выполните:<br><code>backupctl admin-passwd &lt;логин&gt;</code></p>
{{end}}
{{with .Error}}<p class="error">{{.}}</p>{{end}}
<form method="post" action="/login">
<label>Логин <input name="user" autocomplete="username" required autofocus></label>
<label>Пароль <input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary">Войти</button>
</form>
</div>
{{end}}
+17
View File
@@ -0,0 +1,17 @@
{{define "title"}}Настройки — backup-master{{end}}
{{define "content"}}
<h1>Настройки</h1>
{{if .Saved}}<p class="ok-text">Сохранено.</p>{{end}}
<form method="post" action="/settings" class="card narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<h2>Уведомления ntfy</h2>
<label>URL топика <input name="ntfy_url" value="{{.NtfyURL}}" placeholder="https://ntfy.example.com/backups"></label>
<label>Авторизация <input name="ntfy_auth" type="password" autocomplete="off" placeholder="{{if .NtfyAuthSet}}задана — оставьте пустым, чтобы не менять{{else}}tk_... или user:password{{end}}"></label>
{{if .NtfyAuthSet}}<label class="check"><input type="checkbox" name="ntfy_auth_clear" value="1"> Удалить авторизацию</label>{{end}}
<label class="check"><input type="checkbox" name="notify_success" value="1" {{if .NotifySuccess}}checked{{end}}> Сообщать и об успешных бэкапах</label>
<label class="check"><input type="checkbox" name="test" value="1"> Отправить тестовое уведомление после сохранения</label>
<p class="hint">Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда.</p>
<button class="primary">Сохранить</button>
</form>
<p class="hint">Логин и пароль администратора меняются на сервере: <code>backupctl admin-passwd &lt;логин&gt;</code></p>
{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "title"}}{{.Title}} — backup-master{{end}}
{{define "content"}}
<h1>{{.Title}}</h1>
<p>{{.Note}}</p>
<div class="cmd">
<pre id="cmd">{{.Cmd}}</pre>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('cmd').textContent).then(()=>{this.textContent='Скопировано'})">Копировать</button>
</div>
<p><a href="/clients/{{.Client.ID}}">← к клиенту {{.Client.Domain}}</a></p>
{{end}}
+404
View File
@@ -0,0 +1,404 @@
package server
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static
var staticRoot embed.FS
// URL /static/x совпадает с путём static/x внутри embed.FS
var staticFS fs.FS = staticRoot
type pageTemplate = template.Template
var funcs = template.FuncMap{
"ts": func(unix int64) string {
if unix == 0 {
return "—"
}
return time.Unix(unix, 0).Format("02.01.2006 15:04")
},
"ago": func(unix int64) string {
if unix == 0 {
return "никогда"
}
d := time.Since(time.Unix(unix, 0))
switch {
case d < time.Minute:
return "только что"
case d < time.Hour:
return fmt.Sprintf("%d мин назад", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d ч назад", int(d.Hours()))
default:
return fmt.Sprintf("%d дн назад", int(d.Hours()/24))
}
},
"bytes": humanBytes,
"join": strings.Join,
}
func loadPages() (map[string]*pageTemplate, error) {
names, err := fs.Glob(templatesFS, "templates/*.html")
if err != nil {
return nil, err
}
pages := map[string]*pageTemplate{}
for _, n := range names {
base := strings.TrimSuffix(strings.TrimPrefix(n, "templates/"), ".html")
if base == "layout" {
continue
}
t, err := template.New("layout.html").Funcs(funcs).ParseFS(templatesFS, "templates/layout.html", n)
if err != nil {
return nil, fmt.Errorf("шаблон %s: %w", n, err)
}
pages[base] = t
}
return pages, nil
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
t, ok := s.pages[page]
if !ok {
http.Error(w, "нет шаблона "+page, http.StatusInternalServerError)
return
}
if data == nil {
data = map[string]any{}
}
data["CSRF"] = csrfFrom(r)
data["Page"] = page
data["PublicURL"] = s.cfg.PublicURL
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
log.Printf("render %s: %v", page, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "same-origin")
_, _ = buf.WriteTo(w)
}
// clientStatus — сводный статус клиента для списка.
func clientStatus(c *Client) (string, string) {
switch {
case c.Disabled:
return "off", "отключён"
case c.LastErrorAt > c.LastBackupAt:
return "fail", "ошибка"
case c.LastBackupAt == 0:
return "never", "нет бэкапов"
case time.Since(time.Unix(c.LastBackupAt, 0)) > time.Duration(c.StaleHours)*time.Hour:
return "stale", "просрочен"
default:
return "ok", "ok"
}
}
type clientRow struct {
*Client
StatusClass, StatusText string
LastSize int64
}
func (s *Server) webClients(w http.ResponseWriter, r *http.Request) {
clients, err := s.st.ListClients()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var rows []clientRow
for _, c := range clients {
row := clientRow{Client: c}
row.StatusClass, row.StatusText = clientStatus(c)
if b, err := s.st.LatestComplete(c.ID); err == nil {
row.LastSize = b.TotalSize
}
rows = append(rows, row)
}
s.render(w, r, "clients", map[string]any{"Clients": rows})
}
func (s *Server) webClientNew(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "client_new", nil)
}
var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`)
func (s *Server) webClientCreate(w http.ResponseWriter, r *http.Request) {
domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
name := strings.TrimSpace(r.FormValue("name"))
if !domainRe.MatchString(domain) {
s.render(w, r, "client_new", map[string]any{"Error": "Недопустимый домен", "Domain": domain, "Name": name})
return
}
if name == "" {
name = domain
}
tok := newToken()
id, err := s.st.CreateClient(name, domain, hashToken(tok))
if err != nil {
msg := err.Error()
if strings.Contains(msg, "UNIQUE") {
msg = "Клиент с таким доменом уже есть"
}
s.render(w, r, "client_new", map[string]any{"Error": msg, "Domain": domain, "Name": name})
return
}
c, _ := s.st.GetClient(id)
s.showAgentToken(w, r, c, tok)
}
func (s *Server) showAgentToken(w http.ResponseWriter, r *http.Request, c *Client, tok string) {
s.render(w, r, "token", map[string]any{
"Client": c,
"Title": "Токен агента",
"Note": "Токен показывается один раз. Выполните на сервере клиента:",
"Cmd": fmt.Sprintf("curl -fsSL %s/agent.sh | sudo bash -s -- --token %s --now", s.cfg.PublicURL, tok),
})
}
func (s *Server) clientFromPath(w http.ResponseWriter, r *http.Request) (*Client, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return nil, false
}
c, err := s.st.GetClient(id)
if err != nil {
http.NotFound(w, r)
return nil, false
}
return c, true
}
func (s *Server) webClient(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientFromPath(w, r)
if !ok {
return
}
s.renderClient(w, r, c, "")
}
func (s *Server) renderClient(w http.ResponseWriter, r *http.Request, c *Client, formErr string) {
backups, err := s.st.ListBackups(c.ID, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]any{"Client": c, "Backups": backups, "Error": formErr}
data["StatusClass"], data["StatusText"] = clientStatus(c)
if latest, err := s.st.LatestComplete(c.ID); err == nil {
var m manifest.Manifest
if json.Unmarshal([]byte(latest.Manifest), &m) == nil {
data["Latest"] = &m
data["LatestID"] = latest.ID
}
}
s.render(w, r, "client", data)
}
func formInt(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(strings.TrimSpace(r.FormValue(key)))
if err != nil {
return def
}
return v
}
func (s *Server) webClientSettings(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientFromPath(w, r)
if !ok {
return
}
c.Name = strings.TrimSpace(r.FormValue("name"))
c.ConfigYAML = strings.ReplaceAll(r.FormValue("config_yaml"), "\r\n", "\n")
c.KeepDaily = formInt(r, "keep_daily", c.KeepDaily)
c.KeepWeekly = formInt(r, "keep_weekly", c.KeepWeekly)
c.KeepMonthly = formInt(r, "keep_monthly", c.KeepMonthly)
c.HeavyKeep = formInt(r, "heavy_keep", c.HeavyKeep)
c.HeavyEveryDays = formInt(r, "heavy_every_days", c.HeavyEveryDays)
c.StaleHours = formInt(r, "stale_hours", c.StaleHours)
c.Disabled = r.FormValue("disabled") == "1"
if strings.TrimSpace(c.ConfigYAML) != "" {
base, err := catalog.Default()
if err == nil {
var over *catalog.Config
over, err = catalog.Parse([]byte(c.ConfigYAML))
if err == nil {
err = catalog.Merge(base, over).Validate()
}
}
if err != nil {
s.renderClient(w, r, c, "Конфиг не сохранён: "+err.Error())
return
}
}
if err := s.st.UpdateClientSettings(c); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/clients/%d", c.ID), http.StatusSeeOther)
}
func (s *Server) webClientRotateToken(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientFromPath(w, r)
if !ok {
return
}
tok := newToken()
if err := s.st.SetClientToken(c.ID, hashToken(tok)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.render(w, r, "token", map[string]any{
"Client": c,
"Title": "Новый токен агента",
"Note": "Старый токен больше не работает. Обновите конфиг агента на сервере клиента:",
"Cmd": fmt.Sprintf("curl -fsSL %s/agent.sh | sudo bash -s -- --token %s", s.cfg.PublicURL, tok),
})
}
func (s *Server) webClientRestoreToken(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientFromPath(w, r)
if !ok {
return
}
tok := newToken()
if err := s.st.CreateRestoreToken(hashToken(tok), c.ID, 24*time.Hour); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cmd := fmt.Sprintf("curl -fsSL %s/restore.sh | sudo bash -s -- --token %s", s.cfg.PublicURL, tok)
if b := r.FormValue("backup"); b != "" {
cmd += " --backup " + b
}
s.render(w, r, "token", map[string]any{
"Client": c,
"Title": "Восстановление " + c.Domain,
"Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo):",
"Cmd": cmd,
})
}
func (s *Server) webClientDelete(w http.ResponseWriter, r *http.Request) {
c, ok := s.clientFromPath(w, r)
if !ok {
return
}
if r.FormValue("confirm") != c.Domain {
s.renderClient(w, r, c, "Для удаления введите домен клиента")
return
}
backups, _ := s.st.ListBackups(c.ID, "")
for _, b := range backups {
if err := s.deleteBackup(b); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := s.st.DeleteClient(c.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) backupFromPath(w http.ResponseWriter, r *http.Request) (*Backup, *Client, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return nil, nil, false
}
b, err := s.st.GetBackup(id)
if err != nil {
http.NotFound(w, r)
return nil, nil, false
}
c, err := s.st.GetClient(b.ClientID)
if err != nil {
http.NotFound(w, r)
return nil, nil, false
}
return b, c, true
}
func (s *Server) webBackup(w http.ResponseWriter, r *http.Request) {
b, c, ok := s.backupFromPath(w, r)
if !ok {
return
}
var m manifest.Manifest
_ = json.Unmarshal([]byte(b.Manifest), &m)
var pretty bytes.Buffer
_ = json.Indent(&pretty, []byte(b.Manifest), "", " ")
s.render(w, r, "backup", map[string]any{"Backup": b, "Client": c, "M": &m, "Raw": pretty.String()})
}
func (s *Server) webBackupDownload(w http.ResponseWriter, r *http.Request) {
b, _, ok := s.backupFromPath(w, r)
if !ok {
return
}
s.servePart(w, r, b, r.PathValue("name"))
}
func (s *Server) webBackupDelete(w http.ResponseWriter, r *http.Request) {
b, c, ok := s.backupFromPath(w, r)
if !ok {
return
}
if err := s.deleteBackup(b); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/clients/%d", c.ID), http.StatusSeeOther)
}
func (s *Server) webSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "settings", map[string]any{
"NtfyURL": s.st.Setting(settingNtfyURL),
"NtfyAuthSet": s.st.Setting(settingNtfyAuth) != "",
"NotifySuccess": s.st.Setting(settingNotifySuccess) == "1",
"Saved": r.URL.Query().Get("saved") != "",
})
}
func (s *Server) webSettingsSave(w http.ResponseWriter, r *http.Request) {
_ = s.st.SetSetting(settingNtfyURL, strings.TrimSpace(r.FormValue("ntfy_url")))
if r.FormValue("ntfy_auth_clear") == "1" {
_ = s.st.SetSetting(settingNtfyAuth, "")
} else if v := strings.TrimSpace(r.FormValue("ntfy_auth")); v != "" {
_ = s.st.SetSetting(settingNtfyAuth, v)
}
ns := "0"
if r.FormValue("notify_success") == "1" {
ns = "1"
}
_ = s.st.SetSetting(settingNotifySuccess, ns)
if r.FormValue("test") == "1" {
s.notify("backup-master: тест", "Тестовое уведомление", "default", "test_tube")
}
http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
}
+164
View File
@@ -0,0 +1,164 @@
// Package sysx — обёртки над системными командами и файлами Linux.
package sysx
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// Output выполняет команду и возвращает stdout без хвостовых пробелов.
func Output(ctx context.Context, name string, args ...string) (string, error) {
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// ToFile выполняет команду, направляя stdout в файл path.
func ToFile(ctx context.Context, path string, name string, args ...string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
var stderr bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout = f
cmd.Stderr = &stderr
runErr := cmd.Run()
closeErr := f.Close()
if runErr != nil {
return fmt.Errorf("%s: %w: %s", name, runErr, strings.TrimSpace(stderr.String()))
}
return closeErr
}
// FromFile выполняет команду, подавая файл path на stdin.
func FromFile(ctx context.Context, path string, name string, args ...string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return Stream(ctx, f, name, args...)
}
// Stream выполняет команду с заданным stdin, вывод — в консоль.
func Stream(ctx context.Context, stdin io.Reader, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return nil
}
// Run выполняет команду с выводом в консоль.
func Run(ctx context.Context, name string, args ...string) error {
return Stream(ctx, nil, name, args...)
}
// Shell выполняет строку через bash -c с выводом в консоль.
func Shell(ctx context.Context, script string, env ...string) error {
cmd := exec.CommandContext(ctx, "bash", "-c", script)
cmd.Env = append(os.Environ(), env...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("bash -c %q: %w", script, err)
}
return nil
}
func Exists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
func HasCommand(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// OSRelease читает /etc/os-release.
func OSRelease() map[string]string {
out := map[string]string{}
f, err := os.Open("/etc/os-release")
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
k, v, ok := strings.Cut(sc.Text(), "=")
if !ok {
continue
}
out[k] = strings.Trim(v, `"'`)
}
return out
}
// DpkgVersion возвращает установленную версию пакета или "".
func DpkgVersion(ctx context.Context, pkg string) string {
out, err := Output(ctx, "dpkg-query", "-W", "-f=${db:Status-Status} ${Version}", pkg)
if err != nil {
return ""
}
status, ver, _ := strings.Cut(out, " ")
if status != "installed" {
return ""
}
return ver
}
func DpkgArch(ctx context.Context) string {
out, err := Output(ctx, "dpkg", "--print-architecture")
if err != nil {
return ""
}
return out
}
// UnitState возвращает (enabled, active) для systemd-юнита.
func UnitState(ctx context.Context, unit string) (bool, bool) {
en, _ := Output(ctx, "systemctl", "is-enabled", unit)
ac, _ := Output(ctx, "systemctl", "is-active", unit)
return en == "enabled", ac == "active"
}
// UnitLoaded сообщает, известен ли systemd такой юнит.
func UnitLoaded(ctx context.Context, unit string) bool {
out, err := Output(ctx, "systemctl", "show", "-p", "LoadState", "--value", unit)
return err == nil && out == "loaded"
}
// GroupMembers возвращает членов группы из /etc/group.
func GroupMembers(group string) []string {
data, err := os.ReadFile("/etc/group")
if err != nil {
return nil
}
for _, line := range strings.Split(string(data), "\n") {
f := strings.Split(line, ":")
if len(f) == 4 && f[0] == group && f[3] != "" {
return strings.Split(f[3], ",")
}
}
return nil
}