logwatch — легкая системная утилита на C для моментального реагирования на инциденты в логах Linux. Она непрерывно отслеживает свежие логи по маске (в режиме tail -f) и при поиске ключевых слов мгновенно запускает заданный обработчик: отправляет Webhook, записывает алерт или выполняет скрипт реакции.

Типичный сценарий: следить за файлами приложения (например, my*.log), и при появлении слов error или critical немедленно уведомить дежурного или запустить сценарий автоматического восстановления.

Возможности

  • Динамический поиск: автоматический отбор самого свежего файла по маске (*, ?, [...]) в указанном каталоге.
  • Режимы чтения: поведение в стиле tail -f (обработка только новых строк по умолчанию) или сплошное чтение с начала файла (-a).
  • Бесшовный ротация и переключение: автоматический переход на новый лог при его появлении (например, ротация по датам) и корректная обработка усечения (copytruncate).
  • Гибкий поиск: регистронезависимый поиск слов и фраз с контролем границ (разделителей).
  • Конфигурация: простые правила «слово → команда» в /etc/logwatch.cnf.
  • Стратегии срабатывания: выполнение всех совпавших правил или остановка на первом (match = all|first).
  • Горячая перезагрузка: обновление правил без остановки процесса по сигналу SIGHUP.
  • Безопасность execution: строка лога и имя файла передаются в команду через переменные окружения (LOG_LINE, LOG_FILE), что исключает риск shell-инъекций.
  • Автономность: статическая сборка в единый бинарник без внешних зависимостей.

Быстрый старт

gcc -O2 -Wall -static -o logwatch logwatch.c
sudo install -m 755 logwatch /usr/local/bin/logwatch
sudo install -m 644 -o root -g root logwatch.cnf /etc/logwatch.cnf
sudo logwatch -d /opt/atm/logs 'atm*.log'

Проверка работы (в другом терминале допишите строку в лог):

echo "disk error h2h detected" >> /opt/atm/logs/atm_test.log
cat /tmp/errors.txt

Сборка

gcc -O2 -Wall -static -o logwatch logwatch.c

Для статической сборки нужна libc.a:

| Дистрибутив | Пакет |
|---|---|
| Debian / Ubuntu | libc6-dev |
| RHEL / Fedora | glibc-static |
| Arch | входит в glibc |

Для сборки, полностью независимой от glibc, можно использовать musl:

musl-gcc -O2 -static -o logwatch logwatch.c

Бинарник работает на других Linux-системах той же архитектуры. Для выполнения реакций через system() на целевой машине нужен /bin/sh.

Запуск и параметры

logwatch [-a] [-c конфиг] [-d каталог] [маска]
logwatch [-a] [-c конфиг] [/путь/к/каталогу/маска]

| Параметр | Описание |
|---|---|
| -c конфиг | Файл с правилами. По умолчанию /etc/logwatch.cnf. |
| -d каталог | Каталог с логами. По умолчанию /var/log. |
| -a | Читать файл с начала. По умолчанию читаются только новые строки. |
| маска | Шаблон имени файла. По умолчанию *.log. |
| -h | Краткая справка. |

Маску с * и ? всегда берите в кавычки. Иначе shell раскроет ее в список файлов еще до запуска программы, и она получит лишние аргументы (в этом случае программа выдаст подсказку).

Примеры

logwatch                                      # /var/log, маска *.log
logwatch 'atm*.log'                           # /var/log, файлы atm*.log
logwatch -d /opt/atm/logs 'atm*.log'          # другой каталог
logwatch '/opt/atm/logs/atm*.log'             # каталог и маска одним аргументом
logwatch -c ./test.cnf -a '/var/log/pac*.log' # свой конфиг, чтение с начала

Ключ -a удобен для проверки, но обработает весь существующий файл, и реакции сработают на каждую старую строку с нужным словом.

Коды завершения

| Код | Значение |
|---|---|
| 1 | Ошибка: нет конфигурации, нет файлов по маске, ошибка чтения |
| 2 | Неверные параметры командной строки |

В штатном режиме программа работает бесконечно.

Конфигурация правил

Правила хранятся в /etc/logwatch.cnf, по одному на строку:

слово или фраза => команда

Пример:

# комментарий
error h2h  => echo "error hh found" >> /tmp/errors.txt
[ALPM]     => echo "found alpm" >> /tmp/errors.txt
critical   => /usr/local/bin/notify_admin.sh

