This guide takes you from an empty crate to your own WebAssembly filter running inside Arion's HTTP filter chain. Use it when the built-in filters do not cover your logic and you want custom code — header rewrites, auth decisions, counters — in the request path without forking the proxy.
Outcome
- A Rust crate compiled to WebAssembly that sets a header on every request and every response.
- Arion loading the module from disk and running it ahead of the router.
curlshowing the header your filter added.
Prerequisites
- Rust with the Wasm target:
rustup target add wasm32-unknown-unknown. - The Arion source checkout — the SDK crates live in the repo and are referenced by path.
- An Arion binary built with the
wasmfeature; a stock build refuses to start on a config that contains a Wasm filter.
requires: wasm
cargo build -p arion-proxy --features wasmThe architecture
- Your filter is a
.wasmmodule implementing the SDK'sPlugintrait; only the hooks you write are exported. - Arion's Wasm host (Wasmtime) loads the module named in the filter config and calls those hooks at each phase of the HTTP transaction.
- In the chain the module behaves like any built-in HTTP filter: it runs before the router and can mutate the request, the response, or short-circuit with a direct response.
The complete config
# arion.yaml, composed from 4 features.
logging:
log_level: info
envoy_bootstrap:
static_resources:
listeners:
- name: http_8080
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- name: http_8080_chain
filters:
- name: http_gateway
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
http_filters:
- name: arion.filters.http.wasm.my_filter
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
config:
name: my_filter
vm_config:
code:
local:
filename: /etc/arion/wasm/my_filter.wasm
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
route_config:
name: http_8080_routes
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match:
prefix: /
route:
cluster: app
clusters:
- name: app
connect_timeout: 5s
type: STATIC
load_assignment:
cluster_name: app
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 10.0.5.11, port_value: 8080 }Walkthrough
1. Scaffold the crate
Create a library crate. The package name becomes the module filename, so call it my_filter to match the config:
[package]
name = "my_filter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
arion_wasm_sdk = { path = "../arion/arion-wasm-sdk" }
tracing = "0.1"
http = "1.0"
crate-type = ["cdylib"] is required — the host loads a C-ABI dynamic library. The SDK's package name is arion_wasm_sdk (with underscores); point the path at arion-wasm-sdk/ in your checkout.
2. Write the filter
The plugin struct must implement Default. The #[arion_plugin] macro generates the FFI exports for exactly the hooks you implement:
use arion_wasm_sdk::{
arion_plugin, init_tracing, FilterAction, HttpHeaders, Plugin, RequestHandle, ResponseHandle,
};
#[derive(Default)]
struct MyFilter;
#[arion_plugin]
impl Plugin for MyFilter {
fn on_plugin_start(&mut self) {
let _ = init_tracing();
tracing::info!("my_filter loaded");
}
fn on_request_headers(&mut self, ctx: &RequestHandle<HttpHeaders>) -> FilterAction {
let _ = ctx.set_header(
http::header::HeaderName::from_static("x-custom-set"),
http::header::HeaderValue::from_static("set-value"),
);
FilterAction::Continue
}
fn on_response_headers(&mut self, ctx: &ResponseHandle<HttpHeaders>) -> FilterAction {
let _ = ctx.set_header(
http::header::HeaderName::from_static("x-response-set"),
http::header::HeaderValue::from_static("res-set-value"),
);
FilterAction::Continue
}
}
Returning FilterAction::Continue hands the request on; the other actions buffer the body or short-circuit with a direct response — see the Rust SDK.
3. Build the module
cargo build --target wasm32-unknown-unknown --release
The module lands at target/wasm32-unknown-unknown/release/my_filter.wasm. Copy it where the config expects it:
sudo cp target/wasm32-unknown-unknown/release/my_filter.wasm /etc/arion/wasm/my_filter.wasm4. Attach it to the listener
In the config above, the filter chain is the Wasm filter followed by the router. The typed_config is Envoy's Wasm filter type: config.name names the plugin instance and vm_config.code.local.filename points at the module on disk. Field-level detail is on the Wasm filters page.
Run and verify
The recipe proxies / to the app cluster at 10.0.5.11:8080; for a local test, change that endpoint to 127.0.0.1:5678 and run any HTTP server there. Then start the feature-enabled binary:
target/debug/arion --config arion.yaml
Call the listener:
curl -i http://127.0.0.1:8080/
The response includes the header the filter set on the response path:
HTTP/1.1 200 OK
x-response-set: res-set-value
The request-side header travels upstream: if your backend echoes headers, you will see x-custom-set: set-value arrive with every proxied request.
Production notes
- Instances are pooled. The host keeps a pool of warm module instances per filter and reuses them across requests, so treat fields on your plugin struct as per-instance scratch space, not global state.
- For state that must be consistent across workers — counters, shared lists — use the SDK's shared atomics and blobs, and for decisions that need another service use the HTTP/gRPC callouts; both are covered in callouts and shared state.
- Keep modules small: build with
--release, implement only the hooks you need (unimplemented phases are never called), and avoid heavyweight dependencies. init_tracing()bridges thetracingmacros to Arion's logs, so your filter's diagnostics appear alongside the proxy's own.