diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bcaaff1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,196 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +env: + # Pinned to match rust-toolchain.toml. Both move together, in one PR. + RUST_STABLE: "1.97.1" + +jobs: + check: + name: fmt, clippy, test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Installed with rustup directly rather than via a third-party action, so + # the toolchain version is visible in this file and not in someone else's. + - name: Install Rust ${{ env.RUST_STABLE }} + run: | + rustup toolchain install "$RUST_STABLE" --profile minimal --component rustfmt,clippy + rustup default "$RUST_STABLE" + + - uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # A proto edited by hand, or a partial re-vendor, would otherwise ship an + # encoder that silently disagrees with the daemon on the other end. A + # field number that moved is not a compile error — it is a runtime + # disagreement — so this is the only thing that catches it early. + - name: Verify the vendored GoBGP protos + run: | + ./ci/refresh-proto-manifest.sh + git diff --exit-code crates/bgp/proto/SOURCE.json \ + || { echo "::error::vendored protos do not match SOURCE.json"; exit 1; } + + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - run: cargo test --workspace + + cross-build: + name: cross-build ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - aarch64-unknown-linux-musl + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-gnu + - x86_64-unknown-linux-gnu + steps: + - uses: actions/checkout@v7 + + - name: Install Rust ${{ env.RUST_STABLE }} + run: | + rustup toolchain install "$RUST_STABLE" --profile minimal --component clippy + rustup default "$RUST_STABLE" + rustup target add "${{ matrix.target }}" + + - uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cross-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + + - run: cargo install cross --locked --version 0.2.5 + + # Per-triple clippy over --all-targets BEFORE the build. `cross build` + # covers lib and bins only, and the host clippy run above sees exactly one + # triple — so without this, a test that fails to compile on aarch64, or a + # cfg-gated path only reachable on musl, ships green. + # + # Run through `cross`, not bare cargo. `reqwest`'s rustls feature pulls + # aws-lc-sys, which needs a C compiler for the *target*, and the runner + # has no aarch64 or musl cross-toolchain. Linting in a different + # environment from the one you build in is how a lint goes green while + # the build goes red — which is exactly what happened the first time this + # ran. + - run: cross clippy --workspace --all-targets --all-features --target "${{ matrix.target }}" -- -D warnings + + - run: cross build --workspace --release --target "${{ matrix.target }}" + + package: + name: package ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + arch: amd64 + - target: aarch64-unknown-linux-gnu + arch: arm64 + steps: + - uses: actions/checkout@v7 + + - name: Install Rust ${{ env.RUST_STABLE }} + run: | + rustup toolchain install "$RUST_STABLE" --profile minimal + rustup default "$RUST_STABLE" + rustup target add "${{ matrix.target }}" + + - uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-pkg-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + + - run: cargo install cross --locked --version 0.2.5 + # cargo-deb is pinned exactly and bumped in a reviewed PR. 2.7.0 could not + # parse `resolver = "3"` at all — its cargo_toml only knew resolvers 1 and 2 — + # which failed the packaging step outright. + - run: cargo install cargo-deb --locked --version 3.7.0 + + # Reproducible: the timestamp comes from the commit, not from the clock. + - name: Build release + run: | + SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct HEAD) \ + cross build --workspace --release --target "${{ matrix.target }}" + + # Every pull request produces an installable artifact an operator can drop + # on a staging node. The version carries the short SHA so a test package is + # never mistaken for a release, and sorts below the release it precedes. + - name: Build .deb + id: deb + run: | + VERSION="$(cat VERSION)~git$(git rev-parse --short HEAD)" + # An explicit output path rather than globbing the debian directory. + # `target/` is restored from cache, so a .deb from an earlier commit — + # named with that commit's sha — survives there, and `ls *.deb` then + # returns two lines and corrupts $GITHUB_OUTPUT. + OUT="dist/filterframe_${{ matrix.arch }}.deb" + mkdir -p dist + SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct HEAD) \ + cargo deb -p filterframe-cli \ + --target "${{ matrix.target }}" \ + --no-build --no-strip \ + --deb-version "$VERSION" \ + --output "$OUT" + echo "path=$OUT" >> "$GITHUB_OUTPUT" + + # The point of building packages on every PR rather than only at release: + # packaging breakage is caught by the change that caused it. Installing in + # a clean container is what makes this a test and not just an artifact. + - name: Verify the package installs + if: matrix.arch == 'amd64' + run: | + docker run --rm -v "$PWD:/w" -w /w debian:trixie-slim bash -euxo pipefail -c ' + apt-get update -qq + apt-get install -y -qq --no-install-recommends systemd >/dev/null + + # Install with apt, not dpkg, so declared dependencies are actually + # resolved. `dpkg -i` leaves them unsatisfied and the check then + # passes on a package that would not install cleanly on a real host. + apt-get install -y -qq "$PWD/${{ steps.deb.outputs.path }}" >/dev/null + + # The unit must be valid to systemd itself, not merely present. + # `systemd-analyze verify` exits non-zero on a malformed unit and on + # an ExecStart that does not exist. + systemd-analyze verify /lib/systemd/system/filterframe.service + + # Installed disabled and stopped, deliberately. + ! systemctl is-enabled filterframe 2>/dev/null + + test -f /etc/filterframe/example.conf + test -x /usr/bin/filterframe + + # Argument parsing works with no configuration present. + filterframe --version + filterframe version + filterframe --help >/dev/null + + apt-get remove -y -qq filterframe >/dev/null + ' + + - uses: actions/upload-artifact@v7 + with: + name: filterframe-${{ matrix.arch }}-deb + path: dist/*.deb + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d3ffe37 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +name: Release + +# Releases are the same build path as CI's `package` job with publishing +# appended — never a separate, less-exercised one. `dry_run` runs the whole +# matrix and skips publishing, so a release can be rehearsed before the tag +# exists. +on: + push: + tags: ['v*.*.*'] + workflow_dispatch: + inputs: + dry_run: + description: "Build every artifact but do not publish" + type: boolean + default: true + +permissions: + contents: write + +env: + RUST_STABLE: "1.97.1" + +jobs: + build: + name: build ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { target: x86_64-unknown-linux-gnu, arch: amd64, deb: true } + - { target: aarch64-unknown-linux-gnu, arch: arm64, deb: true } + - { target: x86_64-unknown-linux-musl, arch: amd64, deb: false } + - { target: aarch64-unknown-linux-musl, arch: arm64, deb: false } + steps: + - uses: actions/checkout@v7 + + - name: Install Rust ${{ env.RUST_STABLE }} + run: | + rustup toolchain install "$RUST_STABLE" --profile minimal + rustup default "$RUST_STABLE" + rustup target add "${{ matrix.target }}" + + - run: cargo install cross --locked --version 0.2.5 + - name: Install cargo-deb + if: matrix.deb + # cargo-deb is pinned exactly and bumped in a reviewed PR. 2.7.0 could not + # parse `resolver = "3"` at all — its cargo_toml only knew resolvers 1 and 2 — + # which failed the packaging step outright. + run: cargo install cargo-deb --locked --version 3.7.0 + + - name: Build + run: | + SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct HEAD) \ + cross build --workspace --release --target "${{ matrix.target }}" + + # Tarball carries what an operator needs to run it off a non-Debian host: + # the binary, the annotated reference config, the licence, and the docs. + - name: Stage tarball + run: | + VERSION="$(cat VERSION)" + STAGE="filterframe-${VERSION}-${{ matrix.target }}" + mkdir -p "dist/$STAGE" + cp "target/${{ matrix.target }}/release/filterframe" "dist/$STAGE/" + cp conf/example.conf LICENSE README.md VERSION "dist/$STAGE/" + tar -C dist --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@$(git log -1 --pretty=%ct HEAD)" \ + -czf "dist/$STAGE.tar.gz" "$STAGE" + rm -rf "dist/$STAGE" + ( cd dist && sha256sum "$STAGE.tar.gz" > "$STAGE.tar.gz.sha256" ) + + - name: Build .deb + if: matrix.deb + run: | + # Explicit output path rather than globbing target/: a .deb from an + # earlier build can survive there, and copying both would put two + # packages claiming the same version into one release. + DEB="dist/filterframe_$(cat VERSION)_${{ matrix.arch }}.deb" + SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct HEAD) \ + cargo deb -p filterframe-cli --target "${{ matrix.target }}" \ + --no-build --no-strip \ + --output "$DEB" + ( cd dist && for f in *.deb; do sha256sum "$f" > "$f.sha256"; done ) + + - uses: actions/upload-artifact@v7 + with: + name: dist-${{ matrix.target }} + path: dist/* + if-no-files-found: error + + publish: + name: publish + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + path: staging + + - name: Collect artifacts + run: | + mkdir -p dist + find staging -type f -exec cp {} dist/ \; + # One SHA256SUMS covering everything, rather than making a verifier + # fetch a per-file .sha256 for each artifact they downloaded. + ( cd dist && cat *.sha256 | sort -k2 > SHA256SUMS && rm -f *.sha256 ) + ls -la dist + + # Signing is optional so that a fork without the secret still produces a + # complete, verifiable release rather than failing at the last step. + - name: Sign SHA256SUMS + if: env.GPG_PRIVATE_KEY != '' + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + gpg --batch --yes --detach-sign --armor dist/SHA256SUMS + + - name: Publish + if: startsWith(github.ref, 'refs/tags/v') && inputs.dry_run != true + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + # A tag carrying a hyphen is a pre-release by construction: v0.1.0-rc1. + PRERELEASE="" + case "$TAG" in *-*) PRERELEASE="--prerelease" ;; esac + gh release create "$TAG" dist/* --generate-notes $PRERELEASE + + - name: Dry run summary + if: inputs.dry_run == true + run: | + echo "Dry run — the following would have been published:" >> "$GITHUB_STEP_SUMMARY" + ( cd dist && ls -la ) >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adbe8b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target +/Cargo.lock.bak +**/*.rs.bk + +# Working documents that are not part of the distributed source. +/SPEC.md +/plans/ +.claude/ + +# Never commit a bearer token, however it got here. +*.token +/conf/*.token diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c5321db --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# filterframe — working notes + +Keep this file tight: skim it first, then dive in. + +## Project overview + +A DDoS mitigation control plane. filterframe polls a policy engine for active mitigations and +executes them as BGP announcements — a host route with a blackhole community for the fast tier, a +scrubbing-provider diversion for the slow one. It never detects an attack, never forwards a packet, +and never configures an interface. + +Sibling projects, same conventions: **packetframe** (XDP data plane) and **prefixd** (policy engine). + +## Repo layout + +- `crates/common/` — config grammar, shared types, trait definitions. No async runtime, no I/O. +- `crates/cli/` — the `filterframe` binary: CLI, daemon loop, signals, metrics, the reconciler. +- `conf/example.conf` — the annotated reference config. It is a primary document, not a sample. +- `docs/runbooks/` — operator-facing procedures, one per subsystem. + +Tier modules depend only on `crates/common`, never on each other. The CLI is the only crate that +knows all of them. + +## Build and test + +`make help` lists everything. `make lint` is what CI gates on. + +## License + +GPL-3.0-or-later. Every source file inherits it; do not add per-file headers. + +## Platform constraints + +Linux is the deployment target. Development on macOS must work, so anything touching netlink, sysfs, +or signals sits behind `#[cfg(target_os = "linux")]` and the test suite passes on both. + +## Toolchain + +`rust-toolchain.toml` pins the exact stable. `rust-version` in `Cargo.toml` is deliberately lower — +it is the MSRV contributors are held to, not what CI builds with. + +## Error handling + +Scoped `thiserror` enums at each boundary. A variant exists to drive a decision — an exit code, a +refusal, a retry-or-not — never merely to carry a string. Internal helpers may return +`Result`. **Do not add `anyhow`.** + +Validate at system boundaries: config parse, HTTP responses, sysfs reads, gRPC replies. Trust +framework guarantees inside. No fallbacks for conditions that cannot occur. + +## The invariant + +**filterframe only ever *adds* BGP objects. It never withdraws a route that is carrying traffic.** +Its own death must degrade toward normal routing, never toward an outage. If a change appears to +require withdrawing something load-bearing, the design is wrong, not the invariant. + +## Clippy policy + +`-D warnings` in CI. A targeted `#[allow(...)]` needs a comment saying why the thing stays. + +## Comments + +Every file opens with a `//!` docstring: one sentence of what, then why. Every magic number is a +named `const` with a docstring arguing the value. Record rejected alternatives and the incident that +motivated a choice, with dates. + +## PR workflow + +One branch per slice, named `type/kebab-summary`. Conventional Commits with a scope, and a subject +that states the conclusion after an em dash. CI green before review. + +## What not to change casually + +- The additive-only invariant above. +- `Instant` for every damping timer; wall clock only for facts derived from another system's + timestamps, or for a value written down for a human to read later. Nothing enforces this + mechanically — it is a review rule, so check it by hand. (A `clippy.toml` with + `disallowed-methods` for `SystemTime::now` would do it, at the cost of a justified `#[allow]` at + each of the half-dozen legitimate uses. Worth doing; not done.) +- Metric names, which are an operator-facing contract — append-only once shipped. +- The `.deb` and systemd unit's empty capability set. filterframe needs no privilege; that emptiness + is what makes the invariant enforceable rather than merely intended. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..04769b4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2390 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filterframe-bgp" +version = "0.0.1" +dependencies = [ + "filterframe-common", + "ipnet", + "prost", + "prost-types", + "protoc-bin-vendored", + "thiserror", + "tokio", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", +] + +[[package]] +name = "filterframe-cli" +version = "0.0.1" +dependencies = [ + "clap", + "filterframe-bgp", + "filterframe-common", + "filterframe-policy", + "filterframe-rtbh", + "filterframe-scrub-divert", + "ipnet", + "libc", + "serde", + "serde_json", + "signal-hook", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "filterframe-common" +version = "0.0.1" +dependencies = [ + "ipnet", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "filterframe-policy" +version = "0.0.1" +dependencies = [ + "filterframe-common", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "filterframe-rtbh" +version = "0.0.1" +dependencies = [ + "filterframe-common", + "ipnet", + "tracing", +] + +[[package]] +name = "filterframe-scrub-divert" +version = "0.0.1" +dependencies = [ + "filterframe-common", + "ipnet", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fa08662 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,119 @@ +[workspace] +resolver = "3" +members = [ + "crates/common", + "crates/bgp", + "crates/policy", + "crates/modules/rtbh", + "crates/modules/scrub-divert", + "crates/cli", +] +# Nothing is excluded. packetframe excludes its BPF crates because they need a +# pinned nightly and a bpfel target; filterframe has no such crate, so the +# workspace is the whole tree and `cargo test --workspace` genuinely covers it. +exclude = [] + +[workspace.package] +version = "0.0.1" +# Edition 2024, against packetframe's 2021. packetframe's edition is held back +# by aya-ebpf's nightly floor, which filterframe does not have. What it buys +# here: `unsafe_op_in_unsafe_fn` is a hard error (this daemon calls libc for +# signal handling and pidfile identity), and let-chains collapse the nested +# `if let` ladders a hand-written config parser is mostly made of. +edition = "2024" +# MSRV, deliberately behind the rust-toolchain.toml pin so a contributor a few +# releases back still builds. Edition 2024 sets a hard floor of 1.85 and +# let-chains raise it to 1.88; 1.90 is the first round number above both. CI +# always builds with the pinned stable. +rust-version = "1.90" +license = "GPL-3.0-or-later" +repository = "https://github.com/unredacted/filterframe" + +[workspace.dependencies] +filterframe-common = { path = "crates/common" } +filterframe-bgp = { path = "crates/bgp" } +filterframe-policy = { path = "crates/policy" } +filterframe-rtbh = { path = "crates/modules/rtbh" } +filterframe-scrub-divert = { path = "crates/modules/scrub-divert" } + +# Errors are scoped thiserror enums at each boundary, and their variants exist +# to drive a decision — an exit code, a refusal, a retry-or-not. Internal +# helpers return Result. +# +# `anyhow` is deliberately absent. packetframe declares it in four manifests +# and uses it in zero .rs files; carrying that forward would just reproduce the +# drift. +thiserror = "2.0.20" + +clap = { version = "4.6.6", features = ["derive"] } + +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["json", "env-filter"] } + +# Prefix type across the whole tree, not just a config detail. packetframe +# hand-rolled its own because BPF LPM maps needed a specific in-memory repr; +# filterframe has no such constraint, and the same values travel through NLRI +# encoding, the state journal's serde, and metrics labels. One type for all of +# that beats a config type plus conversions. +ipnet = { version = "2.12.1", features = ["serde"] } + +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0" + +# Signal handling and pidfile identity. Linux-only in practice; declared here +# so the cfg-gated deps in crates/cli inherit one version. +libc = "0.2" +signal-hook = "0.4" + +# Scoped to what the policy client and, later, the BGP runtime actually need. +# No `fs` — every file this daemon writes goes through the sync atomic +# write-then-rename in the CLI. No `process` — nothing is ever spawned. No +# `full`, which would pull both in by the back door. +tokio = { version = "1.53.1", default-features = false, features = ["rt-multi-thread", "net", "sync", "time", "macros", "io-util"] } + +# reqwest 0.13 moved `query` behind a feature, and it is not optional here: the +# mitigations endpoint is driven by query parameters, and hand-formatting a +# query string is how you get an encoding bug in a URL carrying a customer's +# victim address. `form` stays off — every body here is JSON. +# +# The feature is `rustls`, not `rustls-tls`: 0.13 renamed it, and every example +# written before that still says the old name. rustls is also the default TLS +# backend from 0.13 on, so a musl build needs no OpenSSL. +# +# It does still need a C compiler: 0.13's rustls provider is aws-lc-rs, which +# builds aws-lc-sys through cc and cmake. That is why every cross-target step in +# CI — including clippy — runs inside `cross` rather than on the bare runner. If +# this ever moves to a pure-Rust provider, that indirection can go with it. +reqwest = { version = "0.13.4", default-features = false, features = ["json", "query", "rustls", "http2", "charset"] } + +# Jittered exponential backoff. `backoff` is the obvious name and the wrong +# one — unmaintained since 2021. backon is maintained and leaves the retried +# future readable at the call site. +backon = "1.6.0" + +# The GoBGP sidecar client. tonic 0.14 split prost codegen out into +# `tonic-prost` and `tonic-prost-build`, so all four move together and are +# bumped in one reviewed PR — a grouped update of three of them does not +# compile. Optional throughout: only a build selecting the gobgp backend pays +# for a gRPC stack. +tonic = { version = "0.14.6", default-features = false, features = ["channel", "codegen", "transport"] } +tonic-prost = "0.14.6" +tonic-prost-build = "0.14.6" +prost = "0.14.4" +prost-types = "0.14.4" + +# Ships a protoc binary for the build host, so codegen needs nothing installed +# and every cross target works. Build-time only; nothing here reaches the +# shipped binary. +protoc-bin-vendored = "3.1" + +# Dependencies land with the slice that needs them rather than up front, so +# that `cargo tree` always reflects what the binary actually does: +# netgauze-bgp-speaker, -bgp-pkt -> the embedded speaker +# governor -> per-peer UPDATE pacing + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true +panic = "abort" diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..be3051b --- /dev/null +++ b/Cross.toml @@ -0,0 +1,10 @@ +# `cross` runs cargo inside a container, and host environment variables do +# not propagate unless listed here. Only one is needed: release.yml derives +# SOURCE_DATE_EPOCH from the tag commit for reproducible .deb builds, and it +# has to reach inside the container to have any effect. +# +# Notably absent: anything for protoc. The GoBGP gRPC backend commits its +# generated code (regenerated behind FILTERFRAME_REGEN_PROTO with a CI diff +# job), so no cross target needs a protobuf compiler installed. +[build.env] +passthrough = ["SOURCE_DATE_EPOCH"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f317cf1 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +CARGO ?= cargo +TARGETS := aarch64-unknown-linux-musl x86_64-unknown-linux-musl aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu + +.PHONY: help build release release-all test lint fmt clean deb + +help: + @echo "filterframe — make targets" + @echo "" + @echo " build debug build of the whole workspace" + @echo " release release build for the host" + @echo " release-all release build for every cross target" + @echo " test cargo test --workspace" + @echo " lint fmt --check, then clippy with warnings denied" + @echo " fmt rewrite sources with rustfmt" + @echo " clean remove target/" + @echo " deb build a .deb for the host architecture" + +build: + $(CARGO) build --workspace + +release: + $(CARGO) build --workspace --release + +release-all: + @for t in $(TARGETS); do \ + echo "==> $$t"; \ + cross build --workspace --release --target $$t || exit 1; \ + done + +test: + $(CARGO) test --workspace + +lint: + $(CARGO) fmt --all --check + $(CARGO) clippy --workspace --all-targets --all-features -- -D warnings + +fmt: + $(CARGO) fmt --all + +clean: + $(CARGO) clean + +# cargo-deb reads [package.metadata.deb] in crates/cli/Cargo.toml. The +# binary must already exist, hence the release dependency and --no-build: +# letting cargo-deb drive the build would bypass the workspace profile. +# +# SOURCE_DATE_EPOCH comes from the HEAD commit so that building the same +# tree twice produces byte-identical packages. +deb: release + SOURCE_DATE_EPOCH=$$(git log -1 --pretty=%ct HEAD) \ + cargo deb -p filterframe-cli --no-build --no-strip diff --git a/README.md b/README.md index f51e66f..b8c033b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,101 @@ # filterframe -A modern DDoS defense control plane for the Internet + +**DDoS mitigation control plane for Linux.** filterframe takes mitigation decisions from a policy +engine and carries them out as BGP announcements — blackholing a host upstream, or diverting a prefix +to a scrubbing provider and bringing it back. It runs on any Linux box that can hold BGP sessions and +is agnostic about which router or provider is on the other end. + +GPL-3.0-or-later. Single static binary, no runtime dependencies, systemd unit and `.deb` provided. + +> **Status: early development.** The workspace skeleton is in place; the mitigation tiers are not yet +> implemented. Not ready for production use. + +## What it does + +filterframe never detects an attack, never forwards a packet, and never configures an interface. It +polls a policy engine for the set of currently active mitigations, works out which prefixes should be +under which mitigation, compares that against what is actually announced, and converges the +difference. + +Two tiers ship, selected per mitigation by an operator-defined rule table: + +**RTBH** — announce a host route to transit carrying a blackhole community, so the attack is dropped +upstream before it reaches your edge. Seconds to take effect. Sacrifices the victim address, which is +the right trade for short, sharp attacks and the only option that works when your own pipe is already +saturated. + +**Scrubber diversion** — announce the covering prefix to a scrubbing provider, confirm it reached a +quorum of their route reflectors, then have your edge stop advertising it to transit. Clean traffic +returns over a tunnel. Tens of seconds, and reserved for attacks that are both large and persistent. + +## The design rule + +**filterframe only ever *adds* BGP objects. It never withdraws a route that is carrying traffic.** + +This is why it does not originate your protected prefixes. Your edge keeps announcing those +unconditionally; filterframe announces a separate *signal* route, and your edge's export policy +suppresses the protected prefix while that signal is present. If filterframe crashes, is killed, or +is simply stopped, the signal ages out and normal routing returns on its own. + +The daemon's contract is therefore router-agnostic: announce a signal, and the upstream policy does +the rest. FRR implements it with conditional advertisement, Junos with a policy statement matching the +community, IOS-XR with RPL. + +A consequence worth stating plainly, because it looks like a bug otherwise: filterframe and its policy +engine have **deliberately opposite failure biases**. A policy engine should fail open, so a dead one +stops mitigating. filterframe holds, because a dropped diversion is an outage. Both are correct, and +they compose because the policy engine announces nothing itself. + +## Safety properties + +- **An unreachable policy engine never causes a teardown.** This is enforced in the type system, not + by convention — the view of active mitigations is either fresh or stale, and the stale variant + carries no list to act on. +- **Diversion is journaled before each step**, so a process killed mid-sequence resumes in the + direction that preserves reachability. Announced to both transit and scrubber is always safe; + announced to neither is the outage, and no reachable state leads there. +- **Damping is layered** — minimum hold, drop-out grace, cooldown, and a flap budget that holds + rather than oscillates. Every divert cycle is real BGP churn, and the cure must not be worse. +- **Observe mode is the default.** The whole loop runs and computes decisions; nothing is announced. + +## Install + +No release has been cut yet. Every pull request builds an installable `.deb` as +a CI artifact, and `make deb` builds one locally. + +``` +sudo dpkg -i filterframe_*_amd64.deb +``` + +The package installs **disabled and stopped**. filterframe needs an +operator-supplied peer list and a policy-engine token before it can do anything, +so auto-starting would only produce a first-boot failure everyone learns to +ignore. Copy `/etc/filterframe/example.conf` to `filterframe.conf`, edit it, run +`filterframe preflight`, then enable the unit. + +It starts in **observe mode**: the whole loop runs and every decision is +computed, and nothing is announced. Read `filterframe status` until the +decisions match what you expect before switching to `enforce`. + +The unit ships with an **empty capability set**. filterframe needs no privilege +to hold BGP sessions and read sysfs, and that emptiness is what makes the +additive-only rule enforceable rather than merely intended — a daemon without +`CAP_NET_ADMIN` cannot install a route or rewrite a firewall rule however wrong +its inputs are. + +## Documentation + +| Runbook | Covers | +|---|---| +| [policy-loss](docs/runbooks/policy-loss.md) | What happens when the policy source is unreachable, and why holding is correct | +| [edge-policy](docs/runbooks/edge-policy.md) | How transit suppression works, per platform, and the one test that matters | +| [bgp-backends](docs/runbooks/bgp-backends.md) | What a confirmation actually proves, and what dying does to each backend | +| [return-path](docs/runbooks/return-path.md) | Why diversion is gated on the tunnel, and the `operstate` trap | + +## Development + +``` +make help +``` + +`make lint` is what CI gates on: `rustfmt --check` followed by clippy with warnings denied. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/ci/refresh-proto-manifest.sh b/ci/refresh-proto-manifest.sh new file mode 100755 index 0000000..1687547 --- /dev/null +++ b/ci/refresh-proto-manifest.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Rewrite crates/bgp/proto/SOURCE.json from the files currently present. +# +# Run this after re-vendoring, never to make a failing CI check pass: the check +# exists to catch a proto that was edited by hand or a re-vendor that only +# copied some of the bundle. +set -euo pipefail +cd "$(dirname "$0")/.." +python3 - <<'PY' +import pathlib, hashlib, json +d = pathlib.Path("crates/bgp/proto") +m = json.loads((d / "SOURCE.json").read_text()) +m["files"] = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in sorted(d.glob("*.proto"))} +(d / "SOURCE.json").write_text(json.dumps(m, indent=2) + "\n") +print(f"recorded {len(m['files'])} files") +PY diff --git a/conf/example.conf b/conf/example.conf new file mode 100644 index 0000000..216eff4 --- /dev/null +++ b/conf/example.conf @@ -0,0 +1,336 @@ +# filterframe example configuration. +# +# Addresses, ASNs, communities and peer names here are illustrative and use +# documentation ranges. They will not apply verbatim anywhere; substitute your +# own. +# +# The grammar is line-based. `#` starts a comment that runs to end of line, +# leading whitespace is cosmetic, and there is no line continuation — every +# directive fits on one line, which is why per-peer settings live in a `peer` +# section rather than on one long line. Unknown directives are fatal: a typo +# stops the daemon at load rather than silently changing behaviour at 3am. +# +# filterframe executes mitigations a policy engine has already decided on. It +# never detects an attack, never forwards a packet, and never configures an +# interface. Everything it does is a BGP announce or a BGP withdraw. +# +# This file is installed to /etc/filterframe/example.conf and is never read by +# the daemon. Copy it to filterframe.conf and edit that. + +global + # Stable identity for this filter node. Appears in every log line, as a + # metrics label, and in the derived BGP router-id when `bgp router-id` is + # omitted. Must be unique across the fleet. + node-id filter1 + + state-dir /var/lib/filterframe + metrics-textfile /var/lib/node_exporter/textfile/filterframe.prom + + # trace | debug | info | warn | error. Hot-reloadable via + # `filterframe reconfigure`. + # + # RUST_LOG in the daemon's environment overrides this for the life of the + # process, and is the only way to raise one module on its own — for example + # RUST_LOG=info,filterframe_scrub_divert=debug. While it is set, the daemon + # says so once at startup rather than ignoring this line silently. + log-level info + + # observe | enforce. + # + # In observe mode the whole loop runs — the policy engine is polled, decisions + # are computed, metrics and status reflect exactly what would happen — and no + # BGP call is made. This is the default, and it is how filterframe lands on a + # production node without a bad night. + # + # Restart-only. Flipping it live would bring real sessions up underneath an + # already-computed desired set and announce the whole thing in one step, with + # no settle time and nobody watching. + mode observe + + # How often the reconciler runs a full poll, plan and converge step. This is + # the floor on how stale filterframe's view can be, and therefore on RTBH + # latency. Two seconds against the reference policy engine's own 30s + # reconcile tick means filterframe is never the slow half of the pair. + # Hot-reloadable. + tick-interval 2s + + # `converge-deadline` is NOT accepted by this version and the daemon refuses + # to start if it is present. Nothing bounds a tick yet, and a ceiling that is + # configured but not enforced is worse than an absent one: it reads as a + # guarantee. It returns with the deadline. + + # --- tier selection ------------------------------------------------------- + # + # The policy engine decides WHETHER to mitigate and how hard. filterframe + # decides BY WHICH MECHANISM this node can do it, because that depends on + # facts the policy engine does not model: which transits this node has, + # whether a scrubbing contract covers the prefix, whether the covering prefix + # is even originated here. + # + # tier-rule [when [and ]...] + # + # Rules are evaluated top to bottom, first match wins. `none` is an explicit + # refusal, and is how you carve an exception out of a broader rule below it — + # which also makes it a live kill switch, since tier-rules are hot-reloadable + # and the reconciler re-plans on the next tick. + # + # Facts: action, vector, customer, age-at-least, age-at-most, bps-at-least, + # acknowledged. A mitigation matching no rule is executed by no tier and + # reported as unhandled — visible, not silent. + + # An operator has already looked at this one. Don't second-guess them. + tier-rule none when acknowledged true + + # Duration is the primary axis, because it is the one fact that is always + # computable. A short sharp attack is worth an address; it is not worth + # moving a whole prefix across the Internet twice. + tier-rule rtbh when age-at-most 45s + + # Divert requires size AND persistence. Either alone is a bad trade: a 2 Gbps + # spike lasting twenty seconds would finish before the diversion converged. + tier-rule divert when bps-at-least 1500mbps and age-at-least 45s + + # The policy engine chose discard over policing, which is the strongest size + # signal available without a rate sample at all. + tier-rule rtbh when action discard + + # Unmatched mitigations land here. RTBH rather than divert or nothing: a gap + # in the rule table should cost one address, not a diversion cycle and not + # the outage the daemon was installed to prevent. + tier-rule rtbh + +# --- policy source --------------------------------------------------------- +# filterframe reads the active mitigation list and never writes to it. Writing +# back would create a feedback loop between two policy engines. + +policy-source + url https://policy.example.net + + # Bearer token, read from this file at startup and on every reload. + # Deliberately not inline: this config is installed 0644, and a token in it + # would be readable by every account on the node. The file must be 0600 and + # owned by the daemon user; `filterframe preflight` refuses anything looser. + token-file /etc/filterframe/policy.token + + # Which POP's mitigations this node executes. Filtered client-side. + pop iad1 + + request-timeout 3s + + # `failure-threshold` is NOT accepted by this version. There is no state + # called "lost": a failed poll already holds every engagement and withdraws + # nothing, from the first failure, and nothing counts consecutive failures + # toward any different behaviour. A threshold that changes nothing would only + # imply a behaviour change exists. + + # What happens once the policy source cannot be read. + # + # hold (default, and the only accepted value) keep every engagement, + # withdraw nothing, and let each module's own ceiling bound how long + # that can last. + # + # `drain` — withdraw everything — is deliberately NOT implemented and is + # refused at load. filterframe only ever adds BGP objects; withdrawing + # protection because a policy engine is unreachable is the exact failure the + # whole design exists to prevent, and there is no age at which "I cannot reach + # my policy engine" becomes "there is no attack". + on-policy-loss hold + + # `policy-loss-grace` is NOT accepted by this version. The ceiling on holding + # is per-module, because only a module knows what its engagement costs while + # it stands: see the rtbh module's `max-lifetime` below. One global grace + # period here would have been a second, weaker answer to the same question. + + # Certificate authority the policy engine's certificate must chain to. Read at + # startup; a path that cannot be read or is not PEM refuses the start rather + # than quietly falling back to the system trust store. + ca-file /etc/ssl/certs/ca-certificates.crt + +# --- BGP -------------------------------------------------------------------- + +bgp + # gobgp | embedded + # + # gobgp drive an external GoBGP over gRPC. GoBGP owns the sessions and + # outlives filterframe, so a restart is invisible to the routers — + # and a crashed filterframe's announcements survive it, which is + # what `origin-community` below exists to clean up. + # embedded filterframe speaks BGP itself. One less moving part, but the + # sessions die when it does. + # + # gobgp is the default because decoupling the speaker's lifetime from the + # control logic's lifetime is worth more, for a safety-critical mitigation, + # than avoiding a sidecar. + mode gobgp + grpc-endpoint 127.0.0.1:50051 + + local-as 64512 + router-id 198.51.100.7 + + hold-time 90s + connect-retry 15s + + # THE AUTHORITY BOUNDARY. filterframe refuses to announce anything not covered + # by one of these, at load and again at every converge step. Without it, a + # confused or compromised policy engine could have this node announce space it + # does not hold — an attempted hijack that upstream filters would probably + # catch, and that should never have left the building. Repeatable. + originate-prefix 198.51.100.0/24 + originate-prefix 2001:db8:1::/48 + + # Provenance tag on every path filterframe originates. This is how a restarted + # daemon tells its own crash orphans from paths another controller put in the + # same RIB: it adopts what carries this community and never withdraws what + # does not. Load-bearing in gobgp mode, where the sidecar outlives us. + origin-community 64512:1 + +# --- peers ------------------------------------------------------------------ +# One section per BGP neighbour. Adding or removing one is restart-only: +# session bring-up is bound to daemon start. + +peer transit-a + # transit | scrubber | edge. Checked against `allow-tier` below, so a + # scrubber peer that allows rtbh is refused at load rather than at 3am. + role transit + address 203.0.113.1 + remote-as 64510 + # Communities attached to an RTBH announcement toward this peer. Every transit + # spells it differently and there is no safe default, so a peer allowing rtbh + # with no community is refused at load — a blackhole announced without one is + # just a host route, forwarded normally. + community blackhole + community 64510:666 + # no-export is standard on RTBH so the blackhole stops at the AS boundary. + # Not attached implicitly: some providers need it to reach their upstreams. + community no-export + allow-tier rtbh + +peer transit-b + role transit + address 203.0.113.5 + remote-as 64511 + community blackhole + community 64511:666 + community no-export + allow-tier rtbh + +peer scrub-rr1 + role scrubber + address 192.0.2.10 + remote-as 64520 + multihop 8 + # Tells the scrubbing provider to attract this prefix. + community 64520:100 + allow-tier divert + +peer scrub-rr2 + role scrubber + address 192.0.2.11 + remote-as 64520 + multihop 8 + community 64520:100 + allow-tier divert + +peer scrub-rr3 + role scrubber + address 192.0.2.12 + remote-as 64520 + multihop 8 + community 64520:100 + allow-tier divert + +peer edge + # The local edge router. filterframe does NOT originate the protected prefix + # here — the edge does that unconditionally, and keeps doing it if filterframe + # dies. filterframe announces a signal route carrying the community below, and + # the edge's export policy suppresses the protected prefix while that signal + # is present. See docs/runbooks/ for the per-platform policy. + role edge + address 10.0.0.1 + remote-as 64512 + community 64512:666 + allow-tier divert-signal + +# --- module rtbh ------------------------------------------------------------ + +module rtbh + # Longest-prefix floor. Blackholing anything shorter than a host route drops + # the attack and the customer together. Upstreams usually reject it too, but + # "usually" is not a guard. + max-prefix-length 32 + max-prefix-length6 128 + + # Hard cap on simultaneously announced blackholes, for the runaway-detector + # case. Refused at, not truncated to: a partially applied blackhole set splits + # traffic along a line nobody chose. + max-active 64 + + # Never blackhole these, whatever the policy engine says. This node's own + # addresses, its BGP peers, the policy engine, the management range. Adding + # one here withdraws a live blackhole that now matches, which makes this the + # fastest lever an operator has during a mistaken mitigation. + never-blackhole 198.51.100.7/32 + never-blackhole 203.0.113.0/29 + + # Dwell before withdrawing a blackhole the policy engine has dropped. A + # detector oscillating around its threshold should not oscillate a BGP + # announcement. Announces are NOT delayed — the fast tier stays fast in the + # direction that matters. + withdraw-hold 30s + + # How long a blackhole may stand without the policy engine re-confirming it. + # The backstop when the engine is unreachable and `on-policy-loss hold` is in + # force. + max-lifetime 30m + +# --- module scrub-divert ---------------------------------------------------- + +module scrub-divert + # The prefixes this node may divert. Each must be covered by a `bgp + # originate-prefix` — you cannot divert what you do not originate — and must + # be no longer than the scrubbing contract covers. A provider who will not + # accept a /25 leaves a diverted /25 attracting nothing while the edge has + # already stopped advertising it, which is a black hole you built by hand. + divertible-prefix 198.51.100.0/24 + + # How many scrubber peers must confirm before the edge is signalled. Read as + # "at least N of the M scrubber peers"; M is checked against the peer sections. + # + # "Confirm" means the session is established and the peer's adj-RIB-out shows + # the path. BGP offers no end-to-end acknowledgement; this is the strongest + # evidence the protocol affords, and the runbook says so in those words. + scrubber-quorum 2 of 3 + + # Dwell after quorum before signalling the edge. Quorum says the reflectors + # took the path; it says nothing about the provider's own propagation. Prefer + # too long over too short — too long costs dirty traffic, too short costs a + # black hole. + divert-settle-time 20s + + # The mirror image on the way back: dwell after restoring the transit + # advertisement before withdrawing from the scrubber. Longer on purpose. An + # overlap costs a little asymmetric routing; a gap costs an outage. + return-settle-time 60s + + # Floor on how long a prefix stays diverted once it has been. Moving a whole + # prefix across the Internet twice because a detector blipped is worse for the + # customer than the attack was. + min-divert-time 10m + + # Where cleaned traffic comes back. filterframe does NOT create or configure + # this tunnel — that is the host's job, and `filterframe tunnel render` will + # emit the systemd-networkd units for it. filterframe verifies it: the + # interface exists, is administratively up, and answers probes. + # + # Note the check is IFF_UP, not operstate: GRE tunnels have no carrier and + # report operstate `unknown` forever, so an operstate check would refuse to + # divert on a perfectly healthy tunnel. + return-tunnel gre-scrub0 + return-probe-target 192.0.2.20 + + # If quorum is lost while diverted: + # restore (default) re-advertise to transit immediately. Traffic comes back + # dirty, but it comes back. Reachability beats cleanliness. + # hold keep the signal up and alarm. Only where dirty traffic is worse + # than no traffic, and you own that judgement. + on-quorum-loss restore diff --git a/crates/bgp/Cargo.toml b/crates/bgp/Cargo.toml new file mode 100644 index 0000000..a3931bd --- /dev/null +++ b/crates/bgp/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "filterframe-bgp" +description = "BGP speaker backends for filterframe: an in-memory mock, and the real ones." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +filterframe-common.workspace = true +ipnet.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["sync"] } +tracing.workspace = true + +# Optional: only a build that selects the gobgp backend pulls in a gRPC stack. +tonic = { workspace = true, optional = true } +tonic-prost = { workspace = true, optional = true } +prost-types = { workspace = true, optional = true } +prost = { workspace = true, optional = true } + +[build-dependencies] +tonic-prost-build = { workspace = true, optional = true } +protoc-bin-vendored = { workspace = true, optional = true } + +[features] +default = [] +# The GoBGP sidecar backend. Off by default so the common case — observe mode +# against the mock — builds with no protobuf toolchain and no gRPC dependencies. +gobgp = [ + "dep:tonic", + "dep:prost-types", + "dep:tonic-prost", + "dep:prost", + "dep:tonic-prost-build", + "dep:protoc-bin-vendored", +] + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/bgp/build.rs b/crates/bgp/build.rs new file mode 100644 index 0000000..17c14de --- /dev/null +++ b/crates/bgp/build.rs @@ -0,0 +1,48 @@ +//! Generate the GoBGP gRPC client from the vendored protos. +//! +//! Only compiled against a build that selects the `gobgp` feature, so a +//! deployment using the mock — which is what `mode observe` runs — needs no +//! protobuf toolchain anywhere near it. Build scripts see the crate's own +//! features as cfgs, which is what lets the optional build-dependencies be +//! referenced at all. +//! +//! `protoc` comes from `protoc-bin-vendored` rather than the host, so it runs +//! on the build machine, every cross target gets it for free, and no +//! contributor has to install anything. The alternative — committing the +//! generated code — would put thousands of lines nobody reads into every diff. + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=proto"); + generate() +} + +#[cfg(feature = "gobgp")] +fn generate() -> Result<(), Box> { + // prost-build shells out to protoc. Pointing it at the vendored binary is + // what keeps `cargo build` working on a machine that has never heard of + // protobuf. + unsafe { + std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); + } + + tonic_prost_build::configure() + // Client only. filterframe drives a speaker; it never is one. + .build_server(false) + .compile_protos( + &[ + "proto/gobgp.proto", + "proto/attribute.proto", + "proto/capability.proto", + "proto/common.proto", + "proto/extcom.proto", + "proto/nlri.proto", + ], + &["proto/"], + )?; + Ok(()) +} + +#[cfg(not(feature = "gobgp"))] +fn generate() -> Result<(), Box> { + Ok(()) +} diff --git a/crates/bgp/proto/README.md b/crates/bgp/proto/README.md new file mode 100644 index 0000000..2776565 --- /dev/null +++ b/crates/bgp/proto/README.md @@ -0,0 +1,19 @@ +# Vendored GoBGP protobuf definitions + +These are copied unmodified from GoBGP's `api/` directory. They are vendored +rather than fetched at build time so that a build is reproducible without +network access, and so that a change to the wire contract shows up as a diff in +a pull request rather than as a silent behaviour change on the next build. + +`SOURCE.json` records a SHA-256 for each file. CI verifies the bundle against it, +so a proto edited by hand — or a partial re-vendor — fails the build rather than +shipping an encoder that silently disagrees with the daemon on the other end. + +## Re-vendoring + + cp /api/*.proto crates/bgp/proto/ + ./ci/refresh-proto-manifest.sh + +Then rebuild and run the GoBGP integration tests. A field number that moved is +not a compile error — it is a runtime disagreement — so those tests are the only +thing that catches it. diff --git a/crates/bgp/proto/SOURCE.json b/crates/bgp/proto/SOURCE.json new file mode 100644 index 0000000..5f742c0 --- /dev/null +++ b/crates/bgp/proto/SOURCE.json @@ -0,0 +1,13 @@ +{ + "upstream": "https://github.com/osrg/gobgp", + "path": "api/", + "note": "Vendored unmodified. Regenerate with the procedure in README.md.", + "files": { + "attribute.proto": "d0a942f22f2e204103d08623127c7ac6195e3bcc1aec78c5a6e759efb98e5ba0", + "capability.proto": "e70485b06564b87795a0a005677711e099ece9d11443107692fa9cab0760bc6d", + "common.proto": "87cb2ebc674d55654b2668f2daec23a0f6f72fa48c9248a5b99f51ac5e52bc99", + "extcom.proto": "6554368e6f70de73dc8c580b7c7a6eed603947f643890a8ee45365f3fd6ec892", + "gobgp.proto": "bb68cb2923cc4fccf70db9284d4fd58a93b5f958e9166244c48112a6fc9334a9", + "nlri.proto": "ca022cbcb2668a4a3434faf2a8eed94f84ed8afb19b1fbfd3e53de8280f4bdcc" + } +} diff --git a/crates/bgp/proto/attribute.proto b/crates/bgp/proto/attribute.proto new file mode 100644 index 0000000..34f2853 --- /dev/null +++ b/crates/bgp/proto/attribute.proto @@ -0,0 +1,584 @@ +// Copyright (C) 2018 Nippon Telegraph and Telephone Corporation. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, +// and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +syntax = "proto3"; + +package api; + +import "common.proto"; +import "extcom.proto"; +import "nlri.proto"; + +option go_package = "github.com/osrg/gobgp/v4/api;api"; + +message Attribute { + oneof attr { + UnknownAttribute unknown = 1; + OriginAttribute origin = 2; + AsPathAttribute as_path = 3; + NextHopAttribute next_hop = 4; + MultiExitDiscAttribute multi_exit_disc = 5; + LocalPrefAttribute local_pref = 6; + AtomicAggregateAttribute atomic_aggregate = 7; + AggregatorAttribute aggregator = 8; + CommunitiesAttribute communities = 9; + OriginatorIdAttribute originator_id = 10; + ClusterListAttribute cluster_list = 11; + MpReachNLRIAttribute mp_reach = 12; + MpUnreachNLRIAttribute mp_unreach = 13; + ExtendedCommunitiesAttribute extended_communities = 14; + As4PathAttribute as4_path = 15; + As4AggregatorAttribute as4_aggregator = 16; + PmsiTunnelAttribute pmsi_tunnel = 17; + TunnelEncapAttribute tunnel_encap = 18; + IP6ExtendedCommunitiesAttribute ip6_extended_communities = 19; + AigpAttribute aigp = 20; + LargeCommunitiesAttribute large_communities = 21; + LsAttribute ls = 22; + PrefixSID prefix_sid = 23; + } +} + +message OriginAttribute { + uint32 origin = 1; +} + +message AsSegment { + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_AS_SET = 1; + TYPE_AS_SEQUENCE = 2; + TYPE_AS_CONFED_SEQUENCE = 3; + TYPE_AS_CONFED_SET = 4; + } + Type type = 1; + repeated uint32 numbers = 2; +} + +message AsPathAttribute { + repeated AsSegment segments = 1; +} + +message NextHopAttribute { + string next_hop = 1; +} + +message MultiExitDiscAttribute { + uint32 med = 1; +} + +message LocalPrefAttribute { + uint32 local_pref = 1; +} + +message AtomicAggregateAttribute {} + +message AggregatorAttribute { + uint32 asn = 1; + string address = 2; +} + +message CommunitiesAttribute { + repeated uint32 communities = 1; +} + +message OriginatorIdAttribute { + string id = 1; +} + +message ClusterListAttribute { + repeated string ids = 1; +} + +message MpReachNLRIAttribute { + Family family = 1; + repeated string next_hops = 2; + repeated NLRI nlris = 3; +} + +message MpUnreachNLRIAttribute { + api.Family family = 1; + // The same as NLRI field of MpReachNLRIAttribute + repeated NLRI nlris = 3; +} + +message ExtendedCommunitiesAttribute { + repeated ExtendedCommunity communities = 1; +} + +message As4PathAttribute { + repeated AsSegment segments = 1; +} + +message As4AggregatorAttribute { + uint32 asn = 2; + string address = 3; +} + +message PmsiTunnelAttribute { + uint32 flags = 1; + uint32 type = 2; + uint32 label = 3; + bytes id = 4; +} + +message TunnelEncapSubTLVEncapsulation { + uint32 key = 1; + bytes cookie = 2; +} + +message TunnelEncapSubTLVProtocol { + uint32 protocol = 1; +} + +message TunnelEncapSubTLVColor { + uint32 color = 1; +} + +message TunnelEncapSubTLVSRPreference { + uint32 flags = 1; + uint32 preference = 2; +} + +message TunnelEncapSubTLVSRCandidatePathName { + string candidate_path_name = 1; +} + +message TunnelEncapSubTLVSRPriority { + uint32 priority = 1; +} + +message TunnelEncapSubTLVSRBindingSID { + oneof bsid { + SRBindingSID sr_binding_sid = 1; + SRv6BindingSID srv6_binding_sid = 2; + } +} + +message SRBindingSID { + bool s_flag = 1; + bool i_flag = 2; + bytes sid = 3; +} + +enum SRV6Behavior { + SRV6_BEHAVIOR_UNSPECIFIED = 0; + SRV6_BEHAVIOR_END = 1; + SRV6_BEHAVIOR_END_WITH_PSP = 2; + SRV6_BEHAVIOR_END_WITH_USP = 3; + SRV6_BEHAVIOR_END_WITH_PSP_USP = 4; + SRV6_BEHAVIOR_ENDX = 5; + SRV6_BEHAVIOR_ENDX_WITH_PSP = 6; + SRV6_BEHAVIOR_ENDX_WITH_USP = 7; + SRV6_BEHAVIOR_ENDX_WITH_PSP_USP = 8; + SRV6_BEHAVIOR_ENDT = 9; + SRV6_BEHAVIOR_ENDT_WITH_PSP = 10; + SRV6_BEHAVIOR_ENDT_WITH_USP = 11; + SRV6_BEHAVIOR_ENDT_WITH_PSP_USP = 12; + SRV6_BEHAVIOR_END_B6_ENCAPS = 14; + SRV6_BEHAVIOR_END_BM = 15; + SRV6_BEHAVIOR_END_DX6 = 16; + SRV6_BEHAVIOR_END_DX4 = 17; + SRV6_BEHAVIOR_END_DT6 = 18; + SRV6_BEHAVIOR_END_DT4 = 19; + SRV6_BEHAVIOR_END_DT46 = 20; + SRV6_BEHAVIOR_END_DX2 = 21; + SRV6_BEHAVIOR_END_DX2V = 22; + SRV6_BEHAVIOR_END_DT2U = 23; + SRV6_BEHAVIOR_END_DT2M = 24; + SRV6_BEHAVIOR_END_B6_ENCAPS_RED = 27; + SRV6_BEHAVIOR_END_WITH_USD = 28; + SRV6_BEHAVIOR_END_WITH_PSP_USD = 29; + SRV6_BEHAVIOR_END_WITH_USP_USD = 30; + SRV6_BEHAVIOR_END_WITH_PSP_USP_USD = 31; + SRV6_BEHAVIOR_ENDX_WITH_USD = 32; + SRV6_BEHAVIOR_ENDX_WITH_PSP_USD = 33; + SRV6_BEHAVIOR_ENDX_WITH_USP_USD = 34; + SRV6_BEHAVIOR_ENDX_WITH_PSP_USP_USD = 35; + SRV6_BEHAVIOR_ENDT_WITH_USD = 36; + SRV6_BEHAVIOR_ENDT_WITH_PSP_USD = 37; + SRV6_BEHAVIOR_ENDT_WITH_USP_USD = 38; + SRV6_BEHAVIOR_ENDT_WITH_PSP_USP_USD = 39; + SRV6_BEHAVIOR_ENDM_GTP6D = 69; // 0x0045 + SRV6_BEHAVIOR_ENDM_GTP6DI = 70; // 0x0046 + SRV6_BEHAVIOR_ENDM_GTP6E = 71; // 0x0047 + SRV6_BEHAVIOR_ENDM_GTP4E = 72; // 0x0048 +} + +message SRv6EndPointBehavior { + SRV6Behavior behavior = 1; + uint32 block_len = 2; + uint32 node_len = 3; + uint32 func_len = 4; + uint32 arg_len = 5; +} + +message SRv6BindingSID { + bool s_flag = 1; + bool i_flag = 2; + bool b_flag = 3; + bytes sid = 4; + SRv6EndPointBehavior endpoint_behavior_structure = 5; +} + +enum ENLPType { + ENLP_TYPE_UNSPECIFIED = 0; + ENLP_TYPE_TYPE1 = 1; + ENLP_TYPE_TYPE2 = 2; + ENLP_TYPE_TYPE3 = 3; + ENLP_TYPE_TYPE4 = 4; +} + +message TunnelEncapSubTLVSRENLP { + uint32 flags = 1; + ENLPType enlp = 2; +} + +message SRWeight { + uint32 flags = 1; + uint32 weight = 2; +} + +message SegmentFlags { + bool v_flag = 1; + bool a_flag = 2; + bool s_flag = 3; + bool b_flag = 4; +} + +message SegmentTypeA { + SegmentFlags flags = 1; + uint32 label = 2; +} + +message SegmentTypeB { + SegmentFlags flags = 1; + bytes sid = 2; + SRv6EndPointBehavior endpoint_behavior_structure = 3; +} + +message TunnelEncapSubTLVSRSegmentList { + SRWeight weight = 1; + + message Segment { + oneof segment { + SegmentTypeA a = 1; + SegmentTypeB b = 2; + } + } + repeated Segment segments = 2; +} + +message TunnelEncapSubTLVEgressEndpoint { + string address = 1; +} + +message TunnelEncapSubTLVUDPDestPort { + uint32 port = 1; +} + +message TunnelEncapSubTLVUnknown { + uint32 type = 1; + bytes value = 2; +} + +message TunnelEncapTLV { + uint32 type = 1; + message TLV { + oneof tlv { + TunnelEncapSubTLVUnknown unknown = 1; + TunnelEncapSubTLVEncapsulation encapsulation = 2; + TunnelEncapSubTLVProtocol protocol = 3; + TunnelEncapSubTLVColor color = 4; + TunnelEncapSubTLVEgressEndpoint egress_endpoint = 5; + TunnelEncapSubTLVUDPDestPort udp_dest_port = 6; + TunnelEncapSubTLVSRPreference sr_preference = 7; + TunnelEncapSubTLVSRPriority sr_priority = 8; + TunnelEncapSubTLVSRCandidatePathName sr_candidate_path_name = 9; + TunnelEncapSubTLVSRENLP sr_enlp = 10; + TunnelEncapSubTLVSRBindingSID sr_binding_sid = 11; + TunnelEncapSubTLVSRSegmentList sr_segment_list = 12; + } + } + repeated TLV tlvs = 2; +} + +message TunnelEncapAttribute { + repeated TunnelEncapTLV tlvs = 1; +} + +message IPv6AddressSpecificExtended { + bool is_transitive = 1; + uint32 sub_type = 2; + string address = 3; + uint32 local_admin = 4; +} + +message RedirectIPv6AddressSpecificExtended { + string address = 1; + uint32 local_admin = 2; +} + +message IP6ExtendedCommunitiesAttribute { + message Community { + oneof extcom { + IPv6AddressSpecificExtended ipv6_address_specific = 1; + RedirectIPv6AddressSpecificExtended redirect_ipv6_address_specific = 2; + } + } + repeated Community communities = 1; +} + +message AigpTLVIGPMetric { + uint64 metric = 1; +} + +message AigpTLVUnknown { + uint32 type = 1; + bytes value = 2; +} + +message AigpAttribute { + message TLV { + oneof tlv { + AigpTLVUnknown unknown = 1; + AigpTLVIGPMetric igp_metric = 2; + } + } + repeated TLV tlvs = 1; +} + +message LargeCommunity { + uint32 global_admin = 1; + uint32 local_data1 = 2; + uint32 local_data2 = 3; +} + +message LargeCommunitiesAttribute { + repeated LargeCommunity communities = 1; +} + +message LsNodeFlags { + bool overload = 1; + bool attached = 2; + bool external = 3; + bool abr = 4; + bool router = 5; + bool v6 = 6; +} + +message LsIGPFlags { + bool down = 1; + bool no_unicast = 2; + bool local_address = 3; + bool propagate_nssa = 4; +} + +message LsSrRange { + uint32 begin = 1; + uint32 end = 2; +} + +message LsSrCapabilities { + bool ipv4_supported = 1; + bool ipv6_supported = 2; + repeated LsSrRange ranges = 3; +} + +message LsSrLocalBlock { + repeated LsSrRange ranges = 1; +} + +message LsAttributeNode { + string name = 1; + LsNodeFlags flags = 2; + string local_router_id = 3; + string local_router_id_v6 = 4; + bytes isis_area = 5; + bytes opaque = 6; + + LsSrCapabilities sr_capabilities = 7; + bytes sr_algorithms = 8; + LsSrLocalBlock sr_local_block = 9; +} + +message LsAttributeLink { + string name = 1; + string local_router_id = 2; + string local_router_id_v6 = 3; + string remote_router_id = 4; + string remote_router_id_v6 = 5; + uint32 admin_group = 6; + uint32 default_te_metric = 7; + uint32 igp_metric = 8; + bytes opaque = 9; + + float bandwidth = 10; + float reservable_bandwidth = 11; + repeated float unreserved_bandwidth = 12; + + uint32 sr_adjacency_sid = 13; + repeated uint32 srlgs = 14; + LsSrv6EndXSID srv6_end_x_sid = 15; +} + +message LsAttributePrefix { + LsIGPFlags igp_flags = 1; + bytes opaque = 2; + + uint32 sr_prefix_sid = 3; +} + +message LsBgpPeerSegmentSIDFlags { + bool value = 1; + bool local = 2; + bool backup = 3; + bool persistent = 4; +} + +message LsBgpPeerSegmentSID { + LsBgpPeerSegmentSIDFlags flags = 1; + uint32 weight = 2; + uint32 sid = 3; +} + +message LsAttributeBgpPeerSegment { + LsBgpPeerSegmentSID bgp_peer_node_sid = 1; + LsBgpPeerSegmentSID bgp_peer_adjacency_sid = 2; + LsBgpPeerSegmentSID bgp_peer_set_sid = 3; +} + +message LsSrv6EndXSID { + uint32 endpoint_behavior = 1; + uint32 flags = 2; + uint32 algorithm = 3; + uint32 weight = 4; + uint32 reserved = 5; + repeated string sids = 6; + LsSrv6SIDStructure srv6_sid_structure = 7; +} + +message LsSrv6SIDStructure { + uint32 local_block = 1; + uint32 local_node = 2; + uint32 local_func = 3; + uint32 local_arg = 4; +} + +message LsSrv6EndpointBehavior { + uint32 endpoint_behavior = 1; + uint32 flags = 2; + uint32 algorithm = 3; +} + +message LsSrv6BgpPeerNodeSID { + uint32 flags = 1; + uint32 weight = 2; + uint32 peer_as = 3; + string peer_bgp_id = 4; +} + +message LsAttributeSrv6SID { + LsSrv6SIDStructure srv6_sid_structure = 1; + LsSrv6EndpointBehavior srv6_endpoint_behavior = 2; + LsSrv6BgpPeerNodeSID srv6_bgp_peer_node_sid = 3; +} + +message LsAttribute { + LsAttributeNode node = 1; + LsAttributeLink link = 2; + LsAttributePrefix prefix = 3; + LsAttributeBgpPeerSegment bgp_peer_segment = 4; + LsAttributeSrv6SID srv6_sid = 5; +} + +message UnknownAttribute { + uint32 flags = 1; + uint32 type = 2; + bytes value = 3; +} + +// https://www.rfc-editor.org/rfc/rfc9252.html#section-3.2.1 +message SRv6StructureSubSubTLV { + uint32 locator_block_length = 1; + uint32 locator_node_length = 2; + uint32 function_length = 3; + uint32 argument_length = 4; + uint32 transposition_length = 5; + uint32 transposition_offset = 6; +} + +message SRv6SubSubTLV { + oneof tlv { + SRv6StructureSubSubTLV structure = 1; + } +} + +message SRv6SubSubTLVs { + repeated SRv6SubSubTLV tlvs = 1; +} + +message SRv6SIDFlags { + // Placeholder for future sid flags + bool flag_1 = 1; +} + +// https://tools.ietf.org/html/draft-dawra-bess-srv6-services-02#section-2.1.1 +message SRv6InformationSubTLV { + bytes sid = 1; + SRv6SIDFlags flags = 2; + uint32 endpoint_behavior = 3; + map sub_sub_tlvs = 4; +} + +message SRv6SubTLV { + oneof tlv { + SRv6InformationSubTLV information = 1; + } +} + +message SRv6SubTLVs { + repeated SRv6SubTLV tlvs = 1; +} + +// https://www.rfc-editor.org/rfc/rfc9252.html#section-2 +message SRv6L3ServiceTLV { + map sub_tlvs = 1; +} + +// https://www.rfc-editor.org/rfc/rfc9252.html#section-2 +message SRv6L2ServiceTLV { + map sub_tlvs = 1; +} + +// https://tools.ietf.org/html/rfc8669 +message PrefixSID { + // tlv is one of: + message TLV { + oneof tlv { + // IndexLabelTLV Type 1 (not yet implemented) + // OriginatorSRGBTLV Type 3 (not yet implemented) + SRv6L3ServiceTLV l3_service = 3; + SRv6L2ServiceTLV l2_service = 4; + } + } + repeated TLV tlvs = 1; +} diff --git a/crates/bgp/proto/capability.proto b/crates/bgp/proto/capability.proto new file mode 100644 index 0000000..4b50147 --- /dev/null +++ b/crates/bgp/proto/capability.proto @@ -0,0 +1,124 @@ +// Copyright (C) 2018 Nippon Telegraph and Telephone Corporation. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, +// and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +syntax = "proto3"; + +package api; + +import "common.proto"; + +option go_package = "github.com/osrg/gobgp/v4/api;api"; + +message Capability { + oneof cap { + UnknownCapability unknown = 1; + MultiProtocolCapability multi_protocol = 2; + RouteRefreshCapability route_refresh = 3; + CarryingLabelInfoCapability carrying_label_info = 4; + ExtendedNexthopCapability extended_nexthop = 5; + GracefulRestartCapability graceful_restart = 6; + FourOctetASNCapability four_octet_asn = 7; + AddPathCapability add_path = 8; + EnhancedRouteRefreshCapability enhanced_route_refresh = 9; + LongLivedGracefulRestartCapability long_lived_graceful_restart = 10; + RouteRefreshCiscoCapability route_refresh_cisco = 11; + FqdnCapability fqdn = 12; + SoftwareVersionCapability software_version = 13; + } +} + +message MultiProtocolCapability { + api.Family family = 1; +} + +message RouteRefreshCapability {} + +message CarryingLabelInfoCapability {} + +message ExtendedNexthopCapabilityTuple { + api.Family nlri_family = 1; + // Nexthop AFI must be either + // gobgp.IPv4 or + // gobgp.IPv6. + api.Family nexthop_family = 2; +} + +message ExtendedNexthopCapability { + repeated ExtendedNexthopCapabilityTuple tuples = 1; +} + +message GracefulRestartCapabilityTuple { + api.Family family = 1; + uint32 flags = 2; +} + +message GracefulRestartCapability { + uint32 flags = 1; + uint32 time = 2; + repeated GracefulRestartCapabilityTuple tuples = 3; +} + +message FourOctetASNCapability { + uint32 asn = 1; +} + +message AddPathCapabilityTuple { + api.Family family = 1; + enum Mode { + MODE_UNSPECIFIED = 0; // NONE + MODE_RECEIVE = 1; + MODE_SEND = 2; + MODE_BOTH = 3; + } + Mode mode = 2; +} + +message AddPathCapability { + repeated AddPathCapabilityTuple tuples = 1; +} + +message EnhancedRouteRefreshCapability {} + +message LongLivedGracefulRestartCapabilityTuple { + api.Family family = 1; + uint32 flags = 2; + uint32 time = 3; +} + +message LongLivedGracefulRestartCapability { + repeated LongLivedGracefulRestartCapabilityTuple tuples = 1; +} + +message RouteRefreshCiscoCapability {} + +message FqdnCapability { + string host_name = 1; + string domain_name = 2; +} + +message SoftwareVersionCapability { + string software_version = 1; +} + +message UnknownCapability { + uint32 code = 1; + bytes value = 2; +} diff --git a/crates/bgp/proto/common.proto b/crates/bgp/proto/common.proto new file mode 100644 index 0000000..b4aab51 --- /dev/null +++ b/crates/bgp/proto/common.proto @@ -0,0 +1,63 @@ +syntax = "proto3"; + +package api; + +option go_package = "github.com/osrg/gobgp/v4/api;api"; + +// Common types for pretty much everywhere + +message Family { + enum Afi { + AFI_UNSPECIFIED = 0; + AFI_IP = 1; + AFI_IP6 = 2; + AFI_L2VPN = 25; + AFI_LS = 16388; + AFI_OPAQUE = 16397; + } + + enum Safi { + SAFI_UNSPECIFIED = 0; + SAFI_UNICAST = 1; + SAFI_MULTICAST = 2; + SAFI_MPLS_LABEL = 4; + SAFI_ENCAPSULATION = 7; + SAFI_VPLS = 65; + SAFI_EVPN = 70; + SAFI_LS = 71; + SAFI_SR_POLICY = 73; + SAFI_MUP = 85; + SAFI_MPLS_VPN = 128; + SAFI_MPLS_VPN_MULTICAST = 129; + SAFI_ROUTE_TARGET_CONSTRAINTS = 132; + SAFI_FLOW_SPEC_UNICAST = 133; + SAFI_FLOW_SPEC_VPN = 134; + SAFI_KEY_VALUE = 241; + } + + Afi afi = 1; + Safi safi = 2; +} + +message RouteDistinguisherTwoOctetASN { + uint32 admin = 1; + uint32 assigned = 2; +} + +message RouteDistinguisherIPAddress { + string admin = 1; + uint32 assigned = 2; +} + +message RouteDistinguisherFourOctetASN { + uint32 admin = 1; + uint32 assigned = 2; +} + +message RouteDistinguisher { + oneof rd { + RouteDistinguisherTwoOctetASN two_octet_asn = 1; + RouteDistinguisherIPAddress ip_address = 2; + RouteDistinguisherFourOctetASN four_octet_asn = 3; + } +} diff --git a/crates/bgp/proto/extcom.proto b/crates/bgp/proto/extcom.proto new file mode 100644 index 0000000..16c7f20 --- /dev/null +++ b/crates/bgp/proto/extcom.proto @@ -0,0 +1,162 @@ +syntax = "proto3"; + +package api; + +option go_package = "github.com/osrg/gobgp/v4/api;api"; + +// BGP Extended communities + +message TwoOctetAsSpecificExtended { + bool is_transitive = 1; + uint32 sub_type = 2; + uint32 asn = 3; + uint32 local_admin = 4; +} + +message IPv4AddressSpecificExtended { + bool is_transitive = 1; + uint32 sub_type = 2; + string address = 3; + uint32 local_admin = 4; +} + +message FourOctetAsSpecificExtended { + bool is_transitive = 1; + uint32 sub_type = 2; + uint32 asn = 3; + uint32 local_admin = 4; +} + +message LinkBandwidthExtended { + uint32 asn = 1; + float bandwidth = 2; +} + +message ValidationExtended { + uint32 state = 1; +} + +message ColorExtended { + uint32 color = 1; +} + +message EncapExtended { + uint32 tunnel_type = 1; +} + +message DefaultGatewayExtended {} + +message OpaqueExtended { + bool is_transitive = 1; + bytes value = 3; +} + +message ESILabelExtended { + bool is_single_active = 1; + uint32 label = 2; +} + +message ESImportRouteTarget { + string es_import = 1; +} + +message MacMobilityExtended { + bool is_sticky = 1; + uint32 sequence_num = 2; +} + +message RouterMacExtended { + string mac = 1; +} + +message TrafficRateExtended { + uint32 asn = 1; + float rate = 2; +} + +message TrafficActionExtended { + bool terminal = 1; + bool sample = 2; +} + +message RedirectTwoOctetAsSpecificExtended { + uint32 asn = 1; + uint32 local_admin = 2; +} + +message RedirectIPv4AddressSpecificExtended { + string address = 1; + uint32 local_admin = 2; +} + +message RedirectFourOctetAsSpecificExtended { + uint32 asn = 1; + uint32 local_admin = 2; +} + +message TrafficRemarkExtended { + uint32 dscp = 1; +} + +message MUPExtended { + uint32 sub_type = 1; + uint32 segment_id2 = 2; + uint32 segment_id4 = 3; +} + +message VPLSExtended { + uint32 control_flags = 1; + uint32 mtu = 2; +} + +message ETreeExtended { + bool is_leaf = 1; + uint32 label = 2; +} + +message MulticastFlagsExtended { + bool is_igmp_proxy = 1; + bool is_mld_proxy = 2; +} + +message UnknownExtended { + uint32 type = 1; + bytes value = 2; +} + +message ExtendedCommunity { + oneof extcom { + UnknownExtended unknown = 1; + TwoOctetAsSpecificExtended two_octet_as_specific = 2; + IPv4AddressSpecificExtended ipv4_address_specific = 3; + FourOctetAsSpecificExtended four_octet_as_specific = 4; + LinkBandwidthExtended link_bandwidth = 5; + ValidationExtended validation = 6; + ColorExtended color = 7; + EncapExtended encap = 8; + DefaultGatewayExtended default_gateway = 9; + OpaqueExtended opaque = 10; + ESILabelExtended esi_label = 11; + ESImportRouteTarget es_import = 12; + MacMobilityExtended mac_mobility = 13; + RouterMacExtended router_mac = 14; + TrafficRateExtended traffic_rate = 15; + TrafficActionExtended traffic_action = 16; + RedirectTwoOctetAsSpecificExtended redirect_two_octet_as_specific = 17; + RedirectIPv4AddressSpecificExtended redirect_ipv4_address_specific = 18; + RedirectFourOctetAsSpecificExtended redirect_four_octet_as_specific = 19; + TrafficRemarkExtended traffic_remark = 20; + MUPExtended mup = 21; + VPLSExtended vpls = 22; + ETreeExtended etree = 23; + MulticastFlagsExtended multicast_flags = 24; + } +} + +message RouteTarget { + oneof rt { + TwoOctetAsSpecificExtended two_octet_as_specific = 1; + IPv4AddressSpecificExtended ipv4_address_specific = 2; + FourOctetAsSpecificExtended four_octet_as_specific = 3; + } +} diff --git a/crates/bgp/proto/gobgp.proto b/crates/bgp/proto/gobgp.proto new file mode 100644 index 0000000..3f0ade2 --- /dev/null +++ b/crates/bgp/proto/gobgp.proto @@ -0,0 +1,1379 @@ +// Copyright (C) 2015-2017 Nippon Telegraph and Telephone Corporation. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, +// and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +syntax = "proto3"; + +package api; + +import "attribute.proto"; +import "capability.proto"; +import "common.proto"; +import "extcom.proto"; +import "nlri.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/osrg/gobgp/v4/api;api"; + +// Interface exported by the server. +service GoBgpService { + rpc StartBgp(StartBgpRequest) returns (StartBgpResponse); + rpc StopBgp(StopBgpRequest) returns (StopBgpResponse); + rpc GetBgp(GetBgpRequest) returns (GetBgpResponse); + + rpc WatchEvent(WatchEventRequest) returns (stream WatchEventResponse); + + rpc AddPeer(AddPeerRequest) returns (AddPeerResponse); + rpc DeletePeer(DeletePeerRequest) returns (DeletePeerResponse); + rpc ListPeer(ListPeerRequest) returns (stream ListPeerResponse); + rpc UpdatePeer(UpdatePeerRequest) returns (UpdatePeerResponse); + rpc ResetPeer(ResetPeerRequest) returns (ResetPeerResponse); + rpc ShutdownPeer(ShutdownPeerRequest) returns (ShutdownPeerResponse); + rpc EnablePeer(EnablePeerRequest) returns (EnablePeerResponse); + rpc DisablePeer(DisablePeerRequest) returns (DisablePeerResponse); + + rpc AddPeerGroup(AddPeerGroupRequest) returns (AddPeerGroupResponse); + rpc DeletePeerGroup(DeletePeerGroupRequest) returns (DeletePeerGroupResponse); + rpc ListPeerGroup(ListPeerGroupRequest) returns (stream ListPeerGroupResponse); + rpc UpdatePeerGroup(UpdatePeerGroupRequest) returns (UpdatePeerGroupResponse); + + rpc AddDynamicNeighbor(AddDynamicNeighborRequest) returns (AddDynamicNeighborResponse); + rpc ListDynamicNeighbor(ListDynamicNeighborRequest) returns (stream ListDynamicNeighborResponse); + rpc DeleteDynamicNeighbor(DeleteDynamicNeighborRequest) returns (DeleteDynamicNeighborResponse); + + rpc AddPath(AddPathRequest) returns (AddPathResponse); + rpc DeletePath(DeletePathRequest) returns (DeletePathResponse); + rpc ListPath(ListPathRequest) returns (stream ListPathResponse); + rpc AddPathStream(stream AddPathStreamRequest) returns (AddPathStreamResponse); + + rpc GetTable(GetTableRequest) returns (GetTableResponse); + + rpc AddVrf(AddVrfRequest) returns (AddVrfResponse); + rpc DeleteVrf(DeleteVrfRequest) returns (DeleteVrfResponse); + rpc ListVrf(ListVrfRequest) returns (stream ListVrfResponse); + + rpc AddPolicy(AddPolicyRequest) returns (AddPolicyResponse); + rpc DeletePolicy(DeletePolicyRequest) returns (DeletePolicyResponse); + rpc ListPolicy(ListPolicyRequest) returns (stream ListPolicyResponse); + rpc SetPolicies(SetPoliciesRequest) returns (SetPoliciesResponse); + + rpc AddDefinedSet(AddDefinedSetRequest) returns (AddDefinedSetResponse); + rpc DeleteDefinedSet(DeleteDefinedSetRequest) returns (DeleteDefinedSetResponse); + rpc ListDefinedSet(ListDefinedSetRequest) returns (stream ListDefinedSetResponse); + + rpc AddStatement(AddStatementRequest) returns (AddStatementResponse); + rpc DeleteStatement(DeleteStatementRequest) returns (DeleteStatementResponse); + rpc ListStatement(ListStatementRequest) returns (stream ListStatementResponse); + + rpc AddPolicyAssignment(AddPolicyAssignmentRequest) returns (AddPolicyAssignmentResponse); + rpc DeletePolicyAssignment(DeletePolicyAssignmentRequest) returns (DeletePolicyAssignmentResponse); + rpc ListPolicyAssignment(ListPolicyAssignmentRequest) returns (stream ListPolicyAssignmentResponse); + rpc SetPolicyAssignment(SetPolicyAssignmentRequest) returns (SetPolicyAssignmentResponse); + + rpc AddRpki(AddRpkiRequest) returns (AddRpkiResponse); + rpc DeleteRpki(DeleteRpkiRequest) returns (DeleteRpkiResponse); + rpc ListRpki(ListRpkiRequest) returns (stream ListRpkiResponse); + rpc EnableRpki(EnableRpkiRequest) returns (EnableRpkiResponse); + rpc DisableRpki(DisableRpkiRequest) returns (DisableRpkiResponse); + rpc ResetRpki(ResetRpkiRequest) returns (ResetRpkiResponse); + rpc ListRpkiTable(ListRpkiTableRequest) returns (stream ListRpkiTableResponse); + + rpc EnableZebra(EnableZebraRequest) returns (EnableZebraResponse); + + rpc EnableMrt(EnableMrtRequest) returns (EnableMrtResponse); + rpc DisableMrt(DisableMrtRequest) returns (DisableMrtResponse); + + rpc AddBmp(AddBmpRequest) returns (AddBmpResponse); + rpc DeleteBmp(DeleteBmpRequest) returns (DeleteBmpResponse); + rpc ListBmp(ListBmpRequest) returns (stream ListBmpResponse); + + rpc SetLogLevel(SetLogLevelRequest) returns (SetLogLevelResponse); +} + +message StartBgpRequest { + Global global = 1; +} + +message StartBgpResponse {} + +message StopBgpRequest { + // Allows the Graceful Restart procedure on the remote peers by not sending a NOTIFICATION message to GR-enabled peers. + bool allow_graceful_restart = 1; +} + +message StopBgpResponse {} + +message GetBgpRequest {} + +message GetBgpResponse { + Global global = 1; +} + +message WatchEventRequest { + message Peer {} + Peer peer = 1; + + message Table { + message Filter { + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_BEST = 1; + TYPE_ADJIN = 2; + TYPE_POST_POLICY = 3; + TYPE_EOR = 4; + } + Type type = 1; + bool init = 2; + string peer_address = 3; + string peer_group = 4; + } + repeated Filter filters = 1; + } + Table table = 2; + + // Max number of paths to include in a single message. 0 for unlimited. + uint32 batch_size = 3; +} + +message WatchEventResponse { + message PeerEvent { + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_INIT = 1; + TYPE_END_OF_INIT = 2; + TYPE_STATE = 3; + } + Type type = 1; + Peer peer = 2; + } + + message TableEvent { + repeated Path paths = 2; + } + + oneof event { + PeerEvent peer = 2; + TableEvent table = 3; + } +} + +message AddPeerRequest { + Peer peer = 1; +} + +message AddPeerResponse {} + +message DeletePeerRequest { + string address = 1; + string interface = 2; +} + +message DeletePeerResponse {} + +message ListPeerRequest { + string address = 1; + bool enable_advertised = 2; +} + +message ListPeerResponse { + Peer peer = 1; +} + +message UpdatePeerRequest { + Peer peer = 1; + // Calls SoftResetIn after updating the peer configuration if needed. + bool do_soft_reset_in = 2; +} + +message UpdatePeerResponse { + // Indicates whether calling SoftResetIn is required due to this update. If + // "true" is set, the client should call SoftResetIn manually. If + // "do_soft_reset_in = true" is set in the request, always returned with + // "false". + bool needs_soft_reset_in = 1; +} + +message ResetPeerRequest { + string address = 1; + string communication = 2; + bool soft = 3; + enum Direction { + DIRECTION_UNSPECIFIED = 0; + DIRECTION_IN = 1; + DIRECTION_OUT = 2; + DIRECTION_BOTH = 3; + } + Direction direction = 4; +} + +message ResetPeerResponse {} + +message ShutdownPeerRequest { + string address = 1; + string communication = 2; +} + +message ShutdownPeerResponse {} + +message EnablePeerRequest { + string address = 1; +} + +message EnablePeerResponse {} + +message DisablePeerRequest { + string address = 1; + string communication = 2; +} + +message DisablePeerResponse {} + +message AddPeerGroupRequest { + PeerGroup peer_group = 1; +} + +message AddPeerGroupResponse {} + +message DeletePeerGroupRequest { + string name = 1; +} + +message DeletePeerGroupResponse {} + +message UpdatePeerGroupRequest { + PeerGroup peer_group = 1; + bool do_soft_reset_in = 2; +} + +message UpdatePeerGroupResponse { + bool needs_soft_reset_in = 1; +} + +message ListPeerGroupRequest { + string peer_group_name = 1; +} + +message ListPeerGroupResponse { + PeerGroup peer_group = 1; +} + +message AddDynamicNeighborRequest { + DynamicNeighbor dynamic_neighbor = 1; +} + +message AddDynamicNeighborResponse {} + +message DeleteDynamicNeighborRequest { + string prefix = 1; + string peer_group = 2; +} + +message DeleteDynamicNeighborResponse {} + +message ListDynamicNeighborRequest { + string peer_group = 1; +} + +message ListDynamicNeighborResponse { + DynamicNeighbor dynamic_neighbor = 1; +} + +message AddPathRequest { + TableType table_type = 1; + string vrf_id = 2; + Path path = 3; +} + +message AddPathResponse { + bytes uuid = 1; +} + +message DeletePathRequest { + TableType table_type = 1; + string vrf_id = 2; + Family family = 3; + Path path = 4; + bytes uuid = 5; +} + +message DeletePathResponse {} + +// API representation of table.LookupPrefix +message TableLookupPrefix { + // API representation of table.LookupOption + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_EXACT = 1; + TYPE_LONGER = 2; + TYPE_SHORTER = 3; + } + string prefix = 1; + Type type = 2; + string rd = 3; +} + +message ListPathRequest { + TableType table_type = 1; + string name = 2; + Family family = 3; + repeated TableLookupPrefix prefixes = 4; + enum SortType { + SORT_TYPE_UNSPECIFIED = 0; + SORT_TYPE_PREFIX = 1; + } + SortType sort_type = 5; + bool enable_filtered = 6; + bool enable_nlri_binary = 7; + bool enable_attribute_binary = 8; + // enable_only_binary == true means that only nlri_binary and pattrs_binary + // will be used instead of nlri and pattrs for each Path in ListPathResponse. + bool enable_only_binary = 9; + // max ammount of paths to be allocated, unlimited by default + uint64 batch_size = 10; +} + +message ListPathResponse { + Destination destination = 1; +} + +message AddPathStreamRequest { + TableType table_type = 1; + string vrf_id = 2; + repeated Path paths = 3; +} + +message AddPathStreamResponse {} + +message GetTableRequest { + TableType table_type = 1; + Family family = 2; + string name = 3; +} + +message GetTableResponse { + uint64 num_destination = 1; + uint64 num_path = 2; + uint64 num_accepted = 3; // only meaningful when type == ADJ_IN +} + +message AddVrfRequest { + Vrf vrf = 1; +} + +message AddVrfResponse {} + +message DeleteVrfRequest { + string name = 1; +} + +message DeleteVrfResponse {} + +message ListVrfRequest { + string name = 1; +} + +message ListVrfResponse { + Vrf vrf = 1; +} + +message AddPolicyRequest { + Policy policy = 1; + // if this flag is set, gobgpd won't define new statements + // but refer existing statements using statement's names in this arguments. + bool refer_existing_statements = 2; +} + +message AddPolicyResponse {} + +message DeletePolicyRequest { + Policy policy = 1; + // if this flag is set, gobgpd won't delete any statements + // even if some statements get not used by any policy by this operation. + bool preserve_statements = 2; + bool all = 3; +} + +message DeletePolicyResponse {} + +message ListPolicyRequest { + string name = 1; +} + +message ListPolicyResponse { + Policy policy = 1; +} + +message SetPoliciesRequest { + repeated DefinedSet defined_sets = 1; + repeated Policy policies = 2; + repeated PolicyAssignment assignments = 3; +} + +message SetPoliciesResponse {} + +message AddDefinedSetRequest { + DefinedSet defined_set = 1; + bool replace = 2; +} + +message AddDefinedSetResponse {} + +message DeleteDefinedSetRequest { + DefinedSet defined_set = 1; + bool all = 2; +} + +message DeleteDefinedSetResponse {} + +message ListDefinedSetRequest { + DefinedType defined_type = 1; + string name = 2; +} + +message ListDefinedSetResponse { + DefinedSet defined_set = 1; +} + +message AddStatementRequest { + Statement statement = 1; +} + +message AddStatementResponse {} + +message DeleteStatementRequest { + Statement statement = 1; + bool all = 2; +} + +message DeleteStatementResponse {} + +message ListStatementRequest { + string name = 1; +} + +message ListStatementResponse { + Statement statement = 1; +} + +message AddPolicyAssignmentRequest { + PolicyAssignment assignment = 1; +} + +message AddPolicyAssignmentResponse {} + +message DeletePolicyAssignmentRequest { + PolicyAssignment assignment = 1; + bool all = 2; +} + +message DeletePolicyAssignmentResponse {} + +message ListPolicyAssignmentRequest { + string name = 1; + PolicyDirection direction = 2; +} + +message ListPolicyAssignmentResponse { + PolicyAssignment assignment = 1; +} + +message SetPolicyAssignmentRequest { + PolicyAssignment assignment = 1; +} + +message SetPolicyAssignmentResponse {} + +message AddRpkiRequest { + string address = 1; + uint32 port = 2; + int64 lifetime = 3; +} + +message AddRpkiResponse {} + +message DeleteRpkiRequest { + string address = 1; + uint32 port = 2; +} + +message DeleteRpkiResponse {} + +message ListRpkiRequest { + Family family = 1; +} + +message ListRpkiResponse { + Rpki server = 1; +} + +message EnableRpkiRequest { + string address = 1; + uint32 port = 2; +} + +message EnableRpkiResponse {} + +message DisableRpkiRequest { + string address = 1; + uint32 port = 2; +} + +message DisableRpkiResponse {} + +message ResetRpkiRequest { + string address = 1; + uint32 port = 2; + bool soft = 3; +} + +message ResetRpkiResponse {} + +message ListRpkiTableRequest { + Family family = 1; +} + +message ListRpkiTableResponse { + Roa roa = 1; +} + +message EnableZebraRequest { + string url = 1; + repeated string route_types = 2; + uint32 version = 3; + bool nexthop_trigger_enable = 4; + uint32 nexthop_trigger_delay = 5; + uint32 mpls_label_range_size = 6; + string software_name = 7; +} + +message EnableZebraResponse {} + +message EnableMrtRequest { + enum DumpType { + DUMP_TYPE_UNSPECIFIED = 0; + DUMP_TYPE_UPDATES = 1; + DUMP_TYPE_TABLE = 2; + } + DumpType dump_type = 1; + string filename = 2; + uint64 dump_interval = 3; + uint64 rotation_interval = 4; +} + +message EnableMrtResponse {} + +message DisableMrtRequest { + string filename = 1; +} + +message DisableMrtResponse {} + +message AddBmpRequest { + string address = 1; + uint32 port = 2; + enum MonitoringPolicy { + MONITORING_POLICY_UNSPECIFIED = 0; + MONITORING_POLICY_PRE = 1; + MONITORING_POLICY_POST = 2; + MONITORING_POLICY_BOTH = 3; + MONITORING_POLICY_LOCAL = 4; + MONITORING_POLICY_ALL = 5; + } + MonitoringPolicy policy = 3; + int32 statistics_timeout = 4; + string sys_name = 5; + string sys_descr = 6; +} + +message AddBmpResponse {} + +message DeleteBmpRequest { + string address = 1; + uint32 port = 2; +} + +message DeleteBmpResponse {} + +message ListBmpRequest {} + +message ListBmpResponse { + message BmpStation { + message Conf { + string address = 1; + uint32 port = 2; + } + Conf conf = 1; + message State { + google.protobuf.Timestamp uptime = 1; + google.protobuf.Timestamp downtime = 2; + } + State state = 2; + } + + BmpStation station = 1; +} + +enum TableType { + TABLE_TYPE_UNSPECIFIED = 0; + TABLE_TYPE_GLOBAL = 1; + TABLE_TYPE_LOCAL = 2; + TABLE_TYPE_ADJ_IN = 3; + TABLE_TYPE_ADJ_OUT = 4; + TABLE_TYPE_VRF = 5; +} + +enum ValidationState { + VALIDATION_STATE_UNSPECIFIED = 0; + VALIDATION_STATE_NONE = 1; + VALIDATION_STATE_NOT_FOUND = 2; + VALIDATION_STATE_VALID = 3; + VALIDATION_STATE_INVALID = 4; +} + +message Validation { + enum Reason { + REASON_UNSPECIFIED = 0; + REASON_NONE = 1; + REASON_ASN = 2; + REASON_LENGTH = 3; + } + + ValidationState state = 1; + Reason reason = 2; + repeated Roa matched = 3; + repeated Roa unmatched_asn = 4; + repeated Roa unmatched_length = 5; +} + +message Path { + NLRI nlri = 1; + repeated Attribute pattrs = 2; + google.protobuf.Timestamp age = 3; + bool best = 4; + bool is_withdraw = 5; + Validation validation = 7; + bool no_implicit_withdraw = 8; + Family family = 9; + uint32 source_asn = 10; + string source_id = 11; + bool filtered = 12; + bool stale = 13; + bool is_from_external = 14; + string neighbor_ip = 15; + bytes uuid = 16; // only paths installed by AddPath API have this + bool is_nexthop_invalid = 17; + uint32 identifier = 18; + uint32 local_identifier = 19; + bytes nlri_binary = 20; + repeated bytes pattrs_binary = 21; + bool send_max_filtered = 22; +} + +message Destination { + string prefix = 1; + repeated Path paths = 2; +} + +message Peer { + ApplyPolicy apply_policy = 1; + PeerConf conf = 2; + EbgpMultihop ebgp_multihop = 3; + RouteReflector route_reflector = 4; + PeerState state = 5; + Timers timers = 6; + Transport transport = 7; + RouteServer route_server = 8; + GracefulRestart graceful_restart = 9; + repeated AfiSafi afi_safis = 10; + TtlSecurity ttl_security = 11; +} + +message PeerGroup { + ApplyPolicy apply_policy = 1; + PeerGroupConf conf = 2; + EbgpMultihop ebgp_multihop = 3; + RouteReflector route_reflector = 4; + PeerGroupState info = 5; + Timers timers = 6; + Transport transport = 7; + RouteServer route_server = 8; + GracefulRestart graceful_restart = 9; + repeated AfiSafi afi_safis = 10; + TtlSecurity ttl_security = 11; +} + +message DynamicNeighbor { + string prefix = 1; + string peer_group = 2; +} + +message ApplyPolicy { + PolicyAssignment export_policy = 1; + PolicyAssignment import_policy = 2; +} + +message PrefixLimit { + Family family = 1; + uint32 max_prefixes = 2; + uint32 shutdown_threshold_pct = 3; +} + +enum PeerType { + PEER_TYPE_UNSPECIFIED = 0; + PEER_TYPE_INTERNAL = 1; + PEER_TYPE_EXTERNAL = 2; +} + +enum RemovePrivate { + REMOVE_PRIVATE_UNSPECIFIED = 0; + REMOVE_PRIVATE_ALL = 1; + REMOVE_PRIVATE_REPLACE = 2; +} + +message PeerConf { + string auth_password = 1; + string description = 2; + uint32 local_asn = 3; + string neighbor_address = 4; + uint32 peer_asn = 5; + string peer_group = 6; + PeerType type = 7; + RemovePrivate remove_private = 8; + bool route_flap_damping = 9; + uint32 send_community = 10; + string neighbor_interface = 11; + string vrf = 12; + uint32 allow_own_asn = 13; + bool replace_peer_asn = 14; + bool admin_down = 15; + bool send_software_version = 16; + bool allow_aspath_loop_local = 17; +} + +message PeerGroupConf { + string auth_password = 1; + string description = 2; + uint32 local_asn = 3; + uint32 peer_asn = 4; + string peer_group_name = 5; + PeerType type = 6; + RemovePrivate remove_private = 7; + bool route_flap_damping = 8; + uint32 send_community = 9; + bool send_software_version = 10; +} + +message PeerGroupState { + string auth_password = 1; + string description = 2; + uint32 local_asn = 3; + uint32 peer_asn = 4; + string peer_group_name = 5; + PeerType type = 6; + RemovePrivate remove_private = 7; + bool route_flap_damping = 8; + uint32 send_community = 9; + uint32 total_paths = 10; + uint32 total_prefixes = 11; +} + +message TtlSecurity { + bool enabled = 1; + uint32 ttl_min = 2; +} + +message EbgpMultihop { + bool enabled = 1; + uint32 multihop_ttl = 2; +} + +message RouteReflector { + bool route_reflector_client = 1; + string route_reflector_cluster_id = 2; +} + +message PeerState { + string auth_password = 1; + string description = 2; + uint32 local_asn = 3; + Messages messages = 4; + string neighbor_address = 5; + uint32 peer_asn = 6; + string peer_group = 7; + PeerType type = 8; + Queues queues = 9; + RemovePrivate remove_private = 10; + bool route_flap_damping = 11; + uint32 send_community = 12; + enum SessionState { + SESSION_STATE_UNSPECIFIED = 0; + SESSION_STATE_IDLE = 1; + SESSION_STATE_CONNECT = 2; + SESSION_STATE_ACTIVE = 3; + SESSION_STATE_OPENSENT = 4; + SESSION_STATE_OPENCONFIRM = 5; + SESSION_STATE_ESTABLISHED = 6; + } + SessionState session_state = 13; + enum AdminState { + ADMIN_STATE_UNSPECIFIED = 0; + ADMIN_STATE_UP = 1; + ADMIN_STATE_DOWN = 2; + ADMIN_STATE_PFX_CT = 3; // prefix counter over limit + } + AdminState admin_state = 15; + uint32 out_q = 16; + uint32 flops = 17; + repeated Capability remote_cap = 18; + repeated Capability local_cap = 19; + string router_id = 20; + // State change reason information + enum DisconnectReason { + DISCONNECT_REASON_UNSPECIFIED = 0; + DISCONNECT_REASON_ADMIN_DOWN = 1; + DISCONNECT_REASON_HOLD_TIMER_EXPIRED = 2; + DISCONNECT_REASON_NOTIFICATION_SENT = 3; + DISCONNECT_REASON_NOTIFICATION_RECEIVED = 4; + DISCONNECT_REASON_READ_FAILED = 5; + DISCONNECT_REASON_WRITE_FAILED = 6; + DISCONNECT_REASON_IDLE_TIMER_EXPIRED = 7; + DISCONNECT_REASON_RESTART_TIMER_EXPIRED = 8; + DISCONNECT_REASON_GRACEFUL_RESTART = 9; + DISCONNECT_REASON_INVALID_MSG = 10; + DISCONNECT_REASON_HARD_RESET = 11; + DISCONNECT_REASON_DECONFIGURED = 12; + DISCONNECT_REASON_BAD_PEER_AS = 13; + } + DisconnectReason disconnect_reason = 21; + string disconnect_message = 22; +} + +message Messages { + Message received = 1; + Message sent = 2; +} + +message Message { + uint64 notification = 1; + uint64 update = 2; + uint64 open = 3; + uint64 keepalive = 4; + uint64 refresh = 5; + uint64 discarded = 6; + uint64 total = 7; + uint64 withdraw_update = 8; + uint64 withdraw_prefix = 9; +} + +message Queues { + uint32 input = 1; + uint32 output = 2; +} + +message Timers { + TimersConfig config = 1; + TimersState state = 2; +} + +message TimersConfig { + uint64 connect_retry = 1; + uint64 hold_time = 2; + uint64 keepalive_interval = 3; + uint64 minimum_advertisement_interval = 4; + uint64 idle_hold_time_after_reset = 5; +} + +message TimersState { + uint64 connect_retry = 1; + uint64 hold_time = 2; + uint64 keepalive_interval = 3; + uint64 minimum_advertisement_interval = 4; + uint64 negotiated_hold_time = 5; + google.protobuf.Timestamp uptime = 6; + google.protobuf.Timestamp downtime = 7; +} + +message Transport { + string local_address = 1; + uint32 local_port = 2; + bool mtu_discovery = 3; + bool passive_mode = 4; + string remote_address = 5; + uint32 remote_port = 6; + uint32 tcp_mss = 7; + string bind_interface = 8; +} + +message RouteServer { + bool route_server_client = 1; + bool secondary_route = 2; +} + +message GracefulRestart { + bool enabled = 1; + uint32 restart_time = 2; + bool helper_only = 3; + uint32 deferral_time = 4; + bool notification_enabled = 5; + bool longlived_enabled = 6; + uint32 stale_routes_time = 7; + uint32 peer_restart_time = 8; + bool peer_restarting = 9; + bool local_restarting = 10; + string mode = 11; +} + +message MpGracefulRestartConfig { + bool enabled = 1; +} + +message MpGracefulRestartState { + bool enabled = 1; + bool received = 2; + bool advertised = 3; + bool end_of_rib_received = 4; + bool end_of_rib_sent = 5; + bool running = 6; +} +message MpGracefulRestart { + MpGracefulRestartConfig config = 1; + MpGracefulRestartState state = 2; +} + +message AfiSafiConfig { + Family family = 1; + bool enabled = 2; +} + +message AfiSafiState { + Family family = 1; + bool enabled = 2; + uint64 received = 3; + uint64 accepted = 4; + uint64 advertised = 5; +} + +message RouteSelectionOptionsConfig { + bool always_compare_med = 1; + bool ignore_as_path_length = 2; + bool external_compare_router_id = 3; + bool advertise_inactive_routes = 4; + bool enable_aigp = 5; + bool ignore_next_hop_igp_metric = 6; + bool disable_best_path_selection = 7; +} + +message RouteSelectionOptionsState { + bool always_compare_med = 1; + bool ignore_as_path_length = 2; + bool external_compare_router_id = 3; + bool advertise_inactive_routes = 4; + bool enable_aigp = 5; + bool ignore_next_hop_igp_metric = 6; + bool disable_best_path_selection = 7; +} + +message RouteSelectionOptions { + RouteSelectionOptionsConfig config = 1; + RouteSelectionOptionsState state = 2; +} + +message UseMultiplePathsConfig { + bool enabled = 1; +} + +message UseMultiplePathsState { + bool enabled = 1; +} + +message EbgpConfig { + bool allow_multiple_asn = 1; + uint32 maximum_paths = 2; +} + +message EbgpState { + bool allow_multiple_asn = 1; + uint32 maximum_paths = 2; +} + +message Ebgp { + EbgpConfig config = 1; + EbgpState state = 2; +} + +message IbgpConfig { + uint32 maximum_paths = 1; +} + +message IbgpState { + uint32 maximum_paths = 1; +} + +message Ibgp { + IbgpConfig config = 1; + IbgpState state = 2; +} + +message UseMultiplePaths { + UseMultiplePathsConfig config = 1; + UseMultiplePathsState state = 2; + Ebgp ebgp = 3; + Ibgp ibgp = 4; +} + +message RouteTargetMembershipConfig { + uint32 deferral_time = 1; +} + +message RouteTargetMembershipState { + uint32 deferral_time = 1; +} + +message RouteTargetMembership { + RouteTargetMembershipConfig config = 1; + RouteTargetMembershipState state = 2; +} + +message LongLivedGracefulRestartConfig { + bool enabled = 1; + uint32 restart_time = 2; +} + +message LongLivedGracefulRestartState { + bool enabled = 1; + bool received = 2; + bool advertised = 3; + uint32 peer_restart_time = 4; + bool peer_restart_timer_expired = 5; + bool running = 6; +} + +message LongLivedGracefulRestart { + LongLivedGracefulRestartConfig config = 1; + LongLivedGracefulRestartState state = 2; +} + +message AfiSafi { + MpGracefulRestart mp_graceful_restart = 1; + AfiSafiConfig config = 2; + AfiSafiState state = 3; + ApplyPolicy apply_policy = 4; + // TODO: + // Support the following structures: + // - Ipv4Unicast + // - Ipv6Unicast + // - Ipv4LabelledUnicast + // - Ipv6LabelledUnicast + // - L3vpnIpv4Unicast + // - L3vpnIpv6Unicast + // - L3vpnIpv4Multicast + // - L3vpnIpv6Multicast + // - L2vpnVpls + // - L2vpnEvpn + RouteSelectionOptions route_selection_options = 5; + UseMultiplePaths use_multiple_paths = 6; + PrefixLimit prefix_limits = 7; + RouteTargetMembership route_target_membership = 8; + LongLivedGracefulRestart long_lived_graceful_restart = 9; + AddPaths add_paths = 10; +} + +message AddPathsConfig { + bool receive = 1; + uint32 send_max = 2; +} + +message AddPathsState { + bool receive = 1; + uint32 send_max = 2; +} + +message AddPaths { + AddPathsConfig config = 1; + AddPathsState state = 2; +} + +message Prefix { + string ip_prefix = 1; + uint32 mask_length_min = 2; + uint32 mask_length_max = 3; +} + +enum DefinedType { + DEFINED_TYPE_UNSPECIFIED = 0; + DEFINED_TYPE_PREFIX = 1; + DEFINED_TYPE_NEIGHBOR = 2; + DEFINED_TYPE_TAG = 3; + DEFINED_TYPE_AS_PATH = 4; + DEFINED_TYPE_COMMUNITY = 5; + DEFINED_TYPE_EXT_COMMUNITY = 6; + DEFINED_TYPE_LARGE_COMMUNITY = 7; + DEFINED_TYPE_NEXT_HOP = 8; +} + +message DefinedSet { + DefinedType defined_type = 1; + string name = 2; + repeated string list = 3; + repeated Prefix prefixes = 4; +} + +message MatchSet { + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_ANY = 1; + TYPE_ALL = 2; + TYPE_INVERT = 3; + } + Type type = 1; + string name = 2; +} + +enum Comparison { + COMPARISON_UNSPECIFIED = 0; + COMPARISON_EQ = 1; + COMPARISON_GE = 2; + COMPARISON_LE = 3; +} + +message AsPathLength { + Comparison type = 1; + uint32 length = 2; +} + +message CommunityCount { + Comparison type = 1; + uint32 count = 2; +} + +enum OriginType { + ORIGIN_TYPE_UNSPECIFIED = 0; + ORIGIN_TYPE_IGP = 1; + ORIGIN_TYPE_EGP = 2; + ORIGIN_TYPE_INCOMPLETE = 3; +} + +message LocalPrefEq { + uint32 value = 1; +} + +message MedEq { + uint32 value = 1; +} + +message Conditions { + MatchSet prefix_set = 1; + MatchSet neighbor_set = 2; + AsPathLength as_path_length = 3; + MatchSet as_path_set = 4; + MatchSet community_set = 5; + MatchSet ext_community_set = 6; + ValidationState rpki_result = 7; + enum RouteType { + ROUTE_TYPE_UNSPECIFIED = 0; + ROUTE_TYPE_INTERNAL = 1; + ROUTE_TYPE_EXTERNAL = 2; + ROUTE_TYPE_LOCAL = 3; + } + RouteType route_type = 8; + MatchSet large_community_set = 9; + repeated string next_hop_in_list = 10; + repeated Family afi_safi_in = 11; + CommunityCount community_count = 12; + OriginType origin = 13; + LocalPrefEq local_pref_eq = 14; + MedEq med_eq = 15; +} + +enum RouteAction { + ROUTE_ACTION_UNSPECIFIED = 0; + ROUTE_ACTION_ACCEPT = 1; + ROUTE_ACTION_REJECT = 2; +} + +message CommunityAction { + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_ADD = 1; + TYPE_REMOVE = 2; + TYPE_REPLACE = 3; + } + Type type = 1; + repeated string communities = 2; +} + +message MedAction { + enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_MOD = 1; + TYPE_REPLACE = 2; + } + Type type = 1; + int64 value = 2; +} + +message AsPrependAction { + uint32 asn = 1; + uint32 repeat = 2; + bool use_left_most = 3; +} + +message NexthopAction { + string address = 1; + bool self = 2; + bool unchanged = 3; + bool peer_address = 4; +} + +message LocalPrefAction { + uint32 value = 1; +} + +message OriginAction { + OriginType origin = 1; +} + +message Actions { + RouteAction route_action = 1; + CommunityAction community = 2; + MedAction med = 3; + AsPrependAction as_prepend = 4; + CommunityAction ext_community = 5; + NexthopAction nexthop = 6; + LocalPrefAction local_pref = 7; + CommunityAction large_community = 8; + OriginAction origin_action = 9; +} + +message Statement { + string name = 1; + Conditions conditions = 2; + Actions actions = 3; +} + +message Policy { + string name = 1; + repeated Statement statements = 2; +} + +enum PolicyDirection { + POLICY_DIRECTION_UNSPECIFIED = 0; + POLICY_DIRECTION_IMPORT = 1; + POLICY_DIRECTION_EXPORT = 2; +} + +message PolicyAssignment { + string name = 1; + PolicyDirection direction = 2; + repeated Policy policies = 4; + RouteAction default_action = 5; +} + +message RoutingPolicy { + repeated DefinedSet defined_sets = 1; + repeated Policy policies = 2; +} + +message Roa { + uint32 asn = 1; + uint32 prefixlen = 2; + uint32 maxlen = 3; + string prefix = 4; + RPKIConf conf = 5; +} + +message Vrf { + string name = 1; + RouteDistinguisher rd = 2; + repeated RouteTarget import_rt = 3; + repeated RouteTarget export_rt = 4; + uint32 id = 5; +} + +message DefaultRouteDistance { + uint32 external_route_distance = 1; + uint32 internal_route_distance = 2; +} + +message Global { + uint32 asn = 1; + string router_id = 2; + int32 listen_port = 3; + repeated string listen_addresses = 4; + repeated uint32 families = 5; + bool use_multiple_paths = 6; + RouteSelectionOptionsConfig route_selection_options = 7; + DefaultRouteDistance default_route_distance = 8; + Confederation confederation = 9; + GracefulRestart graceful_restart = 10; + string bind_to_device = 11; +} + +message Confederation { + bool enabled = 1; + uint32 identifier = 2; + repeated uint32 member_as_list = 3; +} + +message RPKIConf { + string address = 1; + uint32 remote_port = 2; +} + +message RPKIState { + google.protobuf.Timestamp uptime = 1; + google.protobuf.Timestamp downtime = 2; + bool up = 3; + uint32 record_ipv4 = 4; + uint32 record_ipv6 = 5; + uint32 prefix_ipv4 = 6; + uint32 prefix_ipv6 = 7; + uint32 serial = 8; + int64 received_ipv4 = 9; + int64 received_ipv6 = 10; + int64 serial_notify = 11; + int64 cache_reset = 12; + int64 cache_response = 13; + int64 end_of_data = 14; + int64 error = 15; + int64 serial_query = 16; + int64 reset_query = 17; +} + +message Rpki { + RPKIConf conf = 1; + RPKIState state = 2; +} + +message SetLogLevelRequest { + enum Level { + LEVEL_UNSPECIFIED = 0; + LEVEL_PANIC = 1; + LEVEL_FATAL = 2; + LEVEL_ERROR = 3; + LEVEL_WARN = 4; + LEVEL_INFO = 5; + LEVEL_DEBUG = 6; + LEVEL_TRACE = 7; + } + Level level = 1; +} + +message SetLogLevelResponse {} diff --git a/crates/bgp/proto/nlri.proto b/crates/bgp/proto/nlri.proto new file mode 100644 index 0000000..40bf8dd --- /dev/null +++ b/crates/bgp/proto/nlri.proto @@ -0,0 +1,361 @@ +syntax = "proto3"; + +package api; + +import "common.proto"; +import "extcom.proto"; + +option go_package = "github.com/osrg/gobgp/v4/api;api"; + +// Main NLRI type + +message NLRI { + oneof nlri { + IPAddressPrefix prefix = 1; + LabeledIPAddressPrefix labeled_prefix = 2; + EncapsulationNLRI encapsulation = 3; + VPLSNLRI vpls = 4; + EVPNEthernetAutoDiscoveryRoute evpn_ethernet_ad = 5; + EVPNMACIPAdvertisementRoute evpn_macadv = 6; + EVPNInclusiveMulticastEthernetTagRoute evpn_multicast = 7; + EVPNEthernetSegmentRoute evpn_ethernet_segment = 8; + EVPNIPPrefixRoute evpn_ip_prefix = 9; + EVPNIPMSIRoute evpn_i_pmsi = 10; + LabeledVPNIPAddressPrefix labeled_vpn_ip_prefix = 11; + RouteTargetMembershipNLRI route_target_membership = 12; + FlowSpecNLRI flow_spec = 13; + VPNFlowSpecNLRI vpn_flow_spec = 14; + OpaqueNLRI opaque = 15; + LsAddrPrefix ls_addr_prefix = 16; + SRPolicyNLRI sr_policy = 17; + MUPInterworkSegmentDiscoveryRoute mup_interwork_segment_discovery = 18; + MUPDirectSegmentDiscoveryRoute mup_direct_segment_discovery = 19; + MUPType1SessionTransformedRoute mup_type_1_session_transformed = 20; + MUPType2SessionTransformedRoute mup_type_2_session_transformed = 21; + } +} + +// IPAddressPrefix represents the NLRI for: +// - AFI=1, SAFI=1 +// - AFI=2, SAFI=1 +message IPAddressPrefix { + uint32 prefix_len = 1; + string prefix = 2; +} + +// LabeledIPAddressPrefix represents the NLRI for: +// - AFI=1, SAFI=4 +// - AFI=2, SAFI=4 +message LabeledIPAddressPrefix { + repeated uint32 labels = 1; + uint32 prefix_len = 2; + string prefix = 3; +} + +// EncapsulationNLRI represents the NLRI for: +// - AFI=1, SAFI=7 +// - AFI=2, SAFI=7 +message EncapsulationNLRI { + string address = 1; +} + +// VPLSNLRI represents the NLRI for: +// - AFI=25, SAFI=65 +message VPLSNLRI { + RouteDistinguisher rd = 1; + uint32 ve_id = 2; + uint32 ve_block_offset = 3; + uint32 ve_block_size = 4; + uint32 label_block_base = 5; +} + +message EthernetSegmentIdentifier { + uint32 type = 1; + bytes value = 2; +} + +// EVPNEthernetAutoDiscoveryRoute represents the NLRI for: +// - AFI=25, SAFI=70, RouteType=1 +message EVPNEthernetAutoDiscoveryRoute { + RouteDistinguisher rd = 1; + EthernetSegmentIdentifier esi = 2; + uint32 ethernet_tag = 3; + uint32 label = 4; +} + +// EVPNMACIPAdvertisementRoute represents the NLRI for: +// - AFI=25, SAFI=70, RouteType=2 +message EVPNMACIPAdvertisementRoute { + RouteDistinguisher rd = 1; + EthernetSegmentIdentifier esi = 2; + uint32 ethernet_tag = 3; + string mac_address = 4; + string ip_address = 5; + repeated uint32 labels = 6; +} + +// EVPNInclusiveMulticastEthernetTagRoute represents the NLRI for: +// - AFI=25, SAFI=70, RouteType=3 +message EVPNInclusiveMulticastEthernetTagRoute { + RouteDistinguisher rd = 1; + uint32 ethernet_tag = 2; + string ip_address = 3; +} + +// EVPNEthernetSegmentRoute represents the NLRI for: +// - AFI=25, SAFI=70, RouteType=4 +message EVPNEthernetSegmentRoute { + RouteDistinguisher rd = 1; + EthernetSegmentIdentifier esi = 2; + string ip_address = 3; +} + +// EVPNIPPrefixRoute represents the NLRI for: +// - AFI=25, SAFI=70, RouteType=5 +message EVPNIPPrefixRoute { + RouteDistinguisher rd = 1; + EthernetSegmentIdentifier esi = 2; + uint32 ethernet_tag = 3; + string ip_prefix = 4; + uint32 ip_prefix_len = 5; + string gw_address = 6; + uint32 label = 7; +} + +// EVPNIPMSIRoute represents the NLRI for: +// - AFI=25, SAFI=70, RouteType=9 +message EVPNIPMSIRoute { + RouteDistinguisher rd = 1; + uint32 ethernet_tag = 2; + RouteTarget rt = 3; +} + +// SRPolicyNLRI represents the NLRI for: +// - AFI=1, SAFI=73 +// - AFI=2, SAFI=73 +message SRPolicyNLRI { + // length field carries the length of NLRI portion expressed in bits + uint32 length = 1; + // distinguisher field carries 4-octet value uniquely identifying the policy + // in the context of tuple. + uint32 distinguisher = 2; + // color field carries 4-octet value identifying (with the endpoint) the + // policy. The color is used to match the color of the destination + // prefixes to steer traffic into the SR Policy + uint32 color = 3; + // endpoint field identifies the endpoint of a policy. The Endpoint may + // represent a single node or a set of nodes (e.g., an anycast + // address). The Endpoint is an IPv4 (4-octet) address or an IPv6 + // (16-octet) address according to the AFI of the NLRI. + bytes endpoint = 4; +} + +// LabeledVPNIPAddressPrefix represents the NLRI for: +// - AFI=1, SAFI=128 +// - AFI=2, SAFI=128 +message LabeledVPNIPAddressPrefix { + repeated uint32 labels = 1; + RouteDistinguisher rd = 2; + uint32 prefix_len = 3; + string prefix = 4; +} + +// RouteTargetMembershipNLRI represents the NLRI for: +// - AFI=1, SAFI=132 +message RouteTargetMembershipNLRI { + uint32 asn = 1; + RouteTarget rt = 2; +} + +message FlowSpecIPPrefix { + uint32 type = 1; + uint32 prefix_len = 2; + string prefix = 3; + // IPv6 only + uint32 offset = 4; +} + +message FlowSpecMAC { + uint32 type = 1; + string address = 2; +} + +message FlowSpecComponentItem { + // Operator for Numeric type, Operand for Bitmask type + uint32 op = 1; + uint64 value = 2; +} + +message FlowSpecComponent { + uint32 type = 1; + repeated FlowSpecComponentItem items = 2; +} + +message FlowSpecRule { + oneof rule { + FlowSpecIPPrefix ip_prefix = 1; + FlowSpecMAC mac = 2; + FlowSpecComponent component = 3; + } +} + +// FlowSpecNLRI represents the NLRI for: +// - AFI=1, SAFI=133 +// - AFI=2, SAFI=133 +message FlowSpecNLRI { + repeated FlowSpecRule rules = 1; +} + +// VPNFlowSpecNLRI represents the NLRI for: +// - AFI=1, SAFI=134 +// - AFI=2, SAFI=134 +// - AFI=25, SAFI=134 +message VPNFlowSpecNLRI { + RouteDistinguisher rd = 1; + repeated FlowSpecRule rules = 2; +} + +// OpaqueNLRI represents the NLRI for: +// - AFI=16397, SAFI=241 +message OpaqueNLRI { + bytes key = 1; + bytes value = 2; +} + +// Based om RFC 7752, Table 1. +enum LsNLRIType { + LS_NLRI_TYPE_UNSPECIFIED = 0; + LS_NLRI_TYPE_NODE = 1; + LS_NLRI_TYPE_LINK = 2; + LS_NLRI_TYPE_PREFIX_V4 = 3; + LS_NLRI_TYPE_PREFIX_V6 = 4; + LS_NLRI_TYPE_SRV6_SID = 6; +} + +enum LsProtocolID { + LS_PROTOCOL_ID_UNSPECIFIED = 0; + LS_PROTOCOL_ID_ISIS_L1 = 1; + LS_PROTOCOL_ID_ISIS_L2 = 2; + LS_PROTOCOL_ID_OSPF_V2 = 3; + LS_PROTOCOL_ID_DIRECT = 4; + LS_PROTOCOL_ID_STATIC = 5; + LS_PROTOCOL_ID_OSPF_V3 = 6; +} + +message LsNodeDescriptor { + uint32 asn = 1; + uint32 bgp_ls_id = 2; + uint32 ospf_area_id = 3; + bool pseudonode = 4; + string igp_router_id = 5; + string bgp_router_id = 6; + uint32 bgp_confederation_member = 7; +} + +message LsLinkDescriptor { + uint32 link_local_id = 1; + uint32 link_remote_id = 2; + string interface_addr_ipv4 = 3; + string neighbor_addr_ipv4 = 4; + string interface_addr_ipv6 = 5; + string neighbor_addr_ipv6 = 6; +} + +enum LsOspfRouteType { + LS_OSPF_ROUTE_TYPE_UNSPECIFIED = 0; + LS_OSPF_ROUTE_TYPE_INTRA_AREA = 1; + LS_OSPF_ROUTE_TYPE_INTER_AREA = 2; + LS_OSPF_ROUTE_TYPE_EXTERNAL1 = 3; + LS_OSPF_ROUTE_TYPE_EXTERNAL2 = 4; + LS_OSPF_ROUTE_TYPE_NSSA1 = 5; + LS_OSPF_ROUTE_TYPE_NSSA2 = 6; +} + +message LsPrefixDescriptor { + repeated string ip_reachability = 1; + LsOspfRouteType ospf_route_type = 2; +} + +message LsNodeNLRI { + LsNodeDescriptor local_node = 1; +} + +message LsLinkNLRI { + LsNodeDescriptor local_node = 1; + LsNodeDescriptor remote_node = 2; + LsLinkDescriptor link_descriptor = 3; +} + +message LsPrefixV4NLRI { + LsNodeDescriptor local_node = 1; + LsPrefixDescriptor prefix_descriptor = 2; +} + +message LsPrefixV6NLRI { + LsNodeDescriptor local_node = 1; + LsPrefixDescriptor prefix_descriptor = 2; +} + +// https://tools.ietf.org/html/rfc9552 +message LsSrv6SIDInformation { + repeated string sids = 1; +} + +message LsMultiTopologyIdentifier { + repeated uint32 multi_topo_ids = 1; +} + +// TODO: LsSrPolicyiCandidatePathNLRI +message LsSrv6SIDNLRI { + LsNodeDescriptor local_node = 1; + LsSrv6SIDInformation srv6_sid_information = 2; + LsMultiTopologyIdentifier multi_topo_id = 3; +} + +// LsAddrPrefix represents the NLRI for: +// - AFI=16388, SAFI=71 +message LsAddrPrefix { + LsNLRIType type = 1; + message LsNLRI { + oneof nlri { + LsNodeNLRI node = 1; + LsLinkNLRI link = 2; + LsPrefixV4NLRI prefix_v4 = 3; + LsPrefixV6NLRI prefix_v6 = 4; + LsSrv6SIDNLRI srv6_sid = 5; + } + } + LsNLRI nlri = 2; + uint32 length = 3; + LsProtocolID protocol_id = 4; + uint64 identifier = 5; +} + +message MUPInterworkSegmentDiscoveryRoute { + RouteDistinguisher rd = 1; + string prefix = 2; +} + +message MUPDirectSegmentDiscoveryRoute { + RouteDistinguisher rd = 1; + string address = 2; +} + +message MUPType1SessionTransformedRoute { + RouteDistinguisher rd = 1; + uint32 prefix_length = 2 [deprecated = true]; + string prefix = 3; + uint32 teid = 4; + uint32 qfi = 5; + uint32 endpoint_address_length = 6; + string endpoint_address = 7; + uint32 source_address_length = 8; + string source_address = 9; +} + +message MUPType2SessionTransformedRoute { + RouteDistinguisher rd = 1; + uint32 endpoint_address_length = 2; + string endpoint_address = 3; + uint32 teid = 4; +} diff --git a/crates/bgp/src/gobgp.rs b/crates/bgp/src/gobgp.rs new file mode 100644 index 0000000..a8db32a --- /dev/null +++ b/crates/bgp/src/gobgp.rs @@ -0,0 +1,639 @@ +//! Driving an external GoBGP daemon over gRPC. +//! +//! The default backend, and the reason is process separation rather than +//! protocol elegance: GoBGP owns the sessions and outlives filterframe, so a +//! restart — planned or otherwise — is invisible to every router involved. An +//! in-process speaker drops every session when the daemon dies, which is +//! precisely the moment a half-completed divert is most fragile. +//! +//! The cost is that filterframe's own announcements survive its death, and a +//! restarted daemon has to recognise them. That is what the origin community is +//! for: paths carrying it are ours to adopt or withdraw, and paths without it +//! belong to somebody else and are never touched. +//! +//! # What a confirmation here actually means +//! +//! GoBGP's adjacency-out table is **derived on demand**. `ListPath` with the +//! adj-out table type builds a fresh view per call and runs the peer's export +//! policy over it. So it can establish that a path is best for that peer and +//! passes its policy — it cannot establish that any UPDATE was ever written, +//! because there is no record of transmission to consult. +//! +//! That is why this backend reports [`Fidelity::PolicyEligible`] and no higher, +//! and why the runbook has to say so in those words. A quorum built on it means +//! "N peers would send this", not "N peers received it". + +use std::time::Duration; + +use filterframe_common::bgp::{ + Advertisement, BgpError, Fidelity, OriginateRequest, PathKey, PeerStatus, RibObserver, + RouteOriginator, SessionState, Submitted, SuppressReason, UnknownReason, +}; +use filterframe_common::config::Community; +use ipnet::IpNet; +use tonic::transport::{Channel, Endpoint}; + +#[allow(clippy::enum_variant_names, clippy::large_enum_variant, dead_code)] +mod api { + // Only a small part of GoBGP's sixty-RPC surface is used, so most of the + // generated code is unreferenced by design. + tonic::include_proto!("api"); +} + +use api::go_bgp_service_client::GoBgpServiceClient; + +// Protocol constants come from the **generated** enums, never from +// hand-written integers. +// +// 2026-08: `LOOKUP_EXACT` was written as a literal `0`, which in this proto is +// `TYPE_UNSPECIFIED` — `TYPE_EXACT` is 1. Every confirmation query was +// therefore asking for the wrong lookup option. The file already argued that +// GoBGP v4's typed oneof means "a field that moves is a compile error here +// rather than a wire disagreement discovered during an incident"; a numeric +// literal opts straight back out of that protection, so there are none left. + +/// GoBGP's table type for the global RIB. +const TABLE_TYPE_GLOBAL: i32 = api::TableType::Global as i32; +/// GoBGP's table type for a peer's adjacency-out. +const TABLE_TYPE_ADJ_OUT: i32 = api::TableType::AdjOut as i32; +/// AFI for IPv4, per the BGP registry. +const AFI_IP: i32 = api::family::Afi::Ip as i32; +/// AFI for IPv6. +const AFI_IP6: i32 = api::family::Afi::Ip6 as i32; +/// SAFI for unicast. filterframe originates unicast only — FlowSpec is a +/// separate tier that does not exist yet. +const SAFI_UNICAST: i32 = api::family::Safi::Unicast as i32; +/// `TableLookupPrefix` match type for an exact prefix. +const LOOKUP_EXACT: i32 = api::table_lookup_prefix::Type::Exact as i32; + +/// How long to wait for one RPC. +/// +/// Short: every call here is a local unix-domain-ish hop to a sidecar, and a +/// call that has not returned in this long is not going to. The reconcile tick +/// retries anyway, and a slow call that eventually succeeds is worse than a +/// fast failure — it delays the tick behind it. +const RPC_TIMEOUT: Duration = Duration::from_secs(3); + +/// A session established more recently than this contributes `Unknown` rather +/// than a verdict. +/// +/// Closes the race where an adjacency computed on demand reports a path as +/// eligible for a session that came up milliseconds ago and has sent nothing. +/// One keepalive interval is the shortest window in which a real UPDATE could +/// plausibly have gone out. +const MIN_ESTABLISHED: Duration = Duration::from_secs(30); + +/// Client for a GoBGP sidecar. +pub struct GoBgpSpeaker { + client: GoBgpServiceClient, + /// Attached to every path filterframe originates, and the only thing that + /// distinguishes our work from another controller's in a shared RIB. + origin_community: Option, +} + +impl GoBgpSpeaker { + /// Connect lazily to `endpoint`. + /// + /// **Lazily on purpose.** The reference implementation this replaces + /// connected eagerly at startup and exited if the sidecar was not yet up, + /// which turns an ordering problem at boot into a daemon that will not + /// start. It also stored the channel once and never reconnected, so a + /// sidecar restart left it handing out a dead client forever. + /// + /// `connect_lazy` returns immediately and tonic's channel reconnects per + /// request, which fixes both. + pub fn connect(endpoint: &str, origin_community: Option) -> Result { + let uri = if endpoint.contains("://") { + endpoint.to_string() + } else { + format!("http://{endpoint}") + }; + + let channel = Endpoint::from_shared(uri) + .map_err(|e| BgpError::Rejected(format!("bad grpc endpoint: {e}")))? + .connect_timeout(RPC_TIMEOUT) + .timeout(RPC_TIMEOUT) + // Without keepalives a channel to a sidecar that vanished can sit + // apparently healthy until the first write fails, which would be + // mid-sequence. + .http2_keep_alive_interval(Duration::from_secs(10)) + .keep_alive_timeout(Duration::from_secs(5)) + .keep_alive_while_idle(true) + .connect_lazy(); + + Ok(Self { + client: GoBgpServiceClient::new(channel), + origin_community, + }) + } + + fn family(prefix: IpNet) -> api::Family { + api::Family { + afi: match prefix { + IpNet::V4(_) => AFI_IP, + IpNet::V6(_) => AFI_IP6, + }, + safi: SAFI_UNICAST, + } + } + + /// Build the path GoBGP will hold. + /// + /// The origin community is appended to whatever the tier asked for, so that + /// a restarted daemon can tell its own orphans from another controller's + /// paths in the same RIB. + fn build_path(&self, req: &OriginateRequest) -> api::Path { + let prefix = req.key.prefix; + let mut communities: Vec = + req.communities.iter().filter_map(encode_standard).collect(); + if let Some(c) = self.origin_community.as_ref().and_then(encode_standard) { + communities.push(c); + } + + let large: Vec = req + .communities + .iter() + .filter_map(|c| match c { + Community::Large { + global, + local1, + local2, + } => Some(api::LargeCommunity { + global_admin: *global, + local_data1: *local1, + local_data2: *local2, + }), + _ => None, + }) + .collect(); + + // GoBGP v4 carries attributes in a typed oneof rather than a + // protobuf `Any`, so each is built as its own variant. That is a + // better contract for us: a field that moves is a compile error here + // rather than a wire disagreement discovered during an incident. + let mut pattrs = vec![api::Attribute { + attr: Some(api::attribute::Attr::Origin(api::OriginAttribute { + // IGP. filterframe originates these prefixes; it did not learn + // them from anywhere. + origin: 0, + })), + }]; + if !communities.is_empty() { + pattrs.push(api::Attribute { + attr: Some(api::attribute::Attr::Communities( + api::CommunitiesAttribute { communities }, + )), + }); + } + if !large.is_empty() { + pattrs.push(api::Attribute { + attr: Some(api::attribute::Attr::LargeCommunities( + api::LargeCommunitiesAttribute { communities: large }, + )), + }); + } + + api::Path { + nlri: Some(api::Nlri { + nlri: Some(api::nlri::Nlri::Prefix(api::IpAddressPrefix { + prefix_len: u32::from(prefix.prefix_len()), + prefix: prefix.addr().to_string(), + })), + }), + pattrs, + family: Some(Self::family(prefix)), + ..Default::default() + } + } +} + +/// Encode a community into its 32-bit wire form, if it has one. +/// +/// Large communities are carried in their own attribute and are skipped here — +/// returning something plausible for them would silently send the wrong value. +fn encode_standard(c: &Community) -> Option { + match c { + Community::Standard { asn, value } => Some((u32::from(*asn) << 16) | u32::from(*value)), + Community::WellKnown(w) => Some(w.value()), + Community::Large { .. } => None, + } +} + +/// Map GoBGP's numeric session state onto ours. +fn session_state(raw: i32) -> SessionState { + match raw { + 1 => SessionState::Idle, + 2 => SessionState::Connect, + 3 => SessionState::Active, + 4 => SessionState::OpenSent, + 5 => SessionState::OpenConfirm, + 6 => SessionState::Established, + // An unrecognised state is not Established, which is the conservative + // reading: it will contribute Unknown rather than a verdict. + _ => SessionState::Idle, + } +} + +/// Classify a transport error. +/// +/// The distinction matters: an unknown outcome means the request may well have +/// landed and the caller must re-verify, where a rejection means retrying only +/// hides a bug. +fn classify(status: &tonic::Status) -> BgpError { + use tonic::Code; + match status.code() { + Code::InvalidArgument | Code::FailedPrecondition | Code::Unimplemented => { + BgpError::Rejected(status.message().to_string()) + } + Code::DeadlineExceeded | Code::Cancelled => { + // Deliberately not a failure. The sidecar may have applied it. + BgpError::Unknown(status.message().to_string()) + } + _ => BgpError::Unreachable(format!("{}: {}", status.code(), status.message())), + } +} + +impl RouteOriginator for GoBgpSpeaker { + async fn originate(&self, req: &OriginateRequest) -> Result { + let mut client = self.client.clone(); + let request = api::AddPathRequest { + table_type: TABLE_TYPE_GLOBAL, + vrf_id: String::new(), + path: Some(self.build_path(req)), + }; + client.add_path(request).await.map_err(|e| classify(&e))?; + Ok(Submitted { + key: req.key.clone(), + at: std::time::Instant::now(), + }) + } + + async fn withdraw(&self, key: &PathKey) -> Result { + let mut client = self.client.clone(); + // Delete by path, never by a server-assigned identifier: GoBGP's own + // path UUIDs do not survive it restarting, which would leave a + // restarted daemon unable to withdraw its own work. + // + // The path carries family and NLRI and **no attributes**. It used to be + // built through `build_path`, which meant the delete request advertised a + // different attribute set than the origination did — the tier's + // communities were absent (`PathKey` does not carry them and cannot + // reconstruct them) while the origin community was present. Sending a + // near-miss attribute set to a matcher is worse than sending none: an + // identifying field that is *wrong* can fail to match, where one that is + // absent is simply not part of the match. + let request = api::DeletePathRequest { + table_type: TABLE_TYPE_GLOBAL, + vrf_id: String::new(), + family: Some(Self::family(key.prefix)), + uuid: Vec::new(), + path: Some(api::Path { + nlri: Some(api::Nlri { + nlri: Some(api::nlri::Nlri::Prefix(api::IpAddressPrefix { + prefix_len: u32::from(key.prefix.prefix_len()), + prefix: key.prefix.addr().to_string(), + })), + }), + family: Some(Self::family(key.prefix)), + ..Default::default() + }), + }; + client + .delete_path(request) + .await + .map_err(|e| classify(&e))?; + Ok(Submitted { + key: key.clone(), + at: std::time::Instant::now(), + }) + } + + /// **Not implemented, and therefore this backend converges nothing.** + /// + /// Reading ownership back out of a shared RIB is the one piece missing + /// before `mode enforce` can do anything, and it is missing for a specific + /// reason rather than for lack of time: **the tier is not recoverable from + /// what goes on the wire.** A path carries a prefix and the origin + /// community, and that is enough to answer "is this ours" but not "which of + /// our tiers is it". `Tier::Divert` and `Tier::DivertSignal` are the same + /// prefix with the same origin community, so they are indistinguishable — + /// and they are exactly the pair whose confusion suppresses transit for a + /// prefix nobody is scrubbing. Fixing this means deciding on a per-tier + /// wire marker (a distinct community per tier is the obvious candidate) and + /// validating it against a live sidecar. + /// + /// Until then this returns an error, and the honest consequence is spelled + /// out here because the comment that used to sit in its place got it + /// backwards. It claimed "the reconciler will re-originate what it wants and + /// never withdraw what it does not recognise", which reads like a safe + /// degraded mode. It is not what happens. `Reconciler::tick` treats a failed + /// read as *no information about the world* and returns without originating + /// or withdrawing anything at all — the only safe response, since a + /// difference computed against nothing withdraws everything. So a daemon on + /// this backend would log one warning per tick, forever, and converge + /// nothing: not a degraded mode, a no-op. + /// + /// Nothing reaches it today — `mode enforce` refuses to start and is the + /// only way to select a real speaker — but whoever wires enforce up needs + /// this to be the first thing they read. + async fn list_originated(&self) -> Result, BgpError> { + Err(BgpError::Unknown( + "reading existing state from GoBGP is not implemented: a path's tier cannot be \ + recovered from its prefix and origin community, and every reconcile tick will \ + refuse to converge until it can" + .into(), + )) + } +} + +impl RibObserver for GoBgpSpeaker { + async fn peers(&self) -> Result, BgpError> { + let mut client = self.client.clone(); + let mut stream = client + .list_peer(api::ListPeerRequest::default()) + .await + .map_err(|e| classify(&e))? + .into_inner(); + + let mut out = Vec::new(); + while let Some(item) = stream.message().await.map_err(|e| classify(&e))? { + let Some(peer) = item.peer else { continue }; + let Some(conf) = peer.conf.as_ref() else { + continue; + }; + let Ok(address) = conf.neighbor_address.parse() else { + continue; + }; + let state = peer.state.as_ref(); + out.push(PeerStatus { + name: conf.neighbor_address.clone(), + address, + state: state.map_or(SessionState::Idle, |s| session_state(s.session_state)), + // Flap count doubles as the epoch: it increments exactly when a + // confirmation taken before it stops being about this session. + epoch: state.map_or(0, |s| u64::from(s.flops)), + established_for: peer + .timers + .as_ref() + .and_then(|t| t.state.as_ref()) + .map(|s| Duration::from_secs(s.uptime.map_or(0, |u| u.seconds.max(0) as u64))), + }); + } + Ok(out) + } + + async fn advertised(&self, peer: &str, prefix: IpNet) -> Result { + // A session that is not established, or is too young to have sent + // anything, cannot answer the question — and Unknown never counts + // toward a quorum. + let peers = self.peers().await?; + let Some(status) = peers.iter().find(|p| p.name == peer) else { + return Ok(Advertisement::Unknown { + reason: UnknownReason::NotSupported, + }); + }; + if status.state != SessionState::Established { + return Ok(Advertisement::Unknown { + reason: UnknownReason::SessionDown, + }); + } + if status.established_for.is_some_and(|d| d < MIN_ESTABLISHED) { + return Ok(Advertisement::Unknown { + reason: UnknownReason::SessionTooYoung, + }); + } + + let mut client = self.client.clone(); + let request = api::ListPathRequest { + table_type: TABLE_TYPE_ADJ_OUT, + name: peer.to_string(), + family: Some(Self::family(prefix)), + // Scoped to one prefix. A full-table scan per confirmation poll is + // unacceptable at the rate the gate runs. + prefixes: vec![api::TableLookupPrefix { + prefix: prefix.to_string(), + r#type: LOOKUP_EXACT, + ..Default::default() + }], + // Makes GoBGP mark paths its export policy rejected, which is what + // distinguishes "policy says no, waiting is futile" from "not there + // yet, keep waiting". + enable_filtered: true, + ..Default::default() + }; + + let mut stream = client + .list_path(request) + .await + .map_err(|e| classify(&e))? + .into_inner(); + + // A destination can carry several paths. One that is not filtered is + // enough to answer yes; only if *every* path is filtered is the answer + // a suppression, and taking the first path's verdict would report a + // policy rejection while another path was being advertised perfectly + // well. + let mut policy_filtered = false; + let mut limit_reached = false; + + while let Some(item) = stream.message().await.map_err(|e| classify(&e))? { + let Some(dest) = item.destination else { + continue; + }; + for path in dest.paths { + if path.filtered { + policy_filtered = true; + } else if path.send_max_filtered { + limit_reached = true; + } else { + return Ok(Advertisement::Advertised { + // Never higher: see the module docstring. This + // establishes eligibility, not transmission. + fidelity: Fidelity::PolicyEligible, + peer_epoch: status.epoch, + }); + } + } + } + + Ok(if policy_filtered { + Advertisement::Suppressed { + reason: SuppressReason::PolicyFiltered, + } + } else if limit_reached { + Advertisement::Suppressed { + reason: SuppressReason::LimitReached, + } + } else { + Advertisement::Absent + }) + } + + fn max_fidelity(&self) -> Fidelity { + Fidelity::PolicyEligible + } +} + +#[cfg(test)] +mod tests { + use super::*; + use filterframe_common::config::WellKnown; + + /// Every protocol constant must equal the generated enum it names. + /// + /// `LOOKUP_EXACT` was a hand-written `0`, which is `TYPE_UNSPECIFIED` in + /// this proto — so every confirmation query asked for the wrong lookup + /// option, and nothing caught it because an `i32` matches any `i32`. These + /// are now derived from the enums, and this test fails loudly if anyone + /// writes a literal back in. + #[test] + fn protocol_constants_match_the_generated_enums() { + assert_eq!(TABLE_TYPE_GLOBAL, api::TableType::Global as i32); + assert_eq!(TABLE_TYPE_ADJ_OUT, api::TableType::AdjOut as i32); + assert_eq!(AFI_IP, api::family::Afi::Ip as i32); + assert_eq!(AFI_IP6, api::family::Afi::Ip6 as i32); + assert_eq!(SAFI_UNICAST, api::family::Safi::Unicast as i32); + assert_eq!(LOOKUP_EXACT, api::table_lookup_prefix::Type::Exact as i32); + + // The one that was wrong, stated as the thing it must not be. + assert_ne!( + LOOKUP_EXACT, + api::table_lookup_prefix::Type::Unspecified as i32, + "an exact lookup must not be sent as TYPE_UNSPECIFIED" + ); + } + + /// A confirmation query is scoped to one prefix, exactly. A full-table scan + /// at the rate the quorum gate runs is not affordable, and a non-exact + /// lookup would answer about the wrong prefixes. + #[test] + fn a_lookup_prefix_is_exact_and_carries_the_prefix_asked_for() { + let p: IpNet = "198.51.100.0/24".parse().unwrap(); + let lookup = api::TableLookupPrefix { + prefix: p.to_string(), + r#type: LOOKUP_EXACT, + ..Default::default() + }; + assert_eq!(lookup.prefix, "198.51.100.0/24"); + assert_eq!( + api::table_lookup_prefix::Type::try_from(lookup.r#type), + Ok(api::table_lookup_prefix::Type::Exact) + ); + } + + #[test] + fn standard_communities_encode_to_their_wire_value() { + assert_eq!( + encode_standard(&Community::Standard { + asn: 65535, + value: 666 + }), + Some(0xFFFF_029A) + ); + assert_eq!( + encode_standard(&Community::Standard { + asn: 64510, + value: 1 + }), + Some(0xFBFE_0001) + ); + } + + #[test] + fn well_known_communities_encode_to_their_rfc_values() { + assert_eq!( + encode_standard(&Community::WellKnown(WellKnown::Blackhole)), + Some(0xFFFF_029A) + ); + assert_eq!( + encode_standard(&Community::WellKnown(WellKnown::NoExport)), + Some(0xFFFF_FF01) + ); + } + + /// A large community has no 32-bit form. Returning something plausible + /// would silently send the wrong value to a provider. + #[test] + fn large_communities_are_not_squeezed_into_the_standard_attribute() { + assert_eq!( + encode_standard(&Community::Large { + global: 4_200_000_000, + local1: 666, + local2: 0 + }), + None + ); + } + + #[test] + fn session_states_map_to_the_registry_values() { + assert_eq!(session_state(6), SessionState::Established); + assert_eq!(session_state(1), SessionState::Idle); + // Anything unrecognised must not read as established. + assert_ne!(session_state(99), SessionState::Established); + assert_ne!(session_state(0), SessionState::Established); + } + + /// The distinction the whole retry policy rests on: a cancelled request may + /// have landed, so the caller must re-verify rather than assume failure. + #[test] + fn a_cancelled_call_is_unknown_not_a_failure() { + let e = classify(&tonic::Status::new(tonic::Code::Cancelled, "gone")); + assert!(matches!(e, BgpError::Unknown(_))); + let e = classify(&tonic::Status::new(tonic::Code::DeadlineExceeded, "slow")); + assert!(matches!(e, BgpError::Unknown(_))); + } + + /// Retrying these only hides a bug. + #[test] + fn malformed_requests_are_rejected_rather_than_retried() { + for code in [ + tonic::Code::InvalidArgument, + tonic::Code::FailedPrecondition, + tonic::Code::Unimplemented, + ] { + assert!(matches!( + classify(&tonic::Status::new(code, "bad")), + BgpError::Rejected(_) + )); + } + } + + #[test] + fn transport_failures_are_unreachable() { + assert!(matches!( + classify(&tonic::Status::new(tonic::Code::Unavailable, "down")), + BgpError::Unreachable(_) + )); + } + + #[test] + fn families_follow_the_address_family() { + let v4 = GoBgpSpeaker::family("198.51.100.0/24".parse().unwrap()); + assert_eq!((v4.afi, v4.safi), (AFI_IP, SAFI_UNICAST)); + let v6 = GoBgpSpeaker::family("2001:db8::/48".parse().unwrap()); + assert_eq!((v6.afi, v6.safi), (AFI_IP6, SAFI_UNICAST)); + } + + /// Lazily, so a sidecar that is not up yet does not stop the daemon + /// starting. + #[tokio::test] + async fn connecting_does_not_require_a_live_sidecar() { + assert!(GoBgpSpeaker::connect("127.0.0.1:50051", None).is_ok()); + assert!(GoBgpSpeaker::connect("http://127.0.0.1:50051", None).is_ok()); + } + + #[tokio::test] + async fn a_malformed_endpoint_is_rejected_at_construction() { + assert!(GoBgpSpeaker::connect("not a uri at all", None).is_err()); + } + + /// This backend can never claim more than eligibility, and a quorum built + /// on it means "N peers would send this", not "N peers received it". + #[tokio::test] + async fn the_backend_reports_only_what_it_can_establish() { + let s = GoBgpSpeaker::connect("127.0.0.1:50051", None).unwrap(); + assert_eq!(s.max_fidelity(), Fidelity::PolicyEligible); + assert!(Fidelity::PolicyEligible < Fidelity::Written); + } +} diff --git a/crates/bgp/src/lib.rs b/crates/bgp/src/lib.rs new file mode 100644 index 0000000..123731e --- /dev/null +++ b/crates/bgp/src/lib.rs @@ -0,0 +1,18 @@ +//! BGP speaker backends. +//! +//! A sibling crate rather than a module, because both mitigation tiers need a +//! speaker and the house rule is that tier modules depend only on `common` and +//! never on each other. The CLI constructs the backend and hands it down. +//! +//! The mock lives here alongside the real backends rather than behind +//! `#[cfg(test)]`, because it is not a test double: `mode observe` runs the +//! entire daemon against it in production, which is how filterframe is expected +//! to land on a node for the first time. It has to be as correct as the others. + +#[cfg(feature = "gobgp")] +pub mod gobgp; +pub mod mock; + +#[cfg(feature = "gobgp")] +pub use gobgp::GoBgpSpeaker; +pub use mock::MockSpeaker; diff --git a/crates/bgp/src/mock.rs b/crates/bgp/src/mock.rs new file mode 100644 index 0000000..7a281e2 --- /dev/null +++ b/crates/bgp/src/mock.rs @@ -0,0 +1,438 @@ +//! An in-memory speaker that records everything and announces nothing. +//! +//! Two jobs, and they pull in the same direction. +//! +//! In production it backs `mode observe`: the daemon polls, plans, and +//! converges for real, and every announcement lands here instead of on a +//! router. That is how filterframe is meant to arrive on a node — running the +//! whole loop, with `status` showing exactly what it would do, before anyone +//! sets `enforce`. +//! +//! In tests it is the adversary. Every call is recorded so a test can assert on +//! the *sequence* of BGP operations, which is what the safety properties are +//! actually about — that a transit withdrawal never precedes a scrubber quorum, +//! that a single-tick dropout produces no calls at all. Faults can be injected +//! so that the failure branches are reachable without a router. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use filterframe_common::bgp::{ + Advertisement, BgpError, Fidelity, OriginateRequest, PathKey, PeerName, PeerStatus, + RibObserver, RouteOriginator, SessionState, Submitted, UnknownReason, +}; +use ipnet::IpNet; +use tokio::sync::Mutex; + +/// One thing the daemon asked the speaker to do. +/// +/// Recorded in order, because order is the property under test. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Call { + Originate(PathKey), + Withdraw(PathKey), + ListOriginated, + Peers, + Advertised { peer: PeerName, prefix: IpNet }, +} + +/// A fault to inject on the next matching call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fault { + /// The speaker is unreachable. + Unreachable, + /// The outcome is genuinely unknown — the dangerous one, because the caller + /// must re-verify rather than assume nothing happened. + Unknown, + /// A path is accepted and then never appears in any peer's adjacency, which + /// is what a silently-filtered announcement looks like from here. + AcceptButNeverAdvertise, +} + +#[derive(Debug, Default)] +struct State { + originated: HashMap, + /// Paths that were accepted but must never show up as advertised. + swallowed: Vec, + calls: Vec, + peers: Vec, + fault: Option, +} + +/// An in-memory `RouteOriginator` + `RibObserver`. +#[derive(Clone, Default)] +pub struct MockSpeaker { + state: Arc>, +} + +impl MockSpeaker { + pub fn new() -> Self { + Self::default() + } + + /// Declare the peers this speaker pretends to have, all established. + pub async fn with_peers(self, names: &[(&str, &str)]) -> Self { + { + let mut s = self.state.lock().await; + s.peers = names + .iter() + .map(|(name, addr)| PeerStatus { + name: (*name).to_string(), + address: addr.parse().expect("test peer address must parse"), + state: SessionState::Established, + epoch: 1, + established_for: Some(Duration::from_secs(3600)), + }) + .collect(); + } + self + } + + /// Every call made so far, in order. + pub async fn calls(&self) -> Vec { + self.state.lock().await.calls.clone() + } + + /// Calls that changed something, which is usually what a test means when it + /// says "no BGP activity". + pub async fn mutating_calls(&self) -> Vec { + self.state + .lock() + .await + .calls + .iter() + .filter(|c| matches!(c, Call::Originate(_) | Call::Withdraw(_))) + .cloned() + .collect() + } + + pub async fn clear_calls(&self) { + self.state.lock().await.calls.clear(); + } + + /// What the speaker currently holds. + pub async fn originated(&self) -> Vec { + let mut v: Vec<_> = self.state.lock().await.originated.keys().cloned().collect(); + v.sort(); + v + } + + /// Fail the next operation in a particular way. + pub async fn inject(&self, fault: Fault) { + self.state.lock().await.fault = Some(fault); + } + + /// Take a session down, bumping its epoch — a flap, as far as any + /// confirmation taken before now is concerned. + pub async fn flap(&self, peer: &str) { + let mut s = self.state.lock().await; + if let Some(p) = s.peers.iter_mut().find(|p| p.name == peer) { + p.epoch += 1; + p.established_for = Some(Duration::ZERO); + } + } + + pub async fn set_session_state(&self, peer: &str, state: SessionState) { + let mut s = self.state.lock().await; + if let Some(p) = s.peers.iter_mut().find(|p| p.name == peer) { + p.state = state; + } + } + + async fn take_fault(&self) -> Option { + self.state.lock().await.fault.take() + } +} + +impl RouteOriginator for MockSpeaker { + async fn originate(&self, req: &OriginateRequest) -> Result { + match self.take_fault().await { + Some(Fault::Unreachable) => { + return Err(BgpError::Unreachable("mock: injected".into())); + } + Some(Fault::Unknown) => { + // Recorded before returning: the whole point of an unknown + // outcome is that the request may well have landed, and a test + // asserting on recovery needs to see that it did. + let mut s = self.state.lock().await; + s.calls.push(Call::Originate(req.key.clone())); + s.originated.insert(req.key.clone(), req.clone()); + return Err(BgpError::Unknown("mock: injected".into())); + } + Some(Fault::AcceptButNeverAdvertise) => { + let mut s = self.state.lock().await; + s.swallowed.push(req.key.clone()); + } + None => {} + } + + let mut s = self.state.lock().await; + s.calls.push(Call::Originate(req.key.clone())); + s.originated.insert(req.key.clone(), req.clone()); + Ok(Submitted { + key: req.key.clone(), + at: Instant::now(), + }) + } + + async fn withdraw(&self, key: &PathKey) -> Result { + match self.take_fault().await { + Some(Fault::Unreachable) => { + return Err(BgpError::Unreachable("mock: injected".into())); + } + Some(Fault::Unknown) => return Err(BgpError::Unknown("mock: injected".into())), + _ => {} + } + + let mut s = self.state.lock().await; + s.calls.push(Call::Withdraw(key.clone())); + // Idempotent: withdrawing something absent succeeds, which is what lets + // recovery re-assert desired state without checking first. + s.originated.remove(key); + s.swallowed.retain(|k| k != key); + Ok(Submitted { + key: key.clone(), + at: Instant::now(), + }) + } + + async fn list_originated(&self) -> Result, BgpError> { + // `Unreachable` is the one fault a *read* can express, and it must be + // honoured here: the reconciler's response to an unreadable RIB — do + // nothing at all, because a difference computed from nothing withdraws + // everything — is otherwise unreachable from a test, which is exactly + // the branch a fault-injecting mock exists to reach. + // + // `Unknown` and `AcceptButNeverAdvertise` are mutation semantics: the + // first means "the request may well have landed", the second describes + // an announcement that is accepted and silently filtered. Neither says + // anything about a read, so both are left pending for the call they are + // about rather than being swallowed here. + let mut s = self.state.lock().await; + s.calls.push(Call::ListOriginated); + if matches!(s.fault, Some(Fault::Unreachable)) { + s.fault = None; + return Err(BgpError::Unreachable("mock: injected".into())); + } + let mut v: Vec<_> = s.originated.keys().cloned().collect(); + v.sort(); + Ok(v) + } +} + +impl RibObserver for MockSpeaker { + async fn peers(&self) -> Result, BgpError> { + let mut s = self.state.lock().await; + s.calls.push(Call::Peers); + Ok(s.peers.clone()) + } + + async fn advertised(&self, peer: &str, prefix: IpNet) -> Result { + let mut s = self.state.lock().await; + s.calls.push(Call::Advertised { + peer: peer.to_string(), + prefix, + }); + + let Some(status) = s.peers.iter().find(|p| p.name == peer) else { + return Ok(Advertisement::Unknown { + reason: UnknownReason::NotSupported, + }); + }; + if status.state != SessionState::Established { + return Ok(Advertisement::Unknown { + reason: UnknownReason::SessionDown, + }); + } + let epoch = status.epoch; + + if s.swallowed.iter().any(|k| k.prefix == prefix) { + return Ok(Advertisement::Absent); + } + + let held = s + .originated + .iter() + .any(|(k, req)| k.prefix == prefix && req.peers.iter().any(|p| p == peer)); + + Ok(if held { + Advertisement::Advertised { + fidelity: Fidelity::Synthetic, + peer_epoch: epoch, + } + } else { + Advertisement::Absent + }) + } + + /// The mock establishes nothing, and says so. A quorum requiring real + /// evidence will not be satisfied by it — which is correct, and is why + /// `observe` mode can never be mistaken for a working diversion. + fn max_fidelity(&self) -> Fidelity { + Fidelity::Synthetic + } +} + +#[cfg(test)] +mod tests { + use super::*; + use filterframe_common::config::Tier; + + fn key(prefix: &str) -> PathKey { + PathKey { + prefix: prefix.parse().unwrap(), + tier: Tier::Rtbh, + } + } + + fn req(prefix: &str, peers: &[&str]) -> OriginateRequest { + OriginateRequest { + key: key(prefix), + peers: peers.iter().map(|s| (*s).to_string()).collect(), + communities: vec![], + next_hop: None, + } + } + + async fn speaker() -> MockSpeaker { + MockSpeaker::new() + .with_peers(&[("t1", "203.0.113.1"), ("t2", "203.0.113.2")]) + .await + } + + #[tokio::test] + async fn originate_then_advertised() { + let s = speaker().await; + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + + let a = s + .advertised("t1", "198.51.100.5/32".parse().unwrap()) + .await + .unwrap(); + assert!(a.counts(Fidelity::Synthetic)); + + // Not offered to t2, so not advertised there. + let b = s + .advertised("t2", "198.51.100.5/32".parse().unwrap()) + .await + .unwrap(); + assert_eq!(b, Advertisement::Absent); + } + + /// Idempotency is what lets recovery re-assert desired state without + /// checking first. + #[tokio::test] + async fn originate_and_withdraw_are_idempotent() { + let s = speaker().await; + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + assert_eq!(s.originated().await.len(), 1); + + let _ = s.withdraw(&key("198.51.100.5/32")).await.unwrap(); + let _ = s.withdraw(&key("198.51.100.5/32")).await.unwrap(); + assert!(s.originated().await.is_empty()); + } + + #[tokio::test] + async fn calls_are_recorded_in_order() { + let s = speaker().await; + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + let _ = s.withdraw(&key("198.51.100.5/32")).await.unwrap(); + assert_eq!( + s.mutating_calls().await, + vec![ + Call::Originate(key("198.51.100.5/32")), + Call::Withdraw(key("198.51.100.5/32")), + ] + ); + } + + /// An unknown outcome must leave the path in place, because it may well + /// have landed. A caller that assumed failure would originate it twice. + #[tokio::test] + async fn an_unknown_outcome_still_records_the_path() { + let s = speaker().await; + s.inject(Fault::Unknown).await; + let e = s + .originate(&req("198.51.100.5/32", &["t1"])) + .await + .unwrap_err(); + assert!(matches!(e, BgpError::Unknown(_))); + assert_eq!(s.originated().await.len(), 1, "the path may have landed"); + } + + #[tokio::test] + async fn an_unreachable_speaker_records_nothing() { + let s = speaker().await; + s.inject(Fault::Unreachable).await; + assert!(s.originate(&req("198.51.100.5/32", &["t1"])).await.is_err()); + assert!(s.originated().await.is_empty()); + } + + /// What a silently-filtered announcement looks like: accepted, and never + /// visible in anyone's adjacency. + #[tokio::test] + async fn a_swallowed_path_is_never_advertised() { + let s = speaker().await; + s.inject(Fault::AcceptButNeverAdvertise).await; + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + let a = s + .advertised("t1", "198.51.100.5/32".parse().unwrap()) + .await + .unwrap(); + assert_eq!(a, Advertisement::Absent); + } + + #[tokio::test] + async fn a_down_session_yields_unknown_not_absent() { + let s = speaker().await; + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + s.set_session_state("t1", SessionState::Idle).await; + let a = s + .advertised("t1", "198.51.100.5/32".parse().unwrap()) + .await + .unwrap(); + assert!( + matches!(a, Advertisement::Unknown { .. }), + "a route we cannot see because the session is down is not a route that is gone" + ); + } + + /// The epoch is what makes a confirmation invalidatable. + #[tokio::test] + async fn a_flap_bumps_the_epoch() { + let s = speaker().await; + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + let before = s + .advertised("t1", "198.51.100.5/32".parse().unwrap()) + .await + .unwrap(); + + s.flap("t1").await; + let after = s + .advertised("t1", "198.51.100.5/32".parse().unwrap()) + .await + .unwrap(); + + let epoch = |a: &Advertisement| match a { + Advertisement::Advertised { peer_epoch, .. } => *peer_epoch, + other => panic!("expected advertised, got {other:?}"), + }; + assert_ne!(epoch(&before), epoch(&after)); + } + + /// The mock establishes nothing and must not claim otherwise, or observe + /// mode could be mistaken for a working diversion. + #[tokio::test] + async fn the_mock_admits_it_proves_nothing() { + let s = speaker().await; + assert_eq!(s.max_fidelity(), Fidelity::Synthetic); + let _ = s.originate(&req("198.51.100.5/32", &["t1"])).await.unwrap(); + let a = s + .advertised("t1", "198.51.100.5/32".parse().unwrap()) + .await + .unwrap(); + assert!(!a.counts(Fidelity::PolicyEligible)); + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..00bc040 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,95 @@ +[package] +name = "filterframe-cli" +description = "The filterframe daemon and operator CLI." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "filterframe" +path = "src/main.rs" + +[dependencies] +filterframe-bgp.workspace = true +filterframe-common.workspace = true +filterframe-policy.workspace = true +filterframe-rtbh.workspace = true +filterframe-scrub-divert.workspace = true +ipnet.workspace = true +serde.workspace = true +clap.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +# Unix, not Linux-only: the daemon loop, signal handling and pidfile identity +# are portable, and development on macOS has to be able to run `filterframe run` +# rather than only compile it. Linux-only work (netlink, sysfs) will be gated +# where it lands, not here. +[target.'cfg(unix)'.dependencies] +libc.workspace = true +signal-hook.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } + +[features] +default = [] +# Forwarded so a deployment can drop the gRPC stack entirely. The mock backend +# is never optional: `mode observe` runs the whole daemon against it, and so +# does every test. +bgp-gobgp = ["filterframe-bgp/gobgp"] + +# Debian packaging. The .deb is the primary deliverable; tarballs exist for +# hosts that are not Debian-family. Built by `make deb`, and verified on every +# pull request by CI's `package` job, which installs the result in a clean +# container and checks that the unit passes `systemd-analyze verify`. +[package.metadata.deb] +name = "filterframe" +maintainer = "unredacted " +copyright = "filterframe contributors, GPL-3.0-or-later" +license-file = ["../../LICENSE", "0"] +section = "net" +priority = "optional" +extended-description = """\ +filterframe executes DDoS mitigations that a policy engine has already decided \ +on, as BGP announcements: a host route with a blackhole community for the fast \ +tier, and a scrubbing-provider diversion for the slow one. It never detects an \ +attack, never forwards a packet, and never configures an interface.""" +# Declared explicitly rather than with `$auto`, which is not reproducible here. +# `$auto` shells out to dpkg-shlibdeps, and that cannot inspect an aarch64 ELF +# on an x86_64 runner — so the arm64 package silently came out with different +# dependency metadata from the amd64 one, which is worse than either answer. +# +# libc6 >= 2.31 because the gnu targets are built in cross's containers, which +# link against glibc 2.31 deliberately for backward compatibility. procps +# because ExecReload runs /bin/kill, and no shared-library scan can see a binary +# a unit file invokes. +depends = "libc6 (>= 2.31), procps" +# The binary path must start with exactly `target/release/` — that is the +# literal prefix cargo-deb rewrites to `target//release/` when built +# with `--target`. Writing it as `../../target/release/` parses, and then +# silently packages the host binary into a cross-built .deb, or nothing at all. +assets = [ + ["target/release/filterframe", "usr/bin/", "755"], + ["../../conf/example.conf", "etc/filterframe/example.conf", "644"], + ["../../README.md", "usr/share/doc/filterframe/README.md", "644"], + ["debian/README.Debian", "usr/share/doc/filterframe/README.Debian", "644"], +] +# example.conf is shipped as a reference and is never read by the daemon, so it +# is not a conffile: an operator copies it to filterframe.conf and edits that. +# Marking it would make dpkg prompt about a file nobody is expected to modify. + +[package.metadata.deb.systemd-units] +unit-scripts = "debian" +# Installed disabled and stopped on purpose. filterframe needs an +# operator-supplied peer list and a policy-engine token before it can do +# anything, so auto-starting would only produce a first-boot failure in the +# journal that everyone learns to ignore. +enable = false +start = false +restart-after-upgrade = true diff --git a/crates/cli/debian/README.Debian b/crates/cli/debian/README.Debian new file mode 100644 index 0000000..f384f1e --- /dev/null +++ b/crates/cli/debian/README.Debian @@ -0,0 +1,31 @@ +filterframe on Debian and Ubuntu +================================ + +The package installs disabled and stopped. filterframe cannot do anything +useful until an operator supplies a configuration naming its BGP peers and the +policy engine to poll, so starting it automatically would only produce a noisy +first-boot failure. + +To bring it up: + + 1. cp /etc/filterframe/example.conf /etc/filterframe/filterframe.conf + 2. edit it — every directive is documented in place + 3. filterframe preflight # validates config and probes the environment + 4. systemctl enable --now filterframe + +filterframe starts in observe mode by default. It runs the whole loop and +computes every decision without announcing anything. Read `filterframe status` +until the decisions match what you expect, then set `mode enforce` and restart. + +Running the GoBGP backend +------------------------- + +The default unit deliberately does not depend on gobgp.service, because +deployments using the embedded speaker do not run it. If you set `bgp mode +gobgp`, add a drop-in: + + systemctl edit filterframe + + [Unit] + After=gobgp.service + Wants=gobgp.service diff --git a/crates/cli/debian/filterframe.service b/crates/cli/debian/filterframe.service new file mode 100644 index 0000000..7518fbb --- /dev/null +++ b/crates/cli/debian/filterframe.service @@ -0,0 +1,59 @@ +[Unit] +Description=filterframe DDoS mitigation control plane +Documentation=https://github.com/unredacted/filterframe +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/filterframe run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=5 + +# State lives here: the divert journal, the pidfile, and the reconfigure ack +# marker. 0750 because the journal records which prefixes are under mitigation, +# which is not something every account on the box needs to read. +StateDirectory=filterframe +StateDirectoryMode=0750 +RuntimeDirectory=filterframe + +# An in-flight divert sequence must be allowed to reach a safe journal state +# before it is killed. KillMode=mixed sends SIGTERM to the main process only, +# and 30s is well above the worst-case single step. +KillMode=mixed +TimeoutStopSec=30 +SyslogIdentifier=filterframe + +# filterframe holds BGP sessions and reads sysfs. It needs no privilege for +# either, and the empty capability set below is what makes the additive-only +# invariant enforceable rather than merely intended: a daemon with no +# CAP_NET_ADMIN cannot install a route, rewrite a firewall rule, or take an +# interface down, however badly it is compromised or however wrong its inputs. +# +# Do not add capabilities here. If a future feature appears to need them, ship +# it as a separate opt-in drop-in so that deployments which do not use it keep +# the guarantee. +CapabilityBoundingSet= +AmbientCapabilities= +NoNewPrivileges=yes + +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/crates/cli/src/atomic.rs b/crates/cli/src/atomic.rs new file mode 100644 index 0000000..1eddfa2 --- /dev/null +++ b/crates/cli/src/atomic.rs @@ -0,0 +1,186 @@ +//! Atomic file replacement. +//! +//! Everything filterframe writes to its state directory — the divert journal, +//! the reconfigure acknowledgement marker, the metrics textfile, the status +//! snapshot — is read by something else while the daemon is running. A reader +//! must never observe a half-written file, so every write goes through +//! write-to-temp, fsync, rename, fsync-the-directory. +//! +//! The `fsync` on the directory is the part people leave out. Without it the +//! rename is durable only once the filesystem gets round to it, which on a node +//! that has just lost power is exactly when the journal mattered. +//! +//! Temp files are created with `create_new`, so two writers racing produce an +//! error rather than interleaved bytes, and the temp name carries the process +//! id so a crashed run leaves an obviously-orphaned file rather than one that +//! looks live. + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Why an atomic write could not be completed. +/// +/// One variant per stage, because the operator response differs: a `Symlink` +/// refusal means someone has put something in the state directory that should +/// not be there, where `Io` usually means the disk is full or the directory +/// does not exist. +#[derive(Debug, thiserror::Error)] +pub enum AtomicError { + #[error("{path} has no parent directory")] + NoParent { path: PathBuf }, + + #[error("refusing to write through a symlink at {path}")] + Symlink { path: PathBuf }, + + #[error("writing {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +type Result = std::result::Result; + +fn io(path: &Path) -> impl Fn(std::io::Error) -> AtomicError + '_ { + move |source| AtomicError::Io { + path: path.to_path_buf(), + source, + } +} + +/// Replace `path` with `contents`, atomically. +/// +/// Refuses if `path` is a symlink. filterframe writes into a directory it owns, +/// and a symlink appearing there is either a mistake or an attempt to make a +/// root-owned daemon write somewhere it should not — neither is worth +/// following. +pub fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> Result<()> { + let path = path.as_ref(); + let parent = path.parent().ok_or_else(|| AtomicError::NoParent { + path: path.to_path_buf(), + })?; + + if fs::symlink_metadata(path) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(AtomicError::Symlink { + path: path.to_path_buf(), + }); + } + + let tmp = parent.join(format!( + ".{}.tmp.{}", + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("filterframe"), + std::process::id() + )); + + // create_new so two writers racing fail loudly instead of interleaving. + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp) + .map_err(io(&tmp))?; + + let result = (|| { + file.write_all(contents.as_ref()).map_err(io(&tmp))?; + file.sync_all().map_err(io(&tmp)) + })(); + drop(file); + + if let Err(e) = result { + let _ = fs::remove_file(&tmp); + return Err(e); + } + + if let Err(e) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(AtomicError::Io { + path: path.to_path_buf(), + source: e, + }); + } + + // Durability of the rename itself. Skipped silently on platforms where + // opening a directory for sync is not permitted — the rename has still + // happened, it is only the ordering guarantee that is weaker. + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tempdir() -> PathBuf { + let d = std::env::temp_dir().join(format!( + "ff-atomic-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn writes_and_replaces() { + let d = tempdir(); + let f = d.join("state.json"); + + write(&f, b"first").unwrap(); + assert_eq!(fs::read_to_string(&f).unwrap(), "first"); + + write(&f, b"second").unwrap(); + assert_eq!(fs::read_to_string(&f).unwrap(), "second"); + + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn leaves_no_temp_files_behind() { + let d = tempdir(); + write(d.join("a"), b"x").unwrap(); + let leftovers: Vec<_> = fs::read_dir(&d) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) + .filter(|n| n.contains(".tmp.")) + .collect(); + assert!(leftovers.is_empty(), "temp files left: {leftovers:?}"); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn refuses_to_follow_a_symlink() { + let d = tempdir(); + let target = d.join("elsewhere"); + let link = d.join("state.json"); + fs::write(&target, b"original").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let err = write(&link, b"hijacked").unwrap_err(); + assert!(matches!(err, AtomicError::Symlink { .. }), "{err:?}"); + assert_eq!( + fs::read_to_string(&target).unwrap(), + "original", + "the symlink target must be untouched" + ); + + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn missing_directory_is_an_io_error_naming_the_path() { + let d = tempdir(); + let err = write(d.join("nope").join("f"), b"x").unwrap_err(); + assert!(matches!(err, AtomicError::Io { .. }), "{err:?}"); + fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/crates/cli/src/daemon.rs b/crates/cli/src/daemon.rs new file mode 100644 index 0000000..ec2b412 --- /dev/null +++ b/crates/cli/src/daemon.rs @@ -0,0 +1,620 @@ +//! The daemon loop: signals, reload, and shutdown. +//! +//! filterframe runs in the foreground and never forks. systemd is the process +//! supervisor, and a daemon that daemonises itself only makes that harder. +//! +//! Signals are **polled** rather than handled on a dedicated thread. The same +//! loop owns the reconcile tick, module health, and the metrics writer, and a +//! polled signal set keeps all of that in one place with no shared mutable +//! state between a handler thread and the loop. The cost is up to +//! [`SIGNAL_POLL`] of latency answering a signal, which is not a cost anyone +//! notices; the benefit is that there is exactly one place where the daemon +//! decides to do something. +//! +//! ## Reload +//! +//! SIGHUP re-reads the configuration, refuses it if it changes something that +//! is bound at startup, and writes the outcome to an acknowledgement marker in +//! the state directory. `filterframe reconfigure` sends the signal and polls +//! that marker, so an operator gets an exit code rather than having to read the +//! journal to find out whether their edit took. +//! +//! A refused or malformed reload **never** takes the daemon down. The running +//! configuration stays in force. A daemon that exits because someone mistyped a +//! duration during an incident is worse than one that keeps going with the +//! configuration it already had. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use filterframe_bgp::MockSpeaker; +use filterframe_common::Config; +use filterframe_common::config::{BgpMode, Mode}; +use filterframe_common::module::TierModule; +use filterframe_policy::HttpPolicySource; +use filterframe_rtbh::RtbhModule; +use filterframe_scrub_divert::ScrubDivertModule; +use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM, SIGUSR1}; +use signal_hook::iterator::Signals; + +use crate::atomic; +use crate::logging; +use crate::metrics::Metrics; +use crate::presence; +use crate::probe::ReturnPathProbe; +use crate::reconcile::{Reconciler, tick_log_line}; +use crate::status; + +/// How often the loop looks for a signal. +/// +/// 250ms is comfortably below any human's perception of "did that command do +/// anything", and far below the reconcile tick, so signals are never queued +/// behind real work. +pub const SIGNAL_POLL: Duration = Duration::from_millis(250); + +/// Name of the reload acknowledgement marker in the state directory. +pub const RECONFIGURE_MARKER: &str = "last-reconfigure"; + +/// How long `filterframe reconfigure` waits for the daemon to acknowledge. +/// +/// A reload re-reads and validates a file; if it has not finished in five +/// seconds the daemon is wedged, and saying so is more useful than waiting. +pub const RECONFIGURE_TIMEOUT: Duration = Duration::from_secs(5); +const RECONFIGURE_POLL: Duration = Duration::from_millis(100); + +/// Why the loop stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Termination { + /// SIGTERM or SIGINT. + /// + /// filterframe does **not** withdraw its announcements on the way out. They + /// are protecting something, and an upgrade restart must not un-blackhole a + /// host mid-attack. `filterframe drain` is the explicit way to take a node + /// out of service, and it is never implicit. + Requested, +} + +/// Errors that end a run. +/// +/// A `Runtime` variant, mapping to a distinct exit code, lands with the +/// reconcile tick that can produce one. Declaring it before anything returns it +/// would only be a dead-code allow. +#[derive(Debug, thiserror::Error)] +pub enum RunError { + /// Something was wrong before the loop started. Nothing was announced. + #[error("startup: {0}")] + Startup(String), +} + +/// Removes the pidfile when the run ends, however it ends. +/// +/// A `Drop` guard rather than cleanup at the end of `run`, so that an early +/// return or a panic still leaves the state directory tidy. +struct PidfileGuard { + state_dir: PathBuf, +} + +impl Drop for PidfileGuard { + fn drop(&mut self) { + presence::remove_pidfile(&self.state_dir); + } +} + +/// Run the daemon until a signal asks it to stop. +/// +/// `config_path` is retained so SIGHUP can re-read the same file rather than +/// whatever the process's working directory now points at. +pub fn run(config: Config, config_path: PathBuf) -> Result { + let state_dir = config.global.state_dir.clone(); + + std::fs::create_dir_all(&state_dir).map_err(|e| { + RunError::Startup(format!( + "cannot create state directory {}: {e}", + state_dir.display() + )) + })?; + + // Refuse to start a second daemon over a live one. Two reconcilers + // converging the same prefixes would fight, and the loser would be + // whichever wrote last. + let existing = presence::check(&state_dir); + if existing.is_running() { + return Err(RunError::Startup(format!( + "another filterframe is already {} using {}", + existing.describe(), + state_dir.display() + ))); + } + if let presence::Presence::Unknown { why } = &existing { + // Not fatal, but the operator should know we proceeded on an + // unanswered question rather than a clear one. + tracing::warn!( + reason = %why, + "could not confirm no other daemon is running; starting anyway" + ); + } + + presence::write_pidfile(&state_dir) + .map_err(|e| RunError::Startup(format!("cannot write pidfile: {e}")))?; + let _guard = PidfileGuard { + state_dir: state_dir.clone(), + }; + + let mut signals = Signals::new([SIGTERM, SIGINT, SIGHUP, SIGUSR1]) + .map_err(|e| RunError::Startup(format!("cannot install signal handlers: {e}")))?; + + let mut current = config; + + tracing::info!( + node_id = %current.global.node_id, + mode = current.global.mode.as_str(), + tick_interval_secs = current.global.tick_interval.as_secs(), + peers = current.peers.len(), + "filterframe running" + ); + if current.global.mode == filterframe_common::config::Mode::Observe { + tracing::info!( + "observe mode: decisions will be computed and recorded, and nothing will be announced" + ); + } + + // One runtime, owned by the loop. The signal poll stays synchronous — it is + // the one place the daemon decides to act — and each tick is driven to + // completion inside it. A tick that overruns delays the next one rather + // than overlapping with it, because two reconcilers converging the same + // prefixes would fight. + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .map_err(|e| RunError::Startup(format!("cannot start the async runtime: {e}")))?; + + let token = match ¤t.policy_source.token_file { + Some(path) => Some(read_token(path)?), + None => None, + }; + + let policy = HttpPolicySource::new(¤t.policy_source, token) + .map_err(|e| RunError::Startup(e.to_string()))?; + + // The mock is not a stand-in here: in observe mode it *is* the speaker, and + // running the whole loop against it is the point. Enforce mode needs a real + // backend, and refusing to start is better than announcing nothing while + // reporting success. + if current.global.mode == Mode::Enforce { + return Err(RunError::Startup(format!( + "`mode enforce` needs a BGP backend, and `{}` is not built yet in this version. \ + Run in `mode observe` — the full loop runs and every decision is recorded, \ + nothing is announced.", + current.bgp.mode.as_str() + ))); + } + if current.bgp.mode == BgpMode::Gobgp { + tracing::info!( + "bgp mode is gobgp, but observe mode announces nothing, so no sidecar is contacted" + ); + } + + // Modules are constructed from their own sections and validate their own + // directives, so a typo in a safety guard fails here rather than being + // silently absent at three in the morning. + let mut modules: Vec> = Vec::new(); + let mut return_probe: Option = None; + for section in ¤t.modules { + match section.name.as_str() { + "rtbh" => { + let mut m = RtbhModule::new(); + m.configure(section) + .map_err(|e| RunError::Startup(format!("module rtbh: {e}")))?; + modules.push(Box::new(m)); + } + "scrub-divert" => { + let mut m = ScrubDivertModule::new(); + m.configure(section) + .map_err(|e| RunError::Startup(format!("module scrub-divert: {e}")))?; + // The tier is gated on the return path, so the probe is built + // from the module's own configuration rather than a second + // place an operator has to keep in step. + if let Some(iface) = m.return_tunnel() { + return_probe = Some(ReturnPathProbe::new(iface)); + } else { + tracing::warn!( + "scrub-divert has no `return-tunnel`; diversion will not be gated on \ + the return path, so a dead tunnel will not stop a divert" + ); + } + modules.push(Box::new(m)); + } + other => { + return Err(RunError::Startup(format!( + "line {}: no module named `{}` is built into this binary", + section.line, other + ))); + } + } + } + // A tier with no module cannot be served, and a rule selecting it would + // fire into nothing. Better to say so at startup than to look healthy. + if modules.is_empty() { + tracing::warn!( + "no mitigation modules are configured; filterframe will poll and decide, \ + and hold nothing" + ); + } + + let mut reconciler = Reconciler::new(current.clone(), policy, MockSpeaker::new(), modules); + + // Recovery before convergence. The journal says which way each sequence was + // going when the last process died — the one thing that cannot be + // re-derived from the world — and every module must have that back before + // anything is compared or withdrawn. + let recovered = crate::journal::read(&state_dir); + if !recovered.is_empty() { + tracing::info!( + modules = recovered.len(), + "resuming from a journal left by a previous run" + ); + reconciler.restore_journal(&recovered, std::time::Instant::now()); + } + + let mut next_tick = std::time::Instant::now(); + let mut last_journal: std::collections::BTreeMap = recovered; + let started = std::time::Instant::now(); + let mut metrics = Metrics::new(current.global.mode == Mode::Observe, started); + let mut next_metrics = started; + let mut last_fresh_unix: Option = None; + + loop { + for signal in signals.pending() { + match signal { + SIGTERM | SIGINT => { + tracing::info!( + "termination requested; announcements are left in place \ + (use `filterframe drain` to withdraw them)" + ); + // One last write, so a scrape or a `status` after shutdown + // reads the final numbers rather than whatever was there a + // quarter of a minute ago. + if let Some(path) = ¤t.global.metrics_textfile { + let _ = + metrics.write(path, ¤t.global.node_id, std::time::Instant::now()); + } + return Ok(Termination::Requested); + } + SIGHUP => { + let outcome = reload(¤t, &config_path); + match outcome { + Ok(new) => { + logging::apply_config_level(new.global.log_level); + tracing::info!( + path = %config_path.display(), + "configuration reloaded" + ); + current = new.clone(); + reconciler.reconfigure(new); + write_marker(&state_dir, "OK reloaded"); + } + Err(why) => { + tracing::warn!( + path = %config_path.display(), + reason = %why, + "configuration NOT reloaded; the running configuration stays in force" + ); + write_marker(&state_dir, &format!("ERR {why}")); + } + } + } + SIGUSR1 => { + // Reserved for the circuit-breaker trip once there is + // anything to trip. Acknowledged rather than ignored, so an + // operator who sends it is not left wondering. + tracing::info!("SIGUSR1 received; no action is wired to it yet"); + } + other => tracing::debug!(signal = other, "ignoring signal"), + } + } + + // The return path is checked on its own cadence, not the tick's: a + // tunnel that dies while diverted is an outage for the whole prefix, + // and waiting out a tick interval to notice is a second too long. + let now = std::time::Instant::now(); + if let Some(probe) = &mut return_probe { + let before = probe.gate().allows_divert(); + let gate = probe.poll(now).clone(); + if before != gate.allows_divert() { + if gate.allows_divert() { + tracing::info!(interface = probe.interface(), "return path is usable again"); + } else { + tracing::error!( + interface = probe.interface(), + state = %gate.describe(), + "return path is not usable; diversion is blocked" + ); + } + } + reconciler.set_return_path(gate.as_return_path(), now); + } + + if now >= next_tick { + let outcome = runtime.block_on(reconciler.tick(now)); + if let Some(line) = tick_log_line(&outcome) { + tracing::info!("{line}"); + } + + let engagements = runtime.block_on(reconciler.engagements()); + metrics.record(&outcome, engagements.len(), now); + if outcome.fresh { + last_fresh_unix = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ); + } + + // Published every tick so `filterframe status` works with no daemon + // running — which is exactly when an operator reaches for it. + let snap = status::snapshot_from( + reconciler.config(), + &outcome, + &engagements + .iter() + .map(ToString::to_string) + .collect::>(), + last_fresh_unix, + ); + if let Err(e) = status::write(&state_dir, &snap) { + tracing::warn!(error = %e, "cannot write the status snapshot"); + } + // The journal is rewritten only when a module's intent actually + // changed. At a two-second tick, writing unconditionally would be + // forty-three thousand fsyncs a day to record nothing. + let snapshot = reconciler.journal(); + if snapshot != last_journal { + if let Err(e) = crate::journal::write(&state_dir, snapshot.clone()) { + tracing::error!(error = %e, "cannot write the journal; a restart may resume in the wrong direction"); + } else { + last_journal = snapshot; + } + } + + // Scheduled from the end of the tick, not the start, so a slow tick + // does not immediately trigger the next one and pile up. + next_tick = std::time::Instant::now() + reconciler.config().global.tick_interval; + } + + // Metrics on their own cadence, matched to a scrape interval rather + // than to the tick: writing four times as often would be four times the + // fsyncs for data nobody reads in between. + let now = std::time::Instant::now(); + if now >= next_metrics + && let Some(path) = ¤t.global.metrics_textfile + { + if let Err(e) = metrics.write(path, ¤t.global.node_id, now) { + tracing::warn!(error = %e, "cannot write the metrics textfile"); + } + next_metrics = now + crate::metrics::WRITE_INTERVAL; + } + + std::thread::sleep(SIGNAL_POLL); + } +} + +/// Re-read the configuration and decide whether it may be applied live. +/// +/// Returns the new configuration, or a sentence explaining the refusal that is +/// fit to put in front of an operator. +fn reload(current: &Config, path: &Path) -> Result { + let new = Config::from_file(path).map_err(|e| e.to_string())?; + current.restart_only_delta(&new)?; + Ok(new) +} + +/// Record the outcome of a reload where `filterframe reconfigure` can find it. +fn write_marker(state_dir: &Path, body: &str) { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let contents = format!("{stamp} {body}\n"); + if let Err(e) = atomic::write(state_dir.join(RECONFIGURE_MARKER), contents) { + tracing::warn!(error = %e, "could not write the reconfigure marker"); + } +} + +/// What `filterframe reconfigure` observed. +#[derive(Debug, PartialEq, Eq)] +pub enum ReconfigureOutcome { + Applied, + /// The daemon read the file and declined it. The message is the daemon's. + Refused(String), + /// No daemon to signal. + NotRunning, + /// Signalled, but nothing acknowledged within the timeout. + TimedOut, +} + +/// Signal a running daemon to reload, and wait for it to say what happened. +/// +/// The marker is read *before* signalling so that a stale one from a previous +/// reload is not mistaken for this one's answer. +pub fn reconfigure(state_dir: &Path) -> ReconfigureOutcome { + let marker = state_dir.join(RECONFIGURE_MARKER); + let before = std::fs::read_to_string(&marker).unwrap_or_default(); + + let presence::Presence::Running { pid } = presence::check(state_dir) else { + return ReconfigureOutcome::NotRunning; + }; + + #[cfg(unix)] + { + let rc = unsafe { libc::kill(pid as libc::pid_t, libc::SIGHUP) }; + if rc != 0 { + return ReconfigureOutcome::Refused(format!( + "could not signal pid {pid}: {}", + std::io::Error::last_os_error() + )); + } + } + + let deadline = std::time::Instant::now() + RECONFIGURE_TIMEOUT; + while std::time::Instant::now() < deadline { + std::thread::sleep(RECONFIGURE_POLL); + let now = std::fs::read_to_string(&marker).unwrap_or_default(); + if now == before || now.trim().is_empty() { + continue; + } + let body = now.split_once(' ').map(|(_, b)| b.trim()).unwrap_or(""); + return match body.strip_prefix("ERR ") { + Some(why) => ReconfigureOutcome::Refused(why.to_string()), + None => ReconfigureOutcome::Applied, + }; + } + + ReconfigureOutcome::TimedOut +} + +/// Read a bearer token, refusing one that anyone on the box can read. +/// +/// The configuration file is installed 0644 because it is documentation; the +/// token is not, and a token readable by every account is a token that has +/// already leaked. Refused at startup rather than warned about, because a +/// warning in a startup log is a warning nobody reads. +fn read_token(path: &Path) -> Result { + let meta = std::fs::metadata(path).map_err(|e| { + RunError::Startup(format!("cannot read token file {}: {e}", path.display())) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = meta.permissions().mode() & 0o077; + if mode != 0 { + return Err(RunError::Startup(format!( + "{} is readable by group or others (mode {:o}); \ + chmod 600 it before starting", + path.display(), + meta.permissions().mode() & 0o777 + ))); + } + } + + let token = std::fs::read_to_string(path).map_err(|e| { + RunError::Startup(format!("cannot read token file {}: {e}", path.display())) + })?; + let token = token.trim().to_string(); + if token.is_empty() { + return Err(RunError::Startup(format!( + "{} is empty; the policy engine will reject every request", + path.display() + ))); + } + Ok(token) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMAL: &str = r#" +global + node-id test1 +policy-source + url https://policy.example.net +bgp + mode embedded + local-as 64512 + originate-prefix 198.51.100.0/24 +peer t1 + role transit + address 203.0.113.1 + remote-as 64510 + community blackhole + allow-tier rtbh +"#; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ff-daemon-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn a_hot_reloadable_edit_is_accepted() { + let d = tempdir("reload-ok"); + let path = d.join("filterframe.conf"); + std::fs::write(&path, MINIMAL).unwrap(); + let current = Config::from_file(&path).unwrap(); + + std::fs::write( + &path, + MINIMAL.replace(" node-id test1", " node-id test1\n log-level debug"), + ) + .unwrap(); + + let new = reload(¤t, &path).expect("log-level is hot-reloadable"); + assert_eq!( + new.global.log_level, + filterframe_common::config::LogLevel::Debug + ); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// A refused reload must surface the daemon's own explanation, not a + /// generic "reload failed". + #[test] + fn a_restart_only_edit_is_refused_with_the_reason() { + let d = tempdir("reload-refuse"); + let path = d.join("filterframe.conf"); + std::fs::write(&path, MINIMAL).unwrap(); + let current = Config::from_file(&path).unwrap(); + + std::fs::write( + &path, + MINIMAL.replace(" local-as 64512", " local-as 64513"), + ) + .unwrap(); + + let why = reload(¤t, &path).unwrap_err(); + assert!(why.contains("bgp"), "{why}"); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// The property that matters most about reload: a broken file must not be + /// able to take a running daemon down. + #[test] + fn a_malformed_file_is_refused_and_cites_its_line() { + let d = tempdir("reload-broken"); + let path = d.join("filterframe.conf"); + std::fs::write(&path, MINIMAL).unwrap(); + let current = Config::from_file(&path).unwrap(); + + std::fs::write(&path, "global\n nonsense yes\n").unwrap(); + + let why = reload(¤t, &path).unwrap_err(); + assert!(why.contains("line 2"), "should cite the line: {why}"); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn reconfigure_reports_not_running_when_there_is_no_daemon() { + let d = tempdir("reconf-none"); + assert_eq!(reconfigure(&d), ReconfigureOutcome::NotRunning); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn the_marker_records_both_outcomes_distinguishably() { + let d = tempdir("marker"); + write_marker(&d, "OK reloaded"); + let ok = std::fs::read_to_string(d.join(RECONFIGURE_MARKER)).unwrap(); + assert!(ok.contains("OK")); + + write_marker(&d, "ERR line 4: unknown directive"); + let err = std::fs::read_to_string(d.join(RECONFIGURE_MARKER)).unwrap(); + assert!(err.contains("ERR")); + assert!(err.contains("line 4")); + std::fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/crates/cli/src/journal.rs b/crates/cli/src/journal.rs new file mode 100644 index 0000000..cf106a8 --- /dev/null +++ b/crates/cli/src/journal.rs @@ -0,0 +1,186 @@ +//! Durable intent, and only that. +//! +//! Almost nothing needs persisting. Desired state is recomputed from the policy +//! engine every tick, and what is actually announced is read back from the +//! speaker — both are re-derivable, so writing them down would only create an +//! opportunity for the file and the world to disagree. +//! +//! One thing is not re-derivable, and it is why this file exists: **which +//! direction a sequence was moving when the process died.** A prefix announced +//! to both the scrubber and transit is simultaneously "engaging, step one done" +//! and "tearing down, step one done". Those want opposite continuations, and no +//! amount of reading the RIB distinguishes them. +//! +//! Each module owns its own format; this file only carries opaque blobs keyed +//! by module name, so adding a module never means changing the journal. +//! +//! It is **advisory about intent, never authoritative about the network.** A +//! journal saying "diverted" when the speaker disagrees does not make it so; +//! recovery resolves toward reachability and lets the reconciler re-assert. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::atomic; + +pub const JOURNAL: &str = "journal.json"; + +/// Bumped only for a change an older daemon could misread. A journal it cannot +/// understand is discarded rather than guessed at. +const FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct Document { + version: u32, + /// Module name to that module's own serialised state. + modules: BTreeMap, +} + +pub fn path(state_dir: impl AsRef) -> PathBuf { + state_dir.as_ref().join(JOURNAL) +} + +/// Record what each module is in the middle of. +/// +/// Written **after** the tick whose intent it records, once per tick and only +/// when that intent changed. +/// +/// This used to claim the opposite — written before the actions it authorises, +/// so that a process killed between the two over-states progress rather than +/// under-stating it. The daemon has never done that: a tick decides and acts as +/// one step, and the journal is written when it returns. So a process killed +/// mid-tick leaves an entry that *under*-states progress, which is the direction +/// the old comment said was dangerous. +/// +/// Rather than reorder the write, recovery was made indifferent to it. +/// `Machine::recover` now resolves a journaled `Diverted` to `Announcing`, so an +/// entry that over-states progress and one that under-states it both land on +/// "re-assert the scrubber, let transit come back, re-confirm before suppressing +/// it again". The ordering is no longer load-bearing, which is a better place for +/// a durability guarantee to be than in a comment about write order. +pub fn write( + state_dir: impl AsRef, + modules: BTreeMap, +) -> Result<(), atomic::AtomicError> { + let doc = Document { + version: FORMAT_VERSION, + modules, + }; + let body = serde_json::to_vec_pretty(&doc).expect("the journal document always serialises"); + atomic::write(path(state_dir), body) +} + +/// Read a journal, if there is a usable one. +/// +/// Every failure yields an empty result and says why. A missing journal is the +/// normal first start; a corrupt one falls back to adopting whatever the +/// speaker holds, which is the same conservative path a first start takes. +/// Refusing to boot over an unreadable journal would turn a cosmetic problem +/// into an outage. +pub fn read(state_dir: impl AsRef) -> BTreeMap { + let p = path(&state_dir); + + let raw = match std::fs::read_to_string(&p) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return BTreeMap::new(), + Err(e) => { + tracing::warn!(path = %p.display(), error = %e, "cannot read the journal"); + return BTreeMap::new(); + } + }; + + let doc: Document = match serde_json::from_str(&raw) { + Ok(d) => d, + Err(e) => { + tracing::error!( + path = %p.display(), + error = %e, + "the journal is unreadable; recovering from what the speaker holds instead" + ); + return BTreeMap::new(); + } + }; + + if doc.version != FORMAT_VERSION { + tracing::warn!( + found = doc.version, + expected = FORMAT_VERSION, + "the journal was written by a different version; ignoring it" + ); + return BTreeMap::new(); + } + + doc.modules +} + +// A `clear` helper lands with `drain`, the command that withdraws everything +// and takes a node out of service. It is not declared here first: a function +// nothing calls is a dead-code allow wearing a docstring. + +#[cfg(test)] +mod tests { + use super::*; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ff-journal-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn one(k: &str, v: &str) -> BTreeMap { + BTreeMap::from([(k.to_string(), v.to_string())]) + } + + #[test] + fn a_journal_round_trips() { + let d = tempdir("roundtrip"); + let m = one("scrub-divert", r#"[["198.51.100.0/24","diverted"]]"#); + write(&d, m.clone()).unwrap(); + assert_eq!(read(&d), m); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// The normal first start. + #[test] + fn a_missing_journal_is_empty_not_an_error() { + let d = tempdir("missing"); + assert!(read(&d).is_empty()); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Refusing to boot over an unreadable journal would turn a cosmetic + /// problem into an outage. + #[test] + fn a_corrupt_journal_does_not_stop_recovery() { + let d = tempdir("corrupt"); + std::fs::write(path(&d), "{ this is not json").unwrap(); + assert!(read(&d).is_empty()); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn a_journal_from_another_format_version_is_ignored() { + let d = tempdir("version"); + std::fs::write(path(&d), r#"{"version":99,"modules":{"x":"y"}}"#).unwrap(); + assert!(read(&d).is_empty()); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Written through the atomic path, so a reader never sees a half-written + /// document and a symlink in the state directory is refused. + #[test] + fn writing_leaves_no_temporary_files() { + let d = tempdir("atomic"); + write(&d, one("scrub-divert", "[]")).unwrap(); + let leftovers: Vec<_> = std::fs::read_dir(&d) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) + .filter(|n| n.contains(".tmp.")) + .collect(); + assert!(leftovers.is_empty(), "{leftovers:?}"); + std::fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/crates/cli/src/logging.rs b/crates/cli/src/logging.rs new file mode 100644 index 0000000..71e4889 --- /dev/null +++ b/crates/cli/src/logging.rs @@ -0,0 +1,173 @@ +//! Logging setup, and the one thing about it that is load-bearing. +//! +//! `init()` is the first statement in `main`, before argument parsing, because +//! a configuration that fails to parse is itself something worth logging and +//! there is no earlier moment to be ready for it. It installs a filter that can +//! be swapped later, so the level from the config file can be applied once the +//! config has been read, and again on every reload, without tearing the +//! subscriber down. +//! +//! **Precedence is env-beats-file, decided once, and said out loud once.** If +//! `RUST_LOG` is set it wins for the life of the process and the daemon says so +//! at startup rather than silently ignoring a `log-level` line an operator is +//! staring at. A set-but-unparseable `RUST_LOG` warns and hands control back to +//! the file, because refusing to start over a malformed environment variable +//! helps nobody. + +use std::sync::OnceLock; + +use filterframe_common::config::LogLevel; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::reload; +use tracing_subscriber::util::SubscriberInitExt; + +/// Upstream targets whose default verbosity is not useful here. +/// +/// Each entry names the crate and the reason. They are demoted in every filter +/// the daemon builds, so raising the global level to `debug` during an incident +/// does not bury filterframe's own lines under someone else's. +/// +/// To get one back: `RUST_LOG=filterframe=debug,=debug`. +const NOISY_TARGETS: &[(&str, &str)] = &[ + // hyper logs every connection open and close at debug. The policy-source + // client opens one every tick, so at a 2s tick this is 43k lines a day of + // "connection established" surrounding the six lines that matter. + ("hyper", "warn"), + ("hyper_util", "warn"), + // h2 traces frame-level flow control at debug, which is only ever useful + // when debugging h2 itself. + ("h2", "warn"), + // rustls logs the full handshake at debug on every reconnect. + ("rustls", "warn"), + ("tower", "warn"), +]; + +/// Whether the file-configured level has any effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterSource { + /// `RUST_LOG` was set and parsed. The config file's `log-level` is inert + /// for the life of the process. + Environment, + /// The config file is in control. + ConfigFile, +} + +struct LogControl { + handle: reload::Handle, + source: FilterSource, +} + +static CONTROL: OnceLock = OnceLock::new(); + +/// Install the subscriber. Call once, first thing in `main`. +/// +/// Returns whether the environment or the config file will control the level, +/// so the caller can report it rather than leaving an operator to guess why +/// their `log-level` line does nothing. +pub fn init() -> FilterSource { + let (filter, source) = match std::env::var("RUST_LOG") { + Ok(spec) if !spec.trim().is_empty() => match EnvFilter::try_new(&spec) { + Ok(f) => (f, FilterSource::Environment), + Err(e) => { + // Deliberately not fatal. A malformed RUST_LOG is a mistake in + // a unit file or a shell, and refusing to start over it turns a + // typo into an outage. + eprintln!( + "filterframe: RUST_LOG=\"{spec}\" could not be parsed ({e}); \ + falling back to the configured log level" + ); + (base_filter(LogLevel::Info), FilterSource::ConfigFile) + } + }, + _ => (base_filter(LogLevel::Info), FilterSource::ConfigFile), + }; + + let (layer, handle) = reload::Layer::new(filter); + + tracing_subscriber::registry() + .with(layer) + .with( + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_target(false) + .compact(), + ) + .init(); + + let _ = CONTROL.set(LogControl { handle, source }); + source +} + +/// Apply the level from the configuration. +/// +/// A no-op when `RUST_LOG` is in control — the caller does not need to check +/// first, and the asymmetry is deliberate: whoever set the environment variable +/// is debugging something and should not have it undone by a reload. +pub fn apply_config_level(level: LogLevel) { + let Some(control) = CONTROL.get() else { + // init() was never called. Only reachable in tests that use tracing + // without a subscriber, where doing nothing is right. + return; + }; + if control.source == FilterSource::Environment { + return; + } + if let Err(e) = control.handle.reload(base_filter(level)) { + // The subscriber is gone, which means the process is on its way down. + eprintln!("filterframe: could not apply log level {level:?}: {e}"); + } +} + +/// Build a filter for `level`, with the noisy upstream targets demoted. +fn base_filter(level: LogLevel) -> EnvFilter { + let mut spec = String::from(level.as_str()); + for (target, cap) in NOISY_TARGETS { + spec.push(','); + spec.push_str(target); + spec.push('='); + spec.push_str(cap); + } + // The spec is built from a closed set of constants, so a parse failure here + // is a bug in this function rather than anything an operator did. + EnvFilter::try_new(&spec).expect("filter spec is built from constants") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_level_produces_a_valid_filter() { + for level in [ + LogLevel::Trace, + LogLevel::Debug, + LogLevel::Info, + LogLevel::Warn, + LogLevel::Error, + ] { + let f = base_filter(level); + assert!(f.to_string().contains(level.as_str())); + } + } + + /// The whole point of the demotion list: raising filterframe to debug must + /// not raise hyper with it. + #[test] + fn noisy_targets_are_demoted_at_every_level() { + let f = base_filter(LogLevel::Debug).to_string(); + for (target, cap) in NOISY_TARGETS { + assert!( + f.contains(&format!("{target}={cap}")), + "{target} not demoted in {f}" + ); + } + } + + #[test] + fn apply_before_init_is_a_no_op_rather_than_a_panic() { + // CONTROL is unset in this test binary unless another test raced to + // init, and either way this must not panic. + apply_config_level(LogLevel::Debug); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..ee2c66e --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,533 @@ +//! The filterframe binary. +//! +//! filterframe executes DDoS mitigations that a policy engine has already +//! decided on, as BGP announcements: a host route with a blackhole community +//! for the fast tier, and a scrubbing-provider diversion for the slow one. It +//! never detects an attack, never forwards a packet, and never configures an +//! interface. +//! +//! The invariant everything else derives from is that filterframe only ever +//! *adds* BGP objects. It never withdraws a route that is carrying traffic, so +//! its own death degrades toward normal routing rather than toward an outage. + +mod atomic; +mod daemon; +mod journal; +mod logging; +mod metrics; +mod plan_cmd; +mod presence; +mod probe; +mod reconcile; +mod status; +mod tunnel; + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use filterframe_common::Config; + +/// Success. +const EXIT_OK: u8 = 0; +/// Refused before anything was announced: bad configuration, failed validation, +/// or a startup precondition that did not hold. +const EXIT_STARTUP_ERROR: u8 = 1; +/// The daemon was running and something failed afterwards. +const EXIT_RUNTIME_ERROR: u8 = 2; + +/// Where the daemon looks when `--config` is not given. +/// +/// Deliberately not the shipped `example.conf`: that file is a document, and an +/// operator who has not yet written a configuration should get "no such file" +/// rather than a daemon that starts with someone else's peers. +const DEFAULT_CONFIG: &str = "/etc/filterframe/filterframe.conf"; + +#[derive(Parser)] +#[command( + name = "filterframe", + version, + about = "DDoS mitigation control plane.", + long_about = None +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run the daemon in the foreground. + /// + /// filterframe does not fork. systemd is the supervisor, and a daemon that + /// backgrounds itself only makes supervision harder. The process runs until + /// SIGTERM or SIGINT, reloads on SIGHUP, and leaves its announcements in + /// place when it exits — they are protecting something, and a restart must + /// not un-blackhole a host mid-attack. + Run { + #[arg(long, value_name = "PATH")] + config: Option, + }, + + /// Parse and validate a configuration without starting anything. + /// + /// Checks the grammar and every cross-section rule — that peers can serve + /// the tiers they are allowed, that the authority boundary covers what the + /// modules want to announce, that no rule is shadowed by an earlier one. + /// Touches no network and no router. + Check { + #[arg(long, value_name = "PATH")] + config: Option, + }, + + /// Reload a running daemon's configuration. + /// + /// Sends SIGHUP and waits for the daemon to say what happened, so this + /// exits non-zero when an edit is refused rather than leaving you to read + /// the journal. Equivalent to `systemctl reload filterframe`. + /// + /// Directives bound at startup — the BGP section, the peer list, the + /// operating mode — are refused by name. The running configuration always + /// stays in force. + Reconfigure { + #[arg(long, value_name = "PATH")] + config: Option, + }, + + /// Show what filterframe would do with a given set of mitigations. + /// + /// Prints the decision for every mitigation — which tier, which rule line + /// decided it, and why anything was left unhandled — followed by the + /// desired set of engagements. Announces nothing and contacts nothing. + /// + /// With `--from-file` it reads a recorded policy-engine response instead of + /// polling, which makes it a genuine dry run of the daemon's reasoning that + /// can be exercised offline, in review, and in CI. + Plan { + #[arg(long, value_name = "PATH")] + config: Option, + + /// A recorded `GET /v1/mitigations` response body to reason about. + #[arg(long, value_name = "PATH")] + from_file: PathBuf, + + /// Emit JSON rather than the human-readable rendering. + #[arg(long)] + json: bool, + }, + + /// Show what filterframe is doing. + /// + /// Reads the snapshot the daemon publishes to its state directory, so it + /// works with no daemon running — which is often exactly why you are + /// running it. The daemon's presence is reported as the three-valued thing + /// it is: running, not running, or cannot be determined, never guessed. + Status { + #[arg(long, value_name = "PATH")] + config: Option, + + /// Emit the raw snapshot as JSON rather than the report. + #[arg(long)] + json: bool, + }, + + /// Explain what filterframe would decide for a hypothetical mitigation. + /// + /// Answers "why is this blackholed" and "why isn't it" without waiting for + /// it to happen again. Prints which rule matched, on which line, and what + /// the resulting engagement would be — or why there is none. + Explain { + #[arg(long, value_name = "PATH")] + config: Option, + + /// The address under attack. + victim: std::net::IpAddr, + + /// What the policy engine chose. Rules matching on `action` need this. + #[arg(long, default_value = "discard")] + action: String, + + /// The attack vector the detector reported. + #[arg(long, default_value = "udp_flood")] + vector: String, + + /// How long the mitigation has existed, e.g. `30s` or `5m`. + #[arg(long, default_value = "60s", value_parser = parse_duration_arg)] + age: std::time::Duration, + + /// Observed attack rate, e.g. `2gbps`. Omit to model having no sample, + /// which is what a rate rule sees when the history endpoint is empty. + #[arg(long, value_parser = parse_bps_arg)] + bps: Option, + }, + + /// Print the return-path configuration for an operator to install. + /// + /// filterframe verifies the return path and refuses to divert when it is + /// not usable. It does not create it: that needs CAP_NET_ADMIN, which is + /// root over the whole dataplane, and the daemon ships with an empty + /// capability set on purpose. This emits the systemd-networkd units with + /// the MTU arithmetic and the two settings people get wrong already done. + TunnelRender { + /// Interface name, matching `return-tunnel` in the config. + interface: String, + + /// Our end of the tunnel. + #[arg(long)] + local: String, + + /// The provider's end. + #[arg(long)] + remote: String, + + /// Provider-assigned GRE key, if they use one. Costs four bytes of MTU. + #[arg(long)] + key: Option, + + /// Address to put on the tunnel, e.g. the /30 the provider assigned. + #[arg(long)] + address: Option, + + /// Render an IPv6 tunnel. + #[arg(long)] + ipv6: bool, + }, + + /// Print the resolved version. + /// + /// Exists as a subcommand as well as a flag so that a package's install + /// verification has one command exercising argument parsing that exits zero + /// with no configuration file present. + Version, +} + +impl Command { + fn config_path(&self) -> PathBuf { + let explicit = match self { + Self::Run { config } + | Self::Check { config } + | Self::Reconfigure { config } + | Self::Plan { config, .. } + | Self::Status { config, .. } + | Self::Explain { config, .. } => config.as_ref(), + Self::Version | Self::TunnelRender { .. } => None, + }; + explicit + .cloned() + .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG)) + } +} + +/// Accept the same duration spellings the config grammar does, so an operator +/// does not have to learn a second set for the CLI. +fn parse_duration_arg(s: &str) -> Result { + filterframe_common::config::parse_duration(s) +} + +fn parse_bps_arg(s: &str) -> Result { + filterframe_common::config::parse_bps(s) +} + +/// Dry-run the rule table against one hypothetical mitigation. +fn explain( + cfg: &Config, + victim: std::net::IpAddr, + action: &str, + vector: &str, + age: std::time::Duration, + bps: Option, +) { + use filterframe_common::mitigation::{ActionType, Mitigation, MitigationStatus}; + + let m = Mitigation { + id: "explain".into(), + victim, + status: MitigationStatus::Active, + action: match action { + "police" => ActionType::Police, + "discard" => ActionType::Discard, + other => ActionType::Other(other.to_string()), + }, + vector: vector.to_string(), + customer: None, + pop: cfg.policy_source.pop.clone(), + acknowledged: false, + age, + ttl_remaining: None, + bps, + }; + + println!("Given a mitigation for {victim}:"); + println!(" action {action}"); + println!(" vector {vector}"); + println!(" age {}s", age.as_secs()); + match bps { + Some(v) => println!(" rate {v} bps"), + // Worth stating outright: it is the single most common reason a divert + // rule does not fire, and it looks like nothing at all in the output. + None => println!(" rate no sample (rules matching on rate cannot fire)"), + } + println!(); + + match filterframe_common::plan::classify(&cfg.tier_rules, &m) { + Some(rule) => { + println!( + "Matched the rule on line {}: {}", + rule.line, + match rule.tier { + Some(t) => format!("tier {}", t.as_str()), + None => "explicitly refused (tier-rule none)".to_string(), + } + ); + for fact in &rule.facts { + println!(" because {fact:?}"); + } + } + None => { + println!("No rule matched. The mitigation would be reported as unhandled."); + return; + } + } + + let plan = filterframe_common::plan::desired_state(cfg, std::slice::from_ref(&m)); + println!(); + if plan.engagements.is_empty() { + println!("Resulting engagements: none."); + for d in plan.unhandled() { + if let Some(u) = &d.unhandled { + println!(" not actionable here: {u:?}"); + } + } + } else { + println!("Resulting engagements:"); + for e in &plan.engagements { + println!(" {} {}", e.tier.as_str(), e.prefix); + } + } +} + +fn main() -> ExitCode { + // First statement, before argument parsing: a configuration that fails to + // parse is itself worth logging, and there is no earlier moment to be ready. + let filter_source = logging::init(); + + let cli = Cli::parse(); + let path = cli.command.config_path(); + + match cli.command { + Command::TunnelRender { + interface, + local, + remote, + key, + address, + ipv6, + } => { + print!( + "{}", + tunnel::render(&tunnel::Request { + interface, + local, + remote, + key, + address, + ipv6, + }) + ); + ExitCode::from(EXIT_OK) + } + + Command::Version => { + println!("filterframe {}", filterframe_common::VERSION); + ExitCode::from(EXIT_OK) + } + + Command::Plan { + from_file, json, .. + } => { + let cfg = match Config::from_file(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(EXIT_STARTUP_ERROR); + } + }; + match plan_cmd::run(&cfg, &from_file, json) { + Ok(()) => ExitCode::from(EXIT_OK), + Err(e) => { + eprintln!("{e}"); + ExitCode::from(EXIT_STARTUP_ERROR) + } + } + } + + Command::Status { json, .. } => { + let cfg = match Config::from_file(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(EXIT_STARTUP_ERROR); + } + }; + let snap = status::read(&cfg.global.state_dir); + if json { + match &snap { + Some(s) => println!( + "{}", + serde_json::to_string_pretty(s).expect("the snapshot always serialises") + ), + None => println!("null"), + } + } else { + print!("{}", status::render(&cfg.global.state_dir, snap.as_ref())); + } + ExitCode::from(EXIT_OK) + } + + Command::Explain { + victim, + action, + vector, + age, + bps, + .. + } => { + let cfg = match Config::from_file(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(EXIT_STARTUP_ERROR); + } + }; + explain(&cfg, victim, &action, &vector, age, bps); + ExitCode::from(EXIT_OK) + } + + Command::Check { .. } => match Config::from_file(&path) { + Ok(cfg) => { + println!("{} is valid", path.display()); + println!(" node-id {}", cfg.global.node_id); + println!(" mode {}", cfg.global.mode.as_str()); + println!(" bgp {}", cfg.bgp.mode.as_str()); + println!(" peers {}", cfg.peers.len()); + println!(" tier rules {}", cfg.tier_rules.len()); + println!(" modules {}", cfg.modules.len()); + ExitCode::from(EXIT_OK) + } + Err(e) => { + eprintln!("{e}"); + ExitCode::from(EXIT_STARTUP_ERROR) + } + }, + + Command::Reconfigure { .. } => { + let cfg = match Config::from_file(&path) { + Ok(c) => c, + Err(e) => { + // Refuse before signalling: there is no point asking a + // healthy daemon to read a file we already know is broken. + eprintln!("{e}"); + eprintln!("not signalling the daemon; its running configuration is unchanged"); + return ExitCode::from(EXIT_STARTUP_ERROR); + } + }; + match daemon::reconfigure(&cfg.global.state_dir) { + daemon::ReconfigureOutcome::Applied => { + println!("configuration reloaded"); + ExitCode::from(EXIT_OK) + } + daemon::ReconfigureOutcome::Refused(why) => { + eprintln!("the daemon refused this configuration: {why}"); + eprintln!("its running configuration is unchanged"); + ExitCode::from(EXIT_STARTUP_ERROR) + } + daemon::ReconfigureOutcome::NotRunning => { + eprintln!( + "no filterframe is running with state directory {}", + cfg.global.state_dir.display() + ); + ExitCode::from(EXIT_STARTUP_ERROR) + } + daemon::ReconfigureOutcome::TimedOut => { + eprintln!( + "the daemon did not acknowledge within {}s; it may be wedged", + daemon::RECONFIGURE_TIMEOUT.as_secs() + ); + ExitCode::from(EXIT_RUNTIME_ERROR) + } + } + } + + Command::Run { .. } => { + let cfg = match Config::from_file(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(EXIT_STARTUP_ERROR); + } + }; + + logging::apply_config_level(cfg.global.log_level); + if filter_source == logging::FilterSource::Environment { + // Said once, out loud: otherwise an operator edits `log-level`, + // reloads, sees no change, and has no way to know why. + tracing::info!( + "RUST_LOG is set and takes precedence; the configured log-level is inert \ + for the life of this process" + ); + } + + match daemon::run(cfg, path) { + Ok(daemon::Termination::Requested) => { + tracing::info!("stopped"); + ExitCode::from(EXIT_OK) + } + Err(e @ daemon::RunError::Startup(_)) => { + tracing::error!(error = %e, "refusing to start"); + ExitCode::from(EXIT_STARTUP_ERROR) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + /// clap's derive macros can produce a definition that only fails at + /// runtime — a duplicate short flag, say. This is clap's own assertion + /// suite over the built command, and it is cheap enough to keep forever. + #[test] + fn cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn version_is_not_empty() { + assert!(!filterframe_common::VERSION.is_empty()); + } + + #[test] + fn config_defaults_to_the_documented_path() { + let c = Command::Run { config: None }; + assert_eq!(c.config_path(), PathBuf::from(DEFAULT_CONFIG)); + } + + #[test] + fn explicit_config_wins() { + let c = Command::Check { + config: Some(PathBuf::from("/tmp/other.conf")), + }; + assert_eq!(c.config_path(), PathBuf::from("/tmp/other.conf")); + } + + /// The default must not be the shipped example: an operator who has written + /// no configuration should get "no such file", not a daemon running with + /// documentation-range peers. + #[test] + fn the_default_config_is_not_the_shipped_example() { + assert!(!DEFAULT_CONFIG.contains("example")); + } +} diff --git a/crates/cli/src/metrics.rs b/crates/cli/src/metrics.rs new file mode 100644 index 0000000..c9bdbae --- /dev/null +++ b/crates/cli/src/metrics.rs @@ -0,0 +1,447 @@ +//! Prometheus metrics, written to a textfile. +//! +//! No HTTP endpoint. node_exporter's textfile collector already runs on any box +//! that is scraped, and a daemon whose whole safety story rests on needing no +//! privilege should not be opening a listening socket to report on itself. +//! +//! The text is emitted by hand rather than through a metrics crate. There is +//! exactly one writer, and that is what makes the atomic rename meaningful: a +//! scrape either sees the previous complete file or the next one, never a +//! half-written mixture. A global recorder would be a second, push-shaped +//! source of truth for the same file. +//! +//! # Metric names are a contract +//! +//! Once a name has shipped, an operator has built a dashboard and an alert on +//! it. **Append only.** Renaming one silently breaks the alert that was going +//! to page someone, and the failure mode is that nobody is paged — which is +//! indistinguishable from everything being fine. + +use std::fmt::Write as _; +use std::path::Path; +use std::time::{Duration, Instant}; + +use crate::atomic; +use crate::reconcile::TickOutcome; + +/// How often the textfile is rewritten. +/// +/// Matched to a typical scrape interval. Writing per tick would be four times +/// as many fsyncs for data nobody reads in between. +pub const WRITE_INTERVAL: Duration = Duration::from_secs(15); + +/// Everything the exporter needs, accumulated across ticks. +/// +/// Counters here are monotonic for the life of the process; a restart resets +/// them, which is what `rate()` expects. +#[derive(Debug, Default)] +pub struct Metrics { + pub polls_ok: u64, + pub polls_stale: u64, + pub originated_total: u64, + pub withdrawn_total: u64, + pub failed_total: u64, + pub refused_total: Vec<(String, u64)>, + + pub desired: usize, + pub mitigations: usize, + pub unhandled: usize, + pub dwelling: usize, + pub engagements: usize, + + /// When the last successful poll completed. + pub last_fresh: Option, + pub started: Option, + pub observe_mode: bool, +} + +impl Metrics { + pub fn new(observe_mode: bool, now: Instant) -> Self { + Self { + observe_mode, + started: Some(now), + ..Default::default() + } + } + + /// Fold one tick's outcome in. + pub fn record(&mut self, outcome: &TickOutcome, engagements: usize, now: Instant) { + if outcome.fresh { + self.polls_ok += 1; + self.last_fresh = Some(now); + self.desired = outcome.desired; + self.mitigations = outcome.mitigations; + self.unhandled = outcome.unhandled; + self.dwelling = outcome.dwelling; + } else { + self.polls_stale += 1; + } + self.originated_total += outcome.originated as u64; + self.withdrawn_total += outcome.withdrawn as u64; + self.failed_total += outcome.failed as u64; + self.engagements = engagements; + + for (_, label, _) in &outcome.refused { + match self.refused_total.iter_mut().find(|(l, _)| l == label) { + Some((_, n)) => *n += 1, + None => self.refused_total.push(((*label).to_string(), 1)), + } + } + } + + /// Render the exposition format. + pub fn render(&self, node_id: &str, now: Instant) -> String { + let mut out = String::with_capacity(2048); + let node = escape(node_id); + + // The single most important series in the daemon. It is what + // distinguishes "quiet because nothing is happening" from "quiet + // because we cannot see anything", and those look identical in every + // other metric here. Alert on it. + let stale_secs = match self.last_fresh { + Some(t) => now.saturating_duration_since(t).as_secs(), + // Never had a successful poll. Reported as a large number rather + // than zero, because zero would read as perfectly healthy. + None => self + .started + .map_or(0, |t| now.saturating_duration_since(t).as_secs()), + }; + metric( + &mut out, + "filterframe_policy_stale_seconds", + "gauge", + "Seconds since the last successful poll of the policy source.", + &format!("filterframe_policy_stale_seconds{{node=\"{node}\"}} {stale_secs}"), + ); + + metric( + &mut out, + "filterframe_policy_polls_total", + "counter", + "Polls of the policy source, by outcome.", + &format!( + "filterframe_policy_polls_total{{node=\"{node}\",outcome=\"fresh\"}} {}\n\ + filterframe_policy_polls_total{{node=\"{node}\",outcome=\"stale\"}} {}", + self.polls_ok, self.polls_stale + ), + ); + + metric( + &mut out, + "filterframe_engagements", + "gauge", + "Paths filterframe currently holds.", + &format!( + "filterframe_engagements{{node=\"{node}\"}} {}", + self.engagements + ), + ); + + metric( + &mut out, + "filterframe_desired_engagements", + "gauge", + "Paths the planner asked for on the last fresh poll.", + &format!( + "filterframe_desired_engagements{{node=\"{node}\"}} {}", + self.desired + ), + ); + + metric( + &mut out, + "filterframe_mitigations_seen", + "gauge", + "Mitigations returned by the policy source on the last fresh poll.", + &format!( + "filterframe_mitigations_seen{{node=\"{node}\"}} {}", + self.mitigations + ), + ); + + // Not an error, and deliberately visible: silently dropping these is + // how an operator discovers months later that half their address space + // was never protected. + metric( + &mut out, + "filterframe_mitigations_unhandled", + "gauge", + "Mitigations decided but not actionable on this node.", + &format!( + "filterframe_mitigations_unhandled{{node=\"{node}\"}} {}", + self.unhandled + ), + ); + + metric( + &mut out, + "filterframe_dwelling", + "gauge", + "Engagements no longer demanded but still held while a dwell runs.", + &format!("filterframe_dwelling{{node=\"{node}\"}} {}", self.dwelling), + ); + + metric( + &mut out, + "filterframe_bgp_actions_total", + "counter", + "BGP operations attempted, by kind.", + &format!( + "filterframe_bgp_actions_total{{node=\"{node}\",action=\"originate\"}} {}\n\ + filterframe_bgp_actions_total{{node=\"{node}\",action=\"withdraw\"}} {}\n\ + filterframe_bgp_actions_total{{node=\"{node}\",action=\"failed\"}} {}", + self.originated_total, self.withdrawn_total, self.failed_total + ), + ); + + if !self.refused_total.is_empty() { + let mut lines = String::new(); + for (label, n) in &self.refused_total { + let _ = writeln!( + lines, + "filterframe_refused_total{{node=\"{node}\",reason=\"{}\"}} {n}", + escape(label) + ); + } + metric( + &mut out, + "filterframe_refused_total", + "counter", + "Engagements a module declined, by reason.", + lines.trim_end(), + ); + } + + // Whether this node can announce anything at all. Worth a series + // because "everything looks calm" and "we are in observe mode" are + // otherwise the same picture. + metric( + &mut out, + "filterframe_enforcing", + "gauge", + "1 when filterframe may announce, 0 in observe mode.", + &format!( + "filterframe_enforcing{{node=\"{node}\"}} {}", + u8::from(!self.observe_mode) + ), + ); + + let uptime = self + .started + .map_or(0, |t| now.saturating_duration_since(t).as_secs()); + metric( + &mut out, + "filterframe_uptime_seconds", + "gauge", + "Seconds since this process started.", + &format!("filterframe_uptime_seconds{{node=\"{node}\"}} {uptime}"), + ); + + metric( + &mut out, + "filterframe_build_info", + "gauge", + "Build identity. Always 1; the version is in the label.", + &format!( + "filterframe_build_info{{node=\"{node}\",version=\"{}\"}} 1", + escape(filterframe_common::VERSION) + ), + ); + + out + } + + /// Write the textfile, atomically. + pub fn write( + &self, + path: &Path, + node_id: &str, + now: Instant, + ) -> Result<(), atomic::AtomicError> { + atomic::write(path, self.render(node_id, now)) + } +} + +fn metric(out: &mut String, name: &str, kind: &str, help: &str, body: &str) { + let _ = writeln!(out, "# HELP {name} {help}"); + let _ = writeln!(out, "# TYPE {name} {kind}"); + let _ = writeln!(out, "{body}"); +} + +/// Escape a label value per the exposition format. +/// +/// Node identifiers and refusal reasons are operator-supplied, and an +/// unescaped quote would produce a file the scraper rejects wholesale — losing +/// every metric on the node, not just the malformed one. +fn escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', " ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn outcome(fresh: bool) -> TickOutcome { + TickOutcome { + fresh, + mitigations: 2, + desired: 2, + originated: 1, + withdrawn: 0, + unhandled: 1, + refused: vec![], + dwelling: 0, + failed: 0, + stale_reason: None, + progress: vec![], + } + } + + #[test] + fn the_rendered_text_is_well_formed() { + let now = Instant::now(); + let m = Metrics::new(true, now); + let text = m.render("filter1", now); + + for line in text.lines() { + if line.starts_with('#') { + assert!( + line.starts_with("# HELP ") || line.starts_with("# TYPE "), + "bad comment: {line}" + ); + } else { + assert!(line.contains(' '), "a sample needs a value: {line}"); + } + } + } + + /// Every series must carry HELP and TYPE, or the scraper drops it. + #[test] + fn every_series_is_declared() { + let now = Instant::now(); + let mut m = Metrics::new(false, now); + m.record(&outcome(true), 3, now); + let text = m.render("filter1", now); + + let declared: Vec<&str> = text + .lines() + .filter_map(|l| l.strip_prefix("# TYPE ")) + .map(|l| l.split(' ').next().unwrap()) + .collect(); + + for line in text.lines().filter(|l| !l.starts_with('#')) { + let name = line.split(['{', ' ']).next().unwrap(); + assert!(declared.contains(&name), "{name} has no TYPE line"); + } + } + + /// The series operators alert on. Never having polled must not read as + /// healthy. + #[test] + fn staleness_before_any_successful_poll_is_not_zero() { + let start = Instant::now() - Duration::from_secs(120); + let m = Metrics::new(true, start); + let text = m.render("filter1", Instant::now()); + let line = text + .lines() + .find(|l| l.starts_with("filterframe_policy_stale_seconds{")) + .unwrap(); + let value: u64 = line.rsplit(' ').next().unwrap().parse().unwrap(); + assert!(value >= 119, "expected roughly 120, got {value}"); + } + + #[test] + fn a_fresh_poll_resets_staleness() { + let now = Instant::now(); + let mut m = Metrics::new(true, now); + m.record(&outcome(true), 1, now); + let text = m.render("filter1", now); + assert!(text.contains("filterframe_policy_stale_seconds{node=\"filter1\"} 0")); + } + + #[test] + fn stale_polls_are_counted_separately() { + let now = Instant::now(); + let mut m = Metrics::new(true, now); + m.record(&outcome(true), 1, now); + m.record(&outcome(false), 1, now); + m.record(&outcome(false), 1, now); + let text = m.render("filter1", now); + assert!(text.contains("outcome=\"fresh\"} 1")); + assert!(text.contains("outcome=\"stale\"} 2")); + } + + #[test] + fn refusals_are_counted_by_reason() { + let now = Instant::now(); + let mut m = Metrics::new(true, now); + let mut o = outcome(true); + o.refused = vec![ + ( + filterframe_common::bgp::PathKey { + prefix: "198.51.100.0/24".parse().unwrap(), + tier: filterframe_common::config::Tier::Rtbh, + }, + "prefix-too-short", + "too short".into(), + ), + ( + filterframe_common::bgp::PathKey { + prefix: "198.51.100.1/32".parse().unwrap(), + tier: filterframe_common::config::Tier::Rtbh, + }, + "protected", + "on the never-blackhole list".into(), + ), + ]; + m.record(&o, 0, now); + let text = m.render("filter1", now); + assert!(text.contains("reason=\"prefix-too-short\"} 1")); + assert!(text.contains("reason=\"protected\"} 1")); + } + + /// Observe mode and "nothing is happening" look identical in every other + /// series, so it gets one of its own. + #[test] + fn observe_mode_is_visible() { + let now = Instant::now(); + assert!( + Metrics::new(true, now) + .render("n", now) + .contains("filterframe_enforcing{node=\"n\"} 0") + ); + assert!( + Metrics::new(false, now) + .render("n", now) + .contains("filterframe_enforcing{node=\"n\"} 1") + ); + } + + /// An unescaped quote in a node id produces a file the scraper rejects + /// wholesale, losing every metric on the box rather than one. + #[test] + fn label_values_are_escaped() { + let now = Instant::now(); + let text = Metrics::new(true, now).render("we\"ird\\node", now); + assert!(text.contains(r#"node="we\"ird\\node""#), "{text}"); + // Every quote that is not part of an escape sequence must be paired, + // or the scraper rejects the whole file. + for line in text.lines().filter(|l| !l.starts_with('#')) { + let total = line.chars().filter(|c| *c == '"').count(); + let escaped = line.matches("\\\"").count(); + assert_eq!((total - escaped) % 2, 0, "unbalanced quotes: {line}"); + } + } + + #[test] + fn counters_accumulate_across_ticks() { + let now = Instant::now(); + let mut m = Metrics::new(true, now); + for _ in 0..5 { + m.record(&outcome(true), 1, now); + } + assert!(m.render("n", now).contains("action=\"originate\"} 5")); + } +} diff --git a/crates/cli/src/plan_cmd.rs b/crates/cli/src/plan_cmd.rs new file mode 100644 index 0000000..e08b25a --- /dev/null +++ b/crates/cli/src/plan_cmd.rs @@ -0,0 +1,96 @@ +//! `filterframe plan` — show the reasoning without acting on it. +//! +//! This exists because the question an operator asks during an incident is not +//! "what is announced" but "why". Reading a recorded policy-engine response and +//! printing the decision for every mitigation — with the config line that made +//! it — answers that offline, in review, and in CI, without a router anywhere +//! near it. +//! +//! It is also the acceptance test for the planner: the same pure function the +//! daemon uses, driven by a fixture. + +use std::path::Path; + +use filterframe_common::Config; +use filterframe_common::plan::{Plan, desired_state}; +use filterframe_policy::wire::MitigationsPage; + +/// Render a plan for the mitigations recorded in `fixture`. +pub fn run(cfg: &Config, fixture: &Path, json: bool) -> Result<(), String> { + let raw = std::fs::read_to_string(fixture) + .map_err(|e| format!("cannot read {}: {e}", fixture.display()))?; + + let page: MitigationsPage = serde_json::from_str(&raw) + .map_err(|e| format!("{} is not a mitigations response: {e}", fixture.display()))?; + + // A fixture is a recording, so ages are computed against the timestamps it + // carries rather than against now — otherwise a plan would read differently + // every time the file got older, and `age` rules would be untestable. + let now = page + .mitigations + .iter() + .filter_map(|m| filterframe_policy::wire::parse_rfc3339(&m.created_at)) + .max() + .unwrap_or(0); + + let mut mitigations = Vec::with_capacity(page.mitigations.len()); + for item in page.mitigations { + mitigations.push(item.into_mitigation(now, 0).map_err(|e| e.to_string())?); + } + + let plan = desired_state(cfg, &mitigations); + + if json { + println!( + "{}", + serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())? + ); + } else { + render(&plan); + } + Ok(()) +} + +fn render(plan: &Plan) { + println!("DECISIONS"); + if plan.decisions.is_empty() { + println!(" (none — no live mitigations in this recording)"); + } + for d in &plan.decisions { + let tier = d.tier.map_or("none", |t| t.as_str()); + let rule = d.rule_line.map_or_else( + || "no rule matched".to_string(), + |l| format!("rule line {l}"), + ); + match &d.unhandled { + Some(u) => println!( + " {:<24} {:<8} {rule} UNHANDLED: {u:?}", + d.mitigation_id, tier + ), + None => println!(" {:<24} {:<8} {rule}", d.mitigation_id, tier), + } + } + + println!(); + println!("DESIRED ENGAGEMENTS"); + if plan.engagements.is_empty() { + println!(" (none)"); + } + for e in &plan.engagements { + println!( + " {:<8} {:<20} demanded by {}", + e.tier.as_str(), + e.prefix.to_string(), + e.demands.iter().cloned().collect::>().join(", ") + ); + } + + let unhandled = plan.unhandled().count(); + if unhandled > 0 { + println!(); + println!( + "{unhandled} mitigation(s) were decided but could not be acted on. \ + They are counted, not ignored." + ); + } +} diff --git a/crates/cli/src/presence.rs b/crates/cli/src/presence.rs new file mode 100644 index 0000000..4b3c413 --- /dev/null +++ b/crates/cli/src/presence.rs @@ -0,0 +1,293 @@ +//! Is a filterframe daemon running, and is it *this* one? +//! +//! `filterframe status` has to work with no daemon running, and `reconfigure` +//! has to find a daemon to signal. Both need to answer "is the process in the +//! pidfile alive", and the honest answer has **three** values, not two: a pid +//! can be alive, gone, or alive-but-we-cannot-tell-whether-it-is-ours. +//! +//! The third case is not pedantry. Pids are recycled, and a pidfile that +//! outlived its daemon will eventually name somebody else's process. A tool +//! that reports two-valued liveness will one day send SIGHUP to whatever +//! inherited the number. So the pidfile records an identity — the pid together +//! with the process start time — and a mismatch is reported as `Gone`, not as +//! `Running`. +//! +//! Nothing here ever renders `Unknown` as either of the other two answers. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::atomic; + +/// Name of the pidfile inside the state directory. +pub const PIDFILE: &str = "filterframe.pid"; + +/// What we can say about the daemon named by a pidfile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Presence { + /// The pid is alive and its identity matches what the pidfile recorded. + Running { pid: u32 }, + /// No pidfile, or the pid is not alive, or it is alive but is demonstrably + /// a different process that inherited the number. + Gone, + /// A pidfile exists but cannot be believed — unreadable, malformed, or the + /// process exists and we lack the permission to interrogate it. + Unknown { why: String }, +} + +impl Presence { + /// True only for `Running`. Written as a method so that no caller is + /// tempted to `!matches!(p, Presence::Gone)`, which would silently treat + /// `Unknown` as running. + pub fn is_running(&self) -> bool { + matches!(self, Self::Running { .. }) + } + + /// Human-readable, for `status`. `Unknown` always carries its reason, + /// because "unknown" alone tells an operator nothing they can act on. + pub fn describe(&self) -> String { + match self { + Self::Running { pid } => format!("running (pid {pid})"), + Self::Gone => "not running".to_string(), + Self::Unknown { why } => format!("cannot determine ({why})"), + } + } +} + +/// A pidfile's contents: the pid, and enough to tell a recycled pid apart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + pub pid: u32, + /// Process start time in an opaque, platform-specific unit. Compared for + /// equality only — never interpreted, never arithmetic, never rendered. + pub start_token: u64, +} + +impl Identity { + /// The identity of the current process. + pub fn current() -> Self { + Self { + pid: std::process::id(), + start_token: start_token(std::process::id()).unwrap_or(0), + } + } + + fn serialize(&self) -> String { + format!("{} {}\n", self.pid, self.start_token) + } + + fn parse(s: &str) -> Option { + let mut parts = s.split_whitespace(); + let pid = parts.next()?.parse().ok()?; + let start_token = parts.next()?.parse().ok()?; + Some(Self { pid, start_token }) + } +} + +pub fn pidfile_path(state_dir: impl AsRef) -> PathBuf { + state_dir.as_ref().join(PIDFILE) +} + +/// Record this process as the running daemon. +pub fn write_pidfile(state_dir: impl AsRef) -> Result<(), atomic::AtomicError> { + atomic::write(pidfile_path(state_dir), Identity::current().serialize()) +} + +/// Remove the pidfile. Best-effort: a leftover pidfile is handled correctly by +/// [`check`], so failing to clean it up is untidy rather than dangerous. +pub fn remove_pidfile(state_dir: impl AsRef) { + let _ = fs::remove_file(pidfile_path(state_dir)); +} + +/// What the pidfile in `state_dir` says about a running daemon. +pub fn check(state_dir: impl AsRef) -> Presence { + let path = pidfile_path(&state_dir); + + let raw = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Presence::Gone, + Err(e) => { + return Presence::Unknown { + why: format!("cannot read {}: {e}", path.display()), + }; + } + }; + + let Some(recorded) = Identity::parse(&raw) else { + return Presence::Unknown { + why: format!("{} is malformed", path.display()), + }; + }; + + match liveness(recorded.pid) { + Liveness::Gone => Presence::Gone, + Liveness::Unknown(why) => Presence::Unknown { why }, + Liveness::Alive => { + // Alive is not enough: the number may have been recycled. Compare + // the start token, and treat a mismatch as gone rather than + // risking a signal to an unrelated process. + match start_token(recorded.pid) { + Some(now) if now == recorded.start_token => Presence::Running { pid: recorded.pid }, + // The number is alive but belongs to a different process now. + // Gone, not Running: nothing may signal it. + Some(_) => Presence::Gone, + // No token was recorded and none is available, because this + // platform does not offer one. Pid liveness is all there is. + None if recorded.start_token == 0 => Presence::Running { pid: recorded.pid }, + // A token was recorded but cannot be read back — typically + // another user's process. Say so rather than guessing. + None => Presence::Unknown { + why: format!( + "pid {} is alive but its identity cannot be confirmed", + recorded.pid + ), + }, + } + } + } +} + +enum Liveness { + Alive, + Gone, + Unknown(String), +} + +#[cfg(unix)] +fn liveness(pid: u32) -> Liveness { + // Signal 0 performs the permission and existence checks without delivering + // anything, which is exactly the question being asked. + let rc = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if rc == 0 { + return Liveness::Alive; + } + let err = std::io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::ESRCH) => Liveness::Gone, + // It exists, we just may not signal it. That is still "alive", and the + // identity check below will decide whether it is ours. + Some(libc::EPERM) => Liveness::Alive, + _ => Liveness::Unknown(format!("kill({pid}, 0): {err}")), + } +} + +#[cfg(not(unix))] +fn liveness(_pid: u32) -> Liveness { + Liveness::Unknown("liveness checks are implemented for unix only".into()) +} + +/// An opaque token that changes when a pid is reused. +/// +/// Linux: field 22 of `/proc//stat`, the process start time in clock ticks +/// since boot. macOS: the start time from `kinfo_proc`, so development on a +/// laptop gets the same guarantee rather than a weaker one. +#[cfg(target_os = "linux")] +fn start_token(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // The comm field can contain spaces and parentheses, so fields are counted + // from the last ')' rather than from the start of the line. + let after_comm = &stat[stat.rfind(')')? + 1..]; + after_comm.split_whitespace().nth(19)?.parse().ok() +} + +/// No start token on platforms where one is not cheaply available. +/// +/// macOS would need `kinfo_proc`, which the libc crate does not expose, and +/// hand-rolling that struct layout to defend a development platform against pid +/// recycling is not a trade worth making. `check` degrades to pid liveness +/// alone here, and says so rather than pretending to a guarantee it does not +/// have. Linux — the deployment target — gets the real check. +#[cfg(not(target_os = "linux"))] +fn start_token(_pid: u32) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tempdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ff-presence-{}-{tag}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn no_pidfile_is_gone() { + let d = tempdir("none"); + assert_eq!(check(&d), Presence::Gone); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn our_own_pidfile_reads_as_running() { + let d = tempdir("self"); + write_pidfile(&d).unwrap(); + let p = check(&d); + assert!(p.is_running(), "{p:?}"); + assert_eq!( + p, + Presence::Running { + pid: std::process::id() + } + ); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn malformed_pidfile_is_unknown_and_says_why() { + let d = tempdir("junk"); + fs::write(pidfile_path(&d), "not a pid").unwrap(); + match check(&d) { + Presence::Unknown { why } => assert!(why.contains("malformed"), "{why}"), + other => panic!("expected Unknown, got {other:?}"), + } + fs::remove_dir_all(&d).unwrap(); + } + + /// The case the identity exists for: the pid is real and alive, but it is + /// not the process that wrote the file. Reported as Gone, so nothing + /// signals it. + #[test] + fn recycled_pid_is_gone_not_running() { + let d = tempdir("recycled"); + let forged = Identity { + pid: std::process::id(), + start_token: u64::MAX, // cannot match a real start time + }; + fs::write(pidfile_path(&d), forged.serialize()).unwrap(); + // The property that must hold on every platform: whatever else we say, + // we must not say "running", because something would then signal it. + let p = check(&d); + assert!(!p.is_running(), "{p:?}"); + fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn pid_that_cannot_exist_is_gone() { + let d = tempdir("dead"); + // Above any plausible pid_max, so it is reliably ESRCH. + fs::write(pidfile_path(&d), "4194304 1\n").unwrap(); + assert_eq!(check(&d), Presence::Gone); + fs::remove_dir_all(&d).unwrap(); + } + + /// Unknown must never be mistaken for running, and `is_running` is the only + /// approved way to ask. + #[test] + fn unknown_is_not_running() { + let p = Presence::Unknown { why: "test".into() }; + assert!(!p.is_running()); + assert!(p.describe().contains("test")); + } + + #[test] + fn identity_round_trips() { + let id = Identity { + pid: 1234, + start_token: 987_654, + }; + assert_eq!(Identity::parse(&id.serialize()), Some(id)); + } +} diff --git a/crates/cli/src/probe.rs b/crates/cli/src/probe.rs new file mode 100644 index 0000000..e02acf3 --- /dev/null +++ b/crates/cli/src/probe.rs @@ -0,0 +1,595 @@ +//! Is the return path usable? +//! +//! Diverting traffic to a scrubbing provider is only half of it. The cleaned +//! traffic comes back over a tunnel, and if that tunnel is not carrying, the +//! diversion is not a mitigation — it is a hole. So the divert tier is gated on +//! this, and a return path that dies *while* diverted is the most urgent +//! condition in the daemon: every second is a hard outage for the whole prefix +//! rather than just the victim. +//! +//! # `operstate` is the wrong check, and it is the interesting bug here +//! +//! GRE tunnels have no carrier. The kernel therefore never sets an RFC 2863 +//! operational state for them and reports `unknown` forever — which, per the +//! kernel's own documentation, means the interface "must be considered +//! usable". A gate written as `operstate == "up"` refuses to divert on a +//! perfectly healthy tunnel, in production, only during an attack. +//! +//! So the admin flag is read from `flags` instead, and `unknown` is accepted. +//! +//! # Liveness needs more than a flag +//! +//! Linux has no GRE keepalives. A tunnel whose far end has vanished stays `UP` +//! forever, so the interface flag alone establishes almost nothing. Real +//! evidence is traffic: the receive counter moving while diverted. This module +//! reads what the kernel can tell it and leaves ICMP probing to a later slice — +//! and, importantly, reports `Unknown` rather than `Up` when it cannot tell. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +/// Consecutive failures before a path is declared down. +/// +/// Fast to distrust: three probes is a few seconds, and continuing to divert +/// into a dead tunnel is worse than a slightly twitchy gate. +pub const FAIL_THRESHOLD: u32 = 3; + +/// Consecutive successes before a recovered path is trusted again. +/// +/// Deliberately larger than [`FAIL_THRESHOLD`]. Asymmetric on purpose: a +/// flapping tunnel that is trusted quickly produces a divert loop, and each +/// cycle of that is real BGP churn at every peer involved. +pub const RECOVER_THRESHOLD: u32 = 5; + +/// What the kernel says about an interface right now. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reading { + /// Administratively up, and — where the kernel offers an opinion — not + /// reporting a dead lower layer. + Up, + Down { + why: String, + }, + /// The interface could not be read at all. Never rendered as either of the + /// others: diverting into a path we cannot verify is the same bet as + /// diverting into a dead one. + Unknown { + why: String, + }, +} + +/// The gate's own state, with hysteresis. +/// +/// Every non-`Up` variant carries *why*. Dropping the reason from a failing +/// reading meant the daemon logged a bare `state=down` for what this module +/// calls the most urgent condition it can report — an operator paged about a +/// diversion being torn down could not tell an admin-down interface from a +/// vanished one without going to look. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Gate { + Up, + /// Failing, but not yet enough to act on. + Degraded { + failures: u32, + why: String, + }, + Down { + since: Instant, + why: String, + }, + Unknown { + why: String, + }, +} + +impl Gate { + /// Whether the divert tier may engage. + /// + /// `Unknown` blocks exactly as `Down` does. A path we cannot verify is not + /// a path we may bet a customer's prefix on. + pub fn allows_divert(&self) -> bool { + matches!(self, Self::Up) + } + + /// The shared three-valued signal the tier modules act on. + pub fn as_return_path(&self) -> filterframe_common::module::ReturnPath { + use filterframe_common::module::ReturnPath; + match self { + Self::Up => ReturnPath::Up, + Self::Down { .. } => ReturnPath::Down, + // Degraded is failing but unconfirmed, so it blocks engaging + // without undoing anything — the same treatment as Unknown. + Self::Degraded { .. } | Self::Unknown { .. } => ReturnPath::Blocked, + } + } + + pub fn describe(&self) -> String { + match self { + Self::Up => "up".into(), + Self::Degraded { failures, why } => { + format!("degraded ({failures} consecutive failures: {why})") + } + Self::Down { why, .. } => format!("down ({why})"), + Self::Unknown { why } => format!("cannot determine ({why})"), + } + } +} + +/// Watches one interface, applying hysteresis to its readings. +#[derive(Debug)] +pub struct ReturnPathProbe { + interface: String, + sysfs_root: PathBuf, + gate: Gate, + successes: u32, +} + +impl ReturnPathProbe { + pub fn new(interface: impl Into) -> Self { + Self::with_root(interface, "/sys/class/net") + } + + /// Read from an alternative sysfs root. + /// + /// Exists so the whole gate can be tested against a fake tree rather than + /// requiring a real tunnel — which is what makes the `operstate = unknown` + /// case testable at all, since it is the case a developer's laptop cannot + /// reproduce. + pub fn with_root(interface: impl Into, root: impl Into) -> Self { + Self { + interface: interface.into(), + sysfs_root: root.into(), + // Starts unknown, not up: nothing has been established yet, and + // claiming otherwise would let the first tick divert blind. + gate: Gate::Unknown { + why: "not yet probed".into(), + }, + successes: 0, + } + } + + pub fn interface(&self) -> &str { + &self.interface + } + + pub fn gate(&self) -> &Gate { + &self.gate + } + + /// Take one reading and fold it into the gate. + pub fn poll(&mut self, now: Instant) -> &Gate { + let reading = self.read(); + self.apply(reading, now); + &self.gate + } + + /// Fold a reading in. Separated from I/O so hysteresis is testable. + pub fn apply(&mut self, reading: Reading, now: Instant) { + match reading { + Reading::Up => { + self.successes += 1; + self.gate = match &self.gate { + Gate::Up => Gate::Up, + // Anything other than Up must prove itself for longer than + // it took to lose trust — including Degraded. + // + // Returning to Up from Degraded on a single success was a + // real bug: a path alternating down/up recovered on every + // other reading, never accumulated failures, and so was + // never declared down. A tunnel that flaps once a second + // would have read as healthy forever. + _ if self.successes >= RECOVER_THRESHOLD => Gate::Up, + other => other.clone(), + }; + } + Reading::Down { why } => { + self.successes = 0; + self.gate = match &self.gate { + Gate::Degraded { failures, .. } if failures + 1 >= FAIL_THRESHOLD => { + Gate::Down { since: now, why } + } + Gate::Degraded { failures, .. } => Gate::Degraded { + failures: failures + 1, + why, + }, + // The reason is refreshed but `since` is not: how long it has + // been down is what an operator is timing, and restarting + // that on every poll would erase it. + Gate::Down { since, .. } => Gate::Down { since: *since, why }, + // First failure from up or unknown. + _ => { + if FAIL_THRESHOLD <= 1 { + Gate::Down { since: now, why } + } else { + Gate::Degraded { failures: 1, why } + } + } + }; + } + Reading::Unknown { why } => { + // Not a failure, and not a success. It blocks engaging without + // tearing down what is working. + self.successes = 0; + if !matches!(self.gate, Gate::Down { .. }) { + self.gate = Gate::Unknown { why }; + } + } + } + } + + /// Read the interface's current condition from sysfs. + /// + /// Linux only. sysfs does not exist elsewhere, and on a developer's macOS + /// machine every path under it is absent — which, read as a failure, put the + /// gate into `Down`, tore down every diversion and refused every new one. + /// The unit tests never caught it because they point `sysfs_root` at a + /// fabricated tree, so the gate stayed green while the daemon was broken on + /// the platform the project promises to develop on. + #[cfg(target_os = "linux")] + fn read(&self) -> Reading { + let dir = self.sysfs_root.join(&self.interface); + if !dir.exists() { + // `Unknown`, not `Down`. A path we cannot find is a path we cannot + // assess, and only a *confirmed* failure may undo a working + // diversion — a typo in `return-tunnel` must not be able to move a + // customer's prefix back across the Internet. It still blocks a new + // diversion, which is the half that matters for a name that is + // simply wrong. + return Reading::Unknown { + why: format!( + "interface {} is not present under {}", + self.interface, + self.sysfs_root.display() + ), + }; + } + read_interface(&dir) + } + + /// Off Linux there is no sysfs to read, so nothing can be established. + /// + /// Reported as `Unknown` rather than either verdict: the divert tier is + /// gated off, which is correct — this is not a filter node — and nothing + /// already up is torn down. + #[cfg(not(target_os = "linux"))] + fn read(&self) -> Reading { + // Honour an explicitly provided root anyway, so the hysteresis and the + // sysfs rules stay testable on a developer machine. + let dir = self.sysfs_root.join(&self.interface); + if dir.exists() { + return read_interface(&dir); + } + Reading::Unknown { + why: "this platform has no sysfs; the return path cannot be verified here".into(), + } + } +} + +/// Decide an interface's condition from its sysfs directory. +/// +/// Public so the rule — and specifically the `operstate = unknown` case — can be +/// tested against a fabricated tree. +pub fn read_interface(dir: &Path) -> Reading { + // IFF_UP is bit 0 of `flags`. This is the check that matters: a GRE tunnel + // has no carrier, so the kernel never sets an operational state for it. + let flags = match std::fs::read_to_string(dir.join("flags")) { + Ok(s) => s, + Err(e) => { + return Reading::Unknown { + why: format!("cannot read flags: {e}"), + }; + } + }; + let flags = flags.trim().trim_start_matches("0x"); + let Ok(bits) = u32::from_str_radix(flags, 16) else { + return Reading::Unknown { + why: format!("flags value {flags:?} is not hexadecimal"), + }; + }; + if bits & 0x1 == 0 { + return Reading::Down { + why: "administratively down (IFF_UP is clear)".into(), + }; + } + + // Only *contradicting* operational states are treated as failures. + // `unknown` is what a GRE tunnel reports forever, and the kernel documents + // it as meaning the interface must be considered usable — treating it as a + // failure would refuse to divert on a perfectly healthy tunnel. + match std::fs::read_to_string(dir.join("operstate")) { + Ok(s) => match s.trim() { + "down" => Reading::Down { + why: "operstate is down".into(), + }, + "lowerlayerdown" => Reading::Down { + why: "operstate is lowerlayerdown".into(), + }, + _ => Reading::Up, + }, + // No operstate at all is fine; the admin flag already said up. + Err(_) => Reading::Up, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_iface(tag: &str, flags: &str, operstate: Option<&str>) -> PathBuf { + let root = std::env::temp_dir().join(format!("ff-probe-{}-{tag}", std::process::id())); + let dir = root.join("gre0"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("flags"), flags).unwrap(); + if let Some(o) = operstate { + std::fs::write(dir.join("operstate"), o).unwrap(); + } + root + } + + /// **The bug this module exists to avoid.** GRE tunnels report `unknown` + /// forever, and a gate written against `operstate == "up"` would refuse to + /// divert on a perfectly healthy one — in production, during an attack. + #[test] + fn a_gre_tunnel_reporting_unknown_operstate_is_usable() { + let root = fake_iface("gre", "0x1003", Some("unknown")); + assert_eq!(read_interface(&root.join("gre0")), Reading::Up); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn an_interface_with_no_operstate_at_all_is_usable() { + let root = fake_iface("nostate", "0x1003", None); + assert_eq!(read_interface(&root.join("gre0")), Reading::Up); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn an_administratively_down_interface_is_down() { + let root = fake_iface("admindown", "0x1002", Some("unknown")); + assert!(matches!( + read_interface(&root.join("gre0")), + Reading::Down { .. } + )); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn a_contradicting_operstate_is_down() { + for state in ["down", "lowerlayerdown"] { + let root = fake_iface("contradict", "0x1003", Some(state)); + assert!( + matches!(read_interface(&root.join("gre0")), Reading::Down { .. }), + "{state} should be down" + ); + std::fs::remove_dir_all(&root).unwrap(); + } + } + + #[test] + fn an_unreadable_interface_is_unknown_not_down() { + let root = fake_iface("unreadable", "0x1003", None); + std::fs::remove_file(root.join("gre0").join("flags")).unwrap(); + assert!(matches!( + read_interface(&root.join("gre0")), + Reading::Unknown { .. } + )); + std::fs::remove_dir_all(&root).unwrap(); + } + + /// An interface that is not there blocks a new diversion but must never tear + /// down a working one. + /// + /// It used to read as a confirmed `Down`, which meant a typo in + /// `return-tunnel` — or simply running on a machine with no sysfs — was + /// enough to move a customer's prefix back across the Internet. "I cannot + /// find it" is not "I have confirmed it is dead". + #[test] + fn a_missing_interface_blocks_but_does_not_demand_a_return() { + let mut p = ReturnPathProbe::with_root("nope0", std::env::temp_dir()); + for _ in 0..=FAIL_THRESHOLD { + p.poll(Instant::now()); + } + assert!( + matches!(p.gate(), Gate::Unknown { .. }), + "got {:?}", + p.gate() + ); + assert!(!p.gate().allows_divert(), "must still block a new divert"); + assert!( + !p.gate().as_return_path().demands_return(), + "an absent interface must not undo a working diversion" + ); + assert!( + p.gate().describe().contains("nope0") || p.gate().describe().contains("no sysfs"), + "the reason must name what was looked for: {}", + p.gate().describe() + ); + } + + // -- hysteresis ---------------------------------------------------------- + + /// Nothing has been established at startup, and claiming otherwise would + /// let the first tick divert blind. + #[test] + fn a_fresh_probe_starts_unknown_not_up() { + let p = ReturnPathProbe::new("gre0"); + assert!(matches!(p.gate(), Gate::Unknown { .. })); + assert!(!p.gate().allows_divert()); + } + + #[test] + fn one_failure_degrades_rather_than_declaring_down() { + let now = Instant::now(); + let mut p = ReturnPathProbe::new("gre0"); + p.apply(Reading::Up, now); + p.apply(Reading::Up, now); + p.apply(Reading::Up, now); + p.apply(Reading::Up, now); + p.apply(Reading::Up, now); + assert!(p.gate().allows_divert()); + + p.apply(Reading::Down { why: "x".into() }, now); + assert!(matches!(p.gate(), Gate::Degraded { failures: 1, .. })); + assert!( + !p.gate().allows_divert(), + "degraded must not permit a new divert" + ); + assert!( + !p.gate().as_return_path().demands_return(), + "one blip must not tear down a working diversion" + ); + } + + #[test] + fn the_threshold_declares_it_down() { + let now = Instant::now(); + let mut p = ReturnPathProbe::new("gre0"); + for _ in 0..RECOVER_THRESHOLD { + p.apply(Reading::Up, now); + } + for _ in 0..FAIL_THRESHOLD { + p.apply(Reading::Down { why: "x".into() }, now); + } + assert!(p.gate().as_return_path().demands_return()); + } + + /// Asymmetric on purpose: a flapping tunnel trusted quickly produces a + /// divert loop, and every cycle is real BGP churn. + #[test] + fn recovery_takes_longer_than_failure() { + let now = Instant::now(); + let mut p = ReturnPathProbe::new("gre0"); + for _ in 0..FAIL_THRESHOLD { + p.apply(Reading::Down { why: "x".into() }, now); + } + assert!(p.gate().as_return_path().demands_return()); + + for i in 1..RECOVER_THRESHOLD { + p.apply(Reading::Up, now); + assert!( + !p.gate().allows_divert(), + "trusted again after only {i} successes" + ); + } + p.apply(Reading::Up, now); + assert!(p.gate().allows_divert()); + } + + /// A single success must not reset the failure count and let a flapping + /// path avoid ever being declared down. + #[test] + fn an_intermittent_success_does_not_prevent_declaring_down() { + let now = Instant::now(); + let mut p = ReturnPathProbe::new("gre0"); + for _ in 0..RECOVER_THRESHOLD { + p.apply(Reading::Up, now); + } + for _ in 0..20 { + p.apply(Reading::Down { why: "x".into() }, now); + p.apply(Reading::Up, now); + } + // Never sustains RECOVER_THRESHOLD successes, so it never returns to Up + // and keeps blocking new diversions. + assert!( + !p.gate().allows_divert(), + "a flapping path must not read as up" + ); + } + + /// An unreadable sysfs file is a bad reason to move a customer's traffic + /// across the Internet. + #[test] + fn unknown_blocks_engaging_but_does_not_tear_down() { + let now = Instant::now(); + let mut p = ReturnPathProbe::new("gre0"); + for _ in 0..RECOVER_THRESHOLD { + p.apply(Reading::Up, now); + } + p.apply( + Reading::Unknown { + why: "permission denied".into(), + }, + now, + ); + assert!(!p.gate().allows_divert()); + assert!(!p.gate().as_return_path().demands_return()); + } + + /// Once confirmed down, an unreadable reading must not upgrade it. + #[test] + fn unknown_does_not_clear_a_confirmed_down() { + let now = Instant::now(); + let mut p = ReturnPathProbe::new("gre0"); + for _ in 0..FAIL_THRESHOLD { + p.apply(Reading::Down { why: "x".into() }, now); + } + p.apply(Reading::Unknown { why: "x".into() }, now); + assert!(p.gate().as_return_path().demands_return()); + } + + /// The distinction that matters: only a confirmed failure may undo a + /// working diversion. + #[test] + fn only_a_confirmed_down_demands_a_return() { + use filterframe_common::module::ReturnPath; + assert_eq!(Gate::Up.as_return_path(), ReturnPath::Up); + assert_eq!( + Gate::Degraded { + failures: 1, + why: "x".into() + } + .as_return_path(), + ReturnPath::Blocked + ); + assert_eq!( + Gate::Unknown { why: "x".into() }.as_return_path(), + ReturnPath::Blocked + ); + assert_eq!( + Gate::Down { + since: Instant::now(), + why: "x".into() + } + .as_return_path(), + ReturnPath::Down + ); + + assert!( + !ReturnPath::Blocked.demands_return(), + "blocked must not tear down" + ); + assert!(!ReturnPath::Blocked.allows_engage()); + assert!(ReturnPath::Down.demands_return()); + } + + #[test] + fn the_gate_describes_itself_usefully() { + assert!(Gate::Up.describe().contains("up")); + let degraded = Gate::Degraded { + failures: 2, + why: "operstate is down".into(), + } + .describe(); + assert!(degraded.contains('2'), "{degraded}"); + assert!( + degraded.contains("operstate is down"), + "the reason must survive into the description: {degraded}" + ); + // The most urgent condition the daemon reports must never be a bare + // "down" with no reason attached. + let down = Gate::Down { + since: Instant::now(), + why: "administratively down (IFF_UP is clear)".into(), + } + .describe(); + assert!(down.contains("IFF_UP"), "{down}"); + assert!( + Gate::Unknown { + why: "no such file".into() + } + .describe() + .contains("no such file") + ); + } +} diff --git a/crates/cli/src/reconcile.rs b/crates/cli/src/reconcile.rs new file mode 100644 index 0000000..e379522 --- /dev/null +++ b/crates/cli/src/reconcile.rs @@ -0,0 +1,491 @@ +//! One tick: read the world, decide what should be true, converge the +//! difference. +//! +//! The loop is level-triggered. It does not react to events; it re-derives the +//! desired set every tick and compares it with what the speaker actually holds. +//! That is what makes every failure mode a retry rather than a missed message, +//! and what makes a restart cheap — there is no accumulated position to +//! rebuild, because desired state is recomputed from scratch each time. +//! +//! # Two paths, and what separates them +//! +//! On a **fresh** view the planner turns the mitigation list into engagements, +//! each tier module refines them with its own guards and damping, and the +//! reconciler converges on the union. +//! +//! On a **stale** view there is no mitigation list — [`MitigationView::Stale`] +//! carries none — so the planner is never reached and the modules are asked +//! only what they are already holding. Nothing can be released for lack of +//! demand, because there is no demand to be absent from. +//! +//! # Confirmation comes before the decision +//! +//! Between reading what the speaker holds and asking the modules what they want, +//! the tick gathers advertisement verdicts for every path held and hands them to +//! whichever module owns it. That ordering is deliberate: whether a scrubber +//! actually took a path is an *input* to the divert sequence, not a report on it, +//! and without it that sequence has nothing that can move it out of `announcing`. +//! +//! It happens on the stale path too. A quorum lost while diverted has to be +//! acted on whether or not the policy engine is reachable — the two facts are +//! unrelated, and the dangerous one is the quorum. +//! +//! # The one thing a stale view may still withdraw +//! +//! A module's own ceiling. `max-lifetime` measures how long an engagement has +//! stood *unconfirmed*, and it keeps running while the policy engine is +//! unreachable — because otherwise an engine that never comes back would leave +//! an address dark forever. It is a bounded, operator-configured exception, and +//! it is deliberately the only one: absence of demand never releases anything +//! on this path, only expiry of a clock the operator set. + +use std::collections::BTreeSet; +use std::time::Instant; + +use filterframe_common::bgp::{ + Advertisement, Fidelity, OriginateRequest, PathKey, RibObserver, RouteOriginator, +}; +use filterframe_common::config::Config; +use filterframe_common::mitigation::MitigationView; +use filterframe_common::module::{PathEvidence, ReturnPath, TierModule}; +use filterframe_common::plan::desired_state; +use filterframe_policy::PolicySource; + +/// The weakest verdict allowed to count toward a quorum. +/// +/// [`Fidelity::PolicyEligible`] is the floor because it is the weakest thing +/// that says anything about the *world*: the path is best for the peer and +/// passes its export policy. Everything below it — +/// [`Fidelity::Synthetic`] — establishes only that a local function call +/// returned, and a prefix must never be suppressed toward transit on the +/// strength of that. It is also why observe mode never reaches a quorum: the +/// mock's ceiling is `Synthetic`, so a diversion cannot be half-believed there. +const MIN_QUORUM_FIDELITY: Fidelity = Fidelity::PolicyEligible; + +/// What one tick did, for the log line and the metrics. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct TickOutcome { + pub fresh: bool, + pub mitigations: usize, + pub desired: usize, + pub originated: usize, + pub withdrawn: usize, + pub unhandled: usize, + /// Engagements a module declined: the path, the metric label, and a + /// sentence. Counted rather than dropped — an operator needs to know a + /// guard fired, which one, and why. + pub refused: Vec<(PathKey, &'static str, String)>, + /// Engagements no longer demanded but still held while a dwell runs. + pub dwelling: usize, + /// Operations that failed this tick. Not errors: the next tick re-derives + /// the same desired state and tries again, which is the whole recovery + /// mechanism. + pub failed: usize, + pub stale_reason: Option, + /// Where each module's own multi-step sequences have got to. + /// + /// Collected because the announcement list cannot show it: a prefix + /// announced to a scrubber but not yet diverted looks exactly like a + /// finished engagement from outside the module. + pub progress: Vec, +} + +/// Ties the policy source, the planner, the tier modules and the speaker +/// together. +pub struct Reconciler { + config: Config, + policy: P, + speaker: S, + modules: Vec>, + /// Whether startup adoption has run. Deferred to the first tick because it + /// needs the speaker, which may not be reachable at construction. + adopted: bool, +} + +impl Reconciler +where + P: PolicySource, + S: RouteOriginator + RibObserver, +{ + pub fn new(config: Config, policy: P, speaker: S, modules: Vec>) -> Self { + Self { + config, + policy, + speaker, + modules, + adopted: false, + } + } + + /// Replace the running configuration after a reload. + /// + /// Only reached for changes `restart_only_delta` allowed, so the peer list + /// and the authority boundary cannot move underneath a live desired set. + pub fn reconfigure(&mut self, config: Config) { + self.config = config; + } + + pub fn config(&self) -> &Config { + &self.config + } + + /// Tell every module whether the return path is usable. + /// + /// Forwarded rather than queried, because the probe runs on a faster + /// cadence than the tick: a tunnel dying while diverted must not wait out a + /// tick interval to be noticed. + pub fn set_return_path(&mut self, path: ReturnPath, now: Instant) { + for module in &mut self.modules { + module.set_return_path(path, now); + } + } + + /// Everything the speaker currently holds, for `status` and the metrics. + /// + /// Read from the speaker rather than from the modules: what is actually + /// announced is the thing an operator needs, and the two can legitimately + /// differ for a tick while convergence catches up. + pub async fn engagements(&self) -> Vec { + self.speaker.list_originated().await.unwrap_or_default() + } + + /// What every module wants remembered across a restart. + pub fn journal(&self) -> std::collections::BTreeMap { + self.modules + .iter() + .filter_map(|m| { + m.journal() + .map(|blob| (m.tier().as_str().to_string(), blob)) + }) + .collect() + } + + /// Hand each module back its own journal. + /// + /// Runs before the first tick, so recovery has decided which way each + /// sequence was going before anything is converged. Without it the + /// reconciler would see paths no module claims and withdraw them. + pub fn restore_journal( + &mut self, + blobs: &std::collections::BTreeMap, + now: Instant, + ) { + for module in &mut self.modules { + if let Some(blob) = blobs.get(module.tier().as_str()) { + module.restore_journal(blob, now); + } + } + } + + /// Run one full step. + pub async fn tick(&mut self, now: Instant) -> TickOutcome { + let mut outcome = TickOutcome::default(); + + // **One read of the speaker per tick**, shared by adoption and the + // difference computed below. + // + // These used to be two separate reads, and the pair had a hole in it: a + // transient failure on the adoption read followed by a success on the + // convergence read produced a tick where no module had adopted anything + // and every inherited path therefore looked like surplus. Reading once + // makes that unrepresentable — either we know what we hold and adopt it, + // or we know nothing and do nothing. + let held = match self.speaker.list_originated().await { + Ok(v) => v, + Err(e) => { + // We cannot see what we hold, so we cannot compute a + // difference. Doing nothing is right: the next tick retries, + // and nothing was torn down on the strength of a failed read. + tracing::warn!(error = %e, "cannot read what the speaker holds; skipping this tick"); + outcome.failed += 1; + return outcome; + } + }; + self.adopt_once(&held, now); + + // Confirmation evidence is gathered *before* the modules decide, because + // whether the scrubber actually took a path is an input to the divert + // sequence rather than a report on it. + self.observe_paths(&held, now).await; + + let have: BTreeSet = held.into_iter().collect(); + + let view = self.policy.poll().await; + + // Whether the planner is reached at all is the entire difference + // between the two paths, and it is decided by the type: `fresh()` + // returns a list or it does not. + let want: BTreeSet = match view.fresh() { + Some(mitigations) => { + let plan = desired_state(&self.config, mitigations); + outcome.fresh = true; + outcome.mitigations = mitigations.len(); + outcome.desired = plan.engagements.len(); + outcome.unhandled = plan.unhandled().count(); + + let mut want = BTreeSet::new(); + for module in &mut self.modules { + let refined = module.refine(&plan.engagements, now); + outcome.refused.extend( + refined + .refused + .iter() + .map(|(k, r)| (k.clone(), r.label(), r.describe())), + ); + outcome.dwelling += refined.dwelling.len(); + want.extend(refined.effective); + } + want + } + None => { + let why = match &view { + MitigationView::Stale { why, .. } => why.to_string(), + MitigationView::Fresh(_) => unreachable!("fresh() disagreed with itself"), + }; + outcome.stale_reason = Some(why.clone()); + + let mut want = BTreeSet::new(); + for module in &mut self.modules { + // No desired set is passed, because there is none. The + // ceiling is the only rule that can still remove anything. + let refined = module.hold_only(now); + outcome.refused.extend( + refined + .refused + .iter() + .map(|(k, r)| (k.clone(), r.label(), r.describe())), + ); + want.extend(refined.effective); + } + + tracing::warn!( + reason = %why, + holding = want.len(), + "policy source is stale; holding every engagement and releasing nothing \ + except what its own ceiling has expired" + ); + want + } + }; + + for key in want.difference(&have) { + match self.originate(key).await { + Ok(()) => outcome.originated += 1, + Err(()) => outcome.failed += 1, + } + } + + for key in have.difference(&want) { + match self.speaker.withdraw(key).await { + Ok(_) => { + tracing::info!(path = %key, "withdrawn"); + outcome.withdrawn += 1; + } + Err(e) => { + // Withdrawals are the safe direction, so a failure here is + // retried next tick, forever if need be. + tracing::warn!(path = %key, error = %e, "withdraw failed; will retry"); + outcome.failed += 1; + } + } + } + + outcome.progress = self.modules.iter().flat_map(|m| m.progress(now)).collect(); + + outcome + } + + /// Ask the speaker what it can establish about each path we hold, and hand + /// the answer to whichever module owns it. + /// + /// This is the wire between [`RibObserver`] and the divert sequence, and + /// without it that sequence has no way to learn whether its announcement + /// landed — it announces to the scrubber and waits there indefinitely while + /// transit keeps carrying the prefix. + async fn observe_paths(&mut self, held: &[PathKey], now: Instant) { + // A backend that cannot establish anything stronger than a local + // function call has no evidence worth gathering, and asking anyway would + // manufacture a verdict out of nothing. + if self.speaker.max_fidelity() < MIN_QUORUM_FIDELITY { + return; + } + + let peers = match self.speaker.peers().await { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, "cannot read peer state; no quorum evidence this tick"); + return; + } + }; + + // Gathered into owned values first: the queries borrow the config to + // resolve peers, and handing the results to the modules needs them + // mutably. + let mut gathered: Vec<(PathKey, PathEvidence)> = Vec::new(); + for key in held { + // Peers are matched by address, not by name: a sidecar names its + // neighbours by address and knows nothing of the names an operator + // chose, so the configured name is not a join key. + let targets: Vec<(String, u64)> = self + .config + .peers + .iter() + .filter(|c| c.allow_tiers.contains(&key.tier)) + .filter_map(|c| c.address) + .filter_map(|addr| peers.iter().find(|p| p.address == addr)) + .map(|p| (p.name.clone(), p.epoch)) + .collect(); + if targets.is_empty() { + continue; + } + + let mut evidence = PathEvidence::default(); + for (peer, epoch) in &targets { + match self.speaker.advertised(peer, key.prefix).await { + Ok(Advertisement::Advertised { + fidelity, + peer_epoch, + }) if peer_epoch == *epoch && fidelity >= MIN_QUORUM_FIDELITY => { + evidence.confirmed = evidence.confirmed.saturating_add(1); + } + // Settled and negative: an export policy will not relent and + // a prefix limit will not un-reach itself, so waiting out the + // deadline would only delay the alert. + Ok(Advertisement::Suppressed { .. }) => { + evidence.settled_negative = evidence.settled_negative.saturating_add(1); + } + // Absent, Unknown, a verdict from a session that has since + // flapped, or one too weak to count: no information. Counting + // any of these as a "no" would unwind a sequence that is + // merely still in progress. + Ok(_) => {} + Err(e) => { + tracing::debug!(path = %key, %peer, error = %e, "no verdict from this peer"); + } + } + } + gathered.push((key.clone(), evidence)); + } + + for (key, evidence) in gathered { + for module in &mut self.modules { + module.observe(&key, evidence, now); + } + } + } + + /// Hand the modules whatever a previous incarnation left announced. + /// + /// Without this, a restarted daemon would see paths in `have` that no + /// module claims, compute them as surplus, and withdraw the lot — turning + /// every restart into a mass teardown. Adoption runs once, on the first + /// tick rather than at construction, because the speaker may not be + /// reachable when the daemon starts and refusing to start over that would + /// be worse. + /// + /// Takes the list the caller already read rather than reading its own. That + /// is what makes "converged without having adopted" unrepresentable: the + /// only path to convergence runs through a successful read, and that same + /// read is the one adoption saw. + fn adopt_once(&mut self, existing: &[PathKey], now: Instant) { + if self.adopted { + return; + } + if !existing.is_empty() { + tracing::info!( + count = existing.len(), + "adopting engagements left by a previous run" + ); + } + for module in &mut self.modules { + module.adopt(Box::new(existing.iter().cloned()), now); + } + self.adopted = true; + } + + async fn originate(&self, key: &PathKey) -> Result<(), ()> { + let Some(req) = self.build_request(key) else { + // The config validator refuses this at load, so reaching it means + // the peer list changed shape underneath us. Announcing to nobody + // is not a degraded success. + tracing::error!(path = %key, "no peer allows this tier; not announcing"); + return Err(()); + }; + + match self.speaker.originate(&req).await { + Ok(_submitted) => { + // `Submitted` is not confirmation, and nothing here treats it as + // one: the next tick re-reads what the speaker holds, and a path + // that never landed simply reappears in `want - have`. + tracing::info!(path = %key, peers = req.peers.len(), "originated"); + Ok(()) + } + Err(e) => { + tracing::warn!(path = %key, error = %e, "originate failed; will retry"); + Err(()) + } + } + } + + /// Resolve which peers a path is offered to, and with which communities. + fn build_request(&self, key: &PathKey) -> Option { + let peers: Vec<_> = self + .config + .peers + .iter() + .filter(|p| p.allow_tiers.contains(&key.tier)) + .collect(); + if peers.is_empty() { + return None; + } + + // The union is taken here only because the mock speaker holds one path + // per key. The real backends re-resolve per peer at send time, which is + // where a provider wanting a different blackhole community gets it. + let mut communities = Vec::new(); + for p in &peers { + for c in &p.communities { + if !communities.contains(c) { + communities.push(*c); + } + } + } + + Some(OriginateRequest { + key: key.clone(), + peers: peers.iter().map(|p| p.name.clone()).collect(), + communities, + next_hop: None, + }) + } +} + +/// A one-line summary of a tick, for the log. +/// +/// Emitted only when something changed. At a two-second tick a line per tick is +/// forty-three thousand a day, and the six that matter would be buried in it. +pub fn tick_log_line(outcome: &TickOutcome) -> Option { + if !outcome.fresh { + return None; // already logged with its reason, at warn + } + if outcome.originated == 0 && outcome.withdrawn == 0 && outcome.failed == 0 { + return None; + } + Some(format!( + "converged: +{} -{} ({} desired, {} mitigations{}{})", + outcome.originated, + outcome.withdrawn, + outcome.desired, + outcome.mitigations, + if outcome.refused.is_empty() { + String::new() + } else { + format!(", {} refused", outcome.refused.len()) + }, + if outcome.failed > 0 { + format!(", {} failed and will retry", outcome.failed) + } else { + String::new() + } + )) +} diff --git a/crates/cli/src/status.rs b/crates/cli/src/status.rs new file mode 100644 index 0000000..f6ffe2d --- /dev/null +++ b/crates/cli/src/status.rs @@ -0,0 +1,388 @@ +//! `filterframe status` — what is happening, for someone who has four minutes. +//! +//! Two requirements shape this. +//! +//! **It must work with no daemon running.** An operator reaching for `status` +//! is often doing so precisely because they suspect there isn't one. So it +//! reads a snapshot the daemon publishes to its state directory rather than +//! talking to a live process, and it reports the daemon's presence as the +//! three-valued thing it is — never rendering "cannot tell" as either "running" +//! or "stopped". +//! +//! **It must say what needs attention without being read closely.** The +//! ATTENTION block is computed, not left to the reader to spot an `Idle` in a +//! table at three in the morning. + +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::atomic; +use crate::presence; + +/// Name of the snapshot inside the state directory. +pub const SNAPSHOT: &str = "status.json"; + +/// What the daemon publishes each tick. +/// +/// Wall-clock timestamps, unlike everything the daemon decides with: this is +/// for a human reading it later, possibly after a restart, where a monotonic +/// instant would be meaningless. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Snapshot { + pub node_id: String, + pub version: String, + pub mode: String, + pub bgp_backend: String, + pub policy_url: String, + + /// Unix seconds of the last successful poll, if there has been one. + pub last_fresh_unix: Option, + pub last_tick_unix: u64, + pub stale_reason: Option, + + pub mitigations: usize, + pub unhandled: usize, + pub engagements: Vec, + pub dwelling: usize, + pub refused: Vec, + /// Where each module's multi-step sequences have got to. + /// + /// `#[serde(default)]` so a snapshot written by an older daemon still reads: + /// `status` is the command an operator reaches for mid-incident, and it + /// refusing to parse a file half a version out of date would be the worst + /// possible moment for it. + #[serde(default)] + pub progress: Vec, +} + +pub fn path(state_dir: impl AsRef) -> std::path::PathBuf { + state_dir.as_ref().join(SNAPSHOT) +} + +pub fn write(state_dir: impl AsRef, snap: &Snapshot) -> Result<(), atomic::AtomicError> { + let body = serde_json::to_vec_pretty(snap).expect("the snapshot always serialises"); + atomic::write(path(state_dir), body) +} + +pub fn read(state_dir: impl AsRef) -> Option { + let raw = std::fs::read_to_string(path(state_dir)).ok()?; + serde_json::from_str(&raw).ok() +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn ago(then: u64) -> String { + let now = now_unix(); + if then > now { + return "in the future (check the clock)".into(); + } + let secs = now - then; + match secs { + 0..=59 => format!("{secs}s ago"), + 60..=3599 => format!("{}m{}s ago", secs / 60, secs % 60), + _ => format!("{}h{}m ago", secs / 3600, (secs % 3600) / 60), + } +} + +/// Render the human-readable report. +/// +/// Greppable, no colour required, and every timer shows both elapsed and +/// remaining where it has one. +pub fn render(state_dir: &Path, snap: Option<&Snapshot>) -> String { + let mut out = String::new(); + let daemon = presence::check(state_dir); + + let Some(s) = snap else { + out.push_str(&format!( + "filterframe {}\n daemon {}\n\n\ + No status snapshot in {}.\n\ + Either the daemon has never run with this state directory, or it has \n\ + not completed its first tick.\n", + filterframe_common::VERSION, + daemon.describe(), + state_dir.display() + )); + return out; + }; + + out.push_str(&format!( + "filterframe {} — node {} — mode {} — daemon {}\n", + s.version, + s.node_id, + s.mode.to_uppercase(), + daemon.describe() + )); + out.push_str(&format!(" last tick {}\n", ago(s.last_tick_unix))); + + out.push_str("\nPOLICY SOURCE\n"); + out.push_str(&format!(" {}\n", s.policy_url)); + match (&s.stale_reason, s.last_fresh_unix) { + (None, Some(t)) => out.push_str(&format!(" last success {} OK\n", ago(t))), + (Some(why), Some(t)) => { + out.push_str(&format!(" last success {}\n", ago(t))); + out.push_str(&format!(" STALE {why}\n")); + } + (Some(why), None) => out.push_str(&format!(" STALE {why} (never succeeded)\n")), + (None, None) => out.push_str(" no poll has completed yet\n"), + } + out.push_str(&format!( + " mitigations {} ({} not actionable here)\n", + s.mitigations, s.unhandled + )); + + out.push_str("\nENGAGEMENTS\n"); + if s.engagements.is_empty() { + out.push_str(" (none)\n"); + } + for e in &s.engagements { + out.push_str(&format!(" {e}\n")); + } + if s.dwelling > 0 { + out.push_str(&format!( + " {} of these are no longer demanded and will release when their dwell ends\n", + s.dwelling + )); + } + + // An announcement says nothing about how far a multi-step sequence has got. + // A prefix announced to a scrubber but not yet diverted appears in + // ENGAGEMENTS looking finished, so the sequence has to be spelled out. + if !s.progress.is_empty() { + out.push_str("\nSEQUENCES\n"); + for p in &s.progress { + out.push_str(&format!(" {p}\n")); + } + } + + if !s.refused.is_empty() { + out.push_str("\nREFUSED\n"); + for r in &s.refused { + out.push_str(&format!(" {r}\n")); + } + } + + // Computed, not left for the reader to spot. + let mut attention: Vec = Vec::new(); + if let Some(why) = &s.stale_reason { + attention.push(format!( + "the policy source is stale ({why}); engagements are held and nothing will be released" + )); + } + if !daemon.is_running() { + attention.push(format!( + "the daemon is {} — this report is a snapshot, not live", + daemon.describe() + )); + } + if s.mode == "observe" { + attention.push( + "observe mode: decisions are computed and recorded, and nothing is announced".into(), + ); + } + if s.unhandled > 0 { + attention.push(format!( + "{} mitigation(s) were decided but cannot be acted on by this node", + s.unhandled + )); + } + // A sequence waiting on a quorum is announcing to a scrubber while transit + // still carries the prefix. That is not a mitigation, and it does not look + // like a problem anywhere else in this report. + let waiting = s + .progress + .iter() + .filter(|p| p.contains("announcing")) + .count(); + if waiting > 0 { + attention.push(format!( + "{waiting} diversion(s) are waiting for a scrubber quorum and are not yet \ + mitigating anything" + )); + } + if !s.refused.is_empty() { + attention.push(format!( + "{} engagement(s) were refused by a guard", + s.refused.len() + )); + } + + if !attention.is_empty() { + out.push_str("\nATTENTION\n"); + for a in attention { + out.push_str(&format!(" ! {a}\n")); + } + } + + out +} + +/// Build a snapshot from a tick. +pub fn snapshot_from( + cfg: &filterframe_common::Config, + outcome: &crate::reconcile::TickOutcome, + engagements: &[String], + last_fresh_unix: Option, +) -> Snapshot { + Snapshot { + node_id: cfg.global.node_id.clone(), + version: filterframe_common::VERSION.to_string(), + mode: cfg.global.mode.as_str().to_string(), + bgp_backend: cfg.bgp.mode.as_str().to_string(), + policy_url: cfg.policy_source.url.clone(), + last_fresh_unix, + last_tick_unix: now_unix(), + stale_reason: outcome.stale_reason.clone(), + mitigations: outcome.mitigations, + unhandled: outcome.unhandled, + engagements: engagements.to_vec(), + dwelling: outcome.dwelling, + refused: outcome + .refused + .iter() + .map(|(k, _, why)| format!("{k} {why}")) + .collect(), + progress: outcome.progress.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tempdir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ff-status-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn snap() -> Snapshot { + Snapshot { + node_id: "filter1".into(), + version: "0.0.1".into(), + mode: "observe".into(), + bgp_backend: "gobgp".into(), + policy_url: "https://policy.example.net".into(), + last_fresh_unix: Some(now_unix()), + last_tick_unix: now_unix(), + stale_reason: None, + mitigations: 3, + unhandled: 0, + engagements: vec!["rtbh:198.51.100.5/32".into()], + dwelling: 0, + refused: vec![], + progress: vec![], + } + } + + /// A diversion that is announcing but not yet diverted must be visible and + /// must raise attention: transit is still carrying the prefix, so nothing is + /// being mitigated, and the ENGAGEMENTS list alone makes it look finished. + #[test] + fn a_sequence_waiting_on_a_quorum_is_reported_and_flagged() { + let d = tempdir("progress"); + let mut s = snap(); + s.progress = vec![ + "divert 198.51.100.0/24 announcing for 90s — waiting for 2 of 3 reflectors; \ + transit still carrying" + .into(), + ]; + let text = render(&d, Some(&s)); + assert!(text.contains("SEQUENCES"), "{text}"); + assert!(text.contains("announcing for 90s"), "{text}"); + assert!( + text.contains("waiting for a scrubber quorum"), + "must reach ATTENTION: {text}" + ); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// The requirement that shapes the whole command: an operator reaches for + /// this precisely when they suspect nothing is running. + #[test] + fn it_renders_with_no_daemon_and_no_snapshot() { + let d = tempdir("empty"); + let text = render(&d, None); + assert!(text.contains("not running"), "{text}"); + assert!(text.contains("No status snapshot"), "{text}"); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn a_snapshot_round_trips() { + let d = tempdir("roundtrip"); + write(&d, &snap()).unwrap(); + let back = read(&d).unwrap(); + assert_eq!(back.node_id, "filter1"); + assert_eq!(back.engagements.len(), 1); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Staleness is the thing an operator most needs to see, so it must reach + /// the ATTENTION block rather than sitting in a field. + #[test] + fn staleness_reaches_the_attention_block() { + let d = tempdir("stale"); + let mut s = snap(); + s.stale_reason = Some("unreachable: connection refused".into()); + let text = render(&d, Some(&s)); + let attention = text.split("ATTENTION").nth(1).expect("no ATTENTION block"); + assert!(attention.contains("stale"), "{text}"); + assert!(attention.contains("nothing will be released"), "{text}"); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Observe mode and "nothing is happening" look the same in every field, so + /// it has to be called out explicitly. + #[test] + fn observe_mode_is_called_out() { + let d = tempdir("observe"); + let text = render(&d, Some(&snap())); + assert!(text.contains("observe mode"), "{text}"); + std::fs::remove_dir_all(&d).unwrap(); + } + + #[test] + fn unhandled_mitigations_reach_attention() { + let d = tempdir("unhandled"); + let mut s = snap(); + s.unhandled = 2; + let text = render(&d, Some(&s)); + assert!(text.contains("cannot be acted on"), "{text}"); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Never render "cannot tell" as either answer. + #[test] + fn a_snapshot_without_a_live_daemon_says_so() { + let d = tempdir("nodaemon"); + let text = render(&d, Some(&snap())); + assert!(text.contains("not running"), "{text}"); + assert!( + text.contains("snapshot, not live"), + "must not imply the numbers are current: {text}" + ); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// A clock that has gone backwards should read as suspicious rather than as + /// a nonsense duration. + #[test] + fn a_future_timestamp_is_flagged_not_wrapped() { + assert!(ago(now_unix() + 3600).contains("check the clock")); + } + + #[test] + fn durations_read_naturally() { + assert!(ago(now_unix() - 5).ends_with("s ago")); + assert!(ago(now_unix() - 300).contains('m')); + assert!(ago(now_unix() - 7200).contains('h')); + } +} diff --git a/crates/cli/src/tunnel.rs b/crates/cli/src/tunnel.rs new file mode 100644 index 0000000..d239d3a --- /dev/null +++ b/crates/cli/src/tunnel.rs @@ -0,0 +1,281 @@ +//! `filterframe tunnel render` — emit the return-path configuration, and stop. +//! +//! filterframe verifies the return path. It does not create it, and this +//! command is where that line is drawn deliberately rather than by omission. +//! +//! Creating a GRE tunnel needs `CAP_NET_ADMIN`, and that capability is not +//! "may create tunnels" — it is delete-any-interface, inject-any-route, +//! rewrite-the-firewall, enable-promiscuous-mode. Granting it would change +//! filterframe's worst case from *wrong BGP announcements, externally auditable +//! and upstream-filterable* into *silent traffic interception visible only from +//! inside the node*, on a node that is by construction under attack. The +//! daemon's inputs are adversary-influenced by definition; the blast radius of +//! a logic bug is set by the capability set, not by the language. +//! +//! And the feature would not earn it. A tunnel is two-ended: creating our half +//! of one whose far end does not exist produces an interface that is +//! administratively up and functionally a black hole. GRE is stateless, so an +//! idle tunnel costs nothing to leave in place. Deleting one on exit would tear +//! down the scrubbed-traffic return path on every `systemctl restart` — the +//! single most common human action during a degraded mitigation — so the only +//! safe version never deletes, never modifies, and creates once if missing. +//! That is a one-shot bootstrap with no lifecycle, which is exactly what +//! `systemd-networkd` already is. +//! +//! So: config *generation* is a compiler, config *application* is a privilege +//! grant. This emits the units, with the arithmetic and the two settings people +//! get wrong already done, and a human installs them. + +use std::fmt::Write as _; + +/// MTU of the underlying link, before encapsulation. +const BASE_MTU: u32 = 1500; +/// Outer IPv4 header plus the GRE header with no options. +const GRE_OVERHEAD_V4: u32 = 24; +/// Outer IPv6 header plus the GRE header. +const GRE_OVERHEAD_V6: u32 = 44; +/// A GRE key costs four more bytes on every packet. +const GRE_KEY_OVERHEAD: u32 = 4; +/// TCP and IPv4 headers, subtracted from the tunnel MTU to get the MSS. +const TCP_IP_HEADERS: u32 = 40; + +/// What the operator asked for. +pub struct Request { + pub interface: String, + pub local: String, + pub remote: String, + pub key: Option, + pub address: Option, + pub ipv6: bool, +} + +/// Compute the tunnel MTU. +/// +/// Every option costs bytes, and enabling a key without lowering the MTU +/// produces intermittent large-packet loss that looks exactly like congestion. +/// Path MTU discovery will not save you: it depends on ICMP surviving, and ICMP +/// is precisely what gets rate-limited or dropped during an attack. +pub fn tunnel_mtu(ipv6: bool, keyed: bool) -> u32 { + let overhead = if ipv6 { + GRE_OVERHEAD_V6 + } else { + GRE_OVERHEAD_V4 + }; + BASE_MTU - overhead - if keyed { GRE_KEY_OVERHEAD } else { 0 } +} + +/// The MSS to clamp to for a given tunnel MTU. +pub fn clamp_mss(mtu: u32) -> u32 { + mtu - TCP_IP_HEADERS +} + +/// Render the systemd-networkd units and the accompanying notes. +pub fn render(req: &Request) -> String { + let mtu = tunnel_mtu(req.ipv6, req.key.is_some()); + let mss = clamp_mss(mtu); + let kind = if req.ipv6 { "ip6gre" } else { "gre" }; + + let mut out = String::new(); + + let _ = writeln!( + out, + "# Generated by `filterframe tunnel render`. Review before installing.\n\ + #\n\ + # filterframe verifies this path and refuses to divert when it is not\n\ + # usable. It does not create or modify it — see the notes at the end.\n" + ); + + let _ = writeln!( + out, + "# --- /etc/systemd/network/25-{}.netdev ---", + req.interface + ); + let _ = writeln!(out, "[NetDev]"); + let _ = writeln!(out, "Name={}", req.interface); + let _ = writeln!(out, "Kind={kind}"); + let _ = writeln!( + out, + "# {mtu} = {BASE_MTU} - {} outer/GRE{}. Every option costs bytes.", + if req.ipv6 { + GRE_OVERHEAD_V6 + } else { + GRE_OVERHEAD_V4 + }, + if req.key.is_some() { + format!(" - {GRE_KEY_OVERHEAD} for the key") + } else { + String::new() + } + ); + let _ = writeln!(out, "MTUBytes={mtu}\n"); + let _ = writeln!(out, "[Tunnel]"); + let _ = writeln!(out, "Local={}", req.local); + let _ = writeln!(out, "Remote={}", req.remote); + if let Some(k) = req.key { + let _ = writeln!( + out, + "# The provider may quote this as a dotted quad; it is the same 32-bit\n\ + # value. `ip -d link show` prints it that way." + ); + let _ = writeln!(out, "Key={k}"); + } + let _ = writeln!(out, "TTL=255\n"); + + let _ = writeln!( + out, + "# --- /etc/systemd/network/25-{}.network ---", + req.interface + ); + let _ = writeln!(out, "[Match]"); + let _ = writeln!(out, "Name={}\n", req.interface); + let _ = writeln!(out, "[Network]"); + if let Some(a) = &req.address { + let _ = writeln!(out, "Address={a}"); + } + let _ = writeln!( + out, + "# Scrubbed traffic arrives here and leaves via transit, so the return\n\ + # path is asymmetric by construction and strict reverse-path filtering\n\ + # drops it. Note the kernel takes max(all, ): setting this on the\n\ + # tunnel alone does nothing while net.ipv4.conf.all.rp_filter is 1." + ); + let _ = writeln!(out, "IPv4ReversePathFilter=no\n"); + + let _ = writeln!(out, "# --- MSS clamping (nftables) ---"); + let _ = writeln!( + out, + "# Mandatory, not optional. PMTUD depends on ICMP surviving, and ICMP is\n\ + # what gets dropped during an attack. Prefer clamping to PMTU over the\n\ + # literal {mss} so it tracks an MTU change." + ); + let _ = writeln!( + out, + "table inet filterframe {{\n \ + chain forward {{\n \ + type filter hook forward priority mangle; policy accept;\n \ + oifname \"{}\" tcp flags syn tcp option maxseg size set rt mtu\n \ + }}\n\ + }}\n", + req.interface + ); + + let _ = writeln!(out, "# --- What filterframe will not do, and why ---"); + let _ = writeln!( + out, + "#\n\ + # Creating this tunnel needs CAP_NET_ADMIN, which is not 'may create\n\ + # tunnels' — it is delete-any-interface, inject-any-route, rewrite the\n\ + # firewall, enable promiscuous mode. filterframe ships with an empty\n\ + # capability set, and that emptiness is what makes its additive-only\n\ + # rule enforceable rather than merely intended.\n\ + #\n\ + # It would also buy nothing. A tunnel is two-ended: our half alone is a\n\ + # black hole. GRE is stateless, so leaving one up between attacks costs\n\ + # nothing. And deleting on exit would tear down the return path on every\n\ + # `systemctl restart`, which is the most common thing anyone does during\n\ + # a degraded mitigation.\n\ + #\n\ + # Two things filterframe *does* check that nothing else can:\n\ + # - the tunnel's admin flag, read from `flags`, not `operstate` — GRE\n\ + # has no carrier and reports `unknown` forever, so an operstate\n\ + # check would refuse to divert on a healthy tunnel\n\ + # - that {} is not inside any prefix it would divert. If it were, the\n\ + # outer packets would be routed into the tunnel: an instant loop and\n\ + # a total outage. Only filterframe knows both halves of that.", + req.remote + ); + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req() -> Request { + Request { + interface: "gre-scrub0".into(), + local: "198.51.100.7".into(), + remote: "192.0.2.20".into(), + key: None, + address: Some("192.0.2.21/30".into()), + ipv6: false, + } + } + + /// The arithmetic people get wrong. 1476 is the published figure for plain + /// GRE over IPv4, and every option comes off it. + #[test] + fn the_mtu_arithmetic_matches_the_published_figures() { + assert_eq!(tunnel_mtu(false, false), 1476); + assert_eq!(tunnel_mtu(false, true), 1472, "a key costs four bytes"); + assert_eq!( + tunnel_mtu(true, false), + 1456, + "IPv6 has a larger outer header" + ); + assert_eq!(tunnel_mtu(true, true), 1452); + } + + #[test] + fn the_clamp_leaves_room_for_the_tcp_and_ip_headers() { + assert_eq!(clamp_mss(1476), 1436); + } + + /// Enabling a key without lowering the MTU gives intermittent large-packet + /// loss that looks like congestion, so the rendered MTU must move with it. + #[test] + fn adding_a_key_lowers_the_rendered_mtu() { + let plain = render(&req()); + let keyed = render(&Request { + key: Some(105), + ..req() + }); + assert!(plain.contains("MTUBytes=1476"), "{plain}"); + assert!(keyed.contains("MTUBytes=1472"), "{keyed}"); + assert!(keyed.contains("Key=105")); + } + + /// The setting whose absence breaks a scrubbing return path, together with + /// the `max(all, iface)` trap that makes setting it look ineffective. + #[test] + fn reverse_path_filtering_is_disabled_and_the_trap_is_explained() { + let out = render(&req()); + assert!(out.contains("IPv4ReversePathFilter=no")); + assert!(out.contains("max(all"), "the trap must be named: {out}"); + } + + #[test] + fn mss_clamping_is_included_and_tracks_the_mtu() { + let out = render(&req()); + assert!(out.contains("maxseg size set rt mtu"), "{out}"); + assert!(out.contains("PMTUD"), "should say why it is mandatory"); + } + + /// The check only filterframe can make, called out where an operator + /// building the tunnel will read it. + #[test] + fn the_recursive_routing_hazard_is_called_out() { + let out = render(&req()); + assert!(out.contains("192.0.2.20"), "must name the remote endpoint"); + assert!(out.contains("loop"), "{out}"); + } + + #[test] + fn ipv6_renders_the_right_kind() { + let out = render(&Request { + ipv6: true, + ..req() + }); + assert!(out.contains("Kind=ip6gre"), "{out}"); + } + + #[test] + fn both_unit_files_are_present() { + let out = render(&req()); + assert!(out.contains(".netdev")); + assert!(out.contains(".network")); + assert!(out.contains("[NetDev]")); + assert!(out.contains("[Tunnel]")); + } +} diff --git a/crates/cli/tests/converge.rs b/crates/cli/tests/converge.rs new file mode 100644 index 0000000..077c288 --- /dev/null +++ b/crates/cli/tests/converge.rs @@ -0,0 +1,542 @@ +//! The convergence loop, driven by a scripted policy source and an +//! instrumented speaker. +//! +//! These are the highest-value tests in the project. They assert on the +//! *sequence of BGP operations*, because that is what the safety properties are +//! actually about — not on internal state, which can be right while the wire +//! behaviour is wrong. +//! +//! The one that must never be deleted is `stale_never_tears_down`. It is +//! table-driven over every way a poll can fail, so adding a new failure mode +//! without adding a row should feel uncomfortable. + +use std::time::Duration; + +use filterframe_bgp::mock::{Call, MockSpeaker}; +use filterframe_common::Config; +use filterframe_common::mitigation::{ + ActionType, Mitigation, MitigationStatus, MitigationView, StaleReason, +}; +use filterframe_common::module::TierModule; +use filterframe_policy::PolicySource; +use filterframe_rtbh::RtbhModule; +use std::time::Instant; + +// The binary's own reconciler, compiled into this test. The allow is scoped to +// this copy: the daemon uses every item here, but an integration test exercises +// only the ones it needs, and dead-code warnings fire per compilation unit. +#[path = "../src/reconcile.rs"] +#[allow(dead_code)] +mod reconcile; + +use reconcile::Reconciler; + +const CFG: &str = r#" +global + node-id test1 + tier-rule rtbh +policy-source + url https://policy.example.net +bgp + mode embedded + local-as 64512 + originate-prefix 198.51.100.0/24 +peer t1 + role transit + address 203.0.113.1 + remote-as 64510 + community blackhole + allow-tier rtbh +"#; + +fn config() -> Config { + Config::parse(CFG).expect("test config must parse") +} + +fn mitigation(id: &str, victim: &str) -> Mitigation { + Mitigation { + id: id.into(), + victim: victim.parse().unwrap(), + status: MitigationStatus::Active, + action: ActionType::Discard, + vector: "udp_flood".into(), + customer: None, + pop: None, + acknowledged: false, + age: Duration::from_secs(30), + ttl_remaining: Some(Duration::from_secs(90)), + bps: None, + } +} + +/// A policy source that replays a script, so a test can say exactly what the +/// engine did on each tick. +struct Scripted { + steps: Vec, + at: usize, +} + +impl Scripted { + fn new(steps: Vec) -> Self { + Self { steps, at: 0 } + } + + fn fresh(sets: Vec>) -> Self { + Self::new(sets.into_iter().map(MitigationView::Fresh).collect()) + } +} + +impl PolicySource for Scripted { + async fn poll(&mut self) -> MitigationView { + // The last step repeats, so a test can tick past the end of its script. + let v = self + .steps + .get(self.at) + .or_else(|| self.steps.last()) + .cloned_view(); + self.at += 1; + v + } +} + +/// `MitigationView` is deliberately not `Clone` in the library — cloning a view +/// is not something the daemon should ever do. The test harness needs it, so it +/// is rebuilt here rather than weakening the type. +trait ClonedView { + fn cloned_view(&self) -> MitigationView; +} + +impl ClonedView for Option<&MitigationView> { + fn cloned_view(&self) -> MitigationView { + match self { + Some(MitigationView::Fresh(m)) => MitigationView::Fresh(m.clone()), + Some(MitigationView::Stale { last_fresh_at, why }) => MitigationView::Stale { + last_fresh_at: *last_fresh_at, + why: why.clone(), + }, + None => MitigationView::Fresh(vec![]), + } + } +} + +fn stale(why: StaleReason) -> MitigationView { + MitigationView::Stale { + last_fresh_at: None, + why, + } +} + +/// The RTBH module with its damping wound right down, so tests that are about +/// convergence are not also about dwell timers. The damping itself has its own +/// tests in the module crate. +fn modules() -> Vec> { + use filterframe_common::config::{ModuleSection, RawDirective}; + let mut m = RtbhModule::new(); + m.configure(&ModuleSection { + name: "rtbh".into(), + directives: vec![RawDirective { + key: "withdraw-hold".into(), + args: vec!["0s".into()], + line: 1, + }], + line: 0, + }) + .unwrap(); + vec![Box::new(m)] +} + +async fn speaker() -> MockSpeaker { + MockSpeaker::new() + .with_peers(&[("t1", "203.0.113.1")]) + .await +} + +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_new_mitigation_is_announced_once() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![mitigation("m1", "198.51.100.5")]]), + s.clone(), + modules(), + ); + + let out = r.tick(Instant::now()).await; + assert!(out.fresh); + assert_eq!(out.originated, 1); + assert_eq!(s.originated().await.len(), 1); + + // A second tick with the same world must do nothing at all. Level-triggered + // convergence means a steady state is silent. + s.clear_calls().await; + let out = r.tick(Instant::now()).await; + assert_eq!(out.originated, 0); + assert_eq!(out.withdrawn, 0); + assert!( + s.mutating_calls().await.is_empty(), + "a steady state must produce no BGP activity" + ); +} + +#[tokio::test] +async fn an_ended_mitigation_is_withdrawn() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![mitigation("m1", "198.51.100.5")], vec![]]), + s.clone(), + modules(), + ); + + r.tick(Instant::now()).await; + assert_eq!(s.originated().await.len(), 1); + + let out = r.tick(Instant::now()).await; + assert_eq!(out.withdrawn, 1); + assert!(s.originated().await.is_empty()); +} + +/// **The test that must never be deleted.** +/// +/// Table-driven over every way a poll can fail. If a new stale reason is added +/// without a row here, that is the signal to add one. +#[tokio::test] +async fn stale_never_tears_down() { + let reasons = [ + StaleReason::Unreachable("connection refused".into()), + StaleReason::RateLimited { + retry_after: Some(Duration::from_secs(30)), + }, + StaleReason::RateLimited { retry_after: None }, + StaleReason::Unauthorized("401".into()), + StaleReason::Malformed("expected an object".into()), + StaleReason::Pagination("has_more with no cursor".into()), + StaleReason::UnconfirmedEmpty { seen: 1, needed: 3 }, + ]; + + for reason in reasons { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::new(vec![ + MitigationView::Fresh(vec![mitigation("m1", "198.51.100.5")]), + stale(reason.clone()), + ]), + s.clone(), + modules(), + ); + + r.tick(Instant::now()).await; + assert_eq!(s.originated().await.len(), 1, "setup failed for {reason:?}"); + + s.clear_calls().await; + // Twenty ticks of sustained failure. There is no age at which "I cannot + // reach my policy engine" becomes "there is no attack". + for _ in 0..20 { + let out = r.tick(Instant::now()).await; + assert!(!out.fresh, "{reason:?} should be stale"); + assert_eq!(out.withdrawn, 0, "{reason:?} withdrew something"); + } + + let withdrawals: Vec<_> = s + .mutating_calls() + .await + .into_iter() + .filter(|c| matches!(c, Call::Withdraw(_))) + .collect(); + assert!( + withdrawals.is_empty(), + "{reason:?} produced withdrawals: {withdrawals:?}" + ); + assert_eq!( + s.originated().await.len(), + 1, + "{reason:?} lost the engagement" + ); + } +} + +/// Recovery must be automatic and must not re-announce what is already held. +#[tokio::test] +async fn recovering_from_stale_does_not_churn() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::new(vec![ + MitigationView::Fresh(vec![mitigation("m1", "198.51.100.5")]), + stale(StaleReason::Unreachable("down".into())), + MitigationView::Fresh(vec![mitigation("m1", "198.51.100.5")]), + ]), + s.clone(), + modules(), + ); + + r.tick(Instant::now()).await; + r.tick(Instant::now()).await; + s.clear_calls().await; + + let out = r.tick(Instant::now()).await; + assert!(out.fresh); + assert_eq!(out.originated, 0, "already held; must not re-announce"); + assert!(s.mutating_calls().await.is_empty()); +} + +/// A mitigation that disappears for one tick and comes back must produce no BGP +/// activity at all — not a withdraw followed by an announce. +/// +/// Damping proper lands with the tier modules; this asserts the weaker property +/// the level-triggered loop already gives, which is that nothing is re-announced +/// while it is continuously wanted. +#[tokio::test] +async fn a_continuously_wanted_engagement_is_never_touched_again() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![ + vec![mitigation("m1", "198.51.100.5")], + vec![mitigation("m1", "198.51.100.5")], + vec![mitigation("m1", "198.51.100.5")], + ]), + s.clone(), + modules(), + ); + + r.tick(Instant::now()).await; + s.clear_calls().await; + r.tick(Instant::now()).await; + r.tick(Instant::now()).await; + + assert!( + s.mutating_calls().await.is_empty(), + "expected silence, got {:?}", + s.mutating_calls().await + ); +} + +/// Two victims inside one prefix are two host routes, and each releases on its +/// own — the RTBH tier acts on the victim, not the aggregate. +#[tokio::test] +async fn sibling_victims_are_independent_host_routes() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![ + vec![ + mitigation("m1", "198.51.100.5"), + mitigation("m2", "198.51.100.9"), + ], + vec![mitigation("m1", "198.51.100.5")], + ]), + s.clone(), + modules(), + ); + + let out = r.tick(Instant::now()).await; + assert_eq!(out.originated, 2); + + let out = r.tick(Instant::now()).await; + assert_eq!(out.withdrawn, 1); + assert_eq!(s.originated().await.len(), 1); +} + +/// A victim outside the authority boundary must produce no announcement, and +/// must be counted rather than silently dropped. +#[tokio::test] +async fn a_victim_outside_the_authority_is_counted_not_announced() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![mitigation("m1", "203.0.113.77")]]), + s.clone(), + modules(), + ); + + let out = r.tick(Instant::now()).await; + assert_eq!(out.originated, 0); + assert_eq!(out.unhandled, 1); + assert!(s.mutating_calls().await.is_empty()); +} + +/// A failed origination is retried by the next tick, because desired state is +/// recomputed rather than remembered. That is the whole recovery mechanism. +#[tokio::test] +async fn a_failed_origination_is_retried_next_tick() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![mitigation("m1", "198.51.100.5")]]), + s.clone(), + modules(), + ); + + s.inject(filterframe_bgp::mock::Fault::Unreachable).await; + let out = r.tick(Instant::now()).await; + assert_eq!(out.originated, 0); + assert_eq!(out.failed, 1); + assert!(s.originated().await.is_empty()); + + let out = r.tick(Instant::now()).await; + assert_eq!(out.originated, 1, "the next tick must retry"); +} + +/// If the speaker cannot say what it holds, no difference can be computed — and +/// acting on a difference computed from nothing is how everything gets +/// withdrawn at once. +#[tokio::test] +async fn an_unreadable_speaker_produces_no_actions() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![mitigation("m1", "198.51.100.5")]]), + s.clone(), + modules(), + ); + r.tick(Instant::now()).await; + + s.clear_calls().await; + s.inject(filterframe_bgp::mock::Fault::Unreachable).await; + // The injected fault lands on list_originated, which is the first call the + // tick makes against the speaker. + let out = r.tick(Instant::now()).await; + assert_eq!(out.withdrawn, 0); + assert_eq!(out.originated, 0); +} + +/// The branch that guards the whole loop: an unreadable RIB must not be read as +/// an empty one. A difference computed against nothing makes every held path +/// look like surplus, which is a mass teardown. +/// +/// Distinct from `an_unreadable_speaker_produces_no_actions` in that the desired +/// set here is genuinely empty, so a tick that *did* proceed would withdraw — +/// the assertion has something to catch. +#[tokio::test] +async fn an_unreadable_speaker_does_not_withdraw_what_it_holds() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::new(vec![ + MitigationView::Fresh(vec![mitigation("m1", "198.51.100.5")]), + MitigationView::Fresh(vec![]), + ]), + s.clone(), + modules(), + ); + + r.tick(Instant::now()).await; + assert_eq!(s.originated().await.len(), 1, "setup failed"); + + // The demand is gone this tick, so a proceeding tick would withdraw. The + // read fails instead, and a failed read must decide nothing. + s.clear_calls().await; + s.inject(filterframe_bgp::mock::Fault::Unreachable).await; + let out = r.tick(Instant::now()).await; + + assert_eq!(out.failed, 1, "the failed read must be counted"); + assert_eq!(out.withdrawn, 0); + assert!( + s.mutating_calls().await.is_empty(), + "a failed read produced BGP activity: {:?}", + s.mutating_calls().await + ); + assert_eq!( + s.originated().await.len(), + 1, + "the engagement was withdrawn on the strength of a read that failed" + ); +} + +/// Adoption and convergence must come from the *same* read of the speaker. +/// +/// When they were two reads, a transient failure on the first deferred adoption +/// while the second succeeded — so the modules claimed nothing, every inherited +/// path computed as surplus, and a restart became a mass teardown. +#[tokio::test] +async fn a_failed_read_cannot_converge_without_having_adopted() { + use filterframe_common::bgp::{OriginateRequest, PathKey, RouteOriginator}; + use filterframe_common::config::Tier; + + let s = speaker().await; + // A path left behind by a previous incarnation. + let inherited = PathKey { + prefix: "198.51.100.5/32".parse().unwrap(), + tier: Tier::Rtbh, + }; + let _ = s + .originate(&OriginateRequest { + key: inherited.clone(), + peers: vec!["t1".into()], + communities: vec![], + next_hop: None, + }) + .await; + + // The engine reports nothing active, so anything not adopted is surplus. + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![]]), + s.clone(), + modules(), + ); + + s.clear_calls().await; + s.inject(filterframe_bgp::mock::Fault::Unreachable).await; + let out = r.tick(Instant::now()).await; + assert_eq!(out.failed, 1); + assert_eq!( + s.originated().await.len(), + 1, + "an inherited path was withdrawn by a tick that never adopted it" + ); + + // The next tick reads successfully, adopts, and only then may release. + let out = r.tick(Instant::now()).await; + assert_eq!(out.failed, 0, "the retry must succeed"); +} + +/// An unknown outcome is not a failure. The path may well have landed, so the +/// next tick must see it as held rather than announcing it a second time. +#[tokio::test] +async fn an_unknown_outcome_does_not_cause_a_double_announce() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![mitigation("m1", "198.51.100.5")]]), + s.clone(), + modules(), + ); + + s.inject(filterframe_bgp::mock::Fault::Unknown).await; + r.tick(Instant::now()).await; + + s.clear_calls().await; + let out = r.tick(Instant::now()).await; + assert_eq!( + out.originated, 0, + "the path was already recorded; announcing again would be a duplicate" + ); + assert!(s.mutating_calls().await.is_empty()); +} + +/// An empty world is a real answer, and the only one that legitimately releases +/// everything. It is what distinguishes a working teardown from the stale case. +#[tokio::test] +async fn a_confirmed_empty_world_does_release() { + let s = speaker().await; + let mut r = Reconciler::new( + config(), + Scripted::fresh(vec![vec![mitigation("m1", "198.51.100.5")], vec![]]), + s.clone(), + modules(), + ); + + r.tick(Instant::now()).await; + assert_eq!(s.originated().await.len(), 1); + + r.tick(Instant::now()).await; + assert!( + s.originated().await.is_empty(), + "a fresh empty view must release, or nothing could ever end" + ); +} diff --git a/crates/cli/tests/fixtures/mixed-mitigations.json b/crates/cli/tests/fixtures/mixed-mitigations.json new file mode 100644 index 0000000..768ef5e --- /dev/null +++ b/crates/cli/tests/fixtures/mixed-mitigations.json @@ -0,0 +1,24 @@ +{ + "mitigations": [ + {"mitigation_id":"m-short","victim_ip":"198.51.100.5","status":"active","action_type":"discard", + "vector":"udp_flood","customer_id":"acme", + "created_at":"2026-08-20T12:00:40Z","expires_at":"2026-08-20T12:05:00Z"}, + {"mitigation_id":"m-sustained","victim_ip":"198.51.100.9","status":"active","action_type":"police", + "vector":"syn_flood","customer_id":"acme", + "created_at":"2026-08-20T11:55:00Z","expires_at":"2026-08-20T12:10:00Z"}, + {"mitigation_id":"m-sibling","victim_ip":"198.51.100.11","status":"active","action_type":"police", + "vector":"syn_flood","customer_id":"acme", + "created_at":"2026-08-20T11:56:00Z","expires_at":"2026-08-20T12:10:00Z"}, + {"mitigation_id":"m-acked","victim_ip":"198.51.100.20","status":"active","action_type":"discard", + "vector":"icmp_flood","acknowledged_at":"2026-08-20T12:00:00Z", + "created_at":"2026-08-20T11:50:00Z","expires_at":"2026-08-20T12:10:00Z"}, + {"mitigation_id":"m-elsewhere","victim_ip":"203.0.113.77","status":"active","action_type":"discard", + "vector":"udp_flood", + "created_at":"2026-08-20T11:59:00Z","expires_at":"2026-08-20T12:10:00Z"}, + {"mitigation_id":"m-over","victim_ip":"198.51.100.30","status":"expired","action_type":"discard", + "vector":"udp_flood", + "created_at":"2026-08-20T11:00:00Z","expires_at":"2026-08-20T11:05:00Z"} + ], + "has_more": false, + "next_cursor": null +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml new file mode 100644 index 0000000..f77a4eb --- /dev/null +++ b/crates/common/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "filterframe-common" +description = "Shared types, configuration grammar, and trait definitions for filterframe." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +thiserror.workspace = true +tracing.workspace = true +ipnet.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] diff --git a/crates/common/src/bgp.rs b/crates/common/src/bgp.rs new file mode 100644 index 0000000..c2f22d7 --- /dev/null +++ b/crates/common/src/bgp.rs @@ -0,0 +1,373 @@ +//! What filterframe asks a BGP speaker to do, and — more importantly — what a +//! speaker is allowed to claim in return. +//! +//! No implementations live here. `common` stays dependency-light so that tier +//! modules can depend on it without inheriting a gRPC stack or a protocol +//! engine; the backends live in `filterframe-bgp`, and the CLI is the only +//! crate that knows which one is in use. +//! +//! # Sent is not confirmed +//! +//! BGP has no application-layer acknowledgement. There is no message a peer +//! sends back to say "I received and accepted your UPDATE", so no +//! implementation can honestly report one. Every confirmation is therefore +//! evidence of something *weaker*, and the difference matters because a divert +//! sequence withdraws a prefix from transit on the strength of it. +//! +//! Two mechanisms keep that honest: +//! +//! [`Submitted`] is `#[must_use]` and carries no verdict. A reviewer seeing +//! `let _ = speaker.originate(..)` followed by a transit withdrawal is looking +//! at a bug. +//! +//! [`Fidelity`] rides on every positive answer and says what was actually +//! established. A sidecar that computes adjacency on demand can attest that a +//! path is best and passes export policy — not that any bytes were written. An +//! in-process speaker can attest that bytes left the socket. Neither proves the +//! peer's BGP process acted on it. The type refuses to let a quorum count imply +//! more than the protocol affords. +//! +//! # Epochs +//! +//! A confirmation is valid only within one session. If a session flaps between +//! confirming a path and acting on that confirmation, the confirmation is +//! void — the peer that acknowledged it is not the peer we are now talking to. +//! [`PeerStatus::epoch`] is a monotonic flap counter, snapshotted before a +//! confirmation and re-checked after, and that comparison is the entire defence +//! against acting on a stale one. + +use std::fmt; +use std::net::IpAddr; +use std::time::Instant; + +use ipnet::IpNet; +use serde::Serialize; + +use crate::config::{Community, Tier}; + +/// A peer, by its configured name. +/// +/// Names rather than addresses throughout: an address is an implementation +/// detail of a peer, and every log line, metric label and status row an +/// operator reads is written in terms of the name they chose. +pub type PeerName = String; + +/// Identifies a path filterframe originated, stably across restarts. +/// +/// Deliberately **not** keyed on anything the server assigns. A sidecar's own +/// path identifier does not survive that sidecar restarting, so keying on it +/// would leave a restarted daemon unable to recognise or withdraw its own work. +/// Prefix plus tier is derivable from configuration and the mitigation set +/// alone, which is what makes recovery possible at all. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct PathKey { + pub prefix: IpNet, + pub tier: Tier, +} + +impl fmt::Display for PathKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.tier.as_str(), self.prefix) + } +} + +/// A request to originate a path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OriginateRequest { + pub key: PathKey, + /// Peers this path is offered to. Resolved from the tier's `allow-tier` + /// declarations, so a path can never reach a peer the operator did not + /// authorise for that tier. + pub peers: Vec, + /// Communities to attach, already resolved per peer at the call site. + pub communities: Vec, + pub next_hop: Option, +} + +/// The result of asking a speaker to originate or withdraw. +/// +/// Carries **no** confirmation, on purpose. It records that the request was +/// accepted for processing and nothing more; whether the path reached a peer is +/// a separate question with a separate answer, and conflating them is how a +/// transit withdrawal happens on the strength of a local function call. +#[must_use = "Submitted is not confirmation — verify with RibObserver::advertised before acting on it"] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Submitted { + pub key: PathKey, + pub at: Instant, +} + +/// What a positive advertisement verdict actually establishes. +/// +/// Ordered weakest to strongest, and `Ord` so a caller can require a minimum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Fidelity { + /// Nothing was established. Only a mock produces this. + Synthetic, + /// The path is best for this peer and passes its export policy, computed on + /// demand. It does **not** establish that an UPDATE was ever written: a + /// sidecar that derives adjacency per query has no record of transmission + /// to consult. + PolicyEligible, + /// The bytes for this UPDATE were written to the peer's socket, and the + /// session has stayed established since with no notification. + Written, + /// As `Written`, and the peer's TCP stack acknowledged every byte. + /// + /// The strongest evidence available, and still not proof the peer's BGP + /// process acted on it. Say so wherever this is surfaced. + Flushed, +} + +/// Why a path is not being advertised to a peer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SuppressReason { + /// The peer's export policy rejects it. Waiting will not help, which is the + /// whole reason this is distinct from `Absent`. + PolicyFiltered, + /// A prefix limit toward this peer is already reached. + LimitReached, + /// The address family is not negotiated on this session. + FamilyNotNegotiated, +} + +/// Why an advertisement verdict could not be determined. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum UnknownReason { + SessionDown, + /// The session came up too recently to have sent anything yet. Counting it + /// would let a session established milliseconds ago satisfy a quorum. + SessionTooYoung, + QueryFailed(String), + NotSupported, +} + +/// What a speaker can say about one path toward one peer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "verdict")] +pub enum Advertisement { + Advertised { + fidelity: Fidelity, + /// The session epoch this verdict belongs to. A verdict is void if the + /// session flaps before it is acted on. + peer_epoch: u64, + }, + Suppressed { + reason: SuppressReason, + }, + Absent, + /// Never counts toward a quorum. An unanswerable question is not a "no", + /// and it is certainly not a "yes". + Unknown { + reason: UnknownReason, + }, +} + +impl Advertisement { + /// Whether this counts toward a quorum at the required strength. + pub fn counts(&self, min_fidelity: Fidelity) -> bool { + matches!(self, Self::Advertised { fidelity, .. } if *fidelity >= min_fidelity) + } + + /// Whether waiting longer could change this answer. + /// + /// `Suppressed` is settled — an export policy is not going to relent — so a + /// confirmation loop can fail fast instead of burning its deadline. + pub fn could_still_change(&self) -> bool { + matches!(self, Self::Absent | Self::Unknown { .. }) + } +} + +/// BGP finite state machine states, as far as filterframe cares. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SessionState { + Idle, + Connect, + Active, + OpenSent, + OpenConfirm, + Established, +} + +impl SessionState { + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Connect => "connect", + Self::Active => "active", + Self::OpenSent => "open-sent", + Self::OpenConfirm => "open-confirm", + Self::Established => "established", + } + } +} + +/// A peer's current condition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PeerStatus { + pub name: PeerName, + pub address: IpAddr, + pub state: SessionState, + /// Monotonic flap counter. Liveness and epoch must be read as one + /// observation: a peer that is established *now* but has flapped since a + /// confirmation was taken is not the peer that confirmed anything. + pub epoch: u64, + /// How long the session has been established, for the too-young check. + pub established_for: Option, +} + +/// Why a speaker operation failed. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum BgpError { + /// The speaker is not reachable. Withdrawals retry through this forever; + /// originations retry to a deadline and then report the outcome as unknown. + #[error("speaker unreachable: {0}")] + Unreachable(String), + + /// The request was rejected in a way that retrying cannot fix — a malformed + /// attribute, an unconfigured peer. Retrying hides a bug. + #[error("rejected: {0}")] + Rejected(String), + + /// The call did not complete in time, or was cancelled. + /// + /// **Not a failure.** A cancelled origination may well have reached the + /// speaker, so the caller must re-verify rather than assume nothing + /// happened. Treating this as "did not send" is how a path gets originated + /// twice, or a withdrawal gets skipped. + #[error("outcome unknown: {0}")] + Unknown(String), +} + +/// Originates and withdraws paths. +/// +/// Separate from [`RibObserver`] so the fast tier, which announces and +/// withdraws and never needs a quorum, can take the narrower type — its blast +/// radius is then provable from the signature. +pub trait RouteOriginator: Send + Sync { + /// Announce a path. Idempotent: re-originating an identical request is a + /// no-op at the backend, which is what lets recovery re-assert desired + /// state unconditionally. + fn originate( + &self, + req: &OriginateRequest, + ) -> impl std::future::Future> + Send; + + /// Withdraw a path. Idempotent: withdrawing an absent path succeeds. + fn withdraw( + &self, + key: &PathKey, + ) -> impl std::future::Future> + Send; + + /// Every path this speaker currently believes filterframe originated. + /// + /// Used at startup to adopt work a previous incarnation left behind. + fn list_originated( + &self, + ) -> impl std::future::Future, BgpError>> + Send; +} + +/// Reports what a speaker is actually advertising. +pub trait RibObserver: Send + Sync { + fn peers(&self) -> impl std::future::Future, BgpError>> + Send; + + /// What is being advertised for `prefix` toward `peer`. + fn advertised( + &self, + peer: &str, + prefix: IpNet, + ) -> impl std::future::Future> + Send; + + /// The strongest fidelity this backend can ever report. + /// + /// Surfaced in `status` and the runbook so an operator knows what a quorum + /// on this deployment actually means. + fn max_fidelity(&self) -> Fidelity; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn adv(f: Fidelity) -> Advertisement { + Advertisement::Advertised { + fidelity: f, + peer_epoch: 1, + } + } + + #[test] + fn fidelity_is_ordered_weakest_to_strongest() { + assert!(Fidelity::Synthetic < Fidelity::PolicyEligible); + assert!(Fidelity::PolicyEligible < Fidelity::Written); + assert!(Fidelity::Written < Fidelity::Flushed); + } + + /// A caller requiring real evidence must not be satisfied by a weaker + /// backend quietly reporting success. + #[test] + fn a_weaker_fidelity_does_not_satisfy_a_stronger_requirement() { + assert!(!adv(Fidelity::PolicyEligible).counts(Fidelity::Written)); + assert!(adv(Fidelity::Written).counts(Fidelity::Written)); + assert!(adv(Fidelity::Flushed).counts(Fidelity::Written)); + } + + /// The rule the whole confirmation design rests on: an unanswerable + /// question is never a yes. + #[test] + fn unknown_never_counts_toward_a_quorum() { + for reason in [ + UnknownReason::SessionDown, + UnknownReason::SessionTooYoung, + UnknownReason::QueryFailed("timeout".into()), + UnknownReason::NotSupported, + ] { + let a = Advertisement::Unknown { reason }; + assert!(!a.counts(Fidelity::Synthetic), "{a:?} must not count"); + } + } + + #[test] + fn absent_and_suppressed_never_count() { + assert!(!Advertisement::Absent.counts(Fidelity::Synthetic)); + assert!( + !Advertisement::Suppressed { + reason: SuppressReason::PolicyFiltered + } + .counts(Fidelity::Synthetic) + ); + } + + /// Suppressed is settled, so a confirmation loop can fail fast rather than + /// burning a 45-second deadline waiting for an export policy to relent. + #[test] + fn only_unsettled_verdicts_are_worth_waiting_on() { + assert!(Advertisement::Absent.could_still_change()); + assert!( + Advertisement::Unknown { + reason: UnknownReason::SessionDown + } + .could_still_change() + ); + assert!( + !Advertisement::Suppressed { + reason: SuppressReason::PolicyFiltered + } + .could_still_change() + ); + assert!(!adv(Fidelity::Written).could_still_change()); + } + + #[test] + fn path_keys_render_readably() { + let k = PathKey { + prefix: "198.51.100.5/32".parse().unwrap(), + tier: Tier::Rtbh, + }; + assert_eq!(k.to_string(), "rtbh:198.51.100.5/32"); + } +} diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs new file mode 100644 index 0000000..b428856 --- /dev/null +++ b/crates/common/src/config.rs @@ -0,0 +1,1874 @@ +//! The configuration grammar, and the parser for it. +//! +//! filterframe's configuration is a line-based grammar rather than YAML or +//! TOML, matching packetframe. Two properties earn that choice: a parse error +//! can cite the exact source line an operator is looking at, and the shipped +//! reference config can carry a comment above every directive explaining its +//! default, its reload semantics, and why it exists — which makes the config +//! file a primary document rather than a sample to be copied blindly. +//! +//! The parser here is deliberately **pure**: it turns a `&str` into a `Config` +//! and touches no filesystem, no network, and no clock. Everything that needs +//! the world — checking that a state directory is writable, that a peer address +//! is routable — lives in `preflight`, so that parsing can be exercised +//! exhaustively in unit tests and on a developer laptop that is not a filter +//! node. +//! +//! Unknown directives are fatal. A typo must stop the daemon at load rather +//! than silently changing behaviour during an incident. + +use std::collections::HashSet; +use std::net::{IpAddr, Ipv4Addr}; +use std::path::PathBuf; +use std::str::FromStr; +use std::time::Duration; + +use ipnet::IpNet; +use serde::Serialize; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Why a configuration could not be loaded. +/// +/// Variants exist to drive a decision, not to carry a string: `Io` means the +/// operator named a path that is not there, `Parse` means they can be pointed +/// at a line, and the duplicate variants mean the file is internally +/// inconsistent in a way no line number alone would explain. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("cannot read {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("line {line}: {message}")] + Parse { line: usize, message: String }, + + #[error("line {line}: duplicate `{section}` section; it may appear only once")] + DuplicateSection { line: usize, section: &'static str }, + + #[error("line {line}: duplicate {kind} `{name}`")] + DuplicateNamed { + line: usize, + kind: &'static str, + name: String, + }, + + /// Cross-section rules that no single line is responsible for. + #[error("configuration is inconsistent:\n{}", .0.join("\n"))] + Invalid(Vec), +} + +impl ConfigError { + fn parse(line: usize, message: impl Into) -> Self { + Self::Parse { + line, + message: message.into(), + } + } +} + +type Result = std::result::Result; + +// --------------------------------------------------------------------------- +// Small types +// --------------------------------------------------------------------------- + +/// How much the daemon says. +/// +/// `FromStr` and `as_str` are kept adjacent deliberately: when the parse +/// spelling and the display spelling live in separate functions they drift, and +/// the drift shows up as a config file that round-trips into a different file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl LogLevel { + pub fn as_str(self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +impl FromStr for LogLevel { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "trace" => Ok(Self::Trace), + "debug" => Ok(Self::Debug), + "info" => Ok(Self::Info), + "warn" => Ok(Self::Warn), + "error" => Ok(Self::Error), + other => Err(format!( + "unknown log level `{other}`; expected trace, debug, info, warn or error" + )), + } + } +} + +/// Whether the daemon may announce anything at all. +/// +/// `Observe` runs the entire loop — polls the policy engine, computes desired +/// state, records what it would do — and makes no BGP call. It is the default +/// because it is how filterframe lands on a production filter node without a +/// bad night, and it is restart-only because flipping it live would bring real +/// sessions up underneath an already-computed desired set and announce the +/// whole thing in one step with nobody watching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + Observe, + Enforce, +} + +impl Mode { + pub fn as_str(self) -> &'static str { + match self { + Self::Observe => "observe", + Self::Enforce => "enforce", + } + } +} + +impl FromStr for Mode { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "observe" => Ok(Self::Observe), + "enforce" => Ok(Self::Enforce), + other => Err(format!( + "unknown mode `{other}`; expected observe or enforce" + )), + } + } +} + +/// Which BGP implementation carries the announcements. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BgpMode { + /// Drive an external GoBGP daemon over gRPC. The sessions outlive + /// filterframe, so a restart is invisible to the routers. + Gobgp, + /// Speak BGP in-process. One less moving part, but the sessions die with + /// the daemon. + Embedded, +} + +impl BgpMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Gobgp => "gobgp", + Self::Embedded => "embedded", + } + } +} + +impl FromStr for BgpMode { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "gobgp" => Ok(Self::Gobgp), + "embedded" => Ok(Self::Embedded), + other => Err(format!( + "unknown bgp mode `{other}`; expected gobgp or embedded" + )), + } + } +} + +/// What a peer is for. Checked against `allow-tier` so that a scrubber peer +/// configured to accept blackholes is refused at load rather than at 3am. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PeerRole { + /// An upstream that carries traffic to us in normal operation. + Transit, + /// A scrubbing provider's route reflector. + Scrubber, + /// The local edge router, which learns our divert signal. + Edge, +} + +impl PeerRole { + pub fn as_str(self) -> &'static str { + match self { + Self::Transit => "transit", + Self::Scrubber => "scrubber", + Self::Edge => "edge", + } + } +} + +impl FromStr for PeerRole { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "transit" => Ok(Self::Transit), + "scrubber" => Ok(Self::Scrubber), + "edge" => Ok(Self::Edge), + other => Err(format!( + "unknown peer role `{other}`; expected transit, scrubber or edge" + )), + } + } +} + +/// A mitigation mechanism. +/// +/// `Ord` so that the planner can key a `BTreeMap` on `(Tier, IpNet)` and get a +/// stable iteration order — status output and log lines that reorder between +/// ticks are hard to diff during an incident. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Tier { + /// Announce a host route with a blackhole community, dropping the attack + /// upstream. Fast, and sacrifices the victim address. + Rtbh, + /// Announce the covering prefix to a scrubbing provider and signal the edge + /// to stop advertising it to transit. + Divert, + /// Carry the divert signal toward the edge. A peer role rather than a + /// decision a rule can reach. + DivertSignal, +} + +impl Tier { + pub fn as_str(self) -> &'static str { + match self { + Self::Rtbh => "rtbh", + Self::Divert => "divert", + Self::DivertSignal => "divert-signal", + } + } +} + +impl FromStr for Tier { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "rtbh" => Ok(Self::Rtbh), + "divert" => Ok(Self::Divert), + "divert-signal" => Ok(Self::DivertSignal), + other => Err(format!( + "unknown tier `{other}`; expected rtbh, divert or divert-signal" + )), + } + } +} + +/// What to do when the policy engine cannot be reached. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PolicyLoss { + /// Keep converging on the last known set, letting each mitigation lapse at + /// its own expiry. The default, and the only choice that cannot turn a + /// policy-engine outage into a customer outage. + Hold, + /// Withdraw everything. Correct only where losing the policy engine means + /// losing the reason to mitigate at all. + Drain, +} + +impl PolicyLoss { + pub fn as_str(self) -> &'static str { + match self { + Self::Hold => "hold", + Self::Drain => "drain", + } + } +} + +impl FromStr for PolicyLoss { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "hold" => Ok(Self::Hold), + "drain" => Ok(Self::Drain), + other => Err(format!( + "unknown on-policy-loss `{other}`; expected hold or drain" + )), + } + } +} + +/// A BGP community, kept in the canonical textual form an operator would +/// recognise from a provider's documentation. +/// +/// Stored parsed rather than as a string so that a malformed value fails at +/// config load. Rendered back in the same spelling so that `filterframe status` +/// and the provider's docs agree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(untagged)] +pub enum Community { + /// RFC 1997, `asn:value`. Cannot express an ASN above 65535. + Standard { asn: u16, value: u16 }, + /// RFC 8092, `global:local1:local2`. Required for any 4-byte-ASN provider. + Large { + global: u32, + local1: u32, + local2: u32, + }, + /// A reserved well-known value, spelled by name. + /// + /// Names rather than numbers on purpose: a mistyped `65535:665` reads as + /// plausible in review, where `blackhoel` fails at config load. + WellKnown(WellKnown), +} + +/// Reserved RFC 1997 communities that have names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum WellKnown { + /// RFC 7999. The signal that says "drop this". + Blackhole, + NoExport, + NoAdvertise, + NoExportSubconfed, +} + +impl WellKnown { + pub fn as_str(self) -> &'static str { + match self { + Self::Blackhole => "blackhole", + Self::NoExport => "no-export", + Self::NoAdvertise => "no-advertise", + Self::NoExportSubconfed => "no-export-subconfed", + } + } + + /// The 32-bit wire value. + pub fn value(self) -> u32 { + match self { + Self::Blackhole => 0xFFFF_029A, + Self::NoExport => 0xFFFF_FF01, + Self::NoAdvertise => 0xFFFF_FF02, + Self::NoExportSubconfed => 0xFFFF_FF03, + } + } +} + +impl FromStr for Community { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s { + "blackhole" => return Ok(Self::WellKnown(WellKnown::Blackhole)), + "no-export" => return Ok(Self::WellKnown(WellKnown::NoExport)), + "no-advertise" => return Ok(Self::WellKnown(WellKnown::NoAdvertise)), + "no-export-subconfed" => return Ok(Self::WellKnown(WellKnown::NoExportSubconfed)), + _ => {} + } + + let parts: Vec<&str> = s.split(':').collect(); + match parts.as_slice() { + [a, v] => { + let asn = a + .parse::() + .map_err(|_| format!("`{a}` is not a 16-bit ASN in community `{s}`"))?; + let value = v + .parse::() + .map_err(|_| format!("`{v}` is not a 16-bit value in community `{s}`"))?; + Ok(Self::Standard { asn, value }) + } + [g, l1, l2] => Ok(Self::Large { + global: g + .parse() + .map_err(|_| format!("`{g}` is not a 32-bit value in community `{s}`"))?, + local1: l1 + .parse() + .map_err(|_| format!("`{l1}` is not a 32-bit value in community `{s}`"))?, + local2: l2 + .parse() + .map_err(|_| format!("`{l2}` is not a 32-bit value in community `{s}`"))?, + }), + _ => Err(format!( + "`{s}` is not a community; expected asn:value, global:local1:local2, \ + or a well-known name (blackhole, no-export, no-advertise, no-export-subconfed)" + )), + } + } +} + +impl std::fmt::Display for Community { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Standard { asn, value } => write!(f, "{asn}:{value}"), + Self::Large { + global, + local1, + local2, + } => write!(f, "{global}:{local1}:{local2}"), + Self::WellKnown(w) => f.write_str(w.as_str()), + } + } +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- + +/// Defaults are named constants rather than literals in `impl Default`, so that +/// the reference config and the code cannot disagree about what "unset" means. +pub const DEFAULT_STATE_DIR: &str = "/var/lib/filterframe"; +/// Two seconds against the reference policy engine's own thirty-second +/// reconcile tick: filterframe is never the slow half of the pair, and one +/// request every two seconds is negligible against a shared rate limit. +pub const DEFAULT_TICK_INTERVAL: Duration = Duration::from_secs(2); +/// A converge step that has not returned by this point is reported degraded and +/// its slice of desired state waits for the next tick. This bounds *returning*, +/// not finishing — a blocked loop stops answering SIGTERM. +pub const DEFAULT_CONVERGE_DEADLINE: Duration = Duration::from_secs(2); +/// Three consecutive failed polls at the default tick is six seconds of grace, +/// which covers a policy-engine restart without declaring the source lost. +pub const DEFAULT_FAILURE_THRESHOLD: u32 = 3; +/// Long enough to survive a policy-engine deployment, short enough that an +/// operator paged about it can still act before anything is retired. +pub const DEFAULT_POLICY_LOSS_GRACE: Duration = Duration::from_secs(900); +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +pub const DEFAULT_HOLD_TIME: Duration = Duration::from_secs(90); +pub const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GlobalConfig { + pub node_id: String, + pub state_dir: PathBuf, + pub metrics_textfile: Option, + pub log_level: LogLevel, + pub mode: Mode, + #[serde(with = "duration_secs")] + pub tick_interval: Duration, + #[serde(with = "duration_secs")] + pub converge_deadline: Duration, +} + +impl Default for GlobalConfig { + fn default() -> Self { + Self { + node_id: String::new(), + state_dir: PathBuf::from(DEFAULT_STATE_DIR), + metrics_textfile: None, + log_level: LogLevel::Info, + mode: Mode::Observe, + tick_interval: DEFAULT_TICK_INTERVAL, + converge_deadline: DEFAULT_CONVERGE_DEADLINE, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PolicySourceConfig { + pub url: String, + pub token_file: Option, + pub pop: Option, + #[serde(with = "duration_secs")] + pub request_timeout: Duration, + pub failure_threshold: u32, + pub on_policy_loss: PolicyLoss, + #[serde(with = "duration_secs")] + pub policy_loss_grace: Duration, + pub ca_file: Option, +} + +impl Default for PolicySourceConfig { + fn default() -> Self { + Self { + url: String::new(), + token_file: None, + pop: None, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + failure_threshold: DEFAULT_FAILURE_THRESHOLD, + on_policy_loss: PolicyLoss::Hold, + policy_loss_grace: DEFAULT_POLICY_LOSS_GRACE, + ca_file: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BgpConfig { + pub mode: BgpMode, + pub local_as: u32, + pub router_id: Option, + #[serde(with = "duration_secs")] + pub hold_time: Duration, + #[serde(with = "duration_secs")] + pub connect_retry: Duration, + pub grpc_endpoint: Option, + /// The authority boundary. filterframe refuses to announce anything not + /// covered by one of these, at load and again at every converge step. + /// Without it, a confused or compromised policy engine could have this node + /// announce space it does not hold. + pub originate: Vec, + /// Provenance tag on every path filterframe originates, so that a restarted + /// daemon can tell its own orphans from another controller's paths in the + /// same RIB, and never withdraw what is not its own. + pub origin_community: Option, +} + +impl Default for BgpConfig { + fn default() -> Self { + Self { + mode: BgpMode::Gobgp, + local_as: 0, + router_id: None, + hold_time: DEFAULT_HOLD_TIME, + connect_retry: DEFAULT_CONNECT_RETRY, + grpc_endpoint: None, + originate: Vec::new(), + origin_community: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PeerSection { + pub name: String, + pub role: Option, + pub address: Option, + pub remote_as: Option, + pub communities: Vec, + pub allow_tiers: Vec, + pub multihop: Option, + pub line: usize, +} + +impl PeerSection { + fn new(name: String, line: usize) -> Self { + Self { + name, + role: None, + address: None, + remote_as: None, + communities: Vec::new(), + allow_tiers: Vec::new(), + multihop: None, + line, + } + } +} + +/// One fact a tier rule can test. +/// +/// A closed set of typed facts rather than an expression language: an operator +/// should be able to read a rule aloud and predict what it does, and every fact +/// here is one filterframe can actually obtain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "fact", content = "value")] +pub enum Fact { + Action(String), + Vector(String), + Customer(String), + #[serde(with = "duration_secs")] + AgeAtLeast(Duration), + #[serde(with = "duration_secs")] + AgeAtMost(Duration), + BpsAtLeast(u64), + Acknowledged(bool), +} + +/// A first-match-wins rule mapping a mitigation to a tier. +/// +/// `tier: None` is an explicit refusal — the way to carve an exception out of a +/// broader rule below it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TierRule { + pub tier: Option, + pub facts: Vec, + pub line: usize, +} + +/// A module's directives, left uninterpreted here. +/// +/// `common` deliberately does not know what `rtbh` or `scrub-divert` accept: +/// each module validates its own section, so adding a module does not mean +/// editing the shared parser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ModuleSection { + pub name: String, + pub directives: Vec, + pub line: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RawDirective { + pub key: String, + pub args: Vec, + pub line: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)] +pub struct Config { + pub global: GlobalConfig, + pub policy_source: PolicySourceConfig, + pub bgp: BgpConfig, + pub peers: Vec, + pub tier_rules: Vec, + pub modules: Vec, +} + +/// Serialize a `Duration` as whole seconds, so a status dump reads `90` rather +/// than `{ secs: 90, nanos: 0 }`. +mod duration_secs { + use serde::Serializer; + use std::time::Duration; + + pub fn serialize(d: &Duration, s: S) -> Result { + s.serialize_u64(d.as_secs()) + } +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Which section subsequent directives belong to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Cursor { + None, + Global, + PolicySource, + Bgp, + Peer(usize), + Module(usize), +} + +impl Config { + /// Read and parse a configuration file. + pub fn from_file(path: impl Into) -> Result { + let path = path.into(); + let text = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io { + path: path.clone(), + source, + })?; + Self::parse(&text) + } + + /// Parse a configuration from text. + /// + /// Pure: no filesystem, no clock, no network. Cross-section rules that a + /// single line cannot be blamed for run in [`Config::validate`], which this + /// calls last. + pub fn parse(input: &str) -> Result { + let mut cfg = Config::default(); + let mut cursor = Cursor::None; + let mut seen_global = false; + let mut seen_policy = false; + let mut seen_bgp = false; + + for (idx, raw) in input.lines().enumerate() { + let line = idx + 1; + let text = strip_comment(raw).trim(); + if text.is_empty() { + continue; + } + + let mut tokens = text.split_whitespace(); + let head = tokens.next().expect("non-empty after trim"); + let rest: Vec<&str> = tokens.collect(); + + match head { + "global" => { + require_no_args(line, "global", &rest)?; + if seen_global { + return Err(ConfigError::DuplicateSection { + line, + section: "global", + }); + } + seen_global = true; + cursor = Cursor::Global; + } + "policy-source" => { + require_no_args(line, "policy-source", &rest)?; + if seen_policy { + return Err(ConfigError::DuplicateSection { + line, + section: "policy-source", + }); + } + seen_policy = true; + cursor = Cursor::PolicySource; + } + "bgp" => { + require_no_args(line, "bgp", &rest)?; + if seen_bgp { + return Err(ConfigError::DuplicateSection { + line, + section: "bgp", + }); + } + seen_bgp = true; + cursor = Cursor::Bgp; + } + "peer" => { + let name = one_arg(line, "peer", &rest)?; + if let Some(prev) = cfg.peers.iter().find(|p| p.name == name) { + let _ = prev; + return Err(ConfigError::DuplicateNamed { + line, + kind: "peer", + name, + }); + } + cfg.peers.push(PeerSection::new(name, line)); + cursor = Cursor::Peer(cfg.peers.len() - 1); + } + "module" => { + let name = one_arg(line, "module", &rest)?; + if cfg.modules.iter().any(|m| m.name == name) { + return Err(ConfigError::DuplicateNamed { + line, + kind: "module", + name, + }); + } + cfg.modules.push(ModuleSection { + name, + directives: Vec::new(), + line, + }); + cursor = Cursor::Module(cfg.modules.len() - 1); + } + _ => match cursor { + Cursor::None => { + return Err(ConfigError::parse( + line, + format!( + "`{head}` appears before any section header; \ + expected one of global, policy-source, bgp, peer , module " + ), + )); + } + Cursor::Global => parse_global(&mut cfg, line, head, &rest)?, + Cursor::PolicySource => parse_policy_source(&mut cfg, line, head, &rest)?, + Cursor::Bgp => parse_bgp(&mut cfg, line, head, &rest)?, + Cursor::Peer(i) => parse_peer(&mut cfg.peers[i], line, head, &rest)?, + Cursor::Module(i) => cfg.modules[i].directives.push(RawDirective { + key: head.to_string(), + args: rest.iter().map(|s| s.to_string()).collect(), + line, + }), + }, + } + } + + cfg.validate()?; + Ok(cfg) + } + + /// Cross-section rules, each of which refuses at load rather than at 3am. + /// + /// Separate from `parse` so it can be exercised against a hand-built + /// `Config` in tests without going through the grammar. + pub fn validate(&self) -> Result<()> { + let mut errors = Vec::new(); + + if self.global.node_id.is_empty() { + errors.push("global: `node-id` is required".to_string()); + } + if self.policy_source.url.is_empty() { + errors.push("policy-source: `url` is required".to_string()); + } else if !self.policy_source.url.starts_with("https://") + && !self.policy_source.url.starts_with("http://") + { + errors.push(format!( + "policy-source: `url` must be http:// or https://, got `{}`", + self.policy_source.url + )); + } + if self.bgp.local_as == 0 { + errors.push("bgp: `local-as` is required".to_string()); + } + if self.bgp.mode == BgpMode::Gobgp && self.bgp.grpc_endpoint.is_none() { + errors.push( + "bgp: `grpc-endpoint` is required when `mode gobgp` (it is where the \ + GoBGP sidecar listens)" + .to_string(), + ); + } + if self.bgp.originate.is_empty() { + errors.push( + "bgp: at least one `originate-prefix` is required; it is the authority \ + boundary, and without it filterframe would announce whatever it was told to" + .to_string(), + ); + } + + // Peers: a missing field here is a session that silently never comes up. + let mut addresses = HashSet::new(); + for p in &self.peers { + let ctx = format!("peer {} (line {})", p.name, p.line); + let Some(role) = p.role else { + errors.push(format!("{ctx}: `role` is required")); + continue; + }; + if p.address.is_none() { + errors.push(format!("{ctx}: `address` is required")); + } + if p.remote_as.is_none() { + errors.push(format!("{ctx}: `remote-as` is required")); + } + if let Some(addr) = p.address + && !addresses.insert(addr) + { + errors.push(format!("{ctx}: duplicate peer address {addr}")); + } + + for tier in &p.allow_tiers { + // Which role can serve which tier. Spelled out rather than + // inferred, so that adding a tier forces a decision here + // instead of silently defaulting to "any peer will do". + let ok = matches!( + (role, tier), + (PeerRole::Transit, Tier::Rtbh) + | (PeerRole::Scrubber, Tier::Divert) + | (PeerRole::Edge, Tier::DivertSignal) + ); + if !ok { + errors.push(format!( + "{ctx}: role `{}` cannot serve tier `{}`; \ + rtbh needs a transit peer, divert a scrubber, divert-signal an edge", + role.as_str(), + tier.as_str() + )); + } + } + + if p.allow_tiers.contains(&Tier::Rtbh) + && !p.communities.iter().any(|c| { + matches!(c, Community::WellKnown(WellKnown::Blackhole)) + || matches!(c, Community::Standard { .. } | Community::Large { .. }) + }) + { + errors.push(format!( + "{ctx}: allows tier `rtbh` but declares no `community`; a blackhole \ + announced without one is just a host route, forwarded normally" + )); + } + } + + // A tier a rule can select but no peer can serve is a rule that fires + // into nothing. Better to say so now than to watch it not work. + for rule in &self.tier_rules { + let Some(tier) = rule.tier else { continue }; + if !self.peers.iter().any(|p| p.allow_tiers.contains(&tier)) { + errors.push(format!( + "line {}: tier-rule selects `{}` but no peer allows that tier", + rule.line, + tier.as_str() + )); + } + } + + // An unconditional rule shadows everything after it. + if let Some(pos) = self.tier_rules.iter().position(|r| r.facts.is_empty()) + && pos + 1 < self.tier_rules.len() + { + errors.push(format!( + "line {}: this tier-rule has no `when` clause, so it matches everything and \ + the {} rule(s) after it can never fire", + self.tier_rules[pos].line, + self.tier_rules.len() - pos - 1 + )); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(ConfigError::Invalid(errors)) + } + } + + /// Whether a reload may move a running daemon from `self` to `new`. + /// + /// Pure over two values so the rule is testable without a running daemon. + /// Refusals name the directive, because "restart required" without saying + /// which line caused it is the least useful message a daemon can emit. + pub fn restart_only_delta(&self, new: &Config) -> std::result::Result<(), String> { + if self.global.mode != new.global.mode { + return Err(format!( + "`mode` cannot change on reload ({} -> {}): switching to enforce would announce \ + an already-computed desired set in one step. Restart instead.", + self.global.mode.as_str(), + new.global.mode.as_str() + )); + } + if self.global.state_dir != new.global.state_dir { + return Err("`state-dir` cannot change on reload; the journal is already open".into()); + } + if self.global.node_id != new.global.node_id { + return Err( + "`node-id` cannot change on reload; it is baked into metrics labels".into(), + ); + } + if self.bgp != new.bgp { + return Err( + "the `bgp` section cannot change on reload; sessions and the authority \ + boundary are bound at start" + .into(), + ); + } + let old_peers: Vec<&String> = self.peers.iter().map(|p| &p.name).collect(); + let new_peers: Vec<&String> = new.peers.iter().map(|p| &p.name).collect(); + if old_peers != new_peers { + return Err( + "peers cannot be added or removed on reload; session bring-up happens at \ + start. Restart instead." + .into(), + ); + } + for (a, b) in self.peers.iter().zip(new.peers.iter()) { + if a.address != b.address || a.remote_as != b.remote_as || a.role != b.role { + return Err(format!( + "peer `{}`: address, remote-as and role cannot change on reload", + a.name + )); + } + } + // Module sections are bound at start. Each module parses its own section + // once, into its own state, and the reload path replaces only the shared + // `Config` — so accepting an edit here would report "OK reloaded" while + // the module went on using the values it started with. + // + // Refused rather than applied because applying it means rebuilding a + // module, and a module's state *is* its damping: every dwell, every + // hold clock and every ceiling would restart at once, mid-incident, + // which is a worse surprise than being told to restart. The two + // directives that make this urgent are `never-blackhole` — the fastest + // lever during a mistaken mitigation — and `divertible-prefix`, where a + // silent no-op splits the planner's view from the module's and every + // affected diversion is refused as not-divertible until a restart. + if !modules_equivalent(&self.modules, &new.modules) { + let changed = module_delta(&self.modules, &new.modules); + return Err(format!( + "module sections cannot change on reload ({changed}); each module parses \ + its section once at start, and its damping clocks are that state. \ + Restart instead." + )); + } + Ok(()) + } +} + +/// Refuse a directive this build parses but does not act on. +/// +/// The alternative — accept it and ignore it — is strictly worse than a typo. +/// A typo is caught by `unknown_directive`; a directive that is *accepted* and +/// inert leaves an operator believing a guard is in force. `ca-file` was the +/// sharp end of that: a private CA named here and never installed reads as +/// pinned TLS while the connection validates against the system trust store. +/// +/// These come back as the feature lands. Until then the refusal names the thing +/// that actually does the job, where there is one. +fn not_yet_honoured(line: usize, key: &str, why: &str) -> ConfigError { + ConfigError::parse( + line, + format!( + "`{key}` is not honoured by this version, and accepting it would leave you \ + believing it was: {why}. Remove it" + ), + ) +} + +/// One module section reduced to what it actually means: a name and a +/// line-number-free, order-free view of its directives. +type ModuleShape = (String, Vec<(String, Vec)>); + +/// A module section's directives, without their source lines and in a stable +/// order. +/// +/// Order within a section is not semantic — every module reads its directives by +/// key — and the line each one sat on is not either. +fn module_body(m: &ModuleSection) -> Vec<(String, Vec)> { + let mut d: Vec<_> = m + .directives + .iter() + .map(|x| (x.key.clone(), x.args.clone())) + .collect(); + d.sort(); + d +} + +fn module_shapes(v: &[ModuleSection]) -> Vec { + let mut s: Vec = v.iter().map(|m| (m.name.clone(), module_body(m))).collect(); + s.sort(); + s +} + +/// Whether two module lists say the same thing. +/// +/// Compared on *content*, never with `==`: `ModuleSection` and `RawDirective` +/// both carry their source line, so a plain equality check calls a module +/// changed whenever an unrelated edit higher up the file shifts it down — which +/// would refuse a `log-level` reload for having moved a `module` block. +fn modules_equivalent(old: &[ModuleSection], new: &[ModuleSection]) -> bool { + module_shapes(old) == module_shapes(new) +} + +/// Name which module sections differ, so the refusal says what to look at. +/// +/// A refusal an operator has to diff by hand is a refusal they will work around. +fn module_delta(old: &[ModuleSection], new: &[ModuleSection]) -> String { + let names = |v: &[ModuleSection]| -> Vec { + let mut n: Vec = v.iter().map(|m| m.name.clone()).collect(); + n.sort(); + n + }; + if names(old) != names(new) { + return format!( + "modules were added or removed: {} -> {}", + names(old).join(", "), + names(new).join(", ") + ); + } + // Same content comparison as `modules_equivalent`, for the same reason: a + // shifted line number is not an edit. + let mut edited: Vec<&str> = new + .iter() + .filter(|n| { + old.iter() + .find(|o| o.name == n.name) + .is_none_or(|o| module_body(o) != module_body(n)) + }) + .map(|n| n.name.as_str()) + .collect(); + edited.sort_unstable(); + match edited.as_slice() { + [] => "the sections were reordered".to_string(), + one => format!("edited: module {}", one.join(", module ")), + } +} + +// --------------------------------------------------------------------------- +// Per-section directive parsing +// --------------------------------------------------------------------------- + +fn parse_global(cfg: &mut Config, line: usize, key: &str, args: &[&str]) -> Result<()> { + match key { + "node-id" => cfg.global.node_id = one_arg(line, key, args)?, + "state-dir" => cfg.global.state_dir = PathBuf::from(one_arg(line, key, args)?), + "metrics-textfile" => { + cfg.global.metrics_textfile = Some(PathBuf::from(one_arg(line, key, args)?)) + } + "log-level" => cfg.global.log_level = parse_enum(line, key, args)?, + "mode" => cfg.global.mode = parse_enum(line, key, args)?, + "tick-interval" => cfg.global.tick_interval = parse_duration_arg(line, key, args)?, + "converge-deadline" => { + return Err(not_yet_honoured(line, key, "nothing bounds a tick yet")); + } + "tier-rule" => cfg.tier_rules.push(parse_tier_rule(line, args)?), + other => return Err(unknown_directive(line, "global", other)), + } + Ok(()) +} + +fn parse_policy_source(cfg: &mut Config, line: usize, key: &str, args: &[&str]) -> Result<()> { + let p = &mut cfg.policy_source; + match key { + "url" => p.url = one_arg(line, key, args)?, + "token-file" => p.token_file = Some(PathBuf::from(one_arg(line, key, args)?)), + "pop" => p.pop = Some(one_arg(line, key, args)?), + "request-timeout" => p.request_timeout = parse_duration_arg(line, key, args)?, + "failure-threshold" => { + return Err(not_yet_honoured( + line, + key, + "a failed poll already holds every engagement, and nothing counts \ + consecutive failures toward a different behaviour", + )); + } + "on-policy-loss" => { + // `hold` is what the daemon does, and the only thing it can do while + // the additive-only invariant stands. Accepting the word is honest; + // accepting `drain` would not be. + let chosen: PolicyLoss = parse_enum(line, key, args)?; + if chosen != PolicyLoss::Hold { + return Err(ConfigError::parse( + line, + "`on-policy-loss drain` is not implemented: filterframe only ever adds \ + BGP objects, and withdrawing everything because a policy engine is \ + unreachable is the failure the whole design exists to prevent. \ + Use `hold`, and bound it with the rtbh module's `max-lifetime`", + )); + } + p.on_policy_loss = chosen; + } + "policy-loss-grace" => { + return Err(not_yet_honoured( + line, + key, + "the ceiling on holding is per-module — see the rtbh module's \ + `max-lifetime`", + )); + } + "ca-file" => p.ca_file = Some(PathBuf::from(one_arg(line, key, args)?)), + other => return Err(unknown_directive(line, "policy-source", other)), + } + Ok(()) +} + +fn parse_bgp(cfg: &mut Config, line: usize, key: &str, args: &[&str]) -> Result<()> { + let b = &mut cfg.bgp; + match key { + "mode" => b.mode = parse_enum(line, key, args)?, + "local-as" => b.local_as = parse_scalar(line, key, args)?, + "router-id" => b.router_id = Some(parse_scalar(line, key, args)?), + "hold-time" => b.hold_time = parse_duration_arg(line, key, args)?, + "connect-retry" => b.connect_retry = parse_duration_arg(line, key, args)?, + "grpc-endpoint" => b.grpc_endpoint = Some(one_arg(line, key, args)?), + "originate-prefix" => b.originate.push(parse_scalar(line, key, args)?), + "origin-community" => b.origin_community = Some(parse_scalar(line, key, args)?), + other => return Err(unknown_directive(line, "bgp", other)), + } + Ok(()) +} + +fn parse_peer(peer: &mut PeerSection, line: usize, key: &str, args: &[&str]) -> Result<()> { + match key { + "role" => peer.role = Some(parse_enum(line, key, args)?), + "address" => peer.address = Some(parse_scalar(line, key, args)?), + "remote-as" => peer.remote_as = Some(parse_scalar(line, key, args)?), + "multihop" => peer.multihop = Some(parse_scalar(line, key, args)?), + "community" => peer.communities.push(parse_scalar(line, key, args)?), + "allow-tier" => { + let tier: Tier = parse_enum(line, key, args)?; + if !peer.allow_tiers.contains(&tier) { + peer.allow_tiers.push(tier); + } + } + other => return Err(unknown_directive(line, "peer", other)), + } + Ok(()) +} + +/// `tier-rule [when [and ]...]` +fn parse_tier_rule(line: usize, args: &[&str]) -> Result { + let Some((head, rest)) = args.split_first() else { + return Err(ConfigError::parse( + line, + "`tier-rule` needs a tier: rtbh, divert or none", + )); + }; + + let tier = if *head == "none" { + None + } else { + Some( + head.parse::() + .map_err(|e| ConfigError::parse(line, e))?, + ) + }; + + let mut facts = Vec::new(); + if !rest.is_empty() { + if rest[0] != "when" { + return Err(ConfigError::parse( + line, + format!("expected `when` after the tier, found `{}`", rest[0]), + )); + } + // `when a b and c d` -> pairs separated by `and`. + for clause in rest[1..].split(|t| *t == "and") { + match clause { + [fact, value] => facts.push(parse_fact(line, fact, value)?), + [] => { + return Err(ConfigError::parse( + line, + "empty clause; `and` must join two conditions", + )); + } + other => { + return Err(ConfigError::parse( + line, + format!( + "each condition is ` `, found {} token(s): {}", + other.len(), + other.join(" ") + ), + )); + } + } + } + } + + Ok(TierRule { tier, facts, line }) +} + +fn parse_fact(line: usize, fact: &str, value: &str) -> Result { + Ok(match fact { + "action" => Fact::Action(value.to_string()), + "vector" => Fact::Vector(value.to_string()), + "customer" => Fact::Customer(value.to_string()), + "age-at-least" => { + Fact::AgeAtLeast(parse_duration(value).map_err(|e| ConfigError::parse(line, e))?) + } + "age-at-most" => { + Fact::AgeAtMost(parse_duration(value).map_err(|e| ConfigError::parse(line, e))?) + } + "bps-at-least" => { + Fact::BpsAtLeast(parse_bps(value).map_err(|e| ConfigError::parse(line, e))?) + } + "acknowledged" => Fact::Acknowledged(match value { + "true" => true, + "false" => false, + other => { + return Err(ConfigError::parse( + line, + format!("`acknowledged` takes true or false, found `{other}`"), + )); + } + }), + other => { + return Err(ConfigError::parse( + line, + format!( + "unknown fact `{other}`; expected action, vector, customer, age-at-least, \ + age-at-most, bps-at-least or acknowledged" + ), + )); + } + }) +} + +// --------------------------------------------------------------------------- +// Token helpers +// --------------------------------------------------------------------------- + +/// Strip an end-of-line comment. +/// +/// There is no escape for `#`, and no directive value has needed one. If that +/// ever changes, quoting is the answer, not backslashes. +fn strip_comment(line: &str) -> &str { + match line.find('#') { + Some(i) => &line[..i], + None => line, + } +} + +fn require_no_args(line: usize, key: &str, args: &[&str]) -> Result<()> { + if args.is_empty() { + Ok(()) + } else { + Err(ConfigError::parse( + line, + format!("`{key}` is a section header and takes no arguments"), + )) + } +} + +fn one_arg(line: usize, key: &str, args: &[&str]) -> Result { + match args { + [v] => Ok((*v).to_string()), + [] => Err(ConfigError::parse(line, format!("`{key}` needs a value"))), + _ => Err(ConfigError::parse( + line, + format!( + "`{key}` takes exactly one value, found {}; there is no line continuation \ + in this grammar", + args.len() + ), + )), + } +} + +fn parse_scalar(line: usize, key: &str, args: &[&str]) -> Result +where + T: FromStr, + T::Err: std::fmt::Display, +{ + let raw = one_arg(line, key, args)?; + raw.parse::() + .map_err(|e| ConfigError::parse(line, format!("`{key}`: {e}"))) +} + +fn parse_enum(line: usize, key: &str, args: &[&str]) -> Result +where + T: FromStr, +{ + let raw = one_arg(line, key, args)?; + raw.parse::() + .map_err(|e| ConfigError::parse(line, format!("`{key}`: {e}"))) +} + +fn parse_duration_arg(line: usize, key: &str, args: &[&str]) -> Result { + let raw = one_arg(line, key, args)?; + parse_duration(&raw).map_err(|e| ConfigError::parse(line, format!("`{key}`: {e}"))) +} + +/// Accept `30s`, `5m`, `2h`, `500ms`, or a bare number of seconds. +/// +/// The error names every accepted form, because a rejected duration is usually +/// a unit the operator assumed rather than a typo. +pub fn parse_duration(s: &str) -> std::result::Result { + let bad = || { + format!( + "`{s}` is not a duration; expected forms like 500ms, 30s, 5m, 2h, or a bare number of seconds" + ) + }; + + if let Some(v) = s.strip_suffix("ms") { + return v + .parse::() + .map(Duration::from_millis) + .map_err(|_| bad()); + } + for (suffix, mult) in [("s", 1u64), ("m", 60), ("h", 3600)] { + if let Some(v) = s.strip_suffix(suffix) { + return v + .parse::() + .map(|n| Duration::from_secs(n * mult)) + .map_err(|_| bad()); + } + } + s.parse::().map(Duration::from_secs).map_err(|_| bad()) +} + +/// Accept `1500mbps`, `2gbps`, `900kbps`, or a bare bits-per-second figure. +/// +/// Decimal multiples, not binary: network rates are quoted in decimal +/// everywhere an operator will have read this number. +pub fn parse_bps(s: &str) -> std::result::Result { + let bad = || { + format!( + "`{s}` is not a bit rate; expected forms like 900kbps, 1500mbps, 2gbps, or a bare bits-per-second figure" + ) + }; + + for (suffix, mult) in [ + ("gbps", 1_000_000_000u64), + ("mbps", 1_000_000), + ("kbps", 1_000), + ("bps", 1), + ] { + if let Some(v) = s.strip_suffix(suffix) { + return v.parse::().map(|n| n * mult).map_err(|_| bad()); + } + } + s.parse::().map_err(|_| bad()) +} + +fn unknown_directive(line: usize, section: &str, key: &str) -> ConfigError { + ConfigError::parse( + line, + format!("unknown directive `{key}` in section `{section}`"), + ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal configuration that passes validation, for tests that care + /// about one directive rather than the whole file. + const MINIMAL: &str = r#" +global + node-id test1 +policy-source + url https://policy.example.net +bgp + mode embedded + local-as 64512 + originate-prefix 198.51.100.0/24 +peer t1 + role transit + address 203.0.113.1 + remote-as 64510 + community blackhole + allow-tier rtbh +"#; + + fn parse_ok(s: &str) -> Config { + Config::parse(s).expect("expected this configuration to parse") + } + + fn parse_err(s: &str) -> ConfigError { + Config::parse(s).expect_err("expected this configuration to be rejected") + } + + // -- the reference config ------------------------------------------------ + + /// The shipped `conf/example.conf` is a primary document, and a document + /// that does not parse is worse than no document. This is the test that + /// keeps it honest as the grammar grows. + #[test] + fn reference_config_parses() { + let text = include_str!("../../../conf/example.conf"); + let cfg = Config::parse(text).expect("conf/example.conf must parse"); + + assert_eq!(cfg.global.node_id, "filter1"); + assert_eq!(cfg.global.mode, Mode::Observe, "must ship in observe mode"); + assert_eq!(cfg.bgp.mode, BgpMode::Gobgp); + assert_eq!(cfg.bgp.local_as, 64512); + assert_eq!(cfg.bgp.originate.len(), 2, "one v4 and one v6 prefix"); + assert_eq!(cfg.peers.len(), 6); + assert_eq!(cfg.modules.len(), 2); + assert!(!cfg.tier_rules.is_empty()); + } + + /// The example ships in observe mode, and it must stay that way: copying it + /// and uncommenting nothing should not announce anything. + #[test] + fn reference_config_is_safe_by_default() { + let cfg = parse_ok(include_str!("../../../conf/example.conf")); + assert_eq!(cfg.global.mode, Mode::Observe); + assert_eq!(cfg.policy_source.on_policy_loss, PolicyLoss::Hold); + } + + // -- structure ----------------------------------------------------------- + + #[test] + fn minimal_config_parses_and_validates() { + let cfg = parse_ok(MINIMAL); + assert_eq!(cfg.global.node_id, "test1"); + assert_eq!(cfg.global.tick_interval, DEFAULT_TICK_INTERVAL); + assert_eq!(cfg.global.mode, Mode::Observe, "observe is the default"); + } + + #[test] + fn comments_and_blank_lines_are_ignored() { + let cfg = parse_ok( + r#" +# leading comment +global + node-id test1 # trailing comment + +policy-source + url https://policy.example.net +bgp + mode embedded + local-as 64512 + originate-prefix 198.51.100.0/24 +peer t1 + role transit + address 203.0.113.1 + remote-as 64510 + community blackhole + allow-tier rtbh +"#, + ); + assert_eq!(cfg.global.node_id, "test1"); + } + + #[test] + fn directive_before_any_section_is_rejected_with_its_line() { + let err = parse_err("node-id orphan\nglobal\n"); + match err { + ConfigError::Parse { line, ref message } => { + assert_eq!(line, 1); + assert!(message.contains("before any section"), "{message}"); + } + other => panic!("expected a parse error, got {other:?}"), + } + } + + #[test] + fn unknown_directive_is_fatal_and_names_the_section() { + let err = parse_err("global\n node-id t\n nonsense yes\n"); + match err { + ConfigError::Parse { line, ref message } => { + assert_eq!(line, 3); + assert!(message.contains("nonsense"), "{message}"); + assert!(message.contains("global"), "{message}"); + } + other => panic!("expected a parse error, got {other:?}"), + } + } + + #[test] + fn duplicate_singleton_section_is_rejected() { + let err = parse_err("global\n node-id a\nglobal\n"); + assert!(matches!( + err, + ConfigError::DuplicateSection { + line: 3, + section: "global" + } + )); + } + + #[test] + fn duplicate_peer_name_is_rejected() { + let err = parse_err("peer a\n role transit\npeer a\n"); + match err { + ConfigError::DuplicateNamed { + line, + kind, + ref name, + } => { + assert_eq!((line, kind, name.as_str()), (3, "peer", "a")); + } + other => panic!("expected a duplicate-name error, got {other:?}"), + } + } + + #[test] + fn section_header_rejects_arguments() { + let err = parse_err("global extra\n"); + match err { + ConfigError::Parse { line, ref message } => { + assert_eq!(line, 1); + assert!(message.contains("takes no arguments"), "{message}"); + } + other => panic!("expected a parse error, got {other:?}"), + } + } + + /// There is no line continuation in this grammar, so a directive given two + /// values is a mistake — and the message says why rather than just + /// counting. + #[test] + fn extra_tokens_are_rejected_with_an_explanation() { + let err = parse_err("global\n node-id one two\n"); + match err { + ConfigError::Parse { ref message, .. } => { + assert!(message.contains("line continuation"), "{message}"); + } + other => panic!("expected a parse error, got {other:?}"), + } + } + + #[test] + fn module_directives_are_captured_uninterpreted() { + let cfg = parse_ok(&format!("{MINIMAL}\nmodule rtbh\n whatever 1 2 3\n")); + let m = &cfg.modules[0]; + assert_eq!(m.name, "rtbh"); + assert_eq!(m.directives[0].key, "whatever"); + assert_eq!(m.directives[0].args, vec!["1", "2", "3"]); + } + + // -- scalars ------------------------------------------------------------- + + #[test] + fn durations_accept_every_documented_form() { + assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500)); + assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30)); + assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300)); + assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200)); + assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45)); + } + + #[test] + fn duration_error_names_the_accepted_forms() { + let e = parse_duration("30 seconds").unwrap_err(); + assert!(e.contains("500ms"), "{e}"); + assert!(e.contains("2h"), "{e}"); + } + + #[test] + fn bit_rates_are_decimal_not_binary() { + assert_eq!(parse_bps("1500mbps").unwrap(), 1_500_000_000); + assert_eq!(parse_bps("2gbps").unwrap(), 2_000_000_000); + assert_eq!(parse_bps("900kbps").unwrap(), 900_000); + assert_eq!(parse_bps("1000").unwrap(), 1000); + } + + // -- communities --------------------------------------------------------- + + #[test] + fn communities_round_trip_through_their_canonical_spelling() { + for s in [ + "65535:666", + "64512:1", + "4200000000:666:0", + "blackhole", + "no-export", + ] { + let c: Community = s.parse().unwrap(); + assert_eq!(c.to_string(), s, "round trip failed for {s}"); + } + } + + #[test] + fn well_known_community_values_match_their_rfcs() { + assert_eq!(WellKnown::Blackhole.value(), 0xFFFF_029A, "RFC 7999"); + assert_eq!(WellKnown::NoExport.value(), 0xFFFF_FF01); + assert_eq!(WellKnown::NoAdvertise.value(), 0xFFFF_FF02); + } + + /// The reason communities are names rather than numbers: a typo in a + /// well-known name fails at load, where a typo in `65535:665` would not. + #[test] + fn misspelled_well_known_community_is_rejected() { + assert!("blackhoel".parse::().is_err()); + } + + #[test] + fn community_with_oversized_asn_is_rejected_pointing_at_large_form() { + let e = "4200000000:666".parse::().unwrap_err(); + assert!(e.contains("16-bit ASN"), "{e}"); + } + + // -- tier rules ---------------------------------------------------------- + + #[test] + fn tier_rule_parses_conjunction() { + let cfg = parse_ok(&MINIMAL.replace( + " node-id test1", + " node-id test1\n tier-rule rtbh when action discard and age-at-least 45s", + )); + let r = &cfg.tier_rules[0]; + assert_eq!(r.tier, Some(Tier::Rtbh)); + assert_eq!( + r.facts, + vec![ + Fact::Action("discard".into()), + Fact::AgeAtLeast(Duration::from_secs(45)) + ] + ); + } + + #[test] + fn tier_rule_none_is_an_explicit_refusal() { + let cfg = parse_ok(&MINIMAL.replace( + " node-id test1", + " node-id test1\n tier-rule none when vector dns_amplification", + )); + assert_eq!(cfg.tier_rules[0].tier, None); + } + + #[test] + fn tier_rule_with_unknown_fact_is_rejected() { + let err = parse_err(&MINIMAL.replace( + " node-id test1", + " node-id test1\n tier-rule rtbh when phase-of-moon full", + )); + match err { + ConfigError::Parse { ref message, .. } => { + assert!(message.contains("unknown fact"), "{message}"); + assert!( + message.contains("bps-at-least"), + "should list the alternatives: {message}" + ); + } + other => panic!("expected a parse error, got {other:?}"), + } + } + + #[test] + fn tier_rule_missing_when_keyword_is_rejected() { + let err = parse_err(&MINIMAL.replace( + " node-id test1", + " node-id test1\n tier-rule rtbh action discard", + )); + match err { + ConfigError::Parse { ref message, .. } => { + assert!(message.contains("expected `when`"), "{message}"); + } + other => panic!("expected a parse error, got {other:?}"), + } + } + + // -- cross-section validation -------------------------------------------- + + #[test] + fn node_id_is_required() { + let err = parse_err(&MINIMAL.replace(" node-id test1", "")); + assert!(format!("{err}").contains("node-id"), "{err}"); + } + + /// The authority boundary is not optional. Without it filterframe would + /// announce whatever it was told to. + #[test] + fn originate_prefix_is_required() { + let err = parse_err(&MINIMAL.replace(" originate-prefix 198.51.100.0/24", "")); + let s = format!("{err}"); + assert!(s.contains("originate-prefix"), "{s}"); + assert!(s.contains("authority boundary"), "{s}"); + } + + #[test] + fn gobgp_mode_requires_an_endpoint() { + let err = parse_err(&MINIMAL.replace(" mode embedded", " mode gobgp")); + assert!(format!("{err}").contains("grpc-endpoint"), "{err}"); + } + + #[test] + fn a_scrubber_peer_may_not_serve_rtbh() { + let err = parse_err(&MINIMAL.replace(" role transit", " role scrubber")); + let s = format!("{err}"); + assert!(s.contains("cannot serve tier"), "{s}"); + } + + /// A blackhole announced with no community is just a host route, forwarded + /// normally — the mitigation silently does nothing. + #[test] + fn an_rtbh_peer_without_a_community_is_refused() { + let err = parse_err(&MINIMAL.replace(" community blackhole\n", "")); + let s = format!("{err}"); + assert!(s.contains("no `community`"), "{s}"); + assert!(s.contains("forwarded normally"), "{s}"); + } + + #[test] + fn duplicate_peer_addresses_are_refused() { + let cfg = format!( + "{MINIMAL}\npeer t2\n role transit\n address 203.0.113.1\n remote-as 64511\n community blackhole\n allow-tier rtbh\n" + ); + let err = parse_err(&cfg); + assert!(format!("{err}").contains("duplicate peer address"), "{err}"); + } + + #[test] + fn a_rule_selecting_an_unservable_tier_is_refused() { + let err = parse_err(&MINIMAL.replace( + " node-id test1", + " node-id test1\n tier-rule divert when action discard", + )); + let s = format!("{err}"); + assert!(s.contains("no peer allows that tier"), "{s}"); + } + + #[test] + fn an_unconditional_rule_shadowing_later_rules_is_refused() { + let err = parse_err(&MINIMAL.replace( + " node-id test1", + " node-id test1\n tier-rule rtbh\n tier-rule none when action discard", + )); + let s = format!("{err}"); + assert!(s.contains("can never fire"), "{s}"); + } + + /// A directive this build parses but does not act on is refused, and says + /// so. Accepting it silently is strictly worse than a typo: a typo is caught + /// by `unknown_directive`, where an accepted inert directive leaves an + /// operator believing a guard is in force. + #[test] + fn directives_this_version_does_not_honour_are_refused() { + for (section_anchor, directive) in [ + (" url https://policy.example.net", "failure-threshold 3"), + (" url https://policy.example.net", "policy-loss-grace 15m"), + (" node-id test1", "converge-deadline 2s"), + ] { + let err = parse_err( + &MINIMAL.replace(section_anchor, &format!("{section_anchor}\n {directive}")), + ); + let s = format!("{err}"); + assert!(s.contains("not honoured"), "{directive}: {s}"); + assert!(s.contains("Remove it"), "{directive}: {s}"); + } + } + + /// `hold` is what the daemon does. `drain` would withdraw protection because + /// a policy engine was unreachable, which is the failure the whole design + /// exists to prevent — so it is refused rather than accepted and ignored. + #[test] + fn on_policy_loss_drain_is_refused_and_hold_is_accepted() { + let err = parse_err(&MINIMAL.replace( + " url https://policy.example.net", + " url https://policy.example.net\n on-policy-loss drain", + )); + let s = format!("{err}"); + assert!(s.contains("only ever adds"), "{s}"); + assert!( + s.contains("max-lifetime"), + "should name the real ceiling: {s}" + ); + + let cfg = parse_ok(&MINIMAL.replace( + " url https://policy.example.net", + " url https://policy.example.net\n on-policy-loss hold", + )); + assert_eq!(cfg.policy_source.on_policy_loss, PolicyLoss::Hold); + } + + // -- reload semantics ---------------------------------------------------- + + #[test] + fn identical_configs_reload_cleanly() { + let a = parse_ok(MINIMAL); + let b = parse_ok(MINIMAL); + assert!(a.restart_only_delta(&b).is_ok()); + } + + #[test] + fn hot_reloadable_changes_are_permitted() { + let a = parse_ok(MINIMAL); + let b = parse_ok(&MINIMAL.replace(" node-id test1", " node-id test1\n log-level debug")); + assert!(a.restart_only_delta(&b).is_ok(), "log-level must be hot"); + } + + /// Flipping to enforce live would announce an already-computed desired set + /// in one step, with no settle time and nobody watching. + #[test] + fn switching_mode_on_reload_is_refused_with_the_reason() { + let a = parse_ok(MINIMAL); + let b = parse_ok(&MINIMAL.replace(" node-id test1", " node-id test1\n mode enforce")); + let e = a.restart_only_delta(&b).unwrap_err(); + assert!(e.contains("mode"), "{e}"); + assert!(e.contains("one step"), "should say why: {e}"); + } + + #[test] + fn changing_the_bgp_section_on_reload_is_refused() { + let a = parse_ok(MINIMAL); + let b = parse_ok(&MINIMAL.replace(" local-as 64512", " local-as 64513")); + assert!(a.restart_only_delta(&b).is_err()); + } + + /// A module edit that reported "OK reloaded" and did nothing was the worst + /// of both worlds: `never-blackhole` is the fastest lever during a mistaken + /// mitigation, and adding it live left the operator believing it was in + /// force. Refused, and the refusal names the section to look at. + #[test] + fn editing_a_module_section_on_reload_is_refused() { + let with_module = format!("{MINIMAL}\nmodule rtbh\n max-active 64\n"); + let a = parse_ok(&with_module); + let b = parse_ok(&with_module.replace( + " max-active 64", + " max-active 64\n never-blackhole 198.51.100.1/32", + )); + let e = a.restart_only_delta(&b).unwrap_err(); + assert!(e.contains("module sections cannot change"), "{e}"); + assert!(e.contains("module rtbh"), "should name the section: {e}"); + assert!(e.contains("Restart instead"), "{e}"); + } + + #[test] + fn adding_a_module_on_reload_is_refused() { + let a = parse_ok(MINIMAL); + let b = parse_ok(&format!("{MINIMAL}\nmodule rtbh\n max-active 8\n")); + let e = a.restart_only_delta(&b).unwrap_err(); + assert!(e.contains("added or removed"), "{e}"); + } + + /// An unchanged module section must not block a reload of the things that + /// *are* hot — otherwise the refusal would swallow the whole feature. + #[test] + fn an_unchanged_module_section_does_not_block_a_hot_edit() { + let with_module = format!("{MINIMAL}\nmodule rtbh\n max-active 64\n"); + let a = parse_ok(&with_module); + let b = + parse_ok(&with_module.replace(" node-id test1", " node-id test1\n log-level debug")); + assert!(a.restart_only_delta(&b).is_ok(), "log-level must stay hot"); + } + + #[test] + fn adding_a_peer_on_reload_is_refused() { + let a = parse_ok(MINIMAL); + let b = parse_ok(&format!( + "{MINIMAL}\npeer t2\n role transit\n address 203.0.113.2\n remote-as 64511\n community blackhole\n allow-tier rtbh\n" + )); + let e = a.restart_only_delta(&b).unwrap_err(); + assert!(e.contains("peers cannot be added"), "{e}"); + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs new file mode 100644 index 0000000..4dca355 --- /dev/null +++ b/crates/common/src/lib.rs @@ -0,0 +1,24 @@ +//! Shared vocabulary for filterframe. +//! +//! Everything in this crate is either a type both mitigation tiers need to +//! agree on, or a trait whose implementations live elsewhere. It deliberately +//! carries no async runtime, no HTTP client, and no BGP implementation, so +//! that a tier module can depend on it without inheriting the dependency +//! weight of the backends it never touches. +//! +//! The rule this enforces, borrowed from packetframe: tier modules depend only +//! on this crate and never on each other, and the CLI is the only crate that +//! knows all of them. + +pub mod bgp; +pub mod config; +pub mod mitigation; +pub mod module; +pub mod plan; + +pub use config::{Config, ConfigError}; +pub use mitigation::{Mitigation, MitigationView, StaleReason}; +pub use plan::Plan; + +/// Version of the workspace, for `--version` and the `build_info` metric. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/common/src/mitigation.rs b/crates/common/src/mitigation.rs new file mode 100644 index 0000000..9b4e32b --- /dev/null +++ b/crates/common/src/mitigation.rs @@ -0,0 +1,325 @@ +//! What the policy engine says is happening, and the type that stops us acting +//! on a guess. +//! +//! # The invariant, in the type system +//! +//! filterframe's most important safety property is that **an unreachable policy +//! engine must never cause a teardown**. "prefixd is down" and "there are no +//! active mitigations" look identical to a naive client — both produce an empty +//! list — and treating the first as the second withdraws protection during +//! exactly the kind of event that makes a policy engine unreachable. +//! +//! Rather than defend that with review discipline, [`MitigationView`] has two +//! variants and the stale one **carries no list at all**. There is nothing to +//! iterate, nothing to diff against, and no way to compute a desired state from +//! it. A function that derives desired state takes the fresh variant, so +//! "unreachable causes a teardown" is a type error rather than a code-review +//! finding. +//! +//! # Forward compatibility is a safety property here +//! +//! Attack vectors and action types are open sets: the policy engine will add +//! values filterframe has never heard of. Deserialising an unknown value as an +//! error would take the *whole view* stale — meaning an upgrade to the policy +//! engine would blind filterframe, which is the failure this module exists to +//! prevent. So unknown values are preserved as-is and fall through to the +//! default rule instead. + +use std::fmt; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +/// How a mitigation is being applied, as the policy engine sees it. +/// +/// Not a closed set: `Other` keeps an unrecognised value usable as a match +/// target for a tier rule rather than failing the poll that carried it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ActionType { + /// The engine chose to rate-limit rather than drop. + Police, + /// The engine chose to drop outright. The strongest size signal available + /// without a rate sample, because it means policing was judged insufficient. + Discard, + #[serde(untagged)] + Other(String), +} + +impl ActionType { + pub fn as_str(&self) -> &str { + match self { + Self::Police => "police", + Self::Discard => "discard", + Self::Other(s) => s, + } + } +} + +impl fmt::Display for ActionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Lifecycle state of a mitigation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MitigationStatus { + Pending, + Active, + Escalated, + Expired, + Withdrawn, + Rejected, + #[serde(untagged)] + Other(String), +} + +impl MitigationStatus { + /// Whether this status means the policy engine still wants the mitigation + /// in force. + /// + /// An unrecognised status counts as **not** active. A new status the engine + /// invents is more likely to be a terminal one than a live one, and + /// guessing wrong in the other direction would keep a blackhole standing + /// for a mitigation that had ended. + pub fn is_live(&self) -> bool { + matches!(self, Self::Pending | Self::Active | Self::Escalated) + } + + pub fn as_str(&self) -> &str { + match self { + Self::Pending => "pending", + Self::Active => "active", + Self::Escalated => "escalated", + Self::Expired => "expired", + Self::Withdrawn => "withdrawn", + Self::Rejected => "rejected", + Self::Other(s) => s, + } + } +} + +/// One mitigation, reduced to what filterframe actually acts on. +/// +/// Deliberately narrower than the wire record. Fields the daemon does not use +/// are not carried, so that nothing can quietly start depending on them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Mitigation { + pub id: String, + pub victim: IpAddr, + pub status: MitigationStatus, + pub action: ActionType, + pub vector: String, + pub customer: Option, + pub pop: Option, + pub acknowledged: bool, + /// How long the mitigation has existed, per the policy engine's clock, + /// corrected for skew by the client that built this. + pub age: Duration, + /// How long until the engine's own expiry, if it has not already passed. + pub ttl_remaining: Option, + /// Observed attack rate for this victim, in bits per second, when one has + /// been sampled. + /// + /// Deliberately **not** taken from the mitigation record. The engine's + /// `rate_bps` field is the policer rate its playbook chose — a policy + /// output, not a measurement — and it is null for a discard action, which + /// is to say null exactly when the attack is largest. Using it as a size + /// proxy is backwards. + /// + /// `None` means no sample, and a rule with a rate condition must not fire + /// on it. That is what keeps "large *and* persistent" from degrading into + /// "persistent". + pub bps: Option, +} + +/// Why a view could not be refreshed. +/// +/// Each variant is a distinct operator story, which is why they are not one +/// string: `Unauthorized` means a token or an auth-mode mismatch and is fixable +/// in seconds once named, where `RateLimited` means backing off and +/// `Malformed` means the thing on the other end is not what we think it is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case", tag = "reason", content = "detail")] +pub enum StaleReason { + /// No response: connection refused, DNS failure, timeout. + Unreachable(String), + /// HTTP 429. The bucket is shared with everything else talking to the + /// engine, so backing off hard matters. + RateLimited { retry_after: Option }, + /// HTTP 401 or 403. + Unauthorized(String), + /// A response arrived and was not what the contract promises. + Malformed(String), + /// Pagination did not terminate sensibly. Treated as stale rather than as a + /// short list, because a truncated page set is indistinguishable from a + /// world where those mitigations ended. + Pagination(String), + /// The list went from non-empty to empty and has not yet been believed. + /// A status typo, a database hiccup and an auth-mode change all produce + /// "zero rows, HTTP 200", so an empty result is confirmed before it is + /// acted on. + UnconfirmedEmpty { seen: u32, needed: u32 }, +} + +impl fmt::Display for StaleReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unreachable(d) => write!(f, "unreachable: {d}"), + Self::RateLimited { retry_after } => match retry_after { + Some(d) => write!(f, "rate limited, retry after {}s", d.as_secs()), + None => write!(f, "rate limited"), + }, + Self::Unauthorized(d) => write!(f, "unauthorized: {d}"), + Self::Malformed(d) => write!(f, "malformed response: {d}"), + Self::Pagination(d) => write!(f, "pagination: {d}"), + Self::UnconfirmedEmpty { seen, needed } => { + write!(f, "empty result not yet confirmed ({seen}/{needed})") + } + } + } +} + +/// What filterframe currently knows about active mitigations. +/// +/// **`Stale` deliberately carries no mitigation list.** That is the whole +/// point: desired state can only be derived from `Fresh`, so there is no +/// expression that computes a teardown from a failed poll. See the module +/// docstring. +#[derive(Debug, Clone)] +pub enum MitigationView { + Fresh(Vec), + Stale { + /// When the last successful poll completed. Monotonic — never the wall + /// clock, which can step backwards and make a hold look expired. + last_fresh_at: Option, + why: StaleReason, + }, +} + +impl MitigationView { + /// The mitigations, if and only if this view is fresh. + /// + /// The only way to get at the list. Callers that need it must handle the + /// stale case explicitly, which is the intended friction. + pub fn fresh(&self) -> Option<&[Mitigation]> { + match self { + Self::Fresh(m) => Some(m), + Self::Stale { .. } => None, + } + } + + pub fn is_fresh(&self) -> bool { + matches!(self, Self::Fresh(_)) + } + + /// How long this view has been stale, for the metric operators alert on. + pub fn stale_for(&self, now: Instant) -> Option { + match self { + Self::Fresh(_) => None, + Self::Stale { last_fresh_at, .. } => { + Some(last_fresh_at.map_or(Duration::MAX, |t| now.saturating_duration_since(t))) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The type-level invariant. If this ever compiles differently — if `Stale` + /// grows a list, or a helper hands one out — the safety argument is gone. + #[test] + fn a_stale_view_offers_no_mitigations_to_act_on() { + let v = MitigationView::Stale { + last_fresh_at: None, + why: StaleReason::Unreachable("connection refused".into()), + }; + assert!(v.fresh().is_none()); + assert!(!v.is_fresh()); + } + + #[test] + fn a_fresh_view_hands_out_its_list() { + let v = MitigationView::Fresh(vec![]); + assert_eq!(v.fresh().map(<[_]>::len), Some(0)); + assert!(v.is_fresh()); + } + + /// An empty *fresh* list is a real answer and must be distinguishable from + /// a stale one — it is how a mitigation ends. + #[test] + fn an_empty_fresh_list_is_not_stale() { + let v = MitigationView::Fresh(vec![]); + assert!(v.is_fresh()); + assert!(v.stale_for(Instant::now()).is_none()); + } + + #[test] + fn only_live_statuses_count_as_active() { + for s in [ + MitigationStatus::Pending, + MitigationStatus::Active, + MitigationStatus::Escalated, + ] { + assert!(s.is_live(), "{s:?} should be live"); + } + for s in [ + MitigationStatus::Expired, + MitigationStatus::Withdrawn, + MitigationStatus::Rejected, + ] { + assert!(!s.is_live(), "{s:?} should not be live"); + } + } + + /// An unrecognised status is treated as not-live. Guessing the other way + /// would hold a blackhole up for a mitigation that had already ended. + #[test] + fn an_unknown_status_is_not_live() { + assert!(!MitigationStatus::Other("quarantined".into()).is_live()); + } + + /// Forward compatibility is a safety property: a policy engine that adds a + /// vector or an action must not blind filterframe. + #[test] + fn unknown_action_types_survive_deserialization() { + let a: ActionType = serde_json::from_str("\"carpet_bomb\"").unwrap(); + assert_eq!(a, ActionType::Other("carpet_bomb".into())); + assert_eq!(a.as_str(), "carpet_bomb"); + } + + #[test] + fn known_action_types_still_parse_as_themselves() { + let a: ActionType = serde_json::from_str("\"discard\"").unwrap(); + assert_eq!(a, ActionType::Discard); + } + + #[test] + fn unknown_statuses_survive_deserialization() { + let s: MitigationStatus = serde_json::from_str("\"quarantined\"").unwrap(); + assert_eq!(s, MitigationStatus::Other("quarantined".into())); + } + + #[test] + fn stale_reasons_render_something_an_operator_can_act_on() { + let cases = [ + StaleReason::Unreachable("connection refused".into()), + StaleReason::RateLimited { + retry_after: Some(Duration::from_secs(30)), + }, + StaleReason::Unauthorized("401".into()), + StaleReason::Malformed("expected an object".into()), + StaleReason::Pagination("has_more with no cursor".into()), + StaleReason::UnconfirmedEmpty { seen: 1, needed: 3 }, + ]; + for c in cases { + let s = c.to_string(); + assert!(!s.is_empty(), "{c:?} rendered empty"); + } + } +} diff --git a/crates/common/src/module.rs b/crates/common/src/module.rs new file mode 100644 index 0000000..ebb605a --- /dev/null +++ b/crates/common/src/module.rs @@ -0,0 +1,408 @@ +//! What a mitigation tier is, and where it sits in the loop. +//! +//! The planner decides *what should be mitigated*. A tier module decides *what +//! this node will actually hold*, and the two are not the same thing: a tier +//! applies guards that refuse dangerous work, and damping that refuses churn. +//! +//! Modules are pure over `(desired, now)`. They own no I/O, speak no BGP and +//! read no clock of their own — the tick hands them a monotonic instant. That +//! is what makes every damping rule testable by advancing a number, and it is +//! why a flap-budget bug can be found in a unit test rather than in production +//! at three in the morning. +//! +//! The reconciler converges on whatever a module returns. A module that refuses +//! something is not failing; it is doing its job, and the refusal is counted and +//! surfaced rather than swallowed. + +use std::collections::BTreeSet; +use std::time::Instant; + +use serde::Serialize; + +use crate::bgp::PathKey; +use crate::config::{ModuleSection, Tier}; +use crate::plan::Engagement; + +/// Why a module declined to hold something the planner asked for. +/// +/// Each variant is a distinct operator story and a distinct metric label. They +/// are never merged into one string, because "we refused 47 things" is not an +/// answer anyone can act on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case", tag = "reason", content = "detail")] +pub enum Refusal { + /// The prefix is shorter than the module's floor. Blackholing an aggregate + /// drops the attack and the customer together. + PrefixTooShort { got: u8, floor: u8 }, + /// The prefix matches an operator's explicit never-touch list. + Protected(String), + /// The module is already holding as many engagements as it is allowed. + /// + /// Refused at, never truncated to: a partially applied set splits traffic + /// along a line nobody chose. + AtCapacity { cap: usize }, + /// The engagement has been held longer than the module's ceiling without + /// being re-confirmed. + Expired { held_secs: u64, ceiling_secs: u64 }, + /// The prefix is not one this node is contracted to divert. + /// + /// Diverting a prefix the scrubbing contract does not cover leaves it + /// attracting nothing while transit is suppressed — a black hole built by + /// hand. + NotDivertible(String), + /// The path scrubbed traffic returns over is not usable. + /// + /// Not a failure: the demand stays, and the diversion proceeds once the + /// path recovers. The fast tier picks it up meanwhile, because "we cannot + /// divert" must never mean "we do nothing". + ReturnPathDown, +} + +impl Refusal { + /// Stable label for metrics. Append-only once shipped. + pub fn label(&self) -> &'static str { + match self { + Self::PrefixTooShort { .. } => "prefix-too-short", + Self::Protected(_) => "protected", + Self::AtCapacity { .. } => "at-capacity", + Self::Expired { .. } => "expired", + Self::NotDivertible(_) => "not-divertible", + Self::ReturnPathDown => "return-path-down", + } + } + + /// A sentence fit to put in front of an operator, not just a label. + pub fn describe(&self) -> String { + match self { + Self::PrefixTooShort { got, floor } => format!( + "prefix is /{got}, and this module will not act on anything shorter than /{floor}" + ), + Self::Protected(n) => format!("matches the never-blackhole entry {n}"), + Self::AtCapacity { cap } => { + format!("already holding the maximum of {cap} engagements") + } + Self::Expired { + held_secs, + ceiling_secs, + } => format!( + "unconfirmed for {held_secs}s, past the {ceiling_secs}s ceiling" + ), + Self::NotDivertible(p) => { + format!("{p} is not listed as a divertible-prefix") + } + Self::ReturnPathDown => { + "the return path is not usable, so diversion is blocked (the fast tier still applies)" + .into() + } + } + } +} + +/// What a module wants to be true after applying its own rules. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct TierOutcome { + /// Everything the module wants held right now, including engagements that + /// are only still present because a damping timer has not run out. + pub effective: BTreeSet, + /// Work the planner asked for that this module will not do. + pub refused: Vec<(PathKey, Refusal)>, + /// Engagements no longer demanded, still held while a dwell runs. + /// + /// Surfaced separately from `effective` so `status` can say "releases in + /// 12s unless re-demanded" rather than leaving an operator to wonder why a + /// blackhole outlived its mitigation. + pub dwelling: Vec<(PathKey, u64)>, +} + +/// What is known about the path scrubbed traffic returns over. +/// +/// Three-valued on purpose. `Blocked` and `Down` both prevent a *new* +/// diversion, but only `Down` justifies tearing down a working one — an +/// unreadable sysfs file is a bad reason to move a customer's prefix across the +/// Internet, and collapsing the two would make it one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReturnPath { + /// Confirmed usable. + Up, + /// Cannot be confirmed. Blocks engaging; does not tear anything down. + Blocked, + /// Confirmed unusable. While diverted this is an emergency: clean traffic + /// is entering a tunnel that goes nowhere, which is a hard outage for the + /// whole prefix rather than just the victim. + Down, +} + +impl ReturnPath { + /// Whether a new diversion may begin. + pub fn allows_engage(self) -> bool { + matches!(self, Self::Up) + } + + /// Whether an engaged diversion must be undone now. + pub fn demands_return(self) -> bool { + matches!(self, Self::Down) + } +} + +/// How much of a quorum a speaker could actually establish for one path. +/// +/// Counted by the reconciler from [`crate::bgp::Advertisement`] verdicts and +/// handed to whichever module owns the path, so that `common` carries the +/// arithmetic and no module has to speak BGP to learn whether its announcement +/// landed. +/// +/// The two counts are deliberately separate rather than a ratio. +/// `confirmed` drives "we may proceed"; `settled_negative` drives "waiting will +/// not help", which is what lets a doomed sequence unwind at once instead of +/// burning a deadline. A peer that answered [`crate::bgp::Advertisement::Unknown`] +/// is in **neither** count — an unanswerable question is not a yes and not a no. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +pub struct PathEvidence { + /// Peers confirming the path at or above the fidelity the caller required. + pub confirmed: u8, + /// Peers whose answer is settled and negative. + pub settled_negative: u8, +} + +impl PathEvidence { + /// Whether anything at all was established, either way. + /// + /// A speaker that cannot answer produces no evidence, and no evidence must + /// never be read as a negative one: it leaves a sequence where it is rather + /// than unwinding it. + pub fn is_empty(self) -> bool { + self.confirmed == 0 && self.settled_negative == 0 + } +} + +/// One mitigation mechanism. +/// +/// Implementations live in their own crates under `crates/modules/`, depend +/// only on this crate, and never on each other. The CLI is the only thing that +/// knows all of them. +pub trait TierModule: Send { + /// Which tier this module serves. + fn tier(&self) -> Tier; + + /// Configure from this module's section of the config file. + /// + /// `common` deliberately does not know what any module accepts: each parses + /// and validates its own directives, so adding a module never means editing + /// the shared grammar. Errors are returned with the offending line so the + /// operator gets the same quality of message as the core parser gives. + fn configure(&mut self, section: &ModuleSection) -> Result<(), String>; + + /// Take ownership of engagements a previous incarnation left announced. + /// + /// Without this a restarted daemon would see paths no module claims, + /// compute them as surplus and withdraw the lot. Implementations must start + /// every clock from `now` rather than from any persisted timestamp: a crash + /// loop that restored accumulated hold credit would satisfy every dwell at + /// once and release everything together. + fn adopt(&mut self, existing: Box + '_>, now: Instant); + + /// What to hold while the policy engine cannot be reached. + /// + /// Takes no desired set, because there is none — that is the point. An + /// implementation must not release anything for lack of demand here. The + /// only thing it may remove is work its own ceiling has expired, which is + /// the bounded exception that stops an engine outage leaving an address + /// dark forever. + fn hold_only(&mut self, now: Instant) -> TierOutcome; + + /// Report whether the return path for scrubbed traffic is usable. + /// + /// A no-op for tiers that do not depend on one. The divert tier gates on + /// [`ReturnPath::allows_engage`] and treats [`ReturnPath::demands_return`] + /// while engaged as an emergency. + fn set_return_path(&mut self, _path: ReturnPath, _now: Instant) {} + + /// Report what the speaker could establish about a path this module holds. + /// + /// A no-op for tiers that act on a single announcement and need no + /// confirmation — the fast tier withdraws nothing on the strength of a + /// quorum, so it has no use for one. The divert tier does: it suppresses a + /// prefix toward transit, and the whole safety argument is that it never + /// does so before enough of the scrubber's reflectors have the path. + /// + /// Called only for keys in the module's own effective set, and only when the + /// speaker can produce evidence meaning more than "we made a local function + /// call" — see [`crate::bgp::RibObserver::max_fidelity`]. An implementation + /// must treat [`PathEvidence::is_empty`] as *no information* and leave its + /// sequence where it is. + fn observe(&mut self, _key: &PathKey, _evidence: PathEvidence, _now: Instant) {} + + /// Lines describing this module's own internal progress, for `status`. + /// + /// The reconciler reports what is *announced*; only the module knows where a + /// multi-step sequence has got to. A prefix stuck partway through one is + /// invisible in the engagement list — it looks like a normal announcement — + /// so it has to be said here or not at all. + fn progress(&self, _now: Instant) -> Vec { + Vec::new() + } + + /// Serialise whatever this module must remember across a restart. + /// + /// An opaque string: `common` does not know or care about the format, so a + /// module can change how it persists without touching anything shared. + /// Most modules return `None` — almost nothing needs persisting, because + /// desired state is recomputed every tick and actual state is read back + /// from the speaker. + /// + /// The exception is *which direction a sequence was moving* when the + /// process died. A prefix announced two ways is simultaneously "engaging, + /// step one done" and "tearing down, step one done", and no amount of + /// reading the world distinguishes them. + fn journal(&self) -> Option { + None + } + + /// Resume from a journal this module wrote. + /// + /// The journal records intent, not truth. An implementation must resolve + /// toward the safer continuation rather than assuming the world matches, + /// and must restart every clock from `now`. + fn restore_journal(&mut self, _blob: &str, _now: Instant) {} + + /// Decide what to hold, given what the planner wants and the current + /// monotonic time. + /// + /// **Monotonic, always.** Every damping decision here is a comparison of + /// `Instant`s. Wall-clock time can step backwards under NTP, and a hold + /// that expires because the clock moved is a mitigation that flaps for + /// reasons entirely unrelated to the attack. + fn refine(&mut self, desired: &[Engagement], now: Instant) -> TierOutcome; +} + +/// Look up a single-valued directive in a module section. +/// +/// Small helpers rather than a shared derive: modules are few, their sections +/// are small, and an explicit parse per directive is what lets each carry an +/// error message that names the accepted forms. +pub fn directive<'a>(section: &'a ModuleSection, key: &str) -> Option<(&'a str, usize)> { + section + .directives + .iter() + .find(|d| d.key == key) + .and_then(|d| d.args.first().map(|a| (a.as_str(), d.line))) +} + +/// Every value given for a repeatable directive. +pub fn directives<'a>(section: &'a ModuleSection, key: &str) -> Vec<(&'a str, usize)> { + section + .directives + .iter() + .filter(|d| d.key == key) + .filter_map(|d| d.args.first().map(|a| (a.as_str(), d.line))) + .collect() +} + +/// Reject any directive in `section` that is not in `known`. +/// +/// The same rule the core grammar applies, extended to module sections: a +/// mistyped safety guard must fail at load, not be silently absent. +pub fn reject_unknown(section: &ModuleSection, known: &[&str]) -> Result<(), String> { + for d in §ion.directives { + if !known.contains(&d.key.as_str()) { + return Err(format!( + "line {}: unknown directive `{}` in module `{}`; accepted here: {}", + d.line, + d.key, + section.name, + known.join(", ") + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::RawDirective; + + fn section(pairs: &[(&str, &str)]) -> ModuleSection { + ModuleSection { + name: "test".into(), + directives: pairs + .iter() + .enumerate() + .map(|(i, (k, v))| RawDirective { + key: (*k).to_string(), + args: vec![(*v).to_string()], + line: i + 1, + }) + .collect(), + line: 0, + } + } + + #[test] + fn directive_lookup_returns_the_value_and_its_line() { + let s = section(&[("a", "1"), ("b", "2")]); + assert_eq!(directive(&s, "b"), Some(("2", 2))); + assert_eq!(directive(&s, "missing"), None); + } + + #[test] + fn repeatable_directives_are_all_returned() { + let s = section(&[("x", "1"), ("y", "9"), ("x", "2")]); + assert_eq!(directives(&s, "x"), vec![("1", 1), ("2", 3)]); + } + + /// A mistyped guard must fail at load. Silently ignoring it means an + /// operator believes a safety rule is in force when it is not. + #[test] + fn an_unknown_module_directive_is_refused_with_its_line() { + let s = section(&[("known", "1"), ("typoed", "2")]); + let e = reject_unknown(&s, &["known"]).unwrap_err(); + assert!(e.contains("line 2"), "{e}"); + assert!(e.contains("typoed"), "{e}"); + assert!(e.contains("known"), "should list what is accepted: {e}"); + } + + #[test] + fn refusal_labels_are_stable_and_distinct() { + let all = [ + Refusal::PrefixTooShort { got: 24, floor: 32 }, + Refusal::Protected("198.51.100.1/32".into()), + Refusal::AtCapacity { cap: 64 }, + Refusal::Expired { + held_secs: 1, + ceiling_secs: 2, + }, + Refusal::NotDivertible("198.51.100.0/24".into()), + Refusal::ReturnPathDown, + ]; + let labels: BTreeSet<_> = all.iter().map(|r| r.label()).collect(); + assert_eq!(labels.len(), all.len(), "labels must be distinct"); + } + + /// A label is for a metric; an operator reading `status` needs a sentence. + #[test] + fn every_refusal_explains_itself() { + let all = [ + Refusal::PrefixTooShort { got: 24, floor: 32 }, + Refusal::Protected("198.51.100.1/32".into()), + Refusal::AtCapacity { cap: 64 }, + Refusal::Expired { + held_secs: 1, + ceiling_secs: 2, + }, + Refusal::NotDivertible("198.51.100.0/24".into()), + Refusal::ReturnPathDown, + ]; + for r in &all { + let d = r.describe(); + assert!(d.len() > 20, "{r:?} explains itself too tersely: {d}"); + } + // The detail an operator needs must survive into the sentence. + assert!( + Refusal::Protected("198.51.100.1/32".into()) + .describe() + .contains("198.51.100.1/32") + ); + } +} diff --git a/crates/common/src/plan.rs b/crates/common/src/plan.rs new file mode 100644 index 0000000..7666ec6 --- /dev/null +++ b/crates/common/src/plan.rs @@ -0,0 +1,563 @@ +//! Turning a mitigation list into a desired set of BGP objects. +//! +//! Everything here is a pure function of (mitigations, config). No clock, no +//! I/O, no BGP. That is what lets the whole decision layer be tested +//! exhaustively offline, and what makes `filterframe plan --from-file` a +//! genuine dry run of the daemon's reasoning rather than an approximation of it. +//! +//! Two ideas carry the design. +//! +//! **Desired state is a set, derived from the whole mitigation list at once.** +//! Not a stream of on/off events. Two victims inside one covering prefix +//! collapse to a single divert entry, and the entry survives while either +//! mitigation does. Event-driven toggling gets that wrong and tears down while +//! an attack is still live. +//! +//! **Refcounts are sets of mitigation ids, not integers.** The policy engine +//! paginates newest-first with a `created_at < cursor` predicate, so a +//! concurrent insert can hand the same mitigation back on two pages. A counter +//! would over-count and the engagement would never release; a set is immune, +//! and it also answers "which mitigation went away" in the logs. + +use std::collections::{BTreeMap, BTreeSet}; + +use ipnet::IpNet; +use serde::Serialize; + +use crate::config::{Config, Fact, Tier, TierRule}; +use crate::mitigation::Mitigation; + +/// What a mitigation was decided to be, and why. +/// +/// The rule line is carried so that `status` and `explain` can tell an operator +/// *which* line produced a decision. "why is this blackholed" is the question +/// that gets asked at 3am, and an answer that requires re-deriving the rule +/// table by hand is not an answer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Decision { + pub mitigation_id: String, + pub tier: Option, + /// Source line of the rule that matched, or `None` if nothing matched. + pub rule_line: Option, + /// Set when the mitigation matched a rule but filterframe cannot act on it. + pub unhandled: Option, +} + +/// Why a decided mitigation still produces no engagement. +/// +/// These are counted and surfaced rather than dropped. Silently ignoring them +/// is how an operator discovers six months later that half their address space +/// was never protected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "detail")] +pub enum Unhandled { + /// The victim is outside every prefix this node originates. + OutsideAuthority(String), + /// A rule selected a tier, but no configured prefix covers the victim in a + /// way that tier can use. + NoCoveringPrefix(String), + /// The rule table matched nothing at all. + NoRuleMatched, +} + +/// One thing filterframe wants to be true, and the mitigations that want it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Engagement { + pub tier: Tier, + /// The prefix to act on: a host route for RTBH, the covering prefix for + /// diversion. + pub prefix: IpNet, + /// Mitigation ids wanting this engagement. A set, for the reason in the + /// module docstring. Ordered so that logs and status output are stable. + pub demands: BTreeSet, +} + +/// The complete output of one planning pass. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)] +pub struct Plan { + /// What should be true, keyed by (tier, prefix) so it is a genuine set. + pub engagements: Vec, + /// Per-mitigation decisions, including the ones that produced nothing. + pub decisions: Vec, +} + +impl Plan { + /// Mitigations that were decided but could not be acted on. + pub fn unhandled(&self) -> impl Iterator { + self.decisions.iter().filter(|d| d.unhandled.is_some()) + } +} + +/// Decide which tier, if any, a mitigation belongs to. +/// +/// First match wins, top to bottom. A rule with no conditions matches +/// everything, which is why the config refuses one that has rules after it. +pub fn classify<'a>(rules: &'a [TierRule], m: &Mitigation) -> Option<&'a TierRule> { + rules.iter().find(|rule| { + rule.facts.iter().all(|fact| match fact { + Fact::Action(v) => m.action.as_str() == v, + Fact::Vector(v) => &m.vector == v, + Fact::Customer(v) => m.customer.as_deref() == Some(v.as_str()), + Fact::AgeAtLeast(d) => m.age >= *d, + Fact::AgeAtMost(d) => m.age <= *d, + // No sample means the fact is not established, so it is not true. + // A rule requiring a rate must not fire on a mitigation we have no + // rate for — that is what keeps "large and persistent" from + // quietly degrading into "persistent". + Fact::BpsAtLeast(threshold) => m.bps.is_some_and(|bps| bps >= *threshold), + Fact::Acknowledged(v) => m.acknowledged == *v, + }) + }) +} + +/// The longest configured prefix containing `victim`, if any. +/// +/// A linear scan sorted by prefix length rather than an LPM trie: the +/// authority list is tens of entries, and being obviously correct is worth more +/// here than being fast. +pub fn covering_prefix(candidates: &[IpNet], victim: std::net::IpAddr) -> Option { + candidates + .iter() + .filter(|net| net.contains(&victim)) + .max_by_key(|net| net.prefix_len()) + .copied() +} + +/// The host route for a victim: `/32` for IPv4, `/128` for IPv6. +/// +/// Built from the address family rather than a fixed length. Hardcoding /32 +/// regardless of family is a real bug in a real policy engine, and it silently +/// rejects every IPv6 mitigation at the guardrail. +pub fn host_route(victim: std::net::IpAddr) -> IpNet { + match victim { + std::net::IpAddr::V4(a) => { + IpNet::V4(ipnet::Ipv4Net::new(a, 32).expect("32 is a valid IPv4 prefix length")) + } + std::net::IpAddr::V6(a) => { + IpNet::V6(ipnet::Ipv6Net::new(a, 128).expect("128 is a valid IPv6 prefix length")) + } + } +} + +/// Derive the desired set from a fresh mitigation list. +/// +/// Takes `&[Mitigation]` rather than a view, so a caller with a stale view +/// has nothing to pass. That is the invariant from [`crate::mitigation`] +/// carried through to the place it matters. +pub fn desired_state(cfg: &Config, mitigations: &[Mitigation]) -> Plan { + let mut engagements: BTreeMap<(Tier, IpNet), BTreeSet> = BTreeMap::new(); + let mut decisions = Vec::with_capacity(mitigations.len()); + + for m in mitigations { + // A mitigation the engine no longer wants in force produces nothing, + // and is not reported as unhandled — it is simply over. + if !m.status.is_live() { + continue; + } + + // POP filtering is client-side on purpose: the reference policy engine + // accepts a `pop` query parameter and silently ignores it, so trusting + // the server here would mean converging on another node's work. + if let Some(want) = &cfg.policy_source.pop + && let Some(has) = &m.pop + && has != want + { + continue; + } + + let Some(rule) = classify(&cfg.tier_rules, m) else { + decisions.push(Decision { + mitigation_id: m.id.clone(), + tier: None, + rule_line: None, + unhandled: Some(Unhandled::NoRuleMatched), + }); + continue; + }; + + let Some(tier) = rule.tier else { + // An explicit refusal. Decided, and deliberately producing nothing. + decisions.push(Decision { + mitigation_id: m.id.clone(), + tier: None, + rule_line: Some(rule.line), + unhandled: None, + }); + continue; + }; + + // The authority boundary, enforced here as well as at load: nothing is + // announced for a victim outside the space this node originates. + if covering_prefix(&cfg.bgp.originate, m.victim).is_none() { + decisions.push(Decision { + mitigation_id: m.id.clone(), + tier: Some(tier), + rule_line: Some(rule.line), + unhandled: Some(Unhandled::OutsideAuthority(m.victim.to_string())), + }); + continue; + } + + let prefix = match tier { + // The fast tier acts on the victim itself. + Tier::Rtbh => host_route(m.victim), + // The slow tiers act on the covering prefix, because a scrubbing + // provider will not accept a host route and the edge's policy is + // written against the aggregate. + Tier::Divert | Tier::DivertSignal => { + match covering_prefix(&divertible(cfg), m.victim) { + Some(p) => p, + None => { + decisions.push(Decision { + mitigation_id: m.id.clone(), + tier: Some(tier), + rule_line: Some(rule.line), + unhandled: Some(Unhandled::NoCoveringPrefix(m.victim.to_string())), + }); + continue; + } + } + } + }; + + engagements + .entry((tier, prefix)) + .or_default() + .insert(m.id.clone()); + + decisions.push(Decision { + mitigation_id: m.id.clone(), + tier: Some(tier), + rule_line: Some(rule.line), + unhandled: None, + }); + } + + Plan { + engagements: engagements + .into_iter() + .map(|((tier, prefix), demands)| Engagement { + tier, + prefix, + demands, + }) + .collect(), + decisions, + } +} + +/// Prefixes the scrub-divert module is allowed to divert. +/// +/// Read from the module's own section rather than modelled in `common`, so that +/// adding a module does not mean editing the shared config types. Malformed +/// entries are skipped here and refused by the module's own validation, which +/// is where the operator gets a line number. +fn divertible(cfg: &Config) -> Vec { + cfg.modules + .iter() + .filter(|m| m.name == "scrub-divert") + .flat_map(|m| &m.directives) + .filter(|d| d.key == "divertible-prefix") + .filter_map(|d| d.args.first()) + .filter_map(|s| s.parse::().ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mitigation::{ActionType, MitigationStatus}; + use std::time::Duration; + + const CFG: &str = r#" +global + node-id t + tier-rule none when acknowledged true + tier-rule rtbh when age-at-most 45s + tier-rule divert when action police + tier-rule rtbh +policy-source + url https://policy.example.net +bgp + mode embedded + local-as 64512 + originate-prefix 198.51.100.0/24 +peer t1 + role transit + address 203.0.113.1 + remote-as 64510 + community blackhole + allow-tier rtbh +peer s1 + role scrubber + address 192.0.2.10 + remote-as 64520 + community 64520:100 + allow-tier divert +module scrub-divert + divertible-prefix 198.51.100.0/24 +"#; + + fn cfg() -> Config { + Config::parse(CFG).expect("test config must parse") + } + + fn mit(id: &str, victim: &str, age_secs: u64) -> Mitigation { + Mitigation { + id: id.into(), + victim: victim.parse().unwrap(), + status: MitigationStatus::Active, + action: ActionType::Discard, + vector: "udp_flood".into(), + customer: None, + pop: None, + acknowledged: false, + age: Duration::from_secs(age_secs), + ttl_remaining: Some(Duration::from_secs(120)), + bps: None, + } + } + + // -- covering prefix ----------------------------------------------------- + + #[test] + fn longest_match_wins() { + let nets: Vec = ["198.51.100.0/24", "198.51.100.0/26", "198.51.0.0/16"] + .iter() + .map(|s| s.parse().unwrap()) + .collect(); + let got = covering_prefix(&nets, "198.51.100.5".parse().unwrap()).unwrap(); + assert_eq!(got.prefix_len(), 26); + } + + #[test] + fn no_covering_prefix_is_none() { + let nets: Vec = vec!["198.51.100.0/24".parse().unwrap()]; + assert!(covering_prefix(&nets, "203.0.113.9".parse().unwrap()).is_none()); + } + + /// The bug this function exists to avoid: a fixed /32 for every family + /// makes every IPv6 mitigation fail its prefix-length guardrail. + #[test] + fn host_routes_are_family_aware() { + assert_eq!(host_route("198.51.100.5".parse().unwrap()).prefix_len(), 32); + assert_eq!(host_route("2001:db8::5".parse().unwrap()).prefix_len(), 128); + } + + // -- classification ------------------------------------------------------ + + #[test] + fn first_matching_rule_wins() { + let c = cfg(); + // 10s old, so the age-at-most rule matches before the catch-all. + let r = classify(&c.tier_rules, &mit("m1", "198.51.100.5", 10)).unwrap(); + assert_eq!(r.tier, Some(Tier::Rtbh)); + assert_eq!(r.line, 5); + } + + #[test] + fn an_acknowledged_mitigation_is_explicitly_refused() { + let c = cfg(); + let mut m = mit("m1", "198.51.100.5", 10); + m.acknowledged = true; + let r = classify(&c.tier_rules, &m).unwrap(); + assert_eq!(r.tier, None, "acknowledged must hit the `none` rule first"); + } + + #[test] + fn the_catch_all_matches_when_nothing_else_does() { + let c = cfg(); + // 300s old and discard, so neither the age nor the police rule fires. + let r = classify(&c.tier_rules, &mit("m1", "198.51.100.5", 300)).unwrap(); + assert_eq!(r.tier, Some(Tier::Rtbh)); + assert!(r.facts.is_empty(), "should be the unconditional rule"); + } + + /// With a sample above the threshold, the rule fires as written. + #[test] + fn a_rate_condition_matches_when_a_sample_clears_it() { + let rules = Config::parse(&CFG.replace( + " tier-rule rtbh when age-at-most 45s", + " tier-rule divert when bps-at-least 1500mbps", + )) + .unwrap() + .tier_rules; + let mut m = mit("m1", "198.51.100.5", 300); + m.bps = Some(2_000_000_000); + assert_eq!(classify(&rules, &m).unwrap().tier, Some(Tier::Divert)); + } + + /// A sample below the threshold does not. + #[test] + fn a_rate_condition_does_not_match_a_sample_below_it() { + let rules = Config::parse(&CFG.replace( + " tier-rule rtbh when age-at-most 45s", + " tier-rule divert when bps-at-least 1500mbps", + )) + .unwrap() + .tier_rules; + let mut m = mit("m1", "198.51.100.5", 300); + m.bps = Some(100_000_000); + assert_ne!(classify(&rules, &m).unwrap().tier, Some(Tier::Divert)); + } + + /// A rate condition cannot be satisfied without a rate sample. Firing it + /// anyway would defeat the point of requiring size *and* persistence. + #[test] + fn a_rate_condition_never_matches_without_a_sample() { + let rules = Config::parse(&CFG.replace( + " tier-rule rtbh when age-at-most 45s", + " tier-rule divert when bps-at-least 1500mbps", + )) + .unwrap() + .tier_rules; + let m = mit("m1", "198.51.100.5", 10); + let r = classify(&rules, &m).unwrap(); + assert_ne!(r.tier, Some(Tier::Divert)); + } + + // -- desired state ------------------------------------------------------- + + #[test] + fn a_live_mitigation_produces_one_engagement() { + let c = cfg(); + let p = desired_state(&c, &[mit("m1", "198.51.100.5", 10)]); + assert_eq!(p.engagements.len(), 1); + assert_eq!(p.engagements[0].tier, Tier::Rtbh); + assert_eq!(p.engagements[0].prefix.to_string(), "198.51.100.5/32"); + } + + /// The property event-driven toggling gets wrong: two victims inside one + /// covering prefix are one diversion, and it survives while either does. + #[test] + fn two_victims_in_one_prefix_collapse_to_one_divert() { + let c = cfg(); + let mut a = mit("m1", "198.51.100.5", 300); + let mut b = mit("m2", "198.51.100.9", 300); + a.action = ActionType::Police; + b.action = ActionType::Police; + + let p = desired_state(&c, &[a, b]); + let diverts: Vec<_> = p + .engagements + .iter() + .filter(|e| e.tier == Tier::Divert) + .collect(); + assert_eq!(diverts.len(), 1, "one prefix, one engagement"); + assert_eq!( + diverts[0].demands.len(), + 2, + "both mitigations must be counted as demands" + ); + } + + /// The refcount is a set, so the duplicate a paginating API can hand back + /// across a page boundary cannot inflate it — and therefore cannot stop the + /// engagement releasing. + #[test] + fn a_duplicated_mitigation_counts_once() { + let c = cfg(); + let m = mit("m1", "198.51.100.5", 10); + let p = desired_state(&c, &[m.clone(), m]); + assert_eq!(p.engagements.len(), 1); + assert_eq!(p.engagements[0].demands.len(), 1); + } + + #[test] + fn a_non_live_mitigation_produces_nothing() { + let c = cfg(); + let mut m = mit("m1", "198.51.100.5", 10); + m.status = MitigationStatus::Expired; + let p = desired_state(&c, &[m]); + assert!(p.engagements.is_empty()); + assert!( + p.decisions.is_empty(), + "an ended mitigation is not unhandled" + ); + } + + /// The authority boundary, enforced at plan time as well as at load. + #[test] + fn a_victim_outside_the_authority_is_reported_not_announced() { + let c = cfg(); + let p = desired_state(&c, &[mit("m1", "203.0.113.9", 10)]); + assert!(p.engagements.is_empty(), "must announce nothing"); + assert_eq!(p.unhandled().count(), 1, "and must not do so silently"); + assert!(matches!( + p.decisions[0].unhandled, + Some(Unhandled::OutsideAuthority(_)) + )); + } + + #[test] + fn an_unmatched_mitigation_is_reported_as_unhandled() { + // A rule table that deliberately matches nothing. + let c = Config::parse( + &CFG.replace(" tier-rule none when acknowledged true", "") + .replace(" tier-rule rtbh when age-at-most 45s", "") + .replace(" tier-rule divert when action police", "") + .replace( + " tier-rule rtbh\n", + " tier-rule rtbh when vector nothing_matches\n", + ), + ) + .unwrap(); + let p = desired_state(&c, &[mit("m1", "198.51.100.5", 10)]); + assert!(p.engagements.is_empty()); + assert!(matches!( + p.decisions[0].unhandled, + Some(Unhandled::NoRuleMatched) + )); + } + + /// A `none` rule is a decision, not a gap — it must not be reported as + /// unhandled, or a deliberate exception would look like a config error. + #[test] + fn an_explicit_refusal_is_not_unhandled() { + let c = cfg(); + let mut m = mit("m1", "198.51.100.5", 10); + m.acknowledged = true; + let p = desired_state(&c, &[m]); + assert!(p.engagements.is_empty()); + assert_eq!(p.unhandled().count(), 0); + assert!( + p.decisions[0].rule_line.is_some(), + "the rule is still cited" + ); + } + + #[test] + fn a_decision_carries_the_line_that_made_it() { + let c = cfg(); + let p = desired_state(&c, &[mit("m1", "198.51.100.5", 10)]); + assert_eq!(p.decisions[0].rule_line, Some(5)); + } + + /// The reference policy engine accepts a `pop` parameter and ignores it, so + /// filtering has to happen here or this node converges on another's work. + #[test] + fn mitigations_for_another_pop_are_filtered_client_side() { + let c = Config::parse(&CFG.replace( + " url https://policy.example.net", + " url https://policy.example.net\n pop iad1", + )) + .unwrap(); + + let mut mine = mit("m1", "198.51.100.5", 10); + mine.pop = Some("iad1".into()); + let mut theirs = mit("m2", "198.51.100.9", 10); + theirs.pop = Some("ord1".into()); + + let p = desired_state(&c, &[mine, theirs]); + assert_eq!(p.engagements.len(), 1); + assert_eq!(p.decisions.len(), 1); + } + + #[test] + fn planning_an_empty_list_is_an_empty_plan_not_an_error() { + let c = cfg(); + let p = desired_state(&c, &[]); + assert!(p.engagements.is_empty()); + assert!(p.decisions.is_empty()); + } +} diff --git a/crates/modules/rtbh/Cargo.toml b/crates/modules/rtbh/Cargo.toml new file mode 100644 index 0000000..4fc2001 --- /dev/null +++ b/crates/modules/rtbh/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "filterframe-rtbh" +description = "The fast mitigation tier: remotely triggered blackholing." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +filterframe-common.workspace = true +ipnet.workspace = true +tracing.workspace = true diff --git a/crates/modules/rtbh/src/lib.rs b/crates/modules/rtbh/src/lib.rs new file mode 100644 index 0000000..2ca97af --- /dev/null +++ b/crates/modules/rtbh/src/lib.rs @@ -0,0 +1,728 @@ +//! Remotely triggered blackholing: the fast tier. +//! +//! Announce a host route carrying a blackhole community, and the attack is +//! dropped upstream before it reaches the edge. Seconds to take effect, at the +//! cost of the victim address — which is the right trade for a short sharp +//! attack, and the only response that works at all when the local pipe is +//! already the bottleneck. +//! +//! Everything in this module exists because RTBH is *easy to get catastrophically +//! wrong*. It is one announcement away from dropping a customer's traffic on +//! purpose, so the guards refuse rather than clamp, and the damping refuses +//! churn rather than tracking it. +//! +//! # Asymmetry +//! +//! Announcements are immediate. Withdrawals dwell. +//! +//! That asymmetry is deliberate and is the single most important behaviour +//! here. The fast tier has to be fast in the direction that protects, and slow +//! in the direction that exposes: a detector oscillating around its threshold +//! must not oscillate a BGP announcement at every transit it touches, but it +//! also must not wait for a dwell timer before dropping an attack. + +use std::collections::{BTreeSet, HashMap}; +use std::time::{Duration, Instant}; + +use filterframe_common::bgp::PathKey; +use filterframe_common::config::{ModuleSection, Tier, parse_duration}; +use filterframe_common::module::{ + Refusal, TierModule, TierOutcome, directive, directives, reject_unknown, +}; +use filterframe_common::plan::Engagement; +use ipnet::IpNet; + +/// Directives this module accepts. Anything else fails at load. +const KNOWN: &[&str] = &[ + "max-prefix-length", + "max-prefix-length6", + "max-active", + "never-blackhole", + "withdraw-hold", + "max-lifetime", +]; + +/// Host routes only, by default. +/// +/// Blackholing anything shorter drops the attack and every other address in the +/// prefix together. Most upstreams reject a short blackhole anyway, but +/// "usually rejected upstream" is not a guard — it is a hope about somebody +/// else's configuration. +const DEFAULT_MAX_PREFIX_LEN_V4: u8 = 32; +const DEFAULT_MAX_PREFIX_LEN_V6: u8 = 128; + +/// Ceiling on simultaneously announced blackholes. +/// +/// The failure this exists for is a runaway detector. Sixty-four is far more +/// than any real incident on one node and far less than the number that would +/// worry a transit's prefix limit. +const DEFAULT_MAX_ACTIVE: usize = 64; + +/// Dwell before withdrawing a blackhole the policy engine has dropped. +/// +/// Thirty seconds is longer than the flap period of a detector sitting on its +/// threshold, and short enough that a genuinely finished attack releases the +/// address promptly. +const DEFAULT_WITHDRAW_HOLD: Duration = Duration::from_secs(30); + +/// How long a blackhole may stand without the policy engine re-confirming it. +/// +/// The backstop for `on-policy-loss hold`: the engine going away must not mean +/// an address stays dark forever. Thirty minutes is long enough to survive an +/// engine outage and short enough that a forgotten blackhole surfaces within a +/// shift. +const DEFAULT_MAX_LIFETIME: Duration = Duration::from_secs(1800); + +/// Whether two prefixes cover any address in common. +/// +/// Two prefixes of the same family overlap exactly when one contains the +/// other's network address, so this is symmetric by construction rather than by +/// the caller remembering to test both ways. Different families never overlap, +/// which `IpNet::contains` already reports as false. +fn overlaps(a: IpNet, b: IpNet) -> bool { + a.contains(&b.network()) || b.contains(&a.network()) +} + +#[derive(Debug, Clone)] +struct Held { + /// When this engagement was first announced. Monotonic, and used only for + /// reporting how long something has been up. + since: Instant, + /// The last time the policy engine confirmed it wanted this. + /// + /// `max-lifetime` measures from here, not from `since`. The ceiling exists + /// to stop an *unconfirmed* engagement standing forever, so a mitigation + /// the engine keeps re-confirming must never trip it — measuring from + /// `since` would expire a live mitigation and re-announce it in the same + /// tick, which is churn wearing a safety guard's clothes. + last_demanded: Instant, + /// When it stopped being demanded, if it has. + undemanded_since: Option, +} + +/// The fast tier. +pub struct RtbhModule { + max_prefix_len_v4: u8, + max_prefix_len_v6: u8, + max_active: usize, + never: Vec, + withdraw_hold: Duration, + max_lifetime: Duration, + held: HashMap, +} + +impl Default for RtbhModule { + fn default() -> Self { + Self { + max_prefix_len_v4: DEFAULT_MAX_PREFIX_LEN_V4, + max_prefix_len_v6: DEFAULT_MAX_PREFIX_LEN_V6, + max_active: DEFAULT_MAX_ACTIVE, + never: Vec::new(), + withdraw_hold: DEFAULT_WITHDRAW_HOLD, + max_lifetime: DEFAULT_MAX_LIFETIME, + held: HashMap::new(), + } + } +} + +impl RtbhModule { + pub fn new() -> Self { + Self::default() + } + + /// Whether an operator has forbidden this prefix outright. + /// + /// Overlap in **either** direction is a refusal. Asking only whether the + /// never-entry contains the engagement missed the case that matters most: a + /// covering announcement blackholes every address inside it, so with + /// `max-prefix-length` relaxed below /32 a `never-blackhole 198.51.100.1/32` + /// entry did not stop a blackhole of 198.51.100.0/24 — the guard an operator + /// reaches for during a mistaken mitigation silently did nothing. + fn is_protected(&self, prefix: IpNet) -> Option { + self.never.iter().find(|n| overlaps(**n, prefix)).copied() + } + + fn prefix_floor(&self, prefix: IpNet) -> u8 { + match prefix { + IpNet::V4(_) => self.max_prefix_len_v4, + IpNet::V6(_) => self.max_prefix_len_v6, + } + } + + /// Guards that refuse an engagement outright, in the order they apply. + fn guard(&self, key: &PathKey) -> Option { + let floor = self.prefix_floor(key.prefix); + if key.prefix.prefix_len() < floor { + return Some(Refusal::PrefixTooShort { + got: key.prefix.prefix_len(), + floor, + }); + } + if let Some(n) = self.is_protected(key.prefix) { + return Some(Refusal::Protected(n.to_string())); + } + None + } +} + +impl RtbhModule { + /// Drop anything unconfirmed for longer than the ceiling. + /// + /// Shared by the normal path and the hold-only one, because the ceiling is + /// the *only* rule that must keep running while the policy engine is + /// unreachable: it is what stops an outage there becoming an address that + /// stays dark indefinitely. + fn expire(&mut self, now: Instant) -> Vec<(PathKey, Refusal)> { + let stale: Vec = self + .held + .iter() + .filter(|(_, h)| now.saturating_duration_since(h.last_demanded) > self.max_lifetime) + .map(|(k, _)| k.clone()) + .collect(); + + stale + .into_iter() + .map(|key| { + let held = self.held.remove(&key).expect("just listed"); + ( + key, + Refusal::Expired { + held_secs: now.saturating_duration_since(held.since).as_secs(), + ceiling_secs: self.max_lifetime.as_secs(), + }, + ) + }) + .collect() + } + + /// Everything currently held, for `status`. + pub fn held_for_status(&self, now: Instant) -> Vec<(PathKey, u64)> { + let mut v: Vec<_> = self + .held + .iter() + .map(|(k, h)| (k.clone(), now.saturating_duration_since(h.since).as_secs())) + .collect(); + v.sort(); + v + } +} + +impl TierModule for RtbhModule { + fn tier(&self) -> Tier { + Tier::Rtbh + } + + fn adopt(&mut self, existing: Box + '_>, now: Instant) { + for key in existing { + if key.tier == Tier::Rtbh { + self.held.entry(key).or_insert(Held { + since: now, + last_demanded: now, + undemanded_since: None, + }); + } + } + } + + /// Applies the ceiling and **nothing else**. + /// + /// In particular it never marks anything undemanded, because "I cannot see + /// the demand" is not "the demand is gone" — treating it as such is the + /// exact failure the whole design exists to prevent. + fn hold_only(&mut self, now: Instant) -> TierOutcome { + let mut outcome = TierOutcome { + refused: self.expire(now), + ..Default::default() + }; + for key in self.held.keys() { + outcome.effective.insert(key.clone()); + } + outcome + } + + fn configure(&mut self, section: &ModuleSection) -> Result<(), String> { + reject_unknown(section, KNOWN)?; + + if let Some((v, line)) = directive(section, "max-prefix-length") { + self.max_prefix_len_v4 = v + .parse() + .map_err(|_| format!("line {line}: `max-prefix-length` must be 0-32"))?; + if self.max_prefix_len_v4 > 32 { + return Err(format!("line {line}: `max-prefix-length` must be 0-32")); + } + } + if let Some((v, line)) = directive(section, "max-prefix-length6") { + self.max_prefix_len_v6 = v + .parse() + .map_err(|_| format!("line {line}: `max-prefix-length6` must be 0-128"))?; + if self.max_prefix_len_v6 > 128 { + return Err(format!("line {line}: `max-prefix-length6` must be 0-128")); + } + } + if let Some((v, line)) = directive(section, "max-active") { + self.max_active = v + .parse() + .map_err(|_| format!("line {line}: `max-active` must be a positive number"))?; + if self.max_active == 0 { + return Err(format!( + "line {line}: `max-active 0` would refuse every mitigation; \ + remove the rtbh module instead if that is what you want" + )); + } + } + for (v, line) in directives(section, "never-blackhole") { + self.never.push( + v.parse::() + .map_err(|e| format!("line {line}: `never-blackhole`: {e}"))?, + ); + } + if let Some((v, line)) = directive(section, "withdraw-hold") { + self.withdraw_hold = + parse_duration(v).map_err(|e| format!("line {line}: `withdraw-hold`: {e}"))?; + } + if let Some((v, line)) = directive(section, "max-lifetime") { + self.max_lifetime = + parse_duration(v).map_err(|e| format!("line {line}: `max-lifetime`: {e}"))?; + } + // Checked unconditionally, after both directives have been parsed. + // Nesting it inside the `max-lifetime` branch meant raising only + // `withdraw-hold` past the 30m default slipped through, and every + // undemanded engagement then tripped the ceiling before its dwell could + // release it — churn wearing a safety guard's clothes. + if self.max_lifetime <= self.withdraw_hold { + let line = directive(section, "max-lifetime") + .or_else(|| directive(section, "withdraw-hold")) + .map_or(section.line, |(_, l)| l); + return Err(format!( + "line {line}: `max-lifetime` ({}s) must exceed `withdraw-hold` ({}s), or an \ + engagement would expire before it could ever be released cleanly", + self.max_lifetime.as_secs(), + self.withdraw_hold.as_secs() + )); + } + Ok(()) + } + + fn refine(&mut self, desired: &[Engagement], now: Instant) -> TierOutcome { + let mut outcome = TierOutcome::default(); + + let wanted: BTreeSet = desired + .iter() + .filter(|e| e.tier == Tier::Rtbh) + .map(|e| PathKey { + prefix: e.prefix, + tier: e.tier, + }) + .collect(); + + // Mark anything no longer demanded, and clear the mark on anything that + // came back. A single-tick dropout must therefore cost nothing at all. + for (key, held) in self.held.iter_mut() { + if wanted.contains(key) { + // Reaching `refine` at all means the view was fresh, so seeing a + // key here is the policy engine re-confirming it. + held.last_demanded = now; + held.undemanded_since = None; + } else if held.undemanded_since.is_none() { + held.undemanded_since = Some(now); + } + } + + // Expire anything held past its ceiling, demanded or not. This is what + // stops a policy engine that is merely unreachable from leaving an + // address dark indefinitely. + outcome.refused.extend(self.expire(now)); + + // Release anything whose dwell has run out. + let released: Vec = self + .held + .iter() + .filter_map(|(k, h)| { + h.undemanded_since + .filter(|t| now.saturating_duration_since(*t) >= self.withdraw_hold) + .map(|_| k.clone()) + }) + .collect(); + for key in released { + self.held.remove(&key); + } + + // Admit new work, guard by guard. + for key in &wanted { + if self.held.contains_key(key) { + continue; + } + if let Some(refusal) = self.guard(key) { + outcome.refused.push((key.clone(), refusal)); + continue; + } + // Capacity is checked against what is *actually* held, so + // engagements sitting out a dwell still occupy a slot — they are + // still announced, and a cap that ignored them would overshoot. + if self.held.len() >= self.max_active { + outcome.refused.push(( + key.clone(), + Refusal::AtCapacity { + cap: self.max_active, + }, + )); + continue; + } + self.held.insert( + key.clone(), + Held { + since: now, + last_demanded: now, + undemanded_since: None, + }, + ); + } + + for (key, held) in &self.held { + outcome.effective.insert(key.clone()); + if let Some(t) = held.undemanded_since { + let elapsed = now.saturating_duration_since(t); + outcome.dwelling.push(( + key.clone(), + self.withdraw_hold.saturating_sub(elapsed).as_secs(), + )); + } + } + outcome.dwelling.sort(); + outcome.refused.sort_by(|a, b| a.0.cmp(&b.0)); + + outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + use filterframe_common::config::RawDirective; + use std::collections::BTreeSet; + + fn key(prefix: &str) -> PathKey { + PathKey { + prefix: prefix.parse().unwrap(), + tier: Tier::Rtbh, + } + } + + fn want(prefixes: &[&str]) -> Vec { + prefixes + .iter() + .map(|p| Engagement { + tier: Tier::Rtbh, + prefix: p.parse().unwrap(), + demands: BTreeSet::from(["m1".to_string()]), + }) + .collect() + } + + fn section(pairs: &[(&str, &str)]) -> ModuleSection { + ModuleSection { + name: "rtbh".into(), + directives: pairs + .iter() + .enumerate() + .map(|(i, (k, v))| RawDirective { + key: (*k).to_string(), + args: vec![(*v).to_string()], + line: i + 1, + }) + .collect(), + line: 0, + } + } + + fn module(pairs: &[(&str, &str)]) -> RtbhModule { + let mut m = RtbhModule::new(); + m.configure(§ion(pairs)) + .expect("test config must apply"); + m + } + + // -- guards -------------------------------------------------------------- + + /// Blackholing an aggregate drops the attack and the customer together. + #[test] + fn a_prefix_shorter_than_a_host_route_is_refused() { + let mut m = module(&[]); + let out = m.refine(&want(&["198.51.100.0/24"]), Instant::now()); + assert!(out.effective.is_empty()); + assert!(matches!( + out.refused[0].1, + Refusal::PrefixTooShort { got: 24, floor: 32 } + )); + } + + #[test] + fn ipv6_uses_its_own_floor() { + let mut m = module(&[]); + let out = m.refine(&want(&["2001:db8::/48"]), Instant::now()); + assert!(matches!( + out.refused[0].1, + Refusal::PrefixTooShort { floor: 128, .. } + )); + + let out = m.refine(&want(&["2001:db8::1/128"]), Instant::now()); + assert_eq!(out.effective.len(), 1, "a v6 host route is fine"); + } + + /// The fastest lever an operator has during a mistaken mitigation. + #[test] + fn a_protected_prefix_is_never_blackholed() { + let mut m = module(&[("never-blackhole", "198.51.100.1/32")]); + let out = m.refine(&want(&["198.51.100.1/32"]), Instant::now()); + assert!(out.effective.is_empty()); + assert!(matches!(out.refused[0].1, Refusal::Protected(_))); + } + + #[test] + fn protection_covers_a_whole_range() { + let mut m = module(&[("never-blackhole", "198.51.100.0/29")]); + let out = m.refine(&want(&["198.51.100.3/32"]), Instant::now()); + assert!(matches!(out.refused[0].1, Refusal::Protected(_))); + } + + /// The direction the guard used to miss. Blackholing a covering prefix + /// blackholes every address inside it, so a protected host must refuse the + /// aggregate too — otherwise the never-list is escapable by announcing + /// something shorter. + #[test] + fn a_protected_host_is_not_reachable_through_a_covering_prefix() { + let mut m = module(&[ + ("max-prefix-length", "24"), + ("never-blackhole", "198.51.100.1/32"), + ]); + let out = m.refine(&want(&["198.51.100.0/24"]), Instant::now()); + assert!( + out.effective.is_empty(), + "the covering prefix blackholes the protected host" + ); + assert!(matches!(out.refused[0].1, Refusal::Protected(_))); + } + + /// An unrelated prefix is still allowed — the overlap test must not become + /// a blanket refusal. + #[test] + fn protection_does_not_refuse_an_unrelated_prefix() { + let mut m = module(&[("never-blackhole", "198.51.100.1/32")]); + let out = m.refine(&want(&["198.51.100.2/32"]), Instant::now()); + assert_eq!(out.effective.len(), 1); + assert!(out.refused.is_empty()); + } + + /// Refused at, not truncated to: a partially applied blackhole set splits + /// traffic along a line nobody chose. + #[test] + fn capacity_refuses_the_excess_and_keeps_the_rest() { + let mut m = module(&[("max-active", "2")]); + let out = m.refine( + &want(&["198.51.100.1/32", "198.51.100.2/32", "198.51.100.3/32"]), + Instant::now(), + ); + assert_eq!(out.effective.len(), 2); + assert_eq!(out.refused.len(), 1); + assert!(matches!(out.refused[0].1, Refusal::AtCapacity { cap: 2 })); + } + + // -- damping ------------------------------------------------------------- + + /// The asymmetry that defines this tier: protection is immediate. + #[test] + fn an_announcement_is_not_delayed() { + let mut m = module(&[("withdraw-hold", "30s")]); + let out = m.refine(&want(&["198.51.100.5/32"]), Instant::now()); + assert_eq!(out.effective.len(), 1, "announces must not dwell"); + assert!(out.dwelling.is_empty()); + } + + #[test] + fn a_withdrawal_dwells_before_it_happens() { + let t0 = Instant::now(); + let mut m = module(&[("withdraw-hold", "30s")]); + m.refine(&want(&["198.51.100.5/32"]), t0); + + // The dwell starts when the demand disappears, which is this tick. + let out = m.refine(&[], t0 + Duration::from_secs(10)); + assert_eq!(out.effective.len(), 1, "still held during the dwell"); + assert_eq!(out.dwelling.len(), 1); + assert_eq!(out.dwelling[0].1, 30, "the full dwell remains"); + + let out = m.refine(&[], t0 + Duration::from_secs(25)); + assert_eq!(out.effective.len(), 1, "15s in, still held"); + assert_eq!(out.dwelling[0].1, 15); + + let out = m.refine(&[], t0 + Duration::from_secs(41)); + assert!(out.effective.is_empty(), "released once the dwell expires"); + } + + /// The single-tick dropout. A detector sitting on its threshold must not + /// oscillate a BGP announcement. + #[test] + fn a_demand_that_returns_within_the_dwell_costs_nothing() { + let t0 = Instant::now(); + let mut m = module(&[("withdraw-hold", "30s")]); + m.refine(&want(&["198.51.100.5/32"]), t0); + + m.refine(&[], t0 + Duration::from_secs(5)); + let out = m.refine(&want(&["198.51.100.5/32"]), t0 + Duration::from_secs(10)); + + assert_eq!(out.effective.len(), 1); + assert!( + out.dwelling.is_empty(), + "the dwell must be cancelled, not merely paused" + ); + + // And it must not release later on the strength of the old mark. + let out = m.refine(&want(&["198.51.100.5/32"]), t0 + Duration::from_secs(60)); + assert_eq!(out.effective.len(), 1); + } + + /// A mitigation the engine keeps confirming must never trip the ceiling. + /// Measuring from first announcement instead of last confirmation would + /// expire a live mitigation and re-announce it in the same tick. + #[test] + fn a_continuously_confirmed_engagement_never_expires() { + let t0 = Instant::now(); + let mut m = module(&[("withdraw-hold", "30s"), ("max-lifetime", "5m")]); + + for secs in (0..3600).step_by(60) { + let out = m.refine(&want(&["198.51.100.5/32"]), t0 + Duration::from_secs(secs)); + assert_eq!(out.effective.len(), 1, "expired at {secs}s while confirmed"); + assert!(out.refused.is_empty(), "refused at {secs}s while confirmed"); + } + } + + /// A policy engine that is merely unreachable must not leave an address + /// dark forever. The ceiling is the backstop, and it is the only rule that + /// keeps running while the engine cannot be reached. + #[test] + fn an_unconfirmed_engagement_expires_at_the_ceiling() { + let t0 = Instant::now(); + let mut m = module(&[("withdraw-hold", "30s"), ("max-lifetime", "5m")]); + m.refine(&want(&["198.51.100.5/32"]), t0); + + let out = m.hold_only(t0 + Duration::from_secs(299)); + assert_eq!(out.effective.len(), 1, "still inside the ceiling"); + + let out = m.hold_only(t0 + Duration::from_secs(301)); + assert!( + out.effective.is_empty(), + "the ceiling must eventually apply" + ); + assert!(matches!(out.refused[0].1, Refusal::Expired { .. })); + } + + /// The hold-only path must never release something merely because the + /// demand cannot be seen. It has no desired-set parameter precisely so that + /// there is nothing to release against. + #[test] + fn hold_only_never_releases_for_lack_of_demand() { + let t0 = Instant::now(); + let mut m = module(&[("withdraw-hold", "30s"), ("max-lifetime", "30m")]); + m.refine(&want(&["198.51.100.5/32"]), t0); + + for secs in (1..=600).step_by(10) { + let out = m.hold_only(t0 + Duration::from_secs(secs)); + assert_eq!(out.effective.len(), 1, "released at {secs}s"); + assert!(out.dwelling.is_empty(), "must not even start a dwell"); + } + } + + /// A crash loop must not accumulate hold credit and then release + /// everything at once. + #[test] + fn adopted_engagements_start_their_clocks_from_now() { + let t0 = Instant::now(); + let mut m = module(&[("max-lifetime", "5m"), ("withdraw-hold", "30s")]); + m.adopt(Box::new([key("198.51.100.5/32")].into_iter()), t0); + + // Still held well past when it would have expired had the clock been + // restored from a previous life. + let out = m.refine(&want(&["198.51.100.5/32"]), t0 + Duration::from_secs(299)); + assert_eq!(out.effective.len(), 1); + } + + #[test] + fn adoption_ignores_paths_belonging_to_another_tier() { + let mut m = module(&[]); + m.adopt( + Box::new( + [PathKey { + prefix: "198.51.100.0/24".parse().unwrap(), + tier: Tier::Divert, + }] + .into_iter(), + ), + Instant::now(), + ); + let out = m.refine(&[], Instant::now()); + assert!(out.effective.is_empty()); + } + + // -- configuration ------------------------------------------------------- + + #[test] + fn an_unknown_directive_is_refused_with_its_line() { + let mut m = RtbhModule::new(); + let e = m.configure(§ion(&[("nonsense", "1")])).unwrap_err(); + assert!(e.contains("line 1"), "{e}"); + assert!(e.contains("nonsense"), "{e}"); + } + + /// A ceiling below the dwell would expire engagements before they could be + /// released cleanly, which reads as random churn. + #[test] + fn a_lifetime_shorter_than_the_dwell_is_refused() { + let mut m = RtbhModule::new(); + let e = m + .configure(§ion(&[ + ("withdraw-hold", "60s"), + ("max-lifetime", "30s"), + ])) + .unwrap_err(); + assert!(e.contains("must exceed"), "{e}"); + } + + /// The same rule against the *default* ceiling. The check used to live + /// inside the `max-lifetime` branch, so raising only `withdraw-hold` past + /// the 30m default was accepted and every undemanded engagement then + /// expired before its dwell could release it. + #[test] + fn a_dwell_longer_than_the_default_lifetime_is_refused() { + let mut m = RtbhModule::new(); + let e = m + .configure(§ion(&[("withdraw-hold", "45m")])) + .unwrap_err(); + assert!(e.contains("must exceed"), "{e}"); + assert!(e.contains("line 1"), "should cite the line it saw: {e}"); + } + + #[test] + fn a_zero_capacity_is_refused_rather_than_silently_disabling_the_tier() { + let mut m = RtbhModule::new(); + let e = m.configure(§ion(&[("max-active", "0")])).unwrap_err(); + assert!(e.contains("refuse every mitigation"), "{e}"); + } + + #[test] + fn a_bad_prefix_in_never_blackhole_is_refused() { + let mut m = RtbhModule::new(); + assert!( + m.configure(§ion(&[("never-blackhole", "not-a-prefix")])) + .is_err() + ); + } + + #[test] + fn defaults_are_the_documented_ones() { + let m = RtbhModule::new(); + assert_eq!(m.max_prefix_len_v4, 32); + assert_eq!(m.max_prefix_len_v6, 128); + assert_eq!(m.max_active, 64); + assert_eq!(m.withdraw_hold, Duration::from_secs(30)); + assert_eq!(m.max_lifetime, Duration::from_secs(1800)); + } +} diff --git a/crates/modules/scrub-divert/Cargo.toml b/crates/modules/scrub-divert/Cargo.toml new file mode 100644 index 0000000..ab39d46 --- /dev/null +++ b/crates/modules/scrub-divert/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "filterframe-scrub-divert" +description = "The slow mitigation tier: divert a prefix to a scrubbing provider and back." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +filterframe-common.workspace = true +ipnet.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true + +[dev-dependencies] +# The journal format is a persisted contract, so its round-trip is tested. +serde_json.workspace = true diff --git a/crates/modules/scrub-divert/src/lib.rs b/crates/modules/scrub-divert/src/lib.rs new file mode 100644 index 0000000..e65ecc8 --- /dev/null +++ b/crates/modules/scrub-divert/src/lib.rs @@ -0,0 +1,971 @@ +//! Scrubber diversion: the slow tier. +//! +//! Announce the covering prefix to a scrubbing provider, confirm a quorum of +//! their reflectors have it, dwell, then raise a signal route that makes the +//! edge stop advertising that prefix to transit. Traffic is drawn to the +//! scrubber, cleaned, and returned over a tunnel. Reverse exactly on the way +//! back out. +//! +//! Tens of seconds either way, and every cycle is real BGP churn at every +//! transit and reflector involved — which is why this tier is gated behind both +//! a size and a duration threshold, and why its damping is an order of +//! magnitude longer than the fast tier's. +//! +//! The sequence itself lives in [`machine`], as a pure state machine with no +//! I/O, so the safety property can be proved by exhaustive enumeration rather +//! than argued from a running system. + +pub mod machine; + +use std::collections::{BTreeSet, HashMap}; +use std::time::{Duration, Instant}; + +use filterframe_common::bgp::PathKey; +use filterframe_common::config::{ModuleSection, Tier, parse_duration}; +use filterframe_common::module::{ + PathEvidence, Refusal, ReturnPath, TierModule, TierOutcome, directive, directives, + reject_unknown, +}; +use filterframe_common::plan::Engagement; +use ipnet::IpNet; + +pub use machine::{Action, Event, Machine, State}; + +const KNOWN: &[&str] = &[ + "divertible-prefix", + "scrubber-quorum", + "divert-settle-time", + "return-settle-time", + "min-divert-time", + "return-tunnel", + "return-probe-target", + "on-quorum-loss", +]; + +/// Dwell after a quorum before suppressing transit. +/// +/// A quorum establishes that the reflectors took the path. It says nothing +/// about the provider's own propagation, and raising the signal the instant a +/// quorum appears is how a real gap gets created that no adjacency query can +/// see. Prefer too long over too short: too long costs dirty traffic, too short +/// costs a black hole. +const DEFAULT_SETTLE: Duration = Duration::from_secs(20); + +/// Dwell after restoring transit before withdrawing from the scrubber. +/// +/// Deliberately longer than the engage dwell. An overlap costs a little +/// asymmetric routing; a gap costs an outage. +const DEFAULT_RETURN_SETTLE: Duration = Duration::from_secs(60); + +/// Floor on how long a prefix stays diverted once it has been. +/// +/// Moving a whole prefix across the Internet twice because a detector blipped +/// is worse for the customer than the attack was. +const DEFAULT_MIN_DIVERT: Duration = Duration::from_secs(600); + +#[derive(Debug, Clone)] +struct Divert { + machine: Machine, + /// When the current state was entered. Monotonic. + since: Instant, + /// When this prefix first became diverted, for `min-divert-time`. + engaged_since: Option, +} + +/// The slow tier. +pub struct ScrubDivertModule { + divertible: Vec, + quorum_need: u8, + quorum_of: u8, + settle: Duration, + return_settle: Duration, + min_divert: Duration, + tunnel: Option, + probe_target: Option, + diverts: HashMap, + /// Whether the return path is currently usable. Set by the daemon's probe; + /// `true` until something says otherwise, so a deployment without a probe + /// configured is not permanently gated. + return_path_up: bool, +} + +impl Default for ScrubDivertModule { + fn default() -> Self { + Self { + divertible: Vec::new(), + quorum_need: 2, + quorum_of: 3, + settle: DEFAULT_SETTLE, + return_settle: DEFAULT_RETURN_SETTLE, + min_divert: DEFAULT_MIN_DIVERT, + tunnel: None, + probe_target: None, + diverts: HashMap::new(), + return_path_up: true, + } + } +} + +impl ScrubDivertModule { + pub fn new() -> Self { + Self::default() + } + + /// The interface the daemon should probe, if one is configured. + pub fn return_tunnel(&self) -> Option<&str> { + self.tunnel.as_deref() + } + + pub fn return_probe_target(&self) -> Option<&str> { + self.probe_target.as_deref() + } + + pub fn quorum(&self) -> (u8, u8) { + (self.quorum_need, self.quorum_of) + } + + /// What to write to the journal. + /// + /// Only the state per prefix. Timers are deliberately **not** persisted: + /// restoring a dwell that was half elapsed would shorten the window + /// protecting against a gap, and restoring accumulated hold credit would + /// let a crash loop release everything at once. Every clock restarts on + /// recovery, which biases toward holding — the safe direction. + pub fn snapshot(&self) -> Vec<(IpNet, State)> { + let mut v: Vec<_> = self + .diverts + .iter() + .map(|(p, d)| (*p, d.machine.state)) + .collect(); + v.sort_by_key(|(p, _)| p.to_string()); + v + } + + /// Resume from a journal written before a crash. + /// + /// The journal records *intent*, not truth: it says which step was being + /// attempted, and the world may or may not reflect it. [`Machine::recover`] + /// therefore picks the state that preserves reachability regardless of what + /// actually landed, and the reconciler re-asserts from there. + pub fn restore(&mut self, entries: impl IntoIterator, now: Instant) { + for (prefix, journaled) in entries { + let machine = Machine::recover(journaled); + tracing::info!( + %prefix, + journaled = journaled.as_str(), + resuming = machine.state.as_str(), + "resuming a divert interrupted by a restart" + ); + self.diverts.insert( + prefix, + Divert { + machine, + since: now, + // Restarting the floor from now rather than from a + // persisted timestamp: a crash loop must not accumulate + // credit and then tear everything down together. + engaged_since: machine.state.signal_up().then_some(now), + }, + ); + } + } + + /// Per-prefix state, for `status`. + pub fn states_for_status(&self, now: Instant) -> Vec<(IpNet, State, u64)> { + let mut v: Vec<_> = self + .diverts + .iter() + .map(|(p, d)| { + ( + *p, + d.machine.state, + now.saturating_duration_since(d.since).as_secs(), + ) + }) + .collect(); + v.sort_by_key(|(p, _, _)| p.to_string()); + v + } + + /// Report the return path's condition. + /// + /// Going down while diverted is the most urgent event in the daemon: clean + /// traffic is being sent into a tunnel that goes nowhere, which is an + /// outage for the whole prefix rather than just the victim. + fn apply_return_path(&mut self, path: ReturnPath, now: Instant) { + self.return_path_up = path.allows_engage(); + // Only a *confirmed* failure undoes a working diversion. A path we + // merely cannot read blocks new engagements and nothing more. + if path.demands_return() { + for (prefix, d) in self.diverts.iter_mut() { + if d.machine.state == State::Diverted { + tracing::error!( + %prefix, + "return path is down while diverted; restoring transit immediately" + ); + d.machine.step(Event::ReturnPathDown); + d.since = now; + } + } + } + } + + /// Report the current quorum count for a prefix. + /// + /// Reached from [`TierModule::observe`], which the reconciler drives once + /// per tick for every divert path it holds. Nothing else calls it: the + /// module cannot ask the speaker anything itself, by design. + pub fn set_quorum(&mut self, prefix: IpNet, confirmed: u8, settled_negative: u8, now: Instant) { + let Some(d) = self.diverts.get_mut(&prefix) else { + return; + }; + let event = if confirmed >= self.quorum_need { + Event::QuorumMet + } else if self.quorum_of.saturating_sub(settled_negative) < self.quorum_need { + // Enough peers have settled negative answers that the quorum can no + // longer be reached even if every outstanding one turns positive. + // Waiting out the deadline would tell us nothing and delay the alert. + Event::QuorumImpossible + } else if d.machine.state == State::Diverted || d.machine.state == State::Settling { + Event::QuorumLost + } else { + return; + }; + let before = d.machine.state; + d.machine.step(event); + if d.machine.state != before { + d.since = now; + } + } + + /// Whether a prefix may be diverted at all. + fn is_divertible(&self, prefix: IpNet) -> bool { + self.divertible.contains(&prefix) + } + + /// Drive dwell timers. + fn advance_timers(&mut self, now: Instant) { + for d in self.diverts.values_mut() { + let dwell = match d.machine.state { + State::Settling => Some(self.settle), + State::Restoring => Some(self.return_settle), + State::Draining => Some(self.return_settle), + _ => None, + }; + if let Some(dwell) = dwell + && now.saturating_duration_since(d.since) >= dwell + { + d.machine.step(Event::Settled); + d.since = now; + } + } + } +} + +impl TierModule for ScrubDivertModule { + fn tier(&self) -> Tier { + Tier::Divert + } + + fn set_return_path(&mut self, path: ReturnPath, now: Instant) { + self.apply_return_path(path, now); + } + + /// Turn advertisement evidence into a quorum verdict. + /// + /// This is the only way the sequence learns whether the scrubber took the + /// path, and therefore the only thing that can move a prefix out of + /// `Announcing`. Without it the tier announced to the scrubber and waited + /// there forever: transit kept carrying the prefix and nothing was ever + /// scrubbed, with no error anywhere to say so. + fn observe(&mut self, key: &PathKey, evidence: PathEvidence, now: Instant) { + // Only the scrubber announcement carries quorum meaning. The signal + // route is a local instruction to our own edge, not something a + // provider's reflectors have an opinion about. + if key.tier != Tier::Divert { + return; + } + // No evidence is not negative evidence. Leave the sequence where it is. + if evidence.is_empty() { + return; + } + self.set_quorum( + key.prefix, + evidence.confirmed, + evidence.settled_negative, + now, + ); + } + + fn progress(&self, now: Instant) -> Vec { + self.states_for_status(now) + .into_iter() + .map(|(prefix, state, secs)| { + let note = match state { + // The one an operator must not have to infer: announced to + // the scrubber, transit still carrying, waiting on evidence + // that may never arrive. + State::Announcing => format!( + " — waiting for {} of {} reflectors; transit still carrying", + self.quorum_need, self.quorum_of + ), + State::Settling => " — quorum met, dwelling before suppressing transit".into(), + State::Diverted => " — transit suppressed, traffic scrubbed".into(), + State::Restoring => " — transit restored, dwelling".into(), + State::Draining => " — dwelling before withdrawing from the scrubber".into(), + State::Idle => String::new(), + }; + format!("divert {prefix} {} for {secs}s{note}", state.as_str()) + }) + .collect() + } + + fn configure(&mut self, section: &ModuleSection) -> Result<(), String> { + reject_unknown(section, KNOWN)?; + + for (v, line) in directives(section, "divertible-prefix") { + self.divertible.push( + v.parse::() + .map_err(|e| format!("line {line}: `divertible-prefix`: {e}"))?, + ); + } + + if let Some(d) = section + .directives + .iter() + .find(|d| d.key == "scrubber-quorum") + { + // `scrubber-quorum 2 of 3` + match d.args.as_slice() { + [need, of_kw, of] if of_kw == "of" => { + self.quorum_need = need + .parse() + .map_err(|_| format!("line {}: quorum needs a number", d.line))?; + self.quorum_of = of + .parse() + .map_err(|_| format!("line {}: quorum needs a number", d.line))?; + } + _ => { + return Err(format!( + "line {}: `scrubber-quorum` is written ` of `, for example `2 of 3`", + d.line + )); + } + } + if self.quorum_need == 0 { + return Err(format!( + "line {}: a quorum of zero would suppress transit with no evidence at all", + d.line + )); + } + if self.quorum_need > self.quorum_of { + return Err(format!( + "line {}: a quorum of {} of {} can never be met", + d.line, self.quorum_need, self.quorum_of + )); + } + if self.quorum_of >= 3 && self.quorum_need < 2 { + return Err(format!( + "line {}: with {} reflectors a quorum of one is not a quorum; \ + a single misbehaving peer would be enough to suppress transit", + d.line, self.quorum_of + )); + } + } + + if let Some((v, line)) = directive(section, "divert-settle-time") { + self.settle = + parse_duration(v).map_err(|e| format!("line {line}: `divert-settle-time`: {e}"))?; + } + if let Some((v, line)) = directive(section, "return-settle-time") { + self.return_settle = + parse_duration(v).map_err(|e| format!("line {line}: `return-settle-time`: {e}"))?; + } + // Checked unconditionally, after both directives have been parsed. + // Nesting it inside the `return-settle-time` branch meant raising only + // `divert-settle-time` past the 60s default slipped through, leaving the + // teardown dwell shorter than the engage dwell. + if self.return_settle <= self.settle { + let line = directive(section, "return-settle-time") + .or_else(|| directive(section, "divert-settle-time")) + .map_or(section.line, |(_, l)| l); + return Err(format!( + "line {line}: `return-settle-time` ({}s) must exceed `divert-settle-time` \ + ({}s); an overlap costs asymmetric routing, a gap costs an outage", + self.return_settle.as_secs(), + self.settle.as_secs() + )); + } + if let Some((v, line)) = directive(section, "min-divert-time") { + self.min_divert = + parse_duration(v).map_err(|e| format!("line {line}: `min-divert-time`: {e}"))?; + } + if let Some((v, _)) = directive(section, "return-tunnel") { + self.tunnel = Some(v.to_string()); + } + if let Some((v, _)) = directive(section, "return-probe-target") { + self.probe_target = Some(v.to_string()); + } + Ok(()) + } + + fn adopt(&mut self, existing: Box + '_>, now: Instant) { + for key in existing { + if key.tier != Tier::Divert { + continue; + } + // A path found at startup means a previous incarnation had at least + // announced to the scrubber. `Announcing` is the earliest state + // consistent with that, and it is what we resume at: the RIB cannot + // say whether a quorum was ever reached, and assuming one would put + // the signal up — suppressing transit for a prefix the scrubber may + // never have accepted. + // + // Any `divert-signal` path a previous run left behind is therefore + // not claimed here, so the reconciler withdraws it and transit + // comes back. That is the safe direction: filterframe's own death + // must degrade toward normal routing. + self.diverts.entry(key.prefix).or_insert(Divert { + machine: Machine::at(State::Announcing), + since: now, + engaged_since: None, + }); + } + } + + fn journal(&self) -> Option { + let entries: Vec<_> = self + .snapshot() + .into_iter() + .map(|(p, st)| (p.to_string(), st)) + .collect(); + if entries.is_empty() { + return None; + } + serde_json::to_string(&entries).ok() + } + + fn restore_journal(&mut self, blob: &str, now: Instant) { + let Ok(entries) = serde_json::from_str::>(blob) else { + tracing::error!( + "the divert journal is unreadable; resuming from what the speaker holds instead" + ); + return; + }; + let parsed: Vec<(IpNet, State)> = entries + .into_iter() + .filter_map(|(p, st)| match p.parse::() { + Ok(net) => Some((net, st)), + Err(e) => { + tracing::warn!(prefix = %p, error = %e, "skipping a malformed journal entry"); + None + } + }) + .collect(); + self.restore(parsed, now); + } + + fn hold_only(&mut self, now: Instant) -> TierOutcome { + // Timers keep running: a teardown already under way must finish rather + // than freeze halfway. What does *not* happen is any new release for + // lack of demand — this path has no desired set to be absent from, which + // is the whole point of it taking none. + self.advance_timers(now); + + let mut outcome = TierOutcome::default(); + for (prefix, d) in &self.diverts { + if d.machine.state.scrubber_up() { + outcome.effective.insert(PathKey { + prefix: *prefix, + tier: Tier::Divert, + }); + } + if d.machine.state.signal_up() { + outcome.effective.insert(PathKey { + prefix: *prefix, + tier: Tier::DivertSignal, + }); + } + } + outcome + } + + fn refine(&mut self, desired: &[Engagement], now: Instant) -> TierOutcome { + let mut outcome = TierOutcome::default(); + + let wanted: BTreeSet = desired + .iter() + .filter(|e| e.tier == Tier::Divert) + .map(|e| e.prefix) + .collect(); + + for prefix in &wanted { + if !self.is_divertible(*prefix) { + // Diverting a prefix the scrubbing contract does not cover + // leaves it attracting nothing while transit is suppressed — + // a black hole built by hand. + outcome.refused.push(( + PathKey { + prefix: *prefix, + tier: Tier::Divert, + }, + Refusal::NotDivertible(prefix.to_string()), + )); + continue; + } + if !self.return_path_up { + // Not a failure: the demand stays, and the divert proceeds once + // the return path recovers. "We cannot divert" must never mean + // "we do nothing" — the fast tier picks it up meanwhile. + outcome.refused.push(( + PathKey { + prefix: *prefix, + tier: Tier::Divert, + }, + Refusal::ReturnPathDown, + )); + continue; + } + let entry = self.diverts.entry(*prefix).or_insert(Divert { + machine: Machine::new(), + since: now, + engaged_since: None, + }); + if entry.machine.state == State::Idle { + entry.machine.step(Event::Demanded); + entry.since = now; + } else if matches!(entry.machine.state, State::Restoring | State::Draining) { + // Re-demanded mid-teardown: re-engage rather than completing + // the teardown and starting a whole new cycle. + entry.machine.step(Event::Demanded); + entry.since = now; + } + } + + // Release anything no longer wanted, subject to the floor. + // + // Gated on the desired set and the floor, and on nothing else. + // Reaching `refine` means the view was fresh, so a prefix absent from + // `wanted` is one the policy engine positively does not want — and that + // is just as true of a diversion inherited at startup as of one this + // process engaged. + // + // There used to be a `demanded` flag in the way here, which `adopt` and + // `restore` set false and only the engage path above set true. An + // inherited diversion therefore never qualified and stood forever, with + // no `max-lifetime` ceiling in this module to catch it. + let to_release: Vec = self + .diverts + .iter() + .filter(|(p, d)| { + !wanted.contains(*p) + && d.engaged_since + .is_none_or(|t| now.saturating_duration_since(t) >= self.min_divert) + }) + .map(|(p, _)| *p) + .collect(); + for prefix in to_release { + if let Some(d) = self.diverts.get_mut(&prefix) { + d.machine.step(Event::Released); + d.since = now; + } + } + + self.advance_timers(now); + + // Record when a prefix became diverted, for the floor. + for d in self.diverts.values_mut() { + if d.machine.state == State::Diverted && d.engaged_since.is_none() { + d.engaged_since = Some(now); + } + if d.machine.state == State::Idle { + d.engaged_since = None; + } + } + + self.diverts.retain(|_, d| d.machine.state != State::Idle); + + for (prefix, d) in &self.diverts { + if d.machine.state.scrubber_up() { + outcome.effective.insert(PathKey { + prefix: *prefix, + tier: Tier::Divert, + }); + } + if d.machine.state.signal_up() { + outcome.effective.insert(PathKey { + prefix: *prefix, + tier: Tier::DivertSignal, + }); + } + } + outcome.refused.sort_by(|a, b| a.0.cmp(&b.0)); + outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + use filterframe_common::config::RawDirective; + use std::collections::BTreeSet; + + fn section(pairs: &[(&str, &str)]) -> ModuleSection { + ModuleSection { + name: "scrub-divert".into(), + directives: pairs + .iter() + .enumerate() + .map(|(i, (k, v))| RawDirective { + key: (*k).to_string(), + args: v.split(' ').map(String::from).collect(), + line: i + 1, + }) + .collect(), + line: 0, + } + } + + fn module(pairs: &[(&str, &str)]) -> ScrubDivertModule { + let mut m = ScrubDivertModule::new(); + m.configure(§ion(pairs)) + .expect("test config must apply"); + m + } + + /// The default module, with the one prefix these tests divert. + fn divertible() -> ScrubDivertModule { + module(&[("divertible-prefix", "198.51.100.0/24")]) + } + + fn want(prefix: &str) -> Vec { + vec![Engagement { + tier: Tier::Divert, + prefix: prefix.parse().unwrap(), + demands: BTreeSet::from(["m1".to_string()]), + }] + } + + fn key(prefix: &str) -> PathKey { + PathKey { + prefix: prefix.parse().unwrap(), + tier: Tier::Divert, + } + } + + fn met() -> PathEvidence { + PathEvidence { + confirmed: 2, + settled_negative: 0, + } + } + + fn state_of(m: &ScrubDivertModule, now: Instant) -> Option { + m.states_for_status(now).first().map(|(_, s, _)| *s) + } + + // -- the confirmation wire ---------------------------------------------- + + /// **The sequence must be able to finish.** Nothing used to drive the + /// quorum, so a demanded prefix announced to the scrubber and then waited in + /// `Announcing` forever: transit kept carrying it and nothing was scrubbed. + #[test] + fn a_confirmed_quorum_carries_the_sequence_through_to_diverted() { + let t0 = Instant::now(); + let mut m = divertible(); + + m.refine(&want("198.51.100.0/24"), t0); + assert_eq!(state_of(&m, t0), Some(State::Announcing)); + + m.observe(&key("198.51.100.0/24"), met(), t0); + assert_eq!( + state_of(&m, t0), + Some(State::Settling), + "a quorum must start the settle dwell" + ); + + // The dwell is not decoration: the signal must not rise before it ends. + let mid = t0 + DEFAULT_SETTLE - Duration::from_secs(1); + let out = m.refine(&want("198.51.100.0/24"), mid); + assert_eq!(state_of(&m, mid), Some(State::Settling)); + assert!( + !out.effective.contains(&PathKey { + prefix: "198.51.100.0/24".parse().unwrap(), + tier: Tier::DivertSignal, + }), + "transit suppressed before the dwell ended" + ); + + let after = t0 + DEFAULT_SETTLE + Duration::from_secs(1); + let out = m.refine(&want("198.51.100.0/24"), after); + assert_eq!(state_of(&m, after), Some(State::Diverted)); + assert!(out.effective.contains(&PathKey { + prefix: "198.51.100.0/24".parse().unwrap(), + tier: Tier::DivertSignal, + })); + } + + /// No evidence is not negative evidence. A speaker that cannot answer must + /// leave the sequence where it is rather than unwinding it. + #[test] + fn absent_evidence_leaves_the_sequence_alone() { + let t0 = Instant::now(); + let mut m = divertible(); + m.refine(&want("198.51.100.0/24"), t0); + + m.observe(&key("198.51.100.0/24"), PathEvidence::default(), t0); + assert_eq!(state_of(&m, t0), Some(State::Announcing)); + } + + /// Enough settled negatives that the quorum cannot be reached unwinds at + /// once rather than burning a deadline. Transit was never touched. + #[test] + fn an_unreachable_quorum_unwinds_immediately() { + let t0 = Instant::now(); + let mut m = divertible(); + m.refine(&want("198.51.100.0/24"), t0); + + m.observe( + &key("198.51.100.0/24"), + PathEvidence { + confirmed: 0, + settled_negative: 2, + }, + t0, + ); + assert_eq!(state_of(&m, t0), Some(State::Idle)); + } + + /// Evidence about the signal route says nothing about the scrubber's + /// reflectors, and must not be mistaken for it. + #[test] + fn signal_route_evidence_is_not_quorum_evidence() { + let t0 = Instant::now(); + let mut m = divertible(); + m.refine(&want("198.51.100.0/24"), t0); + + m.observe( + &PathKey { + prefix: "198.51.100.0/24".parse().unwrap(), + tier: Tier::DivertSignal, + }, + met(), + t0, + ); + assert_eq!(state_of(&m, t0), Some(State::Announcing)); + } + + // -- inheritance and release -------------------------------------------- + + /// A path in the RIB says the scrubber was announced, and nothing more. + /// Assuming a quorum would suppress transit on no evidence at all. + #[test] + fn an_adopted_path_resumes_before_the_signal_not_after_it() { + let t0 = Instant::now(); + let mut m = divertible(); + m.adopt(Box::new([key("198.51.100.0/24")].into_iter()), t0); + + assert_eq!(state_of(&m, t0), Some(State::Announcing)); + let out = m.hold_only(t0); + assert!( + !out.effective.contains(&PathKey { + prefix: "198.51.100.0/24".parse().unwrap(), + tier: Tier::DivertSignal, + }), + "an inherited path must not put the signal up" + ); + } + + /// **A diversion nobody wants must end.** The release path used to be gated + /// on a `demanded` flag that `adopt` and `restore` set false and nothing + /// ever set true, so an inherited diversion stood forever — and this module + /// has no `max-lifetime` ceiling to catch it. + #[test] + fn an_inherited_diversion_nobody_demands_is_released() { + let t0 = Instant::now(); + let mut m = divertible(); + m.adopt(Box::new([key("198.51.100.0/24")].into_iter()), t0); + assert!(state_of(&m, t0).is_some(), "setup failed"); + + // A fresh view that demands nothing is a positive answer, not silence. + for secs in (1..=600).step_by(10) { + let out = m.refine(&[], t0 + Duration::from_secs(secs)); + if out.effective.is_empty() && state_of(&m, t0).is_none() { + return; + } + } + panic!( + "still held after 600s of zero demand: {:?}", + m.states_for_status(t0 + Duration::from_secs(600)) + ); + } + + /// The stale path is the exception: no demand can be seen, so nothing may be + /// released for the lack of it. + #[test] + fn hold_only_never_releases_an_inherited_diversion() { + let t0 = Instant::now(); + let mut m = divertible(); + m.adopt(Box::new([key("198.51.100.0/24")].into_iter()), t0); + + for secs in (1..=600).step_by(10) { + let out = m.hold_only(t0 + Duration::from_secs(secs)); + assert!(!out.effective.is_empty(), "released at {secs}s"); + } + } + + /// The floor exists so a blipping detector cannot move a whole prefix twice. + #[test] + fn a_diversion_inside_the_floor_is_not_released() { + let t0 = Instant::now(); + let mut m = module(&[ + ("divertible-prefix", "198.51.100.0/24"), + ("min-divert-time", "10m"), + ]); + m.refine(&want("198.51.100.0/24"), t0); + m.observe(&key("198.51.100.0/24"), met(), t0); + let up = t0 + DEFAULT_SETTLE + Duration::from_secs(1); + m.refine(&want("198.51.100.0/24"), up); + assert_eq!(state_of(&m, up), Some(State::Diverted), "setup failed"); + + let early = up + Duration::from_secs(60); + m.refine(&[], early); + assert_eq!( + state_of(&m, early), + Some(State::Diverted), + "released inside the min-divert-time floor" + ); + } + + // -- gating -------------------------------------------------------------- + + #[test] + fn a_prefix_outside_the_contract_is_refused() { + let mut m = divertible(); + let out = m.refine(&want("203.0.113.0/24"), Instant::now()); + assert!(out.effective.is_empty()); + assert!(matches!(out.refused[0].1, Refusal::NotDivertible(_))); + } + + #[test] + fn a_dead_return_path_blocks_engaging() { + let t0 = Instant::now(); + let mut m = divertible(); + m.set_return_path(ReturnPath::Down, t0); + let out = m.refine(&want("198.51.100.0/24"), t0); + assert!(out.effective.is_empty()); + assert!(matches!(out.refused[0].1, Refusal::ReturnPathDown)); + } + + /// `Blocked` blocks a new diversion and undoes nothing: an unreadable sysfs + /// file is a bad reason to move a customer's prefix across the Internet. + #[test] + fn a_blocked_return_path_does_not_undo_a_working_diversion() { + let t0 = Instant::now(); + let mut m = divertible(); + m.refine(&want("198.51.100.0/24"), t0); + m.observe(&key("198.51.100.0/24"), met(), t0); + let up = t0 + DEFAULT_SETTLE + Duration::from_secs(1); + m.refine(&want("198.51.100.0/24"), up); + assert_eq!(state_of(&m, up), Some(State::Diverted), "setup failed"); + + m.set_return_path(ReturnPath::Blocked, up); + assert_eq!(state_of(&m, up), Some(State::Diverted)); + } + + // -- configuration ------------------------------------------------------- + + /// The ordering rule must hold against the *default* on either side. The + /// check used to be nested inside the `return-settle-time` branch, so + /// raising only `divert-settle-time` past the 60s default slipped through + /// and left the teardown dwell shorter than the engage dwell. + #[test] + fn a_settle_time_above_the_default_return_settle_is_refused() { + let mut m = ScrubDivertModule::new(); + let e = m + .configure(§ion(&[("divert-settle-time", "90s")])) + .unwrap_err(); + assert!(e.contains("must exceed"), "{e}"); + assert!(e.contains("line 1"), "should cite the line it saw: {e}"); + } + + #[test] + fn a_return_settle_below_the_engage_dwell_is_refused() { + let mut m = ScrubDivertModule::new(); + let e = m + .configure(§ion(&[ + ("divert-settle-time", "60s"), + ("return-settle-time", "30s"), + ])) + .unwrap_err(); + assert!(e.contains("must exceed"), "{e}"); + } + + #[test] + fn the_documented_defaults_satisfy_their_own_ordering_rule() { + let m = ScrubDivertModule::new(); + assert!(m.return_settle > m.settle); + } + + #[test] + fn a_quorum_of_zero_is_refused() { + let mut m = ScrubDivertModule::new(); + let e = m + .configure(§ion(&[("scrubber-quorum", "0 of 3")])) + .unwrap_err(); + assert!(e.contains("no evidence at all"), "{e}"); + } + + #[test] + fn an_unmeetable_quorum_is_refused() { + let mut m = ScrubDivertModule::new(); + assert!( + m.configure(§ion(&[("scrubber-quorum", "4 of 3")])) + .is_err() + ); + } + + #[test] + fn an_unknown_directive_is_refused_with_its_line() { + let mut m = ScrubDivertModule::new(); + let e = m.configure(§ion(&[("nonsense", "1")])).unwrap_err(); + assert!(e.contains("line 1"), "{e}"); + assert!(e.contains("nonsense"), "{e}"); + } + + // -- reporting ----------------------------------------------------------- + + /// A prefix announced to the scrubber but not diverted looks like a finished + /// engagement from outside the module. It has to say so itself. + #[test] + fn progress_names_the_state_and_says_transit_is_still_carrying() { + let t0 = Instant::now(); + let mut m = divertible(); + m.refine(&want("198.51.100.0/24"), t0); + + let lines = m.progress(t0); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("announcing"), "{:?}", lines[0]); + assert!( + lines[0].contains("transit still carrying"), + "{:?}", + lines[0] + ); + } + + #[test] + fn a_journal_round_trips_through_its_serialised_form() { + let t0 = Instant::now(); + let mut m = divertible(); + m.refine(&want("198.51.100.0/24"), t0); + m.observe(&key("198.51.100.0/24"), met(), t0); + + let blob = m.journal().expect("a live sequence must journal"); + let mut fresh = divertible(); + fresh.restore_journal(&blob, t0); + assert!( + state_of(&fresh, t0).is_some(), + "the journal did not restore anything" + ); + } +} diff --git a/crates/modules/scrub-divert/src/machine.rs b/crates/modules/scrub-divert/src/machine.rs new file mode 100644 index 0000000..61f9145 --- /dev/null +++ b/crates/modules/scrub-divert/src/machine.rs @@ -0,0 +1,717 @@ +//! The divert sequence, as a pure state machine. +//! +//! No async, no I/O, no clock. `step(state, event) -> (state, actions)` and +//! nothing else. That is deliberate and it is the whole testing strategy: the +//! safety property can be checked by **exhaustively enumerating every reachable +//! state against every event**, including a crash at each point, which is a far +//! stronger argument than sampling a live system could ever give. +//! +//! # The invariant +//! +//! ```text +//! signal_up ⟹ scrubber_up +//! ``` +//! +//! filterframe never withdraws the protected prefix — it does not announce it. +//! The edge announces it unconditionally, and filterframe announces a *signal* +//! route whose presence makes the edge's export policy stop advertising the +//! prefix to transit. So: +//! +//! - `signal_up` means transit is **not** carrying the prefix. +//! - `scrubber_up` means the scrubbing provider is. +//! +//! If the signal is up and the scrubber is not, the prefix is announced by +//! nobody and the network is dark. Every state below satisfies the implication, +//! and [`Machine::invariant_holds`] is asserted after every transition in the +//! test suite. +//! +//! Announced to *both* is always safe: traffic splits, some of it is scrubbed, +//! none of it is lost. Every failure path therefore stalls in that direction by +//! construction rather than by care. +//! +//! # Why the order is what it is +//! +//! Engage: scrubber first, confirm a quorum, dwell, then signal. Transit is +//! untouched until the scrubbing path is established, so an abort at any point +//! before the signal costs nothing. +//! +//! Disengage: restore transit first, dwell, then withdraw from the scrubber. +//! Reachability before cleanliness. A stall here leaves the prefix announced +//! twice, which is untidy and safe. + +use serde::{Deserialize, Serialize}; + +/// Where one prefix is in the sequence. +/// +/// Serialised into the journal, so the names are a persisted format: renaming a +/// variant breaks recovery for a daemon that crashed under the old build. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum State { + /// Nothing announced. Transit is carrying the prefix. + Idle, + /// Announcing to the scrubber, waiting for a quorum of reflectors. + /// + /// Transit is untouched, so aborting from here is free. + Announcing, + /// Quorum reached; dwelling before signalling the edge. + /// + /// The dwell is not decoration. A quorum says the reflectors accepted the + /// path; it says nothing about the provider's own propagation, and + /// signalling the instant a quorum appears is how a real gap gets created + /// that no adjacency query can see. + Settling, + /// Signal up, transit suppressed, traffic scrubbed. The working state. + Diverted, + /// Tearing down: the signal has been withdrawn, transit is being restored. + Restoring, + /// Transit restored; dwelling before withdrawing from the scrubber. + /// + /// Longer than the engage dwell on purpose. An overlap costs a little + /// asymmetric routing; a gap costs an outage. + Draining, +} + +impl State { + /// Whether filterframe is announcing to the scrubbing provider. + pub fn scrubber_up(self) -> bool { + matches!( + self, + Self::Announcing | Self::Settling | Self::Diverted | Self::Restoring | Self::Draining + ) + } + + /// Whether the divert signal is up — which means transit is **not** + /// carrying the prefix. + pub fn signal_up(self) -> bool { + matches!(self, Self::Diverted) + } + + /// Whether transit is carrying the prefix. + pub fn transit_up(self) -> bool { + !self.signal_up() + } + + /// Whether this is a resting state rather than a transition. + /// + /// Used by recovery: a daemon that died mid-transition resumes, and one + /// that died at rest simply continues. + pub fn is_settled(self) -> bool { + matches!(self, Self::Idle | Self::Diverted) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Announcing => "announcing", + Self::Settling => "settling", + Self::Diverted => "diverted", + Self::Restoring => "restoring", + Self::Draining => "draining", + } + } +} + +/// What happened. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Event { + /// The policy engine wants this prefix diverted. + Demanded, + /// It no longer does, and the module's damping has already agreed. + Released, + /// Enough scrubber reflectors confirm the announcement. + QuorumMet, + /// Not enough do, and waiting cannot change that — the remaining peers + /// have settled answers. + QuorumImpossible, + /// A quorum that had been met no longer is. + QuorumLost, + /// The current dwell has elapsed. + Settled, + /// The return path is not usable. + /// + /// While diverted this is the most urgent condition in the daemon: clean + /// traffic is being sent into a tunnel that goes nowhere, which is a hard + /// outage for the whole prefix rather than just the victim. + ReturnPathDown, +} + +/// Something to do to the world. +/// +/// Emitted, never performed. The caller executes them and reports back through +/// the next event, which is what keeps this function pure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// Start announcing the covering prefix to the scrubbing provider. + AnnounceScrubber, + /// Stop announcing to the scrubbing provider. + WithdrawScrubber, + /// Raise the divert signal, which suppresses the prefix toward transit. + RaiseSignal, + /// Drop the divert signal, which restores the prefix toward transit. + DropSignal, + /// Begin the engage dwell. + StartSettleTimer, + /// Begin the teardown dwell. + StartDrainTimer, +} + +/// One prefix's position in the sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Machine { + pub state: State, +} + +impl Default for Machine { + fn default() -> Self { + Self { state: State::Idle } + } +} + +impl Machine { + pub fn new() -> Self { + Self::default() + } + + pub fn at(state: State) -> Self { + Self { state } + } + + /// The safety property, checkable at any moment. + /// + /// If the signal is up, the scrubber must be too — otherwise the prefix is + /// announced by nobody. + /// + /// **What this does and does not prove.** [`State::scrubber_up`] means "we + /// are announcing to the scrubber", not "the scrubber accepted it", so this + /// predicate rules out the structural version of the outage and not the + /// operational one. The operational half is carried by the transitions + /// instead: the signal is raised only from `Settling`, which is reachable + /// only through `QuorumMet` plus a settle dwell. Any arm that reaches + /// `Diverted` by another route would satisfy `invariant_holds` and still be + /// a bug, which is why re-demand mid-teardown resumes at `Announcing`. + pub fn invariant_holds(&self) -> bool { + !self.state.signal_up() || self.state.scrubber_up() + } + + /// Apply an event. + /// + /// Every arm is written so that the resulting state satisfies the + /// invariant. There is no arm that raises the signal without the scrubber + /// already announced and confirmed, and none that withdraws the scrubber + /// while the signal is up. + pub fn step(&mut self, event: Event) -> Vec { + use Action::*; + use Event::*; + use State::*; + + let (next, actions) = match (self.state, event) { + // -- engaging ---------------------------------------------------- + (Idle, Demanded) => (Announcing, vec![AnnounceScrubber]), + (Announcing, QuorumMet) => (Settling, vec![StartSettleTimer]), + (Settling, Settled) => (Diverted, vec![RaiseSignal]), + + // A quorum that cannot be reached is not worth waiting out. Transit + // was never touched, so unwinding costs nothing. + (Announcing, QuorumImpossible) => (Idle, vec![WithdrawScrubber]), + + // Losing the quorum before the signal goes up is the same story. + (Announcing | Settling, QuorumLost) => (Idle, vec![WithdrawScrubber]), + + // Released before the signal: abort cleanly. + (Announcing | Settling, Released) => (Idle, vec![WithdrawScrubber]), + + // -- the working state ------------------------------------------- + // + // Losing the quorum *while diverted* is the dangerous one: transit + // is already suppressed. Reachability beats cleanliness, so the + // signal comes down immediately and traffic returns dirty rather + // than not at all. + (Diverted, QuorumLost) => (Restoring, vec![DropSignal, StartDrainTimer]), + + // The return path being dead while diverted is an outage for the + // whole prefix, not just the victim. Same response, same urgency. + (Diverted, ReturnPathDown) => (Restoring, vec![DropSignal, StartDrainTimer]), + + (Diverted, Released) => (Restoring, vec![DropSignal, StartDrainTimer]), + + // -- tearing down ------------------------------------------------ + (Restoring, Settled) => (Draining, vec![]), + (Draining, Settled) => (Idle, vec![WithdrawScrubber]), + + // Re-demanded mid-teardown. The scrubber announcement is still up, + // so there is no need to withdraw it and start a whole new cycle — + // but the signal must **not** go straight back up. We are in a + // teardown because the quorum was lost or the return path failed, + // and neither of those is undone by the demand returning. Resuming + // at `Announcing` keeps the scrubber path and re-runs the + // confirmation and the settle dwell before transit is suppressed + // again. + // + // 2026-08: raising the signal directly from here re-suppressed + // transit with a quorum that was known to be lost, which is the + // prefix-announced-by-nobody outage this module exists to prevent. + (Restoring | Draining, Demanded) => (Announcing, vec![AnnounceScrubber]), + + // -- everything else --------------------------------------------- + // + // No-ops rather than errors. Events arrive from timers and from a + // polling loop, so a duplicate or a late one is normal; treating it + // as a fault would make ordinary operation look like a bug. + _ => (self.state, vec![]), + }; + + self.state = next; + debug_assert!( + self.invariant_holds(), + "transition {:?} + {event:?} -> {next:?} broke the invariant", + self.state + ); + actions + } + + /// Where to resume after a crash. + /// + /// The journal records intent, not truth: it says which step was being + /// attempted, and the world may or may not reflect it. Resuming therefore + /// picks the state that preserves reachability regardless of what actually + /// landed. + /// + /// A transition state resumes as though the step had **not** completed, + /// because re-doing an idempotent announce is free and skipping one is not. + /// `Diverted` is treated the same way: see the arm below for why a + /// journaled diversion is re-confirmed rather than trusted. + pub fn recover(journaled: State) -> Self { + let state = match journaled { + // Mid-engage: transit was never suppressed, so unwinding to Idle is + // safe and the next tick will re-engage if the demand is still + // there. Cheaper than resuming a sequence whose quorum evidence is + // now stale — and quorum evidence *is* stale after a restart, + // because the sessions that gave it are gone. + State::Announcing | State::Settling => State::Announcing, + + // Mid-teardown: the signal may or may not be down. Resuming at + // Restoring re-drops it, which is idempotent, and re-runs the dwell + // before the scrubber withdrawal. Never resume at Draining: that + // would shorten the dwell protecting the gap. + State::Restoring | State::Draining => State::Restoring, + + State::Idle => State::Idle, + + // **Not** resumed as itself. A journaled `Diverted` says the signal + // was up when the journal was last written; it does not say the + // scrubber still has the path, and the quorum evidence that + // justified raising the signal died with the sessions that gave it. + // Resuming at `Announcing` re-asserts the scrubber, lets the signal + // route be withdrawn (which restores transit — the safe direction), + // and re-confirms before suppressing transit again. + // + // This is also what makes the journal's write ordering non + // load-bearing: an entry that over-states progress and one that + // under-states it now resolve the same way. + State::Diverted => State::Announcing, + }; + Self { state } + } + + /// Actions to re-assert on resume, so the world matches the recovered + /// state whatever the crash left behind. + pub fn resume_actions(&self) -> Vec { + match self.state { + State::Idle => vec![Action::WithdrawScrubber, Action::DropSignal], + State::Announcing => vec![Action::AnnounceScrubber], + State::Settling => vec![Action::AnnounceScrubber, Action::StartSettleTimer], + State::Diverted => vec![Action::AnnounceScrubber, Action::RaiseSignal], + State::Restoring => vec![ + Action::AnnounceScrubber, + Action::DropSignal, + Action::StartDrainTimer, + ], + State::Draining => vec![Action::AnnounceScrubber, Action::DropSignal], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL_STATES: &[State] = &[ + State::Idle, + State::Announcing, + State::Settling, + State::Diverted, + State::Restoring, + State::Draining, + ]; + + const ALL_EVENTS: &[Event] = &[ + Event::Demanded, + Event::Released, + Event::QuorumMet, + Event::QuorumImpossible, + Event::QuorumLost, + Event::Settled, + Event::ReturnPathDown, + ]; + + // -- the safety property, exhaustively ----------------------------------- + + /// Every state satisfies the invariant on its own. + #[test] + fn every_state_is_safe_at_rest() { + for &s in ALL_STATES { + assert!( + Machine::at(s).invariant_holds(), + "{s:?} is announced by nobody" + ); + } + } + + /// **The exhaustive proof.** Every state crossed with every event, and the + /// resulting state must still satisfy the invariant. The graph is small + /// enough to enumerate completely, which is a stronger argument than any + /// amount of sampling. + #[test] + fn no_single_transition_can_break_the_invariant() { + for &s in ALL_STATES { + for &e in ALL_EVENTS { + let mut m = Machine::at(s); + m.step(e); + assert!( + m.invariant_holds(), + "{s:?} + {e:?} -> {:?}: prefix announced by nobody", + m.state + ); + } + } + } + + /// Depth-first over every reachable sequence up to a bound, asserting the + /// invariant after every step. Catches anything a single transition would + /// miss. + #[test] + fn no_reachable_sequence_can_break_the_invariant() { + fn walk(m: Machine, depth: usize, trail: &mut Vec) { + if depth == 0 { + return; + } + for &e in ALL_EVENTS { + let mut next = m; + next.step(e); + trail.push(e); + assert!( + next.invariant_holds(), + "sequence {trail:?} reached {:?}, announced by nobody", + next.state + ); + walk(next, depth - 1, trail); + trail.pop(); + } + } + walk(Machine::new(), 6, &mut Vec::new()); + } + + /// A crash at any point, at any depth, must recover into a safe state — + /// and the actions it re-asserts must themselves be safe. + #[test] + fn a_crash_at_any_state_recovers_safely() { + for &s in ALL_STATES { + let recovered = Machine::recover(s); + assert!( + recovered.invariant_holds(), + "recovering from {s:?} landed in {:?}, announced by nobody", + recovered.state + ); + + // Whatever the crash left behind, the resume actions must never + // raise the signal without also asserting the scrubber. + let actions = recovered.resume_actions(); + if actions.contains(&Action::RaiseSignal) { + assert!( + actions.contains(&Action::AnnounceScrubber), + "recovering from {s:?} would raise the signal without the scrubber" + ); + } + } + } + + /// Crash injection: at every point in a sequence, drop to the journaled + /// state and continue. The invariant must hold throughout. + #[test] + fn crash_injection_at_every_step_stays_safe() { + let sequences: &[&[Event]] = &[ + &[Event::Demanded, Event::QuorumMet, Event::Settled], + &[ + Event::Demanded, + Event::QuorumMet, + Event::Settled, + Event::Released, + Event::Settled, + Event::Settled, + ], + &[Event::Demanded, Event::QuorumImpossible, Event::Demanded], + &[ + Event::Demanded, + Event::QuorumMet, + Event::Settled, + Event::QuorumLost, + ], + &[ + Event::Demanded, + Event::QuorumMet, + Event::Settled, + Event::ReturnPathDown, + Event::Demanded, + ], + ]; + + for seq in sequences { + for crash_at in 0..=seq.len() { + let mut m = Machine::new(); + for (i, &e) in seq.iter().enumerate() { + if i == crash_at { + m = Machine::recover(m.state); + assert!(m.invariant_holds(), "unsafe immediately after recovery"); + } + m.step(e); + assert!( + m.invariant_holds(), + "{seq:?} crashing at {crash_at} reached {:?}, announced by nobody", + m.state + ); + } + } + } + } + + // -- ordering ------------------------------------------------------------ + + /// The engage order: transit is never suppressed before the scrubber is + /// confirmed. + #[test] + fn the_signal_never_rises_before_a_quorum() { + let mut m = Machine::new(); + assert_eq!(m.step(Event::Demanded), vec![Action::AnnounceScrubber]); + assert!(m.state.transit_up(), "transit must still be carrying it"); + + // Settling without a quorum must do nothing. + assert!(m.step(Event::Settled).is_empty()); + assert!(m.state.transit_up()); + + m.step(Event::QuorumMet); + assert!( + m.state.transit_up(), + "a quorum alone must not suppress transit" + ); + + let actions = m.step(Event::Settled); + assert_eq!(actions, vec![Action::RaiseSignal]); + assert_eq!(m.state, State::Diverted); + } + + /// The teardown order: transit is restored before the scrubber is dropped. + #[test] + fn transit_is_restored_before_the_scrubber_is_withdrawn() { + let mut m = Machine::at(State::Diverted); + + let actions = m.step(Event::Released); + assert!(actions.contains(&Action::DropSignal)); + assert!( + !actions.contains(&Action::WithdrawScrubber), + "the scrubber must not be dropped in the same step as the signal" + ); + assert!(m.state.transit_up(), "transit restored first"); + assert!(m.state.scrubber_up(), "and the scrubber still carries it"); + + m.step(Event::Settled); // Restoring -> Draining + assert!(m.state.scrubber_up(), "the dwell must keep the scrubber up"); + + let actions = m.step(Event::Settled); // Draining -> Idle + assert_eq!(actions, vec![Action::WithdrawScrubber]); + assert_eq!(m.state, State::Idle); + } + + /// Aborting before the signal is free, and must leave nothing behind. + #[test] + fn an_impossible_quorum_unwinds_without_touching_transit() { + let mut m = Machine::new(); + m.step(Event::Demanded); + let actions = m.step(Event::QuorumImpossible); + assert_eq!(actions, vec![Action::WithdrawScrubber]); + assert_eq!(m.state, State::Idle); + } + + /// The dangerous case: quorum lost while transit is already suppressed. + /// Traffic must come back dirty rather than not at all. + #[test] + fn losing_quorum_while_diverted_restores_transit_immediately() { + let mut m = Machine::at(State::Diverted); + let actions = m.step(Event::QuorumLost); + assert!(actions.contains(&Action::DropSignal)); + assert!(m.state.transit_up()); + } + + /// A dead return path while diverted is an outage for the whole prefix. + #[test] + fn a_dead_return_path_while_diverted_restores_transit_immediately() { + let mut m = Machine::at(State::Diverted); + let actions = m.step(Event::ReturnPathDown); + assert!(actions.contains(&Action::DropSignal)); + assert!(m.state.transit_up()); + } + + /// A return path that is down while *not* diverted is not an emergency — + /// it only blocks engaging, which the module handles as a gate. + #[test] + fn a_dead_return_path_while_idle_changes_nothing() { + let mut m = Machine::new(); + assert!(m.step(Event::ReturnPathDown).is_empty()); + assert_eq!(m.state, State::Idle); + } + + /// Re-demanded mid-teardown should keep the scrubber path rather than + /// completing the teardown and starting a whole new cycle — but it must not + /// put the signal straight back up, because whatever drove the teardown + /// (lost quorum, dead return path) is not undone by the demand returning. + #[test] + fn a_demand_during_teardown_re_engages_without_a_full_cycle() { + let mut m = Machine::at(State::Diverted); + m.step(Event::Released); + assert_eq!(m.state, State::Restoring); + + let actions = m.step(Event::Demanded); + assert_eq!(actions, vec![Action::AnnounceScrubber]); + assert_eq!(m.state, State::Announcing, "the scrubber path is kept"); + assert!( + m.state.transit_up(), + "transit must not be re-suppressed yet" + ); + } + + /// The property the exhaustive invariant check cannot see: the signal rises + /// only out of `Settling`, which is reachable only via `QuorumMet` and a + /// settle dwell. Any other arm producing `RaiseSignal` would suppress + /// transit on evidence nobody gathered. + #[test] + fn the_signal_only_ever_rises_out_of_settling() { + for &s in ALL_STATES { + for &e in ALL_EVENTS { + let mut m = Machine::at(s); + if m.step(e).contains(&Action::RaiseSignal) { + assert_eq!( + (s, e), + (State::Settling, Event::Settled), + "{s:?} + {e:?} raised the signal without a confirmed quorum \ + and a settle dwell" + ); + } + } + } + } + + /// A teardown driven by a lost quorum must not be undone by the demand + /// coming back: the quorum is still lost. + #[test] + fn a_lost_quorum_is_not_forgiven_by_a_returning_demand() { + let mut m = Machine::at(State::Diverted); + m.step(Event::QuorumLost); + assert_eq!(m.state, State::Restoring); + + m.step(Event::Demanded); + assert!( + m.state.transit_up(), + "transit was re-suppressed with the quorum still lost" + ); + + // It takes a fresh quorum and a fresh dwell to get back. + m.step(Event::QuorumMet); + assert_eq!(m.state, State::Settling); + assert_eq!(m.step(Event::Settled), vec![Action::RaiseSignal]); + assert_eq!(m.state, State::Diverted); + } + + // -- recovery ------------------------------------------------------------ + + /// Quorum evidence does not survive a restart: the sessions that gave it + /// are gone. Resuming mid-engage therefore restarts the confirmation rather + /// than trusting a stale quorum. + #[test] + fn resuming_mid_engage_re_confirms_rather_than_trusting_old_evidence() { + assert_eq!(Machine::recover(State::Settling).state, State::Announcing); + assert_eq!(Machine::recover(State::Announcing).state, State::Announcing); + } + + /// Never resume at Draining: that would shorten the dwell that protects + /// against a gap between transit converging and the scrubber going away. + #[test] + fn resuming_mid_teardown_restarts_the_dwell() { + assert_eq!(Machine::recover(State::Draining).state, State::Restoring); + assert_eq!(Machine::recover(State::Restoring).state, State::Restoring); + } + + #[test] + fn idle_resumes_as_itself() { + assert_eq!(Machine::recover(State::Idle).state, State::Idle); + } + + /// A journaled `Diverted` is re-confirmed, not trusted. The quorum evidence + /// that justified suppressing transit died with the sessions that gave it, + /// and the journal cannot say whether the signal route is still standing. + #[test] + fn a_journaled_diversion_re_confirms_before_suppressing_transit_again() { + let m = Machine::recover(State::Diverted); + assert_eq!(m.state, State::Announcing); + assert!(m.state.transit_up(), "transit must be allowed back first"); + + let a = m.resume_actions(); + assert!( + a.contains(&Action::AnnounceScrubber), + "the scrubber path must be re-asserted" + ); + assert!( + !a.contains(&Action::RaiseSignal), + "the signal must not go back up on a journal entry alone" + ); + } + + /// No recovered state may re-raise the signal, whatever the journal said. + #[test] + fn no_recovery_path_raises_the_signal() { + for &s in ALL_STATES { + let a = Machine::recover(s).resume_actions(); + assert!( + !a.contains(&Action::RaiseSignal), + "recovering from {s:?} would suppress transit without re-confirming" + ); + } + } + + /// Duplicate and late events are normal in a polling loop and must not be + /// treated as faults. + #[test] + fn repeated_events_are_idempotent() { + let mut m = Machine::new(); + m.step(Event::Demanded); + let first = m.state; + assert!(m.step(Event::Demanded).is_empty()); + assert_eq!(m.state, first); + } + + #[test] + fn state_names_are_stable_and_distinct() { + let names: std::collections::BTreeSet<_> = ALL_STATES.iter().map(|s| s.as_str()).collect(); + assert_eq!(names.len(), ALL_STATES.len()); + } + + /// The journal format is a persisted contract: a daemon that crashed under + /// one build must be readable by the next. + #[test] + fn states_round_trip_through_their_serialised_form() { + for &s in ALL_STATES { + let json = serde_json::to_string(&s).unwrap(); + let back: State = serde_json::from_str(&json).unwrap(); + assert_eq!(s, back, "{json} did not round trip"); + } + } +} diff --git a/crates/policy/Cargo.toml b/crates/policy/Cargo.toml new file mode 100644 index 0000000..b34e774 --- /dev/null +++ b/crates/policy/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "filterframe-policy" +description = "Reads the active mitigation set from an external policy engine." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +filterframe-common.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +# The stub server in tests/ needs a listener and the test macro. `rt` and +# `macros` are already on via the workspace features; `net` and `io-util` carry +# the TcpListener and the read/write halves. +tokio = { workspace = true, features = ["rt", "macros", "net", "io-util"] } diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs new file mode 100644 index 0000000..88f5e1e --- /dev/null +++ b/crates/policy/src/lib.rs @@ -0,0 +1,456 @@ +//! Reading the active mitigation set from an external policy engine. +//! +//! The client is deliberately paranoid, because every way this can go wrong +//! produces a *plausible* answer rather than an error. "The policy engine is +//! down" and "no mitigations are active" both look like an empty list, and +//! acting on the second when the first is true withdraws protection during +//! exactly the kind of event that takes a policy engine offline. +//! +//! So every failure here resolves to [`MitigationView::Stale`], which carries +//! no list and therefore cannot produce a teardown. See +//! [`filterframe_common::mitigation`] for why that is a type and not a rule. +//! +//! # Guards, and the bug each one exists for +//! +//! These are not hypothetical. Each corresponds to observed behaviour in the +//! reference policy engine: +//! +//! - **The status filter is a constant.** Unrecognised status tokens are +//! silently dropped server-side, so a typo returns zero rows with HTTP 200 — +//! and zero rows is the input to a teardown. +//! - **`pop` is never sent.** The server accepts the parameter and ignores it, +//! so sending it would create a false belief that results were scoped. +//! Filtering happens in the planner instead. +//! - **`limit` is never zero.** A zero limit returns `has_more: true` with a +//! null cursor, and a `while has_more` loop spins forever. +//! - **`has_more` with no cursor aborts the whole poll.** That is the signature +//! of the bug above, and of any other server-side pagination fault. A +//! truncated page set must never be mistaken for a complete world. +//! - **The cursor must strictly advance**, or pagination is looping. +//! - **Time filters are never sent.** They filter on creation time, which does +//! not change when a mitigation is withdrawn — an incremental poll would +//! learn about new mitigations and never about ones that ended. +//! - **An empty result is confirmed before it is believed.** A status typo, a +//! database hiccup and an auth-mode change all produce "zero rows, HTTP 200". +//! Costs one confirmation window; buys a second independent thing that must +//! also fail before protection is withdrawn. + +pub mod rates; +pub mod wire; + +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use filterframe_common::config::PolicySourceConfig; +use filterframe_common::mitigation::{Mitigation, MitigationView, StaleReason}; + +use rates::RateEnricher; +use wire::{MitigationsPage, SkewSecs}; + +/// Statuses filterframe asks for. +/// +/// A constant, never built from configuration. The server drops unrecognised +/// tokens silently and returns HTTP 200 with zero rows, so a typo here would +/// look exactly like every mitigation having ended. +const ACTIVE_STATUSES: &str = "active,escalated,pending"; + +/// How many mitigations to request per page. +/// +/// Well under the server's cap so the request is never silently clamped, and +/// large enough that a normal incident fits in one page. +const PAGE_SIZE: u32 = 200; + +/// A zero page size makes the server return `has_more` with a null cursor, +/// which is an infinite loop for any client that trusts it. Asserted at compile +/// time rather than in a test, so the constant cannot be edited into that state +/// at all. +const _: () = assert!(PAGE_SIZE >= 1, "PAGE_SIZE must never be zero"); + +/// Ceiling on pages per poll. +/// +/// A backstop against a server-side pagination fault. Twenty pages at +/// [`PAGE_SIZE`] is far more than any real mitigation set, so hitting it means +/// something is wrong and the poll should fail rather than return a partial +/// world. +const MAX_PAGES: usize = 20; + +/// How many consecutive empty results are needed before an empty set is +/// believed, once a non-empty one has been seen. +/// +/// Three, because the cost is one or two ticks of delay releasing a mitigation +/// — which is the safe direction — and the benefit is that a transient +/// server-side fault cannot withdraw protection on its own. +/// +/// **Consecutive means consecutive.** Any poll that fails resets the count (see +/// [`HttpPolicySource::lost`]): a failure is not an empty answer, and letting +/// the two interleave meant three empties spread across a flapping engine +/// satisfied a check whose entire purpose is that one transient fault cannot +/// withdraw protection by itself. +const EMPTY_CONFIRMATIONS: u32 = 3; + +/// Reads the active mitigation set. +/// +/// Implemented as a trait so the reconciler can be driven by a scripted fake in +/// tests. Every pathology in the guard list above is reachable through it, and +/// the highest-value tests in this project are the ones that walk them. +pub trait PolicySource: Send + Sync { + /// Fetch the current view. Never returns an error: a failure *is* a + /// [`MitigationView::Stale`], because the caller must handle it as one. + fn poll(&mut self) -> impl std::future::Future + Send; +} + +/// HTTP client for a prefixd-compatible policy engine. +pub struct HttpPolicySource { + http: reqwest::Client, + base: String, + token: Option, + /// Wall-clock offset of the engine relative to us, in seconds. + skew: SkewSecs, + last_fresh_at: Option, + /// How many consecutive empty results have been seen since the last + /// non-empty one. + empty_streak: u32, + /// Whether a non-empty result has ever been seen. Before that, an empty + /// list is simply the truth and needs no confirming. + seen_non_empty: bool, + /// Fills in per-victim attack rates, within a budget. + rates: RateEnricher, +} + +impl HttpPolicySource { + /// Build a client from configuration and an already-read token. + /// + /// The token is passed in rather than read here so that the file-permission + /// check lives in one place, with the rest of preflight. + pub fn new(cfg: &PolicySourceConfig, token: Option) -> Result { + let mut builder = reqwest::Client::builder() + .timeout(cfg.request_timeout) + .user_agent(concat!("filterframe/", env!("CARGO_PKG_VERSION"))); + + // `ca-file` names the authority the policy engine's certificate must + // chain to. Refused loudly rather than skipped: a CA that is configured + // and not installed reads to an operator as pinned TLS while the + // connection is in fact validating against the system trust store, which + // is the one failure mode a TLS setting must never have. + if let Some(path) = &cfg.ca_file { + let pem = std::fs::read(path).map_err(|e| BuildError::CaFile { + path: path.display().to_string(), + detail: e.to_string(), + })?; + let cert = reqwest::Certificate::from_pem(&pem).map_err(|e| BuildError::CaFile { + path: path.display().to_string(), + detail: format!("{e} (expected a PEM-encoded certificate)"), + })?; + builder = builder.add_root_certificate(cert); + } + + let http = builder + .build() + .map_err(|e| BuildError::Http(e.to_string()))?; + + let base = cfg.url.trim_end_matches('/').to_string(); + Ok(Self { + rates: RateEnricher::new(http.clone(), base.clone(), token.clone()), + http, + base, + token, + skew: 0, + last_fresh_at: None, + empty_streak: 0, + seen_non_empty: false, + }) + } + + /// The engine's clock offset as last measured, for the metric. + pub fn skew_secs(&self) -> SkewSecs { + self.skew + } + + /// Rate lookups spent on the most recent poll, for the metric. + pub fn rate_lookups(&self) -> usize { + self.rates.last_spent() + } + + fn stale(&self, why: StaleReason) -> MitigationView { + MitigationView::Stale { + last_fresh_at: self.last_fresh_at, + why, + } + } + + /// A poll that failed outright, which also **breaks an empty streak**. + /// + /// [`EMPTY_CONFIRMATIONS`] means consecutive empty *answers*, and a poll that + /// never produced an answer is not one of them. Leaving the counter standing + /// let unrelated failures interleave — empty, unreachable, unreachable, + /// empty, unreachable, empty — and three empties spread across a flapping + /// engine then satisfied a check whose entire purpose is that a transient + /// server-side fault cannot withdraw protection by itself. A flapping engine + /// is exactly when that pattern occurs. + /// + /// Used for every failure return; the unconfirmed-empty return deliberately + /// keeps its own count and calls [`Self::stale`] directly. + fn lost(&mut self, why: StaleReason) -> MitigationView { + self.empty_streak = 0; + self.stale(why) + } + + async fn fetch_page( + &self, + cursor: Option<&str>, + ) -> Result<(MitigationsPage, SkewSecs), StaleReason> { + let url = format!("{}/v1/mitigations", self.base); + + // Only these parameters, and `limit` is never zero. See the guard list + // in the module docstring for why `pop`, `start` and `end` are absent. + let mut query: Vec<(&str, String)> = vec![ + ("status", ACTIVE_STATUSES.to_string()), + ("limit", PAGE_SIZE.max(1).to_string()), + ]; + if let Some(c) = cursor { + query.push(("cursor", c.to_string())); + } + + let mut req = self.http.get(&url).query(&query); + if let Some(t) = &self.token { + req = req.bearer_auth(t); + } + + let resp = req + .send() + .await + .map_err(|e| StaleReason::Unreachable(e.to_string()))?; + + let status = resp.status(); + + // Measured before anything else can fail: the header is present on + // error responses too, and skew is worth knowing even on a bad poll. + let skew = measure_skew(resp.headers()); + + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + // The bucket is shared with the dashboard and every detector, so a + // client that retries hard makes the engine unusable for the + // operator trying to look at the incident. + let retry_after = resp + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .map(Duration::from_secs); + return Err(StaleReason::RateLimited { retry_after }); + } + + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + // Silent misconfiguration wearing a network problem's clothes. Name + // the likely cause: the engine rejects bearer tokens outright when + // it is running in credentials auth mode, and no amount of checking + // the token will reveal that. + return Err(StaleReason::Unauthorized(format!( + "HTTP {status}; check the token file, and that the policy engine \ + is running in bearer auth mode rather than credentials mode" + ))); + } + + if !status.is_success() { + return Err(StaleReason::Malformed(format!("HTTP {status}"))); + } + + let body = resp + .text() + .await + .map_err(|e| StaleReason::Malformed(format!("reading body: {e}")))?; + + let page: MitigationsPage = serde_json::from_str(&body).map_err(|e| { + StaleReason::Malformed(format!( + "{e} (received {} bytes of {})", + body.len(), + if body.trim_start().starts_with('<') { + "what looks like HTML — a proxy or error page, not the API" + } else { + "unexpected content" + } + )) + })?; + + Ok((page, skew)) + } +} + +impl PolicySource for HttpPolicySource { + async fn poll(&mut self) -> MitigationView { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + let mut pages = 0usize; + + let now_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + loop { + pages += 1; + if pages > MAX_PAGES { + return self.lost(StaleReason::Pagination(format!( + "more than {MAX_PAGES} pages; refusing to treat a truncated set as complete" + ))); + } + + let (page, skew) = match self.fetch_page(cursor.as_deref()).await { + Ok(v) => v, + Err(why) => return self.lost(why), + }; + self.skew = skew; + + for item in page.mitigations { + match item.into_mitigation(now_unix, self.skew) { + Ok(m) => collected.push(m), + // One bad item fails the whole poll. A shortened list looks + // exactly like mitigations having ended. + Err(e) => return self.lost(StaleReason::Malformed(e.to_string())), + } + } + + if !page.has_more { + break; + } + + let Some(next) = page.next_cursor else { + // The signature of the zero-limit bug, and of any other + // server-side pagination fault. + return self.lost(StaleReason::Pagination( + "has_more is set but no cursor was returned".into(), + )); + }; + + if Some(&next) == cursor.as_ref() { + return self.lost(StaleReason::Pagination( + "the cursor did not advance; pagination is looping".into(), + )); + } + cursor = Some(next); + } + + // An empty result is not believed until it has been seen a few times in + // a row — but only once a non-empty one has been seen, so a quiet node + // is not permanently stale. + if collected.is_empty() && self.seen_non_empty { + self.empty_streak += 1; + if self.empty_streak < EMPTY_CONFIRMATIONS { + return self.stale(StaleReason::UnconfirmedEmpty { + seen: self.empty_streak, + needed: EMPTY_CONFIRMATIONS, + }); + } + } else if !collected.is_empty() { + self.empty_streak = 0; + self.seen_non_empty = true; + } + + // Rates are an *enrichment*, attached after the set is known to be + // good. A failure here is silent by design: a victim without a sample + // simply does not satisfy rate rules, where taking the whole view stale + // over an auxiliary lookup would trade a better decision for none. + let now = Instant::now(); + self.rates.enrich(&mut collected, now, self.skew).await; + + self.last_fresh_at = Some(now); + MitigationView::Fresh(collected) + } +} + +/// Why a client could not be built. +/// +/// `CaFile` is separate from `Http` because the operator response differs: a +/// path that cannot be read or parsed is a file they can go and look at, where a +/// client that will not build is a build-time problem they cannot. +#[derive(Debug, thiserror::Error)] +pub enum BuildError { + #[error("cannot build the HTTP client: {0}")] + Http(String), + + #[error("`ca-file` {path} is not usable: {detail}")] + CaFile { path: String, detail: String }, +} + +/// The engine's clock minus ours, from the response's `Date` header. +/// +/// Free and authoritative: it is the server's own clock, stamped by its HTTP +/// layer. Used only for ages and TTLs. Damping timers are monotonic and never +/// touch this. +fn measure_skew(headers: &reqwest::header::HeaderMap) -> SkewSecs { + let Some(date) = headers + .get(reqwest::header::DATE) + .and_then(|v| v.to_str().ok()) + else { + return 0; + }; + let Some(server) = parse_http_date(date) else { + return 0; + }; + let local = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + server - local +} + +/// Parse an RFC 7231 IMF-fixdate, the only form a compliant server sends. +/// +/// `Sun, 06 Nov 1994 08:49:37 GMT` +fn parse_http_date(s: &str) -> Option { + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let rest = s.split_once(", ")?.1; + let mut parts = rest.split_whitespace(); + let day: i64 = parts.next()?.parse().ok()?; + let month_name = parts.next()?; + let month = MONTHS.iter().position(|m| *m == month_name)? as i64 + 1; + let year: i64 = parts.next()?.parse().ok()?; + let mut hms = parts.next()?.split(':'); + let h: i64 = hms.next()?.parse().ok()?; + let m: i64 = hms.next()?.parse().ok()?; + let sec: i64 = hms.next()?.parse().ok()?; + + let iso = format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{sec:02}Z"); + wire::parse_rfc3339(&iso) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_status_filter_is_a_constant_and_covers_every_live_status() { + // If a live status is ever added to the model, this catches the client + // silently not asking for it. + for s in ["active", "escalated", "pending"] { + assert!(ACTIVE_STATUSES.contains(s), "{s} missing from the filter"); + } + } + + #[test] + fn http_dates_parse() { + assert_eq!( + parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"), + Some(784_111_777) + ); + assert_eq!( + parse_http_date("Thu, 20 Aug 2026 12:00:00 GMT"), + Some(1_787_227_200) + ); + } + + #[test] + fn a_malformed_date_header_yields_no_skew_rather_than_a_wrong_one() { + assert!(parse_http_date("nonsense").is_none()); + assert!(parse_http_date("").is_none()); + let mut h = reqwest::header::HeaderMap::new(); + h.insert(reqwest::header::DATE, "nonsense".parse().unwrap()); + assert_eq!(measure_skew(&h), 0); + } + + #[test] + fn a_missing_date_header_yields_no_skew() { + assert_eq!(measure_skew(&reqwest::header::HeaderMap::new()), 0); + } +} diff --git a/crates/policy/src/rates.rs b/crates/policy/src/rates.rs new file mode 100644 index 0000000..17d69c9 --- /dev/null +++ b/crates/policy/src/rates.rs @@ -0,0 +1,330 @@ +//! Per-victim attack rates, and the budget that keeps asking for them cheap. +//! +//! A mitigation record carries no usable attack rate. Its `rate_bps` field is +//! the *policer rate the engine's playbook chose* — a policy output, not a +//! measurement — and it is null for a discard action, which is to say null +//! exactly when the attack is largest. Using it as a size proxy is backwards. +//! +//! The real signal is the engine's per-IP event history, which carries the +//! `bps` and `pps` the detector reported. It needs only ordinary read +//! authorisation, so a bearer token reaches it. +//! +//! Two things make it awkward, and both are handled here rather than left to +//! the caller: +//! +//! **The query has no time predicate.** It returns the newest N events for that +//! address *ever*, so a victim attacked once last March comes back with a +//! confident-looking rate from six months ago. Samples older than +//! [`MAX_SAMPLE_AGE`] are discarded, and a victim with only stale events is +//! treated as having no rate at all — which means a rate rule will not fire for +//! it, which is the safe direction. +//! +//! **It costs a request per victim, against a rate limit shared with the +//! dashboard and every detector.** So lookups are budgeted per tick, cached, +//! and spent oldest-sample-first: during a large incident the victims whose +//! rates are most out of date get refreshed, and the rest keep the sample they +//! have until their turn. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use filterframe_common::mitigation::Mitigation; +use serde::Deserialize; + +/// How many per-victim lookups may be spent in one tick. +/// +/// Ten at a two-second tick is five requests a second at the very worst, +/// against a bucket sized in the hundreds. Large incidents converge over a few +/// ticks rather than in one, which is fine: the rate threshold is paired with a +/// duration threshold, so nothing that needs a rate is urgent in the first tick +/// anyway. +/// +/// It is also the **concurrency** cap, not a queue length: the ten are issued at +/// once, so a tick costs one `request-timeout` rather than ten. That matters +/// because the daemon drives the tick on the thread that also polls signals and +/// the return-path probe — a serialised budget put a thirty-second stall in +/// front of both. +pub const LOOKUP_BUDGET: usize = 10; + +/// How long a sample is reused before it is worth spending a lookup on. +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(15); + +/// How old a detector event may be and still count as a measurement of *this* +/// attack. +/// +/// The endpoint has no time predicate, so without this a victim attacked months +/// ago would return a rate that looks current. Five minutes is comfortably +/// longer than any detector's reporting interval and far shorter than the gap +/// between separate incidents. +pub const MAX_SAMPLE_AGE: Duration = Duration::from_secs(300); + +/// How many events to ask for per victim. Enough to survive a couple of stale +/// entries at the head without paging. +const HISTORY_LIMIT: u32 = 10; + +#[derive(Debug, Clone, Deserialize)] +struct HistoryResponse { + #[serde(default)] + events: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct HistoryEvent { + #[serde(default)] + bps: Option, + /// RFC 3339. The endpoint returns newest first, but that is not promised + /// anywhere, so the timestamp is used rather than the position. + #[serde(default)] + event_timestamp: Option, +} + +#[derive(Debug, Clone, Copy)] +struct Sample { + bps: Option, + taken: Instant, +} + +/// Fills in [`Mitigation::bps`] for as many victims as the budget allows. +pub struct RateEnricher { + http: reqwest::Client, + base: String, + token: Option, + cache: HashMap, + /// Lookups spent on the most recent call, for the metric. + last_spent: usize, +} + +impl RateEnricher { + pub fn new(http: reqwest::Client, base: String, token: Option) -> Self { + Self { + http, + base: base.trim_end_matches('/').to_string(), + token, + cache: HashMap::new(), + last_spent: 0, + } + } + + pub fn last_spent(&self) -> usize { + self.last_spent + } + + /// Attach a rate to each mitigation that has one, refreshing what the + /// budget allows. + /// + /// Failures are silent by design: a rate is an *enrichment*, and a victim + /// without one simply does not satisfy rate rules. Taking the whole view + /// stale because one auxiliary lookup failed would trade a better decision + /// for no decision at all. + pub async fn enrich(&mut self, mitigations: &mut [Mitigation], now: Instant, skew: i64) { + // Spend the budget oldest-first, so during a large incident the samples + // that are most out of date are the ones refreshed. + let mut candidates: Vec = mitigations + .iter() + .map(|m| m.victim) + .filter(|v| { + self.cache + .get(v) + .is_none_or(|s| now.saturating_duration_since(s.taken) >= REFRESH_INTERVAL) + }) + .collect(); + candidates.sort_unstable(); + candidates.dedup(); + candidates.sort_by_key(|v| self.cache.get(v).map(|s| s.taken)); + + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let spend: Vec = candidates.into_iter().take(LOOKUP_BUDGET).collect(); + self.last_spent = spend.len(); + + // Issued **concurrently**, and the budget is the concurrency cap rather + // than a queue length. + // + // These lookups are independent — one victim's history says nothing + // about another's — but awaiting them one at a time made the worst case + // `LOOKUP_BUDGET * request-timeout`, which at the defaults is thirty + // seconds. The daemon drives a tick with `block_on` on the same thread + // that polls signals and the return-path probe, so a slow policy engine + // during a large incident stalled SIGTERM and stalled the probe that is + // supposed to notice a dead tunnel inside a second. + let mut lookups = tokio::task::JoinSet::new(); + for victim in spend { + // Cloned per task rather than borrowed: `reqwest::Client` shares one + // connection pool across clones, so this is a handle copy and not a + // new pool. + let http = self.http.clone(); + let base = self.base.clone(); + let token = self.token.clone(); + let engine_now = now_unix + skew; + lookups.spawn(async move { + ( + victim, + fetch(&http, &base, token.as_deref(), victim, engine_now).await, + ) + }); + } + while let Some(joined) = lookups.join_next().await { + match joined { + Ok((victim, bps)) => { + self.cache.insert(victim, Sample { bps, taken: now }); + } + // A panicked lookup is still just a missing sample: the victim + // ends up without a rate and rate rules will not fire for it, + // which is the safe direction. + Err(e) => tracing::debug!(error = %e, "a rate lookup did not complete"), + } + } + + for m in mitigations { + m.bps = self + .cache + .get(&m.victim) + .filter(|s| now.saturating_duration_since(s.taken) < MAX_SAMPLE_AGE) + .and_then(|s| s.bps); + } + + // Drop what can no longer be read. An entry past `MAX_SAMPLE_AGE` is + // already filtered out above, so keeping it buys nothing and the map + // otherwise grew by one entry per distinct victim for the lifetime of + // the process. + self.cache + .retain(|_, s| now.saturating_duration_since(s.taken) < MAX_SAMPLE_AGE); + } +} + +/// The newest event for `victim` that is recent enough to be about the attack +/// now in progress. +/// +/// A free function taking what it needs by value so each lookup can run as its +/// own task. Borrowing `&self` here is what forced the sequential loop. +async fn fetch( + http: &reqwest::Client, + base: &str, + token: Option<&str>, + victim: IpAddr, + engine_now: i64, +) -> Option { + let url = format!("{base}/v1/ip/{victim}/history"); + let mut req = http + .get(&url) + .query(&[("limit", HISTORY_LIMIT.to_string())]); + if let Some(t) = token { + req = req.bearer_auth(t); + } + + let resp = match req.send().await { + Ok(r) if r.status().is_success() => r, + Ok(r) => { + tracing::debug!(%victim, status = %r.status(), "rate lookup returned an error status"); + return None; + } + Err(e) => { + tracing::debug!(%victim, error = %e, "rate lookup failed"); + return None; + } + }; + + let body: HistoryResponse = match resp.json().await { + Ok(b) => b, + Err(e) => { + tracing::debug!(%victim, error = %e, "rate lookup returned an unreadable body"); + return None; + } + }; + + newest_usable(&body.events, engine_now) +} + +/// The `bps` of the newest event within [`MAX_SAMPLE_AGE`], if any. +/// +/// Split out from the HTTP path so the windowing rule — the part that stops a +/// six-month-old event masquerading as a current rate — is testable directly. +fn newest_usable(events: &[HistoryEvent], engine_now: i64) -> Option { + let cutoff = engine_now - MAX_SAMPLE_AGE.as_secs() as i64; + events + .iter() + .filter_map(|e| { + let ts = crate::wire::parse_rfc3339(e.event_timestamp.as_deref()?)?; + (ts >= cutoff).then_some((ts, e.bps?)) + }) + .max_by_key(|(ts, _)| *ts) + .map(|(_, bps)| bps) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(ts: &str, bps: Option) -> HistoryEvent { + HistoryEvent { + bps, + event_timestamp: Some(ts.to_string()), + } + } + + const NOW: i64 = 1_787_227_200; // 2026-08-20T12:00:00Z + + #[test] + fn the_newest_recent_event_wins() { + let events = [ + ev("2026-08-20T11:59:00Z", Some(1_000_000)), + ev("2026-08-20T11:59:50Z", Some(9_000_000)), + ev("2026-08-20T11:58:00Z", Some(2_000_000)), + ]; + assert_eq!(newest_usable(&events, NOW), Some(9_000_000)); + } + + /// The endpoint has no time predicate, so this is the case that matters: + /// a victim attacked months ago must not come back with a confident rate. + #[test] + fn a_stale_event_is_not_a_current_rate() { + let events = [ev("2026-03-01T00:00:00Z", Some(9_000_000_000))]; + assert_eq!( + newest_usable(&events, NOW), + None, + "an event from months ago must not be treated as a measurement of now" + ); + } + + #[test] + fn an_event_just_inside_the_window_still_counts() { + let events = [ev("2026-08-20T11:55:01Z", Some(5_000_000))]; + assert_eq!(newest_usable(&events, NOW), Some(5_000_000)); + } + + #[test] + fn an_event_just_outside_the_window_does_not() { + let events = [ev("2026-08-20T11:54:00Z", Some(5_000_000))]; + assert_eq!(newest_usable(&events, NOW), None); + } + + #[test] + fn events_without_a_rate_or_a_timestamp_are_skipped() { + let events = [ + ev("2026-08-20T11:59:00Z", None), + HistoryEvent { + bps: Some(7), + event_timestamp: None, + }, + ev("2026-08-20T11:58:00Z", Some(3_000_000)), + ]; + assert_eq!(newest_usable(&events, NOW), Some(3_000_000)); + } + + #[test] + fn no_events_is_no_rate_rather_than_zero() { + assert_eq!(newest_usable(&[], NOW), None); + } + + /// Zero is a real measurement and must be distinguishable from "no sample": + /// a rate rule should fail against it rather than be skipped. + #[test] + fn a_measured_zero_is_a_sample() { + let events = [ev("2026-08-20T11:59:00Z", Some(0))]; + assert_eq!(newest_usable(&events, NOW), Some(0)); + } +} diff --git a/crates/policy/src/wire.rs b/crates/policy/src/wire.rs new file mode 100644 index 0000000..c2f8d65 --- /dev/null +++ b/crates/policy/src/wire.rs @@ -0,0 +1,342 @@ +//! The wire format of the policy engine's mitigation list, and its conversion +//! into filterframe's own model. +//! +//! Kept separate from the client so the contract can be tested against recorded +//! fixtures with no HTTP involved. When the policy engine changes its response +//! shape, exactly one test file has to change, and it fails loudly rather than +//! producing a plausible empty list. +//! +//! Two rules govern deserialisation here, and they point in opposite +//! directions on purpose: +//! +//! **Unknown enum values are preserved, not rejected.** Vectors, actions and +//! statuses are open sets. A policy engine that adds one must not blind +//! filterframe, so an unrecognised value survives into the model and falls +//! through to the default rule. +//! +//! **A structurally malformed item fails the whole page.** The reference +//! implementation drops unparseable rows from its own list output, which is +//! right for rendering a table to a human and wrong for deciding whether to +//! stop protecting something: a short list is indistinguishable from a world +//! where those mitigations ended. + +use std::net::IpAddr; +use std::time::Duration; + +use filterframe_common::mitigation::{ActionType, Mitigation, MitigationStatus}; +use serde::Deserialize; + +/// The list envelope. +#[derive(Debug, Clone, Deserialize)] +pub struct MitigationsPage { + pub mitigations: Vec, + #[serde(default)] + pub next_cursor: Option, + #[serde(default)] + pub has_more: bool, +} + +/// One mitigation as the engine renders it. +/// +/// Only the fields filterframe acts on are declared. Everything else in the +/// response is ignored by serde, which is what keeps this from breaking every +/// time the engine adds a field. +#[derive(Debug, Clone, Deserialize)] +pub struct WireMitigation { + pub mitigation_id: String, + pub victim_ip: String, + pub status: MitigationStatus, + pub action_type: ActionType, + #[serde(default)] + pub vector: String, + #[serde(default)] + pub customer_id: Option, + #[serde(default)] + pub pop: Option, + #[serde(default)] + pub acknowledged_at: Option, + /// RFC 3339. Used only to derive an age, never rendered. + pub created_at: String, + /// RFC 3339. + pub expires_at: String, +} + +/// Why an item could not be turned into a `Mitigation`. +#[derive(Debug, thiserror::Error)] +pub enum ConvertError { + #[error("mitigation {id}: `{field}` is not usable: {detail}")] + Field { + id: String, + field: &'static str, + detail: String, + }, +} + +/// The engine's clock relative to ours, in seconds, positive when the engine is +/// ahead. +/// +/// Ages and TTLs are wall-clock quantities derived from another system's +/// timestamps, so they have to be skew-corrected. Damping timers never use +/// this — they are monotonic, and mixing the two is how an NTP step expires a +/// hold. +pub type SkewSecs = i64; + +impl WireMitigation { + /// Convert, correcting wall-clock quantities for measured skew. + /// + /// `now_unix` is the local wall clock at the moment the response was + /// received; `skew` is what the engine's own `Date` header said about the + /// difference. + pub fn into_mitigation( + self, + now_unix: i64, + skew: SkewSecs, + ) -> Result { + let field = |field: &'static str, detail: String| ConvertError::Field { + id: self.mitigation_id.clone(), + field, + detail, + }; + + let victim: IpAddr = self + .victim_ip + .parse() + .map_err(|e| field("victim_ip", format!("{e}: {:?}", self.victim_ip)))?; + + let created = parse_rfc3339(&self.created_at) + .ok_or_else(|| field("created_at", self.created_at.clone()))?; + let expires = parse_rfc3339(&self.expires_at) + .ok_or_else(|| field("expires_at", self.expires_at.clone()))?; + + // The engine's clock, expressed in ours. + let engine_now = now_unix + skew; + + // A negative age means the engine's clock is ahead of what skew + // correction accounted for. Clamp rather than wrap: a mitigation that + // reports an age of -4h would satisfy every `age-at-most` rule forever. + let age = Duration::from_secs((engine_now - created).max(0) as u64); + let ttl_remaining = (expires - engine_now) + .try_into() + .ok() + .map(Duration::from_secs); + + Ok(Mitigation { + id: self.mitigation_id, + victim, + status: self.status, + action: self.action_type, + vector: self.vector, + customer: self.customer_id, + pop: self.pop, + acknowledged: self.acknowledged_at.is_some(), + age, + ttl_remaining, + // Filled in by rate enrichment, deliberately not from the record: + // the engine's own rate field is a policy output, and null exactly + // when the attack is biggest. + bps: None, + }) + } +} + +/// Parse the RFC 3339 subset the policy engine emits, into a unix timestamp. +/// +/// Hand-written rather than pulling in a date library for one field. The engine +/// renders with `to_rfc3339`, so the shape is fixed: `YYYY-MM-DDTHH:MM:SS` +/// followed by an optional fractional part and either `Z` or a numeric offset. +/// Anything else returns `None` and fails the item, which fails the poll — +/// the safe direction. +pub fn parse_rfc3339(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() < 19 || b[4] != b'-' || b[7] != b'-' || (b[10] != b'T' && b[10] != b't') { + return None; + } + let num = |r: std::ops::Range| s.get(r)?.parse::().ok(); + + let year = num(0..4)?; + let month = num(5..7)?; + let day = num(8..10)?; + let hour = num(11..13)?; + let min = num(14..16)?; + let sec = num(17..19)?; + + if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 60 { + return None; + } + + // Offset, if any. `Z` and `+00:00` are the common cases; a numeric offset + // is subtracted to reach UTC. + let rest = &s[19..]; + let tz_secs = match rest.find(['+', '-']) { + Some(i) => { + let sign = if rest.as_bytes()[i] == b'+' { 1 } else { -1 }; + let off = &rest[i + 1..]; + let (h, m) = off.split_once(':')?; + sign * (h.parse::().ok()? * 3600 + m.parse::().ok()? * 60) + } + None => 0, + }; + + Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + min * 60 + sec - tz_secs) +} + +/// Days since the unix epoch for a civil date. +/// +/// Howard Hinnant's `days_from_civil`, which is exact for the whole proleptic +/// Gregorian range and needs no table. Shifting the year to start in March is +/// what makes the leap day fall at the end of the cycle and removes the special +/// case entirely. +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epoch_and_known_dates_are_exact() { + assert_eq!(parse_rfc3339("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!(parse_rfc3339("2000-01-01T00:00:00Z"), Some(946_684_800)); + assert_eq!(parse_rfc3339("2026-08-20T12:00:00Z"), Some(1_787_227_200)); + } + + #[test] + fn leap_days_are_handled() { + // 2000 is a leap year (divisible by 400); 1900 is not. + assert_eq!( + parse_rfc3339("2000-03-01T00:00:00Z").unwrap() + - parse_rfc3339("2000-02-28T00:00:00Z").unwrap(), + 2 * 86_400 + ); + } + + #[test] + fn fractional_seconds_and_offsets_are_accepted() { + let z = parse_rfc3339("2026-08-20T12:00:00Z").unwrap(); + assert_eq!(parse_rfc3339("2026-08-20T12:00:00.123456Z"), Some(z)); + assert_eq!(parse_rfc3339("2026-08-20T13:00:00+01:00"), Some(z)); + assert_eq!(parse_rfc3339("2026-08-20T11:00:00-01:00"), Some(z)); + } + + #[test] + fn malformed_timestamps_are_rejected_rather_than_guessed() { + for s in [ + "", + "not a date", + "2026-08-20", + "2026-13-01T00:00:00Z", + "2026-08-32T00:00:00Z", + "2026-08-20T25:00:00Z", + ] { + assert!(parse_rfc3339(s).is_none(), "{s} should not parse"); + } + } + + fn wire(status: &str, action: &str) -> String { + format!( + r#"{{ + "mitigation_id": "m1", + "victim_ip": "198.51.100.5", + "status": "{status}", + "action_type": "{action}", + "vector": "udp_flood", + "customer_id": "acme", + "created_at": "2026-08-20T12:00:00Z", + "expires_at": "2026-08-20T12:02:00Z" + }}"# + ) + } + + #[test] + fn a_well_formed_item_converts() { + let w: WireMitigation = serde_json::from_str(&wire("active", "discard")).unwrap(); + let m = w.into_mitigation(1_787_227_260, 0).unwrap(); + assert_eq!(m.id, "m1"); + assert_eq!(m.age, Duration::from_secs(60)); + assert_eq!(m.ttl_remaining, Some(Duration::from_secs(60))); + assert!(!m.acknowledged); + } + + /// Forward compatibility: an engine that invents a vector or an action must + /// not blind us. + #[test] + fn unknown_action_and_status_still_convert() { + let w: WireMitigation = serde_json::from_str(&wire("quarantined", "carpet_bomb")).unwrap(); + let m = w.into_mitigation(1_787_227_260, 0).unwrap(); + assert_eq!(m.action, ActionType::Other("carpet_bomb".into())); + assert!(!m.status.is_live(), "an unknown status is not live"); + } + + /// Structural damage fails the item, which fails the poll. A short list + /// would be indistinguishable from mitigations having ended. + #[test] + fn a_bad_victim_address_fails_the_item() { + let raw = wire("active", "discard").replace("198.51.100.5", "not-an-ip"); + let w: WireMitigation = serde_json::from_str(&raw).unwrap(); + let e = w.into_mitigation(1_787_227_260, 0).unwrap_err(); + assert!(e.to_string().contains("victim_ip"), "{e}"); + } + + #[test] + fn a_bad_timestamp_fails_the_item() { + let raw = wire("active", "discard").replace("2026-08-20T12:00:00Z", "yesterday"); + let w: WireMitigation = serde_json::from_str(&raw).unwrap(); + assert!(w.into_mitigation(1_787_227_260, 0).is_err()); + } + + /// Skew correction is what stops an engine running two minutes fast from + /// making every mitigation look like it has a negative age. + #[test] + fn skew_is_applied_to_age() { + let w: WireMitigation = serde_json::from_str(&wire("active", "discard")).unwrap(); + // Our clock says 12:00:00, the engine's says 12:01:00. + let m = w.into_mitigation(1_787_227_200, 60).unwrap(); + assert_eq!(m.age, Duration::from_secs(60)); + } + + /// A mitigation whose age would compute negative is clamped, not wrapped. + /// An age of -4h would satisfy every `age-at-most` rule forever. + #[test] + fn a_negative_age_clamps_to_zero() { + let w: WireMitigation = serde_json::from_str(&wire("active", "discard")).unwrap(); + let m = w.into_mitigation(1_787_227_200 - 3600, 0).unwrap(); + assert_eq!(m.age, Duration::ZERO); + } + + #[test] + fn an_already_expired_mitigation_has_no_remaining_ttl() { + let w: WireMitigation = serde_json::from_str(&wire("active", "discard")).unwrap(); + let m = w.into_mitigation(1_787_227_200 + 3600, 0).unwrap(); + assert_eq!(m.ttl_remaining, None); + } + + #[test] + fn acknowledgement_is_derived_from_the_timestamp_field() { + let raw = wire("active", "discard").replace( + r#""vector": "udp_flood","#, + r#""vector": "udp_flood", "acknowledged_at": "2026-08-20T12:00:30Z","#, + ); + let w: WireMitigation = serde_json::from_str(&raw).unwrap(); + let m = w.into_mitigation(1_787_227_260, 0).unwrap(); + assert!(m.acknowledged); + } + + /// Fields filterframe does not use must not break the parse when they + /// appear, change, or disappear. + #[test] + fn unrecognised_response_fields_are_ignored() { + let raw = wire("active", "discard").replace( + r#""vector": "udp_flood","#, + r#""vector": "udp_flood", "scope_hash": "abc", "rate_bps": 5000000, "nested": {"a": 1},"#, + ); + let w: WireMitigation = serde_json::from_str(&raw).unwrap(); + assert!(w.into_mitigation(1_787_227_260, 0).is_ok()); + } +} diff --git a/crates/policy/tests/pagination.rs b/crates/policy/tests/pagination.rs new file mode 100644 index 0000000..6207f23 --- /dev/null +++ b/crates/policy/tests/pagination.rs @@ -0,0 +1,447 @@ +//! Every way the policy engine's list endpoint can lie, and the assertion that +//! none of them produces a list filterframe would act on. +//! +//! These run against a stub server rather than a mock client, because the +//! guards being tested live in the HTTP path — the query it builds, the way it +//! reads `has_more`, what it does with a cursor that does not advance. A mock +//! at the trait boundary would test the fake instead. +//! +//! The stub is deliberately tiny: a `TcpListener`, a canned response per +//! request, no framework. It only has to be wrong in the specific ways a real +//! server has been observed to be wrong. + +use std::sync::Arc; +use std::time::Duration; + +use filterframe_common::config::PolicySourceConfig; +use filterframe_common::mitigation::{MitigationView, StaleReason}; +use filterframe_policy::{HttpPolicySource, PolicySource}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +/// A stub HTTP server that replays a scripted sequence of response bodies. +struct Stub { + url: String, + /// Every request line the client sent, so a test can assert on the query + /// filterframe built as well as on what it did with the answer. + requests: Arc>>, +} + +impl Stub { + /// Serve `bodies` in order, each as a 200 with a JSON content type. Once + /// exhausted, the last body repeats. + async fn serve(bodies: Vec) -> Self { + Self::serve_with_status(bodies.into_iter().map(|b| (200u16, b)).collect()).await + } + + async fn serve_with_status(responses: Vec<(u16, String)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&requests); + + tokio::spawn(async move { + let mut n = 0usize; + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + let responses = responses.clone(); + let seen = Arc::clone(&seen); + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + let read = sock.read(&mut buf).await.unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..read]).to_string(); + let line = req.lines().next().unwrap_or_default().to_string(); + + // Rate enrichment queries the per-IP history endpoint after + // a successful poll. Those are auxiliary: they must not + // consume a scripted response, or every test that scripts a + // sequence of pages would silently drift by however many + // victims happened to be in the last one. + let is_history = line.contains("/v1/ip/"); + let (status, body) = if is_history { + (200u16, r#"{"events":[]}"#.to_string()) + } else { + seen.lock().await.push(line.clone()); + let idx = { seen.lock().await.len().saturating_sub(1) }; + responses + .get(idx) + .or_else(|| responses.last()) + .cloned() + .unwrap_or((200, "{}".into())) + }; + let resp = format!( + "HTTP/1.1 {status} OK\r\n\ + Content-Type: application/json\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.flush().await; + }); + n += 1; + // Generous, because enrichment adds a connection per victim on + // top of the scripted pages. + if n > 256 { + return; + } + } + }); + + Self { + url: format!("http://{addr}"), + requests, + } + } + + fn client(&self) -> HttpPolicySource { + let cfg = PolicySourceConfig { + url: self.url.clone(), + request_timeout: Duration::from_secs(2), + ..Default::default() + }; + HttpPolicySource::new(&cfg, Some("token".into())).unwrap() + } +} + +fn item(id: &str, ip: &str) -> String { + format!( + r#"{{"mitigation_id":"{id}","victim_ip":"{ip}","status":"active", + "action_type":"discard","vector":"udp_flood", + "created_at":"2026-08-20T12:00:00Z","expires_at":"2026-08-20T12:05:00Z"}}"# + ) +} + +fn page(items: &[String], has_more: bool, cursor: Option<&str>) -> String { + format!( + r#"{{"mitigations":[{}],"has_more":{has_more},"next_cursor":{}}}"#, + items.join(","), + match cursor { + Some(c) => format!("\"{c}\""), + None => "null".into(), + } + ) +} + +fn stale_reason(v: &MitigationView) -> StaleReason { + match v { + MitigationView::Stale { why, .. } => why.clone(), + MitigationView::Fresh(m) => panic!("expected a stale view, got {} mitigations", m.len()), + } +} + +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_single_page_is_fresh() { + let stub = Stub::serve(vec![page(&[item("m1", "198.51.100.5")], false, None)]).await; + let view = stub.client().poll().await; + assert_eq!(view.fresh().map(<[_]>::len), Some(1)); +} + +#[tokio::test] +async fn pages_are_followed_and_concatenated() { + let stub = Stub::serve(vec![ + page(&[item("m1", "198.51.100.5")], true, Some("c1")), + page(&[item("m2", "198.51.100.6")], false, None), + ]) + .await; + let view = stub.client().poll().await; + assert_eq!(view.fresh().map(<[_]>::len), Some(2)); +} + +/// The signature of the zero-limit bug: `has_more` set with no cursor to +/// follow. A client that treats the pages it got as the whole world would +/// conclude that everything not on page one had ended. +#[tokio::test] +async fn has_more_without_a_cursor_fails_the_whole_poll() { + let stub = Stub::serve(vec![page(&[item("m1", "198.51.100.5")], true, None)]).await; + let view = stub.client().poll().await; + assert!(matches!(stale_reason(&view), StaleReason::Pagination(_))); + assert!(view.fresh().is_none(), "a truncated set must not be usable"); +} + +#[tokio::test] +async fn a_cursor_that_does_not_advance_fails_the_poll() { + let stub = Stub::serve(vec![ + page(&[item("m1", "198.51.100.5")], true, Some("same")), + page(&[item("m2", "198.51.100.6")], true, Some("same")), + ]) + .await; + let view = stub.client().poll().await; + match stale_reason(&view) { + StaleReason::Pagination(d) => assert!(d.contains("looping"), "{d}"), + other => panic!("expected a pagination failure, got {other:?}"), + } +} + +#[tokio::test] +async fn unbounded_pagination_fails_rather_than_running_forever() { + // Always has_more, always a new cursor: a server that will never finish. + let stub = Stub::serve_with_status( + (0..40) + .map(|i| { + ( + 200u16, + page( + &[item(&format!("m{i}"), "198.51.100.5")], + true, + Some(&format!("c{i}")), + ), + ) + }) + .collect(), + ) + .await; + let view = stub.client().poll().await; + assert!(matches!(stale_reason(&view), StaleReason::Pagination(_))); +} + +/// One structurally bad item fails the whole poll. A shortened list is +/// indistinguishable from those mitigations having ended. +#[tokio::test] +async fn one_malformed_item_fails_the_whole_page() { + let bad = item("m2", "not-an-ip"); + let stub = Stub::serve(vec![page(&[item("m1", "198.51.100.5"), bad], false, None)]).await; + let view = stub.client().poll().await; + assert!(matches!(stale_reason(&view), StaleReason::Malformed(_))); + assert!(view.fresh().is_none()); +} + +/// Forward compatibility is a safety property: an engine that adds a vector or +/// an action must not blind filterframe. +#[tokio::test] +async fn an_unknown_action_type_does_not_fail_the_poll() { + let odd = item("m1", "198.51.100.5").replace("discard", "carpet_bomb"); + let stub = Stub::serve(vec![page(&[odd], false, None)]).await; + let view = stub.client().poll().await; + assert_eq!(view.fresh().map(<[_]>::len), Some(1)); +} + +#[tokio::test] +async fn html_from_a_proxy_is_reported_as_such() { + let stub = Stub::serve(vec!["502 Bad Gateway".into()]).await; + let view = stub.client().poll().await; + match stale_reason(&view) { + StaleReason::Malformed(d) => { + assert!(d.contains("HTML"), "should name the likely cause: {d}") + } + other => panic!("expected malformed, got {other:?}"), + } +} + +#[tokio::test] +async fn a_429_is_rate_limited_not_malformed() { + let stub = Stub::serve_with_status(vec![(429, "{}".into())]).await; + let view = stub.client().poll().await; + assert!(matches!( + stale_reason(&view), + StaleReason::RateLimited { .. } + )); +} + +/// A 401 is silent misconfiguration wearing a network problem's clothes. The +/// message has to name the cause no amount of token-checking would reveal. +#[tokio::test] +async fn a_401_names_the_auth_mode_as_a_likely_cause() { + let stub = Stub::serve_with_status(vec![(401, "{}".into())]).await; + let view = stub.client().poll().await; + match stale_reason(&view) { + StaleReason::Unauthorized(d) => { + assert!(d.contains("bearer"), "{d}"); + assert!(d.contains("credentials"), "{d}"); + } + other => panic!("expected unauthorized, got {other:?}"), + } +} + +#[tokio::test] +async fn an_unreachable_engine_is_stale_not_empty() { + // Bind and immediately drop, so the port is closed. + let addr = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap() + }; + let cfg = PolicySourceConfig { + url: format!("http://{addr}"), + request_timeout: Duration::from_millis(500), + ..Default::default() + }; + let view = HttpPolicySource::new(&cfg, None).unwrap().poll().await; + assert!(matches!(stale_reason(&view), StaleReason::Unreachable(_))); + assert!( + view.fresh().is_none(), + "an unreachable engine must never look like an empty world" + ); +} + +// -- the query filterframe actually builds ----------------------------------- + +/// `pop` is accepted and silently ignored by the reference engine, so sending +/// it would create a false belief that results were scoped to this node. +#[tokio::test] +async fn the_query_never_contains_pop_or_time_filters() { + let stub = Stub::serve(vec![page(&[], false, None)]).await; + let _ = stub.client().poll().await; + + let reqs = stub.requests.lock().await; + let line = reqs.first().expect("a request should have been made"); + assert!(!line.contains("pop="), "must not send pop: {line}"); + assert!(!line.contains("start="), "must not send start: {line}"); + assert!(!line.contains("end="), "must not send end: {line}"); +} + +/// A zero limit makes the server return `has_more` with a null cursor, which is +/// an infinite loop for any client that trusts it. +#[tokio::test] +async fn the_query_never_asks_for_a_zero_limit() { + let stub = Stub::serve(vec![page(&[], false, None)]).await; + let _ = stub.client().poll().await; + + let reqs = stub.requests.lock().await; + let line = reqs.first().unwrap(); + assert!(line.contains("limit="), "a limit must be sent: {line}"); + assert!(!line.contains("limit=0"), "{line}"); +} + +#[tokio::test] +async fn the_query_asks_for_every_live_status() { + let stub = Stub::serve(vec![page(&[], false, None)]).await; + let _ = stub.client().poll().await; + + let reqs = stub.requests.lock().await; + let line = reqs.first().unwrap(); + for s in ["active", "escalated", "pending"] { + assert!(line.contains(s), "{s} missing from {line}"); + } +} + +// -- empty-result confirmation ----------------------------------------------- + +/// A node with nothing to do must not be permanently stale, so an empty result +/// is believed immediately until a non-empty one has been seen. +#[tokio::test] +async fn an_empty_result_is_believed_before_anything_has_been_seen() { + let stub = Stub::serve(vec![page(&[], false, None)]).await; + let view = stub.client().poll().await; + assert_eq!(view.fresh().map(<[_]>::len), Some(0)); +} + +/// Going from busy to empty is the dangerous transition: it is the input to a +/// teardown, and a transient fault produces it. It has to be confirmed. +#[tokio::test] +async fn an_empty_result_after_a_busy_one_is_confirmed_before_it_is_believed() { + let stub = Stub::serve(vec![ + page(&[item("m1", "198.51.100.5")], false, None), + page(&[], false, None), + page(&[], false, None), + page(&[], false, None), + ]) + .await; + let mut client = stub.client(); + + assert_eq!(client.poll().await.fresh().map(<[_]>::len), Some(1)); + + let first = client.poll().await; + assert!( + matches!( + stale_reason(&first), + StaleReason::UnconfirmedEmpty { seen: 1, .. } + ), + "the first empty result must not be acted on" + ); + + let second = client.poll().await; + assert!(matches!( + stale_reason(&second), + StaleReason::UnconfirmedEmpty { seen: 2, .. } + )); + + let third = client.poll().await; + assert_eq!( + third.fresh().map(<[_]>::len), + Some(0), + "a sustained empty result is eventually believed, or nothing could ever be released" + ); +} + +/// A single blip between busy polls must reset the confirmation, not accumulate +/// toward one. +#[tokio::test] +async fn a_transient_empty_result_does_not_accumulate() { + let stub = Stub::serve(vec![ + page(&[item("m1", "198.51.100.5")], false, None), + page(&[], false, None), + page(&[item("m1", "198.51.100.5")], false, None), + page(&[], false, None), + ]) + .await; + let mut client = stub.client(); + + assert!(client.poll().await.is_fresh()); + assert!(!client.poll().await.is_fresh(), "empty, unconfirmed"); + assert!(client.poll().await.is_fresh(), "busy again"); + + let after = client.poll().await; + assert!( + matches!( + stale_reason(&after), + StaleReason::UnconfirmedEmpty { seen: 1, .. } + ), + "the streak must have reset to 1, not carried over" + ); +} + +/// **A failed poll is not an empty answer.** Interleaving the two used to walk +/// the confirmation counter up regardless — empty, 503, empty, 503, empty — so +/// three empties spread across a flapping engine believed the empty set and +/// withdrew protection. A flapping engine is exactly when that pattern occurs, +/// and it is exactly when withdrawing is worst. +#[tokio::test] +async fn a_failed_poll_breaks_an_empty_streak() { + let busy = page(&[item("m1", "198.51.100.5")], false, None); + let empty = page(&[], false, None); + let stub = Stub::serve_with_status(vec![ + (200, busy), + (200, empty.clone()), + (503, "upstream is unhappy".into()), + (200, empty.clone()), + (503, "upstream is unhappy".into()), + (200, empty), + ]) + .await; + let mut client = stub.client(); + + assert!(client.poll().await.is_fresh(), "setup: a busy poll"); + + assert!(matches!( + stale_reason(&client.poll().await), + StaleReason::UnconfirmedEmpty { seen: 1, .. } + )); + assert!(matches!( + stale_reason(&client.poll().await), + StaleReason::Malformed(_) + )); + + // Back to empty. Were failures transparent, this would be the second + // confirmation; the failure reset it, so it is the first again. + assert!( + matches!( + stale_reason(&client.poll().await), + StaleReason::UnconfirmedEmpty { seen: 1, .. } + ), + "a failed poll must reset the confirmation count" + ); + assert!(matches!( + stale_reason(&client.poll().await), + StaleReason::Malformed(_) + )); + let last = client.poll().await; + assert!( + !last.is_fresh(), + "two empties separated by failures must not add up to a believed empty set" + ); +} diff --git a/docs/runbooks/bgp-backends.md b/docs/runbooks/bgp-backends.md new file mode 100644 index 0000000..fac1769 --- /dev/null +++ b/docs/runbooks/bgp-backends.md @@ -0,0 +1,90 @@ +# BGP backends: what each one can actually prove + +## Contents + +- [Choosing](#choosing) +- [What a confirmation means](#what-a-confirmation-means) +- [What happens when filterframe dies](#what-happens-when-filterframe-dies) +- [The origin community](#the-origin-community) + +## Choosing + +| `bgp mode` | Sessions owned by | Survives a filterframe restart | Status | +|---|---|---|---| +| `gobgp` | An external GoBGP sidecar | **Yes** | Default. Compile- and unit-tested; not yet exercised against a live sidecar. | +| `embedded` | filterframe itself | No | **Not implemented.** See below. | + +In `mode observe` neither is contacted: the whole loop runs against an in-memory +speaker, which is how filterframe is meant to arrive on a node. + +## What a confirmation means + +This is the most misread part of the system, so it is stated plainly. + +**BGP has no application-layer acknowledgement.** There is no message a peer +sends to say "I received and accepted your UPDATE". No implementation can report +one, so every confirmation is evidence of something weaker. + +The backend reports how strong its evidence is, and the divert quorum is built +on that: + +| Fidelity | Means | Which backend | +|---|---|---| +| `Synthetic` | Nothing was established | mock (`observe` mode) | +| `PolicyEligible` | The path is best for this peer and passes its export policy | gobgp | +| `Written` | The bytes left our socket and the session stayed up | embedded (not implemented) | +| `Flushed` | …and the peer's kernel acknowledged them | embedded (not implemented) | + +**On the gobgp backend, a quorum of 2-of-3 means "two reflectors would send +this", not "two reflectors received it."** GoBGP derives its adjacency-out on +demand and has no record of transmission to consult. That is the honest ceiling, +and it is why the settle dwell after a quorum exists: the quorum establishes +eligibility, and the dwell covers the propagation it says nothing about. + +Even `Flushed` does not prove the peer's BGP process *acted* on the update. It +proves its TCP stack received the bytes. + +## What happens when filterframe dies + +**gobgp**: nothing changes on the wire. GoBGP holds every announcement and +withdrawal exactly as they were. A restarted filterframe re-asserts desired state +idempotently. This is the main reason it is the default — the moment a +half-completed divert is most fragile is precisely when a process is dying. + +**embedded**: every session drops with the process. Announcements vanish, which +for RTBH is the safe direction (a dead filterframe un-blackholes a victim) but +for a divert means the signal route ages out and transit returns — also safe, by +the design in [edge-policy](edge-policy.md), but only because the edge owns the +prefix. + +That asymmetry is the single most surprising thing about the two backends, which +is why it is here rather than in a docstring. + +## The origin community + +In `gobgp` mode the sidecar outlives filterframe, so a restarted daemon finds +paths already in the RIB and has to decide which are its own. + +`bgp origin-community` is attached to everything filterframe originates. Paths +carrying it are ours — adopt or withdraw them. Paths without it belong to +somebody else and are **never touched**, whatever the desired state says. + +Set it. Without it, filterframe cannot distinguish its own crash orphans from +another controller's work, and the conservative behaviour it falls back to is to +leave everything alone. + +## On the embedded backend + +It is not implemented, and shipping it prematurely would be worse than not +having it. + +The research behind the decision: `netgauze-bgp-speaker` 0.13.0 has no public +route-origination path — `PeerHandle`'s send channel is private, and the workable +route is driving `Peer` directly through a six-parameter generic whose +cancel-safety is undocumented. That crate had 24 downloads on that version and +26% documentation coverage. + +Being the first production user of a Rust BGP originator, inside a DDoS +mitigation path, is a bad place to be first. If you need a pure-Rust speaker, the +better move is `rustybgp`, which implements GoBGP's gRPC API deliberately — so +it is a configuration change here, not a rewrite. diff --git a/docs/runbooks/edge-policy.md b/docs/runbooks/edge-policy.md new file mode 100644 index 0000000..dd5ae12 --- /dev/null +++ b/docs/runbooks/edge-policy.md @@ -0,0 +1,125 @@ +# Edge policy: how transit suppression actually works + +## Contents + +- [The mechanism](#the-mechanism) +- [Why not just withdraw the prefix](#why-not-just-withdraw-the-prefix) +- [FRR](#frr) +- [Junos](#junos) +- [IOS-XR](#ios-xr) +- [Verifying it](#verifying-it) +- [If your platform cannot do this](#if-your-platform-cannot-do-this) + +## The mechanism + +filterframe never announces your protected prefix, and therefore never withdraws +it. Your edge router announces it to transit unconditionally, exactly as it does +today. + +To divert, filterframe announces a separate **signal route** carrying a divert +community. Your edge's export policy suppresses the protected prefix while that +signal is present. + +``` + filterframe edge router + │ │ + │ ── signal route ─────────────────▶│ export policy sees it + │ (community DIVERT-ACTIVE) │ → stops advertising the + │ │ protected prefix to transit +``` + +Signal present → transit stops carrying the prefix. +Signal absent → transit carries it. + +**Including when the signal is absent because filterframe died.** The route ages +out with the session and normal routing returns on its own. That is the entire +point. + +## Why not just withdraw the prefix + +If filterframe originated the protected prefix and withdrew it to divert, then +filterframe crashing would withdraw it too — darkening the whole block. The +daemon would be a single point of failure for the thing it exists to protect. + +Additive signalling inverts that. filterframe only ever *adds* BGP objects, so +its worst failure is that it stops adding. + +## FRR + +``` +router bgp + bgp conditional-advertisement timer 5 + address-family ipv4 unicast + network + neighbor advertise-map PROTECTED-PFX non-exist-map DIVERT-SIGNAL + exit-address-family +! +ip prefix-list PROTECTED seq 5 permit +ip prefix-list SIGNAL seq 5 permit +! +bgp community-list standard DIVERT-ACTIVE permit :666 +! +route-map PROTECTED-PFX permit 10 + match ip address prefix-list PROTECTED +! +route-map DIVERT-SIGNAL permit 10 + match ip address prefix-list SIGNAL + match community DIVERT-ACTIVE +``` + +**Reaction latency is bounded by `bgp conditional-advertisement timer`.** The +default is 60 seconds; the minimum is 5. Set it low — diversion is a +tens-of-seconds operation and a 60-second scan is most of that budget spent +waiting. + +FRR has had bugs in conditional advertisement (see FRRouting/frr#14598). **Lab +verify on your exact build, including a SIGKILL test**, before depending on it. + +## Junos + +``` +policy-statement EXPORT-TRANSIT { + term suppress-while-diverted { + from { + route-filter exact; + condition divert-active; + } + then reject; + } +} +condition divert-active { + if-route-exists { + ; + table inet.0; + } +} +``` + +## IOS-XR + +Use RPL with an `if rib-has-route` condition on the signal prefix, rejecting the +protected prefix in the transit export policy when it is satisfied. + +## Verifying it + +This is the prerequisite filterframe **cannot check for you**, and `preflight` +says so rather than implying a clean bill of health. Verify it by hand: + +1. With no signal announced, confirm the edge is advertising the protected + prefix to transit. +2. Announce the signal route by hand, with the community. Confirm the edge stops + advertising the protected prefix, within the conditional-advertisement timer. +3. Withdraw the signal. Confirm the prefix returns. +4. **Announce the signal, then `kill -9` whatever is announcing it.** Confirm the + prefix returns on its own. This is the test that matters; the rest is + plumbing. + +## If your platform cannot do this + +The fallback is filterframe originating the protected prefix directly and +withdrawing it, with a watchdog. That is a materially weaker posture: process +death during a divert leaves the prefix unannounced until restart. + +Do not take that fallback silently. It should be a written, signed-off risk +acceptance, because it converts filterframe from a component that fails safe +into one that can cause the outage it was installed to prevent. diff --git a/docs/runbooks/policy-loss.md b/docs/runbooks/policy-loss.md new file mode 100644 index 0000000..889d68f --- /dev/null +++ b/docs/runbooks/policy-loss.md @@ -0,0 +1,79 @@ +# The policy source is unreachable + +## Contents + +- [What is happening](#what-is-happening) +- [Why filterframe holds](#why-filterframe-holds) +- [What you will see](#what-you-will-see) +- [What to do](#what-to-do) +- [The one thing that still releases](#the-one-thing-that-still-releases) + +## What is happening + +filterframe cannot read the active mitigation list. It is holding every +engagement it already had and withdrawing nothing. + +This is the design working, not failing. + +## Why filterframe holds + +"The policy engine is down" and "no mitigations are active" produce the same +thing over HTTP: an empty answer. A client that acts on the second when the +first is true withdraws protection during exactly the kind of event that takes a +policy engine offline. + +So filterframe treats a failed poll as *no information*, never as *no attacks*. +This is enforced structurally rather than by discipline: the view of the world +is either fresh or stale, and the stale variant carries no mitigation list at +all. There is nothing to iterate, so there is no expression anywhere in the +daemon that computes a withdrawal from a failed poll. + +**There is no age at which "I cannot reach my policy engine" becomes "there is +no attack."** filterframe has no give-up timer, and adding one would be a bug. + +## What you will see + +In the log, once per tick at WARN: + +``` +policy source is stale; holding every engagement and releasing nothing +except what its own ceiling has expired + reason=unreachable: error sending request ... holding=2 +``` + +In `filterframe status`, an ATTENTION line naming the reason. + +In metrics, `filterframe_policy_stale_seconds` climbing. **This is the series to +alert on.** It is the only thing that distinguishes "quiet because nothing is +happening" from "quiet because we cannot see anything" — every other metric +looks identical in both cases. + +## What to do + +Read the reason; each means something different. + +| Reason | What it means | +|---|---| +| `unreachable` | Network, DNS, or the engine is down. Check the engine first. | +| `unauthorized` | The token, **or** the engine is running in credentials auth mode, which rejects bearer tokens outright before checking them. The message says so. | +| `rate limited` | The engine's bucket is shared with its dashboard and every detector. Something else is hammering it. filterframe is already backing off. | +| `malformed response` | Something other than the engine answered — a proxy error page, usually. The message says when it looks like HTML. | +| `pagination` | The engine returned an inconsistent page set. filterframe refused to treat a truncated list as a complete one. | +| `empty result not yet confirmed` | Not an error. The list went from non-empty to empty and is being confirmed over three polls before it is believed. | + +Nothing needs doing to filterframe. When the engine returns, the next successful +poll converges normally and re-announces nothing that is already held. + +## The one thing that still releases + +A module's own ceiling. `max-lifetime` in the `rtbh` module measures how long an +engagement has stood *unconfirmed*, and it keeps running while the policy source +is unreachable — otherwise an engine that never came back would leave an address +dark forever. + +It is bounded, operator-configured, and deliberately the only exception. Absence +of demand never releases anything on this path; only the expiry of a clock you +set. + +If you need engagements to outlive a long outage, raise `max-lifetime`. If you +need them gone, use `filterframe drain` — explicit, never implicit. diff --git a/docs/runbooks/return-path.md b/docs/runbooks/return-path.md new file mode 100644 index 0000000..3cb893d --- /dev/null +++ b/docs/runbooks/return-path.md @@ -0,0 +1,95 @@ +# The return path + +## Contents + +- [Why it gates diversion](#why-it-gates-diversion) +- [The check, and the bug it avoids](#the-check-and-the-bug-it-avoids) +- [Hysteresis](#hysteresis) +- [When it dies mid-divert](#when-it-dies-mid-divert) +- [Building the tunnel](#building-the-tunnel) + +## Why it gates diversion + +Scrubbed traffic comes back over a tunnel. If that tunnel is not carrying, the +diversion is not a mitigation — it is a hole you built by hand, and a worse one +than the attack, because it affects the whole prefix rather than one address. + +So `scrub-divert` refuses to engage while the return path is not usable, and the +mitigation falls through to the fast tier instead. **"We cannot divert" never +means "we do nothing."** + +## The check, and the bug it avoids + +filterframe reads `IFF_UP` from `/sys/class/net//flags`. It does **not** +read `operstate`. + +GRE tunnels have no carrier, so the kernel never sets an RFC 2863 operational +state for them and reports `unknown` forever. The kernel's own documentation +defines `unknown` as meaning the interface must be considered usable. A gate +written the obvious way — `operstate == "up"` — refuses to divert on a perfectly +healthy tunnel, in production, only during an attack, which is the worst possible +time to discover it. + +`operstate` is still consulted, but only a value that actively contradicts the +admin flag (`down`, `lowerlayerdown`) counts as a failure. + +Related: **Linux has no GRE keepalives.** A tunnel whose far end has vanished +stays `UP` forever, so the admin flag alone establishes very little. Treat it as +necessary, not sufficient. + +## Hysteresis + +Three consecutive failures to declare it down; five consecutive successes to +trust it again. Asymmetric on purpose: a flapping tunnel that is trusted quickly +produces a divert loop, and every cycle is real BGP churn at every peer involved. + +The signal is three-valued, and the distinction matters: + +| State | New diversions | Existing diversion | +|---|---|---| +| `Up` | allowed | untouched | +| `Blocked` (degraded, or unreadable) | **blocked** | **untouched** | +| `Down` (confirmed) | blocked | **emergency return** | + +An unreadable sysfs file blocks engaging and nothing more. It is a bad reason to +move a customer's prefix across the Internet. + +## When it dies mid-divert + +This is the most urgent condition in the daemon. Clean traffic is being sent into +a tunnel that goes nowhere. + +filterframe drops the divert signal immediately, restoring transit. Traffic comes +back dirty, which is worse than scrubbed and enormously better than gone. The +probe runs on its own cadence rather than the reconcile tick, because waiting out +a tick to notice is a second too long. + +The prefix then sits blocked until the return path has been up for long enough to +be trusted again. + +## Building the tunnel + +filterframe does not create it. `filterframe tunnel-render` emits the +systemd-networkd units with the arithmetic and the traps handled: + +```bash +filterframe tunnel-render gre-scrub0 \ + --local --remote \ + --key --address +``` + +Three things it gets right that are easy to get wrong by hand: + +- **MTU.** 1476 for plain GRE over IPv4, and every option comes off that — a key + costs four more bytes. Enabling one without lowering the MTU gives intermittent + large-packet loss that looks exactly like congestion. +- **`rp_filter`.** A scrubbing return path is asymmetric by construction, so + strict reverse-path filtering drops it. The kernel takes + `max(all, )`, so setting it on the tunnel alone does nothing while + `net.ipv4.conf.all.rp_filter` is 1. +- **MSS clamping.** Mandatory, not optional. PMTUD depends on ICMP surviving, and + ICMP is what gets dropped during an attack. + +One hazard only filterframe can check: **the provider's tunnel endpoint must not +be inside any prefix you divert.** If it is, the outer packets get routed into +the tunnel — an instant loop and a total outage. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..a5f6edf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +# Pinned exactly rather than "stable", so a toolchain release never changes +# what CI compiled versus what a contributor compiled. The MSRV in +# Cargo.toml (rust-version) is deliberately lower — see the comment there. +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"]