diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c6c8b4..0a9c8c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,7 @@ jobs: run: python3 -B pkg/test-release-metadata.py - name: Build run: cargo build --locked --verbose + - name: Test offline BMP decoder + run: cargo test --locked -p netom-bmpdump --verbose - name: Run tests, including mrtgen MRT corpus run: cargo test --locked --lib --verbose diff --git a/Cargo.lock b/Cargo.lock index 9568245..d01e6bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1594,7 +1594,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2122,7 +2122,7 @@ dependencies = [ [[package]] name = "netom" -version = "0.6.0" +version = "0.6.1" dependencies = [ "allocator-api2", "anyhow", @@ -2198,6 +2198,17 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "netom-bmpdump" +version = "0.6.1" +dependencies = [ + "anyhow", + "chrono", + "clap", + "serde_json", + "terminal_size", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -2533,7 +2544,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2573,7 +2584,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3089,7 +3100,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3519,6 +3530,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 268beb6..0fdfa68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,14 +26,15 @@ name = "netom-cli" path = "src/bin/netom-cli/main.rs" [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2021" rust-version = "1.88" authors = ["NLnet Labs ", "FastNetMon Inc "] [workspace] -#members = [] +members = ["tools/bmpdump"] +default-members = [".", "tools/bmpdump"] [workspace.dependencies] routecore = { git = "https://github.com/FastNetMon/routecore.git", features = ["bgp", "bmp", "serde", "fsm", "mrt"] } @@ -165,15 +166,18 @@ priority = "optional" assets = [ ["target/release/netom", "usr/bin/", "755"], ["target/release/netom-cli", "usr/bin/", "755"], + ["target/release/netom-bmpdump", "usr/bin/", "755"], ["README.md", "usr/share/doc/netom/", "644"], ["NOTICE.md", "usr/share/doc/netom/", "644"], ["docs/bmp-tcp-in.md", "usr/share/doc/netom/", "644"], + ["docs/bmpdump.md", "usr/share/doc/netom/", "644"], ["docs/clickhouse.md", "usr/share/doc/netom/", "644"], ["docs/clickhouse/schema.sql", "usr/share/doc/netom/clickhouse/schema.sql", "644"], ["docs/clickhouse/example.conf", "usr/share/doc/netom/clickhouse/example.conf", "644"], ["docs/clickhouse/schema.sql", "usr/share/netom/clickhouse/schema.sql", "644"], ["doc/netom.1", "usr/share/man/man1/netom.1", "644"], ["doc/netom-cli.1", "usr/share/man/man1/netom-cli.1", "644"], + ["doc/netom-bmpdump.1", "usr/share/man/man1/netom-bmpdump.1", "644"], ["etc/netom.conf", "etc/netom/netom.conf.example", "644"], ["etc/examples/filters.roto.example", "usr/share/netom/examples/filters.roto.example", "644"], diff --git a/Changelog.md b/Changelog.md index 1e53192..d73b54f 100644 --- a/Changelog.md +++ b/Changelog.md @@ -27,6 +27,11 @@ Released yyyy-mm-dd. ### New +* Added `netom-bmpdump`, an offline BMP/BGP/EVPN decoder for PCAP and PCAPNG + captures, with TCP reassembly, readable/colorized output, filters, and + JSON export. The standalone workspace package is included in the + DEB/RPM packages and runtime images. + * `netom-cli show ip bgp detail` (and `show ipv6 bgp detail`) prints every attribute of each path: peer and AS, how it was learned (ingress, BMP, pre/post-policy, ADD-PATH path id), status and RPKI, origin, AS path, diff --git a/README.md b/README.md index f8b83dd..c1089e2 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,20 @@ see [Releases](docs/releases.md). For issues with this fork, use the FastNetMon repository and support channels. Upstream NLnet Labs community resources may not apply to this fork. +## Offline BMP capture decoder + +The additional `netom-bmpdump` tool decodes BMP/BGP/EVPN from PCAP and PCAPNG +files, with TCP reassembly, readable/colorized output, filters, and JSON. +It runs without the daemon, Python, TShark, or root privileges. + +```sh +cargo build --locked --release -p netom-bmpdump +./target/release/netom-bmpdump -r capture.pcapng --evpn -v +``` + +It is included in the Netom DEB/RPM packages and runtime images. See the +[decoder guide](docs/bmpdump.md) for usage and limitations. + ## GOALS #### Modularity diff --git a/TESTING.md b/TESTING.md index a21517a..aa9ae79 100644 --- a/TESTING.md +++ b/TESTING.md @@ -80,3 +80,19 @@ validity states. The `e2e-flowspec` GitHub workflow runs both scripts on demand (workflow_dispatch). + +## Offline BMP decoder + +```sh +cargo test --locked -p netom-bmpdump +cargo clippy --locked -p netom-bmpdump --all-targets -- -D warnings +``` + +Tests construct their own packet fixtures and do not require a running Netom +daemon. For the optional `netom-bmp-evpn.pcapng` reference-capture regression +(the capture is not distributed in this repository), set: + +```sh +NETOM_BMPDUMP_TEST_CAPTURE=/path/to/netom-bmp-evpn.pcapng \ + cargo test --locked -p netom-bmpdump supplied_capture_regression +``` diff --git a/doc/netom-bmpdump.1 b/doc/netom-bmpdump.1 new file mode 100644 index 0000000..68cdce2 --- /dev/null +++ b/doc/netom-bmpdump.1 @@ -0,0 +1,103 @@ +.TH "netom\-bmpdump" "1" "FastNetMon Inc" +.SH NAME +netom\-bmpdump \- offline BMP, BGP and EVPN capture decoder +.SH SYNOPSIS +.B netom\-bmpdump +.BI \-r " FILE" +[options] +.SH DESCRIPTION +Reads PCAP or PCAPNG captures, reassembles TCP streams, and displays BMP +messages and their BGP/EVPN contents. It runs without a Netom daemon, +TShark, Python, or root privileges. +.PP +Each output record represents one BMP message. Multiple messages in one +packet are separated and split messages are reassembled. +.SH OPTIONS +.TP +.BI \-r " FILE" "\fR, \fB\-\-read " FILE +Read a capture. Use a dash for standard input. +.TP +.BI \-p " PORT" "\fR, \fB\-\-port " PORT +Select a BMP TCP port. Repeat to select multiple ports. +Replaces the default ports 11019 and 1790. +.TP +.BI \-c " COUNT" "\fR, \fB\-\-count " COUNT +Stop after this many matching BMP messages. +.TP +.B \-v\fR, \fB\-\-verbose +Print readable message blocks with every route. +Use \fB\-vv\fR to also print the complete decoded JSON. +.TP +.B \-n +Numeric addresses, which are always enabled. +.TP +.B \-j\fR, \fB\-\-json +Print newline-delimited JSON. JSON output never contains ANSI colors. +.TP +.B \-\-summary +Only print aggregate counts. Combine with \fB\-\-json\fR for a JSON summary. +.TP +.BI \-\-color " auto|always|never" +Control ANSI colors. The default, auto, requires a recognized color terminal +and honors NO_COLOR. Always explicitly overrides automatic detection. +.TP +.BI \-\-router " ADDRESS" +Filter by the BMP exporter's source IP address. +.TP +.BI \-\-peer " ADDRESS" +Filter by the monitored BGP peer's IP address. +.TP +.BI \-\-type " TYPE" +Filter by route-monitoring, statistics, peer-down, peer-up, initiation, +termination, or route-mirroring. +.TP +.B \-\-evpn +Only messages containing EVPN NLRI. +.TP +.BI \-\-route-type " 1|2|3|4|5" +Only messages containing this EVPN route type. +.TP +.B \-\-add-path +Force ADD-PATH for supported families when Peer Up negotiation is missing. +Use only when those families are known to use ADD-PATH. +.TP +.B \-h\fR, \fB\-\-help +Show command help. +.TP +.B \-V\fR, \fB\-\-version +Show the Netom release version. +.SH EXAMPLES +.nf +netom-bmpdump -r capture.pcapng --evpn -v +netom-bmpdump -r capture.pcapng --route-type 5 -j +netom-bmpdump -r capture.pcapng --summary +netom-bmpdump -r capture.pcapng -v --color always | less -R +.fi +.SH EXIT STATUS +0: clean completion. +.br +2: read/decode errors or diagnostics; partial output may still be useful. +.br +130: interrupted. +.SH LIMITATIONS +IP fragment reassembly, encrypted transport, IPv6 jumbograms, and extended +BGP OPEN optional-parameter encoding are not implemented. Checksums are not +validated. Missing TCP data is reported. Unknown protocol fields are +retained as raw hexadecimal data in JSON. +.PP +Capture timestamps use UTC and identify the packet completing reassembly. +Filters select whole messages, including other routes in a matching message. +.SH ENVIRONMENT +.TP +.B NO_COLOR +Disable automatic coloring. +.TP +.B TERM +Used for terminal color detection. +.TP +.B COLUMNS +Override the output width, bounded to 60 through 120 columns. +.SH SEE ALSO +.BR netom (1), +.BR netom-cli (1), +.BR tcpdump (8) diff --git a/docs/bmpdump.md b/docs/bmpdump.md new file mode 100644 index 0000000..c1992e0 --- /dev/null +++ b/docs/bmpdump.md @@ -0,0 +1,151 @@ +# Offline BMP capture decoder + +`netom-bmpdump` decodes BMP, BGP, and EVPN messages from PCAP and PCAPNG +captures. Use it to inspect traffic sent to or from a Netom BMP collector. +It does not require a running Netom daemon, Python, TShark, libpcap, or root +privileges. + +## Installation + +The tool is shipped alongside `netom` and `netom-cli` in the Netom DEB/RPM +packages and runtime images. To build just the decoder from source: + +```sh +cargo build --locked --release -p netom-bmpdump +./target/release/netom-bmpdump --version +``` + +It is a separate workspace package and does not link to the daemon library. +It inherits the Netom release version and minimum Rust version. Its source +uses Rust edition 2024, which the project's Rust 1.88 minimum supports. + +To install only this executable with Cargo: + +```sh +cargo install --path tools/bmpdump --locked +``` + +## Reading a capture + +```sh +# First 20 BMP messages. +netom-bmpdump -r capture.pcapng -c 20 + +# Every EVPN route with readable attributes. +netom-bmpdump -r capture.pcapng --evpn -v + +# EVPN IP-prefix routes. +netom-bmpdump -r capture.pcapng --route-type 5 -v + +# Filter exporter, monitored BGP peer, or message type. +netom-bmpdump -r capture.pcapng --router 192.0.2.10 +netom-bmpdump -r capture.pcapng --peer 192.0.2.20 --type route-monitoring + +# Newline-delimited JSON or aggregate counts. +netom-bmpdump -r capture.pcapng --json > messages.jsonl +netom-bmpdump -r capture.pcapng --summary +netom-bmpdump -r capture.pcapng --summary --json + +# Custom BMP ports; repeat -p to select more than one. +netom-bmpdump -r capture.pcap -p 5000 -p 5001 + +# Read a capture from stdin. +cat capture.pcapng | netom-bmpdump -r - +``` + +Default BMP ports are **11019 and 1790**. Specifying `-p` replaces these +defaults. Addresses are always numeric; `-n` is accepted for familiarity. + +Each output record is one BMP message, not one TCP packet. The decoder +reassembles split messages and separates coalesced messages. `-c` counts +messages after filtering. Filters select complete messages, including any +other routes in the same matching message. + +Frame references include a message ordinal: `Frame 20.3` (or `#20.3` in +compact output) means the third BMP message completed in capture frame 20. +The ordinal starts at one in each frame and is assigned before filtering, +so references remain consistent across filters. JSON exposes the components +as `frame` and `frame_message`. + +Times are UTC. Verbose output labels the full date and time as `Capture time`; +compact output shows the time of day. The frame number and capture timestamp +identify the packet that completed reassembly; the BMP peer timestamp remains +available separately in JSON. Untimestamped PCAPNG Simple Packet Blocks display +`unknown-time`. + +## Readable output and colors + +`-v` prints aligned message blocks, individual communities, and a block for +each announcement or withdrawal. `-vv` also prints the full decoded JSON. + +ANSI colors are automatic on recognized color terminals: cyan headings and +labels, green announcements, red withdrawals/errors, and yellow warnings. +Pipes, redirects, `TERM=dumb`, an unset `TERM`, and `NO_COLOR` disable +automatic coloring. + +```sh +netom-bmpdump -r capture.pcapng -v --color never +netom-bmpdump -r capture.pcapng -v --color always | less -R +``` + +`--color always` overrides detection and `NO_COLOR`. JSON on stdout is always +plain. Diagnostics go to stderr. + +## Supported decoding + +- PCAP in either byte order, with microsecond or nanosecond timestamps. +- PCAPNG sections and interfaces, Enhanced/Simple/obsolete Packet Blocks, + decimal/binary timestamp resolution, and timestamp offsets. +- Ethernet and VLANs, Linux cooked v1/v2, raw IP, BSD loopback, IPv4/IPv6 TCP, + and IPv6 extension headers. +- TCP reassembly, out-of-order segments after the initial sequence is + established, retransmissions, overlaps, and sequence wraparound. +- BMP v3 Initiation, Peer Up/Down, Route Monitoring, Statistics, + Termination, and Route Mirroring; Loc-RIB and pre/post-policy metadata. +- BGP OPEN capabilities, UPDATE, NOTIFICATION, KEEPALIVE, ROUTE-REFRESH, + IPv4/IPv6 unicast/multicast NLRI, MP_REACH/MP_UNREACH, and End-of-RIB. +- Common attributes including AS_PATH/AS4_PATH, next hop, MED, local + preference, communities, extended/large communities, and PMSI tunnels. +- EVPN route types 1–5, including RD, ESI, MAC/IP, prefix, next hop, label, + and explicitly signaled overlay VNI fields. +- ADD-PATH inferred from Peer Up capabilities. `--add-path` forces it for + all supported families when negotiation is missing; use it only when + those families are known to use ADD-PATH. + +Unknown message types, attributes, capabilities, route types, and address +families retain raw hexadecimal data in JSON. + +## Errors and limits + +Exit codes are **0** for clean completion, **2** for read/decode errors or +diagnostics, and **130** for interruption. A capture may produce useful +output and still return 2. `-c` intentionally stops early without diagnosing +the unread capture tail. + +Malformed nested OPENs are retained with errors, and decoding continues at +the next BMP boundary. Other malformed bodies appear as raw error records. +An invalid established-stream BMP header or an exceeded buffer limit stops +decoding. + +IP fragment reassembly, encrypted transport, IPv6 jumbograms, and extended +BGP OPEN optional-parameter encoding are not implemented. Checksums are +not validated. Missing TCP bytes are reported; gaps are not filled with +invented data. Midstream capture recovery searches for a plausible BMP v3 +boundary and reports discarded bytes. + +Consumed retransmissions are ignored. Overlapping data follows the bytes +first delivered by the reassembler, which may differ from an endpoint's +overlap policy. Conflicting queued retransmissions with the same starting +sequence fail. Interfaces are tracked separately and can contain duplicate +observations of one connection. + +Limits are 64 MiB per capture block, 4 MiB per BMP message, 8 MiB buffered per +TCP direction, 64 MiB buffered globally, 256 tracked TCP directions, and +4096 peer capability records per direction. + +EVPN withdrawals can omit encapsulation communities. Without an explicit +overlay indication, output retains `label_raw` and its MPLS interpretation, +and does not assume a VNI. + +For decoder tests, see +[Testing](https://github.com/FastNetMon/netom/blob/main/TESTING.md#offline-bmp-decoder). diff --git a/docs/index.md b/docs/index.md index ea2193d..e4ed519 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,7 @@ configuration and API details can change between 0.x versions. See quickstart configuration cli +bmpdump ``` ```{toctree} diff --git a/docs/releases.md b/docs/releases.md index 3e7893e..9ebef40 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -66,7 +66,7 @@ both tested image digests. It also creates a draft GitHub release with all four packages, the container manifest, SHA-256 checksums, and generated release notes. Review and publish the draft in GitHub. A rerun can update an existing draft, but refuses to replace the assets or versioned image of a published release. -There is no automatic `latest` alias: versions such as `0.6.0` are explicit. +There is no automatic `latest` alias: versions such as `0.6.1` are explicit. Temporary `build---` tags retain the individual images. GHCR uses the repository's `GITHUB_TOKEN` with `packages: write`; no separate @@ -92,15 +92,15 @@ python3 pkg/release-metadata.py # Match NETOM_VERSION to Cargo.toml. The Dockerfile defaults match targets.toml. docker build -f pkg/Dockerfile --target artifacts \ --build-arg DISTRO_IMAGE=ubuntu:22.04 --build-arg PACKAGE_FORMAT=deb \ - --build-arg NETOM_VERSION=0.6.0 --output type=local,dest=dist . + --build-arg NETOM_VERSION=0.6.1 --output type=local,dest=dist . docker build -f pkg/Dockerfile --target artifacts \ --build-arg DISTRO_IMAGE=almalinux:8 --build-arg PACKAGE_FORMAT=rpm \ - --build-arg NETOM_VERSION=0.6.0 --output type=local,dest=dist-rpm . + --build-arg NETOM_VERSION=0.6.1 --output type=local,dest=dist-rpm . # dist/ must contain only the DEB for the current native architecture. -docker build --build-arg VERSION=0.6.0 -t netom:local . -bash pkg/test-scripts/smoke-image.sh netom:local 0.6.0 +docker build --build-arg VERSION=0.6.1 -t netom:local . +bash pkg/test-scripts/smoke-image.sh netom:local 0.6.1 ``` Use `--output type=local` with BuildKit to export packages without copying files @@ -113,7 +113,7 @@ To run the published image with your own configuration: ```sh docker run --rm -p 8080:8080 -p 11019:11019 \ -v "$PWD/netom.conf:/etc/netom/netom.conf:ro" \ - ghcr.io/fastnetmon/netom:0.6.0 + ghcr.io/fastnetmon/netom:0.6.1 ``` The packaged example configuration is installed as `netom.conf.example` in host diff --git a/pkg/build-package.sh b/pkg/build-package.sh index b9360e1..7dd3f70 100644 --- a/pkg/build-package.sh +++ b/pkg/build-package.sh @@ -9,16 +9,17 @@ if [ "$(uname -m)" = aarch64 ]; then export JEMALLOC_SYS_WITH_LG_PAGE=16 export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,-z,max-page-size=65536" fi -cargo build --locked --release --bin netom --bin netom-cli +cargo build --locked --release --workspace --bin netom --bin netom-cli --bin netom-bmpdump # Check the requested version against the actual binaries before packaging. test "$(target/release/netom --version)" = "netom $version" test "$(target/release/netom-cli --version)" = "netom-cli $version" +test "$(target/release/netom-bmpdump --version)" = "netom-bmpdump $version" case "$format" in deb) mkdir -p target/debian printf 'netom (%s) unstable; urgency=medium\n\n * Release %s\n\n -- FastNetMon Inc %s\n' \ "$version" "$version" "$(date -R)" > target/debian/changelog - cargo deb --locked --no-build --output /out/ + cargo deb --package netom --locked --no-build --output /out/ ;; rpm) rpm_version=${version/-/\~} diff --git a/pkg/rpm/netom.spec b/pkg/rpm/netom.spec index a6a1b21..44116af 100644 --- a/pkg/rpm/netom.spec +++ b/pkg/rpm/netom.spec @@ -13,21 +13,23 @@ Requires(pre): /usr/bin/getent %description FastNetMon's BGP/BMP routing data engine, including the netom-cli operational -client. Supports route monitoring, collection, and redistribution. +client and netom-bmpdump offline capture decoder. Supports route monitoring, +collection, and redistribution. %install install -Dm755 %{_sourcedir}/target/release/netom %{buildroot}%{_bindir}/netom install -Dm755 %{_sourcedir}/target/release/netom-cli %{buildroot}%{_bindir}/netom-cli +install -Dm755 %{_sourcedir}/target/release/netom-bmpdump %{buildroot}%{_bindir}/netom-bmpdump install -Dm644 %{_sourcedir}/pkg/common/netom.netom.service %{buildroot}/usr/lib/systemd/system/netom.service install -Dm644 %{_sourcedir}/etc/netom.conf %{buildroot}%{_sysconfdir}/netom/netom.conf.example install -Dm644 %{_sourcedir}/etc/examples/filters.roto.example %{buildroot}%{_datadir}/netom/examples/filters.roto.example install -Dm644 %{_sourcedir}/docs/clickhouse/schema.sql %{buildroot}%{_datadir}/netom/clickhouse/schema.sql -for name in netom netom-cli; do +for name in netom netom-cli netom-bmpdump; do install -Dm644 %{_sourcedir}/doc/$name.1 %{buildroot}%{_mandir}/man1/$name.1 done mkdir -p %{buildroot}%{_docdir}/netom/clickhouse %{buildroot}%{_licensedir}/netom %{buildroot}/var/lib/netom cp %{_sourcedir}/LICENSE %{buildroot}%{_licensedir}/netom/ -cp %{_sourcedir}/README.md %{_sourcedir}/NOTICE.md %{_sourcedir}/docs/bmp-tcp-in.md %{_sourcedir}/docs/clickhouse.md %{buildroot}%{_docdir}/netom/ +cp %{_sourcedir}/README.md %{_sourcedir}/NOTICE.md %{_sourcedir}/docs/bmp-tcp-in.md %{_sourcedir}/docs/bmpdump.md %{_sourcedir}/docs/clickhouse.md %{buildroot}%{_docdir}/netom/ cp %{_sourcedir}/docs/clickhouse/schema.sql %{_sourcedir}/docs/clickhouse/example.conf %{buildroot}%{_docdir}/netom/clickhouse/ %pre @@ -55,12 +57,14 @@ fi %files %{_bindir}/netom %{_bindir}/netom-cli +%{_bindir}/netom-bmpdump /usr/lib/systemd/system/netom.service %dir %{_sysconfdir}/netom %config(noreplace) %{_sysconfdir}/netom/netom.conf.example %{_datadir}/netom %{_mandir}/man1/netom.1* %{_mandir}/man1/netom-cli.1* +%{_mandir}/man1/netom-bmpdump.1* %doc %{_docdir}/netom %license %{_licensedir}/netom %dir %attr(0750,netom,netom) /var/lib/netom diff --git a/pkg/test-scripts/smoke-image.sh b/pkg/test-scripts/smoke-image.sh index 34f004d..463ead7 100644 --- a/pkg/test-scripts/smoke-image.sh +++ b/pkg/test-scripts/smoke-image.sh @@ -4,6 +4,7 @@ image=${1:?Image required} version=${2:?Version required} test "$(docker run --rm "$image" --version)" = "netom $version" test "$(docker run --rm --entrypoint netom-cli "$image" --version)" = "netom-cli $version" +test "$(docker run --rm --entrypoint netom-bmpdump "$image" --version)" = "netom-bmpdump $version" test "$(docker run --rm --entrypoint id "$image" -u)" -ne 0 container=$(docker run --detach --publish 127.0.0.1::8080 "$image") trap 'docker logs "$container"; docker rm --force "$container" >/dev/null' EXIT diff --git a/pkg/test-scripts/smoke-package.sh b/pkg/test-scripts/smoke-package.sh index e5387c9..4b50e28 100644 --- a/pkg/test-scripts/smoke-package.sh +++ b/pkg/test-scripts/smoke-package.sh @@ -18,8 +18,10 @@ esac test "$(netom --version)" = "netom $NETOM_VERSION" test "$(netom-cli --version)" = "netom-cli $NETOM_VERSION" +test "$(netom-bmpdump --version)" = "netom-bmpdump $NETOM_VERSION" netom --help > /dev/null netom-cli --help > /dev/null +netom-bmpdump --help > /dev/null user_id=$(id -u netom) test -f /etc/netom/netom.conf.example test -f /lib/systemd/system/netom.service || test -f /usr/lib/systemd/system/netom.service @@ -37,7 +39,7 @@ case "$PACKAGE_FORMAT" in rpm) dnf remove -y netom ;; esac hash -r -if command -v netom || command -v netom-cli; then +if command -v netom || command -v netom-cli || command -v netom-bmpdump; then echo 'Package binaries remain after removal' >&2 exit 1 fi diff --git a/tools/bmpdump/Cargo.toml b/tools/bmpdump/Cargo.toml new file mode 100644 index 0000000..ac66c35 --- /dev/null +++ b/tools/bmpdump/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "netom-bmpdump" +version.workspace = true +edition = "2024" +rust-version.workspace = true +authors.workspace = true +license = "MPL-2.0" +description = "Offline BMP, BGP and EVPN capture decoder for Netom" +repository = "https://github.com/FastNetMon/netom/" +readme = "README.md" + +[lib] +name = "bmpdump" + +[dependencies] +anyhow = "1.0" +chrono = { version = "0.4", default-features = false, features = ["std"] } +clap = { version = "4.6", features = ["derive"] } +serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] } +terminal_size = "0.4" diff --git a/tools/bmpdump/README.md b/tools/bmpdump/README.md new file mode 100644 index 0000000..1a18497 --- /dev/null +++ b/tools/bmpdump/README.md @@ -0,0 +1,15 @@ +# netom-bmpdump + +Standalone offline BMP/BGP/EVPN decoder, distributed as an additional Netom +tool. It does not link to the Netom daemon or require a running collector. + +From the repository root: + +```sh +cargo build --locked --release -p netom-bmpdump +./target/release/netom-bmpdump -r capture.pcapng --evpn -v +cargo test --locked -p netom-bmpdump +``` + +See [the user guide](../../docs/bmpdump.md) for formats, filters, JSON, +terminal colors, and decoding limits. diff --git a/tools/bmpdump/rustfmt.toml b/tools/bmpdump/rustfmt.toml new file mode 100644 index 0000000..0bde812 --- /dev/null +++ b/tools/bmpdump/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 78 +edition = "2024" diff --git a/tools/bmpdump/src/capture.rs b/tools/bmpdump/src/capture.rs new file mode 100644 index 0000000..7a36d88 --- /dev/null +++ b/tools/bmpdump/src/capture.rs @@ -0,0 +1,510 @@ +use anyhow::{Result, bail, ensure}; +use std::io::{ErrorKind, Read}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +const MAX_BLOCK: usize = 64 * 1024 * 1024; + +#[derive(Clone, Copy)] +enum Endian { + Little, + Big, +} + +impl Endian { + fn u16(self, data: &[u8]) -> u16 { + let bytes = [data[0], data[1]]; + match self { + Self::Little => u16::from_le_bytes(bytes), + Self::Big => u16::from_be_bytes(bytes), + } + } + fn u32(self, data: &[u8]) -> u32 { + let bytes = [data[0], data[1], data[2], data[3]]; + match self { + Self::Little => u32::from_le_bytes(bytes), + Self::Big => u32::from_be_bytes(bytes), + } + } + fn i64(self, data: &[u8]) -> i64 { + let bytes = data[..8].try_into().unwrap(); + match self { + Self::Little => i64::from_le_bytes(bytes), + Self::Big => i64::from_be_bytes(bytes), + } + } +} + +pub struct Packet { + pub number: u64, + pub timestamp_ns: Option, + pub linktype: u16, + pub data: Vec, + pub original_length: usize, + pub section: u64, + pub interface: u32, +} + +struct Interface { + linktype: u16, + snaplen: usize, + resolution: Option, + offset: i64, +} + +enum Format { + Pcap { + endian: Endian, + scale: u32, + snaplen: usize, + linktype: u16, + }, + Pcapng { + endian: Endian, + first: bool, + }, +} + +pub struct Capture { + reader: R, + format: Format, + interfaces: Vec, + number: u64, + section: u64, +} + +fn exact(reader: &mut impl Read, size: usize) -> Result> { + let mut data = vec![0; size]; + reader.read_exact(&mut data).map_err(|e| { + if e.kind() == ErrorKind::UnexpectedEof { + anyhow::anyhow!("truncated capture: expected {size} bytes") + } else { + e.into() + } + })?; + Ok(data) +} + +fn optional_exact( + reader: &mut impl Read, + size: usize, +) -> Result>> { + let mut first = [0]; + loop { + match reader.read(&mut first) { + Ok(0) => return Ok(None), + Ok(_) => break, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(e.into()), + } + } + let mut data = vec![first[0]]; + data.extend(exact(reader, size - 1)?); + Ok(Some(data)) +} + +impl Capture { + pub fn new(mut reader: R) -> Result { + let magic = exact(&mut reader, 4)?; + let encoding = match magic.as_slice() { + [0xd4, 0xc3, 0xb2, 0xa1] => Some((Endian::Little, 1000)), + [0xa1, 0xb2, 0xc3, 0xd4] => Some((Endian::Big, 1000)), + [0x4d, 0x3c, 0xb2, 0xa1] => Some((Endian::Little, 1)), + [0xa1, 0xb2, 0x3c, 0x4d] => Some((Endian::Big, 1)), + [0x0a, 0x0d, 0x0d, 0x0a] => None, + _ => bail!("not a PCAP or PCAPNG file"), + }; + let format = if let Some((endian, scale)) = encoding { + let header = exact(&mut reader, 20)?; + ensure!( + endian.u16(&header) == 2 && endian.u16(&header[2..]) == 4, + "unsupported PCAP version" + ); + Format::Pcap { + endian, + scale, + snaplen: endian.u32(&header[12..]) as usize, + linktype: endian.u32(&header[16..]) as u16, + } + } else { + Format::Pcapng { + endian: Endian::Little, + first: true, + } + }; + Ok(Self { + reader, + format, + interfaces: Vec::new(), + number: 0, + section: 0, + }) + } + + pub fn next_packet(&mut self) -> Result> { + if let Format::Pcap { + endian, + scale, + snaplen, + linktype, + } = self.format + { + let Some(h) = optional_exact(&mut self.reader, 16)? else { + return Ok(None); + }; + let sec = endian.u32(&h); + let frac = endian.u32(&h[4..]); + let size = endian.u32(&h[8..]) as usize; + let original = endian.u32(&h[12..]) as usize; + ensure!( + size <= MAX_BLOCK + && size <= original + && (snaplen == 0 || size <= snaplen), + "invalid PCAP captured length" + ); + ensure!( + frac < 1_000_000_000 / scale, + "invalid PCAP timestamp fraction" + ); + self.number += 1; + return Ok(Some(Packet { + number: self.number, + timestamp_ns: Some( + i128::from(sec) * 1_000_000_000 + + i128::from(frac) * i128::from(scale), + ), + linktype, + data: exact(&mut self.reader, size)?, + original_length: original, + section: 0, + interface: 0, + })); + } + loop { + let Format::Pcapng { mut endian, first } = self.format else { + unreachable!() + }; + let magic = if first { + vec![0x0a, 0x0d, 0x0d, 0x0a] + } else if let Some(magic) = optional_exact(&mut self.reader, 4)? { + magic + } else { + return Ok(None); + }; + let length = exact(&mut self.reader, 4)?; + let section_header = magic == [0x0a, 0x0d, 0x0d, 0x0a]; + let prefix = if section_header { + exact(&mut self.reader, 4)? + } else { + vec![] + }; + if section_header { + endian = match prefix.as_slice() { + [0x4d, 0x3c, 0x2b, 0x1a] => Endian::Little, + [0x1a, 0x2b, 0x3c, 0x4d] => Endian::Big, + _ => bail!("invalid PCAPNG byte-order magic"), + }; + } + let size = endian.u32(&length) as usize; + ensure!( + size >= 12 + prefix.len() + && size.is_multiple_of(4) + && size <= MAX_BLOCK, + "invalid or oversized PCAPNG block: {size}" + ); + let rest = exact(&mut self.reader, size - 8 - prefix.len())?; + ensure!( + endian.u32(&rest[rest.len() - 4..]) as usize == size, + "PCAPNG block lengths disagree" + ); + let mut body = prefix; + body.extend_from_slice(&rest[..rest.len() - 4]); + self.format = Format::Pcapng { + endian, + first: false, + }; + let kind = endian.u32(&magic); + match kind { + 0x0a0d0d0a => { + ensure!( + body.len() >= 16 && endian.u16(&body[4..]) == 1, + "unsupported PCAPNG section" + ); + if !first { + self.section += 1; + } + self.interfaces.clear(); + } + 1 => { + ensure!(body.len() >= 8, "short PCAPNG interface block"); + let mut iface = Interface { + linktype: endian.u16(&body), + snaplen: endian.u32(&body[4..]) as usize, + resolution: Some(1_000_000), + offset: 0, + }; + let mut pos = 8; + while pos < body.len() { + ensure!( + body.len() - pos >= 4, + "truncated PCAPNG option" + ); + let code = endian.u16(&body[pos..]); + let size = endian.u16(&body[pos + 2..]) as usize; + pos += 4; + if code == 0 { + ensure!( + size == 0, + "invalid PCAPNG end-of-options" + ); + break; + } + let padded = (size + 3) & !3; + ensure!( + padded <= body.len() - pos, + "PCAPNG option exceeds block" + ); + let value = &body[pos..pos + size]; + match code { + 9 => { + ensure!(size == 1, "invalid if_tsresol"); + let exponent = value[0]; + iface.resolution = if exponent & 128 != 0 { + 2u128.checked_pow(u32::from( + exponent & 127, + )) + } else { + 10u128.checked_pow(u32::from(exponent)) + }; + } + 14 => { + ensure!(size == 8, "invalid if_tsoffset"); + iface.offset = endian.i64(value); + } + _ => {} + } + pos += padded; + } + self.interfaces.push(iface); + } + 2 | 3 | 6 => { + let (interface, captured, original, start, ticks) = + if kind == 3 { + ensure!( + body.len() >= 4 + && !self.interfaces.is_empty(), + "invalid PCAPNG simple packet block" + ); + let original = endian.u32(&body) as usize; + let snaplen = self.interfaces[0].snaplen; + ( + 0, + original.min(if snaplen == 0 { + original + } else { + snaplen + }), + original, + 4, + None, + ) + } else { + ensure!( + body.len() >= 20, + "short PCAPNG packet block" + ); + let iface = if kind == 6 { + endian.u32(&body) + } else { + u32::from(endian.u16(&body)) + }; + let ticks = (u64::from(endian.u32(&body[4..])) + << 32) + | u64::from(endian.u32(&body[8..])); + ( + iface, + endian.u32(&body[12..]) as usize, + endian.u32(&body[16..]) as usize, + 20, + Some(ticks), + ) + }; + let iface = self + .interfaces + .get(interface as usize) + .ok_or_else(|| { + anyhow::anyhow!( + "unknown PCAPNG interface {interface}" + ) + })?; + ensure!( + captured <= original + && (iface.snaplen == 0 + || captured <= iface.snaplen) + && captured <= MAX_BLOCK + && start + ((captured + 3) & !3) <= body.len(), + "invalid PCAPNG packet length" + ); + let timestamp_ns = ticks.map(|t| { + let fraction = iface + .resolution + .map_or(0, |d| u128::from(t) * 1_000_000_000 / d); + fraction as i128 + + i128::from(iface.offset) * 1_000_000_000 + }); + self.number += 1; + return Ok(Some(Packet { + number: self.number, + timestamp_ns, + linktype: iface.linktype, + data: body[start..start + captured].to_vec(), + original_length: original, + section: self.section, + interface, + })); + } + _ => {} + } + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct Flow { + pub section: u64, + pub interface: u32, + pub tags: Vec, + pub src: IpAddr, + pub dst: IpAddr, + pub sport: u16, + pub dport: u16, +} + +pub struct Segment<'a> { + pub flow: Flow, + pub seq: u32, + pub flags: u8, + pub payload: &'a [u8], +} + +fn slice(data: &[u8], start: usize, count: usize) -> Result<&[u8]> { + data.get(start..start + count) + .ok_or_else(|| anyhow::anyhow!("truncated network packet")) +} + +pub fn tcp_segment(packet: &Packet) -> Result>> { + let d = &packet.data; + let mut tags = Vec::new(); + let (mut proto, mut start) = match packet.linktype { + 1 => (Endian::Big.u16(slice(d, 12, 2)?), 14), + 113 => (Endian::Big.u16(slice(d, 14, 2)?), 16), + 276 => { + slice(d, 0, 20)?; + tags.push(Endian::Big.u32(&d[4..])); + (Endian::Big.u16(d), 20) + } + 101 | 228 | 229 => ( + match slice(d, 0, 1)?[0] >> 4 { + 4 => 0x800, + 6 => 0x86dd, + _ => 0, + }, + 0, + ), + 0 | 108 => ( + match slice(d, 4, 1)?[0] >> 4 { + 4 => 0x800, + 6 => 0x86dd, + _ => 0, + }, + 4, + ), + other => bail!("unsupported link type {other}"), + }; + if packet.linktype == 1 { + while matches!(proto, 0x8100 | 0x88a8 | 0x9100) { + let h = slice(d, start, 4)?; + tags.push(u32::from(Endian::Big.u16(h) & 4095)); + proto = Endian::Big.u16(&h[2..]); + start += 4; + } + } + let (src, dst, end) = if proto == 0x800 { + let h = slice(d, start, 20)?; + let ihl = usize::from(h[0] & 15) * 4; + let total = Endian::Big.u16(&h[2..]) as usize; + ensure!( + h[0] >> 4 == 4 && ihl >= 20 && total >= ihl, + "invalid IPv4 header" + ); + slice(d, start, total)?; + ensure!( + Endian::Big.u16(&h[6..]) & 0x3fff == 0, + "fragmented IPv4 packet (IP reassembly is not implemented)" + ); + if h[9] != 6 { + return Ok(None); + } + let src = IpAddr::V4(Ipv4Addr::new(h[12], h[13], h[14], h[15])); + let dst = IpAddr::V4(Ipv4Addr::new(h[16], h[17], h[18], h[19])); + let end = start + total; + start += ihl; + (src, dst, end) + } else if proto == 0x86dd { + let h = slice(d, start, 40)?; + ensure!(h[0] >> 4 == 6, "invalid IPv6 version"); + let length = Endian::Big.u16(&h[4..]) as usize; + ensure!(length != 0, "IPv6 jumbogram"); + let end = start + 40 + length; + slice(d, start, 40 + length)?; + let src = IpAddr::V6(Ipv6Addr::from( + <[u8; 16]>::try_from(&h[8..24]).unwrap(), + )); + let dst = IpAddr::V6(Ipv6Addr::from( + <[u8; 16]>::try_from(&h[24..40]).unwrap(), + )); + proto = u16::from(h[6]); + start += 40; + while matches!(proto, 0 | 43 | 44 | 51 | 60) { + ensure!(start + 2 <= end, "truncated IPv6 extension"); + let size = if proto == 44 { + ensure!(start + 8 <= end, "truncated IPv6 fragment header"); + ensure!( + Endian::Big.u16(&d[start + 2..]) & 0xfff9 == 0, + "fragmented IPv6 packet (IP reassembly is not implemented)" + ); + 8 + } else if proto == 51 { + (usize::from(d[start + 1]) + 2) * 4 + } else { + (usize::from(d[start + 1]) + 1) * 8 + }; + proto = u16::from(d[start]); + start += size; + ensure!(start <= end, "IPv6 extension exceeds packet"); + } + if proto != 6 { + return Ok(None); + } + (src, dst, end) + } else { + return Ok(None); + }; + ensure!(start + 20 <= end, "truncated TCP header"); + let h = &d[start..end]; + let hlen = usize::from(h[12] >> 4) * 4; + ensure!(hlen >= 20 && hlen <= h.len(), "invalid TCP header length"); + Ok(Some(Segment { + flow: Flow { + section: packet.section, + interface: packet.interface, + tags, + src, + dst, + sport: Endian::Big.u16(h), + dport: Endian::Big.u16(&h[2..]), + }, + seq: Endian::Big.u32(&h[4..]), + flags: h[13], + payload: &h[hlen..], + })) +} diff --git a/tools/bmpdump/src/cli.rs b/tools/bmpdump/src/cli.rs new file mode 100644 index 0000000..ce4f881 --- /dev/null +++ b/tools/bmpdump/src/cli.rs @@ -0,0 +1,383 @@ +use crate::capture::{Capture, Flow, tcp_segment}; +use crate::display::{Palette, display_message, safe}; +use crate::protocol::{BMP_NAMES, BmpDecoder, array, bmp_name, hex}; +use crate::stream::Stream; +use anyhow::{Context, Result, bail}; +use clap::{ArgAction, Parser, ValueEnum}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap}; +use std::fs::File; +use std::io::{self, BufReader, BufWriter, Read, Write}; +use std::net::IpAddr; +use std::num::{NonZeroU16, NonZeroU64}; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum Color { + #[default] + Auto, + Always, + Never, +} + +#[derive(Parser, Debug)] +#[command( + version, + about = "Standalone offline BMP/BGP/EVPN decoder for PCAP and PCAPNG" +)] +pub struct Args { + /// Capture file, or - for stdin + #[arg(short = 'r', long = "read", value_name = "FILE")] + pub read: PathBuf, + /// BMP TCP port; repeatable (default: 11019, 1790) + #[arg(short = 'p', long = "port")] + pub port: Vec, + /// Stop after N matching BMP messages + #[arg(short = 'c', long = "count")] + pub count: Option, + /// Readable message blocks; -vv also adds full decoded JSON + #[arg(short='v', long="verbose", action=ArgAction::Count)] + pub verbose: u8, + /// Numeric addresses (always enabled) + #[arg(short = 'n')] + pub numeric: bool, + /// Newline-delimited JSON (always plain) + #[arg(short = 'j', long = "json")] + pub json: bool, + /// ANSI colors for text output; auto honors NO_COLOR + #[arg(long, value_enum, default_value = "auto")] + pub color: Color, + /// Only print aggregate counts (JSON with -j) + #[arg(long)] + pub summary: bool, + /// Filter by BMP exporter source address + #[arg(long)] + pub router: Option, + /// Filter by monitored BGP peer address + #[arg(long)] + pub peer: Option, + /// BMP message type + #[arg(long="type", value_parser=BMP_NAMES)] + pub message_type: Option, + /// Only messages containing EVPN NLRI + #[arg(long)] + pub evpn: bool, + /// Only messages containing this EVPN route type + #[arg(long, value_parser=clap::value_parser!(u8).range(1..=5))] + pub route_type: Option, + /// Force ADD-PATH for supported families when Peer Up is missing + #[arg(long)] + pub add_path: bool, +} + +pub fn routes_of(message: &Value) -> impl Iterator { + array(&message["bgp"]) + .iter() + .flat_map(|b| array(&b["routes"]).iter()) +} +fn matches(message: &Value, args: &Args) -> bool { + args.router.is_none_or(|ip| { + message["source"]["address"].as_str() == Some(ip.to_string().as_str()) + }) && args.peer.is_none_or(|ip| { + message["peer"]["address"].as_str() == Some(ip.to_string().as_str()) + }) && args + .message_type + .as_ref() + .is_none_or(|kind| message["name"].as_str() == Some(kind.as_str())) + && (!args.evpn + || routes_of(message).any(|r| r["afi"] == 25 && r["safi"] == 70)) + && args.route_type.is_none_or(|kind| { + routes_of(message).any(|r| r["route_type"] == kind) + }) +} + +struct Connection { + stream: Stream, + decoder: BmpDecoder, +} +struct Diagnostics { + count: u64, + colors: Palette, +} +impl Diagnostics { + fn warn(&mut self, message: &str) -> Result<()> { + self.count += 1; + if self.count <= 20 { + writeln!( + io::stderr().lock(), + "{}", + self.colors.paint( + &format!("netom-bmpdump: {}", safe(message)), + "warning" + ) + )?; + } else if self.count == 21 { + writeln!( + io::stderr().lock(), + "netom-bmpdump: further diagnostics suppressed; see final count" + )?; + } + Ok(()) + } +} +fn increment(counts: &mut BTreeMap, name: impl Into) { + *counts.entry(name.into()).or_default() += 1; +} + +pub fn run(args: &Args) -> Result { + let ports: Vec = if args.port.is_empty() { + vec![11019, 1790] + } else { + args.port.iter().map(|p| p.get()).collect() + }; + let mut connections: HashMap = HashMap::new(); + let colors = Palette::new( + if args.json { Color::Never } else { args.color }, + false, + ); + let mut diagnostics = Diagnostics { + count: 0, + colors: Palette::new(args.color, true), + }; + let ( + mut packets, + mut tcp_packets, + mut bmp_messages, + mut matched_messages, + mut decode_errors, + ) = (0u64, 0u64, 0u64, 0u64, 0u64); + let (mut types, mut route_types, mut actions, mut exporters) = ( + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + ); + let source: Box = if args.read.as_os_str() == "-" { + Box::new(io::stdin()) + } else { + Box::new(File::open(&args.read).with_context(|| { + format!("cannot open capture '{}'", args.read.display()) + })?) + }; + let mut capture = Capture::new(BufReader::new(source))?; + let mut out = BufWriter::new(io::stdout().lock()); + let mut stopped = false; + while let Some(packet) = capture.next_packet()? { + packets += 1; + let segment = match tcp_segment(&packet) { + Ok(Some(s)) => s, + Ok(None) => continue, + Err(error) => { + diagnostics + .warn(&format!("frame {}: {error}", packet.number))?; + continue; + } + }; + if !ports.contains(&segment.flow.sport) + && !ports.contains(&segment.flow.dport) + { + continue; + } + tcp_packets += 1; + let key = segment.flow; + let syn = segment.flags & 2 != 0; + if syn + && connections + .get(&key) + .is_none_or(|s| s.stream.syn_seq != Some(segment.seq)) + { + if let Some(old) = connections.get(&key) + && let Some(problem) = old.stream.unfinished() + { + diagnostics.warn(&format!( + "frame {}: connection restarted with {problem}", + packet.number + ))?; + } + let mut stream = Stream::new(Some(segment.seq.wrapping_add(1))); + stream.syn_seq = Some(segment.seq); + connections.insert( + key.clone(), + Connection { + stream, + decoder: BmpDecoder::new(args.add_path), + }, + ); + } + if !connections.contains_key(&key) { + if segment.payload.is_empty() { + continue; + } + connections.insert( + key.clone(), + Connection { + stream: Stream::new(None), + decoder: BmpDecoder::new(args.add_path), + }, + ); + } + if connections.len() > 256 { + bail!( + "more than 256 TCP directions; split the capture into smaller files" + ); + } + let conn = connections.get_mut(&key).unwrap(); + let before = conn.stream.skipped; + let messages = conn + .stream + .feed(segment.seq.wrapping_add(u32::from(syn)), segment.payload) + .map_err(|e| { + anyhow::anyhow!( + "frame {}, {}:{}: {e}", + packet.number, + key.src, + key.sport + ) + })?; + if conn.stream.skipped != before { + diagnostics.warn(&format!( + "frame {}: skipped {} bytes searching for BMP at capture start", + packet.number, + conn.stream.skipped - before + ))?; + } + if connections + .values() + .map(|s| s.stream.buffered()) + .sum::() + > 64 * 1024 * 1024 + { + bail!("global TCP buffer limit exceeded"); + } + for (frame_message, raw) in messages.into_iter().enumerate() { + bmp_messages += 1; + let mut message = match connections + .get_mut(&key) + .unwrap() + .decoder + .decode(&raw) + { + Ok(message) => { + let errors = array(&message["errors"]); + if !errors.is_empty() { + decode_errors += 1; + for error in errors { + diagnostics.warn(&format!( + "frame {}: {}", + packet.number, + error.as_str().unwrap() + ))?; + } + } + message + } + Err(error) => { + decode_errors += 1; + diagnostics.warn(&format!( + "frame {}: BMP type {}: {error}", + packet.number, raw[5] + ))?; + json!({"type":raw[5],"name":bmp_name(raw[5]),"length":raw.len(), + "errors":[error.to_string()],"raw":hex(&raw)}) + } + }; + message["frame"] = json!(packet.number); + message["frame_message"] = json!(frame_message + 1); + message["timestamp_ns"] = json!(packet.timestamp_ns); + message["source"] = + json!({"address":key.src.to_string(),"port":key.sport}); + message["destination"] = + json!({"address":key.dst.to_string(),"port":key.dport}); + if !matches(&message, args) { + continue; + } + matched_messages += 1; + increment(&mut types, message["name"].as_str().unwrap()); + increment(&mut exporters, key.src.to_string()); + for route in routes_of(&message) { + increment(&mut actions, route["action"].as_str().unwrap()); + if let Some(kind) = route.get("route_type") { + increment(&mut route_types, kind.to_string()); + } + } + if !args.summary { + if args.json { + serde_json::to_writer(&mut out, &message)?; + writeln!(out)?; + } else { + out.write_all( + display_message(&message, args.verbose, colors) + .as_bytes(), + )?; + } + } + if args.count.is_some_and(|n| matched_messages >= n.get()) { + stopped = true; + break; + } + } + if stopped { + break; + } + } + if !stopped { + for (key, conn) in &connections { + if let Some(problem) = conn.stream.unfinished() { + diagnostics + .warn(&format!("{}:{}: {problem}", key.src, key.sport))?; + } + } + } + if args.summary { + let result = json!({"packets":packets,"tcp_packets":tcp_packets,"bmp_messages":bmp_messages, + "matched_messages":matched_messages,"decode_errors":decode_errors,"tcp_directions":connections.len(), + "diagnostics":diagnostics.count,"stopped_early":stopped,"message_types":types, + "evpn_route_types":route_types,"route_actions":actions,"exporters":exporters}); + if args.json { + serde_json::to_writer_pretty(&mut out, &result)?; + writeln!(out)?; + } else { + writeln!( + out, + "{packets} packets, {} TCP directions, {bmp_messages} BMP messages, {matched_messages} matched", + connections.len() + )?; + for (title, counts) in [ + ("BMP messages", &types), + ("EVPN routes", &route_types), + ("Route actions", &actions), + ("Exporters", &exporters), + ] { + writeln!( + out, + "{title}: {}", + counts + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + )?; + } + writeln!( + out, + "Decode errors: {decode_errors}; diagnostics: {}", + diagnostics.count + )?; + if stopped { + writeln!( + out, + "Stopped at the requested message count; counts cover only the scanned portion." + )?; + } + } + } + out.flush()?; + if diagnostics.count != 0 { + writeln!( + io::stderr().lock(), + "netom-bmpdump: {} diagnostic(s); output may be incomplete", + diagnostics.count + )?; + } + Ok(if diagnostics.count != 0 { 2 } else { 0 }) +} diff --git a/tools/bmpdump/src/display.rs b/tools/bmpdump/src/display.rs new file mode 100644 index 0000000..0dc29e5 --- /dev/null +++ b/tools/bmpdump/src/display.rs @@ -0,0 +1,816 @@ +use crate::cli::{Color, routes_of}; +use crate::protocol::array; +use serde_json::Value; +use std::env; +use std::io::{self, IsTerminal}; + +#[derive(Clone, Copy, Default)] +pub struct Palette { + pub enabled: bool, +} +impl Palette { + pub fn new(mode: Color, stderr: bool) -> Self { + let tty = if stderr { + io::stderr().is_terminal() + } else { + io::stdout().is_terminal() + }; + let term = env::var("TERM").unwrap_or_default().to_lowercase(); + // Conservative detection for ANSI terminals; unusual terminals can opt in. + let ansi = [ + "xterm", + "screen", + "tmux", + "rxvt", + "linux", + "ansi", + "color", + "cygwin", + "konsole", + "alacritty", + "kitty", + "foot", + "wezterm", + ] + .iter() + .any(|name| term.contains(name)) + || env::var_os("COLORTERM").is_some(); + Self { + enabled: match mode { + Color::Always => true, + Color::Never => false, + Color::Auto => { + tty && !term.is_empty() + && term != "dumb" + && env::var_os("NO_COLOR").is_none() + && ansi + } + }, + } + } + pub fn paint(self, text: &str, style: &str) -> String { + let code = match style { + "heading" => "1;36", + "label" => "36", + "announce" => "32", + "withdraw" => "31", + "error" => "1;31", + "warning" => "33", + "muted" => "2", + _ => "", + }; + if self.enabled && !code.is_empty() { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } + } +} +pub fn safe(text: &str) -> String { + let mut result = String::new(); + for c in text.chars() { + if c.is_control() + || matches!(c,'\u{2028}'|'\u{2029}'|'\u{202a}'..='\u{202e}'|'\u{2066}'..='\u{2069}') + { + result.push_str(&format!("\\x{:02x}", c as u32)); + } else { + result.push(c); + } + } + result +} +fn text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + _ => v.to_string(), + } +} +fn strv(v: &Value) -> &str { + v.as_str().unwrap_or("") +} +fn join(v: &Value, sep: &str) -> String { + array(v).iter().map(text).collect::>().join(sep) +} +fn timestamp(ns: &Value, full: bool) -> String { + let Ok(ns) = ns.to_string().parse::() else { + return "unknown-time".into(); + }; + let (seconds, nanos) = + (ns.div_euclid(1_000_000_000), ns.rem_euclid(1_000_000_000)); + if !(-62_135_596_800..=253_402_300_799).contains(&seconds) { + return format!("epoch:{seconds}.{nanos:09}"); + } + if full { + let date = + chrono::DateTime::from_timestamp(seconds as i64, nanos as u32) + .expect("timestamp is within years 1 through 9999"); + return date.format("%Y-%m-%dT%H:%M:%S%.9fZ").to_string(); + } + let seconds = seconds.rem_euclid(86400); + format!( + "{:02}:{:02}:{:02}.{nanos:09}Z", + seconds / 3600, + seconds / 60 % 60, + seconds % 60 + ) +} +fn endpoint(v: &Value) -> String { + let addr = strv(&v["address"]); + if addr.contains(':') { + format!("[{addr}]:{}", v["port"]) + } else { + format!("{addr}:{}", v["port"]) + } +} +fn frame_reference(message: &Value) -> String { + if let Some(ordinal) = message.get("frame_message") { + format!("{}.{}", message["frame"], ordinal) + } else { + text(&message["frame"]) + } +} +fn family(afi: &Value, safi: &Value) -> String { + match (afi.as_u64(), safi.as_u64()) { + (Some(1), Some(1)) => "IPv4 unicast".into(), + (Some(1), Some(2)) => "IPv4 multicast".into(), + (Some(2), Some(1)) => "IPv6 unicast".into(), + (Some(2), Some(2)) => "IPv6 multicast".into(), + (Some(25), Some(70)) => "L2VPN EVPN".into(), + _ => format!("AFI {afi}, SAFI {safi}"), + } +} +fn label(key: &str) -> String { + match key { + "asn" => "ASN".into(), + "router_id" => "Router ID".into(), + "afi" => "AFI".into(), + "safi" => "SAFI".into(), + "vrf-table-name" => "VRF/table name".into(), + _ => { + let mut s = key.replace(['_', '-'], " "); + if let Some(c) = s.get_mut(..1) { + c.make_ascii_uppercase(); + } + s + } + } +} + +struct Block { + out: String, + width: usize, + colors: Palette, +} +impl Block { + fn new(colors: Palette) -> Self { + let width = env::var("COLUMNS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|w| *w > 0) + .or_else(|| { + terminal_size::terminal_size().map(|(w, _)| usize::from(w.0)) + }) + .unwrap_or(100) + .clamp(60, 120); + Self { + out: String::new(), + width, + colors, + } + } + fn blank(&mut self) { + self.out.push('\n'); + } + fn wrap(&self, value: &str, first: &str, next: &str) -> String { + let value = safe(value); + let mut out = first.to_owned(); + let mut column = first.chars().count(); + let mut content = false; + for word in value.split_whitespace() { + let length = word.chars().count(); + if content && column + 1 + length > self.width { + out.push('\n'); + out.push_str(next); + column = next.chars().count(); + content = false; + } + if content { + out.push(' '); + column += 1; + } + for c in word.chars() { + if column >= self.width { + out.push('\n'); + out.push_str(next); + column = next.chars().count(); + } + out.push(c); + column += 1; + } + content = true; + } + out + } + fn line(&mut self, value: &str, indent: usize, style: &str) { + let prefix = " ".repeat(indent); + let line = self.wrap(value, &prefix, &prefix); + self.out.push_str(&self.colors.paint(&line, style)); + self.out.push('\n'); + } + fn field(&mut self, name: &str, value: &Value, indent: usize) { + let value = match value { + Value::Bool(b) => { + if *b { + "yes".into() + } else { + "no".into() + } + } + Value::Array(a) if a.is_empty() => "(empty)".into(), + Value::Array(_) => join(value, ", "), + _ => text(value), + }; + self.field_text( + name, + if value.is_empty() { "(empty)" } else { &value }, + indent, + ); + } + fn field_text(&mut self, name: &str, value: &str, indent: usize) { + let prefix = + format!("{}{:<18} ", " ".repeat(indent), format!("{name}:")); + let line = self.wrap(value, &prefix, &" ".repeat(prefix.len())); + if name == "ERROR" { + self.out.push_str(&self.colors.paint(&line, "error")); + } else { + let style = if name.starts_with("Unknown") + || name.starts_with("Undecoded") + { + "warning" + } else { + "label" + }; + self.out + .push_str(&self.colors.paint(&line[..prefix.len()], style)); + self.out.push_str(&line[prefix.len()..]); + } + self.out.push('\n'); + } + fn tree(&mut self, value: &Value, indent: usize) { + if let Some(obj) = value.as_object() { + for (key, value) in obj { + if value.is_object() { + self.line(&format!("{}:", label(key)), indent, ""); + self.tree(value, indent + 2); + } else if value.is_array() + && array(value).iter().any(Value::is_object) + { + self.line(&format!("{}:", label(key)), indent, ""); + for (index, entry) in array(value).iter().enumerate() { + self.line( + &format!("[{}]", index + 1), + indent + 2, + "", + ); + if entry.is_object() { + self.tree(entry, indent + 4); + } else { + self.line(&text(entry), indent + 4, ""); + } + } + } else { + self.field(&label(key), value, indent); + } + } + } + } +} +fn community(b: &mut Block, entry: &Value) { + let name = strv(&entry["name"]); + match name { + "arp-nd" => b.field_text( + "ARP/ND", + &format!( + "Router={}, Override={}, Immutable={}", + if entry["router"] == true { "yes" } else { "no" }, + if entry["override"] == true { + "yes" + } else { + "no" + }, + if entry["immutable"] == true { + "yes" + } else { + "no" + }, + ), + 4, + ), + "unknown" => b.field_text( + "Unknown community", + &format!( + "type {}, subtype {}; hex {}", + entry["type"], + entry["subtype"], + strv(&entry["raw"]) + ), + 4, + ), + "mac-mobility" => b.field_text( + "MAC mobility", + &format!( + "sequence {}, sticky {}", + entry["sequence"], + if entry["sticky"] == true { "yes" } else { "no" } + ), + 4, + ), + _ => { + let name = match name { + "route-target" => "Route target", + "route-origin" => "Route origin", + "encapsulation" => "Encapsulation", + "default-gateway" => "Default gateway", + "router-mac" => "Router MAC", + _ => name, + }; + b.field(name, &entry["value"], 4); + } + } +} +fn attribute(b: &mut Block, entry: &Value) { + let name = strv(&entry["name"]); + match name { + "as-path" | "as4-path" => { + let parts: Vec<_> = array(&entry["segments"]) + .iter() + .map(|seg| { + let (left, right) = match seg["type"].as_u64() { + Some(1) => ("{", "}"), + Some(3) => ("(", ")"), + Some(4) => ("[", "]"), + _ => ("", ""), + }; + format!("{left}{}{right}", join(&seg["asns"], " ")) + }) + .collect(); + let path = if parts.is_empty() { + "(empty)".to_owned() + } else { + parts.join(" ") + }; + b.field_text( + if name == "as-path" { + "AS path" + } else { + "AS4 path" + }, + &path, + 4, + ); + } + "origin" => b.field_text( + "Origin", + match entry["value"].as_u64() { + Some(0) => "IGP", + Some(1) => "EGP", + Some(2) => "Incomplete", + _ => return b.field("Origin", &entry["value"], 4), + }, + 4, + ), + "extended-communities" => { + for c in array(&entry["values"]) { + community(b, c); + } + } + "mp-reach" | "mp-unreach" => { + if let Some(raw) = entry.get("next_hop_raw") { + b.field("Next hop (hex)", raw, 4); + } + } + "pmsi-tunnel" => { + b.line("PMSI tunnel:", 4, ""); + if entry["tunnel_type"] == 6 { + b.field_text("Type", "Ingress Replication (6)", 6); + } else { + b.field("Type", &entry["tunnel_type"], 6); + } + if let Some(endpoint) = entry.get("endpoint") { + b.field("Endpoint", endpoint, 6); + } else { + b.field("Tunnel ID (hex)", &entry["tunnel_id"], 6); + } + if let Some(vni) = entry.get("vni") { + b.field("VNI", vni, 6); + } else if let Some(label) = entry.get("mpls_label") { + b.field("MPLS label", label, 6); + } else { + b.field("Raw label field", &entry["label_raw"], 6); + } + b.field_text( + "Flags", + &format!( + "0x{:02x}", + entry["tunnel_flags"].as_u64().unwrap_or(0) + ), + 6, + ); + } + _ => { + if let Some(value) = entry.get("value") { + let title = match name { + "next-hop" => "Next hop".into(), + "local-pref" => "Local preference".into(), + "med" => "MED".into(), + "originator-id" => "Originator ID".into(), + _ => label(name), + }; + b.field(&title, value, 4); + } else if let Some(values) = entry.get("values") { + b.field(&label(name), values, 4); + } else { + b.line( + &format!( + "{} (attribute {}):", + label(name), + entry["type"] + ), + 4, + "", + ); + let mut extra = entry.clone(); + for key in ["type", "flags", "name"] { + extra.as_object_mut().unwrap().remove(key); + } + b.tree(&extra, 6); + } + } + } +} +fn route(b: &mut Block, entry: &Value) { + let action = strv(&entry["action"]); + let title = if let Some(kind) = entry.get("route_type") { + let name = match kind.as_u64() { + Some(1) => "Ethernet Auto-Discovery", + Some(2) => "MAC/IP Advertisement", + Some(3) => "Inclusive Multicast", + Some(4) => "Ethernet Segment", + Some(5) => "IP Prefix", + _ => "Unknown", + }; + format!("{} EVPN type {kind} - {name}", action.to_uppercase()) + } else { + format!( + "{} {}", + action.to_uppercase(), + family(&entry["afi"], &entry["safi"]) + ) + }; + b.line( + &title, + 4, + if action == "announce" { + "announce" + } else { + "withdraw" + }, + ); + for (key, name) in [ + ("rd", "RD"), + ("prefix", "Prefix"), + ("mac", "MAC"), + ("ip", "IP"), + ("originator", "Originator"), + ("gateway", "Gateway"), + ("next_hop", "Next hop"), + ("vni", "VNI"), + ("mpls_labels", "MPLS labels"), + ("ethernet_tag", "Ethernet tag"), + ("esi", "ESI"), + ("path_id", "Path ID"), + ] { + if let Some(value) = entry.get(key) { + b.field(name, value, 6); + } + } + if entry.get("vni").is_none() + && let Some(raw) = entry.get("label_raw") + { + b.field("Raw label field", raw, 6); + } + if let Some(raw) = entry.get("raw") { + b.field("Undecoded (hex)", raw, 6); + } +} +fn open_message(b: &mut Block, entry: &Value) { + for (key, name) in [ + ("version", "Version"), + ("asn", "ASN"), + ("hold_time", "Hold time"), + ("router_id", "Router ID"), + ] { + if key == "hold_time" { + b.field_text(name, &format!("{} seconds", entry[key]), 4); + } else { + b.field(name, &entry[key], 4); + } + } + for cap in array(&entry["capabilities"]) { + let mut detail = match cap["code"].as_u64() { + Some(1) => "Multiprotocol".into(), + Some(2) => "Route refresh".into(), + Some(6) => "Extended message".into(), + Some(64) => "Graceful restart".into(), + Some(65) => "4-octet ASN".into(), + Some(69) => "ADD-PATH".into(), + Some(70) => "Enhanced route refresh".into(), + Some(71) => "Long-lived graceful restart".into(), + Some(73) => "FQDN".into(), + Some(76) => "Paths limit".into(), + _ => format!("Unknown code {}", cap["code"]), + }; + if cap.get("afi").is_some() { + detail += &format!(" - {}", family(&cap["afi"], &cap["safi"])); + } else if let Some(asn) = cap.get("asn") { + detail += &format!(" - AS{asn}"); + } else if let Some(families) = cap.get("families") { + let parts: Vec<_> = array(families) + .iter() + .map(|f| { + let mode = match f["mode"].as_u64() { + Some(1) => "receive".into(), + Some(2) => "send".into(), + Some(3) => "send/receive".into(), + _ => text(&f["mode"]), + }; + format!("{} ({mode})", family(&f["afi"], &f["safi"])) + }) + .collect(); + detail += &format!( + " - {}", + if parts.is_empty() { + "no families".into() + } else { + parts.join(", ") + } + ); + } else if !strv(&cap["raw"]).is_empty() { + detail += &format!(" - hex {}", strv(&cap["raw"])); + } + b.field_text("Capability", &detail, 4); + } +} +fn verbose(message: &Value, colors: Palette) -> String { + let mut b = Block::new(colors); + let title = strv(&message["name"]) + .split('-') + .map(label) + .collect::>() + .join(" "); + b.line( + &format!("Frame {} BMP {title}", frame_reference(message)), + 0, + if message["type"] == 2 { + "withdraw" + } else { + "heading" + }, + ); + b.field_text( + "Capture time", + ×tamp(&message["timestamp_ns"], true), + 2, + ); + b.field_text("Exporter", &endpoint(&message["source"]), 2); + b.field_text("Collector", &endpoint(&message["destination"]), 2); + if let Some(peer) = message.get("peer") { + b.field_text( + "Peer", + &format!("{} AS{}", strv(&peer["address"]), peer["asn"]), + 2, + ); + b.field("Router ID", &peer["router_id"], 2); + let mut rib = match strv(&peer["rib"]) { + "loc-rib" => "Loc-RIB", + "adj-rib-in" => "Adj-RIB-In", + "adj-rib-out" => "Adj-RIB-Out", + other => other, + } + .to_owned(); + if let Some(policy) = peer.get("policy") { + rib += &format!(" ({}-policy)", strv(policy)); + } + if peer["filtered"] == true { + rib += " (filtered)"; + } + b.field_text("RIB", &rib, 2); + } + for info in array(&message["information"]) { + let title = info + .get("name") + .map(text) + .unwrap_or_else(|| format!("Information {}", info["type"])); + b.field(&label(&title), &info["value"], 2); + } + for key in [ + "local_address", + "local_port", + "remote_port", + "reason", + "fsm_event", + ] { + if let Some(value) = message.get(key) { + b.field(&label(key), value, 2); + } + } + for error in array(&message["errors"]) { + b.field("ERROR", error, 2); + } + for (index, bgp) in array(&message["bgp"]).iter().enumerate() { + b.blank(); + let direction = if message["type"] == 3 { + if index == 0 { " (sent)" } else { " (received)" } + } else { + "" + }; + b.line( + &format!("BGP {}{direction}", strv(&bgp["name"])), + 2, + "heading", + ); + if let Some(error) = bgp.get("error") { + b.field("ERROR", error, 4); + } + if let Some(open) = bgp.get("open") { + open_message(&mut b, open); + } + for entry in array(&bgp["attributes"]) { + attribute(&mut b, entry); + } + for eor in array(&bgp["end_of_rib"]) { + b.field_text("End of RIB", &family(&eor["afi"], &eor["safi"]), 4); + } + for entry in array(&bgp["routes"]) { + b.blank(); + route(&mut b, entry); + } + let mut extra = bgp.clone(); + for key in [ + "type", + "name", + "length", + "open", + "error", + "attributes", + "routes", + "end_of_rib", + ] { + extra.as_object_mut().unwrap().remove(key); + } + b.tree(&extra, 4); + } + for key in ["statistics", "mirroring"] { + if let Some(value) = message.get(key) { + b.tree(&serde_json::json!({key:value}), 2); + } + } + if let Some(raw) = message.get("raw") { + b.field("Undecoded (hex)", raw, 2); + } + b.blank(); + b.out +} +fn route_text(route: &Value) -> String { + let mut parts = vec![ + if route["action"] == "announce" { + "+" + } else { + "-" + } + .to_owned(), + ]; + if let Some(kind) = route.get("route_type") { + parts.push(format!("EVPN{kind}({})", strv(&route["route_name"]))); + } else if route["unsupported"] == true { + parts.push(format!( + "unsupported-AFI/SAFI={}/{} raw={}", + route["afi"], + route["safi"], + strv(&route["raw"]) + )); + } + for key in [ + "rd", + "prefix", + "mac", + "ip", + "originator", + "gateway", + "ethernet_tag", + "path_id", + ] { + if let Some(value) = route.get(key) { + parts.push(format!("{key}={}", text(value))); + } + } + for key in ["vni", "mpls_labels", "next_hop"] { + if let Some(value) = route.get(key) { + parts.push(format!("{key}={}", join(value, ","))); + } + } + if route.get("vni").is_none() + && let Some(raw) = route.get("label_raw") + { + parts.push(format!("label_raw={}", join(raw, ","))); + } + parts.join(" ") +} +pub fn display_message( + message: &Value, + verbosity: u8, + colors: Palette, +) -> String { + if verbosity != 0 { + let mut rendered = verbose(message, colors); + if verbosity >= 2 { + rendered += &serde_json::to_string_pretty(message).unwrap(); + rendered.push('\n'); + } + return rendered; + } + let name = strv(&message["name"]); + let mut parts = vec![ + timestamp(&message["timestamp_ns"], false), + format!("#{}", frame_reference(message)), + endpoint(&message["source"]), + ">".into(), + endpoint(&message["destination"]), + "BMP".into(), + name.into(), + ]; + if let Some(p) = message.get("peer") { + parts.extend([ + format!("peer={}", strv(&p["address"])), + format!("AS{}", p["asn"]), + text(&p["rib"]), + ]); + if let Some(policy) = p.get("policy") { + parts.push(format!("{}-policy", strv(policy))); + } + } + for info in array(&message["information"]) { + parts.push(format!( + "{}={}", + info.get("name") + .map(text) + .unwrap_or_else(|| text(&info["type"])), + info["value"] + )); + } + if let Some(reason) = message.get("reason") { + parts.push(format!("reason={reason}")); + } + for error in array(&message["errors"]) { + parts.push(format!("[ERROR: {}]", strv(error))); + } + if let Some(stats) = message.get("statistics") { + parts.push(format!("counters={}", array(stats).len())); + } + for bgp in array(&message["bgp"]) { + parts.push(format!("BGP-{}", strv(&bgp["name"]))); + for eor in array(&bgp["end_of_rib"]) { + parts.push(format!("EOR={}/{}", eor["afi"], eor["safi"])); + } + } + let routes: Vec<_> = routes_of(message).collect(); + parts.extend(routes.iter().take(3).map(|r| route_text(r))); + if routes.len() > 3 { + parts.push(format!("... +{} routes (use -v)", routes.len() - 3)); + } + let rendered: Vec<_> = parts + .iter() + .enumerate() + .map(|(index, part)| { + let style = if index < 2 { + "muted" + } else if part.starts_with("[ERROR:") { + "error" + } else if part.starts_with("+ ") { + "announce" + } else if part.starts_with("- ") || part == "peer-down" { + "withdraw" + } else if part.starts_with("BGP-") + || part == "BMP" + || part == name + { + "heading" + } else { + "" + }; + colors.paint(&safe(part), style) + }) + .collect(); + rendered.join(" ") + "\n" +} diff --git a/tools/bmpdump/src/lib.rs b/tools/bmpdump/src/lib.rs new file mode 100644 index 0000000..59cabb7 --- /dev/null +++ b/tools/bmpdump/src/lib.rs @@ -0,0 +1,5 @@ +pub mod capture; +pub mod cli; +pub mod display; +pub mod protocol; +pub mod stream; diff --git a/tools/bmpdump/src/main.rs b/tools/bmpdump/src/main.rs new file mode 100644 index 0000000..df7a36b --- /dev/null +++ b/tools/bmpdump/src/main.rs @@ -0,0 +1,29 @@ +use clap::Parser; + +fn main() { + let args = bmpdump::cli::Args::parse(); + let code = match bmpdump::cli::run(&args) { + Ok(code) => code, + Err(error) => { + if error + .downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::BrokenPipe) + || error.downcast_ref::().is_some_and( + |e| { + e.io_error_kind() + == Some(std::io::ErrorKind::BrokenPipe) + }, + ) + { + 0 + } else { + eprintln!( + "netom-bmpdump: {}", + bmpdump::display::safe(&format!("{error:#}")) + ); + 2 + } + } + }; + std::process::exit(code); +} diff --git a/tools/bmpdump/src/protocol.rs b/tools/bmpdump/src/protocol.rs new file mode 100644 index 0000000..e8364cd --- /dev/null +++ b/tools/bmpdump/src/protocol.rs @@ -0,0 +1,927 @@ +use anyhow::{Result, bail, ensure}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv4Addr, Ipv6Addr}; + +pub type Families = HashSet<(u16, u8)>; +pub const BMP_NAMES: [&str; 7] = [ + "route-monitoring", + "statistics", + "peer-down", + "peer-up", + "initiation", + "termination", + "route-mirroring", +]; + +pub fn bmp_name(kind: u8) -> &'static str { + BMP_NAMES.get(kind as usize).copied().unwrap_or("unknown") +} +fn bgp_name(kind: u8) -> &'static str { + match kind { + 1 => "OPEN", + 2 => "UPDATE", + 3 => "NOTIFICATION", + 4 => "KEEPALIVE", + 5 => "ROUTE-REFRESH", + _ => "unknown", + } +} +pub fn array(value: &Value) -> &[Value] { + value.as_array().map_or(&[], Vec::as_slice) +} +pub fn hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect() +} +fn colon_hex(data: &[u8]) -> String { + data.iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} +fn integer(data: &[u8]) -> u64 { + data.iter().fold(0, |a, b| (a << 8) | u64::from(*b)) +} + +struct Reader<'a> { + data: &'a [u8], + pos: usize, +} +impl<'a> Reader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + fn len(&self) -> usize { + self.data.len() - self.pos + } + fn take(&mut self, count: usize) -> Result<&'a [u8]> { + ensure!( + count <= self.len(), + "truncated protocol field: need {count} bytes, have {}", + self.len() + ); + let data = &self.data[self.pos..self.pos + count]; + self.pos += count; + Ok(data) + } + fn uint(&mut self, count: usize) -> Result { + Ok(integer(self.take(count)?)) + } + fn u8(&mut self) -> Result { + Ok(self.uint(1)? as u8) + } + fn u16(&mut self) -> Result { + Ok(self.uint(2)? as u16) + } + fn sized(&mut self, width: usize) -> Result<&'a [u8]> { + let size = self.uint(width)? as usize; + self.take(size) + } + fn rest(&mut self) -> &'a [u8] { + let data = &self.data[self.pos..]; + self.pos = self.data.len(); + data + } + fn done(&self) -> Result<()> { + ensure!( + self.len() == 0, + "unexpected {} trailing protocol bytes", + self.len() + ); + Ok(()) + } +} + +fn address(raw: &[u8]) -> Result { + match raw.len() { + 4 => { + Ok(Ipv4Addr::from(<[u8; 4]>::try_from(raw).unwrap()).to_string()) + } + 16 => Ok( + Ipv6Addr::from(<[u8; 16]>::try_from(raw).unwrap()).to_string() + ), + n => bail!("invalid IP address length {n}"), + } +} +fn rd(raw: &[u8]) -> Result { + let mut r = Reader::new(raw); + Ok(match r.u16()? { + 0 => format!("{}:{}", r.uint(2)?, r.uint(4)?), + 1 => format!("{}:{}", address(r.take(4)?)?, r.uint(2)?), + 2 => format!("{}:{}", r.uint(4)?, r.uint(2)?), + _ => hex(raw), + }) +} +fn tlvs(data: &[u8]) -> Result> { + let mut r = Reader::new(data); + let mut result = Vec::new(); + while r.len() != 0 { + let kind = r.u16()?; + result.push((kind, r.sized(2)?)); + } + Ok(result) +} +fn information(data: &[u8], names: &[(u16, &str)]) -> Result { + let mut result = Vec::new(); + for (kind, value) in tlvs(data)? { + let name = names.iter().find(|(k, _)| *k == kind).map(|(_, n)| *n); + result.push(json!({"type": kind, "name": name.unwrap_or("unknown"), + "value": if name.is_some() { String::from_utf8_lossy(value).into_owned() } else { hex(value) }})); + } + Ok(json!(result)) +} +fn network(mut raw: Vec, bits: u8) -> Result { + ensure!( + usize::from(bits) <= raw.len() * 8, + "invalid IP prefix length {bits}" + ); + let full = usize::from(bits) / 8; + let partial = bits % 8; + if partial != 0 { + raw[full] &= 0xff << (8 - partial); + } + let start = full + usize::from(partial != 0); + raw[start..].fill(0); + Ok(format!("{}/{bits}", address(&raw)?)) +} +fn prefix(r: &mut Reader<'_>, afi: u16) -> Result { + let bits = r.u8()?; + let width = if afi == 1 { 4 } else { 16 }; + ensure!( + usize::from(bits) <= width * 8, + "invalid IPv{} prefix length {bits}", + if afi == 1 { 4 } else { 6 } + ); + let mut raw = r.take(usize::from(bits).div_ceil(8))?.to_vec(); + raw.resize(width, 0); + network(raw, bits) +} + +pub fn evpn_route(kind: u8, data: &[u8]) -> Result { + let name = match kind { + 1 => "ethernet-auto-discovery", + 2 => "mac-ip", + 3 => "inclusive-multicast", + 4 => "ethernet-segment", + 5 => "ip-prefix", + _ => "unknown", + }; + let mut result = json!({"route_type":kind, "route_name":name}); + if !(1..=5).contains(&kind) { + result["raw"] = json!(hex(data)); + return Ok(result); + } + let mut r = Reader::new(data); + result["rd"] = json!(rd(r.take(8)?)?); + if matches!(kind, 1 | 2 | 4 | 5) { + result["esi"] = json!(colon_hex(r.take(10)?)); + } + if matches!(kind, 1 | 2 | 3 | 5) { + result["ethernet_tag"] = json!(r.uint(4)?); + } + match kind { + 2 => { + ensure!(r.u8()? == 48, "EVPN MAC length is not 48 bits"); + result["mac"] = json!(colon_hex(r.take(6)?)); + let bits = r.u8()?; + ensure!( + matches!(bits, 0 | 32 | 128), + "invalid EVPN IP length {bits}" + ); + if bits != 0 { + result["ip"] = + json!(address(r.take(usize::from(bits) / 8)?)?); + } + } + 3 | 4 => { + let bits = r.u8()?; + ensure!( + matches!(bits, 32 | 128), + "invalid EVPN originator IP length {bits}" + ); + result["originator"] = + json!(address(r.take(usize::from(bits) / 8)?)?); + } + 5 => { + let bits = r.u8()?; + let width = match data.len() { + 34 => 4, + 58 => 16, + _ => bail!("invalid EVPN IP-prefix route length"), + }; + ensure!( + usize::from(bits) <= width * 8, + "invalid EVPN IP-prefix route length" + ); + result["prefix"] = json!(network(r.take(width)?.to_vec(), bits)?); + result["gateway"] = json!(address(r.take(width)?)?); + } + _ => {} + } + if matches!(kind, 1 | 2 | 5) { + ensure!( + r.len() == 3 || (kind == 2 && r.len() == 6), + "invalid EVPN label field length" + ); + let mut labels = Vec::new(); + while r.len() != 0 { + labels.push(r.uint(3)?); + } + result["label_raw"] = json!(labels); + } + r.done()?; + Ok(result) +} + +fn nlri( + data: &[u8], + afi: u16, + safi: u8, + action: &str, + add_paths: &Families, +) -> Result> { + if !matches!((afi, safi), (1 | 2, 1 | 2) | (25, 70)) { + return Ok(if data.is_empty() { + vec![] + } else { + vec![ + json!({"afi":afi, "safi":safi, "action":action, "unsupported":true, "raw":hex(data)}), + ] + }); + } + let mut r = Reader::new(data); + let mut routes = Vec::new(); + while r.len() != 0 { + let path_id = if add_paths.contains(&(afi, safi)) { + Some(r.uint(4)?) + } else { + None + }; + let mut entry = if (afi, safi) == (25, 70) { + let kind = r.u8()?; + evpn_route(kind, r.sized(1)?)? + } else { + json!({"prefix":prefix(&mut r, afi)?}) + }; + entry["afi"] = json!(afi); + entry["safi"] = json!(safi); + entry["action"] = json!(action); + if let Some(id) = path_id { + entry["path_id"] = json!(id); + } + routes.push(entry); + } + Ok(routes) +} + +fn extended_community(raw: &[u8]) -> Result { + let (kind, sub) = (raw[0], raw[1]); + let mut result = json!({"type":kind, "subtype":sub, "raw":hex(raw)}); + if matches!(kind, 0 | 1 | 2 | 0x40 | 0x41 | 0x42) && matches!(sub, 2 | 3) + { + let value = match kind & 0x3f { + 0 => format!("{}:{}", integer(&raw[2..4]), integer(&raw[4..])), + 1 => format!("{}:{}", address(&raw[2..6])?, integer(&raw[6..])), + _ => format!("{}:{}", integer(&raw[2..6]), integer(&raw[6..])), + }; + result["name"] = json!(if sub == 2 { + "route-target" + } else { + "route-origin" + }); + result["value"] = json!(value); + } else { + match (kind, sub) { + (3, 12) => { + let tunnel = integer(&raw[6..]); + result["name"] = json!("encapsulation"); + result["tunnel_type"] = json!(tunnel); + result["value"] = json!(match tunnel { + 8 => "VXLAN".to_owned(), + 9 => "NVGRE".to_owned(), + 19 => "GENEVE".to_owned(), + _ => tunnel.to_string(), + }); + } + (3, 13) => { + result["name"] = json!("default-gateway"); + result["value"] = json!(true); + } + (6, 0) => { + result["name"] = json!("mac-mobility"); + result["sequence"] = json!(integer(&raw[4..])); + result["sticky"] = json!(raw[2] & 1 != 0); + } + (6, 3) => { + result["name"] = json!("router-mac"); + result["value"] = json!(colon_hex(&raw[2..])); + } + (6, 8) => { + // RFC 9047: R, O and I occupy bits 7, 6 and 4 + // respectively, numbered from the most significant bit. + result["name"] = json!("arp-nd"); + result["flags"] = json!(raw[2]); + result["router"] = json!(raw[2] & 0x01 != 0); + result["override"] = json!(raw[2] & 0x02 != 0); + result["immutable"] = json!(raw[2] & 0x08 != 0); + } + _ => { + result["name"] = json!("unknown"); + } + } + } + Ok(result) +} + +fn parse_open(r: &mut Reader<'_>) -> Result { + let mut result = json!({"version":r.u8()?, "asn":r.uint(2)?, "hold_time":r.uint(2)?, + "router_id":address(r.take(4)?)?}); + let size = r.u8()?; + ensure!( + size != 255, + "extended BGP OPEN optional-parameter encoding is not supported" + ); + let mut params = Reader::new(r.take(size as usize)?); + let mut capabilities = Vec::new(); + while params.len() != 0 { + let kind = params.u8()?; + let value = params.sized(1)?; + if kind != 2 { + continue; + } + let mut caps = Reader::new(value); + while caps.len() != 0 { + let code = caps.u8()?; + let mut c = Reader::new(caps.sized(1)?); + let mut cap = json!({"code":code, "raw":hex(c.data)}); + match code { + 1 => { + cap["afi"] = json!(c.u16()?); + c.u8()?; + cap["safi"] = json!(c.u8()?); + c.done()?; + } + 65 => { + cap["asn"] = json!(c.uint(4)?); + c.done()?; + } + 69 => { + let mut families = Vec::new(); + while c.len() != 0 { + families.push(json!({"afi":c.u16()?, "safi":c.u8()?, "mode":c.u8()?})); + } + cap["families"] = json!(families); + } + _ => {} + } + capabilities.push(cap); + } + } + r.done()?; + result["capabilities"] = json!(capabilities); + Ok(result) +} + +pub fn parse_bgp( + data: &[u8], + asn_size: usize, + add_paths: &Families, +) -> Result { + let mut r = Reader::new(data); + ensure!(r.take(16)? == [0xff; 16], "invalid BGP marker"); + let size = r.u16()? as usize; + let kind = r.u8()?; + ensure!( + size == data.len() && size >= 19, + "invalid BGP message length" + ); + let mut result = + json!({"type":kind, "name":bgp_name(kind), "length":size}); + match kind { + 1 => { + result["open"] = parse_open(&mut r)?; + } + 2 => { + let withdrawn = r.sized(2)?; + let mut attrs = Reader::new(r.sized(2)?); + let announced = r.rest(); + let mut routes = nlri(withdrawn, 1, 1, "withdraw", add_paths)?; + routes.extend(nlri(announced, 1, 1, "announce", add_paths)?); + let mut attributes = Vec::new(); + let mut communities = Vec::new(); + let mut mp = Vec::new(); + while attrs.len() != 0 { + let flags = attrs.u8()?; + let code = attrs.u8()?; + let mut a = Reader::new(attrs.sized(if flags & 16 != 0 { + 2 + } else { + 1 + })?); + let mut item = json!({"type":code, "flags":flags}); + match code { + 1 => { + item["name"] = json!("origin"); + item["value"] = json!(a.u8()?); + } + 2 | 17 => { + item["name"] = json!(if code == 2 { + "as-path" + } else { + "as4-path" + }); + let mut segments = Vec::new(); + while a.len() != 0 { + let segment_type = a.u8()?; + let count = a.u8()?; + ensure!( + (1..=4).contains(&segment_type), + "invalid AS_PATH segment type" + ); + let mut asns = Vec::new(); + for _ in 0..count { + asns.push(a.uint(if code == 17 { + 4 + } else { + asn_size + })?); + } + segments.push( + json!({"type":segment_type, "asns":asns}), + ); + } + item["segments"] = json!(segments); + } + 3 | 9 => { + item["name"] = json!(if code == 3 { + "next-hop" + } else { + "originator-id" + }); + item["value"] = json!(address(a.take(4)?)?); + } + 4 | 5 => { + item["name"] = json!(if code == 4 { + "med" + } else { + "local-pref" + }); + item["value"] = json!(a.uint(4)?); + } + 6 => { + item["name"] = json!("atomic-aggregate"); + item["value"] = json!(true); + } + 7 | 18 => { + item["name"] = json!(if code == 7 { + "aggregator" + } else { + "as4-aggregator" + }); + item["asn"] = json!(a.uint(if code == 18 { + 4 + } else { + asn_size + })?); + item["address"] = json!(address(a.take(4)?)?); + } + 8 => { + item["name"] = json!("communities"); + let mut values = Vec::new(); + while a.len() != 0 { + values.push(format!("{}:{}", a.u16()?, a.u16()?)); + } + item["values"] = json!(values); + } + 10 => { + item["name"] = json!("cluster-list"); + let mut values = Vec::new(); + while a.len() != 0 { + values.push(address(a.take(4)?)?); + } + item["values"] = json!(values); + } + 14 | 15 => { + let (afi, safi) = (a.u16()?, a.u8()?); + item["name"] = json!(if code == 14 { + "mp-reach" + } else { + "mp-unreach" + }); + item["afi"] = json!(afi); + item["safi"] = json!(safi); + if code == 14 { + let hop = a.sized(1)?; + match hop.len() { + 4 | 16 => { + item["next_hop"] = json!([address(hop)?]); + } + 32 => { + item["next_hop"] = json!([ + address(&hop[..16])?, + address(&hop[16..])? + ]); + } + _ => { + item["next_hop_raw"] = json!(hex(hop)); + } + } + a.u8()?; + } + mp.push(( + afi, + safi, + if code == 14 { "announce" } else { "withdraw" }, + a.rest(), + item.clone(), + )); + } + 16 => { + item["name"] = json!("extended-communities"); + let mut values = Vec::new(); + while a.len() != 0 { + let c = extended_community(a.take(8)?)?; + communities.push(c.clone()); + values.push(c); + } + item["values"] = json!(values); + } + 22 => { + item["name"] = json!("pmsi-tunnel"); + item["tunnel_flags"] = json!(a.u8()?); + item["tunnel_type"] = json!(a.u8()?); + item["label_raw"] = json!(a.uint(3)?); + let tunnel_id = a.rest(); + item["tunnel_id"] = json!(hex(tunnel_id)); + // RFC 6514 section 5: ingress replication uses + // a unicast IPv4 or IPv6 endpoint identifier. + if item["tunnel_type"] == 6 + && matches!(tunnel_id.len(), 4 | 16) + { + item["endpoint"] = json!(address(tunnel_id)?); + } + } + 32 => { + item["name"] = json!("large-communities"); + let mut values = Vec::new(); + while a.len() != 0 { + values.push(format!( + "{}:{}:{}", + a.uint(4)?, + a.uint(4)?, + a.uint(4)? + )); + } + item["values"] = json!(values); + } + _ => { + item["name"] = json!("unknown"); + item["raw"] = json!(hex(a.rest())); + } + } + a.done()?; + attributes.push(item); + } + let mut end_of_rib = Vec::new(); + if withdrawn.is_empty() + && announced.is_empty() + && attributes.is_empty() + { + end_of_rib.push(json!({"afi":1,"safi":1})); + } + for (afi, safi, action, raw, item) in mp { + let mut decoded = nlri(raw, afi, safi, action, add_paths)?; + for route in &mut decoded { + if let Some(hop) = item.get("next_hop") { + route["next_hop"] = hop.clone(); + } + } + routes.extend(decoded); + if action == "withdraw" && raw.is_empty() { + end_of_rib.push(json!({"afi":afi,"safi":safi})); + } + } + let overlay = communities.iter().any(|c| { + matches!(c["tunnel_type"].as_u64(), Some(8 | 9 | 19)) + }); + for route in &mut routes { + if let Some(raw) = route.get("label_raw") { + let values = if overlay { + raw.clone() + } else { + json!( + array(raw) + .iter() + .map(|v| v.as_u64().unwrap() >> 4) + .collect::>() + ) + }; + route[if overlay { "vni" } else { "mpls_labels" }] = + values; + } + } + for item in &mut attributes { + if item["type"] == 22 { + item[if overlay { "vni" } else { "mpls_label" }] = json!( + item["label_raw"].as_u64().unwrap() + >> if overlay { 0 } else { 4 } + ); + } + } + result["routes"] = json!(routes); + result["attributes"] = json!(attributes); + result["end_of_rib"] = json!(end_of_rib); + } + 3 => { + result["error_code"] = json!(r.u8()?); + result["error_subcode"] = json!(r.u8()?); + result["data"] = json!(hex(r.rest())); + } + 4 => { + r.done()?; + } + 5 => { + result["afi"] = json!(r.u16()?); + result["subtype"] = json!(r.u8()?); + result["safi"] = json!(r.u8()?); + result["data"] = json!(hex(r.rest())); + } + _ => { + result["raw"] = json!(hex(r.rest())); + } + } + Ok(result) +} + +fn bgp_from_reader( + r: &mut Reader<'_>, + asn_size: usize, + add_paths: &Families, + preserve_error: bool, +) -> Result { + ensure!(r.len() >= 19, "truncated BGP header"); + let size = integer(&r.data[r.pos + 16..r.pos + 18]) as usize; + ensure!(size >= 19, "BGP message shorter than header"); + let raw = r.take(size)?; + match parse_bgp(raw, asn_size, add_paths) { + Ok(message) => Ok(message), + Err(error) if preserve_error => { + Ok(json!({"type":raw[18],"name":bgp_name(raw[18]), + "length":size,"error":error.to_string(),"raw":hex(raw)})) + } + Err(error) => Err(error), + } +} +fn add_path_families(message: &Value) -> HashMap<(u16, u8), u8> { + let mut families = HashMap::new(); + for cap in array(&message["open"]["capabilities"]) { + if cap["code"] == 69 { + for f in array(&cap["families"]) { + families.insert( + ( + f["afi"].as_u64().unwrap() as u16, + f["safi"].as_u64().unwrap() as u8, + ), + f["mode"].as_u64().unwrap() as u8, + ); + } + } + } + families +} + +#[derive(Clone, Eq, Hash, PartialEq)] +struct PeerKey(u8, Vec, Vec); +struct PeerState { + incoming: Families, + outgoing: Families, +} +pub struct BmpDecoder { + peers: HashMap, + force_add_path: bool, +} +impl BmpDecoder { + pub fn new(force_add_path: bool) -> Self { + Self { + peers: HashMap::new(), + force_add_path, + } + } + pub fn decode(&mut self, data: &[u8]) -> Result { + let mut r = Reader::new(data); + let (version, length, kind) = (r.u8()?, r.uint(4)? as usize, r.u8()?); + ensure!( + version == 3 && length == data.len(), + "invalid BMP version or length" + ); + let mut result = json!({"version":version,"length":length,"type":kind,"name":bmp_name(kind)}); + let (mut peer_type, mut flags, mut asn_size) = (0, 0, 4); + let mut key = PeerKey(0, vec![], vec![]); + let mut add_paths = Families::new(); + if matches!(kind, 0 | 1 | 2 | 3 | 6) { + peer_type = r.u8()?; + flags = r.u8()?; + let distinguisher = r.take(8)?; + let peer_address = r.take(16)?; + let mut peer = json!({"type":peer_type,"flags":flags, + "distinguisher":rd(distinguisher)?, + "address":address(if flags & 128 != 0 && peer_type != 3 { peer_address } else { &peer_address[12..] })?, + "asn":r.uint(4)?,"router_id":address(r.take(4)?)?, + "timestamp_seconds":r.uint(4)?,"timestamp_microseconds":r.uint(4)?}); + if peer_type == 3 { + peer["rib"] = json!("loc-rib"); + peer["filtered"] = json!(flags & 128 != 0); + } else { + peer["rib"] = json!(if flags & 16 != 0 { + "adj-rib-out" + } else { + "adj-rib-in" + }); + peer["policy"] = + json!(if flags & 64 != 0 { "post" } else { "pre" }); + } + result["peer"] = peer; + key = PeerKey( + peer_type, + distinguisher.to_vec(), + peer_address.to_vec(), + ); + asn_size = if peer_type == 3 || flags & 32 == 0 { + 4 + } else { + 2 + }; + if let Some(state) = self.peers.get(&key) { + add_paths = if flags & 16 != 0 { + state.outgoing.clone() + } else { + state.incoming.clone() + }; + } + if self.force_add_path { + add_paths = [(1, 1), (1, 2), (2, 1), (2, 2), (25, 70)].into(); + } + } + match kind { + 0 => { + let bgp = + bgp_from_reader(&mut r, asn_size, &add_paths, false)?; + ensure!( + bgp["type"] == 2, + "BMP Route Monitoring does not contain a BGP UPDATE" + ); + result["bgp"] = json!([bgp]); + } + 1 => { + let count = r.uint(4)?; + let mut stats = Vec::new(); + for _ in 0..count { + let code = r.u16()?; + let raw = r.sized(2)?; + let mut stat = json!({"type":code,"raw":hex(raw)}); + if matches!(code, 9 | 10) && raw.len() == 11 { + stat["afi"] = json!(integer(&raw[..2])); + stat["safi"] = json!(raw[2]); + stat["value"] = json!(integer(&raw[3..])); + } else if matches!(raw.len(), 4 | 8) { + stat["value"] = json!(integer(raw)); + } + stats.push(stat); + } + result["statistics"] = json!(stats); + } + 2 => { + let reason = r.u8()?; + result["reason"] = json!(reason); + match reason { + 1 | 3 => { + result["bgp"] = json!([bgp_from_reader( + &mut r, asn_size, &add_paths, false + )?]); + } + 2 => { + result["fsm_event"] = json!(r.u16()?); + } + 6 => { + result["information"] = + information(r.rest(), &[(3, "vrf-table-name")])?; + } + _ => { + result["data"] = json!(hex(r.rest())); + } + } + self.peers.remove(&key); + } + 3 => { + let local = r.take(16)?; + result["local_address"] = + json!(address(if flags & 128 != 0 && peer_type != 3 { + local + } else { + &local[12..] + })?); + result["local_port"] = json!(r.u16()?); + result["remote_port"] = json!(r.u16()?); + let empty = Families::new(); + let sent = bgp_from_reader(&mut r, 4, &empty, true)?; + let received = bgp_from_reader(&mut r, 4, &empty, true)?; + ensure!( + sent["type"] == 1 && received["type"] == 1, + "BMP Peer Up must contain two BGP OPENs" + ); + let errors: Vec<_> = [&sent, &received] + .iter() + .filter_map(|b| { + b.get("error").map(|e| { + format!( + "malformed BGP OPEN: {}", + e.as_str().unwrap() + ) + }) + }) + .collect(); + if !errors.is_empty() { + result["errors"] = json!(errors); + self.peers.remove(&key); + } else { + let (a, b) = ( + add_path_families(&sent), + add_path_families(&received), + ); + let mut incoming: Families = b + .iter() + .filter(|(f, m)| { + **m & 2 != 0 && a.get(f).unwrap_or(&0) & 1 != 0 + }) + .map(|(f, _)| *f) + .collect(); + let mut outgoing: Families = a + .iter() + .filter(|(f, m)| { + **m & 2 != 0 && b.get(f).unwrap_or(&0) & 1 != 0 + }) + .map(|(f, _)| *f) + .collect(); + if peer_type == 3 { + incoming = a.keys().copied().collect(); + outgoing = incoming.clone(); + } + ensure!( + self.peers.contains_key(&key) + || self.peers.len() < 4096, + "BMP peer state limit exceeded" + ); + self.peers.insert(key, PeerState { incoming, outgoing }); + } + result["bgp"] = json!([sent, received]); + result["information"] = information( + r.rest(), + &[ + (0, "string"), + (1, "sys-descr"), + (2, "sys-name"), + (3, "vrf-table-name"), + (4, "admin-label"), + ], + )?; + } + 4 => { + result["information"] = information( + r.rest(), + &[(0, "string"), (1, "sys-descr"), (2, "sys-name")], + )?; + } + 5 => { + let mut info = Vec::new(); + for (code, raw) in tlvs(r.rest())? { + let value = if code == 0 { + json!(String::from_utf8_lossy(raw)) + } else if code == 1 && raw.len() == 2 { + json!(integer(raw)) + } else { + json!(hex(raw)) + }; + info.push(json!({"type":code,"value":value})); + } + result["information"] = json!(info); + } + 6 => { + let (mut bgps, mut mirror) = (Vec::new(), Vec::new()); + for (code, raw) in tlvs(r.rest())? { + if code == 0 { + bgps.push(parse_bgp(raw, asn_size, &add_paths)?); + } else { + mirror.push(json!({"type":code,"raw":hex(raw)})); + } + } + result["bgp"] = json!(bgps); + result["mirroring"] = json!(mirror); + } + _ => { + result["raw"] = json!(hex(r.rest())); + } + } + r.done()?; + Ok(result) + } +} diff --git a/tools/bmpdump/src/stream.rs b/tools/bmpdump/src/stream.rs new file mode 100644 index 0000000..fc448c0 --- /dev/null +++ b/tools/bmpdump/src/stream.rs @@ -0,0 +1,133 @@ +use anyhow::{Result, bail, ensure}; +use std::collections::HashMap; + +const MAX_MESSAGE: usize = 4 * 1024 * 1024; +const MAX_BUFFER: usize = 8 * 1024 * 1024; + +fn delta(seq: u32, reference: u32) -> i64 { + i64::from(seq.wrapping_sub(reference) as i32) +} + +pub struct Stream { + next_seq: Option, + pending: HashMap>, + buffer: Vec, + synchronized: bool, + pub skipped: usize, + pub syn_seq: Option, +} + +impl Stream { + pub fn new(initial_seq: Option) -> Self { + Self { + next_seq: initial_seq, + pending: HashMap::new(), + buffer: Vec::new(), + synchronized: initial_seq.is_some(), + skipped: 0, + syn_seq: None, + } + } + pub fn buffered(&self) -> usize { + self.buffer.len() + self.pending.values().map(Vec::len).sum::() + } + pub fn feed( + &mut self, + mut seq: u32, + mut payload: &[u8], + ) -> Result>> { + if payload.is_empty() { + return Ok(vec![]); + } + let mut next = *self.next_seq.get_or_insert(seq); + let offset = delta(seq, next); + if offset < 0 { + payload = &payload[(-offset as usize).min(payload.len())..]; + seq = next; + } + if payload.is_empty() { + return Ok(vec![]); + } + if let Some(old) = self.pending.get(&seq) { + let common = old.len().min(payload.len()); + ensure!( + old[..common] == payload[..common], + "conflicting TCP retransmission" + ); + if old.len() >= payload.len() { + return Ok(vec![]); + } + } + self.pending.insert(seq, payload.to_vec()); + ensure!( + self.buffered() <= MAX_BUFFER && self.pending.len() <= 8192, + "TCP stream buffer limit exceeded (possibly a capture gap)" + ); + while let Some(first) = self + .pending + .keys() + .min_by_key(|n| delta(**n, next)) + .copied() + { + let offset = delta(first, next); + if offset > 0 { + break; + } + let chunk = self.pending.remove(&first).unwrap(); + let chunk = &chunk[(-offset as usize).min(chunk.len())..]; + self.buffer.extend_from_slice(chunk); + next = next.wrapping_add(chunk.len() as u32); + } + self.next_seq = Some(next); + let mut messages = Vec::new(); + let mut pos = 0; + while self.buffer.len() - pos >= 6 { + let version = self.buffer[pos]; + let length = u32::from_be_bytes( + self.buffer[pos + 1..pos + 5].try_into().unwrap(), + ) as usize; + let kind = self.buffer[pos + 5]; + let minimum = match kind { + 0 => 67, + 1 => 52, + 2 => 49, + 3 => 126, + 6 => 48, + _ => 6, + }; + if version != 3 || !(minimum..=MAX_MESSAGE).contains(&length) { + if self.synchronized { + bail!( + "invalid BMP header: version={version} length={length} type={kind}" + ); + } + pos += 1; + self.skipped += 1; + continue; + } + if self.buffer.len() - pos < length { + break; + } + self.synchronized = true; + messages.push(self.buffer[pos..pos + length].to_vec()); + pos += length; + } + self.buffer.drain(..pos); + Ok(messages) + } + pub fn unfinished(&self) -> Option { + if !self.pending.is_empty() { + Some(format!( + "TCP gap: {} bytes remain undecoded", + self.buffered() + )) + } else if !self.buffer.is_empty() { + Some(format!( + "incomplete BMP message: {} trailing bytes", + self.buffer.len() + )) + } else { + None + } + } +} diff --git a/tools/bmpdump/tests/decoder.rs b/tools/bmpdump/tests/decoder.rs new file mode 100644 index 0000000..af1f530 --- /dev/null +++ b/tools/bmpdump/tests/decoder.rs @@ -0,0 +1,817 @@ +use bmpdump::capture::{Capture, Packet, tcp_segment}; +use bmpdump::protocol::{BmpDecoder, Families, evpn_route, parse_bgp}; +use bmpdump::stream::Stream; +use serde_json::{Value, json}; +use std::io::{Cursor, Write}; +use std::net::Ipv6Addr; +use std::process::{Command, Output, Stdio}; + +fn bmp(kind: u8, body: &[u8]) -> Vec { + let mut out = vec![3]; + out.extend(((body.len() + 6) as u32).to_be_bytes()); + out.push(kind); + out.extend(body); + out +} +fn bgp(kind: u8, body: &[u8]) -> Vec { + let mut out = vec![255; 16]; + out.extend(((body.len() + 19) as u16).to_be_bytes()); + out.push(kind); + out.extend(body); + out +} +fn attr(kind: u8, body: &[u8]) -> Vec { + let mut out = vec![0x90, kind]; + out.extend((body.len() as u16).to_be_bytes()); + out.extend(body); + out +} +fn update(attrs: &[u8], withdrawn: &[u8], announced: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend((withdrawn.len() as u16).to_be_bytes()); + out.extend(withdrawn); + out.extend((attrs.len() as u16).to_be_bytes()); + out.extend(attrs); + out.extend(announced); + bgp(2, &out) +} +fn peer(flags: u8, kind: u8) -> Vec { + let mut out = vec![kind, flags]; + out.extend([0; 20]); + out.extend([192, 0, 2, 1]); + out.extend(65000u32.to_be_bytes()); + out.extend([192, 0, 2, 2]); + out.extend(1u32.to_be_bytes()); + out.extend(123456u32.to_be_bytes()); + out +} +fn open(caps: &[u8]) -> Vec { + let params = if caps.is_empty() { + vec![] + } else { + [vec![2, caps.len() as u8], caps.to_vec()].concat() + }; + let mut out = vec![4]; + out.extend(65000u16.to_be_bytes()); + out.extend(90u16.to_be_bytes()); + out.extend([192, 0, 2, 2]); + out.push(params.len() as u8); + out.extend(params); + bgp(1, &out) +} +fn ip_packet(payload: &[u8], ipv6: bool, seq: u32, flags: u8) -> Vec { + let mut tcp = Vec::new(); + tcp.extend(12345u16.to_be_bytes()); + tcp.extend(11019u16.to_be_bytes()); + tcp.extend(seq.to_be_bytes()); + tcp.extend([0; 4]); + tcp.extend([0x50, flags, 0xff, 0xff, 0, 0, 0, 0]); + tcp.extend(payload); + if ipv6 { + let mut out = vec![0x60, 0, 0, 0]; + out.extend((tcp.len() as u16).to_be_bytes()); + out.extend([6, 64]); + out.extend("2001:db8::1".parse::().unwrap().octets()); + out.extend("2001:db8::2".parse::().unwrap().octets()); + out.extend(tcp); + out + } else { + let mut out = vec![0x45, 0]; + out.extend(((tcp.len() + 20) as u16).to_be_bytes()); + out.extend([0, 1, 0x40, 0, 64, 6, 0, 0, 192, 0, 2, 1, 192, 0, 2, 2]); + out.extend(tcp); + out + } +} +fn ethernet(payload: &[u8]) -> Vec { + [vec![0; 12], vec![8, 0], ip_packet(payload, false, 1, 0x18)].concat() +} +fn word(value: u32, little: bool) -> [u8; 4] { + if little { + value.to_le_bytes() + } else { + value.to_be_bytes() + } +} +fn half(value: u16, little: bool) -> [u8; 2] { + if little { + value.to_le_bytes() + } else { + value.to_be_bytes() + } +} +fn pcap(packets: &[Vec], little: bool, nano: bool) -> Vec { + let mut out = Vec::new(); + out.extend(word(if nano { 0xa1b23c4d } else { 0xa1b2c3d4 }, little)); + out.extend(half(2, little)); + out.extend(half(4, little)); + for v in [0, 0, 65535, 1] { + out.extend(word(v, little)); + } + for packet in packets { + for v in [100, 123, packet.len() as u32, packet.len() as u32] { + out.extend(word(v, little)); + } + out.extend(packet); + } + out +} +fn block(kind: u32, mut body: Vec, little: bool) -> Vec { + body.resize(body.len().next_multiple_of(4), 0); + let size = (body.len() + 12) as u32; + [ + word(kind, little).to_vec(), + word(size, little).to_vec(), + body, + word(size, little).to_vec(), + ] + .concat() +} +fn pcapng(data: &[u8], little: bool, resolution: u8) -> Vec { + let mut h = word(0x1a2b3c4d, little).to_vec(); + h.extend(half(1, little)); + h.extend(half(0, little)); + h.extend([255; 8]); + let mut iface = half(1, little).to_vec(); + iface.extend(half(0, little)); + iface.extend(word(65535, little)); + iface.extend(half(9, little)); + iface.extend(half(1, little)); + iface.extend([resolution, 0, 0, 0]); + iface.extend(half(14, little)); + iface.extend(half(8, little)); + iface.extend(if little { + 2i64.to_le_bytes() + } else { + 2i64.to_be_bytes() + }); + let mut packet = Vec::new(); + for v in [0, 0, 1024, data.len() as u32, data.len() as u32] { + packet.extend(word(v, little)); + } + packet.extend(data); + [ + block(0x0a0d0d0a, h, little), + block(1, iface, little), + block(6, packet, little), + ] + .concat() +} +fn packets(data: Vec) -> anyhow::Result> { + let mut capture = Capture::new(Cursor::new(data))?; + let mut out = Vec::new(); + while let Some(p) = capture.next_packet()? { + out.push(p); + } + Ok(out) +} +fn parse(data: &[u8]) -> Value { + parse_bgp(data, 4, &Families::new()).unwrap() +} +fn invoke(args: &[&str], input: &[u8]) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_netom-bmpdump")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("TERM", "xterm-256color") + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn pcap_endian_and_precision() { + for little in [false, true] { + for nano in [false, true] { + let p = packets(pcap(&[ethernet(&bmp(4, &[]))], little, nano)) + .unwrap(); + assert_eq!( + p[0].timestamp_ns, + Some(100_000_000_000 + 123 * if nano { 1 } else { 1000 }) + ); + assert_eq!( + tcp_segment(&p[0]).unwrap().unwrap().payload, + bmp(4, &[]) + ); + } + } +} +#[test] +fn pcapng_sections_and_timestamps() { + let data = ethernet(&bmp(4, &[])); + let p = packets( + [pcapng(&data, true, 9), pcapng(&data, false, 0x8a)].concat(), + ) + .unwrap(); + assert_eq!((p[0].section, p[1].section), (0, 1)); + assert_eq!( + (p[0].timestamp_ns, p[1].timestamp_ns), + (Some(2_000_001_024), Some(3_000_000_000)) + ); +} +#[test] +fn pcapng_simple_and_obsolete_blocks() { + let data = ethernet(&bmp(4, &[])); + let mut section = pcapng(&data, true, 9); + section.extend(block( + 3, + [word(data.len() as u32, true).to_vec(), data.clone()].concat(), + true, + )); + let mut old = vec![0; 4]; + for n in [0, 1024, data.len() as u32, data.len() as u32] { + old.extend(word(n, true)); + } + old.extend(&data); + section.extend(block(2, old, true)); + let p = packets(section).unwrap(); + assert_eq!(p.len(), 3); + assert_eq!(p[1].timestamp_ns, None); + assert_eq!(p[1].data, data); + assert_eq!(p[2].timestamp_ns, Some(2_000_001_024)); +} +#[test] +fn extreme_timestamp_resolution_does_not_overflow() { + let p = packets(pcapng(ðernet(&bmp(4, &[])), true, 127)).unwrap(); + assert_eq!(p[0].timestamp_ns, Some(2_000_000_000)); +} +#[test] +fn corrupt_capture_lengths() { + let mut p = pcap(&[ethernet(&bmp(4, &[]))], true, false); + p.pop(); + let mut ng = pcapng(ðernet(&bmp(4, &[])), true, 9); + ng.pop(); + let mut mismatch = pcapng(ðernet(&bmp(4, &[])), true, 9); + let len = mismatch.len(); + mismatch[len - 4..].fill(0); + for raw in [vec![], b"nope".to_vec(), p, ng, mismatch] { + assert!(packets(raw).is_err()); + } +} +#[test] +fn link_types_ipv6_and_vlan() { + let raw = ip_packet(&bmp(4, &[]), false, 1, 0x18); + let fixtures = [ + ( + 1, + [vec![0; 12], vec![0x81, 0, 0, 100, 8, 0], raw.clone()].concat(), + ), + (101, raw.clone()), + (113, [vec![0; 14], vec![8, 0], raw.clone()].concat()), + (276, [vec![8, 0], vec![0; 18], raw.clone()].concat()), + (101, ip_packet(&bmp(4, &[]), true, 1, 0x18)), + (0, [vec![2, 0, 0, 0], raw.clone()].concat()), + (108, [vec![0, 0, 0, 2], raw].concat()), + ]; + for (linktype, data) in fixtures { + let p = Packet { + number: 1, + timestamp_ns: Some(0), + linktype, + original_length: data.len(), + data, + section: 0, + interface: 0, + }; + assert_eq!(tcp_segment(&p).unwrap().unwrap().payload, bmp(4, &[])); + } +} +#[test] +fn fragments_and_truncated_tcp() { + let mut raw = ip_packet(&bmp(4, &[]), false, 1, 0x18); + raw[6..8].copy_from_slice(&[0x20, 0]); + let mut short = ip_packet(&bmp(4, &[]), false, 1, 0x18); + short.pop(); + for data in [raw, short] { + let p = Packet { + number: 1, + timestamp_ns: None, + linktype: 101, + original_length: data.len(), + data, + section: 0, + interface: 0, + }; + assert!(tcp_segment(&p).is_err()); + } +} +#[test] +fn split_coalesced_and_duplicate_messages() { + let m = bmp(4, &[]); + let mut s = Stream::new(Some(10)); + assert!(s.feed(10, &m[..3]).unwrap().is_empty()); + assert_eq!( + s.feed(13, &[m[3..].to_vec(), m.clone()].concat()).unwrap(), + vec![m.clone(), m.clone()] + ); + assert!(s.feed(10, &m).unwrap().is_empty()); + assert!(s.unfinished().is_none()); +} +#[test] +fn out_of_order_and_overlap() { + let m = bmp(4, &[]); + let mut s = Stream::new(Some(100)); + assert!(s.feed(103, &m[3..]).unwrap().is_empty()); + assert_eq!(s.feed(100, &m[..3]).unwrap(), vec![m.clone()]); + let mut s = Stream::new(Some(1)); + assert!(s.feed(1, &m[..4]).unwrap().is_empty()); + assert_eq!( + s.feed(3, &[m[2..].to_vec(), m.clone()].concat()).unwrap(), + vec![m.clone(), m] + ); +} +#[test] +fn sequence_wrap_gaps_and_midstream() { + let m = bmp(4, &[]); + let mut s = Stream::new(Some(0xfffffffd)); + assert!(s.feed(0xfffffffd, &m[..3]).unwrap().is_empty()); + assert_eq!(s.feed(0, &m[3..]).unwrap(), vec![m.clone()]); + let mut s = Stream::new(Some(10)); + s.feed(11, &m).unwrap(); + assert!(s.unfinished().unwrap().contains("TCP gap")); + let mut s = Stream::new(None); + assert_eq!( + s.feed(10, &[b"junk".to_vec(), m.clone()].concat()).unwrap(), + vec![m] + ); + assert_eq!(s.skipped, 4); +} +#[test] +fn invalid_framing_and_conflicting_retransmission() { + assert!( + Stream::new(Some(1)) + .feed(1, &[3, 255, 255, 255, 255, 4]) + .is_err() + ); + let mut s = Stream::new(Some(1)); + s.feed(2, &[1, 2, 3]).unwrap(); + assert!(s.feed(2, &[1, 2, 4]).is_err()); +} +#[test] +fn bmp_initiation_and_unknown() { + let mut decoder = BmpDecoder::new(false); + assert_eq!( + decoder + .decode(&bmp(4, &[0, 2, 0, 3, b'r', b'0', b'1'])) + .unwrap()["information"][0]["value"], + "r01" + ); + assert_eq!( + decoder.decode(&bmp(222, b"test")).unwrap()["raw"], + "74657374" + ); +} +#[test] +fn unicast_withdrawals_and_eor() { + let m = parse(&update(&[], &[8, 10], &[24, 192, 0, 2])); + assert_eq!(m["routes"][0]["prefix"], "10.0.0.0/8"); + assert_eq!(m["routes"][0]["action"], "withdraw"); + assert_eq!(m["routes"][1]["prefix"], "192.0.2.0/24"); + assert_eq!( + parse(&update(&[], &[], &[]))["end_of_rib"], + json!([{"afi":1,"safi":1}]) + ); + assert_eq!( + parse(&update(&attr(15, &[0, 2, 1]), &[], &[]))["end_of_rib"], + json!([{"afi":2,"safi":1}]) + ); +} +#[test] +fn ipv6_mp_reach() { + let mut value = vec![0, 2, 1, 16]; + value.extend("2001:db8::1".parse::().unwrap().octets()); + value.extend([0, 32, 0x20, 1, 0xd, 0xb8]); + let m = parse(&update(&attr(14, &value), &[], &[])); + assert_eq!(m["routes"][0]["prefix"], "2001:db8::/32"); + assert_eq!(m["routes"][0]["next_hop"], json!(["2001:db8::1"])); +} +#[test] +fn two_and_four_byte_asns() { + for (flags, width) in [(0, 4), (32, 2)] { + let mut path = vec![1, 2]; + for asn in [65000u32, 65001] { + path.extend(&asn.to_be_bytes()[4 - width..]); + } + let m = BmpDecoder::new(false) + .decode(&bmp( + 0, + &[peer(flags, 0), update(&attr(2, &path), &[], &[])].concat(), + )) + .unwrap(); + assert_eq!( + m["bgp"][0]["attributes"][0]["segments"], + json!([{"type":1,"asns":[65000,65001]}]) + ); + } +} +fn evpn_bodies() -> Vec> { + let rd = vec![0, 0, 0xfd, 0xe8, 0, 0, 0, 1]; + let esi = vec![0; 10]; + let tag = vec![0; 4]; + let label = vec![1, 0x8a, 0x88]; + vec![ + [rd.clone(), esi.clone(), tag.clone(), label.clone()].concat(), + [ + rd.clone(), + esi.clone(), + tag.clone(), + vec![48, 2, 3, 4, 5, 6, 7, 32, 192, 0, 2, 1], + label.clone(), + ] + .concat(), + [rd.clone(), tag.clone(), vec![32, 192, 0, 2, 2]].concat(), + [rd.clone(), esi.clone(), vec![32, 192, 0, 2, 2]].concat(), + [rd, esi, tag, vec![24, 192, 0, 2, 0, 192, 0, 2, 1], label].concat(), + ] +} +#[test] +fn evpn_types_one_through_five() { + let bodies = evpn_bodies(); + for (i, body) in bodies.iter().enumerate() { + assert_eq!(evpn_route(i as u8 + 1, body).unwrap()["rd"], "65000:1"); + } + assert_eq!(evpn_route(5, &bodies[4]).unwrap()["prefix"], "192.0.2.0/24"); + let mut v6 = bodies[4][..22].to_vec(); + v6.push(64); + v6.extend("2001:db8::".parse::().unwrap().octets()); + v6.extend([0; 16]); + v6.extend([1, 0x8a, 0x88]); + assert_eq!(evpn_route(5, &v6).unwrap()["prefix"], "2001:db8::/64"); +} +#[test] +fn evpn_vni_and_withdrawal_raw_labels() { + let bodies = evpn_bodies(); + let encoded = + [vec![2, bodies[1].len() as u8], bodies[1].clone()].concat(); + let mp = attr( + 14, + &[vec![0, 25, 70, 4, 192, 0, 2, 2, 0], encoded.clone()].concat(), + ); + let vxlan = attr( + 16, + &[3, 12, 0, 0, 0, 0, 0, 8, 0, 2, 0xfd, 0xe8, 0, 0, 3, 0xe8], + ); + let m = parse(&update(&[mp, vxlan].concat(), &[], &[])); + assert_eq!(m["routes"][0]["vni"], json!([101000])); + assert!(m["routes"][0].get("mpls_labels").is_none()); + let m = parse(&update( + &attr(15, &[vec![0, 25, 70], encoded].concat()), + &[], + &[], + )); + assert_eq!(m["routes"][0]["action"], "withdraw"); + assert_eq!(m["routes"][0]["label_raw"], json!([101000])); +} +#[test] +fn arp_nd_community_flags() { + for (flags, router, override_flag, immutable) in [ + (0, false, false, false), + (1, true, false, false), + (2, false, true, false), + (8, false, false, true), + (11, true, true, true), + (0xf4, false, false, false), + ] { + let bgp = parse(&update( + &attr(16, &[6, 8, flags, 0, 0, 0, 0, 0]), + &[], + &[], + )); + let c = &bgp["attributes"][0]["values"][0]; + assert_eq!(c["name"], "arp-nd"); + assert_eq!(c["router"], router); + assert_eq!(c["override"], override_flag); + assert_eq!(c["immutable"], immutable); + assert_eq!(c["flags"], flags); + assert_eq!(c["raw"], format!("0608{flags:02x}0000000000")); + let rendered = bmpdump::display::display_message( + &json!({"bgp":[bgp]}), + 1, + Default::default(), + ); + assert!(rendered.contains(&format!( + "Router={}, Override={}, Immutable={}", + if router { "yes" } else { "no" }, + if override_flag { "yes" } else { "no" }, + if immutable { "yes" } else { "no" }, + ))); + assert!(!rendered.contains("Unknown community")); + } +} + +#[test] +fn pmsi_tunnel_endpoints_and_labels() { + for (kind, id, endpoint) in [ + (6, vec![10, 0, 0, 8], Some("10.0.0.8")), + ( + 6, + "2001:db8::8".parse::().unwrap().octets().to_vec(), + Some("2001:db8::8"), + ), + (6, vec![10, 0, 0], None), + (240, vec![10, 0, 0, 8], None), + ] { + for overlay in [false, true] { + let mut attrs = attr( + 22, + &[vec![1, kind, 1, 0x8a, 0x88], id.clone()].concat(), + ); + if overlay { + attrs.extend(attr(16, &[3, 12, 0, 0, 0, 0, 0, 8])); + } + let bgp = parse(&update(&attrs, &[], &[])); + let pmsi = &bgp["attributes"][0]; + assert_eq!(pmsi["endpoint"].as_str(), endpoint); + assert_eq!(pmsi["label_raw"], 101000); + assert_eq!(pmsi["tunnel_id"], bmpdump::protocol::hex(&id)); + assert_eq!( + pmsi[if overlay { "vni" } else { "mpls_label" }], + if overlay { 101000 } else { 6312 } + ); + let rendered = bmpdump::display::display_message( + &json!({"bgp":[bgp]}), + 1, + Default::default(), + ); + assert!(rendered.contains("PMSI tunnel:")); + assert!(rendered.contains("0x01")); + assert!(!rendered.contains("Label raw")); + assert!(rendered.contains(if overlay { + "VNI:" + } else { + "MPLS label:" + })); + if kind == 6 { + assert!(rendered.contains("Ingress Replication (6)")); + } + if let Some(endpoint) = endpoint { + assert!(rendered.contains(endpoint)); + assert!(!rendered.contains("Tunnel ID (hex)")); + } else { + assert!(rendered.contains("Tunnel ID (hex)")); + } + } + } +} + +#[test] +fn add_path_negotiation() { + let mut decoder = BmpDecoder::new(false); + decoder + .decode(&bmp( + 3, + &[ + peer(0, 0), + vec![0; 20], + open(&[69, 4, 0, 1, 1, 1]), + open(&[69, 4, 0, 1, 1, 2]), + ] + .concat(), + )) + .unwrap(); + let m = decoder + .decode(&bmp( + 0, + &[peer(0, 0), update(&[], &[], &[0, 0, 0, 7, 24, 192, 0, 2])] + .concat(), + )) + .unwrap(); + assert_eq!(m["bgp"][0]["routes"][0]["path_id"], 7); +} +#[test] +fn loc_rib_flags_and_malformed_open() { + let m = BmpDecoder::new(false) + .decode(&bmp(0, &[peer(128, 3), update(&[], &[], &[])].concat())) + .unwrap(); + assert_eq!(m["peer"]["address"], "192.0.2.1"); + assert_eq!(m["peer"]["filtered"], true); + let m = BmpDecoder::new(false) + .decode(&bmp( + 3, + &[peer(0, 0), vec![0; 20], open(&[]), bgp(1, &[])].concat(), + )) + .unwrap(); + assert_eq!(m["peer"]["asn"], 65000); + assert!(m["errors"].is_array()); + assert_eq!(m["bgp"][1]["length"], 19); +} +#[test] +fn unknown_attributes_families_and_truncation() { + let m = parse(&update( + &[attr(240, b"xyz"), attr(15, &[0, 1, 128, 255])].concat(), + &[], + &[], + )); + assert_eq!(m["attributes"][0]["raw"], "78797a"); + assert_eq!(m["routes"][0]["unsupported"], true); + for data in [ + bgp(1, &[]), + update(&attr(2, &[2, 1]), &[], &[]), + update(&[], &[], &[33]), + update(&attr(16, &[0]), &[], &[]), + bgp(4, b"x"), + ] { + assert!(parse_bgp(&data, 4, &Families::new()).is_err()); + } +} +#[test] +fn cli_stdin_json_and_count() { + let raw = pcap( + &[ethernet(&[bmp(4, &[]), bmp(4, &[])].concat())], + true, + false, + ); + let out = invoke(&["-r", "-", "-j", "-c", "1"], &raw); + assert!(out.status.success(), "{:?}", out); + let m: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(m["name"], "initiation"); +} +#[test] +fn frame_message_ordinals_survive_filtering_and_reassembly() { + let initiation = bmp(4, &[]); + let monitoring = bmp(0, &[peer(0, 0), update(&[], &[], &[])].concat()); + let first = [&initiation[..], &monitoring[..5]].concat(); + let second = [&monitoring[5..], &initiation[..]].concat(); + let packets: Vec<_> = [(1, first), (12, second)] + .into_iter() + .map(|(seq, payload)| { + [ + vec![0; 12], + vec![8, 0], + ip_packet(&payload, false, seq, 0x18), + ] + .concat() + }) + .collect(); + let raw = pcap(&packets, true, false); + let out = invoke(&["-r", "-", "-j"], &raw); + assert!(out.status.success(), "{out:?}"); + let messages: Vec = String::from_utf8(out.stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(messages.len(), 3); + for (m, (frame, ordinal)) in messages.iter().zip([(1, 1), (2, 1), (2, 2)]) + { + assert_eq!(m["frame"], frame); + assert_eq!(m["frame_message"], ordinal); + } + let out = + invoke(&["-r", "-", "-j", "--type", "initiation", "-c", "2"], &raw); + assert!(out.status.success()); + let filtered: Vec = String::from_utf8(out.stdout) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(filtered, vec![messages[0].clone(), messages[2].clone()]); + for (args, expected) in [ + (vec!["-r", "-", "--type", "initiation", "-v"], "Frame 2.2"), + (vec!["-r", "-", "--type", "initiation"], "#2.2"), + ] { + let out = invoke(&args, &raw); + assert!(out.status.success()); + assert!(String::from_utf8(out.stdout).unwrap().contains(expected)); + } +} + +#[test] +fn cli_filters_and_invalid_input() { + let raw = pcap( + &[ethernet( + &[ + bmp(0, &[peer(0, 0), update(&[], &[], &[])].concat()), + bmp(4, &[]), + ] + .concat(), + )], + true, + false, + ); + let out = invoke( + &[ + "-r", + "-", + "--type", + "route-monitoring", + "--peer", + "192.0.2.1", + "-j", + ], + &raw, + ); + assert!(out.status.success()); + assert_eq!( + serde_json::from_slice::(&out.stdout).unwrap()["name"], + "route-monitoring" + ); + let out = invoke(&["-r", "-"], b"bad!"); + assert_eq!(out.status.code(), Some(2)); + assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked")); +} +#[test] +fn verbose_capture_dates() { + for (ns, expected) in [ + ( + json!(1791177729333856618i64), + "2026-10-05T05:22:09.333856618Z", + ), + (json!(0), "1970-01-01T00:00:00.000000000Z"), + (json!(-1), "1969-12-31T23:59:59.999999999Z"), + ( + json!(951782400000000000i64), + "2000-02-29T00:00:00.000000000Z", + ), + (Value::Null, "unknown-time"), + ( + json!(253402300800000000000i128), + "epoch:253402300800.000000000", + ), + ] { + let rendered = bmpdump::display::display_message( + &json!({"name":"initiation","frame":1,"timestamp_ns":ns}), + 1, + Default::default(), + ); + assert!(rendered.contains("Capture time:")); + assert!(rendered.contains(expected), "{rendered}"); + } +} + +#[test] +fn cli_colors_pipes_and_json() { + let raw = pcap(&[ethernet(&bmp(4, &[]))], true, false); + for args in [ + vec!["-r", "-", "-v"], + vec!["-r", "-", "--color", "never"], + vec!["-r", "-", "--color", "always", "-j"], + ] { + let out = invoke(&args, &raw); + assert!(out.status.success()); + assert!(!out.stdout.contains(&27)); + } + let out = invoke(&["-r", "-", "-v", "--color", "always"], &raw); + assert!(out.status.success()); + assert!(out.stdout.contains(&27)); +} + +#[test] +fn closed_output_pipe_exits_cleanly() { + let raw = pcap(&[ethernet(&bmp(4, &[]).repeat(4000))], true, false); + for extra in [vec![], vec!["-j"]] { + let mut child = Command::new(env!("CARGO_BIN_EXE_netom-bmpdump")) + .args(["-r", "-"]) + .args(extra) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + drop(child.stdout.take()); + let _ = child.stdin.take().unwrap().write_all(&raw); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty()); + } +} +#[test] +fn supplied_capture_regression() { + let Some(path) = std::env::var_os("NETOM_BMPDUMP_TEST_CAPTURE") else { + return; + }; + let path = std::path::PathBuf::from(path); + assert!( + path.is_file(), + "reference capture does not exist: {}", + path.display() + ); + let out = invoke(&["-r", path.to_str().unwrap(), "--summary", "-j"], &[]); + assert_eq!(out.status.code(), Some(2)); + let m: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(m["packets"], 7513); + assert_eq!(m["bmp_messages"], 44398); + assert_eq!(m["matched_messages"], 44398); + assert_eq!(m["decode_errors"], 6); + assert_eq!(m["diagnostics"], 6); + assert_eq!(m["message_types"]["peer-up"], 96); + assert_eq!( + m["evpn_route_types"], + json!({"1":78,"2":28576,"3":14916,"4":52,"5":30}) + ); +} +#[test] +fn truncated_valid_messages_never_panic() { + let data = + bmp(3, &[peer(0, 0), vec![0; 20], open(&[]), open(&[])].concat()); + for n in 0..data.len() { + assert!(BmpDecoder::new(false).decode(&data[..n]).is_err()); + } + let bgp = + update(&attr(16, &[3, 12, 0, 0, 0, 0, 0, 8]), &[], &[24, 192, 0, 2]); + for n in 0..bgp.len() { + assert!(parse_bgp(&bgp[..n], 4, &Families::new()).is_err()); + } +}