From 083570968ea6a1fa7cd079152645faafb9164a3d Mon Sep 17 00:00:00 2001 From: lorenzg Date: Tue, 20 Oct 2026 16:53:47 +0200 Subject: [PATCH 1/4] support multiline external commands and check_multi config --- pkg/snclient/check_multi_test.go | 20 +++++++++++++ pkg/snclient/config.go | 17 +++++++++-- pkg/snclient/config_test.go | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/pkg/snclient/check_multi_test.go b/pkg/snclient/check_multi_test.go index efcc5834..5f2ec623 100644 --- a/pkg/snclient/check_multi_test.go +++ b/pkg/snclient/check_multi_test.go @@ -11,6 +11,26 @@ import ( "github.com/stretchr/testify/require" ) +func TestCheckMultiLineContinuation(t *testing.T) { + config := ` +[/settings/check/multi/multiline] +command[ok] = check_dummy \ + 0 \ + 'multiline ok' +command[warning] = check_dummy \ + 1 \ + 'multiline warning' +` + snc := StartTestAgent(t, config) + defer StopTestAgent(t, snc) + + res := snc.RunCheck("check_multi", []string{"config=multiline"}) + assert.Equal(t, CheckExitWarning, res.State) + assert.Contains(t, res.Output, "2 plugins checked: 1 ok, 1 warning, 0 critical, 0 unknown") + assert.Contains(t, res.Details, "[ok] multiline ok") + assert.Contains(t, res.Details, "[warning] multiline warning") +} + func TestCheckMultiInline(t *testing.T) { config := ` [/modules] diff --git a/pkg/snclient/config.go b/pkg/snclient/config.go index 0d96075a..6aa52318 100644 --- a/pkg/snclient/config.go +++ b/pkg/snclient/config.go @@ -282,9 +282,9 @@ func (config *Config) ParseINI(configData, iniPath string, snc *Agent) error { lines = lines[:len(lines)-1] } - for x, line := range lines { - lineNr := x + 1 - line = strings.TrimSpace(line) + for lineIndex := 0; lineIndex < len(lines); lineIndex++ { + lineNr := lineIndex + 1 + line := strings.TrimSpace(lines[lineIndex]) if line == "" || line[0] == ';' || line[0] == '#' { currentComments = append(currentComments, line) @@ -337,6 +337,17 @@ func (config *Config) ParseINI(configData, iniPath string, snc *Agent) error { continue } val[0] = strings.TrimSpace(val[0]) + + // Require whitespace before continuation to preserve trailing Windows path separators. + for strings.HasSuffix(val[1], " \\") || strings.HasSuffix(val[1], "\t\\") { + if lineIndex+1 == len(lines) || strings.TrimSpace(lines[lineIndex+1]) == "" { + parseErrors = append(parseErrors, fmt.Errorf("config error in %s:%d: unfinished line continuation", iniPath, lineNr)) + + break + } + lineIndex++ + val[1] = strings.TrimSpace(strings.TrimSuffix(val[1], "\\")) + " " + strings.TrimSpace(lines[lineIndex]) + } val[1] = strings.TrimSpace(val[1]) // silently skip UNKNOWN values which were placeholder in nsclient diff --git a/pkg/snclient/config_test.go b/pkg/snclient/config_test.go index 56800c84..1a103608 100644 --- a/pkg/snclient/config_test.go +++ b/pkg/snclient/config_test.go @@ -61,6 +61,57 @@ test4 += c assert.Equalf(t, expData, cfg.Section("/test").data, "config parsed") } +func TestConfigLineContinuation(t *testing.T) { + configText := `[/settings/external scripts/scripts] +check_ext = snclient run check_dummy \ + 0 \ + $ARGS$ +[/test] +quoted = "hello \ + world" +path = C:\scripts\ +next = unchanged +append = first +append += \ + second +` + for _, newline := range []string{"\n", "\r\n"} { + t.Run(fmt.Sprintf("newline=%q", newline), func(t *testing.T) { + cfg := NewConfig(false) + err := cfg.ParseINI(strings.ReplaceAll(configText, "\n", newline), "testfile.ini", nil) + require.NoError(t, err) + command, _ := cfg.Section("/settings/external scripts/scripts").GetString("check_ext") + assert.Equal(t, "snclient run check_dummy 0 $ARGS$", command) + assert.Equal(t, ConfigData{ + "quoted": "hello world", + "path": `C:\scripts\`, + "next": "unchanged", + "append": "firstsecond", + }, cfg.Section("/test").data) + }) + } +} + +func TestConfigLineContinuationErrors(t *testing.T) { + for _, test := range []struct { + name string + config string + error string + }{ + {"unfinished", "[/test]\nkey = value \\", "testfile.ini:2: unfinished line continuation"}, + {"blank line", "[/test]\nkey = value \\\n\nnext = value\n", "testfile.ini:2: unfinished line continuation"}, + {"whitespace line", "[/test]\r\nkey = value \\\r\n \t\r\nnext = value\r\n", "testfile.ini:2: unfinished line continuation"}, + {"trailing blank line", "[/test]\nkey = value \\\n\n", "testfile.ini:2: unfinished line continuation"}, + {"line number", "[/test]\nkey = value \\\n more\nbad\n", "testfile.ini:4: found key without '='"}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := NewConfig(false) + err := cfg.ParseINI(test.config, "testfile.ini", nil) + require.ErrorContains(t, err, test.error) + }) + } +} + func TestConfigErrorI(t *testing.T) { configText := ` [/test] From d5b7e5599e5aa83eb7e845637872daa40dd60f6f Mon Sep 17 00:00:00 2001 From: lorenzg Date: Tue, 20 Oct 2026 17:36:50 +0200 Subject: [PATCH 2/4] add docs, changelog --- Changes | 1 + docs/checks/external_commands/_index.md | 16 +++++++++++++++ docs/configuration/_index.md | 26 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/Changes b/Changes index 9d26b559..e6b07ea5 100644 --- a/Changes +++ b/Changes @@ -1,6 +1,7 @@ This file documents the revision history for the SNClient agent. 1.00 Fri Oct 16 16:47:03 CEST 2026 + - add backslash line continuation for configuration values, including external commands and check_multi - add check_multi: execute multiple subchecks and evaluate the overall result - check_dns: resend lost packets and use the timeout and attempts settings of the resolv.conf - fix remaining zombie processes after automatic updates diff --git a/docs/checks/external_commands/_index.md b/docs/checks/external_commands/_index.md index 9ce0aded..f47f8ff3 100644 --- a/docs/checks/external_commands/_index.md +++ b/docs/checks/external_commands/_index.md @@ -44,6 +44,22 @@ Both formats achieve the same outcome by adding two new commands, `my_check1` an the `check_custom.bat` script. Usually you use the short format, but if you want to provide individual options to a command, the long format is the way to go. +### Multiline Commands + +Long commands can be split across lines using a space or tab followed by `\` at +the end of each continued line. For example: + +```ini +[/settings/external scripts/scripts] +check_myscript = ${scripts}/myscript.sh \ + --arg1 foo \ + --arg2 bar +``` + +Run `snclient run check_myscript`. +The lines are joined with a single space before the command is parsed. +See [multiline values](../../configuration/#multiline-values) for the syntax rules. + ### Handling Script Arguments You can manage script arguments in two ways: embedding them directly into the command or allowing for argument pass-through. diff --git a/docs/configuration/_index.md b/docs/configuration/_index.md index a562330b..23b1455d 100644 --- a/docs/configuration/_index.md +++ b/docs/configuration/_index.md @@ -100,6 +100,32 @@ check_also_keep = "part 1" something else "part 2" check_wrong = "unclosed quotes ``` +### Multiline Values + +End a line with a space or tab followed by `\` to continue its value on the next +line. The backslash and surrounding whitespace, including indentation on the next +line, are replaced with a single space. This works for configuration values in +all sections, including external commands and `check_multi` commands. + +For example, check the size of a backup folder: + +```ini +[/settings/check/multi/backups] +command[size] = check_files \ + "path=/srv/backups" \ + "warn=total_size > 10GiB" +``` + +Run `snclient run check_multi config=backups`. + +A continuation must be followed immediately by a nonempty line. A blank or +whitespace-only line, or the end of the file, causes an `unfinished line +continuation` error. Windows paths such as `C:\scripts\` remain unchanged because +the final backslash is not preceded by a space or tab. + +See [external commands](../checks/external_commands/#multiline-commands) for an +external command example. + ### Appending Values You may use the `+=` operator to append to existing values and write From 335bbf6242443b66093c8f11eaf6cf19e3136a39 Mon Sep 17 00:00:00 2001 From: lorenzg Date: Tue, 20 Oct 2026 17:47:45 +0200 Subject: [PATCH 3/4] fix docs for verbose external scripts --- docs/checks/external_commands/_index.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/checks/external_commands/_index.md b/docs/checks/external_commands/_index.md index f47f8ff3..45d88aec 100644 --- a/docs/checks/external_commands/_index.md +++ b/docs/checks/external_commands/_index.md @@ -25,7 +25,7 @@ You can add your custom scripts to SNClient using either a concise or verbose fo **Concise Format**: ```ini -[/settings/external scripts] +[/settings/external scripts/scripts] my_check1 = check_custom.bat my_check2 = myscripts\check_custom.bat ``` @@ -34,16 +34,21 @@ my_check2 = myscripts\check_custom.bat ```ini [/settings/external scripts/scripts/my_check1] -my_check1 = check_custom.bat +command = check_custom.bat [/settings/external scripts/scripts/my_check2] -my_check2 = myscripts\check_custom.bat +command = myscripts\check_custom.bat ``` Both formats achieve the same outcome by adding two new commands, `my_check1` and `my_check2`, which execute the `check_custom.bat` script. Usually you use the short format, but if you want to provide individual options to a command, the long format is the way to go. +In the concise format, the key is the command name. In the verbose format, the last +part of the section path is the command name and the `command` key specifies what +to execute. Settings such as `timeout` and `allow arguments` can be set in that +section to override the global external script settings for that command. + ### Multiline Commands Long commands can be split across lines using a space or tab followed by `\` at From 508ddebb05be3bcd661c5b2166b6b17d6fce21fe Mon Sep 17 00:00:00 2001 From: lorenzg Date: Tue, 20 Oct 2026 17:53:14 +0200 Subject: [PATCH 4/4] remove snclient run examples --- docs/checks/external_commands/_index.md | 1 - docs/configuration/_index.md | 2 -- 2 files changed, 3 deletions(-) diff --git a/docs/checks/external_commands/_index.md b/docs/checks/external_commands/_index.md index 45d88aec..e937ffaa 100644 --- a/docs/checks/external_commands/_index.md +++ b/docs/checks/external_commands/_index.md @@ -61,7 +61,6 @@ check_myscript = ${scripts}/myscript.sh \ --arg2 bar ``` -Run `snclient run check_myscript`. The lines are joined with a single space before the command is parsed. See [multiline values](../../configuration/#multiline-values) for the syntax rules. diff --git a/docs/configuration/_index.md b/docs/configuration/_index.md index 23b1455d..9f646e88 100644 --- a/docs/configuration/_index.md +++ b/docs/configuration/_index.md @@ -116,8 +116,6 @@ command[size] = check_files \ "warn=total_size > 10GiB" ``` -Run `snclient run check_multi config=backups`. - A continuation must be followed immediately by a nonempty line. A blank or whitespace-only line, or the end of the file, causes an `unfinished line continuation` error. Windows paths such as `C:\scripts\` remain unchanged because