Skip to content
Merged
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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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` |
Expand Down Expand Up @@ -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 <secret> --environment dev --osctrl-url https://osctrl.example.com \
sudo osctrld --secret <secret> --environment <environment-uuid> --osctrl-url https://osctrl.example.com \
--osquery-sha256 <sha256-of-the-package> install
```

Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion cmd/osctrld/actions_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
11 changes: 9 additions & 2 deletions cmd/osctrld/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"net/url"
"regexp"
"runtime"
"strings"

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -290,13 +291,19 @@ 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 {
problems = append(problems, "osctrlSecret is required")
}
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")
Expand Down
35 changes: 32 additions & 3 deletions cmd/osctrld/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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")
})
}
}
9 changes: 9 additions & 0 deletions cmd/osctrld/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
Expand All @@ -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)
}
Expand Down
26 changes: 26 additions & 0 deletions cmd/osctrld/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
15 changes: 13 additions & 2 deletions cmd/osctrld/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -96,15 +107,15 @@ 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")

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")
Expand Down
91 changes: 91 additions & 0 deletions cmd/osctrld/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ package main

import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -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))
}
Loading
Loading