feat: add Juniper chassis alarm alerting, Discord notifications, and global timezone configuration for accurate syslog timestamp parsing

This commit is contained in:
byrsapty 2026-07-22 18:48:26 +03:00
parent eed2de0571
commit 0db85fa860
8 changed files with 182 additions and 52 deletions

View file

@ -268,6 +268,34 @@ each widget needs a matching `search_types` entry (in `search.json`) and
docs still reference 6.0.x — don't trust cached documentation over
what the running server actually reports).
- **RFC3164 syslog timestamps have no timezone - Graylog assumes UTC by
default**: most network gear and accel-ppp send classic RFC3164 syslog
(`Jul 22 09:15:13`, no year, no offset). Without an explicit `timezone`
setting on the input, Graylog stores that bare timestamp as if it were
already UTC - so a device logging in local Kyiv time (UTC+3) shows up
3 hours in the future in Graylog. Confirmed live: a raw test packet with
`Jul 22 09:15:13` was stored as `09:15:13Z` (wrong) until each Syslog UDP
input's `timezone` config was set to the container's own timezone
(`Europe/Kyiv` here); afterwards it correctly stored as `06:15:13Z`
(`09:15:13` Kyiv = `06:15:13` UTC). `step_inputs()` in
`install-graylog.sh` sets this automatically from `/etc/timezone` for
every input it creates, and self-heals it on existing inputs that
predate this fix.
- **The built-in `admin` user's displayed timezone is a separate setting
from the input-level fix above**: even after the RFC3164 fix, the Web UI
can still show times in UTC for the read-only built-in `admin` account.
That account's timezone is `read_only: true` and **cannot** be changed
via `PUT /api/users/admin` (confirmed live - fails with `"state should
be: hexString has 24 characters"`, since that endpoint isn't meant for
the special built-in account). It can only be set server-side via the
`root_timezone` config option, i.e. Docker's `GRAYLOG_ROOT_TIMEZONE` env
var. `step_compose_files()` sets this from `/etc/timezone` for fresh
installs and self-heals it into any pre-existing `.env` that predates
this fix; `docker-compose.yml` passes it through to the `graylog`
service. A `docker compose up -d` re-run picks up the change and
recreates the container automatically.
- **Network gear sends syslog to port 514, not a custom port**: most
switches/OLTs (confirmed live with a BDCOM S5612) only support
`logging <host>`, which always uses the standard UDP/514, with no way to
@ -299,6 +327,7 @@ are parsed and searchable but never page anyone):
| RADIUS server unreachable | `radius: server(N) not responding` or `radius: no available servers` (verified strings from accel-ppp source, `radius/req.c`) | High |
| conntrack table full (packet loss) | `nf_conntrack: table full, dropping packet` (standard Linux kernel message - active, ongoing packet loss) | High |
| Unrecognized critical-severity syslog | Any message (any vendor, either stream) with RFC5424/3164 syslog severity Emergency/Alert/Critical (0-2) that no specific pipeline rule already classified | Medium |
| Juniper chassis hardware alarm | `CHASSISD_SNMP_TRAP`/`CHASSISD_SNMP_TRAP6` (over temperature, fan, power supply, etc.) - confirmed live against a real `VC.MYRONIVKA` chassis | High |
That third one is the "universal network equipment problem" catch-all: it
doesn't depend on knowing any vendor's specific message format, just the
@ -316,13 +345,29 @@ when running `create-graylog-lxc.sh`. Under the hood this uses Graylog's
built-in **Slack** notification type pointed at
`<your-webhook-url>/slack` - Discord's Slack-compatibility endpoint - so no
extra converter service is needed. The message template shows the event
title/description plus the source and raw text of every matched message:
title/description plus the source, sending IP, and raw text of every
matched message:
```
*${event_definition_title}*
${event_definition_description}
${if backlog}${foreach backlog message}• `${message.source}`: ${message.message}
${if backlog}${foreach backlog message}• `${message.source}` (IP: ${message.fields.gl2_remote_ip}): ${message.message}
${end}${end}
```
`gl2_remote_ip` is a field Graylog attaches automatically to every message
based on the actual UDP packet's source address, regardless of what
hostname the device itself claims in the syslog `source` field.
### Searching by IP address
Every message is searchable by the sending device's real IP via the same
`gl2_remote_ip` field, in the Search page query bar:
```
gl2_remote_ip:93.171.243.4
```
`source:<value>` also works, but only matches if the device's self-reported
hostname was used (some equipment sends its actual IP as the hostname,
others send a configured name) - `gl2_remote_ip` is the reliable one since
it's derived from the packet itself, not device-supplied data.
### Verifying a test alert

