diff --git a/config.go b/config.go index 0635050..0e3a3cf 100755 --- a/config.go +++ b/config.go @@ -78,6 +78,7 @@ var ( cfgStateFilePath = "/tmp/port_checker_state.json" cfgServiceStateFilePath = "/tmp/auspex_service_state.json" cfgDockerStateFilePath = "/tmp/auspex_docker_state.json" + cfgFDRecoverDir = "/var/tmp/auspex-recovered" ) // --- Параметры проверки портов --- @@ -223,6 +224,9 @@ func applyConfig(env map[string]string) { if v, ok := env["LOG_DIR"]; ok && v != "" { cfgLogDir = v } + if v, ok := env["FD_RECOVER_DIR"]; ok && v != "" { + cfgFDRecoverDir = v + } // Thresholds if v, ok := env["CERT_CRITICAL_DAYS"]; ok && v != "" { @@ -398,4 +402,4 @@ func printConfigStatus() { } fmt.Println() -} +} \ No newline at end of file diff --git a/fdcheck.go b/fdcheck.go new file mode 100644 index 0000000..90db377 --- /dev/null +++ b/fdcheck.go @@ -0,0 +1,404 @@ +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 +} diff --git a/go.mod b/go.mod index 8d36114..03635c8 100755 --- a/go.mod +++ b/go.mod @@ -31,5 +31,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect -) \ No newline at end of file + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum index 5a3e32b..dc1cbfc 100755 --- a/go.sum +++ b/go.sum @@ -50,9 +50,13 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= diff --git a/main.go b/main.go index fdf435d..19fb018 100755 --- a/main.go +++ b/main.go @@ -104,6 +104,13 @@ func runByFlag(flag string, extraArgs []string) { os.Exit(1) } + case "-fd": + fmt.Printf("%s🗑️ Auspex — Удалённые-но-открытые файловые дескрипторы%s\n\n", cCyan, cReset) + if err := runFDCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + case "-v", "--version": fmt.Printf("auspex v%s\n", appVersion) @@ -129,6 +136,7 @@ func showMenu() { fmt.Printf(" %s6%s) %s-sv%s Проверка состояния служб (systemd)\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s7%s) %s-dc%s Проверка контейнеров Docker\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s8%s) %s-w %s Проверка доступности сайтов (lynx) [sid]\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s9%s) %s-fd%s Удалённые-но-открытые файловые дескрипторы\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s0%s) Выход\n\n", cGreen, cReset) fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, cReset) fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc | auspex -w %s\n\n", cYellow, cReset) @@ -187,6 +195,11 @@ func showMenu() { fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) } return + case "9", "-fd", "fd": + if err := runFDCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return case "0", "q", "quit", "exit": fmt.Println("Выход.") return @@ -219,7 +232,8 @@ func printUsage() { fmt.Printf(" %-12s Проверка состояния служб systemd\n", "-sv") fmt.Printf(" %-12s Проверка контейнеров Docker\n", "-dc") fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid]\n", "-w") + fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd") fmt.Printf(" %-12s Показать версию\n", "-v") fmt.Printf(" %-12s Показать эту справку\n\n", "-h") fmt.Printf("Без аргументов запускается интерактивное меню.\n") -} +} \ No newline at end of file