diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index de0b4c789..afad6f7d2 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -5,6 +5,12 @@ rotation source, its consumers moved off a licence near its limit by the manager ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module keeps none. +It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its +own, never in the machine's tool runner: its service part (the carry-over at start, the usage timer, the switch rule, +the conditions) and its tools beside it, so every state, log and secret below is issued to its own account. Its +account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and +a node's `claude_code_grant`), the events under `emits` and `consumes`. + ## How a licence comes to exist Nothing is configured. Every node running `claude-code` says what it holds as the event diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index d33c6d033..1d34a8da9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -1,7 +1,9 @@ -// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control -// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the -// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports -// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. +// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under +// its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner, +// and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the +// carry-over at start, the usage timer, the switch rule, the conditions): it watches what every node reports holding +// and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. Its tools part, the +// seat's verbs and the module's tools, is served beside it and acts only through that one running manager. // // stdout is the MCP channel; everything this module says, it says on stderr. package main @@ -58,7 +60,7 @@ func manager() (*Manager, error) { if err != nil { return nil, err } - keys, err := keypair(dir) + keys, err := keypair(dir, say) if err != nil { return nil, err } @@ -129,28 +131,60 @@ func configLevels(node string) (map[string]string, error) { } // keypair is this module's own, made once in its state directory; the private half never leaves it. -// Written whole, then linked into place, so a second process reads the first's key and never half of it. -func keypair(dir string) (KeyPair, error) { +// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it +// finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said +// in its journal (design 39 bus-and-switch C2): every use passes the public key per ask, so nothing depends on the old. +func keypair(dir string, log func(string, ...any)) (KeyPair, error) { file := filepath.Join(dir, "manager-key.json") if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { - k, err := GenerateKeyPair() - if err != nil { + if err := writeKeyPair(dir, file, false); err != nil { return KeyPair{}, err } - raw, _ := json.Marshal(k) - tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid())) - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return KeyPair{}, err - } - _ = os.Link(tmp, file) // fails when another made it first, which is right - _ = os.Remove(tmp) } + k, err := readKeyPair(file) + if err == nil { + return k, nil + } + log("the key pair %s cannot be read (%v); a new one is made in its place", file, err) + if err := writeKeyPair(dir, file, true); err != nil { + return KeyPair{}, err + } + return readKeyPair(file) +} + +func readKeyPair(file string) (KeyPair, error) { raw, err := os.ReadFile(file) if err != nil { return KeyPair{}, err } var k KeyPair - return k, json.Unmarshal(raw, &k) + if err := json.Unmarshal(raw, &k); err != nil { + return KeyPair{}, err + } + if k.PublicKey == "" || k.PrivateKey == "" { + return KeyPair{}, errors.New("it holds no whole key pair") + } + return k, nil +} + +// writeKeyPair writes a new pair whole beside file, then links it into place, or, replacing, renames it over the one +// that cannot be read. +func writeKeyPair(dir, file string, replace bool) error { + k, err := GenerateKeyPair() + if err != nil { + return err + } + raw, _ := json.Marshal(k) + tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid())) + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + if replace { + return os.Rename(tmp, file) + } + _ = os.Link(tmp, file) // fails when another made it first, which is right + _ = os.Remove(tmp) + return nil } // ---- the long-running half --------------------------------------------------------------------------- diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go index e087078ab..8916b3680 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" + "strings" "testing" ) @@ -48,3 +50,105 @@ func TestTheManifestKeepsNoOldForm(t *testing.T) { } } } + +// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the +// states and logs it keeps (`requires` by template), what it may ask (`invokes`) and say (`emits`). +type issued struct { + RunsAs, SecretsOwner string + OwnSecrets map[string]string + Resources []map[string]any + States, Logs map[string]bool + Invokes, Emits map[string]bool +} + +func manifestIssued(t testing.TB) issued { + t.Helper() + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + RunsAs string `json:"runs-as"` + SecretsOwner string `json:"secrets-owner"` + OwnSecrets map[string]string `json:"own-secrets"` + Requires []json.RawMessage `json:"requires"` + Resources []map[string]any `json:"resources"` + Invokes []string `json:"invokes"` + Emits []string `json:"emits"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + out := issued{RunsAs: m.RunsAs, SecretsOwner: m.SecretsOwner, OwnSecrets: m.OwnSecrets, Resources: m.Resources, + States: map[string]bool{}, Logs: map[string]bool{}, Invokes: map[string]bool{}, Emits: map[string]bool{}} + for _, r := range m.Requires { + var e struct{ Name, Storage string } + if json.Unmarshal(r, &e) != nil { + continue + } + switch e.Storage { + case "key-value": + out.States[e.Name] = true + case "stream": + out.Logs[e.Name] = true + } + } + for _, k := range m.Invokes { + out.Invokes[k] = true + } + for _, e := range m.Emits { + out.Emits[e] = true + } + return out +} + +// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`.@`) +// is invoked as `.`. +func (i issued) mayAsk(key string) bool { + k, _, _ := strings.Cut(key, "@") + return i.Invokes[k] +} + +// design 39 bus-and-switch C1: the manager runs as an account of its own, which it makes and which owns what it places +// on its machine and its secrets, reaching the bus as itself; and that account is issued every ask its code makes, +// since its runtime is not the machine's tool runner. +func TestTheManagerRunsAsItsOwnAccount(t *testing.T) { + m := manifestIssued(t) + if m.RunsAs != Publisher { + t.Fatalf("the manager runs as %q, not as its own account %s", m.RunsAs, Publisher) + } + if m.SecretsOwner != Publisher || m.OwnSecrets["broker"] == "" { + t.Errorf("its secrets belong to %q and its own broker secret is %q: both are its account's", m.SecretsOwner, + m.OwnSecrets["broker"]) + } + made := false + for _, r := range m.Resources { + switch r["type"] { + case "user": + made = made || r["name"] == Publisher + case "directory", "file": + if r["owner"] != Publisher { + t.Errorf("resource %v is owned by %v, not by the manager's account", r["id"], r["owner"]) + } + } + } + if !made { + t.Errorf("the manager does not make its account: no user resource named %s", Publisher) + } + files, _ := filepath.Glob("*.go") + asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`) + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, k := range asked.FindAllStringSubmatch(string(src), -1) { + if !m.mayAsk(k[1]) { + t.Errorf("%s asks %s, which the manifest's invokes do not name", f, k[1]) + } + } + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go index 87038f0d7..2f096ba9b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go @@ -2,7 +2,10 @@ package main import ( "encoding/json" + "fmt" "os" + "path/filepath" + "strings" "testing" ) @@ -41,3 +44,29 @@ func TestABoxOpensOnlyForItsRecipient(t *testing.T) { t.Fatal("a box opened for another key") } } + +// design 39 bus-and-switch C2: a key pair the manager finds and cannot read (left by another account, here mode 000) is +// replaced in its place by a new one, said in its journal; the new one is read back by the next start. +func TestAKeyPairItCannotReadIsMadeAnew(t *testing.T) { + if os.Geteuid() == 0 { + t.Fatal("run as root, a file of mode 000 is still read: this test needs an ordinary account") + } + dir := t.TempDir() + file := filepath.Join(dir, "manager-key.json") + if err := os.WriteFile(file, []byte(`{"publicKey":"old","privateKey":"old"}`), 0o000); err != nil { + t.Fatal(err) + } + var said []string + log := func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) } + k, err := keypair(dir, log) + if err != nil { + t.Fatalf("a key pair it cannot read stops the manager: %v", err) + } + if k.PublicKey == "" || k.PublicKey == "old" || len(said) != 1 || !strings.Contains(said[0], "manager-key.json") { + t.Fatalf("not made anew and said: key %q, journal %q", k.PublicKey, said) + } + again, err := keypair(dir, log) + if err != nil || again != k || len(said) != 1 { + t.Fatalf("the new pair is not the one read at the next start: %v, %v, %q", again == k, err, said) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index b71549bfc..e399ef6ac 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -287,6 +287,7 @@ type mesh struct { dir string dbURL string vendor *vendor + own issued // what the manager's own runtime is issued from its manifest (C1) mu sync.Mutex said []said @@ -299,11 +300,14 @@ type mesh struct { conditions []oc.Condition } -const busStates = "licences bindings leases offered carried conditions" -const busLogs = "usage audit" - func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Helper() + // The manager runs as its own account, never in the machine's tool runner, whose account is issued none of its + // logs or states (C1): this player plays that runtime of its own, issuing what the manifest names and nothing else. + own := manifestIssued(t) + if own.RunsAs != Publisher { + t.Fatalf("the manager declares runs-as %q: it would run in the machine's tool runner, not as its own account", own.RunsAs) + } ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) dir := t.TempDir() @@ -323,12 +327,12 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Fatal(err) } // What the bus module creates from the manifest's templates (§1 to §9) before the manager's want moves (§50). - for _, s := range strings.Fields(busStates) { + for s := range own.States { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Publisher + "_" + s, History: 8}); err != nil { t.Fatal(err) } } - for _, l := range strings.Fields(busLogs) { + for l := range own.Logs { if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l, Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil { t.Fatal(err) @@ -340,7 +344,7 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { t.Fatal(err) } } - m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), + m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), own: own, news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{}, callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}} sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) { @@ -521,6 +525,10 @@ func (p *started) handle(w wire) { Pattern string `json:"pattern"` } _ = json.Unmarshal(w.Params, &a) + if why := p.refused(w.Method, w.Params); why != nil { + p.answer(w.ID, nil, why) + return + } if strings.HasPrefix(w.Method, "mesh/log.") { p.log(w) return @@ -620,6 +628,28 @@ func (p *started) handle(w wire) { } } +// refused is the runtime's refusal of what the manager's own account was not issued (C1): a state or a log its +// manifest does not ask for, an ask its invokes do not name, an event its emits do not name; nil when it was issued. +func (p *started) refused(method string, params json.RawMessage) error { + var a struct { + State string `json:"state"` + Log string `json:"log"` + Key string `json:"key"` + } + _ = json.Unmarshal(params, &a) + switch { + case strings.HasPrefix(method, "mesh/state.") && !p.own.States[a.State]: + return fmt.Errorf("%s keeps no state called %q", Publisher, a.State) + case strings.HasPrefix(method, "mesh/log.") && !p.own.Logs[a.Log]: + return fmt.Errorf("%s keeps no log called %q", Publisher, a.Log) + case method == "mesh/ask" && !p.own.mayAsk(a.Key): + return fmt.Errorf("this account may not call %s", a.Key) + case method == "mesh/publish" && !p.own.Emits[a.Key]: + return fmt.Errorf("%s does not emit %s", Publisher, a.Key) + } + return nil +} + func (p *started) say(key string, body json.RawMessage) { p.mu.Lock() p.said = append(p.said, said{key: key, body: append(json.RawMessage(nil), body...)}) @@ -899,7 +929,7 @@ func (m *mesh) written() map[string]uint64 { i, _ := info.Info(m.ctx) out[s] = i.State.LastSeq } - for _, l := range strings.Fields(busLogs) { + for l := range m.own.Logs { info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l) if err != nil { m.t.Fatal(err) diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 215aaa7f5..3f7e34450 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -2,6 +2,7 @@ "module": "claude-licence-manager", "version": "0.1", "slug": "licmgr", + "runs-as": "claude-licence-manager", "requires": [ "postgres-database", "secret", @@ -91,6 +92,10 @@ "grant-key": "${dir:state}/grant.key" } }, + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "claude-licence-manager", "uses": [ "operator-channel" ], @@ -98,7 +103,8 @@ "seat:operator-channel.tell", "seat:mesh-vault.licence-put", "seat:mesh-vault.get", - "seat:mesh-config.config" + "seat:mesh-config.config", + "claude-code.claude_code_grant" ], "seats": [ { @@ -232,10 +238,18 @@ } }, "resources": [ + { + "id": "account", + "type": "user", + "name": "claude-licence-manager", + "shell": "/usr/bin/nologin", + "home": "/var/lib/claude-licence-manager" + }, { "id": "state", "type": "directory", "mode": "0700", + "owner": "claude-licence-manager", "place": ".", "unknown": "program" }, @@ -244,6 +258,7 @@ "type": "file", "path": "${dir:state}/database.url", "mode": "0600", + "owner": "claude-licence-manager", "template": "files/database.url" }, { @@ -251,6 +266,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "owner": "claude-licence-manager", "takes-config": [ "cadence_minutes", "floor_minutes", diff --git a/modules/distribution/internal/holder/holder_test.go b/modules/distribution/internal/holder/holder_test.go index 615fc0ff4..6c06fe9a1 100644 --- a/modules/distribution/internal/holder/holder_test.go +++ b/modules/distribution/internal/holder/holder_test.go @@ -223,7 +223,6 @@ const builder = "ace.mesh-build-go" // TestTheHolderOfTheTwoImageSeats holds design 51's holder cases at the unit level (CAT-D1, D2, D3, D3b, D4, D4b, D5): // one table or case per rule, the caller list and the store front's. func TestTheHolderOfTheTwoImageSeats(t *testing.T) { - t.Run("a publish is checked against the builder's reservation", reservedChecksAPublishAgainstTheReservation) blob := []byte(strings.Repeat("a", 3000)) part := Part{Digest: digestOf(blob), Size: int64(len(blob))} req := func(id string) PublishRequest { @@ -243,14 +242,14 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if a, _ := b.h.Publish(ArtifactSeat, builder, req("t1")); !reflect.DeepEqual(a.Missing, []string{part.Digest}) { t.Errorf("resumed publish = %+v", a) } - // A chunk 0 again starts the part afresh (it travels whole again, design 51 Publishing); a chunk that is neither the - // cursor nor 0 is refused, naming the cursor. - 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 sent again was refused: %v", err) - } + // A chunk that is neither the cursor nor 0 is refused; a chunk 0 again is a part lacked travelling whole again + // (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:]}) if err != nil || !a.Ended || len(b.said) != 1 || b.said[0].event != EventPublished || b.said[0].body.Reference == "" { t.Fatalf("last chunk = %+v, %v; said %+v", a, err, b.said) @@ -292,12 +291,12 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { _, err := b.h.Publish(ArtifactSeat, builder, req("t")) return err }, "at their cap"}, - {"a version the builder did not reserve", func(b *bed) error { + {"a version the builder did not number", func(b *bed) error { b.h.Builder = fakeBuilder{ok: false} b.store.has[part.Digest] = true // held already: the publish ends at once _, err := b.h.Publish(ArtifactSeat, builder, req("t")) return err - }, "the builder did not reserve it"}, + }, "the builder did not number it"}, {"an image on the artifact seat", func(b *bed) error { r := req("t") r.Kind = KindImage @@ -849,9 +848,9 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - // The reservation is asked through the seat mesh-builds, so the manifest takes no package of the builder (design 47 §381). - if len(m.Packages) != 0 { - t.Errorf("packages %v: want none", m.Packages) + // The builder's question is its published package, taken by its series (ADR 0634 §1). + if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" { + t.Errorf("packages %v: want builds of series 0.1", m.Packages) } // E15: the provision artifact-store retires (ADR 0632 §3); the bootstrap keeps it as an alias on port 5100 while // the running node-engines still find the store by it (issue 1966), and nothing else is provided or served. @@ -870,7 +869,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if _, ok := claims[OldSeat]; !ok { t.Error("the old seat mesh-artifact-store is no longer claimed while its window is open") } - if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed", "mesh-conditions.findings-asked"}) { + if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-view.delivered", "node-engine.manifest-reads-changed"}) { t.Errorf("consumes = %v, want delivered and manifest-reads-changed (the windows' wakes)", m.Consumes) } // E14: the registry container, on the loopback, is listed before the program, so the port is free when it starts. @@ -916,34 +915,19 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { // Running review F2: incoming asks the bus 8 GiB, the most the objects template allows, equal to IncomingCap // (said only where no binding is read); transfers keeps for good, the holder dropping an idle one by its key // in idle, a state of keys with their own time to live. - // The image registry is a foundation part (design 47 G §584): its bus state is in its old sections, incoming in - // `objects`, the others in `state`. var req struct { - Objects []struct { - Name string `json:"name"` - MaxMiB uint64 `json:"max-mib"` - } `json:"objects"` - State []json.RawMessage `json:"state"` + Requires []struct { + Name string `json:"name"` + Options map[string]any `json:"options"` + } `json:"requires"` } _ = json.Unmarshal(raw, &req) opts := map[string]map[string]any{} - for _, e := range req.State { - var o map[string]any - if json.Unmarshal(e, &o) != nil { - var name string - _ = json.Unmarshal(e, &name) - o = map[string]any{"name": name} - } - opts[fmt.Sprint(o["name"])] = o + for _, r := range req.Requires { + opts[r.Name] = r.Options } - var incoming uint64 - for _, o := range req.Objects { - if o.Name == IncomingObjects { - incoming = o.MaxMiB - } - } - if incoming<<20 != IncomingCap { - t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", incoming, uint64(IncomingCap)) + if mib, _ := opts[IncomingObjects]["max-mib"].(float64); uint64(mib)<<20 != IncomingCap { + t.Errorf("incoming asks %v MiB, IncomingCap is %d bytes: want them equal", opts[IncomingObjects]["max-mib"], uint64(IncomingCap)) } if _, ok := opts[TransfersState]["ttl-seconds"]; ok { t.Errorf("transfers has a lifetime %v: the bus would drop a transfer's key and leave its parts", opts[TransfersState]) diff --git a/modules/systemd/cmd/systemd-tools/client.go b/modules/systemd/cmd/systemd-tools/client.go index c7c837e3b..4eaf14651 100644 --- a/modules/systemd/cmd/systemd-tools/client.go +++ b/modules/systemd/cmd/systemd-tools/client.go @@ -319,6 +319,9 @@ func (m *Manager) Journal(scope Scope, unit string, q JournalQuery) (map[string] if err := unitArg(unit); err != nil { 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() if err != nil { return nil, err @@ -332,28 +335,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 +358,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..bf005b220 100644 --- a/modules/systemd/cmd/systemd-tools/journal_test.go +++ b/modules/systemd/cmd/systemd-tools/journal_test.go @@ -241,3 +241,89 @@ func TestFailedIsOnTheSeatAndNarrowsToAScope(t *testing.T) { 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) + } +} 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 77c6311b5..a2c492a17 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",