diff --git a/README.md b/README.md index e3c375e..ae41632 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ The goal of this library is to provide a simple and efficient way to parse syslo ## Supported RFCs Currently, the library supports the following RFCs: + - [RFC3164](https://datatracker.ietf.org/doc/html/rfc3164) - [RFC5424](https://datatracker.ietf.org/doc/html/rfc5424) The implementation is close to feature complete for the RFC5424 format. The `SD-IDS` are not yet supported, however feel free to open an issue if you need them. @@ -13,6 +14,15 @@ The implementation is close to feature complete for the RFC5424 format. The `SD- The library is designed around the `io.ByteScanner` interface. This allows for parsing in a streaming fashion as well as from memory. +```go +parser := rfc3164.NewParser() +message := []byte("<34>Oct 11 22:14:15 mymachine su: 'su root' failed for lonvick on /dev/pts/8") +msg, err := parser.Parse(bytes.NewReader(message)) +if err != nil { + panic(err) +} +``` + ```go parser := rfc5424.NewParser() message := []byte("<34>1 2003-10-11T22:14:15.003Z mymachine.example.com su - ID47 - 'su root' failed for lonvick on /dev/pts/8'") @@ -22,6 +32,7 @@ if err != nil { } ``` + The parser will take options during initialisation to allow for customisation of the parsing process. The options are passed as variadic arguments to the `NewParser` function. ```go diff --git a/rfc3164/errors.go b/rfc3164/errors.go new file mode 100644 index 0000000..8b835e2 --- /dev/null +++ b/rfc3164/errors.go @@ -0,0 +1,9 @@ +package rfc3164 + +import "errors" + +var ( + ErrInvalidPRI = errors.New("invalid PRI") + ErrInvalidTimestamp = errors.New("invalid timestamp") + ErrInvalidHostname = errors.New("invalid hostname") +) diff --git a/rfc3164/model.go b/rfc3164/model.go new file mode 100644 index 0000000..0e230b4 --- /dev/null +++ b/rfc3164/model.go @@ -0,0 +1,36 @@ +package rfc3164 + +import ( + "time" +) + +type Message struct { + PRI PRI + Timestamp time.Time + Hostname string + Tag string + Content string +} + +// PRI represents the Priority value of a syslog message. +// The PRI is a single byte that encodes the facility and severity of the message. +type PRI struct { + value byte +} + +func NewPRI(value byte) (PRI, error) { + if value > 191 { + return PRI{}, ErrInvalidPRI + } + return PRI{value: value}, nil +} + +// Facility returns the facility value of the PRI. +func (p PRI) Facility() byte { + return p.value & 0xF8 >> 3 +} + +// Severity returns the severity value of the PRI. +func (p PRI) Severity() byte { + return p.value & 0x07 +} diff --git a/rfc3164/rfc3164.go b/rfc3164/rfc3164.go new file mode 100644 index 0000000..35a0669 --- /dev/null +++ b/rfc3164/rfc3164.go @@ -0,0 +1,136 @@ +package rfc3164 + +import ( + "io" + "strings" + "time" +) + +type Parser struct{} + +// NewParser creates a new Parser. +func NewParser() Parser { + return Parser{} +} + +func (p Parser) Parse(input io.ByteScanner) (Message, error) { + var m Message + + pri, err := parsePRI(input) + if err != nil { + return m, err + } + + timestamp, err := parseTimestamp(input) + if err != nil { + return m, err + } + + hostname, err := parseHostname(input) + if err != nil { + return m, err + } + + tag, content := parseMessage(input) + + return Message{ + PRI: PRI{pri}, + Timestamp: timestamp, + Hostname: hostname, + Tag: tag, + Content: content, + }, nil +} + +// parsePRI parses the PRI part of a syslog message. +func parsePRI(input io.ByteScanner) (byte, error) { + b, err := input.ReadByte() + if err != nil || b != '<' { + return 0, ErrInvalidPRI + } + + PRI := byte(0) + for i := 0; i < 4; i++ { + b, err = input.ReadByte() + if err != nil { + return 0, ErrInvalidPRI + } + if b == '>' { + if PRI > 191 { + return 0, ErrInvalidPRI + } + return PRI, nil + } + if b < '0' || b > '9' { + return 0, ErrInvalidPRI + } + PRI = PRI*10 + (b - '0') + } + + return 0, ErrInvalidPRI +} + +func parseTimestamp(input io.ByteScanner) (time.Time, error) { + b, err := input.ReadByte() + if err != nil { + return time.Time{}, ErrInvalidTimestamp + } + if b == ' ' { + return time.Time{}, nil + } + + builder := strings.Builder{} + builder.WriteByte(b) + for i := 0; i < 14; i++ { + b, err := input.ReadByte() + if err != nil { + return time.Time{}, ErrInvalidTimestamp + } + builder.WriteByte(b) + } + + space, err := input.ReadByte() + if err != nil || space != ' ' { + return time.Time{}, ErrInvalidTimestamp + } + + timestamp, err := time.Parse(time.Stamp, builder.String()) + if err != nil { + return time.Time{}, ErrInvalidTimestamp + } + return timestamp, nil +} + +func parseHostname(input io.ByteScanner) (string, error) { + builder := strings.Builder{} + for { + b, err := input.ReadByte() + if err != nil { + return "", ErrInvalidHostname + } + if b == ' ' { + break + } + builder.WriteByte(b) + } + return builder.String(), nil +} + +func parseMessage(input io.ByteScanner) (tag string, content string) { + builder := strings.Builder{} + tagFound := false + for { + b, err := input.ReadByte() + if err != nil { + break + } + if (b == '[' || b == ']' || b == ':') && !tagFound { + tagFound = true + tag = builder.String() + builder.Reset() + } + builder.WriteByte(b) + } + content = builder.String() + return +} diff --git a/rfc3164/rfc3164_test.go b/rfc3164/rfc3164_test.go new file mode 100644 index 0000000..62d4478 --- /dev/null +++ b/rfc3164/rfc3164_test.go @@ -0,0 +1,279 @@ +package rfc3164 + +import ( + "bytes" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + msg []byte + expectedMessage Message + expectedError error + }{ + { + name: "valid message - example 1", + msg: []byte("<34>Oct 11 22:14:15 mymachine su: 'su root' failed for lonvick on /dev/pts/8"), + expectedMessage: Message{ + PRI: PRI{34}, + Timestamp: time.Date(0, time.October, 11, 22, 14, 15, 0, time.UTC), + Hostname: "mymachine", + Tag: "su", + Content: ": 'su root' failed for lonvick on /dev/pts/8", + }, + }, + { + name: "valid message - example 2 (after relay)", + msg: []byte("<13>Feb 5 17:32:18 10.0.0.99 Use the BFG!"), + expectedMessage: Message{ + PRI: PRI{13}, + Timestamp: time.Date(0, time.February, 5, 17, 32, 18, 0, time.UTC), + Hostname: "10.0.0.99", + Tag: "", + Content: "Use the BFG!", + }, + }, + { + name: "valid message - example 3", + msg: []byte("<165>Aug 24 05:34:00 CST 1987 mymachine myproc[10]: %% It's time to make the do-nuts. %% Ingredients: Mix=OK, Jelly=OK # Devices: Mixer=OK, Jelly_Injector=OK, Frier=OK # Transport: Conveyer1=OK, Conveyer2=OK # %%"), + expectedMessage: Message{ + PRI: PRI{165}, + Timestamp: time.Date(0, time.August, 24, 5, 34, 0, 0, time.UTC), + Hostname: "CST", + Tag: "1987 mymachine myproc", + Content: "[10]: %% It's time to make the do-nuts. %% Ingredients: Mix=OK, Jelly=OK # Devices: Mixer=OK, Jelly_Injector=OK, Frier=OK # Transport: Conveyer1=OK, Conveyer2=OK # %%", + }, + }, + } + + for _, tc := range testcases { + r := NewParser() + msg, err := r.Parse(bytes.NewReader(tc.msg)) + assert.Nil(t, err, tc.name) + assert.Equal(t, tc.expectedMessage, msg, tc.name) + } +} + +func TestParsePRI(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + msg []byte + expectedPRI byte + expectedError error + }{ + { + name: "valid PRI - single digit", + msg: []byte("<3>"), + expectedPRI: 3, + }, + { + name: "valid PRI - double digit", + msg: []byte("<34>"), + expectedPRI: 34, + }, + { + name: "valid PRI - triple digit", + msg: []byte("<165>"), + expectedPRI: 165, + }, + { + name: "invalid PRI - missing closing bracket", + msg: []byte("<165"), + expectedPRI: 0, + expectedError: ErrInvalidPRI, + }, + { + name: "invalid PRI - invalid character", + msg: []byte("<1a5>"), + expectedPRI: 0, + expectedError: ErrInvalidPRI, + }, + { + name: "invalid PRI - value too high", + msg: []byte("<192>"), + expectedPRI: 0, + expectedError: ErrInvalidPRI, + }, + { + name: "invalid PRI - value too long", + msg: []byte("<0192>"), + expectedPRI: 0, + expectedError: ErrInvalidPRI, + }, + { + name: "invalid PRI - missing opening bracket", + msg: []byte("165>"), + expectedPRI: 0, + expectedError: ErrInvalidPRI, + }, + { + name: "invalid PRI - empty", + msg: []byte(""), + expectedPRI: 0, + expectedError: ErrInvalidPRI, + }, + } + + for _, tc := range testcases { + pri, err := parsePRI(bytes.NewReader(tc.msg)) + assert.Equal(t, tc.expectedPRI, pri, tc.name) + assert.Equal(t, tc.expectedError, err, tc.name) + } +} + +func TestParseTimestamp(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + msg []byte + expectedTime time.Time + expectedError error + }{ + { + name: "invalid timestamp - empty", + msg: []byte(""), + expectedTime: time.Time{}, + expectedError: ErrInvalidTimestamp, + }, + { + name: "invalid timestamp - no space", + msg: []byte("Aug 4 05:14:15"), + expectedTime: time.Time{}, + expectedError: ErrInvalidTimestamp, + }, + { + name: "valid timestamp", + msg: []byte("Aug 4 05:14:15 "), + expectedTime: time.Date(0, time.August, 4, 5, 14, 15, 0, time.UTC), + expectedError: nil, + }, + { + name: "valid timestamp - empty", + msg: []byte(" "), + expectedTime: time.Time{}, + expectedError: nil, + }, + { + name: "invalid timestamp - too short", + msg: []byte("Aug 4 05:14:1"), + expectedTime: time.Time{}, + expectedError: ErrInvalidTimestamp, + }, + { + name: "invalid timestamp - invalid month", + msg: []byte("Aut 4 05:14:15 "), + expectedTime: time.Time{}, + expectedError: ErrInvalidTimestamp, + }, + } + + for _, tc := range testcases { + timestamp, err := parseTimestamp(bytes.NewReader(tc.msg)) + assert.Equal(t, tc.expectedTime, timestamp, tc.name) + assert.Equal(t, tc.expectedError, err, tc.name) + } +} + +func TestParseHostname(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + msg []byte + expectedHost string + expectedError error + }{ + { + name: "valid hostname", + msg: []byte("host "), + expectedHost: "host", + expectedError: nil, + }, + { + name: "invalid hostname - no space", + msg: []byte("host"), + expectedHost: "", + expectedError: ErrInvalidHostname, + }, + { + name: "invalid hostname - empty", + msg: []byte(""), + expectedHost: "", + expectedError: ErrInvalidHostname, + }, + } + + for _, tc := range testcases { + hostname, err := parseHostname(bytes.NewReader(tc.msg)) + assert.Equal(t, tc.expectedHost, hostname, tc.name) + assert.Equal(t, tc.expectedError, err, tc.name) + } +} + +func TestParseMessage(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + msg []byte + expectedTag string + expectedContent string + }{ + { + name: "valid message", + msg: []byte("tag: content"), + expectedTag: "tag", + expectedContent: ": content", + }, + { + name: "valid message - no tag", + msg: []byte("content"), + expectedTag: "", + expectedContent: "content", + }, + { + name: "valid message - no content", + msg: []byte("tag:"), + expectedTag: "tag", + expectedContent: ":", + }, + { + name: "valid message - empty", + msg: []byte(""), + expectedTag: "", + expectedContent: "", + }, + { + name: "valid message - process id", + msg: []byte("tag[id]: content"), + expectedTag: "tag", + expectedContent: "[id]: content", + }, + } + + for _, tc := range testcases { + tag, content := parseMessage(bytes.NewReader(tc.msg)) + assert.Equal(t, tc.expectedTag, tag, tc.name) + assert.Equal(t, tc.expectedContent, content, tc.name) + } +} + +func BenchmarkParse(b *testing.B) { + r := Parser{} + msg := []byte("<34>Oct 11 22:14:15 mymachine su: 'su root' failed for lonvick on /dev/pts/8") + for i := 0; i < b.N; i++ { + _, err := r.Parse(bytes.NewReader(msg)) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/rfc5424/model.go b/rfc5424/model.go index ac759ac..c05e37d 100644 --- a/rfc5424/model.go +++ b/rfc5424/model.go @@ -22,6 +22,13 @@ type PRI struct { value byte } +func NewPRI(value byte) (PRI, error) { + if value > 191 { + return PRI{}, ErrInvalidPRI + } + return PRI{value: value}, nil +} + // Facility returns the facility value of the PRI. func (p PRI) Facility() byte { return p.value & 0xF8 >> 3