404 lines
14 KiB
Go
404 lines
14 KiB
Go
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
|
||
}
|