Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions internal/agent/kernelio/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,30 @@ type FakeSysctlTransport struct {
// Runs records shell commands issued via Run (e.g. the mount handler's
// remount, which stays on mount(8) even on the kernel-IO path).
Runs []string

// DeletedModules records DeleteModule calls (the module handler's
// runtime unload). DeleteModuleErr, keyed by module name, injects an
// unload error (e.g. ErrModuleNotLoaded or a busy module).
DeletedModules []string
DeleteModuleErr map[string]error
}

// NewFakeSysctl returns a FakeSysctlTransport with initialized maps.
func NewFakeSysctl() *FakeSysctlTransport {
return &FakeSysctlTransport{
Runtime: map[string]string{},
Files: map[string]string{},
WriteErr: map[string]error{},
Runtime: map[string]string{},
Files: map[string]string{},
WriteErr: map[string]error{},
DeleteModuleErr: map[string]error{},
}
}

// DeleteModule records the unload and returns any canned error.
func (f *FakeSysctlTransport) DeleteModule(name string) error {
f.DeletedModules = append(f.DeletedModules, name)
return f.DeleteModuleErr[name]
}

// WriteSysctl records the runtime value, or returns the canned WriteErr.
func (f *FakeSysctlTransport) WriteSysctl(key, value string) error {
if err := f.WriteErr[key]; err != nil {
Expand Down Expand Up @@ -130,5 +143,6 @@ func (f *failingFakeSysctl) AtomicRemove(ctx context.Context, p string) error {
// Compile-time assertions.
var (
_ SysctlTransport = (*FakeSysctlTransport)(nil)
_ ModuleTransport = (*FakeSysctlTransport)(nil)
_ api.Transport = (*FakeSysctlTransport)(nil)
)
40 changes: 40 additions & 0 deletions internal/agent/kernelio/module.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kernelio

import (
"errors"
"fmt"

"golang.org/x/sys/unix"
)

// ErrModuleNotLoaded is the sentinel a caller can use to recognize the
// "module was not loaded" outcome of an unload (delete_module → ENOENT).
// The kernel_module_disable handler treats it, like any unload failure,
// as a best-effort no-op: the persistent blacklist is what guarantees the
// module stays out; the runtime unload is opportunistic cleanup.
var ErrModuleNotLoaded = errors.New("kernelio: module not loaded")

// DeleteModule unloads a kernel module from the running kernel via the
// delete_module(2) syscall (the rmmod primitive). O_NONBLOCK makes the
// call return immediately rather than waiting for the module refcount to
// drain; a module that is in use returns EBUSY/EWOULDBLOCK. delete_module
// removes only the named module, not its now-unused dependencies (that
// dependency walk is a modprobe(8) nicety) — adequate here because the
// persistent blacklist+install-/bin/true entry is what actually keeps the
// module out, and this unload is best-effort cleanup of an already-loaded
// instance.
//
// ENOENT (module not loaded) is normalised to ErrModuleNotLoaded so the
// caller can distinguish it from a genuine failure.
func DeleteModule(name string) error {
if name == "" {
return errors.New("kernelio: empty module name")
}
if err := unix.DeleteModule(name, unix.O_NONBLOCK); err != nil {
if errors.Is(err, unix.ENOENT) {
return ErrModuleNotLoaded
}
return fmt.Errorf("kernelio: delete_module %q: %w", name, err)
}
return nil
}
24 changes: 24 additions & 0 deletions internal/agent/kernelio/module_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kernelio

import (
"errors"
"testing"
)

// TestDeleteModule_EmptyName covers the deterministic guard. The real
// delete_module(2) path needs root + a loaded module and is exercised by
// live validation, not CI: as a non-root test process it returns EPERM,
// and asserting on that would be uid-dependent and flaky.
//
// @spec kernelio-module
// @ac AC-01
func TestDeleteModule_EmptyName(t *testing.T) {
t.Run("kernelio-module/AC-01", func(t *testing.T) {})
if err := DeleteModule(""); err == nil {
t.Error("DeleteModule(\"\") should error")
}
// ErrModuleNotLoaded is a distinct sentinel callers can branch on.
if !errors.Is(ErrModuleNotLoaded, ErrModuleNotLoaded) {
t.Error("sentinel identity check")
}
}
11 changes: 11 additions & 0 deletions internal/agent/kernelio/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ type FileTransport interface {
ReadFileIfExists(path string) (content string, existed bool, err error)
}

// ModuleTransport is the capability a transport implements when it can
// manage kernel modules via direct kernel IO: the FileTransport ops for
// the /etc/modprobe.d blacklist drop-in, plus DeleteModule (the
// delete_module(2) runtime unload). The kernel_module_disable handler
// asserts it and falls back to the modprobe + shell file-write path
// otherwise.
type ModuleTransport interface {
FileTransport
DeleteModule(name string) error
}

// SysctlTransport is the capability interface a transport implements when
// it can apply sysctl changes via direct kernel IO — i.e. the agent-mode
// local transport on the target host. A handler type-asserts
Expand Down
8 changes: 8 additions & 0 deletions internal/agent/transport/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,18 @@ func (t *Transport) ReadFileIfExists(path string) (string, bool, error) {
return kernelio.ReadFileIfExists(path)
}

// DeleteModule delegates to kernelio.DeleteModule (delete_module(2)).
// Satisfies kernelio.ModuleTransport for the kernel_module_disable
// handler's runtime unload.
func (t *Transport) DeleteModule(name string) error {
return kernelio.DeleteModule(name)
}

// Compile-time interface check.
var (
_ api.Transport = (*Transport)(nil)
_ fsatomic.Transport = (*Transport)(nil)
_ systemd.Transport = (*Transport)(nil)
_ kernelio.SysctlTransport = (*Transport)(nil)
_ kernelio.ModuleTransport = (*Transport)(nil)
)
123 changes: 106 additions & 17 deletions internal/handlers/kernelmoduledisable/kernelmoduledisable.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
// it from the running kernel if loaded. Capture records whether the
// blacklist file existed for rollback.
// Spec: specs/handlers/kernel_module_disable.spec.yaml.
//
// Dual path: when the transport implements kernelio.ModuleTransport
// (agent mode on the target host) the handler writes the blacklist
// drop-in atomically (fsatomic) and unloads via delete_module(2),
// instead of the shell printf + modprobe pipeline. Otherwise it falls
// back to the shell path. The runtime unload is best-effort on both
// paths — the persistent blacklist+install-/bin/true entry is what keeps
// the module out. Both paths write a byte-identical drop-in and record an
// identical PreState shape, so capture/rollback are path-agnostic.
package kernelmoduledisable

import (
Expand All @@ -13,11 +22,23 @@ import (
"time"

"github.com/Hanalyx/kensa/api"
"github.com/Hanalyx/kensa/internal/agent/kernelio"
)

// mechanism is the canonical handler name.
const mechanism = "kernel_module_disable"

// blacklistMode is the modprobe.d drop-in file mode.
const blacklistMode = 0o644

// blacklistContent renders the canonical drop-in body, so the kernel-IO
// and shell paths write byte-identical files. The `install ... /bin/true`
// line prevents loading even via explicit modprobe; blacklist alone does
// not.
func blacklistContent(module string) string {
return fmt.Sprintf("# Managed by Kensa.\nblacklist %s\ninstall %s /bin/true\n", module, module)
}

// Params is the decoded parameter struct for kernel_module_disable.
type Params struct {
// Module is the kernel module name (e.g. "usb-storage", "cramfs").
Expand Down Expand Up @@ -62,25 +83,45 @@ func (h *Handler) Name() string { return mechanism }
// Capturable reports true.
func (h *Handler) Capturable() bool { return true }

// Apply writes the blacklist + install-as-true entry to modprobe.d
// and unloads the module from the running kernel (best-effort via
// modprobe -r; failure to unload is reported but not fatal when the
// module is in use).
// Apply writes the blacklist + install-as-true entry to modprobe.d and
// unloads the module from the running kernel (best-effort).
//
// Idempotent: rewriting the same blacklist file is a no-op.
func (h *Handler) Apply(ctx context.Context, transport api.Transport, params api.Params, _ *api.PreState) (*api.StepResult, error) {
p, err := decodeParams(params)
if err != nil {
return nil, err
}
if mt, ok := transport.(kernelio.ModuleTransport); ok {
return h.applyKernel(ctx, mt, p)
}
return h.applyShell(ctx, transport, p)
}

// applyKernel writes the blacklist drop-in atomically and unloads the
// module via delete_module(2). The unload is best-effort: a module in use
// (EBUSY) or not loaded (ErrModuleNotLoaded) is not a failure — the
// persistent blacklist is what guarantees it stays out.
func (h *Handler) applyKernel(ctx context.Context, mt kernelio.ModuleTransport, p *Params) (*api.StepResult, error) {
path := blacklistPath(p.Module)
// install /bin/true prevents the module from being loaded even via
// explicit modprobe; blacklist alone doesn't block that.
content := fmt.Sprintf("# Managed by Kensa.\nblacklist %s\ninstall %s /bin/true\n", p.Module, p.Module)
if err := mt.AtomicReplace(ctx, path, blacklistMode, []byte(blacklistContent(p.Module))); err != nil {
return nil, fmt.Errorf("kernel_module_disable: write blacklist: %w", err)
}
// Best-effort unload; the blacklist is the load-bearing change.
_ = mt.DeleteModule(p.Module)
return &api.StepResult{
Success: true,
Detail: fmt.Sprintf("kernel_module_disable: blacklisted %s at %s (kernel-io)", p.Module, path),
}, nil
}

// applyShell writes the blacklist via printf and unloads via modprobe -r
// (best-effort).
func (h *Handler) applyShell(ctx context.Context, transport api.Transport, p *Params) (*api.StepResult, error) {
path := blacklistPath(p.Module)
cmd := fmt.Sprintf(
"printf '%%s' %s > %s && modprobe -r %s 2>/dev/null || true",
shellEscape(content), shellEscape(path), shellEscape(p.Module),
shellEscape(blacklistContent(p.Module)), shellEscape(path), shellEscape(p.Module),
)
res, err := transport.Run(ctx, cmd)
if err != nil {
Expand All @@ -104,6 +145,24 @@ func (h *Handler) Capture(ctx context.Context, transport api.Transport, params a
if err != nil {
return nil, err
}
if mt, ok := transport.(kernelio.ModuleTransport); ok {
return h.captureKernel(mt, p)
}
return h.captureShell(ctx, transport, p)
}

// captureKernel reads the blacklist file directly.
func (h *Handler) captureKernel(mt kernelio.ModuleTransport, p *Params) (*api.PreState, error) {
path := blacklistPath(p.Module)
content, existed, err := mt.ReadFileIfExists(path)
if err != nil {
return nil, fmt.Errorf("kernel_module_disable: capture read %s: %w (%v)", path, api.ErrCaptureIncomplete, err)
}
return h.preState(p, path, existed, content), nil
}

// captureShell reads the blacklist file via cat + an absent sentinel.
func (h *Handler) captureShell(ctx context.Context, transport api.Transport, p *Params) (*api.PreState, error) {
path := blacklistPath(p.Module)
cmd := fmt.Sprintf(
"test -e %[1]s && cat %[1]s || printf '__KENSA_ABSENT__'",
Expand All @@ -117,11 +176,17 @@ func (h *Handler) Capture(ctx context.Context, transport api.Transport, params a
return nil, fmt.Errorf("kernel_module_disable: capture failed for %s: %w (stderr: %s)",
path, api.ErrCaptureIncomplete, strings.TrimSpace(res.Stderr))
}
fileExisted := res.Stdout != "__KENSA_ABSENT__"
priorContent := ""
if fileExisted {
priorContent = res.Stdout
existed := res.Stdout != "__KENSA_ABSENT__"
content := ""
if existed {
content = res.Stdout
}
return h.preState(p, path, existed, content), nil
}

// preState builds the canonical PreState shape used by both capture
// paths, so Rollback is path-agnostic.
func (h *Handler) preState(p *Params, path string, fileExisted bool, priorContent string) *api.PreState {
return &api.PreState{
Mechanism: mechanism,
Capturable: true,
Expand All @@ -132,13 +197,13 @@ func (h *Handler) Capture(ctx context.Context, transport api.Transport, params a
"file_existed": fileExisted,
"prior_content": priorContent,
},
}, nil
}
}

// Rollback removes the blacklist file (or restores prior content) and
// reloads the module if it was previously present in a running state.
// Note: reloading kernel modules after removal requires a manual step
// or reboot in some configurations — this is a disclosed limitation.
// Rollback removes the blacklist file (or restores prior content).
// Re-enabling the module at runtime after removal can require a manual
// step or reboot in some configurations — a disclosed limitation that
// both paths share.
func (h *Handler) Rollback(ctx context.Context, transport api.Transport, pre *api.PreState) (*api.RollbackResult, error) {
if pre == nil || pre.Data == nil {
return nil, errors.New("kernel_module_disable: rollback called with nil pre-state")
Expand All @@ -150,6 +215,30 @@ func (h *Handler) Rollback(ctx context.Context, transport api.Transport, pre *ap
fileExisted, _ := pre.Data["file_existed"].(bool)
priorContent, _ := pre.Data["prior_content"].(string)

if mt, ok := transport.(kernelio.ModuleTransport); ok {
return h.rollbackKernel(ctx, mt, path, fileExisted, priorContent)
}
return h.rollbackShell(ctx, transport, path, fileExisted, priorContent)
}

// rollbackKernel restores or removes the blacklist drop-in atomically.
func (h *Handler) rollbackKernel(ctx context.Context, mt kernelio.ModuleTransport, path string, fileExisted bool, priorContent string) (*api.RollbackResult, error) {
if fileExisted {
if err := mt.AtomicReplace(ctx, path, blacklistMode, []byte(priorContent)); err != nil {
return nil, fmt.Errorf("kernel_module_disable: rollback rewrite %s: %w", path, err)
}
} else if err := mt.AtomicRemove(ctx, path); err != nil {
return nil, fmt.Errorf("kernel_module_disable: rollback remove %s: %w", path, err)
}
return &api.RollbackResult{
Success: true,
Detail: fmt.Sprintf("kernel_module_disable: restored %s (file_existed=%v) (kernel-io); module re-enable may require reboot", path, fileExisted),
ExecutedAt: time.Now().UTC(),
}, nil
}

// rollbackShell restores or removes the blacklist file via shell.
func (h *Handler) rollbackShell(ctx context.Context, transport api.Transport, path string, fileExisted bool, priorContent string) (*api.RollbackResult, error) {
var cmd string
if fileExisted {
cmd = fmt.Sprintf("printf '%%s' %s > %s", shellEscape(priorContent), shellEscape(path))
Expand Down
Loading