mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 19:36:52 +01:00
Add support for accessing the previously ssh-only debug commands via `nebula ctl <command>.` - unix socket (named pipe on windows) for communication - sockets scoped to the user + an optional group - in cmd/nebula and cmd/nebula-service, but not nebula.Main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
467 lines
10 KiB
Go
467 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"math"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"dario.cat/mergo"
|
|
"go.yaml.in/yaml/v3"
|
|
)
|
|
|
|
type C struct {
|
|
path string
|
|
files []string
|
|
Settings map[string]any
|
|
oldSettings map[string]any
|
|
callbacks []func(*C, *Reload)
|
|
l *slog.Logger
|
|
reloadLock sync.Mutex
|
|
|
|
// Set by the first reload that needs a restart and never cleared. Guarded by reloadLock
|
|
needsRestart bool
|
|
|
|
// callbackLock guards callbacks. Registration is not confined to startup: CatchHUP is
|
|
// running from inside Main, and anything wired up after Main returns registers while
|
|
// a HUP could already be reloading, so append and iteration have to be serialized.
|
|
callbackLock sync.Mutex
|
|
}
|
|
|
|
func NewC(l *slog.Logger) *C {
|
|
return &C{
|
|
Settings: make(map[string]any),
|
|
l: l,
|
|
}
|
|
}
|
|
|
|
// Load will find all yaml files within path and load them in lexical order
|
|
func (c *C) Load(path string) error {
|
|
c.path = path
|
|
c.files = make([]string, 0)
|
|
|
|
err := c.resolve(path, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(c.files) == 0 {
|
|
return fmt.Errorf("no config files found at %s", path)
|
|
}
|
|
|
|
sort.Strings(c.files)
|
|
|
|
err = c.parse()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *C) LoadString(raw string) error {
|
|
if raw == "" {
|
|
return errors.New("Empty configuration")
|
|
}
|
|
return c.parseRaw([]byte(raw))
|
|
}
|
|
|
|
// RegisterReloadCallback stores a function to be called when a config reload is triggered. The functions registered
|
|
// here should decide if they need to make a change to the current process before making the change. HasChanged can be
|
|
// used to help decide if a change is necessary.
|
|
// These functions should return quickly or spawn their own go routine if they will take a while.
|
|
// Use r to report anything that failed or needs a restart to apply.
|
|
func (c *C) RegisterReloadCallback(f func(c *C, r *Reload)) {
|
|
c.callbackLock.Lock()
|
|
defer c.callbackLock.Unlock()
|
|
c.callbacks = append(c.callbacks, f)
|
|
}
|
|
|
|
// reloadCallbacks returns a snapshot of the registered callbacks. Reloads iterate the
|
|
// snapshot rather than the live slice, so a registration from another goroutine cannot
|
|
// race the loop; a callback registered while a reload is in progress runs from the next
|
|
// reload on. callbackLock is not held during the calls themselves, so a callback that
|
|
// registers another callback does not deadlock.
|
|
func (c *C) reloadCallbacks() []func(*C, *Reload) {
|
|
c.callbackLock.Lock()
|
|
defer c.callbackLock.Unlock()
|
|
return append(make([]func(*C, *Reload), 0, len(c.callbacks)), c.callbacks...)
|
|
}
|
|
|
|
// InitialLoad returns true if this is the first load of the config, and ReloadConfig has not been called yet.
|
|
func (c *C) InitialLoad() bool {
|
|
return c.oldSettings == nil
|
|
}
|
|
|
|
// Render returns keys and their values as yaml, for a subsystem to remember what it applied. Empty means they couldn't
|
|
// be rendered and should never match.
|
|
func (c *C) Render(keys ...string) string {
|
|
m := make(map[string]any, len(keys))
|
|
for _, k := range keys {
|
|
m[k] = c.Get(k)
|
|
}
|
|
b, err := yaml.Marshal(m)
|
|
if err != nil {
|
|
c.l.Debug("Failed to render config", "keys", keys, "error", err)
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// HasChanged checks if the underlying structure of the provided key has changed after a config reload. The value of
|
|
// k in both the old and new settings will be serialized, the result of the string comparison is returned.
|
|
// If k is an empty string the entire config is tested.
|
|
// It's important to note that this is very rudimentary and susceptible to configuration ordering issues indicating
|
|
// there is change when there actually wasn't any.
|
|
func (c *C) HasChanged(k string) bool {
|
|
if c.oldSettings == nil {
|
|
return false
|
|
}
|
|
|
|
var (
|
|
nv any
|
|
ov any
|
|
)
|
|
|
|
if k == "" {
|
|
nv = c.Settings
|
|
ov = c.oldSettings
|
|
k = "all settings"
|
|
} else {
|
|
nv = c.get(k, c.Settings)
|
|
ov = c.get(k, c.oldSettings)
|
|
}
|
|
|
|
newVals, err := yaml.Marshal(nv)
|
|
if err != nil {
|
|
c.l.Error("Error while marshaling new config",
|
|
"config_path", k,
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
oldVals, err := yaml.Marshal(ov)
|
|
if err != nil {
|
|
c.l.Error("Error while marshaling old config",
|
|
"config_path", k,
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
return string(newVals) != string(oldVals)
|
|
}
|
|
|
|
// CatchHUP will listen for the HUP signal in a go routine and reload all configs found in the
|
|
// original path provided to Load. The old settings are shallow copied for change detection after the reload.
|
|
func (c *C) CatchHUP(ctx context.Context) {
|
|
if c.path == "" {
|
|
return
|
|
}
|
|
|
|
ch := make(chan os.Signal, 1)
|
|
signal.Notify(ch, syscall.SIGHUP)
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
signal.Stop(ch)
|
|
close(ch)
|
|
return
|
|
case <-ch:
|
|
c.l.Info("Caught HUP, reloading config")
|
|
c.ReloadConfig().Log(c.l)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// ReloadConfig reloads the config from the path provided to Load and runs every registered callback. A config that
|
|
// fails to load is logged here, like any other failure, and the result carries the error for the summary.
|
|
func (c *C) ReloadConfig() ReloadResult {
|
|
c.reloadLock.Lock()
|
|
defer c.reloadLock.Unlock()
|
|
|
|
c.oldSettings = make(map[string]any)
|
|
maps.Copy(c.oldSettings, c.Settings)
|
|
|
|
if err := c.Load(c.path); err != nil {
|
|
c.l.Error("Error occurred while reloading config", "config_path", c.path, "error", err)
|
|
return ReloadResult{Err: err, RestartRequired: c.needsRestart}
|
|
}
|
|
|
|
return c.runCallbacks()
|
|
}
|
|
|
|
// ReloadConfigString is ReloadConfig for a config held in memory rather than on disk.
|
|
func (c *C) ReloadConfigString(raw string) ReloadResult {
|
|
c.reloadLock.Lock()
|
|
defer c.reloadLock.Unlock()
|
|
|
|
c.oldSettings = make(map[string]any)
|
|
maps.Copy(c.oldSettings, c.Settings)
|
|
|
|
if err := c.LoadString(raw); err != nil {
|
|
c.l.Error("Error occurred while reloading config", "error", err)
|
|
return ReloadResult{Err: err, RestartRequired: c.needsRestart}
|
|
}
|
|
|
|
return c.runCallbacks()
|
|
}
|
|
|
|
func (c *C) runCallbacks() ReloadResult {
|
|
r := &Reload{c: c}
|
|
for _, v := range c.reloadCallbacks() {
|
|
v(c, r)
|
|
}
|
|
|
|
if r.restart {
|
|
c.needsRestart = true
|
|
}
|
|
return ReloadResult{Failed: r.failed, RestartRequired: c.needsRestart, RestartKeys: r.keys}
|
|
}
|
|
|
|
// GetString will get the string for k or return the default d if not found or invalid
|
|
func (c *C) GetString(k, d string) string {
|
|
r := c.Get(k)
|
|
if r == nil {
|
|
return d
|
|
}
|
|
|
|
return fmt.Sprintf("%v", r)
|
|
}
|
|
|
|
// GetStringSlice will get the slice of strings for k or return the default d if not found or invalid
|
|
func (c *C) GetStringSlice(k string, d []string) []string {
|
|
r := c.Get(k)
|
|
if r == nil {
|
|
return d
|
|
}
|
|
|
|
rv, ok := r.([]any)
|
|
if !ok {
|
|
return d
|
|
}
|
|
|
|
v := make([]string, len(rv))
|
|
for i := range v {
|
|
v[i] = fmt.Sprintf("%v", rv[i])
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// GetMap will get the map for k or return the default d if not found or invalid
|
|
func (c *C) GetMap(k string, d map[string]any) map[string]any {
|
|
r := c.Get(k)
|
|
if r == nil {
|
|
return d
|
|
}
|
|
|
|
v, ok := r.(map[string]any)
|
|
if !ok {
|
|
return d
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// GetInt will get the int for k or return the default d if not found or invalid
|
|
func (c *C) GetInt(k string, d int) int {
|
|
r := c.GetString(k, strconv.Itoa(d))
|
|
v, err := strconv.Atoi(r)
|
|
if err != nil {
|
|
return d
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// GetUint32 will get the uint32 for k or return the default d if not found or invalid
|
|
func (c *C) GetUint32(k string, d uint32) uint32 {
|
|
r := c.GetInt(k, int(d))
|
|
if r < 0 || uint64(r) > uint64(math.MaxUint32) {
|
|
return d
|
|
}
|
|
return uint32(r)
|
|
}
|
|
|
|
// GetBool will get the bool for k or return the default d if not found or invalid
|
|
func (c *C) GetBool(k string, d bool) bool {
|
|
r := strings.ToLower(c.GetString(k, fmt.Sprintf("%v", d)))
|
|
v, err := strconv.ParseBool(r)
|
|
if err != nil {
|
|
switch r {
|
|
case "y", "yes":
|
|
return true
|
|
case "n", "no":
|
|
return false
|
|
}
|
|
return d
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
func AsBool(v any) (value bool, ok bool) {
|
|
switch x := v.(type) {
|
|
case bool:
|
|
return x, true
|
|
case string:
|
|
switch x {
|
|
case "y", "yes":
|
|
return true, true
|
|
case "n", "no":
|
|
return false, true
|
|
}
|
|
}
|
|
|
|
return false, false
|
|
}
|
|
|
|
// GetDuration will get the duration for k or return the default d if not found or invalid
|
|
func (c *C) GetDuration(k string, d time.Duration) time.Duration {
|
|
r := c.GetString(k, "")
|
|
v, err := time.ParseDuration(r)
|
|
if err != nil {
|
|
return d
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (c *C) Get(k string) any {
|
|
return c.get(k, c.Settings)
|
|
}
|
|
|
|
func (c *C) IsSet(k string) bool {
|
|
return c.get(k, c.Settings) != nil
|
|
}
|
|
|
|
func (c *C) get(k string, v any) any {
|
|
parts := strings.SplitSeq(k, ".")
|
|
for p := range parts {
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
v, ok = m[p]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// direct signifies if this is the config path directly specified by the user,
|
|
// versus a file/dir found by recursing into that path
|
|
func (c *C) resolve(path string, direct bool) error {
|
|
i, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
if !i.IsDir() {
|
|
c.addFile(path, direct)
|
|
return nil
|
|
}
|
|
|
|
paths, err := readDirNames(path)
|
|
if err != nil {
|
|
return fmt.Errorf("problem while reading directory %s: %s", path, err)
|
|
}
|
|
|
|
for _, p := range paths {
|
|
err := c.resolve(filepath.Join(path, p), false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *C) addFile(path string, direct bool) error {
|
|
ext := filepath.Ext(path)
|
|
|
|
if !direct && ext != ".yaml" && ext != ".yml" {
|
|
return nil
|
|
}
|
|
|
|
ap, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.files = append(c.files, ap)
|
|
return nil
|
|
}
|
|
|
|
func (c *C) parseRaw(b []byte) error {
|
|
var m map[string]any
|
|
|
|
err := yaml.Unmarshal(b, &m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.Settings = m
|
|
return nil
|
|
}
|
|
|
|
func (c *C) parse() error {
|
|
var m map[string]any
|
|
|
|
for _, path := range c.files {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var nm map[string]any
|
|
err = yaml.Unmarshal(b, &nm)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
|
|
// We need to use WithAppendSlice so that firewall rules in separate
|
|
// files are appended together
|
|
err = mergo.Merge(&nm, m, mergo.WithAppendSlice)
|
|
m = nm
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
c.Settings = m
|
|
return nil
|
|
}
|
|
|
|
func readDirNames(path string) ([]string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
paths, err := f.Readdirnames(-1)
|
|
f.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.Strings(paths)
|
|
return paths, nil
|
|
}
|