Netpulse_SasS/agent/internal/modules/syslog/parse.go
byrsapty 0bc2335661 Приймач syslog на зонді: розбір RFC3164/5424, черга, ліміти
Модуль слухає UDP у мережі клієнта: комутатор у закритій мережі до
сервера не достукається, а зонд уже має вихідний канал.

Розбір трьома рівнями суворості — 5424, 3164 і «як є». Останній не
запасний варіант, а робочий режим: пристрої, що шлють голий текст,
існують, і втратити подію гірше, ніж зберегти саме повідомлення.
Текст Cisco "%SYS-5-CONFIG_I: ..." свідомо НЕ ріжеться в поле tag —
саме за ним шукають зміну конфігу.

Ліміт частоти на джерело, а не спільний: комутатор, що зациклився на
помилці, інакше витіснив би з журналу всю решту мережі — тобто рівно
те, що треба бачити під час аварії. З тієї ж причини переповнена черга
викидає найстаріше, а не найновіше.

Транспорт до сервера ще не під'єднано: серверний StreamLogs уже є,
лишається цикл відправки на зонді.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:31:40 +03:00

252 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package syslog
import (
"strconv"
"strings"
"time"
)
// Message — розібрана подія.
//
// Порожні поля — норма, а не помилка розбору: половина заліза шле щось
// віддалено схоже на RFC3164, а комутатор із дешевої серії може почати
// рядок одразу з тексту. Втратити таку подію гірше, ніж зберегти її з
// самим лише повідомленням.
type Message struct {
Time time.Time
Facility uint32
Severity uint32
Hostname string
Tag string
Message string
// Structured data з RFC5424, зведені в пласкі ключі "id.параметр".
Parsed map[string]string
}
// Parse розбирає рядок syslog.
//
// Три формати в порядку спадання суворості: RFC5424, RFC3164 і «як є».
// Останній не запасний варіант на випадок помилки, а окремий робочий
// режим: пристрої, що шлють голий текст, існують, і їхні повідомлення
// теж потрібні в журналі.
//
// now передається зовні, щоб розбір лишався передбачуваним у тестах:
// RFC3164 не містить року, і його доводиться домислювати.
func Parse(raw string, now time.Time) Message {
m := Message{Time: now, Severity: 6, Facility: 1} // info / user
rest, pri, ok := cutPriority(raw)
if ok {
m.Facility = pri / 8
m.Severity = pri % 8
}
m.Message = strings.TrimSpace(rest)
if parse5424(&m, rest) {
return m
}
parse3164(&m, rest, now)
return m
}
// cutPriority знімає "<190>" з початку.
func cutPriority(s string) (rest string, pri uint32, ok bool) {
if len(s) < 3 || s[0] != '<' {
return s, 0, false
}
end := strings.IndexByte(s, '>')
// Пріоритет — щонайбільше три цифри; довше означає, що кутова дужка
// просто трапилась у тексті.
if end < 2 || end > 4 {
return s, 0, false
}
n, err := strconv.Atoi(s[1:end])
if err != nil || n < 0 || n > 191 {
return s, 0, false
}
return s[end+1:], uint32(n), true
}
// parse5424 розбирає "1 TIMESTAMP HOST APP PROCID MSGID [SD] MSG".
func parse5424(m *Message, s string) bool {
if !strings.HasPrefix(s, "1 ") {
return false
}
f := strings.SplitN(s[2:], " ", 6)
if len(f) < 6 {
return false
}
ts, host, app, procID, msgID, tail := f[0], f[1], f[2], f[3], f[4], f[5]
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
m.Time = t
} else if ts != "-" {
// Мітка часу є, але нечитабельна — це вже не 5424.
return false
}
m.Hostname = nilDash(host)
m.Tag = nilDash(app)
if p := nilDash(procID); p != "" {
m.Tag += "[" + p + "]"
}
sd, msg := cutStructured(tail)
m.Parsed = sd
if id := nilDash(msgID); id != "" {
if m.Parsed == nil {
m.Parsed = map[string]string{}
}
m.Parsed["msgid"] = id
}
m.Message = strings.TrimSpace(msg)
return true
}
// cutStructured знімає з початку блоки [id key="value" ...].
func cutStructured(s string) (map[string]string, string) {
s = strings.TrimLeft(s, " ")
if strings.HasPrefix(s, "-") {
return nil, strings.TrimPrefix(s, "-")
}
out := map[string]string{}
for strings.HasPrefix(s, "[") {
end := findElementEnd(s)
if end < 0 {
// Незакрита дужка: усе, що лишилось, — текст.
return orNil(out), s
}
parseElement(out, s[1:end])
s = s[end+1:]
}
return orNil(out), s
}
// findElementEnd шукає ']', яка закриває елемент, не плутаючись у
// лапках: значення параметра цілком може містити дужку.
func findElementEnd(s string) int {
inQuotes := false
for i := 1; i < len(s); i++ {
switch s[i] {
case '\\':
i++
case '"':
inQuotes = !inQuotes
case ']':
if !inQuotes {
return i
}
}
}
return -1
}
func parseElement(out map[string]string, body string) {
id, params, _ := strings.Cut(body, " ")
id = strings.TrimSpace(id)
if id == "" {
return
}
for _, kv := range splitParams(params) {
k, v, ok := strings.Cut(kv, "=")
if !ok {
continue
}
v = strings.Trim(v, `"`)
v = strings.ReplaceAll(v, `\"`, `"`)
out[id+"."+strings.TrimSpace(k)] = v
}
}
// splitParams ділить 'a="1" b="2 3"' на пари, не розриваючи лапки.
func splitParams(s string) []string {
var (
out []string
start int
inQuotes bool
)
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
i++
case '"':
inQuotes = !inQuotes
case ' ':
if !inQuotes {
if i > start {
out = append(out, s[start:i])
}
start = i + 1
}
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
// parse3164 розбирає "MMM d hh:mm:ss HOST tag[pid]: msg".
func parse3164(m *Message, s string, now time.Time) {
s = strings.TrimLeft(s, " ")
if len(s) < 16 {
return
}
// Рік у форматі не передбачений — беремо поточний. У ніч на перше
// січня це дає майбутню дату для грудневих подій, тому відкочуємо
// на рік назад: подія з майбутнього псує сортування журналу
// набагато помітніше, ніж зсув на добу.
t, err := time.ParseInLocation(time.Stamp, s[:15], now.Location())
if err != nil {
return
}
t = t.AddDate(now.Year(), 0, 0)
if t.Sub(now) > 24*time.Hour {
t = t.AddDate(-1, 0, 0)
}
m.Time = t
rest := strings.TrimLeft(s[15:], " ")
host, tail, ok := strings.Cut(rest, " ")
if !ok {
m.Message = rest
return
}
m.Hostname = host
// Тег закінчується двокрапкою або пробілом — але лише якщо він
// схожий на тег. Cisco шле "%SYS-5-CONFIG_I: ...", і відрізати це
// в поле tag не можна: саме за цим текстом шукають зміну конфігу.
if tag, msg, ok := strings.Cut(tail, ": "); ok && isTag(tag) {
m.Tag = tag
m.Message = strings.TrimSpace(msg)
return
}
m.Message = strings.TrimSpace(tail)
}
// isTag відсіює довгі й дивні «теги»: у RFC3164 це коротке ім'я
// програми, а не половина повідомлення.
func isTag(s string) bool {
if s == "" || len(s) > 48 || strings.ContainsAny(s, " %") {
return false
}
return true
}
func nilDash(s string) string {
if s == "-" {
return ""
}
return s
}
func orNil(m map[string]string) map[string]string {
if len(m) == 0 {
return nil
}
return m
}