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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ jobs:
go-version-file: go.mod
- run: go build ./...
- run: go test -race ./...
# The e2e tests are behind a build tag and so are never compiled by the
# run above; type-check them here so a changed signature cannot rot there
# unnoticed. They still only execute against a kind cluster.
- run: go vet -tags e2e ./...

lint:
name: Lint (golangci-lint)
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ SPOOL_READER_IMAGE ?= busybox:1.37
build: ## Build the agent binary into bin/
go build -ldflags '$(LDFLAGS)' -o bin/agent ./cmd/agent

test: ## Run all tests with the race detector
test: ## Run all tests with the race detector, and type-check the e2e-tagged ones
go test -race ./...
# The e2e tests are behind a build tag, so `go test ./...` never compiles
# them and a changed signature can rot there unnoticed. Type-check them on
# every run; they still only execute against a kind cluster.
go vet -tags e2e ./...

lint: ## Run golangci-lint
golangci-lint run
Expand Down
10 changes: 7 additions & 3 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
if targetsPublisher != nil {
targeter = nodeTargeter{publisher: targetsPublisher, pw: podWatcher}
}
intake, err := buildNodeIntake(logger, restConfig, cfg.NodeIntake, onReport, onProfile, targeter)
intake, err := buildNodeIntake(logger, restConfig, cfg.NodeIntake, onReport, onProfile, targeter, podWatcher)
if err != nil {
return fmt.Errorf("configuring node intake: %w", err)
}
Expand Down Expand Up @@ -458,7 +458,7 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
// by the cluster JWKS (fetched over the API server's in-cluster transport, no
// TokenReview) and an HTTP server that decodes node reports and hands each to
// onReport (which joins it into the Go inventory, ADR 0010).
func buildNodeIntake(logger *slog.Logger, restConfig *rest.Config, cfg config.NodeIntake, onReport func(nodeauth.Identity, nodescan.Report), onProfile func(nodeauth.Identity, nodeintake.ProfileReport), targeter nodeintake.NodeTargeter) (*nodeintake.Server, error) {
func buildNodeIntake(logger *slog.Logger, restConfig *rest.Config, cfg config.NodeIntake, onReport func(nodeauth.Identity, nodescan.Report), onProfile func(nodeauth.Identity, nodeintake.ProfileReport), targeter nodeintake.NodeTargeter, scoper nodeintake.NodeScoper) (*nodeintake.Server, error) {
addr := cfg.ListenAddress
if addr == "" {
addr = config.DefaultNodeIntakeListenAddress
Expand All @@ -485,9 +485,13 @@ func buildNodeIntake(logger *slog.Logger, restConfig *rest.Config, cfg config.No
if targeter != nil {
targetsHandler = nodeintake.NewTargetsHandler(verifier, logger, targeter)
}
// The scope endpoint is always mounted when node intake is on: without it a
// node cannot know which pods pass the customer's filters, and so — failing
// closed — scans nothing at all (ADR 0015).
scopeHandler := nodeintake.NewScopeHandler(verifier, logger, scoper)
logger.Info("node intake enabled", "addr", addr, "audience", audience,
"expected_subject", cfg.ExpectedSubject, "targeting", targeter != nil)
return nodeintake.NewServer(addr, handler, profileHandler, targetsHandler, logger), nil
return nodeintake.NewServer(addr, handler, profileHandler, targetsHandler, scopeHandler, logger), nil
}

// nodeTargeter answers a node's targets query: it intersects the publisher's
Expand Down
29 changes: 28 additions & 1 deletion cmd/agent/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func runNode(ctx context.Context, logger *slog.Logger, args []string) error {
enableEBPF := fs.Bool("enable-ebpf", false, "master switch for the eBPF CPU profiler (ADR 0011); when set, the node checks eBPF readiness at startup and refuses gracefully if the kernel cannot support it")
sysRoot := fs.String("sys", "/sys", "sysfs root used to check kernel BTF at <sys>/kernel/btf/vmlinux")
configPath := fs.String("config", "", "path to the agent configuration file (YAML); supplies the eBPF profiling config (ADR 0011)")
scopeEndpoint := fs.String("scope-endpoint", "", "controller URL to query for the pods this node may scan (ADR 0015); empty means the node scans nothing — it cannot honor namespace filters on its own")
targetsEndpoint := fs.String("targets-endpoint", "", "controller URL to query for profiling targets (ADR 0011); empty disables targeting")
profileEndpoint := fs.String("profile-endpoint", "", "controller URL to ship captured profiles to (ADR 0011); empty runs capture-only")
if err := fs.Parse(args); err != nil {
Expand All @@ -57,6 +58,7 @@ func runNode(ctx context.Context, logger *slog.Logger, args []string) error {
// and container ID, not node name).
node := os.Getenv("NODE_NAME")
shipper := newReportShipper(*endpoint, *tokenPath, node)
scoper := newScopeClient(*scopeEndpoint, *tokenPath)

scanner := nodescan.NewScanner(*procRoot, nodescan.NewModuleFilter(nodescan.DefaultInfraModulePrefixes))
logger.Info("node scanner starting",
Expand Down Expand Up @@ -93,8 +95,31 @@ func runNode(ctx context.Context, logger *slog.Logger, args []string) error {
}
}

// scanScope asks the controller which pods on this node passed the
// customer's namespace filters and opt-out annotations. It is fetched per
// pass, and it fails closed: with no endpoint configured, or a controller
// that cannot be reached, the node scans no process's executable at all
// (ADR 0015). The node cannot decide eligibility itself — its cgroup gives
// it a pod UID, never a namespace — so scanning without an answer would
// break the promise in docs/security.md §10.2. A skipped pass costs
// nothing: the next one re-scans (loss-harmless, ADR 0003).
scanScope := func() nodescan.Scope {
if scoper == nil {
logger.Warn("no scope endpoint configured; scanning nothing",
"hint", "set -scope-endpoint so the controller can supply the pods this node may scan")
return nodescan.DenyAll()
}
uids, err := scoper.fetch(ctx, node)
if err != nil {
logger.Error("fetching scan scope failed; scanning nothing this pass", "error", err)
return nodescan.DenyAll()
}
return nodescan.NewScope(uids)
}

scanOnce := func() {
res, err := scanner.Scan()
scope := scanScope()
res, err := scanner.Scan(scope)
if err != nil {
// A failure to read the process tree at all (not an individual
// unreadable binary). Log and try again next tick.
Expand All @@ -118,7 +143,9 @@ func runNode(ctx context.Context, logger *slog.Logger, args []string) error {
// invariant 6, docs/security.md §8).
logger.Info("scan coverage",
"processes_scanned", res.Counters.ProcessesScanned,
"pods_in_scope", scope.Size(),
"go_found", res.Counters.GoFound,
"filtered_scope", res.Counters.FilteredScope,
"filtered_infra", res.Counters.FilteredInfra,
"unreadable", res.Counters.Unreadable,
)
Expand Down
58 changes: 58 additions & 0 deletions cmd/agent/nodeship.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,64 @@ func (c *targetsClient) fetch(ctx context.Context, node string) ([]string, error
return out.ContainerIDs, nil
}

// scopeClient asks the controller which pods on this node passed the customer's
// filters (ADR 0015). The node cannot answer this itself: it has no API access
// and its cgroup gives it a pod UID, never a namespace. Like the targets query,
// the reply can only narrow what the node does — an absent or failed reply means
// the node scans nothing.
type scopeClient struct {
endpoint string
tokenPath string
client *http.Client
}

// newScopeClient builds a client for endpoint, or returns nil when empty. A nil
// client is not "scan everything": the caller treats it as a denying scope.
func newScopeClient(endpoint, tokenPath string) *scopeClient {
if endpoint == "" {
return nil
}
return &scopeClient{
endpoint: endpoint,
tokenPath: tokenPath,
client: &http.Client{Timeout: 30 * time.Second},
}
}

// fetch queries the controller with this node's name and returns the UIDs of the
// pods it may scan.
func (c *scopeClient) fetch(ctx context.Context, node string) ([]string, error) {
token, err := readProjectedToken(c.tokenPath)
if err != nil {
return nil, fmt.Errorf("reading controller token: %w", err)
}
body, err := json.Marshal(nodeintake.ScopeRequest{Node: node})
if err != nil {
return nil, fmt.Errorf("encoding scope query: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) // #nosec G704 -- endpoint is operator-set config, not tainted input
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)

resp, err := c.client.Do(req) // #nosec G704 -- endpoint is operator-set config, not tainted input
if err != nil {
return nil, fmt.Errorf("querying scope: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
return nil, fmt.Errorf("controller rejected scope query: status %d", resp.StatusCode)
}
var out nodeintake.ScopeResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&out); err != nil {
return nil, fmt.Errorf("decoding scope: %w", err)
}
return out.PodUIDs, nil
}

// profileShipper delivers one captured eBPF CPU profile to the controller's
// profile endpoint (ADR 0011 §5.5), over the same node-initiated, projected-
// token transport as the inventory shipper. Only already allow-list-filtered
Expand Down
138 changes: 138 additions & 0 deletions cmd/agent/nodeship_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package main

import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/RebuildStackCo/runtime-agent/internal/nodeintake"
)

func TestScopeClientFetch(t *testing.T) {
var gotReq nodeintake.ScopeRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
t.Errorf("auth = %q, want Bearer tok", got)
}
_ = json.NewDecoder(r.Body).Decode(&gotReq)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(nodeintake.ScopeResponse{PodUIDs: []string{"uid-a", "uid-b"}})
}))
t.Cleanup(srv.Close)

c := newScopeClient(srv.URL, writeToken(t, "tok"))
uids, err := c.fetch(context.Background(), "kind-worker")
if err != nil {
t.Fatal(err)
}
if gotReq.Node != "kind-worker" {
t.Errorf("query node = %q, want kind-worker", gotReq.Node)
}
if len(uids) != 2 || uids[0] != "uid-a" {
t.Errorf("pod uids = %+v, want [uid-a uid-b]", uids)
}
}

func TestScopeClientNilWhenNoEndpoint(t *testing.T) {
if newScopeClient("", "/x") != nil {
t.Error("expected nil client for empty endpoint")
}
}

func TestScopeClientRejectsNon2xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)

c := newScopeClient(srv.URL, writeToken(t, "tok"))
if _, err := c.fetch(context.Background(), "n"); err == nil {
t.Error("expected an error for a non-2xx scope reply")
}
}

// runNodeOnce runs the node role for a single pass and returns its log output.
func runNodeOnce(t *testing.T, args ...string) string {
t.Helper()
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, nil))
done := make(chan error, 1)
go func() {
done <- runNode(context.Background(), logger, append([]string{"-proc", t.TempDir(), "-interval", "0"}, args...))
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("runNode returned error: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("runNode single pass did not return")
}
return buf.String()
}

// With no scope endpoint the node cannot know which pods passed the customer's
// filters, so it must scan nothing rather than scan everything (ADR 0015). The
// warning is part of the behavior: an operator who misconfigures the flag must
// be able to see why no inventory appears.
func TestRunNodeWithoutScopeEndpointScansNothing(t *testing.T) {
out := runNodeOnce(t)

if !strings.Contains(out, "no scope endpoint configured") {
t.Errorf("log does not explain the empty scope:\n%s", out)
}
if !strings.Contains(out, "pods_in_scope=0") {
t.Errorf("log does not report an empty scope:\n%s", out)
}
}

// The scope the controller answers with is what the scanner is given: the flag,
// the client, and the scanner are wired end to end.
func TestRunNodeUsesTheControllerScope(t *testing.T) {
var gotNode string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req nodeintake.ScopeRequest
_ = json.NewDecoder(r.Body).Decode(&req)
gotNode = req.Node
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(nodeintake.ScopeResponse{PodUIDs: []string{"uid-a", "uid-b", "uid-c"}})
}))
t.Cleanup(srv.Close)
t.Setenv("NODE_NAME", "kind-worker")

out := runNodeOnce(t, "-scope-endpoint", srv.URL, "-token-path", writeToken(t, "tok"))

if gotNode != "kind-worker" {
t.Errorf("controller was asked for node %q, want kind-worker", gotNode)
}
if !strings.Contains(out, "pods_in_scope=3") {
t.Errorf("scanner did not receive the controller's scope:\n%s", out)
}
}

// A controller that cannot be reached fails closed, not open: one pass is lost,
// which the next recovers (loss-harmless), and no process is scanned meanwhile.
func TestRunNodeFailsClosedWhenScopeQueryFails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)

out := runNodeOnce(t, "-scope-endpoint", srv.URL, "-token-path", writeToken(t, "tok"))

if !strings.Contains(out, "fetching scan scope failed") {
t.Errorf("log does not report the failed scope query:\n%s", out)
}
if !strings.Contains(out, "pods_in_scope=0") {
t.Errorf("a failed scope query must leave the scope empty:\n%s", out)
}
}
5 changes: 5 additions & 0 deletions deploy/node-daemonset-ebpf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ spec:
- /sys
- -controller-endpoint
- http://runtime-agent-controller.runtime-agent.svc:8080/v1/node-inventory
# Which pods on this node passed the customer's namespace filters
# and opt-out annotations (ADR 0015); without it the scanner fails
# closed and scans nothing.
- -scope-endpoint
- http://runtime-agent-controller.runtime-agent.svc:8080/v1/node-scope
# Where to ask which containers to profile, and where to ship the
# captured, filtered profiles (ADR 0011 §3, §5.5). Same controller
# Service, same projected-token auth, different paths.
Expand Down
6 changes: 6 additions & 0 deletions deploy/node-daemonset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ spec:
# the single in-cluster target. Remove this arg to run log-only.
- -controller-endpoint
- http://runtime-agent-controller.runtime-agent.svc:8080/v1/node-inventory
# Which pods on this node passed the customer's namespace filters
# and opt-out annotations (ADR 0015). The node has no API access and
# its cgroup yields a pod UID, never a namespace, so it must ask.
# Without this it fails closed and scans nothing.
- -scope-endpoint
- http://runtime-agent-controller.runtime-agent.svc:8080/v1/node-scope
env:
# The node name for the report; the controller keys facts by pod UID
# and container ID, so this is for observability, not attribution.
Expand Down
Loading