From 5ea0bbfc87ba39af9b641e730088c4506902b90a Mon Sep 17 00:00:00 2001 From: Laurence Withers Date: Sun, 18 Feb 2024 10:31:14 +0000 Subject: [PATCH] Initial working version --- LICENSE | 2 +- attr.go | 32 +++++++++++ attr_key.go | 138 +++++++++++++++++++++++++++++++++++++++++++++ attr_key_test.go | 85 ++++++++++++++++++++++++++++ attr_test.go | 21 +++++++ conn.go | 91 ++++++++++++++++++++++++++++++ global.go | 127 +++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + priority.go | 68 ++++++++++++++++++++++ wire_proto.go | 44 +++++++++++++++ wire_proto_test.go | 38 +++++++++++++ 11 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 attr.go create mode 100644 attr_key.go create mode 100644 attr_key_test.go create mode 100644 attr_test.go create mode 100644 conn.go create mode 100644 global.go create mode 100644 go.mod create mode 100644 priority.go create mode 100644 wire_proto.go create mode 100644 wire_proto_test.go diff --git a/LICENSE b/LICENSE index b346127..7d25034 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 go +Copyright (c) 2024 Laurence Withers. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/attr.go b/attr.go new file mode 100644 index 0000000..28bb255 --- /dev/null +++ b/attr.go @@ -0,0 +1,32 @@ +package journal + +// Attr represents a key/value pair ("attribute"), several of which comprise a +// journal entry. +type Attr struct { + Key AttrKey + Value []byte +} + +// UseTextProto returns true if we can use the text protocol to encode the +// message on the wire. It is heuristic; it will only consider messages of +// ≤ 128 bytes. Following guidance at https://systemd.io/JOURNAL_NATIVE_PROTOCOL/ +// it is intended to make for nice strace(1) traces. +func (a Attr) UseTextProto() bool { + if len(a.Value) > 128 { + return false + } + for _, b := range a.Value { + if b < 32 || b > 0xFE { + return false + } + } + return true +} + +// StringAttr captures the common paradigm of building an Attr from a string. +func StringAttr(key AttrKey, value string) Attr { + return Attr{ + Key: key, + Value: []byte(value), + } +} diff --git a/attr_key.go b/attr_key.go new file mode 100644 index 0000000..fbb2175 --- /dev/null +++ b/attr_key.go @@ -0,0 +1,138 @@ +package journal + +import ( + "fmt" +) + +// AttrKey holds the key part of a key/value (attribute) pair. Passing an +// uninitialised (or invalid) AttrKey to other journal methods will cause the +// attribute to be ignored; check for errors from NewAttrKey to ensure that +// your keys are valid ahead of time. +type AttrKey struct { + // key is written directly to the journal socket. If it is empty, then + // the attribute should be silently discarded. By having it unexported, + // we can ensure that library users can only ever have valid string + // values here. + key string +} + +// Key returns the string value of the key. If the attribute is invalid, it +// returns an empty string. +func (ak AttrKey) Key() string { + return ak.key +} + +// Valid returns true if ak is valid. +func (ak AttrKey) Valid() bool { + return ak.key != "" +} + +// NewAttrKey initialises and returns an AttrKey that may be used when +// constructing key/value (attribute) pairs for logging. If the key is invalid, +// then an error will be returned (and the returned key value will be invalid, +// and any attribute using that key will silently be omitted from the logs). +// Rules for valid attribute keys: +// - must not be empty; +// - must not begin with an underscore '_'; +// - must only contain uppercase ASCII 'A'–'Z', digits '0'–'9', or +// underscores; +// - must be < 256 characters. +func NewAttrKey(key string) (AttrKey, error) { + if err := AttrKeyValid(key); err != nil { + return AttrKey{}, err + } + return AttrKey{ + key: key, + }, nil +} + +// MustAttrKey returns an AttrKey for the given string. If the string is not a +// valid systemd attribute key, it returns an invalid AttrKey that will cause +// the attribute not to be emitted to the journal. +func MustAttrKey(key string) AttrKey { + if err := AttrKeyValid(key); err != nil { + return AttrKey{} + } + return AttrKey{ + key: key, + } +} + +// AttrKeyValid checks the given attribute key and returns an error if there is +// a fault with it. +func AttrKeyValid(key string) error { + switch { + case key == "": + return &InvalidAttrKey{ + Key: key, + Reason: AttrKeyEmpty, + } + case key[0] == '_': + return &InvalidAttrKey{ + Key: key, + Reason: AttrKeyTrusted, + } + case len(key) >= 256: + return &InvalidAttrKey{ + Key: key, + Reason: AttrKeyLength, + } + case !attrKeyValidChars(key): + return &InvalidAttrKey{ + Key: key, + Reason: AttrKeyInvalidChars, + } + default: + return nil + } +} + +// attrKeyValidChars returns true if the given key comprises only ASCII 'A'–'Z', +// '0'–'9' and underscores. +func attrKeyValidChars(key string) bool { + for _, b := range key { + switch { + case b >= 'A' && b <= 'Z': + case b >= '0' && b <= '9': + case b == '_': + + default: + return false + } + } + return true +} + +// InvalidAttrKey details why a given attribute key is invalid. +type InvalidAttrKey struct { + Key string + Reason InvalidAttrKeyReason +} + +func (iak *InvalidAttrKey) Error() string { + return fmt.Sprintf("invalid systemd journal attribute key %q: %s", iak.Key, iak.Reason) +} + +type InvalidAttrKeyReason int + +const ( + AttrKeyEmpty InvalidAttrKeyReason = iota + AttrKeyTrusted + AttrKeyLength + AttrKeyInvalidChars +) + +func (iar InvalidAttrKeyReason) String() string { + switch iar { + case AttrKeyEmpty: + return "key is empty" + case AttrKeyTrusted: + return "key is trusted (begins with '_' character)" + case AttrKeyLength: + return "key is too long (max 256 chars)" + case AttrKeyInvalidChars: + return "key contains invalid character (only 'A'–'Z', '0'–'9', or '_' allowed)" + default: + return "???" + } +} diff --git a/attr_key_test.go b/attr_key_test.go new file mode 100644 index 0000000..f30f5c2 --- /dev/null +++ b/attr_key_test.go @@ -0,0 +1,85 @@ +package journal + +import ( + "errors" + "strings" + "testing" +) + +func TestAttrKeyValid(t *testing.T) { + for _, testcase := range []struct { + name, key string + valid bool + reason InvalidAttrKeyReason + }{ + {"short", "MESSAGE", true, -1}, + {"underscore", "MESSAGE_", true, -1}, + {"numeric", "12345", true, -1}, + {"long", strings.Repeat("Z", 255), true, -1}, + {"too_long", strings.Repeat("Z", 256), false, AttrKeyLength}, + {"empty", "", false, AttrKeyEmpty}, + {"trusted", "_MESSAGE", false, AttrKeyTrusted}, + {"punctuation", "MESSAGE-2", false, AttrKeyInvalidChars}, + {"lowercase", "message", false, AttrKeyInvalidChars}, + {"space", "MESS AGE", false, AttrKeyInvalidChars}, + {"newline", "MESS\nAGE", false, AttrKeyInvalidChars}, + {"NUL", "\x00", false, AttrKeyInvalidChars}, + } { + if testcase.valid { + t.Run("valid/"+testcase.name, func(t *testing.T) { + if err := AttrKeyValid(testcase.key); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } else { + t.Run("invalid/"+testcase.name, func(t *testing.T) { + err := AttrKeyValid(testcase.key) + var iak *InvalidAttrKey + switch { + case err == nil: + t.Error("unexpectedly succeeded") + case errors.As(err, &iak): + if iak.Key != testcase.key { + t.Errorf("InvalidAttrKey error has wrong key %q", iak.Key) + } + if iak.Reason != testcase.reason { + t.Errorf("InvalidAttrKey error has wrong reason %v", iak.Reason) + } + default: + t.Errorf("unexpected error type %T: %v", err, err) + } + }) + } + } +} + +func TestNewAttrKeyValid(t *testing.T) { + k, err := NewAttrKey("MESSAGE") + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if k.key != "MESSAGE" { + t.Errorf(".key does not match expected: %q", k.key) + } +} + +func TestNewAttrKeyInvalid(t *testing.T) { + k, err := NewAttrKey("_MESSAGE") + var iak *InvalidAttrKey + switch { + case err == nil: + t.Error("unexpectedly succeeded") + case errors.As(err, &iak): + if iak.Key != "_MESSAGE" { + t.Errorf("InvalidAttrKey error has wrong key %q", iak.Key) + } + if iak.Reason != AttrKeyTrusted { + t.Errorf("InvalidAttrKey error has wrong reason %v", iak.Reason) + } + default: + t.Errorf("unexpected error type %T: %v", err, err) + } + if k.key != "" { + t.Errorf(".key not empty: %q", k.key) + } +} diff --git a/attr_test.go b/attr_test.go new file mode 100644 index 0000000..f1bae9a --- /dev/null +++ b/attr_test.go @@ -0,0 +1,21 @@ +package journal + +import "testing" + +func TestAttrTextProtoOK(t *testing.T) { + a := Attr{ + Value: []byte("hello, world!"), + } + if !a.UseTextProto() { + t.Error("UseTextProto returned false") + } +} + +func TestAttrTextProtoNewline(t *testing.T) { + a := Attr{ + Value: []byte("hello, brave new\nworld!"), + } + if a.UseTextProto() { + t.Error("UseTextProto returned true") + } +} diff --git a/conn.go b/conn.go new file mode 100644 index 0000000..4ab8b2c --- /dev/null +++ b/conn.go @@ -0,0 +1,91 @@ +package journal + +import ( + "net" + "sync" +) + +const ( + // WellKnownSocketPath is where we generally expect to find the systemd + // journal daemon's socket. + WellKnownSocketPath = "/run/systemd/journal/socket" +) + +// Conn represents an active, open connection to the systemd journal that is +// ready to write log entries. +type Conn struct { + // Common attributes which are emitted for all messages. + Common []Attr + + // ErrHandler is called if there is an error writing to the log. If nil, + // the error is silently discarded. + ErrHandler func(error) + + s *net.UnixConn + bufs sync.Pool +} + +// Connect to the systemd journal. If the path string is empty, then it uses the +// well-known socket path. +func Connect(path string) (*Conn, error) { + if path == "" { + path = WellKnownSocketPath + } + + s, err := net.DialUnix("unixgram", nil, &net.UnixAddr{ + Name: path, + Net: "unixgram", + }) + if err != nil { + return nil, err + } + + return &Conn{ + s: s, + bufs: sync.Pool{ + New: func() any { + return &net.Buffers{} + }, + }, + }, nil +} + +var messageAttrKey = AttrKey{key: "MESSAGE"} + +// Entry emits a log entry. It will add PRIORITY and MESSAGE key/value pairs +// as well as any Common attributes. +// +// Note: to avoid allocation / garbage, ensure attrs has capacity for an extra +// 2+len(c.Common) values. +func (c *Conn) Entry(pri Priority, msg string, attrs []Attr) { + err := c.EntryErr(pri, msg, attrs) + switch { + case err == nil: + return + case c.ErrHandler == nil: + return + default: + c.ErrHandler(err) + } +} + +// EntryErr is like Entry, but will propagate errors to the caller, rather than +// using the built-in error handler. +func (c *Conn) EntryErr(pri Priority, msg string, attrs []Attr) error { + attrs = append(attrs, pri.Attr(), Attr{ + Key: messageAttrKey, + Value: []byte(msg), + }) + attrs = append(attrs, c.Common...) + return c.WriteAttrs(attrs) +} + +// WriteAttrs is a low-level method which writes a journal entry comprised of +// only the given set of attributes. +func (c *Conn) WriteAttrs(attrs []Attr) error { + buf := c.bufs.Get().(*net.Buffers) + *buf = (*buf)[:0] + err := WireWrite(buf, c.s, attrs) + c.bufs.Put(buf) + return err +} diff --git a/global.go b/global.go new file mode 100644 index 0000000..e9e45c8 --- /dev/null +++ b/global.go @@ -0,0 +1,127 @@ +package journal + +import ( + "errors" + "fmt" + "os" + "sync" + "time" +) + +var ( + global *Conn + globalLock sync.Mutex + globalCommon []Attr + globalErrHandler func(error) + globalRateLimit time.Time + + errGlobalBackoff = errors.New("journal unavailable; retrying after 30s") +) + +const ( + globalConnectRetry = 30 * time.Second +) + +// CheckConnection returns nil if the connection to the journal is OK, or an +// error otherwise. It will attempt to establish a connection to the journal +// if there is not already one in progress. +func CheckConnection() error { + _, err := getGlobal() + return err +} + +// SetGlobalConn can be used to override the default connection to the journal +// that is used by the global logging functions (the ones which do not directly +// operate on a Conn). +func SetGlobalConn(c *Conn) { + globalLock.Lock() + defer globalLock.Unlock() + global = c +} + +func getGlobal() (*Conn, error) { + globalLock.Lock() + defer globalLock.Unlock() + + // if already connected, return + if global != nil { + return global, nil + } + + // rate limit new connections + if time.Now().Before(globalRateLimit) { + return nil, errGlobalBackoff + } + + // attempt a new connection + var err error + global, err = Connect(WellKnownSocketPath) + if err != nil { + fmt.Fprintln(os.Stderr, "[journal] failed to connect:", err) + globalRateLimit = time.Now().Add(globalConnectRetry) + return nil, err + } + + global.Common = globalCommon + if globalErrHandler != nil { + global.ErrHandler = globalErrHandler + } else { + global.ErrHandler = newDefaultGlobalErrHandler() + } + + return global, nil +} + +func newDefaultGlobalErrHandler() func(error) { + var errHandlerOnce sync.Once + return func(err error) { + errHandlerOnce.Do(func() { + fmt.Fprintln(os.Stderr, "[journal] failed to write entry:", err) + globalLock.Lock() + defer globalLock.Unlock() + if global != nil { + global.s.Close() + global = nil + globalRateLimit = time.Now().Add(globalConnectRetry) + } + }) + } +} + +// SetGlobalCommonAttr sets common attributes which will be added to every entry +// sent through the global connection. +func SetGlobalCommonAttr(attr []Attr) { + globalLock.Lock() + defer globalLock.Unlock() + + globalCommon = attr + if global != nil { + global.Common = attr + } +} + +// SetGlobalErrHandler allows overriding the default error handler that is +// called when the connection fails. The default will attempt to reconnect +// to the journal socket after a short delay. +func SetGlobalErrHandler(errHandler func(err error)) { + globalLock.Lock() + defer globalLock.Unlock() + + globalErrHandler = errHandler + if global != nil { + global.ErrHandler = errHandler + } +} + +// Entry writes a journal entry via the global connection. It will establish +// a connection as necessary. +// +// Note: to avoid allocation / garbage, ensure attrs has capacity for an extra +// 2+len(GlobalCommonAttr) values. +func Entry(pri Priority, msg string, attrs []Attr) { + c, _ := getGlobal() + if c == nil { + return + } + c.Entry(pri, msg, attrs) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a721442 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module src.lwithers.me.uk/go/journal + +go 1.22.0 diff --git a/priority.go b/priority.go new file mode 100644 index 0000000..7e36cc2 --- /dev/null +++ b/priority.go @@ -0,0 +1,68 @@ +package journal + +// Priority is the "level" or severity of the message. +type Priority int + +const ( + PriEmerg Priority = iota + PriAlert + PriCrit + PriErr + PriWarning + PriNotice + PriInfo + PriDebug +) + +// String returns the same value as Keyword. +func (pri Priority) String() string { + return pri.Keyword() +} + +// Keyword returns a keyword associated with the priority level. These should be +// recognisable to system operators. +func (pri Priority) Keyword() string { + switch pri { + case PriEmerg: + return "emerg" + case PriAlert: + return "alert" + case PriCrit: + return "crit" + case PriErr: + return "err" + case PriWarning: + return "warning" + case PriNotice: + return "notice" + case PriInfo: + return "info" + case PriDebug: + return "debug" + default: + return "?" + } +} + +var priorityAttrKey = AttrKey{key: "PRIORITY"} + +var priorityAttrMap = map[Priority]Attr{ + PriEmerg: Attr{Key: priorityAttrKey, Value: []byte{'0'}}, + PriAlert: Attr{Key: priorityAttrKey, Value: []byte{'1'}}, + PriCrit: Attr{Key: priorityAttrKey, Value: []byte{'2'}}, + PriErr: Attr{Key: priorityAttrKey, Value: []byte{'3'}}, + PriWarning: Attr{Key: priorityAttrKey, Value: []byte{'4'}}, + PriNotice: Attr{Key: priorityAttrKey, Value: []byte{'5'}}, + PriInfo: Attr{Key: priorityAttrKey, Value: []byte{'6'}}, + PriDebug: Attr{Key: priorityAttrKey, Value: []byte{'7'}}, +} + +var unknownPriorityAttr = Attr{Key: priorityAttrKey, Value: []byte{'6'}} + +// Attr returns an attribute value ready to be used in a journal entry. +func (pri Priority) Attr() Attr { + if a, ok := priorityAttrMap[pri]; ok { + return a + } + return unknownPriorityAttr +} diff --git a/wire_proto.go b/wire_proto.go new file mode 100644 index 0000000..7f1b41c --- /dev/null +++ b/wire_proto.go @@ -0,0 +1,44 @@ +package journal + +import ( + "encoding/binary" + "io" + "net" +) + +var ( + wireProtocolAttributeSeparator = []byte{'='} + wireProtocolAttributeTerminator = []byte{'\n'} +) + +// WireWrite is the lowest-level routine. It writes a journal entry using the +// native wire protocol documented at https://systemd.io/JOURNAL_NATIVE_PROTOCOL/ . +// Invalid attributes are silently skipped. It appends to buf as needed. +// +// As long as w supports the net.buffersWriter interface, the write will be +// atomic, and this function is safe to use across goroutines. Otherwise, +// writes may be interleaved. +func WireWrite(buf *net.Buffers, w io.Writer, attrs []Attr) error { + for i := range attrs { + key := attrs[i].Key.key + if key == "" { + // silently discard invalid attributes + continue + } + + *buf = append(*buf, []byte(key)) + if attrs[i].UseTextProto() { + *buf = append(*buf, wireProtocolAttributeSeparator) + *buf = append(*buf, attrs[i].Value) + } else { + var valueSize [9]byte + valueSize[0] = '\n' + binary.LittleEndian.PutUint64(valueSize[1:], uint64(len(attrs[i].Value))) + *buf = append(*buf, valueSize[:]) + *buf = append(*buf, attrs[i].Value) + } + *buf = append(*buf, wireProtocolAttributeTerminator) + } + _, err := buf.WriteTo(w) + return err +} diff --git a/wire_proto_test.go b/wire_proto_test.go new file mode 100644 index 0000000..ba4637d --- /dev/null +++ b/wire_proto_test.go @@ -0,0 +1,38 @@ +package journal + +import ( + "bytes" + "encoding/hex" + "net" + "testing" +) + +func TestWireWrite(t *testing.T) { + var buf net.Buffers + out := bytes.NewBuffer(nil) + err := WireWrite(&buf, out, []Attr{ + Attr{ + Key: AttrKey{key: "MESSAGE"}, + Value: []byte("hello, world!"), + }, + priorityAttrMap[PriDebug], + Attr{ + Key: AttrKey{key: "MULTI_LINE"}, + Value: []byte("hello,\nworld!"), + }, + }) + if err != nil { + t.Errorf("unexpected error writing to buffer: %v", err) + } + + if out.String() != wireWriteExpected { + t.Error("unexpected output buffer:\n" + hex.Dump(out.Bytes())) + } +} + +const wireWriteExpected = `MESSAGE=hello, world! +PRIORITY=7 +MULTI_LINE +` + "\x0D\x00\x00\x00\x00\x00\x00\x00" + `hello, +world! +`