systemd: the journal verb reads a window, and failed is the seat's verb
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 covered: a later merge that contains it was delivered: novox/mesh-catalog@cb733d1b7cea (merged as 2fa39eac into main, walk plan-17914094762…
mesh/delivery-group group feat/journal-window-on-the-seat merged: every member is merged

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:
jochen
2026-10-07 19:15:20 +02:00
parent c74f407abd
commit 66106d93ac
7 changed files with 728 additions and 28 deletions
+82 -11
View File
@@ -29,7 +29,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -288,27 +287,92 @@ func (m *Manager) Act(scope Scope, verb, unit string) (map[string]any, error) {
return answer, nil return answer, nil
} }
// Journal is the last lines of one unit's journal. // Journal is the last lines of one unit's journal that a query keeps, its secrets redacted.
func (m *Manager) Journal(scope Scope, unit string, lines int) (map[string]any, error) { //
// **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 { if err := unitArg(unit); err != nil {
return nil, err 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 { if err != nil {
return nil, err 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") { for _, l := range strings.Split(out, "\n") {
if l != "" { 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, // unitSecrets are the values of the unit's own Environment= that must not be answered. Read with
// beside the other's answer — never as "nothing failed". // systemctl show, which needs no escalation; a unit that does not exist has none.
func (m *Manager) Failed() map[string]any { 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 { in := func(scope Scope) any {
units, err := m.Units(scope, "") units, err := m.Units(scope, "")
if err != nil { if err != nil {
@@ -322,7 +386,14 @@ func (m *Manager) Failed() map[string]any {
} }
return failed 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 // unitArg refuses a unit name systemctl or journalctl would read as an option — which under sudo would be
@@ -97,7 +97,7 @@ func TestTheUserScopeIsPlainUserWithTheAccountsRuntimeDirectoryAndBus(t *testing
if !strings.Contains(env, "XDG_RUNTIME_DIR=/run/user/1234") || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1234/bus") || !strings.Contains(env, "HOME=/h") { if !strings.Contains(env, "XDG_RUNTIME_DIR=/run/user/1234") || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1234/bus") || !strings.Contains(env, "HOME=/h") {
t.Fatalf("%v", calls[0].env) t.Fatalf("%v", calls[0].env)
} }
if _, err := m.Journal(User, "watcher.service", 10); err != nil { if _, err := m.Journal(User, "watcher.service", JournalQuery{Lines: 10}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if calls[1].cmd != "journalctl" || calls[1].args[0] != "--user" { if calls[1].cmd != "journalctl" || calls[1].args[0] != "--user" {
@@ -242,7 +242,7 @@ func TestAUnitsNameIsNeverAnOption(t *testing.T) {
} }
} }
// The manifest owns the systemd package, claims the seat's eight verbs, and lists exactly the tools served. // The manifest owns the systemd package, claims the seat's nine verbs, and lists exactly the tools served.
func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) { func TestTheManifestOwnsThePackageAndListsWhatIsServed(t *testing.T) {
raw, err := os.ReadFile("../../module.json") raw, err := os.ReadFile("../../module.json")
if err != nil { if err != nil {
@@ -0,0 +1,171 @@
package main
// What the journal verb may be asked (the operator's direction 2026-10-07, recorded in novox/hq): a time
// window, a priority and a fixed text, beside the unit and how many lines.
//
// **Nothing a caller says reaches a shell, and nothing is read as an option.** journalctl is run with an
// argv of its own words (execRunner), under `sudo -n` for the system journal (issue 255) — so a value that
// journalctl read as an option would be root's option. Every value is therefore held to the forms
// journalctl reads for it and given as `--name=value`, one word: a relative "-30min" is the value of
// --since, never a flag. A value in any other form is refused naming the forms, before anything runs.
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
const (
// MostLines is the most lines one answer carries: well below what the runtime carries back in one reply.
MostLines = 2000
// DefaultLines is how many when the caller does not say.
DefaultLines = 100
// MatchScan is how many of the window's last lines a match is looked for in.
MatchScan = 50000
// LongestLine is where one line is cut, so a single runaway line cannot fill the answer.
LongestLine = 4096
// LongestMatch is the longest text a match may be.
LongestMatch = 256
)
// JournalQuery is what a journal read is narrowed by.
type JournalQuery struct {
Lines int
Since string
Until string
Match string
Priority string
}
// relative is a time journalctl reads relative to now: -30min, +1h, 2h30min ago, and the day words.
var relative = regexp.MustCompile(`^(now|today|yesterday|tomorrow|[+-]?([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y))+|([0-9]+(usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y) ?)+ago)$`)
// localDate is a date, or a date and a time, in the machine's own zone, as journalctl reads it.
var localDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}( [0-9]{2}:[0-9]{2}(:[0-9]{2})?)?$`)
// priorities are journalctl's priority names, and the words people use for them.
var priorities = map[string]int{
"emerg": 0, "emergency": 0, "panic": 0, "alert": 1, "crit": 2, "critical": 2, "err": 3, "error": 3,
"warning": 4, "warn": 4, "notice": 5, "info": 6, "debug": 7,
}
// when is one bound of the window as journalctl is given it, and the absolute time it names when it is
// one: an RFC 3339 time becomes seconds since the epoch, which every journalctl reads whatever its version.
func when(name, v string, ceil bool) (string, *time.Time, error) {
if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
s := t.Unix()
if ceil && t.Nanosecond() > 0 {
s++
}
return "@" + strconv.FormatInt(s, 10), &t, nil
}
if relative.MatchString(v) || localDate.MatchString(v) {
return v, nil, nil
}
return "", nil, fmt.Errorf("%s %q: an RFC 3339 time (2026-10-07T09:30:00Z), a time relative to now "+
"(-30min, -2h, 1h ago, yesterday) or a local date (2026-10-07 09:30)", name, v)
}
// valid is the query with its defaults applied and every value held to its forms.
func (q JournalQuery) valid() (JournalQuery, error) {
switch {
case q.Lines == 0:
q.Lines = DefaultLines
case q.Lines < 0:
return q, fmt.Errorf("lines %d: at least 1", q.Lines)
case q.Lines > MostLines:
q.Lines = MostLines
}
var since, until *time.Time
var err error
if q.Since != "" {
if _, since, err = when("since", q.Since, false); err != nil {
return q, err
}
}
if q.Until != "" {
if _, until, err = when("until", q.Until, true); err != nil {
return q, err
}
}
if since != nil && until != nil && until.Before(*since) {
return q, fmt.Errorf("the window ends (%s) before it starts (%s)", q.Until, q.Since)
}
if q.Priority != "" {
p := strings.ToLower(q.Priority)
if n, ok := priorities[p]; ok {
q.Priority = strconv.Itoa(n)
} else if len(p) != 1 || p[0] < '0' || p[0] > '7' {
return q, fmt.Errorf("priority %q: 0-7, or emerg, alert, crit, err, warning, notice, info, debug", q.Priority)
}
}
if len(q.Match) > LongestMatch {
return q, fmt.Errorf("a match is at most %d bytes", LongestMatch)
}
if strings.ContainsAny(q.Match, "\n\r\x00") {
return q, fmt.Errorf("a match is one line of text")
}
return q, nil
}
// 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}
if q.Since != "" {
v, _, _ := when("since", q.Since, false)
args = append(args, "--since="+v)
}
if q.Until != "" {
v, _, _ := when("until", q.Until, true)
args = append(args, "--until="+v)
}
if q.Priority != "" {
args = append(args, "--priority="+q.Priority)
}
return append(args, "--lines="+strconv.Itoa(lines))
}
// journalQuery is the query a call's arguments say. A number may come as a JSON number or as its text:
// the seat's schema carries every argument as a string.
func journalQuery(a map[string]any) (JournalQuery, error) {
q := JournalQuery{}
switch v := a["lines"].(type) {
case nil:
case float64:
if v != float64(int(v)) {
return q, fmt.Errorf("lines %v: a whole number", v)
}
q.Lines = int(v)
case string:
if strings.TrimSpace(v) != "" {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return q, fmt.Errorf("lines %q: a whole number", v)
}
q.Lines = n
}
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} {
switch v := a[name].(type) {
case nil:
case string:
if name == "match" {
*into = v
} else {
*into = strings.TrimSpace(v)
}
case float64:
if name != "priority" {
return q, fmt.Errorf("%s: text", name)
}
*into = strconv.FormatFloat(v, 'f', -1, 64)
default:
return q, fmt.Errorf("%s: text", name)
}
}
return q, nil
}
@@ -0,0 +1,243 @@
package main
// The journal verb's window, priority and match (the operator's direction 2026-10-07): every value one
// word of journalctl's argv, nothing read as an option, the cap kept, a secret never answered — and
// what has failed on the seat.
import (
"fmt"
"strings"
"testing"
)
// journalOf is a manager whose journalctl answers the given lines and whose unit has the given
// Environment=, keeping each call.
func journalOf(lines []string, env string, calls *[]call) *Manager {
return operator(fake(func(c call) Ran {
if contains(c.args, "journalctl") || c.cmd == "journalctl" {
return Ran{Stdout: strings.Join(lines, "\n") + "\n"}
}
if contains(c.args, "--property=Environment") {
return Ran{Stdout: env + "\n"}
}
return Ran{}
}, calls))
}
func journalArgs(t *testing.T, calls []call) []string {
t.Helper()
for _, c := range calls {
if c.cmd == "sudo" && len(c.args) > 1 && c.args[1] == "journalctl" {
return c.args[2:]
}
if c.cmd == "journalctl" {
return c.args
}
}
t.Fatalf("journalctl was not run: %+v", calls)
return nil
}
func TestAWindowAndAPriorityAreEachOneWordOfJournalctl(t *testing.T) {
var calls []call
m := journalOf([]string{"a"}, "", &calls)
_, err := m.Journal(System, "mail.service", JournalQuery{Lines: 50, Since: "-30min", Until: "2026-10-07T10:00:00.5Z", Priority: "warning"})
if err != nil {
t.Fatal(err)
}
got := strings.Join(journalArgs(t, calls), " ")
want := "--no-pager --output=short-iso --unit=mail.service --since=-30min --until=@1791367201 --priority=4 --lines=50"
if got != want {
t.Fatalf("journalctl was given\n %s\nnot\n %s", got, want)
}
if calls[0].cmd != "sudo" || calls[0].args[0] != "-n" {
t.Fatalf("the system journal was not read escalated: %+v", calls[0])
}
}
func TestTheFormsAWindowIsReadIn(t *testing.T) {
for _, ok := range []string{"-30min", "-2h", "+1h", "-1h30min", "2h ago", "30min ago", "yesterday", "now", "today",
"2026-10-07T09:30:00Z", "2026-10-07T09:30:00+02:00", "2026-10-07", "2026-10-07 09:30", "2026-10-07 09:30:15"} {
if _, err := (JournalQuery{Since: ok}).valid(); err != nil {
t.Errorf("%q refused: %v", ok, err)
}
}
for _, bad := range []string{"--user", "-u", "-D/etc", "--directory=/", "-30min --merge", "-30min;id", "$(id)",
"-x", "--", "1h; rm", "2026-10-07T09:30:00", "last week", "-30min\n--merge"} {
if _, err := (JournalQuery{Since: bad}).valid(); err == nil {
t.Errorf("since %q accepted", bad)
}
if _, err := (JournalQuery{Until: bad}).valid(); err == nil {
t.Errorf("until %q accepted", bad)
}
}
if _, err := (JournalQuery{Since: "2026-10-07T10:00:00Z", Until: "2026-10-07T09:00:00Z"}).valid(); err == nil {
t.Error("a window ending before it starts was accepted")
}
}
func TestPriorityIsANumberOrAName(t *testing.T) {
for in, want := range map[string]string{"0": "0", "7": "7", "err": "3", "ERROR": "3", "warning": "4", "debug": "7", "emerg": "0"} {
q, err := (JournalQuery{Priority: in}).valid()
if err != nil || q.Priority != want {
t.Errorf("%q: %q %v", in, q.Priority, err)
}
}
for _, bad := range []string{"8", "-1", "--user", "3..5", "loud", "33"} {
if _, err := (JournalQuery{Priority: bad}).valid(); err == nil {
t.Errorf("priority %q accepted", bad)
}
}
}
func TestNothingRunsWhenAValueIsRefused(t *testing.T) {
var calls []call
m := journalOf(nil, "", &calls)
for _, q := range []JournalQuery{{Since: "--merge"}, {Until: "-D/"}, {Priority: "--user"}, {Lines: -1},
{Match: "a\nb"}, {Match: strings.Repeat("x", LongestMatch+1)}} {
if _, err := m.Journal(System, "x.service", q); err == nil {
t.Errorf("%+v accepted", q)
}
}
if _, err := m.Journal(System, "--merge", JournalQuery{}); err == nil {
t.Error("an option was accepted as a unit")
}
if len(calls) != 0 {
t.Fatalf("something ran: %+v", calls)
}
}
func TestTheCapIsKeptAndTheDefaultIsAHundred(t *testing.T) {
for in, want := range map[int]int{0: DefaultLines, 1: 1, 2000: 2000, 2001: 2000, 1 << 30: 2000} {
q, err := (JournalQuery{Lines: in}).valid()
if err != nil || q.Lines != want {
t.Errorf("%d: %d %v", in, q.Lines, err)
}
}
// What the seat's schema carries is text, and an agent may send a number: both read the same.
for _, a := range []map[string]any{{"lines": "250"}, {"lines": float64(250)}} {
q, err := journalQuery(a)
if err != nil || q.Lines != 250 {
t.Errorf("%v: %+v %v", a, q, err)
}
}
for _, a := range []map[string]any{{"lines": "many"}, {"lines": 2.5}, {"since": float64(3)}, {"match": []any{"x"}}} {
if _, err := journalQuery(a); err == nil {
t.Errorf("%v accepted", a)
}
}
}
func TestAMatchIsAFixedStringOverTheWindowsLastLines(t *testing.T) {
var calls []call
lines := []string{"one (a.b)", "two a.b", "three axb", "four (a.b)"}
r, err := journalOf(lines, "", &calls).Journal(System, "x.service", JournalQuery{Lines: 1, Match: "(a.b)"})
if err != nil {
t.Fatal(err)
}
if got := r["lines"].([]string); len(got) != 1 || got[0] != "four (a.b)" {
t.Fatalf("%v", got)
}
if r["scanned"] != 4 || r["count"] != 1 || r["match"] != "(a.b)" {
t.Fatalf("%v", r)
}
if a := journalArgs(t, calls); a[len(a)-1] != fmt.Sprintf("--lines=%d", MatchScan) {
t.Fatalf("a match was not looked for over a scan: %v", a)
}
for _, a := range journalArgs(t, calls) {
if strings.Contains(a, "a.b") || strings.HasPrefix(a, "--grep") {
t.Fatalf("the match reached journalctl: %v", a)
}
}
}
func TestASecretTheUnitPrintedIsNeverAnsweredNorFoundByAMatch(t *testing.T) {
env := `HOME=/var/lib/x "DB_PASSWORD=hunter2hunter2" DATABASE_URL=postgres://app:s3cr3tpw@db/app PORT=5432`
lines := []string{
"starting with password hunter2hunter2",
"connecting to postgres://app:s3cr3tpw@db/app",
"proxy at https://u:otherpassword@example.test/",
"ran: tool --password=flagsecret1 --token tokensecret2",
"API_TOKEN=abcdefghij set",
"nothing secret here",
}
r, err := journalOf(lines, env, nil).Journal(System, "x.service", JournalQuery{})
if err != nil {
t.Fatal(err)
}
all := strings.Join(r["lines"].([]string), "\n")
for _, secret := range []string{"hunter2hunter2", "s3cr3tpw", "otherpassword", "flagsecret1", "tokensecret2", "abcdefghij"} {
if strings.Contains(all, secret) {
t.Errorf("%s was answered:\n%s", secret, all)
}
}
if !strings.Contains(all, "[redacted: DB_PASSWORD]") || !strings.Contains(all, "nothing secret here") {
t.Fatalf("%s", all)
}
if r["redacted"] == nil || r["leak"] == nil {
t.Fatalf("the redaction was not said: %v", r)
}
found, _ := journalOf(lines, env, nil).Journal(System, "x.service", JournalQuery{Match: "hunter2"})
if found["count"] != 0 {
t.Fatalf("a match found a secret: %v", found)
}
}
func TestAnUnreadableEnvironmentStillRedactsByShapeAndSaysSo(t *testing.T) {
m := operator(fake(func(c call) Ran {
if contains(c.args, "--property=Environment") {
return Ran{Status: 1, Stderr: "Failed to get properties: Access denied\n"}
}
return Ran{Stdout: "postgres://app:s3cr3tpw@db/app\n"}
}, nil))
r, err := m.Journal(System, "x.service", JournalQuery{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.Join(r["lines"].([]string), ""), "s3cr3tpw") || r["redaction"] == nil {
t.Fatalf("%v", r)
}
}
func TestALongLineIsCut(t *testing.T) {
r, _ := journalOf([]string{strings.Repeat("y", LongestLine+10)}, "", nil).Journal(System, "x.service", JournalQuery{})
if l := r["lines"].([]string)[0]; len(l) > LongestLine+len("…") {
t.Fatalf("a line of %d bytes", len(l))
}
}
func TestTheEnvironmentPropertyIsReadAsWords(t *testing.T) {
got := environment(`A=1 "B=two words" 'C=x\'y' D=`)
if strings.Join(got, "|") != "A=1|B=two words|C=x'y|D=" {
t.Fatalf("%q", got)
}
}
func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) {
var calls []call
m := operator(fake(func(call) Ran { return Ran{Stdout: list} }, &calls))
var failed func(map[string]any) (any, error)
for _, tool := range tools(m) {
if tool.Name == seat+".failed" {
failed = tool.Run
}
if tool.Name == "systemd_failed" {
t.Fatal("the module still serves its own systemd_failed beside the seat's verb")
}
}
if failed == nil {
t.Fatal("the seat's failed is not served")
}
both, err := failed(map[string]any{})
if err != nil || len(both.(map[string]any)) != 2 {
t.Fatalf("%v %v", both, err)
}
calls = nil
one, err := failed(map[string]any{"scope": "system"})
if err != nil || len(one.(map[string]any)) != 1 || len(calls) != 1 || contains(calls[0].args, "--user") {
t.Fatalf("%v %v %+v", one, err, calls)
}
if _, err := failed(map[string]any{"scope": "everything"}); err == nil {
t.Fatal("a scope that is none was accepted")
}
}
+29 -11
View File
@@ -1,5 +1,5 @@
// systemd's tools: the node-service-manager seat's eight verbs — the units on this machine in both scopes, // systemd's tools: the node-service-manager seat's nine verbs — the units on this machine in both scopes,
// read and acted on by name — and the module's own reading of what has failed (novox/hq ADR 0177). The node // read and acted on by name, their journal, and what has failed (novox/hq ADR 0177). The node
// tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193); // tools runtime launches this bundle as a process of its own and serves what it serves (ADR 0188, ADR 0193);
// it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with // it runs as the operator account, so acts on the system manager, and reads of its journal, escalate with
// sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers // sudo -n, and the user scope is the account's own manager (client.go). The host applies units; this answers
@@ -98,9 +98,16 @@ func tools(m *Manager) []stdio.Tool {
act("enable", "Make one unit start at boot (or at the account's login, in user scope)."), act("enable", "Make one unit start at boot (or at the account's login, in user scope)."),
act("disable", "Stop one unit starting at boot (or at login, in user scope)."), act("disable", "Stop one unit starting at boot (or at login, in user scope)."),
{Name: seat + ".journal", {Name: seat + ".journal",
Description: "The last lines of one unit's journal (at most 2000) — a system service's included: the read is escalated, so it is the service's own lines and not only the operator account's.", Description: "The last lines of one unit's journal (at most 2000), in a time window and narrowed to a priority " +
"and to lines holding a text when asked — a system service's included: the read is escalated, so it is the " +
"service's own lines and not only the operator account's. A secret the unit printed is shown as " +
"[redacted: <what it was>].",
Input: map[string]any{"scope": scopeArg, "unit": unitArgS, Input: map[string]any{"scope": scopeArg, "unit": unitArgS,
"lines": map[string]any{"type": "number", "description": "how many lines from the end (default 100, at most 2000)"}}, "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) { Run: func(a map[string]any) (any, error) {
scope, err := scopeOf(a) scope, err := scopeOf(a)
if err != nil { if err != nil {
@@ -110,16 +117,27 @@ func tools(m *Manager) []stdio.Tool {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Bounded so the answer stays well below what the runtime carries back in one reply. q, err := journalQuery(a)
n := 100 if err != nil {
if v, ok := a["lines"].(float64); ok && v >= 1 { return nil, err
n = min(int(v), 2000)
} }
return m.Journal(scope, unit, n) return m.Journal(scope, unit, q)
}}, }},
{Name: "systemd_failed", // Was the module's own systemd_failed; on the seat since the operator's direction of 2026-10-07, so
// whatever holds the role answers it and every machine is asked the same way.
{Name: seat + ".failed",
Description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.", Description: "Every failed unit on this machine, in the system manager and in the operator account's; a manager that does not answer is reported with its error, not as nothing failed.",
Run: func(map[string]any) (any, error) { return m.Failed(), nil }}, Input: map[string]any{"scope": str(`"system" or "user": only that manager (both when absent)`)},
Run: func(a map[string]any) (any, error) {
if s, _ := a["scope"].(string); s == "" {
return m.Failed(), nil
}
scope, err := scopeOf(a)
if err != nil {
return nil, err
}
return m.Failed(scope), nil
}},
} }
} }
@@ -0,0 +1,199 @@
package main
// A unit's secrets in its own journal (novox/hq issue 268, issue 282, as the docker module reads a
// container's log).
//
// **The leak this hides.** Software prints what it was given — a server announcing its password, a
// script echoing the URI it connects with, a command line logged with its password flag — and the
// journal keeps it. The journal verb's answer is read by agents and kept in their transcripts, which
// would make it a second copy of the leak; and with a window and a filter on the verb, a caller could
// otherwise go looking for one.
//
// **What is known here.** The values of the unit's own Environment= named like a secret (PASSWORD,
// SECRET, TOKEN, KEY, …) and the password inside any URI one of them holds; and, whatever the source,
// what a line carries by its shape: a credential-bearing URI (`scheme://user:password@`), the word after
// a flag that takes a password, a NAME=value whose name says secret. A secret given only in an
// EnvironmentFile= or a credential is not known — the unit's files are root's — and is caught only by
// its shape.
//
// Copied from the docker module's secrets.go and cmdline.go, narrowed to what a journal line needs: each
// module is its own Go module, and the two share no package.
import (
"net/url"
"regexp"
"strings"
)
// secretName is a variable name that says its value is a secret.
var secretName = regexp.MustCompile(`(?i)(pass(word|wd|phrase)?|secret|token|api_?key|private_?key|access_?key|credential|auth)`)
// notAValue is a name that says its value is where a secret is, not the secret: a file or a path.
var notAValue = regexp.MustCompile(`(?i)(_FILE|FILE|_PATH|_DIR)$`)
// uriPassword is a URI carrying a password in its userinfo: scheme://user:password@.
var uriPassword = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^\s/:@'"]*:([^\s/@'"]+)@`)
// masked is a password a program already hid: ***, xxx, <redacted>, [REDACTED].
var masked = regexp.MustCompile(`^(\*+|x+|X+|<[^>]*>|\[[^\]]*\]|%2A+)$`)
// ordinary is a value under a secret's name that is not one: a path, an address, a number, a switch.
var ordinary = regexp.MustCompile(`^(/.*|[A-Za-z][A-Za-z0-9+.-]*://.*|[0-9.]+[a-z]?|(?i:true|false|yes|no|on|off|none|null))$`)
// leastSecret is the shortest value compared as a secret: a shorter one matches ordinary words.
const leastSecret = 6
// passwordFlags take a secret as their next word, or after `=`, whatever the program.
var passwordFlags = map[string]bool{
"-P": true, "--password": true, "--pass": true, "--passwd": true, "--secret": true, "--secret-key": true,
"--token": true, "--api-key": true, "--apikey": true, "--auth": true,
}
// knownSecret is one value a unit was given, by the name it came under.
type knownSecret struct {
Name string
Value string
}
// secretsIn are the values in a unit's environment that must never appear in what it answers.
func secretsIn(env []string) []knownSecret {
var out []knownSecret
seen := map[string]bool{}
add := func(name, value string) {
if len(value) < leastSecret || masked.MatchString(value) || seen[name+"\x00"+value] {
return
}
seen[name+"\x00"+value] = true
out = append(out, knownSecret{name, value})
}
for _, e := range env {
name, value, ok := strings.Cut(e, "=")
if !ok || value == "" {
continue
}
for _, m := range uriPassword.FindAllStringSubmatch(value, -1) {
add(name+" (the password in its URI)", m[1])
if dec, err := url.PathUnescape(m[1]); err == nil && dec != m[1] {
add(name+" (the password in its URI)", dec)
}
}
if secretName.MatchString(name) && !notAValue.MatchString(name) && !ordinary.MatchString(value) {
add(name, value)
}
}
return out
}
// environment is the words of systemd's Environment= property as `systemctl show --value` prints it:
// separated by spaces, a word holding one quoted in C style.
func environment(value string) []string {
var out []string
var word strings.Builder
quote := byte(0)
in := false
for i := 0; i < len(value); i++ {
c := value[i]
switch {
case quote != 0 && c == '\\' && i+1 < len(value):
i++
word.WriteByte(value[i])
case quote != 0 && c == quote:
quote = 0
case quote == 0 && (c == '"' || c == '\''):
quote, in = c, true
case quote == 0 && (c == ' ' || c == '\t' || c == '\n'):
if in {
out = append(out, word.String())
word.Reset()
in = false
}
default:
word.WriteByte(c)
in = true
}
}
if in {
out = append(out, word.String())
}
return out
}
// forms are the ways a value may appear printed: as given, and URL-encoded.
func forms(value string) []string {
out := []string{value}
for _, f := range []string{url.QueryEscape(value), url.PathEscape(value)} {
if f != value && !has(out, f) {
out = append(out, f)
}
}
return out
}
func has(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// shaped are the values a line carries by their shape: the word after a password flag, or the value of
// one given with `=`, and a NAME=value whose name says secret.
func shaped(line string) []knownSecret {
var out []knownSecret
add := func(name, value string) {
value = strings.Trim(value, `"',;`)
if len(value) < leastSecret || masked.MatchString(value) || ordinary.MatchString(value) {
return
}
out = append(out, knownSecret{name, value})
}
words := strings.Fields(line)
for i, w := range words {
if flag, value, ok := strings.Cut(w, "="); ok && strings.HasPrefix(flag, "-") {
if passwordFlags[flag] {
add("the value of "+flag, value)
}
continue
}
if name, value, ok := strings.Cut(w, "="); ok && name != "" && secretName.MatchString(name) &&
!notAValue.MatchString(name) && !strings.ContainsAny(name, "/:") {
add("the value of "+name, value)
continue
}
if i+1 < len(words) && passwordFlags[w] {
add("the word after "+w, words[i+1])
}
}
return out
}
// redact is a line with every known secret, every value its shape says is one, and every password
// inside a URI replaced by a mark naming what was there; and how many were replaced.
func redact(line string, known []knownSecret) (string, int) {
n := 0
replace := func(s knownSecret) {
for _, f := range forms(s.Value) {
if c := strings.Count(line, f); c > 0 {
line = strings.ReplaceAll(line, f, "[redacted: "+s.Name+"]")
n += c
}
}
}
for _, s := range known {
replace(s)
}
for _, s := range shaped(line) {
replace(s)
}
line = uriPassword.ReplaceAllStringFunc(line, func(m string) string {
sub := uriPassword.FindStringSubmatch(m)
if masked.MatchString(sub[1]) || strings.HasPrefix(sub[1], "[redacted") {
return m
}
n++
return strings.TrimSuffix(m, sub[1]+"@") + "[redacted: a password in a URI]@"
})
return line, n
}
+2 -4
View File
@@ -17,13 +17,11 @@
"restart", "restart",
"enable", "enable",
"disable", "disable",
"journal" "journal",
"failed"
] ]
} }
], ],
"tools": [
"systemd_failed"
],
"build": { "build": {
"artifacts": [ "artifacts": [
{ {