diff --git a/frameworks/veil/Dockerfile b/frameworks/veil/Dockerfile new file mode 100644 index 000000000..124f0c5da --- /dev/null +++ b/frameworks/veil/Dockerfile @@ -0,0 +1,13 @@ +# The Proxy-Wasm module that answers /baseline11, /baseline2, /pipeline and /json/{count}. +FROM rust:1.98-bookworm AS filter +RUN rustup target add wasm32-wasip1 +WORKDIR /filter +COPY filter/ . +RUN cargo build --release --locked --target wasm32-wasip1 + +# veil's published container image (full-container build: epoll readiness reactor, static binary). +FROM ghcr.io/aofusa/veil:musl-v0.10.1 +COPY --from=filter /filter/target/wasm32-wasip1/release/httparena_filter.wasm /etc/veil/httparena_filter.wasm +COPY veil.toml /etc/veil/veil.toml +EXPOSE 8080 8081 8082 8443/tcp 8443/udp +CMD ["/veil", "-c", "/etc/veil/veil.toml"] diff --git a/frameworks/veil/README.md b/frameworks/veil/README.md new file mode 100644 index 000000000..176ec7b31 --- /dev/null +++ b/frameworks/veil/README.md @@ -0,0 +1,34 @@ +# veil + +[veil](https://github.com/aofusa/veil-proxy) is a reverse proxy and web server written in Rust on its own async +runtime (io_uring on Linux by default; this entry uses the published container image, which is the +`full-container` build with the epoll readiness reactor). This is an **infrastructure** entry: veil is configured, +not programmed. + +## How the endpoints are answered + +| Endpoint | Answered by | +|---|---| +| `/baseline11` (GET, POST with Content-Length or chunked), `/baseline2`, `/pipeline`, `/json/{count}?m=` | A Proxy-Wasm module (`filter/`, Rust, `wasm32-wasip1`) loaded into veil. It answers with `send_local_response`; the POST body is read in the request-body phase. The route is `type = "Respond"` with the module, so nothing is proxied. | +| `/static/*` | veil's File backend: br/gzip compressed on the fly on HTTP/1.1, HTTP/2 and HTTP/3 (`[route.compression]`, cached; precompressed siblings are not used), uncompressed responses with `sendfile(2)` on HTTP/1.1, `open_file_cache` + `static_file_cache` with `watch = true` (inotify), so replaced files are served at once. | + +The dataset is read once by veil at startup (`[[wasm.modules]] data_files`) and handed to the module as a property; +the module parses it once per instance (`instance_reuse = true`) and **serializes the JSON response on every +request**. Nothing is cached between requests. + +## Listeners (one process) + +| Port | Listener | +|---|---| +| 8080 | HTTP/1.1 (h2c by prior knowledge also accepted) | +| 8081 | TLS, HTTP/1.1 only (no `h2` in ALPN) | +| 8082 | h2c | +| 8443 | TLS h2 + HTTP/1.1, and HTTP/3 (quiche) on UDP 8443 | + +All four are `[[listener]]` entries in `veil.toml`, served by one veil process. Worker threads default to the CPUs +the container may use (affinity and cgroup quota). The access log is off; veil logs nothing per request. + +## Security + +The configuration keeps veil's container hardening on: privileges dropped to `nonroot`, seccomp filter mode and +Landlock limited to `/certs`, `/data` and `/etc/veil`. diff --git a/frameworks/veil/filter/Cargo.lock b/frameworks/veil/filter/Cargo.lock new file mode 100644 index 000000000..18566d464 --- /dev/null +++ b/frameworks/veil/filter/Cargo.lock @@ -0,0 +1,153 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "httparena-filter" +version = "0.1.0" +dependencies = [ + "proxy-wasm", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "proxy-wasm" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0" +dependencies = [ + "hashbrown", + "log", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/frameworks/veil/filter/Cargo.toml b/frameworks/veil/filter/Cargo.toml new file mode 100644 index 000000000..058adc83a --- /dev/null +++ b/frameworks/veil/filter/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "httparena-filter" +version = "0.1.0" +edition = "2021" +description = "Proxy-Wasm module that answers the HttpArena endpoints inside veil" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +lto = true +opt-level = 3 +codegen-units = 1 +panic = "abort" + +[workspace] diff --git a/frameworks/veil/filter/src/lib.rs b/frameworks/veil/filter/src/lib.rs new file mode 100644 index 000000000..acd9748dc --- /dev/null +++ b/frameworks/veil/filter/src/lib.rs @@ -0,0 +1,238 @@ +//! Proxy-Wasm module that answers the HttpArena endpoints inside veil. +//! +//! - `GET /baseline11?a=&b=`, `POST /baseline11` (the body is an integer): the sum, as `text/plain`. +//! - `GET /baseline2?a=&b=`: the sum (HTTP/2 and HTTP/3). +//! - `GET /pipeline`: `ok`. +//! - `GET /json/{count}?m=`: the first `count` items of the dataset with `total = price * quantity * m`, +//! serialized for every request. +//! +//! The dataset comes from veil's `data_files` (`plugin_data.dataset`). It is parsed once per instance +//! (`instance_reuse = true` keeps the instance between requests) into typed fields, like nginx's +//! module; the response is written field by field for every request into a reused buffer. + +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use serde::Deserialize; +use std::cell::RefCell; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Warn); + proxy_wasm::set_root_context(|_| -> Box { Box::new(Root) }); +}} + +#[derive(Deserialize)] +struct Rating { + score: i64, + count: i64, +} + +#[derive(Deserialize)] +struct Item { + id: i64, + name: String, + category: String, + price: i64, + quantity: i64, + active: bool, + tags: Vec, + rating: Rating, +} + +/// One dataset item held as typed fields; the strings are kept JSON-escaped (with their quotes) so they are +/// re-emitted verbatim. The body is still written field by field for every request (`total` depends on `m`). +struct Row { + id: i64, + name: String, + category: String, + price: i64, + quantity: i64, + active: bool, + tags: String, // `["a","b"]` + score: i64, + count: i64, +} + +fn quoted(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) +} + +fn load(raw: &[u8]) -> Vec { + let items: Vec = serde_json::from_slice(raw).unwrap_or_default(); + items + .into_iter() + .map(|it| Row { + id: it.id, + name: quoted(&it.name), + category: quoted(&it.category), + price: it.price, + quantity: it.quantity, + active: it.active, + tags: serde_json::to_string(&it.tags).unwrap_or_else(|_| "[]".into()), + score: it.rating.score, + count: it.rating.count, + }) + .collect() +} + +fn put_i64(out: &mut Vec, v: i64) { + let mut buf = [0u8; 20]; + let mut i = buf.len(); + let neg = v < 0; + let mut n = v.unsigned_abs(); + loop { + i -= 1; + buf[i] = b'0' + (n % 10) as u8; + n /= 10; + if n == 0 { + break; + } + } + if neg { + out.push(b'-'); + } + out.extend_from_slice(&buf[i..]); +} + +fn write_body(out: &mut Vec, rows: &[Row], m: i64) { + out.extend_from_slice(b"{\"items\":["); + for (i, r) in rows.iter().enumerate() { + if i > 0 { + out.push(b','); + } + out.extend_from_slice(b"{\"id\":"); + put_i64(out, r.id); + out.extend_from_slice(b",\"name\":"); + out.extend_from_slice(r.name.as_bytes()); + out.extend_from_slice(b",\"category\":"); + out.extend_from_slice(r.category.as_bytes()); + out.extend_from_slice(b",\"price\":"); + put_i64(out, r.price); + out.extend_from_slice(b",\"quantity\":"); + put_i64(out, r.quantity); + out.extend_from_slice(if r.active { b",\"active\":true,\"tags\":" } else { b",\"active\":false,\"tags\":" }); + out.extend_from_slice(r.tags.as_bytes()); + out.extend_from_slice(b",\"rating\":{\"score\":"); + put_i64(out, r.score); + out.extend_from_slice(b",\"count\":"); + put_i64(out, r.count); + out.extend_from_slice(b"},\"total\":"); + put_i64(out, r.price * r.quantity * m); + out.push(b'}'); + } + out.extend_from_slice(b"],\"count\":"); + put_i64(out, rows.len() as i64); + out.push(b'}'); +} + +thread_local! { + static DATASET: RefCell>> = const { RefCell::new(None) }; + /// Response buffer reused between requests (the instance is reused, see `instance_reuse`) + static OUT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct Root; + +impl Context for Root {} + +impl RootContext for Root { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _context_id: u32) -> Option> { + Some(Box::new(Arena)) + } +} + +struct Arena; + +impl Context for Arena {} + +fn query_param(query: &str, name: &str) -> Option { + query + .split('&') + .filter_map(|kv| kv.split_once('=')) + .find(|(k, _)| *k == name) + .and_then(|(_, v)| v.parse().ok()) +} + +fn split_path(full: &str) -> (&str, &str) { + full.split_once('?').unwrap_or((full, "")) +} + +impl Arena { + fn path(&self) -> String { + self.get_property(vec!["request", "path"]) + .and_then(|v| String::from_utf8(v).ok()) + .unwrap_or_default() + } + + fn text(&self, body: &[u8]) { + self.send_http_response(200, vec![("content-type", "text/plain")], Some(body)); + } + + fn not_found(&self) { + self.send_http_response(404, vec![("content-type", "text/plain")], Some(b"not found")); + } + + fn sum(&self, query: &str, extra: i64) { + let s = query_param(query, "a").unwrap_or(0) + query_param(query, "b").unwrap_or(0) + extra; + self.text(s.to_string().as_bytes()); + } + + fn json(&self, count: &str, query: &str) { + let Ok(count) = count.parse::() else { + return self.not_found(); + }; + let m = query_param(query, "m").unwrap_or(1); + DATASET.with(|d| { + let mut d = d.borrow_mut(); + if d.is_none() { + let raw = self.get_property(vec!["plugin_data", "dataset"]).unwrap_or_default(); + *d = Some(load(&raw)); + } + let rows = d.as_deref().unwrap_or(&[]); + let rows = &rows[..count.min(rows.len())]; + OUT.with(|o| { + let mut out = o.borrow_mut(); + out.clear(); + out.reserve(512 * rows.len() + 32); + write_body(&mut out, rows, m); + self.send_http_response(200, vec![("content-type", "application/json")], Some(&out)); + }); + }); + } +} + +impl HttpContext for Arena { + fn on_http_request_headers(&mut self, _num_headers: usize, end_of_stream: bool) -> Action { + let full = self.path(); + let (path, query) = split_path(&full); + match path { + "/baseline11" if end_of_stream => self.sum(query, 0), + "/baseline11" => return Action::Continue, // the body phase answers + "/baseline2" => self.sum(query, 0), + "/pipeline" => self.text(b"ok"), + p => match p.strip_prefix("/json/") { + Some(count) => self.json(count, query), + None => self.not_found(), + }, + } + Action::Pause + } + + fn on_http_request_body(&mut self, body_size: usize, end_of_stream: bool) -> Action { + if !end_of_stream { + return Action::Pause; + } + let full = self.path(); + let (_, query) = split_path(&full); + let body = self.get_http_request_body(0, body_size).unwrap_or_default(); + let n: i64 = std::str::from_utf8(&body) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + self.sum(query, n); + Action::Pause + } +} diff --git a/frameworks/veil/meta.json b/frameworks/veil/meta.json new file mode 100644 index 000000000..8ce0c3f22 --- /dev/null +++ b/frameworks/veil/meta.json @@ -0,0 +1,24 @@ +{ + "display_name": "veil", + "language": "Rust", + "type": "infrastructure", + "engine": "veil", + "description": "veil 0.10 reverse proxy from its published container image (full-container build: epoll readiness reactor, no tokio), one process with four listeners. A Proxy-Wasm module loaded into veil answers /baseline11 (including the POST body), /baseline2, /pipeline and /json/{count}, serializing the JSON body per request from the dataset veil loads at startup; /static/* is veil's File backend (sendfile, on-the-fly compression on every protocol, caches that follow the disk). TLS via rustls: HTTP/1.1 on :8081, h2 and h3 (quiche) on :8443, h2c on :8082.", + "repo": "https://github.com/aofusa/veil-proxy", + "website": "https://github.com/aofusa/veil-proxy", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json-tls", + "static-tls", + "baseline-h2", + "static-h2", + "baseline-h2c", + "json-h2c", + "baseline-h3", + "static-h3" + ], + "maintainers": ["aofusa"] +} diff --git a/frameworks/veil/veil.toml b/frameworks/veil/veil.toml new file mode 100644 index 000000000..d09bbcd32 --- /dev/null +++ b/frameworks/veil/veil.toml @@ -0,0 +1,95 @@ +# veil for HttpArena: one process, four listeners. +# The dynamic endpoints are answered by a Proxy-Wasm module loaded into veil (httparena_filter.wasm); +# /static/* is veil's File backend. Nothing is proxied to an upstream application. + +[server] +graceful_shutdown_timeout_secs = 5 + +[[listener]] +name = "plain" # HTTP/1.1 (and h2c by prior knowledge) +listen = "0.0.0.0:8080" +tls = false + +[[listener]] +name = "h1tls" # TLS, HTTP/1.1 only (no h2 in ALPN) +listen = "0.0.0.0:8081" +tls = true +http2 = false + +[[listener]] +name = "h2c" # cleartext HTTP/2 (prior knowledge) +listen = "0.0.0.0:8082" +tls = false + +[[listener]] +name = "https" # TLS h2 + HTTP/1.1, and HTTP/3 on UDP 8443 +listen = "0.0.0.0:8443" +tls = true +http2 = true +http3 = true + +[tls] +cert_path = "/certs/server.crt" +key_path = "/certs/server.key" + +[logging] +level = "warn" + +[access_log] +enabled = false + +[security] +drop_privileges_user = "nonroot" +drop_privileges_group = "nonroot" +enable_seccomp = true +seccomp_mode = "filter" +enable_landlock = true +landlock_read_paths = ["/certs", "/data", "/etc/veil"] +landlock_write_paths = [] + +[wasm] +enabled = true + +[wasm.defaults] +max_execution_time_ms = 100 + +[wasm.defaults.pooling] +# every worker of every listener keeps reused instances +total_memories = 4096 +total_tables = 4096 + +[[wasm.modules]] +name = "arena" +path = "/etc/veil/httparena_filter.wasm" +instance_reuse = true +data_files = { dataset = "/data/dataset.json" } + +[wasm.modules.capabilities] +allow_request_headers_read = true +allow_request_body_read = true +allow_send_local_response = true +allowed_properties = ["request.path", "plugin_data.*"] + +[[route]] +[route.conditions] +path = "/static/*" +[route.action] +type = "File" +path = "/data/static/" +mode = "sendfile" +[route.open_file_cache] +enabled = true +watch = true +[route.static_file_cache] +enabled = true +watch = true +[route.compression] +enabled = true # br / gzip by Accept-Encoding; compressed results are cached + +[[route]] +modules = ["arena"] +[route.conditions] +path = "/*" +[route.action] +type = "Respond" +status = 404