From 49deb5fe7238a1cdbe64a7ff8f06a2f98d535549 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Oct 2026 18:12:46 -0400 Subject: [PATCH 1/4] chore(dot1x): bump dot1x table to macadmins/osquery-extension#113 head 0f992a0 Repoint the replace at the current head of robbiet480/osquery-extension add-eapol-status (0f992a0, 2026-10-03; PR #113 is still open). The table grows from 24 to 41 columns (none removed): interface_type, ssid, mac_address, client_status_name, authenticated_since, failure_reason, failure_code, failure_eap_code, identity, profile_name, mdm_payload_uuid, mdm_payload_type, mdm_profile_name, mdm_profile_identifier, server_validation, tls_trusted_server_names and tls_server_certificate_not_after. The macOS backend also gains known-networks / `profiles` attribution (the `profiles` call is bounded by a 10s timeout) and the PR adds a Windows backend (not built here). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0126vJSowUXBoEHiGS5Sbrvs --- dot1x/go.mod | 3 ++- dot1x/go.sum | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dot1x/go.mod b/dot1x/go.mod index ada0c9d..13ae73e 100644 --- a/dot1x/go.mod +++ b/dot1x/go.mod @@ -2,7 +2,7 @@ module github.com/CampusTech/osquery-extensions/dot1x go 1.26.2 -replace github.com/macadmins/osquery-extension => github.com/robbiet480/osquery-extension v1.4.2-0.20260610232042-f9087b9cdba5 +replace github.com/macadmins/osquery-extension => github.com/robbiet480/osquery-extension v1.4.2-0.20261003192026-0f992a0b9fe5 require ( github.com/macadmins/osquery-extension v0.0.0-00010101000000-000000000000 @@ -15,6 +15,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/micromdm/plist v0.2.3-0.20260123201933-667adaf87d87 // indirect github.com/pkg/errors v0.9.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.41.0 // indirect diff --git a/dot1x/go.sum b/dot1x/go.sum index 0098248..776214c 100644 --- a/dot1x/go.sum +++ b/dot1x/go.sum @@ -13,14 +13,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/micromdm/plist v0.2.3-0.20260123201933-667adaf87d87 h1:U9A+0ZED3cPxb5ufiTzyn2kyo6UFoR5bMggCR0Q/DOg= +github.com/micromdm/plist v0.2.3-0.20260123201933-667adaf87d87/go.mod h1:flkfm0od6GzyXBqI28h5sgEyi3iPO28W2t1Zm9LpwWs= github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df h1:GEa8SFf/OXRXSHU5EtjUxs4yPz8caf9UlHN/F89/OJA= github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df/go.mod h1:/Nxgn14on72pNnpjn9Fbw9WQc8JKY1sVv5K+SRZknEk= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/robbiet480/osquery-extension v1.4.2-0.20260610232042-f9087b9cdba5 h1:Lvgw8XV0rpx4TH+4cina26OCgxVg9ykAk5eNU10k11o= -github.com/robbiet480/osquery-extension v1.4.2-0.20260610232042-f9087b9cdba5/go.mod h1:WFTX1rypPMYrDKC/0de0sy/g9rDEdlj4JXkYNgd+XDM= +github.com/robbiet480/osquery-extension v1.4.2-0.20261003192026-0f992a0b9fe5 h1:yGMO+O5Fortz2PLy6rVPWBsaq30iq012WTDksmIupXI= +github.com/robbiet480/osquery-extension v1.4.2-0.20261003192026-0f992a0b9fe5/go.mod h1:bfYLdHLnY32gcICdfzV/InPa9ZZLLnZ9P5R7hPGkw8s= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= From 46bf27d78259b1d454c50fff41babd08b2c592d1 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Oct 2026 18:12:46 -0400 Subject: [PATCH 2/4] test(dot1x): reproduce the duplicate-registration wedge from host 671 On 2026-10-05 dot1x.ext on host 671 exited after a single failed ping to osqueryd ("extension ping failed: timeout after 200ms"). osqueryd never finished reaping its registration, so every respawned dot1x.ext was refused with "Duplicate extension registered", exited with status 1 and was respawned, every ~3s for ~3h. Extract newServer/run (behaviour unchanged: run still calls osquery-go's Server.Run) and add tests against a fake extension manager: - TestTransientPingFailureDoesNotExit: one failed ping must not kill the extension. FAILS: run exits with "extension ping failed". - TestDuplicateRegistrationRetries: a duplicate refusal must be retried in process. FAILS: run exits with "status 1 registering extension: Duplicate extension registered". - TestRegistersVersion: the extension must register with a version (the Fleet install policy will gate on it) and stop on cancel. FAILS. - TestExitsWhenOsqueryGone: guard rail, passes today and must keep passing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0126vJSowUXBoEHiGS5Sbrvs --- dot1x/main.go | 36 ++++++-- dot1x/main_test.go | 205 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 dot1x/main_test.go diff --git a/dot1x/main.go b/dot1x/main.go index 24f9bf1..1581eb4 100644 --- a/dot1x/main.go +++ b/dot1x/main.go @@ -21,6 +21,7 @@ package main import ( + "context" "flag" "log" "time" @@ -42,19 +43,38 @@ func main() { log.Fatalln("--socket is required") } + client, err := osquery.NewClient(*socket, time.Duration(*timeout)*time.Second) + if err != nil { + log.Fatalf("error creating extension manager client: %s", err) + } + defer client.Close() + + server, err := newServer(*socket, client, time.Duration(*timeout)*time.Second, time.Duration(*interval)*time.Second) + if err != nil { + log.Fatalf("error creating extension manager: %s", err) + } + + if err := run(context.Background(), server, client, time.Duration(*interval)*time.Second); err != nil { + log.Fatalln(err) + } +} + +func newServer(socket string, client osquery.ExtensionManager, timeout, interval time.Duration) (*osquery.ExtensionManagerServer, error) { server, err := osquery.NewExtensionManagerServer( "dot1x", - *socket, - osquery.ServerTimeout(time.Duration(*timeout)*time.Second), - osquery.ServerPingInterval(time.Duration(*interval)*time.Second), + socket, + osquery.WithClient(client), + osquery.ServerTimeout(timeout), + osquery.ServerPingInterval(interval), ) if err != nil { - log.Fatalf("error creating extension manager: %s", err) + return nil, err } - server.RegisterPlugin(table.NewPlugin("dot1x", dot1x.Dot1XStatusColumns(), dot1x.Dot1XStatusGenerate)) + return server, nil +} - if err := server.Run(); err != nil { - log.Fatalln(err) - } +// run serves the extension until ctx is cancelled or osquery goes away. +func run(_ context.Context, server *osquery.ExtensionManagerServer, _ osquery.ExtensionManager, _ time.Duration) error { + return server.Run() } diff --git a/dot1x/main_test.go b/dot1x/main_test.go new file mode 100644 index 0000000..8f1b715 --- /dev/null +++ b/dot1x/main_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + osquery "github.com/osquery/osquery-go" + gen "github.com/osquery/osquery-go/gen/osquery" +) + +const testInterval = 10 * time.Millisecond + +// fakeOsquery stands in for osqueryd's extension manager. ping and register +// are given the 1-based call number so a test can script a sequence of +// responses. +type fakeOsquery struct { + osquery.ExtensionManager // methods the extension never calls + + ping func(n int) error + register func(n int) *gen.ExtensionStatus + + pings, registers, deregisters atomic.Int32 + + mu sync.Mutex + version string +} + +func (f *fakeOsquery) Close() {} + +func (f *fakeOsquery) Ping() (*gen.ExtensionStatus, error) { + if err := f.ping(int(f.pings.Add(1))); err != nil { + return nil, err + } + return &gen.ExtensionStatus{Code: 0, Message: "OK"}, nil +} + +func (f *fakeOsquery) RegisterExtension(info *gen.InternalExtensionInfo, _ gen.ExtensionRegistry) (*gen.ExtensionStatus, error) { + f.mu.Lock() + f.version = info.Version + f.mu.Unlock() + return f.register(int(f.registers.Add(1))), nil +} + +func (f *fakeOsquery) DeregisterExtension(gen.ExtensionRouteUUID) (*gen.ExtensionStatus, error) { + f.deregisters.Add(1) + return &gen.ExtensionStatus{Code: 0, Message: "OK"}, nil +} + +func registered(n int) *gen.ExtensionStatus { + return &gen.ExtensionStatus{Code: 0, Message: "OK", UUID: gen.ExtensionRouteUUID(n)} +} + +// duplicate is what osqueryd answers while a stale registration named dot1x +// is still in its registry (osquery/extensions/interface.cpp). +func duplicate(int) *gen.ExtensionStatus { + return &gen.ExtensionStatus{Code: 1, Message: "Duplicate extension registered"} +} + +func healthy(int) error { return nil } + +// startRun runs the extension against f in the background and returns a +// cancel func plus the channel run's result arrives on. +func startRun(t *testing.T, f *fakeOsquery) (context.CancelFunc, <-chan error) { + t.Helper() + // The server listens on .; keep the path short enough for + // a unix socket (macOS t.TempDir() paths are too long). + dir, err := os.MkdirTemp("/tmp", "dot1x") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + server, err := newServer(filepath.Join(dir, "em"), f, time.Second, testInterval) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + done := make(chan error, 1) + go func() { done <- run(ctx, server, f, testInterval) }() + return cancel, done +} + +// waitFor polls cond until it holds, failing the test if run exits first. +func waitFor(t *testing.T, done <-chan error, what string, cond func() bool) { + t.Helper() + deadline := time.After(5 * time.Second) + for !cond() { + select { + case err := <-done: + t.Fatalf("run exited before %s: %v", what, err) + case <-deadline: + t.Fatalf("timed out waiting for %s", what) + case <-time.After(testInterval / 2): + } + } +} + +func stop(t *testing.T, cancel context.CancelFunc, done <-chan error) error { + t.Helper() + cancel() + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + t.Fatal("run did not return after cancel") + return nil + } +} + +// Host 671, 2026-10-05: a single failed ping ("timeout after 200ms") made +// dot1x.ext exit, leaving a registration osqueryd never reaped. One blip must +// not kill the extension. +func TestTransientPingFailureDoesNotExit(t *testing.T) { + f := &fakeOsquery{ + ping: func(n int) error { + if n == 1 { + return errors.New("timeout after 200ms") + } + return nil + }, + register: registered, + } + cancel, done := startRun(t, f) + + waitFor(t, done, "pings to recover", func() bool { return f.pings.Load() >= 5 }) + + if err := stop(t, cancel, done); err != nil { + t.Fatalf("run returned %v after cancel, want nil", err) + } + if got := f.deregisters.Load(); got != 1 { + t.Errorf("deregistered %d times on shutdown, want 1", got) + } +} + +// Host 671, 2026-10-05: with a stale dot1x registration in osqueryd, every +// respawned dot1x.ext was refused as a duplicate, exited with status 1 and +// was respawned again, every ~3s for hours. The extension must keep retrying +// in-process until osquery reaps the stale entry. +func TestDuplicateRegistrationRetries(t *testing.T) { + f := &fakeOsquery{ + ping: healthy, + register: func(n int) *gen.ExtensionStatus { + if n <= 2 { + return duplicate(n) + } + return registered(n) + }, + } + cancel, done := startRun(t, f) + + waitFor(t, done, "registration to succeed", func() bool { return f.registers.Load() >= 3 }) + pings := f.pings.Load() + waitFor(t, done, "pings after registering", func() bool { return f.pings.Load() >= pings+3 }) + + if err := stop(t, cancel, done); err != nil { + t.Fatalf("run returned %v after cancel, want nil", err) + } + if got := f.registers.Load(); got != 3 { + t.Errorf("registered %d times, want 3", got) + } +} + +// Tolerating blips must not keep an orphaned extension alive once osquery +// is really gone. +func TestExitsWhenOsqueryGone(t *testing.T) { + f := &fakeOsquery{ + ping: func(int) error { return errors.New("broken pipe") }, + register: registered, + } + _, done := startRun(t, f) + + select { + case err := <-done: + if err == nil { + t.Fatal("run returned nil, want an error") + } + case <-time.After(5 * time.Second): + t.Fatal("run kept going with osquery gone") + } + if got := f.deregisters.Load(); got != 1 { + t.Errorf("deregistered %d times, want 1", got) + } +} + +// The fleet install policy gates on osquery_extensions.version, so the +// extension must register with a version. +func TestRegistersVersion(t *testing.T) { + f := &fakeOsquery{ping: healthy, register: registered} + cancel, done := startRun(t, f) + waitFor(t, done, "registration", func() bool { return f.registers.Load() >= 1 }) + _ = stop(t, cancel, done) + + f.mu.Lock() + defer f.mu.Unlock() + if f.version == "" { + t.Error("registered with an empty version") + } +} From 46deda5089d7bf58aa86f39b40f07e32e2e828f4 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Oct 2026 18:26:33 -0400 Subject: [PATCH 3/4] fix: keep extensions from wedging osqueryd's extension manager Both extensions ran osquery-go's ExtensionManagerServer.Run, which exits on the FIRST failed ping. On host 671 (2026-10-05) dot1x.ext died on "extension ping failed: timeout after 200ms" -- osquery-go's client lock wait, 200ms by default, which can time out even when the lock is free (its select races a fired timer after a scheduling stall). osqueryd 5.23.1 then had to reap the dead registration itself; its watcher can block indefinitely behind a query running on the primary SQLite connection, so the stale "dot1x" entry stayed and every respawn was refused as a duplicate, exited and respawned every ~3s for ~3h. mac_enclosure_color hit the same loop on other hosts. Move the server loop into a shared extserver module used by both extensions: - client lock wait = --timeout (3s) instead of 200ms; - exit only after 3 consecutive failed pings; - retry "Duplicate extension registered" in-process with exponential backoff (interval .. 1m) instead of exiting into a respawn loop; - always deregister on exit, including SIGTERM/SIGINT; - register a real version (-X main.version, stamped from the release tag) so the Fleet install policy can gate on osquery_extensions.version. The tests from the previous commit move to extserver and now pass. Also add a PR test workflow (vet + race tests for all three modules on macOS). This removes our extensions as triggers; it cannot unwedge an osqueryd whose extension manager is already deadlocked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0126vJSowUXBoEHiGS5Sbrvs --- .github/workflows/release.yml | 12 +- .github/workflows/test.yml | 21 +++ Makefile | 2 +- dot1x/go.mod | 3 + dot1x/main.go | 61 +------ extserver/extserver.go | 159 ++++++++++++++++++ .../extserver_test.go | 22 ++- extserver/go.mod | 17 ++ extserver/go.sum | 31 ++++ mac_enclosure_color/go.mod | 5 +- mac_enclosure_color/go.sum | 4 +- mac_enclosure_color/main.go | 40 +---- 12 files changed, 277 insertions(+), 100 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 extserver/extserver.go rename dot1x/main_test.go => extserver/extserver_test.go (89%) create mode 100644 extserver/go.mod create mode 100644 extserver/go.sum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51fe763..9de37c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,18 +27,20 @@ jobs: env: CGO_ENABLED: "1" GOOS: darwin + RELEASE_TAG: ${{ github.event.release.tag_name }} GOARCH: arm64 run: | - go build -trimpath -ldflags='-s -w' -o ../mac_enclosure_color.arm64.ext . + go build -trimpath -ldflags="-s -w -X main.version=${RELEASE_TAG#v}" -o ../mac_enclosure_color.arm64.ext . - name: Build (amd64) working-directory: mac_enclosure_color env: CGO_ENABLED: "1" GOOS: darwin + RELEASE_TAG: ${{ github.event.release.tag_name }} GOARCH: amd64 run: | - go build -trimpath -ldflags='-s -w' -o ../mac_enclosure_color.amd64.ext . + go build -trimpath -ldflags="-s -w -X main.version=${RELEASE_TAG#v}" -o ../mac_enclosure_color.amd64.ext . - name: Create universal binary run: | @@ -76,18 +78,20 @@ jobs: env: CGO_ENABLED: "1" GOOS: darwin + RELEASE_TAG: ${{ github.event.release.tag_name }} GOARCH: arm64 run: | - go build -trimpath -ldflags='-s -w' -o ../dot1x.arm64.ext . + go build -trimpath -ldflags="-s -w -X main.version=${RELEASE_TAG#v}" -o ../dot1x.arm64.ext . - name: Build (amd64) working-directory: dot1x env: CGO_ENABLED: "1" GOOS: darwin + RELEASE_TAG: ${{ github.event.release.tag_name }} GOARCH: amd64 run: | - go build -trimpath -ldflags='-s -w' -o ../dot1x.amd64.ext . + go build -trimpath -ldflags="-s -w -X main.version=${RELEASE_TAG#v}" -o ../dot1x.amd64.ext . - name: Create universal binary run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d90c791 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: test + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: extserver/go.mod + cache-dependency-path: "*/go.sum" + - name: vet and test + run: | + for m in extserver dot1x mac_enclosure_color; do + (cd "$m" && go vet ./... && go test -race ./...) + done diff --git a/Makefile b/Makefile index a2e30b6..4de7141 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ build: done test: - @for ext in $(EXTENSIONS); do \ + @for ext in extserver $(EXTENSIONS); do \ cd $$ext && go test ./... && cd ..; \ done diff --git a/dot1x/go.mod b/dot1x/go.mod index 13ae73e..47d10be 100644 --- a/dot1x/go.mod +++ b/dot1x/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + github.com/CampusTech/osquery-extensions/extserver v0.0.0 github.com/Microsoft/go-winio v0.6.2 // indirect github.com/apache/thrift v0.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -23,3 +24,5 @@ require ( go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/sys v0.36.0 // indirect ) + +replace github.com/CampusTech/osquery-extensions/extserver => ../extserver diff --git a/dot1x/main.go b/dot1x/main.go index 1581eb4..f1b6f3a 100644 --- a/dot1x/main.go +++ b/dot1x/main.go @@ -13,7 +13,7 @@ // // Build: // -// GOOS=darwin go build -o dot1x.ext +// GOOS=darwin go build -ldflags "-X main.version=0.4.1" -o dot1x.ext // // Run standalone (for testing): // @@ -21,60 +21,17 @@ package main import ( - "context" - "flag" - "log" - "time" - + "github.com/CampusTech/osquery-extensions/extserver" "github.com/macadmins/osquery-extension/tables/dot1x" - osquery "github.com/osquery/osquery-go" "github.com/osquery/osquery-go/plugin/table" ) -func main() { - socket := flag.String("socket", "", "Path to the osquery extension socket") - timeout := flag.Int("timeout", 3, "Seconds to wait for a successful connection") - interval := flag.Int("interval", 3, "Seconds between connection checks") - verbose := flag.Bool("verbose", false, "Enable verbose extension logging") - flag.Parse() - _ = *verbose - - if *socket == "" { - log.Fatalln("--socket is required") - } - - client, err := osquery.NewClient(*socket, time.Duration(*timeout)*time.Second) - if err != nil { - log.Fatalf("error creating extension manager client: %s", err) - } - defer client.Close() - - server, err := newServer(*socket, client, time.Duration(*timeout)*time.Second, time.Duration(*interval)*time.Second) - if err != nil { - log.Fatalf("error creating extension manager: %s", err) - } +// version is reported in osquery_extensions.version; the Fleet install +// policy gates on it. Release builds set it with +// -ldflags "-X main.version=". +var version = "dev" - if err := run(context.Background(), server, client, time.Duration(*interval)*time.Second); err != nil { - log.Fatalln(err) - } -} - -func newServer(socket string, client osquery.ExtensionManager, timeout, interval time.Duration) (*osquery.ExtensionManagerServer, error) { - server, err := osquery.NewExtensionManagerServer( - "dot1x", - socket, - osquery.WithClient(client), - osquery.ServerTimeout(timeout), - osquery.ServerPingInterval(interval), - ) - if err != nil { - return nil, err - } - server.RegisterPlugin(table.NewPlugin("dot1x", dot1x.Dot1XStatusColumns(), dot1x.Dot1XStatusGenerate)) - return server, nil -} - -// run serves the extension until ctx is cancelled or osquery goes away. -func run(_ context.Context, server *osquery.ExtensionManagerServer, _ osquery.ExtensionManager, _ time.Duration) error { - return server.Run() +func main() { + extserver.Main("dot1x", version, + table.NewPlugin("dot1x", dot1x.Dot1XStatusColumns(), dot1x.Dot1XStatusGenerate)) } diff --git a/extserver/extserver.go b/extserver/extserver.go new file mode 100644 index 0000000..fd9f831 --- /dev/null +++ b/extserver/extserver.go @@ -0,0 +1,159 @@ +// Package extserver runs a Campus osquery extension. It replaces osquery-go's +// ExtensionManagerServer.Run, whose failure handling wedged osqueryd's +// extension manager on several Macs (first seen on Fleet host 671, +// 2026-10-05): +// +// - Run exits on the first failed ping. "timeout after 200ms" is not +// osqueryd being gone: it is osquery-go's client lock wait (200ms by +// default), which can fail even uncontended after a scheduling stall. +// - The exited extension leaves its registration for osqueryd to reap, and +// osqueryd 5.23.1 can block reaping indefinitely behind a running query. +// Every respawn is then refused with "Duplicate extension registered" and +// exits, every ~3s, until osqueryd restarts. +// +// Run instead waits up to the connection timeout for the client lock, +// tolerates transient ping failures, retries duplicate registrations +// in-process with backoff, and always deregisters on the way out (including +// on SIGTERM/SIGINT). +package extserver + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + "time" + + osquery "github.com/osquery/osquery-go" +) + +const ( + // maxPingFailures consecutive failed pings mean osquery is really gone. + maxPingFailures = 3 + // maxRetryBackoff caps the wait between duplicate-registration retries. + maxRetryBackoff = time.Minute +) + +// Main parses the standard osquery extension flags (--socket, --timeout, +// --interval, --verbose), registers plugins as extension name/version and +// serves until osquery goes away or the process is signalled. +func Main(name, version string, plugins ...osquery.OsqueryPlugin) { + socket := flag.String("socket", "", "Path to the osquery extension socket") + timeoutSec := flag.Int("timeout", 3, "Seconds to wait for a successful connection") + intervalSec := flag.Int("interval", 3, "Seconds between connection checks") + verbose := flag.Bool("verbose", false, "Enable verbose extension logging") + flag.Parse() + _ = *verbose + + if *socket == "" { + log.Fatalln("--socket is required") + } + timeout := time.Duration(*timeoutSec) * time.Second + interval := time.Duration(*intervalSec) * time.Second + + // Wait up to timeout for the client's socket lock instead of osquery-go's + // 200ms default. + client, err := osquery.NewClient(*socket, timeout, osquery.DefaultWaitTime(timeout)) + if err != nil { + log.Fatalf("error creating extension manager client: %s", err) + } + + server, err := newServer(name, version, *socket, client, timeout, plugins...) + if err != nil { + client.Close() + log.Fatalf("error creating extension manager: %s", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + err = run(ctx, server, client, interval) + stop() + client.Close() + if err != nil { + log.Fatalln(err) + } +} + +func newServer(name, version, socket string, client osquery.ExtensionManager, timeout time.Duration, plugins ...osquery.OsqueryPlugin) (*osquery.ExtensionManagerServer, error) { + server, err := osquery.NewExtensionManagerServer( + name, + socket, + osquery.WithClient(client), + osquery.ServerTimeout(timeout), + osquery.ExtensionVersion(version), + ) + if err != nil { + return nil, err + } + server.RegisterPlugin(plugins...) + return server, nil +} + +// run registers the extension and serves until ctx is cancelled, osquery asks +// it to shut down, or maxPingFailures consecutive pings fail. +func run(ctx context.Context, server *osquery.ExtensionManagerServer, client osquery.ExtensionManager, interval time.Duration) error { + // Always deregister on the way out so osqueryd never has to reap a stale + // registration itself. + defer func() { + if err := server.Shutdown(context.Background()); err != nil { + log.Println(err) + } + }() + ctx, cancel := context.WithCancel(ctx) + defer cancel() // runs before Shutdown: stops registration retries + + served := make(chan error, 1) + go func() { served <- start(ctx, server, interval) }() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + failures := 0 + for { + select { + case <-ctx.Done(): + return nil + case err := <-served: + return err + case <-ticker.C: + status, err := client.Ping() + if err == nil && status.Code != 0 { + err = fmt.Errorf("status %d: %s", status.Code, status.Message) + } + if err == nil { + failures = 0 + continue + } + failures++ + if failures >= maxPingFailures { + return fmt.Errorf("osquery unreachable, %d pings failed: %w", failures, err) + } + log.Printf("ping %d/%d failed: %v", failures, maxPingFailures, err) + } + } +} + +// start registers and serves. While osqueryd still holds a stale registration +// with our name it refuses us as a duplicate; its extension watcher normally +// reaps the stale entry within a couple of --extensions_interval periods, so +// retry in-process with backoff instead of exiting into a respawn loop. +// +// ponytail: a retry that races run's shutdown can register just before the +// process exits; osqueryd then reaps it like any dead extension. +func start(ctx context.Context, server *osquery.ExtensionManagerServer, backoff time.Duration) error { + for { + err := server.Start() + if err == nil || !strings.Contains(err.Error(), "Duplicate extension registered") { + return err + } + log.Printf("%v; retrying in %s", err, backoff) + select { + case <-ctx.Done(): + return nil + case <-time.After(backoff): + } + backoff = min(2*backoff, maxRetryBackoff) + } +} diff --git a/dot1x/main_test.go b/extserver/extserver_test.go similarity index 89% rename from dot1x/main_test.go rename to extserver/extserver_test.go index 8f1b715..f9ba627 100644 --- a/dot1x/main_test.go +++ b/extserver/extserver_test.go @@ -1,4 +1,4 @@ -package main +package extserver import ( "context" @@ -12,9 +12,13 @@ import ( osquery "github.com/osquery/osquery-go" gen "github.com/osquery/osquery-go/gen/osquery" + "github.com/osquery/osquery-go/plugin/table" ) -const testInterval = 10 * time.Millisecond +const ( + testInterval = 10 * time.Millisecond + testVersion = "1.2.3" +) // fakeOsquery stands in for osqueryd's extension manager. ping and register // are given the 1-based call number so a test can script a sequence of @@ -56,7 +60,7 @@ func registered(n int) *gen.ExtensionStatus { return &gen.ExtensionStatus{Code: 0, Message: "OK", UUID: gen.ExtensionRouteUUID(n)} } -// duplicate is what osqueryd answers while a stale registration named dot1x +// duplicate is what osqueryd answers while a stale registration with our name // is still in its registry (osquery/extensions/interface.cpp). func duplicate(int) *gen.ExtensionStatus { return &gen.ExtensionStatus{Code: 1, Message: "Duplicate extension registered"} @@ -70,13 +74,15 @@ func startRun(t *testing.T, f *fakeOsquery) (context.CancelFunc, <-chan error) { t.Helper() // The server listens on .; keep the path short enough for // a unix socket (macOS t.TempDir() paths are too long). - dir, err := os.MkdirTemp("/tmp", "dot1x") + dir, err := os.MkdirTemp("/tmp", "extserver") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.RemoveAll(dir) }) - server, err := newServer(filepath.Join(dir, "em"), f, time.Second, testInterval) + plugin := table.NewPlugin("t", []table.ColumnDefinition{table.TextColumn("c")}, + func(context.Context, table.QueryContext) ([]map[string]string, error) { return nil, nil }) + server, err := newServer("test", testVersion, filepath.Join(dir, "em"), f, time.Second, plugin) if err != nil { t.Fatal(err) } @@ -116,7 +122,7 @@ func stop(t *testing.T, cancel context.CancelFunc, done <-chan error) error { // Host 671, 2026-10-05: a single failed ping ("timeout after 200ms") made // dot1x.ext exit, leaving a registration osqueryd never reaped. One blip must -// not kill the extension. +// not kill the extension, and shutting down must deregister. func TestTransientPingFailureDoesNotExit(t *testing.T) { f := &fakeOsquery{ ping: func(n int) error { @@ -199,7 +205,7 @@ func TestRegistersVersion(t *testing.T) { f.mu.Lock() defer f.mu.Unlock() - if f.version == "" { - t.Error("registered with an empty version") + if f.version != testVersion { + t.Errorf("registered version %q, want %q", f.version, testVersion) } } diff --git a/extserver/go.mod b/extserver/go.mod new file mode 100644 index 0000000..e7a462f --- /dev/null +++ b/extserver/go.mod @@ -0,0 +1,17 @@ +module github.com/CampusTech/osquery-extensions/extserver + +go 1.26.2 + +require github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/apache/thrift v0.23.0 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/pkg/errors v0.8.0 // indirect + go.opentelemetry.io/otel v1.16.0 // indirect + go.opentelemetry.io/otel/metric v1.16.0 // indirect + go.opentelemetry.io/otel/trace v1.16.0 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/extserver/go.sum b/extserver/go.sum new file mode 100644 index 0000000..58c9934 --- /dev/null +++ b/extserver/go.sum @@ -0,0 +1,31 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/apache/thrift v0.23.0 h1:wKR6YnefQSEnxpEfmgTPuJibNG4bF0p2TK34tHLWi3s= +github.com/apache/thrift v0.23.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df h1:GEa8SFf/OXRXSHU5EtjUxs4yPz8caf9UlHN/F89/OJA= +github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df/go.mod h1:/Nxgn14on72pNnpjn9Fbw9WQc8JKY1sVv5K+SRZknEk= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= +go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mac_enclosure_color/go.mod b/mac_enclosure_color/go.mod index 3075ea4..a39d398 100644 --- a/mac_enclosure_color/go.mod +++ b/mac_enclosure_color/go.mod @@ -2,9 +2,10 @@ module github.com/CampusTech/osquery-extensions/mac_enclosure_color go 1.26.2 -require github.com/osquery/osquery-go v0.0.0-20260508130258-3e773449a5d4 +require github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df require ( + github.com/CampusTech/osquery-extensions/extserver v0.0.0 github.com/Microsoft/go-winio v0.6.2 // indirect github.com/apache/thrift v0.24.0 // indirect github.com/go-logr/logr v1.2.4 // indirect @@ -15,3 +16,5 @@ require ( go.opentelemetry.io/otel/trace v1.16.0 // indirect golang.org/x/sys v0.25.0 // indirect ) + +replace github.com/CampusTech/osquery-extensions/extserver => ../extserver diff --git a/mac_enclosure_color/go.sum b/mac_enclosure_color/go.sum index 23dd202..6687b39 100644 --- a/mac_enclosure_color/go.sum +++ b/mac_enclosure_color/go.sum @@ -11,8 +11,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/osquery/osquery-go v0.0.0-20260508130258-3e773449a5d4 h1:A+koNnz8s49uOiFQuUOAfvDirxt2c28roPF0aXCfyBQ= -github.com/osquery/osquery-go v0.0.0-20260508130258-3e773449a5d4/go.mod h1:/Nxgn14on72pNnpjn9Fbw9WQc8JKY1sVv5K+SRZknEk= +github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df h1:GEa8SFf/OXRXSHU5EtjUxs4yPz8caf9UlHN/F89/OJA= +github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df/go.mod h1:/Nxgn14on72pNnpjn9Fbw9WQc8JKY1sVv5K+SRZknEk= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/mac_enclosure_color/main.go b/mac_enclosure_color/main.go index b170323..b7d7795 100644 --- a/mac_enclosure_color/main.go +++ b/mac_enclosure_color/main.go @@ -10,7 +10,7 @@ // // Build: // -// GOOS=darwin go build -o mac_enclosure_color.ext +// GOOS=darwin go build -ldflags "-X main.version=0.4.1" -o mac_enclosure_color.ext // // Run standalone (for testing): // @@ -21,39 +21,15 @@ package main import ( - "flag" - "log" - "time" - - osquery "github.com/osquery/osquery-go" + "github.com/CampusTech/osquery-extensions/extserver" "github.com/osquery/osquery-go/plugin/table" ) -func main() { - socket := flag.String("socket", "", "Path to the osquery extension socket") - timeout := flag.Int("timeout", 3, "Seconds to wait for a successful connection") - interval := flag.Int("interval", 3, "Seconds between connection checks") - verbose := flag.Bool("verbose", false, "Enable verbose extension logging") - flag.Parse() - _ = *verbose - - if *socket == "" { - log.Fatalln("--socket is required") - } +// version is reported in osquery_extensions.version. Release builds set it +// with -ldflags "-X main.version=". +var version = "dev" - server, err := osquery.NewExtensionManagerServer( - "mac_enclosure_color", - *socket, - osquery.ServerTimeout(time.Duration(*timeout)*time.Second), - osquery.ServerPingInterval(time.Duration(*interval)*time.Second), - ) - if err != nil { - log.Fatalf("error creating extension manager: %s", err) - } - - server.RegisterPlugin(table.NewPlugin("mac_enclosure_color", columns(), osqueryGenerate)) - - if err := server.Run(); err != nil { - log.Fatalln(err) - } +func main() { + extserver.Main("mac_enclosure_color", version, + table.NewPlugin("mac_enclosure_color", columns(), osqueryGenerate)) } From ade04b42fc5903619d0720ca48f05383154efd74 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 5 Oct 2026 18:33:24 -0400 Subject: [PATCH 4/4] fix(extserver): never register after shutdown; address review - A registration racing shutdown (SIGTERM before the first Start, or a duplicate retry waking during cancellation) could complete after Shutdown had already deregistered, leaving a registration nothing removes -- the stale-entry problem this package exists to prevent. osquery-go only guards this for clients it creates itself, so wrap our client in a gate that refuses RegisterExtension once run begins shutting down and waits out an in-flight one so Shutdown deregisters it. TestNoRegistrationAfterShutdown failed (1 registration left behind on the first iteration) before this change. - extserver/go.mod: require apache/thrift v0.24.0 (GHSA-8wv5-x4w7-5gww) when built standalone, matching the extension modules. - test.yml: read-only token, no persisted checkout credentials. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0126vJSowUXBoEHiGS5Sbrvs --- .github/workflows/test.yml | 5 ++++ extserver/extserver.go | 47 +++++++++++++++++++++++++------- extserver/extserver_test.go | 54 ++++++++++++++++++++++++++++++++----- extserver/go.mod | 2 +- extserver/go.sum | 4 +-- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d90c791..e63e1a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,11 +5,16 @@ on: push: branches: [main] +permissions: + contents: read + jobs: test: runs-on: macos-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-go@v5 with: go-version-file: extserver/go.mod diff --git a/extserver/extserver.go b/extserver/extserver.go index fd9f831..87e8468 100644 --- a/extserver/extserver.go +++ b/extserver/extserver.go @@ -19,16 +19,19 @@ package extserver import ( "context" + "errors" "flag" "fmt" "log" "os" "os/signal" "strings" + "sync" "syscall" "time" osquery "github.com/osquery/osquery-go" + gen "github.com/osquery/osquery-go/gen/osquery" ) const ( @@ -62,14 +65,14 @@ func Main(name, version string, plugins ...osquery.OsqueryPlugin) { log.Fatalf("error creating extension manager client: %s", err) } - server, err := newServer(name, version, *socket, client, timeout, plugins...) + server, gated, err := newServer(name, version, *socket, client, timeout, plugins...) if err != nil { client.Close() log.Fatalf("error creating extension manager: %s", err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - err = run(ctx, server, client, interval) + err = run(ctx, server, gated, interval) stop() client.Close() if err != nil { @@ -77,27 +80,54 @@ func Main(name, version string, plugins ...osquery.OsqueryPlugin) { } } -func newServer(name, version, socket string, client osquery.ExtensionManager, timeout time.Duration, plugins ...osquery.OsqueryPlugin) (*osquery.ExtensionManagerServer, error) { +// gate refuses registrations once shut, so none can begin after run starts +// shutting down. osquery-go only guards this for clients it creates itself. +type gate struct { + osquery.ExtensionManager + mu sync.Mutex + shut bool +} + +func (g *gate) RegisterExtension(info *gen.InternalExtensionInfo, registry gen.ExtensionRegistry) (*gen.ExtensionStatus, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.shut { + return nil, errors.New("shutting down") + } + return g.ExtensionManager.RegisterExtension(info, registry) +} + +// close waits out an in-flight registration (so Shutdown deregisters it) and +// refuses any later one. +func (g *gate) close() { + g.mu.Lock() + g.shut = true + g.mu.Unlock() +} + +func newServer(name, version, socket string, client osquery.ExtensionManager, timeout time.Duration, plugins ...osquery.OsqueryPlugin) (*osquery.ExtensionManagerServer, *gate, error) { + g := &gate{ExtensionManager: client} server, err := osquery.NewExtensionManagerServer( name, socket, - osquery.WithClient(client), + osquery.WithClient(g), osquery.ServerTimeout(timeout), osquery.ExtensionVersion(version), ) if err != nil { - return nil, err + return nil, nil, err } server.RegisterPlugin(plugins...) - return server, nil + return server, g, nil } // run registers the extension and serves until ctx is cancelled, osquery asks // it to shut down, or maxPingFailures consecutive pings fail. -func run(ctx context.Context, server *osquery.ExtensionManagerServer, client osquery.ExtensionManager, interval time.Duration) error { +func run(ctx context.Context, server *osquery.ExtensionManagerServer, client *gate, interval time.Duration) error { // Always deregister on the way out so osqueryd never has to reap a stale // registration itself. defer func() { + client.close() if err := server.Shutdown(context.Background()); err != nil { log.Println(err) } @@ -139,9 +169,6 @@ func run(ctx context.Context, server *osquery.ExtensionManagerServer, client osq // with our name it refuses us as a duplicate; its extension watcher normally // reaps the stale entry within a couple of --extensions_interval periods, so // retry in-process with backoff instead of exiting into a respawn loop. -// -// ponytail: a retry that races run's shutdown can register just before the -// process exits; osqueryd then reaps it like any dead extension. func start(ctx context.Context, server *osquery.ExtensionManagerServer, backoff time.Duration) error { for { err := server.Start() diff --git a/extserver/extserver_test.go b/extserver/extserver_test.go index f9ba627..9d0aafb 100644 --- a/extserver/extserver_test.go +++ b/extserver/extserver_test.go @@ -33,6 +33,7 @@ type fakeOsquery struct { mu sync.Mutex version string + live map[gen.ExtensionRouteUUID]bool // registered, not yet deregistered } func (f *fakeOsquery) Close() {} @@ -45,17 +46,33 @@ func (f *fakeOsquery) Ping() (*gen.ExtensionStatus, error) { } func (f *fakeOsquery) RegisterExtension(info *gen.InternalExtensionInfo, _ gen.ExtensionRegistry) (*gen.ExtensionStatus, error) { + status := f.register(int(f.registers.Add(1))) f.mu.Lock() + defer f.mu.Unlock() f.version = info.Version - f.mu.Unlock() - return f.register(int(f.registers.Add(1))), nil + if status.Code == 0 { + if f.live == nil { + f.live = map[gen.ExtensionRouteUUID]bool{} + } + f.live[status.UUID] = true + } + return status, nil } -func (f *fakeOsquery) DeregisterExtension(gen.ExtensionRouteUUID) (*gen.ExtensionStatus, error) { +func (f *fakeOsquery) DeregisterExtension(uuid gen.ExtensionRouteUUID) (*gen.ExtensionStatus, error) { f.deregisters.Add(1) + f.mu.Lock() + defer f.mu.Unlock() + delete(f.live, uuid) return &gen.ExtensionStatus{Code: 0, Message: "OK"}, nil } +func (f *fakeOsquery) leaked() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.live) +} + func registered(n int) *gen.ExtensionStatus { return &gen.ExtensionStatus{Code: 0, Message: "OK", UUID: gen.ExtensionRouteUUID(n)} } @@ -71,6 +88,12 @@ func healthy(int) error { return nil } // startRun runs the extension against f in the background and returns a // cancel func plus the channel run's result arrives on. func startRun(t *testing.T, f *fakeOsquery) (context.CancelFunc, <-chan error) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + return cancel, startRunCtx(t, f, ctx) +} + +func startRunCtx(t *testing.T, f *fakeOsquery, ctx context.Context) <-chan error { t.Helper() // The server listens on .; keep the path short enough for // a unix socket (macOS t.TempDir() paths are too long). @@ -82,15 +105,15 @@ func startRun(t *testing.T, f *fakeOsquery) (context.CancelFunc, <-chan error) { plugin := table.NewPlugin("t", []table.ColumnDefinition{table.TextColumn("c")}, func(context.Context, table.QueryContext) ([]map[string]string, error) { return nil, nil }) - server, err := newServer("test", testVersion, filepath.Join(dir, "em"), f, time.Second, plugin) + server, client, err := newServer("test", testVersion, filepath.Join(dir, "em"), f, time.Second, plugin) if err != nil { t.Fatal(err) } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(ctx) t.Cleanup(cancel) done := make(chan error, 1) - go func() { done <- run(ctx, server, f, testInterval) }() - return cancel, done + go func() { done <- run(ctx, server, client, testInterval) }() + return done } // waitFor polls cond until it holds, failing the test if run exits first. @@ -209,3 +232,20 @@ func TestRegistersVersion(t *testing.T) { t.Errorf("registered version %q, want %q", f.version, testVersion) } } + +// A registration must never complete after run has shut down: nothing would +// ever deregister it, recreating the stale entry this package exists to avoid. +func TestNoRegistrationAfterShutdown(t *testing.T) { + for i := 0; i < 100; i++ { + f := &fakeOsquery{ping: healthy, register: registered} + ctx, cancel := context.WithCancel(context.Background()) + cancel() // shut down immediately, racing the first registration + if err := <-startRunCtx(t, f, ctx); err != nil { + t.Fatalf("run returned %v, want nil", err) + } + time.Sleep(time.Millisecond) // let a straggling registration land + if n := f.leaked(); n != 0 { + t.Fatalf("iteration %d: %d registration(s) left behind after shutdown", i, n) + } + } +} diff --git a/extserver/go.mod b/extserver/go.mod index e7a462f..c38591d 100644 --- a/extserver/go.mod +++ b/extserver/go.mod @@ -6,7 +6,7 @@ require github.com/osquery/osquery-go v0.0.0-20260630173615-eb39ad3443df require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/apache/thrift v0.23.0 // indirect + github.com/apache/thrift v0.24.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/pkg/errors v0.8.0 // indirect diff --git a/extserver/go.sum b/extserver/go.sum index 58c9934..6687b39 100644 --- a/extserver/go.sum +++ b/extserver/go.sum @@ -1,7 +1,7 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/apache/thrift v0.23.0 h1:wKR6YnefQSEnxpEfmgTPuJibNG4bF0p2TK34tHLWi3s= -github.com/apache/thrift v0.23.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= +github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ= +github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=