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:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user