Initial working version

This commit is contained in:
Laurence Withers 2024-02-18 10:31:14 +00:00
parent 3bf8ccebf8
commit 5ea0bbfc87
11 changed files with 648 additions and 1 deletions

View File

@ -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:

32
attr.go Normal file
View File

@ -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),
}
}

138
attr_key.go Normal file
View File

@ -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 "???"
}
}

85
attr_key_test.go Normal file
View File

@ -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)
}
}

21
attr_test.go Normal file
View File

@ -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")
}
}

91
conn.go Normal file
View File

@ -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
}

127
global.go Normal file
View File

@ -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)
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module src.lwithers.me.uk/go/journal
go 1.22.0

68
priority.go Normal file
View File

@ -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
}

44
wire_proto.go Normal file
View File

@ -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
}

38
wire_proto_test.go Normal file
View File

@ -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!
`