auspex/fdcheck.go
2026-07-27 13:13:01 +03:00

523 lines
20 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)
}
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 (пункты 35).%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)
}