1144 lines
29 KiB
Go
1144 lines
29 KiB
Go
package logs
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/coreos/go-systemd/v22/sdjournal"
|
|
)
|
|
|
|
const defaultLimit = 500
|
|
const initialFileTailBytes = 1 << 20
|
|
|
|
type Collector struct{}
|
|
|
|
type journalSource struct {
|
|
ID string
|
|
Kind string
|
|
Unit string
|
|
Name string
|
|
Parser string
|
|
Category string
|
|
EventType string
|
|
IncludeMatches map[string][]string
|
|
ExcludeMatches map[string][]string
|
|
}
|
|
|
|
type fileSource struct {
|
|
ID string
|
|
Path string
|
|
Name string
|
|
Parser string
|
|
Category string
|
|
EventType string
|
|
}
|
|
|
|
var (
|
|
syslogLinePattern = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d+\s+\d\d:\d\d:\d\d)\s+(\S+)\s+([^\[:]+)(?:\[(\d+)\])?:\s*(.*)$`)
|
|
nginxAccessPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"([A-Z]+)\s+([^\s"]+)\s+HTTP/[^"]+"\s+(\d{3})\s+(\S+)`)
|
|
numericTimePattern = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
|
|
)
|
|
|
|
func NewCollector() *Collector {
|
|
return &Collector{}
|
|
}
|
|
|
|
func (c *Collector) Collect(
|
|
ctx context.Context,
|
|
limit int,
|
|
sources []SourceConfig,
|
|
state map[string]SourceState,
|
|
) ([]Event, map[string]SourceState, error) {
|
|
if limit <= 0 {
|
|
limit = defaultLimit
|
|
}
|
|
journalSources, fileSources := normalizeSources(sources)
|
|
nextState := cloneSourceState(state)
|
|
events := make([]Event, 0, limit)
|
|
seen := map[string]struct{}{}
|
|
|
|
collectJournalByDefault := len(journalSources) == 0 && len(fileSources) == 0
|
|
if collectJournalByDefault {
|
|
journalSources = []journalSource{{
|
|
ID: "default-journal",
|
|
Kind: "journal",
|
|
Name: "journal",
|
|
Parser: "none",
|
|
Category: "system",
|
|
EventType: "system",
|
|
}}
|
|
}
|
|
|
|
for _, source := range journalSources {
|
|
if len(events) >= limit {
|
|
break
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return events, nextState, ctx.Err()
|
|
default:
|
|
}
|
|
current := nextState[source.ID]
|
|
sourceEvents, sourceState, err := c.collectJournalSource(ctx, source, current, limit-len(events), collectJournalByDefault)
|
|
if err != nil {
|
|
return nil, state, err
|
|
}
|
|
nextState[source.ID] = sourceState
|
|
events = appendUniqueEvents(events, sourceEvents, seen, limit)
|
|
}
|
|
|
|
if collectJournalByDefault && len(events) == 0 {
|
|
source := journalSources[0]
|
|
current := nextState[source.ID]
|
|
if current.Cursor == "" {
|
|
sourceEvents, sourceState, err := c.collectJournalSource(ctx, source, current, limit-len(events), false)
|
|
if err == nil {
|
|
nextState[source.ID] = sourceState
|
|
events = appendUniqueEvents(events, sourceEvents, seen, limit)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(events) >= limit || len(fileSources) == 0 {
|
|
return events, nextState, nil
|
|
}
|
|
|
|
var firstFileErr error
|
|
for _, source := range fileSources {
|
|
if len(events) >= limit {
|
|
break
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return events, nextState, ctx.Err()
|
|
default:
|
|
}
|
|
current := nextState[source.ID]
|
|
fileEvents, sourceState, err := collectFileSource(source, current, limit-len(events))
|
|
if err != nil {
|
|
if firstFileErr == nil {
|
|
firstFileErr = fmt.Errorf("collect file source %s: %w", source.Path, err)
|
|
}
|
|
continue
|
|
}
|
|
nextState[source.ID] = sourceState
|
|
events = appendUniqueEvents(events, fileEvents, seen, limit)
|
|
}
|
|
if len(events) == 0 && firstFileErr != nil {
|
|
return events, nextState, firstFileErr
|
|
}
|
|
|
|
return events, nextState, nil
|
|
}
|
|
|
|
func (c *Collector) collectJournalSource(
|
|
ctx context.Context,
|
|
source journalSource,
|
|
state SourceState,
|
|
limit int,
|
|
currentBootOnly bool,
|
|
) ([]Event, SourceState, error) {
|
|
if limit <= 0 {
|
|
return []Event{}, state, nil
|
|
}
|
|
j, err := sdjournal.NewJournal()
|
|
if err != nil {
|
|
return nil, state, err
|
|
}
|
|
defer j.Close()
|
|
|
|
bootID := currentBootID()
|
|
if currentBootOnly && bootID != "" {
|
|
if err := j.AddMatch("_BOOT_ID=" + bootID); err != nil {
|
|
return nil, state, err
|
|
}
|
|
}
|
|
if source.Unit != "" {
|
|
if err := j.AddMatch("_SYSTEMD_UNIT=" + source.Unit); err != nil {
|
|
return nil, state, err
|
|
}
|
|
}
|
|
|
|
// Always collect from tail and stop at the last seen cursor to prioritize
|
|
// newest logs and avoid head-of-journal backlog blocking recent events.
|
|
return c.collectJournalFromTail(ctx, j, source, state, limit, currentBootOnly, bootID)
|
|
}
|
|
|
|
func (c *Collector) collectJournalFromTail(
|
|
ctx context.Context,
|
|
j *sdjournal.Journal,
|
|
source journalSource,
|
|
state SourceState,
|
|
limit int,
|
|
currentBootOnly bool,
|
|
bootID string,
|
|
) ([]Event, SourceState, error) {
|
|
if err := j.SeekTail(); err != nil {
|
|
return nil, state, err
|
|
}
|
|
events := make([]Event, 0, limit)
|
|
nextState := state
|
|
newestCursor := ""
|
|
stopCursor := strings.TrimSpace(state.Cursor)
|
|
for len(events) < limit {
|
|
select {
|
|
case <-ctx.Done():
|
|
return events, nextState, ctx.Err()
|
|
default:
|
|
}
|
|
n, err := j.Previous()
|
|
if err != nil {
|
|
return events, nextState, err
|
|
}
|
|
if n == 0 {
|
|
break
|
|
}
|
|
entry, err := j.GetEntry()
|
|
if err != nil {
|
|
return events, nextState, err
|
|
}
|
|
if newestCursor == "" {
|
|
newestCursor = entry.Cursor
|
|
}
|
|
if stopCursor != "" && entry.Cursor == stopCursor {
|
|
break
|
|
}
|
|
if !journalEntryMatchesSource(entry.Fields, source, currentBootOnly, bootID) {
|
|
continue
|
|
}
|
|
events = append(events, buildJournalEvent(entry, source))
|
|
}
|
|
if newestCursor != "" {
|
|
nextState.Cursor = newestCursor
|
|
}
|
|
return events, nextState, nil
|
|
}
|
|
|
|
func buildJournalEvent(entry *sdjournal.JournalEntry, source journalSource) Event {
|
|
event := fromEntry(entry)
|
|
event.SourceKind = source.Kind
|
|
event.SourceName = source.Name
|
|
if source.Category != "" {
|
|
event.Category = source.Category
|
|
}
|
|
if event.EventType == "" {
|
|
if source.EventType != "" {
|
|
event.EventType = source.EventType
|
|
} else {
|
|
event.EventType = defaultEventType(event.Category)
|
|
}
|
|
}
|
|
if applied := applyParser(&event, source.Parser); applied {
|
|
if event.EventType == "" {
|
|
event.EventType = source.EventType
|
|
}
|
|
}
|
|
return event
|
|
}
|
|
|
|
func journalEntryMatchesSource(fields map[string]string, source journalSource, currentBootOnly bool, bootID string) bool {
|
|
if currentBootOnly && bootID != "" {
|
|
if !strings.EqualFold(strings.TrimSpace(fields["_BOOT_ID"]), strings.TrimSpace(bootID)) {
|
|
return false
|
|
}
|
|
}
|
|
if source.Unit != "" && !strings.EqualFold(strings.TrimSpace(fields["_SYSTEMD_UNIT"]), strings.TrimSpace(source.Unit)) {
|
|
return false
|
|
}
|
|
for key, values := range source.IncludeMatches {
|
|
if !fieldMatchesAny(fields[key], values) {
|
|
return false
|
|
}
|
|
}
|
|
for key, values := range source.ExcludeMatches {
|
|
if fieldMatchesAny(fields[key], values) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func fieldMatchesAny(fieldValue string, values []string) bool {
|
|
fieldValue = strings.TrimSpace(fieldValue)
|
|
if fieldValue == "" {
|
|
return false
|
|
}
|
|
for _, candidate := range values {
|
|
if strings.EqualFold(fieldValue, strings.TrimSpace(candidate)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func collectFileSource(source fileSource, state SourceState, limit int) ([]Event, SourceState, error) {
|
|
if limit <= 0 {
|
|
return []Event{}, state, nil
|
|
}
|
|
file, err := os.Open(source.Path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
next := state
|
|
next.Offset = 0
|
|
next.Inode = 0
|
|
return []Event{}, next, nil
|
|
}
|
|
return nil, state, err
|
|
}
|
|
defer file.Close()
|
|
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return nil, state, err
|
|
}
|
|
size := info.Size()
|
|
inode := fileInode(info)
|
|
offset := state.Offset
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
if state.Inode == 0 && state.Offset == 0 && size > initialFileTailBytes {
|
|
offset = size - initialFileTailBytes
|
|
}
|
|
if state.Inode != 0 && inode != 0 && state.Inode != inode {
|
|
offset = 0
|
|
}
|
|
if offset > size {
|
|
offset = 0
|
|
}
|
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
|
return nil, state, err
|
|
}
|
|
|
|
reader := bufio.NewReader(file)
|
|
current := offset
|
|
if offset > 0 {
|
|
skipped, skipErr := reader.ReadString('\n')
|
|
current += int64(len(skipped))
|
|
if skipErr != nil && skipErr != io.EOF {
|
|
next := state
|
|
next.Offset = current
|
|
next.Inode = inode
|
|
return nil, next, skipErr
|
|
}
|
|
}
|
|
events := make([]Event, 0, limit)
|
|
|
|
for len(events) < limit {
|
|
line, err := reader.ReadString('\n')
|
|
if len(line) > 0 {
|
|
lineStart := current
|
|
current += int64(len(line))
|
|
message := strings.TrimRight(line, "\r\n")
|
|
if message != "" {
|
|
event := NewEvent(time.Now())
|
|
event.SourceKind = "file"
|
|
event.SourceName = source.Name
|
|
event.Category = source.Category
|
|
event.EventType = source.EventType
|
|
event.Message = compactWhitespace(message)
|
|
event.Raw = message
|
|
event.Fields = map[string]string{
|
|
"file_path": source.Path,
|
|
"file_inode": strconv.FormatUint(inode, 10),
|
|
"file_offset": strconv.FormatInt(lineStart, 10),
|
|
}
|
|
_ = applyParser(&event, source.Parser)
|
|
events = append(events, event)
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
next := state
|
|
next.Offset = current
|
|
next.Inode = inode
|
|
return events, next, err
|
|
}
|
|
}
|
|
|
|
next := state
|
|
next.Offset = current
|
|
next.Inode = inode
|
|
reverseEvents(events)
|
|
return events, next, nil
|
|
}
|
|
|
|
func normalizeSources(sources []SourceConfig) ([]journalSource, []fileSource) {
|
|
journalSources := make([]journalSource, 0, len(sources))
|
|
fileSources := make([]fileSource, 0, len(sources))
|
|
for _, source := range sources {
|
|
kind := strings.ToLower(strings.TrimSpace(source.Kind))
|
|
id := sourceStateID(source)
|
|
parser := normalizeParser(source.Parser)
|
|
includeMatches := normalizeMatches(source.IncludeMatches)
|
|
excludeMatches := normalizeMatches(source.ExcludeMatches)
|
|
|
|
switch kind {
|
|
case "service":
|
|
unit := strings.TrimSpace(source.ServiceUnit)
|
|
if unit == "" {
|
|
unit = strings.TrimSpace(source.Name)
|
|
}
|
|
if unit == "" {
|
|
continue
|
|
}
|
|
category := strings.TrimSpace(source.Category)
|
|
if category == "" {
|
|
category = defaultCategoryForService(unit)
|
|
}
|
|
eventType := strings.TrimSpace(source.EventType)
|
|
if eventType == "" {
|
|
eventType = defaultEventType(category)
|
|
}
|
|
name := strings.TrimSpace(source.Name)
|
|
if name == "" {
|
|
name = unit
|
|
}
|
|
includeMatches["_SYSTEMD_UNIT"] = appendUniqueStrings(includeMatches["_SYSTEMD_UNIT"], unit)
|
|
journalSources = append(journalSources, journalSource{
|
|
ID: id,
|
|
Kind: "service",
|
|
Unit: unit,
|
|
Name: name,
|
|
Parser: parser,
|
|
Category: category,
|
|
EventType: eventType,
|
|
IncludeMatches: includeMatches,
|
|
ExcludeMatches: excludeMatches,
|
|
})
|
|
case "journal":
|
|
category := strings.TrimSpace(source.Category)
|
|
if category == "" {
|
|
category = "system"
|
|
}
|
|
eventType := strings.TrimSpace(source.EventType)
|
|
if eventType == "" {
|
|
eventType = defaultEventType(category)
|
|
}
|
|
name := strings.TrimSpace(source.Name)
|
|
if name == "" {
|
|
name = "journal"
|
|
}
|
|
journalSources = append(journalSources, journalSource{
|
|
ID: id,
|
|
Kind: "journal",
|
|
Unit: strings.TrimSpace(source.ServiceUnit),
|
|
Name: name,
|
|
Parser: parser,
|
|
Category: category,
|
|
EventType: eventType,
|
|
IncludeMatches: includeMatches,
|
|
ExcludeMatches: excludeMatches,
|
|
})
|
|
case "file":
|
|
path := strings.TrimSpace(source.FilePath)
|
|
if path == "" {
|
|
path = strings.TrimSpace(source.Name)
|
|
}
|
|
if path == "" {
|
|
continue
|
|
}
|
|
category := strings.TrimSpace(source.Category)
|
|
if category == "" {
|
|
category = "file"
|
|
}
|
|
eventType := strings.TrimSpace(source.EventType)
|
|
if eventType == "" {
|
|
eventType = "file.line"
|
|
}
|
|
name := strings.TrimSpace(source.Name)
|
|
if name == "" {
|
|
name = path
|
|
}
|
|
fileSources = append(fileSources, fileSource{
|
|
ID: id,
|
|
Path: path,
|
|
Name: name,
|
|
Parser: parser,
|
|
Category: category,
|
|
EventType: eventType,
|
|
})
|
|
}
|
|
}
|
|
return journalSources, fileSources
|
|
}
|
|
|
|
func sourceStateID(source SourceConfig) string {
|
|
explicit := strings.TrimSpace(source.SourceID)
|
|
if explicit != "" {
|
|
return explicit
|
|
}
|
|
parts := []string{
|
|
strings.ToLower(strings.TrimSpace(source.Kind)),
|
|
strings.TrimSpace(source.Name),
|
|
strings.TrimSpace(source.ServiceUnit),
|
|
strings.TrimSpace(source.FilePath),
|
|
strings.ToLower(strings.TrimSpace(source.Parser)),
|
|
strings.TrimSpace(source.Category),
|
|
strings.TrimSpace(source.EventType),
|
|
encodeMatchMap(source.IncludeMatches),
|
|
encodeMatchMap(source.ExcludeMatches),
|
|
}
|
|
sum := sha256.Sum256([]byte(strings.Join(parts, "\x1f")))
|
|
return "src-" + hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
func normalizeParser(parser string) string {
|
|
switch strings.ToLower(strings.TrimSpace(parser)) {
|
|
case "syslog", "nginx_access", "json":
|
|
return strings.ToLower(strings.TrimSpace(parser))
|
|
default:
|
|
return "none"
|
|
}
|
|
}
|
|
|
|
func normalizeMatches(in map[string][]string) map[string][]string {
|
|
if len(in) == 0 {
|
|
return map[string][]string{}
|
|
}
|
|
out := map[string][]string{}
|
|
for key, values := range in {
|
|
keyText := strings.TrimSpace(key)
|
|
if keyText == "" {
|
|
continue
|
|
}
|
|
cleanValues := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
valueText := strings.TrimSpace(value)
|
|
if valueText == "" {
|
|
continue
|
|
}
|
|
cleanValues = append(cleanValues, valueText)
|
|
}
|
|
if len(cleanValues) == 0 {
|
|
continue
|
|
}
|
|
out[keyText] = appendUniqueStrings(nil, cleanValues...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func appendUniqueStrings(base []string, values ...string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := append([]string{}, base...)
|
|
for _, value := range out {
|
|
seen[strings.ToLower(strings.TrimSpace(value))] = struct{}{}
|
|
}
|
|
for _, value := range values {
|
|
normalized := strings.TrimSpace(value)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(normalized)
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, normalized)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func cloneSourceState(in map[string]SourceState) map[string]SourceState {
|
|
out := map[string]SourceState{}
|
|
for key, value := range in {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func appendUniqueEvents(dst []Event, src []Event, seen map[string]struct{}, limit int) []Event {
|
|
for _, event := range src {
|
|
if len(dst) >= limit {
|
|
break
|
|
}
|
|
key := eventDedupKey(event)
|
|
if key != "" {
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
dst = append(dst, event)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func reverseEvents(events []Event) {
|
|
for left, right := 0, len(events)-1; left < right; left, right = left+1, right-1 {
|
|
events[left], events[right] = events[right], events[left]
|
|
}
|
|
}
|
|
|
|
func eventDedupKey(event Event) string {
|
|
if len(event.Fields) > 0 {
|
|
if cursor := strings.TrimSpace(event.Fields["journal_cursor"]); cursor != "" {
|
|
return "journal:" + cursor
|
|
}
|
|
path := strings.TrimSpace(event.Fields["file_path"])
|
|
inode := strings.TrimSpace(event.Fields["file_inode"])
|
|
offset := strings.TrimSpace(event.Fields["file_offset"])
|
|
if path != "" && offset != "" {
|
|
return "file:" + path + ":" + inode + ":" + offset
|
|
}
|
|
}
|
|
if event.SourceKind != "" && event.SourceName != "" && event.Timestamp != "" && event.Message != "" {
|
|
return strings.Join([]string{"fallback", event.SourceKind, event.SourceName, event.Timestamp, event.Message}, "|")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func applyParser(event *Event, parser string) bool {
|
|
switch normalizeParser(parser) {
|
|
case "json":
|
|
return applyJSONParser(event)
|
|
case "nginx_access":
|
|
return applyNginxAccessParser(event)
|
|
case "syslog":
|
|
return applySyslogParser(event)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func applyJSONParser(event *Event) bool {
|
|
payload := map[string]any{}
|
|
if err := json.Unmarshal([]byte(event.Message), &payload); err != nil {
|
|
return false
|
|
}
|
|
if event.Fields == nil {
|
|
event.Fields = map[string]string{}
|
|
}
|
|
for key, value := range payload {
|
|
keyText := strings.TrimSpace(key)
|
|
if keyText == "" {
|
|
continue
|
|
}
|
|
event.Fields["json."+keyText] = fmt.Sprint(value)
|
|
}
|
|
if parsedTimestamp, ok := parseJSONTimestamp(payload); ok {
|
|
maybeSetParsedTimestamp(event, parsedTimestamp)
|
|
}
|
|
for _, candidate := range []string{"message", "msg", "log"} {
|
|
if value, ok := payload[candidate]; ok {
|
|
message := compactWhitespace(fmt.Sprint(value))
|
|
if message != "" {
|
|
event.Message = message
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if event.Username == "" {
|
|
for _, candidate := range []string{"username", "user"} {
|
|
if value, ok := payload[candidate]; ok {
|
|
event.Username = strings.TrimSpace(fmt.Sprint(value))
|
|
if event.Username != "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if event.SourceIP == "" {
|
|
for _, candidate := range []string{"source_ip", "ip", "remote_addr", "client_ip"} {
|
|
if value, ok := payload[candidate]; ok {
|
|
event.SourceIP = strings.TrimSpace(fmt.Sprint(value))
|
|
if event.SourceIP != "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func applyNginxAccessParser(event *Event) bool {
|
|
matches := nginxAccessPattern.FindStringSubmatch(event.Message)
|
|
if len(matches) == 0 {
|
|
return false
|
|
}
|
|
if parsedTimestamp, err := time.Parse("02/Jan/2006:15:04:05 -0700", strings.TrimSpace(matches[2])); err == nil {
|
|
maybeSetParsedTimestamp(event, parsedTimestamp)
|
|
}
|
|
if event.SourceIP == "" {
|
|
event.SourceIP = strings.TrimSpace(matches[1])
|
|
}
|
|
if event.Fields == nil {
|
|
event.Fields = map[string]string{}
|
|
}
|
|
event.Fields["nginx.time_local"] = matches[2]
|
|
event.Fields["nginx.method"] = matches[3]
|
|
event.Fields["nginx.path"] = matches[4]
|
|
event.Fields["nginx.status"] = matches[5]
|
|
event.Fields["nginx.body_bytes_sent"] = matches[6]
|
|
if event.EventType == "" || event.EventType == "file.line" || event.EventType == "system" {
|
|
event.EventType = "http.access"
|
|
}
|
|
if event.Category == "system" {
|
|
event.Category = "access"
|
|
}
|
|
return true
|
|
}
|
|
|
|
func applySyslogParser(event *Event) bool {
|
|
message := strings.TrimSpace(event.Message)
|
|
if message == "" {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(message, "<") {
|
|
if end := strings.Index(message, ">"); end > 1 {
|
|
priorityText := strings.TrimSpace(message[1:end])
|
|
if priorityValue, err := strconv.Atoi(priorityText); err == nil {
|
|
severity := priorityValue % 8
|
|
if event.Priority == "" {
|
|
event.Priority = strconv.Itoa(severity)
|
|
}
|
|
message = strings.TrimSpace(message[end+1:])
|
|
}
|
|
}
|
|
}
|
|
matches := syslogLinePattern.FindStringSubmatch(message)
|
|
if len(matches) == 0 {
|
|
return false
|
|
}
|
|
if parsedTimestamp, ok := parseSyslogTimestamp(matches[1], time.Now()); ok {
|
|
maybeSetParsedTimestamp(event, parsedTimestamp)
|
|
}
|
|
if event.Hostname == "" {
|
|
event.Hostname = strings.TrimSpace(matches[2])
|
|
}
|
|
identifier := strings.TrimSpace(matches[3])
|
|
pid := strings.TrimSpace(matches[4])
|
|
parsedMessage := compactWhitespace(matches[5])
|
|
if parsedMessage != "" {
|
|
event.Message = parsedMessage
|
|
}
|
|
if event.Fields == nil {
|
|
event.Fields = map[string]string{}
|
|
}
|
|
if identifier != "" {
|
|
event.Fields["syslog_identifier"] = identifier
|
|
}
|
|
if pid != "" {
|
|
event.Fields["syslog_pid"] = pid
|
|
}
|
|
if event.SourceName == "" && identifier != "" {
|
|
event.SourceName = identifier
|
|
}
|
|
if event.SourceIP == "" || event.Username == "" {
|
|
_, username, sourceIP, _ := parseMessage(event.Category, event.Message)
|
|
if event.Username == "" {
|
|
event.Username = username
|
|
}
|
|
if event.SourceIP == "" {
|
|
event.SourceIP = sourceIP
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func maybeSetParsedTimestamp(event *Event, timestamp time.Time) {
|
|
if timestamp.IsZero() {
|
|
return
|
|
}
|
|
// Journal entries already provide canonical timestamps from journald.
|
|
if event.SourceKind != "" && event.SourceKind != "file" {
|
|
return
|
|
}
|
|
event.Timestamp = timestamp.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
|
|
func parseJSONTimestamp(payload map[string]any) (time.Time, bool) {
|
|
for _, candidate := range []string{
|
|
"@timestamp",
|
|
"timestamp",
|
|
"time",
|
|
"ts",
|
|
"datetime",
|
|
"date",
|
|
"event_time",
|
|
} {
|
|
value, ok := payload[candidate]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if parsed, ok := parseTimestampValue(value); ok {
|
|
return parsed, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func parseTimestampValue(value any) (time.Time, bool) {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return parseTimestampString(typed)
|
|
case float64:
|
|
return parseNumericTimestamp(strconv.FormatFloat(typed, 'f', -1, 64))
|
|
case json.Number:
|
|
return parseNumericTimestamp(typed.String())
|
|
case int:
|
|
return parseNumericTimestamp(strconv.FormatInt(int64(typed), 10))
|
|
case int8:
|
|
return parseNumericTimestamp(strconv.FormatInt(int64(typed), 10))
|
|
case int16:
|
|
return parseNumericTimestamp(strconv.FormatInt(int64(typed), 10))
|
|
case int32:
|
|
return parseNumericTimestamp(strconv.FormatInt(int64(typed), 10))
|
|
case int64:
|
|
return parseNumericTimestamp(strconv.FormatInt(typed, 10))
|
|
case uint:
|
|
return parseNumericTimestamp(strconv.FormatUint(uint64(typed), 10))
|
|
case uint8:
|
|
return parseNumericTimestamp(strconv.FormatUint(uint64(typed), 10))
|
|
case uint16:
|
|
return parseNumericTimestamp(strconv.FormatUint(uint64(typed), 10))
|
|
case uint32:
|
|
return parseNumericTimestamp(strconv.FormatUint(uint64(typed), 10))
|
|
case uint64:
|
|
if typed > math.MaxInt64 {
|
|
return time.Time{}, false
|
|
}
|
|
return parseNumericTimestamp(strconv.FormatUint(typed, 10))
|
|
default:
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
|
|
func parseTimestampString(value string) (time.Time, bool) {
|
|
candidate := strings.TrimSpace(value)
|
|
if candidate == "" {
|
|
return time.Time{}, false
|
|
}
|
|
if numericTimePattern.MatchString(candidate) {
|
|
if parsed, ok := parseNumericTimestamp(candidate); ok {
|
|
return parsed, true
|
|
}
|
|
}
|
|
|
|
layoutsWithZone := []string{
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05.999999999Z07:00",
|
|
"2006-01-02 15:04:05Z07:00",
|
|
"02/Jan/2006:15:04:05 -0700",
|
|
time.RFC1123Z,
|
|
time.RFC1123,
|
|
time.RFC822Z,
|
|
time.RFC822,
|
|
time.RFC850,
|
|
time.RubyDate,
|
|
time.UnixDate,
|
|
}
|
|
for _, layout := range layoutsWithZone {
|
|
if parsed, err := time.Parse(layout, candidate); err == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
|
|
layoutsWithoutZone := []string{
|
|
"2006-01-02 15:04:05.999999999",
|
|
"2006-01-02 15:04:05,999",
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02T15:04:05.999999999",
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02",
|
|
time.ANSIC,
|
|
}
|
|
for _, layout := range layoutsWithoutZone {
|
|
if parsed, err := time.ParseInLocation(layout, candidate, time.Local); err == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func parseNumericTimestamp(value string) (time.Time, bool) {
|
|
candidate := strings.TrimSpace(value)
|
|
if candidate == "" {
|
|
return time.Time{}, false
|
|
}
|
|
if strings.Contains(candidate, ".") {
|
|
numericValue, err := strconv.ParseFloat(candidate, 64)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return parseUnixFloatTimestamp(numericValue), true
|
|
}
|
|
numericValue, err := strconv.ParseInt(candidate, 10, 64)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return parseUnixIntTimestamp(numericValue), true
|
|
}
|
|
|
|
func parseUnixIntTimestamp(value int64) time.Time {
|
|
absValue := value
|
|
if absValue < 0 {
|
|
absValue = -absValue
|
|
}
|
|
switch {
|
|
case absValue >= 1e18:
|
|
return time.Unix(0, value)
|
|
case absValue >= 1e15:
|
|
return time.Unix(0, value*int64(time.Microsecond))
|
|
case absValue >= 1e12:
|
|
return time.Unix(0, value*int64(time.Millisecond))
|
|
default:
|
|
return time.Unix(value, 0)
|
|
}
|
|
}
|
|
|
|
func parseUnixFloatTimestamp(value float64) time.Time {
|
|
absValue := math.Abs(value)
|
|
switch {
|
|
case absValue >= 1e18:
|
|
return time.Unix(0, int64(value))
|
|
case absValue >= 1e15:
|
|
return time.Unix(0, int64(value*float64(time.Microsecond)))
|
|
case absValue >= 1e12:
|
|
return time.Unix(0, int64(value*float64(time.Millisecond)))
|
|
default:
|
|
seconds, fraction := math.Modf(value)
|
|
return time.Unix(int64(seconds), int64(fraction*float64(time.Second)))
|
|
}
|
|
}
|
|
|
|
func parseSyslogTimestamp(value string, now time.Time) (time.Time, bool) {
|
|
candidate := compactWhitespace(value)
|
|
if candidate == "" {
|
|
return time.Time{}, false
|
|
}
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
nowLocal := now.In(time.Local)
|
|
parsed, err := time.ParseInLocation("Jan 2 15:04:05 2006", fmt.Sprintf("%s %d", candidate, nowLocal.Year()), time.Local)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
// Syslog timestamps omit the year; pull back one year if parsing lands
|
|
// in the future due to year rollover.
|
|
if parsed.After(nowLocal.Add(24 * time.Hour)) {
|
|
parsed = parsed.AddDate(-1, 0, 0)
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
func fileInode(info os.FileInfo) uint64 {
|
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return stat.Ino
|
|
}
|
|
|
|
func encodeMatchMap(in map[string][]string) string {
|
|
if len(in) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(in))
|
|
for key := range in {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
values := append([]string{}, in[key]...)
|
|
sort.Strings(values)
|
|
parts = append(parts, key+"="+strings.Join(values, ","))
|
|
}
|
|
return strings.Join(parts, ";")
|
|
}
|
|
|
|
func currentBootID() string {
|
|
data, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func fromEntry(entry *sdjournal.JournalEntry) Event {
|
|
ts := time.Unix(0, int64(entry.RealtimeTimestamp)*int64(time.Microsecond))
|
|
event := NewEvent(ts)
|
|
fields := entry.Fields
|
|
unit := fields["_SYSTEMD_UNIT"]
|
|
rawMessage := strings.TrimSpace(fields["MESSAGE"])
|
|
identifier := fields["SYSLOG_IDENTIFIER"]
|
|
pid := fields["_PID"]
|
|
|
|
event.Unit = unit
|
|
event.Message = compactJournalLine(identifier, pid, rawMessage)
|
|
if rawMessage != "" && rawMessage != event.Message {
|
|
event.Raw = rawMessage
|
|
}
|
|
event.Priority = fields["PRIORITY"]
|
|
event.Hostname = fields["_HOSTNAME"]
|
|
event.Fields = compactJournalFields(fields, entry.Cursor)
|
|
|
|
event.Category = categorize(unit, identifier, fields)
|
|
event.EventType, event.Username, event.SourceIP, event.SessionID = parseMessage(event.Category, rawMessage)
|
|
if event.EventType == "" {
|
|
event.EventType = defaultEventType(event.Category)
|
|
}
|
|
return event
|
|
}
|
|
|
|
func compactJournalLine(identifier string, pid string, message string) string {
|
|
normalizedMessage := compactWhitespace(message)
|
|
identifier = strings.TrimSpace(identifier)
|
|
pid = strings.TrimSpace(pid)
|
|
|
|
if normalizedMessage == "" {
|
|
if identifier != "" && pid != "" {
|
|
return identifier + "[" + pid + "]"
|
|
}
|
|
return identifier
|
|
}
|
|
if identifier != "" && pid != "" {
|
|
return identifier + "[" + pid + "]: " + normalizedMessage
|
|
}
|
|
if identifier != "" {
|
|
return identifier + ": " + normalizedMessage
|
|
}
|
|
return normalizedMessage
|
|
}
|
|
|
|
func compactWhitespace(value string) string {
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|
|
|
|
func compactJournalFields(fields map[string]string, cursor string) map[string]string {
|
|
compact := map[string]string{}
|
|
if cursor != "" {
|
|
compact["journal_cursor"] = cursor
|
|
}
|
|
if bootID := strings.TrimSpace(fields["_BOOT_ID"]); bootID != "" {
|
|
compact["boot_id"] = bootID
|
|
}
|
|
if transport := strings.TrimSpace(fields["_TRANSPORT"]); transport != "" {
|
|
compact["transport"] = transport
|
|
}
|
|
if comm := strings.TrimSpace(fields["_COMM"]); comm != "" {
|
|
compact["comm"] = comm
|
|
}
|
|
if exe := strings.TrimSpace(fields["_EXE"]); exe != "" {
|
|
compact["exe"] = exe
|
|
}
|
|
if pid := strings.TrimSpace(fields["_PID"]); pid != "" {
|
|
compact["pid"] = pid
|
|
}
|
|
if uid := strings.TrimSpace(fields["_UID"]); uid != "" {
|
|
compact["uid"] = uid
|
|
}
|
|
if gid := strings.TrimSpace(fields["_GID"]); gid != "" {
|
|
compact["gid"] = gid
|
|
}
|
|
if len(compact) == 0 {
|
|
return nil
|
|
}
|
|
return compact
|
|
}
|
|
|
|
func categorize(unit string, identifier string, fields map[string]string) string {
|
|
switch {
|
|
case unit == "sshd.service" || unit == "ssh.service" || identifier == "sshd":
|
|
return "access"
|
|
case unit == "sudo.service" || identifier == "sudo":
|
|
return "auth"
|
|
case unit == "systemd-networkd.service" || identifier == "NetworkManager":
|
|
return "network"
|
|
case fields["_TRANSPORT"] == "kernel":
|
|
return "system"
|
|
default:
|
|
return "system"
|
|
}
|
|
}
|
|
|
|
func defaultCategoryForService(unit string) string {
|
|
switch strings.ToLower(unit) {
|
|
case "sshd.service", "ssh.service":
|
|
return "access"
|
|
case "sudo.service":
|
|
return "auth"
|
|
case "systemd-networkd.service", "networkmanager.service":
|
|
return "network"
|
|
default:
|
|
return "system"
|
|
}
|
|
}
|
|
|
|
func defaultEventType(category string) string {
|
|
switch category {
|
|
case "access":
|
|
return "ssh"
|
|
case "auth":
|
|
return "auth"
|
|
case "network":
|
|
return "network"
|
|
case "file":
|
|
return "file.line"
|
|
default:
|
|
return "system"
|
|
}
|
|
}
|
|
|
|
func parseMessage(category string, msg string) (eventType string, username string, sourceIP string, sessionID string) {
|
|
if msg == "" {
|
|
return "", "", "", ""
|
|
}
|
|
lower := strings.ToLower(msg)
|
|
if category == "access" {
|
|
switch {
|
|
case strings.Contains(lower, "accepted"):
|
|
eventType = "ssh.login.success"
|
|
username = extractBetween(msg, "for ", " from")
|
|
sourceIP = extractBetween(msg, "from ", " port")
|
|
case strings.Contains(lower, "failed password"):
|
|
eventType = "ssh.login.fail"
|
|
username = extractBetween(msg, "for ", " from")
|
|
sourceIP = extractBetween(msg, "from ", " port")
|
|
case strings.Contains(lower, "session opened"):
|
|
eventType = "ssh.session.open"
|
|
username = extractBetween(msg, "for user ", " by")
|
|
case strings.Contains(lower, "session closed"):
|
|
eventType = "ssh.session.close"
|
|
username = extractBetween(msg, "for user ", " by")
|
|
}
|
|
}
|
|
return eventType, strings.TrimSpace(username), strings.TrimSpace(sourceIP), strings.TrimSpace(sessionID)
|
|
}
|
|
|
|
func extractBetween(msg string, start string, end string) string {
|
|
startIdx := strings.Index(msg, start)
|
|
if startIdx == -1 {
|
|
return ""
|
|
}
|
|
startIdx += len(start)
|
|
rest := msg[startIdx:]
|
|
endIdx := strings.Index(rest, end)
|
|
if endIdx == -1 {
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
return strings.TrimSpace(rest[:endIdx])
|
|
}
|