auspex/fdcheck.go

404 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 процесса (пункты 34).%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
}