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"