diff --git a/README.md b/README.md index 24f8c25..4a3d4de 100644 --- a/README.md +++ b/README.md @@ -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 `, 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 `/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:` 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 diff --git a/README.uk.md b/README.uk.md index bb45b29..64d5e07 100644 --- a/README.uk.md +++ b/README.uk.md @@ -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` надійніший, бо береться з самого +пакета, а не з даних, які надає пристрій. ### Перевірка тестового алерту diff --git a/alerts/alert4_juniper_chassis_alarm.json b/alerts/alert4_juniper_chassis_alarm.json new file mode 100644 index 0000000..0aee725 --- /dev/null +++ b/alerts/alert4_juniper_chassis_alarm.json @@ -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__"}] +} diff --git a/alerts/notification_discord.json b/alerts/notification_discord.json index 1a02efb..1a478ae 100644 --- a/alerts/notification_discord.json +++ b/alerts/notification_discord.json @@ -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, diff --git a/docker-compose.yml b/docker-compose.yml index c6f5817..f700ae8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/install-graylog.sh b/install-graylog.sh index 526f845..6c24765 100644 --- a/install-graylog.sh +++ b/install-graylog.sh @@ -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() { diff --git a/pipelines/pipeline1_network.json b/pipelines/pipeline1_network.json index 0785b07..fb48f7e 100644 --- a/pipelines/pipeline1_network.json +++ b/pipelines/pipeline1_network.json @@ -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" } diff --git a/rules/rule19_juniper_chassis_alarm.json b/rules/rule19_juniper_chassis_alarm.json new file mode 100644 index 0000000..91d3c13 --- /dev/null +++ b/rules/rule19_juniper_chassis_alarm.json @@ -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" +}