diff --git a/engine/resources/sysctl.go b/engine/resources/sysctl.go new file mode 100644 index 00000000..4fa2e72a --- /dev/null +++ b/engine/resources/sysctl.go @@ -0,0 +1,398 @@ +// Mgmt +// Copyright (C) 2013-2024+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this program, or any covered work, by linking or combining it +// with embedded mcl code and modules (and that the embedded mcl code and +// modules which link with this program, contain a copy of their source code in +// the authoritative form) containing parts covered by the terms of any other +// license, the licensors of this program grant you additional permission to +// convey the resulting work. Furthermore, the licensors of this program grant +// the original author, James Shubin, additional permission to update this +// additional permission if he deems it necessary to achieve the goals of this +// additional permission. + +package resources + +import ( + "bytes" + "context" + "fmt" + "os" + "path" + "strings" + "sync" + + "github.com/purpleidea/mgmt/engine" + "github.com/purpleidea/mgmt/engine/traits" + "github.com/purpleidea/mgmt/util/errwrap" + "github.com/purpleidea/mgmt/util/recwatch" +) + +func init() { + engine.RegisterResource("sysctl", func() engine.Res { return &SysctlRes{} }) + + if !strings.HasSuffix(SysctlConfDir, "/") { + panic("the SysctlConfDir does not end with a slash") + } +} + +const ( + // SysctlConfDir is the directory to store persistent sysctl files in. + SysctlConfDir = "/etc/sysctl.d/" + + // SysctlConfPrefix is the prefix we prepend to any automatically chosen + // filename that we put in the /etc/sysctl.d/ directory. + // TODO: What prefix should we use if any? + SysctlConfPrefix = "99-" +) + +// SysctlRes is a resource for setting kernel parameters. +// TODO: Add a sysctl:clean resource that removes any unmanaged files from +// /etc/sysctl.d/ and optionally blanks out the stock /etc/sysctl.conf file too. +type SysctlRes struct { + traits.Base // add the base methods without re-implementation + + init *engine.Init + + // Value is the string value to set. Make sure you specify it in the + // same format that the kernel parses it as to avoid automation + // "flapping". You can test this by writing a value to the correct + // /proc/sys/ path entry with `echo foo >` and then reading it back out + // and seeing what the "parsed" correct format is. You must not include + // the trailing newline which is present in the readback for all values. + Value string `lang:"value" yaml:"value"` + + // Runtime specifies whether this value should be set immediately. It + // defaults to true. If this is not set, then the value must be set in a + // file and the machine will have to reboot for the setting to take + // effect. + Runtime bool `lang:"runtime" yaml:"runtime"` + + // Persist specifies whether this value should be stored on disk where + // it will persist across reboots. It defaults to true. Keep in mind, + // that if this is not used, but `Runtime` is true, then the value will + // be restored anyways if `mgmt` runs on boot, which may be what you + // want anyways. + Persist bool `lang:"persist" yaml:"persist"` + + // Filename is the full path for the persistence file which is usually + // read on boot. We usually use entries in the /etc/sysctl.d/ directory. + // By convention, they end in .conf and start with a numeric prefix and + // a dash. For example: /etc/sysctl.d/10-dmesg.conf for example. If this + // is omitted, the filename will be chosen automatically. + Filename string `lang:"path" yaml:"path"` +} + +// toPath converts our name into the magic kernel path. It does not validate +// that the name is in a valid format. +func (obj *SysctlRes) toPath() string { + return path.Join("/proc/sys/", strings.ReplaceAll(obj.Name(), ".", "/")) +} + +// getFilename returns the filename of the config that we would use if we're +// setting one. This does not look at the is persistent aspect. +func (obj *SysctlRes) getFilename() string { + if obj.Filename != "" { + return obj.Filename + } + return SysctlConfDir + SysctlConfPrefix + obj.Name() + ".conf" +} + +// Default returns some sensible defaults for this resource. +func (obj *SysctlRes) Default() engine.Res { + return &SysctlRes{ + Runtime: true, + Persist: true, + } +} + +// Validate reports any problems with the struct definition. +func (obj *SysctlRes) Validate() error { + if strings.Contains(obj.Name(), "/") { + // We do this to avoid having two resources which "fight" with + // each other by using the alternative representation. + return fmt.Errorf("name contains slashes, use the dotted representation") + } + if strings.Contains(obj.Name(), "=") { + return fmt.Errorf("name contains equals sign, this is illegal") + } + if strings.HasPrefix(obj.Name(), ".") || strings.HasSuffix(obj.Name(), ".") { + return fmt.Errorf("name contains leading or trailing periods") + } + if obj.Name() != strings.TrimSpace(obj.Name()) { + return fmt.Errorf("name has leading or trailing whitespace") + } + + if obj.Value == "" { + return fmt.Errorf("value is empty") + } + if strings.TrimSpace(obj.Value) != obj.Value { + return fmt.Errorf("value contains leading or trailing whitespace") + } + + // TODO: We could probably relax this check I suppose. + if !obj.Runtime && !obj.Persist { + return fmt.Errorf("you must either set the value at runtime or you must persist") + } + + // Parse the Name() and see if it's a valid path under /proc/sys/ dir. + if _, err := os.Stat(obj.toPath()); err != nil && !os.IsNotExist(err) { + // system or permissions error? + return errwrap.Wrapf(err, "unknown stat error") + + } else if err != nil { + // TODO: Could there be a kernel that doesn't show this path? + return fmt.Errorf("name is not a valid kernel path: %s", obj.toPath()) + } + + if obj.Persist && !strings.HasSuffix(obj.getFilename(), ".conf") { + return fmt.Errorf("filename must end with .conf") + } + if obj.Persist && !strings.HasPrefix(obj.getFilename(), "/") { + return fmt.Errorf("filename must be absolute and start with slash") + } + + return nil +} + +// Init runs some startup code for this resource. +func (obj *SysctlRes) Init(init *engine.Init) error { + obj.init = init // save for later + + return nil +} + +// Cleanup is run by the engine to clean up after the resource is done. +func (obj *SysctlRes) Cleanup() error { + return nil +} + +// Watch is the primary listener for this resource and it outputs events. This +// one watches the on disk filename if it creates one, as well as the runtime +// value the kernel has stored! +func (obj *SysctlRes) Watch(ctx context.Context) error { + wg := &sync.WaitGroup{} + defer wg.Wait() + + recurse := false // single file + + var events1, events2 chan recwatch.Event + + if obj.Runtime { + recWatcher, err := recwatch.NewRecWatcher(obj.toPath(), recurse) + if err != nil { + return err + } + defer recWatcher.Close() + events1 = recWatcher.Events() + } + + if obj.Persist { + recWatcher, err := recwatch.NewRecWatcher(obj.getFilename(), recurse) + if err != nil { + return err + } + defer recWatcher.Close() + events2 = recWatcher.Events() + } + + obj.init.Running() // when started, notify engine that we're running + + var send = false // send event? + for { + select { + case event, ok := <-events1: + if !ok { // channel shutdown + return fmt.Errorf("unexpected close") + } + if err := event.Error; err != nil { + return err + } + if obj.init.Debug { // don't access event.Body if event.Error isn't nil + obj.init.Logf("event(%s): %v", event.Body.Name, event.Body.Op) + } + send = true + + case event, ok := <-events2: + if !ok { // channel shutdown + return fmt.Errorf("unexpected close") + } + if err := event.Error; err != nil { + return err + } + if obj.init.Debug { // don't access event.Body if event.Error isn't nil + obj.init.Logf("event(%s): %v", event.Body.Name, event.Body.Op) + } + send = true + + case <-ctx.Done(): // closed by the engine to signal shutdown + return nil + } + + // do all our event sending all together to avoid duplicate msgs + if send { + send = false + obj.init.Event() // notify engine of an event (this can block) + } + } +} + +// CheckApply checks the resource state and applies the resource if the bool +// input is true. It returns error info and if the state check passed or not. +func (obj *SysctlRes) CheckApply(ctx context.Context, apply bool) (bool, error) { + checkOK := true + + // TODO: If there any reason to do one of these before the other? At + // least right now, if the runtime change causes a kernel panic, the + // machine will have a better chance of coming back online without the + // persisted value being stored. + + if c, err := obj.runtimeCheckApply(ctx, apply); err != nil { + return false, err + } else if !c { + checkOK = false + } + + if c, err := obj.persistCheckApply(ctx, apply); err != nil { + return false, err + } else if !c { + checkOK = false + } + + return checkOK, nil // w00t +} + +// runtimeCheckApply checks the runtime value in the kernel, and modifies it if +// needed. +func (obj *SysctlRes) runtimeCheckApply(ctx context.Context, apply bool) (bool, error) { + if !obj.Runtime { + return true, nil + } + + // Clean off any whitespace it comes with and then always add a newline. + expected := []byte(obj.Value + "\n") + + b, err := os.ReadFile(obj.toPath()) + if err != nil && !os.IsNotExist(err) { + // system or permissions error? + return false, nil + } + if err == nil && bytes.Equal(expected, b) { + return true, nil // we match! + } + + // Down here, file does not exist or does not match... + + if !apply { + return false, nil + } + + if err := os.WriteFile(obj.toPath(), expected, 0644); err != nil { + return false, err + } + + obj.init.Logf("runtime `%s` to: %s\n", obj.Value, obj.toPath()) + + return false, err +} + +// persistCheckApply checks the on-disk value for the kernel, and modifies it if +// needed. +func (obj *SysctlRes) persistCheckApply(ctx context.Context, apply bool) (bool, error) { + if !obj.Persist { + return true, nil + } + + // Clean off any whitespace and put it in the standard format. + // TODO: Should we add a "last managed by mgmt on $date" line ? + s := fmt.Sprintf("%s = %s\n", obj.Name(), obj.Value) + expected := []byte(s) + + b, err := os.ReadFile(obj.getFilename()) + if err != nil && !os.IsNotExist(err) { + // system or permissions error? + return false, nil + } + if err == nil && bytes.Equal(expected, b) { + return true, nil // we match! + } + + // Down here, file does not exist or does not match... + + if !apply { + return false, nil + } + + if err := os.WriteFile(obj.getFilename(), expected, 0644); err != nil { + return false, err + } + + obj.init.Logf("persist `%s` to: %s\n", obj.Value, obj.getFilename()) + + return false, nil +} + +// Cmp compares two resources and returns an error if they are not equivalent. +func (obj *SysctlRes) Cmp(r engine.Res) error { + // we can only compare SysctlRes to others of the same resource kind + res, ok := r.(*SysctlRes) + if !ok { + return fmt.Errorf("not a %s", obj.Kind()) + } + + if obj.Value != res.Value { + return fmt.Errorf("the Value differs") + } + + if obj.Runtime != res.Runtime { + return fmt.Errorf("the Runtime value differs") + } + if obj.Persist != res.Persist { + return fmt.Errorf("the Persist value differs") + } + + // TODO: We could compare the actual resultant Filename if we're using + // it, even if it comes from different representations, eg: specified vs + // chosen automatically. If they don't differ, it's fine with us! + if obj.Filename != res.Filename { + return fmt.Errorf("the contents of Filename differ") + } + + return nil +} + +// UnmarshalYAML is the custom unmarshal handler for this struct. It is +// primarily useful for setting the defaults. +func (obj *SysctlRes) UnmarshalYAML(unmarshal func(interface{}) error) error { + type rawRes SysctlRes // indirection to avoid infinite recursion + + def := obj.Default() // get the default + res, ok := def.(*SysctlRes) // put in the right format + if !ok { + return fmt.Errorf("could not convert to SysctlRes") + } + raw := rawRes(*res) // convert; the defaults go here + + if err := unmarshal(&raw); err != nil { + return err + } + + *obj = SysctlRes(raw) // restore from indirection with type conversion! + return nil +} diff --git a/examples/lang/sysctl.mcl b/examples/lang/sysctl.mcl new file mode 100644 index 00000000..751a6b61 --- /dev/null +++ b/examples/lang/sysctl.mcl @@ -0,0 +1,9 @@ +# To know where this value is, just prepend /proc/sys/ and then replace the dots +# with slashes, so this becomes /proc/sys/net/ipv4/ip_forward which you can cat! +sysctl "net.ipv4.ip_forward" { # firewalls love this! + value => "1", +} + +#sysctl "kernel.hostname" { +# value => "example.com", +#}