systemd: serve kernel-log, the kernel's messages of one boot (issue 1975)

journalctl --dmesg --boot=N under the same escalation, window, priority, fixed-string match, 2000-line cap
and redaction as the journal verb (shapeLines is the one filter both use); a boot is 0 or a negative whole
number, refused otherwise before anything runs. No scope: the kernel belongs to no manager.

Builds: issue 1975, the operator's request of 2026-11-12 (the lead's handover; no design line names the verb)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 02:17:28 +01:00
parent d9f2b7bcfa
commit 3ebcb15a4b
6 changed files with 190 additions and 27 deletions
+69 -22
View File
@@ -332,28 +332,7 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
return nil, err
}
known, envErr := m.unitSecrets(scope, unit)
all := []string{}
for _, l := range strings.Split(out, "\n") {
if l != "" {
all = append(all, l)
}
}
scanned := len(all)
kept, redacted := []string{}, 0
for _, l := range all {
l, n := redact(l, known)
redacted += n
if q.Match != "" && !strings.Contains(l, q.Match) {
continue
}
if len(l) > LongestLine {
l = l[:LongestLine] + "…"
}
kept = append(kept, l)
}
if len(kept) > q.Lines {
kept = kept[len(kept)-q.Lines:]
}
kept, scanned, redacted := shapeLines(out, known, q)
answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)}
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
if v != "" {
@@ -376,6 +355,74 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
return answer, nil
}
// shapeLines is the lines of journalctl's output that a query keeps, each redacted and cut, the last q.Lines of
// those holding the match; and how many lines were read and how many secrets replaced.
func shapeLines(out string, known []knownSecret, q JournalQuery) (kept []string, scanned, redacted int) {
all := []string{}
for _, l := range strings.Split(out, "\n") {
if l != "" {
all = append(all, l)
}
}
scanned = len(all)
kept = []string{}
for _, l := range all {
l, n := redact(l, known)
redacted += n
if q.Match != "" && !strings.Contains(l, q.Match) {
continue
}
if len(l) > LongestLine {
l = l[:LongestLine] + "…"
}
kept = append(kept, l)
}
if len(kept) > q.Lines {
kept = kept[len(kept)-q.Lines:]
}
return kept, scanned, redacted
}
// KernelLog is the kernel's messages of one boot (journalctl -k, dmesg's, of this boot or an earlier one) that a
// query keeps: the same window, priority, match, cap and redaction as Journal. The kernel's messages belong to
// no unit and no manager, so it has no scope; the read is the system journal's, escalated as Journal's is.
func (m *Manager) KernelLog(q JournalQuery) (map[string]any, error) {
q, err := q.valid()
if err != nil {
return nil, err
}
read := q.Lines
if q.Match != "" {
read = MatchScan
}
out, err := m.call(System, "journalctl", q.kernelArgv(read)...)
if err != nil {
return nil, err
}
kept, scanned, redacted := shapeLines(out, nil, q)
boot := q.Boot
if boot == "" {
boot = "0"
}
answer := map[string]any{"boot": boot, "lines": kept, "count": len(kept)}
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
if v != "" {
answer[k] = v
}
}
if q.Match != "" {
answer["scanned"] = scanned
if scanned >= MatchScan {
answer["note"] = fmt.Sprintf("the match was looked for in the window's last %d lines only: narrow the window to reach earlier ones", MatchScan)
}
}
if redacted > 0 {
answer["redacted"] = redacted
answer["leak"] = "the kernel's messages hold secrets, shown as [redacted: <what it was>]"
}
return answer, nil
}
// unitSecrets are the values of the unit's own Environment= that must not be answered. Read with
// systemctl show, which needs no escalation; a unit that does not exist has none.
func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) {
+28 -3
View File
@@ -28,6 +28,8 @@ const (
LongestLine = 4096
// LongestMatch is the longest text a match may be.
LongestMatch = 256
// MostBoots is how far back a kernel-log read may name a boot.
MostBoots = 9999
)
// JournalQuery is what a journal read is narrowed by.
@@ -37,6 +39,8 @@ type JournalQuery struct {
Until string
Match string
Priority string
// Boot is which boot a kernel-log read is of: "0" this boot, "-1" the one before, and so on (empty is "0").
Boot string
}
// relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words.
@@ -101,6 +105,13 @@ func (q JournalQuery) valid() (JournalQuery, error) {
return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority)
}
}
if q.Boot != "" {
n, err := strconv.Atoi(q.Boot)
if err != nil || n > 0 || n < -MostBoots {
return q, fmt.Errorf("boot %q: 0 for this boot, -1 for the one before, and so on (down to -%d)", q.Boot, MostBoots)
}
q.Boot = strconv.Itoa(n)
}
if len(q.Match) > LongestMatch {
return q, fmt.Errorf("a match is at most %d bytes", LongestMatch)
}
@@ -112,7 +123,21 @@ func (q JournalQuery) valid() (JournalQuery, error) {
// argv is journalctl's words for a valid query: each value inside the word of its own option.
func (q JournalQuery) argv(unit string, lines int) []string {
args := []string{"--no-pager", "--output=short-iso", "--unit=" + unit}
return q.window([]string{"--no-pager", "--output=short-iso", "--unit=" + unit}, lines)
}
// kernelArgv is journalctl's words for a valid query of the kernel's messages of one boot (-k, as dmesg
// reads them, but also of an earlier boot and in a window).
func (q JournalQuery) kernelArgv(lines int) []string {
boot := q.Boot
if boot == "" {
boot = "0"
}
return q.window([]string{"--no-pager", "--output=short-iso", "--dmesg", "--boot=" + boot}, lines)
}
// window adds the window, the priority and the line count to the words that choose what is read.
func (q JournalQuery) window(args []string, lines int) []string {
if q.Since != "" {
v, _, _ := when("since", q.Since, false)
args = append(args, "--since="+v)
@@ -149,7 +174,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
default:
return q, fmt.Errorf("lines: a whole number")
}
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority} {
for name, into := range map[string]*string{"since": &q.Since, "until": &q.Until, "match": &q.Match, "priority": &q.Priority, "boot": &q.Boot} {
switch v := a[name].(type) {
case nil:
case string:
@@ -159,7 +184,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
*into = strings.TrimSpace(v)
}
case float64:
if name != "priority" {
if name != "priority" && name != "boot" {
return q, fmt.Errorf("%s: text", name)
}
*into = strconv.FormatFloat(v, 'f', -1, 64)
@@ -241,3 +241,75 @@ func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) {
t.Fatal("a scope that is none was accepted")
}
}
// The kernel-log verb (issue 1975): the kernel's messages of one boot, in the journal verb's window, priority,
// match, cap and redaction, and a boot named 0, -1, and so on.
func TestTheKernelLogIsOneBootsMessagesInTheJournalsForms(t *testing.T) {
var calls []call
m := journalOf([]string{"2026-11-12T10:00:00+0100 host kernel: usb 1-1: new device"}, "", &calls)
got, err := m.KernelLog(JournalQuery{Lines: 50, Since: "-2h", Priority: "err", Boot: "-1"})
if err != nil {
t.Fatal(err)
}
want := "--no-pager --output=short-iso --dmesg --boot=-1 --since=-2h --priority=3 --lines=50"
if a := strings.Join(journalArgs(t, calls), " "); a != want {
t.Fatalf("journalctl was given\n %s\nnot\n %s", a, want)
}
if calls[0].cmd != "sudo" || got["boot"] != "-1" || got["count"] != 1 {
t.Fatalf("not escalated, or the answer is wrong: %+v %+v", calls[0], got)
}
calls = nil
if _, err := m.KernelLog(JournalQuery{}); err != nil {
t.Fatal(err)
}
if a := strings.Join(journalArgs(t, calls), " "); a != "--no-pager --output=short-iso --dmesg --boot=0 --lines=100" {
t.Fatalf("the default is this boot and a hundred lines: %s", a)
}
// Arguments arrive as the seat's strings or as numbers.
for _, a := range []map[string]any{{"boot": float64(-2)}, {"boot": "-2"}} {
q, err := journalQuery(a)
if err != nil || q.Boot != "-2" {
t.Errorf("%v: %+v %v", a, q, err)
}
}
}
func TestAKernelLogBootIsRefusedBeforeAnythingRunsUnlessItIsZeroOrBefore(t *testing.T) {
for _, bad := range []string{"1", "abc", "--user", "-1 --merge", "-10000", "0x1", "-1;id"} {
var calls []call
m := journalOf(nil, "", &calls)
if _, err := m.KernelLog(JournalQuery{Boot: bad}); err == nil {
t.Errorf("boot %q accepted", bad)
}
if len(calls) != 0 {
t.Errorf("boot %q reached journalctl: %v", bad, calls)
}
}
}
func TestTheKernelLogKeepsTheCapTheMatchAndTheRedaction(t *testing.T) {
var calls []call
m := journalOf([]string{"k one", "k password=hunter2secretvalue here", "k two"}, "", &calls)
got, err := m.KernelLog(JournalQuery{Match: "k t", Lines: 5000})
if err != nil {
t.Fatal(err)
}
if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, fmt.Sprintf("--lines=%d", MatchScan)) {
t.Errorf("a match scans the window's last %d lines: %s", MatchScan, a)
}
if lines := got["lines"].([]string); len(lines) != 1 || lines[0] != "k two" {
t.Errorf("the match kept %v", lines)
}
calls = nil
m = journalOf([]string{"k password=hunter2secretvalue here"}, "", &calls)
got, err = m.KernelLog(JournalQuery{Lines: 5000})
if err != nil {
t.Fatal(err)
}
if a := strings.Join(journalArgs(t, calls), " "); !strings.HasSuffix(a, "--lines=2000") {
t.Errorf("the cap is 2000: %s", a)
}
if strings.Contains(fmt.Sprint(got["lines"]), "hunter2") || got["redacted"] == nil {
t.Errorf("a secret in a kernel line was answered: %v", got)
}
}
+18
View File
@@ -124,6 +124,24 @@ func tools(m *Manager) []stdio.Tool {
}
return m.Journal(scope, unit, q)
}},
{Name: seat + ".kernel-log",
Description: "The kernel's messages (what journalctl -k and dmesg give) of this boot or an earlier one, at most 2000 lines, " +
"in a time window and narrowed to a priority and to lines holding a text when asked. A secret a line held is " +
"shown as [redacted: <what it was>].",
Input: map[string]any{
"boot": str("which boot: 0 this boot (the default), -1 the one before, -2 the one before that, and so on (optional)"),
"lines": str("how many lines from the end of what matches (default 100, at most 2000)"),
"since": str("the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)"),
"until": str("the window's end, in the same forms (optional; now when absent)"),
"match": str("only the lines holding this text, as written — a fixed string, not a pattern (optional)"),
"priority": str("only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)")},
Run: func(a map[string]any) (any, error) {
q, err := journalQuery(a)
if err != nil {
return nil, err
}
return m.KernelLog(q)
}},
// **Clearing a failed record** (novox/hq issue 332): a unit whose file is gone keeps its failed state,
// and its start-limit count, in its manager until it is reset. A unit's name is required, so the
// verb never resets every failed unit at once.
@@ -274,8 +274,8 @@ func TestAGlobIsRefusedAsAUnitByEveryVerb(t *testing.T) {
m.State = t.TempDir()
for _, tool := range tools(m) {
verb := strings.TrimPrefix(tool.Name, seat+".")
if verb == "units" || verb == "failed" {
continue // a pattern, and no unit
if verb == "units" || verb == "failed" || verb == "kernel-log" {
continue // a pattern, or no unit at all
}
_, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"})
if err == nil {
+1
View File
@@ -18,6 +18,7 @@
"enable",
"disable",
"journal",
"kernel-log",
"failed",
"reset-failed",
"wanted-by",