Added file descriptor module

This commit is contained in:
Magnus Root 2026-07-27 13:00:20 +03:00
parent 8e2544b431
commit bce97223f0
5 changed files with 430 additions and 4 deletions

View file

@ -78,6 +78,7 @@ var (
cfgStateFilePath = "/tmp/port_checker_state.json" cfgStateFilePath = "/tmp/port_checker_state.json"
cfgServiceStateFilePath = "/tmp/auspex_service_state.json" cfgServiceStateFilePath = "/tmp/auspex_service_state.json"
cfgDockerStateFilePath = "/tmp/auspex_docker_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 != "" { if v, ok := env["LOG_DIR"]; ok && v != "" {
cfgLogDir = v cfgLogDir = v
} }
if v, ok := env["FD_RECOVER_DIR"]; ok && v != "" {
cfgFDRecoverDir = v
}
// Thresholds // Thresholds
if v, ok := env["CERT_CRITICAL_DAYS"]; ok && v != "" { if v, ok := env["CERT_CRITICAL_DAYS"]; ok && v != "" {
@ -398,4 +402,4 @@ func printConfigStatus() {
} }
fmt.Println() fmt.Println()
} }

404
fdcheck.go Normal file
View file

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

4
go.mod
View file

@ -31,5 +31,5 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.23.0 // indirect
) )

4
go.sum
View file

@ -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= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 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/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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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=

16
main.go
View file

@ -104,6 +104,13 @@ func runByFlag(flag string, extraArgs []string) {
os.Exit(1) 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": case "-v", "--version":
fmt.Printf("auspex v%s\n", appVersion) 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(" %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(" %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(" %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(" %s0%s) Выход\n\n", cGreen, cReset)
fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, 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 <sid>%s\n\n", cYellow, cReset) fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc | auspex -w <sid>%s\n\n", cYellow, cReset)
@ -187,6 +195,11 @@ func showMenu() {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
} }
return return
case "9", "-fd", "fd":
if err := runFDCheck(); err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
}
return
case "0", "q", "quit", "exit": case "0", "q", "quit", "exit":
fmt.Println("Выход.") fmt.Println("Выход.")
return return
@ -219,7 +232,8 @@ func printUsage() {
fmt.Printf(" %-12s Проверка состояния служб systemd\n", "-sv") fmt.Printf(" %-12s Проверка состояния служб systemd\n", "-sv")
fmt.Printf(" %-12s Проверка контейнеров Docker\n", "-dc") fmt.Printf(" %-12s Проверка контейнеров Docker\n", "-dc")
fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid]\n", "-w") 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", "-v")
fmt.Printf(" %-12s Показать эту справку\n\n", "-h") fmt.Printf(" %-12s Показать эту справку\n\n", "-h")
fmt.Printf("Без аргументов запускается интерактивное меню.\n") fmt.Printf("Без аргументов запускается интерактивное меню.\n")
} }