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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ COMMANDS:
flags Retrieve flags for osquery from osctrl and write them locally
cert Retrieve server certificate for osquery from osctrl and write it locally
service Run as a daemon, periodically syncing flags and certificate
install Enroll this node natively: install osquery if needed, write secret, flags and certificate, and start the service
uninstall Remove this node from osctrl natively: stop the service and delete secret, flags and certificate. osquery itself is left installed
check-config Validate configuration and exit
default-config Print a default YAML configuration
```
Expand Down Expand Up @@ -211,6 +213,32 @@ In daemon mode, osctrld will:
3. Restart osquery through the OS service manager when changes are detected.
4. Shut down gracefully on `SIGINT` or `SIGTERM`.

## Native enrollment

`osctrld install` does everything the osctrl quick-add script does, without a shell:
it installs osquery when it is missing or out of date, writes the secret, flags and
certificate, and starts and enables the service. `osctrld uninstall` reverses the
configuration — it stops the service and deletes those three files, and deliberately
leaves osquery itself installed.

Both commands require root on Linux and macOS, and Administrator on Windows. osctrld
never calls `sudo` itself; run it under sudo.

The osquery package is verified before installation. The SHA-256 comes from the osctrl
server when it provides one, otherwise from `--osquery-sha256`. Without either, the
install is refused unless you pass `--allow-unverified`.

| Flag | Environment variable | Purpose |
| --- | --- | --- |
| `--osquery-sha256` | `OSQUERY_SHA256` | Expected SHA-256 of the osquery package |
| `--osquery-package` | `OSQUERY_PACKAGE` | Override the package URL, for mirrors and air-gapped installs |
| `--allow-unverified` | `OSCTRL_ALLOW_UNVERIFIED` | Install without verifying the package |

```bash
sudo osctrld --secret <secret> --environment dev --osctrl-url https://osctrl.example.com \
--osquery-sha256 <sha256-of-the-package> install
```

## 🚢 Deployment

### 🐧 Linux systemd
Expand Down
5 changes: 3 additions & 2 deletions cmd/osctrld/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type VerifyResponse struct {
Flags string `json:"flags"`
Certificate string `json:"certificate"`
OsqueryVersion string `json:"osquery_version"`
OsquerySHA256 string `json:"osquery_sha256"`
}

// ExtensionEntry represents a single extension from the manifest
Expand Down Expand Up @@ -92,7 +93,7 @@ func getFlags(ctx context.Context, cmd *cli.Command) (bool, error) {
return false, fmt.Errorf("error retrieving flags - %v", err)
}
log.Debug().Str("flags", flags).Msg("flags content")
changed, err := writeContentExists(appConfig.OsqueryFlagFile, flags, "flags", appConfig.Force)
changed, err := writeContentExists(appConfig.OsqueryFlagFile, flags, "flags", appConfig.Force, 0700)
if err != nil {
return false, err
}
Expand All @@ -110,7 +111,7 @@ func getCert(ctx context.Context, cmd *cli.Command) (bool, error) {
return false, fmt.Errorf("error retrieving cert - %v", err)
}
log.Debug().Str("cert", cert).Msg("cert content")
changed, err := writeContentExists(appConfig.OsqueryCertFile, cert, "cert", appConfig.Force)
changed, err := writeContentExists(appConfig.OsqueryCertFile, cert, "cert", appConfig.Force, 0700)
if err != nil {
return false, err
}
Expand Down
25 changes: 21 additions & 4 deletions cmd/osctrld/actions_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,26 +106,43 @@ func checkFileContent(path, content string) bool {
return (strings.TrimSpace(string(fContent)) == content)
}

// Helper function to write content to a file if not different from existing
func writeContentExists(path, content, name string, force bool) (bool, error) {
// Helper function to write content to a file if not different from existing.
// os.WriteFile's mode argument only takes effect when it creates the file: an
// existing file keeps whatever mode it already had, even when we overwrite its
// content. So every successful return path chmods explicitly, to guarantee the
// file is at mode regardless of whether it was created, overwritten, or left
// alone because the content already matched.
func writeContentExists(path, content, name string, force bool, mode os.FileMode) (bool, error) {
if checkFileExist(path) {
if !checkFileContent(path, content) {
if force {
if err := os.WriteFile(path, []byte(content), 0700); err != nil {
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return false, fmt.Errorf("error overwriting %s to %s - %v", name, path, err)
}
if err := os.Chmod(path, mode); err != nil {
return false, fmt.Errorf("error setting mode on %s - %v", path, err)
}
return true, nil
}
return false, fmt.Errorf("%s exists, please use --force to overwrite", path)
}
if err := os.Chmod(path, mode); err != nil {
return false, fmt.Errorf("error setting mode on %s - %v", path, err)
}
return false, nil
}
if err := os.WriteFile(path, []byte(content), 0700); err != nil {
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return false, fmt.Errorf("error writing %s to %s - %v", name, path, err)
}
if err := os.Chmod(path, mode); err != nil {
return false, fmt.Errorf("error setting mode on %s - %v", path, err)
}
return true, nil
}

// osqueryVersionReader is a seam so the install decision can be tested without osqueryd present
var osqueryVersionReader = getOsqueryVersion

// Helper function to execute the "osqueryd -version" command and return output
func getOsqueryVersion() string {
var osquerydBin string
Expand Down
52 changes: 48 additions & 4 deletions cmd/osctrld/actions_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestWriteContentExists_NewFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "newfile.txt")

changed, err := writeContentExists(path, "hello", "test", false)
changed, err := writeContentExists(path, "hello", "test", false, 0700)
assert.NoError(t, err)
assert.True(t, changed, "new file should report changed")

Expand All @@ -30,7 +30,7 @@ func TestWriteContentExists_SameContent(t *testing.T) {
path := filepath.Join(dir, "existing.txt")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0700))

changed, err := writeContentExists(path, "hello", "test", false)
changed, err := writeContentExists(path, "hello", "test", false, 0700)
assert.NoError(t, err)
assert.False(t, changed, "same content should not report changed")
}
Expand All @@ -40,7 +40,7 @@ func TestWriteContentExists_DifferentContentNoForce(t *testing.T) {
path := filepath.Join(dir, "existing.txt")
require.NoError(t, os.WriteFile(path, []byte("old"), 0700))

changed, err := writeContentExists(path, "new", "test", false)
changed, err := writeContentExists(path, "new", "test", false, 0700)
assert.Error(t, err)
assert.Contains(t, err.Error(), "please use --force")
assert.False(t, changed, "should not report changed on error")
Expand All @@ -54,14 +54,58 @@ func TestWriteContentExists_DifferentContentWithForce(t *testing.T) {
path := filepath.Join(dir, "existing.txt")
require.NoError(t, os.WriteFile(path, []byte("old"), 0700))

changed, err := writeContentExists(path, "new", "test", true)
changed, err := writeContentExists(path, "new", "test", true, 0700)
assert.NoError(t, err)
assert.True(t, changed, "forced overwrite should report changed")

content, _ := os.ReadFile(path)
assert.Equal(t, "new", string(content))
}

func TestWriteContentExistsHonorsMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "secret")

changed, err := writeContentExists(path, "s3cr3t", "secret", false, 0600)
require.NoError(t, err)
assert.True(t, changed)

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
}

// os.WriteFile applies perm only at creation, so an existing file keeps its old
// mode unless we chmod explicitly. The realistic case is a secret written by the
// osctrl quick-add script at 0644, then migrated to osctrld install.
func TestWriteContentExistsTightensExistingFileMode(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "osquery.secret")
require.NoError(t, os.WriteFile(path, []byte("old-secret"), 0644))

_, err := writeContentExists(path, "new-secret", "secret", true, 0600)
require.NoError(t, err)

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(),
"an existing secret must be tightened, not left world-readable")
}

func TestWriteContentExistsTightensModeWhenContentUnchanged(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "osquery.secret")
require.NoError(t, os.WriteFile(path, []byte("same-secret"), 0644))

_, err := writeContentExists(path, "same-secret", "secret", false, 0600)
require.NoError(t, err)

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(),
"matching content must still leave the file at the requested mode")
}

func mockOsctrlServer() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/env/osctrld-flags", func(w http.ResponseWriter, r *http.Request) {
Expand Down
29 changes: 29 additions & 0 deletions cmd/osctrld/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type Configuration struct {
LogFormat string `json:"logFormat" yaml:"logFormat" mapstructure:"logFormat"`
Interval int `json:"interval" yaml:"interval" mapstructure:"interval"`
ExtensionsDir string `json:"extensionsDir" yaml:"extensionsDir" mapstructure:"extensionsDir"`
OsquerySHA256 string `json:"osquerySHA256" yaml:"osquerySHA256" mapstructure:"osquerySHA256"`
OsqueryPackage string `json:"osqueryPackage" yaml:"osqueryPackage" mapstructure:"osqueryPackage"`
AllowUnverified bool `json:"allowUnverified" yaml:"allowUnverified" mapstructure:"allowUnverified"`
}

type ConfigurationFile struct {
Expand All @@ -95,6 +98,9 @@ func defaultConfiguration() Configuration {
LogFormat: defLogFormat,
Interval: defInterval,
ExtensionsDir: "/path/to/extensions/",
OsquerySHA256: "sha256-of-the-osquery-package",
OsqueryPackage: "",
AllowUnverified: false,
}
}

Expand Down Expand Up @@ -204,6 +210,29 @@ func buildConfigFlags() []cli.Flag {
Sources: cli.EnvVars("OSCTRL_INTERVAL"),
Destination: &appConfig.Interval,
},
&cli.StringFlag{
Name: "osquery-sha256",
Aliases: []string{"H"},
Value: defEmptyValue,
Usage: "Expected SHA-256 of the osquery package, used by the install command",
Sources: cli.EnvVars("OSQUERY_SHA256"),
Destination: &appConfig.OsquerySHA256,
},
&cli.StringFlag{
Name: "osquery-package",
Aliases: []string{"P"},
Value: defEmptyValue,
Usage: "Override the osquery package URL, for mirrors and air-gapped installs",
Sources: cli.EnvVars("OSQUERY_PACKAGE"),
Destination: &appConfig.OsqueryPackage,
},
&cli.BoolFlag{
Name: "allow-unverified",
Value: false,
Usage: "Install the osquery package even when no SHA-256 is available to verify it",
Sources: cli.EnvVars("OSCTRL_ALLOW_UNVERIFIED"),
Destination: &appConfig.AllowUnverified,
},
}
}

Expand Down
14 changes: 13 additions & 1 deletion cmd/osctrld/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func TestBuildConfigFlagsIncludesConfigurationDefaults(t *testing.T) {

configFlags := buildConfigFlags()

assert.Len(t, configFlags, 13)
assert.Len(t, configFlags, 16)
assert.Equal(t, "configuration", configFlags[0].Names()[0])
assert.Equal(t, "secret", configFlags[1].Names()[0])
logFormatFlag, ok := configFlags[11].(*cli.StringFlag)
Expand Down Expand Up @@ -168,6 +168,18 @@ func TestValidateConfigurationRejectsInvalidValues(t *testing.T) {
assert.Contains(t, err.Error(), "interval must be greater than 0")
}

func TestBuildConfigFlagsIncludesInstallFlags(t *testing.T) {
configFlags := buildConfigFlags()

names := map[string]bool{}
for _, f := range configFlags {
names[f.Names()[0]] = true
}
assert.True(t, names["osquery-sha256"], "osquery-sha256 flag missing")
assert.True(t, names["allow-unverified"], "allow-unverified flag missing")
assert.True(t, names["osquery-package"], "osquery-package flag missing")
}

func TestCheckConfigCommandValidatesConfigurationFile(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "osctrld.yaml")
Expand Down
7 changes: 1 addition & 6 deletions cmd/osctrld/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,10 @@ func downloadExtension(url, destPath string, insecure bool) (bool, error) {
if code != http.StatusOK {
return false, fmt.Errorf("HTTP %d downloading extension", code)
}
changed, err := writeContentExists(destPath, string(body), filepath.Base(destPath), true)
changed, err := writeContentExists(destPath, string(body), filepath.Base(destPath), true, 0755)
if err != nil {
return false, err
}
if changed {
if err := os.Chmod(destPath, 0755); err != nil {
return false, fmt.Errorf("error setting extension permissions - %v", err)
}
}
return changed, nil
}

Expand Down
5 changes: 5 additions & 0 deletions cmd/osctrld/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ func TestDownloadExtension_NoChange(t *testing.T) {
changed, err := downloadExtension(server.URL, path, false)
assert.NoError(t, err)
assert.False(t, changed, "same content should not report changed")

info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0755), info.Mode().Perm(),
"a resync of unchanged content must not strip the extension's execute bits")
}

func TestSyncExtensions_Success(t *testing.T) {
Expand Down
Loading
Loading