Правила разбора:

  • Слева от => искомое слово или фраза, справа shell-команда.
  • Строка делится по первому =>. Пробелы вокруг частей отбрасываются.
  • Регистр в искомом не важен.
  • Пустые строки и строки, начинающиеся с #, игнорируются. Символ # в середине строки комментарием не считается.
  • В команде можно использовать |, ;, &&, >>, кавычки и любые другие конструкции shell: она передается в /bin/sh -c как есть.
  • Если в одной строке лога подходит несколько правил, по умолчанию выполняются все соответствующие реакции в порядке следования в файле. Режим можно изменить директивой match (см. ниже).
  • Строка без => (кроме директивы match) или с пустой частью пропускается, при этом в stderr выводится файл:номер: .... Остальные правила загружаются.

Режим выбора правил: match

Директива match определяет, что делать, если строке лога соответствует несколько правил:

match = all      # выполнить все подходящие правила (по умолчанию)
match = first    # выполнить только первое подходящее и остановиться

Директива записывается отдельной строкой, регистр не важен, пробелы вокруг = необязательны (match=first тоже подходит). Если директивы нет, действует all, поэтому старые конфигурации работают без изменений. Некорректное значение пропускается с сообщением в stderr, режим остается all.

Пример с match = first: частное правило стоит выше общего.

match = first

error h2h  => echo "specific: $LOG_LINE" >> /tmp/errors.txt
error      => echo "generic: $LOG_LINE"  >> /tmp/errors.txt

| Строка в логе | match = all | match = first |
|---|---|---|
| error h2h | обе реакции | только specific |
| error | generic | generic |

В режиме first порядок строк в конфигурации становится приоритетом. Если общее правило поставить выше частного, частное не сработает никогда.

Режим применяется и при перечитывании конфигурации по SIGHUP.

Правило, у которого искомое слово совпадает с match, пишется как обычное: match => команда (наличие => делает строку правилом, а не директивой).

Переменные окружения для команд

Перед выполнением реакции в окружение передаются:

| Переменная | Содержимое |
|---|---|
| LOG_LINE | Строка лога, вызвавшая срабатывание |
| LOG_FILE | Полный путь к файлу, который сейчас читается |

Примеры:

failed    => echo "$LOG_FILE: $LOG_LINE" >> /tmp/failed.txt
timeout   => logger -p user.err "logwatch: $LOG_LINE"

Всегда берите $LOG_LINE в двойные кавычки. Строка лога никогда не подставляется в текст команды, поэтому ее содержимое не может быть выполнено как shell-код. Небезопасным может быть только неосторожное использование переменной внутри самой команды, например eval $LOG_LINE. Так делать нельзя.

Перечитывание конфигурации

Конфигурацию можно изменить без остановки программы:

sudo kill -HUP $(pidof logwatch)

В журнале появится строка Загружено правил: N, match=all (/etc/logwatch.cnf). Если новый файл содержит ошибку открытия или не проходит проверку прав, остаются прежние правила, и в stderr выводится сообщение. Строки с синтаксической ошибкой пропускаются по отдельности, остальные правила применяются.

При работе под systemd используйте systemctl reload logwatch (см. ниже).

Как проходит поиск

Искомое (слово или фраза) должно совпасть с текстом строки посимвольно, без учета регистра, и быть ограничено с обеих сторон началом/концом строки либо символом из DELIMS (по умолчанию пробел, табуляция и \r).

| Правило | Строка лога | Сработает |
|---|---|---|
| error | disk error detected | да |
| error | disk ERROR detected | да |
| error | disk errors detected | нет (слово errors) |
| error | error: disk | нет (после слова двоеточие) |
| error | [ERROR] disk | нет (скобки не разделители) |
| connection timed out | x connection timed out y | да |
| connection timed out | x connection timed out y | нет (два пробела) |
| [ALPM] | [2026-10-01] [ALPM] upgraded | да |

Чтобы ловить варианты вроде error: и [ERROR], расширьте разделители в исходнике и пересоберите программу:

#define DELIMS " \t\r,:;[]()"

Правила со скобками вроде [ALPM] продолжат работать: границы проверяются снаружи искомой фразы, а скобки внутри нее сравниваются как обычные символы.

Установка

# бинарник
sudo install -m 755 logwatch /usr/local/bin/logwatch

# конфигурация: владелец root, без записи для группы и остальных
sudo install -m 644 -o root -g root logwatch.cnf /etc/logwatch.cnf

