diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f9825d..cee43c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Install WASI SDK uses: konsumer/install-wasi-sdk@v1 with: - version: "25" + version: "30" - name: Run clippy run: cargo clippy --all -- -D warnings @@ -49,7 +49,7 @@ jobs: - name: Install WASI SDK uses: konsumer/install-wasi-sdk@v1 with: - version: "25" + version: "30" - name: Check C++ Format run: /opt/wasi-sdk/bin/clang-format --dry-run --Werror include/**/*.h src/**/*.h src/**/*.cpp @@ -63,17 +63,22 @@ jobs: uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + - name: Install Viceroy run: cargo install viceroy --locked - name: Install WASI SDK uses: konsumer/install-wasi-sdk@v1 with: - version: "25" + version: "30" - uses: extractions/setup-just@v3 - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: wasm32-wasip1 - name: Build WebAssembly module run: just diff --git a/Cargo.lock b/Cargo.lock index 85e2f35..0b84006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -523,9 +523,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "link-cplusplus" -version = "1.0.10" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" dependencies = [ "cc", ] @@ -656,9 +656,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" diff --git a/Cargo.toml b/Cargo.toml index 00e5bc9..5acd0f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Kat Marchán ", "Sy Brand +#include + +namespace fastly::erl { +/// Errors that can arise during ERL operations. +class ERLError { +public: + enum Code { + Unexpected, + Unknown, + InvalidArgument, + }; + explicit ERLError(Code code) : code_(code) {} + Code code() const { return code_; } + +private: + Code code_; +}; + +/// A penalty box that can be used with the edge rate limiter or stand alone for +/// adding and checking if some entry is in the data set. +class PenaltyBox { +public: + explicit PenaltyBox(std::string name) : name_(std::move(name)) {}; + /// Add entry to a the penaltybox for the duration of ttl. Valid ttl span is + /// 1m to 1h. + tl::expected add(std::string_view entry, + std::chrono::minutes ttl); + /// Check if entry is in the penaltybox. + tl::expected has(std::string_view entry) const; + std::string_view name() const { return name_; } + +private: + std::string name_; +}; + +/// To be used for picking the duration in a rate counter `lookup_count` call +enum class CounterDuration { + TenSec = 10, + TwentySecs = 20, + ThirtySecs = 30, + FortySecs = 40, + FiftySecs = 50, + SixtySecs = 60 +}; + +/// To be used for picking the window in a rate counter `lookup_rate` or a ERL +/// `check_rate` call. +enum class RateWindow { + OneSec = 1, + TenSecs = 10, + SixtySecs = 60, +}; + +/// A rate counter that can be used with an edge rate limiter or stand alone for +/// counting and rate calculations +class RateCounter { +public: + explicit RateCounter(std::string name) : name_(std::move(name)) {} + /// Increment an entry in the ratecounter by delta. + tl::expected increment(std::string_view entry, + std::uint32_t delta); + /// Lookup the current rate for entry in the rate counter for a window. + tl::expected lookup_rate(std::string_view entry, + RateWindow window) const; + /// Lookup the current count for entry in the rate counter for a duration. + tl::expected + lookup_count(std::string_view entry, CounterDuration duration) const; + std::string_view name() const { return name_; } + +private: + std::string name_; +}; + +class ERL { +public: + ERL(RateCounter rate_counter, PenaltyBox penalty_box) + : rate_counter_(std::move(rate_counter)), + penalty_box_(std::move(penalty_box)) {} + + /// Increment an entry in a rate counter and check if the client has exceeded + /// some average number + /// of requests per second (RPS) over the window. If the client is over the + /// rps limit for the window, add to the penaltybox for ttl. Valid ttl span is + /// 1m to 1h. + tl::expected + check_rate(std::string_view entry, std::uint32_t delta, RateWindow window, + std::uint32_t limit, std::chrono::minutes ttl) const; + + const RateCounter &rate_counter() const { return rate_counter_; } + const PenaltyBox &penalty_box() const { return penalty_box_; } + +private: + RateCounter rate_counter_; + PenaltyBox penalty_box_; +}; +} // namespace fastly::erl +#endif \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e8594ed..a49da94 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,5 @@ [toolchain] +# We have to keep this at a version that syncs up with our llvm version. +# wask-sdk as of 30.0 is using LLVM 19, but Rust 1.88 onwards is on LLVM 21 channel = "1.86.0" targets = [ "wasm32-wasip1" ] \ No newline at end of file diff --git a/src/cpp/erl.cpp b/src/cpp/erl.cpp new file mode 100644 index 0000000..e6c19cb --- /dev/null +++ b/src/cpp/erl.cpp @@ -0,0 +1,84 @@ +#include "fastly.h" +#include +#include + +namespace { +fastly::erl::ERLError from_status(const fastly::Status &status) { + using Code = fastly::Status::Code; + switch (status.code()) { + case Code::InvalidArgument: + return fastly::erl::ERLError(fastly::erl::ERLError::Code::InvalidArgument); + case Code::GenericError: + return fastly::erl::ERLError(fastly::erl::ERLError::Code::Unexpected); + default: + return fastly::erl::ERLError(fastly::erl::ERLError::Code::Unknown); + } +} +} // namespace + +#define ERL_TRY(expr) \ + do { \ + auto status = (expr); \ + if (!status.is_ok()) { \ + return tl::unexpected(from_status(status)); \ + } \ + } while (0) + +namespace fastly::erl { +tl::expected PenaltyBox::add(std::string_view entry, + std::chrono::minutes ttl) { + // The host expects the TTL in seconds, even though it's truncated to minutes. + std::chrono::seconds ttl_seconds = + std::chrono::duration_cast(ttl); + ERL_TRY(fastly::penaltybox_add(name_.c_str(), name_.size(), entry.data(), + entry.size(), ttl_seconds.count())); + return {}; +} +tl::expected PenaltyBox::has(std::string_view entry) const { + alignas(4) bool has_out; + ERL_TRY(fastly::penaltybox_has(name_.c_str(), name_.size(), entry.data(), + entry.size(), &has_out)); + return has_out; +} + +tl::expected RateCounter::increment(std::string_view entry, + std::uint32_t delta) { + ERL_TRY(fastly::ratecounter_increment(name_.c_str(), name_.size(), + entry.data(), entry.size(), delta)); + return {}; +} + +tl::expected +RateCounter::lookup_rate(std::string_view entry, RateWindow window) const { + std::uint32_t rate_out; + ERL_TRY(fastly::ratecounter_lookup_rate( + name_.c_str(), name_.size(), entry.data(), entry.size(), + static_cast(window), &rate_out)); + return rate_out; +} + +tl::expected +RateCounter::lookup_count(std::string_view entry, + CounterDuration duration) const { + std::uint32_t count_out; + ERL_TRY(fastly::ratecounter_lookup_count( + name_.c_str(), name_.size(), entry.data(), entry.size(), + static_cast(duration), &count_out)); + return count_out; +} + +tl::expected +ERL::check_rate(std::string_view entry, std::uint32_t delta, RateWindow window, + std::uint32_t limit, std::chrono::minutes ttl) const { + // The host expects the TTL in seconds, even though it's truncated to minutes. + std::chrono::seconds ttl_seconds = + std::chrono::duration_cast(ttl); + alignas(4) bool blocked_out; + ERL_TRY(fastly::check_rate( + rate_counter_.name().data(), rate_counter_.name().size(), entry.data(), + entry.size(), delta, static_cast(window), limit, + penalty_box_.name().data(), penalty_box_.name().size(), + static_cast(ttl_seconds.count()), &blocked_out)); + return blocked_out; +} +} // namespace fastly::erl \ No newline at end of file diff --git a/src/cpp/fastly.h b/src/cpp/fastly.h new file mode 100644 index 0000000..cc9ba21 --- /dev/null +++ b/src/cpp/fastly.h @@ -0,0 +1,71 @@ +#ifndef FASTLY_H +#define FASTLY_H + +#include +#include +#include + +#define WASM_IMPORT(module, name) \ + __attribute__((import_module(module), import_name(name))) + +namespace fastly { +class Status { +public: + enum Code : std::uint32_t { + Ok = 0, + GenericError = 1, + InvalidArgument = 2, + BadHandle = 3, + BufferLen = 4, + Unsupported = 5, + BadAlign = 6, + HttpInvalid = 7, + HttpUser = 8, + HttpIncomplete = 9, + OptionalNone = 10, + HttpHeadTooLarge = 11, + HttpInvalidStatus = 12, + LimitExceeded = 13, + }; + bool is_ok() const { return code_ == Ok; } + explicit operator bool() const { return is_ok(); } + Code code() const { return code_; } + Status() = default; + Status(Code code) : code_(code) {} + Status(const Status &) = default; + Status &operator=(const Status &) = default; + +private: + Code code_; +}; +static_assert(std::is_trivial_v, "Status must be trivial"); + +WASM_IMPORT("fastly_erl", "check_rate") +Status check_rate(const char *rc, size_t rc_len, const char *entry, + size_t entry_len, uint32_t delta, uint32_t window, + uint32_t limit, const char *pb, size_t pb_len, uint32_t ttl, + bool *blocked_out); + +WASM_IMPORT("fastly_erl", "ratecounter_increment") +Status ratecounter_increment(const char *rc, size_t rc_len, const char *entry, + size_t entry_len, uint32_t delta); + +WASM_IMPORT("fastly_erl", "ratecounter_lookup_rate") +Status ratecounter_lookup_rate(const char *rc, size_t rc_len, const char *entry, + size_t entry_len, uint32_t window, + uint32_t *rate_out); + +WASM_IMPORT("fastly_erl", "ratecounter_lookup_count") +Status ratecounter_lookup_count(const char *rc, size_t rc_len, + const char *entry, size_t entry_len, + uint32_t duration, uint32_t *count_out); + +WASM_IMPORT("fastly_erl", "penaltybox_add") +Status penaltybox_add(const char *pb, size_t pb_len, const char *entry, + size_t entry_len, uint32_t ttl); + +WASM_IMPORT("fastly_erl", "penaltybox_has") +Status penaltybox_has(const char *pb, size_t pb_len, const char *entry, + size_t entry_len, bool *has_out); +} // namespace fastly +#endif // FASTLY_H \ No newline at end of file diff --git a/src/http/request.rs b/src/http/request.rs index 143cb58..032f56f 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -43,6 +43,7 @@ pub mod request { } } + #[allow(clippy::large_enum_variant)] pub enum PollResult { Pending(PendingRequest), Response(Response), diff --git a/src/lib.rs b/src/lib.rs index 414aa42..8998a6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1116,6 +1116,7 @@ mod ffi { } #[namespace = "fastly::sys::esi"] + extern "Rust" { type Processor; pub unsafe fn m_esi_processor_process_response( diff --git a/test/erl.cpp b/test/erl.cpp new file mode 100644 index 0000000..26dde83 --- /dev/null +++ b/test/erl.cpp @@ -0,0 +1,44 @@ +#include +#include + +using namespace fastly::erl; + +// The Viceroy implementation of ERL is stubbed, so this just ensures that +// the C++ wrapper functions are correctly calling into the host and handling +// the results. + +TEST_CASE("PenaltyBox add and has") { + PenaltyBox box("testbox"); + auto result = box.add("bad_entry", std::chrono::minutes(5)); + REQUIRE(result.has_value()); + + auto has_result = box.has("good_entry"); + REQUIRE(has_result.has_value()); + REQUIRE(!*has_result); +} + +TEST_CASE("RateCounter increment, lookup_rate, and lookup_count") { + RateCounter counter("testcounter"); + auto result = counter.increment("entry1", 1); + REQUIRE(result.has_value()); + + auto rate_result = counter.lookup_rate("entry1", RateWindow::OneSec); + REQUIRE(rate_result.has_value()); + + auto count_result = counter.lookup_count("entry1", CounterDuration::TenSec); + REQUIRE(count_result.has_value()); +} + +TEST_CASE("ERL check_rate") { + ERL erl(RateCounter("testcounter"), PenaltyBox("testbox")); + auto result = erl.check_rate("entry1", 1, RateWindow::OneSec, 5, + std::chrono::minutes(5)); + REQUIRE(result.has_value()); + + auto has_result = erl.penalty_box().has("entry1"); + REQUIRE(has_result.has_value()); +} + +// Required due to https://github.com/WebAssembly/wasi-libc/issues/485 +#include +int main(int argc, char *argv[]) { return Catch::Session().run(argc, argv); } \ No newline at end of file