Skip to content

feat(enrichment): harden ASN/locality enrichment, protobuf structure v2, lookup-table metrics - #126

Draft
randomizedcoder wants to merge 4 commits into
mainfrom
feat/enrichment-hardening-proto-v2
Draft

randomizedcoder wants to merge 4 commits into
mainfrom
feat/enrichment-hardening-proto-v2

Conversation

@randomizedcoder

@randomizedcoder randomizedcoder commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Summary

Three stacked commits, all new to main:

  1. b8f9b0b feat(ipfeed): fold ipfeed-collector into xtcp2 + IP→ASN enrichment (pkg/ipasn, -enrichAsn).
  2. db40c79 feat(locality): destination locality enrichment via native rtnetlink (pkg/localnet, enrich_socket_dest_locality, egress ifindex/ifname).
  3. 62a4673 feat(enrichment): review-driven hardening of both, a full protobuf structure pass (schema v2), and Prometheus metrics for the lookup tables.

Draft while the ClickHouse v2 migration and the schema-version bump get a second pair of eyes.

Protobuf structure v2 — XtcpFlatRecordSchemaVersion 1 → 2

  • xtcp_flat_record.proto now documents the allocation policy (metadata 1–299 · enrichment 300–399 · spare 400–999 · payload 1000+ one block per kernel subsystem · every tag ≤ 2047 stays 2 bytes on the wire). Enrichment block regrouped by subject; source-side range reserved.
  • Payload field names mirror the kernel struct member they copy, and every payload field carries a trailing comment naming its kernel source. tools/proto-field-audit gains a guard so a payload field cannot land without one.
  • Renamed (old numbers/names reserved): tcp_info_{snd,rcv}_wscale, tcp_info_fastopen_client_fail, tcp_info_rttvar, tcp_info_advmss, tcp_info_notsent_bytes, sk_mem_info_{rcvbuf,sndbuf}, vegas_info_{rttcnt,minrtt}, inet_diag_{cong,cong_enum,tos,tclass,shutdown,class_id,sockopt,cgroup_id}. Post-6.10 tcp_info members pre-assigned 1266–1276 by comment.
  • XtcpConfig renumbered into blocks (gRPC-internal; nothing persists the binary form).
  • Propagated to gen/*, ParquetRow/rowFromProto, recordfmt, ClickHouse Kafka table + per-version MVs (new _v2 table, explicit column aliases for _v0/_v1), k8s configMaps, hand-written SQL, docs.

Migration for existing ClickHouse deployments: build/containers/clickhouse/sql/migrations/v2.sql. Parquet consumers should branch on schema_version (see docs/record-versioning.md, docs/parquet-format.md).

Correctness fixes

  • Locality classification: RTN_LOCAL uses the route's real prefix; multipath / RTA_VIA / RTA_NH_ID routes classify as gateway-reached; Self beats Subnet regardless of dump order; only RT_TABLE_MAIN/LOCAL read; unspecified dest → LOCALITY_UNSPECIFIED.
  • Locality lifecycle: negative cache with backoff for failed namespaces, re-dump of loopback-only snapshots, capped new-namespace dumps per reconcile, NLM_F_DUMP_INTR retry, default refresh 60 s.
  • ASN loader: only io.EOF ends a read; zero usable prefixes is ErrNoPrefixes; a bad reload never degrades the table in service; stat-gated ReloadIfChanged; index installed even when the first load fails so a late artifact is picked up on the tick.
  • Collector: atomic Parquet write (.tmp + fsync + rename), 256 MiB body cap, six previously untested parsers now covered.

Lookup-table metrics (operational visibility)

Daemon, following the existing function/variable/type pattern under function="loadAsn":

metric meaning
xtcp_gauges{...variable="prefixes"} entries in the trie in service
xtcp_gauges{...variable="artifactBytes"} size of the artifact it was built from
xtcp_gauges{...variable="loadedAt"} Unix time of the last successful load
xtcp_histograms{...variable="build"} read + build duration per successful load
xtcp_histograms{...variable="error"} duration of a failed load attempt

Collector (-daemon -http-addr): OTel instruments bridged to Prometheus on /metrics next to /healthz and /readyz. New: ipfeed_source_records{source,provider}, ipfeed_artifact_records, ipfeed_artifact_size_bytes, ipfeed_write_duration_seconds, ipfeed_upload_duration_seconds, ipfeed_cycle_duration_seconds{outcome}. Operator table in cmd/ipfeed-collector/README.md.

Test plan

  • go vet ./..., gofmt -l . clean; go test ./... green (-race on localnet, xtcpnl, ipasn, xtcp, telemetry, health)
  • golangci-lint run — only the pre-existing govet unusedwrite in poller_pure_test.go
  • go run ./tools/proto-field-audit clean incl. the new kernel-annotation check; nix run .#regen-protos leaves the tree clean
  • nix build .#xtcp2-all (new goVendorHash)
  • nix run .#test-microvm-lifecycle-x86_64-interface-namingXTCP2_SELF_TEST_OVERALL_PASS, all 20 checks, including XTCP2_SELF_TEST_ASN_PASS (dest=8.8.8.8:53 asn=15169 owner=google prefixes=3) and XTCP2_SELF_TEST_IFNAME_PASS
  • ClickHouse DDL validated with clickhouse local (Kafka engine stood in by a MergeTree table with the same columns), two scenarios:
    • fresh initdb.d install: epoch 0/1/2 rows route to _v0/_v1/_v2, Merge view shows all three, _v2 has 161 columns;
    • existing deployment on main's DDL with an epoch-1 row already stored, then migrations/v2.sql: the stored row survives with its old column names, new epoch 0/1/2 rows route correctly, SELECT schema_version, count() FROM xtcp.xtcp_flat_records GROUP BY 1 shows all epochs.
  • Same against a real ClickHouse + Kafka compose stack (ProtobufList decode path)
  • tcp-stress / minimal lifecycle flavors on a quiet host (the minimal flavor's 180 s deadline is starved on a loaded box)

Pre-existing reds on main independent of this PR: go-sec G104 ×4, s3parquet schema-drift check, xsync race flake.

🤖 Generated with Claude Code

randomizedcoder and others added 4 commits September 9, 2026 17:44
Move the ipfeed-collector daemon from the internal repo into this single
module and wire its IP-range feeds into xtcp2's per-socket enrichment.

Phase 0 — move (no behavior change):
  - binary -> cmd/ipfeed-collector; guts -> internal/ipfeed/*
  - drop the tool's go.mod; merge deps into the root module
  - replace the self-contained flake with the repo's Nix machinery
    (binaries.nix, cli-help-smoke, oci image, checks/tests)

Phase 1 — pkg/ipasn consumer + asnmap producer:
  - internal/ipfeed/asnmap: curated networkOwner/provider -> representative
    ASN table; Annotate() sets model.Record.ASN before WriteParquet
  - pkg/ipasn: gaissmai/bart LPM trie loaded from the collector artifact,
    atomic hot-swap, alloc/lock-free Lookup; table-driven + race + bench tests

Phase 2 — hot-path wiring:
  - flat-record proto: new inet_diag_msg_socket_dest_network_owner (1018),
    threaded through the s3parquet ParquetRow/schema and ClickHouse DDL
  - config proto: enrich_asn_enable / asn_db_path / asn_refresh_interval
  - pkg/xtcp: initAsnEnricher loads the artifact and (optionally) refreshes on
    an interval; applyEnrichment fills dest_asn (1011) + dest_network_owner
    (1018) from the destination IP, no-op when disabled or index nil

Design doc: docs/ipfeed-asn-enrichment.md (lookup-structure pros/cons +
in-proc bart recommendation; representative-ASN caveat; BGP RIB deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The standalone go-sec check (gosec, G104 not excluded) flags unhandled
errors that golangci `//nolint` does not silence. Rather than annotate
them, handle each error for real so `nix build .#checks.<sys>.go-sec`
reports zero issues:

  - fetch: a 304 carries no body (RFC 7232) so drop that drain entirely;
    route the error-path drains through drainStatusErr, which folds any
    drain failure into the returned HTTP-status error.
  - summary: Print now returns the tabwriter flush error; both callers in
    cmd/ipfeed-collector handle it.
  - dockermeta: drainClose logs a drain failure (it only forfeits reuse).
  - lldp: a SetDeadline failure now aborts Fetch (the conn is unusable).
  - nsdiscover: if the recv timeout cannot be set, degrade to (0,false)
    rather than risk blocking the per-namespace reconcile path.
  - xtcp: a per-namespace handle close failure is logged.

No behavior change on the success paths; go-sec: Issues 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Classify each socket's destination as SELF / LOCAL_SUBNET / REMOTE using
per-namespace address/route discovery over raw NETLINK_ROUTE (no
vishvananda/netlink dependency), evaluated before the ipfeed IP->ASN lookup so
on-host and on-link traffic skips the internet ASN feed.

- pkg/xtcpnl: rtnetlink DUMP client + wire-format parsers (RTM_GETLINK/GETADDR/
  GETROUTE), family-header deserializers, and DumpRtnetlink transport. Real
  nlmon-captured 7.1.8 fixtures (testdata/7_1_8) with a deterministic extract
  generator and real-fixture-driven, table-driven deserialize tests alongside
  the synthetic positive/negative/boundary/corner suites.
- pkg/localnet: pure BuildSnapshot/Classify with an atomic-snapshot contract
  mirroring pkg/ipasn; table-driven + race + real-fixture end-to-end tests.
- pkg/xtcp: per-namespace route-socket lifecycle, background refresh with atomic
  swap, and the dest-locality hot-path step in applyEnrichment.
- record/config/schema plumbing: xtcp_flat_record + xtcp_config protos (+regen),
  parquet schema, ClickHouse DDL, recordfmt column/humanizer.
- nix: reproducible `capture-netlink-fixtures` writeShellApplication (nlmon +
  tcpdump) wired into the modular nix/ tree.
- docs/locality-enrichment.md design doc.

The connected-subnet rule is family-agnostic (unicast + no gateway + has Dst):
real captures show IPv4 connected subnets are scope-link but IPv6 connected
subnets are scope-universe, so a scope-link gate would misclassify every IPv6
on-link subnet as REMOTE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…v2, lookup-table metrics

Review of the stacked ipfeed-collector/ASN and locality enrichment work found
contained but real holes; this commit closes them and adds the operational
metrics for the IP->ASN lookup table.

Protobuf structure v2 (XtcpFlatRecordSchemaVersion 1 -> 2)
- xtcp_flat_record.proto: documented allocation policy (metadata 1-299,
  enrichment 300-399, spare 400-999, payload 1000+ per kernel subsystem, every
  tag <= 2047); enrichment block regrouped by subject (socket-side 300s,
  dest-side 310s/320s, source-side reserved 350-389).
- Payload field names now mirror the kernel struct member they copy and every
  payload field carries a trailing comment naming its kernel source
  (include/uapi/linux/{inet_diag,tcp,sock_diag}.h, net/ipv4/inet_diag.c).
  Renames: tcp_info_{snd,rcv}_wscale, fastopen_client_fail, rttvar, advmss,
  notsent_bytes; sk_mem_info_{rcvbuf,sndbuf}; vegas_info_{rttcnt,minrtt};
  inet_diag_{cong,cong_enum,tos,tclass,shutdown,class_id,sockopt,cgroup_id}.
  Old numbers/names are reserved; post-6.10 tcp_info members pre-assigned
  1266-1276 by comment. tools/proto-field-audit gains a kernel-annotation
  guard so a payload field cannot land without its source.
- XtcpConfig renumbered into blocks (gRPC-internal only, nothing persists it).
- Propagated to gen/*, ParquetRow/rowFromProto, recordfmt, the ClickHouse
  Kafka table + per-version MVs (_v2 table, explicit column aliases for
  _v0/_v1), k8s configMaps, hand-written SQL, and docs
  (protobuf-formats, parquet-format, record-versioning, socket-analysis).
  build/containers/clickhouse/sql/migrations/v2.sql for existing deployments.

Locality classification and lifecycle (pkg/localnet, pkg/xtcpnl, pkg/xtcp)
- RTN_LOCAL uses the route's real prefix; multipath/RTA_VIA/RTA_NH_ID routes
  classify as gateway-reached (Remote, oif 0); Self wins over Subnet
  regardless of dump order; only RT_TABLE_MAIN/LOCAL are read; unspecified
  destinations -> LOCALITY_UNSPECIFIED.
- Failed namespaces are negative-cached with backoff; loopback-only snapshots
  are re-dumped; new-namespace dumps per reconcile are capped;
  NLM_F_DUMP_INTR is retried; default locality_refresh_interval 60s.
- DumpRtnetlink loop factored into walkNlMsgs with table + fuzz tests over
  real nlmon captures; refreshLocality tests via an injected dumper.

ASN enricher hardening (pkg/ipasn, pkg/xtcp, internal/ipfeed, cmd/*)
- ipasn: only io.EOF ends a read, zero usable prefixes is ErrNoPrefixes, a bad
  reload never degrades the table in service, ReloadIfChanged skips an
  unchanged artifact by stat.
- initAsnEnricher installs the index even when the first load fails so a
  late-arriving artifact is picked up on the refresh tick.
- output.WriteParquet is atomic (.tmp + fsync + rename); fetch bodies are
  capped at 256 MiB; -enrichAsn/-asnDbPath/-asnRefreshInterval flags + env.
- Tests for the six previously untested feed parsers, summary, envInt,
  s3.parseEndpoint; tabularised the remaining ad-hoc tests.

Lookup-table metrics (operational visibility)
- Daemon: function="loadAsn" gauges prefixes / artifactBytes / loadedAt and
  summaries build / error duration, published by one loadAsn helper used by
  both the start-up load and the refresh tick; ipasn.Index.Stats() backs it.
- Collector: OTel instruments bridged to Prometheus via
  go.opentelemetry.io/otel/exporters/prometheus on a private registry and
  served as /metrics on the daemon health server. New instruments:
  ipfeed.source.records, ipfeed.artifact.records, ipfeed.artifact.size,
  ipfeed.write.duration, ipfeed.upload.duration, ipfeed.cycle.duration.
- goVendorHash updated for the new dependency.

E2E and tooling
- self-test check 5f distinguishes bound vs egress records; check 5g asserts
  the ASN, owner, locality AND the loadAsn/prefixes gauge (fixture-derived);
  client units Restart=always; TestPrintFlags nil-deref fixed.

Verification: go vet/gofmt clean; go test ./... green (-race on localnet,
xtcpnl, ipasn, xtcp, telemetry, health); golangci-lint only the pre-existing
govet unusedwrite; proto-field-audit clean; nix build .#xtcp2-all;
nix run .#test-microvm-lifecycle-x86_64-interface-naming OVERALL PASS
(XTCP2_SELF_TEST_ASN_PASS dest=8.8.8.8:53 asn=15169 owner=google prefixes=3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant