Added docker actions

This commit is contained in:
Magnus Root 2026-07-27 13:35:23 +03:00
parent 61af364008
commit d74a323cdb
15 changed files with 862 additions and 35 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
# Binaries
auspex
auspex_waf
*.exe
*.exe~
*.dll

View file

@ -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 <t
---
### `dockerview.go` — Docker Live TUI (аналог lazydocker)
**Точка входа:** `runDockerTUI()`
В отличие от `dockercheck.go` — это интерактивный инструмент прямого действия, а не cron-снапшот с диффом. Переиспользует `dcNormalizeStatus`/`dcGetHealth`/`dcStatusEmoji`/`dcCheckDockerAvailable` из `dockercheck.go` (тот же пакет `main`), не дублируя парсинг статуса.
**Реализация:** `bubbletea`/`bubbles`/`lipgloss` (те же зависимости, что и в `portsview.go`, новых пакетов в `go.mod` не добавляет — `viewport` уже часть модуля `bubbles`).
**Автообновление:** список контейнеров (`docker ps -a` + `docker inspect` на health) обновляется раз в 3 сек, если включено (`a`).
**Действия:**
| Клавиша | Действие | Механизм |
|---|---|---|
| `l` | Логи | `docker logs -f --tail 200 <name>`, терминал передаётся через `tea.ExecProcess` |
| `e` | Шелл в контейнер | `docker exec -it <name> 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>` | `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`, иначе он может решить, что зависимость лишняя).

66
README.md Executable file → Normal file
View file

@ -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).

6
buildinfo_generic.go Normal file
View file

@ -0,0 +1,6 @@
//go:build !waf
package main
const buildHasWAF = false
const buildVariant = "generic"

6
buildinfo_waf.go Normal file
View file

@ -0,0 +1,6 @@
//go:build waf
package main
const buildHasWAF = true
const buildVariant = "full"

632
dockerview.go Normal file
View file

@ -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}
})
}

2
logcheck.go Executable file → Normal file
View file

@ -1,3 +1,5 @@
//go:build waf
package main
import (

13
logcheck_stub.go Normal file
View file

@ -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 .', чтобы получить этот модуль")
}

45
main.go Executable file → Normal file
View file

@ -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 <sid>%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")
}
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)
}
}

2
matchcheck.go Executable file → Normal file
View file

@ -1,3 +1,5 @@
//go:build waf
package main
import (

14
matchcheck_stub.go Normal file
View file

@ -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 .', чтобы получить этот модуль")
}

2
portcheck.go Executable file → Normal file
View file

@ -1,3 +1,5 @@
//go:build waf
package main
import (

13
portcheck_stub.go Normal file
View file

@ -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 .', чтобы получить этот модуль")
}

2
webcheck.go Executable file → Normal file
View file

@ -1,3 +1,5 @@
//go:build waf
package main
import (

13
webcheck_stub.go Normal file
View file

@ -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 .', чтобы получить этот модуль")
}