From 3fa2a04dffae9472b302f0b99e93ea6fdd41e343 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:49:58 +0200 Subject: [PATCH] Restart osctrld on every platform --- .goreleaser.yml | 3 ++ README.md | 13 +++++--- cmd/osctrld/extensions.go | 7 ++++ cmd/osctrld/extensions_test.go | 39 ++++++++++++++++++++++ cmd/osctrld/http-utils.go | 11 +++++-- cmd/osctrld/http-utils_test.go | 22 +++++++++++++ cmd/osctrld/osquery.go | 27 ++++++++++------ cmd/osctrld/osquery_other.go | 3 ++ cmd/osctrld/osquery_test.go | 45 +++++++++++++++++++------- cmd/osctrld/osquery_windows.go | 8 +++++ service/darwin/net.osctrl.daemon.plist | 8 +++-- service/linux/systemd.service | 23 ++++--------- 12 files changed, 163 insertions(+), 46 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 1bd14b0..c32bd18 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -114,6 +114,9 @@ nfpms: - src: service/osctrld-sample.yaml dst: /etc/osctrld/osctrld.yaml type: config|noreplace + # It holds the osctrl secret + file_info: + mode: 0600 brews: - name: osctrld diff --git a/README.md b/README.md index 3b3e78b..63473be 100644 --- a/README.md +++ b/README.md @@ -244,29 +244,32 @@ sudo osctrld --secret --environment --osctrl-url htt ### 🐧 Linux systemd ```shell -cp osctrld /opt/osctrld/ +cp osctrld /usr/bin/osctrld cp service/linux/systemd.service /etc/systemd/system/osctrld.service -cp service/osctrld-sample.yaml /etc/osctrld/osctrld.yaml +mkdir -p /etc/osctrld +install -m 0600 service/osctrld-sample.yaml /etc/osctrld/osctrld.yaml systemctl daemon-reload systemctl enable osctrld systemctl start osctrld ``` -Edit `/etc/osctrld/osctrld.yaml` with the correct osctrl URL, environment, secret, and local osquery paths before starting the service in production. +Edit `/etc/osctrld/osctrld.yaml` with the correct osctrl URL, environment, secret, and local osquery paths before starting the service in production. The deb, rpm and apk packages install the binary, unit and configuration to these same paths. + +The service runs as root: it writes the osquery flagfile, certificate and extensions, and restarts osqueryd when they change. Keep the configuration file `0600`, since it holds the osctrl secret. ### 🍎 macOS launchd ```shell cp osctrld /usr/local/bin/ mkdir -p /usr/local/etc/osctrld -cp service/osctrld-sample.yaml /usr/local/etc/osctrld/osctrld.yaml +install -m 0600 service/osctrld-sample.yaml /usr/local/etc/osctrld/osctrld.yaml cp service/darwin/net.osctrl.daemon.plist /Library/LaunchDaemons/ launchctl load /Library/LaunchDaemons/net.osctrl.daemon.plist ``` -Edit the plist so it points to your installed binary and `/usr/local/etc/osctrld/osctrld.yaml`, then update that configuration file for your environment before loading the daemon. +The plist runs `/usr/local/bin/osctrld` with `/usr/local/etc/osctrld/osctrld.yaml` and logs to `/var/log/osctrld.log`; adjust the paths if you installed elsewhere (Homebrew on Apple silicon uses `/opt/homebrew`). Update the configuration file for your environment before loading the daemon. ## 💻 Development diff --git a/cmd/osctrld/extensions.go b/cmd/osctrld/extensions.go index f0260a8..25a0a66 100644 --- a/cmd/osctrld/extensions.go +++ b/cmd/osctrld/extensions.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/rs/zerolog/log" ) @@ -62,6 +63,12 @@ func syncExtensions() (bool, error) { } anyChanged := false for _, ext := range manifest { + // The name comes from the network and the file is written as root, so + // only a plain file name, never a path, may come through + if ext.Name == "" || ext.Name == "." || ext.Name == ".." || ext.Name != filepath.Base(ext.Name) || strings.ContainsAny(ext.Name, `/\`) { + log.Error().Str("name", ext.Name).Msg("refusing extension whose name is not a plain file name") + continue + } destPath := filepath.Join(appConfig.ExtensionsDir, ext.Name) log.Debug().Str("name", ext.Name).Str("url", ext.URL).Msg("downloading extension") changed, err := downloadExtension(ext.URL, destPath, appConfig.Insecure) diff --git a/cmd/osctrld/extensions_test.go b/cmd/osctrld/extensions_test.go index f466bd0..3bd4032 100644 --- a/cmd/osctrld/extensions_test.go +++ b/cmd/osctrld/extensions_test.go @@ -164,3 +164,42 @@ func TestSyncExtensions_ServerWithoutExtensionsEndpoint(t *testing.T) { assert.NoError(t, err) assert.False(t, changed) } + +// The manifest comes over the network, and osctrld writes as root: an entry +// name must never place a file outside the extensions directory. +func TestSyncExtensions_RejectsNamesOutsideTheDirectory(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) { + bin := "http://" + r.Host + "/binary" + _ = json.NewEncoder(w).Encode([]ExtensionEntry{ + {Name: "../escape.ext", URL: bin}, + {Name: "sub/nested.ext", URL: bin}, + {Name: "..", URL: bin}, + {Name: "", URL: bin}, + {Name: "good.ext", URL: bin}, + }) + }) + mux.HandleFunc("/binary", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("bin")) + }) + server := httptest.NewServer(mux) + defer server.Close() + + root := t.TempDir() + dir := filepath.Join(root, "extensions") + prevConfig, prevURLs := appConfig, osctrlURLs + t.Cleanup(func() { appConfig, osctrlURLs = prevConfig, prevURLs }) + appConfig.OsctrlSecret = "test-secret" + appConfig.ExtensionsDir = dir + osctrlURLs.Extensions = server.URL + "/manifest" + + changed, err := syncExtensions() + require.NoError(t, err) + assert.True(t, changed, "the valid entry is still installed") + assert.FileExists(t, filepath.Join(dir, "good.ext")) + assert.NoFileExists(t, filepath.Join(root, "escape.ext")) + assert.NoDirExists(t, filepath.Join(dir, "sub")) + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Len(t, entries, 1, "only good.ext may be written") +} diff --git a/cmd/osctrld/http-utils.go b/cmd/osctrld/http-utils.go index 481aa1c..ae42d27 100644 --- a/cmd/osctrld/http-utils.go +++ b/cmd/osctrld/http-utils.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "time" "github.com/rs/zerolog/log" ) @@ -35,13 +36,20 @@ const Authorization string = "Authorization" // osctrlUserAgent for customized User-Agent const osctrlUserAgent string = "osctrld-http-client/" + OsctrldVersion +// httpTimeout bounds a whole request, body included, so a stalled server +// cannot hang the service loop. It is generous because the same client +// downloads osquery packages of around 100 MB. +// ponytail: one timeout for everything; split dial/header timeouts from the +// body timeout if a stalled server must be detected faster. +var httpTimeout = 10 * time.Minute + // SendRequest - Helper function to send HTTP requests func SendRequest(reqType, reqURL string, params io.Reader, headers map[string]string, insecure bool) (int, []byte, error) { u, err := url.Parse(reqURL) if err != nil { return 0, nil, fmt.Errorf("invalid url: %v", err) } - client := &http.Client{} + client := &http.Client{Timeout: httpTimeout} if u.Scheme == "https" { certPool, err := x509.SystemCertPool() if err != nil { @@ -68,7 +76,6 @@ func SendRequest(reqType, reqURL string, params io.Reader, headers map[string]st if err != nil { return 0, []byte("Error sending request"), err } - //defer resp.Body.Close() defer func() { if err := resp.Body.Close(); err != nil { log.Error().Err(err).Msg("failed to close response body") diff --git a/cmd/osctrld/http-utils_test.go b/cmd/osctrld/http-utils_test.go index 5dc44c5..3da2865 100644 --- a/cmd/osctrld/http-utils_test.go +++ b/cmd/osctrld/http-utils_test.go @@ -4,8 +4,10 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func serverMock() *httptest.Server { @@ -54,3 +56,23 @@ func TestSendRequest(t *testing.T) { assert.NoError(t, err) }) } + +// A server that accepts the connection and never answers must not hang the +// service loop forever +func TestSendRequestTimesOut(t *testing.T) { + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer server.Close() + defer close(release) + + prev := httpTimeout + t.Cleanup(func() { httpTimeout = prev }) + httpTimeout = 50 * time.Millisecond + + start := time.Now() + _, _, err := SendRequest(http.MethodGet, server.URL, nil, map[string]string{}, false) + require.Error(t, err) + assert.Less(t, time.Since(start), 5*time.Second) +} diff --git a/cmd/osctrld/osquery.go b/cmd/osctrld/osquery.go index c5c5b99..f43ca22 100644 --- a/cmd/osctrld/osquery.go +++ b/cmd/osctrld/osquery.go @@ -42,23 +42,32 @@ func runFirstAvailable(candidates [][]string) error { return fmt.Errorf("no service manager found for osquery") } -func osqueryRestartCommand() (string, []string) { - switch runtime.GOOS { +// osqueryRestartCandidates is the restart fallback chain for goos. Windows +// restarts through the service manager instead, so it has none. +func osqueryRestartCandidates(goos string) [][]string { + switch goos { case LinuxOS: - return "systemctl", []string{"restart", "osqueryd"} + return [][]string{ + {"systemctl", "restart", osqueryService}, + {"service", osqueryService, "restart"}, + {"/etc/init.d/" + osqueryService, "restart"}, + } case DarwinOS: - return "launchctl", []string{"kickstart", "-k", "system/io.osquery.agent"} - default: - return "", nil + return [][]string{{"launchctl", "kickstart", "-k", "system/io.osquery.agent"}} } + return nil } func restartOsquery() error { - cmd, args := osqueryRestartCommand() - if cmd == "" { + var err error + if runtime.GOOS == WindowsOS { + err = windowsServiceRestart() + } else if candidates := osqueryRestartCandidates(runtime.GOOS); candidates != nil { + err = runFirstAvailable(candidates) + } else { return fmt.Errorf("osquery restart not supported on %s", runtime.GOOS) } - if err := runCommand(cmd, args...); err != nil { + if err != nil { return fmt.Errorf("failed to restart osquery: %v", err) } log.Info().Msg("osquery restarted successfully") diff --git a/cmd/osctrld/osquery_other.go b/cmd/osctrld/osquery_other.go index e68a9e0..252e634 100644 --- a/cmd/osctrld/osquery_other.go +++ b/cmd/osctrld/osquery_other.go @@ -10,3 +10,6 @@ import "fmt" func windowsServiceStop() error { return fmt.Errorf("windows service control unavailable") } func windowsServiceStart() error { return fmt.Errorf("windows service control unavailable") } func windowsServiceEnable() error { return fmt.Errorf("windows service control unavailable") } +func windowsServiceRestart() error { + return fmt.Errorf("windows service control unavailable") +} diff --git a/cmd/osctrld/osquery_test.go b/cmd/osctrld/osquery_test.go index 96afb61..c6a8251 100644 --- a/cmd/osctrld/osquery_test.go +++ b/cmd/osctrld/osquery_test.go @@ -9,19 +9,40 @@ import ( "github.com/stretchr/testify/require" ) -func TestOsqueryRestartCommand(t *testing.T) { - cmd, args := osqueryRestartCommand() - switch runtime.GOOS { - case "linux": - assert.Equal(t, "systemctl", cmd) - assert.Equal(t, []string{"restart", "osqueryd"}, args) - case "darwin": - assert.Equal(t, "launchctl", cmd) - assert.Equal(t, []string{"kickstart", "-k", "system/io.osquery.agent"}, args) - default: - assert.Empty(t, cmd, "unsupported OS should return empty command") - assert.Nil(t, args) +func TestOsqueryRestartCandidates(t *testing.T) { + // Linux restarts through the same systemctl -> service -> init.d chain as + // install's stop and start, so hosts without systemd can apply new flags + assert.Equal(t, [][]string{ + {"systemctl", "restart", "osqueryd"}, + {"service", "osqueryd", "restart"}, + {"/etc/init.d/osqueryd", "restart"}, + }, osqueryRestartCandidates(LinuxOS)) + assert.Equal(t, [][]string{{"launchctl", "kickstart", "-k", "system/io.osquery.agent"}}, osqueryRestartCandidates(DarwinOS)) + // Windows restarts through the service manager, not a command + assert.Nil(t, osqueryRestartCandidates(WindowsOS)) + assert.Nil(t, osqueryRestartCandidates("plan9")) +} + +func TestRestartOsqueryFallsBackWithoutSystemd(t *testing.T) { + if runtime.GOOS != LinuxOS { + t.Skip("linux-only dispatch") } + recorded := fakeExec(t) + fakeLookPath(t, "service") + + require.NoError(t, restartOsquery()) + assert.Equal(t, [][]string{{"service", "osqueryd", "restart"}}, *recorded) +} + +func TestRestartOsqueryDarwin(t *testing.T) { + if runtime.GOOS != DarwinOS { + t.Skip("darwin-only dispatch") + } + recorded := fakeExec(t) + fakeLookPath(t, "launchctl") + + require.NoError(t, restartOsquery()) + assert.Equal(t, [][]string{{"launchctl", "kickstart", "-k", "system/io.osquery.agent"}}, *recorded) } // fakeExec records every command instead of running it. The returned commands diff --git a/cmd/osctrld/osquery_windows.go b/cmd/osctrld/osquery_windows.go index 392cbc0..ea80221 100644 --- a/cmd/osctrld/osquery_windows.go +++ b/cmd/osctrld/osquery_windows.go @@ -70,6 +70,14 @@ func windowsServiceStart() error { return nil } +// windowsServiceRestart stops osqueryd, waiting for it to stop, then starts it +func windowsServiceRestart() error { + if err := windowsServiceStop(); err != nil { + return err + } + return windowsServiceStart() +} + // windowsServiceEnable sets osqueryd to start automatically at boot func windowsServiceEnable() error { m, s, err := openOsqueryService() diff --git a/service/darwin/net.osctrl.daemon.plist b/service/darwin/net.osctrl.daemon.plist index 90461ca..bca7b1e 100644 --- a/service/darwin/net.osctrl.daemon.plist +++ b/service/darwin/net.osctrl.daemon.plist @@ -10,11 +10,15 @@ net.osctrl.daemon ProgramArguments - /path/to/osctrld + /usr/local/bin/osctrld service - --config=/path/to/osctrld.yaml + --config=/usr/local/etc/osctrld/osctrld.yaml RunAtLoad + StandardOutPath + /var/log/osctrld.log + StandardErrorPath + /var/log/osctrld.log diff --git a/service/linux/systemd.service b/service/linux/systemd.service index 3a7ca06..b65c6b4 100644 --- a/service/linux/systemd.service +++ b/service/linux/systemd.service @@ -1,25 +1,16 @@ [Unit] -Description=Daemon for osctrld, the fast and efficient osquery management -ConditionPathExists=/opt/osctrld -After=network.target +Description=osctrld, keeps osquery configured from osctrl +After=network-online.target +Wants=network-online.target [Service] Type=simple -User=osctrld -Group=osctrld +# Root: osctrld writes the osquery flagfile, certificate and extensions, and +# restarts osqueryd when they change +User=root +ExecStart=/usr/bin/osctrld service --config=/etc/osctrld/osctrld.yaml Restart=on-failure RestartSec=10 - -WorkingDirectory=/opt/osctrld -ExecStart=/opt/osctrld/osctrld service --config=/etc/osctrld/osctrld.yaml - -# make sure log directory exists and owned by syslog -PermissionsStartOnly=true -ExecStartPre=/bin/mkdir -p /var/log/osctrld -ExecStartPre=/bin/chown osctrld:osctrld /var/log/osctrld -ExecStartPre=/bin/chmod 755 /var/log/osctrld -StandardOutput=syslog -StandardError=syslog SyslogIdentifier=osctrld [Install]