Модуль слухає UDP у мережі клієнта: комутатор у закритій мережі до сервера не достукається, а зонд уже має вихідний канал. Розбір трьома рівнями суворості — 5424, 3164 і «як є». Останній не запасний варіант, а робочий режим: пристрої, що шлють голий текст, існують, і втратити подію гірше, ніж зберегти саме повідомлення. Текст Cisco "%SYS-5-CONFIG_I: ..." свідомо НЕ ріжеться в поле tag — саме за ним шукають зміну конфігу. Ліміт частоти на джерело, а не спільний: комутатор, що зациклився на помилці, інакше витіснив би з журналу всю решту мережі — тобто рівно те, що треба бачити під час аварії. З тієї ж причини переповнена черга викидає найстаріше, а не найновіше. Транспорт до сервера ще не під'єднано: серверний StreamLogs уже є, лишається цикл відправки на зонді. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
6.8 KiB
Go
252 lines
6.8 KiB
Go
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
|
||
}
|