package agent import ( "database/sql" "fmt" "os" "path/filepath" "regexp" "strings" "gitea.hsrv.site/mrleo1nid/backup-master/internal/archive" "gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest" "gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx" _ "modernc.org/sqlite" ) const xuiDB = "/etc/x-ui/x-ui.db" // snapshotXUI делает консистентную копию базы 3x-ui на горячую (VACUUM INTO), // без логина в панель и без остановки сервиса. func (c *collector) snapshotXUI(mc *manifest.Category) error { if _, err := os.Stat(xuiDB); err != nil { c.warn("xui: нет %s", xuiDB) return nil } dir, err := c.spoolDir(mc.Name) if err != nil { return err } out := filepath.Join(dir, "x-ui.db") if err := vacuumInto(xuiDB, out); err != nil { c.warn("xui: снимок базы не удался: %v", err) return nil } mc.Dumps = append(mc.Dumps, manifest.Dump{Name: "x-ui.db", Kind: "sqlite", Target: xuiDB}) c.add(manifest.MainPart, dumpItem(mc.Name, out, "x-ui.db")) // сертификаты панели, подписки и инбаундов могут лежать где угодно — берём пути из базы for _, p := range xuiCertPaths(out) { if !sysx.Exists(p) { c.warn("xui: в настройках указан %s, но файла нет", p) continue } if archive.Covers(mc.Paths, filepath.ToSlash(p)) { continue } mc.Paths = append(mc.Paths, p) c.add(manifest.MainPart, filesItem(mc.Name, p, nil)) } return nil } var certFileRe = regexp.MustCompile(`"(?:certificateFile|keyFile)"\s*:\s*"([^"]+)"`) // xuiCertPaths читает из снимка базы 3x-ui пути к сертификатам и ключам. func xuiCertPaths(dbPath string) []string { db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro") if err != nil { return nil } defer db.Close() seen := map[string]bool{} var out []string addPath := func(p string) { p = strings.TrimSpace(p) if strings.HasPrefix(p, "/") && !seen[p] { seen[p] = true out = append(out, p) } } if rows, err := db.Query(`SELECT value FROM settings WHERE key IN ('webCertFile','webKeyFile','subCertFile','subKeyFile')`); err == nil { for rows.Next() { var v string if rows.Scan(&v) == nil { addPath(v) } } rows.Close() } if rows, err := db.Query(`SELECT stream_settings FROM inbounds`); err == nil { for rows.Next() { var v string if rows.Scan(&v) == nil { for _, m := range certFileRe.FindAllStringSubmatch(v, -1) { addPath(m[1]) } } } rows.Close() } return out } func vacuumInto(src, dst string) error { _ = os.Remove(dst) db, err := sql.Open("sqlite", "file:"+src+"?_pragma=busy_timeout(10000)") if err != nil { return err } defer db.Close() _, err = db.Exec(fmt.Sprintf("VACUUM INTO '%s'", strings.ReplaceAll(dst, "'", "''"))) return err }