Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15db34169e | ||
|
|
ebcc7761f1 | ||
|
|
3ebcb15a4b |
@@ -242,8 +242,13 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) {
|
|||||||
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) {
|
||||||
t.Errorf("resumed publish = %+v", a)
|
t.Errorf("resumed publish = %+v", a)
|
||||||
}
|
}
|
||||||
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err == nil {
|
// A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again
|
||||||
t.Error("a chunk sent twice was taken: the transfer goes on from the chunks held")
|
// (mesh-delivery's parts.Receiver.Take, 1966/AC203-1, AC203-2): its earlier chunks are dropped and it is taken.
|
||||||
|
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 2, Data: blob[:1000]}); err == nil {
|
||||||
|
t.Error("a chunk out of sequence was taken: the transfer goes on from the chunks held")
|
||||||
|
}
|
||||||
|
if _, err := b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 0, Data: blob[:1000]}); err != nil {
|
||||||
|
t.Errorf("a chunk 0 again was refused: %v", err)
|
||||||
}
|
}
|
||||||
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
|
a, err = b.h.PublishPart(ArtifactSeat, builder, PartRequest{Transfer: "t1", Digest: part.Digest, Sequence: 1, Last: true, Data: blob[1000:]})
|
||||||
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
|
if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" {
|
||||||
|
|||||||
@@ -319,6 +319,9 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
|
|||||||
if err := unitArg(unit); err != nil {
|
if err := unitArg(unit); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if q.Boot != "" {
|
||||||
|
return nil, fmt.Errorf("boot %q: the journal of a unit is not read by boot; kernel-log takes a boot", q.Boot)
|
||||||
|
}
|
||||||
q, err := q.valid()
|
q, err := q.valid()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -332,28 +335,7 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
known, envErr := m.unitSecrets(scope, unit)
|
known, envErr := m.unitSecrets(scope, unit)
|
||||||
all := []string{}
|
kept, scanned, redacted := shapeLines(out, known, q)
|
||||||
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:]
|
|
||||||
}
|
|
||||||
answer := map[string]any{"unit": unit, "scope": string(scope), "lines": kept, "count": len(kept)}
|
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} {
|
for k, v := range map[string]string{"since": q.Since, "until": q.Until, "match": q.Match, "priority": q.Priority} {
|
||||||
if v != "" {
|
if v != "" {
|
||||||
@@ -376,6 +358,74 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]
|
|||||||
return answer, nil
|
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
|
// 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.
|
// systemctl show, which needs no escalation; a unit that does not exist has none.
|
||||||
func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) {
|
func (m *Manager) unitSecrets(scope Scope, unit string) ([]knownSecret, error) {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const (
|
|||||||
LongestLine = 4096
|
LongestLine = 4096
|
||||||
// LongestMatch is the longest text a match may be.
|
// LongestMatch is the longest text a match may be.
|
||||||
LongestMatch = 256
|
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.
|
// JournalQuery is what a journal read is narrowed by.
|
||||||
@@ -37,6 +39,8 @@ type JournalQuery struct {
|
|||||||
Until string
|
Until string
|
||||||
Match string
|
Match string
|
||||||
Priority 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.
|
// 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)
|
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 {
|
if len(q.Match) > LongestMatch {
|
||||||
return q, fmt.Errorf("a match is at most %d bytes", 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.
|
// 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 {
|
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 != "" {
|
if q.Since != "" {
|
||||||
v, _, _ := when("since", q.Since, false)
|
v, _, _ := when("since", q.Since, false)
|
||||||
args = append(args, "--since="+v)
|
args = append(args, "--since="+v)
|
||||||
@@ -149,7 +174,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
|
|||||||
default:
|
default:
|
||||||
return q, fmt.Errorf("lines: a whole number")
|
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) {
|
switch v := a[name].(type) {
|
||||||
case nil:
|
case nil:
|
||||||
case string:
|
case string:
|
||||||
@@ -159,7 +184,7 @@ func journalQuery(a map[string]any) (JournalQuery, error) {
|
|||||||
*into = strings.TrimSpace(v)
|
*into = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
case float64:
|
case float64:
|
||||||
if name != "priority" {
|
if name != "priority" && name != "boot" {
|
||||||
return q, fmt.Errorf("%s: text", name)
|
return q, fmt.Errorf("%s: text", name)
|
||||||
}
|
}
|
||||||
*into = strconv.FormatFloat(v, 'f', -1, 64)
|
*into = strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
|
|||||||
@@ -241,3 +241,89 @@ func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) {
|
|||||||
t.Fatal("a scope that is none was accepted")
|
t.Fatal("a scope that is none was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1975/AC1: kernel-log reads journalctl --dmesg --boot=N under journal's escalation (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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1975/AC2: a boot that is not 0 to -9999 is refused and no command runs.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1975/AC3: the window, priority, match, 2000-line cap and redaction are journal's.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1975/AC4: journal refuses a boot argument it does not use, never reading this boot quietly.
|
||||||
|
func TestJournalRefusesABootArgument(t *testing.T) {
|
||||||
|
var calls []call
|
||||||
|
m := journalOf([]string{"a"}, "", &calls)
|
||||||
|
if _, err := m.Journal(System, "mail.service", JournalQuery{Boot: "-1"}); err == nil {
|
||||||
|
t.Fatal("journal accepted a boot and read this boot")
|
||||||
|
}
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Fatalf("a refused boot reached the manager: %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,24 @@ func tools(m *Manager) []stdio.Tool {
|
|||||||
}
|
}
|
||||||
return m.Journal(scope, unit, q)
|
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,
|
// **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
|
// 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.
|
// verb never resets every failed unit at once.
|
||||||
|
|||||||
@@ -274,8 +274,8 @@ func TestAGlobIsRefusedAsAUnitByEveryVerb(t *testing.T) {
|
|||||||
m.State = t.TempDir()
|
m.State = t.TempDir()
|
||||||
for _, tool := range tools(m) {
|
for _, tool := range tools(m) {
|
||||||
verb := strings.TrimPrefix(tool.Name, seat+".")
|
verb := strings.TrimPrefix(tool.Name, seat+".")
|
||||||
if verb == "units" || verb == "failed" {
|
if verb == "units" || verb == "failed" || verb == "kernel-log" {
|
||||||
continue // a pattern, and no unit
|
continue // a pattern, or no unit at all
|
||||||
}
|
}
|
||||||
_, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"})
|
_, err := tool.Run(map[string]any{"unit": "*", "scope": "user", "why": "test"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"enable",
|
"enable",
|
||||||
"disable",
|
"disable",
|
||||||
"journal",
|
"journal",
|
||||||
|
"kernel-log",
|
||||||
"failed",
|
"failed",
|
||||||
"reset-failed",
|
"reset-failed",
|
||||||
"wanted-by",
|
"wanted-by",
|
||||||
|
|||||||
Reference in New Issue
Block a user