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) } fmt.Println("\nВыберите действие:") fmt.Println(" 1) Восстановить содержимое в новый файл (recover)") fmt.Println(" 2) Мягкий reopen сигналом (SIGHUP / SIGUSR1)") fmt.Println(" 3) Перезапустить службу (systemctl restart)") fmt.Println(" 4) Убить процесс (SIGTERM → SIGKILL)") 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 "0", "": fmt.Println("Отменено.") default: fmt.Printf("%sНеизвестный выбор.%s\n", cRed, cReset) } } // 1. Восстановление содержимого в новый файл func fdActionRecover(e fdEntry, reader *bufio.Reader) { defaultDir := cfgFDRecoverDir 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) fmt.Printf("Путь для сохранения [%s]: ", defaultPath) dest, _ := reader.ReadString('\n') dest = strings.TrimSpace(dest) if dest == "" { dest = defaultPath } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { fmt.Printf("%s❌ Не удалось создать директорию %s: %v%s\n", cRed, filepath.Dir(dest), err, cReset) return } src, err := os.Open(e.FDPath) if err != nil { fmt.Printf("%s❌ Не удалось открыть %s: %v%s\n", cRed, e.FDPath, err, cReset) return } defer src.Close() out, err := os.Create(dest) if err != nil { fmt.Printf("%s❌ Не удалось создать %s: %v%s\n", cRed, dest, err, cReset) return } defer out.Close() written, err := io.Copy(out, src) 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"+ " место освободится только после kill/restart процесса (пункты 3–4).%s\n", cYellow, e.PID, cReset) } // 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 }