diff --git a/README.md b/README.md index 3bce923..3b3e78b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ It is designed to run close to osquery as a small operational companion: it can - 🔎 Verify local flags, certificates, and secrets against the server. - ⚡ Run as a daemon with a configurable sync interval and jitter. - 🔁 Restart osquery when managed files or extensions change. -- 🧩 Deploy osquery extensions from osctrl extension manifests. +- 🧩 Deploy osquery extensions from osctrl extension manifests, when the osctrl server serves them. - ⚙️ Use YAML or JSON configuration files, CLI flags, or environment variables. - 📋 Emit human-readable or JSON structured logs. - 🖥️ Run on Linux, macOS, and Windows. @@ -118,7 +118,7 @@ osctrld: osquerySecretFile: "/path/to/osquery.secret" osqueryFlagFile: "/path/to/osquery.flags" osqueryCertFile: "/path/to/osquery.crt" - environment: "environment_name_or_UUID" + environment: "osctrl_environment_UUID" baseurl: "https://osctrl.url" insecure: false verbose: false @@ -141,7 +141,7 @@ JSON configuration files are also supported. Use a `.json` extension and osctrld | `enrollScript` | Path to the enroll script | OS-dependent | | `removeScript` | Path to the remove script | OS-dependent | | `osquery` | Path to the osquery installation directory | OS-dependent | -| `environment` | osctrl environment name or UUID | Required | +| `environment` | UUID of the osctrl environment. osctrl rejects environment names on the osctrld endpoints | Required | | `baseurl` | Base URL of the osctrl server | Required | | `insecure` | Ignore TLS certificate warnings | `false` | | `verbose` | Enable debug logging | `false` | @@ -235,7 +235,7 @@ install is refused unless you pass `--allow-unverified`. | `--allow-unverified` | `OSCTRL_ALLOW_UNVERIFIED` | Install without verifying the package | ```bash -sudo osctrld --secret --environment dev --osctrl-url https://osctrl.example.com \ +sudo osctrld --secret --environment --osctrl-url https://osctrl.example.com \ --osquery-sha256 install ``` @@ -297,7 +297,7 @@ go tool cover -func=coverage.out ```text --configuration FILE, -c FILE Configuration file for osctrld --secret value, -s value osctrl enrollment secret ---environment value, -e value osctrl environment name or UUID +--environment value, -e value UUID of the osctrl environment --secret-file FILE, -S FILE Local osquery enrollment secret file --flagfile FILE, -F FILE Local osquery flags file --certificate FILE, -C FILE Local osquery TLS certificate file diff --git a/cmd/osctrld/actions_helpers.go b/cmd/osctrld/actions_helpers.go index 26cd863..bf23a60 100644 --- a/cmd/osctrld/actions_helpers.go +++ b/cmd/osctrld/actions_helpers.go @@ -35,6 +35,17 @@ func retrieveFlags(osctrlSecret, osquerySecretFile, osqueryCertFile string) (str return strings.TrimSpace(string(body)), nil } +// httpStatusError is a non-200 answer from osctrl, kept typed so a caller can +// react to a specific status instead of parsing the message +type httpStatusError struct { + code int + body []byte +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("HTTP %d - Response: %s", e.code, string(e.body)) +} + // Helper function to retrieve from server func genericRetrieve(url string, insecure bool, data any) ([]byte, error) { jsonReq, err := json.Marshal(data) @@ -47,7 +58,7 @@ func genericRetrieve(url string, insecure bool, data any) ([]byte, error) { return []byte{}, fmt.Errorf("error sending request - %v", err) } if code != http.StatusOK { - return []byte{}, fmt.Errorf("HTTP %d - Response: %s", code, string(body)) + return []byte{}, &httpStatusError{code: code, body: body} } return body, nil } diff --git a/cmd/osctrld/config.go b/cmd/osctrld/config.go index 07c93eb..0714850 100644 --- a/cmd/osctrld/config.go +++ b/cmd/osctrld/config.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "net/url" + "regexp" "runtime" "strings" @@ -90,7 +91,7 @@ func defaultConfiguration() Configuration { EnrollScript: "/path/to/osctrld-enroll.sh", RemoveScript: "/path/to/osctrld-remove.sh", OsqueryPath: "/path/to/osquery/", - Environment: "environment_name_or_UUID", + Environment: "osctrl_environment_UUID", BaseURL: "https://osctrl.url", Insecure: false, Verbose: false, @@ -126,7 +127,7 @@ func buildConfigFlags() []cli.Flag { Name: "environment", Aliases: []string{"e", "env"}, Value: defEmptyValue, - Usage: "Environment in osctrl to enrolled nodes to", + Usage: "UUID of the osctrl environment to enroll nodes in", Sources: cli.EnvVars("OSCTRL_ENV"), Destination: &appConfig.Environment, }, @@ -290,6 +291,10 @@ func applyOsqueryPathDefaults(cfg *Configuration, defaultPath, scriptExtension s } } +// envUUIDPattern is the only environment form osctrl's osctrld endpoints accept; +// they look the environment up by UUID and answer HTTP 400 to anything else +var envUUIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + func validateConfiguration(cfg Configuration) error { var problems []string if cfg.OsctrlSecret == defEmptyValue { @@ -297,6 +302,8 @@ func validateConfiguration(cfg Configuration) error { } if cfg.Environment == defEmptyValue { problems = append(problems, "environment is required") + } else if !envUUIDPattern.MatchString(cfg.Environment) { + problems = append(problems, "environment must be the osctrl environment UUID, not its name") } if cfg.BaseURL == defEmptyValue { problems = append(problems, "baseurl is required") diff --git a/cmd/osctrld/config_test.go b/cmd/osctrld/config_test.go index 909f958..9ec8488 100644 --- a/cmd/osctrld/config_test.go +++ b/cmd/osctrld/config_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" ) @@ -124,7 +125,7 @@ func TestBuildConfigFlagsIncludesConfigurationDefaults(t *testing.T) { func TestValidateConfigurationAcceptsValidConfig(t *testing.T) { cfg := Configuration{ OsctrlSecret: "test-secret", - Environment: "dev", + Environment: "11111111-1111-4111-8111-111111111111", BaseURL: "https://localhost:9000", LogFormat: "json", Interval: 30, @@ -154,7 +155,7 @@ func TestValidateConfigurationRejectsMissingRequiredFields(t *testing.T) { func TestValidateConfigurationRejectsInvalidValues(t *testing.T) { cfg := Configuration{ OsctrlSecret: "test-secret", - Environment: "dev", + Environment: "11111111-1111-4111-8111-111111111111", BaseURL: "ftp://localhost", LogFormat: "xml", Interval: 0, @@ -185,7 +186,7 @@ func TestCheckConfigCommandValidatesConfigurationFile(t *testing.T) { configPath := filepath.Join(dir, "osctrld.yaml") configData := []byte(`osctrld: osctrlSecret: "test-secret" - environment: "dev" + environment: "11111111-1111-4111-8111-111111111111" baseurl: "https://localhost:9000" logFormat: "text" interval: 60 @@ -203,3 +204,31 @@ func TestCheckConfigCommandValidatesConfigurationFile(t *testing.T) { assert.NoError(t, err) assert.Contains(t, output.String(), "configuration is valid") } + +// osctrl's osctrld endpoints look the environment up by UUID and answer a bare +// HTTP 400 to anything else, so a name must be caught here with a clear message. +func TestValidateConfigurationRequiresEnvironmentUUID(t *testing.T) { + cases := []struct { + name string + env string + valid bool + }{ + {"canonical uuid", "11111111-1111-4111-8111-111111111111", true}, + {"uppercase uuid", "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D", true}, + {"environment name", "dev", false}, + {"uuid without dashes", "11111111111141118111111111111111", false}, + {"uuid with trailing text", "11111111-1111-4111-8111-111111111111x", false}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + cfg := Configuration{OsctrlSecret: "s", Environment: tt.env, BaseURL: "https://osctrl.example", LogFormat: defLogFormat, Interval: defInterval} + err := validateConfiguration(cfg) + if tt.valid { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), "environment must be the osctrl environment UUID") + }) + } +} diff --git a/cmd/osctrld/extensions.go b/cmd/osctrld/extensions.go index 806caf1..f0260a8 100644 --- a/cmd/osctrld/extensions.go +++ b/cmd/osctrld/extensions.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "net/http" "os" @@ -13,6 +14,14 @@ import ( func retrieveExtensionManifest(osctrlSecret, url string, insecure bool) ([]ExtensionEntry, error) { reqData := ExtensionsRequest{OsctrlSecret: osctrlSecret} body, err := genericRetrieve(url, insecure, reqData) + // osctrl does not serve osctrld-extensions yet. Treat its 404 as an empty + // manifest so the daemon does not log a failure every cycle; a wrong URL or + // environment still fails loudly earlier in the cycle, on flags and cert. + var statusErr *httpStatusError + if errors.As(err, &statusErr) && statusErr.code == http.StatusNotFound { + log.Debug().Msg("osctrl server does not serve extensions, skipping") + return nil, nil + } if err != nil { return nil, fmt.Errorf("error retrieving extension manifest - %v", err) } diff --git a/cmd/osctrld/extensions_test.go b/cmd/osctrld/extensions_test.go index 58d8198..f466bd0 100644 --- a/cmd/osctrld/extensions_test.go +++ b/cmd/osctrld/extensions_test.go @@ -138,3 +138,29 @@ func TestSyncExtensions_EmptyManifest(t *testing.T) { assert.NoError(t, err) assert.False(t, changed) } + +// osctrl does not serve osctrld-extensions yet, so the daemon must treat a 404 +// as an empty manifest rather than log a sync failure on every cycle. +func TestRetrieveExtensionManifest_NotFoundIsEmpty(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + entries, err := retrieveExtensionManifest("test-secret", server.URL, false) + assert.NoError(t, err) + assert.Empty(t, entries) +} + +func TestSyncExtensions_ServerWithoutExtensionsEndpoint(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + prevConfig, prevURLs := appConfig, osctrlURLs + t.Cleanup(func() { appConfig, osctrlURLs = prevConfig, prevURLs }) + appConfig.OsctrlSecret = "test-secret" + appConfig.ExtensionsDir = t.TempDir() + osctrlURLs.Extensions = server.URL + + changed, err := syncExtensions() + assert.NoError(t, err) + assert.False(t, changed) +} diff --git a/cmd/osctrld/install.go b/cmd/osctrld/install.go index d8daaae..7db1d70 100644 --- a/cmd/osctrld/install.go +++ b/cmd/osctrld/install.go @@ -2,10 +2,12 @@ package main import ( "context" + "errors" "fmt" "os" "path/filepath" "runtime" + "strings" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" @@ -53,6 +55,15 @@ func ensureOsquery(v VerifyResponse) error { path, err := spin("downloading osquery "+v.OsqueryVersion, func() (string, error) { return downloadPackage(url, digest, appConfig.AllowUnverified, appConfig.Insecure) }) + if errors.Is(err, errDigestMismatch) { + // osctrl serves one server-wide digest, but osquery ships a package per + // format and architecture, so say where the expected digest came from + source := "--osquery-sha256" + if v.OsquerySHA256 != "" { + source = "the osctrl server (osquery_sha256), which overrides --osquery-sha256 and only fits one package format and architecture" + } + return fmt.Errorf("%w; the expected digest came from %s", err, source) + } if err != nil { return err } @@ -96,7 +107,7 @@ func installNode(ctx context.Context, cmd *cli.Command) error { if err := os.MkdirAll(filepath.Dir(appConfig.OsqueryFlagFile), 0755); err != nil { return fmt.Errorf("error creating the flags directory - %v", err) } - if _, err := writeContentExists(appConfig.OsqueryFlagFile, verification.Flags, "flags", true, 0700); err != nil { + if _, err := writeContentExists(appConfig.OsqueryFlagFile, strings.TrimSpace(verification.Flags), "flags", true, 0700); err != nil { return err } log.Info().Str("path", appConfig.OsqueryFlagFile).Msg("flags ready") @@ -104,7 +115,7 @@ func installNode(ctx context.Context, cmd *cli.Command) error { if err := os.MkdirAll(filepath.Dir(appConfig.OsqueryCertFile), 0755); err != nil { return fmt.Errorf("error creating the certificate directory - %v", err) } - if _, err := writeContentExists(appConfig.OsqueryCertFile, verification.Certificate, "cert", true, 0700); err != nil { + if _, err := writeContentExists(appConfig.OsqueryCertFile, strings.TrimSpace(verification.Certificate), "cert", true, 0700); err != nil { return err } log.Info().Str("path", appConfig.OsqueryCertFile).Msg("certificate ready") diff --git a/cmd/osctrld/install_test.go b/cmd/osctrld/install_test.go index 5910802..093c6a4 100644 --- a/cmd/osctrld/install_test.go +++ b/cmd/osctrld/install_test.go @@ -4,6 +4,11 @@ package main import ( "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -102,3 +107,89 @@ func TestUninstallNodeRemovesTheThreeFiles(t *testing.T) { assert.NoFileExists(t, appConfig.OsqueryFlagFile) assert.NoFileExists(t, appConfig.OsqueryCertFile) } + +// saveInstallGlobals restores every package global these install tests mutate +func saveInstallGlobals(t *testing.T) { + t.Helper() + prevConfig, prevURLs, prevUID, prevReader := appConfig, osctrlURLs, geteuid, osqueryVersionReader + t.Cleanup(func() { + appConfig, osctrlURLs, geteuid, osqueryVersionReader = prevConfig, prevURLs, prevUID, prevReader + }) +} + +// osctrl's verify response carries one server-wide osquery_sha256, and osquery +// ships a different package per format and architecture, so a mismatch has to +// say where the expected digest came from or the operator cannot tell a wrong +// server digest from a wrong --osquery-sha256. +func TestEnsureOsquerySaysWhereTheExpectedDigestCameFrom(t *testing.T) { + payload := []byte("pretend osquery package") + good := fmt.Sprintf("%x", sha256.Sum256(payload)) + wrong := "00" + good[2:] + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(payload) + })) + defer server.Close() + + cases := []struct { + name string + serverDigest string + configDigest string + source string + }{ + {"server digest overrides the node's own", wrong, good, "osctrl server"}, + {"node digest when the server sends none", "", wrong, "--osquery-sha256"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + saveInstallGlobals(t) + recorded := fakeExec(t) + osqueryVersionReader = func() string { return "" } + appConfig.OsqueryPackage = server.URL + "/osquery-5.23.1.pkg" + appConfig.OsquerySHA256 = tt.configDigest + + err := ensureOsquery(VerifyResponse{OsqueryVersion: "5.23.1", OsquerySHA256: tt.serverDigest}) + require.Error(t, err) + assert.Contains(t, err.Error(), "mismatch") + assert.Contains(t, err.Error(), tt.source) + assert.Empty(t, *recorded, "a mismatched package must never reach the installer") + }) + } +} + +// osctrl serves the operator-edited environment flags, which end in a newline. +// install must write them exactly as the flags and cert commands do (trimmed), +// so all three writers leave byte-identical files. +func TestInstallNodeWritesTrimmedFlagsAndCert(t *testing.T) { + saveInstallGlobals(t) + fakeExec(t) + fakeLookPath(t, "systemctl") + geteuid = func() int { return 0 } + osqueryVersionReader = func() string { return "5.23.1" } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(VerifyResponse{ + Flags: "--host_identifier=uuid\n--tls_hostname=osctrl.example\n", + Certificate: "-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----\n", + OsqueryVersion: "5.23.1", + }) + })) + defer server.Close() + + dir := t.TempDir() + appConfig = Configuration{ + OsctrlSecret: "test-secret", + OsquerySecretFile: filepath.Join(dir, "osquery.secret"), + OsqueryFlagFile: filepath.Join(dir, "osquery.flags"), + OsqueryCertFile: filepath.Join(dir, "certs", "osctrl.crt"), + } + osctrlURLs.Verify = server.URL + + require.NoError(t, installNode(context.Background(), newTestCLICommand())) + + flags, err := os.ReadFile(appConfig.OsqueryFlagFile) + require.NoError(t, err) + assert.Equal(t, "--host_identifier=uuid\n--tls_hostname=osctrl.example", string(flags)) + cert, err := os.ReadFile(appConfig.OsqueryCertFile) + require.NoError(t, err) + assert.Equal(t, "-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----", string(cert)) +} diff --git a/cmd/osctrld/main_test.go b/cmd/osctrld/main_test.go index 783cda3..98ba9dd 100644 --- a/cmd/osctrld/main_test.go +++ b/cmd/osctrld/main_test.go @@ -15,7 +15,7 @@ import ( func TestForceFlagDoesNotAffectVerbose(t *testing.T) { appConfig = Configuration{} app := buildApp() - err := app.Run(context.Background(), []string{"osctrld", "--force", "--secret", "test-secret", "--environment", "dev", "--osctrl-url", "http://localhost", "flags"}) + err := app.Run(context.Background(), []string{"osctrld", "--force", "--secret", "test-secret", "--environment", "11111111-1111-4111-8111-111111111111", "--osctrl-url", "http://localhost", "flags"}) _ = err assert.True(t, appConfig.Force, "Force should be true when --force flag is set") assert.False(t, appConfig.Verbose, "Verbose should be false when only --force flag is set") @@ -24,7 +24,7 @@ func TestForceFlagDoesNotAffectVerbose(t *testing.T) { func TestVerboseFlagDoesNotAffectForce(t *testing.T) { appConfig = Configuration{} app := buildApp() - err := app.Run(context.Background(), []string{"osctrld", "--verbose", "--secret", "test-secret", "--environment", "dev", "--osctrl-url", "http://localhost", "flags"}) + err := app.Run(context.Background(), []string{"osctrld", "--verbose", "--secret", "test-secret", "--environment", "11111111-1111-4111-8111-111111111111", "--osctrl-url", "http://localhost", "flags"}) _ = err assert.True(t, appConfig.Verbose, "Verbose should be true when --verbose flag is set") assert.False(t, appConfig.Force, "Force should be false when only --verbose flag is set") diff --git a/cmd/osctrld/packages.go b/cmd/osctrld/packages.go index 2bd2368..7bdf671 100644 --- a/cmd/osctrld/packages.go +++ b/cmd/osctrld/packages.go @@ -2,6 +2,7 @@ package main import ( "crypto/sha256" + "errors" "fmt" "net/http" "net/url" @@ -83,6 +84,9 @@ func packageFormatFor(goos, root string) (packageFormat, error) { return "", fmt.Errorf("installing osquery is not supported on %s", goos) } +// errDigestMismatch marks a downloaded package whose SHA-256 is not the expected one +var errDigestMismatch = errors.New("package digest mismatch") + // resolveDigest picks the expected package digest. The server's value wins so a // future osctrl release can turn verification on for every node at once; the // configured value is the fallback until then. An empty result means there is @@ -126,7 +130,7 @@ func downloadPackage(url, digest string, allowUnverified, insecure bool) (string if digest == "" { log.Warn().Str("url", url).Str("sha256", sum).Msg("installing an unverified osquery package") } else if !strings.EqualFold(sum, digest) { - return "", fmt.Errorf("package digest mismatch for %s - expected %s, got %s", url, digest, sum) + return "", fmt.Errorf("%w for %s - expected %s, got %s", errDigestMismatch, url, digest, sum) } f, err := os.CreateTemp("", "osquery-package-*"+packageURLExt(url)) if err != nil { diff --git a/service/osctrld-sample.yaml b/service/osctrld-sample.yaml index 11c3d5a..9717f63 100644 --- a/service/osctrld-sample.yaml +++ b/service/osctrld-sample.yaml @@ -5,7 +5,7 @@ osctrld: osquerySecretFile: "/path/to/osquery.secret" osqueryFlagFile: "/path/to/osquery.flags" osqueryCertFile: "/path/to/osquery.crt" - environment: "environment_name_or_UUID" + environment: "osctrl_environment_UUID" baseurl: "https://osctrl.url" insecure: false verbose: false diff --git a/tests/osctrld-test.json b/tests/osctrld-test.json index 16cf4af..bf2eeb4 100644 --- a/tests/osctrld-test.json +++ b/tests/osctrld-test.json @@ -4,7 +4,7 @@ "osquerySecretFile": "/Users/javier/Github/osctrl/tmp/osctrl.secret", "osqueryFlagFile": "/Users/javier/Github/osctrl/tmp/osctrl-uuid.flags", "osqueryCertFile": "/Users/javier/Github/osctrl/osctrl.crt", - "environment": "dev", + "environment": "11111111-1111-4111-8111-111111111111", "baseurl": "https://localhost:9000", "insecure": true, "verbose": true, diff --git a/tests/osctrld-test.yaml b/tests/osctrld-test.yaml index e03d141..12e57ab 100644 --- a/tests/osctrld-test.yaml +++ b/tests/osctrld-test.yaml @@ -3,7 +3,7 @@ osctrld: osquerySecretFile: "/Users/javier/Github/osctrl/tmp/osctrl.secret" osqueryFlagFile: "/Users/javier/Github/osctrl/tmp/osctrl-uuid.flags" osqueryCertFile: "/Users/javier/Github/osctrl/osctrl.crt" - environment: "dev" + environment: "11111111-1111-4111-8111-111111111111" baseurl: "https://localhost:9000" insecure: true verbose: true