Files
logstream/dmesg_test.go
cedricandClaude Opus 5.5 0beaa24f82 Kernel messages (dmesg) source read from /dev/kmsg
New source in Settings > Sources, off by default: reads the kernel ring
buffer of the host through /dev/kmsg, dates records from the boot time and
resumes after the last sequence number of the same boot (no duplicates on
restart). Missing device or denied access is shown in the UI, retried every
10 s. docker-compose.yml and .env.example document the device and the
kernel.dmesg_restrict / CAP_SYSLOG options.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:52:17 +02:00

266 lines
7.7 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func TestParseKmsg(t *testing.T) {
rec, ok := parseKmsg([]byte("6,339,5140900,-;NET: Registered protocol family 10\n SUBSYSTEM=net\n DEVICE=n2"))
if !ok || rec.Prio != 6 || rec.Seq != 339 || rec.Usec != 5140900 || rec.Msg != "NET: Registered protocol family 10" {
t.Fatalf("record %+v", rec)
}
if rec.Dict["SUBSYSTEM"] != "net" || rec.Dict["DEVICE"] != "n2" {
t.Fatalf("dict %+v", rec.Dict)
}
// Extra header fields (newer kernels: caller id) and a ';' in the message.
rec, ok = parseKmsg([]byte("4,12,99,c,caller=T1;a;b \\x1b[31mred\\x5c"))
if !ok || rec.Seq != 12 || rec.Msg != "a;b \x1b[31mred\\" {
t.Fatalf("record %+v", rec)
}
for _, bad := range []string{"", "no header", "6,1;short", "x,1,2,-;msg", "6,-1,2,-;msg", "-1,1,2,-;msg"} {
if _, ok := parseKmsg([]byte(bad)); ok {
t.Errorf("%q parsed", bad)
}
}
}
func TestUnescapeKmsg(t *testing.T) {
for in, want := range map[string]string{
`plain`: "plain",
`a\x0ab`: "a\nb",
`end\x4`: `end\x4`,
`bad\xzz`: `bad\xzz`,
`\x41\x42\x4`: `AB\x4`,
} {
if got := unescapeKmsg(in); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
}
func TestSplitKmsg(t *testing.T) {
got := splitKmsg([]byte("6,1,0,-;one\n SUBSYSTEM=pci\n6,2,0,-;two\n6,3,0,-;three"))
if len(got) != 3 || string(got[0]) != "6,1,0,-;one\n SUBSYSTEM=pci" || string(got[2]) != "6,3,0,-;three" {
t.Fatalf("got %q", got)
}
}
func TestKmsgEntry(t *testing.T) {
boot := time.Date(2026, 10, 4, 6, 0, 0, 0, time.UTC)
now := boot.Add(time.Hour)
rec, _ := parseKmsg([]byte("3,7,2500000,-;usb 1-1: device descriptor read/64, error -71\n SUBSYSTEM=usb\n DEVICE=c189:1"))
e := kmsgEntry(rec, boot.Add(2500*time.Millisecond), now)
if e.App != "kernel" || e.Facility != "kern" || e.Severity != "err" || e.SevNum != 3 || e.SourceType != "kernel" || e.Proto != "kmsg" || !e.Wait {
t.Fatalf("entry %+v", e)
}
if e.Message != "usb 1-1: device descriptor read/64, error -71" || e.Extra["subsystem"] != "usb" || e.Extra["device"] != "c189:1" {
t.Fatalf("entry %+v", e)
}
if !e.Time.Equal(boot.Add(2500*time.Millisecond)) || !e.Received.Equal(now) {
t.Fatalf("times %v %v", e.Time, e.Received)
}
// Written by a program (facility daemon = 3<<3 | info).
rec, _ = parseKmsg([]byte("30,8,3000000,-;systemd[1]: Started Journal Service."))
e = kmsgEntry(rec, now, now)
if e.App != "systemd" || e.ProcID != "1" || e.Facility != "daemon" || e.Severity != "info" || e.Message != "Started Journal Service." || e.Extra != nil {
t.Fatalf("entry %+v", e)
}
// Facility out of range.
rec, _ = parseKmsg([]byte("255,9,0,-;odd"))
if e = kmsgEntry(rec, now, now); e.Facility != "user" || e.Severity != "debug" {
t.Fatalf("entry %+v", e)
}
}
// fakeHost writes a kmsg file, a boot id and an uptime in a temp dir.
func fakeHost(t *testing.T, uptime float64, records ...string) (*Dmesg, *[]*Entry, *sync.Mutex) {
t.Helper()
dir := t.TempDir()
write := func(name, s string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
return p
}
var mu sync.Mutex
var got []*Entry
d := NewDmesg(write("kmsg", strings.Join(records, "\n")+"\n"), filepath.Join(dir, "data"), time.Hour, func(e *Entry) {
mu.Lock()
got = append(got, e)
mu.Unlock()
})
d.bootIDPath = write("boot_id", "b1\n")
d.uptimePath = write("uptime", strconv.FormatFloat(uptime, 'f', 2, 64)+" 1234.00\n")
d.retry = 20 * time.Millisecond
return d, &got, &mu
}
func TestDmesgBackfillAndResume(t *testing.T) {
// Booted 3 h ago: the record at 1 s is outside the 1 h backfill window,
// the ones at 2.5 h and 2.9 h are inside.
d, got, mu := fakeHost(t, 3*3600,
"6,1,1000000,-;Linux version 6.12",
"4,2,9000000000,-;eth0: link down",
"6,3,10440000000,-;eth0: link up",
)
s, err := d.start()
if err != nil {
t.Fatal(err)
}
<-s.done
mu.Lock()
if len(*got) != 2 || (*got)[0].Message != "eth0: link down" || (*got)[1].Message != "eth0: link up" {
t.Fatalf("got %+v", *got)
}
if age := time.Since((*got)[1].Time); age < 5*time.Minute || age > 7*time.Minute {
t.Fatalf("record dated %v ago", age)
}
mu.Unlock()
d.saveState()
// Restart with the saved position: same boot, nothing is sent again,
// and a new record is.
f, _ := os.OpenFile(d.device, os.O_APPEND|os.O_WRONLY, 0)
_, _ = f.WriteString("6,6,10500000000,-;eth0: renamed\n")
f.Close()
d2 := NewDmesg(d.device, filepath.Dir(d.statePath), time.Hour, d.sink)
d2.bootIDPath, d2.uptimePath = d.bootIDPath, d.uptimePath
if d2.pos != (dmesgPos{BootID: "b1", Seq: 3}) {
t.Fatalf("saved position %+v", d2.pos)
}
s, err = d2.start()
if err != nil {
t.Fatal(err)
}
<-s.done
mu.Lock()
defer mu.Unlock()
if len(*got) != 3 || (*got)[2].Message != "eth0: renamed" || (*got)[2].App != "kernel" {
t.Fatalf("after restart %+v", (*got)[2:])
}
if st := d2.Status(); st["lost"] != uint64(2) || st["read"] != uint64(1) {
t.Fatalf("status %+v", st) // records 4 and 5 were overwritten
}
}
func TestDmesgNewBoot(t *testing.T) {
d, got, mu := fakeHost(t, 60, "6,1,1000000,-;booted")
d.pos = dmesgPos{BootID: "old", Seq: 500}
s, err := d.start()
if err != nil {
t.Fatal(err)
}
<-s.done
mu.Lock()
defer mu.Unlock()
if len(*got) != 1 || d.pos != (dmesgPos{BootID: "b1", Seq: 1}) {
t.Fatalf("got %d entries, position %+v", len(*got), d.pos)
}
}
func TestDmesgNoBackfill(t *testing.T) {
d, got, mu := fakeHost(t, 60, "6,1,1000000,-;old")
d.backfill = 0
s, err := d.start()
if err != nil {
t.Fatal(err)
}
<-s.done
mu.Lock()
defer mu.Unlock()
if len(*got) != 0 {
t.Fatalf("got %+v", *got)
}
}
func TestDmesgRun(t *testing.T) {
d, got, mu := fakeHost(t, 60, "6,1,1000000,-;first", "6,2,2000000,-;second")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
d.Run(ctx)
close(done)
}()
if err := d.Configure(dmesgConfig{Enabled: true}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for {
mu.Lock()
n := len(*got)
mu.Unlock()
if n >= 2 {
break
}
if time.Now().After(deadline) {
t.Fatalf("got %d entries", n)
}
time.Sleep(10 * time.Millisecond)
}
// The file is re-opened after each end of file: still no duplicate.
time.Sleep(100 * time.Millisecond)
cancel()
<-done
mu.Lock()
defer mu.Unlock()
if len(*got) != 2 {
t.Fatalf("got %d entries", len(*got))
}
b, err := os.ReadFile(d.statePath)
if err != nil || !strings.Contains(string(b), `"seq": 2`) {
t.Fatalf("state %s %v", b, err)
}
b, _ = os.ReadFile(d.cfgPath)
if !strings.Contains(string(b), `"enabled": true`) {
t.Fatalf("config %s", b)
}
}
func TestDmesgMissingDevice(t *testing.T) {
d := NewDmesg(filepath.Join(t.TempDir(), "kmsg"), t.TempDir(), time.Hour, func(*Entry) {})
d.retry = 20 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
d.Run(ctx)
close(done)
}()
_ = d.Configure(dmesgConfig{Enabled: true})
deadline := time.Now().Add(5 * time.Second)
for d.Status()["code"] != "not_found" {
if time.Now().After(deadline) {
t.Fatalf("status %+v", d.Status())
}
time.Sleep(10 * time.Millisecond)
}
// Turning it off clears the error.
_ = d.Configure(dmesgConfig{Enabled: false})
if st := d.Status(); st["code"] != "" || st["enabled"] != false {
t.Fatalf("status %+v", st)
}
cancel()
<-done
}
func TestDmesgPermission(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads everything")
}
p := filepath.Join(t.TempDir(), "kmsg")
_ = os.WriteFile(p, []byte("6,1,0,-;x\n"), 0o000)
d := NewDmesg(p, t.TempDir(), time.Hour, func(*Entry) {})
_, err := d.start()
d.fail(err)
if st := d.Status(); st["code"] != "permission" {
t.Fatalf("status %+v", st)
}
}