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

+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}}