diff --git a/.golangci-comprehensive.yml b/.golangci-comprehensive.yml index 7a3d74b97..76f5403cc 100644 --- a/.golangci-comprehensive.yml +++ b/.golangci-comprehensive.yml @@ -37,6 +37,8 @@ run: - dest_s3parquet - enrich_asn - enrich_locality + - capture + - dest_file formatters: enable: diff --git a/.golangci-quick.yml b/.golangci-quick.yml index 5f0f55d18..e130f29b3 100644 --- a/.golangci-quick.yml +++ b/.golangci-quick.yml @@ -31,6 +31,8 @@ run: - dest_s3parquet - enrich_asn - enrich_locality + - capture + - dest_file formatters: enable: diff --git a/.golangci.yml b/.golangci.yml index b38645ded..406c3131d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -28,6 +28,8 @@ run: - dest_s3parquet - enrich_asn - enrich_locality + - capture + - dest_file formatters: enable: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7302ef8..c5a955448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Breaking + +- **The debug writers are build-time opt-ins.** The netlink capture writer (`-writeFiles` / `-capturePath`) is compiled in only with `-tags capture`, which no flavor or image sets; use `nix build .#xtcp2-capture`. The `file:` destination needs `-tags dest_file` and is in the full (development) flavor only, so `xtcp2-` slim flavors and their images no longer have it. Asking for either on a binary without it exits 64 at startup with the tag to rebuild with; `ConfigService.Set` refuses it with `FailedPrecondition`. A capture build reports `+capture` in its version line, and when armed logs a warning and sets `xtcp2_debug_capture_enabled 1`. + ## 0.2.1 (2026-11) Fixes for what the first fleet rollout of 0.2.0 found: the link monitor baseline, the uplink columns, and container attribution. No schema, flag-default or `XtcpConfig` change; 0.2.0 clients and consumers work unchanged. diff --git a/cmd/xtcp2/exit.go b/cmd/xtcp2/exit.go new file mode 100644 index 000000000..39cb7bc9e --- /dev/null +++ b/cmd/xtcp2/exit.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + + "github.com/randomizedcoder/xtcp2/pkg/xtcp" +) + +// Process exit codes. Configuration the operator must change exits with +// exitUsage (sysexits EX_USAGE), so a supervisor can tell "fix the command +// line" from a runtime failure (exitFailure). +const ( + exitOK = 0 + exitFailure = 1 + exitUsage = 64 +) + +// exitCodeFor maps a startup error to a process exit code. +func exitCodeFor(err error) int { + switch { + case err == nil: + return exitOK + case errors.Is(err, xtcp.ErrNotCompiledIn): + return exitUsage + default: + return exitFailure + } +} diff --git a/cmd/xtcp2/exit_test.go b/cmd/xtcp2/exit_test.go new file mode 100644 index 000000000..132595eb2 --- /dev/null +++ b/cmd/xtcp2/exit_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/randomizedcoder/xtcp2/gen/go/xtcp_config" + "github.com/randomizedcoder/xtcp2/pkg/xtcp" +) + +func TestExitCodeFor(t *testing.T) { + t.Parallel() + cases := []struct { + name string + category string + description string + err error + expected int + }{ + {"nil", "positive", "no error exits 0", nil, exitOK}, + {"not_compiled_in", "negative", "a feature not in this build is a usage error", + xtcp.ErrNotCompiledIn, exitUsage}, + {"wrapped_not_compiled_in", "boundary", "the sentinel is found through wrapping", + fmt.Errorf("-writeFiles 5: %w", xtcp.ErrNotCompiledIn), exitUsage}, + {"other_error", "corner", "any other error is a plain failure", + errors.New("boom"), exitFailure}, + } + for _, tc := range cases { + t.Run(tc.category+"/"+tc.name, func(t *testing.T) { + t.Parallel() + if got := exitCodeFor(tc.err); got != tc.expected { + t.Errorf("%s: exit = %d, want %d", tc.description, got, tc.expected) + } + }) + } +} + +// TestRunMain_debugWritersNotCompiledIn drives the startup refusal of the +// build-tag-gated debug writers. Each row's expectation follows what the test +// binary was built with, so the table holds under every flavor leg: without +// the tag the run is a usage error before the daemon starts; with it the +// (stubbed) daemon runs. +func TestRunMain_debugWritersNotCompiledIn(t *testing.T) { + fileIn := xtcp.CheckBuildSupport(&xtcp_config.XtcpConfig{Dest: "file:/x"}) == nil + cases := []struct { + name string + category string + description string + args []string + compiledIn bool + }{ + {"capture_not_compiled", "negative", "-writeFiles 5 needs -tags capture", + []string{"-dest", "null", "-writeFiles", "5"}, xtcp.CaptureCompiledIn}, + {"file_not_compiled", "negative", "-dest file: needs -tags dest_file", + []string{"-dest", "file:" + filepath.Join(t.TempDir(), "out.jsonl"), "-marshal", "jsonl"}, fileIn}, + {"writefiles_one", "boundary", "the smallest capture budget is refused too", + []string{"-dest", "null", "-writeFiles", "1"}, xtcp.CaptureCompiledIn}, + {"writefiles_zero_default", "corner", "-writeFiles 0 starts on every build", + []string{"-dest", "null", "-writeFiles", "0"}, true}, + {"stderr_dest", "positive", "the stderr destination needs no tag", + []string{"-dest", "stderr", "-marshal", "jsonl"}, true}, + } + for _, tc := range cases { + t.Run(tc.category+"/"+tc.name, func(t *testing.T) { + envHelperReset(t) + prevArgs := os.Args + os.Args = append([]string{"xtcp2"}, tc.args...) + t.Cleanup(func() { os.Args = prevArgs }) + + prevProm := promHandlerStarter + promHandlerStarter = func(context.Context, *sync.WaitGroup, promParams) error { return nil } + t.Cleanup(func() { promHandlerStarter = prevProm }) + + prevDaemon := daemonRunner + started := false + daemonRunner = func(context.Context, context.CancelFunc, *xtcp_config.XtcpConfig) { started = true } + t.Cleanup(func() { daemonRunner = prevDaemon }) + + wantRC, wantStarted := exitUsage, false + if tc.compiledIn { + wantRC, wantStarted = exitOK, true + } + captureLog(t, func() { + if rc := runMain(t.Context()); rc != wantRC { + t.Errorf("%s: rc = %d, want %d", tc.description, rc, wantRC) + } + }) + if started != wantStarted { + t.Errorf("%s: daemon started = %v, want %v", tc.description, started, wantStarted) + } + }) + } +} + +// TestVersionString_captureSuffix: a binary carrying the capture writer says +// so in its version line; every other binary does not. +func TestVersionString_captureSuffix(t *testing.T) { + if got := strings.HasSuffix(versionString(), "+capture"); got != xtcp.CaptureCompiledIn { + t.Errorf("version %q: +capture suffix = %v, want %v", versionString(), got, xtcp.CaptureCompiledIn) + } +} diff --git a/cmd/xtcp2/testdata/defineflags-golden.txt b/cmd/xtcp2/testdata/defineflags-golden.txt index 504359b64..0b2f3fc4f 100644 --- a/cmd/xtcp2/testdata/defineflags-golden.txt +++ b/cmd/xtcp2/testdata/defineflags-golden.txt @@ -121,5 +121,5 @@ topic xtcp Kafka or NSQ topic uplinkCount 2 number of uplink slots to populate for LLDP/NIC enrichment (hosts are typically dual-homed = 2; max 2). Falls back to UPLINK_COUNT env. uplinkInterfaces comma-separated explicit uplink interface names (e.g. "eth0,eth1") overriding default-route auto-detection for -enrichLldp/-enrichNic. Falls back to UPLINK_INTERFACES env. v false show version -writeFiles 0 Write netlink packets to writeFiles number of files ( to generate test data ) per netlinker +writeFiles 0 Write netlink packets to writeFiles number of files ( to generate test data ) per netlinker. Debugging only: needs a binary built with -tags capture xtcpProtoFile /xtcp_flat_record.proto xtcpProtoFile for registering with the schema registry diff --git a/cmd/xtcp2/xtcp2.go b/cmd/xtcp2/xtcp2.go index eb50a07fd..ea840421d 100644 --- a/cmd/xtcp2/xtcp2.go +++ b/cmd/xtcp2/xtcp2.go @@ -450,7 +450,7 @@ func defineFlags() *mainFlags { // packetSize of the buffer the netlinkers syscall.Recvfrom to read into f.packetSize = flag.Uint64("packetSize", packetSizeCst, "netlinker packetSize. buffer size = packetSize * packetSizeMply. Use zero (0) for syscall.Getpagesize()") f.packetSizeMply = flag.Uint("packetSizeMply", packetSizeMplyCst, "netlinker packetSize multiplier. buffer size = packetSize * packetSizeMply") - f.writeFiles = flag.Uint("writeFiles", WriteFilesCst, "Write netlink packets to writeFiles number of files ( to generate test data ) per netlinker") + f.writeFiles = flag.Uint("writeFiles", WriteFilesCst, "Write netlink packets to writeFiles number of files ( to generate test data ) per netlinker. Debugging only: needs a binary built with -tags capture") f.capturePath = flag.String("capturePath", capturePathCst, "Write files path") f.modulus = flag.Uint64("modulus", modulusCst, "modulus. Report every X inetd messages to output") f.marshal = flag.String("marshal", marshalCst, "Marshaling of the exported data (protobufList, protoJson, protoText, msgpack, jsonl, csv, tsv)") @@ -950,8 +950,17 @@ func startPyroscope(url, appName string, sampleHz, uploadSec uint, debugLevel ui // same package, called from tests) so the version-flag path is testable // without a subprocess. func versionString() string { - return fmt.Sprintf("xtcp commit:%s\tdate(UTC):%s\tversion:%s", - commit, date, version) + return fmt.Sprintf("xtcp commit:%s\tdate(UTC):%s\tversion:%s%s", + commit, date, version, buildSuffix()) +} + +// buildSuffix marks a binary that carries the netlink capture writer, so a +// debug build is recognizable from its version line. +func buildSuffix() string { + if xtcp.CaptureCompiledIn { + return "+capture" + } + return "" } // prepareConfig runs the env-override + validation portion of main() up @@ -1068,6 +1077,15 @@ func runMain(ctx context.Context) int { } } + // Refuse a config that needs a debug writer this binary was built + // without (netlink capture, the file destination), before anything + // binds or starts. Checked after the soft-restart carry so that config + // is covered too. + if err := xtcp.CheckBuildSupport(c); err != nil { + fmt.Fprintln(os.Stderr, "xtcp2:", err) + return exitCodeFor(err) + } + environmentOverrideGoMaxProcs(f.goMaxProcs, debugLevel) if runtime.NumCPU() > int(*f.goMaxProcs) { mp := runtime.GOMAXPROCS(int(*f.goMaxProcs)) diff --git a/docs/build-flavors.md b/docs/build-flavors.md index eb1864b72..686c1e8ca 100644 --- a/docs/build-flavors.md +++ b/docs/build-flavors.md @@ -6,7 +6,14 @@ xtcp2 binaries are built along three orthogonal axes, so you can produce anythin 2. **Destination flavor** — which message-destination clients are compiled in: `full` / `min` / `kafka` / `nats` / `nsq` / `valkey` / `s3parquet` (`nix/versions.nix` → `destinationFlavors`). 3. **Enrichment flavor** — whether the two heavyweight enrichers are compiled in: `none` / `asn` / `locality` / `enrich` (both) (`nix/versions.nix` → `enrichmentFlavors`). -Stdlib destinations (`null`, `stdout`, `stderr`, `file`, `tcp`, `http`, `https`, `udp`, `unix`, `unixgram`) are **always** compiled regardless of flavor. Only the library destinations (`kafka`, `nats`, `nsq`, `valkey`, `s3parquet`) are gated, by `//go:build dest_`. +Stdlib destinations (`null`, `stdout`, `stderr`, `tcp`, `http`, `https`, `udp`, `unix`, `unixgram`) are **always** compiled regardless of flavor. The library destinations (`kafka`, `nats`, `nsq`, `valkey`, `s3parquet`) are gated, by `//go:build dest_`. + +Two **debug writers** are gated too, because they write to the filesystem wherever configuration points them: + +- **`file:` destination** (`//go:build dest_file`): compiled into the full (development) flavor only — `xtcp2`, `xtcp2-full-*` and the fat images. No slim flavor or slim image has it. +- **Netlink capture writer** (`-writeFiles` / `-capturePath`, `//go:build capture`): writes raw netlink replies, which hold every socket's addresses, to disk. No flavor or image carries it; build `nix build .#xtcp2-capture` (the development build plus `capture`) when you need fixtures. A capture build says so in its version line (`version:…+capture`), and when armed it logs a warning and sets `xtcp2_debug_capture_enabled 1`. + +Asking a binary for a debug writer it lacks (`-writeFiles 1`, `-dest file:…`) exits with status 64 before anything starts, naming the tag to rebuild with; `ConfigService.Set` refuses the same config with `FailedPrecondition`. `nix flake check` runs `flavor-tags`, which fails if a slim flavor carries either tag. Likewise, most metadata enrichers (container, LLDP, NIC, nsid) are **always** compiled and merely toggled at runtime — each is pure Go with no third-party dependency, so carrying them costs nothing. Only **ASN** and **locality** are build-tag gated, by `//go:build enrich_asn` / `//go:build enrich_locality`: `pkg/ipasn` pulls in `parquet-go` and `gaissmai/bart`, and `pkg/localnet` pulls in `bart`. Left unconditional they would land in `xtcp2-min`, whose entire purpose is to be the stdlib-only slim daemon. @@ -241,7 +248,9 @@ The absent ones are published as `0` deliberately: a binary built without the ta | File | Build tag | Compiled in | |---|---|---| -| `destinations_core.go`, `destinations_null.go`, `destinations_udp.go`, `destinations_unix.go`, `destinations_unixgram.go` | (none) | always | +| `destinations_core.go`, `destinations_null.go`, `destinations_stderr.go`, `destinations_udp.go`, `destinations_unix.go`, `destinations_unixgram.go` | (none) | always | +| `destinations_file.go` | `//go:build dest_file` | only with `-tags dest_file` (full flavor) | +| `netlinker_capture.go` / `netlinker_nocapture.go` | `//go:build capture` / `!capture` | the capture writer, or its no-op stub | | `destinations_kafka.go` | `//go:build dest_kafka` | only with `-tags dest_kafka` | | `destinations_nats.go` | `//go:build dest_nats` | only with `-tags dest_nats` | | `destinations_nsq.go` | `//go:build dest_nsq` | only with `-tags dest_nsq` | diff --git a/docs/netlink/collection.md b/docs/netlink/collection.md index 18eb5dfbc..846e13a0d 100644 --- a/docs/netlink/collection.md +++ b/docs/netlink/collection.md @@ -252,7 +252,7 @@ Netlink dump replies can be large, so the receive buffer is tunable. The buffer | `-packetSizeMply` | — | Buffer multiplier; buffer = `packetSize × packetSizeMply`. | | `-nlmsgSeq` | — | Starting netlink message sequence number (uint32). | | `-modulus` | — | Report every Nth inet_diag message to output (sampling/debug). | -| `-writeFiles` / `-capturePath` | — | Dump raw netlink packets to files for generating test data. | +| `-writeFiles` / `-capturePath` | `0` / `./` | Dump raw netlink packets to files for generating test data. Debugging only: compiled in only with `-tags capture` (`nix build .#xtcp2-capture`); other builds refuse `-writeFiles > 0` with exit 64. The files hold every socket's addresses. | ## See also diff --git a/docs/output-and-destinations.md b/docs/output-and-destinations.md index b17983f67..d767e8808 100644 --- a/docs/output-and-destinations.md +++ b/docs/output-and-destinations.md @@ -50,8 +50,8 @@ The `protobufList` format is the production wire format: it frames each Envelope | `valkey` | `valkey:valkey:6379` | `dest_valkey` | `destinations_valkey.go` | | `s3parquet` | `s3parquet:...` | `dest_s3parquet` | `destinations_s3parquet.go` | | `stdout` | `stdout` | *(always built)* | `destinations_stdout.go` | -| `stderr` | `stderr` | *(always built)* | `destinations_file.go` | -| `file` | `file:/var/log/xtcp.jsonl` | *(always built)* | `destinations_file.go` | +| `stderr` | `stderr` | *(always built)* | `destinations_stderr.go` | +| `file` | `file:/var/log/xtcp.jsonl` | `dest_file` (full flavor only) | `destinations_file.go` | | `tcp` | `tcp:127.0.0.1:9000` | *(always built)* | `destinations_tcp.go` | | `http` / `https` | `http://host:8080/ingest` | *(always built)* | `destinations_http.go` | | `udp` | `udp:127.0.0.1:13000` | *(always built)* | `destinations_udp.go` | @@ -59,7 +59,7 @@ The `protobufList` format is the production wire format: it frames each Envelope | `unixgram` | `unixgram:/tmp/xtcp.sock` | *(always built)* | `destinations_unixgram.go` | | `null` | `null` | *(always built)* | `destinations_null.go` | -The stdlib destinations (`stdout`, `stderr`, `file`, `tcp`, `http`/`https`, `udp`, `unix`, `unixgram`, `null`) are always compiled in; the library-backed ones (`kafka`, `nats`, `nsq`, `valkey`, `s3parquet`) are only present when their build tag is set. `null` discards output and is handy for benchmarking the collection path in isolation. +The stdlib destinations (`stdout`, `stderr`, `tcp`, `http`/`https`, `udp`, `unix`, `unixgram`, `null`) are always compiled in; the library-backed ones (`kafka`, `nats`, `nsq`, `valkey`, `s3parquet`) are only present when their build tag is set. `file` is a debug writer: it is in the full (development) flavor only, behind `dest_file`, so production flavors cannot be configured to write records to disk. `null` discards output and is handy for benchmarking the collection path in isolation. Notes on the stream sinks: @@ -101,6 +101,7 @@ These exercise the analysis-friendly formats and sinks. xtcp2 needs `CAP_NET_ADM sudo ./result/bin/xtcp2 -dest stdout -marshal jsonl -d 1 | jq . # CSV of just the columns you care about, into a file → open in DuckDB/R +# (file: needs the full flavor, e.g. `nix build .#xtcp2`) sudo ./result/bin/xtcp2 -dest file:/tmp/socks.csv -marshal csv -d 1 \ -columns hostname,inetDiagMsgSocketSource,inetDiagMsgSocketSourcePort,inetDiagMsgState,inetDiagCongEnum,tcpInfoRtt duckdb -c "select inetDiagMsgState, count(*) from '/tmp/socks.csv' group by 1" diff --git a/nix/binaries.nix b/nix/binaries.nix index e56524938..3519f80c8 100644 --- a/nix/binaries.nix +++ b/nix/binaries.nix @@ -271,6 +271,23 @@ let # `enrichments = [ ]` is there for exactly the same reason: untagged host # tests don't compile the enrich_asn / enrich_locality files either, so # including them here would add blocks the host profile can never cover. + # The netlink capture writer (-writeFiles / -capturePath) is a debugging + # tool that writes raw netlink replies — every socket's addresses — to disk. + # It is compiled in only here; no flavor or image carries the tag (see + # versions.optInOnlyTags and nix/checks/flavor-tags.nix). Full destinations + # and enrichers otherwise, so it is the development build plus capture. + xtcp2-capture = mkGoBinary { + name = "xtcp2"; + inherit + src + commit + date + version + ; + variant = "default"; + extraTags = [ "capture" ]; + }; + xtcp2-cover = mkGoBinary { name = "xtcp2"; inherit @@ -298,6 +315,9 @@ defaultBinaries # Coverage-instrumented xtcp2 for the microvm coverage harness. inherit xtcp2-cover; + # Development build with the netlink capture writer compiled in. + inherit xtcp2-capture; + # Joined builds. xtcp2-all = joins.default; xtcp2-all-debug = joins.debug; diff --git a/nix/checks/default.nix b/nix/checks/default.nix index 11d6ed084..517147053 100644 --- a/nix/checks/default.nix +++ b/nix/checks/default.nix @@ -95,6 +95,9 @@ in }; go-sec = import ./go-sec.nix { inherit pkgs vendoredSource; }; + # Slim flavors must not carry the debug writers (dest_file, capture). + flavor-tags = import ./flavor-tags.nix { inherit pkgs lib binaries; }; + # The lint ratchet, and the only check here that is GREEN at baseline. The # three tier checks above report a tier's whole finding list, which has been # non-empty for long enough that their red carries no information; this one diff --git a/nix/checks/flavor-tags.nix b/nix/checks/flavor-tags.nix new file mode 100644 index 000000000..2667a7a76 --- /dev/null +++ b/nix/checks/flavor-tags.nix @@ -0,0 +1,65 @@ +# nix/checks/flavor-tags.nix +# +# Every slim (production) xtcp2 flavor must leave out the debug writers: the +# file destination (`dest_file`, full flavor only) and the netlink capture +# writer (`capture`, the dedicated xtcp2-capture attr only). The full flavor +# must carry `dest_file`, and xtcp2-capture must carry `capture`, so the check +# also proves the tags are wired at all. +# +# Reads the build tags Go records in each binary (`go version -m`), which +# survive -s -w and strip, rather than symbol names. +# +{ + pkgs, + lib, + binaries, +}: + +let + versions = import ../versions.nix { inherit pkgs; }; + + debugTags = map (d: "dest_${d}") versions.debugDestinations ++ versions.optInOnlyTags; + + slim = lib.concatMap ( + dest: + map (enrich: { + name = "${dest}-${enrich}"; + bin = binaries.xtcp2OnlyByFlavor.${dest}.${enrich}; + }) (builtins.attrNames versions.enrichmentFlavors) + ) (lib.remove "full" (builtins.attrNames versions.destinationFlavors)); +in +pkgs.runCommand "xtcp2-flavor-tags" + { + nativeBuildInputs = [ versions.go ]; + } + '' + export HOME=$(mktemp -d) + fail=0 + tags_of() { + go version -m "$1/bin/xtcp2" | sed -n 's/^[[:space:]]*build[[:space:]]*-tags=//p' + } + has_tag() { + case ",$1," in *",$2,"*) return 0 ;; esac + return 1 + } + + ${lib.concatMapStrings (f: '' + t=$(tags_of ${f.bin}) + for d in ${lib.concatStringsSep " " debugTags}; do + if has_tag "$t" "$d"; then + echo "FAIL: slim flavor ${f.name} carries debug tag $d (tags: $t)" + fail=1 + fi + done + '') slim} + + t=$(tags_of ${binaries.xtcp2}) + has_tag "$t" dest_file || { echo "FAIL: full flavor lacks dest_file (tags: $t)"; fail=1; } + has_tag "$t" capture && { echo "FAIL: full flavor carries capture (tags: $t)"; fail=1; } + + t=$(tags_of ${binaries.xtcp2-capture}) + has_tag "$t" capture || { echo "FAIL: xtcp2-capture lacks capture (tags: $t)"; fail=1; } + + [ "$fail" = 0 ] || exit 1 + echo "ok: ${toString (builtins.length slim)} slim flavors free of: ${lib.concatStringsSep " " debugTags}" > $out + '' diff --git a/nix/lib/mkGoBinary.nix b/nix/lib/mkGoBinary.nix index 4cc7b50dc..9233f4d4c 100644 --- a/nix/lib/mkGoBinary.nix +++ b/nix/lib/mkGoBinary.nix @@ -66,6 +66,10 @@ in # coverage harness (nix/microvms/) to capture integration-test # coverage that unit tests alone can't reach. coverage ? false, + # Extra build tags beyond the destination and enrichment axes, e.g. + # [ "capture" ] for the netlink capture writer. Each tag is appended to the + # pname so the derivation is distinct from the plain build. + extraTags ? [ ], # When coverage=true, the comma-separated package patterns whose code # gets instrumented. Defaults to the full xtcp2 namespace. coverPkg ? "github.com/randomizedcoder/xtcp2/...", @@ -78,7 +82,10 @@ let # Resolve the effective destination list. effectiveDestinations = - if destinations == null then versions.allLibraryDestinations else destinations; + if destinations == null then + versions.allLibraryDestinations ++ versions.debugDestinations + else + destinations; # `dest_kafka`, `dest_nats`, … — one tag per included library destination. destinationTags = map (s: "dest_${s}") effectiveDestinations; @@ -110,7 +117,9 @@ let "-" + lib.concatStringsSep "-" enrichments; in buildGoModule { - pname = "${name}${destSuffix}${enrichSuffix}${variantCfg.tagSuffix}${lib.optionalString coverage "-cover"}"; + pname = "${name}${destSuffix}${enrichSuffix}${ + lib.concatMapStrings (t: "-${t}") extraTags + }${variantCfg.tagSuffix}${lib.optionalString coverage "-cover"}"; inherit version src @@ -124,7 +133,7 @@ buildGoModule { CGO_ENABLED = if versions.cgoEnabled then "1" else "0"; }; - tags = versions.buildTags ++ destinationTags ++ enrichmentTags; + tags = versions.buildTags ++ destinationTags ++ enrichmentTags ++ extraTags; ldflags = variantCfg.extraLdflags diff --git a/nix/tests/go-test-flavors.nix b/nix/tests/go-test-flavors.nix index e7198578e..18edbfd31 100644 --- a/nix/tests/go-test-flavors.nix +++ b/nix/tests/go-test-flavors.nix @@ -72,8 +72,18 @@ let tags = "dest_s3parquet enrich_asn enrich_locality"; }; + # Debug writers: the file destination (full flavor only) and the netlink + # capture writer (no shipped flavor). Their tagged tests and the + # compiled-in branches of the startup/Set refusals only build here. + file = { + tags = "dest_file"; + }; + capture = { + tags = "capture"; + }; + all = { - tags = "dest_kafka dest_nats dest_nsq dest_valkey dest_s3parquet enrich_asn enrich_locality"; + tags = "dest_kafka dest_nats dest_nsq dest_valkey dest_s3parquet dest_file capture enrich_asn enrich_locality"; }; }; diff --git a/nix/versions.nix b/nix/versions.nix index d8168d873..6cc50a998 100644 --- a/nix/versions.nix +++ b/nix/versions.nix @@ -104,9 +104,10 @@ # Destination flavors. Each maps to a list of `dest_` build tags # appended to the binary's build. `null` means "all" — backward-compat - # default that pulls in every library destination (kafka/nats/nsq/valkey/s3parquet). - # Stdlib destinations (null/udp/unix/unixgram) are always compiled - # regardless of this list. + # default that pulls in every library destination (kafka/nats/nsq/valkey/s3parquet) + # plus the debug destinations below. + # Stdlib destinations (null/stdout/stderr/tcp/http/udp/unix/unixgram) are + # always compiled regardless of this list. # # See nix/binaries.nix for which flavors are surfaced as top-level attrs. destinationFlavors = { @@ -130,6 +131,19 @@ "s3parquet" ]; + # Debug destinations: stdlib code, but they write wherever configuration + # points them, so they are tag-gated like the library destinations and + # compiled only into the full (development) flavor — `destinations = null`. + # No slim (production) flavor carries them; nix/checks/flavor-tags.nix + # enforces that. + debugDestinations = [ "file" ]; + + # Build tags that no shipped flavor or image carries. `capture` compiles in + # the netlink capture writer (-writeFiles), which writes raw netlink replies + # to disk; it is reachable only through the dedicated `xtcp2-capture` attr. + # nix/checks/flavor-tags.nix fails if one of these reaches a flavor. + optInOnlyTags = [ "capture" ]; + # Enrichment flavors — the third build axis, alongside buildVariants and # destinationFlavors. Each maps to a list of `enrich_` build tags. # diff --git a/pkg/xtcp/build_support.go b/pkg/xtcp/build_support.go new file mode 100644 index 000000000..9ba6fc36e --- /dev/null +++ b/pkg/xtcp/build_support.go @@ -0,0 +1,32 @@ +package xtcp + +import ( + "errors" + "fmt" + "strings" + + "github.com/randomizedcoder/xtcp2/gen/go/xtcp_config" +) + +// ErrNotCompiledIn marks a config that asks for a feature this binary was +// built without. The CLI maps it to a usage exit; ConfigService.Set refuses +// it, because Set re-execs the same binary and would hit the same wall. +var ErrNotCompiledIn = errors.New("not compiled into this binary") + +// CheckBuildSupport reports whether c asks for a build-tag-gated debug writer +// that is not in this binary: the netlink capture writer (`-tags capture`) +// or a destination such as `file:` (`-tags dest_file`). An unknown scheme is +// not its concern; validateInput reports that. +func CheckBuildSupport(c *xtcp_config.XtcpConfig) error { + if c.GetWriteFiles() > 0 && !CaptureCompiledIn { + return fmt.Errorf("-writeFiles %d: the netlink capture writer is %w; "+ + "it is a debugging tool that writes raw netlink packets to disk. "+ + "Rebuild with '-tags capture' (or use the `xtcp2-capture` Nix attribute) to opt in", + c.GetWriteFiles(), ErrNotCompiledIn) + } + scheme, _, _ := strings.Cut(c.GetDest(), ":") + if _, status := lookupDestinationFactory(scheme); status == destLookupNotCompiledIn { + return fmt.Errorf("%w: %w", ErrNotCompiledIn, destinationLookupError(scheme, status)) + } + return nil +} diff --git a/pkg/xtcp/build_support_test.go b/pkg/xtcp/build_support_test.go new file mode 100644 index 000000000..9ccd79742 --- /dev/null +++ b/pkg/xtcp/build_support_test.go @@ -0,0 +1,101 @@ +package xtcp + +import ( + "context" + "errors" + "strings" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/randomizedcoder/xtcp2/gen/go/xtcp_config" +) + +// TestCheckBuildSupport covers the build-tag-gated debug writers. The rows +// whose outcome depends on the tags compiled into the test binary derive +// their expectation from CaptureCompiledIn and the destination registry, so +// the same table is correct under every flavor leg. +func TestCheckBuildSupport(t *testing.T) { + t.Parallel() + _, fileStatus := lookupDestinationFactory(schemeFile) + fileIn := fileStatus == destLookupFound + cases := []struct { + name string + category string + description string + cfg *xtcp_config.XtcpConfig + expected bool // true = refused with ErrNotCompiledIn + wantHint string + }{ + {"writefiles_zero", "positive", "capture idle is always fine", + &xtcp_config.XtcpConfig{Dest: "null", WriteFiles: 0}, false, ""}, + {"stderr_dest", "positive", "stdlib destination needs no tag", + &xtcp_config.XtcpConfig{Dest: "stderr"}, false, ""}, + {"writefiles_armed", "negative", "capture armed needs -tags capture", + &xtcp_config.XtcpConfig{Dest: "null", WriteFiles: 5}, !CaptureCompiledIn, "-tags capture"}, + {"file_dest", "negative", "file: needs -tags dest_file", + &xtcp_config.XtcpConfig{Dest: "file:/x"}, !fileIn, "-tags dest_file"}, + {"writefiles_one", "boundary", "smallest armed budget is still refused", + &xtcp_config.XtcpConfig{Dest: "null", WriteFiles: 1}, !CaptureCompiledIn, "-tags capture"}, + {"unknown_scheme", "corner", "unknown scheme is left to validateInput", + &xtcp_config.XtcpConfig{Dest: "bogus:x"}, false, ""}, + {"nil_config", "corner", "nil config asks for nothing", + nil, false, ""}, + } + for _, tc := range cases { + t.Run(tc.category+"/"+tc.name, func(t *testing.T) { + t.Parallel() + err := CheckBuildSupport(tc.cfg) + if got := errors.Is(err, ErrNotCompiledIn); got != tc.expected { + t.Fatalf("%s: refused = %v, want %v (err: %v)", tc.description, got, tc.expected, err) + } + if err == nil && tc.expected { + t.Fatalf("%s: want an error", tc.description) + } + if err != nil && !strings.Contains(err.Error(), tc.wantHint) { + t.Errorf("%s: error %q lacks the rebuild hint %q", tc.description, err, tc.wantHint) + } + }) + } +} + +// TestConfigService_Set_debugWritersNotCompiledIn: Set re-execs the same +// binary, so it refuses a config needing a debug writer this build lacks and +// leaves the running daemon alone. With the tag compiled in, Set gets past +// the guard (and stops at the missing reconfigure hook: Unavailable). +func TestConfigService_Set_debugWritersNotCompiledIn(t *testing.T) { + _, fileStatus := lookupDestinationFactory(schemeFile) + cases := []struct { + name string + category string + description string + mutate func(*xtcp_config.XtcpConfig) + compiledIn bool + }{ + {"set_write_files", "negative", "write_files=1 needs the capture tag", + func(c *xtcp_config.XtcpConfig) { c.WriteFiles = 1 }, CaptureCompiledIn}, + {"set_file_dest", "negative", "dest file: needs the dest_file tag", + func(c *xtcp_config.XtcpConfig) { c.Dest = "file:/tmp/x"; c.MarshalTo = "jsonl" }, + fileStatus == destLookupFound}, + {"set_write_files_zero", "boundary", "write_files=0 passes the guard on every build", + func(c *xtcp_config.XtcpConfig) { c.WriteFiles = 0 }, true}, + {"set_stdlib_dest", "positive", "a stdlib destination passes the guard", + func(c *xtcp_config.XtcpConfig) { c.Dest = "stderr" }, true}, + } + for _, tc := range cases { + t.Run(tc.category+"/"+tc.name, func(t *testing.T) { + c, _ := newConfigServiceFixture(t) + cfg := validSetConfig() + tc.mutate(cfg) + _, err := c.Set(context.Background(), &xtcp_config.SetRequest{Config: cfg}) + want := codes.FailedPrecondition + if tc.compiledIn { + want = codes.Unavailable + } + if got := status.Code(err); got != want { + t.Errorf("%s: Set code = %v, want %v (err: %v)", tc.description, got, want, err) + } + }) + } +} diff --git a/pkg/xtcp/destinations_file.go b/pkg/xtcp/destinations_file.go index 5c2bbc052..f21c0f0df 100644 --- a/pkg/xtcp/destinations_file.go +++ b/pkg/xtcp/destinations_file.go @@ -1,3 +1,5 @@ +//go:build dest_file + package xtcp import ( @@ -7,15 +9,11 @@ import ( "strings" ) -// stderr and file destinations: thin factories over the reusable writerDest. -// Both are stdlib (no build tag). Pair with a line/tabular marshaller -// (jsonl/csv/tsv/protoJson) — the marshaller frames each flush. - -// newStderrDest writes records to the process's stderr. Handy when stdout is -// reserved for something else, or for `2>records.log`. -func newStderrDest(_ context.Context, x *XTCP) (Destination, error) { - return &writerDest{x: x, w: os.Stderr, label: "destStderr"}, nil -} +// file destination: a thin factory over the reusable writerDest. It writes +// records to a path chosen by configuration, so it is compiled in only with +// `-tags dest_file` (the full/dev flavor); production flavors omit it. Pair +// with a line/tabular marshaller (jsonl/csv/tsv/protoJson) — the marshaller +// frames each flush. // newFileDest appends records to a file: `-dest file:/var/log/xtcp.jsonl`. // The file is created if missing and opened for append; the *os.File is @@ -33,6 +31,5 @@ func newFileDest(_ context.Context, x *XTCP) (Destination, error) { } func init() { - RegisterDestination(schemeStderr, newStderrDest) RegisterDestination(schemeFile, newFileDest) } diff --git a/pkg/xtcp/destinations_file_test.go b/pkg/xtcp/destinations_file_test.go index 14a1026fb..c3b56ba02 100644 --- a/pkg/xtcp/destinations_file_test.go +++ b/pkg/xtcp/destinations_file_test.go @@ -1,3 +1,5 @@ +//go:build dest_file + package xtcp import ( @@ -43,3 +45,52 @@ func TestFileDest_send(t *testing.T) { t.Errorf("file content = %q, want %q", got, want) } } + +// TestFileDest writes payloads and reads them back from the file. +func TestFileDest(t *testing.T) { + path := filepath.Join(t.TempDir(), "out.jsonl") + x := newTestXTCP(t, "file:"+path) + dest, err := newFileDest(context.Background(), x) + if err != nil { + t.Fatalf("newFileDest: %v", err) + } + payload := []byte("hello\n") + if _, err := dest.Send(context.Background(), &payload); err != nil { + t.Fatalf("Send: %v", err) + } + if err := dest.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "hello\n" { + t.Errorf("file content = %q, want %q", got, "hello\n") + } +} + +func TestFileDest_emptyPath(t *testing.T) { + x := newTestXTCP(t, "file:") + if _, err := newFileDest(context.Background(), x); err == nil { + t.Fatal("expected error for empty file path") + } +} + +// TestFileSchemeRegistered confirms the dest_file tag registers the scheme. +func TestFileSchemeRegistered(t *testing.T) { + if _, status := lookupDestinationFactory(schemeFile); status != destLookupFound { + t.Errorf("scheme %q not registered (status %v)", schemeFile, status) + } +} + +// TestValidateInput_fileDest: with the tag, `-dest file:` validates. +func TestValidateInput_fileDest(t *testing.T) { + x := newTestXTCP(t, "file:/var/log/xtcp.csv") + x.config.MarshalTo = MarshallerProtoJSON + x.config.Topic = "xtcp" + x.Marshallers.Store(MarshallerProtoJSON, struct{}{}) + if err := x.validateInput(); err != nil { + t.Errorf("validateInput: %v", err) + } +} diff --git a/pkg/xtcp/destinations_stderr.go b/pkg/xtcp/destinations_stderr.go new file mode 100644 index 000000000..267f68440 --- /dev/null +++ b/pkg/xtcp/destinations_stderr.go @@ -0,0 +1,20 @@ +package xtcp + +import ( + "context" + "os" +) + +// stderr destination: a thin factory over the reusable writerDest. Stdlib +// (no build tag). Pair with a line/tabular marshaller (jsonl/csv/tsv/ +// protoJson) — the marshaller frames each flush. + +// newStderrDest writes records to the process's stderr. Handy when stdout is +// reserved for something else, or for `2>records.log`. +func newStderrDest(_ context.Context, x *XTCP) (Destination, error) { + return &writerDest{x: x, w: os.Stderr, label: "destStderr"}, nil +} + +func init() { + RegisterDestination(schemeStderr, newStderrDest) +} diff --git a/pkg/xtcp/destinations_stream_test.go b/pkg/xtcp/destinations_stream_test.go index bc2d7e591..bde72486b 100644 --- a/pkg/xtcp/destinations_stream_test.go +++ b/pkg/xtcp/destinations_stream_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "sync" "testing" ) @@ -32,37 +31,6 @@ func TestStderrDestFactory(t *testing.T) { } } -// TestFileDest writes payloads and reads them back from the file. -func TestFileDest(t *testing.T) { - path := filepath.Join(t.TempDir(), "out.jsonl") - x := newTestXTCP(t, "file:"+path) - dest, err := newFileDest(context.Background(), x) - if err != nil { - t.Fatalf("newFileDest: %v", err) - } - payload := []byte("hello\n") - if _, err := dest.Send(context.Background(), &payload); err != nil { - t.Fatalf("Send: %v", err) - } - if err := dest.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(got) != "hello\n" { - t.Errorf("file content = %q, want %q", got, "hello\n") - } -} - -func TestFileDest_emptyPath(t *testing.T) { - x := newTestXTCP(t, "file:") - if _, err := newFileDest(context.Background(), x); err == nil { - t.Fatal("expected error for empty file path") - } -} - // TestTCPDest dials a listener, sends, and reads the bytes back verbatim. func TestTCPDest(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -162,7 +130,7 @@ func TestHTTPDest_non2xx(t *testing.T) { // TestNewStreamSchemesRegistered confirms all new schemes resolve. func TestNewStreamSchemesRegistered(t *testing.T) { - for _, s := range []string{schemeStderr, schemeFile, schemeTCP, schemeHTTP, schemeHTTPS} { + for _, s := range []string{schemeStderr, schemeTCP, schemeHTTP, schemeHTTPS} { if _, status := lookupDestinationFactory(s); status != destLookupFound { t.Errorf("scheme %q not registered (status %v)", s, status) } diff --git a/pkg/xtcp/grpc_configService.go b/pkg/xtcp/grpc_configService.go index 78df008cb..6c5d58606 100644 --- a/pkg/xtcp/grpc_configService.go +++ b/pkg/xtcp/grpc_configService.go @@ -176,6 +176,14 @@ func (c *xtcpConfigService) Set( return nil, status.Error(codes.FailedPrecondition, err.Error()) } + // Same reasoning for the build-tag-gated debug writers (the netlink + // capture writer, the file destination): a binary built without them + // cannot be restarted into using them. + if err := CheckBuildSupport(in.Config); err != nil { + c.pC.WithLabelValues("Set", "notCompiledIn", "error").Inc() + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + // Secret preservation: an operator does Get → edit → Set, and Get redacts // the S3 credentials (see below). Empty credential fields therefore mean // "unchanged", not "clear" — inherit them from the running config so a diff --git a/pkg/xtcp/grpc_configService_test.go b/pkg/xtcp/grpc_configService_test.go index f385888e4..070167466 100644 --- a/pkg/xtcp/grpc_configService_test.go +++ b/pkg/xtcp/grpc_configService_test.go @@ -72,7 +72,7 @@ func validSetConfig() *xtcp_config.XtcpConfig { NlmsgSeq: 1, Modulus: 1, MarshalTo: "protobufList", - Dest: "kafka:127.0.0.1:9092", + Dest: "udp:127.0.0.1:13000", DebugLevel: 111, GrpcPort: 8080, CapturePath: "/tmp", diff --git a/pkg/xtcp/init.go b/pkg/xtcp/init.go index 3f0a15dbe..246615303 100644 --- a/pkg/xtcp/init.go +++ b/pkg/xtcp/init.go @@ -47,6 +47,8 @@ func (x *XTCP) Init(ctx context.Context) { return } + x.announceCapture() + // initChanenls first, so that signaling channels are ready x.initChannels() diff --git a/pkg/xtcp/input_validation_test.go b/pkg/xtcp/input_validation_test.go index e98712de1..0f0cbb473 100644 --- a/pkg/xtcp/input_validation_test.go +++ b/pkg/xtcp/input_validation_test.go @@ -113,14 +113,6 @@ func TestValidateInput_happyPaths(t *testing.T) { Topic: "xtcp", }, }, - { - name: "file dest with path", - cfg: &xtcp_config.XtcpConfig{ - MarshalTo: MarshallerProtoJSON, - Dest: "file:/var/log/xtcp.csv", - Topic: "xtcp", - }, - }, { name: "tcp dest host:port", cfg: &xtcp_config.XtcpConfig{ diff --git a/pkg/xtcp/netlinker.go b/pkg/xtcp/netlinker.go index 8d5b4163d..7c5f2393f 100644 --- a/pkg/xtcp/netlinker.go +++ b/pkg/xtcp/netlinker.go @@ -18,7 +18,6 @@ import ( "encoding/binary" "log" "net" - "os" "runtime" "sync" "sync/atomic" @@ -29,10 +28,8 @@ import ( ) const ( - // Raw netlink captures carry host addresses: owner read/write only. - writeFilesPermissionsCst = 0o600 - forceGCModulesCst = 1000 - nlmsgAlignToCst = 4 + forceGCModulesCst = 1000 + nlmsgAlignToCst = 4 ) // NetlinkerFunc runs the netlinkers for one fd until ctx is canceled, @@ -67,38 +64,6 @@ func (x *XTCP) recvOneFromKernel(fd int, buf []byte) (n int, retry bool) { return n, false } -// captureToFileIfEnabled writes the packet to disk when the WriteFiles -// budget is positive. Returns the updated budget — 0 means "stop trying -// for the rest of this goroutine's life" (a write error disables -// captures permanently rather than killing the daemon). -func (x *XTCP) captureToFileIfEnabled(wf uint32, packet []byte, id uint32) uint32 { - if wf == 0 { - return wf - } - now := time.Now() - // Capture only the n bytes Recvfrom actually filled. Writing the - // raw *packetBuffer here used to dump the full pool-buffer size - // (e.g. 8 KiB), trailing the real packet with stale bytes from a - // previous Recvfrom — pcap-like consumers parsed garbage past - // the real end of the message. - err := os.WriteFile( - x.config.CapturePath+"netlink."+now.Format(time.RFC3339Nano), - packet, - writeFilesPermissionsCst) - if err == nil { - return wf - 1 - } - // Diagnostic capture-to-file is a side feature; a disk-full / - // EACCES / etc. here must NOT take down the daemon (and every - // other netlinker for every other namespace with it). Stop - // capturing further packets and count the failure. - x.pC.WithLabelValues("Netlinker", "WriteFile", "error").Inc() - if x.debugLevel > 10 { - log.Printf("Netlinker %d WriteFile err (disabling further captures): %v", id, err) - } - return 0 -} - // maybeForceGC fires runtime.GC every forceGCModulesCst packets. The // previous body fired on packets==0 too, forcing a GC before any work // was done; skip the zero case so it fires every Nth packet AFTER the diff --git a/pkg/xtcp/netlinker_capture.go b/pkg/xtcp/netlinker_capture.go new file mode 100644 index 000000000..7990e908f --- /dev/null +++ b/pkg/xtcp/netlinker_capture.go @@ -0,0 +1,80 @@ +//go:build capture + +package xtcp + +import ( + "log" + "os" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// The netlink capture writer is a debugging tool: it writes raw inet_diag +// replies, which carry every socket's addresses, to disk. It is compiled in +// only with `-tags capture` and no shipped flavor or image sets that tag. + +// CaptureCompiledIn reports whether this binary contains the netlink capture +// writer (`-writeFiles` / `-capturePath`). +const CaptureCompiledIn = true + +// Raw netlink captures carry host addresses: owner read/write only. +const writeFilesPermissionsCst = 0o600 + +// debugCaptureEnabled is 1 while this process is armed to write captures. +var debugCaptureEnabled = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "xtcp2", + Subsystem: "debug", + Name: "capture_enabled", + Help: "1 when the netlink capture writer is compiled in and armed (-writeFiles > 0)", +}) + +func init() { + prometheus.MustRegister(debugCaptureEnabled) +} + +// announceCapture warns, once at Init, when the capture writer is armed. +// Compiled in but idle (-writeFiles 0) it says nothing and the gauge is 0. +func (x *XTCP) announceCapture() { + if x.config.GetWriteFiles() == 0 { + debugCaptureEnabled.Set(0) + return + } + debugCaptureEnabled.Set(1) + log.Printf("WARNING: netlink capture is ARMED: up to %d raw netlink packets per netlinker "+ + "will be written under %q. Captures hold the addresses and ports of every socket on this "+ + "host. This is a debugging feature; do not run it in production.", + x.config.GetWriteFiles(), x.config.GetCapturePath()) +} + +// captureToFileIfEnabled writes the packet to disk when the WriteFiles +// budget is positive. Returns the updated budget — 0 means "stop trying +// for the rest of this goroutine's life" (a write error disables +// captures permanently rather than killing the daemon). +func (x *XTCP) captureToFileIfEnabled(wf uint32, packet []byte, id uint32) uint32 { + if wf == 0 { + return wf + } + now := time.Now() + // Capture only the n bytes Recvfrom actually filled. Writing the + // raw *packetBuffer here used to dump the full pool-buffer size + // (e.g. 8 KiB), trailing the real packet with stale bytes from a + // previous Recvfrom — pcap-like consumers parsed garbage past + // the real end of the message. + err := os.WriteFile( + x.config.CapturePath+"netlink."+now.Format(time.RFC3339Nano), + packet, + writeFilesPermissionsCst) + if err == nil { + return wf - 1 + } + // Diagnostic capture-to-file is a side feature; a disk-full / + // EACCES / etc. here must NOT take down the daemon (and every + // other netlinker for every other namespace with it). Stop + // capturing further packets and count the failure. + x.pC.WithLabelValues("Netlinker", "WriteFile", "error").Inc() + if x.debugLevel > 10 { + log.Printf("Netlinker %d WriteFile err (disabling further captures): %v", id, err) + } + return 0 +} diff --git a/pkg/xtcp/netlinker_capture_test.go b/pkg/xtcp/netlinker_capture_test.go new file mode 100644 index 000000000..02e932d20 --- /dev/null +++ b/pkg/xtcp/netlinker_capture_test.go @@ -0,0 +1,149 @@ +//go:build capture + +package xtcp + +import ( + "bytes" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +// TestAnnounceCapture: armed, the capture writer warns and raises its gauge; +// compiled in but idle it does neither. +func TestAnnounceCapture(t *testing.T) { + cases := []struct { + name string + category string + description string + writeFiles uint32 + expected float64 // gauge; a warning is logged iff it is 1 + }{ + {"capture_compiled_warns", "positive", "armed: warning and gauge 1", 3, 1}, + {"capture_compiled_idle", "negative", "idle: no warning, gauge 0", 0, 0}, + {"capture_budget_one", "boundary", "the smallest budget still arms", 1, 1}, + {"capture_rearm_then_idle", "corner", "an idle announce clears a prior armed gauge", 0, 0}, + } + for _, tc := range cases { + t.Run(tc.category+"/"+tc.name, func(t *testing.T) { + x := newNetlinkerXTCP(t) + x.config.WriteFiles = tc.writeFiles + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(prev) }) + debugCaptureEnabled.Set(1) // a stale armed value must not survive an idle announce + x.announceCapture() + if got := testutil.ToFloat64(debugCaptureEnabled); got != tc.expected { + t.Errorf("%s: gauge = %v, want %v", tc.description, got, tc.expected) + } + warned := strings.Contains(buf.String(), "netlink capture is ARMED") + if warned != (tc.expected == 1) { + t.Errorf("%s: warned = %v, log %q", tc.description, warned, buf.String()) + } + if warned && !strings.Contains(buf.String(), x.config.CapturePath) { + t.Errorf("%s: warning does not name the capture path: %q", tc.description, buf.String()) + } + }) + } +} + +// ─────────────────────────────────────────────────────────────────────── +// captureToFileIfEnabled — positive / negative / boundary / corner / +// adversarial. Each row asserts the returned wf and whether a file was +// created in CapturePath. +// ─────────────────────────────────────────────────────────────────────── + +func TestCaptureToFileIfEnabled_table(t *testing.T) { + t.Parallel() + cases := []struct { + name string + category string + inputWF uint32 + corruptPath bool + wantWF uint32 + wantFileExists bool + }{ + {"positive_writes_and_decrements", "positive", 3, false, 2, true}, + {"positive_single_budget", "positive", 1, false, 0, true}, + {"negative_zero_budget_noop", "negative", 0, false, 0, false}, + {"boundary_max_uint32_decrements", "boundary", ^uint32(0), false, ^uint32(0) - 1, true}, + {"corner_write_fails_resets_to_zero", "corner", 5, true, 0, false}, + {"adversarial_repeated_write_fail_stays_zero", "adversarial", 0, true, 0, false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.category+"/"+tc.name, func(t *testing.T) { + t.Parallel() + x := newNetlinkerXTCP(t) + if tc.corruptPath { + // Point CapturePath at a non-existent + unwritable dir + // so os.WriteFile fails. Cleanup is automatic via + // t.TempDir below the unwritable dir. + x.config.CapturePath = filepath.Join(t.TempDir(), "does_not_exist", "subdir") + string(os.PathSeparator) + "cap_" + } + payload := []byte("hello") + gotWF := x.captureToFileIfEnabled(tc.inputWF, payload, 7) + if gotWF != tc.wantWF { + t.Errorf("returned wf = %d, want %d", gotWF, tc.wantWF) + } + // Look for any "netlink." file under the capture dir. + capDir := filepath.Dir(x.config.CapturePath) + matched := false + if entries, err := os.ReadDir(capDir); err == nil { + for _, e := range entries { + if strings.HasPrefix(e.Name(), "netlink.") || strings.HasPrefix(e.Name(), "cap_netlink.") { + matched = true + break + } + } + } + if matched != tc.wantFileExists { + t.Errorf("file-exists = %v, want %v (capDir=%s)", matched, tc.wantFileExists, capDir) + } + }) + } +} + +// TestCaptureToFileIfEnabled_payloadLength pins bug-39-style behavior: +// the file holds exactly len(packet) bytes, not the pool's full +// capacity. This is the original reason for the captureToFile change. +func TestCaptureToFileIfEnabled_payloadLength(t *testing.T) { + x := newNetlinkerXTCP(t) + x.captureToFileIfEnabled(1, []byte("abc"), 0) + entries, _ := os.ReadDir(filepath.Dir(x.config.CapturePath)) + if len(entries) == 0 { + t.Fatal("expected one capture file, found none") + } + first := entries[0] + root, err := os.OpenRoot(filepath.Dir(x.config.CapturePath)) + if err != nil { + t.Fatalf("open capture dir: %v", err) + } + t.Cleanup(func() { + if cerr := root.Close(); cerr != nil { + t.Errorf("close capture dir: %v", cerr) + } + }) + data, err := root.ReadFile(first.Name()) + if err != nil { + t.Fatalf("read capture: %v", err) + } + if string(data) != "abc" { + t.Errorf("capture contents = %q, want %q", data, "abc") + } +} + +func BenchmarkCaptureToFileIfEnabled_off(b *testing.B) { + b.ReportAllocs() + x := newNetlinkerXTCP(&testing.T{}) + payload := []byte("z") + b.ResetTimer() + for i := 0; i < b.N; i++ { + x.captureToFileIfEnabled(0, payload, 1) // wf=0 → no I/O + } +} diff --git a/pkg/xtcp/netlinker_helpers_test.go b/pkg/xtcp/netlinker_helpers_test.go index 30be0501f..9d596eca7 100644 --- a/pkg/xtcp/netlinker_helpers_test.go +++ b/pkg/xtcp/netlinker_helpers_test.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "log" - "os" "path/filepath" "strings" "sync" @@ -154,92 +153,6 @@ func TestRecvOneFromKernel_table(t *testing.T) { }) } -// ─────────────────────────────────────────────────────────────────────── -// captureToFileIfEnabled — positive / negative / boundary / corner / -// adversarial. Each row asserts the returned wf and whether a file was -// created in CapturePath. -// ─────────────────────────────────────────────────────────────────────── - -func TestCaptureToFileIfEnabled_table(t *testing.T) { - t.Parallel() - cases := []struct { - name string - category string - inputWF uint32 - corruptPath bool - wantWF uint32 - wantFileExists bool - }{ - {"positive_writes_and_decrements", "positive", 3, false, 2, true}, - {"positive_single_budget", "positive", 1, false, 0, true}, - {"negative_zero_budget_noop", "negative", 0, false, 0, false}, - {"boundary_max_uint32_decrements", "boundary", ^uint32(0), false, ^uint32(0) - 1, true}, - {"corner_write_fails_resets_to_zero", "corner", 5, true, 0, false}, - {"adversarial_repeated_write_fail_stays_zero", "adversarial", 0, true, 0, false}, - } - for _, tc := range cases { - tc := tc - t.Run(tc.category+"/"+tc.name, func(t *testing.T) { - t.Parallel() - x := newNetlinkerXTCP(t) - if tc.corruptPath { - // Point CapturePath at a non-existent + unwritable dir - // so os.WriteFile fails. Cleanup is automatic via - // t.TempDir below the unwritable dir. - x.config.CapturePath = filepath.Join(t.TempDir(), "does_not_exist", "subdir") + string(os.PathSeparator) + "cap_" - } - payload := []byte("hello") - gotWF := x.captureToFileIfEnabled(tc.inputWF, payload, 7) - if gotWF != tc.wantWF { - t.Errorf("returned wf = %d, want %d", gotWF, tc.wantWF) - } - // Look for any "netlink." file under the capture dir. - capDir := filepath.Dir(x.config.CapturePath) - matched := false - if entries, err := os.ReadDir(capDir); err == nil { - for _, e := range entries { - if strings.HasPrefix(e.Name(), "netlink.") || strings.HasPrefix(e.Name(), "cap_netlink.") { - matched = true - break - } - } - } - if matched != tc.wantFileExists { - t.Errorf("file-exists = %v, want %v (capDir=%s)", matched, tc.wantFileExists, capDir) - } - }) - } -} - -// TestCaptureToFileIfEnabled_payloadLength pins bug-39-style behavior: -// the file holds exactly len(packet) bytes, not the pool's full -// capacity. This is the original reason for the captureToFile change. -func TestCaptureToFileIfEnabled_payloadLength(t *testing.T) { - x := newNetlinkerXTCP(t) - x.captureToFileIfEnabled(1, []byte("abc"), 0) - entries, _ := os.ReadDir(filepath.Dir(x.config.CapturePath)) - if len(entries) == 0 { - t.Fatal("expected one capture file, found none") - } - first := entries[0] - root, err := os.OpenRoot(filepath.Dir(x.config.CapturePath)) - if err != nil { - t.Fatalf("open capture dir: %v", err) - } - t.Cleanup(func() { - if cerr := root.Close(); cerr != nil { - t.Errorf("close capture dir: %v", cerr) - } - }) - data, err := root.ReadFile(first.Name()) - if err != nil { - t.Fatalf("read capture: %v", err) - } - if string(data) != "abc" { - t.Errorf("capture contents = %q, want %q", data, "abc") - } -} - // ─────────────────────────────────────────────────────────────────────── // maybeForceGC — table covers the offset-modulus contract. // ─────────────────────────────────────────────────────────────────────── @@ -422,16 +335,6 @@ func BenchmarkLogRecvDebug_off(b *testing.B) { } } -func BenchmarkCaptureToFileIfEnabled_off(b *testing.B) { - b.ReportAllocs() - x := newNetlinkerXTCP(&testing.T{}) - payload := []byte("z") - b.ResetTimer() - for i := 0; i < b.N; i++ { - x.captureToFileIfEnabled(0, payload, 1) // wf=0 → no I/O - } -} - func BenchmarkRecvOneFromKernel_closedFD(b *testing.B) { // Exercises the err-path (closed fd → syscall error, retry=true). b.ReportAllocs() diff --git a/pkg/xtcp/netlinker_nocapture.go b/pkg/xtcp/netlinker_nocapture.go new file mode 100644 index 000000000..f2a199053 --- /dev/null +++ b/pkg/xtcp/netlinker_nocapture.go @@ -0,0 +1,16 @@ +//go:build !capture + +package xtcp + +// CaptureCompiledIn reports whether this binary contains the netlink capture +// writer (`-writeFiles` / `-capturePath`). It is a debugging tool, compiled in +// only with `-tags capture`; see netlinker_capture.go. +const CaptureCompiledIn = false + +// announceCapture is a no-op: there is no capture writer to arm. +func (x *XTCP) announceCapture() {} + +// captureToFileIfEnabled is a no-op without the capture build tag. Startup +// and ConfigService.Set both refuse -writeFiles > 0 on this build (see +// CheckBuildSupport), so the budget is always 0 here. +func (x *XTCP) captureToFileIfEnabled(_ uint32, _ []byte, _ uint32) uint32 { return 0 }