systemd: the journal verb reads a window, and failed is the seat's verb
mesh/delivery-group group feat/journal-window-on-the-seat delivering: 0 of 2 delivered
mesh/merge-gate pass: builds systemd → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery held for a person: its group feat/journal-window-on-the-seat's composed check did not pass for the heads that merged; a person decides that…
mesh/delivery-group group feat/journal-window-on-the-seat delivering: 0 of 2 delivered
mesh/merge-gate pass: builds systemd → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery held for a person: its group feat/journal-window-on-the-seat's composed check did not pass for the heads that merged; a person decides that…
An incident is read for the minutes it happened in (the operator's direction 2026-10-07): journal takes since, until, priority and a fixed-string match. Every value is one word of journalctl's argv, held to the forms journalctl reads, so nothing reaches a shell or is read as an option under sudo. What the unit printed of a secret is redacted, as docker_logs does, before the match is applied, so a match cannot find one. systemd_failed becomes the seat's failed, with an optional scope. Needs the controller's seat with these verbs (mesh-controller, same branch): an older controller refuses a claim serving a verb its seat does not promise.
This commit is contained in:
@@ -29,7 +29,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -288,27 +287,92 @@ func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// Journal is the last lines of one unit's journal.
|
||||
func (m *Manager) Journal(scope Scope, unit string, lines int) (map[string]any, error) {
|
||||
// Journal is the last lines of one unit's journal that a query keeps, its secrets redacted.
|
||||
//
|
||||
// **Every argument is one word of journalctl's argv, never a shell's** (journal.go): the unit is refused
|
||||
// when it would read as an option, a window bound is given as --since=<v> so a relative "-30min" is its
|
||||
// value and never a flag, and the rest is validated to the forms journalctl reads before anything runs —
|
||||
// under sudo, a word read as an option would be root's option.
|
||||
//
|
||||
// **A match is a fixed string, applied here to the redacted lines**, not journalctl's --grep, which is a
|
||||
// pattern and depends on how journalctl was built; and applied after redaction, so a caller cannot find a
|
||||
// secret by asking which lines hold it. journalctl is then asked for a bounded scan of the window's last
|
||||
// lines, and the answer says how many were read, so a match that found fewer than asked is not read as
|
||||
// all there is when the scan was full.
|
||||
func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string]any, error) {
|
||||
if err := unitArg(unit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := m.call(scope, "journalctl", "--no-pager", "-n", strconv.Itoa(lines), "-u", unit, "-o", "short-iso")
|
||||
q, err := q.valid()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept := []string{}
|
||||
read := q.Lines
|
||||
if q.Match != "" {
|
||||
read = MatchScan
|
||||
}
|
||||
out, err := m.call(scope, "journalctl", q.argv(unit, read)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known, envErr := m.unitSecrets(scope, unit)
|
||||
all := []string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l != "" {
|
||||
kept = append(kept, l)
|
||||
all = append(all, l)
|
||||
}
|
||||
}
|
||||
return map[string]any{"unit": unit, "scope": string(scope), "lines": kept}, nil
|
||||
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)}
|
||||
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 envErr != nil {
|
||||
answer["redaction"] = "only what a line's shape says is a secret: the unit's environment could not be read (" + envErr.Error() + ")"
|
||||
}
|
||||
if redacted > 0 {
|
||||
answer["redacted"] = redacted
|
||||
answer["leak"] = "this unit's journal holds secrets, shown as [redacted: <what it was>]: rotate each one after the program stops printing it"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// Failed is every failed unit in both managers. A manager that does not answer is reported as such,
|
||||
// beside the other's answer — never as "nothing failed".
|
||||
func (m *Manager) Failed() map[string]any {
|
||||
// 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) {
|
||||
out, err := m.call(scope, "systemctl", "show", unit, "--no-pager", "--property=Environment", "--value")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secretsIn(environment(strings.TrimSpace(out))), nil
|
||||
}
|
||||
|
||||
// Failed is every failed unit in the managers asked — both when none is named. A manager that does not
|
||||
// answer is reported as such, beside the other's answer — never as "nothing failed".
|
||||
func (m *Manager) Failed(scopes ...Scope) map[string]any {
|
||||
in := func(scope Scope) any {
|
||||
units, err := m.Units(scope, "")
|
||||
if err != nil {
|
||||
@@ -322,7 +386,14 @@ func (m *Manager) Failed() map[string]any {
|
||||
}
|
||||
return failed
|
||||
}
|
||||
return map[string]any{"system": in(System), "user": in(User)}
|
||||
if len(scopes) == 0 {
|
||||
scopes = []Scope{System, User}
|
||||
}
|
||||
answer := map[string]any{}
|
||||
for _, s := range scopes {
|
||||
answer[string(s)] = in(s)
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
// unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be
|
||||
|
||||
Reference in New Issue
Block a user