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
2 changes: 2 additions & 0 deletions .golangci-comprehensive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ run:
- dest_s3parquet
- enrich_asn
- enrich_locality
- capture
- dest_file

formatters:
enable:
Expand Down
2 changes: 2 additions & 0 deletions .golangci-quick.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ run:
- dest_s3parquet
- enrich_asn
- enrich_locality
- capture
- dest_file

formatters:
enable:
Expand Down
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ run:
- dest_s3parquet
- enrich_asn
- enrich_locality
- capture
- dest_file

formatters:
enable:
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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-<dest>` 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.
Expand Down
28 changes: 28 additions & 0 deletions cmd/xtcp2/exit.go
Original file line number Diff line number Diff line change
@@ -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
}
}
107 changes: 107 additions & 0 deletions cmd/xtcp2/exit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion cmd/xtcp2/testdata/defineflags-golden.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 21 additions & 3 deletions cmd/xtcp2/xtcp2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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
Expand Down Expand 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))
Expand Down
13 changes: 11 additions & 2 deletions docs/build-flavors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<scheme>`.
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_<scheme>`.

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.

Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion docs/netlink/collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions docs/output-and-destinations.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,16 @@ 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` |
| `unix` | `unix:/tmp/xtcp.sock` | *(always built)* | `destinations_unix.go` |
| `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:

Expand Down Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions nix/binaries.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions nix/checks/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading