diff --git a/README.md b/README.md index fa53c221..9ef939c1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - [HTTP Configuration](./examples/http_config/) - [gRPC Auth (random)](./examples/grpc_auth_random/) - [Envoy filter metadata](./examples/envoy_filter_metadata/) +- [TCP Rerouting](./examples/tcp_rerouting/) ## Articles & blog posts from the community diff --git a/bazel/cargo/Cargo.Bazel.lock b/bazel/cargo/Cargo.Bazel.lock index 21d41bc9..92dd9b11 100644 --- a/bazel/cargo/Cargo.Bazel.lock +++ b/bazel/cargo/Cargo.Bazel.lock @@ -33,9 +33,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "mockalloc" @@ -59,9 +59,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -97,6 +97,6 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" diff --git a/bazel/cargo/remote/BUILD.bazel b/bazel/cargo/remote/BUILD.bazel index 294313a7..df6e7fb1 100644 --- a/bazel/cargo/remote/BUILD.bazel +++ b/bazel/cargo/remote/BUILD.bazel @@ -44,14 +44,14 @@ alias( ) alias( - name = "log-0.4.27", - actual = "@crates_vendor__log-0.4.27//:log", + name = "log-0.4.28", + actual = "@crates_vendor__log-0.4.28//:log", tags = ["manual"], ) alias( name = "log", - actual = "@crates_vendor__log-0.4.27//:log", + actual = "@crates_vendor__log-0.4.28//:log", tags = ["manual"], ) diff --git a/bazel/cargo/remote/BUILD.log-0.4.27.bazel b/bazel/cargo/remote/BUILD.log-0.4.28.bazel similarity index 99% rename from bazel/cargo/remote/BUILD.log-0.4.27.bazel rename to bazel/cargo/remote/BUILD.log-0.4.28.bazel index dbfcf020..b0dc7030 100644 --- a/bazel/cargo/remote/BUILD.log-0.4.27.bazel +++ b/bazel/cargo/remote/BUILD.log-0.4.28.bazel @@ -88,5 +88,5 @@ rust_library( "@rules_rust//rust/platform:x86_64-unknown-uefi": [], "//conditions:default": ["@platforms//:incompatible"], }), - version = "0.4.27", + version = "0.4.28", ) diff --git a/bazel/cargo/remote/BUILD.mockalloc-macros-0.1.0.bazel b/bazel/cargo/remote/BUILD.mockalloc-macros-0.1.0.bazel index 70ab9d81..591dac06 100644 --- a/bazel/cargo/remote/BUILD.mockalloc-macros-0.1.0.bazel +++ b/bazel/cargo/remote/BUILD.mockalloc-macros-0.1.0.bazel @@ -90,8 +90,8 @@ rust_proc_macro( }), version = "0.1.0", deps = [ - "@crates_vendor__proc-macro2-1.0.101//:proc_macro2", - "@crates_vendor__quote-1.0.41//:quote", + "@crates_vendor__proc-macro2-1.0.103//:proc_macro2", + "@crates_vendor__quote-1.0.42//:quote", "@crates_vendor__syn-1.0.109//:syn", ], ) diff --git a/bazel/cargo/remote/BUILD.proc-macro2-1.0.101.bazel b/bazel/cargo/remote/BUILD.proc-macro2-1.0.103.bazel similarity index 96% rename from bazel/cargo/remote/BUILD.proc-macro2-1.0.101.bazel rename to bazel/cargo/remote/BUILD.proc-macro2-1.0.103.bazel index 51c8d72d..87577bc0 100644 --- a/bazel/cargo/remote/BUILD.proc-macro2-1.0.101.bazel +++ b/bazel/cargo/remote/BUILD.proc-macro2-1.0.103.bazel @@ -96,10 +96,10 @@ rust_library( "@rules_rust//rust/platform:x86_64-unknown-uefi": [], "//conditions:default": ["@platforms//:incompatible"], }), - version = "1.0.101", + version = "1.0.103", deps = [ - "@crates_vendor__proc-macro2-1.0.101//:build_script_build", - "@crates_vendor__unicode-ident-1.0.19//:unicode_ident", + "@crates_vendor__proc-macro2-1.0.103//:build_script_build", + "@crates_vendor__unicode-ident-1.0.22//:unicode_ident", ], ) @@ -155,7 +155,7 @@ cargo_build_script( "noclippy", "norustfmt", ], - version = "1.0.101", + version = "1.0.103", visibility = ["//visibility:private"], ) diff --git a/bazel/cargo/remote/BUILD.quote-1.0.41.bazel b/bazel/cargo/remote/BUILD.quote-1.0.42.bazel similarity index 96% rename from bazel/cargo/remote/BUILD.quote-1.0.41.bazel rename to bazel/cargo/remote/BUILD.quote-1.0.42.bazel index 2576b03c..f2c6332b 100644 --- a/bazel/cargo/remote/BUILD.quote-1.0.41.bazel +++ b/bazel/cargo/remote/BUILD.quote-1.0.42.bazel @@ -96,10 +96,10 @@ rust_library( "@rules_rust//rust/platform:x86_64-unknown-uefi": [], "//conditions:default": ["@platforms//:incompatible"], }), - version = "1.0.41", + version = "1.0.42", deps = [ - "@crates_vendor__proc-macro2-1.0.101//:proc_macro2", - "@crates_vendor__quote-1.0.41//:build_script_build", + "@crates_vendor__proc-macro2-1.0.103//:proc_macro2", + "@crates_vendor__quote-1.0.42//:build_script_build", ], ) @@ -155,7 +155,7 @@ cargo_build_script( "noclippy", "norustfmt", ], - version = "1.0.41", + version = "1.0.42", visibility = ["//visibility:private"], ) diff --git a/bazel/cargo/remote/BUILD.syn-1.0.109.bazel b/bazel/cargo/remote/BUILD.syn-1.0.109.bazel index 057bf1d9..ef19ab41 100644 --- a/bazel/cargo/remote/BUILD.syn-1.0.109.bazel +++ b/bazel/cargo/remote/BUILD.syn-1.0.109.bazel @@ -104,10 +104,10 @@ rust_library( }), version = "1.0.109", deps = [ - "@crates_vendor__proc-macro2-1.0.101//:proc_macro2", - "@crates_vendor__quote-1.0.41//:quote", + "@crates_vendor__proc-macro2-1.0.103//:proc_macro2", + "@crates_vendor__quote-1.0.42//:quote", "@crates_vendor__syn-1.0.109//:build_script_build", - "@crates_vendor__unicode-ident-1.0.19//:unicode_ident", + "@crates_vendor__unicode-ident-1.0.22//:unicode_ident", ], ) diff --git a/bazel/cargo/remote/BUILD.unicode-ident-1.0.19.bazel b/bazel/cargo/remote/BUILD.unicode-ident-1.0.22.bazel similarity index 99% rename from bazel/cargo/remote/BUILD.unicode-ident-1.0.19.bazel rename to bazel/cargo/remote/BUILD.unicode-ident-1.0.22.bazel index c60baf29..ea7867d2 100644 --- a/bazel/cargo/remote/BUILD.unicode-ident-1.0.19.bazel +++ b/bazel/cargo/remote/BUILD.unicode-ident-1.0.22.bazel @@ -88,5 +88,5 @@ rust_library( "@rules_rust//rust/platform:x86_64-unknown-uefi": [], "//conditions:default": ["@platforms//:incompatible"], }), - version = "1.0.19", + version = "1.0.22", ) diff --git a/bazel/cargo/remote/defs.bzl b/bazel/cargo/remote/defs.bzl index 9587c7f8..19090501 100644 --- a/bazel/cargo/remote/defs.bzl +++ b/bazel/cargo/remote/defs.bzl @@ -296,7 +296,7 @@ _NORMAL_DEPENDENCIES = { "": { _COMMON_CONDITION: { "hashbrown": Label("@crates_vendor//:hashbrown-0.16.0"), - "log": Label("@crates_vendor//:log-0.4.27"), + "log": Label("@crates_vendor//:log-0.4.28"), }, }, } @@ -454,12 +454,12 @@ def crate_repositories(): maybe( http_archive, - name = "crates_vendor__log-0.4.27", - sha256 = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94", + name = "crates_vendor__log-0.4.28", + sha256 = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432", type = "tar.gz", - urls = ["https://static.crates.io/crates/log/0.4.27/download"], - strip_prefix = "log-0.4.27", - build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.log-0.4.27.bazel"), + urls = ["https://static.crates.io/crates/log/0.4.28/download"], + strip_prefix = "log-0.4.28", + build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.log-0.4.28.bazel"), ) maybe( @@ -484,22 +484,22 @@ def crate_repositories(): maybe( http_archive, - name = "crates_vendor__proc-macro2-1.0.101", - sha256 = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de", + name = "crates_vendor__proc-macro2-1.0.103", + sha256 = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8", type = "tar.gz", - urls = ["https://static.crates.io/crates/proc-macro2/1.0.101/download"], - strip_prefix = "proc-macro2-1.0.101", - build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.proc-macro2-1.0.101.bazel"), + urls = ["https://static.crates.io/crates/proc-macro2/1.0.103/download"], + strip_prefix = "proc-macro2-1.0.103", + build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.proc-macro2-1.0.103.bazel"), ) maybe( http_archive, - name = "crates_vendor__quote-1.0.41", - sha256 = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1", + name = "crates_vendor__quote-1.0.42", + sha256 = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f", type = "tar.gz", - urls = ["https://static.crates.io/crates/quote/1.0.41/download"], - strip_prefix = "quote-1.0.41", - build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.quote-1.0.41.bazel"), + urls = ["https://static.crates.io/crates/quote/1.0.42/download"], + strip_prefix = "quote-1.0.42", + build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.quote-1.0.42.bazel"), ) maybe( @@ -514,16 +514,16 @@ def crate_repositories(): maybe( http_archive, - name = "crates_vendor__unicode-ident-1.0.19", - sha256 = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d", + name = "crates_vendor__unicode-ident-1.0.22", + sha256 = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5", type = "tar.gz", - urls = ["https://static.crates.io/crates/unicode-ident/1.0.19/download"], - strip_prefix = "unicode-ident-1.0.19", - build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.unicode-ident-1.0.19.bazel"), + urls = ["https://static.crates.io/crates/unicode-ident/1.0.22/download"], + strip_prefix = "unicode-ident-1.0.22", + build_file = Label("@proxy_wasm_rust_sdk//bazel/cargo/remote:BUILD.unicode-ident-1.0.22.bazel"), ) return [ struct(repo = "crates_vendor__hashbrown-0.16.0", is_dev_dep = False), - struct(repo = "crates_vendor__log-0.4.27", is_dev_dep = False), + struct(repo = "crates_vendor__log-0.4.28", is_dev_dep = False), struct(repo = "crates_vendor__mockalloc-0.1.2", is_dev_dep = True), ] diff --git a/examples/tcp_rerouting/Cargo.toml b/examples/tcp_rerouting/Cargo.toml new file mode 100644 index 00000000..766f32aa --- /dev/null +++ b/examples/tcp_rerouting/Cargo.toml @@ -0,0 +1,19 @@ +[package] +publish = false +name = "proxy-wasm-example-tcp-rerouting" +version = "0.0.1" +authors = ["Proxy-Wasm contributors"] +description = "Proxy-Wasm plugin example: TCP Rerouting based on source IP" +license = "Apache-2.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +log = "0.4" +proxy-wasm = { path = "../../" } +prost = "0.12" + +[build-dependencies] +prost-build = "0.12" diff --git a/examples/tcp_rerouting/README.md b/examples/tcp_rerouting/README.md new file mode 100644 index 00000000..b805aad1 --- /dev/null +++ b/examples/tcp_rerouting/README.md @@ -0,0 +1,81 @@ +## Proxy-Wasm plugin example: TCP Rerouting + +Proxy-Wasm TCP filter that dynamically routes connections to different upstream clusters based on the source IP address. + +Most WASM filter examples focus on HTTP, but this example shows how to work at the TCP/IP level. + +This example is inspired by the [wasmerang](https://github.com/SiiiTschiii/wasmerang) project, which demonstrates advanced TCP routing patterns in Envoy/Istio/K8s using WASM filters. + +### Overview + +This example demonstrates how to build a TCP filter that: + +- Intercepts incoming TCP connections +- Extracts the source IP address +- Routes traffic to different upstream clusters based on whether the last octet is even or odd + - **Even last octet** → routes to `egress-router1` + - **Odd last octet** → routes to `egress-router2` + +The filter uses Envoy's `set_envoy_filter_state` foreign function to dynamically override the TCP proxy cluster at runtime, requiring proper protobuf encoding via the included `set_envoy_filter_state.proto` file. + +### Building + +Build the WASM plugin from the example directory: + +```sh +$ cargo build --target wasm32-wasip1 --release +``` + +### Running with Docker Compose + +This example can be run with [`docker compose`](https://docs.docker.com/compose/install/) and has a matching Envoy configuration. + +From the example directory: + +```sh +$ docker compose up +``` + +### Test the Routing + +In separate terminals, test the routing behavior with different source IP addresses: + +```bash +# Even IP (last octet 10) → routes to egress-router1 +docker run --rm -it --network tcp_rerouting_envoymesh --ip 172.22.0.10 curlimages/curl curl http://proxy:10000/ip -H "Host: httpbin.org" + +# Odd IP (last octet 11) → routes to egress-router2 +docker run --rm -it --network tcp_rerouting_envoymesh --ip 172.22.0.11 curlimages/curl curl http://proxy:10000/ip -H "Host: httpbin.org" +``` + +### Expected Output + +Check the Docker Compose logs to see the WASM filter in action: + +```console +$ docker compose logs -f +``` + +**For even IP (last octet 10) → routes to egress-router1:** + +``` +proxy-1 | [TCP WASM] Source address: 172.22.0.10:39484 +proxy-1 | [TCP WASM] Source IP last octet: 10, intercepting ALL traffic +proxy-1 | [TCP WASM] Routing to egress-router1 +proxy-1 | [TCP WASM] set_envoy_filter_state status (envoy.tcp_proxy.cluster): Ok(None) +proxy-1 | [TCP WASM] Rerouting to egress-router1 via filter state +proxy-1 | [2025-11-20T03:08:18.423Z] cluster=egress-router1 src=172.22.0.10:39484 dst=172.22.0.2:10000 -> 35.170.145.70:80 +``` + +**For odd IP (last octet 11) → routes to egress-router2:** + +``` +proxy-1 | [TCP WASM] Source address: 172.22.0.11:55320 +proxy-1 | [TCP WASM] Source IP last octet: 11, intercepting ALL traffic +proxy-1 | [TCP WASM] Routing to egress-router2 +proxy-1 | [TCP WASM] set_envoy_filter_state status (envoy.tcp_proxy.cluster): Ok(None) +proxy-1 | [TCP WASM] Rerouting to egress-router2 via filter state +proxy-1 | [2025-11-20T03:08:39.974Z] cluster=egress-router2 src=172.22.0.11:55320 dst=172.22.0.2:10000 -> 52.44.182.178:80 +``` + +The `Ok(None)` status confirms that the filter state was successfully set, and you can see in the access logs that traffic is being routed to the correct clusters (`egress-router1` for even IPs, `egress-router2` for odd IPs). diff --git a/examples/tcp_rerouting/build.rs b/examples/tcp_rerouting/build.rs new file mode 100644 index 00000000..a4d41c42 --- /dev/null +++ b/examples/tcp_rerouting/build.rs @@ -0,0 +1,11 @@ +use std::fs; + +fn main() { + let out_dir = "src/generated"; + fs::create_dir_all(out_dir).unwrap(); + prost_build::Config::new() + .out_dir(out_dir) + .compile_protos(&["src/set_envoy_filter_state.proto"], &["src/"]) + .unwrap(); + println!("cargo:rerun-if-changed=src/set_envoy_filter_state.proto"); +} diff --git a/examples/tcp_rerouting/docker-compose.yaml b/examples/tcp_rerouting/docker-compose.yaml new file mode 100644 index 00000000..35439e04 --- /dev/null +++ b/examples/tcp_rerouting/docker-compose.yaml @@ -0,0 +1,18 @@ +services: + proxy: + image: envoyproxy/envoy:v1.34.1 + entrypoint: /usr/local/bin/envoy -c /etc/envoy.yaml -l info --service-cluster proxy + volumes: + - ./envoy/envoy.yaml:/etc/envoy.yaml + - ./target/wasm32-wasip1/release/proxy_wasm_example_tcp_rerouting.wasm:/etc/tcp_rerouting.wasm + networks: + - envoymesh + ports: + - "10000:10000" + - "8001:8001" + +networks: + envoymesh: + ipam: + config: + - subnet: 172.22.0.0/16 diff --git a/examples/tcp_rerouting/envoy/envoy.yaml b/examples/tcp_rerouting/envoy/envoy.yaml new file mode 100644 index 00000000..6f2b2567 --- /dev/null +++ b/examples/tcp_rerouting/envoy/envoy.yaml @@ -0,0 +1,76 @@ +# Envoy configuration for TCP rerouting example +static_resources: + listeners: + - name: main + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + # WASM filter for TCP rerouting + - name: envoy.filters.network.wasm + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.wasm.v3.Wasm + config: + name: tcp_rerouting_filter + root_id: tcp_rerouting_filter + configuration: + "@type": type.googleapis.com/google.protobuf.StringValue + value: "standalone" + vm_config: + vm_id: vm.tcp_rerouting + runtime: envoy.wasm.runtime.v8 + code: + local: + filename: /etc/tcp_rerouting.wasm + allow_precompiled: true + # TCP proxy filter + - name: envoy.filters.network.tcp_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy + stat_prefix: destination + cluster: egress-router1 # Default cluster, overridden by WASM filter + access_log: + - name: envoy.access_loggers.file + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog + path: /dev/stdout + log_format: + text_format: "[%START_TIME%] cluster=%UPSTREAM_CLUSTER% src=%DOWNSTREAM_REMOTE_ADDRESS% dst=%DOWNSTREAM_LOCAL_ADDRESS% -> %UPSTREAM_HOST%\n" + + clusters: + - name: egress-router1 + connect_timeout: 30s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + load_assignment: + cluster_name: egress-router1 + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: httpbin.org + port_value: 80 + + - name: egress-router2 + connect_timeout: 30s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + load_assignment: + cluster_name: egress-router2 + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: httpbin.org + port_value: 80 + +admin: + access_log_path: "/dev/null" + address: + socket_address: + address: 0.0.0.0 + port_value: 8001 diff --git a/examples/tcp_rerouting/src/generated/envoy.source.extensions.common.wasm.rs b/examples/tcp_rerouting/src/generated/envoy.source.extensions.common.wasm.rs new file mode 100644 index 00000000..53b19a2b --- /dev/null +++ b/examples/tcp_rerouting/src/generated/envoy.source.extensions.common.wasm.rs @@ -0,0 +1,42 @@ +// This file is @generated by prost-build. +/// Argument expected by set_envoy_filter_state in envoy +/// +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SetEnvoyFilterStateArguments { + #[prost(string, tag = "1")] + pub path: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub value: ::prost::alloc::string::String, + #[prost(enumeration = "LifeSpan", tag = "3")] + pub span: i32, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum LifeSpan { + FilterChain = 0, + DownstreamRequest = 1, + DownstreamConnection = 2, +} +impl LifeSpan { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + LifeSpan::FilterChain => "FilterChain", + LifeSpan::DownstreamRequest => "DownstreamRequest", + LifeSpan::DownstreamConnection => "DownstreamConnection", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "FilterChain" => Some(Self::FilterChain), + "DownstreamRequest" => Some(Self::DownstreamRequest), + "DownstreamConnection" => Some(Self::DownstreamConnection), + _ => None, + } + } +} diff --git a/examples/tcp_rerouting/src/lib.rs b/examples/tcp_rerouting/src/lib.rs new file mode 100644 index 00000000..ca5b2e61 --- /dev/null +++ b/examples/tcp_rerouting/src/lib.rs @@ -0,0 +1,174 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! TCP Rerouting Example +//! +//! This example demonstrates dynamic TCP routing based on source IP address. +//! Inspired by https://github.com/SiiiTschiii/wasmerang +//! +//! The filter intercepts TCP connections and routes them to different upstream +//! clusters based on whether the last octet of the source IP is even or odd: +//! - Even last octet → egress-router1 +//! - Odd last octet → egress-router2 + +use log::{info, warn}; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; + +// Include the generated protobuf code +pub mod set_envoy_filter_state { + include!("generated/envoy.source.extensions.common.wasm.rs"); +} + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { + Box::new(TcpReroutingRoot) + }); +}} + +struct TcpReroutingRoot; + +impl Context for TcpReroutingRoot {} + +impl RootContext for TcpReroutingRoot { + fn on_configure(&mut self, _plugin_configuration_size: usize) -> bool { + if let Some(config_bytes) = self.get_plugin_configuration() { + info!( + "[TCP WASM] Configuration: {:?}", + std::str::from_utf8(&config_bytes).unwrap_or("invalid UTF-8") + ); + } + true + } + + fn create_stream_context(&self, _context_id: u32) -> Option> { + Some(Box::new(TcpReroutingStream)) + } + + fn get_type(&self) -> Option { + Some(ContextType::StreamContext) + } +} + +struct TcpReroutingStream; + +impl Context for TcpReroutingStream {} + +impl StreamContext for TcpReroutingStream { + fn on_new_connection(&mut self) -> Action { + if let Some(source_addr_bytes) = self.get_property(vec!["source", "address"]) { + if let Ok(source_addr) = std::str::from_utf8(&source_addr_bytes) { + info!("[TCP WASM] Source address: {}", source_addr); + + // Extract the last octet from the source IP address + if let Some(last_octet) = extract_last_octet(source_addr) { + info!( + "[TCP WASM] Source IP last octet: {}, intercepting ALL traffic", + last_octet + ); + + // Determine target cluster based on even/odd last octet + let cluster = if last_octet % 2 == 0 { + "egress-router1" + } else { + "egress-router2" + }; + + info!("[TCP WASM] Routing to {}", cluster); + + // Set the cluster via Envoy's filter state mechanism using proper protobuf encoding + use set_envoy_filter_state::{LifeSpan, SetEnvoyFilterStateArguments}; + + let args = SetEnvoyFilterStateArguments { + path: "envoy.tcp_proxy.cluster".to_string(), + value: cluster.to_string(), + span: LifeSpan::FilterChain as i32, + }; + + let mut buf = Vec::new(); + if let Err(e) = prost::Message::encode(&args, &mut buf) { + warn!("[TCP WASM] Failed to encode filter state: {}", e); + return Action::Continue; + } + + // Use the Envoy-specific filter state mechanism + // https://github.com/envoyproxy/envoy/issues/28128 + let status = self.call_foreign_function("set_envoy_filter_state", Some(&buf)); + + info!( + "[TCP WASM] set_envoy_filter_state status (envoy.tcp_proxy.cluster): {:?}", + status + ); + info!("[TCP WASM] Rerouting to {} via filter state", cluster); + } + } + } + Action::Continue + } +} + +/// Extracts the last octet from an IP address string. +/// +/// Handles both IPv4 addresses with and without port numbers. +/// Examples: +/// - "192.168.1.10" → Some(10) +/// - "192.168.1.10:8080" → Some(10) +/// - "172.21.0.11:58762" → Some(11) +fn extract_last_octet(addr: &str) -> Option { + // Remove port if present (format: "ip:port") + let ip_part = addr.split(':').next()?; + + // Split by '.' and get the last segment + let last_segment = ip_part.split('.').next_back()?; + + // Parse as u8 + last_segment.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_last_octet() { + assert_eq!(extract_last_octet("192.168.1.10"), Some(10)); + assert_eq!(extract_last_octet("192.168.1.10:8080"), Some(10)); + assert_eq!(extract_last_octet("172.21.0.11:58762"), Some(11)); + assert_eq!(extract_last_octet("10.0.0.2"), Some(2)); + assert_eq!(extract_last_octet("invalid"), None); + assert_eq!(extract_last_octet(""), None); + } + + #[test] + fn test_routing_logic() { + // Even last octet should route to egress-router1 + let last_octet = 10; + let cluster = if last_octet % 2 == 0 { + "egress-router1" + } else { + "egress-router2" + }; + assert_eq!(cluster, "egress-router1"); + + // Odd last octet should route to egress-router2 + let last_octet = 11; + let cluster = if last_octet % 2 == 0 { + "egress-router1" + } else { + "egress-router2" + }; + assert_eq!(cluster, "egress-router2"); + } +} diff --git a/examples/tcp_rerouting/src/set_envoy_filter_state.proto b/examples/tcp_rerouting/src/set_envoy_filter_state.proto new file mode 100644 index 00000000..3caceedf --- /dev/null +++ b/examples/tcp_rerouting/src/set_envoy_filter_state.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.source.extensions.common.wasm; + +enum LifeSpan { + FilterChain = 0; + DownstreamRequest = 1; + DownstreamConnection = 2; +} + +// Argument expected by set_envoy_filter_state in envoy +// https://github.com/envoyproxy/envoy/blob/d741713c376d1e024236519fb59406c05702ad77/source/extensions/common/wasm/foreign.cc#L116 +message SetEnvoyFilterStateArguments { + string path = 1; + string value = 2; + LifeSpan span = 3; +}