feat: add automated deployment scripts and monitoring configuration for Graylog infrastructure

This commit is contained in:
byrsapty 2026-07-22 04:52:27 +03:00
parent aa5cfbfbd8
commit 06233065cb
33 changed files with 1699 additions and 0 deletions

400
README.uk.md Normal file
View 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
```

View 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__"}]
}

View 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__"}]
}

View 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__"}]
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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": {}
}
}

View 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": {}
}
}