1 Commits
Author SHA1 Message Date
jochen 66106d93ac systemd: the journal verb reads a window, and failed is the seat's verb
mesh/merge-gate fail: builds systemd → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: systemd claims node-serv…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery rejected: the gate failed or could not run, or the repository's own check failed
mesh/delivery-group group feat/journal-window-on-the-seat delivering: 0 of 2 delivered
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.
2026-10-07 19:15:20 +02:00
7 changed files with 728 additions and 28 deletions
+82 -11
View File
@@ -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
@@ -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") {
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)
}
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) {
raw, err := os.ReadFile("../../module.json")
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,
// read and acted on by name — and the module's own reading of what has failed (novox/hq ADR 0177). The node
// 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, 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);
// 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
@@ -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("disable", "Stop one unit starting at boot (or at login, in user scope)."),
{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,
"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) {
scope, err := scopeOf(a)
if err != nil {
@@ -110,16 +117,27 @@ func tools(m *Manager) []stdio.Tool {
if err != nil {
return nil, err
}
// Bounded so the answer stays well below what the runtime carries back in one reply.
n := 100
if v, ok := a["lines"].(float64); ok && v >= 1 {
n = min(int(v), 2000)
q, err := journalQuery(a)
if err != nil {
return nil, err
}
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.",
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",
"enable",
"disable",
"journal"
"journal",
"failed"
]
}
],
"tools": [
"systemd_failed"
],
"build": {
"artifacts": [
{