Файл конфигурации обязательно должен принадлежать root и не быть доступным на запись группе и остальным (см. [Безопасность](#безопасность)).

Работа через systemd (systemctl)

1. Юнит-файл

Создайте /etc/systemd/system/logwatch.service:

[Unit]
Description=Log watcher (reactions to words in the newest log file)
After=local-fs.target

[Service]
Type=simple
ExecStart=/usr/local/bin/logwatch -c /etc/logwatch.cnf -d /opt/atm/logs 'atm*.log'
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10
User=root

[Install]
WantedBy=multi-user.target

Что здесь важно:

  • ExecStart. Укажите свой каталог и маску. Маску берите в одинарные кавычки: systemd не раскрывает *, а программа сама разбирает маску через fnmatch.
  • ExecReload. Отправляет SIGHUP, по которому программа перечитывает конфигурацию. Благодаря этому работает systemctl reload logwatch.
  • Restart=on-failure и RestartSec=10. Если файлов по маске нет, программа завершается с кодом 1. С такими настройками systemd будет пробовать снова каждые 10 секунд, а не зациклится мгновенно.
  • After=local-fs.target. Если логи лежат на отдельном разделе или сетевой файловой системе, добавьте нужный RequiresMountsFor=/opt/atm/logs.

2. Включение и запуск

sudo systemctl daemon-reload            # после создания или изменения юнита
sudo systemctl enable --now logwatch    # автозапуск при загрузке и немедленный старт

3. Управление

systemctl status logwatch               # состояние
sudo systemctl restart logwatch         # перезапуск (после смены бинарника, маски, каталога)
sudo systemctl reload logwatch          # перечитать /etc/logwatch.cnf без перезапуска
sudo systemctl stop logwatch            # остановка
sudo systemctl disable logwatch         # убрать из автозапуска

После правки конфигурации достаточно reload. После замены бинарника или изменения параметров запуска в юните нужен restart (а после правки юнита еще и daemon-reload).

4. Просмотр сообщений

Сообщения программы (Слежу за: ..., Переключился на: ..., Загружено правил: ..., match=..., ошибки конфигурации) выводятся в stderr, который systemd направляет в журнал:

journalctl -u logwatch -f               # в реальном времени
journalctl -u logwatch -b               # с начала текущей загрузки
journalctl -u logwatch --since "1 hour ago"

Проверка работы после запуска:

systemctl status logwatch
journalctl -u logwatch -n 20

В журнале должна быть строка Слежу за: <файл> (маска '<маска>').

5. Запуск не от root (рекомендуется)

Если правилам не нужны права root, запускайте программу от отдельного пользователя:

sudo useradd -r -s /usr/sbin/nologin logwatch

В юните замените строку User=root на:

User=logwatch
SupplementaryGroups=adm

Группа adm дает чтение большинства файлов в /var/log на Debian/Ubuntu. Для логов приложения в своем каталоге выдайте пользователю права на чтение этого каталога любым привычным способом (группа, ACL).

Не забудьте, что команды-реакции тоже будут выполняться от этого пользователя. Файлы, куда они пишут (например, /tmp/errors.txt), должны быть ему доступны.

Конфигурация по-прежнему должна принадлежать root: проверка прав это допускает (root:root, режим 644), а пользователь logwatch изменить ее не сможет.

6. Дополнительное ужесточение (по желанию)

Можно добавить в секцию [Service]:

NoNewPrivileges=true
ProtectHome=true

Не включайте PrivateTmp=true, если реакции пишут в /tmp: служба получит собственный изолированный /tmp, и файл /tmp/errors.txt не будет виден снаружи. Для записи результатов лучше использовать постоянный каталог, например /var/log/logwatch-reactions.log, а при ProtectSystem=strict добавить его в ReadWritePaths=. Эти параметры проверяйте на своей системе: набор доступных директив зависит от версии systemd.

7. Что учитывать при работе под systemd

  • Пропуск строк при перезапуске. По умолчанию программа читает только новые строки. Все, что записано в лог, пока служба была остановлена, будет пропущено.
  • Нет файлов по маске при старте. Программа завершится с кодом 1, systemd повторит попытку через RestartSec. Если нужно ждать появления файла дольше, увеличьте RestartSec.
  • Зависшая реакция. Команда выполняется синхронно: пока она не завершилась, чтение логов приостановлено. Для команд, которые могут надолго зависнуть (сеть, почта), используйте timeout 10 команда или запуск в фоне: команда &.

Безопасность

  • Строка лога не интерпретируется как код. Она передается только через переменную окружения LOG_LINE. Если в лог попадет что-то вроде error $(rm -rf /), оно останется простым текстом, пока ваша команда не передаст его в eval или аналогичный механизм.
  • Команды из конфигурации выполняются с правами процесса. Кто может изменить /etc/logwatch.cnf, тот может выполнять команды от имени процесса. Поэтому при загрузке проверяется, что файл принадлежит root (или владельцу процесса) и не доступен на запись группе и остальным. Иначе конфигурация отклоняется с сообщением.
  • Проверка выполняется и при SIGHUP. Если права на файл ослабли, перезагрузка будет отклонена, и продолжат работать прежние правила.
  • Права на чтение логов. Многие файлы в /var/log доступны только root или группе adm. Давайте процессу только те права, которые ему нужны.

Настройки в коде

Константы в начале logwatch.c (после изменения программу нужно пересобрать):

| Константа | Значение по умолчанию | Назначение |
|---|---|---|
| DEFAULT_CFG | /etc/logwatch.cnf | Путь к конфигурации по умолчанию |
| DEFAULT_DIR | /var/log | Каталог по умолчанию |
| DEFAULT_MASK | *.log | Маска по умолчанию |
| DELIMS | " \t\r" | Разделители слов |
| RULE_SEP | "=>" | Разделитель «фраза => команда» в конфигурации |
| POLL_USEC | 200000 | Пауза (мкс) при отсутствии новых данных |
| RESCAN_SEC | 5 | Как часто (сек) искать более свежий файл |
| BUF_SIZE | 65536 | Размер буфера чтения; более длинная строка обрабатывается частями |

Как это работает

1. Читает параметры командной строки и загружает правила и режим match из конфигурации. Если файл не открывается или не проходит проверку прав, программа завершается.
2. Находит в каталоге самый свежий обычный файл, подходящий под маску.
3. Открывает его и, если не указан -a, переходит в конец файла.
4. Читает новые данные и режет их на строки. Неполная последняя строка ждет дописывания.
5. Для каждой строки проверяет правила по порядку и выполняет реакции (все подходящие или только первую, в зависимости от match), передавая LOG_LINE и LOG_FILE.
6. При отсутствии новых данных делает паузу и проверяет файл: если размер стал меньше текущей позиции (усечение), или под тем же именем появился другой файл (новый inode после ротации, mv или сохранения в редакторе), читает файл заново с начала.
7. Раз в RESCAN_SEC секунд проверяет, не появился ли более свежий файл по маске. Если появился, переключается на него и читает с начала.
8. По сигналу SIGHUP перечитывает конфигурацию.

Сообщения о состоянии выводятся в stderr.

Ограничения

  • Отслеживается один файл: самый свежий по маске на данный момент.
  • Если после ротации под тем же именем создается новый файл, он обнаруживается в течение долей секунды (по смене inode) и читается с начала. Если же ротация переименовывает файл так, что новое имя тоже подходит под маску, переключение происходит при очередной проверке (раз в RESCAN_SEC секунд). Строки, дописанные в старый файл между последним чтением и переключением, не будут прочитаны.
  • Каждое срабатывание запускает отдельную команду через system(), и до ее завершения чтение приостановлено. При очень большом потоке совпадений лучше использовать одну быструю команду или скрипт с буферизацией.
  • Опрос файла выполняется периодически (polling), а не через inotify, для простоты и переносимости.
  • Искомая фраза сравнивается посимвольно: разное число пробелов или табуляция между словами в логе приводят к пропуску совпадения.
  • Правила в конфигурации нельзя разносить на несколько строк.
  • Фраза не может содержать => и не может начинаться или заканчиваться пробелом (пробелы по краям отбрасываются).

Диагностика проблем

| Симптом | Причина и решение |
|---|---|
| В каталоге ... нет файлов по маске | Проверьте путь и маску: ls -l /путь/маска. Маска в кавычках, регистр в именах важен. |
| Лишний аргумент '...'. Возможно, shell раскрыл маску | Маску не взяли в кавычки. Пишите 'atm*.log'. |
| Конфигурация ... отклонена | Файл доступен на запись группе/остальным или принадлежит чужому пользователю. Исправьте: chown root:root, chmod 644. |
| Конфигурация ...: No such file or directory | Нет файла /etc/logwatch.cnf. Создайте его или укажите другой через -c. |
| Сработало не одно правило, а несколько (или наоборот, не сработали все подходящие) | Проверьте директиву match: all выполняет все подходящие правила, first только первое. Текущий режим выводится в строке Загружено правил: N, match=.... |
| Загружено правил: 0 или реакции не срабатывают | Проверьте формат (слово => команда) и сообщения вида файл:номер: в stderr. Убедитесь, что слово стоит в строке отдельным токеном (см. [Как проходит поиск](#как-проходит-поиск)). |
| Ничего не происходит на существующем логе | Без -a обрабатываются только новые строки. Допишите строку в файл или запустите с -a для проверки. |
| Поведение не соответствует документации (старые сообщения) | Скорее всего, запускается старый бинарник. Пересоберите: gcc -O2 -Wall -static -o logwatch logwatch.c и обновите /usr/local/bin/logwatch. |
| Permission denied при чтении лога | Запустите от root или добавьте пользователя в нужную группу (adm). Для службы укажите SupplementaryGroups=. |
| Служба постоянно перезапускается | Смотрите journalctl -u logwatch -n 50. Чаще всего нет файлов по маске или ошибка в конфигурации. |
| Реакция пишет в /tmp, но файла не видно | Служба запущена с PrivateTmp=true. Пишите в постоянный каталог. |

Быстрая проверка вручную, без влияния на рабочие логи:

mkdir /tmp/lwtest
printf 'error h2h => echo "FOUND: $LOG_LINE" >> /tmp/lwtest/out.txt\n' > /tmp/lwtest/test.cnf
chmod 644 /tmp/lwtest/test.cnf
touch /tmp/lwtest/test1.log                   # файл по маске должен существовать к запуску
logwatch -c /tmp/lwtest/test.cnf -d /tmp/lwtest 'test*.log' &
sleep 1
echo "x Error H2H y" >> /tmp/lwtest/test1.log
sleep 1; cat /tmp/lwtest/out.txt
kill %1

Планы по развитию

Разработка новых функций планируется с приоритетом на максимальный практический эффект при минимальном усложнении кодовой базы:

1. Задержка повторных срабатываний (debounce / rate limiting): предотвращает шторм уведомлений и спам командами при массовом появлении одинаковых ошибок.
2. Таймаут выполнения реакций: защищает от зависания дочерних процессов и блокировки основного цикла обработки.
3. Поддержка регулярных выражений (regex): расширяет возможности фильтрации для сложных шаблонов логов.
4. Сохранение состояния и Heartbeat: запоминание позиции чтения (offset) между перезапусками утилиты и самодиагностика работоспособности процесса.

> Первые два пункта — задержка срабатываний и таймауты — дадут максимальную надежность и пользу на практике, требуя минимального объема кода.

# cat logwatch.c 
/*
 * logwatch.c - "tail -f" по самому свежему лог-файлу (по маске) с реакцией на слова
 *
 * Сборка:  gcc -O2 -Wall -static -o logwatch logwatch.c
 *
 * Запуск:  ./logwatch [-a] [-c конфиг] [-d каталог] [маска]
 *          ./logwatch [-a] [-c конфиг] [/путь/к/каталогу/маска]
 *
 *   -c конфиг    файл с правилами (по умолчанию /etc/logwatch.cnf)
 *   -d каталог   каталог с логами (по умолчанию /var/log)
 *   -a           читать файл с начала (по умолчанию - только новые строки)
 *   маска        шаблон имени файла, по умолчанию *.log
 *                ВАЖНО: маску с * берите в кавычки, иначе ее раскроет shell
 *
 * Примеры:
 *   ./logwatch 'atm*.log'
 *   ./logwatch -d /opt/atm/logs 'atm*.log'
 *   ./logwatch -c /root/test.cnf '/opt/atm/logs/atm*.log'
 *
 * ФОРМАТ КОНФИГУРАЦИИ (/etc/logwatch.cnf), одно правило на строку:
 *
 *     слово или фраза => команда
 *
 *   - слева от "=>" искомое слово или фраза (без учета регистра),
 *     справа shell-команда, выполняемая через system();
 *   - делится по ПЕРВОМУ "=>", пробелы вокруг отбрасываются;
 *   - пустые строки и строки, начинающиеся с #, игнорируются;
 *   - строка лога и имя файла доступны команде в $LOG_LINE и $LOG_FILE;
 *   - строка "match = all" (по умолчанию) выполняет ВСЕ подходящие правила,
 *     "match = first" - только первое подходящее по порядку в файле
 *     (тогда частные правила ставьте выше общих);
 *   - перечитать конфигурацию без перезапуска:  kill -HUP <pid>
 *     (если новый файл с ошибкой, продолжают работать прежние правила);
 *   - команды выполняются с правами процесса, поэтому файл должен принадлежать
 *     root (или владельцу процесса) и не быть доступным на запись группе/другим.
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <ctype.h>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <time.h>
#include <dirent.h>
#include <fnmatch.h>
#include <limits.h>
#include <sys/stat.h>

/* ===================== НАСТРОЙКИ ===================== */

#define DEFAULT_CFG   "/etc/logwatch.cnf"
#define DEFAULT_DIR   "/var/log"
#define DEFAULT_MASK  "*.log"
#define DELIMS        " \t\r"   /* разделители слов; можно добавить ",:;[]()" */
#define RULE_SEP      "=>"      /* разделитель "фраза => команда" в конфигурации */
#define POLL_USEC     200000    /* пауза при отсутствии новых данных */
#define RESCAN_SEC    5         /* как часто искать более свежий файл */
#define BUF_SIZE      65536

/* ===================== КОД ===================== */

typedef struct { char *word; char *cmd; } Rule;

static Rule  *rules;
static size_t nrules;
static int    match_first;      /* 1 - после первого сработавшего правила остановиться */
static char   cfg_path[PATH_MAX] = DEFAULT_CFG;
static volatile sig_atomic_t reload_flag;

static char   buf[BUF_SIZE];
static size_t buflen;
static char   cur_path[PATH_MAX];

/* ---------- конфигурация ---------- */

static void free_rules(Rule *r, size_t n)
{
    for (size_t i = 0; i < n; i++) { free(r[i].word); free(r[i].cmd); }
    free(r);
}

static char *trim(char *s)
{
    while (isspace((unsigned char)*s)) s++;
    char *e = s + strlen(s);
    while (e > s && isspace((unsigned char)e[-1])) *--e = '\0';
    return s;
}

/* Загрузка правил. При ошибке прежние правила остаются без изменений. */
static int load_rules(const char *path)
{
    Rule *nr = NULL;
    size_t n = 0, cap = 0, lcap = 0;
    char *line = NULL;
    int lineno = 0, nm = 0;

    FILE *f = fopen(path, "r");
    if (!f) {
        fprintf(stderr, "Конфигурация %s: %s\n", path, strerror(errno));
        return -1;
    }

    struct stat st;
    if (fstat(fileno(f), &st) != 0 || (st.st_mode & (S_IWGRP | S_IWOTH)) ||
        (st.st_uid != 0 && st.st_uid != geteuid())) {
        fprintf(stderr, "Конфигурация %s отклонена: файл должен принадлежать root "
                        "(или владельцу процесса) и не быть доступным на запись "
                        "группе/другим\n", path);
        fclose(f);
        return -1;
    }

    while (getline(&line, &lcap, f) != -1) {
        lineno++;
        char *s = trim(line);
        if (*s == '\0' || *s == '#') continue;

        char *sep = strstr(s, RULE_SEP);
        if (!sep) {
            /* директива "match = first|all" */
            if (strncasecmp(s, "match", 5) == 0) {
                char *p = s + 5;
                while (isspace((unsigned char)*p)) p++;
                if (*p == '=') {
                    char *v = trim(p + 1);
                    if (strcasecmp(v, "first") == 0)    nm = 1;
                    else if (strcasecmp(v, "all") == 0) nm = 0;
                    else fprintf(stderr, "%s:%d: match должен быть first или all, "
                                         "строка пропущена\n", path, lineno);
                    continue;
                }
            }
            fprintf(stderr, "%s:%d: нет разделителя '%s', строка пропущена\n",
                    path, lineno, RULE_SEP);
            continue;
        }
        *sep = '\0';
        char *w = trim(s);
        char *c = trim(sep + strlen(RULE_SEP));
        if (*w == '\0' || *c == '\0') {
            fprintf(stderr, "%s:%d: пустое слово или команда, строка пропущена\n",
                    path, lineno);
            continue;
        }

        if (n == cap) {
            size_t nc = cap ? cap * 2 : 16;
            Rule *t = realloc(nr, nc * sizeof *nr);
            if (!t) goto nomem;
            nr = t;
            cap = nc;
        }
        nr[n].word = strdup(w);
        nr[n].cmd  = strdup(c);
        if (!nr[n].word || !nr[n].cmd) {
            free(nr[n].word);
            free(nr[n].cmd);
            goto nomem;
        }
        n++;
    }
    free(line);
    fclose(f);

    free_rules(rules, nrules);
    rules = nr;
    nrules = n;
    match_first = nm;
    fprintf(stderr, "Загружено правил: %zu, match=%s (%s)\n",
            nrules, match_first ? "first" : "all", path);
    return 0;

nomem:
    fprintf(stderr, "Недостаточно памяти при чтении %s\n", path);
    free_rules(nr, n);
    free(line);
    fclose(f);
    return -1;
}

static void on_sighup(int sig)
{
    (void)sig;
    reload_flag = 1;
}

/* ---------- поиск и обработка ---------- */

/* Есть ли в строке слово или фраза word (без учета регистра),
 * ограниченная началом/концом строки или разделителями DELIMS */
static int line_has_word(const char *line, const char *word)
{
    size_t wl = strlen(word);
    if (wl == 0) return 0;

    const char *p = line;
    while ((p = strcasestr(p, word)) != NULL) {
        int left_ok  = (p == line) || strchr(DELIMS, p[-1]) != NULL;
        int right_ok = (p[wl] == '\0') || strchr(DELIMS, p[wl]) != NULL;
        if (left_ok && right_ok) return 1;
        p++;   /* искать следующее вхождение */
    }
    return 0;
}

static void handle_line(const char *line)
{
    int env_set = 0;
    for (size_t i = 0; i < nrules; i++) {
        if (!line_has_word(line, rules[i].word)) continue;
        if (!env_set) {
            setenv("LOG_LINE", line, 1);
            setenv("LOG_FILE", cur_path, 1);
            env_set = 1;
        }
        if (system(rules[i].cmd) == -1) perror("system");
        if (match_first) break;
    }
}

/* Разбор накопленного буфера на полные строки; неполный хвост остается */
static void process_buffer(void)
{
    size_t start = 0;
    char *nl;
    while ((nl = memchr(buf + start, '\n', buflen - start)) != NULL) {
        *nl = '\0';
        handle_line(buf + start);
        start = (size_t)(nl - buf) + 1;
    }
    if (start == 0 && buflen == BUF_SIZE - 1) {   /* слишком длинная строка */
        buf[buflen] = '\0';
        handle_line(buf);
        buflen = 0;
        return;
    }
    memmove(buf, buf + start, buflen - start);
    buflen -= start;
}

/* Самый свежий (по mtime) обычный файл, подходящий под маску */
static int find_newest(const char *dir, const char *mask, char *out, size_t outsz)
{
    DIR *d = opendir(dir);
    if (!d) return -1;

    struct dirent *e;
    struct timespec best = {0, 0};
    int found = 0;

    while ((e = readdir(d)) != NULL) {
        if (fnmatch(mask, e->d_name, 0) != 0) continue;

        char full[PATH_MAX];
        if (snprintf(full, sizeof full, "%s/%s", dir, e->d_name) >= (int)sizeof full)
            continue;

        struct stat st;
        if (stat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue;

        if (!found || st.st_mtim.tv_sec > best.tv_sec ||
            (st.st_mtim.tv_sec == best.tv_sec && st.st_mtim.tv_nsec > best.tv_nsec)) {
            best = st.st_mtim;
            snprintf(out, outsz, "%s", full);
            found = 1;
        }
    }
    closedir(d);
    return found ? 0 : -1;
}

static void usage(const char *prog)
{
    fprintf(stderr,
        "Использование: %s [-a] [-c конфиг] [-d каталог] [маска]\n"
        "  конфиг по умолчанию: %s\n"
        "  маска по умолчанию: %s, каталог по умолчанию: %s\n"
        "  маску с * берите в кавычки: %s 'atm*.log'\n",
        prog, DEFAULT_CFG, DEFAULT_MASK, DEFAULT_DIR, prog);
}

int main(int argc, char **argv)
{
    char dir[PATH_MAX] = DEFAULT_DIR;
    char mask[NAME_MAX + 1] = DEFAULT_MASK;
    int from_start = 0, npos = 0;

    for (int i = 1; i < argc; i++) {
        if (strcmp(argv[i], "-a") == 0) {
            from_start = 1;
        } else if (strcmp(argv[i], "-d") == 0 && i + 1 < argc) {
            snprintf(dir, sizeof dir, "%s", argv[++i]);
        } else if (strcmp(argv[i], "-c") == 0 && i + 1 < argc) {
            snprintf(cfg_path, sizeof cfg_path, "%s", argv[++i]);
        } else if (strcmp(argv[i], "-h") == 0 || argv[i][0] == '-') {
            usage(argv[0]);
            return 2;
        } else {
            if (++npos > 1) {
                fprintf(stderr, "Лишний аргумент '%s'. Возможно, shell раскрыл маску "
                                "- возьмите ее в кавычки.\n", argv[i]);
                usage(argv[0]);
                return 2;
            }
            /* допускается вид /каталог/маска */
            char *slash = strrchr(argv[i], '/');
            if (slash) {
                size_t dl = (size_t)(slash - argv[i]);
                if (dl == 0) dl = 1;                     /* корень "/" */
                if (dl >= sizeof dir) dl = sizeof dir - 1;
                memcpy(dir, argv[i], dl);
                dir[dl] = '\0';
                snprintf(mask, sizeof mask, "%s", slash + 1);
            } else {
                snprintf(mask, sizeof mask, "%s", argv[i]);
            }
            if (mask[0] == '\0') snprintf(mask, sizeof mask, "%s", DEFAULT_MASK);
        }
    }

    if (load_rules(cfg_path) != 0) return 1;
    if (nrules == 0)
        fprintf(stderr, "Предупреждение: в %s нет ни одного правила\n", cfg_path);

    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_sighup;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGHUP, &sa, NULL);

    if (find_newest(dir, mask, cur_path, sizeof cur_path) != 0) {
        fprintf(stderr, "В каталоге %s нет файлов по маске '%s'\n", dir, mask);
        return 1;
    }

    int fd = open(cur_path, O_RDONLY);
    if (fd < 0) { perror(cur_path); return 1; }
    if (!from_start) lseek(fd, 0, SEEK_END);
    fprintf(stderr, "Слежу за: %s (маска '%s')\n", cur_path, mask);

    time_t last_scan = time(NULL);

    for (;;) {
        if (reload_flag) {
            reload_flag = 0;
            if (load_rules(cfg_path) != 0)
                fprintf(stderr, "Остаются прежние правила (%zu)\n", nrules);
        }

        ssize_t n = read(fd, buf + buflen, BUF_SIZE - buflen - 1);
        if (n > 0) { buflen += (size_t)n; process_buffer(); continue; }
        if (n < 0 && errno != EINTR) { perror("read"); break; }

        /* конец файла: проверка усечения (logrotate copytruncate)
         * и подмены файла под тем же именем (logrotate create, сохранение
         * в редакторе, mv): у нового файла другой inode */
        struct stat st;
        if (fstat(fd, &st) == 0) {
            off_t pos = lseek(fd, 0, SEEK_CUR);
            if (st.st_size < pos) {
                lseek(fd, 0, SEEK_SET);
                buflen = 0;
            }

            struct stat ps;
            if (stat(cur_path, &ps) == 0 &&
                (ps.st_ino != st.st_ino || ps.st_dev != st.st_dev)) {
                int nfd = open(cur_path, O_RDONLY);
                if (nfd >= 0) {
                    close(fd);
                    fd = nfd;
                    buflen = 0;
                    fprintf(stderr, "Файл пересоздан, читаю заново: %s\n", cur_path);
                    continue;
                }
            }
        }

        usleep(POLL_USEC);

        /* не появился ли более свежий файл по маске? */
        if (time(NULL) - last_scan >= RESCAN_SEC) {
            char np[PATH_MAX];
            last_scan = time(NULL);
            if (find_newest(dir, mask, np, sizeof np) == 0 && strcmp(np, cur_path) != 0) {
                int nfd = open(np, O_RDONLY);
                if (nfd >= 0) {
                    close(fd);
                    fd = nfd;
                    snprintf(cur_path, sizeof cur_path, "%s", np);
                    buflen = 0;
                    fprintf(stderr, "Переключился на: %s\n", cur_path);
                }
            }
        }
    }
    close(fd);
    return 1;
}

##########################
# cat logwatch.cnf 
# /etc/logwatch.cnf - правила для logwatch
#
# Формат:   слово или фраза => команда
#
#   - регистр не важен, фраза может состоять из нескольких слов;
#   - делится по первому "=>", пробелы вокруг отбрасываются;
#   - в команде доступны $LOG_LINE и $LOG_FILE (берите в двойные кавычки);
#   - строки с # и пустые строки игнорируются;
#   - применить изменения без перезапуска:  kill -HUP $(pidof logwatch)
#   - директива "match = all" (по умолчанию) выполняет ВСЕ подходящие правила,
#     "match = first" - только первое подходящее (порядок строк = приоритет:
#     частные правила ставьте выше общих)
#
# Права: владелец root, без записи для группы и остальных:
#   install -m 644 -o root -g root logwatch.cnf /etc/logwatch.cnf

match = all

error h2h  => echo "error hh found" >> /tmp/errors.txt
[ALPM]     => echo "found alpm" >> /tmp/errors.txt
critical   => /usr/local/bin/notify_admin.sh

# error    => echo "$LOG_FILE: $LOG_LINE" >> /tmp/errors.txt
# failed   => logger -p user.err "logwatch: $LOG_LINE"