Skip to content
Open
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
1 change: 1 addition & 0 deletions Changes
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
This file documents the revision history for the SNClient agent.

next:
- support environment variables with $ENV{NAME} in ini config values
- update golang build chain to 1.27.4
- check_os_updates: use JSON for Windows update data and expose update metadata and driver attributes, tolerating unavailable properties
- check_os_updates: exclude optional Windows updates by default, add --include-optional to include them
Expand Down
42 changes: 42 additions & 0 deletions docs/configuration/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,45 @@ For example, to define a dummy command which prints the hostname in lowercase le
[/settings/external scripts/alias]
alias_hostname = check_dummy 0 "host:${hostname:lc}"
```

## Environment Variables

Configuration values can reference environment variables with the `$ENV{NAME}` syntax:

```ini
[/settings/default]
allowed hosts = 127.0.0.1, $ENV{SNCLIENT_ALLOWED_HOSTS}

[/settings/WEB/server]
password = $ENV{SNCLIENT_WEB_PASSWORD}

[/paths]
shared-path = $ENV{SNCLIENT_SHARED_PATH}

[/includes]
node = $ENV{SNCLIENT_NODE_INI}
```

The variable is read from the environment of the running SNClient process when
the configuration is loaded. This makes it possible to distribute the same
configuration files and only change the environment per node.

- An unset variable is a configuration error. `snclient config check` reports
the variable name and config location.
- A variable that is set but empty expands to an empty string.
- Values are expanded once. `$ENV{...}` inside the value of an environment
variable is not expanded again.
- Use `$$ENV{NAME}` to write a literal `$ENV{NAME}`.
- Only config values are expanded. Section names and keys are not.
- The syntax is only available in configuration files, not in check output
templates or runtime macros.
- Environment variables are not re-read on config reload. Restart the service
after changing them. One-shot commands pick up the environment of the new
process automatically.

On Linux with systemd, set variables with `Environment=` or `EnvironmentFile=` in
the service unit. On Windows, set them in the service or system environment and
restart the service.

HTTP(S) includes are parsed by the local agent, so environment variables in remote
config files are resolved on the client.
46 changes: 38 additions & 8 deletions pkg/snclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,9 @@ func (config *Config) ParseINI(configData, iniPath string, snc *Agent) error {
continue
}

err := currentSection.SetRaw(val[0], val[1])
if err != nil {
parseErrors = append(parseErrors, fmt.Errorf("config error in %s:%d: %s", iniPath, lineNr, err.Error()))
setErr := currentSection.SetRaw(val[0], val[1])
if setErr != nil {
parseErrors = append(parseErrors, fmt.Errorf("config error in %s:%d: %s", iniPath, lineNr, setErr.Error()))
}

if len(currentComments) > 0 {
Expand All @@ -366,13 +366,18 @@ func (config *Config) ParseINI(configData, iniPath string, snc *Agent) error {
}

// recurse directly when in the /includes section to maintain order of settings
if config.recursive && currentSection.name == "/includes" {
value, err := configParseString(val[1])
if config.recursive && currentSection.name == "/includes" && setErr == nil {
value, err := configParseValue(val[1])
if err != nil {
parseErrors = append(parseErrors, fmt.Errorf("%s (included in %s:%d)", err.Error(), iniPath, lineNr))

continue
}
if value == "" {
parseErrors = append(parseErrors, fmt.Errorf("config error in %s:%d: empty include path", iniPath, lineNr))

continue
}
err = config.parseInclude(value, iniPath, currentSection, snc)
if err != nil {
parseErrors = append(parseErrors, fmt.Errorf("%s (included in %s:%d)", err.Error(), iniPath, lineNr))
Expand Down Expand Up @@ -646,6 +651,15 @@ func configParseString(val string) (string, error) {
return val, nil
}

func configParseValue(val string) (string, error) {
val, err := configParseString(val)
if err != nil {
return "", err
}

return ReplaceEnvMacros(val)
}

func (config *Config) SectionNames() []string {
keys := make([]string, 0, len(config.sections))
for name := range config.sections {
Expand Down Expand Up @@ -714,18 +728,24 @@ func (config *Config) ReplaceOnDemandConfigMacros(value string, timezone *time.L
}

// ReplaceMacrosDefault replaces default macros in all config data.
func (config *Config) ReplaceMacrosDefault(section *ConfigSection, timezone *time.Location) {
func (config *Config) ReplaceMacrosDefault(section *ConfigSection, timezone *time.Location) error {
defaultMacros := config.DefaultMacros()
for key, val := range section.data {
val = ReplaceMacros(val, timezone, defaultMacros)
section.data[key] = val

raw := section.raw[key]
for i, r := range raw {
raw[i] = ReplaceMacros(r, timezone, defaultMacros)
envExpanded, err := ReplaceEnvMacros(r)
if err != nil {
return fmt.Errorf("config error in section %s key %q: %s", section.name, key, err.Error())
}
raw[i] = ReplaceMacros(envExpanded, timezone, defaultMacros)
}
section.raw[key] = raw
}

return nil
}

// DefaultMacros returns a map of default macros.
Expand Down Expand Up @@ -835,6 +855,7 @@ func (cs *ConfigSection) String() string {
// Set sets a raw single key/value pair. Existing keys will be overwritten.
// Raw means this value will be stored parsed (stripping quotes) but also
// be stored as is, including quotes.
// Environment variable macros are replaced, if they can be resolved.
func (cs *ConfigSection) SetRaw(key, value string) error {
rawValue := value
newRawValue := []string{rawValue}
Expand All @@ -850,6 +871,15 @@ func (cs *ConfigSection) SetRaw(key, value string) error {
return err
}

// keep the unexpanded value on env errors, so the setting survives config rewrites
parsedValue := value
expandedValue, envErr := ReplaceEnvMacros(value)
if envErr == nil {
value = expandedValue
} else {
value = parsedValue
}

if useAppend {
curRaw, cur, ok := cs.GetStringRaw(key)
if ok {
Expand All @@ -875,7 +905,7 @@ func (cs *ConfigSection) SetRaw(key, value string) error {
cs.data[key] = value
cs.raw[key] = newRawValue

return nil
return envErr
}

// Set sets a single key/value pair. Existing keys will be overwritten.
Expand Down
75 changes: 75 additions & 0 deletions pkg/snclient/config_env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package snclient

import (
"fmt"
"os"
"strings"
)

const (
envMacroStart = "$ENV{"
envMacroEscape = "$$ENV{"
)

// ReplaceEnvMacros replaces $ENV{NAME} with the value of the environment variable NAME.
// The substituted value is not expanded again. $$ENV{NAME} is replaced with a literal $ENV{NAME}.
func ReplaceEnvMacros(value string) (string, error) {
var result strings.Builder
result.Grow(len(value))

var firstErr error
for pos := 0; pos < len(value); {
switch {
case strings.HasPrefix(value[pos:], envMacroEscape):
// add it to the result literally, and then move the index after it
result.WriteString(envMacroStart)
pos += len(envMacroEscape)
case strings.HasPrefix(value[pos:], envMacroStart):
closingBracePos := strings.IndexByte(value[pos+len(envMacroStart):], '}')
if closingBracePos == -1 {
// no closing brace, keep the rest as is
result.WriteString(value[pos:])

pos = len(value)

continue
}
closingBracePos += pos + len(envMacroStart)

nameStartPos := pos + len(envMacroStart)
name := strings.TrimSpace(value[nameStartPos:closingBracePos])

if name == "" {
if firstErr == nil {
firstErr = fmt.Errorf("empty environment variable name")
}
result.WriteString(value[pos : closingBracePos+1])

pos = closingBracePos + 1

continue
}

envValue, ok := os.LookupEnv(name)
if !ok {
if firstErr == nil {
firstErr = fmt.Errorf("environment variable %q is not set", name)
}
result.WriteString(value[pos : closingBracePos+1])

pos = closingBracePos + 1

continue
}
result.WriteString(envValue)

pos = closingBracePos + 1
default:
result.WriteByte(value[pos])

pos++
}
}

return result.String(), firstErr
}
Loading
Loading