From d74a323cdbdf8d960a0c548e1d19a1074735bd63 Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Mon, 27 Jul 2026 13:35:23 +0300 Subject: [PATCH] Added docker actions --- .gitignore | 1 + ARCHITECTURE.md | 80 +++++- README.md | 66 +++-- buildinfo_generic.go | 6 + buildinfo_waf.go | 6 + dockerview.go | 632 +++++++++++++++++++++++++++++++++++++++++++ logcheck.go | 2 + logcheck_stub.go | 13 + main.go | 45 ++- matchcheck.go | 2 + matchcheck_stub.go | 14 + portcheck.go | 2 + portcheck_stub.go | 13 + webcheck.go | 2 + webcheck_stub.go | 13 + 15 files changed, 862 insertions(+), 35 deletions(-) mode change 100755 => 100644 README.md create mode 100644 buildinfo_generic.go create mode 100644 buildinfo_waf.go create mode 100644 dockerview.go mode change 100755 => 100644 logcheck.go create mode 100644 logcheck_stub.go mode change 100755 => 100644 main.go mode change 100755 => 100644 matchcheck.go create mode 100644 matchcheck_stub.go mode change 100755 => 100644 portcheck.go create mode 100644 portcheck_stub.go mode change 100755 => 100644 webcheck.go create mode 100644 webcheck_stub.go diff --git a/.gitignore b/.gitignore index d67c02b..796fab6 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binaries auspex +auspex_waf *.exe *.exe~ *.dll diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f202d35..502ec01 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,22 +31,24 @@ Auspex — монолитное CLI-приложение на Go. Все мод Отвечает за два режима запуска: -**CLI-флаги** (`runByFlag`): прямой вызов нужного модуля по флагу (`-c`, `-p`, `-l`, `-sv`, `-d`, `-m`, `-ss`, `-w`). +**CLI-флаги** (`runByFlag`): прямой вызов нужного модуля по флагу (`-c`, `-p`, `-l`, `-sv`, `-d`, `-m`, `-ss`, `-w`, `-fd`, `-dt`). -**Интерактивное меню** (`showMenu`): ASCII-баннер, numbered menu, `bufio.Reader` для ввода. Поддерживает как цифры (`1`-`8`), так и текстовые алиасы (`-c`, `c`). +**Интерактивное меню** (`showMenu`): ASCII-баннер, numbered menu, `bufio.Reader` для ввода. Поддерживает как цифры (`1`-`10`), так и текстовые алиасы (`-c`, `c`). ``` auspex [флаг] [аргументы] │ ├── -ss → runPortsView() ├── -c → runCertCheck() - ├── -p → runPortCheck() - ├── -m → runMatchCheck(clientFilter) - ├── -l → runLogCheck() + ├── -p → runPortCheck() [WAF, build tag] + ├── -m → runMatchCheck(clientFilter) [WAF, build tag] + ├── -l → runLogCheck() [WAF, build tag] ├── -sv → runServiceCheck() ├── -d → runDockerCheck() - ├── -w → runWebCheck(sid) - ├── -v → версия + ├── -w → runWebCheck(sid) [WAF, build tag] + ├── -fd → runFDCheck() + ├── -dt → runDockerTUI() + ├── -v → версия (+ вариант сборки: full/generic) └── -h → usage ``` @@ -305,6 +307,56 @@ Endpoint: `POST /api/v4/posts` с заголовком `Authorization: Bearer `, терминал передаётся через `tea.ExecProcess` | +| `e` | Шелл в контейнер | `docker exec -it sh -c 'exec bash \|\| exec sh'`, тоже через `tea.ExecProcess` | +| `i` | `docker inspect` | JSON форматируется через `encoding/json.Indent`, показывается в прокручиваемом `viewport` | +| `s`/`t`/`R`/`d` | stop/start/restart/remove (`rm -f`) | Через диалог подтверждения (`y`/`n`), показывающий точную команду | +| `r` / `a` | Ручной рефреш / автообновление | — | + +`tea.ExecProcess` — ключевой механизм для логов и шелла: приостанавливает рендеринг Bubble Tea, отдаёт реальный терминал дочернему процессу (полноценный интерактивный сеанс — ввод, TTY), и восстанавливает TUI после его завершения. Работает только в интерактивном терминале (не через cron/pipe). + +--- + +### `fdcheck.go` — удалённые-но-открытые файловые дескрипторы + +**Точка входа:** `runFDCheck()` + +Находит ситуации "место не освобождается после `rm`, потому что кто-то держит файл открытым". + +**Алгоритм:** +1. Обход `/proc/[pid]/fd/*` по всем процессам, `os.Readlink` на каждый — ищем таргеты с суффиксом `" (deleted)"` +2. Фильтрация шума: memfd, anon_inode, сокеты/пайпы, всё вне обычной ФС — не считаются утечкой места +3. Размер — через `os.Stat` на `/proc/PID/fd/N` (следует по magic-symlink к живому, хоть и удалённому, инoду) +4. Таблица отсортирована по размеру, интерактивный CLI-выбор записи → меню действий + +**Действия:** + +| Действие | Механизм | Примечание | +|---|---|---| +| Recover | `io.Copy` из `/proc/PID/fd/N` в новый файл | Снимок данных, места не освобождает | +| Reopen (SIGHUP/SIGUSR1) | `syscall.Kill(pid, sig)` | Работает только если демон сам поддерживает reopen (nginx/angie/rsyslog) | +| Restart | `systemctl restart ` | `unit` определяется через regex по `/proc/PID/cgroup` | +| Kill | SIGTERM → (3 сек) → SIGKILL | Освобождает место немедленно | +| Truncate на месте | `os.OpenFile(fdPath, O_WRONLY).Truncate(0)` | Тот же приём, что `logrotate copytruncate`; безопасно только для `O_APPEND`-файлов — режим открытия проверяется через `/proc/PID/fdinfo/N` (`flags:`, бит `O_APPEND`) | + +Truncate — единственное действие, которое освобождает место **без рестарта демона**: `ftruncate` меняет размер inode, а не конкретный fd, поэтому воздействует на все файловые описания, ссылающиеся на тот же inode, включая fd исходного процесса. + +--- + ### `webcheck.go` — проверка доступности сайтов **Точка входа:** `runWebCheck(sidArg string)` @@ -486,7 +538,21 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать | matchcheck | `mc` | | servicecheck | `sc` | | dockercheck | `dc` | +| dockerview | `dv` | +| fdcheck | `fd` | | email | `email` | | mattermost | `mm` | | webcheck | `wc` | | config | `cfg` | + +--- + +## Build tags: full vs generic + +`logcheck.go`, `webcheck.go`, `portcheck.go`, `matchcheck.go` требуют БД `waf_info` и/или структуру каталогов PT AF — они помечены `//go:build waf` и компилируются только при `go build -tags waf .`. + +Для generic-сборки (`go build .`, без тега) их заменяют файлы `*_stub.go` с обратным ограничением `//go:build !waf` — те же имена и сигнатуры функций (`runLogCheck`, `runWebCheck`, `runPortCheck`, `runMatchCheck`), но вместо логики — `fmt.Errorf` с подсказкой пересобрать с нужным тегом. Благодаря этому `main.go` не содержит условной компиляции и одинаково линкуется в обоих вариантах. + +`buildinfo_waf.go` / `buildinfo_generic.go` по той же схеме задают константу `buildVariant` (`"full"` / `"generic"`), которую показывают баннер меню и `auspex -v`. + +`lib/pq` используется только в трёх WAF-тегированных файлах — в generic-сборке он просто не компилируется (в `go.mod` остаётся объявленным, это нормально для двух конфигураций одного модуля; не стоит гонять `go mod tidy` без тега `waf`, иначе он может решить, что зависимость лишняя). \ No newline at end of file diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 7a81c26..88b9b9a --- a/README.md +++ b/README.md @@ -8,40 +8,69 @@ |------|----------| | `-ss` | Просмотр занятых портов (интерактивный TUI на bubbletea) | | `-c` | Проверка SSL сертификатов (сроки истечения, алерты) | -| `-p` | Проверка доступности портов из nginx upstream конфигов | -| `-m` | Проверка совпадений портов Angie ↔ Docker (+ проверка запуска контейнеров) | -| `-l` | Проверка обновления файлов PT AF access logs | +| `-p` | Проверка доступности портов из nginx upstream конфигов **[WAF]** | +| `-m` | Проверка совпадений портов Angie ↔ Docker (+ проверка запуска контейнеров) **[WAF]** | +| `-l` | Проверка обновления файлов PT AF access logs **[WAF]** | | `-sv` | Проверка работоспособности служб systemd | -| `-d` или `-dc` | Проверка работоспособности Docker контейнеров | +| `-d` или `-dc` | Проверка работоспособности Docker контейнеров (снапшот состояния + алерты при изменениях) | +| `-w` | Проверка доступности сайтов клиента через lynx **[WAF]** | +| `-fd` | Поиск удалённых-но-открытых файловых дескрипторов (recover/reopen/truncate/restart/kill) | +| `-dt` | Docker Live — интерактивный TUI (аналог lazydocker): логи, шелл, stop/start/restart/remove | + +Модули **[WAF]** требуют БД `waf_info` и/или структуру каталогов PT AF и доступны +только в сборке `full` (см. [Сборка](#сборка)). ## Структура проекта ``` auspex/ -├── main.go # Точка входа, меню, роутинг CLI-флагов -├── config.go # Глобальная конфигурация (переменные + загрузка из .env) -├── telegram.go # Отправка алертов в Telegram (поддержка HTTP и SOCKS5 прокси) -├── email.go # Отправка алертов по Email (SMTP, AUTH LOGIN) -├── mattermost.go # Отправка алертов в Mattermost (Bot API) -├── certcheck.go # -c: проверка SSL сертификатов -├── portcheck.go # -p: проверка доступности портов -├── matchcheck.go # -m: совпадения Angie ↔ Docker -├── logcheck.go # -l: проверка свежести логов -├── servicecheck.go # -sv: проверка служб systemd -├── dockercheck.go # -d: проверка Docker контейнеров -├── portsview.go # -ss: TUI просмотр занятых портов +├── main.go # Точка входа, меню, роутинг CLI-флагов +├── config.go # Глобальная конфигурация (переменные + загрузка из .env) +├── telegram.go # Отправка алертов в Telegram (поддержка HTTP и SOCKS5 прокси) +├── email.go # Отправка алертов по Email (SMTP, AUTH LOGIN) +├── mattermost.go # Отправка алертов в Mattermost (Bot API) +├── certcheck.go # -c: проверка SSL сертификатов +├── servicecheck.go # -sv: проверка служб systemd +├── dockercheck.go # -d: проверка Docker контейнеров (снапшот + diff) +├── dockerview.go # -dt: Docker Live TUI (аналог lazydocker) +├── portsview.go # -ss: TUI просмотр занятых портов +├── fdcheck.go # -fd: удалённые-но-открытые файловые дескрипторы +├── portcheck.go # -p: проверка доступности портов [WAF, build tag] +├── portcheck_stub.go # заглушка -p для generic-сборки [!WAF] +├── matchcheck.go # -m: совпадения Angie ↔ Docker [WAF, build tag] +├── matchcheck_stub.go # заглушка -m для generic-сборки [!WAF] +├── logcheck.go # -l: проверка свежести логов [WAF, build tag] +├── logcheck_stub.go # заглушка -l для generic-сборки [!WAF] +├── webcheck.go # -w: проверка доступности сайтов (lynx) [WAF, build tag] +├── webcheck_stub.go # заглушка -w для generic-сборки [!WAF] +├── buildinfo_waf.go # buildVariant = "full" [build tag: waf] +├── buildinfo_generic.go# buildVariant = "generic" [build tag: !waf] ├── go.mod └── README.md ``` ## Сборка +Два варианта сборки через Go build tags: + ```bash go mod tidy + +# generic — только модули без зависимости от WAF/PT AF: -c -sv -dc -ss -fd -dt go build -o auspex . + +# full — все модули, включая -p -m -l -w (требуют БД waf_info / структуру PT AF) +go build -tags waf -o auspex . + sudo cp auspex /usr/local/bin/auspex ``` +Файлы `logcheck.go`, `webcheck.go`, `portcheck.go`, `matchcheck.go` помечены +`//go:build waf` и не попадают в generic-сборку; вместо них компилируются +заглушки (`*_stub.go`), которые при вызове `-l`/`-w`/`-p`/`-m` выводят понятную +ошибку с подсказкой пересобрать с `-tags waf`. Какая сборка используется — +видно в баннере меню и в выводе `auspex -v` (`full`/`generic`). + ## Использование ```bash @@ -58,6 +87,8 @@ sudo cp auspex /usr/local/bin/auspex ./auspex -sv # Проверка работоспособности служб ./auspex -d # Проверка работоспособности контейнеров ./auspex -w # Проверка веб доступности +./auspex -fd # Поиск удалённых-но-открытых файловых дескрипторов +./auspex -dt # Docker Live TUI (аналог lazydocker) ./auspex -h # Справка ./auspex -v # Версия ``` @@ -132,6 +163,7 @@ sudo nano /etc/auspex/auspex.env | `STATE_FILE_PATH` | State-файл portcheck | `/tmp/port_checker_state.json` | | `SERVICE_STATE_FILE_PATH` | State-файл servicecheck | `/tmp/auspex_service_state.json` | | `DOCKER_STATE_FILE_PATH` | State-файл dockercheck | `/tmp/auspex_docker_state.json` | +| `FD_RECOVER_DIR` | Куда сохраняются снимки восстановленных файлов (`-fd`) | `/var/tmp/auspex-recovered` | #### Пороги сертификатов @@ -251,4 +283,4 @@ CERT_INFO_DAYS=30 ## Подробнее -См. [ARCHITECTURE.md](ARCHITECTURE.md). +См. [ARCHITECTURE.md](ARCHITECTURE.md). \ No newline at end of file diff --git a/buildinfo_generic.go b/buildinfo_generic.go new file mode 100644 index 0000000..9d49fef --- /dev/null +++ b/buildinfo_generic.go @@ -0,0 +1,6 @@ +//go:build !waf + +package main + +const buildHasWAF = false +const buildVariant = "generic" diff --git a/buildinfo_waf.go b/buildinfo_waf.go new file mode 100644 index 0000000..59dc78d --- /dev/null +++ b/buildinfo_waf.go @@ -0,0 +1,6 @@ +//go:build waf + +package main + +const buildHasWAF = true +const buildVariant = "full" diff --git a/dockerview.go b/dockerview.go new file mode 100644 index 0000000..85ea712 --- /dev/null +++ b/dockerview.go @@ -0,0 +1,632 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ============================================================================= +// dockerview — live TUI для Docker-контейнеров (аналог lazydocker) +// ============================================================================= +// +// В отличие от dockercheck.go (-d/-dc, снапшот + диффы состояния для cron), +// этот модуль — интерактивный, с автообновлением и прямыми действиями: +// stop/start/restart/remove, просмотр логов (docker logs -f) и подключение +// к шеллу контейнера (docker exec -it), плюс просмотр docker inspect. +// +// Переиспользует dcNormalizeStatus/dcGetHealth/dcStatusEmoji из dockercheck.go +// (тот же пакет main) — не дублируем парсинг статуса. +// ============================================================================= + +// ── Типы ───────────────────────────────────────────────────────────────────── + +type dvContainerEntry struct { + ID string + Name string + Image string + Status string + Health string + Ports string +} + +type dvLoadedMsg struct { + entries []dvContainerEntry + err error +} + +type dvAutoTickMsg struct{} + +type dvActionResultMsg struct { + action string + target string + output string + err error +} + +type dvInspectMsg struct { + content string + err error +} + +type dvExecFinishedMsg struct { + action string + err error +} + +type dvModel struct { + table table.Model + textInput textinput.Model + viewport viewport.Model + + entries []dvContainerEntry + filtered []dvContainerEntry + + err error + status string + + width, height int + + isFiltering bool + loading bool + autoRefresh bool + lastRefresh time.Time + + showInspect bool + loadingInspect bool + + confirmAction string // "stop" | "start" | "restart" | "remove" + confirmTarget dvContainerEntry +} + +// ── Стили ──────────────────────────────────────────────────────────────────── + +var ( + dvBaseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#6c7086")). + Padding(1, 2) + + dvLogoStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#89b4fa")). + Padding(0, 1) + + dvHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6c7086")). + Padding(0, 1) + + dvStatusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a6e3a1")). + Bold(true) + + dvErrorStatusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f38ba8")). + Bold(true) + + dvInputStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#89b4fa")). + Padding(0, 1) + + dvDetailsStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#89b4fa")). + Padding(1, 2). + Width(100) + + dvDetailsTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#89b4fa")). + Padding(0, 0, 1, 0) + + dvConfirmStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#f38ba8")). + Padding(1, 3) + + dvConfirmTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#f38ba8")). + Padding(0, 0, 1, 0) + + dvLoadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f9e2af")). + Bold(true) +) + +// ── Точка входа ────────────────────────────────────────────────────────────── + +func runDockerTUI() error { + if err := dcCheckDockerAvailable(); err != nil { + return fmt.Errorf("docker недоступен: %v", err) + } + + columns := []table.Column{ + {Title: "Имя", Width: 22}, + {Title: "Статус", Width: 14}, + {Title: "Health", Width: 10}, + {Title: "Образ", Width: 26}, + {Title: "Порты", Width: 22}, + {Title: "ID", Width: 12}, + } + + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(12), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#6c7086")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("#cdd6f4")). + Background(lipgloss.Color("#313244")). + Bold(false) + t.SetStyles(s) + + ti := textinput.New() + ti.Placeholder = "Поиск по имени, образу, статусу..." + ti.CharLimit = 156 + ti.Width = 40 + + vp := viewport.New(96, 20) + + m := dvModel{ + table: t, + textInput: ti, + viewport: vp, + autoRefresh: true, + } + + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("ошибка запуска TUI: %w", err) + } + return nil +} + +// ── Tea Model ──────────────────────────────────────────────────────────────── + +func (m dvModel) Init() tea.Cmd { + return tea.Batch(dvLoadAsync, textinput.Blink, dvTickAuto) +} + +func dvTickAuto() tea.Msg { + time.Sleep(3 * time.Second) + return dvAutoTickMsg{} +} + +func dvLoadAsync() tea.Msg { + entries, err := dvGetContainers() + return dvLoadedMsg{entries: entries, err: err} +} + +func (m dvModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Автообновление — работает в фоне независимо от текущего режима. + if _, ok := msg.(dvAutoTickMsg); ok { + var cmds []tea.Cmd + if m.autoRefresh && !m.loading && time.Since(m.lastRefresh) > 3*time.Second { + cmds = append(cmds, dvLoadAsync) + } + cmds = append(cmds, dvTickAuto) + return m, tea.Batch(cmds...) + } + + // ── Просмотр docker inspect ────────────────────────────────────────── + if m.showInspect { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "esc" || msg.String() == "q" { + m.showInspect = false + m.loadingInspect = false + return m, nil + } + case dvInspectMsg: + m.loadingInspect = false + if msg.err != nil { + m.viewport.SetContent(fmt.Sprintf("Ошибка: %v", msg.err)) + } else { + m.viewport.SetContent(msg.content) + } + return m, nil + } + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + // ── Диалог подтверждения (stop/start/restart/remove) ───────────────── + if m.confirmAction != "" { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "y", "Y", "enter": + action := m.confirmAction + target := m.confirmTarget + m.confirmAction = "" + m.status = fmt.Sprintf("⏳ %s: %s...", action, target.Name) + return m, dvRunAction(action, target.Name) + case "n", "N", "esc": + m.status = "Отменено" + m.confirmAction = "" + return m, nil + } + } + return m, nil + } + + // ── Строка поиска ───────────────────────────────────────────────────── + if m.isFiltering { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "enter" || keyMsg.String() == "esc" { + m.isFiltering = false + m.table.Focus() + return m, nil + } + } + m.textInput, cmd = m.textInput.Update(msg) + m.dvFilter() + return m, cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "/": + m.isFiltering = true + m.textInput.Focus() + m.textInput.SetValue("") + return m, textinput.Blink + + case "r": + if !m.loading { + m.status = "Обновляю..." + m.loading = true + return m, dvLoadAsync + } + + case "a": + m.autoRefresh = !m.autoRefresh + if m.autoRefresh { + m.status = "Автообновление: включено" + } else { + m.status = "Автообновление: выключено" + } + + case "s": // stop + if e, ok := m.dvSelected(); ok { + m.confirmAction = "stop" + m.confirmTarget = e + } + + case "t": // start + if e, ok := m.dvSelected(); ok { + m.confirmAction = "start" + m.confirmTarget = e + } + + case "R": // restart (заглавная, чтобы не конфликтовать с "r" = refresh) + if e, ok := m.dvSelected(); ok { + m.confirmAction = "restart" + m.confirmTarget = e + } + + case "d": // remove + if e, ok := m.dvSelected(); ok { + m.confirmAction = "remove" + m.confirmTarget = e + } + + case "l": // logs -f (передаём терминал docker logs) + if e, ok := m.dvSelected(); ok { + return m, dvExecLogs(e.Name) + } + + case "e": // shell в контейнер + if e, ok := m.dvSelected(); ok { + return m, dvExecShell(e.Name) + } + + case "i": // docker inspect + if e, ok := m.dvSelected(); ok { + m.showInspect = true + m.loadingInspect = true + m.viewport.SetContent("Загрузка...") + return m, dvInspectCmd(e.Name) + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + avail := m.height - 7 + dvBaseStyle = dvBaseStyle.Width(m.width - 2).Height(avail) + m.table.SetWidth(m.width - 4) + th := avail - 2 + if th < 2 { + th = 2 + } + m.table.SetHeight(th) + m.viewport.Width = m.width - 8 + m.viewport.Height = avail - 2 + + case dvLoadedMsg: + m.loading = false + m.lastRefresh = time.Now() + if msg.err != nil { + m.err = msg.err + } else { + m.entries = msg.entries + m.err = nil + m.dvFilter() + if m.status == "Обновляю..." { + m.status = "Обновлено" + } + } + + case dvActionResultMsg: + if msg.err != nil { + m.status = fmt.Sprintf("❌ %s (%s): %v — %s", msg.action, msg.target, msg.err, msg.output) + } else { + m.status = fmt.Sprintf("✅ %s (%s) выполнено", msg.action, msg.target) + } + return m, dvLoadAsync + + case dvExecFinishedMsg: + switch msg.action { + case "logs": + m.status = "Просмотр логов завершён" + case "exec": + if msg.err != nil { + m.status = "Шелл завершён с ошибкой (см. вывод выше)" + } else { + m.status = "Шелл закрыт" + } + } + return m, dvLoadAsync + + case error: + m.err = msg + m.loading = false + } + + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m *dvModel) dvSelected() (dvContainerEntry, bool) { + idx := m.table.Cursor() + if idx < 0 || idx >= len(m.filtered) { + return dvContainerEntry{}, false + } + return m.filtered[idx], true +} + +func (m *dvModel) dvFilter() { + q := strings.ToLower(m.textInput.Value()) + m.filtered = nil + for _, e := range m.entries { + if q == "" || + strings.Contains(strings.ToLower(e.Name), q) || + strings.Contains(strings.ToLower(e.Image), q) || + strings.Contains(strings.ToLower(e.Status), q) || + strings.Contains(strings.ToLower(e.Ports), q) { + m.filtered = append(m.filtered, e) + } + } + m.dvUpdateTable() +} + +func (m *dvModel) dvUpdateTable() { + rows := []table.Row{} + for _, e := range m.filtered { + icon := dcStatusEmoji(e.Status, e.Health) + health := e.Health + if health == "" { + health = "none" + } + rows = append(rows, table.Row{ + e.Name, icon + " " + e.Status, health, e.Image, e.Ports, e.ID, + }) + } + m.table.SetRows(rows) +} + +// ── Отрисовка ──────────────────────────────────────────────────────────────── + +func (m dvModel) View() string { + if m.err != nil { + return fmt.Sprintf("Ошибка: %v\nНажмите 'q' для выхода", m.err) + } + + if m.showInspect { + title := dvDetailsTitleStyle.Render("🔍 docker inspect") + body := title + "\n" + if m.loadingInspect { + body += dvLoadingStyle.Render("Загрузка...") + "\n" + } else { + body += m.viewport.View() + } + body += "\n\n" + dvHelpStyle.Render("↑/↓ PgUp/PgDn: Прокрутка • Esc/q: Закрыть") + box := dvDetailsStyle.Render(body) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) + } + + if m.confirmAction != "" { + box := dvConfirmStyle.Render(dvConfirmMessage(m.confirmAction, m.confirmTarget)) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) + } + + logo := dvLogoStyle.Render("🐳 Auspex — Docker Live") + if m.loading { + logo += " " + dvLoadingStyle.Render("●") + } + if m.autoRefresh { + logo += " " + dvHelpStyle.Render("[AUTO]") + } + + tableView := dvBaseStyle.Render(m.table.View()) + + controls := "↑/↓: Навигация • /: Поиск • l: Логи • e: Шелл • i: Inspect • " + + "s: Stop • t: Start • R: Restart • d: Remove • r: Refresh • a: Auto • q: Выход" + + if m.isFiltering { + inputView := dvInputStyle.Render(m.textInput.View()) + return fmt.Sprintf("%s\n%s\n%s\n%s", logo, tableView, inputView, + dvHelpStyle.Render("Введите текст для поиска • Esc/Enter: Готово")) + } + + status := m.status + renderedControls := dvHelpStyle.Render(controls) + if status != "" { + style := dvStatusStyle + if strings.HasPrefix(status, "❌") { + style = dvErrorStatusStyle + } + renderedControls = style.Render(status) + " • " + renderedControls + } + + return fmt.Sprintf("%s\n%s\n%s", logo, tableView, renderedControls) +} + +func dvConfirmMessage(action string, t dvContainerEntry) string { + var verb, cmdStr string + switch action { + case "stop": + verb, cmdStr = "ОСТАНОВИТЬ", fmt.Sprintf("docker stop %s", t.Name) + case "start": + verb, cmdStr = "ЗАПУСТИТЬ", fmt.Sprintf("docker start %s", t.Name) + case "restart": + verb, cmdStr = "ПЕРЕЗАПУСТИТЬ", fmt.Sprintf("docker restart %s", t.Name) + case "remove": + verb, cmdStr = "УДАЛИТЬ (force)", fmt.Sprintf("docker rm -f %s", t.Name) + } + + title := dvConfirmTitleStyle.Render(fmt.Sprintf("⚠️ %s контейнер?", verb)) + return fmt.Sprintf("%s\nКонтейнер: %s\nКоманда: %s\n\n%s", + title, t.Name, cmdStr, dvHelpStyle.Render("y: Да • n/Esc: Отмена")) +} + +// ── Системные функции ──────────────────────────────────────────────────────── + +// dvGetContainers получает список контейнеров с портами. +// Health переиспользует dcGetHealth, статус — dcNormalizeStatus (dockercheck.go). +func dvGetContainers() ([]dvContainerEntry, error) { + out, err := exec.Command( + "docker", "ps", "-a", + "--format", "{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}|{{.Ports}}", + ).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("docker ps: %v\n%s", err, string(out)) + } + + var entries []dvContainerEntry + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 5) + if len(parts) < 4 { + continue + } + + e := dvContainerEntry{ + ID: strings.TrimSpace(parts[0]), + Name: strings.TrimSpace(parts[1]), + Status: dcNormalizeStatus(parts[2]), + Image: strings.TrimSpace(parts[3]), + } + if len(parts) == 5 { + e.Ports = strings.TrimSpace(parts[4]) + } + entries = append(entries, e) + } + + for i := range entries { + entries[i].Health = dcGetHealth(entries[i].ID) + } + + sort.Slice(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) + }) + + return entries, nil +} + +// dvRunAction выполняет docker stop/start/restart/rm -f и возвращает результат. +func dvRunAction(action, name string) tea.Cmd { + return func() tea.Msg { + var cmd *exec.Cmd + switch action { + case "stop": + cmd = exec.Command("docker", "stop", name) + case "start": + cmd = exec.Command("docker", "start", name) + case "restart": + cmd = exec.Command("docker", "restart", name) + case "remove": + cmd = exec.Command("docker", "rm", "-f", name) + default: + return dvActionResultMsg{action: action, target: name, err: fmt.Errorf("неизвестное действие")} + } + + out, err := cmd.CombinedOutput() + return dvActionResultMsg{ + action: action, target: name, + output: strings.TrimSpace(string(out)), err: err, + } + } +} + +// dvInspectCmd получает docker inspect и красиво форматирует JSON. +func dvInspectCmd(name string) tea.Cmd { + return func() tea.Msg { + out, err := exec.Command("docker", "inspect", name).Output() + if err != nil { + return dvInspectMsg{err: fmt.Errorf("docker inspect: %w", err)} + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, out, "", " "); err != nil { + return dvInspectMsg{content: string(out)} // fallback — как есть + } + return dvInspectMsg{content: pretty.String()} + } +} + +// dvExecLogs передаёт терминал `docker logs -f` — TUI приостанавливается на время +// просмотра, возврат в TUI происходит по Ctrl+C (завершает logs) или выходу. +func dvExecLogs(name string) tea.Cmd { + c := exec.Command("docker", "logs", "-f", "--tail", "200", name) + return tea.ExecProcess(c, func(err error) tea.Msg { + return dvExecFinishedMsg{action: "logs", err: err} + }) +} + +// dvExecShell аналогично передаёт терминал интерактивному шеллу внутри контейнера. +// Пробует bash, если недоступен — падает обратно на sh (стандартный подход +// для минимальных образов вроде alpine). +func dvExecShell(name string) tea.Cmd { + shellProbe := `if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi` + c := exec.Command("docker", "exec", "-it", name, "sh", "-c", shellProbe) + return tea.ExecProcess(c, func(err error) tea.Msg { + return dvExecFinishedMsg{action: "exec", err: err} + }) +} diff --git a/logcheck.go b/logcheck.go old mode 100755 new mode 100644 index 439af35..c779edb --- a/logcheck.go +++ b/logcheck.go @@ -1,3 +1,5 @@ +//go:build waf + package main import ( diff --git a/logcheck_stub.go b/logcheck_stub.go new file mode 100644 index 0000000..f6c17d4 --- /dev/null +++ b/logcheck_stub.go @@ -0,0 +1,13 @@ +//go:build !waf + +package main + +import "fmt" + +// Заглушка для сборки без тега 'waf'. Реальная реализация — в logcheck.go +// (собирается только с 'go build -tags waf .'), т.к. модуль требует БД waf_info +// и специфичной для PT AF структуры путей (/var/log/ptaf_nginx/...). +func runLogCheck() error { + return fmt.Errorf("модуль -l (logcheck) недоступен в этой сборке: требует БД waf_info.\n" + + "Пересоберите бинарник с 'go build -tags waf .', чтобы получить этот модуль") +} diff --git a/main.go b/main.go old mode 100755 new mode 100644 index 19fb018..ef7e48c --- a/main.go +++ b/main.go @@ -111,8 +111,14 @@ func runByFlag(flag string, extraArgs []string) { os.Exit(1) } + case "-dt": + if err := runDockerTUI(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + case "-v", "--version": - fmt.Printf("auspex v%s\n", appVersion) + fmt.Printf("auspex v%s (%s)\n", appVersion, buildVariant) case "-h", "--help": printUsage() @@ -130,13 +136,14 @@ func showMenu() { fmt.Printf("%sВыберите утилиту:%s\n\n", cBlue, cReset) fmt.Printf(" %s1%s) %s-ss%s Просмотр занятых портов (TUI интерфейс)\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s2%s) %s-c %s Проверка SSL сертификатов\n", cGreen, cReset, cCyan, cReset) - fmt.Printf(" %s3%s) %s-p %s Проверка доступности портов (nginx upstream)\n", cGreen, cReset, cCyan, cReset) - fmt.Printf(" %s4%s) %s-m %s Проверка совпадений портов Angie ↔ Docker\n", cGreen, cReset, cCyan, cReset) - fmt.Printf(" %s5%s) %s-l %s Проверка активности логов (PostgreSQL)\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s3%s) %s-p %s Проверка доступности портов (nginx upstream) [WAF]\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s4%s) %s-m %s Проверка совпадений портов Angie ↔ Docker [WAF]\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s5%s) %s-l %s Проверка активности логов (PostgreSQL) [WAF]\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(" %s8%s) %s-w %s Проверка доступности сайтов (lynx) [sid]\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s8%s) %s-w %s Проверка доступности сайтов (lynx) [sid] [WAF]\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s9%s) %s-fd%s Удалённые-но-открытые файловые дескрипторы\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s10%s) %s-dt%s Docker Live — TUI (аналог lazydocker)\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) @@ -200,6 +207,11 @@ func showMenu() { fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) } return + case "10", "-dt", "dt": + if err := runDockerTUI(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return case "0", "q", "quit", "exit": fmt.Println("Выход.") return @@ -218,7 +230,12 @@ func printBanner() { ██║ ██║╚██████╔╝███████║██║ ███████╗██╔╝ ██╗ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝ %s`, cCyan, cReset) - fmt.Printf(" %sv%s%s — набор утилит мониторинга инфраструктуры\n\n", cBlue, appVersion, cReset) + fmt.Printf(" %sv%s%s — набор утилит мониторинга инфраструктуры\n", cBlue, appVersion, cReset) + if buildHasWAF { + fmt.Printf(" %sСборка: full (включает WAF-модули -p, -l, -m, -w)%s\n\n", cYellow, cReset) + } else { + fmt.Printf(" %sСборка: generic (без WAF-модулей -p, -l, -m, -w — см. -h)%s\n\n", cYellow, cReset) + } } func printUsage() { @@ -226,14 +243,20 @@ func printUsage() { fmt.Printf("Флаги:\n") fmt.Printf(" %-12s Просмотр занятых портов (интерактивный TUI)\n", "-ss") fmt.Printf(" %-12s Проверка SSL сертификатов в %s\n", "-c", cfgSSLDir) - fmt.Printf(" %-12s Проверка доступности портов (nginx upstream)\n", "-p") - fmt.Printf(" %-12s Проверка совпадений портов Angie ↔ Docker [имя_клиента]\n", "-m") - fmt.Printf(" %-12s Проверка активности логов (PostgreSQL)\n", "-l") + fmt.Printf(" %-12s Проверка доступности портов (nginx upstream) [WAF]\n", "-p") + fmt.Printf(" %-12s Проверка совпадений портов Angie ↔ Docker [имя_клиента] [WAF]\n", "-m") + fmt.Printf(" %-12s Проверка активности логов (PostgreSQL) [WAF]\n", "-l") fmt.Printf(" %-12s Проверка состояния служб systemd\n", "-sv") fmt.Printf(" %-12s Проверка контейнеров Docker\n", "-dc") - fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid]\n", "-w") + fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid] [WAF]\n", "-w") fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd") + fmt.Printf(" %-12s Docker Live — интерактивный TUI (аналог lazydocker)\n", "-dt") fmt.Printf(" %-12s Показать версию\n", "-v") fmt.Printf(" %-12s Показать эту справку\n\n", "-h") fmt.Printf("Без аргументов запускается интерактивное меню.\n") -} \ No newline at end of file + if !buildHasWAF { + fmt.Printf("\n%s⚠️ Эта сборка (generic) не включает модули [WAF] (-p, -m, -l, -w) —\n"+ + " они требуют БД waf_info / структуры PT AF. Соберите с 'go build -tags waf .',\n"+ + " чтобы получить полную (full) сборку.%s\n", cYellow, cReset) + } +} diff --git a/matchcheck.go b/matchcheck.go old mode 100755 new mode 100644 index 17226b5..d61c553 --- a/matchcheck.go +++ b/matchcheck.go @@ -1,3 +1,5 @@ +//go:build waf + package main import ( diff --git a/matchcheck_stub.go b/matchcheck_stub.go new file mode 100644 index 0000000..c963853 --- /dev/null +++ b/matchcheck_stub.go @@ -0,0 +1,14 @@ +//go:build !waf + +package main + +import "fmt" + +// Заглушка для сборки без тега 'waf'. Реальная реализация — в matchcheck.go +// (собирается только с 'go build -tags waf .'). БД не использует, но жёстко +// завязан на структуру каталогов и нейминг контейнеров PT AF (cfgPTAFBaseDir, +// паттерны вида "{client}-ptaf-agent003"), поэтому на generic-сервере бесполезен. +func runMatchCheck(clientFilter string) error { + return fmt.Errorf("модуль -m (matchcheck) недоступен в этой сборке: завязан на структуру PT AF.\n" + + "Пересоберите бинарник с 'go build -tags waf .', чтобы получить этот модуль") +} diff --git a/portcheck.go b/portcheck.go old mode 100755 new mode 100644 index 92eccef..3548ac2 --- a/portcheck.go +++ b/portcheck.go @@ -1,3 +1,5 @@ +//go:build waf + package main import ( diff --git a/portcheck_stub.go b/portcheck_stub.go new file mode 100644 index 0000000..a4a7cc2 --- /dev/null +++ b/portcheck_stub.go @@ -0,0 +1,13 @@ +//go:build !waf + +package main + +import "fmt" + +// Заглушка для сборки без тега 'waf'. Реальная реализация — в portcheck.go +// (собирается только с 'go build -tags waf .'), т.к. модуль фильтрует конфиги +// через флаг check_ports из таблицы apps_settings в БД waf_info. +func runPortCheck() error { + return fmt.Errorf("модуль -p (portcheck) недоступен в этой сборке: требует БД waf_info.\n" + + "Пересоберите бинарник с 'go build -tags waf .', чтобы получить этот модуль") +} diff --git a/webcheck.go b/webcheck.go old mode 100755 new mode 100644 index 5838f07..51238fd --- a/webcheck.go +++ b/webcheck.go @@ -1,3 +1,5 @@ +//go:build waf + package main import ( diff --git a/webcheck_stub.go b/webcheck_stub.go new file mode 100644 index 0000000..55f0f51 --- /dev/null +++ b/webcheck_stub.go @@ -0,0 +1,13 @@ +//go:build !waf + +package main + +import "fmt" + +// Заглушка для сборки без тега 'waf'. Реальная реализация — в webcheck.go +// (собирается только с 'go build -tags waf .'), т.к. модуль читает домены +// клиента из таблицы sp_info в БД waf_info. +func runWebCheck(sidArg string) error { + return fmt.Errorf("модуль -w (webcheck) недоступен в этой сборке: требует БД waf_info.\n" + + "Пересоберите бинарник с 'go build -tags waf .', чтобы получить этот модуль") +}