View file

@ -277,6 +277,35 @@ keyboard/click-події без одночасного тригера внут
документація досі згадує 6.0.x — не довіряйте кешованій документації
більше, ніж тому, що фактично повідомляє запущений сервер).
- **У RFC3164 syslog немає часового поясу — Graylog за замовчуванням
вважає це UTC**: більшість мережевого обладнання і accel-ppp шлють
класичний RFC3164 syslog (`Jul 22 09:15:13`, без року, без зсуву). Без
явного налаштування `timezone` на input Graylog зберігає цей "голий"
час так, ніби він уже в UTC — тож пристрій, що пише за київським часом
(UTC+3), в Graylog виглядає так, ніби події відбулись на 3 години в
майбутньому. Перевірено наживо: тестовий пакет із `Jul 22 09:15:13`
зберігався як `09:15:13Z` (неправильно), поки в конфігурації кожного
Syslog UDP input не виставили `timezone` на часовий пояс самого
контейнера (`Europe/Kyiv`); після цього він коректно зберігається як
`06:15:13Z` (`09:15:13` за Києвом = `06:15:13` UTC). `step_inputs()` у
`install-graylog.sh` виставляє це автоматично з `/etc/timezone` для
кожного input, який створює, і сам виправляє це на вже існуючих input,
створених до цього фіксу.
- **Часовий пояс відображення для вбудованого користувача `admin` — це
окреме налаштування, не те саме, що фікс вище**: навіть після фіксу
RFC3164 веб-інтерфейс міг далі показувати час в UTC для вбудованого
read-only акаунта `admin`. Часовий пояс цього акаунта **не можна**
змінити через `PUT /api/users/admin` (перевірено наживо — падає з
`"state should be: hexString has 24 characters"`, оскільки цей ендпоінт
не призначений для спеціального вбудованого акаунта). Він
налаштовується лише на рівні сервера — опція `root_timezone`, тобто
змінна оточення Docker `GRAYLOG_ROOT_TIMEZONE`. `step_compose_files()`
виставляє її з `/etc/timezone` для нових інсталяцій і сам додає її в
уже існуючий `.env`, створений до цього фіксу; `docker-compose.yml`
прокидає її в сервіс `graylog`. Повторний запуск `docker compose up -d`
підхоплює зміну і сам перестворює контейнер.
- **Retention індексів звужений навмисно**: фабричний дефолт Graylog 7.1
зберігає 30-40 днів даних у до 20 індексах — прийнятно загалом, але
ризиковано на малому диску (це розгортання: 50GB) у поєднанні з
@ -316,6 +345,7 @@ keyboard/click-події без одночасного тригера внут
| 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 |
| Juniper chassis hardware alarm | `CHASSISD_SNMP_TRAP`/`CHASSISD_SNMP_TRAP6` (перегрів, вентилятор, блок живлення тощо) — перевірено наживо на реальному шасі `VC.MYRONIVKA` | High |
Третій алерт і є тим самим "універсальним" покриттям проблем мережевого
обладнання: він не залежить від знання формату повідомлень конкретного
@ -334,13 +364,29 @@ keyboard/click-події без одночасного тригера внут
використовується вбудований тип нотифікації Graylog **Slack**, спрямований
на `<ваш-webhook-url>/slack` — Slack-сумісний ендпоінт Discord — тож окремий
конвертер не потрібен. Шаблон повідомлення показує заголовок/опис події
плюс джерело і повний текст кожного повідомлення, що спрацювало:
плюс джерело, IP відправника і повний текст кожного повідомлення, що
спрацювало:
```
*${event_definition_title}*
${event_definition_description}
${if backlog}${foreach backlog message}• `${message.source}`: ${message.message}
${if backlog}${foreach backlog message}• `${message.source}` (IP: ${message.fields.gl2_remote_ip}): ${message.message}
${end}${end}
```
`gl2_remote_ip` — поле, яке Graylog проставляє автоматично для кожного
повідомлення на основі реальної адреси відправника UDP-пакета, незалежно
від того, яке ім'я хоста заявляє сам пристрій у полі syslog `source`.
### Пошук за IP-адресою
Кожне повідомлення можна знайти за реальною IP-адресою пристрою через те
саме поле `gl2_remote_ip`, у рядку пошуку на сторінці Search:
```
gl2_remote_ip:93.171.243.4
```
`source:<значення>` теж працює, але лише якщо пристрій сам надсилає свою
IP-адресу як ім'я хоста (деяке обладнання так робить, інше шле
налаштоване ім'я) — `gl2_remote_ip` надійніший, бо береться з самого
пакета, а не з даних, які надає пристрій.
### Перевірка тестового алерту

