Files
backup-master/internal/restore/recipes.go
T
mrleo1nidandClaude Opus 5.5 cbd3cde01d
CI / test (push) Successful in 1m48s
Бэкап fail2ban и своих geo-файлов 3x-ui
- новая категория fail2ban: jail.local, jail.d (в т.ч. 3x-ipl.local),
  IP-лимит 3x-ui (filter.d/action.d/3x-ipl.conf), свои *.local;
  при восстановлении создаются логи 3xipl*.log, без которых jail не поднимается
- xui: свои *.dat из /usr/local/x-ui/bin (ipcheck, discord-*) — на них ссылается
  роутинг; штатные geo-файлы релиза не бэкапятся
- recipe 3x-ui больше не очищает /usr/local/x-ui и не стирает восстановленные файлы
- маски (*, ?) в paths и keep_binaries каталога
- provision ставит nftables вместе с fail2ban
- .gitattributes: LF во всех файлах

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:24:21 +03:00

242 lines
6.6 KiB
Go

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)
// каталог не очищаем: в нём уже лежат восстановленные свои geo-файлы (ipcheck.dat и т.п.),
// архив релиза перезапишет только свои файлы
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
}