Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ bazel-*

access_logs/

*.so
*.so

*.jar
java/out
12 changes: 10 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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)
38 changes: 38 additions & 0 deletions integration/envoy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 87 additions & 3 deletions integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
Expand All @@ -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)
}
}()
Expand All @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
21 changes: 21 additions & 0 deletions java/Makefile
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions java/src/io/envoyproxy/dynamicmodules/EnvoyHttpFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.envoyproxy.dynamicmodules;

/**
* Interface for implementing Envoy HTTP filters in Java.
*
* <p>Implement this interface, package your class in a JAR (together with
* {@link HeaderMutation}), and configure the Rust dynamic module:
*
* <pre>{@code
* {
* "jar_path": "/path/to/your-filter.jar",
* "class_name": "com.example.YourFilter"
* }
* }</pre>
*
* <p>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);
}
55 changes: 55 additions & 0 deletions java/src/io/envoyproxy/dynamicmodules/ExampleFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package io.envoyproxy.dynamicmodules;

/**
* Example Envoy HTTP filter implemented in Java.
*
* <p>This filter:
* <ul>
* <li>Adds {@code x-java-filter: active} to every request.</li>
* <li>Mirrors the request {@code :path} pseudo-header back as
* {@code x-java-filter-path} on the response.</li>
* <li>Removes the {@code x-powered-by} response header (if present).</li>
* </ul>
*
* <p>Build and package this example with:
* <pre>{@code
* make -C java
* }</pre>
*
* <p>Then reference it in your Envoy config:
* <pre>{@code
* {
* "jar_path": "/path/to/envoy-java-filter.jar",
* "class_name": "io.envoyproxy.dynamicmodules.ExampleFilter"
* }
* }</pre>
*/
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;
}
}
34 changes: 34 additions & 0 deletions java/src/io/envoyproxy/dynamicmodules/HeaderMutation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.envoyproxy.dynamicmodules;

/**
* Describes mutations the Java filter wants to apply to HTTP headers.
*
* <p>Return an instance of this class (or {@code null} for no changes) from
* {@link EnvoyHttpFilter#onRequestHeaders} and
* {@link EnvoyHttpFilter#onResponseHeaders}.
*
* <p>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", …]}.
*
* <p>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;
}
Loading
Loading