Files
backup-master/internal/server/store.go
T
mrleo1nidandClaude Opus 5.5 1d44d067e9 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>
2026-10-06 21:52:40 +03:00

443 lines
13 KiB
Go

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
}