diff --git a/internal/agent/kernelio/fake.go b/internal/agent/kernelio/fake.go index 8fe955b..b2313c3 100644 --- a/internal/agent/kernelio/fake.go +++ b/internal/agent/kernelio/fake.go @@ -32,6 +32,9 @@ type FakeSysctlTransport struct { // unload error (e.g. ErrModuleNotLoaded or a busy module). DeletedModules []string DeleteModuleErr map[string]error + + // Dirs records MkdirAll calls (the dconf handler's drop-in dirs). + Dirs []string } // NewFakeSysctl returns a FakeSysctlTransport with initialized maps. @@ -70,6 +73,13 @@ func (f *FakeSysctlTransport) ReadFileIfExists(path string) (string, bool, error return c, ok, nil } +// MkdirAll records the directory (the in-memory Files layer needs no real +// dir for AtomicReplace/Write to succeed). +func (f *FakeSysctlTransport) MkdirAll(path string, _ fs.FileMode) error { + f.Dirs = append(f.Dirs, path) + return nil +} + // AtomicReplace writes content to the in-memory persist layer. func (f *FakeSysctlTransport) AtomicReplace(_ context.Context, fullPath string, _ fs.FileMode, content []byte) error { f.Files[fullPath] = string(content) diff --git a/internal/agent/kernelio/sysctl.go b/internal/agent/kernelio/sysctl.go index 7f3a78b..da20b6b 100644 --- a/internal/agent/kernelio/sysctl.go +++ b/internal/agent/kernelio/sysctl.go @@ -99,6 +99,17 @@ func ReadFileIfExists(path string) (content string, existed bool, err error) { return string(b), true, nil } +// MkdirAll creates a directory and any missing parents, like +// os.MkdirAll. Used by handlers (e.g. dconf_set) that must ensure a +// config drop-in directory exists before an atomic write into it, on the +// agent's direct-IO path. +func MkdirAll(path string, mode os.FileMode) error { + if err := os.MkdirAll(path, mode); err != nil { + return fmt.Errorf("kernelio: mkdir %s: %w", path, err) + } + return nil +} + // ReadSysctl returns the current runtime value of a kernel parameter, // trimmed of trailing whitespace (procfs values carry a trailing // newline). diff --git a/internal/agent/kernelio/transport.go b/internal/agent/kernelio/transport.go index fda3b16..aacc221 100644 --- a/internal/agent/kernelio/transport.go +++ b/internal/agent/kernelio/transport.go @@ -1,16 +1,22 @@ package kernelio -import "github.com/Hanalyx/kensa/internal/agent/fsatomic" +import ( + "io/fs" + + "github.com/Hanalyx/kensa/internal/agent/fsatomic" +) // FileTransport is the capability a transport implements when it can do // atomic file IO on the target host: the fsatomic write/replace/remove -// primitives plus an existence-aware read. The mount_option_set handler -// asserts this for its /etc/fstab edits (its runtime remount stays on -// mount(8) by design — see the kernelio-mount spec). SysctlTransport -// embeds it for the sysctl persist drop-in. +// primitives, an existence-aware read, and a mkdir-all. The +// mount_option_set handler asserts this for its /etc/fstab edits (its +// runtime remount stays on mount(8) by design — see the kernelio-mount +// spec); dconf_set asserts it for its /etc/dconf drop-in writes. +// SysctlTransport embeds it for the sysctl persist drop-in. type FileTransport interface { fsatomic.Transport ReadFileIfExists(path string) (content string, existed bool, err error) + MkdirAll(path string, mode fs.FileMode) error } // ModuleTransport is the capability a transport implements when it can diff --git a/internal/agent/transport/local/local.go b/internal/agent/transport/local/local.go index 822e097..a5a897d 100644 --- a/internal/agent/transport/local/local.go +++ b/internal/agent/transport/local/local.go @@ -303,6 +303,11 @@ func (t *Transport) ReadFileIfExists(path string) (string, bool, error) { return kernelio.ReadFileIfExists(path) } +// MkdirAll delegates to kernelio.MkdirAll (os.MkdirAll). +func (t *Transport) MkdirAll(path string, mode fs.FileMode) error { + return kernelio.MkdirAll(path, mode) +} + // DeleteModule delegates to kernelio.DeleteModule (delete_module(2)). // Satisfies kernelio.ModuleTransport for the kernel_module_disable // handler's runtime unload. diff --git a/internal/handlers/dconfset/dconfset.go b/internal/handlers/dconfset/dconfset.go index 768812d..52e7c4d 100644 --- a/internal/handlers/dconfset/dconfset.go +++ b/internal/handlers/dconfset/dconfset.go @@ -3,6 +3,18 @@ // it to prevent user override, and run `dconf update`. // Capturable: records the prior file content for rollback. // Spec: specs/handlers/dconf_set.spec.yaml. +// +// Dual path: when the transport implements kernelio.FileTransport (agent +// mode on the target host) the handler writes the dconf profile / keyfile +// snippet / lock files atomically (fsatomic), instead of the shell +// printf + mkdir pipeline. The `dconf update` compile step DELIBERATELY +// stays shell on both paths — it is the dconf toolchain's job to compile +// the keyfile drop-ins into the binary database, exactly as mount keeps +// the remount on mount(8) and audit keeps the load on augenrules. (The +// migration doc's "D-Bus to ca.desrt.dconf" does not apply here: that is +// the user-session dconf API, whereas dconf_set manages system policy, +// which is file-based.) Both paths write byte-identical files and record +// an identical PreState shape. package dconfset import ( @@ -13,11 +25,54 @@ import ( "time" "github.com/Hanalyx/kensa/api" + "github.com/Hanalyx/kensa/internal/agent/kernelio" ) // mechanism is the canonical handler name. const mechanism = "dconf_set" +// dconfDirMode / dconfFileMode are the modes for dconf config dirs/files +// (root-owned, world-readable). +const ( + dconfDirMode = 0o755 + dconfFileMode = 0o644 +) + +// dconfPaths bundles the four filesystem paths a dconf_set apply touches. +type dconfPaths struct { + profile string + dbDir string + snippet string + locksD string + lock string +} + +// pathsFor computes the dconf paths for the decoded params. +func pathsFor(p *Params) dconfPaths { + dbDir := fmt.Sprintf("/etc/dconf/db/%s.d", p.DB) + return dconfPaths{ + profile: fmt.Sprintf("/etc/dconf/profile/%s", p.DB), + dbDir: dbDir, + snippet: fmt.Sprintf("%s/%s", dbDir, p.File), + locksD: fmt.Sprintf("%s/locks", dbDir), + lock: fmt.Sprintf("%s/locks/%s", dbDir, p.File), + } +} + +// profileBody / snippetBody / lockBody render the file contents, shared by +// both paths so they write byte-identical files. +func profileBody(db string) string { return fmt.Sprintf("user\nsystem-db:%s\n", db) } + +func snippetBody(p *Params) string { + valueStr := p.Value + if p.ValueType != "" { + valueStr = fmt.Sprintf("%s(%s)", p.ValueType, p.Value) + } + return fmt.Sprintf("[%s]\n%s=%s\n", p.Schema, p.Key, valueStr) +} + +func lockBody(p *Params) string { return fmt.Sprintf("/%s/%s\n", p.Schema, p.Key) } + // defaultDB is the dconf database name used when the rule does not // specify one. const defaultDB = "local" @@ -150,7 +205,55 @@ func (h *Handler) Apply(ctx context.Context, transport api.Transport, params api if err != nil { return nil, err } + if ft, ok := transport.(kernelio.FileTransport); ok { + return h.applyKernel(ctx, ft, transport, p) + } + return h.applyShell(ctx, transport, p) +} + +// applyKernel writes the profile / snippet / lock files atomically and +// runs `dconf update` via the shell (the compile step). +func (h *Handler) applyKernel(ctx context.Context, ft kernelio.FileTransport, transport api.Transport, p *Params) (*api.StepResult, error) { + paths := pathsFor(p) + + // 1. Ensure the profile exists (create-if-absent, matching the shell). + if _, existed, rerr := ft.ReadFileIfExists(paths.profile); rerr != nil { + return nil, fmt.Errorf("dconf_set: read profile: %w", rerr) + } else if !existed { + if werr := ft.AtomicReplace(ctx, paths.profile, dconfFileMode, []byte(profileBody(p.DB))); werr != nil { + return nil, fmt.Errorf("dconf_set: profile write: %w", werr) + } + } + // 2. db.d dir + snippet. + if err := ft.MkdirAll(paths.dbDir, dconfDirMode); err != nil { + return nil, fmt.Errorf("dconf_set: mkdir db.d: %w", err) + } + if err := ft.AtomicReplace(ctx, paths.snippet, dconfFileMode, []byte(snippetBody(p))); err != nil { + return nil, fmt.Errorf("dconf_set: snippet write: %w", err) + } + // 3. Optional lock. + if p.Lock { + if err := ft.MkdirAll(paths.locksD, dconfDirMode); err != nil { + return nil, fmt.Errorf("dconf_set: mkdir locks: %w", err) + } + if err := ft.AtomicReplace(ctx, paths.lock, dconfFileMode, []byte(lockBody(p))); err != nil { + return nil, fmt.Errorf("dconf_set: lock write: %w", err) + } + } + // 4. Compile via the dconf toolchain (stays shell). + if res, err := transport.Run(ctx, "dconf update"); err != nil { + return nil, fmt.Errorf("dconf_set: dconf update transport error: %w", err) + } else if !res.OK() { + return &api.StepResult{Success: false, Detail: fmt.Sprintf("dconf_set: dconf update failed (exit %d): %s", res.ExitCode, strings.TrimSpace(res.Stderr))}, nil + } + return &api.StepResult{ + Success: true, + Detail: fmt.Sprintf("dconf_set: %s/%s written to %s and dconf updated (kernel-io)", p.Schema, p.Key, paths.snippet), + }, nil +} +// applyShell creates/updates the dconf files via shell and runs dconf update. +func (h *Handler) applyShell(ctx context.Context, transport api.Transport, p *Params) (*api.StepResult, error) { profilePath := fmt.Sprintf("/etc/dconf/profile/%s", p.DB) dbDir := fmt.Sprintf("/etc/dconf/db/%s.d", p.DB) snippetPath := fmt.Sprintf("%s/%s", dbDir, p.File) @@ -250,9 +353,16 @@ func (h *Handler) Capture(ctx context.Context, transport api.Transport, params a if err != nil { return nil, err } - snippetPath := fmt.Sprintf("/etc/dconf/db/%s.d/%s", p.DB, p.File) + if ft, ok := transport.(kernelio.FileTransport); ok { + content, existed, rerr := ft.ReadFileIfExists(snippetPath) + if rerr != nil { + return nil, fmt.Errorf("dconf_set: capture read %s: %w (%v)", snippetPath, api.ErrCaptureIncomplete, rerr) + } + return preState(snippetPath, content, existed), nil + } + // Read existing file content; fall back sentinel when absent. checkCmd := fmt.Sprintf("test -f %s && cat %s || printf '__KENSA_ABSENT__'", shellEscape(snippetPath), shellEscape(snippetPath)) @@ -267,17 +377,21 @@ func (h *Handler) Capture(ctx context.Context, transport api.Transport, params a priorContent = "" fileExisted = false } + return preState(snippetPath, priorContent, fileExisted), nil +} +// preState builds the canonical PreState shape used by both capture paths. +func preState(filePath, priorContent string, fileExisted bool) *api.PreState { return &api.PreState{ Mechanism: mechanism, Capturable: true, CapturedAt: time.Now().UTC(), Data: map[string]interface{}{ - "file_path": snippetPath, + "file_path": filePath, "prior_content": priorContent, "file_existed": fileExisted, }, - }, nil + } } // Rollback restores the prior snippet file content (or removes it if @@ -294,6 +408,10 @@ func (h *Handler) Rollback(ctx context.Context, transport api.Transport, pre *ap return nil, errors.New("dconf_set: pre-state missing 'file_path'") } + if ft, ok := transport.(kernelio.FileTransport); ok { + return h.rollbackKernel(ctx, ft, transport, filePath, priorContent, fileExisted) + } + var restoreCmd string if fileExisted { restoreCmd = fmt.Sprintf("printf %s > %s", shellEscape(priorContent), shellEscape(filePath)) @@ -334,6 +452,37 @@ func (h *Handler) Rollback(ctx context.Context, transport api.Transport, pre *ap }, nil } +// rollbackKernel restores or removes the snippet atomically, then runs +// `dconf update` (the compile step, stays shell). +func (h *Handler) rollbackKernel(ctx context.Context, ft kernelio.FileTransport, transport api.Transport, filePath, priorContent string, fileExisted bool) (*api.RollbackResult, error) { + if fileExisted { + if err := ft.AtomicReplace(ctx, filePath, dconfFileMode, []byte(priorContent)); err != nil { + return nil, fmt.Errorf("dconf_set: rollback restore: %w", err) + } + } else if err := ft.AtomicRemove(ctx, filePath); err != nil { + return nil, fmt.Errorf("dconf_set: rollback remove: %w", err) + } + if res, err := transport.Run(ctx, "dconf update"); err != nil { + return nil, fmt.Errorf("dconf_set: rollback dconf update transport error: %w", err) + } else if !res.OK() { + return &api.RollbackResult{ + Success: false, + PartialRestore: true, + Detail: fmt.Sprintf("dconf_set: file restored but dconf update failed (exit %d): %s", res.ExitCode, strings.TrimSpace(res.Stderr)), + ExecutedAt: time.Now().UTC(), + }, nil + } + action := "restored" + if !fileExisted { + action = "removed (was absent before apply)" + } + return &api.RollbackResult{ + Success: true, + Detail: fmt.Sprintf("dconf_set: %s %s and dconf updated (kernel-io)", filePath, action), + ExecutedAt: time.Now().UTC(), + }, nil +} + // shellEscape wraps s in single quotes for safe shell inclusion. func shellEscape(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" diff --git a/internal/handlers/dconfset/dconfset_kernelio_test.go b/internal/handlers/dconfset/dconfset_kernelio_test.go new file mode 100644 index 0000000..9496599 --- /dev/null +++ b/internal/handlers/dconfset/dconfset_kernelio_test.go @@ -0,0 +1,162 @@ +package dconfset_test + +import ( + "context" + "strings" + "testing" + + "github.com/Hanalyx/kensa/api" + "github.com/Hanalyx/kensa/internal/agent/kernelio" + "github.com/Hanalyx/kensa/internal/engine" + "github.com/Hanalyx/kensa/internal/handlers/dconfset" +) + +const ( + profilePath = "/etc/dconf/profile/local" + snippetPath = "/etc/dconf/db/local.d/00-login" + lockPath = "/etc/dconf/db/local.d/locks/00-login" +) + +func lockedParams() api.Params { + return api.Params{ + "schema": "org/gnome/login-screen", + "key": "disable-user-list", + "value": "true", + "file": "00-login", + "lock": true, + } +} + +// Kernel-IO Apply writes the profile / snippet / lock files atomically and +// runs `dconf update` via the shell. +// +// @spec kernelio-dconf +// @ac AC-01 +func TestApply_Kernel(t *testing.T) { + t.Run("kernelio-dconf/AC-01", func(t *testing.T) {}) + f := kernelio.NewFakeSysctl() + res, err := dconfset.New().Apply(context.Background(), f, lockedParams(), nil) + if err != nil || !res.Success { + t.Fatalf("Apply: err=%v success=%v detail=%s", err, res.Success, res.Detail) + } + if !strings.Contains(f.Files[snippetPath], "[org/gnome/login-screen]") || !strings.Contains(f.Files[snippetPath], "disable-user-list=true") { + t.Errorf("snippet = %q", f.Files[snippetPath]) + } + if !strings.Contains(f.Files[profilePath], "system-db:local") { + t.Errorf("profile = %q", f.Files[profilePath]) + } + if f.Files[lockPath] != "/org/gnome/login-screen/disable-user-list\n" { + t.Errorf("lock = %q", f.Files[lockPath]) + } + var sawUpdate bool + for _, c := range f.Runs { + if c == "dconf update" { + sawUpdate = true + } + } + if !sawUpdate { + t.Errorf("expected `dconf update`; Runs=%v", f.Runs) + } +} + +// A pre-existing profile is not overwritten. +// +// @spec kernelio-dconf +// @ac AC-01 +func TestApply_Kernel_KeepsExistingProfile(t *testing.T) { + t.Run("kernelio-dconf/AC-01", func(t *testing.T) {}) + f := kernelio.NewFakeSysctl() + f.Files[profilePath] = "user\nsystem-db:local\n# operator note\n" + if _, err := dconfset.New().Apply(context.Background(), f, lockedParams(), nil); err != nil { + t.Fatalf("Apply: %v", err) + } + if !strings.Contains(f.Files[profilePath], "# operator note") { + t.Errorf("existing profile should be left untouched; got %q", f.Files[profilePath]) + } +} + +// Kernel-IO Capture → Apply → Rollback removes a snippet that did not +// exist at capture. +// +// @spec kernelio-dconf +// @ac AC-02 +func TestRoundTrip_Kernel_RemovesWhenAbsent(t *testing.T) { + t.Run("kernelio-dconf/AC-02", func(t *testing.T) {}) + f := kernelio.NewFakeSysctl() + h := dconfset.New() + params := lockedParams() + + pre, err := h.Capture(context.Background(), f, params) + if err != nil { + t.Fatalf("Capture: %v", err) + } + if pre.Data["file_existed"] != false { + t.Fatalf("want file_existed=false, got %+v", pre.Data) + } + if _, err := h.Apply(context.Background(), f, params, nil); err != nil { + t.Fatalf("Apply: %v", err) + } + if _, ok := f.Files[snippetPath]; !ok { + t.Fatal("apply should have written the snippet") + } + if _, err := h.Rollback(context.Background(), f, pre); err != nil { + t.Fatalf("Rollback: %v", err) + } + if _, ok := f.Files[snippetPath]; ok { + t.Error("rollback should have removed the snippet that did not exist at capture") + } +} + +// A pre-existing snippet is restored to its prior content on rollback. +// +// @spec kernelio-dconf +// @ac AC-02 +func TestRoundTrip_Kernel_RestoresPrior(t *testing.T) { + t.Run("kernelio-dconf/AC-02", func(t *testing.T) {}) + f := kernelio.NewFakeSysctl() + f.Files[snippetPath] = "[org/gnome/login-screen]\ndisable-user-list=false\n" + h := dconfset.New() + params := lockedParams() + + pre, err := h.Capture(context.Background(), f, params) + if err != nil { + t.Fatalf("Capture: %v", err) + } + if pre.Data["file_existed"] != true { + t.Fatalf("want file_existed=true, got %+v", pre.Data) + } + if _, err := h.Apply(context.Background(), f, params, nil); err != nil { + t.Fatalf("Apply: %v", err) + } + if _, err := h.Rollback(context.Background(), f, pre); err != nil { + t.Fatalf("Rollback: %v", err) + } + if f.Files[snippetPath] != "[org/gnome/login-screen]\ndisable-user-list=false\n" { + t.Errorf("rolled-back snippet = %q, want prior", f.Files[snippetPath]) + } +} + +// Fallback: a transport without the kernelio capability uses the shell path. +// +// @spec kernelio-dconf +// @ac AC-03 +func TestApply_FallsBackToShell(t *testing.T) { + t.Run("kernelio-dconf/AC-03", func(t *testing.T) {}) + tp := engine.NewFakeTransport() + res, err := dconfset.New().Apply(context.Background(), tp, lockedParams(), nil) + if err != nil || !res.Success { + t.Fatalf("shell Apply: err=%v success=%v", err, res.Success) + } + var sawPrintf, sawUpdate bool + for _, c := range tp.Runs { + if strings.Contains(c, "printf") { + sawPrintf = true + } + if c == "dconf update" { + sawUpdate = true + } + } + if !sawPrintf || !sawUpdate { + t.Errorf("expected shell printf + dconf update; Runs=%v", tp.Runs) + } +} diff --git a/specs/handlers/kernelio-dconf.spec.yaml b/specs/handlers/kernelio-dconf.spec.yaml new file mode 100644 index 0000000..831666a --- /dev/null +++ b/specs/handlers/kernelio-dconf.spec.yaml @@ -0,0 +1,93 @@ +spec: + id: kernelio-dconf + version: 0.1.0 + status: draft + tier: 1 + + context: + system: kensa + feature: kernelio-dconf + description: | + The dconf_set handler writes the system dconf policy files — the + profile (/etc/dconf/profile/), the keyfile snippet + (/etc/dconf/db/.d/), and an optional lock + (/etc/dconf/db/.d/locks/) — via direct atomic file IO + (fsatomic) when running on the target host (agent mode), instead + of the shell printf + mkdir pipeline. The `dconf update` compile + step DELIBERATELY stays on the shell on both paths: it is the + dconf toolchain's job to compile the keyfile drop-ins into the + binary database, exactly as mount keeps the remount on mount(8) + and audit keeps the load on augenrules. + + The migration doc's "D-Bus to ca.desrt.dconf" does NOT apply + here: that is the user-session dconf API, whereas dconf_set + manages SYSTEM policy, which is file-based (profiles, keyfiles, + locks compiled by dconf update). The handler asserts + kernelio.FileTransport and falls back to the shell path when the + transport does not implement it. + related_specs: + - handler-dconf-set + - kernelio-mount + + objective: + summary: | + Wire dconf_set's Apply/Capture/Rollback to write the dconf + policy files atomically in agent mode, preserving the shell + fallback, byte-identical file content, an identical captured + PreState shape, and the `dconf update` compile on both paths. + scope: + includes: + - kernelio.MkdirAll primitive + FileTransport addition + - dconf_set dual-path Apply/Capture/Rollback (file layer) + excludes: + - the dconf update compile step (stays shell on both paths) + - the user-session ca.desrt.dconf D-Bus API (not applicable to system policy) + - the shell path's behavior (handler-dconf-set) + + constraints: + - id: C-01 + description: | + On the kernel-IO path, Apply MUST write the profile + (create-if-absent — an existing operator profile is left + untouched), ensure the db.d (and locks) directory exists via + MkdirAll, and write the snippet (and optional lock) via + fsatomic atomic replace, producing byte-identical content to + the shell path. The `dconf update` compile MUST run on both + paths. + type: technical + enforcement: error + - id: C-02 + description: | + dconf_set MUST select the kernel-IO path iff the transport + implements kernelio.FileTransport, else the shell path. Both + paths MUST record an identical PreState shape (file_path, + prior_content, file_existed) so capture/rollback are + path-agnostic. Rollback MUST restore the prior snippet content + when it existed at capture and remove the snippet when it did + not, then run dconf update. + type: technical + enforcement: error + + acceptance_criteria: + - id: AC-01 + description: | + Kernel-IO Apply writes the profile (create-if-absent — an + existing profile is preserved), snippet, and (when requested) + lock files atomically and runs `dconf update`. + references_constraints: [C-01] + priority: critical + - id: AC-02 + description: | + Kernel-IO Capture → Apply → Rollback removes a snippet that + did not exist at capture and restores the prior content of one + that did. + references_constraints: [C-02] + priority: critical + - id: AC-03 + description: | + A transport without the kernelio capability falls back to the + shell printf + mkdir + dconf update path. + references_constraints: [C-02] + priority: critical + + tags: [handler, kernelio, dconf, agent, tier-1, phase-7]