diff --git a/README.uk.md b/README.uk.md new file mode 100644 index 0000000..bb45b29 --- /dev/null +++ b/README.uk.md @@ -0,0 +1,400 @@ +# Централізований лог-сервер Graylog — скрипти розгортання + +Розгортає Debian 12 LXC-контейнер на Proxmox VE зі стеком Docker Compose +(MongoDB + OpenSearch + Graylog) для збору syslog з мережевого обладнання +(Juniper, ZTE OLT, D-Link) та серверів (RADIUS, accel-ppp). + +## Швидкий старт + +Виконайте на хості Proxmox під користувачем `claude-deploy` (або будь-яким +іншим користувачем з такими самими обмеженими правами sudo на `pct`/`pveam` +для VMID 200-299): + +```bash +./create-graylog-lxc.sh \ + --ip 10.254.254.202/24 \ + --gw 10.254.254.235 \ + --vlan 1254 \ + --external-uri http://93.171.241.5:9000/ \ + --discord-webhook "https://discord.com/api/webhooks/xxx/yyy" +``` + +Це **єдина команда, яка потрібна**. Вона створює контейнер, встановлює Docker, +застосовує firewall (nftables: відкриті лише 9000/tcp, 514+1514/udp, +5140/udp), піднімає стек, створює Syslog inputs, імпортує pipeline rules і +в кінці виводить дані адміністратора. + +Обидва скрипти ідемпотентні — повторний запуск після збою (або для +оновлення) продовжує з того місця, де зупинився, а не дублює роботу. + +## Покрокове розгортання з нуля + +### 0. Передумови + +- SSH-доступ до хоста Proxmox під користувачем з sudo-правами на `pct` + create/set/start/stop/exec/status/list та `pveam` update/list/download + (root **не потрібен** — весь цей інструментарій побудований і + протестований саме в такому обмеженому обсязі прав). +- Storage на хості Proxmox з увімкненим content type `vztmpl`, де вже є + (або можна завантажити) template `debian-12-standard` — типова назва за + замовчуванням: `local-btrfs`. Перевірте: + ```bash + sudo pveam list local-btrfs # замініть на назву вашого storage + ``` + Якщо помилка "storage is disabled" чи "does not exist" — знайдіть + правильний (`sudo pveam list ` для кандидатів) і передайте через + `--template-storage`. +- Storage для кореневої файлової системи контейнера (типово: `EX-Ceph`) з + достатнім вільним місцем під `--disk` (за замовчуванням 50GB). +- IP-адреса, шлюз і VLAN-тег (якщо є) мережі, де житиме контейнер — тобто + та сама мережа, звідки ваші RADIUS/NAS-сервери й мережеве обладнання + зможуть до нього достукатись. +- Якщо потрібен доступ до Web UI ззовні цієї мережі — вільний зовнішній + порт для DNAT на `9000/tcp` контейнера (див. примітку "доступ до Web UI + ззовні" нижче — порт 9000 під час тестування виявився вже зайнятий + сторонім ClickHouse, тож не покладайтесь, що якийсь конкретний порт + вільний). +- (Опційно) Discord webhook URL, якщо хочете отримувати критичні алерти в + канал. + +### 1. Перенести скрипти на хост Proxmox + +З вашої робочої машини: +```bash +tar czf - -C /шлях/до/graylog-deploy . | ssh claude-deploy@ \ + "mkdir -p ~/graylog-deploy && tar xzf - -C ~/graylog-deploy && chmod +x ~/graylog-deploy/*.sh" +``` +(Або `git clone`/`scp` теки, якщо тримаєте її в репозиторії — підійде +будь-який спосіб, що перенесе всю теку, включно з `rules/`, `pipelines/`, +`streams/`, `alerts/`.) + +### 2. Визначитись із параметрами + +Заздалегідь виберіть: + +| Потрібно | Приклад | Навіщо | +|---|---|---| +| Вільний VMID у 200-299 | `210` | або пропустіть `--vmid`, щоб скрипт сам обрав перший вільний | +| IP контейнера + CIDR у мережі керування | `10.254.254.220/24` | має бути досяжним з кожного пристрою, що шле syslog | +| Шлюз у цій мережі | `10.254.254.235` | | +| VLAN-тег (якщо міст транкований) | `1254` | пропустіть `--vlan`, якщо без тегу | +| Публічна URL для Web UI | `http://<публічний-ip>:<порт>/` | використовується і в конфігу Graylog, і в посиланнях Discord-алертів | +| Discord webhook (опційно) | `https://discord.com/api/webhooks/.../...` | не вказуйте, щоб пропустити Discord повністю | + +**Спершу перевірте, що IP реально вільний** — дубльований IP у цій мережі +непомітно спричиняє ARP flapping і плутану, важко діагностовану +мережеву поведінку (саме так сталось під час першого розгортання: `.201` +виявився вже зайнятим, і трафік випадково потрапляв не на той хост). +Швидка перевірка без жодних змін: +```bash +ssh claude-deploy@ "sudo /usr/sbin/pct exec <будь-який-запущений-vmid> -- ping -c2 -W1 <кандидат-ip>" +``` +Якщо є відповіді — ця IP зайнята, оберіть іншу. + +### 3. Запустити `create-graylog-lxc.sh` + +```bash +ssh claude-deploy@ +cd ~/graylog-deploy +./create-graylog-lxc.sh \ + --vmid 210 \ + --ip 10.254.254.220/24 \ + --gw 10.254.254.235 \ + --vlan 1254 \ + --external-uri http://<публічний-ip>:<порт>/ \ + --discord-webhook "https://discord.com/api/webhooks/xxx/yyy" +``` + +Що відбувається по черзі: перевірка/завантаження template → `pct create` +→ старт контейнера → очікування мережі → копіювання всієї цієї теки в +контейнер за шляхом `/opt/graylog-deploy/` → запуск `install-graylog.sh` +всередині нього, який встановлює Docker, застосовує firewall, піднімає +MongoDB/OpenSearch/Graylog, чекає, поки стек стане healthy, створює Syslog +inputs, імпортує pipeline rules/pipelines/streams, і (якщо переданий +webhook) створює Discord-notification та три alert definitions. + +**Якщо скрипт зупиняється з помилкою AppArmor** (`open sysctl +net.ipv4.ip_unprivileged_port_start: permission denied`) — це очікувано на +деяких Proxmox-налаштуваннях і потребує одного ручного кроку від root на +хості — див. "Docker-in-unprivileged-LXC AppArmor block" нижче. Зробіть +це, а потім просто запустіть ту саму команду ще раз; усе вже зроблене +автоматично пропускається. + +Загальний час чистого прогону: кілька хвилин, здебільшого очікування +завантаження образів і поки Graylog повідомить healthy. + +### 4. Прочитати креденшели адміністратора + +```bash +ssh claude-deploy@ "sudo pct exec 210 -- cat /opt/graylog/.admin_credentials_ONE_TIME" +``` +Скопіюйте пароль у менеджер паролів, потім видаліть файл: +```bash +ssh claude-deploy@ "sudo pct exec 210 -- rm /opt/graylog/.admin_credentials_ONE_TIME" +``` +Зайдіть на `http://<публічний-ip>:<порт>/` під користувачем `admin` з цим +паролем. + +### 5. Доступ до Web UI ззовні мережі керування (якщо потрібно) + +Якщо IP контейнера не досяжна напряму звідти, звідки ви заходите в +браузер, додайте DNAT-правило на вашому edge-роутері/файрволі для +`9000/tcp`: +``` +-A PREROUTING -d <публічний-ip>/32 -p tcp -m tcp --dport <порт> -j DNAT --to-destination :9000 +``` +Оберіть порт, який справді вільний — під час першого розгортання порт +9000 виявився вже зайнятий ClickHouse на тій самій публічній IP, і +відповідь (`Port 9000 is for clickhouse-client program...`) якийсь час +виглядала як проблема Graylog, поки причину не знайшли. Якщо на обраному +порту приходить підозріла/неочікувана відповідь — спершу підозрюйте +вже існуючий сервіс на цьому порту, а не Graylog. + +### 6. Направити реальне обладнання на сервер + +| Джерело | Порт | Примітка | +|---|---|---| +| Мережеве обладнання (свічі, OLT, роутери) | **514/udp** | стандартний syslog-порт — майже жодне обладнання не дозволяє обрати інший (`logging ` на типовому свічі завжди йде на 514) | +| Мережеве обладнання, яке *може* задати кастомний порт | 1514/udp | залишений як другорядний input, той самий стрім, що й 514 | +| Сервери (RADIUS, accel-ppp, conntrack/kernel-повідомлення) | **5140/udp** | | + +На кожному пристрої це зазвичай один рядок конфігу (наприклад, на +Cisco-подібному CLI: `logging `). Жодних налаштувань з боку +Graylog під кожен пристрій не потрібно — inputs і стріми вже слухають на +всіх трьох портах. + +### 7. Перевірити, що дані реально надходять + +1. Web UI → **System → Inputs**: кожен input показує живий "Traffic Last + Minute". Якщо залишається 0 після того, як пристрій мав щось надіслати + — проблема в мережі/firewall, а не в Graylog — підтвердіть через + `tcpdump` всередині контейнера, перш ніж чіпати налаштування Graylog: + ```bash + sudo pct exec 210 -- tcpdump -i eth0 -n udp port 514 + ``` +2. Web UI → **Search**, розширте часовий діапазон (вгорі зліва), клікніть + на будь-яке повідомлення, щоб розгорнути. Якщо поля `vendor` / + `event_type` заповнені — спрацювало pipeline rule. Якщо повідомлення + прийшло, але ці поля порожні — воно дійшло до Graylog нормально, але + жодне правило поки не розпізнає його формат — це ознака, що новому + пристрою/вендору потрібне нове правило (див. "Що ще НЕ реалізовано" + нижче щодо процесу). +3. Web UI → **Streams**: колонка "Throughput" показує живий msg/s по + кожному стріму. + +### 8. (Опційно) Викликати реальний тестовий алерт + +Див. "Перевірка тестового алерту" нижче — надішліть один із відомих +критичних рядків логу через `logger` зсередини контейнера і подивіться, +як він приходить у Discord протягом приблизно хвилини. + +## Параметри + +Кожен флаг має відповідник у вигляді змінної середовища (див. початок +`create-graylog-lxc.sh`), тож можна також робити `export MEMORY_MB=16384` +тощо замість передачі флагів. + +| Флаг | За замовчуванням | Примітка | +|---|---|---| +| `--ip` | *(обов'язково)* | Статична IP + CIDR для контейнера | +| `--gw` | *(обов'язково)* | IP шлюзу | +| `--external-uri` | *(обов'язково)* | Публічна URL-адреса Web UI Graylog (використовується в `GRAYLOG_HTTP_EXTERNAL_URI`) | +| `--vmid` | перший вільний 200-299 | | +| `--hostname` | `graylog` | | +| `--cores` | `4` | | +| `--memory` | `8192` (МБ) | | +| `--swap` | `512` (МБ) | | +| `--disk` | `50` (ГБ) | розмір rootfs на `--rootfs-storage` | +| `--bridge` | `vmbr0` | | +| `--vlan` | *(немає = без тегу)* | | +| `--nameserver` | `1.1.1.1` | | +| `--searchdomain` | *(немає)* | | +| `--timezone` | `Europe/Kyiv` | | +| `--template-storage` | `local-btrfs` | має мати увімкнений content type `vztmpl` | +| `--rootfs-storage` | `EX-Ceph` | сховище для диска контейнера | +| `--discord-webhook` | *(немає)* | передається як `DISCORD_WEBHOOK_URL` у скрипт всередині контейнера | + +## Дашборд + +Дашборд "Network & RADIUS Monitoring" створюється автоматично (Dashboards +→ Network & RADIUS Monitoring), з п'ятьма віджетами за замовчуванням на +7-денному вікні: + +- **Messages Over Time by Stream** — накопичувальна стовпчикова діаграма, + щоб одним поглядом бачити обсяг мережевого обладнання vs. серверів +- **Vendor Breakdown** — кругова діаграма за полем `vendor`, яке + проставляють pipeline rules +- **Top Event Types** — таблиця з підрахунком по `event_type` +- **Critical Events by Type** — те саме, але відфільтроване по + `severity_tag:critical` — тобто саме те, чим переймаються три алерти вище +- **Top Sources** — які пристрої/сервери генерують найбільше обсягу + +Побудований через Views API (`dashboards/search.json` + `dashboards/view.json`), +а не через власний конструктор віджетів Graylog у браузері — цей +конструктор виявився складно керованим надійно через браузерну +автоматизацію (React `combobox`-віджети, які не реагують на прості +keyboard/click-події без одночасного тригера внутрішнього React-стану), +тоді як REST API прийняв ту саму структуру чисто з першої спроби, щойно +формат був реконструйований із JSON існуючого дашборду. Якщо хочете додати +віджет — або скористайтесь Graylog UI напряму (людина з мишкою не +натикається на проблему автоматизації), а потім за бажанням перенесіть +результат назад у ці два JSON-файли, або розширте +`dashboards/search.json`/`view.json` вручну — кожен віджет потребує +відповідного запису в `search_types` (у `search.json`) та +`widgets` + `widget_mapping` + `positions` + `titles.widget` (у +`view.json`) з однаковим ID. + +## Особливості середовища, які скрипт обходить + +- **Блокування Docker-в-unprivileged-LXC через AppArmor**: контейнери + падають з помилкою `open sysctl net.ipv4.ip_unprivileged_port_start: + permission denied`, якщо адміністратор хоста не додасть сирий рядок + конфігурації LXC. `pct set` не підтримує цю опцію, тож автоматизувати + це в межах прав `claude-deploy` неможливо. Якщо зіткнетесь із цим, + виконайте під root на хості: + ```bash + echo "lxc.apparmor.profile: unconfined" >> /etc/pve/lxc/.conf + pct reboot + ``` + а потім перезапустіть `create-graylog-lxc.sh` (ідемпотентний, продовжить + з цього місця). + +- **`vm.max_map_count`**: OpenSearch вимагає >= 262144. Це + загальносистемний параметр ядра хоста, не прив'язаний до конкретного + LXC, тож встановити його зсередини контейнера теж неможливо. + `install-graylog.sh` лише перевіряє значення і завершується з + інструкціями, якщо воно замале — у цьому розгортанні воно вже було + 262144 за замовчуванням, тож нічого робити не довелось. + +- **TLS Docker Hub через IPv6**: у цій мережі шляхи IPv6 до + `registry-1.docker.io` періодично перехоплюються і повертають невідповідний + сертифікат (`*.docker.com`). Скрипт встановлення вимикає IPv6 всередині + контейнера, щоб форсувати вихід тільки через IPv4. Завантаження образів + також повторюється до 5 разів, бо навіть IPv4 інколи потрапляє на + проблемний вузол. + +- **Версія MongoDB**: Graylog 7.1 вимагає MongoDB >= 7.0 (деяка застаріла + документація досі згадує 6.0.x — не довіряйте кешованій документації + більше, ніж тому, що фактично повідомляє запущений сервер). + +- **Retention індексів звужений навмисно**: фабричний дефолт Graylog 7.1 + зберігає 30-40 днів даних у до 20 індексах — прийнятно загалом, але + ризиковано на малому диску (це розгортання: 50GB) у поєднанні з + неперевіреним реальним обсягом логів (деякі джерела, наприклад accel-ppp + на debug-рівні, можуть бути дуже "балакучими"). Звужено до вікна 14-21 + день / 15 індексів для більшого запасу безпеки. Перегляньте це рішення, + коли назбирається кілька тижнів реального продакшн-обсягу. + +- **Мережеве обладнання шле syslog на порт 514, а не на кастомний порт**: + більшість комутаторів/OLT (перевірено наживо на BDCOM S5612) підтримують + лише `logging `, що завжди використовує стандартний UDP/514, без + можливості вказати інший порт. Тому створено **два** input для + "мережевого обладнання" — 514 (те, що реально використовують пристрої) і + 1514 (залишений для обладнання, яке *може* слати на кастомний порт), і + обидва ведуть у той самий стрім "Network Equipment" (`matching_type: + OR`). Якщо додасте нове обладнання, і воно не з'являється — перевірте + `tcpdump -i eth0 udp port 514` всередині контейнера, перш ніж + припускати, що проблема в pipeline rules. + +- **nftables ніколи не повинен робити `flush ruleset`**: рання версія + цього кроку firewall використовувала `flush ruleset`, що також знищує + власні таблиці Docker у iptables-nft (`DOCKER`, `DOCKER-USER` тощо), + ламаючи публікацію портів контейнера при наступному `docker compose up` + (перевірено наживо — довелось відновлювати через повний `docker compose + down && up`). `nftables.conf` тут робить лише `add table inet filter` + + `flush table inet filter`, що торкається лише цієї однієї таблиці і + безпечне незалежно від порядку запуску відносно `docker.service`. + +## Алерти та Discord-нотифікації + +Три алерти працюють одразу з коробки, і всі — тільки на критичні події +(рутинні auth-fail, поодинокі розриви сесій тощо парсяться й доступні для +пошуку, але нікого не турбують сповіщенням): + +| Алерт | Спрацьовує на | Пріоритет | +|---|---|---| +| RADIUS server unreachable | `radius: server(N) not responding` або `radius: no available servers` (перевірені рядки з вихідного коду accel-ppp, `radius/req.c`) | High | +| conntrack table full (packet loss) | `nf_conntrack: table full, dropping packet` (стандартне повідомлення ядра Linux — активна втрата пакетів прямо зараз) | High | +| Unrecognized critical-severity syslog | Будь-яке повідомлення (будь-який вендор, будь-який стрім) із syslog-severity Emergency/Alert/Critical (0-2) за RFC5424/3164, яке не класифікувало жодне спеціальне правило | Medium | + +Третій алерт і є тим самим "універсальним" покриттям проблем мережевого +обладнання: він не залежить від знання формату повідомлень конкретного +вендора — лише від стандартного рівня severity syslog, який шле будь-який +притомний пристрій. + +Також парситься (доступне для пошуку, але без алерту — це рутинний обсяг, +а не інцидент сам по собі): +- accel-ppp: PPP authentication failed (`ppp_auth.c`) +- Окремий FreeRADIUS: `Auth: (n) Login OK: [user] (from client X port P)` / + `Auth: Login incorrect: [user] (from client X port P)` (типовий формат + `auth_log`) + +Щоб підключити Discord, передайте `--discord-webhook` (або встановіть +`DISCORD_WEBHOOK_URL`) при запуску `create-graylog-lxc.sh`. Під капотом +використовується вбудований тип нотифікації Graylog **Slack**, спрямований +на `<ваш-webhook-url>/slack` — Slack-сумісний ендпоінт Discord — тож окремий +конвертер не потрібен. Шаблон повідомлення показує заголовок/опис події +плюс джерело і повний текст кожного повідомлення, що спрацювало: +``` +*${event_definition_title}* +${event_definition_description} +${if backlog}${foreach backlog message}• `${message.source}`: ${message.message} +${end}${end} +``` + +### Перевірка тестового алерту + +```bash +# зсередини контейнера, імітуючи кожен тригер: +docker exec -it graylog-server bash +logger -n 127.0.0.1 -P 5140 -d 'radius: server(1) not responding' +logger -n 127.0.0.1 -P 5140 -d 'kernel: nf_conntrack: table full, dropping packet' +``` +Планувальник перевіряє раз на 60с, тож повідомлення в Discord може прийти +із затримкою до хвилини. Перевірте сторінку Alerts у Graylog та відповідний +Discord-канал. + +## Що ще НЕ реалізовано + +- **Парсинг D-Link switch** — не було зразків логів. +- **ZTE OLT ONU online/offline + оптичні аларми** — не було зразків логів + (надані були лише BDCOM OLT-подібні CLI-логи: privilege-mode/logout/ + ARP-move/config-write, які *вже* парсяться). +- **Чистий RADIUS Access-Accept/Reject із реального розгортання** — + правило для окремого FreeRADIUS вище побудоване на задокументованому + типовому форматі логів FreeRADIUS, а не на зразку з реальних RADIUS-серверів + цієї мережі (не було SSH-доступу до них); формат добре встановлений у + проєкті, але варто звірити з реальним рядком логу, коли він з'явиться. + +Щоб закрити ці прогалини: дайте кілька реальних сирих рядків логів по +кожному джерелу (D-Link syslog, ZTE OLT ONU up/down + оптичні аларми) — і +відповідні `rules/*.json` та alert definitions можна буде додати так само, +як були побудовані наявні. + +## Структура файлів + +``` +create-graylog-lxc.sh # запускається на хості Proxmox +install-graylog.sh # запускається всередині контейнера (автоматично) +docker-compose.yml # опис стеку MongoDB + OpenSearch + Graylog +nftables.conf # firewall-правила, що застосовуються всередині контейнера +rules/*.json # визначення Graylog Pipeline Rule (імпортуються через API) +pipelines/*.json # визначення Graylog Pipeline, що посилаються на правила +streams/*.json # визначення Graylog Stream (маршрутизація за портом input) +``` + +Всередині контейнера все лежить у `/opt/graylog/` (`docker-compose.yml`, +`.env` із секретами) та `/opt/graylog-deploy/` (копія цього репозиторію, +використовується для ідемпотентних повторних запусків). + +## Креденшели + +`install-graylog.sh` генерує `GRAYLOG_PASSWORD_SECRET` та випадковий +пароль адміністратора під час першого запуску, одноразово записуючи +пароль адміністратора у `/opt/graylog/.admin_credentials_ONE_TIME` +всередині контейнера — прочитайте його, збережіть у менеджері паролів, +а потім видаліть файл: +```bash +pct exec -- cat /opt/graylog/.admin_credentials_ONE_TIME +pct exec -- rm /opt/graylog/.admin_credentials_ONE_TIME +``` diff --git a/alerts/alert1_radius_server_down.json b/alerts/alert1_radius_server_down.json new file mode 100644 index 0000000..5e11a57 --- /dev/null +++ b/alerts/alert1_radius_server_down.json @@ -0,0 +1,21 @@ +{ + "title": "CRITICAL: RADIUS server unreachable", + "description": "accel-ppp cannot reach a RADIUS server, or all RADIUS servers are down", + "priority": 3, + "alert": true, + "config": { + "type": "aggregation-v1", + "query": "event_type:radius_server_timeout OR event_type:radius_all_servers_down", + "streams": ["__SERVERS_STREAM_ID__"], + "group_by": [], + "series": [], + "conditions": {"expression": null}, + "search_within_ms": 60000, + "execute_every_ms": 60000, + "event_limit": 100 + }, + "field_spec": {}, + "key_spec": [], + "notification_settings": {"grace_period_ms": 300000, "backlog_size": 5}, + "notifications": [{"notification_id": "__DISCORD_NOTIFICATION_ID__"}] +} diff --git a/alerts/alert2_conntrack_full.json b/alerts/alert2_conntrack_full.json new file mode 100644 index 0000000..07f7a06 --- /dev/null +++ b/alerts/alert2_conntrack_full.json @@ -0,0 +1,21 @@ +{ + "title": "CRITICAL: conntrack table full (packet loss)", + "description": "nf_conntrack table full on a server - active packet loss is happening right now", + "priority": 3, + "alert": true, + "config": { + "type": "aggregation-v1", + "query": "event_type:conntrack_table_full", + "streams": ["__SERVERS_STREAM_ID__"], + "group_by": [], + "series": [], + "conditions": {"expression": null}, + "search_within_ms": 60000, + "execute_every_ms": 60000, + "event_limit": 100 + }, + "field_spec": {}, + "key_spec": [], + "notification_settings": {"grace_period_ms": 300000, "backlog_size": 5}, + "notifications": [{"notification_id": "__DISCORD_NOTIFICATION_ID__"}] +} diff --git a/alerts/alert3_generic_critical.json b/alerts/alert3_generic_critical.json new file mode 100644 index 0000000..cbd74f1 --- /dev/null +++ b/alerts/alert3_generic_critical.json @@ -0,0 +1,21 @@ +{ + "title": "CRITICAL: unrecognized critical-severity syslog", + "description": "Any network equipment or server sent a message with syslog severity Emergency/Alert/Critical that no specific pipeline rule recognizes", + "priority": 2, + "alert": true, + "config": { + "type": "aggregation-v1", + "query": "event_type:generic_critical_syslog", + "streams": ["__NETWORK_STREAM_ID__", "__SERVERS_STREAM_ID__"], + "group_by": [], + "series": [], + "conditions": {"expression": null}, + "search_within_ms": 60000, + "execute_every_ms": 60000, + "event_limit": 100 + }, + "field_spec": {}, + "key_spec": [], + "notification_settings": {"grace_period_ms": 300000, "backlog_size": 5}, + "notifications": [{"notification_id": "__DISCORD_NOTIFICATION_ID__"}] +} diff --git a/alerts/notification_discord.json b/alerts/notification_discord.json new file mode 100644 index 0000000..1a02efb --- /dev/null +++ b/alerts/notification_discord.json @@ -0,0 +1,21 @@ +{ + "title": "Discord - Critical Alerts", + "description": "Discord webhook via Slack-compatible endpoint", + "config": { + "type": "slack-notification-v1", + "color": "#FF0000", + "webhook_url": "__DISCORD_WEBHOOK_URL_SLACK__", + "channel": "#alerts", + "custom_message": "*${event_definition_title}*\n${event_definition_description}\n${if backlog}${foreach backlog message}• `${message.source}`: ${message.message}\n${end}${end}", + "user_name": "Graylog", + "notify_channel": false, + "notify_here": false, + "link_names": false, + "icon_url": "", + "icon_emoji": "", + "include_title": true, + "include_event_procedure": false, + "time_zone": "Europe/Kyiv", + "backlog_size": 5 + } +} diff --git a/create-graylog-lxc.sh b/create-graylog-lxc.sh new file mode 100644 index 0000000..1d7ebc9 --- /dev/null +++ b/create-graylog-lxc.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# Runs on the Proxmox host as the claude-deploy user (restricted sudo: only +# pct create/set/start/stop/exec/status/list and pveam update/list/download +# for VMIDs 200-299). Creates the Graylog LXC container and triggers the +# in-container install. Idempotent: safe to re-run. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ---- defaults (override via flags or env) ---------------------------------- +VMID="${VMID:-}" +CT_HOSTNAME="${CT_HOSTNAME:-graylog}" +CORES="${CORES:-4}" +MEMORY_MB="${MEMORY_MB:-8192}" +SWAP_MB="${SWAP_MB:-512}" +DISK_GB="${DISK_GB:-50}" +TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local-btrfs}" +ROOTFS_STORAGE="${ROOTFS_STORAGE:-EX-Ceph}" +BRIDGE="${BRIDGE:-vmbr0}" +VLAN_TAG="${VLAN_TAG:-}" +IP_CIDR="${IP_CIDR:-}" +GATEWAY="${GATEWAY:-}" +NAMESERVER="${NAMESERVER:-1.1.1.1}" +SEARCHDOMAIN="${SEARCHDOMAIN:-}" +TIMEZONE="${TIMEZONE:-Europe/Kyiv}" +DEBIAN_TEMPLATE_GLOB="${DEBIAN_TEMPLATE_GLOB:-debian-12-standard_*_amd64.tar.zst}" +GRAYLOG_EXTERNAL_URI="${GRAYLOG_EXTERNAL_URI:-}" +DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}" + +# Colors only when stderr is an actual terminal (never garble log files/pipes). +if [ -t 2 ]; then + C_RESET=$'\033[0m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[1;31m' +else + C_RESET=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED='' +fi + +log() { echo "${C_CYAN}[create-graylog-lxc]${C_RESET} $*" >&2; } +ok() { echo "${C_GREEN}[create-graylog-lxc] ✓${C_RESET} $*" >&2; } +skip() { echo "${C_YELLOW}[create-graylog-lxc] ⏭${C_RESET} $*" >&2; } +die() { echo "${C_RED}[create-graylog-lxc] ✗ ERROR:${C_RESET} $*" >&2; exit 1; } + +usage() { + cat < Static IP for the container, e.g. 10.254.254.202/24 + --gw Gateway IP + --external-uri Public URL used for GRAYLOG_HTTP_EXTERNAL_URI, e.g. http://93.171.241.5:9000/ + +Optional (sane defaults shown): + --vmid <200-299> default: first free VMID in 200-299 + --hostname default: graylog + --cores default: 4 + --memory default: 8192 + --swap default: 512 + --disk default: 50 + --bridge default: vmbr0 + --vlan default: none (untagged) + --nameserver default: 1.1.1.1 + --searchdomain default: (none) + --timezone default: Europe/Kyiv + --template-storage default: local-btrfs (must support content type vztmpl) + --rootfs-storage default: EX-Ceph (container disk) + --discord-webhook optional, passed through to install-graylog.sh + +Everything can also be set via environment variables of the same name in +UPPER_SNAKE_CASE (see top of script). +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --vmid) VMID="$2"; shift 2 ;; + --hostname) CT_HOSTNAME="$2"; shift 2 ;; + --cores) CORES="$2"; shift 2 ;; + --memory) MEMORY_MB="$2"; shift 2 ;; + --swap) SWAP_MB="$2"; shift 2 ;; + --disk) DISK_GB="$2"; shift 2 ;; + --bridge) BRIDGE="$2"; shift 2 ;; + --vlan) VLAN_TAG="$2"; shift 2 ;; + --ip) IP_CIDR="$2"; shift 2 ;; + --gw) GATEWAY="$2"; shift 2 ;; + --nameserver) NAMESERVER="$2"; shift 2 ;; + --searchdomain) SEARCHDOMAIN="$2"; shift 2 ;; + --timezone) TIMEZONE="$2"; shift 2 ;; + --template-storage) TEMPLATE_STORAGE="$2"; shift 2 ;; + --rootfs-storage) ROOTFS_STORAGE="$2"; shift 2 ;; + --external-uri) GRAYLOG_EXTERNAL_URI="$2"; shift 2 ;; + --discord-webhook) DISCORD_WEBHOOK_URL="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1 (see --help)" ;; + esac +done + +[ -n "$IP_CIDR" ] || { usage; die "--ip is required"; } +[ -n "$GATEWAY" ] || { usage; die "--gw is required"; } +[ -n "$GRAYLOG_EXTERNAL_URI" ] || { usage; die "--external-uri is required"; } + +# --------------------------------------------------------------------------- +pick_free_vmid() { + local used id + used="$(sudo /usr/sbin/pct list | awk 'NR>1{print $1}')" + for id in $(seq 200 299); do + if ! echo "$used" | grep -qx "$id"; then + echo "$id" + return + fi + done + die "no free VMID in range 200-299" +} + +[ -n "$VMID" ] || VMID="$(pick_free_vmid)" +case "$VMID" in + 2[0-9][0-9]) ;; + *) die "VMID must be in range 200-299, got: $VMID" ;; +esac + +CONTAINER_EXISTS=0 +if sudo /usr/sbin/pct list | awk 'NR>1{print $1}' | grep -qx "$VMID"; then + CONTAINER_EXISTS=1 + skip "container VMID $VMID already exists, reusing it" +fi + +# --------------------------------------------------------------------------- +ensure_template() { + log "Checking for Debian 12 template on storage '$TEMPLATE_STORAGE'..." + local found + found="$(sudo /usr/bin/pveam list "$TEMPLATE_STORAGE" 2>/dev/null | awk -v s="$TEMPLATE_STORAGE" '$1 ~ s":vztmpl/debian-12-standard" {print $1}' | tail -1)" + if [ -n "$found" ]; then + TEMPLATE_VOLID="$found" + skip "template already cached: $TEMPLATE_VOLID" + return + fi + log "No cached template found, downloading (this can take a minute)..." + sudo /usr/bin/pveam update + local latest + latest="$(sudo /usr/bin/pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)" + [ -n "$latest" ] || die "could not find a debian-12-standard template in the pveam catalog" + sudo /usr/bin/pveam download "$TEMPLATE_STORAGE" "$latest" + TEMPLATE_VOLID="$(sudo /usr/bin/pveam list "$TEMPLATE_STORAGE" | awk -v s="$TEMPLATE_STORAGE" '$1 ~ s":vztmpl/debian-12-standard" {print $1}' | tail -1)" + [ -n "$TEMPLATE_VOLID" ] || die "template download reported success but volume not found" + ok "downloaded template: $TEMPLATE_VOLID" +} + +create_container() { + local net0="name=eth0,bridge=$BRIDGE,ip=${IP_CIDR},gw=${GATEWAY}" + [ -n "$VLAN_TAG" ] && net0="${net0},tag=${VLAN_TAG}" + + log "Creating container $VMID ($CT_HOSTNAME) from $TEMPLATE_VOLID ..." + sudo /usr/sbin/pct create "$VMID" "$TEMPLATE_VOLID" \ + --hostname "$CT_HOSTNAME" \ + --cores "$CORES" \ + --memory "$MEMORY_MB" \ + --swap "$SWAP_MB" \ + --rootfs "${ROOTFS_STORAGE}:${DISK_GB}" \ + --net0 "$net0" \ + --unprivileged 1 \ + --features nesting=1,keyctl=1 \ + --onboot 1 \ + --nameserver "$NAMESERVER" \ + ${SEARCHDOMAIN:+--searchdomain "$SEARCHDOMAIN"} \ + --timezone "$TIMEZONE" \ + --description "Graylog centralized log server" + ok "container $VMID created" +} + +start_container_and_wait_net() { + log "Starting container $VMID..." + sudo /usr/sbin/pct start "$VMID" + log "Waiting for network..." + local i + for i in $(seq 1 20); do + if sudo /usr/sbin/pct exec "$VMID" -- ping -c1 -W2 "$(echo "$GATEWAY")" >/dev/null 2>&1; then + ok "network is up" + return + fi + sleep 2 + done + die "container did not get network connectivity within ~40s" +} + +check_apparmor_hint() { + # We cannot fix this ourselves (no permission to edit /etc/pve/lxc/*.conf), + # so just remind the operator up front; install-graylog.sh will hard-stop + # with the same instructions if it's actually needed. + log "NOTE: if Docker fails inside the container with a sysctl/AppArmor permission error," + log " the host admin must run:" + log " echo 'lxc.apparmor.profile: unconfined' >> /etc/pve/lxc/${VMID}.conf && pct reboot ${VMID}" +} + +copy_install_payload() { + log "Copying install payload into the container (/opt/graylog-deploy)..." + sudo /usr/sbin/pct exec "$VMID" -- mkdir -p /opt/graylog-deploy + tar czf - -C "$SCRIPT_DIR" rules streams pipelines alerts dashboards install-graylog.sh docker-compose.yml nftables.conf \ + | sudo /usr/sbin/pct exec "$VMID" -- bash -c 'tar xzf - -C /opt/graylog-deploy' + sudo /usr/sbin/pct exec "$VMID" -- chmod +x /opt/graylog-deploy/install-graylog.sh +} + +run_install() { + log "Running install-graylog.sh inside the container..." + # GRAYLOG_ADMIN_PASSWORD is only needed on a re-run after the one-time + # credentials file was deleted (as instructed in the README); forward it + # through if the operator set it, otherwise install-graylog.sh reads the + # one-time file itself on a fresh install. + sudo /usr/sbin/pct exec "$VMID" -- env \ + "GRAYLOG_EXTERNAL_URI=$GRAYLOG_EXTERNAL_URI" \ + "DISCORD_WEBHOOK_URL=$DISCORD_WEBHOOK_URL" \ + ${GRAYLOG_ADMIN_PASSWORD:+"GRAYLOG_ADMIN_PASSWORD=$GRAYLOG_ADMIN_PASSWORD"} \ + bash /opt/graylog-deploy/install-graylog.sh +} + +# --------------------------------------------------------------------------- +if [ "$CONTAINER_EXISTS" -eq 0 ]; then + ensure_template + create_container + start_container_and_wait_net + check_apparmor_hint +else + sudo /usr/sbin/pct status "$VMID" | grep -q running || sudo /usr/sbin/pct start "$VMID" +fi + +copy_install_payload +run_install + +echo >&2 +echo "${C_GREEN}==================================================${C_RESET}" >&2 +echo "${C_GREEN} DONE - VMID=$VMID, Web UI: $GRAYLOG_EXTERNAL_URI${C_RESET}" >&2 +echo "${C_GREEN}==================================================${C_RESET}" >&2 diff --git a/dashboards/search.json b/dashboards/search.json new file mode 100644 index 0000000..7a867ef --- /dev/null +++ b/dashboards/search.json @@ -0,0 +1,94 @@ +{ + "queries": [ + { + "id": "89d6c0f3-3d35-42ed-9ad3-14cd7daff7c1", + "timerange": {"type": "relative", "range": 604800}, + "filter": null, + "filters": [], + "query": {"type": "elasticsearch", "query_string": ""}, + "search_types": [ + { + "id": "b5613b69-ccc8-40a6-a7c6-8c9b9b909abf", + "type": "pivot", + "name": "chart", + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "series": [{"type": "count", "id": "Message count", "field": null}], + "sort": [], + "rollup": false, + "row_groups": [{"type": "time", "fields": ["timestamp"], "interval": {"type": "auto", "scaling": 1.0}}], + "column_groups": [{"type": "values", "fields": ["streams"], "limit": 15, "skip_empty_values": false}], + "filter": null, + "filters": [] + }, + { + "id": "01f7bdc5-14f0-4221-9bbf-ba3bea6b308c", + "type": "pivot", + "name": "chart", + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "series": [{"type": "count", "id": "Message count", "field": null}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "rollup": true, + "row_groups": [{"type": "values", "fields": ["vendor"], "limit": 10, "skip_empty_values": true}], + "column_groups": [], + "filter": null, + "filters": [] + }, + { + "id": "1295ec49-8bb6-42d2-87ba-adad9ed43e73", + "type": "pivot", + "name": "chart", + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "series": [{"type": "count", "id": "Message count", "field": null}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "rollup": true, + "row_groups": [{"type": "values", "fields": ["event_type"], "limit": 15, "skip_empty_values": true}], + "column_groups": [], + "filter": null, + "filters": [] + }, + { + "id": "d9551aab-be85-43b6-a205-02a8a6434401", + "type": "pivot", + "name": "chart", + "timerange": {"type": "relative", "range": 604800}, + "query": {"type": "elasticsearch", "query_string": "severity_tag:critical"}, + "streams": [], + "stream_categories": [], + "series": [{"type": "count", "id": "Message count", "field": null}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "rollup": true, + "row_groups": [{"type": "values", "fields": ["event_type"], "limit": 15, "skip_empty_values": true}], + "column_groups": [], + "filter": null, + "filters": [] + }, + { + "id": "8694431d-0f1a-4162-910e-de6d3fbe0b3e", + "type": "pivot", + "name": "chart", + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "series": [{"type": "count", "id": "Message count", "field": null}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "rollup": true, + "row_groups": [{"type": "values", "fields": ["source"], "limit": 15, "skip_empty_values": true}], + "column_groups": [], + "filter": null, + "filters": [] + } + ] + } + ], + "parameters": [] +} diff --git a/dashboards/view.json b/dashboards/view.json new file mode 100644 index 0000000..fb3a27f --- /dev/null +++ b/dashboards/view.json @@ -0,0 +1,176 @@ +{ + "entity": { + "type": "DASHBOARD", + "title": "Network & RADIUS Monitoring", + "summary": "Overview of collected syslog: volume, sources, vendors, event types, and critical issues.", + "description": "Built for the Juniper/BDCOM/accel-ppp/RADIUS log collection pipeline. Default range is 7 days - adjust with the time selector at the top.", + "search_id": "__SEARCH_ID__", + "properties": [], + "requires": {}, + "state": { + "89d6c0f3-3d35-42ed-9ad3-14cd7daff7c1": { + "selected_fields": null, + "static_message_list_id": null, + "titles": { + "tab": {"title": "Overview"}, + "widget": { + "b5613b69-ccc8-40a6-a7c6-8c9b9b909abf": "Messages Over Time by Stream", + "01f7bdc5-14f0-4221-9bbf-ba3bea6b308c": "Vendor Breakdown", + "1295ec49-8bb6-42d2-87ba-adad9ed43e73": "Top Event Types", + "d9551aab-be85-43b6-a205-02a8a6434401": "Critical Events by Type", + "8694431d-0f1a-4162-910e-de6d3fbe0b3e": "Top Sources" + } + }, + "widgets": [ + { + "id": "b5613b69-ccc8-40a6-a7c6-8c9b9b909abf", + "type": "aggregation", + "filter": null, + "filters": [], + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "config": { + "row_pivots": [{"fields": ["timestamp"], "type": "time", "config": {"interval": {"type": "auto", "scaling": 1.0}}}], + "units": {}, + "column_pivots": [{"fields": ["streams"], "type": "values", "config": {"limit": 15}}], + "series": [{"config": {"name": "Message count", "thresholds": []}, "function": "count()"}], + "sort": [], + "visualization": "bar", + "visualization_config": {"barmode": "stack", "axis_type": "linear", "axis_config": null}, + "formatting_settings": null, + "rollup": false, + "event_annotation": false, + "row_limit": null, + "column_limit": 15 + }, + "description": null, + "context": null + }, + { + "id": "01f7bdc5-14f0-4221-9bbf-ba3bea6b308c", + "type": "aggregation", + "filter": null, + "filters": [], + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "config": { + "row_pivots": [{"fields": ["vendor"], "type": "values", "config": {"limit": 10}}], + "units": {}, + "column_pivots": [], + "series": [{"config": {"name": "Message count", "thresholds": []}, "function": "count()"}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "visualization": "pie", + "visualization_config": null, + "formatting_settings": null, + "rollup": true, + "event_annotation": false, + "row_limit": 10, + "column_limit": null + }, + "description": null, + "context": null + }, + { + "id": "1295ec49-8bb6-42d2-87ba-adad9ed43e73", + "type": "aggregation", + "filter": null, + "filters": [], + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "config": { + "row_pivots": [{"fields": ["event_type"], "type": "values", "config": {"limit": 15}}], + "units": {}, + "column_pivots": [], + "series": [{"config": {"name": "Message count", "thresholds": []}, "function": "count()"}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "visualization": "table", + "visualization_config": {"pinned_columns": [], "show_row_numbers": true}, + "formatting_settings": null, + "rollup": true, + "event_annotation": false, + "row_limit": 15, + "column_limit": null + }, + "description": null, + "context": null + }, + { + "id": "d9551aab-be85-43b6-a205-02a8a6434401", + "type": "aggregation", + "filter": null, + "filters": [], + "timerange": {"type": "relative", "range": 604800}, + "query": "severity_tag:critical", + "streams": [], + "stream_categories": [], + "config": { + "row_pivots": [{"fields": ["event_type"], "type": "values", "config": {"limit": 15}}], + "units": {}, + "column_pivots": [], + "series": [{"config": {"name": "Message count", "thresholds": []}, "function": "count()"}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "visualization": "table", + "visualization_config": {"pinned_columns": [], "show_row_numbers": true}, + "formatting_settings": null, + "rollup": true, + "event_annotation": false, + "row_limit": 15, + "column_limit": null + }, + "description": null, + "context": null + }, + { + "id": "8694431d-0f1a-4162-910e-de6d3fbe0b3e", + "type": "aggregation", + "filter": null, + "filters": [], + "timerange": {"type": "relative", "range": 604800}, + "query": null, + "streams": [], + "stream_categories": [], + "config": { + "row_pivots": [{"fields": ["source"], "type": "values", "config": {"limit": 15}}], + "units": {}, + "column_pivots": [], + "series": [{"config": {"name": "Message count", "thresholds": []}, "function": "count()"}], + "sort": [{"type": "series", "field": "count()", "direction": "Descending"}], + "visualization": "table", + "visualization_config": {"pinned_columns": [], "show_row_numbers": true}, + "formatting_settings": null, + "rollup": true, + "event_annotation": false, + "row_limit": 15, + "column_limit": null + }, + "description": null, + "context": null + } + ], + "widget_mapping": { + "b5613b69-ccc8-40a6-a7c6-8c9b9b909abf": ["b5613b69-ccc8-40a6-a7c6-8c9b9b909abf"], + "01f7bdc5-14f0-4221-9bbf-ba3bea6b308c": ["01f7bdc5-14f0-4221-9bbf-ba3bea6b308c"], + "1295ec49-8bb6-42d2-87ba-adad9ed43e73": ["1295ec49-8bb6-42d2-87ba-adad9ed43e73"], + "d9551aab-be85-43b6-a205-02a8a6434401": ["d9551aab-be85-43b6-a205-02a8a6434401"], + "8694431d-0f1a-4162-910e-de6d3fbe0b3e": ["8694431d-0f1a-4162-910e-de6d3fbe0b3e"] + }, + "positions": { + "b5613b69-ccc8-40a6-a7c6-8c9b9b909abf": {"col": 1, "row": 1, "height": 4, "width": "Infinity"}, + "01f7bdc5-14f0-4221-9bbf-ba3bea6b308c": {"col": 1, "row": 5, "height": 4, "width": 6}, + "1295ec49-8bb6-42d2-87ba-adad9ed43e73": {"col": 7, "row": 5, "height": 4, "width": 6}, + "d9551aab-be85-43b6-a205-02a8a6434401": {"col": 1, "row": 9, "height": 4, "width": 6}, + "8694431d-0f1a-4162-910e-de6d3fbe0b3e": {"col": 7, "row": 9, "height": 4, "width": 6} + }, + "formatting": {"highlighting": []}, + "display_mode_settings": {"positions": {}} + } + } + }, + "share_request": {"selected_grantee_capabilities": {}} +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c6f5817 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +services: + mongodb: + image: mongo:7.0.37 + container_name: graylog-mongodb + restart: unless-stopped + mem_limit: 1g + volumes: + - mongo_data:/data/db + networks: + - graylog-net + + opensearch: + image: opensearchproject/opensearch:2.15.0 + container_name: graylog-opensearch + restart: unless-stopped + mem_limit: 3g + environment: + - cluster.name=graylog-cluster + - node.name=opensearch-node1 + - discovery.type=single-node + - "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g" + - plugins.security.disabled=true + - plugins.security.ssl.http.enabled=false + - DISABLE_INSTALL_DEMO_CONFIG=true + ulimits: + nofile: + soft: 65536 + hard: 65536 + volumes: + - os_data:/usr/share/opensearch/data + networks: + - graylog-net + + graylog: + image: graylog/graylog:7.1.5-1 + container_name: graylog-server + restart: unless-stopped + mem_limit: 3g + depends_on: + - mongodb + - opensearch + environment: + - GRAYLOG_PASSWORD_SECRET=${GRAYLOG_PASSWORD_SECRET} + - GRAYLOG_ROOT_PASSWORD_SHA2=${GRAYLOG_ROOT_PASSWORD_SHA2} + - GRAYLOG_HTTP_EXTERNAL_URI=${GRAYLOG_HTTP_EXTERNAL_URI} + - GRAYLOG_MONGODB_URI=mongodb://mongodb:27017/graylog + - GRAYLOG_ELASTICSEARCH_HOSTS=http://opensearch:9200 + - "GRAYLOG_SERVER_JAVA_OPTS=-Xms1g -Xmx1g -XX:NewRatio=1 -server -XX:+UseG1GC" + ports: + - "9000:9000/tcp" + - "514:514/udp" # standard syslog port - most network gear can't be pointed anywhere else + - "1514:1514/udp" # kept for equipment that *can* use a custom port + - "5140:5140/udp" + volumes: + - graylog_data:/usr/share/graylog/data + networks: + - graylog-net + +networks: + graylog-net: + driver: bridge + +volumes: + mongo_data: + os_data: + graylog_data: diff --git a/install-graylog.sh b/install-graylog.sh new file mode 100644 index 0000000..526f845 --- /dev/null +++ b/install-graylog.sh @@ -0,0 +1,485 @@ +#!/usr/bin/env bash +# Runs INSIDE the Graylog LXC container as root (invoked via `pct exec -- bash install-graylog.sh`). +# Idempotent: safe to re-run after a partial failure. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="/opt/graylog" +GRAYLOG_EXTERNAL_URI="${GRAYLOG_EXTERNAL_URI:?Set GRAYLOG_EXTERNAL_URI, e.g. http://93.171.241.5:9000/}" +DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}" +ADMIN_USER="admin" + +# Colors only when stderr is an actual terminal (never garble log files/pipes). +if [ -t 2 ]; then + C_RESET=$'\033[0m'; C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[1;31m' +else + C_RESET=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED='' +fi + +log() { echo "${C_CYAN}[install-graylog]${C_RESET} $*" >&2; } +ok() { echo "${C_GREEN}[install-graylog] ✓${C_RESET} $*" >&2; } +skip() { echo "${C_YELLOW}[install-graylog] ⏭${C_RESET} $*" >&2; } +die() { echo "${C_RED}[install-graylog] ✗ ERROR:${C_RESET} $*" >&2; exit 1; } + +require_root() { + [ "$(id -u)" -eq 0 ] || die "must run as root inside the container (use pct exec)" +} + +# --------------------------------------------------------------------------- +step_packages() { + log "Installing base packages (curl, gnupg, python3, jq, nftables)..." + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq ca-certificates curl gnupg python3 jq nftables >/dev/null +} + +step_disable_ipv6() { + # Workaround: on this hosting network, IPv6 paths to some registries + # (docker.io) get intercepted and served a mismatched TLS cert. Disabling + # IPv6 in the container forces IPv4-only egress and avoids it. + if [ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || echo 0)" = "1" ]; then + skip "IPv6 already disabled" + return + fi + log "Disabling IPv6 (registry TLS workaround for this network)..." + cat > /etc/sysctl.d/99-disable-ipv6.conf <<'EOF' +net.ipv6.conf.all.disable_ipv6 = 1 +net.ipv6.conf.default.disable_ipv6 = 1 +EOF + sysctl -p /etc/sysctl.d/99-disable-ipv6.conf >/dev/null + ok "IPv6 disabled" +} + +step_docker_install() { + if command -v docker >/dev/null 2>&1; then + skip "Docker already installed ($(docker --version))" + return + fi + log "Installing Docker Engine from the official apt repo..." + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + . /etc/os-release + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $VERSION_CODENAME stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null + systemctl enable --now docker >/dev/null + ok "Docker Engine installed" +} + +step_check_max_map_count() { + local current + current="$(cat /proc/sys/vm/max_map_count)" + if [ "$current" -lt 262144 ]; then + die "vm.max_map_count=$current (<262144), required by OpenSearch. This is a HOST-level kernel \ +parameter and cannot be changed from inside an unprivileged LXC. Ask the Proxmox host admin to run: \ + echo 'vm.max_map_count=262144' >> /etc/sysctl.d/99-opensearch.conf && sysctl -p /etc/sysctl.d/99-opensearch.conf \ +Then re-run this script." + fi + ok "vm.max_map_count=$current (>= 262144)" +} + +step_docker_smoke_test() { + log "Testing Docker can actually run a container (AppArmor check)..." + if docker run --rm hello-world >/tmp/docker-test.log 2>&1; then + ok "Docker smoke test passed" + return + fi + if grep -q "ip_unprivileged_port_start" /tmp/docker-test.log; then + die "Docker containers fail to start due to unprivileged-LXC AppArmor confinement. \ +This must be fixed on the PROXMOX HOST (not from inside the container) by the host admin: \ + echo 'lxc.apparmor.profile: unconfined' >> /etc/pve/lxc/.conf && pct reboot \ +Then re-run this script." + fi + cat /tmp/docker-test.log >&2 + die "docker run hello-world failed for an unrecognized reason; see output above." +} + +# --------------------------------------------------------------------------- +step_compose_files() { + mkdir -p "$INSTALL_DIR" + cp "$SCRIPT_DIR/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml" + + if [ -f "$INSTALL_DIR/.env" ]; then + skip ".env already exists, keeping existing secrets" + return + fi + log "Generating fresh secrets into $INSTALL_DIR/.env ..." + local secret admin_pass admin_sha2 + secret="$(openssl rand -hex 48)" + admin_pass="$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c20)" + admin_sha2="$(echo -n "$admin_pass" | sha256sum | cut -d' ' -f1)" + + cat > "$INSTALL_DIR/.env" < "$INSTALL_DIR/.admin_credentials_ONE_TIME" < set." + fi +} + +gcurl() { + local method="$1" path="$2" data="${3:-}" + resolve_admin_password + if [ -n "$data" ]; then + curl -s -u "${ADMIN_USER}:${ADMIN_PASSWORD}" -H 'X-Requested-By: cli' -H 'Content-Type: application/json' \ + -X "$method" "${GRAYLOG_API}${path}" -d "$data" + else + curl -s -u "${ADMIN_USER}:${ADMIN_PASSWORD}" -H 'X-Requested-By: cli' -X "$method" "${GRAYLOG_API}${path}" + fi +} + +step_index_retention() { + # Graylog's factory default (as of 7.1) keeps 30-40 days of data across up + # to 20 indices - reasonable in general, but risky on a small disk (this + # deployment: 50GB) combined with unverified log volume (some sources, + # e.g. accel-ppp at debug level, can be very chatty). Tightened to a + # 14-21 day window / 15 indices for a larger safety margin. Revisit once + # real production volume has been observed for a few weeks. + log "Tightening default index set retention (idempotent)..." + local idx_id current + idx_id="$(gcurl GET /system/indices/index_sets | python3 -c "import json,sys;d=json.load(sys.stdin);print(next(s['id'] for s in d['index_sets'] if s.get('default')))")" + current="$(gcurl GET "/system/indices/index_sets/$idx_id")" + if echo "$current" | python3 -c "import json,sys;d=json.load(sys.stdin);sys.exit(0 if d['rotation_strategy']['index_lifetime_max']=='P21D' else 1)" 2>/dev/null; then + skip "index retention already tightened (P14D/P21D, max 15 indices)" + return + fi + gcurl PUT "/system/indices/index_sets/$idx_id" "$(python3 -c " +import json +d = json.loads('''$current''') +d['rotation_strategy']['index_lifetime_min'] = 'P14D' +d['rotation_strategy']['index_lifetime_max'] = 'P21D' +d['retention_strategy']['max_number_of_indices'] = 15 +d['data_tiering']['index_lifetime_min'] = 'P14D' +d['data_tiering']['index_lifetime_max'] = 'P21D' +d['writable'] = True +print(json.dumps(d)) +")" >/dev/null + ok "index retention tightened to P14D/P21D, max 15 indices" +} + +step_inputs() { + log "Creating Syslog UDP inputs (idempotent)..." + local existing + existing="$(gcurl GET /system/inputs)" + + # Port 514 is the standard syslog port and the one most network gear + # actually sends to (confirmed live: BDCOM switches here can't be pointed + # at a custom port). Port 1514 is kept as a secondary input for any + # equipment that *can* be configured with a non-standard destination port. + NETWORK_INPUT_ID_514="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((i['id'] for i in d['inputs'] if i['attributes'].get('port')==514),''))")" + if [ -z "$NETWORK_INPUT_ID_514" ]; then + NETWORK_INPUT_ID_514="$(gcurl POST /system/inputs '{ + "title": "Network Equipment Syslog (standard port 514)", + "type": "org.graylog2.inputs.syslog.udp.SyslogUDPInput", + "configuration": {"bind_address":"0.0.0.0","port":514,"recv_buffer_size":262144, + "number_worker_threads":2,"force_rdns":false,"allow_override_date":true, + "store_full_message":false,"expand_structured_data":true,"charset_name":"UTF-8"}, + "global": true + }' | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")" + ok "Created Network Equipment input (514): $NETWORK_INPUT_ID_514" + else + skip "Network Equipment input (514) already exists: $NETWORK_INPUT_ID_514" + fi + + NETWORK_INPUT_ID_1514="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((i['id'] for i in d['inputs'] if i['attributes'].get('port')==1514),''))")" + if [ -z "$NETWORK_INPUT_ID_1514" ]; then + NETWORK_INPUT_ID_1514="$(gcurl POST /system/inputs '{ + "title": "Network Equipment Syslog (Juniper-ZTE-DLink)", + "type": "org.graylog2.inputs.syslog.udp.SyslogUDPInput", + "configuration": {"bind_address":"0.0.0.0","port":1514,"recv_buffer_size":262144, + "number_worker_threads":2,"force_rdns":false,"allow_override_date":true, + "store_full_message":false,"expand_structured_data":true,"charset_name":"UTF-8"}, + "global": true + }' | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")" + ok "Created Network Equipment input (1514): $NETWORK_INPUT_ID_1514" + else + skip "Network Equipment input (1514) already exists: $NETWORK_INPUT_ID_1514" + fi + + SERVERS_INPUT_ID="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((i['id'] for i in d['inputs'] if i['attributes'].get('port')==5140),''))")" + if [ -z "$SERVERS_INPUT_ID" ]; then + SERVERS_INPUT_ID="$(gcurl POST /system/inputs '{ + "title": "Servers Syslog (RADIUS-accel-ppp)", + "type": "org.graylog2.inputs.syslog.udp.SyslogUDPInput", + "configuration": {"bind_address":"0.0.0.0","port":5140,"recv_buffer_size":262144, + "number_worker_threads":2,"force_rdns":false,"allow_override_date":true, + "store_full_message":false,"expand_structured_data":true,"charset_name":"UTF-8"}, + "global": true + }' | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")" + ok "Created Servers input: $SERVERS_INPUT_ID" + else + skip "Servers input already exists: $SERVERS_INPUT_ID" + fi +} + +step_pipeline_rules() { + log "Importing pipeline rules from $SCRIPT_DIR/rules/*.json (idempotent)..." + local existing_titles + existing_titles="$(gcurl GET /system/pipelines/rule | python3 -c "import json,sys;print('\n'.join(r['title'] for r in json.load(sys.stdin)))")" + + local f title + for f in "$SCRIPT_DIR"/rules/*.json; do + title="$(python3 -c "import json;print(json.load(open('$f'))['title'])")" + if echo "$existing_titles" | grep -qx "$title"; then + skip "rule '$title' already exists" + continue + fi + local result + result="$(gcurl POST /system/pipelines/rule "$(cat "$f")")" + if echo "$result" | python3 -c "import json,sys;d=json.load(sys.stdin);sys.exit(0 if d.get('errors') is None else 1)"; then + ok "created rule '$title'" + else + die "rule '$title' failed to compile: $result" + fi + done +} + +step_pipelines() { + log "Importing pipelines from $SCRIPT_DIR/pipelines/*.json (idempotent)..." + local existing + existing="$(gcurl GET /system/pipelines/pipeline)" + + local f title id + for f in "$SCRIPT_DIR"/pipelines/*.json; do + title="$(python3 -c "import json;print(json.load(open('$f'))['title'])")" + id="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((p['id'] for p in d if p['title']=='$title'),''))")" + if [ -n "$id" ]; then + skip "pipeline '$title' already exists ($id)" + if [ "$title" = "Network Equipment Parsing" ]; then NETWORK_PIPELINE_ID="$id"; fi + if [ "$title" = "Servers Parsing" ]; then SERVERS_PIPELINE_ID="$id"; fi + continue + fi + result="$(gcurl POST /system/pipelines/pipeline "$(cat "$f")")" + id="$(echo "$result" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))")" + [ -n "$id" ] || die "pipeline '$title' failed to create: $result" + ok "created pipeline '$title' ($id)" + if [ "$title" = "Network Equipment Parsing" ]; then NETWORK_PIPELINE_ID="$id"; fi + if [ "$title" = "Servers Parsing" ]; then SERVERS_PIPELINE_ID="$id"; fi + done +} + +step_streams() { + log "Creating streams from $SCRIPT_DIR/streams/*.json (idempotent)..." + local index_set_id existing + index_set_id="$(gcurl GET /system/indices/index_sets | python3 -c "import json,sys;d=json.load(sys.stdin);print(next(s['id'] for s in d['index_sets'] if s.get('default')))")" + existing="$(gcurl GET /streams)" + + # $2.. are extra `sed -e` substitution expressions applied on top of the + # index-set-id one, so each template can carry however many input-id + # placeholders it needs. + render_and_create() { + local tmpl="$1" title="$2"; shift 2 + local id + id="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((s['id'] for s in d['streams'] if s['title']=='$title'),''))")" + if [ -n "$id" ]; then + skip "stream '$title' already exists ($id)" + echo "$id" + return + fi + local sed_args=(-e "s/__DEFAULT_INDEX_SET_ID__/$index_set_id/") + local expr + for expr in "$@"; do sed_args+=(-e "$expr"); done + local body + body="$(sed "${sed_args[@]}" "$tmpl")" + id="$(gcurl POST /streams "$body" | python3 -c "import json,sys;print(json.load(sys.stdin)['stream_id'])")" + ok "created stream '$title' ($id)" + gcurl POST "/streams/$id/resume" "" >/dev/null + echo "$id" + } + + NETWORK_STREAM_ID="$(render_and_create "$SCRIPT_DIR/streams/stream1_network.json" "Network Equipment" \ + "s/__NETWORK_INPUT_ID_514__/$NETWORK_INPUT_ID_514/" \ + "s/__NETWORK_INPUT_ID_1514__/$NETWORK_INPUT_ID_1514/")" + SERVERS_STREAM_ID="$(render_and_create "$SCRIPT_DIR/streams/stream2_servers.json" "Servers" \ + "s/__SERVERS_INPUT_ID__/$SERVERS_INPUT_ID/")" +} + +step_connect_pipelines() { + log "Connecting pipelines to streams..." + gcurl POST /system/pipelines/connections/to_stream \ + "{\"stream_id\":\"$NETWORK_STREAM_ID\",\"pipeline_ids\":[\"$NETWORK_PIPELINE_ID\"]}" >/dev/null + gcurl POST /system/pipelines/connections/to_stream \ + "{\"stream_id\":\"$SERVERS_STREAM_ID\",\"pipeline_ids\":[\"$SERVERS_PIPELINE_ID\"]}" >/dev/null + ok "Pipelines connected to streams" +} + +step_firewall() { + cp "$SCRIPT_DIR/nftables.conf" /etc/nftables.conf + systemctl enable nftables >/dev/null 2>&1 || true + nft -f /etc/nftables.conf + ok "nftables applied (only 9000/tcp, 514+1514/udp, 5140/udp open)" +} + +step_dashboard() { + if [ ! -f "$SCRIPT_DIR/dashboards/search.json" ]; then + skip "Dashboard (no dashboards/search.json in this checkout)" + return + fi + log "Creating dashboard (idempotent)..." + local title id + title="$(python3 -c "import json;print(json.load(open('$SCRIPT_DIR/dashboards/view.json'))['entity']['title'])")" + id="$(gcurl GET /views | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((v['id'] for v in d['views'] if v['title']=='$title'),''))")" + if [ -n "$id" ]; then + skip "dashboard '$title' already exists ($id)" + return + fi + + local search_id + search_id="$(gcurl POST /views/search "$(cat "$SCRIPT_DIR/dashboards/search.json")" | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")" + [ -n "$search_id" ] || die "dashboard search creation failed" + + local body + body="$(sed "s/__SEARCH_ID__/$search_id/" "$SCRIPT_DIR/dashboards/view.json")" + id="$(gcurl POST /views "$body" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))")" + [ -n "$id" ] || die "dashboard '$title' failed to create" + ok "created dashboard '$title' ($id)" +} + +step_alerts() { + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + skip "Discord notification and alerts (DISCORD_WEBHOOK_URL not set)" + return + fi + if [ ! -f "$SCRIPT_DIR/alerts/notification_discord.json" ]; then + skip "Alerts (no alerts/notification_discord.json in this checkout)" + return + fi + + log "Creating Discord notification (idempotent)..." + local existing_notifs notif_id + existing_notifs="$(gcurl GET /events/notifications)" + notif_id="$(echo "$existing_notifs" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((n['id'] for n in d['notifications'] if n['title']=='Discord - Critical Alerts'),''))")" + if [ -n "$notif_id" ]; then + skip "Discord notification already exists ($notif_id)" + else + local webhook_slack body + webhook_slack="${DISCORD_WEBHOOK_URL%/}/slack" + body="$(sed "s#__DISCORD_WEBHOOK_URL_SLACK__#${webhook_slack}#" "$SCRIPT_DIR/alerts/notification_discord.json")" + notif_id="$(gcurl POST /events/notifications "{\"entity\": ${body}, \"share_request\": {\"selected_grantee_capabilities\": {}}}" \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")" + ok "created Discord notification ($notif_id)" + fi + + log "Creating alert (event) definitions from $SCRIPT_DIR/alerts/*.json (idempotent)..." + local existing_defs + existing_defs="$(gcurl GET /events/definitions)" + local f title id + for f in "$SCRIPT_DIR"/alerts/alert*.json; do + [ -f "$f" ] || continue + title="$(python3 -c "import json;print(json.load(open('$f'))['title'])")" + id="$(echo "$existing_defs" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((e['id'] for e in d['event_definitions'] if e['title']=='$title'),''))")" + if [ -n "$id" ]; then + skip "alert '$title' already exists ($id)" + continue + fi + local body + body="$(sed \ + -e "s/__NETWORK_STREAM_ID__/$NETWORK_STREAM_ID/" \ + -e "s/__SERVERS_STREAM_ID__/$SERVERS_STREAM_ID/" \ + -e "s/__DISCORD_NOTIFICATION_ID__/$notif_id/" \ + "$f")" + id="$(gcurl POST /events/definitions "{\"entity\": ${body}, \"share_request\": {\"selected_grantee_capabilities\": {}}}" \ + | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))")" + [ -n "$id" ] || die "alert '$title' failed to create" + gcurl PUT "/events/definitions/$id/schedule" "" >/dev/null + ok "created and enabled alert '$title' ($id)" + done +} + +# --------------------------------------------------------------------------- +main() { + require_root + step_packages + step_disable_ipv6 + step_docker_install + step_check_max_map_count + step_docker_smoke_test + step_firewall + step_compose_files + step_compose_up + + # Must be a plain statement (not `x=$(...)`) so a failure here truly + # aborts the script - gcurl() is always called from inside command + # substitutions downstream, where `die`'s `exit` would only kill a subshell. + resolve_admin_password + + step_index_retention + step_inputs + step_pipeline_rules + step_pipelines + step_streams + step_connect_pipelines + step_dashboard + step_alerts + + echo >&2 + echo "${C_GREEN}==================================================${C_RESET}" >&2 + echo "${C_GREEN} DONE - Graylog is up${C_RESET}" >&2 + echo "${C_GREEN}==================================================${C_RESET}" >&2 + echo " Web UI: $GRAYLOG_EXTERNAL_URI" >&2 + echo " Config: $INSTALL_DIR/docker-compose.yml and $INSTALL_DIR/.env" >&2 + if [ -f "$INSTALL_DIR/.admin_credentials_ONE_TIME" ]; then + echo " Admin credentials: $INSTALL_DIR/.admin_credentials_ONE_TIME (read once, then delete)" >&2 + fi + echo "${C_GREEN}==================================================${C_RESET}" >&2 +} + +main "$@" diff --git a/nftables.conf b/nftables.conf new file mode 100644 index 0000000..6b8b692 --- /dev/null +++ b/nftables.conf @@ -0,0 +1,34 @@ +#!/usr/sbin/nft -f +# NOTE: deliberately does NOT `flush ruleset` - that wipes Docker's own +# iptables-nft managed nat/filter tables too, breaking container port +# publishing on `docker compose up` (confirmed live: this exact mistake +# broke Graylog's Docker network and had to be recovered with a full +# `docker compose down && up`). Only this table is touched, and only this +# table's own rules are flushed - safe whether or not it already exists, +# and safe regardless of nftables.service vs docker.service start order. + +add table inet filter +flush table inet filter + +table inet filter { + chain input { + type filter hook input priority 0; policy drop; + + iif lo accept + ct state established,related accept + ip protocol icmp accept + + tcp dport 9000 accept # Graylog Web UI / REST API + udp dport 514 accept # syslog, standard port (most network gear can't use anything else) + udp dport 1514 accept # syslog, network equipment that can use a custom port + udp dport 5140 accept # syslog, servers (RADIUS/accel-ppp) + } + + chain forward { + type filter hook forward priority 0; policy accept; + } + + chain output { + type filter hook output priority 0; policy accept; + } +} diff --git a/pipelines/pipeline1_network.json b/pipelines/pipeline1_network.json new file mode 100644 index 0000000..0785b07 --- /dev/null +++ b/pipelines/pipeline1_network.json @@ -0,0 +1,5 @@ +{ + "title": "Network Equipment Parsing", + "description": "Parses Juniper and BDCOM/OLT-style syslog, plus universal critical-severity fallback", + "source": "pipeline \"Network Equipment Parsing\"\nstage 0 match either\n rule \"juniper_ntp_unreachable\";\n rule \"juniper_ssh_login_success\";\n rule \"juniper_ssh_login_failed_tagged\";\n rule \"juniper_ssh_failed_password\";\n rule \"juniper_ui_configuration_error\";\n rule \"olt_privilege_mode\";\n rule \"olt_cli_logout\";\n rule \"olt_ip_arp_moved\";\n rule \"olt_config_write\";\n rule \"generic_critical_severity\";\nend" +} diff --git a/pipelines/pipeline2_servers.json b/pipelines/pipeline2_servers.json new file mode 100644 index 0000000..818b32d --- /dev/null +++ b/pipelines/pipeline2_servers.json @@ -0,0 +1,5 @@ +{ + "title": "Servers Parsing", + "description": "Parses accel-ppp/RADIUS/conntrack syslog", + "source": "pipeline \"Servers Parsing\"\nstage 0 match either\n rule \"accelppp_router_address_error\";\n rule \"accelppp_radius_accounting\";\n rule \"accelppp_radius_server_down\";\n rule \"accelppp_no_radius_servers\";\n rule \"accelppp_auth_failed\";\n rule \"conntrack_table_full\";\n rule \"freeradius_login_ok\";\n rule \"freeradius_login_incorrect\";\n rule \"generic_critical_severity\";\nend" +} diff --git a/rules/rule10_accelppp_router_addr_error.json b/rules/rule10_accelppp_router_addr_error.json new file mode 100644 index 0000000..c99e24c --- /dev/null +++ b/rules/rule10_accelppp_router_addr_error.json @@ -0,0 +1,5 @@ +{ + "title": "accelppp_router_address_error", + "description": "accel-ppp: can't determine router address (PPPoE/IPoE interface error)", + "source": "rule \"accelppp_router_address_error\"\nwhen\n contains(to_string($message.message), \"can't determine router address\")\nthen\n set_field(\"vendor\", \"accel-ppp\");\n set_field(\"event_type\", \"router_address_error\");\n let m = regex(\"error: (\\\\S+): can't determine router address\", to_string($message.message), [\"interface\"]);\n set_field(\"accelppp_interface\", m[\"interface\"]);\nend" +} diff --git a/rules/rule11_accelppp_radius_accounting.json b/rules/rule11_accelppp_radius_accounting.json new file mode 100644 index 0000000..bbc1751 --- /dev/null +++ b/rules/rule11_accelppp_radius_accounting.json @@ -0,0 +1,5 @@ +{ + "title": "accelppp_radius_accounting", + "description": "accel-ppp: RADIUS Accounting-Request (session start/stop/alive/interim)", + "source": "rule \"accelppp_radius_accounting\"\nwhen\n contains(to_string($message.message), \"Accounting-Request\")\nthen\n set_field(\"vendor\", \"accel-ppp\");\n set_field(\"event_type\", \"radius_accounting\");\n let m = regex(\"(\\\\S+): send \\\\[RADIUS\\\\(\\\\d+\\\\) Accounting-Request.*?.*?.*?\", to_string($message.message), [\"interface\",\"nas_identifier\",\"acct_status_type\",\"framed_ip\"]);\n set_field(\"accelppp_interface\", m[\"interface\"]);\n set_field(\"nas_identifier\", m[\"nas_identifier\"]);\n set_field(\"acct_status_type\", m[\"acct_status_type\"]);\n set_field(\"framed_ip\", m[\"framed_ip\"]);\nend" +} diff --git a/rules/rule12_accelppp_radius_server_down.json b/rules/rule12_accelppp_radius_server_down.json new file mode 100644 index 0000000..eca9bd1 --- /dev/null +++ b/rules/rule12_accelppp_radius_server_down.json @@ -0,0 +1,5 @@ +{ + "title": "accelppp_radius_server_down", + "description": "accel-ppp: RADIUS server not responding (verified string from accel-ppp source: radius/req.c)", + "source": "rule \"accelppp_radius_server_down\"\nwhen\n contains(to_string($message.message), \"radius: server\") && contains(to_string($message.message), \"not responding\")\nthen\n set_field(\"vendor\", \"accel-ppp\");\n set_field(\"event_type\", \"radius_server_timeout\");\n set_field(\"severity_tag\", \"critical\");\n let m = regex(\"radius: server\\\\((\\\\d+)\\\\) not responding\", to_string($message.message), [\"server_id\"]);\n set_field(\"radius_server_id\", m[\"server_id\"]);\nend" +} diff --git a/rules/rule13_accelppp_no_radius_servers.json b/rules/rule13_accelppp_no_radius_servers.json new file mode 100644 index 0000000..471e39b --- /dev/null +++ b/rules/rule13_accelppp_no_radius_servers.json @@ -0,0 +1,5 @@ +{ + "title": "accelppp_no_radius_servers", + "description": "accel-ppp: no available RADIUS servers at all - total auth backend outage (verified string from accel-ppp source: radius/req.c)", + "source": "rule \"accelppp_no_radius_servers\"\nwhen\n contains(to_string($message.message), \"radius: no available servers\")\nthen\n set_field(\"vendor\", \"accel-ppp\");\n set_field(\"event_type\", \"radius_all_servers_down\");\n set_field(\"severity_tag\", \"critical\");\nend" +} diff --git a/rules/rule14_accelppp_auth_failed.json b/rules/rule14_accelppp_auth_failed.json new file mode 100644 index 0000000..cbfdc92 --- /dev/null +++ b/rules/rule14_accelppp_auth_failed.json @@ -0,0 +1,5 @@ +{ + "title": "accelppp_auth_failed", + "description": "accel-ppp: PPP authentication failed (verified string from accel-ppp source: ppp/ppp_auth.c)", + "source": "rule \"accelppp_auth_failed\"\nwhen\n contains(to_string($message.message), \"authentication failed\")\nthen\n set_field(\"vendor\", \"accel-ppp\");\n set_field(\"event_type\", \"accelppp_auth_failed\");\nend" +} diff --git a/rules/rule15_conntrack_table_full.json b/rules/rule15_conntrack_table_full.json new file mode 100644 index 0000000..ec6acbb --- /dev/null +++ b/rules/rule15_conntrack_table_full.json @@ -0,0 +1,5 @@ +{ + "title": "conntrack_table_full", + "description": "Linux kernel: nf_conntrack table full, dropping packets - active packet loss on this NAT/firewall host (standard, well-documented kernel message)", + "source": "rule \"conntrack_table_full\"\nwhen\n contains(to_string($message.message), \"nf_conntrack: table full, dropping packet\")\nthen\n set_field(\"vendor\", \"linux-conntrack\");\n set_field(\"event_type\", \"conntrack_table_full\");\n set_field(\"severity_tag\", \"critical\");\nend" +} diff --git a/rules/rule16_freeradius_login_ok.json b/rules/rule16_freeradius_login_ok.json new file mode 100644 index 0000000..1b4bdc9 --- /dev/null +++ b/rules/rule16_freeradius_login_ok.json @@ -0,0 +1,5 @@ +{ + "title": "freeradius_login_ok", + "description": "Standalone FreeRADIUS: successful authentication (default auth_log format: 'Auth: (n) Login OK: [user] (from client X port P)')", + "source": "rule \"freeradius_login_ok\"\nwhen\n contains(to_string($message.message), \"Login OK\")\nthen\n set_field(\"vendor\", \"freeradius\");\n set_field(\"event_type\", \"radius_auth_success\");\n let m = regex(\"Login OK: \\\\[([^\\\\]]+)\\\\] \\\\(from client (\\\\S+)\", to_string($message.message), [\"user\",\"client\"]);\n set_field(\"radius_user\", m[\"user\"]);\n set_field(\"radius_client\", m[\"client\"]);\nend" +} diff --git a/rules/rule17_freeradius_login_incorrect.json b/rules/rule17_freeradius_login_incorrect.json new file mode 100644 index 0000000..1c67700 --- /dev/null +++ b/rules/rule17_freeradius_login_incorrect.json @@ -0,0 +1,5 @@ +{ + "title": "freeradius_login_incorrect", + "description": "Standalone FreeRADIUS: rejected authentication (default auth_log format: 'Auth: Login incorrect: [user] (from client X port P)')", + "source": "rule \"freeradius_login_incorrect\"\nwhen\n contains(to_string($message.message), \"Login incorrect\")\nthen\n set_field(\"vendor\", \"freeradius\");\n set_field(\"event_type\", \"radius_auth_reject\");\n let m = regex(\"Login incorrect.*?: \\\\[([^\\\\]]+)\\\\] \\\\(from client (\\\\S+)\", to_string($message.message), [\"user\",\"client\"]);\n set_field(\"radius_user\", m[\"user\"]);\n set_field(\"radius_client\", m[\"client\"]);\nend" +} diff --git a/rules/rule18_generic_critical_severity.json b/rules/rule18_generic_critical_severity.json new file mode 100644 index 0000000..54c3a82 --- /dev/null +++ b/rules/rule18_generic_critical_severity.json @@ -0,0 +1,5 @@ +{ + "title": "generic_critical_severity", + "description": "Universal fallback: any syslog message (any vendor) with RFC5424/3164 severity Emergency/Alert/Critical (0-2) that wasn't already classified by a more specific rule", + "source": "rule \"generic_critical_severity\"\nwhen\n !has_field(\"event_type\") && has_field(\"level\") && to_long($message.level) <= 2\nthen\n set_field(\"event_type\", \"generic_critical_syslog\");\n set_field(\"severity_tag\", \"critical\");\nend" +} diff --git a/rules/rule1_juniper_ntp.json b/rules/rule1_juniper_ntp.json new file mode 100644 index 0000000..9470401 --- /dev/null +++ b/rules/rule1_juniper_ntp.json @@ -0,0 +1,5 @@ +{ + "title": "juniper_ntp_unreachable", + "description": "Juniper xntpd NTP unreachable", + "source": "rule \"juniper_ntp_unreachable\"\nwhen\n contains(to_string($message.message), \"NTP Server\") && contains(to_string($message.message), \"is Unreachable\")\nthen\n set_field(\"vendor\", \"juniper\");\n set_field(\"event_type\", \"ntp_unreachable\");\n let m = regex(\"NTP Server (\\\\S+) is Unreachable\", to_string($message.message), [\"ntp_server\"]);\n set_field(\"ntp_server\", m[\"ntp_server\"]);\nend" +} diff --git a/rules/rule2_juniper_ssh_ok.json b/rules/rule2_juniper_ssh_ok.json new file mode 100644 index 0000000..17e5680 --- /dev/null +++ b/rules/rule2_juniper_ssh_ok.json @@ -0,0 +1,5 @@ +{ + "title": "juniper_ssh_login_success", + "description": "Juniper sshd Accepted password/keyboard-interactive", + "source": "rule \"juniper_ssh_login_success\"\nwhen\n contains(to_string($message.message), \"sshd\") && contains(to_string($message.message), \"Accepted \")\nthen\n set_field(\"vendor\", \"juniper\");\n set_field(\"event_type\", \"ssh_auth_success\");\n let m = regex(\"Accepted (password|keyboard-interactive/pam) for (\\\\S+) from (\\\\S+) port (\\\\d+)\", to_string($message.message), [\"method\",\"user\",\"src_ip\",\"src_port\"]);\n set_field(\"auth_method\", m[\"method\"]);\n set_field(\"auth_user\", m[\"user\"]);\n set_field(\"src_ip\", m[\"src_ip\"]);\nend" +} diff --git a/rules/rule3_juniper_ssh_failed_tagged.json b/rules/rule3_juniper_ssh_failed_tagged.json new file mode 100644 index 0000000..e1aecc6 --- /dev/null +++ b/rules/rule3_juniper_ssh_failed_tagged.json @@ -0,0 +1,5 @@ +{ + "title": "juniper_ssh_login_failed_tagged", + "description": "Juniper SSHD_LOGIN_FAILED tagged message", + "source": "rule \"juniper_ssh_login_failed_tagged\"\nwhen\n contains(to_string($message.message), \"SSHD_LOGIN_FAILED\")\nthen\n set_field(\"vendor\", \"juniper\");\n set_field(\"event_type\", \"ssh_auth_failed\");\n let m = regex(\"Login failed for user '(\\\\S+)' from host '(\\\\S+)'\", to_string($message.message), [\"user\",\"src_ip\"]);\n set_field(\"auth_user\", m[\"user\"]);\n set_field(\"src_ip\", m[\"src_ip\"]);\nend" +} diff --git a/rules/rule4_juniper_ssh_failed_password.json b/rules/rule4_juniper_ssh_failed_password.json new file mode 100644 index 0000000..237a68c --- /dev/null +++ b/rules/rule4_juniper_ssh_failed_password.json @@ -0,0 +1,5 @@ +{ + "title": "juniper_ssh_failed_password", + "description": "Juniper sshd Failed password line", + "source": "rule \"juniper_ssh_failed_password\"\nwhen\n contains(to_string($message.message), \"Failed password for\")\nthen\n set_field(\"vendor\", \"juniper\");\n set_field(\"event_type\", \"ssh_auth_failed\");\n let m = regex(\"Failed password for (\\\\S+) from (\\\\S+) port (\\\\d+)\", to_string($message.message), [\"user\",\"src_ip\",\"src_port\"]);\n set_field(\"auth_user\", m[\"user\"]);\n set_field(\"src_ip\", m[\"src_ip\"]);\nend" +} diff --git a/rules/rule5_juniper_ui_config_error.json b/rules/rule5_juniper_ui_config_error.json new file mode 100644 index 0000000..834180e --- /dev/null +++ b/rules/rule5_juniper_ui_config_error.json @@ -0,0 +1,5 @@ +{ + "title": "juniper_ui_configuration_error", + "description": "Juniper UI_CONFIGURATION_ERROR process/path/statement", + "source": "rule \"juniper_ui_configuration_error\"\nwhen\n contains(to_string($message.message), \"UI_CONFIGURATION_ERROR\")\nthen\n set_field(\"vendor\", \"juniper\");\n set_field(\"event_type\", \"config_error\");\n let m = regex(\"Process: (\\\\S+), path: \\\\[(.*?)\\\\], statement: (.*)\", to_string($message.message), [\"process\",\"path\",\"statement\"]);\n set_field(\"config_process\", m[\"process\"]);\n set_field(\"config_path\", m[\"path\"]);\n set_field(\"config_statement\", m[\"statement\"]);\nend" +} diff --git a/rules/rule6_olt_privilege_mode.json b/rules/rule6_olt_privilege_mode.json new file mode 100644 index 0000000..c2e8512 --- /dev/null +++ b/rules/rule6_olt_privilege_mode.json @@ -0,0 +1,5 @@ +{ + "title": "olt_privilege_mode", + "description": "BDCOM/OLT-style CLI: user entered privilege mode", + "source": "rule \"olt_privilege_mode\"\nwhen\n contains(to_string($message.message), \"enter privilege mode\")\nthen\n set_field(\"vendor\", \"bdcom_olt\");\n set_field(\"event_type\", \"cli_privilege_mode\");\n let m = regex(\"User (\\\\S+) enter privilege mode from vty (\\\\d+), level = (\\\\d+)\", to_string($message.message), [\"user\",\"vty\",\"level\"]);\n set_field(\"cli_user\", m[\"user\"]);\n set_field(\"cli_vty\", m[\"vty\"]);\nend" +} diff --git a/rules/rule7_olt_logout.json b/rules/rule7_olt_logout.json new file mode 100644 index 0000000..1937509 --- /dev/null +++ b/rules/rule7_olt_logout.json @@ -0,0 +1,5 @@ +{ + "title": "olt_cli_logout", + "description": "BDCOM/OLT-style CLI: user logged out from vty", + "source": "rule \"olt_cli_logout\"\nwhen\n contains(to_string($message.message), \"logouted from\")\nthen\n set_field(\"vendor\", \"bdcom_olt\");\n set_field(\"event_type\", \"cli_logout\");\n let m = regex(\"User (\\\\S+) logouted from (\\\\S+) on vty (\\\\d+)\", to_string($message.message), [\"user\",\"from_ip\",\"vty\"]);\n set_field(\"cli_user\", m[\"user\"]);\n set_field(\"cli_vty\", m[\"vty\"]);\nend" +} diff --git a/rules/rule8_olt_arp_move.json b/rules/rule8_olt_arp_move.json new file mode 100644 index 0000000..ba4082c --- /dev/null +++ b/rules/rule8_olt_arp_move.json @@ -0,0 +1,5 @@ +{ + "title": "olt_ip_arp_moved", + "description": "BDCOM/OLT-style: IP ARP moved between MACs (possible duplicate IP / flapping)", + "source": "rule \"olt_ip_arp_moved\"\nwhen\n contains(to_string($message.message), \"IP ARP:\") && contains(to_string($message.message), \"moved from\")\nthen\n set_field(\"vendor\", \"bdcom_olt\");\n set_field(\"event_type\", \"arp_moved\");\n let m = regex(\"IP ARP: (\\\\S+) moved from (\\\\S+) to (\\\\S+)\", to_string($message.message), [\"ip\",\"from_mac\",\"to_mac\"]);\n set_field(\"arp_ip\", m[\"ip\"]);\n set_field(\"arp_from_mac\", m[\"from_mac\"]);\n set_field(\"arp_to_mac\", m[\"to_mac\"]);\nend" +} diff --git a/rules/rule9_olt_config_write.json b/rules/rule9_olt_config_write.json new file mode 100644 index 0000000..f16b8e5 --- /dev/null +++ b/rules/rule9_olt_config_write.json @@ -0,0 +1,5 @@ +{ + "title": "olt_config_write", + "description": "BDCOM/OLT-style: config file written", + "source": "rule \"olt_config_write\"\nwhen\n contains(to_string($message.message), \"is wrote, TID:\")\nthen\n set_field(\"vendor\", \"bdcom_olt\");\n set_field(\"event_type\", \"config_write\");\n let m = regex(\"/(\\\\S+) is wrote, TID:(\\\\S+)\", to_string($message.message), [\"config_file\",\"tid\"]);\n set_field(\"config_file\", m[\"config_file\"]);\n set_field(\"config_tid\", m[\"tid\"]);\nend" +} diff --git a/streams/stream1_network.json b/streams/stream1_network.json new file mode 100644 index 0000000..4e97d4b --- /dev/null +++ b/streams/stream1_network.json @@ -0,0 +1,16 @@ +{ + "entity": { + "title": "Network Equipment", + "description": "Syslog from Juniper/ZTE OLT/D-Link (ports 514 and 1514)", + "matching_type": "OR", + "index_set_id": "__DEFAULT_INDEX_SET_ID__", + "remove_matches_from_default_stream": false, + "rules": [ + {"field": "gl2_source_input", "type": 1, "value": "__NETWORK_INPUT_ID_514__", "inverted": false}, + {"field": "gl2_source_input", "type": 1, "value": "__NETWORK_INPUT_ID_1514__", "inverted": false} + ] + }, + "share_request": { + "selected_grantee_capabilities": {} + } +} diff --git a/streams/stream2_servers.json b/streams/stream2_servers.json new file mode 100644 index 0000000..37f07a7 --- /dev/null +++ b/streams/stream2_servers.json @@ -0,0 +1,15 @@ +{ + "entity": { + "title": "Servers", + "description": "Syslog from RADIUS/accel-ppp servers (port 5140)", + "matching_type": "AND", + "index_set_id": "__DEFAULT_INDEX_SET_ID__", + "remove_matches_from_default_stream": false, + "rules": [ + {"field": "gl2_source_input", "type": 1, "value": "__SERVERS_INPUT_ID__", "inverted": false} + ] + }, + "share_request": { + "selected_grantee_capabilities": {} + } +}