feat: add automated deployment scripts and monitoring configuration for Graylog infrastructure
This commit is contained in:
parent
aa5cfbfbd8
commit
06233065cb
33 changed files with 1699 additions and 0 deletions
400
README.uk.md
Normal file
400
README.uk.md
Normal file
|
|
@ -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 <name>` для кандидатів) і передайте через
|
||||
`--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@<proxmox-host> \
|
||||
"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@<proxmox-host> "sudo /usr/sbin/pct exec <будь-який-запущений-vmid> -- ping -c2 -W1 <кандидат-ip>"
|
||||
```
|
||||
Якщо є відповіді — ця IP зайнята, оберіть іншу.
|
||||
|
||||
### 3. Запустити `create-graylog-lxc.sh`
|
||||
|
||||
```bash
|
||||
ssh claude-deploy@<proxmox-host>
|
||||
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@<proxmox-host> "sudo pct exec 210 -- cat /opt/graylog/.admin_credentials_ONE_TIME"
|
||||
```
|
||||
Скопіюйте пароль у менеджер паролів, потім видаліть файл:
|
||||
```bash
|
||||
ssh claude-deploy@<proxmox-host> "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 <ip-контейнера>:9000
|
||||
```
|
||||
Оберіть порт, який справді вільний — під час першого розгортання порт
|
||||
9000 виявився вже зайнятий ClickHouse на тій самій публічній IP, і
|
||||
відповідь (`Port 9000 is for clickhouse-client program...`) якийсь час
|
||||
виглядала як проблема Graylog, поки причину не знайшли. Якщо на обраному
|
||||
порту приходить підозріла/неочікувана відповідь — спершу підозрюйте
|
||||
вже існуючий сервіс на цьому порту, а не Graylog.
|
||||
|
||||
### 6. Направити реальне обладнання на сервер
|
||||
|
||||
| Джерело | Порт | Примітка |
|
||||
|---|---|---|
|
||||
| Мережеве обладнання (свічі, OLT, роутери) | **514/udp** | стандартний syslog-порт — майже жодне обладнання не дозволяє обрати інший (`logging <host>` на типовому свічі завжди йде на 514) |
|
||||
| Мережеве обладнання, яке *може* задати кастомний порт | 1514/udp | залишений як другорядний input, той самий стрім, що й 514 |
|
||||
| Сервери (RADIUS, accel-ppp, conntrack/kernel-повідомлення) | **5140/udp** | |
|
||||
|
||||
На кожному пристрої це зазвичай один рядок конфігу (наприклад, на
|
||||
Cisco-подібному CLI: `logging <ip-контейнера>`). Жодних налаштувань з боку
|
||||
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/<VMID>.conf
|
||||
pct reboot <VMID>
|
||||
```
|
||||
а потім перезапустіть `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 <host>`, що завжди використовує стандартний 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 <VMID> -- cat /opt/graylog/.admin_credentials_ONE_TIME
|
||||
pct exec <VMID> -- rm /opt/graylog/.admin_credentials_ONE_TIME
|
||||
```
|
||||
21
alerts/alert1_radius_server_down.json
Normal file
21
alerts/alert1_radius_server_down.json
Normal file
|
|
@ -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__"}]
|
||||
}
|
||||
21
alerts/alert2_conntrack_full.json
Normal file
21
alerts/alert2_conntrack_full.json
Normal file
|
|
@ -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__"}]
|
||||
}
|
||||
21
alerts/alert3_generic_critical.json
Normal file
21
alerts/alert3_generic_critical.json
Normal file
|
|
@ -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__"}]
|
||||
}
|
||||
21
alerts/notification_discord.json
Normal file
21
alerts/notification_discord.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
229
create-graylog-lxc.sh
Normal file
229
create-graylog-lxc.sh
Normal file
|
|
@ -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 <<EOF
|
||||
Usage: $0 --ip 10.254.254.202/24 --gw 10.254.254.235 --vlan 1254 [options]
|
||||
|
||||
Required:
|
||||
--ip <cidr> Static IP for the container, e.g. 10.254.254.202/24
|
||||
--gw <ip> Gateway IP
|
||||
--external-uri <url> 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 <name> default: graylog
|
||||
--cores <n> default: 4
|
||||
--memory <MB> default: 8192
|
||||
--swap <MB> default: 512
|
||||
--disk <GB> default: 50
|
||||
--bridge <name> default: vmbr0
|
||||
--vlan <tag> default: none (untagged)
|
||||
--nameserver <ip> default: 1.1.1.1
|
||||
--searchdomain <domain> default: (none)
|
||||
--timezone <tz> default: Europe/Kyiv
|
||||
--template-storage <s> default: local-btrfs (must support content type vztmpl)
|
||||
--rootfs-storage <s> default: EX-Ceph (container disk)
|
||||
--discord-webhook <url> 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
|
||||
94
dashboards/search.json
Normal file
94
dashboards/search.json
Normal file
|
|
@ -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": []
|
||||
}
|
||||
176
dashboards/view.json
Normal file
176
dashboards/view.json
Normal file
|
|
@ -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": {}}
|
||||
}
|
||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
|
|
@ -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:
|
||||
485
install-graylog.sh
Normal file
485
install-graylog.sh
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs INSIDE the Graylog LXC container as root (invoked via `pct exec <vmid> -- 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/<VMID>.conf && pct reboot <VMID> \
|
||||
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" <<EOF
|
||||
GRAYLOG_PASSWORD_SECRET=$secret
|
||||
GRAYLOG_ROOT_PASSWORD_SHA2=$admin_sha2
|
||||
GRAYLOG_HTTP_EXTERNAL_URI=$GRAYLOG_EXTERNAL_URI
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
|
||||
# Only shown once, at generation time - not re-printed on later runs.
|
||||
cat > "$INSTALL_DIR/.admin_credentials_ONE_TIME" <<EOF
|
||||
Graylog admin user: $ADMIN_USER
|
||||
Graylog admin password: $admin_pass
|
||||
(This file is only written once, at first install. Store the password in your
|
||||
password manager, then delete this file: rm $INSTALL_DIR/.admin_credentials_ONE_TIME)
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/.admin_credentials_ONE_TIME"
|
||||
ok "Admin password generated. See $INSTALL_DIR/.admin_credentials_ONE_TIME (read it now, then delete it)."
|
||||
}
|
||||
|
||||
step_compose_up() {
|
||||
log "Pulling images (retrying up to 5x - this registry intermittently serves a bad TLS cert)..."
|
||||
local i
|
||||
for i in 1 2 3 4 5; do
|
||||
if (cd "$INSTALL_DIR" && docker compose pull); then
|
||||
break
|
||||
fi
|
||||
log "Pull attempt $i failed, retrying..."
|
||||
sleep 3
|
||||
[ "$i" -eq 5 ] && die "docker compose pull failed after 5 attempts."
|
||||
done
|
||||
|
||||
log "Starting the stack..."
|
||||
(cd "$INSTALL_DIR" && docker compose up -d)
|
||||
|
||||
log "Waiting for graylog-server to become healthy (up to 5 minutes)..."
|
||||
local waited=0
|
||||
while true; do
|
||||
status="$(docker inspect --format '{{.State.Health.Status}}' graylog-server 2>/dev/null || echo starting)"
|
||||
[ "$status" = "healthy" ] && break
|
||||
waited=$((waited + 5))
|
||||
[ "$waited" -ge 300 ] && die "graylog-server did not become healthy within 5 minutes. Check: docker logs graylog-server"
|
||||
sleep 5
|
||||
done
|
||||
ok "Stack is healthy"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
GRAYLOG_API="http://localhost:9000/api"
|
||||
|
||||
# Resolves the admin password into the global ADMIN_PASSWORD exactly once.
|
||||
# Must be called as a plain statement (never via `x=$(resolve_admin_password)`)
|
||||
# so that `die`'s `exit` terminates the real script instead of a subshell.
|
||||
ADMIN_PASSWORD=""
|
||||
resolve_admin_password() {
|
||||
[ -n "$ADMIN_PASSWORD" ] && return
|
||||
if [ -f "$INSTALL_DIR/.admin_credentials_ONE_TIME" ]; then
|
||||
ADMIN_PASSWORD="$(grep -oP '(?<=password: ).*' "$INSTALL_DIR/.admin_credentials_ONE_TIME")"
|
||||
elif [ -n "${GRAYLOG_ADMIN_PASSWORD:-}" ]; then
|
||||
ADMIN_PASSWORD="$GRAYLOG_ADMIN_PASSWORD"
|
||||
else
|
||||
die "Cannot find admin password: $INSTALL_DIR/.admin_credentials_ONE_TIME was deleted. \
|
||||
Re-run with GRAYLOG_ADMIN_PASSWORD=<password> 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 "$@"
|
||||
34
nftables.conf
Normal file
34
nftables.conf
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
5
pipelines/pipeline1_network.json
Normal file
5
pipelines/pipeline1_network.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
pipelines/pipeline2_servers.json
Normal file
5
pipelines/pipeline2_servers.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule10_accelppp_router_addr_error.json
Normal file
5
rules/rule10_accelppp_router_addr_error.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule11_accelppp_radius_accounting.json
Normal file
5
rules/rule11_accelppp_radius_accounting.json
Normal file
|
|
@ -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.*?<NAS-Identifier \\\"(\\\\S+)\\\">.*?<Acct-Status-Type (\\\\S+)>.*?<Framed-IP-Address (\\\\S+)>\", 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"
|
||||
}
|
||||
5
rules/rule12_accelppp_radius_server_down.json
Normal file
5
rules/rule12_accelppp_radius_server_down.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule13_accelppp_no_radius_servers.json
Normal file
5
rules/rule13_accelppp_no_radius_servers.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule14_accelppp_auth_failed.json
Normal file
5
rules/rule14_accelppp_auth_failed.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule15_conntrack_table_full.json
Normal file
5
rules/rule15_conntrack_table_full.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule16_freeradius_login_ok.json
Normal file
5
rules/rule16_freeradius_login_ok.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule17_freeradius_login_incorrect.json
Normal file
5
rules/rule17_freeradius_login_incorrect.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule18_generic_critical_severity.json
Normal file
5
rules/rule18_generic_critical_severity.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule1_juniper_ntp.json
Normal file
5
rules/rule1_juniper_ntp.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule2_juniper_ssh_ok.json
Normal file
5
rules/rule2_juniper_ssh_ok.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule3_juniper_ssh_failed_tagged.json
Normal file
5
rules/rule3_juniper_ssh_failed_tagged.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule4_juniper_ssh_failed_password.json
Normal file
5
rules/rule4_juniper_ssh_failed_password.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule5_juniper_ui_config_error.json
Normal file
5
rules/rule5_juniper_ui_config_error.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule6_olt_privilege_mode.json
Normal file
5
rules/rule6_olt_privilege_mode.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule7_olt_logout.json
Normal file
5
rules/rule7_olt_logout.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule8_olt_arp_move.json
Normal file
5
rules/rule8_olt_arp_move.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
5
rules/rule9_olt_config_write.json
Normal file
5
rules/rule9_olt_config_write.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
16
streams/stream1_network.json
Normal file
16
streams/stream1_network.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
15
streams/stream2_servers.json
Normal file
15
streams/stream2_servers.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue