diff --git a/.gitignore b/.gitignore index 2b2e639..1f11060 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ bazel-* access_logs/ -*.so \ No newline at end of file +*.so + +*.jar +java/out \ No newline at end of file diff --git a/Makefile b/Makefile index 25627cb..c66b62e 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,7 @@ test-rust: ## Run the unit tests for the Rust codebase. @$(call print_success,Rust unit tests completed) .PHONY: build -build: build-go build-rust ## Build all dynamic modules. +build: build-go build-rust build-java ## Build all dynamic modules. .PHONY: build-go build-go: ## Build the Go dynamic module. @@ -92,6 +92,14 @@ build-go: ## Build the Go dynamic module. @$(call print_task,Copying Go dynamic module for easier use with Envoy) @cp go/libgo_module.so integration/libgo_module.so +.PHONY: build-java +build-java: ## Build the Java filter JAR and copy it to the integration directory. + @$(call print_task,Building Java filter JAR) + @make -C java + @$(call print_success,Java filter JAR built at java/out/envoy-java-filter.jar) + @$(call print_task,Copying Java filter JAR for use with Envoy) + @cp java/out/envoy-java-filter.jar integration/envoy-java-filter.jar + .PHONY: build-rust build-rust: ## Build the Rust dynamic module. @$(call print_task,Building Rust dynamic module) @@ -102,7 +110,7 @@ build-rust: ## Build the Rust dynamic module. @cp rust/target/debug/librust_module.so integration/librust_module.so || true .PHONY: integration-test -integration-test: build-go build-rust ## Run the integration tests. +integration-test: build-go build-rust build-java ## Run the integration tests. @$(call print_task,Running integration tests) @cd integration && go test -v ./... @$(call print_success,Integration tests completed) diff --git a/integration/envoy.yaml b/integration/envoy.yaml index 0494abb..a44ffb0 100644 --- a/integration/envoy.yaml +++ b/integration/envoy.yaml @@ -207,6 +207,44 @@ static_resources: typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + - address: + socket_address: + address: 0.0.0.0 + port_value: 1065 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + route_config: + virtual_hosts: + - name: local_route + domains: + - "*" + routes: + - match: + prefix: "/" + route: + cluster: httpbin + http_filters: + - name: dynamic_modules/java_filter + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter + dynamic_module_config: + name: rust_module + filter_name: java_filter + filter_config: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: | + { + "jar_path": "./envoy-java-filter.jar", + "class_name": "io.envoyproxy.dynamicmodules.ExampleFilter" + } + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: - name: httpbin # This demonstrates how to use the dynamic module HTTP filter as an upstream filter. diff --git a/integration/main_test.go b/integration/main_test.go index e896ee8..ae49b66 100644 --- a/integration/main_test.go +++ b/integration/main_test.go @@ -6,9 +6,11 @@ import ( "context" "encoding/json" "io" + "net" "net/http" "os" "os/exec" + "path/filepath" "strconv" "strings" "testing" @@ -26,12 +28,15 @@ func TestIntegration(t *testing.T) { // Setup the httpbin upstream local server. httpbinHandler := httpbin.New() - server := &http.Server{Addr: ":1234", Handler: httpbinHandler, + server := &http.Server{Handler: httpbinHandler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, } + // Use tcp4 to avoid conflicting with any IPv6-only service already on :1234. + httpbinListener, err := net.Listen("tcp4", ":1234") + require.NoError(t, err) go func() { - if err = server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := server.Serve(httpbinListener); err != nil && err != http.ErrServerClosed { t.Logf("HTTP server error: %v", err) } }() @@ -43,7 +48,7 @@ func TestIntegration(t *testing.T) { // Health check to ensure the server is up before starting tests. require.Eventually(t, func() bool { - resp, err := http.Get("http://localhost:1234/uuid") + resp, err := http.Get("http://127.0.0.1:1234/uuid") if err != nil { t.Logf("httpbin server not ready yet: %v", err) return false @@ -60,6 +65,11 @@ func TestIntegration(t *testing.T) { require.NoError(t, os.Mkdir(accessLogsDir, 0o700)) require.NoError(t, os.Chmod(accessLogsDir, 0o777)) + // Detect the JVM server library directory so librust_module.so can dlopen + // libjvm.so when the java_filter initialises the embedded JVM. + jvmLibPath := jvmServerLibPath(t) + t.Logf("JVM lib path: %s", jvmLibPath) + if envoyImage := cmp.Or(os.Getenv("ENVOY_IMAGE")); envoyImage != "" { cmd := exec.Command( "docker", @@ -92,9 +102,15 @@ func TestIntegration(t *testing.T) { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + existingLD := os.Getenv("LD_LIBRARY_PATH") + ldLibPath := jvmLibPath + if existingLD != "" { + ldLibPath = jvmLibPath + ":" + existingLD + } cmd.Env = append(os.Environ(), "ENVOY_DYNAMIC_MODULES_SEARCH_PATH="+cwd, "GODEBUG=cgocheck=0", + "LD_LIBRARY_PATH="+ldLibPath, ) require.NoError(t, cmd.Start()) defer func() { @@ -422,6 +438,47 @@ func TestIntegration(t *testing.T) { }, 30*time.Second, 200*time.Millisecond) }) + t.Run("java_filter", func(t *testing.T) { + require.Eventually(t, func() bool { + req, err := http.NewRequest("GET", "http://127.0.0.1:1065/headers", nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("Envoy not ready yet: %v", err) + return false + } + defer func() { + require.NoError(t, resp.Body.Close()) + }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Logf("Envoy not ready yet: %v", err) + return false + } + + t.Logf("response: headers=%v, body=%s", resp.Header, string(body)) + require.Equal(t, 200, resp.StatusCode) + + // httpbin echoes request headers back as JSON. + type httpBinHeadersBody struct { + Headers map[string][]string `json:"headers"` + } + var headersBody httpBinHeadersBody + require.NoError(t, json.Unmarshal(body, &headersBody)) + + // ExampleFilter adds x-java-filter: active to every request. + require.Contains(t, headersBody.Headers["X-Java-Filter"], "active", + "x-java-filter request header should be set by the Java filter") + + // ExampleFilter mirrors the :path back as x-java-filter-path on the response. + require.Equal(t, "/headers", resp.Header.Get("x-java-filter-path"), + "x-java-filter-path response header should mirror the request :path") + + return true + }, 30*time.Second, 200*time.Millisecond) + }) + t.Run("http_metrics", func(t *testing.T) { // Send test request require.Eventually(t, func() bool { @@ -495,3 +552,30 @@ func TestIntegration(t *testing.T) { }, 5*time.Second, 200*time.Millisecond) }) } + +// jvmServerLibPath returns the directory containing libjvm.so, needed by the +// java_filter at runtime so the Rust module can dlopen the JVM. +// It first checks $JAVA_HOME, then falls back to running `java -XshowSettings:all -version`. +func jvmServerLibPath(t *testing.T) string { + t.Helper() + javaHome := os.Getenv("JAVA_HOME") + if javaHome == "" { + out, err := exec.Command("java", "-XshowSettings:all", "-version").CombinedOutput() + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "java.home") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + javaHome = strings.TrimSpace(parts[1]) + break + } + } + } + } + } + if javaHome == "" { + t.Log("JAVA_HOME not set and could not be detected; java_filter may fail to load libjvm.so") + return "" + } + return filepath.Join(javaHome, "lib", "server") +} diff --git a/java/Makefile b/java/Makefile new file mode 100644 index 0000000..c843425 --- /dev/null +++ b/java/Makefile @@ -0,0 +1,21 @@ +JAVA_SRC_DIR := src +JAVA_OUT_DIR := out +JAR := $(JAVA_OUT_DIR)/envoy-java-filter.jar + +JAVA_SOURCES := \ + $(JAVA_SRC_DIR)/io/envoyproxy/dynamicmodules/HeaderMutation.java \ + $(JAVA_SRC_DIR)/io/envoyproxy/dynamicmodules/EnvoyHttpFilter.java \ + $(JAVA_SRC_DIR)/io/envoyproxy/dynamicmodules/ExampleFilter.java + +.PHONY: all clean + +all: $(JAR) + +$(JAR): $(JAVA_SOURCES) + mkdir -p $(JAVA_OUT_DIR) + javac -source 11 -target 11 -d $(JAVA_OUT_DIR) $(JAVA_SOURCES) + jar cf $(JAR) -C $(JAVA_OUT_DIR) . + @echo "Built $(JAR)" + +clean: + rm -rf $(JAVA_OUT_DIR) diff --git a/java/src/io/envoyproxy/dynamicmodules/EnvoyHttpFilter.java b/java/src/io/envoyproxy/dynamicmodules/EnvoyHttpFilter.java new file mode 100644 index 0000000..6ffcdf6 --- /dev/null +++ b/java/src/io/envoyproxy/dynamicmodules/EnvoyHttpFilter.java @@ -0,0 +1,41 @@ +package io.envoyproxy.dynamicmodules; + +/** + * Interface for implementing Envoy HTTP filters in Java. + * + *

Implement this interface, package your class in a JAR (together with + * {@link HeaderMutation}), and configure the Rust dynamic module: + * + *

{@code
+ * {
+ *   "jar_path": "/path/to/your-filter.jar",
+ *   "class_name": "com.example.YourFilter"
+ * }
+ * }
+ * + *

Your class must have a public no-arg constructor. A single instance is + * created per filter-chain config block and reused across all requests, so + * implementations must be thread-safe if Envoy dispatches requests concurrently. + */ +public interface EnvoyHttpFilter { + + /** + * Called when request headers arrive from the downstream client. + * + * @param names header names (parallel array) + * @param values header values (parallel array, same length as {@code names}) + * @return mutations to apply, or {@code null} to leave headers unchanged + * and continue the filter chain + */ + HeaderMutation onRequestHeaders(String[] names, String[] values); + + /** + * Called when response headers arrive from the upstream. + * + * @param names header names (parallel array) + * @param values header values (parallel array, same length as {@code names}) + * @return mutations to apply, or {@code null} to leave headers unchanged + * and continue the filter chain + */ + HeaderMutation onResponseHeaders(String[] names, String[] values); +} diff --git a/java/src/io/envoyproxy/dynamicmodules/ExampleFilter.java b/java/src/io/envoyproxy/dynamicmodules/ExampleFilter.java new file mode 100644 index 0000000..7cd9505 --- /dev/null +++ b/java/src/io/envoyproxy/dynamicmodules/ExampleFilter.java @@ -0,0 +1,55 @@ +package io.envoyproxy.dynamicmodules; + +/** + * Example Envoy HTTP filter implemented in Java. + * + *

This filter: + *

+ * + *

Build and package this example with: + *

{@code
+ * make -C java
+ * }
+ * + *

Then reference it in your Envoy config: + *

{@code
+ * {
+ *   "jar_path": "/path/to/envoy-java-filter.jar",
+ *   "class_name": "io.envoyproxy.dynamicmodules.ExampleFilter"
+ * }
+ * }
+ */ +public class ExampleFilter implements EnvoyHttpFilter { + + // Captured from request headers; written into the response. + // In a concurrent environment you would use ThreadLocal instead. + private volatile String lastPath = ""; + + @Override + public HeaderMutation onRequestHeaders(String[] names, String[] values) { + // Capture :path for use in onResponseHeaders. + for (int i = 0; i < names.length; i++) { + if (":path".equals(names[i])) { + lastPath = values[i]; + break; + } + } + + HeaderMutation m = new HeaderMutation(); + m.addHeaders = new String[]{"x-java-filter", "active"}; + return m; + } + + @Override + public HeaderMutation onResponseHeaders(String[] names, String[] values) { + HeaderMutation m = new HeaderMutation(); + m.addHeaders = new String[]{"x-java-filter-path", lastPath}; + m.removeHeaders = new String[]{"x-powered-by"}; + return m; + } +} diff --git a/java/src/io/envoyproxy/dynamicmodules/HeaderMutation.java b/java/src/io/envoyproxy/dynamicmodules/HeaderMutation.java new file mode 100644 index 0000000..ed392f0 --- /dev/null +++ b/java/src/io/envoyproxy/dynamicmodules/HeaderMutation.java @@ -0,0 +1,34 @@ +package io.envoyproxy.dynamicmodules; + +/** + * Describes mutations the Java filter wants to apply to HTTP headers. + * + *

Return an instance of this class (or {@code null} for no changes) from + * {@link EnvoyHttpFilter#onRequestHeaders} and + * {@link EnvoyHttpFilter#onResponseHeaders}. + * + *

All fields are optional; leave them {@code null} or {@code false} to + * skip the corresponding action. + */ +public class HeaderMutation { + + /** + * If {@code true} the filter chain is stopped (Envoy StopIteration). + * Use this to short-circuit a request, e.g. for authentication failures. + * The upstream will not receive the request. + */ + public boolean stopIteration = false; + + /** + * Headers to add or overwrite, expressed as alternating name/value pairs: + * {@code ["name0", "value0", "name1", "value1", …]}. + * + *

Must be an even-length array (or {@code null}). + */ + public String[] addHeaders = null; + + /** + * Names of headers to remove. {@code null} means no removals. + */ + public String[] removeHeaders = null; +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cfabf0d..6f47ac3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -60,6 +60,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -83,7 +89,17 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.8", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", ] [[package]] @@ -163,6 +179,7 @@ dependencies = [ "dashmap", "envoy-proxy-dynamic-modules-rust-sdk", "hickory-proto", + "jni", "matchers", "once_cell", "parking_lot", @@ -431,12 +448,55 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "java-locator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c46c1fe465c59b1474e665e85e1256c3893dd00927b8d55f63b09044c1e64f" +dependencies = [ + "glob", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "java-locator", + "jni-sys", + "libloading 0.7.4", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "libc" version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.8" @@ -789,6 +849,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1016,6 +1085,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1031,12 +1110,52 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1064,6 +1183,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1096,6 +1230,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1108,6 +1248,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1120,6 +1266,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1144,6 +1296,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1156,6 +1314,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1168,6 +1332,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1180,6 +1350,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1324a41..0320053 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,6 +16,7 @@ dashmap = "6.1.0" once_cell = "1.20.2" hickory-proto = "0.24" parking_lot = "0.12" +jni = { version = "0.21", features = ["invocation"] } [dev-dependencies] tempfile = "3.16.0" diff --git a/rust/src/http_java_filter.rs b/rust/src/http_java_filter.rs new file mode 100644 index 0000000..f043fab --- /dev/null +++ b/rust/src/http_java_filter.rs @@ -0,0 +1,406 @@ +//! Java HTTP filter — embeds a JVM to execute user-provided Java filter logic. +//! +//! Users write their filter in Java by implementing the +//! `io.envoyproxy.dynamicmodules.EnvoyHttpFilter` interface, compile it to a JAR, +//! and point to it from the Envoy config. +//! +//! # Envoy filter config (JSON) +//! +//! ```json +//! { +//! "jar_path": "/path/to/your-filter.jar", +//! "class_name": "io.envoyproxy.dynamicmodules.ExampleFilter" +//! } +//! ``` +//! +//! # Java interface contract +//! +//! ```java +//! public class MyFilter implements EnvoyHttpFilter { +//! public HeaderMutation onRequestHeaders(String[] names, String[] values) { +//! HeaderMutation m = new HeaderMutation(); +//! m.addHeaders = new String[]{"x-java-filter", "active"}; +//! return m; +//! } +//! public HeaderMutation onResponseHeaders(String[] names, String[] values) { +//! return null; // no changes +//! } +//! } +//! ``` +//! +//! # Build requirements +//! +//! Java (JDK 11+) must be installed both at compile time (for the `jni` invocation +//! feature) and at runtime (Envoy needs `libjvm.so` in `LD_LIBRARY_PATH`). +//! +//! To compile the Java sources and build the example JAR: +//! +//! ```sh +//! make -C java +//! ``` + +use envoy_proxy_dynamic_modules_rust_sdk::*; +use jni::{ + objects::{GlobalRef, JObject, JObjectArray, JString, JValue}, + sys::jsize, + InitArgsBuilder, JNIVersion, JavaVM, +}; +use serde::Deserialize; +use std::sync::OnceLock; + +// --------------------------------------------------------------------------- +// Global JVM — there can only be one per process. +// --------------------------------------------------------------------------- + +static JVM: OnceLock = OnceLock::new(); + +fn jvm() -> Option<&'static JavaVM> { + JVM.get() +} + +// --------------------------------------------------------------------------- +// Config (parsed from Envoy filter JSON config) +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Debug)] +struct JavaFilterConfigData { + /// Colon-separated classpath entries (JAR files, directories). + /// The first `java_filter` config to be loaded wins for JVM initialisation. + jar_path: String, + /// Fully-qualified Java class name, e.g. `"io.envoyproxy.dynamicmodules.ExampleFilter"`. + class_name: String, +} + +// --------------------------------------------------------------------------- +// FilterConfig — created once per filter chain config block +// --------------------------------------------------------------------------- + +/// Holds a global reference to the Java filter *instance* that is shared +/// across all requests for this filter chain. +pub struct FilterConfig { + filter_instance: GlobalRef, +} + +impl FilterConfig { + pub fn new(filter_config: &str) -> Option { + let data: JavaFilterConfigData = match serde_json::from_str(filter_config) { + Ok(d) => d, + Err(e) => { + eprintln!("[java_filter] Failed to parse config: {e}"); + return None; + } + }; + + // Initialise the JVM exactly once. The classpath is fixed at init time; + // if multiple java_filter configs are loaded, all needed JARs must be + // in the *first* config's jar_path (use ':' as separator on Linux). + let jvm = JVM.get_or_init(|| { + let classpath_opt = format!("-Djava.class.path={}", data.jar_path); + let args = InitArgsBuilder::new() + .version(JNIVersion::V8) + .option(&classpath_opt) + .build() + .expect("[java_filter] Failed to build JVM args"); + JavaVM::new(args).expect("[java_filter] Failed to create JVM") + }); + + let mut env = match jvm.attach_current_thread_permanently() { + Ok(e) => e, + Err(e) => { + eprintln!("[java_filter] Failed to attach thread: {e}"); + return None; + } + }; + + let class_jni = data.class_name.replace('.', "/"); + let filter_class = match env.find_class(&class_jni) { + Ok(c) => c, + Err(e) => { + eprintln!( + "[java_filter] Class not found '{}': {e}", + data.class_name + ); + return None; + } + }; + + let filter_obj = match env.new_object(&filter_class, "()V", &[]) { + Ok(o) => o, + Err(e) => { + eprintln!( + "[java_filter] Failed to instantiate '{}': {e}", + data.class_name + ); + return None; + } + }; + + let filter_instance = env + .new_global_ref(filter_obj) + .expect("[java_filter] Failed to create global ref"); + + Some(FilterConfig { filter_instance }) + } +} + +impl HttpFilterConfig for FilterConfig { + fn new_http_filter(&self, _envoy: &mut EHF) -> Box> { + Box::new(Filter { + filter_instance: self.filter_instance.clone(), + }) + } +} + +// --------------------------------------------------------------------------- +// Filter — per-request state +// --------------------------------------------------------------------------- + +pub struct Filter { + filter_instance: GlobalRef, +} + +impl HttpFilter for Filter { + fn on_request_headers( + &mut self, + envoy_filter: &mut EHF, + _end_of_stream: bool, + ) -> abi::envoy_dynamic_module_type_on_http_filter_request_headers_status { + let headers = collect_headers(envoy_filter.get_request_headers()); + let mutation = call_java_on_headers(&self.filter_instance, "onRequestHeaders", &headers); + for (k, v) in &mutation.add { + envoy_filter.set_request_header(k, v.as_bytes()); + } + for k in &mutation.remove { + envoy_filter.remove_request_header(k); + } + if mutation.stop_iteration { + abi::envoy_dynamic_module_type_on_http_filter_request_headers_status::StopIteration + } else { + abi::envoy_dynamic_module_type_on_http_filter_request_headers_status::Continue + } + } + + fn on_response_headers( + &mut self, + envoy_filter: &mut EHF, + _end_of_stream: bool, + ) -> abi::envoy_dynamic_module_type_on_http_filter_response_headers_status { + let headers = collect_headers(envoy_filter.get_response_headers()); + let mutation = call_java_on_headers(&self.filter_instance, "onResponseHeaders", &headers); + for (k, v) in &mutation.add { + envoy_filter.set_response_header(k, v.as_bytes()); + } + for k in &mutation.remove { + envoy_filter.remove_response_header(k); + } + if mutation.stop_iteration { + abi::envoy_dynamic_module_type_on_http_filter_response_headers_status::StopIteration + } else { + abi::envoy_dynamic_module_type_on_http_filter_response_headers_status::Continue + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Mutations returned by the Java filter (owned Rust data, no JNI lifetime). +#[derive(Default)] +struct JavaMutation { + stop_iteration: bool, + /// Headers to add: Vec of (name, value) pairs. + add: Vec<(String, String)>, + /// Header names to remove. + remove: Vec, +} + +/// Collect `(EnvoyBuffer, EnvoyBuffer)` pairs into owned `(String, String)`. +fn collect_headers(raw: Vec<(EnvoyBuffer, EnvoyBuffer)>) -> Vec<(String, String)> { + raw.into_iter() + .filter_map(|(k, v)| { + let k = std::str::from_utf8(k.as_slice()).ok()?.to_owned(); + let v = std::str::from_utf8(v.as_slice()).ok()?.to_owned(); + Some((k, v)) + }) + .collect() +} + +/// Calls `(String[] names, String[] values) → HeaderMutation` on +/// the Java filter instance and returns the parsed mutations as owned Rust data. +fn call_java_on_headers( + filter_instance: &GlobalRef, + method_name: &str, + headers: &[(String, String)], +) -> JavaMutation { + let Some(jvm) = jvm() else { + eprintln!("[java_filter] JVM not initialised"); + return JavaMutation::default(); + }; + + // attach_current_thread_permanently is idempotent — safe to call every time. + let mut env = match jvm.attach_current_thread_permanently() { + Ok(e) => e, + Err(e) => { + eprintln!("[java_filter] Failed to attach thread: {e}"); + return JavaMutation::default(); + } + }; + + // Build parallel String[] arrays for names and values. + let len = headers.len() as jsize; + let string_class = match env.find_class("java/lang/String") { + Ok(c) => c, + Err(e) => { + eprintln!("[java_filter] Cannot find String class: {e}"); + return JavaMutation::default(); + } + }; + + let names_arr = match env.new_object_array(len, &string_class, JObject::null()) { + Ok(a) => a, + Err(e) => { + eprintln!("[java_filter] Cannot create names array: {e}"); + return JavaMutation::default(); + } + }; + let values_arr = match env.new_object_array(len, &string_class, JObject::null()) { + Ok(a) => a, + Err(e) => { + eprintln!("[java_filter] Cannot create values array: {e}"); + return JavaMutation::default(); + } + }; + + for (i, (name, value)) in headers.iter().enumerate() { + let js_name = match env.new_string(name) { + Ok(s) => s, + Err(_) => continue, + }; + let js_value = match env.new_string(value) { + Ok(s) => s, + Err(_) => continue, + }; + let _ = env.set_object_array_element(&names_arr, i as jsize, js_name); + let _ = env.set_object_array_element(&values_arr, i as jsize, js_value); + } + + // Call onRequestHeaders / onResponseHeaders. + let sig = "([Ljava/lang/String;[Ljava/lang/String;)Lio/envoyproxy/dynamicmodules/HeaderMutation;"; + let result = match env.call_method( + filter_instance, + method_name, + sig, + &[JValue::Object(&names_arr), JValue::Object(&values_arr)], + ) { + Ok(r) => r, + Err(e) => { + eprintln!("[java_filter] JNI call to {method_name} failed: {e}"); + let _ = env.exception_clear(); + return JavaMutation::default(); + } + }; + + let mutation_obj = match result.l() { + Ok(obj) if !obj.is_null() => obj, + _ => return JavaMutation::default(), // null → no mutations, continue + }; + + let mut out = JavaMutation::default(); + + // --- stopIteration (boolean field) --- + out.stop_iteration = env + .get_field(&mutation_obj, "stopIteration", "Z") + .ok() + .and_then(|v| v.z().ok()) + .unwrap_or(false); + + // --- addHeaders: String[] of alternating [name, value, …] --- + if let Ok(field) = env.get_field(&mutation_obj, "addHeaders", "[Ljava/lang/String;") { + if let Ok(obj) = field.l() { + if !obj.is_null() { + let arr = JObjectArray::from(obj); + let arr_len = env.get_array_length(&arr).unwrap_or(0); + let mut i = 0; + while i + 1 < arr_len { + if let (Ok(name_obj), Ok(val_obj)) = ( + env.get_object_array_element(&arr, i), + env.get_object_array_element(&arr, i + 1), + ) { + let name: String = env + .get_string(&JString::from(name_obj)) + .map(|s| s.into()) + .unwrap_or_default(); + let value: String = env + .get_string(&JString::from(val_obj)) + .map(|s| s.into()) + .unwrap_or_default(); + if !name.is_empty() { + out.add.push((name, value)); + } + } + i += 2; + } + } + } + } + + // --- removeHeaders: String[] --- + if let Ok(field) = env.get_field(&mutation_obj, "removeHeaders", "[Ljava/lang/String;") { + if let Ok(obj) = field.l() { + if !obj.is_null() { + let arr = JObjectArray::from(obj); + let arr_len = env.get_array_length(&arr).unwrap_or(0); + for i in 0..arr_len { + if let Ok(name_obj) = env.get_object_array_element(&arr, i) { + let name: String = env + .get_string(&JString::from(name_obj)) + .map(|s| s.into()) + .unwrap_or_default(); + if !name.is_empty() { + out.remove.push(name); + } + } + } + } + } + } + + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Unit tests for the Java filter require a JVM and the example JAR to be + // present. Run `make -C java` first, then `cargo test -- java_filter`. + + #[test] + fn test_collect_headers() { + let raw = vec![ + (EnvoyBuffer::new("host"), EnvoyBuffer::new("example.com")), + (EnvoyBuffer::new(":path"), EnvoyBuffer::new("/api")), + ]; + let headers = collect_headers(raw); + assert_eq!(headers.len(), 2); + assert_eq!(headers[0], ("host".to_string(), "example.com".to_string())); + assert_eq!(headers[1], (":path".to_string(), "/api".to_string())); + } + + #[test] + fn test_collect_headers_with_mock() { + let mut envoy_filter = envoy_proxy_dynamic_modules_rust_sdk::MockEnvoyHttpFilter::new(); + envoy_filter + .expect_get_request_headers() + .returning(|| vec![(EnvoyBuffer::new("host"), EnvoyBuffer::new("example.com"))]); + + let headers = collect_headers(envoy_filter.get_request_headers()); + assert_eq!(headers.len(), 1); + assert_eq!(headers[0], ("host".to_string(), "example.com".to_string())); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index d87865d..73af243 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -40,6 +40,7 @@ use envoy_proxy_dynamic_modules_rust_sdk::*; // HTTP filter examples. mod http_access_logger; mod http_header_mutation; +mod http_java_filter; mod http_metrics; mod http_passthrough; mod http_random_auth; @@ -99,6 +100,8 @@ fn new_http_filter_config_fn( .map(|config| Box::new(config) as Box>), "metrics" => http_metrics::FilterConfig::new(filter_config, envoy_filter_config) .map(|config| Box::new(config) as Box>), + "java_filter" => http_java_filter::FilterConfig::new(filter_config) + .map(|config| Box::new(config) as Box>), _ => panic!("Unknown filter name: {filter_name}"), } }