package main import ( "bufio" "fmt" "io" "os" "os/exec" "os/user" "path/filepath" "regexp" "sort" "strconv" "strings" "syscall" "time" ) // ============================================================================= // fdcheck — поиск "фантомных" удалённых-но-открытых файловых дескрипторов // ============================================================================= // // Ситуация: файл удалён (rm), но место на диске не освободилось, потому что // какой-то процесс всё ещё держит открытый файловый дескриптор на него. // Модуль находит такие дескрипторы через /proc/[pid]/fd/*, показывает какой // процесс и сколько места держит, и даёт интерактивно выбрать действие: // восстановить содержимое, отправить reopen-сигнал, перезапустить службу // или убить процесс. // ============================================================================= type fdEntry struct { PID int ProcessName string FDNum string FDPath string // /proc/PID/fd/N OriginalPath string SizeBytes int64 Owner string } var fdDeletedSuffix = " (deleted)" // runFDCheck — точка входа модуля func runFDCheck() error { if os.Geteuid() != 0 { fmt.Printf("%s⚠️ Запущено не от root — часть процессов будет недоступна для просмотра (Permission denied в /proc/PID/fd).%s\n\n", cYellow, cReset) } reader := bufio.NewReader(os.Stdin) for { fmt.Println("🔎 Сканирую /proc в поисках удалённых-но-открытых файлов...") entries := fdScan() if len(entries) == 0 { fmt.Printf("\n%s✅ Удалённые-но-открытые дескрипторы не найдены.%s\n", cGreen, cReset) return nil } sort.Slice(entries, func(i, j int) bool { return entries[i].SizeBytes > entries[j].SizeBytes }) fdPrintTable(entries) fmt.Printf("\n%sВведите номер записи для действия (Enter — обновить список, q — выход): %s", cGreen, cReset) input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) if input == "q" || input == "Q" || input == "quit" || input == "exit" { return nil } if input == "" { continue // обновить список } idx, err := strconv.Atoi(input) if err != nil || idx < 1 || idx > len(entries) { fmt.Printf("%sНекорректный номер.%s\n\n", cRed, cReset) continue } fdHandleAction(entries[idx-1], reader) fmt.Println() } } // ── Сканирование ───────────────────────────────────────────────────────────── func fdScan() []fdEntry { var results []fdEntry procEntries, err := os.ReadDir("/proc") if err != nil { fmt.Printf("%sОшибка чтения /proc: %v%s\n", cRed, err, cReset) return results } for _, pe := range procEntries { pid, err := strconv.Atoi(pe.Name()) if err != nil { continue // не PID-директория } fdDir := fmt.Sprintf("/proc/%d/fd", pid) fds, err := os.ReadDir(fdDir) if err != nil { continue // нет прав или процесс уже завершился } procName := fdGetProcessName(pid) owner := fdGetOwner(pid) for _, fdInfo := range fds { fdPath := filepath.Join(fdDir, fdInfo.Name()) target, err := os.Readlink(fdPath) if err != nil { continue } if !strings.HasSuffix(target, fdDeletedSuffix) { continue } originalPath := strings.TrimSuffix(target, fdDeletedSuffix) // Отфильтровываем то, что не является настоящей утечкой места на диске: // memfd, анонимные inode, сокеты/пайпы и всё вне обычной ФС. if !strings.HasPrefix(originalPath, "/") || strings.HasPrefix(originalPath, "/dev/") || strings.HasPrefix(originalPath, "/proc/") || strings.Contains(originalPath, "memfd:") || strings.HasPrefix(originalPath, "anon_inode:") || strings.HasPrefix(originalPath, "socket:") || strings.HasPrefix(originalPath, "pipe:") { continue } var size int64 if st, err := os.Stat(fdPath); err == nil { size = st.Size() } results = append(results, fdEntry{ PID: pid, ProcessName: procName, FDNum: fdInfo.Name(), FDPath: fdPath, OriginalPath: originalPath, SizeBytes: size, Owner: owner, }) } } return results } func fdGetProcessName(pid int) string { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) if err != nil { return "unknown" } return strings.TrimSpace(string(data)) } func fdGetOwner(pid int) string { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) if err != nil { return "?" } for _, line := range strings.Split(string(data), "\n") { if strings.HasPrefix(line, "Uid:") { fields := strings.Fields(line) if len(fields) >= 2 { if u, err := user.LookupId(fields[1]); err == nil { return u.Username } return fields[1] } } } return "?" } // ── Вывод таблицы ───────────────────────────────────────────────────────────── func fdPrintTable(entries []fdEntry) { var totalSize int64 fmt.Printf("\n%s%-4s %-8s %-16s %-6s %-10s %-10s %s%s\n", cCyan, "№", "PID", "Процесс", "FD", "Владелец", "Размер", "Путь (удалён)", cReset) fmt.Println(strings.Repeat("─", 100)) for i, e := range entries { fmt.Printf("%-4d %-8d %-16s %-6s %-10s %-10s %s\n", i+1, e.PID, fdTruncate(e.ProcessName, 16), e.FDNum, e.Owner, fdHumanSize(e.SizeBytes), e.OriginalPath) totalSize += e.SizeBytes } fmt.Println(strings.Repeat("─", 100)) fmt.Printf("%sНайдено записей: %d | Суммарно занято (с учётом возможных дублей): %s%s\n", cYellow, len(entries), fdHumanSize(totalSize), cReset) } func fdTruncate(s string, n int) string { if len(s) <= n { return s } return s[:n-1] + "…" } func fdHumanSize(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp]) } // ── Интерактивное меню действий ─────────────────────────────────────────────── func fdHandleAction(e fdEntry, reader *bufio.Reader) { fmt.Printf("\n%sДескриптор:%s PID %d (%s), fd %s, владелец %s\n", cBlue, cReset, e.PID, e.ProcessName, e.FDNum, e.Owner) fmt.Printf("%sПуть:%s %s (%s)\n", cBlue, cReset, e.OriginalPath, fdHumanSize(e.SizeBytes)) unit, hasUnit := fdDetectSystemdUnit(e.PID) if hasUnit { 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) choice, _ := reader.ReadString('\n') choice = strings.TrimSpace(choice) switch choice { case "1": fdActionRecover(e, reader) case "2": fdActionReopenSignal(e, reader) case "3": fdActionRestart(e, unit, hasUnit) case "4": fdActionKill(e) case "5": fdActionTruncate(e, reader, appendMode, appendErr) case "0", "": fmt.Println("Отменено.") default: fmt.Printf("%sНеизвестный выбор.%s\n", cRed, cReset) } } // 1. Восстановление содержимого в новый файл func fdActionRecover(e fdEntry, reader *bufio.Reader) { 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(cfgFDRecoverDir, defaultName) fmt.Printf("Путь для сохранения [%s]: ", defaultPath) dest, _ := reader.ReadString('\n') dest = strings.TrimSpace(dest) 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 { return 0, fmt.Errorf("не удалось создать директорию %s: %w", filepath.Dir(dest), err) } src, err := os.Open(e.FDPath) if err != nil { return 0, fmt.Errorf("не удалось открыть %s: %w", e.FDPath, err) } defer src.Close() out, err := os.Create(dest) if err != nil { return 0, fmt.Errorf("не удалось создать %s: %w", dest, err) } defer out.Close() written, err := io.Copy(out, src) if err != nil { return written, fmt.Errorf("ошибка копирования: %w", err) } return written, nil } // 2. Мягкий reopen сигнал (SIGHUP/SIGUSR1) — для служб, поддерживающих переоткрытие // файлов логов без полного рестарта (nginx, angie, rsyslog и т.п.) func fdActionReopenSignal(e fdEntry, reader *bufio.Reader) { fmt.Println("Какой сигнал отправить?") fmt.Println(" 1) SIGHUP (большинство демонов: nginx, angie, rsyslog, sshd)") fmt.Println(" 2) SIGUSR1 (некоторые демоны используют его для reopen логов)") fmt.Printf("%s>>> %s", cGreen, cReset) choice, _ := reader.ReadString('\n') choice = strings.TrimSpace(choice) var sig syscall.Signal var sigName string switch choice { case "1", "": sig, sigName = syscall.SIGHUP, "SIGHUP" case "2": sig, sigName = syscall.SIGUSR1, "SIGUSR1" default: fmt.Printf("%sНекорректный выбор.%s\n", cRed, cReset) return } if err := syscall.Kill(e.PID, sig); err != nil { fmt.Printf("%s❌ Не удалось отправить %s процессу %d: %v%s\n", cRed, sigName, e.PID, err, cReset) return } fmt.Printf("%s✅ %s отправлен процессу %d (%s).%s\n", cGreen, sigName, e.PID, e.ProcessName, cReset) fmt.Printf("%sℹ️ Сигнал сработает только если процесс явно обрабатывает reopen-логику.\n"+ " Если через пару секунд дескриптор всё ещё в списке — сигнал не помог, используйте restart/kill.%s\n", cYellow, cReset) } // 3. Перезапуск через systemd (если удалось определить unit) func fdActionRestart(e fdEntry, unit string, hasUnit bool) { if !hasUnit { fmt.Printf("%s❌ Не удалось определить systemd-unit для PID %d (процесс не в cgroup системной службы).\n"+ " Перезапустите процесс вручную или используйте kill (пункт 4).%s\n", cRed, e.PID, cReset) return } fmt.Printf("Выполняю: systemctl restart %s\n", unit) cmd := exec.Command("systemctl", "restart", unit) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("%s❌ Ошибка перезапуска %s: %v\n%s%s\n", cRed, unit, err, string(output), cReset) return } fmt.Printf("%s✅ Служба %s перезапущена.%s\n", cGreen, unit, cReset) } // fdDetectSystemdUnit пытается определить systemd-unit процесса через его cgroup. func fdDetectSystemdUnit(pid int) (string, bool) { data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)) if err != nil { return "", false } re := regexp.MustCompile(`([A-Za-z0-9_.@-]+\.service)`) matches := re.FindStringSubmatch(string(data)) if len(matches) < 2 { return "", false } return matches[1], true } // 4. Убить процесс: сначала SIGTERM, при неудаче — SIGKILL func fdActionKill(e fdEntry) { fmt.Printf("Отправляю SIGTERM процессу %d (%s)...\n", e.PID, e.ProcessName) if err := syscall.Kill(e.PID, syscall.SIGTERM); err != nil { fmt.Printf("%s❌ Не удалось отправить SIGTERM: %v%s\n", cRed, err, cReset) return } time.Sleep(3 * time.Second) if fdProcessAlive(e.PID) { fmt.Printf("Процесс всё ещё жив, отправляю SIGKILL...\n") if err := syscall.Kill(e.PID, syscall.SIGKILL); err != nil { fmt.Printf("%s❌ Не удалось отправить SIGKILL: %v%s\n", cRed, err, cReset) return } } fmt.Printf("%s✅ Процесс %d завершён, место на диске будет освобождено.%s\n", cGreen, e.PID, cReset) fmt.Printf("%sℹ️ Если процесс управляется systemd/супервизором — он должен перезапуститься автоматически\n"+ " и заново открыть файл по исходному пути.%s\n", cYellow, cReset) } 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) }