diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go index c7c837e3b..3dd64ecbe 100644 --- a/modules/systemd/cmd/systemd-tools/client.go +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -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: ]" + } + 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) { diff --git a/modules/systemd/cmd/systemd-tools/journal.go b/modules/systemd/cmd/systemd-tools/journal.go index 30fc3a69b..f23669824 100644 --- a/modules/systemd/cmd/systemd-tools/journal.go +++ b/modules/systemd/cmd/systemd-tools/journal.go @@ -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) diff --git a/modules/systemd/cmd/systemd-tools/journal_test.go b/modules/systemd/cmd/systemd-tools/journal_test.go index 2a8e15c51..d7be03174 100644 --- a/modules/systemd/cmd/systemd-tools/journal_test.go +++ b/modules/systemd/cmd/systemd-tools/journal_test.go @@ -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) + } +} diff --git a/modules/systemd/cmd/systemd-tools/main.go b/modules/systemd/cmd/systemd-tools/main.go index 4b9f4280e..3d36389cf 100644 --- a/modules/systemd/cmd/systemd-tools/main.go +++ b/modules/systemd/cmd/systemd-tools/main.go @@ -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: ].", + 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. diff --git a/modules/systemd/cmd/systemd-tools/wanted_test.go b/modules/systemd/cmd/systemd-tools/wanted_test.go index 5013d68a6..c8fa6a218 100644 --- a/modules/systemd/cmd/systemd-tools/wanted_test.go +++ b/modules/systemd/cmd/systemd-tools/wanted_test.go @@ -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 { diff --git a/modules/systemd/module.json b/modules/systemd/module.json index 8c9403d2d..7d453c8ce 100644 --- a/modules/systemd/module.json +++ b/modules/systemd/module.json @@ -18,6 +18,7 @@ "enable", "disable", "journal", + "kernel-log", "failed", "reset-failed", "wanted-by",