diff --git a/cmd/glchat/cli.go b/cmd/glchat/cli.go index 033f856..ea4b85d 100644 --- a/cmd/glchat/cli.go +++ b/cmd/glchat/cli.go @@ -294,8 +294,8 @@ func cliCleanup(args []string) int { options.OrphanHours = *orphanHours } stats, err := retention.New(st, options).RunOnce(ctx) - fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d (%d байт)\n", - stats.Sessions, stats.Audit, stats.Files, stats.Bytes) + fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d (%d байт)\n", + stats.Sessions, stats.Audit, stats.Files, stats.Unknown, stats.Bytes) return err }) } diff --git a/deploy/glchat-cli.sh b/deploy/glchat-cli.sh index 6b9b4d6..ef1cd13 100755 --- a/deploy/glchat-cli.sh +++ b/deploy/glchat-cli.sh @@ -18,6 +18,7 @@ glchat — управление инстансом glchat restart [service] перезапуск стека или сервиса glchat backup бэкап БД и файлов (age-шифрование) glchat restore <файл> восстановление из бэкапа + glchat verify-backup <файл> проверить бэкап, не трогая данные glchat update [--channel X] обновление (бэкап → pull → up → smoke) glchat reset-password сброс пароля пользователя (фаза 1) glchat make-admin выдать права инстанс-админа (фаза 1) @@ -151,6 +152,17 @@ cmd_version() { [ -f "$ENV_FILE" ] && grep -E '^(APP_VERSION|IMAGE_TAG|DOMAIN|PROFILE)=' "$ENV_FILE" || true } +# cmd_verify_backup проверяет архив бэкапа: расшифровка, целостность базы и +# состав данных (AGENT.md 10.8). +cmd_verify_backup() { + require_stack + [ -n "${1:-}" ] || { + echo "использование: glchat verify-backup <файл.tar.gz[.age]>" >&2 + exit 2 + } + GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR}" bash "${GLCHAT_ETC_DIR}/verify-backup.sh" "$@" +} + cmd_reindex() { require_stack ${COMPOSE} exec -T app glchat reindex @@ -182,6 +194,7 @@ main() { profile) cmd_profile "$@" ;; security-check) cmd_security_check "$@" ;; reindex) cmd_reindex "$@" ;; + verify-backup) cmd_verify_backup "$@" ;; cleanup) cmd_cleanup "$@" ;; change-domain) cmd_change_domain "$@" ;; uninstall) cmd_uninstall "$@" ;; diff --git a/deploy/install.sh b/deploy/install.sh index b457c97..b344266 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -814,8 +814,10 @@ ENVEOF cat >"$pairs_file" <&2 + exit 1 +} + +ARCHIVE="${1:-}" +[ -n "$ARCHIVE" ] || die "использование: glchat verify-backup <файл.tar.gz[.age]>" +[ -f "$ARCHIVE" ] || die "файл не найден: $ARCHIVE" + +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + set -a && . "$ENV_FILE" && set +a +fi + +WORK_DIR="$(mktemp -d)" +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT + +# 1. Расшифровка: age-архив требует приватный ключ. +case "$ARCHIVE" in + *.age) + command -v age >/dev/null 2>&1 || die "age не установлен" + KEY_FILE="${AGE_KEY_FILE:-${GLCHAT_DATA_DIR}/backups/age-key.txt}" + [ -f "$KEY_FILE" ] || die "не найден приватный age-ключ (${KEY_FILE}); укажите AGE_KEY_FILE" + log "расшифровываем $(basename "$ARCHIVE")" + age -d -i "$KEY_FILE" -o "${WORK_DIR}/backup.tar.gz" "$ARCHIVE" || + die "не удалось расшифровать бэкап (проверьте ключ)" + ;; + *) cp "$ARCHIVE" "${WORK_DIR}/backup.tar.gz" ;; +esac + +# 2. Распаковка и обязательные файлы. +log "распаковываем" +tar -xzf "${WORK_DIR}/backup.tar.gz" -C "$WORK_DIR" || die "архив повреждён" +[ -f "${WORK_DIR}/glchat.db" ] || die "в архиве нет glchat.db — это не бэкап glchat" + +stats() { printf '%s' "$1"; } +DB="${WORK_DIR}/glchat.db" + +# 3. Целостность базы: sqlite3 есть на хосте или в контейнере приложения. +sqlite_query() { + local query="$1" + if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 -readonly "$DB" "$query" + return + fi + if docker ps --format '{{.Names}}' | grep -q '^glchat-app$'; then + docker cp "$DB" glchat-app:/tmp/verify-backup.db >/dev/null + docker exec glchat-app sh -c "sqlite3 -readonly /tmp/verify-backup.db \"$query\"" + docker exec glchat-app rm -f /tmp/verify-backup.db >/dev/null 2>&1 || true + return + fi + die "нет sqlite3 ни на хосте, ни в контейнере glchat-app" +} + +log "проверяем целостность базы" +integrity="$(sqlite_query 'PRAGMA integrity_check;')" +[ "$integrity" = "ok" ] || die "integrity_check вернул: ${integrity}" +log "integrity_check: ok" + +foreign_keys="$(sqlite_query 'PRAGMA foreign_key_check;' || true)" +[ -z "$foreign_keys" ] || die "нарушены внешние ключи: ${foreign_keys}" +log "foreign_key_check: чисто" + +schema_version="$(sqlite_query 'SELECT MAX(version_id) FROM goose_db_version;' 2>/dev/null || echo '?')" +users="$(sqlite_query 'SELECT COUNT(*) FROM users WHERE deleted_at IS NULL;')" +guilds="$(sqlite_query 'SELECT COUNT(*) FROM guilds;')" +messages="$(sqlite_query 'SELECT COUNT(*) FROM messages;')" +files="$(sqlite_query 'SELECT COUNT(*) FROM files;')" + +# 4. Манифест: подсказывает, с какого инстанса снят бэкап. +manifest="нет" +if [ -f "${WORK_DIR}/MANIFEST.txt" ]; then + manifest="$(tr '\n' ' ' <"${WORK_DIR}/MANIFEST.txt")" +fi + +files_on_disk=0 +if [ -d "${WORK_DIR}/files" ]; then + files_on_disk="$(find "${WORK_DIR}/files" -type f | wc -l | tr -d ' ')" +fi + +printf '\n=== проверка бэкапа ===\n' +printf ' архив: %s\n' "$(basename "$ARCHIVE")" +printf ' схема: %s\n' "$schema_version" +printf ' пользователи: %s\n' "$users" +printf ' серверы: %s\n' "$guilds" +printf ' сообщения: %s\n' "$messages" +printf ' файлов в БД: %s (на диске: %s)\n' "$files" "$files_on_disk" +printf ' манифест: %s\n' "$manifest" + +# 5. Файлы: если в базе есть записи о файлах, каталог не должен быть пустым. +if [ "${files:-0}" -gt 0 ] && [ "${files_on_disk:-0}" -eq 0 ]; then + die "в базе ${files} файлов, а в архиве нет каталога files — бэкап неполный" +fi + +log "бэкап пригоден к восстановлению" diff --git a/internal/retention/retention.go b/internal/retention/retention.go index 8d8fdd7..c0b4899 100644 --- a/internal/retention/retention.go +++ b/internal/retention/retention.go @@ -36,6 +36,8 @@ type Stats struct { Sessions int64 Audit int64 Files int64 + // Unknown — файлы на диске, у которых не осталось записи в базе. + Unknown int64 // Bytes — сколько освободили на диске. Bytes int64 } @@ -104,6 +106,18 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { } } + // 4. Файлы на диске без записи в базе: так остаются вложения удалённых + // серверов — запись уходит каскадом, а содержимое лежит мусором. + if r.options.OrphanHours > 0 { + cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour) + removed, bytes, err := r.pruneUnknownFiles(ctx, cutoff) + if err != nil { + errs = append(errs, fmt.Errorf("unknown files: %w", err)) + } + stats.Unknown = removed + stats.Bytes += bytes + } + stats2 := stats if len(errs) > 0 { return stats2, errors.Join(errs...) @@ -111,6 +125,89 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { return stats2, nil } +// pruneUnknownFiles удаляет с диска файлы, которых нет в базе, если они старше +// cutoff. Возвращает число файлов и освобождённые байты. +func (r *Runner) pruneUnknownFiles(ctx context.Context, cutoff time.Time) (int64, int64, error) { + known, err := r.store.FileStoragePaths(ctx) + if err != nil { + return 0, 0, err + } + // Каталог файлов: пути в базе абсолютные, как их пишет загрузка. + root := filepath.Join(r.options.DataDir, "files") + // os.Root ограничивает работу каталогом файлов: подменённая символическая + // ссылка не выведет удаление за его пределы (AGENT.md 9.2). + dir, err := os.OpenRoot(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return 0, 0, nil + } + return 0, 0, err + } + defer func() { _ = dir.Close() }() + + var ( + removed int64 + bytes int64 + errs []error + ) + walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || known[path] { + return nil + } + info, err := entry.Info() + if err != nil { + errs = append(errs, err) + return nil + } + if info.ModTime().After(cutoff) { + // Свежий файл мог ещё не получить запись: не трогаем. + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + errs = append(errs, err) + return nil + } + if err := dir.Remove(relative); err != nil && !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, err) + return nil + } + removed++ + bytes += info.Size() + return nil + }) + if walkErr != nil { + errs = append(errs, walkErr) + } + // Пустые каталоги после уборки не нужны: их создаёт раскладка по префиксам. + if removed > 0 { + pruneEmptyDirs(dir, root) + } + return removed, bytes, errors.Join(errs...) +} + +// pruneEmptyDirs удаляет пустые подкаталоги, начиная с самых глубоких. +// Ошибки игнорируются: каталог мог исчезнуть или быть непустым — это норма. +func pruneEmptyDirs(dir *os.Root, root string) { + dirs := make([]string, 0, 16) + _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err == nil && entry.IsDir() && path != root { + dirs = append(dirs, path) + } + return nil + }) + for i := len(dirs) - 1; i >= 0; i-- { + relative, err := filepath.Rel(root, dirs[i]) + if err != nil { + continue + } + _ = dir.Remove(relative) + } +} + // Run запускает чистку по расписанию; возвращает функцию остановки. func (r *Runner) Run(ctx context.Context, interval time.Duration) func() { if interval <= 0 { @@ -137,6 +234,7 @@ func (r *Runner) Run(ctx context.Context, interval time.Duration) func() { slog.Int64("sessions", stats.Sessions), slog.Int64("audit", stats.Audit), slog.Int64("files", stats.Files), + slog.Int64("unknown_files", stats.Unknown), slog.Int64("bytes", stats.Bytes)) } } @@ -169,6 +267,8 @@ func (r *Runner) removeFile(file store.File) (int64, error) { } // storagePath проверяет, что путь лежит внутри каталога данных (AGENT.md 9.2). +// Файлы на диске лежат плоско в `/files`, поэтому проверка сводится +// к сравнению с этим каталогом. func (r *Runner) storagePath(stored string) (string, error) { root := filepath.Join(r.options.DataDir, "files") clean := filepath.Clean(stored) diff --git a/internal/retention/retention_test.go b/internal/retention/retention_test.go index 538bf5f..3610326 100644 --- a/internal/retention/retention_test.go +++ b/internal/retention/retention_test.go @@ -209,3 +209,49 @@ func TestCleanupKeepsFreshOrphansAndRecentAudit(t *testing.T) { t.Fatalf("свежий файл удалён: %v", err) } } + +func TestCleanupRemovesFilesWithoutRecords(t *testing.T) { + st, _, dataDir := newRetentionStore(t) + ctx := context.Background() + filesDir := filepath.Join(dataDir, "files") + nested := filepath.Join(filesDir, "ab") + if err := os.MkdirAll(nested, 0o700); err != nil { + t.Fatalf("prepare files dir: %v", err) + } + + // Файл на диске, записи о котором нет: так остаются вложения удалённых + // серверов (запись уходит каскадом, содержимое — нет). + orphan := filepath.Join(nested, "orphan.bin") + if err := os.WriteFile(orphan, []byte("мусор"), 0o600); err != nil { + t.Fatalf("write orphan: %v", err) + } + old := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(orphan, old, old); err != nil { + t.Fatalf("touch orphan: %v", err) + } + + // Свежий файл без записи не трогаем: он может ещё не получить запись. + fresh := filepath.Join(filesDir, "fresh.bin") + if err := os.WriteFile(fresh, []byte("новый"), 0o600); err != nil { + t.Fatalf("write fresh: %v", err) + } + + runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24}) + stats, err := runner.RunOnce(ctx) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + if stats.Unknown != 1 { + t.Fatalf("удалено файлов без записи: %d, ожидался 1", stats.Unknown) + } + if _, err := os.Stat(orphan); !os.IsNotExist(err) { + t.Fatalf("файл без записи остался: %v", err) + } + if _, err := os.Stat(fresh); err != nil { + t.Fatalf("свежий файл удалён: %v", err) + } + // Пустой подкаталог после уборки удалён. + if _, err := os.Stat(nested); !os.IsNotExist(err) { + t.Fatalf("пустой каталог остался: %v", err) + } +} diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index d9f6416..1bd63e0 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "net/http" + "os" "strconv" "strings" "time" @@ -1686,9 +1687,22 @@ func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uin if guild.IsMain { return humaErrorStatus(http.StatusForbidden, "guild.main_protected", "main guild cannot be deleted") } + // Пути файлов собираем до удаления: записи уходят каскадом, а содержимое + // на диске осталось бы мусором (AGENT.md 6.4). + storagePaths, err := s.store.GuildStoragePaths(ctx, guildID) + if err != nil { + s.logger.WarnContext(ctx, "failed to list guild files", + slog.String("guild_id", formatSnowflake(guildID)), slog.Any("error", err)) + } if err := s.store.DeleteGuild(ctx, guildID); err != nil { return humaError(err) } + for _, path := range storagePaths { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + s.logger.WarnContext(ctx, "failed to remove guild file", + slog.String("path", path), slog.Any("error", err)) + } + } s.invalidateGuild(guildID) s.recordAudit(ctx, actor, 0, "guild.delete", "guild", &guildID, "") if s.gateway != nil { diff --git a/internal/store/files.go b/internal/store/files.go index 4ba924d..01627b1 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -164,3 +164,44 @@ func scanFile(scanner interface{ Scan(...any) error }) (*File, error) { file.CreatedAt = parseTimestamp(createdAt) return &file, nil } + +// FileStoragePaths возвращает пути всех файлов, известных базе: по ним +// обслуживание понимает, какие файлы на диске остались без записи. +func (s *Store) FileStoragePaths(ctx context.Context) (map[string]bool, error) { + rows, err := s.reader.QueryContext(ctx, `SELECT storage_path FROM files WHERE storage_path <> ''`) + if err != nil { + return nil, mapError(err) + } + defer rows.Close() + + paths := map[string]bool{} + for rows.Next() { + var path string + if err := rows.Scan(&path); err != nil { + return nil, mapError(err) + } + paths[path] = true + } + return paths, rows.Err() +} + +// GuildStoragePaths возвращает пути файлов сервера: их нужно удалить с диска +// перед удалением сервера, иначе содержимое останется мусором (AGENT.md 6.4). +func (s *Store) GuildStoragePaths(ctx context.Context, guildID uint64) ([]string, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT storage_path FROM files WHERE guild_id = ? AND storage_path <> ''`, int64(guildID)) + if err != nil { + return nil, mapError(err) + } + defer rows.Close() + + paths := make([]string, 0, 8) + for rows.Next() { + var path string + if err := rows.Scan(&path); err != nil { + return nil, err + } + paths = append(paths, path) + } + return paths, rows.Err() +}