diff --git a/fdcheck.go b/fdcheck.go index 90db377..f8061a2 100644 --- a/fdcheck.go +++ b/fdcheck.go @@ -236,11 +236,18 @@ func fdHandleAction(e fdEntry, reader *bufio.Reader) { fmt.Printf("%sОпределена служба systemd:%s %s\n", cBlue, cReset, unit) } + appendMode, appendErr := fdIsAppendMode(e.PID, e.FDNum) + fmt.Println("\nВыберите действие:") fmt.Println(" 1) Восстановить содержимое в новый файл (recover)") fmt.Println(" 2) Мягкий reopen сигналом (SIGHUP / SIGUSR1)") fmt.Println(" 3) Перезапустить службу (systemctl restart)") fmt.Println(" 4) Убить процесс (SIGTERM → SIGKILL)") + if appendErr == nil && appendMode { + fmt.Println(" 5) Truncate на месте — без рестарта (файл открыт в O_APPEND, безопасно)") + } else { + fmt.Println(" 5) Truncate на месте — без рестарта (⚠️ НЕ O_APPEND, риск повреждения данных)") + } fmt.Println(" 0) Отмена") fmt.Printf("%s>>> %s", cGreen, cReset) @@ -256,6 +263,8 @@ func fdHandleAction(e fdEntry, reader *bufio.Reader) { fdActionRestart(e, unit, hasUnit) case "4": fdActionKill(e) + case "5": + fdActionTruncate(e, reader, appendMode, appendErr) case "0", "": fmt.Println("Отменено.") default: @@ -265,10 +274,24 @@ func fdHandleAction(e fdEntry, reader *bufio.Reader) { // 1. Восстановление содержимого в новый файл func fdActionRecover(e fdEntry, reader *bufio.Reader) { - defaultDir := cfgFDRecoverDir + dest := fdPromptRecoverPath(e, reader) + + written, err := fdRecoverToFile(e, dest) + if err != nil { + fmt.Printf("%s❌ %v%s\n", cRed, err, cReset) + return + } + + fmt.Printf("%s✅ Восстановлено %s в %s%s\n", cGreen, fdHumanSize(written), dest, cReset) + fmt.Printf("%sℹ️ Это снимок текущего содержимого. Процесс %d продолжит писать в удалённый инод,\n"+ + " место освободится только после truncate/kill/restart (пункты 3–5).%s\n", cYellow, e.PID, cReset) +} + +// fdPromptRecoverPath спрашивает путь для сохранения снимка, предлагая разумный дефолт. +func fdPromptRecoverPath(e fdEntry, reader *bufio.Reader) string { timestamp := time.Now().Format("20060102_150405") defaultName := fmt.Sprintf("%s_pid%d_fd%s_%s", filepath.Base(e.OriginalPath), e.PID, e.FDNum, timestamp) - defaultPath := filepath.Join(defaultDir, defaultName) + defaultPath := filepath.Join(cfgFDRecoverDir, defaultName) fmt.Printf("Путь для сохранения [%s]: ", defaultPath) dest, _ := reader.ReadString('\n') @@ -276,35 +299,33 @@ func fdActionRecover(e fdEntry, reader *bufio.Reader) { if dest == "" { dest = defaultPath } + return dest +} +// fdRecoverToFile копирует текущее содержимое удалённого файла в dest. +func fdRecoverToFile(e fdEntry, dest string) (int64, error) { if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { - fmt.Printf("%s❌ Не удалось создать директорию %s: %v%s\n", cRed, filepath.Dir(dest), err, cReset) - return + return 0, fmt.Errorf("не удалось создать директорию %s: %w", filepath.Dir(dest), err) } src, err := os.Open(e.FDPath) if err != nil { - fmt.Printf("%s❌ Не удалось открыть %s: %v%s\n", cRed, e.FDPath, err, cReset) - return + return 0, fmt.Errorf("не удалось открыть %s: %w", e.FDPath, err) } defer src.Close() out, err := os.Create(dest) if err != nil { - fmt.Printf("%s❌ Не удалось создать %s: %v%s\n", cRed, dest, err, cReset) - return + return 0, fmt.Errorf("не удалось создать %s: %w", dest, err) } defer out.Close() written, err := io.Copy(out, src) if err != nil { - fmt.Printf("%s❌ Ошибка копирования: %v%s\n", cRed, err, cReset) - return + return written, fmt.Errorf("ошибка копирования: %w", err) } - fmt.Printf("%s✅ Восстановлено %s в %s%s\n", cGreen, fdHumanSize(written), dest, cReset) - fmt.Printf("%sℹ️ Это снимок текущего содержимого. Процесс %d продолжит писать в удалённый инод,\n"+ - " место освободится только после kill/restart процесса (пункты 3–4).%s\n", cYellow, e.PID, cReset) + return written, nil } // 2. Мягкий reopen сигнал (SIGHUP/SIGUSR1) — для служб, поддерживающих переоткрытие @@ -402,3 +423,101 @@ func fdProcessAlive(pid int) bool { _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) return err == nil } + +// ── Truncate на месте (copytruncate без рестарта) ──────────────────────────── + +// fdIsAppendMode определяет, открыт ли fd в режиме O_APPEND через /proc/PID/fdinfo/N. +// Это критично: если O_APPEND — ядро само двигает позицию записи в конец файла +// перед каждым write(), поэтому truncate-до-нуля безопасен (ровно то, что делает +// logrotate в режиме copytruncate). Если нет — у процесса свой курсор записи, +// и после truncate следующая запись создаст дыру не в том месте, где ожидает +// приложение (риск повреждения данных / SIGBUS при mmap). +func fdIsAppendMode(pid int, fdNum string) (bool, error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/fdinfo/%s", pid, fdNum)) + if err != nil { + return false, fmt.Errorf("не удалось прочитать fdinfo: %w", err) + } + + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "flags:") { + fields := strings.Fields(line) + if len(fields) < 2 { + return false, fmt.Errorf("неожиданный формат строки flags") + } + flags, err := strconv.ParseInt(fields[1], 8, 64) + if err != nil { + return false, fmt.Errorf("не удалось распарсить flags: %w", err) + } + return flags&syscall.O_APPEND != 0, nil + } + } + + return false, fmt.Errorf("строка flags не найдена в fdinfo") +} + +// 5. Truncate на месте: обнуляет файл через тот же inode, процесс не перезапускается. +func fdActionTruncate(e fdEntry, reader *bufio.Reader, appendMode bool, appendErr error) { + fmt.Printf("\n%s── Truncate на месте (copytruncate) ──%s\n", cBlue, cReset) + fmt.Println("Механизм: открываем тот же inode через /proc/PID/fd/N и обнуляем его.") + fmt.Println("Место освобождается мгновенно, процесс НЕ перезапускается и продолжает писать в этот же fd.") + + switch { + case appendErr != nil: + fmt.Printf("%s⚠️ Не удалось определить режим открытия (%v).\n"+ + " Считаю файл НЕ append-safe — truncate может повредить данные.%s\n", cYellow, appendErr, cReset) + case appendMode: + fmt.Printf("%s✅ Файл открыт в режиме O_APPEND — это типичный лог, truncate безопасен\n"+ + " (процесс сам переходит в конец файла перед каждой записью).%s\n", cGreen, cReset) + default: + fmt.Printf("%s⚠️⚠️⚠️ Файл НЕ открыт в O_APPEND! У процесса свой курсор записи —\n"+ + " после truncate следующая запись попадёт не в начало, а в старую позицию,\n"+ + " образуя дыру. Если файл ещё и замаплен в память (mmap) — возможен SIGBUS\n"+ + " и падение процесса. Это НЕ похоже на append-лог (БД, бинарный формат,\n"+ + " временный файл с произвольным доступом). Рекомендуется kill/restart вместо этого.%s\n", + cRed, cReset) + } + + fmt.Print("\nСохранить снимок содержимого перед truncate? [Y/n]: ") + snapAns, _ := reader.ReadString('\n') + snapAns = strings.TrimSpace(strings.ToLower(snapAns)) + if snapAns != "n" && snapAns != "no" { + dest := fdPromptRecoverPath(e, reader) + written, err := fdRecoverToFile(e, dest) + if err != nil { + fmt.Printf("%s❌ Снимок не сохранён: %v — truncate прерван для безопасности.%s\n", cRed, err, cReset) + return + } + fmt.Printf("%s✅ Снимок сохранён: %s (%s)%s\n", cGreen, dest, fdHumanSize(written), cReset) + } + + if !appendMode { + fmt.Print("\nФайл не в режиме O_APPEND. Введите заглавными 'ПОНИМАЮ РИСК' для продолжения: ") + confirm, _ := reader.ReadString('\n') + if strings.TrimSpace(confirm) != "ПОНИМАЮ РИСК" { + fmt.Println("Отменено.") + return + } + } else { + fmt.Print("\nПодтвердите truncate, введите 'TRUNCATE': ") + confirm, _ := reader.ReadString('\n') + if strings.TrimSpace(confirm) != "TRUNCATE" { + fmt.Println("Отменено.") + return + } + } + + out, err := os.OpenFile(e.FDPath, os.O_WRONLY, 0) + if err != nil { + fmt.Printf("%s❌ Не удалось открыть %s на запись: %v%s\n", cRed, e.FDPath, err, cReset) + return + } + defer out.Close() + + if err := out.Truncate(0); err != nil { + fmt.Printf("%s❌ Ошибка truncate: %v%s\n", cRed, err, cReset) + return + } + + fmt.Printf("%s✅ Файл обнулён (%s освобождено), процесс %d (%s) продолжает работать без рестарта.%s\n", + cGreen, fdHumanSize(e.SizeBytes), e.PID, e.ProcessName, cReset) +}