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
3 changes: 3 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,29 +244,32 @@ sudo osctrld --secret <secret> --environment <environment-uuid> --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

Expand Down
7 changes: 7 additions & 0 deletions cmd/osctrld/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"

"github.com/rs/zerolog/log"
)
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions cmd/osctrld/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
11 changes: 9 additions & 2 deletions cmd/osctrld/http-utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"time"

"github.com/rs/zerolog/log"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions cmd/osctrld/http-utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
27 changes: 18 additions & 9 deletions cmd/osctrld/osquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions cmd/osctrld/osquery_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
45 changes: 33 additions & 12 deletions cmd/osctrld/osquery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions cmd/osctrld/osquery_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 6 additions & 2 deletions service/darwin/net.osctrl.daemon.plist
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
<string>net.osctrl.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/osctrld</string>
<string>/usr/local/bin/osctrld</string>
<string>service</string>
<string>--config=/path/to/osctrld.yaml</string>
<string>--config=/usr/local/etc/osctrld/osctrld.yaml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/osctrld.log</string>
<key>StandardErrorPath</key>
<string>/var/log/osctrld.log</string>
</dict>
</plist>
23 changes: 7 additions & 16 deletions service/linux/systemd.service
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
Loading