Skip to content

Commit 4224b1c

Browse files
committed
Add runnable iRules-style Wasm policy example
1 parent a381f1b commit 4224b1c

13 files changed

Lines changed: 196 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ behavior when the change improves security or project direction.
99

1010
## 1.7.9 - Unreleased
1111

12+
### Added
13+
14+
- Add the checked-in F5 iRules-style route access policy and configuration
15+
example, with real listener coverage for normal origin traffic, pre-origin
16+
denial, and fail-closed plugin traps.
17+
- Add a dedicated Wasm policy-example smoke to the human test starter and the
18+
opt-in Wasm release gate.
19+
1220
### Security
1321

1422
- Open snapshot files with no-follow semantics before validating the opened

crates/fluxheim-config/src/config_tests_wasm.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ fn base_wasm_config(extra: &str) -> Config {
3333
.unwrap()
3434
}
3535

36+
#[cfg(feature = "wasm")]
37+
#[test]
38+
fn irules_access_policy_example_config_validates() {
39+
let config: Config = toml::from_str(include_str!(
40+
"../../../examples/wasm/irules-access-policy.toml"
41+
))
42+
.unwrap();
43+
44+
assert_eq!(config.validate(), Ok(()));
45+
}
46+
3647
#[cfg(feature = "wasm")]
3748
fn proxy_preview_wasm_config(plugin_fields: &str) -> Config {
3849
toml::from_str(&format!(

crates/fluxheim-server/src/native_http1_route_proxy_tests/wasm.rs

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,30 @@ async fn native_wasm_access_decision_denies_before_upstream() {
3333
assert!(response.ends_with("wasm access denied\n"));
3434
}
3535

36+
#[tokio::test]
37+
async fn native_wasm_irules_policy_example_allows_public_and_denies_admin() {
38+
let fixture =
39+
WasmRouteFixture::new(&[("irules_policy", WasmPluginBody::IrulesAccessPolicyExample)]);
40+
let upstream = super::upstream_expect_path("/public", "public origin").await;
41+
let mut config = fixture.config_with_attachments(
42+
upstream,
43+
vec![wasm_attachment("irules_policy", "admin", 100)],
44+
);
45+
config.vhosts[0].routes[0].name = "admin".to_owned();
46+
config.vhosts[0].routes[0].path_exact = Some("/admin".to_owned());
47+
let router =
48+
NativeHttp1HostRouter::from_config(&config, DownstreamHttp1Policy::default(), 0).unwrap();
49+
let proxy = router_listener(router).await;
50+
51+
let allowed = downstream_get(proxy, "/public").await;
52+
assert!(allowed.starts_with("HTTP/1.1 200 OK\r\n"));
53+
assert!(allowed.ends_with("public origin"));
54+
55+
let denied = downstream_get(proxy, "/admin").await;
56+
assert!(denied.starts_with("HTTP/1.1 403 Forbidden\r\n"));
57+
assert!(denied.ends_with("wasm access denied\n"));
58+
}
59+
3660
#[tokio::test]
3761
async fn native_wasm_access_decision_uses_first_deny_in_priority_order() {
3862
let fixture = WasmRouteFixture::new(&[
@@ -1875,6 +1899,7 @@ impl WasmRouteFixture {
18751899
#[derive(Clone, Copy)]
18761900
enum WasmPluginBody {
18771901
Decision(i32),
1902+
IrulesAccessPolicyExample,
18781903
HeaderPolicy,
18791904
ForbiddenHeader,
18801905
RouteDecision,
@@ -1897,8 +1922,11 @@ enum WasmPluginBody {
18971922
Trap,
18981923
#[cfg(feature = "wasm-proxy-abi")]
18991924
ProxyPreviewLogCall,
1925+
#[cfg(feature = "wasm-wasi")]
19001926
WasiRandomGranted,
1927+
#[cfg(feature = "wasm-wasi")]
19011928
WasiRandomDenied,
1929+
#[cfg(feature = "wasm-wasi")]
19021930
WasiForbiddenFdWrite,
19031931
BusyLoop,
19041932
}
@@ -1911,6 +1939,9 @@ impl WasmPluginBody {
19111939
r#"(module (func (export "fluxheim_access_decision") (result i32) i32.const {decision}))"#
19121940
)
19131941
}
1942+
Self::IrulesAccessPolicyExample => {
1943+
include_str!("../../../../examples/wasm/irules-access-policy.wat").to_owned()
1944+
}
19141945
Self::HeaderPolicy => {
19151946
r#"
19161947
(module
@@ -2203,6 +2234,7 @@ impl WasmPluginBody {
22032234
"#
22042235
.to_owned()
22052236
}
2237+
#[cfg(feature = "wasm-wasi")]
22062238
Self::WasiRandomGranted | Self::WasiRandomDenied => {
22072239
r#"
22082240
(module
@@ -2216,6 +2248,7 @@ impl WasmPluginBody {
22162248
"#
22172249
.to_owned()
22182250
}
2251+
#[cfg(feature = "wasm-wasi")]
22192252
Self::WasiForbiddenFdWrite => {
22202253
r#"
22212254
(module
@@ -2264,12 +2297,15 @@ fn wasm_plugin(root: &Path, name: &str, body: WasmPluginBody) -> fluxheim_config
22642297
let proxy_preview = matches!(body, WasmPluginBody::ProxyPreviewLogCall);
22652298
#[cfg(not(feature = "wasm-proxy-abi"))]
22662299
let proxy_preview = false;
2300+
#[cfg(feature = "wasm-wasi")]
22672301
let wasi_preview = matches!(
22682302
body,
22692303
WasmPluginBody::WasiRandomGranted
22702304
| WasmPluginBody::WasiRandomDenied
22712305
| WasmPluginBody::WasiForbiddenFdWrite
22722306
);
2307+
#[cfg(not(feature = "wasm-wasi"))]
2308+
let wasi_preview = false;
22732309
fluxheim_config::WasmPluginConfig {
22742310
name: name.to_owned(),
22752311
path,
@@ -2289,11 +2325,29 @@ fn wasm_plugin(root: &Path, name: &str, body: WasmPluginBody) -> fluxheim_config
22892325
fluxheim_config::WasmHostCallNamespace::FluxheimPolicyV1
22902326
},
22912327
wasi: fluxheim_config::WasmWasiCapabilitiesConfig {
2292-
clocks: matches!(body, WasmPluginBody::WasiForbiddenFdWrite),
2293-
randomness: matches!(
2294-
body,
2295-
WasmPluginBody::WasiRandomGranted | WasmPluginBody::WasiForbiddenFdWrite
2296-
),
2328+
clocks: {
2329+
#[cfg(feature = "wasm-wasi")]
2330+
{
2331+
matches!(body, WasmPluginBody::WasiForbiddenFdWrite)
2332+
}
2333+
#[cfg(not(feature = "wasm-wasi"))]
2334+
{
2335+
false
2336+
}
2337+
},
2338+
randomness: {
2339+
#[cfg(feature = "wasm-wasi")]
2340+
{
2341+
matches!(
2342+
body,
2343+
WasmPluginBody::WasiRandomGranted | WasmPluginBody::WasiForbiddenFdWrite
2344+
)
2345+
}
2346+
#[cfg(not(feature = "wasm-wasi"))]
2347+
{
2348+
false
2349+
}
2350+
},
22972351
},
22982352
phases: wasm_plugin_phases(body),
22992353
fail_mode: fluxheim_config::WasmPluginFailMode::FailClosed,
@@ -2337,11 +2391,15 @@ fn wasm_plugin_phases(body: WasmPluginBody) -> Vec<fluxheim_config::WasmPluginPh
23372391
vec![fluxheim_config::WasmPluginPhase::CacheStore]
23382392
}
23392393
WasmPluginBody::Decision(_)
2394+
| WasmPluginBody::IrulesAccessPolicyExample
23402395
| WasmPluginBody::Trap
2341-
| WasmPluginBody::WasiRandomGranted
2342-
| WasmPluginBody::WasiRandomDenied
2343-
| WasmPluginBody::WasiForbiddenFdWrite
23442396
| WasmPluginBody::BusyLoop => vec![fluxheim_config::WasmPluginPhase::AccessDecision],
2397+
#[cfg(feature = "wasm-wasi")]
2398+
WasmPluginBody::WasiRandomGranted
2399+
| WasmPluginBody::WasiRandomDenied
2400+
| WasmPluginBody::WasiForbiddenFdWrite => {
2401+
vec![fluxheim_config::WasmPluginPhase::AccessDecision]
2402+
}
23452403
#[cfg(feature = "wasm-proxy-abi")]
23462404
WasmPluginBody::ProxyPreviewLogCall => {
23472405
vec![fluxheim_config::WasmPluginPhase::AccessDecision]

docs/wasm-policy-example-parity.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ Required live test:
4848

4949
Target release: `v1.7.1`.
5050

51+
Status: the checked-in `examples/wasm/irules-access-policy.wat` and
52+
`examples/wasm/irules-access-policy.toml` pair packages the bounded
53+
access-decision subset as a runnable `v1.7.9` migration example. Fluxheim owns
54+
host/path/method/trusted-client/TLS classification; the route-scoped plugin can
55+
continue, allow, or deny and cannot inspect arbitrary request state. The live
56+
listener smoke proves an unattached public request reaches origin, the attached
57+
admin request receives the plugin's 403 before origin dispatch, and a trapping
58+
plugin fails closed.
59+
5160
### nginx Lua/OpenResty-Style Header Policy
5261

5362
Capability target:

examples/wasm/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ These examples are source-level policy examples. Build the `.wat` files to
55
`wasm.plugin_roots`, and pin the module SHA-256 in config before enabling it in
66
production.
77

8+
`irules-access-policy.wat` and `irules-access-policy.toml` demonstrate the
9+
F5 iRules-style access-policy mapping. Fluxheim first classifies the request
10+
through configured vhosts, routes, methods, trusted-client ACLs, and TLS
11+
policy. The example plugin is attached only to the bounded `admin` route and
12+
returns the typed deny decision, producing a small fixed 403 response before
13+
origin dispatch. Requests outside that attachment continue normally. A trap,
14+
timeout, invalid result, or admission failure follows `fail_mode =
15+
"fail-closed"` and cannot silently bypass the policy.
16+
17+
This is capability parity, not Tcl syntax compatibility. The access-decision
18+
module receives no raw headers, body, filesystem, network, admin-token, or TLS
19+
secret capability. Use native route and access configuration for classification
20+
instead of attempting arbitrary parsing inside the plugin.
21+
822
`cache-lookup-policy.wat` and `cache-store-policy.wat` demonstrate the bounded
923
1.7.5 cache-policy ABI:
1024

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Compile irules-access-policy.wat to the configured .wasm path and replace the
2+
# sha256 placeholder with the compiled module digest before use.
3+
4+
[wasm]
5+
enabled = true
6+
plugin_roots = ["/etc/fluxheim/plugins"]
7+
max_total_concurrent_executions = 256
8+
9+
[[wasm.plugins]]
10+
name = "admin_route_policy"
11+
path = "/etc/fluxheim/plugins/irules-access-policy.wasm"
12+
sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
13+
abi = "fluxheim-policy-v1"
14+
host_call_namespace = "fluxheim-policy-v1"
15+
phases = ["access-decision"]
16+
fail_mode = "fail-closed"
17+
18+
[wasm.plugins.limits]
19+
max_module_bytes = "1MiB"
20+
max_memory_bytes = "16MiB"
21+
max_table_elements = 10000
22+
fuel = 5000000
23+
timeout_ms = 50
24+
compile_timeout_ms = 500
25+
26+
[[wasm.attachments]]
27+
plugin = "admin_route_policy"
28+
vhost = "example"
29+
route = "admin"
30+
priority = 100
31+
phases = ["access-decision"]
32+
33+
[[vhosts]]
34+
name = "example"
35+
hosts = ["example.com"]
36+
37+
[vhosts.proxy]
38+
upstreams = ["127.0.0.1:3000"]
39+
40+
[[vhosts.routes]]
41+
name = "admin"
42+
path_prefix = "/admin/"
43+
44+
[vhosts.routes.proxy]
45+
upstreams = ["127.0.0.1:3000"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
(module
2+
;; This policy is attached only to the configured admin route. Fluxheim
3+
;; performs path and client classification before invoking the module.
4+
(func (export "fluxheim_access_decision") (result i32)
5+
i32.const 2))

packaging/rpm/fluxheim.spec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ fi
156156
%changelog
157157
* Sun Jul 12 2026 Fluxheim Maintainers <1921261+eldryoth@users.noreply.github.com> - 1.7.9-1
158158
- Start documented and runnable Wasm migration-example parity work.
159+
- Add the F5 iRules-style route access example and real listener smoke.
159160
- Harden snapshot opens against pathname races and isolate corruption fixtures
160161
behind safe store primitives.
161162

release-notes/RELEASE_NOTES_1.7.9.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ and VCL-style policy jobs into Fluxheim's typed WebAssembly policy ABI. It
66
provides capability mappings, not syntax or runtime compatibility with those
77
products.
88

9+
## Added
10+
11+
- Add a checked-in F5 iRules-style route access policy and complete config
12+
fixture using Fluxheim's typed access-decision ABI.
13+
- Add real listener coverage proving public requests reach origin, attached
14+
admin requests are denied before origin dispatch, and plugin traps fail
15+
closed.
16+
- Add `scripts/smoke_wasm_policy_examples.sh` to `scripts/test_starter.py` and
17+
the opt-in Wasm release gate.
18+
919
## Security
1020

1121
- Open private snapshot files with platform no-follow semantics before
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env sh
2+
set -eu
3+
4+
cargo test --locked -p fluxheim-server --features wasm \
5+
native_wasm_irules_policy_example_allows_public_and_denies_admin
6+
cargo test --locked -p fluxheim-server --features wasm \
7+
native_wasm_access_decision_fails_closed_on_trap
8+
9+
echo "wasm policy examples smoke: ok"

0 commit comments

Comments
 (0)