View file

@ -0,0 +1,21 @@
{
"title": "CRITICAL: Juniper chassis hardware alarm",
"description": "Chassis-level SNMP trap from Juniper equipment (over temperature, fan, power supply, etc.) - potential hardware failure risk",
"priority": 3,
"alert": true,
"config": {
"type": "aggregation-v1",
"query": "event_type:chassis_alarm",
"streams": ["__NETWORK_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": 600000, "backlog_size": 5},
"notifications": [{"notification_id": "__DISCORD_NOTIFICATION_ID__"}]
}

View file

@ -6,7 +6,7 @@
"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}",
"custom_message": "*${event_definition_title}*\n${event_definition_description}\n${if backlog}${foreach backlog message}• `${message.source}` (IP: ${message.fields.gl2_remote_ip}): ${message.message}\n${end}${end}",
"user_name": "Graylog",
"notify_channel": false,
"notify_here": false,

View file

@ -43,6 +43,7 @@ services:
- GRAYLOG_PASSWORD_SECRET=${GRAYLOG_PASSWORD_SECRET}
- GRAYLOG_ROOT_PASSWORD_SHA2=${GRAYLOG_ROOT_PASSWORD_SHA2}
- GRAYLOG_HTTP_EXTERNAL_URI=${GRAYLOG_HTTP_EXTERNAL_URI}
- GRAYLOG_ROOT_TIMEZONE=${GRAYLOG_ROOT_TIMEZONE}
- 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"

View file

@ -101,8 +101,21 @@ step_compose_files() {
mkdir -p "$INSTALL_DIR"
cp "$SCRIPT_DIR/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml"
# Same timezone detection used by step_inputs() for Syslog input parsing.
# GRAYLOG_ROOT_TIMEZONE governs the display timezone of the built-in
# read-only "admin" user - it CANNOT be changed via the Users REST API
# (confirmed live: PUT /api/users/admin with a timezone field fails with
# "state should be: hexString has 24 characters"), only via this env var.
local tz
tz="$(cat /etc/timezone 2>/dev/null || echo UTC)"
if [ -f "$INSTALL_DIR/.env" ]; then
skip ".env already exists, keeping existing secrets"
if grep -q '^GRAYLOG_ROOT_TIMEZONE=' "$INSTALL_DIR/.env"; then
skip ".env already exists, keeping existing secrets"
else
echo "GRAYLOG_ROOT_TIMEZONE=$tz" >> "$INSTALL_DIR/.env"
ok ".env existed but was missing GRAYLOG_ROOT_TIMEZONE - added it ($tz)"
fi
return
fi
log "Generating fresh secrets into $INSTALL_DIR/.env ..."
@ -115,6 +128,7 @@ step_compose_files() {
GRAYLOG_PASSWORD_SECRET=$secret
GRAYLOG_ROOT_PASSWORD_SHA2=$admin_sha2
GRAYLOG_HTTP_EXTERNAL_URI=$GRAYLOG_EXTERNAL_URI
GRAYLOG_ROOT_TIMEZONE=$tz
EOF
chmod 600 "$INSTALL_DIR/.env"
@ -217,57 +231,55 @@ print(json.dumps(d))
step_inputs() {
log "Creating Syslog UDP inputs (idempotent)..."
local existing
local existing tz
existing="$(gcurl GET /system/inputs)"
# RFC3164 syslog (which is what most network gear/accel-ppp send) has no
# timezone in its timestamp ("Jul 22 09:15:13"). Without this setting,
# Graylog defaults to treating that bare timestamp as UTC - so a device
# logging in local Kyiv time (UTC+3) shows up 3 hours in the future.
# Confirmed live: a raw test packet with "Jul 22 09:15:13" was stored as
# 09:15:13Z UTC (wrong) until this was set; afterwards it correctly
# stored as 06:15:13Z UTC (09:15:13 Kyiv time). Uses the container's own
# configured timezone so it stays correct regardless of where this is
# deployed.
tz="$(cat /etc/timezone 2>/dev/null || echo UTC)"
ensure_syslog_input() {
local port="$1" title="$2" var_name="$3"
local id current_tz
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')==$port),''))")"
local body="{
\"title\": \"$title\",
\"type\": \"org.graylog2.inputs.syslog.udp.SyslogUDPInput\",
\"global\": true,
\"configuration\": {\"bind_address\":\"0.0.0.0\",\"port\":$port,\"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\",
\"timezone\":\"$tz\"}
}"
if [ -z "$id" ]; then
id="$(gcurl POST /system/inputs "$body" | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")"
ok "Created $title: $id"
else
current_tz="$(echo "$existing" | python3 -c "import json,sys;d=json.load(sys.stdin);print(next((i['attributes'].get('timezone') for i in d['inputs'] if i['attributes'].get('port')==$port),''))")"
if [ "$current_tz" != "$tz" ]; then
gcurl PUT "/system/inputs/$id" "$body" >/dev/null
ok "$title already existed - fixed timezone ($current_tz -> $tz)"
else
skip "$title already exists: $id"
fi
fi
printf -v "$var_name" '%s' "$id"
}
# 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
ensure_syslog_input 514 "Network Equipment Syslog (standard port 514)" NETWORK_INPUT_ID_514
ensure_syslog_input 1514 "Network Equipment Syslog (Juniper-ZTE-DLink)" NETWORK_INPUT_ID_1514
ensure_syslog_input 5140 "Servers Syslog (RADIUS-accel-ppp)" SERVERS_INPUT_ID
}
step_pipeline_rules() {

View file

@ -1,5 +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"
"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 \"juniper_chassis_alarm\";\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": "juniper_chassis_alarm",
"description": "Juniper chassisd SNMP trap (hardware health: over temperature, fan, power supply, etc.) - CHASSISD_SNMP_TRAP6",
"source": "rule \"juniper_chassis_alarm\"\nwhen\n contains(to_string($message.message), \"CHASSISD_SNMP_TRAP\")\nthen\n set_field(\"vendor\", \"juniper\");\n set_field(\"event_type\", \"chassis_alarm\");\n set_field(\"severity_tag\", \"critical\");\n let m = regex(\"SNMP trap generated: ([^(]+?)\\\\s*\\\\(\", to_string($message.message), [\"alarm\"]);\n set_field(\"chassis_alarm_type\", m[\"alarm\"]);\n let d = regex(\"jnxContentsDescr ([^,]+)\", to_string($message.message), [\"descr\"]);\n set_field(\"chassis_component\", d[\"descr\"]);\nend"
}