CI / test (push) Successful in 1m29s
- сервер отдаёт агенту итоговый каталог (встроенный каталог сервера + переопределения клиента); если каталог не подходит версии агента — встроенный каталог агента и предупреждение - самообновление перед бэкапом: версия сервера новее → скачивание с того же сервера (только /dl/...), проверка sha256 и запуска новой версии, подмена с откатом, перезапуск через exec; при ошибке бэкап идёт текущей версией - галочка «Автообновление» у клиента (по умолчанию включена), версия агента в списке и карточке, отставшие подсвечены; версия сервера в шапке - миграция существующей базы: колонки auto_update и agent_version - backupctl backup --no-update Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
502 lines
15 KiB
Go
502 lines
15 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)
|
|
}
|
|
st := &Store{db: db}
|
|
// колонки, добавленные после первых релизов: у существующих баз их нет
|
|
for _, col := range []struct{ table, name, def string }{
|
|
{"clients", "auto_update", "INTEGER NOT NULL DEFAULT 1"},
|
|
{"clients", "agent_version", "TEXT NOT NULL DEFAULT ''"},
|
|
} {
|
|
if err := st.ensureColumn(col.table, col.name, col.def); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("миграция %s.%s: %w", col.table, col.name, err)
|
|
}
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
func (s *Store) ensureColumn(table, name, def string) error {
|
|
rows, err := s.db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
found := false
|
|
for rows.Next() {
|
|
var n string
|
|
if rows.Scan(&n) == nil && n == name {
|
|
found = true
|
|
}
|
|
}
|
|
rows.Close()
|
|
if found {
|
|
return nil
|
|
}
|
|
_, err = s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, name, def))
|
|
return err
|
|
}
|
|
|
|
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
|
|
AutoUpdate bool // агент сам обновляется до версии сервера
|
|
AgentVersion string // версия агента при последнем обращении
|
|
}
|
|
|
|
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, auto_update, agent_version`
|
|
|
|
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,
|
|
&c.AutoUpdate, &c.AgentVersion)
|
|
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 = ?, auto_update = ?
|
|
WHERE id = ?`,
|
|
c.Name, c.ConfigYAML, c.KeepDaily, c.KeepWeekly, c.KeepMonthly, c.HeavyKeep, c.HeavyEveryDays,
|
|
c.StaleHours, c.Disabled, c.AutoUpdate, 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) SetAgentVersion(id int64, version string) error {
|
|
_, err := s.db.Exec(`UPDATE clients SET agent_version = ? WHERE id = ?`, version, 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
|
|
}
|
|
|
|
// LastSizes возвращает размер последнего успешного бэкапа по каждому клиенту одним запросом.
|
|
func (s *Store) LastSizes() (map[int64]int64, error) {
|
|
rows, err := s.db.Query(`SELECT client_id, total_size FROM backups b WHERE status = ?1 AND id = (
|
|
SELECT id FROM backups WHERE client_id = b.client_id AND status = ?1 ORDER BY created_at DESC, id DESC LIMIT 1)`, StatusComplete)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int64]int64{}
|
|
for rows.Next() {
|
|
var id, size int64
|
|
if err := rows.Scan(&id, &size); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = size
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// 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
|
|
}
|